Azure Automation runbook logging redux
This time I wanted to show a more comprehensive example from a real life need and how I went about solving it.
The challenge we faced was that we had previously set our Azure AD Connector to use the e-mail field in AD to be synced to Azure AD as the UPN. This would have been fine if not for that our need changed. As we matured in the use of cloud services we needed to also add some of our contractors to Azure AD. The problem was that in the local AD the email field was populated with the contractor’s actual email, and this would make the sync fail if the accounts were added.
We therefore decided to revert the connector back to mapping UPN –> UPN (Azure AD), as was the default. Though this did not mitigate the need to still have the username in Azure AD be email.
There are probably many ways to solve this, though we decided to leverage our investment in Azure Automation and our onprem hybrid workers to solve the issue. I wrote a Runbook that iterates through all the users needed to be synced to Azure and make sure UPN is the same as mail, and if not change it to be.
Another thing I wanted to tackle was to inform helpdesk about changes made and if any of the changes failed. This so they could go in and do a manual fix if needed.
As I already had integrated AA with Azure Log Analytics I just needed to make sure I could identify and group all the logs into one logical unit. This I solved on the Log Analytics side by writing a query and an alert that called a webhook. This webhook was tied to another Runbook that took all the logs and did some basic formatting to make it more readable. I had to do this because at the time Log Analytics did not have any formatting controls (maybe still dont) to leverage. The Runbooks groups Errors, Warnings and Information into different packages and creates a ticket for them in the helpdesk system, so helpdesk personnel can follow up on them.
How I did these things is not the focus of this post, here I will only discuss how I did the layout of the UPN change Runbook.
So let’s jump into some code.
First I always try to externalise as much of the variables that potential is used for input as possible. I do not like to set this in code, so as to allow for others than the code maintainers to change the behaviour of the logic. This is why here I load in the external config parameters from AA, noteable AD OU structure and credentials to be able to change information in AD. As these values are important for the execution of the logic, one will want to fail the Runbook if there are any problems with loading them in.
1 2 3 4 5 6 | # Get AD base structure $ADSearchBase = Get-AutomationVariable -Name "ADUpdateUPNouSearchBase" -ErrorAction Continue -ErrorVariable oErr if($oErr) { Write-Error -Message "Failed to retrieve OU search base from AA" -ErrorAction Stop } |
Here the pattern discussed in the previous post is used.
In addition I mix the use of Write-Verbose and Write-Output, where Write-Output contains the information I want to give helpdesk and the like. While Write-Verbose is reserved for information I might need to debug, or check that the code flow behaves as expected. Knowing when to use what can be a bit challenging to begin with, but is usually solved by iterating on the code.
One might also notice the use of $VerbosePreference in different parts of the code. Usually this is to forcefully silence some output from reaching the logs. I mostly use it before running the Import-Module command, this because the verbose part of this command usually contains a lot of unwanted log information. So this is why I do $VerbosePreference = “silentlycontinue”. Then just before I use the first Write-Verbose command I add in $VerbosePreference = “continue” so as the verbose information once again flows to the logs. Though note you will still have to activate verbose logging on the Runbook for it to be actually captured.
For the first big ticket item I do a couple of things different than most scripts found on the web.
1 2 | $AdUsers = Get-ADUser -SearchBase $ADSearchBase -Filter 'Enabled -eq $True -and accountExpires -ge $Today' -Properties userPrincipalName,mail -Server $ADServer -Credential $ADCredential -ErrorAction Continue -ErrorVariable oErr | Where-Object {$_.DistinguishedName -like $ADSearchMatch -and $_.DistinguishedName -notlike $ADSearchFilter} |
First I set the starting point for my search in AD, default is the root. This to not have to iterate through all users each time the code runs. Next I use the Filter parameter to make sure to only return users that are enabled and with accounts that are not expired. Next I limit the amount of properties returned. This also with performance in mind. Earlier in the code I retrieve a DC that is responsive and healthy, targeting this for the user retrieval logic. I call this in the context of a user that has the needed security rights to do changes to users in AD. The users that are retrieved I further cut down using a include and exclude filter. This is useful if the AD structure has users a bit spread out, and also have some unwanted users mixed up with the desired once.
This method is not perfect, though it was good enough for us. If one want a 100% method one could first fetch the information from the Azure AD Connect server, then use this to build the filter logic that retrieves the OUs to where the users that are set up to cloud sync are. I did a blog about how to get these a while back.
We now have the users that we want to work on, and in situations like this I like to lead with a ForEach loop. This so I can layer inn a lot of tests to check for the validity of the data that is most important to me. As we are working with email addresses we need to be reasonably comfortable that these are correct. Also there is no need to do unnecessary processing, so if a user already has mail = UPN we do not need to do anything.
Now let’s look at the reason for this post, the “Continue on Error” pattern.
Below is an excerpt from the greater code, where this pattern can be observed. Now Set-ADObject is an good example of functions written by 3rd parties not always following the normal PS error flow. In this instance -ErrorAction does not behave as expected, and therefore we will need to compensate for this. This is why I wrapp it in a try/catch block, but it still follows the same global pattern.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | Write-Verbose -Message "Updating UPN with mail address for user: $($AdUser.Name). UPN current value: $($AdUser.userPrincipalName). Mail current value: $(($AdUser.mail).ToLower())" # Removing capital letters from mail address # Set-ADObject does not follow ErrorAction, so will need to do try / catch also $NoException = $true try { Set-ADObject -Identity $AdUser.DistinguishedName -Replace @{userPrincipalName=$(($AdUser.mail).ToLower())} -Server $ADServer -Credential $ADCredential -ErrorAction Continue -ErrorVariable oErr } catch { if($_.Exception.Message -like "The operation failed because UPN value provided*") { Write-Warning -Message "User: $($AdUser.Name) mail is already in use on UPN for another user in AD" } else { Write-Error -Message "Failed to update UPN of user: $($AdUser.Name). Error: $($_.Exception.Message)" -ErrorAction Continue } $ErrorCounter++ $NoException = $false } if($oErr -and $NoException) { # log error but dont fail Runbook Write-Error -Message "Failed to update UPN of user: $($AdUser.Name)" -ErrorAction Continue $ErrorCounter++ $oErr = $Null } elseif(-not $oErr) { Write-Output -InputObject "Updated UPN for user: $($AdUser.Name)" } |
Now as there are potential 1000s of users we need to iterate through, we do not want the code to fail and terminate the run if it encounters just some user objects that has some kind of error during processing. We instead want to continue even if an error occurs, but we want to know that an error happened and the user it happened to. To do this I added two different types of helpers for this. First I write the name of the user and the error into the AA logs, next I keep a count of how many error happens throughout the run. This will help with readability of the ticket created for helpdesk to process at the end.
During testing I also found another issue that might happen, where an UPN was potentially already taken by another user object. This usually happened where an error occurred during a manual update. As this is not what I would call typical execution error I wanted to add some logic that I could use to separate out this information for 3rd party callout. I therefore chose to treat these types of things as a warning, so I could put the information outside of the error stream and inform helpdesk through the separate warning information flow.
Next I found out through testing that Set-ADObject was not 100% consistent when it came to errors. Sometimes the $oErr variable would get populated with an error without the catch-block being triggered. This is why added a separate test for when the variable was populated but the catch block had not been entered ($NoException).
The last thing to handle is if the update was successful, in this situation I chose to use Write-Output as this is important summary information for the 3rd parties. As Write-Verbose has a lot of other code execution related information, the stream is not a good candidate to be used for the important information one will want to use to inform stakeholders with.
I therefore try to think about what type of information the different stakeholders are interested in and use all the information streams available to reduce as much noise as possible in them. Stakeholders might be helpdesk, managers, code maintainers and the automation code itself.
By combining all these techniques one are both able to let the automation go through all the users, but also inform someone else if some kind of intervention is needed.
The last thing I do for readability is to give some indication of how each run of the code went. I do this by utilizing the finally part of the try/catch-block.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | finally { if($WarningCounter -gt 0) { Write-Output -InputObject "Warning summary: $WarningCounter users failed validation and was not changed. Users names are in runbook log as warning entries" } if($ErrorCounter -gt 0) { Write-Output -InputObject "Error summary: $ErrorCounter users failed to be updated. Users names are in runbook log as error entries" } Write-Output -InputObject "Summary: $DifCounter users logon name updated" Write-Verbose -Message "Summary: $DifCounter UPN updated to match mail attribute address from $($AdUsers.Count) users processed" Write-Verbose -Message "Summary: $EqCounter of $($AdUsers.Count) users processed already have matching UPN and mail address attribute" Write-Verbose -Message "Runbook ended at time: $(get-Date -format r)" } |
Here I blend the use of Write-Output and Write-Verbose to separate out the information I think will be more easily readable and consumable by stakeholders, and information that might be needed to get deeper insight in how the run went.
That’s it, the journey is complete. In closing there are probably many ways to solve this, but I hope my way of doing it helps further the discussion of how one can think about running robust code in Azure Automation.
Lastly the example code in it’s whole.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 | <# .SYNOPSIS Automation to make sure all employees AD account has the same User Principal Name as mail .DESCRIPTION .EXAMPLE .INPUTS .OUTPUTS No output .NOTES NAME: Set-ADuserMailEqualUpn AUTHOR: Morten Lerudjordet VERSION: 1.0 #> #Requires -Modules ActiveDirectory #Requires -Version 5.0 Write-Verbose -Message "Starting Runbook at time: $(get-Date -format r). Running PS version: $($PSVersionTable.PSVersion). Hybrid worker: $($env:COMPUTERNAME)" $VerbosePreference = "silentlycontinue" Import-Module ActiveDirectory -ErrorAction Continue -ErrorVariable oErr if($oErr) { Write-Error -Message "Failed to load needed modules. Error: $($oErr.Message)" -ErrorAction Stop } try { # Get AD base structure $ADSearchBase = Get-AutomationVariable -Name "ADUpdateUPNouSearchBase" -ErrorAction Continue -ErrorVariable oErr if($oErr) { Write-Error -Message "Failed to retrieve OU search base from AA" -ErrorAction Stop } # Get AD OU filter pattern: "*OU=Users,OU=Admin,OU=lerun,DC=lerun,DC=info" $ADSearchFilter = Get-AutomationVariable -Name "ADUpdateUPNouSearchFilter" -ErrorAction Continue -ErrorVariable oErr if($oErr) { Write-Error -Message "Failed to retrieve OU search filter from AA" -ErrorAction Stop } # Get AD OU match pattern: "*,OU=Users,*" $ADSearchMatch = Get-AutomationVariable -Name "ADUpdateUPNouSearchMatch" -ErrorAction Continue -ErrorVariable oErr if($oErr) { Write-Error -Message "Failed to retrieve OU search match from AA" -ErrorAction Stop } # accountExpires on user object uses filetime $Today = (Get-Date).ToFileTime() # Get credentials to do changes to AD $ADCredential = Get-AutomationPSCredential -Name "ADUserMgmt" -ErrorAction Continue -ErrorVariable oErr if($oErr) { Write-Error -Message "Failed to retrieve credentials for AD admin" -ErrorAction Stop } # Check for a online DC and use this when calling AD $ADServer = (Get-ADDomainController -Discover -ForceDiscover -ErrorAction Continue -ErrorVariable oErr).HostName -as [string] If($oErr) { Write-Error -Message "Error fetching name of domain controller. Error: $($oErr.Exception.Message)" -ErrorAction Stop } # Allowed domain suffixes $AllowedDomainSuffixes = (Get-ADForest -Server $ADServer -Credential $ADCredential -ErrorAction Continue -ErrorVariable oErr).UPNSuffixes -as [array] if($oErr) { Write-Error -Message "Failed to retrieve allowed domain suffixes." -ErrorAction Stop } # Allowed domains $AllowedDomainSuffixes += (Get-ADForest -Server $ADServer -Credential $ADCredential -ErrorAction Continue -ErrorVariable oErr).Domains if($oErr) { Write-Error -Message "Failed to retrieve allowed domains" -ErrorAction Stop } $VerbosePreference = "continue" Write-Verbose -Message "Valid AD domain suffixes for e-mail addresses: $AllowedDomainSuffixes" $AdUsers = Get-ADUser -SearchBase $ADSearchBase -Filter 'Enabled -eq $True -and accountExpires -ge $Today' -Properties userPrincipalName,mail -Server $ADServer -Credential $ADCredential -ErrorAction Continue -ErrorVariable oErr | Where-Object {$_.DistinguishedName -like $ADSearchMatch -and $_.DistinguishedName -notlike $ADSearchFilter} if($oErr) { Write-Error -Message "Failed to retrieve users from AD." -ErrorAction Stop } Write-Verbose -Message "Retrieved $($AdUsers.Count) users from AD" $DifCounter = 0 $EqCounter = 0 $WarningCounter = 0 $ErrorCounter = 0 $DifADusers = @() ForEach($AdUser in $AdUsers) { # Check if mail and UPN is different if($AdUser.userPrincipalName -ne $AdUser.mail) { # check that the mail field is not empty if($AdUser.mail) { # check if mail address is valid # Most strict regex catches .myname@lerun.info # "^(?("")(""[^""]+?""@)|(([0-9a-zA-Z]((\.(?!\.))|[-!#\$%&'\*\+/=\?\^`\{\}\|~\w])*)(?<=[0-9a-zA-Z])@))(?(\[)(\[(\d{1,3}\.){3}\d{1,3}\])|(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,6}))$" # Relaxed but more inline with valid email addresses, .myname@lerun.info is a valid address. # "^[a-zA-Z0-9.!£#$%&'^_`{}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$" if($AdUser.mail -cmatch "^(?("")(""[^""]+?""@)|(([0-9a-zA-Z]((\.(?!\.))|[-!#\$%&'\*\+/=\?\^`\{\}\|~\w])*)(?<=[0-9a-zA-Z])@))(?(\[)(\[(\d{1,3}\.){3}\d{1,3}\])|(([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,6}))$") { # check that the email domain is on the allowed list if($AllowedDomainSuffixes -contains ($AdUser.mail).split("@")[-1]) { Write-Verbose -Message "Updating UPN with mail address for user: $($AdUser.Name). UPN current value: $($AdUser.userPrincipalName). Mail current value: $(($AdUser.mail).ToLower())" # Removing captial letters from mail address # Set-ADObject does not follow ErrorAction, so will need to do try / catch also $NoException = $true try { Set-ADObject -Identity $AdUser.DistinguishedName -Replace @{userPrincipalName=$(($AdUser.mail).ToLower())} -Server $ADServer -Credential $ADCredential -ErrorAction Continue -ErrorVariable oErr } catch { if($_.Exception.Message -like "The operation failed because UPN value provided*") { Write-Warning -Message "User: $($AdUser.Name) mail is already in use on UPN for another user in AD" } else { Write-Error -Message "Failed to update UPN of user: $($AdUser.Name). Error: $($_.Exception.Message)" -ErrorAction Continue } $ErrorCounter++ $NoException = $false } if($oErr -and $NoException) { # log error but dont fail Runbook Write-Error -Message "Failed to update UPN of user: $($AdUser.Name)" -ErrorAction Continue $ErrorCounter++ $oErr = $Null } elseif(-not $oErr) { Write-Output -InputObject "Updated UPN for user: $($AdUser.Name)" } $DifADusers += $AdUser | Select-Object -Property Name, userPrincipalName, @{Label="mail";Expression={($AdUser.mail).ToLower()}} , @{Label="Error";Expression={""}} $DifCounter++ } else { # log error but dont fail Runbook Write-Warning -Message "User: $($AdUser.Name) does not have a valid AD domain suffix in mail attribute: $(($AdUser.mail).ToLower())" $WarningCounter++ $DifADusers += $AdUser | Select-Object -Property Name, userPrincipalName, @{Label="mail";Expression={($AdUser.mail).ToLower()}} , @{Label="Error";Expression={"User does not have valid domain suffix in email address"}} } } else { Write-Warning -Message "User: $($AdUser.Name) does not have a valid mail address: $(($AdUser.mail).ToLower())" $DifADusers += $AdUser | Select-Object -Property Name, userPrincipalName, @{Label="mail";Expression={($AdUser.mail).ToLower()}} , @{Label="Error";Expression={"User does not have valid email address"}} $WarningCounter++ } } else { # log error but dont fail Runbook Write-Warning -Message "User: $($AdUser.Name) is missing email address in AD" $WarningCounter++ $DifADusers += $AdUser | Select-Object -Property Name, userPrincipalName, @{Label="mail";Expression={($AdUser.mail).ToLower()}} , @{Label="Error";Expression={"User is missing email address"}} } } else { Write-Verbose -Message "UPN and mail is equal for user: $($AdUser.Name)" $EqCounter++ } } # If need to export to csv, remember this can not be activated while running in AA #$DifADusers | Export-Csv -Path c:\temp\DiffAdUsers.csv -Encoding UTF8 -NoTypeInformation } catch { if($_.Exception.Message) { Write-Error -Message "$($_.Exception.Message)" -ErrorAction Continue } else { Write-Error -Message "$($_.Exception)" -ErrorAction Continue } throw "$($_.Exception)" } finally { if($WarningCounter -gt 0) { Write-Output -InputObject "Warning summary: $WarningCounter users failed validation and was not changed. Users names are in runbook log as warning entries" } if($ErrorCounter -gt 0) { Write-Output -InputObject "Error summary: $ErrorCounter users failed to be updated. Users names are in runbook log as error entries" } Write-Output -InputObject "Summary: $DifCounter users logon name updated" Write-Verbose -Message "Summary: $DifCounter UPN updated to match mail attribute address from $($AdUsers.Count) users processed" Write-Verbose -Message "Summary: $EqCounter of $($AdUsers.Count) users processed already have matching UPN and mail address attribute" Write-Verbose -Message "Runbook ended at time: $(get-Date -format r)" } |
Happy tinkering!