From 11735910b6b0cdc7ccf8cefd3b852ccb477f42eb Mon Sep 17 00:00:00 2001 From: WillyMoselhy <20515747+WillyMoselhy@users.noreply.github.com> Date: Thu, 15 Aug 2024 16:43:32 +0400 Subject: [PATCH] Add Include pre-existing session hosts --- FunctionApp/FunctionParameters.psd1 | 3 +- .../functions/Get-SHRSessionHost.ps1 | 10 +- .../bicep/DeployAVDSessionHostReplacer.bicep | 7 + deploy/portal-ui/portal-ui.json | 1599 +++++++++-------- docs/CodeDeploy.md | 1 + docs/icons/powershell.png | Bin 12760 -> 2882 bytes 6 files changed, 819 insertions(+), 801 deletions(-) diff --git a/FunctionApp/FunctionParameters.psd1 b/FunctionApp/FunctionParameters.psd1 index 5b9ca63..fa3e9a3 100644 --- a/FunctionApp/FunctionParameters.psd1 +++ b/FunctionApp/FunctionParameters.psd1 @@ -5,7 +5,8 @@ _Tag_ScalingPlanExclusionTag = @{Required = $false ; Type = 'string' ; Default = 'ScalingPlanExclusion' ; Description = '' } _TargetVMAgeDays = @{Required = $false ; Type = 'int ' ; Default = 45 ; Description = 'Automatically replaces the session hosts when they are older than X number of days even if there is no new image. The default is 45 days. Setting this value to 0 disables the feature.' } _DrainGracePeriodHours = @{Required = $false ; Type = 'int ' ; Default = 24 ; Description = '' } - _FixSessionHostTags = @{Required = $false ; Type = 'bool ' ; Default = $true ; Description = '' } + _FixSessionHostTags = @{Required = $false ; Type = 'bool ' ; Default = $true ; Description = 'For Pre-existing Session Hosts, this will add the IncludeInAutomation and DeployTimestamp tags. This is required if IncludePreExistingVMs in enabled.' } + _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 = '' } _ReplaceSessionHostOnNewImageVersion = @{Required = $false ; Type = 'bool ' ; Default = $true ; Description = '' } diff --git a/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRSessionHost.ps1 b/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRSessionHost.ps1 index 0f5fd4e..ce2d39b 100644 --- a/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRSessionHost.ps1 +++ b/FunctionApp/Modules/SessionHostReplacer/functions/Get-SHRSessionHost.ps1 @@ -24,10 +24,10 @@ function Get-SHRSessionHost { [string] $TagDeployTimestamp = (Get-FunctionConfig _Tag_DeployTimestamp), [Parameter()] [string] $TagPendingDrainTimeStamp = (Get-FunctionConfig _Tag_PendingDrainTimestamp), - - [Parameter()] - [switch] $FixSessionHostTags + [switch] $FixSessionHostTags, + [Parameter()] + [bool] $IncludePreExistingSessionHosts = (Get-FunctionConfig _IncludePreExistingSessionHosts) ) @@ -78,10 +78,10 @@ function Get-SHRSessionHost { 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 False' -StringValues $TagIncludeInAutomation - Update-AzTag -ResourceId $item.ResourceId -Tag @{ $TagIncludeInAutomation = 'False' } -Operation Merge + Update-AzTag -ResourceId $item.ResourceId -Tag @{ $TagIncludeInAutomation = "$IncludePreExistingSessionHosts" } -Operation Merge } - $vmIncludeInAutomation = $false + $vmIncludeInAutomation = $IncludePreExistingSessionHosts } #endregion: Tag IncludeInAutomation diff --git a/deploy/bicep/DeployAVDSessionHostReplacer.bicep b/deploy/bicep/DeployAVDSessionHostReplacer.bicep index c2302de..3a8bd47 100644 --- a/deploy/bicep/DeployAVDSessionHostReplacer.bicep +++ b/deploy/bicep/DeployAVDSessionHostReplacer.bicep @@ -122,6 +122,9 @@ param DrainGracePeriodHours int = 24 @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 +@description('Required: No | When enabled, the Session Host Replacer will automatically consider pre-existing VMs for replacement if they meet the criteria | Default: False.') +param IncludePreExistingSessionHosts bool = false + @description('Required: No | Prefix used for the deployment name of the session hosts. | Default: AVDSessionHostReplacer') param SHRDeploymentPrefix string = 'AVDSessionHostReplacer' @@ -384,6 +387,10 @@ var varReplacementPlanSettings = [ name: '_FixSessionHostTags' value: FixSessionHostTags } + { + name: '_IncludePreExistingSessionHosts' + value: IncludePreExistingSessionHosts + } { name: '_SHRDeploymentPrefix' value: SHRDeploymentPrefix diff --git a/deploy/portal-ui/portal-ui.json b/deploy/portal-ui/portal-ui.json index 6048b21..b2a3a4e 100644 --- a/deploy/portal-ui/portal-ui.json +++ b/deploy/portal-ui/portal-ui.json @@ -1,797 +1,806 @@ { - "$schema": "https://schema.management.azure.com/schemas/2021-09-09/uiFormDefinition.schema.json", - "view": { - "kind": "Form", - "properties": { - "title": "Azure Virtual Desktop - Session Host Replacer Deployment", - "steps": [ - { - "name": "basics", - "label": "Basics", - "elements": [ - { - "name": "resourceScope", - "type": "Microsoft.Common.ResourceScope", - "location": { - "resourceTypes": [] - } - }, - { - "name": "HostPoolSettingsSection", - "type": "Microsoft.Common.Section", - "label": "Host Pool Settings", - "visible": true, - "elements": [ + "$schema": "https://schema.management.azure.com/schemas/2021-09-09/uiFormDefinition.schema.json", + "view": { + "kind": "Form", + "properties": { + "title": "Azure Virtual Desktop - Session Host Replacer Deployment", + "steps": [ + { + "name": "basics", + "label": "Basics", + "elements": [ + { + "name": "resourceScope", + "type": "Microsoft.Common.ResourceScope", + "location": { + "resourceTypes": [] + } + }, + { + "name": "HostPoolSettingsSection", + "type": "Microsoft.Common.Section", + "label": "Host Pool Settings", + "visible": true, + "elements": [ + { + "name": "HostPoolSelector", + "type": "Microsoft.Solutions.ResourceSelector", + "label": "Select target Host Pool", + "resourceType": "Microsoft.DesktopVirtualization/HostPools", + "constraints": { + "required": true + }, + "options": { + "filter": { + "subscription": "onBasics", + "location": "onBasics" + } + } + }, + { + "name": "TargetSessionHostCount", + "type": "Microsoft.Common.Slider", + "min": 1, + "max": 5000, + "label": "Target Number of Session Hosts", + "subLabel": "VMs", + "defaultValue": 10, + "showStepMarkers": false, + "toolTip": "The target number of session hosts in the host pool.", + "constraints": { + "required": true + }, + "visible": true + }, + { + "name": "TargetSessionHostBuffer", + "type": "Microsoft.Common.Slider", + "min": 1, + "max": "[steps('basics').HostPoolSettingsSection.TargetSessionHostCount]", + "label": "Session Hosts Buffer", + "subLabel": "VMs", + "defaultValue": 5, + "showStepMarkers": false, + "toolTip": "

The maximum number of session hosts to add during a replacement process.

Example:

Target is 10, buffer is 2, and we need to replace all VMs

Then SHR will add 2 new VMs, delete 2 old ones, and repeat until all hosts are replaced. This is useful to avoid exhausting subnets, capacity limits, and reduce costs.

", + "constraints": { + "required": true + }, + "visible": true + }, + { + "name": "IncludePreExistingSessionHosts", + "type": "Microsoft.Common.CheckBox", + "label": "Include pre-existing session hosts", + "toolTip": "
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.
", + "constraints": { + "required": false + } + }, { - "name": "HostPoolSelector", - "type": "Microsoft.Solutions.ResourceSelector", - "label": "Select target Host Pool", - "resourceType": "Microsoft.DesktopVirtualization/HostPools", - "constraints": { - "required": true - }, - "options": { - "filter": { - "subscription": "onBasics", - "location": "onBasics" - } - } - }, - { - "name": "TargetSessionHostCount", - "type": "Microsoft.Common.Slider", - "min": 1, - "max": 5000, - "label": "Target Number of Session Hosts", - "subLabel": "VMs", - "defaultValue": 10, - "showStepMarkers": false, - "toolTip": "The target number of session hosts in the host pool.", - "constraints": { - "required": true - }, - "visible": true - }, - { - "name": "TargetSessionHostBuffer", - "type": "Microsoft.Common.Slider", - "min": 1, - "max": "[steps('basics').HostPoolSettingsSection.TargetSessionHostCount]", - "label": "Session Hosts Buffer", - "subLabel": "VMs", - "defaultValue": 5, - "showStepMarkers": false, - "toolTip": "

The maximum number of session hosts to add during a replacement process.

Example:

Target is 10, buffer is 2, and we need to replace all VMs

Then SHR will add 2 new VMs, delete 2 old ones, and repeat until all hosts are replaced. This is useful to avoid exhausting subnets, capacity limits, and reduce costs.

", - "constraints": { - "required": true - }, - "visible": true - }, - { - "name": "sessionHostNamePrefix", - "type": "Microsoft.Common.TextBox", - "label": "Session Host Name Prefix", - "toolTip": "

The prefix for the session host names.

Make sure this produces a unique value across all device names in your environment.

Try selecting a prefix that helps you identify the host pool such as AVDHP01

", - "constraints": { - "required": true, - "validationMessage": "Must be a valid name less than 12 characters long to allow for the 3 character suffix (eg. prefix-01)." - } - }, - { - "name": "SessionHostExampleNameInfoBox", - "type": "Microsoft.Common.InfoBox", - "visible": "[not(empty(steps('basics').HostPoolSettingsSection.sessionHostNamePrefix))]", - "options": { - "icon": "Info", - "text": "[concat('Example Session Host name: ', steps('basics').HostPoolSettingsSection.sessionHostNamePrefix , if(steps('optionalParametersStep')._SessionHostNameSeparator,'-','') , take('00000000', sub(steps('optionalParametersStep')._SessionHostInstanceNumberPadding,1)) ,'1
You can customize the separator and padding from Optional Parameters.') ]" - } - } - ] - }, - { - "name": "IdentitySection", - "type": "Microsoft.Common.Section", - "label": "Identity", - "visible": true, - "elements": [ - { - "name": "UseUserAssignedManagedIdentity", - "type": "Microsoft.Common.CheckBox", - "label": "Use User Assigned Managed Identity", - "toolTip": "[Recommended] When enabled, The Session Host Replacer will use the selected Identity to take actions. Otherwise System Identity (MSI) is used.", - "defaultValue": true, - "constraints": { - "required": false - } - }, - { - "name": "UserAssignedManagedIdentitySelector", - "type": "Microsoft.Solutions.ResourceSelector", - "visible": "[steps('basics').IdentitySection.UseUserAssignedManagedIdentity]", - "label": "Select User Assigned Managed Identity", - "resourceType": "Microsoft.ManagedIdentity/userAssignedIdentities", - "constraints": { - "required": true - }, - "options": { - "filter": {} - } - }, - { - "name": "UserAssignedManagedIdentityInfoBox", - "type": "Microsoft.Common.InfoBox", - "visible": "[steps('basics').IdentitySection.UseUserAssignedManagedIdentity]", - "options": { - "icon": "Info", - "text": "When using a User Assigned Managed Identity, make sure the identity has the needed permissions in Azure and Entra. Follow the link for more info.", - "uri": "https://github.com/Azure/AVDReplacementPlans/blob/v0.2.9-beta.16/docs/Permissions.md" - } - } - ] - }, - { - "name": "MonitoringSection", - "type": "Microsoft.Common.Section", - "label": "Monitoring", - "visible": true, - "elements": [ - { - "name": "EnableMonitoring", - "type": "Microsoft.Common.CheckBox", - "label": "Enable Monitoring", - "toolTip": "[Recommended] When enabled, the session host replacer will use App Insights and Log Analytics to collect metrics and logs.", - "defaultValue": true, - "constraints": { - "required": false - } - }, - { - "name": "UseExistingLAW", - "visible": "[steps('basics').MonitoringSection.EnableMonitoring]", - "type": "Microsoft.Common.CheckBox", - "label": "Select existing Log Analytics Workspace", - "toolTip": "When enabled, the session host replacer will use the selected Log Analytics Workspace. If disabled, the session host replacer will create a new Log Analytics Workspace.", - "defaultValue": false, - "constraints": { - "required": false - } - }, - { - "name": "LAWSelector", - "type": "Microsoft.Solutions.ResourceSelector", - "visible": "[and(steps('basics').MonitoringSection.EnableMonitoring, steps('basics').MonitoringSection.UseExistingLAW)]", - "label": "Log Analytics Workspace", - "resourceType": "Microsoft.OperationalInsights/workspaces", - "constraints": { - "required": true - }, - "options": { - "filter": {} - } - } - ] - }, - { - "name": "computeApi", - "type": "Microsoft.Solutions.ArmApiControl", - "request": { - "method": "GET", - "path": "[concat(steps('basics').resourceScope.subscription.id,'/providers/Microsoft.Compute/resourceTypes?api-version=2022-01-01')]" - } - }, - { - "name": "VersionInfo", - "type": "Microsoft.Common.TextBlock", - "visible": true, - "options": { - "text": "AVD session host replacer Portal UI Version: v0.2.9-beta.16", - "link": { - "label": "GitHub Repository", - "uri": "https://github.com/Azure/AVDSessionHostReplacer" - } - } - } - ] - }, - { - "name": "SessionHostsTemplate", - "label": "Session Hosts Template", - "elements": [ - { - "name": "SessionHostsRegion", - "type": "Microsoft.Common.DropDown", - "label": "Session Hosts Region", - "visible": true, - "filter": true, - "multiselect": false, - "selectAll": false, - "toolTip": "Select region to deploy session hosts.", - "constraints": { - "required": true, - "allowedValues": "[map( first( map( filter( steps('basics').computeApi.value, (resourceTypes) => equals(resourceTypes.resourceType, 'virtualMachines') ), (item) => item.locations) ), (item) => parse(concat('{\"label\":\"', item, '\",\"value\":\"', toLower(replace(item, ' ', '')), '\"}')) )]" - } - }, - { - "name": "AvailabilityZones", - "type": "Microsoft.Common.DropDown", - "label": "Availability Zones", - "visible": true, - "filter": false, - "defaultValue": [], - "multiselect": true, - "selectAll": false, - "toolTip": "Select Availability Zones for the session hosts. Make sure the selected size is available in the selected zones. Session hosts will be deployed across the zones.", - "constraints": { - "required": false, - "allowedValues": [ - { - "label": "Zone 1", - "value": "1" - }, - { - "label": "Zone 2", - "value": "2" - }, - { - "label": "Zone 3", - "value": "3" - } - ] - } - }, - { - "name": "SessionHostSize", - "type": "Microsoft.Compute.SizeSelector", - "label": "VM Size", - "toolTip": "", - "recommendedSizes": [ - "Standard_D4ads_v5" - ], - "constraints": { - "allowedSizes": [], - "excludedSizes": [], - "required": true - }, - "options": { - "hideDiskTypeFilter": true - }, - "osPlatform": "Windows", - "imageReference": { - "publisher": "MicrosoftWindowsDesktop", - "offer": "Windows-11", - "sku": "win11-23h2-avd" - } - }, - { - "name": "AcceleratedNetworking", - "type": "Microsoft.Common.CheckBox", - "label": "Enable accelerated networking", - "defaultValue": true, - "toolTip": "Enables low latency and high throughput on the network interface." - }, - { - "name": "SessionHostDiskType", - "type": "Microsoft.Common.DropDown", - "label": "OS Disk type", - "filter": false, - "defaultValue": "Premium SSD", - "toolTip": "Select session host disk type to host the OS.", - "constraints": { - "required": true, - "allowedValues": [ - { - "label": "Standard HDD", - "value": "Standard_LRS" - }, - { - "label": "Standard SSD", - "value": "StandardSSD_LRS" - }, - { - "label": "Premium SSD", - "value": "Premium_LRS" - } - ] - } - }, - { - "name": "optionMarketPlaceOrCustomImage", - "type": "Microsoft.Common.OptionsGroup", - "label": "Image Source", - "defaultValue": "Marketplace", - "toolTip": "", - "constraints": { - "allowedValues": [ - { - "label": "Marketplace", - "value": "Marketplace" - }, - { - "label": "Gallery Image", - "value": "Gallery" - } - ], - "required": true - }, - "visible": true - }, - { - "name": "dropDownMarketPlaceImage", - "type": "Microsoft.Common.DropDown", - "label": "Select a Marketplace Image", - "placeholder": "", - "defaultValue": [ - "Windows 11 23H2 multi-session" - ], - "toolTip": "Marketplace images are updated on monthly basis. The Session Host Replacer will replace the session hosts when a new image is available.", - "multiselect": false, - "selectAll": false, - "filter": true, - "filterPlaceholder": "Filter items ...", - "multiLine": true, - "constraints": { - "allowedValues": [ - { - "label": "Windows 10 Enterprise 21h2 multi-session", - "value": "win10-21h2-avd" - }, - { - "label": "Windows 10 Enterprise 21h2 multi-session (Gen 2)", - "value": "win10-21h2-avd-g2" - }, - { - "label": "Windows 10 Enterprise 21h2 multi-session + Microsoft 365 Apps", - "value": "win10-21h2-avd-m365" - }, - { - "label": "Windows 10 Enterprise 21h2 multi-session + Microsoft 365 Apps (Gen 2)", - "value": "win10-21h2-avd-m365-g2" - }, - { - "label": "Windows 10 Enterprise 22h2 multi-session", - "value": "win10-22h2-avd" - }, - { - "label": "Windows 10 Enterprise 22h2 multi-session (Gen 2)", - "value": "win10-22h2-avd-g2" - }, - { - "label": "Windows 10 Enterprise 22h2 multi-session + Microsoft 365 Apps", - "value": "win10-22h2-avd-m365" - }, - { - "label": "Windows 10 Enterprise 22h2 multi-session + Microsoft 365 Apps (Gen 2)", - "value": "win10-22h2-avd-m365-g2" - }, - { - "label": "Windows 11 Enterprise 21h2 multi-session", - "value": "win11-21h2-avd" - }, - { - "label": "Windows 11 Enterprise 21h2 multi-session + Microsoft 365 Apps", - "value": "win11-21h2-avd-m365" - }, - { - "label": "Windows 11 Enterprise 22h2 multi-session", - "value": "win11-22h2-avd" - }, - { - "label": "Windows 11 Enterprise 22h2 multi-session + Microsoft 365 Apps", - "value": "win11-22h2-avd-m365" - }, - { - "label": "Windows 11 Enterprise 23h2 multi-session", - "value": "win11-23h2-avd" - }, - { - "label": "Windows 11 Enterprise 23h2 multi-session + Microsoft 365 Apps", - "value": "win11-23h2-avd-m365" - } - ], - "required": true - }, - "visible": "[equals(steps('SessionHostsTemplate').optionMarketPlaceOrCustomImage,'Marketplace')]" - }, - { - "name": "GalleryImageInfoBox", - "type": "Microsoft.Common.InfoBox", - "visible": "[equals(steps('SessionHostsTemplate').optionMarketPlaceOrCustomImage,'Gallery')]", - "options": { - "icon": "Warning", - "text": "The system identity of the Session Host Replacer function is assigned the 'Desktop Virtualization Virtual Machine Contributor' role against the subscription. If the Image Definition is in a different subscription, please make sure you manually assign the permission post deployment." - } - }, - { - "name": "resourceSelectorSessionHostGalleryImageId", - "type": "Microsoft.Solutions.ResourceSelector", - "label": "Select Gallery Image", - "resourceType": "Microsoft.Compute/galleries/images", - "constraints": { - "required": true - }, - "options": { - "filter": {} - }, - "visible": "[equals(steps('SessionHostsTemplate').optionMarketPlaceOrCustomImage,'Gallery')]" - }, - { - "name": "sessionHostsSecuritySection", - "type": "Microsoft.Common.Section", - "visible": true, - "label": "Security profile", - "elements": [ - { - "name": "SecurityType", - "type": "Microsoft.Common.DropDown", - "label": "Security type", - "filter": true, - "defaultValue": "Trusted Launch Virtual Machines", - "toolTip": "Choose a type of security that matches your needs: Trusted launch virtual machines provide additional security features on Gen2 virtual machines to protect against persistent and advanced attacks.", - "constraints": { - "required": true, - "allowedValues": [ - { - "label": "Standard", - "value": "Standard" - }, - { - "label": "Trusted Launch Virtual Machines", - "value": "TrustedLaunch" - }, - { - "label": "Confidential Virtual Machines", - "value": "ConfidentialVM" - } - ] - } - }, - { - "name": "SecureBootEnabled", - "type": "Microsoft.Common.CheckBox", - "visible": "[or(equals(steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType, 'TrustedLaunch'), equals(steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType, 'ConfidentialVM'))]", - "label": "Enable secure boot", - "defaultValue": true, - "toolTip": "Secure boot helps protect your VMs against boot kits, rootkits, and kernel-level malware." - }, - { - "name": "TpmEnabled", - "type": "Microsoft.Common.CheckBox", - "visible": "[or(equals(steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType, 'TrustedLaunch'), equals(steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType, 'ConfidentialVM'))]", - "label": "Enable vTPM", - "defaultValue": true, - "toolTip": "Virtual Trusted Platform Module (vTPM) is TPM2.0 compliant and validates your VM boot integrity apart from securely storing keys and secrets." - } - ] - }, - { - "name": "SubnetId", - "type": "Microsoft.Common.TextBox", - "label": "Subnet ID", - "visible": true, - "placeholder": "Add resource id of subnet in the same region as the Session Hosts", - "constraints": { - "required": true, - "validations": [ - { - "regex": ".*/subnets/[A-Za-z0-9_\\.-]+$", - "message": "Invalid Subnet Resource ID. Make sure it ends with /subnets/SubnetName" - } - ] - } - }, - { - "name": "DomainJoinSection", - "type": "Microsoft.Common.Section", - "visible": true, - "label": "Domain Join", - "elements": [ - { - "name": "IdentityServiceProvider", - "type": "Microsoft.Common.OptionsGroup", - "visible": true, - "label": "Identity service provider", - "defaultValue": "Microsoft Entra ID", - "toolTip": "Identity service provider (Active Directory or EntraDS) that already exist and will be used for Azure Virtual Desktop.", - "constraints": { - "required": true, - "allowedValues": [ - { - "label": "Microsoft Entra ID", - "value": "EntraID" - }, - { - "label": "Active Directory (AD DS)", - "value": "ActiveDirectory" - }, - { - "label": "Microsoft Entra Domain Services", - "value": "EntraDS" - } - ] - } - }, - { - "name": "EntraJoinedInfoBox", - "type": "Microsoft.Common.InfoBox", - "visible": "[equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID')]", - "options": { - "icon": "Warning", - "text": "When the VMs are Entra Joined, session host replacer will attempt to delete its device object from Entra ID. This requires additional permissions to be granted to the service principal used by session host replacer. Please refer to the documentation for more information.", - "uri": "https://github.com/Azure/AVDSessionHostReplacer/blob/main/docs/Permissions.md" - } - }, - { - "name": "IntuneEnrollment", - "type": "Microsoft.Common.CheckBox", - "visible": "[equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID')]", - "label": "Intune enrollment", - "defaultValue": false, - "toolTip": "If Intune is configured in your Microsoft Entra ID tenant, you can choose to have the VM automatically enrolled during the deployment by selecting this box. Session Host Replacer will delete the device from Intune during replacement." - }, - { - "name": "ADDomainName", - "type": "Microsoft.Common.TextBox", - "label": "AD Domain name", - "visible": "[or(equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'ActiveDirectory'), equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraDS'))]", - "placeholder": "Example: contoso.com", - "constraints": { - "required": true - } - }, - { - "name": "ADDomainJoinUserName", - "type": "Microsoft.Common.TextBox", - "label": "User principal name", - "visible": "[not(equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID'))]", - "toolTip": "Provide username with permissions to join session host to the domain.", - "placeholder": "Example: avdadmin@contoso.com", - "defaultValue": "", - "constraints": { - "required": true - } - }, - { - "name": "ADJoinUserPassword", - "type": "Microsoft.Common.PasswordBox", - "label": { - "password": "Password" - }, - "visible": "[not(equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID'))]", - "toolTip": "Provide password for domain join account. This will be stored in a new Azure Key Vault.", - "constraints": { - "required": true - }, - "options": { - "hideConfirmation": true - } - }, - { - "name": "ADOUPath", - "type": "Microsoft.Common.TextBox", - "visible": "[not(equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID'))]", - "label": "Custom OU path (Optional)", - "toolTip": "Provide OU where to locate session hosts, if not provided session hosts will be placed on the default (computers) OU.", - "placeholder": "Example: OU=session-hosts,OU=avd,DC=contoso,DC=com", - "constraints": {} - } - ] - }, - { - "name": "LocalAdminUsername", - "type": "Microsoft.Common.TextBox", - "label": "Local Administrator Username", - "toolTip": "Provide username for session host local admin account. Administrator can't be used as username, it is reserved by the system. The password is randomly generated at deployment time.", - "placeholder": "Example: avdadmin", - "defaultValue": "", - "constraints": { - "regex": "^(?!.*[aA]dministrator).*$", - "validationMessage": "This username can't be used, it is a reserved word.", - "required": true - } - } - ] - }, - { - "name": "optionalParametersStep", - "label": "Optional Parameters", - "elements": [ - { - "name": "_Tag_IncludeInAutomation", - "type": "Microsoft.Common.TextBox", - "label": "Include in Automation Tag Name", - "toolTip": "The name of the tag to use to determine if an existing session host should be included in the automation. After deployment, if the tag is present and set to 'true', the session host will be included. If the tag is not present or set to 'false', the session host will be excluded.", - "defaultValue": "IncludeInAutoReplace", - "constraints": { - "required": false - } - }, - { - "name": "_Tag_DeployTimestamp", - "type": "Microsoft.Common.TextBox", - "label": "Deploy Timestamp Tag Name", - "toolTip": "The name of the tag to use to determine when the session host was deployed. This is updated by the session host replacer function on new session hosts. After deployment, you can edit the value of this tag to force replace a VM.", - "defaultValue": "AutoReplaceDeployTimestamp", - "constraints": { - "required": false - } - }, - { - "name": "_Tag_PendingDrainTimestamp", - "type": "Microsoft.Common.TextBox", - "label": "Pending Drain Timestamp Tag Name", - "toolTip": "The name of the tag to use to determine when the session host was marked for drain. This is updated by the session host replacer function on hosts pending deletion.", - "defaultValue": "AutoReplacePendingDrainTimestamp", - "constraints": { - "required": false - } - }, - { - "name": "_Tag_ScalingPlanExclusionTag", - "type": "Microsoft.Common.TextBox", - "label": "Scaling Plan Exclusion Tag Name", - "toolTip": "The name of the tag session host replacer will set to exclude a session host from scaling plans actions.", - "defaultValue": "ScalingPlanExclusion", - "constraints": { - "required": false - } - }, - { - "name": "_TargetVMAgeDays", - "type": "Microsoft.Common.TextBox", - "label": "Target VM Age (Days)", - "toolTip": "The maximum age of a VM in days before it is replaced. This is compared to the value of the Deploy Timestamp Tag.", - "defaultValue": 45, - "constraints": { - "required": false - } - }, - { - "name": "_DrainGracePeriodHours", - "type": "Microsoft.Common.TextBox", - "label": "Drain Grace Period (Hours)", - "toolTip": "The number of hours to wait after marking a VM for drain before deleting it. This is to allow users to finish their sessions before the VM is deleted.", - "defaultValue": 24, - "constraints": { - "required": false - } - }, - { - "name": "_FixSessionHostTags", - "type": "Microsoft.Common.CheckBox", - "label": "Fix Existing Session Host Tags", - "toolTip": "If enabled, the session host replacer will fix the tags on existing session hosts or if tags are mistakenly deleted. The tag values will NOT allow deletion of existing session hosts and must be changed post deployment. This is useful if you are deploying a new session host replacer to an existing host pool.", - "defaultValue": true, - "constraints": { - "required": false - } - }, - { - "name": "_SHRDeploymentPrefix", - "type": "Microsoft.Common.TextBox", - "label": "Deployment Prefix", - "toolTip": "The prefix of the deployment created in the session hosts resource group when replacement VMs are deploying. This is used to track running and failed deployments.", - "defaultValue": "AVDSessionHostReplacer", - "constraints": { - "required": false - } - }, - { - "name": "_SessionHostInstanceNumberPadding", - "type": "Microsoft.Common.Slider", - "min": 1, - "max": 4, - "label": "Session Host VM Number Padding", - "defaultValue": 2, - "showStepMarkers": true, - "constraints": { - "required": false - }, - "visible": true - }, - { - "name": "_SessionHostNameSeparator", - "type": "Microsoft.Common.CheckBox", - "label": "Use '-' as separator", - "toolTip": "If enabled, the session host replacer will use '-' as a separator between the prefix and the instance number. If disabled, the session host replacer will not use separator.", - "defaultValue": true, - "constraints": { - "required": false - } - }, - { - "name": "SessionHostExampleNameInfoBox", - "type": "Microsoft.Common.InfoBox", - "visible": "[not(empty(steps('basics').HostPoolSettingsSection.sessionHostNamePrefix))]", - "options": { - "icon": "Info", - "text": "[concat('Example Session Host name: ', steps('basics').HostPoolSettingsSection.sessionHostNamePrefix , if(steps('optionalParametersStep')._SessionHostNameSeparator,'-','') , take('00000000', sub(steps('optionalParametersStep')._SessionHostInstanceNumberPadding,1)) ,'1') ]" - } - }, - { - "name": "_ReplaceSessionHostOnNewImageVersion", - "type": "Microsoft.Common.CheckBox", - "label": "Replace Session Hosts On New Image Version", - "toolTip": "(Recommended) If enabled, the session host replacer will replace session hosts when a new image version is available. This works for both marketplace and custom images. If disabled, the session host replacer will only replace session hosts when the VM age is greater than the target VM age.", - "defaultValue": true, - "constraints": { - "required": false - } - }, - { - "name": "_ReplaceSessionHostOnNewImageVersionDelayDays", - "type": "Microsoft.Common.TextBox", - "visible": "[steps('optionalParametersStep')._ReplaceSessionHostOnNewImageVersion]", - "label": "Replace on New Image Version Delay (Days)", - "toolTip": "The number of days to wait after a new image is available before replacing session hosts. This is to allow time for the image to be tested before replacing session hosts.", - "defaultValue": "0", - "constraints": { - "required": false - } - }, - { - "name": "_SessionHostResourceGroupName", - "type": "Microsoft.Common.TextBox", - "label": "Session Hosts Resource Group Name", - "placeholder": "Same As Host Pool Resource Group", - "toolTip": "Leave this empty to deploy to same resource group as the host pool.", - "defaultValue": "", - "constraints": { - "required": false - } - } - ] - } - ] - }, - "outputs": { - "kind": "ResourceGroup", - "resourceGroupId": "[steps('basics').resourceScope.resourceGroup.id]", - "location": "[steps('basics').resourceScope.location.name]", - "parameters": { - "HostPoolResourceGroupName": "[steps('basics').HostPoolSettingsSection.HostPoolSelector.resourceGroup]", - "HostPoolName": "[steps('basics').HostPoolSettingsSection.HostPoolSelector.name]", - "SessionHostNamePrefix": "[steps('basics').HostPoolSettingsSection.sessionHostNamePrefix]", - "UseUserAssignedManagedIdentity": "[if(steps('basics').IdentitySection.UseUserAssignedManagedIdentity, true, false)]", - "UserAssignedManagedIdentityResourceId": "[if(steps('basics').IdentitySection.UseUserAssignedManagedIdentity, steps('basics').IdentitySection.UserAssignedManagedIdentitySelector.id, '')]", - "TargetSessionHostCount": "[steps('basics').HostPoolSettingsSection.TargetSessionHostCount]", - "TargetSessionHostBuffer": "[steps('basics').HostPoolSettingsSection.TargetSessionHostBuffer]", - "EnableMonitoring": "[steps('basics').MonitoringSection.EnableMonitoring]", - "UseExistingLAW": "[steps('basics').MonitoringSection.UseExistingLAW]", - "LogAnalyticsWorkspaceId": "[if(steps('basics').MonitoringSection.UseExistingLAW, steps('basics').MonitoringSection.LAWSelector.id, '')]", - "SessionHostsRegion": "[steps('SessionHostsTemplate').SessionHostsRegion]", - "AvailabilityZones": "[steps('SessionHostsTemplate').AvailabilityZones]", - "SessionHostSize": "[steps('SessionHostsTemplate').SessionHostSize]", - "AcceleratedNetworking": "[steps('SessionHostsTemplate').AcceleratedNetworking]", - "SessionHostDiskType": "[steps('SessionHostsTemplate').SessionHostDiskType]", - "MarketPlaceOrCustomImage": "[steps('SessionHostsTemplate').optionMarketPlaceOrCustomImage]", - "MarketPlaceImage": "[steps('SessionHostsTemplate').dropDownMarketPlaceImage]", - "GalleryImageId": "[steps('SessionHostsTemplate').resourceSelectorSessionHostGalleryImageId.id]", - "SecurityType": "[steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType]", - "SecureBootEnabled": "[steps('SessionHostsTemplate').sessionHostsSecuritySection.SecureBootEnabled]", - "TpmEnabled": "[steps('SessionHostsTemplate').sessionHostsSecuritySection.TpmEnabled]", - "IdentityServiceProvider": "[steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider]", - "IntuneEnrollment": "[steps('SessionHostsTemplate').DomainJoinSection.IntuneEnrollment]", - "SubnetId": "[steps('SessionHostsTemplate').SubnetId]", - "ADDomainName": "[steps('SessionHostsTemplate').DomainJoinSection.ADDomainName]", - "ADDomainJoinUserName": "[steps('SessionHostsTemplate').DomainJoinSection.ADDomainJoinUserName]", - "ADJoinUserPassword": "[steps('SessionHostsTemplate').DomainJoinSection.ADJoinUserPassword]", - "ADOUPath": "[steps('SessionHostsTemplate').DomainJoinSection.ADOUPath]", - "LocalAdminUsername": "[steps('SessionHostsTemplate').LocalAdminUsername]", - "TagIncludeInAutomation": "[steps('optionalParametersStep')._Tag_IncludeInAutomation]", - "TagDeployTimestamp": "[steps('optionalParametersStep')._Tag_DeployTimestamp]", - "TagPendingDrainTimestamp": "[steps('optionalParametersStep')._Tag_PendingDrainTimestamp]", - "TagScalingPlanExclusionTag": "[steps('optionalParametersStep')._Tag_ScalingPlanExclusionTag]", - "TargetVMAgeDays": "[steps('optionalParametersStep')._TargetVMAgeDays]", - "DrainGracePeriodHours": "[steps('optionalParametersStep')._DrainGracePeriodHours]", - "FixSessionHostTags": "[steps('optionalParametersStep')._FixSessionHostTags]", - "DeploymentPrefix": "[steps('optionalParametersStep')._SHRDeploymentPrefix]", - "SessionHostInstanceNumberPadding": "[steps('optionalParametersStep')._SessionHostInstanceNumberPadding]", - "SessionHostNameSeparator": "[if(steps('optionalParametersStep')._SessionHostNameSeparator,'-','')]", - "ReplaceSessionHostOnNewImageVersion": "[steps('optionalParametersStep')._ReplaceSessionHostOnNewImageVersion]", - "ReplaceSessionHostOnNewImageVersionDelayDays": "[steps('optionalParametersStep')._ReplaceSessionHostOnNewImageVersionDelayDays]", - "VMNamesTemplateParameterName": "VMNames", - "SessionHostResourceGroupName": "[steps('optionalParametersStep')._SessionHostResourceGroupName]" - } - } - } -} + "name": "sessionHostNamePrefix", + "type": "Microsoft.Common.TextBox", + "label": "Session Host Name Prefix", + "toolTip": "

The prefix for the session host names.

Make sure this produces a unique value across all device names in your environment.

Try selecting a prefix that helps you identify the host pool such as AVDHP01

", + "constraints": { + "required": true, + "validationMessage": "Must be a valid name less than 12 characters long to allow for the 3 character suffix (eg. prefix-01)." + } + }, + { + "name": "SessionHostExampleNameInfoBox", + "type": "Microsoft.Common.InfoBox", + "visible": "[not(empty(steps('basics').HostPoolSettingsSection.sessionHostNamePrefix))]", + "options": { + "icon": "Info", + "text": "[concat('Example Session Host name: ', steps('basics').HostPoolSettingsSection.sessionHostNamePrefix , if(steps('optionalParametersStep')._SessionHostNameSeparator,'-','') , take('00000000', sub(steps('optionalParametersStep')._SessionHostInstanceNumberPadding,1)) ,'1
You can customize the separator and padding from Optional Parameters.') ]" + } + } + ] + }, + { + "name": "IdentitySection", + "type": "Microsoft.Common.Section", + "label": "Identity", + "visible": true, + "elements": [ + { + "name": "UseUserAssignedManagedIdentity", + "type": "Microsoft.Common.CheckBox", + "label": "Use User Assigned Managed Identity", + "toolTip": "[Recommended] When enabled, The Session Host Replacer will use the selected Identity to take actions. Otherwise System Identity (MSI) is used.", + "defaultValue": true, + "constraints": { + "required": false + } + }, + { + "name": "UserAssignedManagedIdentitySelector", + "type": "Microsoft.Solutions.ResourceSelector", + "visible": "[steps('basics').IdentitySection.UseUserAssignedManagedIdentity]", + "label": "Select User Assigned Managed Identity", + "resourceType": "Microsoft.ManagedIdentity/userAssignedIdentities", + "constraints": { + "required": true + }, + "options": { + "filter": {} + } + }, + { + "name": "UserAssignedManagedIdentityInfoBox", + "type": "Microsoft.Common.InfoBox", + "visible": "[steps('basics').IdentitySection.UseUserAssignedManagedIdentity]", + "options": { + "icon": "Info", + "text": "When using a User Assigned Managed Identity, make sure the identity has the needed permissions in Azure and Entra. Follow the link for more info.", + "uri": "https://github.com/Azure/AVDReplacementPlans/blob/v0.2.9-beta.16/docs/Permissions.md" + } + } + ] + }, + { + "name": "MonitoringSection", + "type": "Microsoft.Common.Section", + "label": "Monitoring", + "visible": true, + "elements": [ + { + "name": "EnableMonitoring", + "type": "Microsoft.Common.CheckBox", + "label": "Enable Monitoring", + "toolTip": "[Recommended] When enabled, the session host replacer will use App Insights and Log Analytics to collect metrics and logs.", + "defaultValue": true, + "constraints": { + "required": false + } + }, + { + "name": "UseExistingLAW", + "visible": "[steps('basics').MonitoringSection.EnableMonitoring]", + "type": "Microsoft.Common.CheckBox", + "label": "Select existing Log Analytics Workspace", + "toolTip": "When enabled, the session host replacer will use the selected Log Analytics Workspace. If disabled, the session host replacer will create a new Log Analytics Workspace.", + "defaultValue": false, + "constraints": { + "required": false + } + }, + { + "name": "LAWSelector", + "type": "Microsoft.Solutions.ResourceSelector", + "visible": "[and(steps('basics').MonitoringSection.EnableMonitoring, steps('basics').MonitoringSection.UseExistingLAW)]", + "label": "Log Analytics Workspace", + "resourceType": "Microsoft.OperationalInsights/workspaces", + "constraints": { + "required": true + }, + "options": { + "filter": {} + } + } + ] + }, + { + "name": "computeApi", + "type": "Microsoft.Solutions.ArmApiControl", + "request": { + "method": "GET", + "path": "[concat(steps('basics').resourceScope.subscription.id,'/providers/Microsoft.Compute/resourceTypes?api-version=2022-01-01')]" + } + }, + { + "name": "VersionInfo", + "type": "Microsoft.Common.TextBlock", + "visible": true, + "options": { + "text": "AVD session host replacer Portal UI Version: v0.2.9-beta.16", + "link": { + "label": "GitHub Repository", + "uri": "https://github.com/Azure/AVDSessionHostReplacer" + } + } + } + ] + }, + { + "name": "SessionHostsTemplate", + "label": "Session Hosts Template", + "elements": [ + { + "name": "SessionHostsRegion", + "type": "Microsoft.Common.DropDown", + "label": "Session Hosts Region", + "visible": true, + "filter": true, + "multiselect": false, + "selectAll": false, + "toolTip": "Select region to deploy session hosts.", + "constraints": { + "required": true, + "allowedValues": "[map( first( map( filter( steps('basics').computeApi.value, (resourceTypes) => equals(resourceTypes.resourceType, 'virtualMachines') ), (item) => item.locations) ), (item) => parse(concat('{\"label\":\"', item, '\",\"value\":\"', toLower(replace(item, ' ', '')), '\"}')) )]" + } + }, + { + "name": "AvailabilityZones", + "type": "Microsoft.Common.DropDown", + "label": "Availability Zones", + "visible": true, + "filter": false, + "defaultValue": [], + "multiselect": true, + "selectAll": false, + "toolTip": "Select Availability Zones for the session hosts. Make sure the selected size is available in the selected zones. Session hosts will be deployed across the zones.", + "constraints": { + "required": false, + "allowedValues": [ + { + "label": "Zone 1", + "value": "1" + }, + { + "label": "Zone 2", + "value": "2" + }, + { + "label": "Zone 3", + "value": "3" + } + ] + } + }, + { + "name": "SessionHostSize", + "type": "Microsoft.Compute.SizeSelector", + "label": "VM Size", + "toolTip": "", + "recommendedSizes": [ + "Standard_D4ads_v5" + ], + "constraints": { + "allowedSizes": [], + "excludedSizes": [], + "required": true + }, + "options": { + "hideDiskTypeFilter": true + }, + "osPlatform": "Windows", + "imageReference": { + "publisher": "MicrosoftWindowsDesktop", + "offer": "Windows-11", + "sku": "win11-23h2-avd" + } + }, + { + "name": "AcceleratedNetworking", + "type": "Microsoft.Common.CheckBox", + "label": "Enable accelerated networking", + "defaultValue": true, + "toolTip": "Enables low latency and high throughput on the network interface." + }, + { + "name": "SessionHostDiskType", + "type": "Microsoft.Common.DropDown", + "label": "OS Disk type", + "filter": false, + "defaultValue": "Premium SSD", + "toolTip": "Select session host disk type to host the OS.", + "constraints": { + "required": true, + "allowedValues": [ + { + "label": "Standard HDD", + "value": "Standard_LRS" + }, + { + "label": "Standard SSD", + "value": "StandardSSD_LRS" + }, + { + "label": "Premium SSD", + "value": "Premium_LRS" + } + ] + } + }, + { + "name": "optionMarketPlaceOrCustomImage", + "type": "Microsoft.Common.OptionsGroup", + "label": "Image Source", + "defaultValue": "Marketplace", + "toolTip": "", + "constraints": { + "allowedValues": [ + { + "label": "Marketplace", + "value": "Marketplace" + }, + { + "label": "Gallery Image", + "value": "Gallery" + } + ], + "required": true + }, + "visible": true + }, + { + "name": "dropDownMarketPlaceImage", + "type": "Microsoft.Common.DropDown", + "label": "Select a Marketplace Image", + "placeholder": "", + "defaultValue": "Windows 11 Enterprise 23h2 multi-session + Microsoft 365 Apps", + "toolTip": "Marketplace images are updated on monthly basis. The Session Host Replacer will replace the session hosts when a new image is available.", + "multiselect": false, + "selectAll": false, + "filter": true, + "filterPlaceholder": "Filter items ...", + "multiLine": true, + "constraints": { + "allowedValues": [ + { + "label": "Windows 10 Enterprise 21h2 multi-session", + "value": "win10-21h2-avd" + }, + { + "label": "Windows 10 Enterprise 21h2 multi-session (Gen 2)", + "value": "win10-21h2-avd-g2" + }, + { + "label": "Windows 10 Enterprise 21h2 multi-session + Microsoft 365 Apps", + "value": "win10-21h2-avd-m365" + }, + { + "label": "Windows 10 Enterprise 21h2 multi-session + Microsoft 365 Apps (Gen 2)", + "value": "win10-21h2-avd-m365-g2" + }, + { + "label": "Windows 10 Enterprise 22h2 multi-session", + "value": "win10-22h2-avd" + }, + { + "label": "Windows 10 Enterprise 22h2 multi-session (Gen 2)", + "value": "win10-22h2-avd-g2" + }, + { + "label": "Windows 10 Enterprise 22h2 multi-session + Microsoft 365 Apps", + "value": "win10-22h2-avd-m365" + }, + { + "label": "Windows 10 Enterprise 22h2 multi-session + Microsoft 365 Apps (Gen 2)", + "value": "win10-22h2-avd-m365-g2" + }, + { + "label": "Windows 11 Enterprise 21h2 multi-session", + "value": "win11-21h2-avd" + }, + { + "label": "Windows 11 Enterprise 21h2 multi-session + Microsoft 365 Apps", + "value": "win11-21h2-avd-m365" + }, + { + "label": "Windows 11 Enterprise 22h2 multi-session", + "value": "win11-22h2-avd" + }, + { + "label": "Windows 11 Enterprise 22h2 multi-session + Microsoft 365 Apps", + "value": "win11-22h2-avd-m365" + }, + { + "label": "Windows 11 Enterprise 23h2 multi-session", + "value": "win11-23h2-avd" + }, + { + "label": "Windows 11 Enterprise 23h2 multi-session + Microsoft 365 Apps", + "value": "win11-23h2-avd-m365" + } + ], + "required": true + }, + "visible": "[equals(steps('SessionHostsTemplate').optionMarketPlaceOrCustomImage,'Marketplace')]" + }, + { + "name": "GalleryImageInfoBox", + "type": "Microsoft.Common.InfoBox", + "visible": "[equals(steps('SessionHostsTemplate').optionMarketPlaceOrCustomImage,'Gallery')]", + "options": { + "icon": "Warning", + "text": "The system identity of the Session Host Replacer function is assigned the 'Desktop Virtualization Virtual Machine Contributor' role against the subscription. If the Image Definition is in a different subscription, please make sure you manually assign the permission post deployment." + } + }, + { + "name": "resourceSelectorSessionHostGalleryImageId", + "type": "Microsoft.Solutions.ResourceSelector", + "label": "Select Gallery Image", + "resourceType": "Microsoft.Compute/galleries/images", + "constraints": { + "required": true + }, + "options": { + "filter": {} + }, + "visible": "[equals(steps('SessionHostsTemplate').optionMarketPlaceOrCustomImage,'Gallery')]" + }, + { + "name": "sessionHostsSecuritySection", + "type": "Microsoft.Common.Section", + "visible": true, + "label": "Security profile", + "elements": [ + { + "name": "SecurityType", + "type": "Microsoft.Common.DropDown", + "label": "Security type", + "filter": true, + "defaultValue": "Trusted Launch Virtual Machines", + "toolTip": "Choose a type of security that matches your needs: Trusted launch virtual machines provide additional security features on Gen2 virtual machines to protect against persistent and advanced attacks.", + "constraints": { + "required": true, + "allowedValues": [ + { + "label": "Standard", + "value": "Standard" + }, + { + "label": "Trusted Launch Virtual Machines", + "value": "TrustedLaunch" + }, + { + "label": "Confidential Virtual Machines", + "value": "ConfidentialVM" + } + ] + } + }, + { + "name": "SecureBootEnabled", + "type": "Microsoft.Common.CheckBox", + "visible": "[or(equals(steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType, 'TrustedLaunch'), equals(steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType, 'ConfidentialVM'))]", + "label": "Enable secure boot", + "defaultValue": true, + "toolTip": "Secure boot helps protect your VMs against boot kits, rootkits, and kernel-level malware." + }, + { + "name": "TpmEnabled", + "type": "Microsoft.Common.CheckBox", + "visible": "[or(equals(steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType, 'TrustedLaunch'), equals(steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType, 'ConfidentialVM'))]", + "label": "Enable vTPM", + "defaultValue": true, + "toolTip": "Virtual Trusted Platform Module (vTPM) is TPM2.0 compliant and validates your VM boot integrity apart from securely storing keys and secrets." + } + ] + }, + { + "name": "SubnetId", + "type": "Microsoft.Common.TextBox", + "label": "Subnet ID", + "visible": true, + "placeholder": "Add resource id of subnet in the same region as the Session Hosts", + "constraints": { + "required": true, + "validations": [ + { + "regex": ".*/subnets/[A-Za-z0-9_\\.-]+$", + "message": "Invalid Subnet Resource ID. Make sure it ends with /subnets/SubnetName" + } + ] + } + }, + { + "name": "DomainJoinSection", + "type": "Microsoft.Common.Section", + "visible": true, + "label": "Domain Join", + "elements": [ + { + "name": "IdentityServiceProvider", + "type": "Microsoft.Common.OptionsGroup", + "visible": true, + "label": "Identity service provider", + "defaultValue": "Microsoft Entra ID", + "toolTip": "Identity service provider (Active Directory or EntraDS) that already exist and will be used for Azure Virtual Desktop.", + "constraints": { + "required": true, + "allowedValues": [ + { + "label": "Microsoft Entra ID", + "value": "EntraID" + }, + { + "label": "Active Directory (AD DS)", + "value": "ActiveDirectory" + }, + { + "label": "Microsoft Entra Domain Services", + "value": "EntraDS" + } + ] + } + }, + { + "name": "EntraJoinedInfoBox", + "type": "Microsoft.Common.InfoBox", + "visible": "[equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID')]", + "options": { + "icon": "Warning", + "text": "When the VMs are Entra Joined, session host replacer will attempt to delete its device object from Entra ID. This requires additional permissions to be granted to the service principal used by session host replacer. Please refer to the documentation for more information.", + "uri": "https://github.com/Azure/AVDSessionHostReplacer/blob/main/docs/Permissions.md" + } + }, + { + "name": "IntuneEnrollment", + "type": "Microsoft.Common.CheckBox", + "visible": "[equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID')]", + "label": "Intune enrollment", + "defaultValue": false, + "toolTip": "If Intune is configured in your Microsoft Entra ID tenant, you can choose to have the VM automatically enrolled during the deployment by selecting this box. Session Host Replacer will delete the device from Intune during replacement." + }, + { + "name": "ADDomainName", + "type": "Microsoft.Common.TextBox", + "label": "AD Domain name", + "visible": "[or(equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'ActiveDirectory'), equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraDS'))]", + "placeholder": "Example: contoso.com", + "constraints": { + "required": true + } + }, + { + "name": "ADDomainJoinUserName", + "type": "Microsoft.Common.TextBox", + "label": "User principal name", + "visible": "[not(equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID'))]", + "toolTip": "Provide username with permissions to join session host to the domain.", + "placeholder": "Example: avdadmin@contoso.com", + "defaultValue": "", + "constraints": { + "required": true + } + }, + { + "name": "ADJoinUserPassword", + "type": "Microsoft.Common.PasswordBox", + "label": { + "password": "Password" + }, + "visible": "[not(equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID'))]", + "toolTip": "Provide password for domain join account. This will be stored in a new Azure Key Vault.", + "constraints": { + "required": true + }, + "options": { + "hideConfirmation": true + } + }, + { + "name": "ADOUPath", + "type": "Microsoft.Common.TextBox", + "visible": "[not(equals(steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider, 'EntraID'))]", + "label": "Custom OU path (Optional)", + "toolTip": "Provide OU where to locate session hosts, if not provided session hosts will be placed on the default (computers) OU.", + "placeholder": "Example: OU=session-hosts,OU=avd,DC=contoso,DC=com", + "constraints": {} + } + ] + }, + { + "name": "LocalAdminUsername", + "type": "Microsoft.Common.TextBox", + "label": "Local Administrator Username", + "toolTip": "Provide username for session host local admin account. Administrator can't be used as username, it is reserved by the system. The password is randomly generated at deployment time.", + "placeholder": "Example: avdadmin", + "defaultValue": "", + "constraints": { + "regex": "^(?!.*[aA]dministrator).*$", + "validationMessage": "This username can't be used, it is a reserved word.", + "required": true + } + } + ] + }, + { + "name": "optionalParametersStep", + "label": "Optional Parameters", + "elements": [ + { + "name": "_Tag_IncludeInAutomation", + "type": "Microsoft.Common.TextBox", + "label": "Include in Automation Tag Name", + "toolTip": "The name of the tag to use to determine if an existing session host should be included in the automation. After deployment, if the tag is present and set to 'true', the session host will be included. If the tag is not present or set to 'false', the session host will be excluded.", + "defaultValue": "IncludeInAutoReplace", + "constraints": { + "required": false + } + }, + { + "name": "_Tag_DeployTimestamp", + "type": "Microsoft.Common.TextBox", + "label": "Deploy Timestamp Tag Name", + "toolTip": "The name of the tag to use to determine when the session host was deployed. This is updated by the session host replacer function on new session hosts. After deployment, you can edit the value of this tag to force replace a VM.", + "defaultValue": "AutoReplaceDeployTimestamp", + "constraints": { + "required": false + } + }, + { + "name": "_Tag_PendingDrainTimestamp", + "type": "Microsoft.Common.TextBox", + "label": "Pending Drain Timestamp Tag Name", + "toolTip": "The name of the tag to use to determine when the session host was marked for drain. This is updated by the session host replacer function on hosts pending deletion.", + "defaultValue": "AutoReplacePendingDrainTimestamp", + "constraints": { + "required": false + } + }, + { + "name": "_Tag_ScalingPlanExclusionTag", + "type": "Microsoft.Common.TextBox", + "label": "Scaling Plan Exclusion Tag Name", + "toolTip": "The name of the tag session host replacer will set to exclude a session host from scaling plans actions.", + "defaultValue": "ScalingPlanExclusion", + "constraints": { + "required": false + } + }, + { + "name": "_TargetVMAgeDays", + "type": "Microsoft.Common.TextBox", + "label": "Target VM Age (Days)", + "toolTip": "The maximum age of a VM in days before it is replaced. This is compared to the value of the Deploy Timestamp Tag.", + "defaultValue": 45, + "constraints": { + "required": false + } + }, + { + "name": "_DrainGracePeriodHours", + "type": "Microsoft.Common.TextBox", + "label": "Drain Grace Period (Hours)", + "toolTip": "The number of hours to wait after marking a VM for drain before deleting it. This is to allow users to finish their sessions before the VM is deleted.", + "defaultValue": 24, + "constraints": { + "required": false + } + }, + { + "name": "_FixSessionHostTags", + "type": "Microsoft.Common.CheckBox", + "label": "Fix Existing Session Host Tags", + "toolTip": "If enabled, the session host replacer will fix the tags on existing session hosts or if tags are mistakenly deleted. The tag values will NOT allow deletion of existing session hosts and must be changed post deployment. This is useful if you are deploying a new session host replacer to an existing host pool.", + "defaultValue": true, + "constraints": { + "required": "[steps('basics').HostPoolSettingsSection.IncludePreExistingSessionHosts]", + "validationMessage": "This is required if Include pre-existing session hosts is selected." + } + }, + { + "name": "_SHRDeploymentPrefix", + "type": "Microsoft.Common.TextBox", + "label": "Deployment Prefix", + "toolTip": "The prefix of the deployment created in the session hosts resource group when replacement VMs are deploying. This is used to track running and failed deployments.", + "defaultValue": "AVDSessionHostReplacer", + "constraints": { + "required": false + } + }, + { + "name": "_SessionHostInstanceNumberPadding", + "type": "Microsoft.Common.Slider", + "min": 1, + "max": 4, + "label": "Session Host VM Number Padding", + "defaultValue": 2, + "showStepMarkers": true, + "constraints": { + "required": false + }, + "visible": true + }, + { + "name": "_SessionHostNameSeparator", + "type": "Microsoft.Common.CheckBox", + "label": "Use '-' as separator", + "toolTip": "If enabled, the session host replacer will use '-' as a separator between the prefix and the instance number. If disabled, the session host replacer will not use separator.", + "defaultValue": true, + "constraints": { + "required": false + } + }, + { + "name": "SessionHostExampleNameInfoBox", + "type": "Microsoft.Common.InfoBox", + "visible": "[not(empty(steps('basics').HostPoolSettingsSection.sessionHostNamePrefix))]", + "options": { + "icon": "Info", + "text": "[concat('Example Session Host name: ', steps('basics').HostPoolSettingsSection.sessionHostNamePrefix , if(steps('optionalParametersStep')._SessionHostNameSeparator,'-','') , take('00000000', sub(steps('optionalParametersStep')._SessionHostInstanceNumberPadding,1)) ,'1') ]" + } + }, + { + "name": "_ReplaceSessionHostOnNewImageVersion", + "type": "Microsoft.Common.CheckBox", + "label": "Replace Session Hosts On New Image Version", + "toolTip": "(Recommended) If enabled, the session host replacer will replace session hosts when a new image version is available. This works for both marketplace and custom images. If disabled, the session host replacer will only replace session hosts when the VM age is greater than the target VM age.", + "defaultValue": true, + "constraints": { + "required": false + } + }, + { + "name": "_ReplaceSessionHostOnNewImageVersionDelayDays", + "type": "Microsoft.Common.TextBox", + "visible": "[steps('optionalParametersStep')._ReplaceSessionHostOnNewImageVersion]", + "label": "Replace on New Image Version Delay (Days)", + "toolTip": "The number of days to wait after a new image is available before replacing session hosts. This is to allow time for the image to be tested before replacing session hosts.", + "defaultValue": "0", + "constraints": { + "required": false + } + }, + { + "name": "_SessionHostResourceGroupName", + "type": "Microsoft.Common.TextBox", + "label": "Session Hosts Resource Group Name", + "placeholder": "Same As Host Pool Resource Group", + "toolTip": "Leave this empty to deploy to same resource group as the host pool.", + "defaultValue": "", + "constraints": { + "required": false + } + } + ] + } + ] + }, + "outputs": { + "kind": "ResourceGroup", + "resourceGroupId": "[steps('basics').resourceScope.resourceGroup.id]", + "location": "[steps('basics').resourceScope.location.name]", + "parameters": { + "HostPoolResourceGroupName": "[steps('basics').HostPoolSettingsSection.HostPoolSelector.resourceGroup]", + "HostPoolName": "[steps('basics').HostPoolSettingsSection.HostPoolSelector.name]", + "SessionHostNamePrefix": "[steps('basics').HostPoolSettingsSection.sessionHostNamePrefix]", + "UseUserAssignedManagedIdentity": "[if(steps('basics').IdentitySection.UseUserAssignedManagedIdentity, true, false)]", + "UserAssignedManagedIdentityResourceId": "[if(steps('basics').IdentitySection.UseUserAssignedManagedIdentity, steps('basics').IdentitySection.UserAssignedManagedIdentitySelector.id, '')]", + "TargetSessionHostCount": "[steps('basics').HostPoolSettingsSection.TargetSessionHostCount]", + "TargetSessionHostBuffer": "[steps('basics').HostPoolSettingsSection.TargetSessionHostBuffer]", + "EnableMonitoring": "[steps('basics').MonitoringSection.EnableMonitoring]", + "UseExistingLAW": "[steps('basics').MonitoringSection.UseExistingLAW]", + "LogAnalyticsWorkspaceId": "[if(steps('basics').MonitoringSection.UseExistingLAW, steps('basics').MonitoringSection.LAWSelector.id, '')]", + "SessionHostsRegion": "[steps('SessionHostsTemplate').SessionHostsRegion]", + "AvailabilityZones": "[steps('SessionHostsTemplate').AvailabilityZones]", + "SessionHostSize": "[steps('SessionHostsTemplate').SessionHostSize]", + "AcceleratedNetworking": "[steps('SessionHostsTemplate').AcceleratedNetworking]", + "SessionHostDiskType": "[steps('SessionHostsTemplate').SessionHostDiskType]", + "MarketPlaceOrCustomImage": "[steps('SessionHostsTemplate').optionMarketPlaceOrCustomImage]", + "MarketPlaceImage": "[steps('SessionHostsTemplate').dropDownMarketPlaceImage]", + "GalleryImageId": "[steps('SessionHostsTemplate').resourceSelectorSessionHostGalleryImageId.id]", + "SecurityType": "[steps('SessionHostsTemplate').sessionHostsSecuritySection.SecurityType]", + "SecureBootEnabled": "[steps('SessionHostsTemplate').sessionHostsSecuritySection.SecureBootEnabled]", + "TpmEnabled": "[steps('SessionHostsTemplate').sessionHostsSecuritySection.TpmEnabled]", + "IdentityServiceProvider": "[steps('SessionHostsTemplate').DomainJoinSection.IdentityServiceProvider]", + "IntuneEnrollment": "[steps('SessionHostsTemplate').DomainJoinSection.IntuneEnrollment]", + "SubnetId": "[steps('SessionHostsTemplate').SubnetId]", + "ADDomainName": "[steps('SessionHostsTemplate').DomainJoinSection.ADDomainName]", + "ADDomainJoinUserName": "[steps('SessionHostsTemplate').DomainJoinSection.ADDomainJoinUserName]", + "ADJoinUserPassword": "[steps('SessionHostsTemplate').DomainJoinSection.ADJoinUserPassword]", + "ADOUPath": "[steps('SessionHostsTemplate').DomainJoinSection.ADOUPath]", + "LocalAdminUsername": "[steps('SessionHostsTemplate').LocalAdminUsername]", + "TagIncludeInAutomation": "[steps('optionalParametersStep')._Tag_IncludeInAutomation]", + "TagDeployTimestamp": "[steps('optionalParametersStep')._Tag_DeployTimestamp]", + "TagPendingDrainTimestamp": "[steps('optionalParametersStep')._Tag_PendingDrainTimestamp]", + "TagScalingPlanExclusionTag": "[steps('optionalParametersStep')._Tag_ScalingPlanExclusionTag]", + "TargetVMAgeDays": "[steps('optionalParametersStep')._TargetVMAgeDays]", + "DrainGracePeriodHours": "[steps('optionalParametersStep')._DrainGracePeriodHours]", + "FixSessionHostTags": "[steps('optionalParametersStep')._FixSessionHostTags]", + "IncludePreExistingSessionHosts": "[steps('basics').HostPoolSettingsSection.IncludePreExistingSessionHosts]", + "DeploymentPrefix": "[steps('optionalParametersStep')._SHRDeploymentPrefix]", + "SessionHostInstanceNumberPadding": "[steps('optionalParametersStep')._SessionHostInstanceNumberPadding]", + "SessionHostNameSeparator": "[if(steps('optionalParametersStep')._SessionHostNameSeparator,'-','')]", + "ReplaceSessionHostOnNewImageVersion": "[steps('optionalParametersStep')._ReplaceSessionHostOnNewImageVersion]", + "ReplaceSessionHostOnNewImageVersionDelayDays": "[steps('optionalParametersStep')._ReplaceSessionHostOnNewImageVersionDelayDays]", + "VMNamesTemplateParameterName": "VMNames", + "SessionHostResourceGroupName": "[steps('optionalParametersStep')._SessionHostResourceGroupName]" + } + } + } +} \ No newline at end of file diff --git a/docs/CodeDeploy.md b/docs/CodeDeploy.md index f301937..bc2e28c 100644 --- a/docs/CodeDeploy.md +++ b/docs/CodeDeploy.md @@ -16,6 +16,7 @@ $TemplateParameters = @{ SessionHostNamePrefix = 'avdshr' # Will be appended by '-XX' TargetSessionHostCount = 10 # How many session hosts to maintain in the Host Pool TargetSessionHostBuffer = 5 # The maximum number of session hosts to add during a replacement process + IncludePreExistingSessionHosts = $false # Include existing session hosts in automation # Identity # Using a User Managed Identity is recommended. You can assign the same identity to different instances of session host replacer instances. The identity should have the proper permissions in Azure and Entra. diff --git a/docs/icons/powershell.png b/docs/icons/powershell.png index 77ac1d32a5d2a835635f9d329340d7d4762486de..406b59ce16b7b0dd74148e78980c354684b9e23f 100644 GIT binary patch delta 2861 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 e?ElO$fd2v?{#v3Qd?*6|0000