Deploying Azure Virtual Desktop with Nerdio

clippy

Today, we’re going to talk about my adventures with learning Nerdio. We used Nerdio and did some solid orchestration to fully deploy a production-ready Azure Virtual Desktop environment. Specifically, we will cover the following today:

Deploying Nerdio Manager for Enterprise

Overall, the deployment of Nerdio Manager for Enterprise (NME), is pretty easy. It can be a little bit confusing because of some of the dialogues. We cover most of it in the video demo below, but when we see “quota” issues.

I was like WTF? I spent time trying to see if I needed to get VM quotas or something along those lines. Amusingly, it was simple. There were just capacity issues in East US. Luckily, once I deployed to East US 2, it worked perfectly fine.

The steps overall are super simple:

  1. Go to Azure Marketplace
  2. Search for Nerdio Manager for Enterprise and click install
  3. It will do its little install song and dance

So, the question is “what does it actually install?”

NME like certain products is a “Managed Application” which installs these services to your resource group:

  • Two app services along with its app service plan (nmw-app and nmw-ccl): CCL appears to be their automation layer for scaling AVD.
  • Application Insights for both of the app services
  • Automation Accounts for nmw automation and their scripted actions
  • Data collection endpoint and rule
  • Key vaults for the two app services
  • Log Analytics workspaces workspaces for the two app services and Application Insights.
  • Runbooks for scripted actions and “UpdateRunAs”
  • Smart detector alert rules for the two app services
  • SQL database and server
  • Storage accounts

Overall, it’s a really neat product and uses many table stakes that people are familiar with like PowerShell, Automation, Runbooks, etc. Enjoy the video below on deploying Nerdio Manager which includes the initial setup wizard:

Customizing the Deployment Script in Nerdio

Before deployment and setup, we will cover how the Initial AVD Setup Deployment Template script works with MY code. I say MY code, because chunks of the code didn’t work. It’s best to just cover it all in detail to help everyone understand a good way of doing it.

We will break it up into:

AVD Deployment Script Variables

This part is fairly easy, but the code is documented in a way that it could trip you up. Let’s check out the code and give you a few tips:

##### Required Variables #####

$client_secret = $SecureVars.ClientSecret # Set this variable in NMW under Settings -> Nerdio Integrations
$app_url = '' # no trailing slash
$client_id = '' #get this from the REST API Credentials screen in Settings > Integrations
$scope = '' #get this from the REST API Credentials screen in Settings > Integrations
$tenant_id ='' #get this from the REST API Credentials screen in Settings > Integrations

$SubscriptionId = '' #get this from the REST API Credentials screen in Settings > Integrations
$ResourceGroupName = "" #No Spaces

$VnetName = '' #Use a Vnet in the same Resource Group
$SubnetName = '' # subnet where AVD hosts will be provisioned
$RegionName = "" # e.g. "northcentralus"


##### Additional Variables #####

$WindowsVersion = 'microsoftwindowsdesktop/office-365/win11-24h2-avd-m365/latest' # Version of windows to use in desktop image and host pool
$ImageVmSize = 'Standard_D2s_v3'

$WorkspaceName = "" #No spaces
$WorkspaceFriendlyName = ""
$WorkspaceDescription = ""

$AdConfigId = $null # The id of the AD Configuration to use for the desktop image and host pool VMs. Will use the default config if none specified.

$DesktopImageName = "" #No spaces
$ImageStorageType = 'StandardSSD_LRS'

$TimeZone = "Eastern Standard Time"

$HostPoolName = "" #No spaces
$HostPoolDescription = ""
$ScriptedActionIDs = @() # List of Scripted Actions (by id) to be run on the image. Can be used to install software. E.g.: $ScriptedActionIDs = @(35, 36) found in Windows Scripts (very cool stuff)

$UserUPNs = @('', '') # Users to assign to the new host pool. E.g.: $UserUPNs =  @('[email protected]', '[email protected]', '[email protected]')

$HostVmPrefix = "" #No spaces
$HostVmSize = "Standard_D2s_v3"
$HostVmStorageType = 'StandardSSD_LRS'
$HostVmDiskSize = 128
$hasEphemeralOSDisk = $false
$BaseHostPoolCapacity = 1
$MinActiveHostsCount = 1
$BurstCapacity = 2

if (!$AdConfigId){
    $AdConfigs = Invoke-RestMethod -Uri "$app_url/api/v1/ad/config" -Method Get -UseBasicParsing -Headers $headers
    $AdConfigId = ($AdConfigs | Where-Object isDefault -eq $true).id
} 

Creating the Workspace and Desktop Image

This one deserves more explanation so you can understand how it works. Workspaces are logical groupings on app groups. We associate an app group with a workspace so users can see desktops and apps published to them. You can only assign an app group to a single workspace.

To create the workspace, they craft a body, which passes some variables and invokes the Nerdio API to create the workspace as seen below. Overall, it’s pretty simple. I saw a few issues mainly because the code documentation was unclear to me:

$NewWorkspaceBody = @"
{
  "id": {
    "subscriptionId": "$SubscriptionId",
    "resourceGroup": "$ResourceGroupName",
    "name": "$WorkspaceName"
  },
  "location": "$RegionName",
  "friendlyName": "$WorkspaceFriendlyName",
  "description": "$WorkspaceDescription"
}
"@

$NewWorkspace = Invoke-RestMethod "$app_url/api/v1/workspace" -Headers $headers -Body $NewWorkspaceBody -Method Post -UseBasicParsing 

The desktop image is what we will use with autoscaling to spin up AVD session hosts. Basically, Nerdio creates a VM, configures it, generalizes it, burns it down, copies the disk, and makes a nice pretty image to be used with your AVD environment. The orchestration value can’t be understated as the entire process takes like 25 minutes.

I ran into a few issues here and learned that my vNet had to be in the same resource group as the rest of my stuff AND it also needed to be configured inside of Nerdio (I cover this in my video later on):

$NewDesktopImageBody = @"
{
 "jobPayload": {
		"imageId": {
            "subscriptionId": "$SubscriptionId",
            "resourceGroup": "$ResourceGroupName",
            "name": "$DesktopImageName"
        },
        "sourceImageId": "$WindowsVersion",
        "vmSize": "$ImageVmSize",
        "storageType": "$ImageStorageType",
        "diskSize": 128,
        "networkId": "/subscriptions/$SubscriptionId/ResourceGroups/$ResourceGroupName/providers/Microsoft.Network/virtualNetworks/$VnetName",
        "subnet": "$SubnetName",
        "description": "image description",
        "noImageObjectRequired": false,
        "enableTimezoneRedirection": true,
        "vmTimezone": "Eastern Standard Time",
        "scriptedActionsIds": $(if ($ScriptedActionIDs){$ScriptedActionIDs | ConvertTo-Json} else{'null'})
    },
    "failurePolicy": {
        "restart": true,
        "cleanup": true
    }
}
"@

$NewDesktopImage = Invoke-RestMethod "$app_url/api/v1/desktop-image/create-from-library" -Method 'POST' -Headers $headers -Body $NewDesktopImageBody 

# Get status of job
$NewDesktopImageStatus = Invoke-RestMethod "$app_url/api/v1/job/$($NewDesktopImage.job.id)" -Method Get -Headers $headers 

while ($NewDesktopImageStatus.jobStatus -eq 'Pending' -or $NewDesktopImageStatus.jobStatus -eq 'Running')
{
    Sleep 60
    $NewDesktopImageStatus = Invoke-RestMethod "$app_url/api/v1/job/$($NewDesktopImage.job.id)" -Method Get -Headers $headers 
}

if ($NewDesktopImageStatus.jobStatus -eq 'Failed') {
    Throw "Error creating desktop image"
}

Creating the Host Pool and Autoscaling Configuration

The majority of my challenges ended up being around this, which I built out solidly now. Firstly, the “host pool” is a collection of AVD session hosts sourced from a single image. You can either do “personal” which is the persistent use case aka Windows 365 Cloud PCs, or “pooled” where we load balance user sessions.

The application group also comes into play at this point. Your app group controls access to the desktop or app collections available on your session hosts in a pool. We assign users to application groups to grant access to the resources they need.

Simply, we craft a body that is used to create the host pool via the API. Once it’s created, we use the API to convert it to a dynamic host pool (because dynamic is awesome obviously).

Once that’s done, we will assign the users we specified to the host pool and enable autoscaling. We ran into a few issues here because we had some stuff around the wrong verbs and efficiency items. With the code below, it will work cleanly:

$NewHostPoolBody = @"
{
  "workspaceId": {
    "subscriptionId": "$SubscriptionId",
    "resourceGroup": "$ResourceGroupName",
    "name": "$WorkspaceName"
  },
  "pooledParams": {
    "isDesktop": true,
    "isSingleUser": false
  },
  "description": "$HostPoolDescription"
}
"@

$NewHostPool = Invoke-RestMethod "$app_url/api/v1/arm/hostpool/$SubscriptionId/$ResourceGroupName/$HostPoolName" -Method Post -Headers $headers -Body $NewHostPoolBody


$ConvertToDynamic = Invoke-RestMethod "$app_url/api/v1/arm/hostpool/$SubscriptionId/$ResourceGroupName/$HostPoolName/auto-scale" -Method Post -Headers $headers 

if ($UserUPNs)
{
    $AssignUserBody = @"
    {
        "users": $($UserUPNs | ConvertTo-Json)
    }
"@

    Invoke-RestMethod "$app_url/api/v1/arm/hostpool/$SubscriptionId/$ResourceGroupName/$HostPoolName/assign" -Method Post -Headers $headers -body $AssignUserBody

}

$AutoScaleEnableBody = @"
{
    "isEnabled": true
    }
 
"@

Invoke-RestMethod "$app_url/api/v1/arm/hostpool/$SubscriptionId/$ResourceGroupName/$HostPoolName/auto-scale" -Method Patch -Headers $headers -Body $AutoScaleEnableBody

Customizing Autoscaling and RDP Settings

The final piece of this setup involves configuring the autoscaling settings like we did below. For example, I am specifying the prefix for all session hosts that are created and how many session hosts should actually be created.

I also configure some good RDP best practice settings like enabling Entra SSO for Azure Virtual Desktop. The string you find for RDP settings can be seen easily in any of your host pools under RDP Properties > Advanced:

screenshot of RDP properties for an AVD Host Pool
$AutoScaleConfigBody = @"
{
    "isEnabled" : true,
    "timezoneId" : "Eastern Standard Time", 
    "vmTemplate": {
        "prefix": "$HostVmPrefix",
        "size": "$HostVmSize",
        "image": "/subscriptions/$SubscriptionId/resourceGroups/$ResourceGroupName/providers/Microsoft.Compute/virtualmachines/$DesktopImageName",
        "storageType": "$HostVmStorageType",
        "resourceGroupId": "$((Get-AzResourceGroup -Name $ResourceGroupName).resourceid)",
        "networkId": "$((Get-AzVirtualNetwork -Name $vnetname -ResourceGroupName $ResourceGroupName).id)",
        "subnet": "$SubnetName",
        "diskSize": $HostVmDiskSize,
        "hasEphemeralOSDisk": $($hasEphemeralOSDisk | ConvertTo-Json)
    },
    "stoppedDiskType": null,
    "reuseVmNames": true,
    "enableFixFailedTask": true,
    "isSingleUserDesktop": false,
    "activeHostType": "Running",
    "minCountCreatedVmsType": "HostPoolCapacity",
    "scalingMode": "Default",
    "hostPoolCapacity": $BaseHostPoolCapacity,
    "minActiveHostsCount": $MinActiveHostsCount,
    "burstCapacity": $BurstCapacity,
    "autoScaleCriteria":  "CPUUsage",
    "scaleInAggressiveness":  "Low",
    "workingHoursScaleOutBehavior":  null,
    "workingHoursScaleInBehavior":  null,
    "hostUsageScaleCriteria":  {
        "scaleOut":  {
                        "averageTimeRangeInMinutes":  5,
                        "hostChangeCount":  1,
                        "value":  65,
                        "collectAlways":  true
                    },
        "scaleIn":  {
                        "averageTimeRangeInMinutes":  15,
                        "hostChangeCount":  1,
                        "value":  40,
                        "collectAlways":  true
                    }
                                },
    "activeSessionsScaleCriteria":  {
        "scaleOut":  {
                            "hostChangeCount":  1,
                            "value":  1
                          
                        },
        "scaleIn":  {
                        "hostChangeCount":  1,
                        "value":  0
                       
                    }
    },
    "availableUserSessionsScaleCriteria":  {
        "maxSessionsPerHost":  null,
        "minAvailableUserSessions":  1,
        "maxAvailableUserSessions":  1,
        "availableSessionRestriction":  "Always",
        "outsideWorkHoursSessions":  null
    },
    "scaleInRestriction":  {
        "enable":  false,
        "timeRange":  null
    },
    "preStageHosts":  {
                            "enable":  false,
                            "config":  null
                        },
    "removeMessaging":  {
                            "minutesBeforeRemove":  10,
                            "message":  "Sorry for the interruption. We are doing some housekeeping and need you to log out."
                        },
    "autoHeal":  {
                        "enable":  true,
                        "config":  {
                                    "wvdStatuses":  ["Unavailable"],
                                    "sessionCriteria":  "WithoutActive",
                                    "staleHeartbeatMinutes":  null,
                                    "restartAttempts":  2,
                                    "waitMinutes":  10,
                                    "finalAction":  "DeleteHost"
                                }
                    }
}
"@

Invoke-RestMethod "$app_url/api/v1/arm/hostpool/$SubscriptionId/$ResourceGroupName/$HostPoolName/auto-scale" -Method Put -Headers $headers -Body $AutoScaleConfigBody

$ASconfig = Invoke-RestMethod "$app_url/api/v1/arm/hostpool/$SubscriptionId/$ResourceGroupName/$HostPoolName/auto-scale" -Method Get -Headers $headers

    $RDPConfigBody = @"
{
  "configurationName": null,
  "rdpProperties": "drivestoredirect:s:*;audiomode:i:0;videoplaybackmode:i:1;redirectclipboard:i:1;redirectprinters:i:1;devicestoredirect:s:*;redirectcomports:i:1;redirectsmartcards:i:1;usbdevicestoredirect:s:*;enablecredsspsupport:i:1;redirectwebauthn:i:1;use multimon:i:1;enablerdsaadauth:i:1;autoreconnection enabled:i:1;bandwidthautodetect:i:1;networkautodetect:i:1;compression:i:1;audiocapturemode:i:1;encode redirected video capture:i:1;redirected video capture encoding quality:i:1;camerastoredirect:s:*;redirectlocation:i:1;targetisaadjoined:i:1"
}
"@
Invoke-RestMethod "$app_url/api/v1/arm/hostpool/$SubscriptionId/$ResourceGroupName/$HostPoolName/rdp" -Method Put -Headers $headers -Body $RDPConfigBody
$RDPConfig = Invoke-RestMethod "$app_url/api/v1/arm/hostpool/$SubscriptionId/$ResourceGroupName/$HostPoolName/rdp" -Method Get -Headers $headers

Update:

Unfortunately, there appears to be a bug in the API currently for this call: Invoke-RestMethod “$app_url/api/v1/arm/hostpool/$SubscriptionId/$ResourceGroupName/
$HostPoolName/auto-scale” -Method Put -Headers $headers -Body $AutoScaleConfigBody

The AutoHeal config doesn’t appear to work, which makes me change the code to this:

##New AutoHeal Code##
    "autoHeal":  {
                        "enable":  false,
                        "config":  null
}
}

Essentially, I disable AutoHeal and will now be modifying it inside of the GUI. You go to “Dynamic Host Pools” > “Manage Hosts” > “Autoscale” > “Configure”:

Now, you just enable the auto-heal and configure the actions as required like you can see below:

Hopefully, it will be fixed in the near future, but for now this is a process that works.

Deploying Azure Virtual Desktop with Nerdio

The actual deployment was an act of labor and love over the last few tries trying to perfect it. The video below will properly do it justice overall. The process itself is iterative, while you try to make it work for you.

Some of the tips on handling these deployments I’d give are:

  • Leverage the scripted action IDs like Optimize Edge for AVD, Enable RDP Shortpath, Enable AVD screen capture protection, and more.
  • Keep the deployment script simple and leverage the scripted actions.
  • Manage your stuff inside of NME, which does a nice job with cleanup actions.
  • Checkout the integrations options like RDP Settings (even though I did it via my deployment), ControlUp DaaS Monitoring, and more.
  • Explore webhooks and email notifications

Closing Thoughts

It was really fun having the opportunity to check out the Nerdio platform. I can see why they won awards this year. Tasks that take VDI administrators SO LONG to do today, you can do with ease. In my next article, I’m going to look at their Intune integration in Nerdio, which is interesting. I’m a big fan of a single pane-of-glass approach to managing devices. They would be an excellent candidate to try to tackle some of the SCCM gaps that Intune has today.

I hope everyone enjoys this article, which was fun to engineer/work on today. Hopefully, I can see some of you at Microsoft Ignite for the Workplace Ninjas US event and much more!

Facebook
Twitter
LinkedIn
The article discusses the author's experiences with deploying Nerdio Manager for Enterprise to set up an Azure Virtual Desktop environment. It covers various steps like deploying the Manager, customizing deployment scripts, and configuring autoscaling and RDP settings. The user faced and resolved challenges during deployment, highlighting Nerdio's efficiency in simplifying VDI tasks.

2 thoughts on “Deploying Azure Virtual Desktop with Nerdio”

Leave a Reply to fabianCancel reply

Scroll to Top

Discover more from Mobile Jon's Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading