diff --git a/Build/Bicep/FunctionApps.bicep b/Build/Bicep/FunctionApps.bicep index 150a298..8752ecc 100644 --- a/Build/Bicep/FunctionApps.bicep +++ b/Build/Bicep/FunctionApps.bicep @@ -34,8 +34,10 @@ param SessionHostResourceGroupName string = '' @description('Required: Yes | Name of the Azure Virtual Desktop Host Pool.') param HostPoolName string -@description('Required: No | URL of the FunctionApp.zip file. This is the zip file containing the Function App code. | Default: The latest release of the Function App code.') -param FunctionAppZipUrl string = 'https://github.com/WillyMoselhy/AVDReplacementPlans/releases/download/v0.1.5/FunctionApp.zip' // TODO - Update this to the new URL under Azure Org +@description('Required: No | URL of the FunctionApp.zip package. By default, this is derived from the current template URL so it follows the deployed repo branch automatically.') +param FunctionAppZipUrl string = contains(deployment().properties, 'templateLink') && !empty(deployment().properties.templateLink.uri) + ? uri(replace(split(deployment().properties.templateLink.uri, '?')[0], 'DeployAVDSessionHostReplacer.json', ''), '../../FunctionApp/FunctionApp.zip') + : 'https://github.com/WillyMoselhy/AVDReplacementPlans/releases/download/v0.1.5/FunctionApp.zip' @description('Required: No | If true, will apply tags for Include In Auto Replace and Deployment Timestamp to existing session hosts. This will not enable automatic deletion of existing session hosts. | Default: True.') param FixSessionHostTags bool = true @@ -82,6 +84,9 @@ param SubnetId string @description('Required: No | Number of digits to use for the instance number of the session hosts (eg. AVDVM-01). | Default: 2') param SessionHostInstanceNumberPadding int = 2 +@description('Required: No | Minimum numeric suffix for managed session hosts. Hosts below this suffix are ignored when calculating how many new hosts to deploy. | Default: 1025') +param ManagedSessionHostMinSuffix int = 1025 + @description('Required: No | If true, will replace session hosts when a new image version is detected. | Default: true') param ReplaceSessionHostOnNewImageVersion bool = true @@ -106,6 +111,10 @@ var varFunctionAppSettings = [ name: 'FUNCTIONS_WORKER_RUNTIME' value: 'powershell' } + { + name: 'AzureWebJobs.timerTrigger1.Disabled' + value: '1' + } { name: 'AzureWebJobsStorage' value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' @@ -170,6 +179,10 @@ var varFunctionAppSettings = [ name: '_SessionHostInstanceNumberPadding' value: SessionHostInstanceNumberPadding } + { + name: '_ManagedSessionHostMinSuffix' + value: ManagedSessionHostMinSuffix + } { name: '_TargetSessionHostCount' value: TargetSessionHostCount @@ -286,7 +299,7 @@ resource functionApp 'Microsoft.Web/sites@2022-03-01' = { serverFarmId: appServicePlan.id siteConfig: { use32BitWorkerProcess: false - powerShellVersion: '7.2' + powerShellVersion: '7.4' netFrameworkVersion: 'v6.0' appSettings: varFunctionAppSettings ftpsState: 'Disabled' diff --git a/FunctionApp/FunctionParameters.psd1 b/FunctionApp/FunctionParameters.psd1 index a5e9d3f..20609e3 100644 --- a/FunctionApp/FunctionParameters.psd1 +++ b/FunctionApp/FunctionParameters.psd1 @@ -9,6 +9,7 @@ _IncludePreExistingSessionHosts = @{Required = $false ; Type = 'bool ' ; Default = $false ; Description = 'When enabled, the Session Host Replacer will automatically consider pre-existing VMs for replacement if they meet the criteria by setting the IncludeInAutomation tag to True during the first run. When disabled, the session hosts are not counted as part of the target number of VMs. You can manually include a VM after deployment by updating its tag.' } _SHRDeploymentPrefix = @{Required = $false ; Type = 'string' ; Default = 'AVDSessionHostReplacer' ; Description = '' } _SessionHostInstanceNumberPadding = @{Required = $false ; Type = 'int ' ; Default = 2 ; Description = '' } + _ManagedSessionHostMinSuffix = @{Required = $false ; Type = 'int ' ; Default = 1025 ; Description = 'Minimum numeric suffix to consider when calculating target host count and allocating new VM names.' } _ReplaceSessionHostOnNewImageVersion = @{Required = $false ; Type = 'bool ' ; Default = $true ; Description = '' } _ReplaceSessionHostOnNewImageVersionDelayDays = @{Required = $false ; Type = 'int ' ; Default = 0 ; Description = '' } _VMNamesTemplateParameterName = @{Required = $false ; Type = 'string' ; Default = 'VMNames' ; Description = 'The name of the array parameter used in the Session Host deployment template to define the VM names. Default is "VMNames"' } diff --git a/FunctionApp/Modules/SessionHostReplacer/functions/Deploy-SHRSessionHost.ps1 b/FunctionApp/Modules/SessionHostReplacer/functions/Deploy-SHRSessionHost.ps1 index a1162ce..7992db9 100644 --- a/FunctionApp/Modules/SessionHostReplacer/functions/Deploy-SHRSessionHost.ps1 +++ b/FunctionApp/Modules/SessionHostReplacer/functions/Deploy-SHRSessionHost.ps1 @@ -25,6 +25,9 @@ function Deploy-SHRSessionHost { [Parameter()] [int] $SessionHostInstanceNumberPadding = (Get-FunctionConfig _SessionHostInstanceNumberPadding), + [Parameter()] + [int] $ManagedSessionHostMinSuffix = (Get-FunctionConfig _ManagedSessionHostMinSuffix), + [Parameter()] [string] $DeploymentPrefix = (Get-FunctionConfig _SHRDeploymentPrefix), @@ -51,13 +54,14 @@ function Deploy-SHRSessionHost { # Calculate Session Host Names Write-PSFMessage -Level Host -Message "Existing session host VM names: {0}" -StringValues ($ExistingSessionHostVMNames -join ',') + $vmNumber = [Math]::Max(1, $ManagedSessionHostMinSuffix) [array] $sessionHostNames = for ($i = 0; $i -lt $NewSessionHostsCount; $i++) { - $vmNumber = 1 While (("$SessionHostNamePrefix$SessionHostNameSeparator{0:d$SessionHostInstanceNumberPadding}" -f $vmNumber) -in $ExistingSessionHostVMNames) { $vmNumber++ } $vmName = "$SessionHostNamePrefix$SessionHostNameSeparator{0:d$SessionHostInstanceNumberPadding}" -f $vmNumber $ExistingSessionHostVMNames += $vmName + $vmNumber++ $vmName } Write-PSFMessage -Level Host -Message "Creating session host(s) {0}" -StringValues ($sessionHostNames -join ',') diff --git a/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRHostPoolDecision.ps1 b/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRHostPoolDecision.ps1 index 4a3a4a4..e084b05 100644 --- a/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRHostPoolDecision.ps1 +++ b/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRHostPoolDecision.ps1 @@ -1,126 +1,151 @@ -function Get-SHRHostPoolDecision { - <# - .SYNOPSIS - This function will decide how many session hosts to deploy and if we should decommission any session hosts. - #> - [CmdletBinding()] - param ( - # Session hosts to consider - [Parameter()] - [array] $SessionHosts = @(), - - # Running deployments - [Parameter()] - $RunningDeployments, - - # Target age of session hosts in days - after this many days we consider a session host for replacement. - [Parameter()] - [int] $TargetVMAgeDays = (Get-FunctionConfig _TargetVMAgeDays), - - # Target number of session hosts in the host pool. If we have more than or equal to this number of session hosts we will decommission some. - [Parameter()] - [int] $TargetSessionHostCount = (Get-FunctionConfig _TargetSessionHostCount), - - [Parameter()] - [int] $TargetSessionHostBuffer = (Get-FunctionConfig _TargetSessionHostBuffer), - - # Latest image version - [Parameter()] - [PSCustomObject] $LatestImageVersion, - - # Should we replace session hosts on new image version - [Parameter()] - [bool] $ReplaceSessionHostOnNewImageVersion = (Get-FunctionConfig _ReplaceSessionHostOnNewImageVersion), - - # Delay days before replacing session hosts on new image version - [Parameter()] - [int] $ReplaceSessionHostOnNewImageVersionDelayDays = (Get-FunctionConfig _ReplaceSessionHostOnNewImageVersionDelayDays) - ) - # Basic Info - Write-PSFMessage -Level Host -Message "We have {0} session hosts (included in Automation)" -StringValues $SessionHosts.Count - - # Identify Session hosts that should be replaced - if ($TargetVMAgeDays -gt 0) { - $targetReplacementDate = (Get-Date).AddDays(-$TargetVMAgeDays) - [array] $sessionHostsOldAge = $SessionHosts | Where-Object { $_.DeployTimestamp -lt $targetReplacementDate } - Write-PSFMessage -Level Host -Message "Found {0} session hosts to replace due to old age. {1}" -StringValues $sessionHostsOldAge.Count, ($sessionHostsOldAge.VMName -join ',') - - } - - if ($ReplaceSessionHostOnNewImageVersion) { - $latestImageAge = (New-TimeSpan -Start $LatestImageVersion.Date -End (Get-Date -AsUTC)).TotalDays - Write-PSFMessage -Level Host -Message "Latest Image {0} is {1:N0} days old." -StringValues $LatestImageVersion.Version, $latestImageAge - if ($latestImageAge -ge $ReplaceSessionHostOnNewImageVersionDelayDays) { - Write-PSFMessage -Level Host -Message "Latest Image age is older than (or equal) New Image Delay value {0}" -StringValues $ReplaceSessionHostOnNewImageVersionDelayDays - [array] $sessionHostsOldVersion = $sessionHosts | Where-Object { $_.ImageVersion -ne $LatestImageVersion.Version } - Write-PSFMessage -Level Host -Message "Found {0} session hosts to replace due to new image version. {1}" -StringValues $sessionHostsOldVersion.Count, ($sessionHostsOldVersion.VMName -Join ',') - } - } - - [array] $sessionHostsToReplace = ($sessionHostsOldAge + $sessionHostsOldVersion) | Select-Object -Property * -Unique - Write-PSFMessage -Level Host -Message "Found {0} session hosts to replace in total. {1}" -StringValues $sessionHostsToReplace.Count, ($sessionHostsToReplace.VMName -join ',') - - # Good Session Hosts - - $goodSessionHosts = $SessionHosts | Where-Object { $_.VMName -notin $sessionHostsToReplace.VMName } - $sessionHostsCurrentTotal = ([array]$goodSessionHosts.VMName + [array]$runningDeployments.SessionHostNames ) | Select-Object -Unique - - Write-PSFMessage -Level Host -Message "We have {0} good session hosts including {1} session hosts being deployed" -StringValues $sessionHostsCurrentTotal.Count, $runningDeployments.SessionHostNames.Count - Write-PSFMessage -Level Host -Message "We target having {0} session hosts in good shape" -StringValues $TargetSessionHostCount - Write-PSFMessage -Level Host -Message "We have a buffer of {0} session hosts more than the target." -StringValues $TargetSessionHostBuffer - - $weCanDeployUpTo = $TargetSessionHostCount + $TargetSessionHostBuffer - $SessionHosts.count - $RunningDeployments.SessionHostNames.Count - if ($weCanDeployUpTo -ge 0) { - Write-PSFMessage -Level Host -Message "We can deploy up to {0} session hosts" -StringValues $weCanDeployUpTo - - $weNeedToDeploy = $TargetSessionHostCount - $sessionHostsCurrentTotal.Count - if ($weNeedToDeploy -gt 0) { - Write-PSFMessage -Level Host -Message "We need to deploy {0} new session hosts" -StringValues $weNeedToDeploy - $weCanDeploy = if ($weNeedToDeploy -gt $weCanDeployUpTo) { $weCanDeployUpTo } else { $weNeedToDeploy } # If we need to deploy 10 machines, and we can deploy 5, we should only deploy 5. - Write-PSFMessage -Level Host -Message "Buffer allows deploying {0} session hosts" -StringValues $weCanDeploy - } - else { - $weCanDeploy = 0 - Write-PSFMessage -Level Host -Message "We have enough session hosts in good shape." - } - } - else { - Write-PSFMessage -Level Host -Message "Buffer is full. We can not deploy more session hosts" - $weCanDeploy = 0 - } - - - $weCanDelete = $SessionHosts.Count - $TargetSessionHostCount - if ($weCanDelete -gt 0) { - Write-PSFMessage -Level Host -Message "We need to delete {0} session hosts" -StringValues $weCanDelete - if ($weCanDelete -gt $sessionHostsToReplace.Count) { - Write-PSFMessage -Level Host -Message "Host pool is over populated" - - $goodSessionHostsToDeleteCount = $weCanDelete - $sessionHostsToReplace.Count - Write-PSFMessage -Level Host -Message "We will delete {0} good session hosts" -StringValues $goodSessionHostsToDeleteCount - - $selectedGoodHostsTotDelete = [array] ($goodSessionHosts | Sort-Object -Property Session | Select-Object -First $goodSessionHostsToDeleteCount) - Write-PSFMessage -Level Host -Message "Selected the following good session hosts to delete: {0}" -StringValues ($selectedGoodHostsTotDelete.VMName -join ',') - } - else { - $selectedGoodHostsTotDelete = @() - Write-PSFMessage -Level Host -Message "Host pool is not over populated" - } - - $sessionHostsPendingDelete = ($sessionHostsToReplace + $selectedGoodHostsTotDelete) | Select-Object -First $weCanDelete - Write-PSFMessage -Level Host -Message "The following Session Hosts are now pending delete: {0}" -StringValues ($SessionHostsPendingDelete.VMName -join ',') - - } - elseif ($sessionHostsToReplace.Count -gt 0) { - Write-PSFMessage -Level Host -Message "We need to delete {0} session hosts but we don't have enough session hosts in the host pool." -StringValues ($sessionHostsToReplace.Count) - } - else { Write-PSFMessage -Level Host -Message "We do not need to delete any session hosts" } - - - [PSCustomObject]@{ - PossibleDeploymentsCount = $weCanDeploy - PossibleSessionHostDeleteCount = $weCanDelete - SessionHostsPendingDelete = $sessionHostsPendingDelete - ExistingSessionHostVMNames = ([array]$SessionHosts.VMName + [array]$runningDeployments.SessionHostNames) | Select-Object -Unique - } +function Get-SHRHostPoolDecision { + <# + .SYNOPSIS + This function will decide how many session hosts to deploy and if we should decommission any session hosts. + #> + [CmdletBinding()] + param ( + # Session hosts to consider + [Parameter()] + [array] $SessionHosts = @(), + + # Running deployments + [Parameter()] + $RunningDeployments, + + # Target age of session hosts in days - after this many days we consider a session host for replacement. + [Parameter()] + [int] $TargetVMAgeDays = (Get-FunctionConfig _TargetVMAgeDays), + + # Target number of session hosts in the host pool. If we have more than or equal to this number of session hosts we will decommission some. + [Parameter()] + [int] $TargetSessionHostCount = (Get-FunctionConfig _TargetSessionHostCount), + + [Parameter()] + [int] $TargetSessionHostBuffer = (Get-FunctionConfig _TargetSessionHostBuffer), + + # Latest image version + [Parameter()] + [PSCustomObject] $LatestImageVersion, + + # Should we replace session hosts on new image version + [Parameter()] + [bool] $ReplaceSessionHostOnNewImageVersion = (Get-FunctionConfig _ReplaceSessionHostOnNewImageVersion), + + # Delay days before replacing session hosts on new image version + [Parameter()] + [int] $ReplaceSessionHostOnNewImageVersionDelayDays = (Get-FunctionConfig _ReplaceSessionHostOnNewImageVersionDelayDays), + + # Minimum numeric suffix for session hosts managed by this function. + [Parameter()] + [int] $ManagedSessionHostMinSuffix = (Get-FunctionConfig _ManagedSessionHostMinSuffix) + ) + + function Get-SHRSessionHostNumericSuffix { + param( + [Parameter(Mandatory = $true)] + [string] $VMName + ) + + $suffixMatch = [regex]::Match($VMName, '(\d+)$') + if (-not $suffixMatch.Success) { + return $null + } + + [int]$suffixMatch.Groups[1].Value + } + + # Basic Info + Write-PSFMessage -Level Host -Message "We have {0} session hosts (included in Automation)" -StringValues $SessionHosts.Count + + [array] $managedSessionHosts = foreach ($sessionHost in $SessionHosts) { + $numericSuffix = Get-SHRSessionHostNumericSuffix -VMName $sessionHost.VMName + if ($null -eq $numericSuffix -or $numericSuffix -ge $ManagedSessionHostMinSuffix) { + $sessionHost + } + } + + [array] $legacySessionHosts = foreach ($sessionHost in $SessionHosts) { + $numericSuffix = Get-SHRSessionHostNumericSuffix -VMName $sessionHost.VMName + if ($null -ne $numericSuffix -and $numericSuffix -lt $ManagedSessionHostMinSuffix) { + $sessionHost + } + } + + Write-PSFMessage -Level Host -Message "Managed session host suffix baseline is {0}" -StringValues $ManagedSessionHostMinSuffix + Write-PSFMessage -Level Host -Message "Found {0} managed session hosts (suffix >= baseline or non-numeric suffix)." -StringValues $managedSessionHosts.Count + Write-PSFMessage -Level Host -Message "Ignoring {0} legacy session hosts with suffix below baseline for deployment count evaluation." -StringValues $legacySessionHosts.Count + + [array] $managedRunningDeployments = foreach ($runningSessionHostName in $RunningDeployments.SessionHostNames) { + $numericSuffix = Get-SHRSessionHostNumericSuffix -VMName $runningSessionHostName + if ($null -eq $numericSuffix -or $numericSuffix -ge $ManagedSessionHostMinSuffix) { + $runningSessionHostName + } + } + + [array] $deletionEligibleSessionHosts = $managedSessionHosts | Where-Object { [string]::IsNullOrWhiteSpace($_.AssignedUser) } + [array] $assignedSessionHosts = $SessionHosts | Where-Object { -not [string]::IsNullOrWhiteSpace($_.AssignedUser) } + Write-PSFMessage -Level Host -Message "Found {0} session hosts assigned to users." -StringValues $assignedSessionHosts.Count + + # Identify Session hosts that should be replaced + if ($TargetVMAgeDays -gt 0) { + $targetReplacementDate = (Get-Date).AddDays(-$TargetVMAgeDays) + [array] $sessionHostsOldAge = $deletionEligibleSessionHosts | Where-Object { $_.DeployTimestamp -lt $targetReplacementDate } + Write-PSFMessage -Level Host -Message "Found {0} session hosts to replace due to old age. {1}" -StringValues $sessionHostsOldAge.Count, ($sessionHostsOldAge.VMName -join ',') + + } + + if ($ReplaceSessionHostOnNewImageVersion) { + $latestImageAge = (New-TimeSpan -Start $LatestImageVersion.Date -End (Get-Date -AsUTC)).TotalDays + Write-PSFMessage -Level Host -Message "Latest Image {0} is {1:N0} days old." -StringValues $LatestImageVersion.Version, $latestImageAge + if ($latestImageAge -ge $ReplaceSessionHostOnNewImageVersionDelayDays) { + Write-PSFMessage -Level Host -Message "Latest Image age is older than (or equal) New Image Delay value {0}" -StringValues $ReplaceSessionHostOnNewImageVersionDelayDays + [array] $sessionHostsOldVersion = $deletionEligibleSessionHosts | Where-Object { $_.ImageVersion -ne $LatestImageVersion.Version } + Write-PSFMessage -Level Host -Message "Found {0} session hosts to replace due to new image version. {1}" -StringValues $sessionHostsOldVersion.Count, ($sessionHostsOldVersion.VMName -Join ',') + } + } + + [array] $sessionHostsToReplace = ($sessionHostsOldAge + $sessionHostsOldVersion) | Select-Object -Property * -Unique + Write-PSFMessage -Level Host -Message "Found {0} session hosts to replace in total. {1}" -StringValues $sessionHostsToReplace.Count, ($sessionHostsToReplace.VMName -join ',') + + # Good Session Hosts + + $managedSessionHostsCurrentTotal = ([array]$managedSessionHosts.VMName + [array]$managedRunningDeployments ) | Select-Object -Unique + + Write-PSFMessage -Level Host -Message "We have {0} managed session hosts including {1} managed session hosts being deployed" -StringValues $managedSessionHostsCurrentTotal.Count, $managedRunningDeployments.Count + Write-PSFMessage -Level Host -Message "We target having {0} session hosts in good shape" -StringValues $TargetSessionHostCount + Write-PSFMessage -Level Host -Message "We have a buffer of {0} session hosts more than the target." -StringValues $TargetSessionHostBuffer + + $weCanDeployUpTo = $TargetSessionHostCount + $TargetSessionHostBuffer - $managedSessionHosts.count - $managedRunningDeployments.Count + if ($weCanDeployUpTo -ge 0) { + Write-PSFMessage -Level Host -Message "We can deploy up to {0} session hosts" -StringValues $weCanDeployUpTo + + $weNeedToDeploy = $TargetSessionHostCount - $managedSessionHostsCurrentTotal.Count + if ($weNeedToDeploy -gt 0) { + Write-PSFMessage -Level Host -Message "We need to deploy {0} new session hosts" -StringValues $weNeedToDeploy + $weCanDeploy = if ($weNeedToDeploy -gt $weCanDeployUpTo) { $weCanDeployUpTo } else { $weNeedToDeploy } # If we need to deploy 10 machines, and we can deploy 5, we should only deploy 5. + Write-PSFMessage -Level Host -Message "Buffer allows deploying {0} session hosts" -StringValues $weCanDeploy + } + else { + $weCanDeploy = 0 + Write-PSFMessage -Level Host -Message "We have enough session hosts in good shape." + } + } + else { + Write-PSFMessage -Level Host -Message "Buffer is full. We can not deploy more session hosts" + $weCanDeploy = 0 + } + + + $weCanDelete = 0 + $sessionHostsPendingDelete = @() + Write-PSFMessage -Level Host -Message "Session host decommissioning is disabled. No session hosts will be deleted." + + + [PSCustomObject]@{ + PossibleDeploymentsCount = $weCanDeploy + PossibleSessionHostDeleteCount = $weCanDelete + SessionHostsPendingDelete = $sessionHostsPendingDelete + ExistingSessionHostVMNames = ([array]$SessionHosts.VMName + [array]$managedRunningDeployments) | Select-Object -Unique + } } \ No newline at end of file diff --git a/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRSessionHost.ps1 b/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRSessionHost.ps1 index 1a43d1d..f2802ab 100644 --- a/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRSessionHost.ps1 +++ b/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRSessionHost.ps1 @@ -1,116 +1,116 @@ -function Get-SHRSessionHost { - <# -.SYNOPSIS - This function gets Session Host details from a host pool. -.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 - Test-MyTestFunction -Verbose - Explanation of the function or its result. You can include multiple examples with additional .EXAMPLE lines -#> - [CmdletBinding()] - param ( - [Parameter()] - [string] $ResourceGroupName = (Get-FunctionConfig _HostPoolResourceGroupName), - [Parameter()] - [string] $HostPoolName = (Get-FunctionConfig _HostPoolName), - [Parameter()] - [string] $TagIncludeInAutomation = (Get-FunctionConfig _Tag_IncludeInAutomation), - [Parameter()] - [string] $TagDeployTimestamp = (Get-FunctionConfig _Tag_DeployTimestamp), - [Parameter()] - [string] $TagPendingDrainTimeStamp = (Get-FunctionConfig _Tag_PendingDrainTimestamp), - [Parameter()] - [switch] $FixSessionHostTags, - [Parameter()] - [bool] $IncludePreExistingSessionHosts = (Get-FunctionConfig _IncludePreExistingSessionHosts) - - ) - - # Get current session hosts - Write-PSFMessage -Level Host -Message 'Getting current session hosts in host pool {0}' -StringValues $HostPoolName - $sessionHosts = Get-AzWvdSessionHost -ResourceGroupName $ResourceGroupName -HostPoolName $HostPoolName -ErrorAction Stop | Select-Object Name, ResourceId, Session, AllowNewSession, Status - Write-PSFMessage -Level Host -Message 'Found {0} session hosts' -StringValues $sessionHosts.Count - - # For each session host, get the VM details - $result = foreach ($item in $sessionHosts) { - Write-PSFMessage -Level Host -Message 'Getting VM details for {0}' -StringValues $item.Name - - $vm = Get-AzVM -ResourceId $item.ResourceId | Select-Object Name, TimeCreated,StorageProfile - Write-PSFMessage -Level Host -Message 'VM was created on {0}' -StringValues $vm.TimeCreated - Write-PSFMessage -Level Host -Message 'VM exact version is {0}' -StringValues $vm.StorageProfile.ImageReference.ExactVersion - - Write-PSFMessage -Level Host -Message 'Getting VM tags' -StringValues $item.Name - $vmTags = Get-AzTag -ResourceId $item.ResourceId - #region: Tag DeployTimestamp - $vmDeployTimeStamp = $vmTags.Properties.TagsProperty[$TagDeployTimestamp] - try { - $vmDeployTimeStamp = [DateTime]::Parse($vmDeployTimeStamp) - Write-PSFMessage -Level Host -Message 'VM has a tag {0} with value {1}' -StringValues $TagDeployTimestamp, $vmDeployTimeStamp - } - catch { - $value = if ($null -eq $vmDeployTimeStamp) { 'null' } else { $vmDeployTimeStamp } - Write-PSFMessage -Level Host -Message 'VM tag {0} with value {1} is not a valid date' -StringValues $TagDeployTimestamp, $value - if ($FixSessionHostTags) { - Write-PSFMessage -Level Host -Message 'Copying VM CreateTime to tag {0} with value {1}' -StringValues $TagDeployTimestamp, $vm.TimeCreated.ToString('o') - Update-AzTag -ResourceId $item.ResourceId -Tag @{ $TagDeployTimestamp = $vm.TimeCreated.ToString('o') } -Operation Merge - } - $vmDeployTimeStamp = $vm.TimeCreated - } - #endregion: Tag DeployTimestamp - - #region: Tag IncludeInAutomation - $vmIncludeInAutomation = $vmTags.Properties.TagsProperty[$TagIncludeInAutomation] - if ($vmIncludeInAutomation -eq "True") { - Write-PSFMessage -Level Host -Message 'VM has a tag {0} with value {1}' -StringValues $TagIncludeInAutomation, $vmIncludeInAutomation - $vmIncludeInAutomation = $true - } - elseif ($vmIncludeInAutomation -eq "False") { - Write-PSFMessage -Level Host -Message 'VM has a tag {0} with value {1}' -StringValues $TagIncludeInAutomation, $vmIncludeInAutomation - $vmIncludeInAutomation = $false - } - else { - $value = if ($null -eq $vmIncludeInAutomation) { 'null' } else { $vmIncludeInAutomation } - Write-PSFMessage -Level Host -Message 'VM tag {0} with value {1} is not set to True/False' -StringValues $TagIncludeInAutomation, $value - if ($FixSessionHostTags) { - Write-PSFMessage -Level Host -Message 'Setting tag {0} to {1}' -StringValues $TagIncludeInAutomation, $IncludePreExistingSessionHosts - Update-AzTag -ResourceId $item.ResourceId -Tag @{ $TagIncludeInAutomation = "$IncludePreExistingSessionHosts" } -Operation Merge - } - - $vmIncludeInAutomation = $IncludePreExistingSessionHosts - } - #endregion: Tag IncludeInAutomation - - #region: Tag PendingDrainTimeStamp - $vmPendingDrainTimeStamp = $vmTags.Properties.TagsProperty[$TagPendingDrainTimeStamp] - try { - $vmPendingDrainTimeStamp = [DateTime]::Parse($vmPendingDrainTimeStamp) - Write-PSFMessage -Level Host -Message 'VM has a tag {0} with value {1}' -StringValues $TagPendingDrainTimeStamp, $vmPendingDrainTimeStamp - } - catch { - Write-PSFMessage -Level Host -Message "VM tag {0} is not set." -StringValues $TagPendingDrainTimeStamp - $vmPendingDrainTimeStamp = $null - } - - #endregion: Tag PendingDrainTimeStamp - - $vmOutput = @{ # We are combining the VM details and SessionHost objects into a single PS Custom Object - VMName = $vm.Name - FQDN = $item.Name -replace ".+\/(.+)", '$1' - DeployTimestamp = $vmDeployTimeStamp - IncludeInAutomation = $vmIncludeInAutomation - PendingDrainTimeStamp = $vmPendingDrainTimeStamp - ImageVersion = $vm.StorageProfile.ImageReference.ExactVersion - } - $item.PSObject.Properties.ForEach{ $vmOutput[$_.Name] = $_.Value } - - [PSCustomObject]$vmOutput - - } - - $result +function Get-SHRSessionHost { + <# +.SYNOPSIS + This function gets Session Host details from a host pool. +.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 + Test-MyTestFunction -Verbose + Explanation of the function or its result. You can include multiple examples with additional .EXAMPLE lines +#> + [CmdletBinding()] + param ( + [Parameter()] + [string] $ResourceGroupName = (Get-FunctionConfig _HostPoolResourceGroupName), + [Parameter()] + [string] $HostPoolName = (Get-FunctionConfig _HostPoolName), + [Parameter()] + [string] $TagIncludeInAutomation = (Get-FunctionConfig _Tag_IncludeInAutomation), + [Parameter()] + [string] $TagDeployTimestamp = (Get-FunctionConfig _Tag_DeployTimestamp), + [Parameter()] + [string] $TagPendingDrainTimeStamp = (Get-FunctionConfig _Tag_PendingDrainTimestamp), + [Parameter()] + [switch] $FixSessionHostTags, + [Parameter()] + [bool] $IncludePreExistingSessionHosts = (Get-FunctionConfig _IncludePreExistingSessionHosts) + + ) + + # Get current session hosts + Write-PSFMessage -Level Host -Message 'Getting current session hosts in host pool {0}' -StringValues $HostPoolName + $sessionHosts = Get-AzWvdSessionHost -ResourceGroupName $ResourceGroupName -HostPoolName $HostPoolName -ErrorAction Stop | Select-Object Name, ResourceId, Session, AllowNewSession, Status, AssignedUser + Write-PSFMessage -Level Host -Message 'Found {0} session hosts' -StringValues $sessionHosts.Count + + # For each session host, get the VM details + $result = foreach ($item in $sessionHosts) { + Write-PSFMessage -Level Host -Message 'Getting VM details for {0}' -StringValues $item.Name + + $vm = Get-AzVM -ResourceId $item.ResourceId | Select-Object Name, TimeCreated,StorageProfile + Write-PSFMessage -Level Host -Message 'VM was created on {0}' -StringValues $vm.TimeCreated + Write-PSFMessage -Level Host -Message 'VM exact version is {0}' -StringValues $vm.StorageProfile.ImageReference.ExactVersion + + Write-PSFMessage -Level Host -Message 'Getting VM tags' -StringValues $item.Name + $vmTags = Get-AzTag -ResourceId $item.ResourceId + #region: Tag DeployTimestamp + $vmDeployTimeStamp = $vmTags.Properties.TagsProperty[$TagDeployTimestamp] + try { + $vmDeployTimeStamp = [DateTime]::Parse($vmDeployTimeStamp) + Write-PSFMessage -Level Host -Message 'VM has a tag {0} with value {1}' -StringValues $TagDeployTimestamp, $vmDeployTimeStamp + } + catch { + $value = if ($null -eq $vmDeployTimeStamp) { 'null' } else { $vmDeployTimeStamp } + Write-PSFMessage -Level Host -Message 'VM tag {0} with value {1} is not a valid date' -StringValues $TagDeployTimestamp, $value + if ($FixSessionHostTags) { + Write-PSFMessage -Level Host -Message 'Copying VM CreateTime to tag {0} with value {1}' -StringValues $TagDeployTimestamp, $vm.TimeCreated.ToString('o') + Update-AzTag -ResourceId $item.ResourceId -Tag @{ $TagDeployTimestamp = $vm.TimeCreated.ToString('o') } -Operation Merge + } + $vmDeployTimeStamp = $vm.TimeCreated + } + #endregion: Tag DeployTimestamp + + #region: Tag IncludeInAutomation + $vmIncludeInAutomation = $vmTags.Properties.TagsProperty[$TagIncludeInAutomation] + if ($vmIncludeInAutomation -eq "True") { + Write-PSFMessage -Level Host -Message 'VM has a tag {0} with value {1}' -StringValues $TagIncludeInAutomation, $vmIncludeInAutomation + $vmIncludeInAutomation = $true + } + elseif ($vmIncludeInAutomation -eq "False") { + Write-PSFMessage -Level Host -Message 'VM has a tag {0} with value {1}' -StringValues $TagIncludeInAutomation, $vmIncludeInAutomation + $vmIncludeInAutomation = $false + } + else { + $value = if ($null -eq $vmIncludeInAutomation) { 'null' } else { $vmIncludeInAutomation } + Write-PSFMessage -Level Host -Message 'VM tag {0} with value {1} is not set to True/False' -StringValues $TagIncludeInAutomation, $value + if ($FixSessionHostTags) { + Write-PSFMessage -Level Host -Message 'Setting tag {0} to {1}' -StringValues $TagIncludeInAutomation, $IncludePreExistingSessionHosts + Update-AzTag -ResourceId $item.ResourceId -Tag @{ $TagIncludeInAutomation = "$IncludePreExistingSessionHosts" } -Operation Merge + } + + $vmIncludeInAutomation = $IncludePreExistingSessionHosts + } + #endregion: Tag IncludeInAutomation + + #region: Tag PendingDrainTimeStamp + $vmPendingDrainTimeStamp = $vmTags.Properties.TagsProperty[$TagPendingDrainTimeStamp] + try { + $vmPendingDrainTimeStamp = [DateTime]::Parse($vmPendingDrainTimeStamp) + Write-PSFMessage -Level Host -Message 'VM has a tag {0} with value {1}' -StringValues $TagPendingDrainTimeStamp, $vmPendingDrainTimeStamp + } + catch { + Write-PSFMessage -Level Host -Message "VM tag {0} is not set." -StringValues $TagPendingDrainTimeStamp + $vmPendingDrainTimeStamp = $null + } + + #endregion: Tag PendingDrainTimeStamp + + $vmOutput = @{ # We are combining the VM details and SessionHost objects into a single PS Custom Object + VMName = $vm.Name + FQDN = $item.Name -replace ".+\/(.+)", '$1' + DeployTimestamp = $vmDeployTimeStamp + IncludeInAutomation = $vmIncludeInAutomation + PendingDrainTimeStamp = $vmPendingDrainTimeStamp + ImageVersion = $vm.StorageProfile.ImageReference.ExactVersion + } + $item.PSObject.Properties.ForEach{ $vmOutput[$_.Name] = $_.Value } + + [PSCustomObject]$vmOutput + + } + + $result } \ No newline at end of file diff --git a/FunctionApp/Modules/SessionHostReplacer/functions/Remove-SHRSessionHost.ps1 b/FunctionApp/Modules/SessionHostReplacer/functions/Remove-SHRSessionHost.ps1 index 798583c..46e0070 100644 --- a/FunctionApp/Modules/SessionHostReplacer/functions/Remove-SHRSessionHost.ps1 +++ b/FunctionApp/Modules/SessionHostReplacer/functions/Remove-SHRSessionHost.ps1 @@ -28,6 +28,9 @@ function Remove-SHRSessionHost { ) + Write-PSFMessage -Level Warning -Message 'Remove-SHRSessionHost is disabled. No drain or delete actions will be performed.' + return + foreach ($sessionHost in $SessionHostsPendingDelete) { # Does the session host currently have sessions? # No sessions => Delete + Remove from host pool diff --git a/FunctionApp/TimerTrigger1/run.ps1 b/FunctionApp/TimerTrigger1/run.ps1 index 3c3fe04..b74c0a7 100644 --- a/FunctionApp/TimerTrigger1/run.ps1 +++ b/FunctionApp/TimerTrigger1/run.ps1 @@ -50,14 +50,8 @@ if ($hostPoolDecisions.PossibleDeploymentsCount -gt 0) { Deploy-SHRSessionHost -SessionHostResourceGroupName $sessionHostResourceGroupName -NewSessionHostsCount $hostPoolDecisions.PossibleDeploymentsCount -ExistingSessionHostVMNames $existingSessionHostVMNames } -# Delete session hosts -if ($hostPoolDecisions.PossibleSessionHostDeleteCount -gt 0 -and $hostPoolDecisions.SessionHostsPendingDelete.Count -gt 0) { - Write-PSFMessage -Level Host -Message "We will decommission {0} session hosts from this list: {1}" -StringValues $hostPoolDecisions.SessionHostsPendingDelete.Count, ($hostPoolDecisions.SessionHostsPendingDelete.VMName -join ',') - # Decommission session hosts - $removeEntraDevice = Get-FunctionConfig _RemoveEntraDevice - $removeIntuneDevice = Get-FunctionConfig _RemoveIntuneDevice - Remove-SHRSessionHost -SessionHostsPendingDelete $hostPoolDecisions.SessionHostsPendingDelete -RemoveEntraDevice $removeEntraDevice -RemoveIntuneDevice $removeIntuneDevice -} +# Delete session hosts capability is intentionally disabled. +Write-PSFMessage -Level Host -Message "Session host decommissioning is disabled by configuration and no delete actions will be taken." # Write an information log with the current time. diff --git a/FunctionApp/host.json b/FunctionApp/host.json index 6744b7b..70c29a0 100644 --- a/FunctionApp/host.json +++ b/FunctionApp/host.json @@ -10,10 +10,10 @@ }, "extensionBundle": { "id": "Microsoft.Azure.Functions.ExtensionBundle", - "version": "[3.*, 4.0.0)" + "version": "[4.*, 5.0.0)" }, "managedDependency": { "enabled": true }, "functionTimeout": "00:10:00" -} \ No newline at end of file +} diff --git a/FunctionApp/profile.ps1 b/FunctionApp/profile.ps1 index 02309b7..5b1750e 100644 --- a/FunctionApp/profile.ps1 +++ b/FunctionApp/profile.ps1 @@ -16,7 +16,7 @@ Set-PSFConfig -FullName PSFramework.Message.style.NoColor -Value $true #This is ## Version Banner ## Updated by Build\Build-Zip-File.ps1 -Write-PSFMessage -Level Host -Message "This is SessionHostReplacer version {0}" -StringValues 'v0.3.0' +Write-PSFMessage -Level Host -Message "This is SessionHostReplacer version {0}" -StringValues 'v0.3.1-beta.3' # Import Function Parameters diff --git a/README.md b/README.md index de73d10..a9068c8 100644 --- a/README.md +++ b/README.md @@ -5,13 +5,13 @@ This tool automates the deployment and replacement of session hosts in an Azure Virtual Desktop host pool. The best practice for AVD recommends replacing the session hosts instead of maintaining them, -the AVD Session Host Replacer helps you manage the task of replacing old session hosts with new ones automatically. +the AVD Session Host Replacer helps you manage the task of deploying refreshed session hosts automatically. ## Getting started | Deployment Type | Link | | :------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Azure Portal UI | [![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#blade/Microsoft_Azure_CreateUIDef/CustomDeploymentBlade/uri/https%3A%2F%2Fraw.githubusercontent.com%2FAzure%2FAVDSessionHostReplacer%2Fv0.3.0%2Fdeploy%2Farm%2FDeployAVDSessionHostReplacer.json/uiFormDefinitionUri/https%3A%2F%2Fraw.githubusercontent.com%2FAzure%2FAVDSessionHostReplacer%2Fv0.3.0%2Fdeploy%2Fportal-ui%2Fportal-ui.json) [![Deploy to Azure Gov](https://aka.ms/deploytoazuregovbutton)](https://portal.azure.us/#blade/Microsoft_Azure_CreateUIDef/CustomDeploymentBlade/uri/https%3A%2F%2Fraw.githubusercontent.com%2FAzure%2FAVDSessionHostReplacer%2Fv0.3.0%2Fdeploy%2Farm%2FDeployAVDSessionHostReplacer.json/uiFormDefinitionUri/https%3A%2F%2Fraw.githubusercontent.com%2FAzure%2FAVDSessionHostReplacer%2Fv0.3.0%2Fdeploy%2Fportal-ui%2Fportal-ui.json) [![Deploy to Azure China](https://aka.ms/deploytoazurechinabutton)](https://portal.azure.cn/#blade/Microsoft_Azure_CreateUIDef/CustomDeploymentBlade/uri/https%3A%2F%2Fraw.githubusercontent.com%2FAzure%2FAVDSessionHostReplacer%2Fv0.3.0%2Fdeploy%2Farm%2FDeployAVDSessionHostReplacer.json/uiFormDefinitionUri/https%3A%2F%2Fraw.githubusercontent.com%2FAzure%2FAVDSessionHostReplacer%2Fv0.3.0%2Fdeploy%2Fportal-ui%2Fportal-ui.json) | +| Azure Portal UI | [![Deploy to Azure](https://aka.ms/deploytoazurebutton)](https://portal.azure.com/#blade/Microsoft_Azure_CreateUIDef/CustomDeploymentBlade/uri/https%3A%2F%2Fraw.githubusercontent.com%2Fstefze%2FAVDSessionHostReplacer%2Fmain%2Fdeploy%2Farm%2FDeployAVDSessionHostReplacer.json/uiFormDefinitionUri/https%3A%2F%2Fraw.githubusercontent.com%2Fstefze%2FAVDSessionHostReplacer%2Fmain%2Fdeploy%2Fportal-ui%2Fportal-ui.json) | Command line (Bicep/ARM) | [![Powershell/Azure CLI](./docs/icons/powershell.png)](./docs/CodeDeploy.md) ## Pre-requisites @@ -23,23 +23,20 @@ Detailed instructions on the required permissions and how to assign them are ava ## How it works? -There are two criteria for replacing a session host, -1. **Image Version:** Is there a new image version available? If so, we create a new session host with the new image version. This can be from Marketplace or Gallery Image Definition. -2. **Session Host VM Age:** If the session host is older than a certain age, default is 45 days, we create a new session host and drain the old one. +There are two criteria for deploying refreshed session hosts, +1. **Image Version:** Is there a new image version available? If so, we create a new session host with the new image version. This can be from Marketplace or Gallery Image Definition. +2. **Session Host VM Age:** If a managed session host is older than a certain age (default is 45 days), we create a new session host. The core of an AVD Session Host Replacer is an Azure Function App built using PowerShell, the function is triggered every hour to check each session host against the above criteria. To deploy new session hosts, the function uses an ARM Template that is stored as a Template Spec at deployment time. -When deleting an old session host, the function will check if it has existing sessions and, +Session host decommissioning is disabled. The function does not drain, remove, or delete existing session hosts. -1. Place the session host drain mode. -2. Send a notification to all sessions. -3. Add a tag to the session host with a timestamp -4. Delete the session host once there are no sessions or the grace period has passed. - - Delete VM - - Remove from Host Pool - - (If Entra Joined) Delete device from Entra ID +Only managed session hosts are counted toward `_TargetSessionHostCount`. +The managed baseline is controlled by `_ManagedSessionHostMinSuffix` (default `1025`). +Session hosts with a numeric suffix lower than this value are ignored when calculating how many new hosts to deploy. +New session hosts start at this suffix baseline and fill available gaps in the managed range. ## FAQ - **Can I use a custom Template Spec for Session Hosts deployment?** @@ -64,11 +61,19 @@ When deleting an old session host, the function will check if it has existing se - **How can I force replace a specific session host?** - On the VM(s) you want to replace, update the the tag `AutoReplaceDeployTimestamp` to any date older that 45 days. The Session Host Replacer will replace the VM on the next run. + On the VM(s) you want to prioritize for refresh logic, update the tag `AutoReplaceDeployTimestamp` to a date older than the target age. On the next run, the function can deploy additional session hosts. + + Existing hosts are not deleted automatically. Decommission/removal must be handled manually. + +- **How does target host count work with legacy host names?** + + The setting `_ManagedSessionHostMinSuffix` (default `1025`) defines the lowest numeric suffix included in target count calculations. + + Example: If `_TargetSessionHostCount` is `5` and existing suffixes are `0231`, `0678`, `0976`, `1025`, `1027`, then only `1025` and `1027` are counted, and the function deploys `3` new hosts: `1026`, `1028`, `1029`. - **What about AVD Scaling Plans?** - When the Session Host Replacer needs to delete a session host that has users logged in, it will add a tag `ScalingPlanExclusion` to the VM. The name of the tag is configurable and it should be the same as the tag used in the scaling plan. + Because decommissioning is disabled, the function does not place hosts in drain mode and does not apply scaling-plan exclusion tags for pending deletion. - **What happens if a deployment fails?** diff --git a/StandardSessionHostTemplate/DeploySessionHosts.json b/StandardSessionHostTemplate/DeploySessionHosts.json index ef8baab..4cfd8b0 100644 --- a/StandardSessionHostTemplate/DeploySessionHosts.json +++ b/StandardSessionHostTemplate/DeploySessionHosts.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "11784787085459809456" + "version": "0.32.4.45862", + "templateHash": "13792602582245425536" } }, "parameters": { @@ -20,6 +20,9 @@ "VMNames": { "type": "array" }, + "VMNamePrefixLength": { + "type": "int" + }, "VMSize": { "type": "string" }, @@ -35,6 +38,19 @@ "DiskType": { "type": "string" }, + "DiskSizeGB": { + "type": "int", + "defaultValue": 128, + "metadata": { + "description": "OS disk size in GB" + } + }, + "DnsServers": { + "type": "array", + "metadata": { + "description": "List of DNS server IP addresses to set on the NIC" + } + }, "Tags": { "type": "object", "defaultValue": {} @@ -104,12 +120,21 @@ "VMName": { "value": "[parameters('VMNames')[copyIndex()]]" }, + "VMNamePrefixLength": { + "value": "[parameters('VMNamePrefixLength')]" + }, "VMSize": { "value": "[parameters('VMSize')]" }, "DiskType": { "value": "[parameters('DiskType')]" }, + "DiskSizeGB": { + "value": "[parameters('DiskSizeGB')]" + }, + "DnsServers": { + "value": "[parameters('DnsServers')]" + }, "WVDArtifactsURL": { "value": "[parameters('WVDArtifactsURL')]" }, @@ -135,20 +160,36 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "10911582647457079552" + "version": "0.32.4.45862", + "templateHash": "9402242463429439832" } }, "parameters": { "VMName": { "type": "string" }, + "VMNamePrefixLength": { + "type": "int" + }, "VMSize": { "type": "string" }, "DiskType": { "type": "string" }, + "DiskSizeGB": { + "type": "int", + "defaultValue": 128, + "metadata": { + "description": "OS disk size in GB" + } + }, + "DnsServers": { + "type": "array", + "metadata": { + "description": "List of DNS server IP addresses to set on the NIC" + } + }, "Location": { "type": "string", "defaultValue": "[resourceGroup().location]" @@ -200,7 +241,7 @@ }, "variables": { "varRequireNvidiaGPU": "[or(startsWith(parameters('VMSize'), 'Standard_NC'), contains(parameters('VMSize'), '_A10_v5'))]", - "varVMNumber": "[int(substring(parameters('VMName'), add(lastIndexOf(parameters('VMName'), '-'), 1), sub(sub(length(parameters('VMName')), lastIndexOf(parameters('VMName'), '-')), 1)))]", + "varVMNumber": "[int(substring(parameters('VMName'), parameters('VMNamePrefixLength'), sub(length(parameters('VMName')), parameters('VMNamePrefixLength'))))]", "varAvailabilityZone": "[if(equals(parameters('AvailabilityZones'), createArray()), createArray(), createArray(format('{0}', parameters('AvailabilityZones')[mod(variables('varVMNumber'), length(parameters('AvailabilityZones')))])))]" }, "resources": [ @@ -323,26 +364,32 @@ "[resourceId('Microsoft.Compute/virtualMachines', parameters('VMName'))]" ] }, - { - "type": "Microsoft.Network/networkInterfaces", - "apiVersion": "2023-09-01", - "name": "[format('{0}-vNIC', parameters('VMName'))]", - "location": "[parameters('Location')]", - "properties": { - "ipConfigurations": [ - { - "name": "ipconfig1", - "properties": { - "subnet": { - "id": "[parameters('SubnetID')]" - } - } - } - ], - "enableAcceleratedNetworking": "[parameters('AcceleratedNetworking')]" - }, - "tags": "[parameters('Tags')]" - }, + +{ + "type": "Microsoft.Network/networkInterfaces", + "apiVersion": "2023-09-01", + "name": "[format('{0}-vNIC', parameters('VMName'))]", + "location": "[parameters('Location')]", + "properties": { + "ipConfigurations": [ + { + "name": "ipconfig1", + "properties": { + "subnet": { + "id": "[parameters('SubnetID')]" + } + } + } + ], + +"dnsSettings": { + "dnsServers": "[parameters('DnsServers')]" +}, + "enableAcceleratedNetworking": "[parameters('AcceleratedNetworking')]" + }, + "tags": "[parameters('Tags')]" +}, + { "type": "Microsoft.Compute/virtualMachines", "apiVersion": "2023-09-01", @@ -359,11 +406,15 @@ "hardwareProfile": { "vmSize": "[parameters('VMSize')]" }, + "additionalCapabilities": { + "hibernationEnabled": true + }, "storageProfile": { "osDisk": { "name": "[format('{0}-OSDisk', parameters('VMName'))]", "createOption": "FromImage", "deleteOption": "Delete", + "diskSizeGB": "[parameters('DiskSizeGB')]", "managedDisk": { "storageAccountType": "[parameters('DiskType')]" } @@ -398,4 +449,4 @@ } } ] -} \ No newline at end of file +} diff --git a/deploy/arm/DeployAVDSessionHostReplacer.json b/deploy/arm/DeployAVDSessionHostReplacer.json index c829954..a726024 100644 --- a/deploy/arm/DeployAVDSessionHostReplacer.json +++ b/deploy/arm/DeployAVDSessionHostReplacer.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.29.47.4906", - "templateHash": "7416287688351186923" + "templateHash": "16749693425937561368" } }, "parameters": { @@ -31,18 +31,25 @@ "description": "Required: Yes | Name of the Log Analytics Workspace used by the Function App Insights." } }, + "UseStandardTemplate": { + "type": "bool", + "defaultValue": true + }, "SessionHostsRegion": { - "type": "string" + "type": "string", + "defaultValue": "" }, "AvailabilityZones": { "type": "array", "defaultValue": [] }, "SessionHostSize": { - "type": "string" + "type": "string", + "defaultValue": "" }, "AcceleratedNetworking": { - "type": "bool" + "type": "bool", + "defaultValue": false }, "SessionHostDiskType": { "type": "string", @@ -53,8 +60,23 @@ "Premium_LRS" ] }, + "DiskSizeGB": { + "type": "int", + "defaultValue": 128, + "metadata": { + "description": "OS disk size in GB" + } + }, + "DnsServers": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "List of DNS server IP addresses to set on the NIC" + } + }, "MarketPlaceOrCustomImage": { "type": "string", + "defaultValue": "Marketplace", "allowedValues": [ "Marketplace", "Gallery" @@ -102,10 +124,12 @@ "defaultValue": true }, "SubnetId": { - "type": "string" + "type": "string", + "defaultValue": "" }, "IdentityServiceProvider": { "type": "string", + "defaultValue": "EntraID", "allowedValues": [ "EntraID", "ActiveDirectory", @@ -133,7 +157,23 @@ "defaultValue": "" }, "LocalAdminUsername": { - "type": "string" + "type": "string", + "defaultValue": "" + }, + "CustomTemplateSpecResourceId": { + "type": "string", + "defaultValue": "" + }, + "VMNamesTemplateParameterName": { + "type": "string", + "defaultValue": "VMNames", + "metadata": { + "description": "Required: No | The name of the parameter in the template that specifies the VM Names array." + } + }, + "CustomTemplateSpecParameters": { + "type": "object", + "defaultValue": {} }, "HostPoolResourceGroupName": { "type": "string", @@ -265,6 +305,13 @@ "description": "Required: No | Number of digits to use for the instance number of the session hosts (eg. AVDVM-01). | Default: 2" } }, + "ManagedSessionHostMinSuffix": { + "type": "int", + "defaultValue": 1025, + "metadata": { + "description": "Required: No | Minimum numeric suffix for managed session hosts. Hosts below this suffix are ignored when calculating how many new hosts to deploy. | Default: 1025" + } + }, "ReplaceSessionHostOnNewImageVersion": { "type": "bool", "defaultValue": true, @@ -279,18 +326,18 @@ "description": "Required: No | Delay in days before replacing session hosts when a new image version is detected. | Default: 0 (no delay)." } }, - "VMNamesTemplateParameterName": { + "SessionHostResourceGroupName": { "type": "string", - "defaultValue": "VMNames", + "defaultValue": "", "metadata": { - "description": "Required: No | The name of the parameter in the template that specifies the VM Names array." + "description": "Required: No | Leave this empty to deploy to same resource group as the host pool." } }, - "SessionHostResourceGroupName": { + "FunctionAppUrl": { "type": "string", - "defaultValue": "", + "defaultValue": "[if(and(contains(deployment().properties, 'templateLink'), not(empty(deployment().properties.templateLink.uri))), uri(replace(split(deployment().properties.templateLink.uri, '?')[0], 'DeployAVDSessionHostReplacer.json', ''), '../../FunctionApp/FunctionApp.zip'), 'https://raw.githubusercontent.com/stefze/AVDSessionHostReplacer/main/deploy/zip/FunctionApp.zip')]", "metadata": { - "description": "Required: No | Leave this empty to deploy to same resource group as the host pool." + "description": "Required: No | URL of the FunctionApp.zip package. By default, this is derived from the current template URL so it follows the deployed repo branch automatically." } }, "TimeStamp": { @@ -379,10 +426,12 @@ "AzureUSGovernment", "AzureChinaCloud" ], + "splitParts": "[split(parameters('HostPoolResourceGroupName'), '-')]", + "rgpattern": "[concat('-',variables('splitParts')[2], '-', variables('splitParts')[3], '-', variables('splitParts')[4], '-', variables('splitParts')[5])]", + "rgpattern2": "[concat(variables('splitParts')[2],variables('splitParts')[3],variables('splitParts')[4],variables('splitParts')[5])]", "varGraphEnvironmentNames": "[if(parameters('UseGovDodGraph'), createArray('Global', 'USGovDod', 'China'), createArray('Global', 'USGov', 'China'))]", "varGraphEnvironmentName": "[variables('varGraphEnvironmentNames')[indexOf(variables('varAzureEnvironments'), environment().name)]]", - "varUniqueString": "[uniqueString(resourceGroup().id, parameters('HostPoolName'))]", - "varFunctionAppName": "[format('AVDSessionHostReplacer-{0}', uniqueString(resourceGroup().id, parameters('HostPoolName')))]", + "varFunctionAppName": "[concat('AVDReplacer', variables('rgpattern'))]", "varFunctionAppIdentity": "[if(parameters('UseUserAssignedManagedIdentity'), createObject('type', 'UserAssigned', 'userAssignedIdentities', createObject(format('{0}', parameters('UserAssignedManagedIdentityResourceId')), createObject())), createObject('type', 'SystemAssigned'))]" }, "resources": [ @@ -402,6 +451,9 @@ "FunctionAppName": { "value": "[variables('varFunctionAppName')]" }, + "FunctionAppUrl": { + "value": "[parameters('FunctionAppUrl')]" + }, "EnableMonitoring": { "value": "[parameters('EnableMonitoring')]" }, @@ -443,7 +495,7 @@ }, { "name": "_SessionHostParameters", - "value": "[string(createObject('Location', parameters('SessionHostsRegion'), 'AvailabilityZones', parameters('AvailabilityZones'), 'VMSize', parameters('SessionHostSize'), 'AcceleratedNetworking', parameters('AcceleratedNetworking'), 'DiskType', parameters('SessionHostDiskType'), 'ImageReference', variables('varImageReference'), 'SecurityProfile', variables('varSecurityProfile'), 'SubnetId', parameters('SubnetId'), 'DomainJoinObject', variables('varDomainJoinObject'), 'DomainJoinPassword', if(equals(parameters('IdentityServiceProvider'), 'EntraID'), null(), createObject('reference', createObject('keyVault', createObject('id', reference(resourceId('Microsoft.Resources/deployments', 'deployKeyVault'), '2022-09-01').outputs.keyVaultId.value), 'secretName', 'DomainJoinPassword'))), 'AdminUsername', parameters('LocalAdminUsername'), 'tags', createObject()))]" + "value": "[string(createObject('Location', parameters('SessionHostsRegion'), 'AvailabilityZones', parameters('AvailabilityZones'), 'VMSize', parameters('SessionHostSize'), 'AcceleratedNetworking', parameters('AcceleratedNetworking'), 'DiskType', parameters('SessionHostDiskType'), 'DiskSizeGB', parameters('DiskSizeGB'), 'DnsServers', parameters('DnsServers'), 'ImageReference', variables('varImageReference'), 'SecurityProfile', variables('varSecurityProfile'), 'SubnetId', parameters('SubnetId'), 'DomainJoinObject', variables('varDomainJoinObject'), 'DomainJoinPassword', if(equals(parameters('IdentityServiceProvider'), 'EntraID'), null(), createObject('reference', createObject('keyVault', createObject('id', reference(resourceId('Microsoft.Resources/deployments', 'deployKeyVault'), '2022-09-01').outputs.keyVaultId.value), 'secretName', 'DomainJoinPassword'))), 'AdminUsername', parameters('LocalAdminUsername'), 'VMNamePrefixLength', add(length(parameters('SessionHostNamePrefix')), length(parameters('SessionHostNameSeparator'))), 'tags', createObject()))]" }, { "name": "_SubscriptionId", @@ -459,11 +511,11 @@ }, { "name": "_ClientId", - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', split(parameters('UserAssignedManagedIdentityResourceId'), '/')[2], split(parameters('UserAssignedManagedIdentityResourceId'), '/')[4]), 'Microsoft.ManagedIdentity/userAssignedIdentities', split(parameters('UserAssignedManagedIdentityResourceId'), '/')[8]), '2023-01-31').clientId]" + "value": "[if(parameters('UseUserAssignedManagedIdentity'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', split(parameters('UserAssignedManagedIdentityResourceId'), '/')[2], split(parameters('UserAssignedManagedIdentityResourceId'), '/')[4]), 'Microsoft.ManagedIdentity/userAssignedIdentities', split(parameters('UserAssignedManagedIdentityResourceId'), '/')[8]), '2023-01-31').clientId, '')]" }, { "name": "_TenantId", - "value": "[reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', split(parameters('UserAssignedManagedIdentityResourceId'), '/')[2], split(parameters('UserAssignedManagedIdentityResourceId'), '/')[4]), 'Microsoft.ManagedIdentity/userAssignedIdentities', split(parameters('UserAssignedManagedIdentityResourceId'), '/')[8]), '2023-01-31').tenantId]" + "value": "[if(parameters('UseUserAssignedManagedIdentity'), reference(extensionResourceId(format('/subscriptions/{0}/resourceGroups/{1}', split(parameters('UserAssignedManagedIdentityResourceId'), '/')[2], split(parameters('UserAssignedManagedIdentityResourceId'), '/')[4]), 'Microsoft.ManagedIdentity/userAssignedIdentities', split(parameters('UserAssignedManagedIdentityResourceId'), '/')[8]), '2023-01-31').tenantId, '')]" }, { "name": "_GraphEnvironmentName", @@ -513,6 +565,10 @@ "name": "_SessionHostInstanceNumberPadding", "value": "[parameters('SessionHostInstanceNumberPadding')]" }, + { + "name": "_ManagedSessionHostMinSuffix", + "value": "[parameters('ManagedSessionHostMinSuffix')]" + }, { "name": "_ReplaceSessionHostOnNewImageVersion", "value": "[parameters('ReplaceSessionHostOnNewImageVersion')]" @@ -542,7 +598,7 @@ "_generator": { "name": "bicep", "version": "0.29.47.4906", - "templateHash": "17223373059197527263" + "templateHash": "3734734378229800954" } }, "parameters": { @@ -574,9 +630,9 @@ "description": "Required: Yes | Name of the Function App." } }, - "FunctionAppZipUrl": { + "FunctionAppUrl": { "type": "string", - "defaultValue": "https://github.com/Azure/AVDSessionHostReplacer/releases/download/v0.3.0/FunctionApp.zip", + "defaultValue": "https://raw.githubusercontent.com/stefze/AVDSessionHostReplacer/main/deploy/zip/FunctionApp.zip", "metadata": { "description": "Required: No | URL of the FunctionApp.zip file. This is the zip file containing the Function App code. | Default: The latest release of the Function App code." } @@ -609,17 +665,22 @@ } }, "variables": { - "varStorageAccountName": "[format('stavdrpfunc{0}', uniqueString(parameters('FunctionAppName')))]", - "varLogAnalyticsWorkspaceName": "[format('{0}-law', parameters('FunctionAppName'))]", - "varAppServicePlanName": "[format('{0}-asp', parameters('FunctionAppName'))]" + "rgname": "[toLower(resourceGroup().name)]", + "splitParts": "[split(variables('rgname'), '-')]", + "rgpattern2": "[concat(variables('splitParts')[2],variables('splitParts')[3],variables('splitParts')[4],variables('splitParts')[5])]", + "varStorageAccountName": "[concat('st', variables('rgpattern2'))]", + "varLogAnalyticsWorkspaceName": "[concat('law-', parameters('FunctionAppName'))]", + "varAppServicePlanName": "[concat('Asp-', parameters('FunctionAppName'))]", + "varGraphEnvironmentName": "your_value_here" }, "resources": [ { "type": "Microsoft.Web/sites/extensions", "apiVersion": "2023-01-01", - "name": "[format('{0}/{1}', parameters('FunctionAppName'), 'MSDeploy')]", + "name": "[format('{0}/{1}', parameters('FunctionAppName'), 'onedeploy')]", "properties": { - "packageUri": "[parameters('FunctionAppZipUrl')]" + "packageUri": "[parameters('FunctionAppUrl')]", + "type": "zip" }, "dependsOn": [ "[resourceId('Microsoft.Web/sites', parameters('FunctionAppName'))]" @@ -688,9 +749,9 @@ "serverFarmId": "[resourceId('Microsoft.Web/serverfarms', variables('varAppServicePlanName'))]", "siteConfig": { "use32BitWorkerProcess": false, - "powerShellVersion": "7.2", + "powerShellVersion": "7.4", "netFrameworkVersion": "v6.0", - "appSettings": "[union(createArray(createObject('name', 'FUNCTIONS_EXTENSION_VERSION', 'value', '~4'), createObject('name', 'FUNCTIONS_WORKER_RUNTIME', 'value', 'powershell'), createObject('name', 'AzureWebJobsStorage', 'value', format('DefaultEndpointsProtocol=https;AccountName={0};EndpointSuffix={1};AccountKey={2}', variables('varStorageAccountName'), environment().suffixes.storage, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('varStorageAccountName')), '2022-05-01').keys[0].value)), createObject('name', 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', 'value', format('DefaultEndpointsProtocol=https;AccountName={0};EndpointSuffix={1};AccountKey={2}', variables('varStorageAccountName'), environment().suffixes.storage, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('varStorageAccountName')), '2022-05-01').keys[0].value)), createObject('name', 'APPINSIGHTS_INSTRUMENTATIONKEY', 'value', reference(resourceId('Microsoft.Insights/components', variables('varAppServicePlanName')), '2020-02-02').InstrumentationKey), createObject('name', 'WEBSITE_CONTENTSHARE', 'value', toLower(parameters('FunctionAppName')))), if(parameters('EnableMonitoring'), createArray(createObject('name', 'APPINSIGHTS_INSTRUMENTATIONKEY', 'value', reference(resourceId('Microsoft.Insights/components', variables('varAppServicePlanName')), '2020-02-02').InstrumentationKey)), createArray()), parameters('ReplacementPlanSettings'))]", + "appSettings": "[union(createArray(createObject('name', 'FUNCTIONS_EXTENSION_VERSION', 'value', '~4'), createObject('name', 'FUNCTIONS_WORKER_RUNTIME', 'value', 'powershell'), createObject('name', 'AzureWebJobs.timerTrigger1.Disabled', 'value', '1'), createObject('name', 'AzureWebJobsStorage', 'value', format('DefaultEndpointsProtocol=https;AccountName={0};EndpointSuffix={1};AccountKey={2}', variables('varStorageAccountName'), environment().suffixes.storage, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('varStorageAccountName')), '2022-05-01').keys[0].value)), createObject('name', 'WEBSITE_CONTENTAZUREFILECONNECTIONSTRING', 'value', format('DefaultEndpointsProtocol=https;AccountName={0};EndpointSuffix={1};AccountKey={2}', variables('varStorageAccountName'), environment().suffixes.storage, listKeys(resourceId('Microsoft.Storage/storageAccounts', variables('varStorageAccountName')), '2022-05-01').keys[0].value)), createObject('name', 'APPINSIGHTS_INSTRUMENTATIONKEY', 'value', reference(resourceId('Microsoft.Insights/components', variables('varAppServicePlanName')), '2020-02-02').InstrumentationKey), createObject('name', 'WEBSITE_CONTENTSHARE', 'value', toLower(parameters('FunctionAppName')))), if(parameters('EnableMonitoring'), createArray(createObject('name', 'APPINSIGHTS_INSTRUMENTATIONKEY', 'value', reference(resourceId('Microsoft.Insights/components', variables('varAppServicePlanName')), '2020-02-02').InstrumentationKey)), createArray()), parameters('ReplacementPlanSettings'))]", "ftpsState": "Disabled", "cors": { "allowedOrigins": [ @@ -734,7 +795,7 @@ "value": "[parameters('Location')]" }, "KeyVaultName": { - "value": "[format('kv-AVDSHR-{0}', variables('varUniqueString'))]" + "value": "[concat('kvSHR', variables('rgpattern2'))]" }, "DomainJoinPassword": { "value": "[parameters('ADJoinUserPassword')]" @@ -823,7 +884,7 @@ "_generator": { "name": "bicep", "version": "0.29.47.4906", - "templateHash": "8573904393442968176" + "templateHash": "10208603161915711494" } }, "parameters": { @@ -842,8 +903,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "11784787085459809456" + "version": "0.33.4.45862", + "templateHash": "13792602582245425536" } }, "parameters": { @@ -858,6 +919,9 @@ "VMNames": { "type": "array" }, + "VMNamePrefixLength": { + "type": "int" + }, "VMSize": { "type": "string" }, @@ -873,6 +937,20 @@ "DiskType": { "type": "string" }, + "DiskSizeGB": { + "type": "int", + "defaultValue": 128, + "metadata": { + "description": "OS disk size in GB" + } + }, + "DnsServers": { + "type": "array", + "defaultValue": [], + "metadata": { + "description": "List of DNS server IP addresses to set on the NIC" + } + }, "Tags": { "type": "object", "defaultValue": {} @@ -942,12 +1020,21 @@ "VMName": { "value": "[[parameters('VMNames')[copyIndex()]]" }, + "VMNamePrefixLength": { + "value": "[[parameters('VMNamePrefixLength')]" + }, "VMSize": { "value": "[[parameters('VMSize')]" }, "DiskType": { "value": "[[parameters('DiskType')]" }, + "DiskSizeGB": { + "value": "[[parameters('DiskSizeGB')]" + }, + "DnsServers": { + "value": "[[parameters('DnsServers')]" + }, "WVDArtifactsURL": { "value": "[[parameters('WVDArtifactsURL')]" }, @@ -973,20 +1060,36 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.29.47.4906", - "templateHash": "10911582647457079552" + "version": "0.32.4.45862", + "templateHash": "9402242463429439832" } }, "parameters": { "VMName": { "type": "string" }, + "VMNamePrefixLength": { + "type": "int" + }, "VMSize": { "type": "string" }, "DiskType": { "type": "string" }, + "DiskSizeGB": { + "type": "int", + "defaultValue": 128, + "metadata": { + "description": "OS disk size in GB" + } + }, + "DnsServers": { + "type": "array", + "metadata": { + "description": "List of DNS server IP addresses to set on the NIC" + } + }, "Location": { "type": "string", "defaultValue": "[[resourceGroup().location]" @@ -1038,7 +1141,7 @@ }, "variables": { "varRequireNvidiaGPU": "[[or(startsWith(parameters('VMSize'), 'Standard_NC'), contains(parameters('VMSize'), '_A10_v5'))]", - "varVMNumber": "[[int(substring(parameters('VMName'), add(lastIndexOf(parameters('VMName'), '-'), 1), sub(sub(length(parameters('VMName')), lastIndexOf(parameters('VMName'), '-')), 1)))]", + "varVMNumber": "[[int(substring(parameters('VMName'), parameters('VMNamePrefixLength'), sub(length(parameters('VMName')), parameters('VMNamePrefixLength'))))]", "varAvailabilityZone": "[[if(equals(parameters('AvailabilityZones'), createArray()), createArray(), createArray(format('{0}', parameters('AvailabilityZones')[mod(variables('varVMNumber'), length(parameters('AvailabilityZones')))])))]" }, "resources": [ @@ -1177,6 +1280,9 @@ } } ], + "dnsSettings": { + "dnsServers": "[[parameters('DnsServers')]" + }, "enableAcceleratedNetworking": "[[parameters('AcceleratedNetworking')]" }, "tags": "[[parameters('Tags')]" @@ -1197,11 +1303,15 @@ "hardwareProfile": { "vmSize": "[[parameters('VMSize')]" }, + "additionalCapabilities": { + "hibernationEnabled": true + }, "storageProfile": { "osDisk": { "name": "[[format('{0}-OSDisk', parameters('VMName'))]", "createOption": "FromImage", "deleteOption": "Delete", + "diskSizeGB": "[[parameters('DiskSizeGB')]", "managedDisk": { "storageAccountType": "[[parameters('DiskType')]" } @@ -1395,4 +1505,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/deploy/bicep/modules/deployFunctionApp.bicep b/deploy/bicep/modules/deployFunctionApp.bicep index 5937a80..dfcb5ad 100644 --- a/deploy/bicep/modules/deployFunctionApp.bicep +++ b/deploy/bicep/modules/deployFunctionApp.bicep @@ -173,7 +173,7 @@ resource functionApp 'Microsoft.Web/sites@2023-01-01' = { serverFarmId: appServicePlan.id siteConfig: { use32BitWorkerProcess: false - powerShellVersion: '7.2' + powerShellVersion: '7.4' netFrameworkVersion: 'v6.0' appSettings: varFunctionAppSettingsAndReplacementPlanSettings ftpsState: 'Disabled' diff --git a/deploy/portal-ui/portal-ui.json b/deploy/portal-ui/portal-ui.json index 83d6e5e..5697af1 100644 --- a/deploy/portal-ui/portal-ui.json +++ b/deploy/portal-ui/portal-ui.json @@ -12,8 +12,11 @@ { "name": "resourceScope", "type": "Microsoft.Common.ResourceScope", + "subscription": {}, + "resourceGroup": {}, "location": { - "resourceTypes": [] + "resourceTypes": ["Microsoft.DesktopVirtualization/HostPools" + ] } }, { @@ -32,8 +35,7 @@ }, "options": { "filter": { - "subscription": "onBasics", - "location": "onBasics" + "subscription": "onBasics" } } }, @@ -193,10 +195,10 @@ "type": "Microsoft.Common.TextBlock", "visible": true, "options": { - "text": "AVD session host replacer Portal UI Version: v0.3.0", + "text": "AVD session host replacer Portal UI Version: v0.3.2betasz", "link": { "label": "GitHub Repository", - "uri": "https://github.com/Azure/AVDSessionHostReplacer" + "uri": "https://github.com/stefze/AVDSessionHostReplacer" } } } @@ -303,6 +305,21 @@ ] } }, + { + "name": "DiskSizeGB", + "type": "Microsoft.Common.Slider", + "min": 64, + "max": 512, + "label": "OS Disk Size", + "subLabel": "GB", + "defaultValue": 128, + "showStepMarkers": false, + "toolTip": "Size of the OS disk in GB.", + "constraints": { + "required": true + }, + "visible": true + }, { "name": "optionMarketPlaceOrCustomImage", "type": "Microsoft.Common.OptionsGroup", @@ -486,6 +503,35 @@ ] } }, + { + "name": "DnsSettings", + "type": "Microsoft.Common.Section", + "label": "DNS settings", + "elements": [ + { + "name": "DnsServer1", + "type": "Microsoft.Common.TextBox", + "label": "Primary DNS (IPv4)", + "placeholder": "e.g., 10.0.0.4", + "constraints": { + "required": true, + "regex": "^((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)$", + "validationMessage": "Enter a valid IPv4 address (0-255 in each octet)." + } + }, + { + "name": "DnsServer2", + "type": "Microsoft.Common.TextBox", + "label": "Secondary DNS (IPv4)", + "placeholder": "e.g., 10.0.0.5", + "constraints": { + "required": true, + "regex": "^((25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)\\.){3}(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)$", + "validationMessage": "Enter a valid IPv4 address (0-255 in each octet)." + } + } + ] + }, { "name": "DomainJoinSection", "type": "Microsoft.Common.Section", @@ -771,6 +817,7 @@ "SessionHostSize": "[steps('SessionHostsTemplate').SessionHostSize]", "AcceleratedNetworking": "[steps('SessionHostsTemplate').AcceleratedNetworking]", "SessionHostDiskType": "[steps('SessionHostsTemplate').SessionHostDiskType]", + "DiskSizeGB": "[int(steps('SessionHostsTemplate').DiskSizeGB)]", "MarketPlaceOrCustomImage": "[steps('SessionHostsTemplate').optionMarketPlaceOrCustomImage]", "MarketPlaceImage": "[steps('SessionHostsTemplate').dropDownMarketPlaceImage]", "GalleryImageId": "[steps('SessionHostsTemplate').resourceSelectorSessionHostGalleryImageId.id]", @@ -799,7 +846,8 @@ "ReplaceSessionHostOnNewImageVersion": "[steps('optionalParametersStep')._ReplaceSessionHostOnNewImageVersion]", "ReplaceSessionHostOnNewImageVersionDelayDays": "[steps('optionalParametersStep')._ReplaceSessionHostOnNewImageVersionDelayDays]", "VMNamesTemplateParameterName": "VMNames", - "SessionHostResourceGroupName": "[steps('optionalParametersStep')._SessionHostResourceGroupName]" + "SessionHostResourceGroupName": "[steps('optionalParametersStep')._SessionHostResourceGroupName]", + "DnsServers": "[createArray(steps('SessionHostsTemplate').DnsSettings.DnsServer1, steps('SessionHostsTemplate').DnsSettings.DnsServer2)]" } } } diff --git a/deploy/zip/FunctionApp.zip b/deploy/zip/FunctionApp.zip new file mode 100644 index 0000000..4d4ea3e Binary files /dev/null and b/deploy/zip/FunctionApp.zip differ