├── .gitignore ├── Cleanup-AutoPilotImportedDevices.ps1 ├── Import-AutoPilotInfo.ps1 └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /Cleanup-AADDevices.ps1 -------------------------------------------------------------------------------- /Cleanup-AutoPilotImportedDevices.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | Version: 1.0 3 | Author: Oliver Kieselbach 4 | Script: Cleanup-AutoPiloImportedDevices.ps1 5 | 6 | Description: 7 | In case something went wrong we can cleanup the stating area of the AutoPilot import API. 8 | 9 | Release notes: 10 | Version 1.0: Original published version. 11 | 12 | The script is provided "AS IS" with no warranties. 13 | #> 14 | 15 | function Get-AuthToken { 16 | 17 | try { 18 | $AadModule = Import-Module -Name AzureAD -ErrorAction Stop -PassThru 19 | } 20 | catch { 21 | throw 'AzureAD PowerShell module is not installed!' 22 | } 23 | 24 | $intuneAutomationCredential = Get-AutomationPSCredential -Name automation 25 | $intuneAutomationAppId = Get-AutomationVariable -Name IntuneClientId 26 | $tenant = Get-AutomationVariable -Name Tenant 27 | 28 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 29 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 30 | [System.Reflection.Assembly]::LoadFrom($adal) | Out-Null 31 | [System.Reflection.Assembly]::LoadFrom($adalforms) | Out-Null 32 | $redirectUri = "urn:ietf:wg:oauth:2.0:oob" 33 | $resourceAppIdURI = "https://graph.microsoft.com" 34 | $authority = "https://login.microsoftonline.com/$tenant" 35 | 36 | try { 37 | $authContext = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" -ArgumentList $authority 38 | $platformParameters = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.PlatformParameters" -ArgumentList "Auto" 39 | $userId = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.UserIdentifier" -ArgumentList ($intuneAutomationCredential.Username, "OptionalDisplayableId") 40 | $userCredentials = New-Object Microsoft.IdentityModel.Clients.ActiveDirectory.UserPasswordCredential -ArgumentList $intuneAutomationCredential.Username, $intuneAutomationCredential.Password 41 | $authResult = [Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContextIntegratedAuthExtensions]::AcquireTokenAsync($authContext, $resourceAppIdURI, $intuneAutomationAppId, $userCredentials); 42 | 43 | if ($authResult.Result.AccessToken) { 44 | $authHeader = @{ 45 | 'Content-Type' = 'application/json' 46 | 'Authorization' = "Bearer " + $authResult.Result.AccessToken 47 | 'ExpiresOn' = $authResult.Result.ExpiresOn 48 | } 49 | return $authHeader 50 | } 51 | elseif ($authResult.Exception) { 52 | throw "An error occured getting access token: $($authResult.Exception.InnerException)" 53 | } 54 | } 55 | catch { 56 | throw $_.Exception.Message 57 | } 58 | } 59 | 60 | 61 | function Connect-AutoPilotIntune { 62 | 63 | if($global:authToken){ 64 | $DateTime = (Get-Date).ToUniversalTime() 65 | $TokenExpires = ($authToken.ExpiresOn.datetime - $DateTime).Minutes 66 | 67 | if($TokenExpires -le 0){ 68 | Write-Output "Authentication Token expired" $TokenExpires "minutes ago" 69 | $global:authToken = Get-AuthToken 70 | } 71 | } 72 | else { 73 | $global:authToken = Get-AuthToken 74 | } 75 | } 76 | 77 | 78 | Function Get-AutoPilotDevice(){ 79 | [cmdletbinding()] 80 | param 81 | ( 82 | [Parameter(Mandatory=$false)] $id 83 | ) 84 | 85 | # Defining Variables 86 | $graphApiVersion = "beta" 87 | $Resource = "deviceManagement/windowsAutopilotDeviceIdentities" 88 | 89 | if ($id) { 90 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource/$id" 91 | } 92 | else { 93 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource" 94 | } 95 | try { 96 | $response = Invoke-RestMethod -Uri $uri -Headers $authToken -Method Get 97 | if ($id) { 98 | $response 99 | } 100 | else { 101 | $response.Value 102 | } 103 | } 104 | catch { 105 | 106 | $ex = $_.Exception 107 | $errorResponse = $ex.Response.GetResponseStream() 108 | $reader = New-Object System.IO.StreamReader($errorResponse) 109 | $reader.BaseStream.Position = 0 110 | $reader.DiscardBufferedData() 111 | $responseBody = $reader.ReadToEnd(); 112 | 113 | Write-Output "Response content:`n$responseBody" 114 | Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)" 115 | 116 | break 117 | } 118 | 119 | } 120 | 121 | 122 | Function Get-AutoPilotImportedDevice(){ 123 | [cmdletbinding()] 124 | param 125 | ( 126 | [Parameter(Mandatory=$false)] $id 127 | ) 128 | 129 | # Defining Variables 130 | $graphApiVersion = "beta" 131 | $Resource = "deviceManagement/importedWindowsAutopilotDeviceIdentities" 132 | 133 | if ($id) { 134 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource/$id" 135 | } 136 | else { 137 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource" 138 | } 139 | try { 140 | $response = Invoke-RestMethod -Uri $uri -Headers $authToken -Method Get 141 | if ($id) { 142 | $response 143 | } 144 | else { 145 | $response.Value 146 | } 147 | } 148 | catch { 149 | 150 | $ex = $_.Exception 151 | $errorResponse = $ex.Response.GetResponseStream() 152 | $reader = New-Object System.IO.StreamReader($errorResponse) 153 | $reader.BaseStream.Position = 0 154 | $reader.DiscardBufferedData() 155 | $responseBody = $reader.ReadToEnd(); 156 | 157 | Write-Output "Response content:`n$responseBody" 158 | Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)" 159 | 160 | #break 161 | # in case we cannot verify we exit the script to prevent cleanups and loosing of .csv files in the blob storage 162 | Exit 163 | } 164 | 165 | } 166 | 167 | Function Add-AutoPilotImportedDevice(){ 168 | [cmdletbinding()] 169 | param 170 | ( 171 | [Parameter(Mandatory=$true)] $serialNumber, 172 | [Parameter(Mandatory=$true)] $hardwareIdentifier, 173 | [Parameter(Mandatory=$false)] $orderIdentifier = "" 174 | ) 175 | 176 | # Defining Variables 177 | $graphApiVersion = "beta" 178 | $Resource = "deviceManagement/importedWindowsAutopilotDeviceIdentities" 179 | 180 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource" 181 | $json = @" 182 | { 183 | "@odata.type": "#microsoft.graph.importedWindowsAutopilotDeviceIdentity", 184 | "orderIdentifier": "$orderIdentifier", 185 | "serialNumber": "$serialNumber", 186 | "productKey": "", 187 | "hardwareIdentifier": "$hardwareIdentifier", 188 | "state": { 189 | "@odata.type": "microsoft.graph.importedWindowsAutopilotDeviceIdentityState", 190 | "deviceImportStatus": "pending", 191 | "deviceRegistrationId": "", 192 | "deviceErrorCode": 0, 193 | "deviceErrorName": "" 194 | } 195 | } 196 | "@ 197 | 198 | try { 199 | Invoke-RestMethod -Uri $uri -Headers $authToken -Method Post -Body $json -ContentType "application/json" 200 | } 201 | catch { 202 | 203 | $ex = $_.Exception 204 | $errorResponse = $ex.Response.GetResponseStream() 205 | $reader = New-Object System.IO.StreamReader($errorResponse) 206 | $reader.BaseStream.Position = 0 207 | $reader.DiscardBufferedData() 208 | $responseBody = $reader.ReadToEnd(); 209 | 210 | Write-Output "Response content:`n$responseBody" 211 | Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)" 212 | 213 | break 214 | } 215 | 216 | } 217 | 218 | 219 | Function Remove-AutoPilotImportedDevice(){ 220 | [cmdletbinding()] 221 | param 222 | ( 223 | [Parameter(Mandatory=$true)] $id 224 | ) 225 | 226 | # Defining Variables 227 | $graphApiVersion = "beta" 228 | $Resource = "deviceManagement/importedWindowsAutopilotDeviceIdentities" 229 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource/$id" 230 | 231 | try { 232 | Invoke-RestMethod -Uri $uri -Headers $authToken -Method Delete | Out-Null 233 | } 234 | catch { 235 | 236 | $ex = $_.Exception 237 | $errorResponse = $ex.Response.GetResponseStream() 238 | $reader = New-Object System.IO.StreamReader($errorResponse) 239 | $reader.BaseStream.Position = 0 240 | $reader.DiscardBufferedData() 241 | $responseBody = $reader.ReadToEnd(); 242 | 243 | Write-Output "Response content:`n$responseBody" 244 | Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)" 245 | 246 | break 247 | } 248 | 249 | } 250 | 251 | #################################################### 252 | 253 | Function Cleanup-AutoPilotImportedDevices(){ 254 | 255 | $deviceStatuses = Get-AutoPilotImportedDevice 256 | 257 | # Cleanup the imported device records 258 | $deviceStatuses | ForEach-Object { 259 | Write-Output "removing id: $_.id" 260 | Remove-AutoPilotImportedDevice -id $_.id 261 | } 262 | } 263 | 264 | 265 | #################################################### 266 | 267 | # Connect to Intune 268 | Connect-AutoPilotIntune 269 | 270 | # Cleanup 271 | Cleanup-AutoPilotImportedDevices -------------------------------------------------------------------------------- /Import-AutoPilotInfo.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | Version: 1.0 3 | Author: Oliver Kieselbach 4 | Runbook: Import-AutoPilotInfo 5 | 6 | Description: 7 | Get AutoPilot device information from Azure Blob Storage and import device to Intune 8 | AutoPilot service via Intune API running from a Azure Automation runbook. 9 | Cleanup Blob Storage and send import notification to a Microsoft Teams channel. 10 | 11 | Release notes: 12 | Version 1.0: Original published version. 13 | 14 | The script is provided "AS IS" with no warranties. 15 | #> 16 | 17 | #################################################### 18 | 19 | # Based on PowerShell Gallery WindowsAutoPilotIntune 20 | # https://www.powershellgallery.com/packages/WindowsAutoPilotIntune 21 | # modified to support unattended authentication within a runbook 22 | 23 | function Get-AuthToken { 24 | 25 | try { 26 | $AadModule = Import-Module -Name AzureAD -ErrorAction Stop -PassThru 27 | } 28 | catch { 29 | throw 'AzureAD PowerShell module is not installed!' 30 | } 31 | 32 | $intuneAutomationCredential = Get-AutomationPSCredential -Name automation 33 | $intuneAutomationAppId = Get-AutomationVariable -Name IntuneClientId 34 | $tenant = Get-AutomationVariable -Name Tenant 35 | 36 | $adal = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.dll" 37 | $adalforms = Join-Path $AadModule.ModuleBase "Microsoft.IdentityModel.Clients.ActiveDirectory.Platform.dll" 38 | [System.Reflection.Assembly]::LoadFrom($adal) | Out-Null 39 | [System.Reflection.Assembly]::LoadFrom($adalforms) | Out-Null 40 | $redirectUri = "urn:ietf:wg:oauth:2.0:oob" 41 | $resourceAppIdURI = "https://graph.microsoft.com" 42 | $authority = "https://login.microsoftonline.com/$tenant" 43 | 44 | try { 45 | $authContext = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContext" -ArgumentList $authority 46 | $platformParameters = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.PlatformParameters" -ArgumentList "Auto" 47 | $userId = New-Object "Microsoft.IdentityModel.Clients.ActiveDirectory.UserIdentifier" -ArgumentList ($intuneAutomationCredential.Username, "OptionalDisplayableId") 48 | $userCredentials = New-Object Microsoft.IdentityModel.Clients.ActiveDirectory.UserPasswordCredential -ArgumentList $intuneAutomationCredential.Username, $intuneAutomationCredential.Password 49 | $authResult = [Microsoft.IdentityModel.Clients.ActiveDirectory.AuthenticationContextIntegratedAuthExtensions]::AcquireTokenAsync($authContext, $resourceAppIdURI, $intuneAutomationAppId, $userCredentials); 50 | 51 | if ($authResult.Result.AccessToken) { 52 | $authHeader = @{ 53 | 'Content-Type' = 'application/json' 54 | 'Authorization' = "Bearer " + $authResult.Result.AccessToken 55 | 'ExpiresOn' = $authResult.Result.ExpiresOn 56 | } 57 | return $authHeader 58 | } 59 | elseif ($authResult.Exception) { 60 | throw "An error occured getting access token: $($authResult.Exception.InnerException)" 61 | } 62 | } 63 | catch { 64 | throw $_.Exception.Message 65 | } 66 | } 67 | 68 | 69 | function Connect-AutoPilotIntune { 70 | 71 | if($global:authToken){ 72 | $DateTime = (Get-Date).ToUniversalTime() 73 | $TokenExpires = ($authToken.ExpiresOn.datetime - $DateTime).Minutes 74 | 75 | if($TokenExpires -le 0){ 76 | Write-Output "Authentication Token expired" $TokenExpires "minutes ago" 77 | $global:authToken = Get-AuthToken 78 | } 79 | } 80 | else { 81 | $global:authToken = Get-AuthToken 82 | } 83 | } 84 | 85 | 86 | Function Get-AutoPilotDevice(){ 87 | [cmdletbinding()] 88 | param 89 | ( 90 | [Parameter(Mandatory=$false)] $id 91 | ) 92 | 93 | # Defining Variables 94 | $graphApiVersion = "beta" 95 | $Resource = "deviceManagement/windowsAutopilotDeviceIdentities" 96 | 97 | if ($id) { 98 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource/$id" 99 | } 100 | else { 101 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource" 102 | } 103 | try { 104 | $response = Invoke-RestMethod -Uri $uri -Headers $authToken -Method Get 105 | if ($id) { 106 | $response 107 | } 108 | else { 109 | $response.Value 110 | } 111 | } 112 | catch { 113 | 114 | $ex = $_.Exception 115 | $errorResponse = $ex.Response.GetResponseStream() 116 | $reader = New-Object System.IO.StreamReader($errorResponse) 117 | $reader.BaseStream.Position = 0 118 | $reader.DiscardBufferedData() 119 | $responseBody = $reader.ReadToEnd(); 120 | 121 | Write-Output "Response content:`n$responseBody" 122 | Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)" 123 | 124 | break 125 | } 126 | 127 | } 128 | 129 | 130 | Function Get-AutoPilotImportedDevice(){ 131 | [cmdletbinding()] 132 | param 133 | ( 134 | [Parameter(Mandatory=$false)] $id 135 | ) 136 | 137 | # Defining Variables 138 | $graphApiVersion = "beta" 139 | $Resource = "deviceManagement/importedWindowsAutopilotDeviceIdentities" 140 | 141 | if ($id) { 142 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource/$id" 143 | } 144 | else { 145 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource" 146 | } 147 | try { 148 | $response = Invoke-RestMethod -Uri $uri -Headers $authToken -Method Get 149 | if ($id) { 150 | $response 151 | } 152 | else { 153 | $response.Value 154 | } 155 | } 156 | catch { 157 | 158 | $ex = $_.Exception 159 | $errorResponse = $ex.Response.GetResponseStream() 160 | $reader = New-Object System.IO.StreamReader($errorResponse) 161 | $reader.BaseStream.Position = 0 162 | $reader.DiscardBufferedData() 163 | $responseBody = $reader.ReadToEnd(); 164 | 165 | Write-Output "Response content:`n$responseBody" 166 | Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)" 167 | 168 | #break 169 | # in case we cannot verify we exit the script to prevent cleanups and loosing of .csv files in the blob storage 170 | Exit 171 | } 172 | 173 | } 174 | 175 | Function Add-AutoPilotImportedDevice(){ 176 | [cmdletbinding()] 177 | param 178 | ( 179 | [Parameter(Mandatory=$true)] $serialNumber, 180 | [Parameter(Mandatory=$true)] $hardwareIdentifier, 181 | [Parameter(Mandatory=$false)] $orderIdentifier = "" 182 | ) 183 | 184 | # Defining Variables 185 | $graphApiVersion = "beta" 186 | $Resource = "deviceManagement/importedWindowsAutopilotDeviceIdentities" 187 | 188 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource" 189 | $json = @" 190 | { 191 | "@odata.type": "#microsoft.graph.importedWindowsAutopilotDeviceIdentity", 192 | "orderIdentifier": "$orderIdentifier", 193 | "serialNumber": "$serialNumber", 194 | "productKey": "", 195 | "hardwareIdentifier": "$hardwareIdentifier", 196 | "state": { 197 | "@odata.type": "microsoft.graph.importedWindowsAutopilotDeviceIdentityState", 198 | "deviceImportStatus": "pending", 199 | "deviceRegistrationId": "", 200 | "deviceErrorCode": 0, 201 | "deviceErrorName": "" 202 | } 203 | } 204 | "@ 205 | 206 | try { 207 | Invoke-RestMethod -Uri $uri -Headers $authToken -Method Post -Body $json -ContentType "application/json" 208 | } 209 | catch { 210 | 211 | $ex = $_.Exception 212 | $errorResponse = $ex.Response.GetResponseStream() 213 | $reader = New-Object System.IO.StreamReader($errorResponse) 214 | $reader.BaseStream.Position = 0 215 | $reader.DiscardBufferedData() 216 | $responseBody = $reader.ReadToEnd(); 217 | 218 | Write-Output "Response content:`n$responseBody" 219 | Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)" 220 | 221 | break 222 | } 223 | 224 | } 225 | 226 | 227 | Function Remove-AutoPilotImportedDevice(){ 228 | [cmdletbinding()] 229 | param 230 | ( 231 | [Parameter(Mandatory=$true)] $id 232 | ) 233 | 234 | # Defining Variables 235 | $graphApiVersion = "beta" 236 | $Resource = "deviceManagement/importedWindowsAutopilotDeviceIdentities" 237 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource/$id" 238 | 239 | try { 240 | Invoke-RestMethod -Uri $uri -Headers $authToken -Method Delete | Out-Null 241 | } 242 | catch { 243 | 244 | $ex = $_.Exception 245 | $errorResponse = $ex.Response.GetResponseStream() 246 | $reader = New-Object System.IO.StreamReader($errorResponse) 247 | $reader.BaseStream.Position = 0 248 | $reader.DiscardBufferedData() 249 | $responseBody = $reader.ReadToEnd(); 250 | 251 | Write-Output "Response content:`n$responseBody" 252 | Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)" 253 | 254 | break 255 | } 256 | 257 | } 258 | 259 | #################################################### 260 | 261 | Function Import-AutoPilotCSV(){ 262 | [cmdletbinding()] 263 | param 264 | ( 265 | [Parameter(Mandatory=$true)] $csvFile, 266 | [Parameter(Mandatory=$false)] $orderIdentifier = "" 267 | ) 268 | 269 | $deviceStatusesInitial = Get-AutoPilotImportedDevice 270 | $deviceCountInitial = $deviceStatusesInitial.Length 271 | if ($deviceCountInitial -ge 175) { 272 | Write-Output "Previous cleanup didn't work, stopping any further actions to prevent filling up Autopilot imported device space!" 273 | Exit 274 | } 275 | 276 | # Read CSV and process each device 277 | $devices = Import-CSV $csvFile 278 | foreach ($device in $devices) { 279 | Add-AutoPilotImportedDevice -serialNumber $device.'Device Serial Number' -hardwareIdentifier $device.'Hardware Hash' -orderIdentifier $orderIdentifier 280 | } 281 | 282 | # While we could keep a list of all the IDs that we added and then check each one, it is 283 | # easier to just loop through all of them 284 | $processingCount = 1 285 | while ($processingCount -gt 0) 286 | { 287 | $deviceStatuses = Get-AutoPilotImportedDevice 288 | $deviceCount = $deviceStatuses.Length 289 | 290 | # Check to see if any devices are still processing (enhanced by check for pending) 291 | $processingCount = 0 292 | foreach ($device in $deviceStatuses){ 293 | if ($($device.state.deviceImportStatus).ToLower() -eq "unknown" -or $($device.state.deviceImportStatus).ToLower() -eq "pending") { 294 | $processingCount = $processingCount + 1 295 | } 296 | } 297 | Write-Output "Waiting for $processingCount of $deviceCount" 298 | 299 | # Still processing? Sleep before trying again. 300 | if ($processingCount -gt 0){ 301 | Start-Sleep 15 302 | } 303 | } 304 | 305 | # Generate some statistics for reporting... 306 | $global:totalCount = $deviceStatuses.Count 307 | $global:successCount = 0 308 | $global:errorCount = 0 309 | $global:softErrorCount = 0 310 | $global:errorList = @{} 311 | 312 | ForEach ($deviceStatus in $deviceStatuses) { 313 | if ($($deviceStatus.state.deviceImportStatus).ToLower() -eq 'success' -or $($deviceStatus.state.deviceImportStatus).ToLower() -eq 'complete') { 314 | $global:successCount += 1 315 | } elseif ($($deviceStatus.state.deviceImportStatus).ToLower() -eq 'error') { 316 | $global:errorCount += 1 317 | # ZtdDeviceAlreadyAssigned will be counted as soft error, free to delete 318 | if ($($deviceStatus.state.deviceErrorCode) -eq 806) { 319 | $global:softErrorCount += 1 320 | } 321 | $global:errorList.Add($deviceStatus.serialNumber, $deviceStatus.state) 322 | } 323 | } 324 | 325 | # Display the statuses 326 | $deviceStatuses | ForEach-Object { 327 | Write-Output "Serial number $($_.serialNumber): $($_.state.deviceImportStatus), $($_.state.deviceErrorCode), $($_.state.deviceErrorName)" 328 | } 329 | 330 | # Cleanup the imported device records 331 | $deviceStatuses | ForEach-Object { 332 | Remove-AutoPilotImportedDevice -id $_.id 333 | } 334 | } 335 | 336 | Function Invoke-AutopilotSync(){ 337 | [cmdletbinding()] 338 | param 339 | ( 340 | ) 341 | # Defining Variables 342 | $graphApiVersion = "beta" 343 | $Resource = "deviceManagement/windowsAutopilotSettings/sync" 344 | 345 | $uri = "https://graph.microsoft.com/$graphApiVersion/$Resource" 346 | try { 347 | $response = Invoke-RestMethod -Uri $uri -Headers $authToken -Method Post 348 | $response.Value 349 | } 350 | catch { 351 | 352 | $ex = $_.Exception 353 | $errorResponse = $ex.Response.GetResponseStream() 354 | $reader = New-Object System.IO.StreamReader($errorResponse) 355 | $reader.BaseStream.Position = 0 356 | $reader.DiscardBufferedData() 357 | $responseBody = $reader.ReadToEnd(); 358 | 359 | Write-Host "Response content:`n$responseBody" -f Red 360 | Write-Error "Request to $Uri failed with HTTP Status $($ex.Response.StatusCode) $($ex.Response.StatusDescription)" 361 | 362 | break 363 | } 364 | 365 | } 366 | 367 | 368 | #################################################### 369 | 370 | $global:totalCount = 0 371 | 372 | # Connect to Intune 373 | Connect-AutoPilotIntune 374 | 375 | # Get Credentials an Automation variables 376 | $intuneAutomationCredential = Get-AutomationPSCredential -Name automation 377 | Login-AzureRmAccount -Credential $intuneAutomationCredential | Out-Null 378 | $tenant = Get-AutomationVariable -Name Tenant 379 | $StorageKey = Get-AutomationVariable -Name StorageKey 380 | $TeamsWebhookUrl = Get-AutomationVariable -Name TeamsWebhookUrl 381 | 382 | #################################################### 383 | 384 | # Based on Preventing Azure Automation Concurrent Jobs In the Runbook 385 | # https://blog.tyang.org/2017/07/03/preventing-azure-automation-concurrent-jobs-in-the-runbook/ 386 | # modified some outputs 387 | 388 | $CurrentJobId= $PSPrivateMetadata.JobId.Guid 389 | Write-Output "Current Job ID: '$CurrentJobId'" 390 | 391 | #Get Automation account and resource group names 392 | $AutomationAccounts = Find-AzureRmResource -ResourceType "Microsoft.Automation/AutomationAccounts" 393 | foreach ($item in $AutomationAccounts) { 394 | # Loop through each Automation account to find this job 395 | $Job = Get-AzureRmAutomationJob -ResourceGroupName $item.ResourceGroupName -AutomationAccountName $item.Name -Id $CurrentJobId -ErrorAction SilentlyContinue 396 | if ($Job) { 397 | $AutomationAccountName = $item.Name 398 | $ResourceGroupName = $item.ResourceGroupName 399 | $RunbookName = $Job.RunbookName 400 | break 401 | } 402 | } 403 | Write-Output "Automation Account Name: '$AutomationAccountName'" 404 | Write-Output "Resource Group Name: '$ResourceGroupName'" 405 | Write-Output "Runbook Name: '$RunbookName'" 406 | 407 | #Check if the runbook is already running 408 | if ($RunbookName) { 409 | $CurrentRunningJobs = Get-AzureRmAutomationJob -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -RunbookName $RunbookName | Where-object {($_.Status -imatch '\w+ing$' -or $_.Status -imatch 'queued') -and $_.JobId.tostring() -ine $CurrentJobId} 410 | If ($CurrentRunningJobs) { 411 | Write-output "Active runbook job detected." 412 | Foreach ($job in $CurrentRunningJobs) { 413 | Write-Output " - JobId: $($job.JobId), Status: '$($job.Status)'." 414 | } 415 | Write-output "The runbook job will stop now." 416 | Exit 417 | } else { 418 | Write-Output "No concurrent runbook jobs found. OK to continue." 419 | } 420 | } 421 | else { 422 | Write-output "Runbook not found will stop now." 423 | Exit 424 | } 425 | 426 | #################################################### 427 | 428 | # Main logic 429 | 430 | $StorageAccountName = Get-AutomationVariable -Name StorageAccountName 431 | $ContainerName = Get-AutomationVariable -Name ContainerName 432 | $accountContext = New-AzureStorageContext -StorageAccountName $StorageAccountName -StorageAccountKey $StorageKey 433 | 434 | $PathCsvFiles = "$env:TEMP" 435 | $CombinedOutput = "$pathCsvFiles\combined.csv" 436 | 437 | $countOnline = $(Get-AzureStorageContainer -Container $ContainerName -Context $accountContext | Get-AzureStorageBlob | measure).Count 438 | if ($countOnline -gt 0) { 439 | Get-AzureStorageContainer -Container $ContainerName -Context $accountContext | Get-AzureStorageBlob | Get-AzureStorageBlobContent -Force -Destination $PathCsvFiles | Out-Null 440 | 441 | # Intune has a limit for 175 rows as maximum allowed import currently! We select max 175 csv files to combine them 442 | $downloadFiles = Get-ChildItem -Path $PathCsvFiles -Filter "*.csv" | select -First 175 443 | 444 | # parse all .csv files and combine to single one for batch upload! 445 | Set-Content -Path $CombinedOutput -Value "Device Serial Number,Windows Product ID,Hardware Hash" -Encoding Unicode 446 | $downloadFiles | % { Get-Content $_.FullName | Select -Index 1 } | Add-Content -Path $CombinedOutput -Encoding Unicode 447 | } 448 | 449 | if (Test-Path $CombinedOutput) { 450 | # measure import timespan 451 | $importStartTime = Get-Date 452 | 453 | # Add a batch of AutoPilot devices 454 | Import-AutoPilotCSV $CombinedOutput 455 | 456 | # calculate import timespan 457 | $importEndTime = Get-Date 458 | $importTotalTime = $importEndTime - $importStartTime 459 | $importTotalTime = "$($importTotalTime.Hours):$($importTotalTime.Minutes):$($importTotalTime.Seconds)s" 460 | 461 | # Online blob storage cleanup, leave error device .csv files there expect it's ZtdDeviceAlreadyAssigned error 462 | # in case of error someone needs to check manually but we inform via Teams message later in the runbook 463 | $downloadFilesSearchableByName = @{} 464 | $downloadFilesSearchableBySerialNumber = @{} 465 | 466 | ForEach ($downloadFile in $downloadFiles) { 467 | $serialNumber = $(Get-Content $downloadFile.FullName | Select -Index 1 ).Split(',')[0] 468 | 469 | $downloadFilesSearchableBySerialNumber.Add($serialNumber, $downloadFile.Name) 470 | $downloadFilesSearchableByName.Add($downloadFile.Name, $serialNumber) 471 | } 472 | $serialNumber = $null 473 | 474 | $csvBlobs = Get-AzureStorageContainer -Container $ContainerName -Context $accountContext | Get-AzureStorageBlob 475 | ForEach ($csvBlob in $csvBlobs) { 476 | $serialNumber = $downloadFilesSearchableByName[$csvBlob.Name] 477 | 478 | $isErrorDevice = $false 479 | $isSafeToDelete = $false 480 | 481 | if ($serialNumber) { 482 | ForEach ($number in $global:errorList.Keys){ 483 | if ($number -eq $serialNumber) { 484 | $isErrorDevice = $true 485 | if ($global:errorList[$number].deviceErrorCode -eq 806) { 486 | $isSafeToDelete = $true 487 | } 488 | } 489 | } 490 | 491 | if (-not $isErrorDevice -or $isSafeToDelete) { 492 | Remove-AzureStorageBlob -Container $ContainerName -Blob $csvBlob.Name-Context $accountContext 493 | } 494 | } 495 | } 496 | 497 | # Sync new devices to Intune 498 | Write-output "Triggering Sync to Intune." 499 | Invoke-AutopilotSync 500 | } 501 | else { 502 | Write-Output "" 503 | Write-Output "Nothing to import." 504 | } 505 | 506 | #################################################### 507 | 508 | # if there are imported devices generate some statistics and report via Teams 509 | If ($global:totalCount -ne 0) { 510 | Write-Output "=========================" 511 | Write-Output "Import took $importTotalTime for a total of $global:totalCount device, $global:successCount devices successfully imported, $global:errorCount devices failed to import ($global:softErrorCount soft errors*)" 512 | Write-Output "*ZtdDeviceAlreadyAssigned is counted as soft error and therefore the device .csv is deleted from the blob storage" 513 | if ($global:errorCount -ne 0) { 514 | Write-Output "Detailed error list:" 515 | ForEach ($errorDevice in $global:errorList.Keys) { 516 | $errorDeviceName = $downloadFilesSearchableBySerialNumber[$errorDevice] 517 | # Device details: DESKTOP-TG5C1S5.csv with S/N: 6191-7437-4504-1377-2572-0616-16, ImportStatus: error, ErrorCode: 806 (ZtdDeviceAlreadyAssigned) 518 | Write-Output "Device details: $errorDeviceName with S/N: $errorDevice, ImportStatus: $($global:errorList[$errorDevice].deviceImportStatus), ErrorCode: $($global:errorList[$errorDevice].deviceErrorCode) ($($global:errorList[$errorDevice].deviceErrorName))" 519 | } 520 | } 521 | 522 | $uri = $TeamsWebhookUrl 523 | 524 | $totalDeviceCount = $global:totalCount 525 | $successDeviceCount = $global:successCount 526 | 527 | $errorDevices = $global:errorList.Keys 528 | $errorDeviceCount = $errorDevices.Count 529 | 530 | $subscriptionUrl = Get-AutomationVariable -Name SubscriptionUrl 531 | $azurePortalUrl = "$subscriptionUrl/resourceGroups/$ResourceGroupName/providers/Microsoft.Automation/automationAccounts/$AutomationAccountName/runbooks/$RunbookName/overview" 532 | 533 | # generate the json code for Teams Notification (AdaptiveCard) 534 | # it's even possible to build dynamically the json via an PS object and then using "ConvertTo-Json -Depth 4 $jsonCode" to generate the json 535 | $jsonCode =@" 536 | { 537 | "title": "AutoPilot Import Job Notification", 538 | "sections": [ 539 | { 540 | "activityTitle": 'Total of $totalDeviceCount device(s) processed', 541 | "activityText": 'REMARK: ZtdDeviceAlreadyAssigned error is counted as soft error and device info is deleted from blob storage', 542 | }, 543 | { 544 | "facts": [ 545 | { 546 | "value": $successDeviceCount, 547 | "name": "Import successful" 548 | }, 549 | { 550 | "value": $errorDeviceCount, 551 | "name": "Import error" 552 | } 553 | ] 554 | }, 555 | { 556 | "facts": [ 557 | { 558 | "value": "", 559 | "name": "Error Summary" 560 | } 561 | ] 562 | } 563 | ], 564 | "text": "Details of runbook job: [$CurrentJobId]($azurePortalUrl)" 565 | } 566 | "@ 567 | 568 | $jsonCodeDeviceList = @" 569 | , 570 | { 571 | "value": "", 572 | "name": "Device details" 573 | } 574 | "@ 575 | 576 | switch ($errorDeviceCount) 577 | { 578 | 0 { $deviceList = "" } 579 | default { $errorDevices | ForEach { 580 | $errorDeviceName = $downloadFilesSearchableBySerialNumber[$_] 581 | # Device details: DESKTOP-TG5C1S5.csv with S/N: 6191-7437-4504-1377-2572-0616-16, ErrorCode: 806 (ZtdDeviceAlreadyAssigned) 582 | $deviceList += $jsonCodeDeviceList.Replace("", "$errorDeviceName with S/N: $_, ErrorCode: $($global:errorList[$_].deviceErrorCode) ($($global:errorList[$_].deviceErrorName))") 583 | } 584 | } 585 | } 586 | $jsonCode = $jsonCode.Replace("", $deviceList) 587 | $deviceList = "" 588 | 589 | Invoke-RestMethod -uri $uri -Method Post -body $jsonCode -ContentType 'application/json' 590 | } 591 | 592 | Write-Output "Finish" -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Azure-Automation 2 | Azure Automation Runbooks. 3 | 4 | All PowerShell runbooks are provided "AS IS" with no liability and should always be tested in a test environment before used in production! 5 | --------------------------------------------------------------------------------