Import-AutopilotDevice.ps1
|
<#PSScriptInfo
.VERSION 1.1.20260914.3 .GUID 5c9800d6-0239-4a66-86a7-a906f956bf35 .AUTHOR andreas.lucas@microsoft.com .COMPANYNAME .COPYRIGHT .TAGS WindowsAutopilot Intune Entra Azure REST HardwareHash .LICENSEURI .PROJECTURI https://github.com/anluca_microsoft/Intune-Autopilotimporter .ICONURI .EXTERNALMODULEDEPENDENCIES Az.Accounts .REQUIREDSCRIPTS .EXTERNALSCRIPTDEPENDENCIES .RELEASENOTES Standalone REST client with CSV and local hardware hash import, PowerShell 5.1 compatibility, automatic Az.Accounts installation, and status polling. .PRIVATEDATA #> #Requires -Version 5.1 # Project-Version: 1.1.20260914.3 # Author: andreas.lucas@microsoft.com (aka Kili) <# .SYNOPSIS Imports Windows Autopilot devices through the secured REST API. .DESCRIPTION Reads the public runtime configuration from an Autopilot Import application, validates device data from a CSV file or collects the local device hardware hash, and submits each device directly to the secured REST API. The script imports without a confirmation prompt and polls every 10 seconds until Intune reports the import and Entra device attribute processing as complete or the import reaches an error state. Use -Verbose for additional configuration, authentication, request, and polling details. The script uses Az.Accounts only to acquire the signed-in user's API token. If the required commands are unavailable, it installs Az.Accounts from the PowerShell Gallery for the current user. It does not load or require AutopilotImport.Client. .PARAMETER ApplicationUrl HTTPS URL of the Autopilot Import application. The Function App root, /api/ui, and the complete /api/ui/index.html URL are accepted. .PARAMETER CsvPath Optional path to a CSV containing Device Serial Number and Hardware Hash. When omitted, the script reads both values from the local Windows device. Local hash collection requires an elevated PowerShell session. .PARAMETER GroupTag Autopilot Group Tag requested for every device. The signed-in user must be authorized to use this tag. .PARAMETER ValidateOnly Validates the application configuration and device data without signing in or sending an import request. .EXAMPLE .\Import-AutopilotDevice.ps1 ` -ApplicationUrl 'https://autopilot.contoso.com' ` -GroupTag 'PAW' Collects the local device hash, imports it without a confirmation prompt, and displays an updated status every 10 seconds until processing finishes. .EXAMPLE .\Import-AutopilotDevice.ps1 ` -ApplicationUrl 'https://autopilot.contoso.com/api/ui/index.html' ` -CsvPath '.\devices.csv' ` -GroupTag 'PAW' ` -ValidateOnly Validates the application and CSV without authentication or an import. .EXAMPLE .\Import-AutopilotDevice.ps1 ` -ApplicationUrl 'https://autopilot.contoso.com' ` -CsvPath '.\devices.csv' ` -GroupTag 'PAW' ` -WhatIf Shows the proposed import without authentication or an import request. .OUTPUTS ValidateOnly returns a validation summary. A completed import returns the final REST status response for each device. #> [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Low')] param( [Parameter(Mandatory)] [ValidateNotNullOrEmpty()] [string] $ApplicationUrl, [ValidateScript({ Test-Path -LiteralPath $_ -PathType Leaf })] [string] $CsvPath, [Parameter(Mandatory)] [ValidateLength(1, 128)] [string] $GroupTag, [switch] $ValidateOnly ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $pollIntervalSeconds = 10 # Windows PowerShell 5.1 may otherwise negotiate an obsolete Gallery protocol. [Net.ServicePointManager]::SecurityProtocol = ` [Net.ServicePointManager]::SecurityProtocol -bor ` [Net.SecurityProtocolType]::Tls12 function Resolve-AutoPilotApplicationUrl { <# .SYNOPSIS Normalizes an Autopilot Import application URL. .DESCRIPTION Validates that the supplied URL uses HTTPS and converts a Function App root or /api/ui URL to the canonical /api/ui/index.html URL. .PARAMETER Url Function App root, /api/ui, or /api/ui/index.html URL to normalize. .OUTPUTS System.Uri. The canonical application page URL without query or fragment. #> param([Parameter(Mandatory)][string] $Url) $parsedUrl = $null if (-not [uri]::TryCreate($Url.Trim(), [UriKind]::Absolute, [ref] $parsedUrl) -or $parsedUrl.Scheme -ne 'https') { throw 'ApplicationUrl must be an absolute HTTPS URL.' } # Normalize every supported entry URL to the page beside the public config. $builder = [UriBuilder]::new($parsedUrl) if ($builder.Path -eq '/' -or [string]::IsNullOrWhiteSpace($builder.Path)) { $builder.Path = '/api/ui/index.html' } elseif ($builder.Path.TrimEnd('/') -eq '/api/ui') { $builder.Path = '/api/ui/index.html' } elseif (-not $builder.Path.EndsWith( '/api/ui/index.html', [StringComparison]::OrdinalIgnoreCase)) { throw 'ApplicationUrl must identify the Function App root, /api/ui, or /api/ui/index.html.' } $builder.Query = '' $builder.Fragment = '' return $builder.Uri } function Get-AutoPilotRuntimeConfiguration { <# .SYNOPSIS Reads and validates the public application configuration. .DESCRIPTION Requests the frontend configuration endpoint and validates its Entra authority, DeviceHash.Import scope, tenant ID, and same-origin import URL. .PARAMETER ApplicationUri Canonical /api/ui/index.html URL of the Autopilot Import application. .OUTPUTS System.Management.Automation.PSCustomObject. Validated application URL, configuration URL, import URL, API audience, and tenant ID. #> param([Parameter(Mandatory)][uri] $ApplicationUri) # The frontend publishes tenant, audience, and endpoint data without secrets. $configurationUrl = [uri]::new($ApplicationUri, './config') Write-Verbose "Reading runtime configuration from '$configurationUrl'." try { $configuration = Invoke-RestMethod ` -Method Get ` -Uri $configurationUrl ` -ErrorAction Stop } catch { throw "Could not read the Autopilot Import application configuration from '$configurationUrl'. $($_.Exception.Message)" } # Expected value: api://<36-character application GUID>/DeviceHash.Import # ^ and $ require the expression to match the complete scope value. # (?<audience>...) captures the token audience without the delegated scope. # (?<clientId>...) captures the application GUID inside that audience. # [0-9a-fA-F-]{36} allows exactly 36 GUID characters; TryParse below then # verifies their required GUID structure. \. escapes the dot so it matches # a literal period instead of the regex wildcard. $scope = [string] $configuration.scope if ($scope -notmatch '^(?<audience>api://(?<clientId>[0-9a-fA-F-]{36}))/DeviceHash\.Import$') { throw 'The application configuration does not contain a valid DeviceHash.Import scope.' } $audience = [string] $Matches.audience $clientId = [string] $Matches.clientId $parsedClientId = [guid]::Empty if (-not [guid]::TryParse($clientId, [ref] $parsedClientId)) { throw 'The DeviceHash.Import scope does not contain a valid application GUID.' } $authority = $null if (-not [uri]::TryCreate( [string] $configuration.authority, [UriKind]::Absolute, [ref] $authority) -or $authority.Scheme -ne 'https' -or $authority.Host -ne 'login.microsoftonline.com') { throw 'The application configuration does not contain a valid Microsoft Entra authority.' } $tenantId = $authority.AbsolutePath.Trim('/') $parsedTenantId = [guid]::Empty if (-not [guid]::TryParse($tenantId, [ref] $parsedTenantId)) { throw 'The Microsoft Entra authority does not contain a tenant GUID.' } # Keep authenticated device data on the same origin selected by the user. $importUri = $null if (-not [uri]::TryCreate( [string] $configuration.importUrl, [UriKind]::Absolute, [ref] $importUri) -or $importUri.Scheme -ne 'https' -or $importUri.GetLeftPart([UriPartial]::Authority) -ne $ApplicationUri.GetLeftPart([UriPartial]::Authority)) { throw 'The application configuration contains an invalid import URL.' } Write-Verbose "Validated runtime configuration for tenant '$($parsedTenantId.ToString())' and import endpoint '$($importUri.AbsoluteUri)'." return [pscustomobject]@{ ApplicationUrl = $ApplicationUri.AbsoluteUri ConfigurationUrl = $configurationUrl.AbsoluteUri ImportUrl = $importUri.AbsoluteUri ApiAudience = $audience TenantId = $parsedTenantId.ToString() } } function ConvertTo-AutoPilotDeviceRecord { <# .SYNOPSIS Validates and creates an Autopilot device record. .DESCRIPTION Verifies that a serial number is present and that the hardware hash is a non-empty Base64 value, then creates the normalized internal record. .PARAMETER SerialNumber Device serial number to validate and trim. .PARAMETER HardwareHash Base64-encoded Windows Autopilot hardware hash. .PARAMETER Source Human-readable data source included in validation error messages. .OUTPUTS System.Management.Automation.PSCustomObject. Validated serial number, hardware hash, and source description. #> param( [Parameter(Mandatory)][string] $SerialNumber, [Parameter(Mandatory)][string] $HardwareHash, [Parameter(Mandatory)][string] $Source ) if ([string]::IsNullOrWhiteSpace($SerialNumber)) { throw "$Source does not contain a device serial number." } if ([string]::IsNullOrWhiteSpace($HardwareHash)) { throw "$Source does not contain a hardware hash." } # Decoding verifies the hash format without changing the submitted value. try { $hashBytes = [Convert]::FromBase64String($HardwareHash) } catch { throw "$Source contains an invalid Base64 hardware hash." } if ($hashBytes.Length -eq 0) { throw "$Source contains an empty hardware hash." } return [pscustomobject]@{ SerialNumber = $SerialNumber.Trim() HardwareHash = $HardwareHash Source = $Source } } function Get-AutoPilotCsvDevice { <# .SYNOPSIS Reads Autopilot device records from a CSV file. .DESCRIPTION Verifies that the CSV is not empty and contains Device Serial Number and Hardware Hash columns, then validates every row as an Autopilot device. .PARAMETER Path Path to the Windows Autopilot CSV file. .OUTPUTS System.Management.Automation.PSCustomObject[]. Validated device records in the same order as the CSV rows. #> param([Parameter(Mandatory)][string] $Path) $csvFile = Get-Item -LiteralPath $Path if ($csvFile.Length -eq 0) { throw "The Autopilot CSV file '$($csvFile.FullName)' is empty." } $rows = @(Import-Csv -LiteralPath $csvFile.FullName) if ($rows.Count -eq 0) { throw 'The Autopilot CSV does not contain any devices.' } $headers = @($rows[0].PSObject.Properties.Name) $missingColumns = @( 'Device Serial Number', 'Hardware Hash' | Where-Object { $_ -notin $headers } ) if ($missingColumns.Count -gt 0) { throw "The Autopilot CSV is missing required columns: $($missingColumns -join ', ')." } return @( for ($rowIndex = 0; $rowIndex -lt $rows.Count; $rowIndex++) { ConvertTo-AutoPilotDeviceRecord ` -SerialNumber ([string] $rows[$rowIndex].'Device Serial Number') ` -HardwareHash ([string] $rows[$rowIndex].'Hardware Hash') ` -Source "CSV row $($rowIndex + 2)" } ) } function Get-LocalAutoPilotDevice { <# .SYNOPSIS Reads the local Windows Autopilot device identity. .DESCRIPTION Requires an elevated Windows session, reads the BIOS serial number and the hardware hash from the MDM Bridge WMI provider, and validates both values. .OUTPUTS System.Management.Automation.PSCustomObject. The validated local device record. #> if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Local Autopilot hardware hash collection is supported only on Windows.' } $identity = [Security.Principal.WindowsIdentity]::GetCurrent() $principal = [Security.Principal.WindowsPrincipal]::new($identity) if (-not $principal.IsInRole( [Security.Principal.WindowsBuiltInRole]::Administrator)) { throw 'Run this script from an elevated PowerShell session to collect the local hardware hash.' } # Autopilot hardware hashes are exposed through the Windows MDM Bridge WMI provider. $serialNumber = [string] (Get-CimInstance ` -ClassName Win32_BIOS ` -ErrorAction Stop).SerialNumber $deviceDetail = Get-CimInstance ` -Namespace 'root/cimv2/mdm/dmmap' ` -ClassName 'MDM_DevDetail_Ext01' ` -Filter "InstanceID='Ext' AND ParentID='./DevDetail'" ` -ErrorAction Stop return ConvertTo-AutoPilotDeviceRecord ` -SerialNumber $serialNumber ` -HardwareHash ([string] $deviceDetail.DeviceHardwareData) ` -Source 'The local Windows device' } function Install-AutoPilotAzAccounts { <# .SYNOPSIS Ensures that the required Az.Accounts commands are available. .DESCRIPTION Checks for the Az.Accounts authentication commands. When they are missing, bootstraps the NuGet provider if necessary and installs Az.Accounts from the PowerShell Gallery for the current user. .OUTPUTS None. #> # Command checks also recognize an already loaded or auto-discoverable module. $requiredCommands = @( 'Get-AzContext' 'Connect-AzAccount' 'Get-AzAccessToken' ) $missingCommands = @( $requiredCommands | Where-Object { -not (Get-Command $_ -ErrorAction SilentlyContinue) } ) if ($missingCommands.Count -eq 0) { return } if (-not (Get-Command Install-Module -ErrorAction SilentlyContinue)) { throw 'Az.Accounts is required, but PowerShellGet is not available. Install PowerShellGet and Az.Accounts for the current user, then try again.' } Write-Host 'Az.Accounts is not installed. Installing it from the PowerShell Gallery for the current user...' try { # Older Windows PowerShell installations may not have bootstrapped NuGet yet. if ((Get-Command Get-PackageProvider -ErrorAction SilentlyContinue) -and (Get-Command Install-PackageProvider -ErrorAction SilentlyContinue) -and -not (Get-PackageProvider -Name NuGet -ListAvailable -ErrorAction SilentlyContinue)) { Install-PackageProvider ` -Name NuGet ` -MinimumVersion '2.8.5.201' ` -Scope CurrentUser ` -Force | Out-Null } Install-Module ` -Name Az.Accounts ` -MinimumVersion '2.2.0' ` -Scope CurrentUser ` -Repository PSGallery ` -AllowClobber ` -Force ` -ErrorAction Stop Import-Module Az.Accounts -MinimumVersion '2.2.0' -Force -ErrorAction Stop } catch { throw "Az.Accounts could not be installed for the current user. $($_.Exception.Message)" } $missingCommands = @( $requiredCommands | Where-Object { -not (Get-Command $_ -ErrorAction SilentlyContinue) } ) if ($missingCommands.Count -gt 0) { throw "Az.Accounts was installed, but required commands are unavailable: $($missingCommands -join ', '). Close PowerShell, start it again, and retry." } } function ConvertFrom-AutoPilotSecureString { <# .SYNOPSIS Converts a SecureString access token to plain text. .DESCRIPTION Uses protected BSTR marshalling for Windows PowerShell 5.1 compatibility and clears the unmanaged buffer after the token has been copied. .PARAMETER SecureString SecureString value returned by Az.Accounts. .OUTPUTS System.String. The plain-text access token used in the Authorization header. #> param([Parameter(Mandatory)][Security.SecureString] $SecureString) # Windows PowerShell 5.1 lacks the newer plain-text SecureString switch. $buffer = [IntPtr]::Zero try { $buffer = [Runtime.InteropServices.Marshal]::SecureStringToBSTR( $SecureString) return [Runtime.InteropServices.Marshal]::PtrToStringBSTR($buffer) } finally { if ($buffer -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($buffer) } } } function Get-AutoPilotAccessToken { <# .SYNOPSIS Acquires an access token for the Autopilot Import API. .DESCRIPTION Ensures Az.Accounts is available, signs in to the requested tenant when the current context does not match, and requests a token for the API audience. .PARAMETER TenantId Microsoft Entra tenant GUID used for interactive authentication. .PARAMETER ApiAudience Application ID URI for which the access token is requested. .OUTPUTS System.String. A plain-text bearer token. #> param( [Parameter(Mandatory)][string] $TenantId, [Parameter(Mandatory)][string] $ApiAudience ) Install-AutoPilotAzAccounts $context = Get-AzContext -ErrorAction SilentlyContinue if (-not $context -or [string] $context.Tenant.Id -ne $TenantId) { Write-Verbose "Signing in to Microsoft Entra tenant '$TenantId'." Connect-AzAccount -Tenant $TenantId | Out-Null } else { Write-Verbose "Using the existing Azure context for tenant '$TenantId'." } Write-Verbose "Requesting an access token for '$ApiAudience'." $tokenResult = Get-AzAccessToken -ResourceUrl $ApiAudience # Az.Accounts versions return either a SecureString or a plain token string. if ($tokenResult.Token -is [Security.SecureString]) { return ConvertFrom-AutoPilotSecureString ` -SecureString $tokenResult.Token } return [string] $tokenResult.Token } function Get-AutoPilotRestErrorMessage { <# .SYNOPSIS Creates a concise message from a REST API error. .DESCRIPTION Reads a JSON error response across Windows PowerShell 5.1 and PowerShell 7, maps known service errors to actionable guidance, and writes the correlation ID to the Verbose stream for diagnostics. .PARAMETER ErrorRecord PowerShell error record raised by Invoke-RestMethod. .PARAMETER GroupTag Group Tag requested by the failed import. .OUTPUTS System.String. A user-facing REST error description. #> param( [Parameter(Mandatory)] [System.Management.Automation.ErrorRecord] $ErrorRecord, [Parameter(Mandatory)] [string] $GroupTag ) $response = $null $responseText = if ($ErrorRecord.ErrorDetails -and $ErrorRecord.ErrorDetails.Message) { [string] $ErrorRecord.ErrorDetails.Message } else { '' } $httpResponse = if ($ErrorRecord.Exception.PSObject.Properties['Response']) { $ErrorRecord.Exception.Response } else { $null } if ([string]::IsNullOrWhiteSpace($responseText) -and $httpResponse) { try { $contentProperty = $httpResponse.PSObject.Properties['Content'] if ($contentProperty -and $contentProperty.Value -and $contentProperty.Value.PSObject.Methods['ReadAsStringAsync']) { $responseText = ` $contentProperty.Value.ReadAsStringAsync().Result } elseif ($httpResponse.PSObject.Methods['GetResponseStream']) { $responseStream = $httpResponse.GetResponseStream() if ($responseStream) { $reader = [IO.StreamReader]::new($responseStream) try { $responseText = $reader.ReadToEnd() } finally { $reader.Dispose() } } } } catch { $responseText = '' } } if (-not [string]::IsNullOrWhiteSpace($responseText)) { try { $response = $responseText | ConvertFrom-Json } catch { $response = $null } } $errorCode = if ($response -and $response.PSObject.Properties['error']) { [string] $response.error } else { '' } $serverMessage = if ($response -and $response.PSObject.Properties['message']) { [string] $response.message } else { '' } $statusCode = if ($httpResponse -and $httpResponse.PSObject.Properties['StatusCode']) { [int] $httpResponse.StatusCode } elseif ($ErrorRecord.Exception.Message -match '(?<!\d)403(?!\d)') { 403 } else { 0 } $message = switch ($errorCode) { 'groupTagNotAllowed' { "Group Tag '$GroupTag' is not allowed for the signed-in user. Verify the tag and the user's Entra group assignments." } 'authenticationRequired' { 'Authentication is required. Sign in again and retry the import.' } 'invalidPrincipal' { 'The signed-in identity could not be validated by the service. Sign in again and retry the import.' } 'invalidRequest' { if ([string]::IsNullOrWhiteSpace($serverMessage)) { 'The import request is invalid.' } else { "The import request is invalid: $serverMessage" } } 'graphImportFailed' { 'Microsoft Graph rejected or could not complete the Autopilot import. Contact the service administrator.' } 'serviceNotConfigured' { 'The Autopilot import service is not configured correctly. Contact the service administrator.' } default { if ($statusCode -eq 403) { "Group Tag '$GroupTag' is not allowed for the signed-in user. Verify the tag and the user's Entra group assignments." } elseif (-not [string]::IsNullOrWhiteSpace($serverMessage)) { $serverMessage } else { $ErrorRecord.Exception.Message } } } $correlationId = if ($response -and $response.PSObject.Properties['correlationId']) { [string] $response.correlationId } else { '' } if ([string]::IsNullOrWhiteSpace($correlationId) -and $httpResponse -and $httpResponse.PSObject.Properties['Headers']) { try { $headerValues = $null if ($httpResponse.Headers.PSObject.Methods['TryGetValues'] -and $httpResponse.Headers.TryGetValues( 'X-Correlation-Id', [ref] $headerValues)) { $correlationId = [string] (@($headerValues) | Select-Object -First 1) } else { $correlationId = [string] ` $httpResponse.Headers['X-Correlation-Id'] } } catch { $correlationId = '' } } if (-not [string]::IsNullOrWhiteSpace($correlationId)) { Write-Verbose "Correlation ID: $correlationId." } return $message } function Write-AutoPilotImportStatus { <# .SYNOPSIS Displays the current status of an Autopilot import. .DESCRIPTION Writes a timestamped status line containing the Intune import, workflow, and Entra extension-attribute processing states. .PARAMETER Status REST status response returned for an accepted import. .OUTPUTS None. Status information is written to the host. #> param([Parameter(Mandatory)][object] $Status) $extensionStatus = if ($Status.PSObject.Properties['extensionAttributeStatus']) { [string] $Status.extensionAttributeStatus } else { 'pending' } Write-Host ('[{0}] {1}: import={2}, workflow={3}, device attribute={4}' -f ` (Get-Date -Format 'HH:mm:ss'), [string] $Status.serialNumber, [string] $Status.status, [string] $Status.workflowStatus, $extensionStatus) } $applicationUri = Resolve-AutoPilotApplicationUrl -Url $ApplicationUrl Write-Verbose "Resolved application URL to '$($applicationUri.AbsoluteUri)'." $runtimeConfiguration = Get-AutoPilotRuntimeConfiguration ` -ApplicationUri $applicationUri # The outer array prevents one CSV row from becoming a scalar under PS 5.1. $devices = @( if ($PSBoundParameters.ContainsKey('CsvPath')) { Get-AutoPilotCsvDevice -Path $CsvPath } else { Get-LocalAutoPilotDevice } ) Write-Verbose "Validated $($devices.Count) device record(s) from $(if ($CsvPath) { "CSV '$((Resolve-Path -LiteralPath $CsvPath).Path)'" } else { 'the local Windows device' })." if ($ValidateOnly) { Write-Verbose 'Validation completed; authentication and import were skipped.' return [pscustomobject]@{ ApplicationUrl = $runtimeConfiguration.ApplicationUrl ImportUrl = $runtimeConfiguration.ImportUrl TenantId = $runtimeConfiguration.TenantId DeviceSource = if ($CsvPath) { (Resolve-Path -LiteralPath $CsvPath).Path } else { 'Local device' } DeviceCount = $devices.Count SerialNumbers = @($devices.SerialNumber) GroupTag = $GroupTag IsValid = $true } } $target = "$($devices.Count) device(s) using '$($runtimeConfiguration.ApplicationUrl)'" # Low impact preserves -WhatIf without prompting under PowerShell defaults. if (-not $PSCmdlet.ShouldProcess($target, "Import with Group Tag '$GroupTag'")) { return } Write-Verbose "Starting import of $($devices.Count) device(s) with Group Tag '$GroupTag'." $token = Get-AutoPilotAccessToken ` -TenantId $runtimeConfiguration.TenantId ` -ApiAudience $runtimeConfiguration.ApiAudience $authorizationHeaders = @{ Authorization = "Bearer $token" } $tokenAcquiredAt = [datetimeoffset]::Now $pendingImports = [Collections.Generic.List[object]]::new() foreach ($device in $devices) { $body = @{ serialNumber = $device.SerialNumber hardwareIdentifier = $device.HardwareHash groupTag = $GroupTag } | ConvertTo-Json -Compress try { Write-Verbose "Submitting import request for serial '$($device.SerialNumber)'." $response = Invoke-RestMethod ` -Method Post ` -Uri $runtimeConfiguration.ImportUrl ` -Headers $authorizationHeaders ` -ContentType 'application/json' ` -Body $body ` -ErrorAction Stop } catch { $errorMessage = Get-AutoPilotRestErrorMessage ` -ErrorRecord $_ ` -GroupTag $GroupTag Write-Host ` "Import failed for serial '$($device.SerialNumber)'. $errorMessage" ` -ForegroundColor Red exit 1 } $pendingImports.Add([pscustomobject]@{ ImportId = [guid] $response.importId SerialNumber = $device.SerialNumber LastStatus = $response }) Write-Host "Import accepted for '$($device.SerialNumber)' with ID '$($response.importId)'." } $finalStatuses = [Collections.Generic.List[object]]::new() while ($pendingImports.Count -gt 0) { # Poll all accepted imports until backend and extension-attribute work is terminal. Write-Verbose "Waiting $pollIntervalSeconds seconds before polling $($pendingImports.Count) pending import(s)." Start-Sleep -Seconds $pollIntervalSeconds # Refresh before typical access-token expiry during unusually long imports. if (([datetimeoffset]::Now - $tokenAcquiredAt).TotalMinutes -ge 45) { Write-Verbose 'Refreshing the API access token before polling continues.' $token = Get-AutoPilotAccessToken ` -TenantId $runtimeConfiguration.TenantId ` -ApiAudience $runtimeConfiguration.ApiAudience $authorizationHeaders.Authorization = "Bearer $token" $tokenAcquiredAt = [datetimeoffset]::Now } foreach ($pendingImport in @($pendingImports)) { $statusUrl = "$($runtimeConfiguration.ImportUrl)?importId=$($pendingImport.ImportId)" try { Write-Verbose "Requesting status for import '$($pendingImport.ImportId)' and serial '$($pendingImport.SerialNumber)'." $status = Invoke-RestMethod ` -Method Get ` -Uri $statusUrl ` -Headers $authorizationHeaders ` -ErrorAction Stop } catch { Write-Warning "Status request for '$($pendingImport.SerialNumber)' failed and will be retried in $pollIntervalSeconds seconds. $(Get-AutoPilotRestErrorMessage -ErrorRecord $_ -GroupTag $GroupTag)" continue } $pendingImport.LastStatus = $status Write-AutoPilotImportStatus -Status $status if (([string] $status.workflowStatus) -in @('complete', 'error')) { $finalStatuses.Add($status) [void] $pendingImports.Remove($pendingImport) } } } $failedStatuses = @( $finalStatuses | Where-Object { [string] $_.workflowStatus -eq 'error' } ) $finalStatuses | Write-Output if ($failedStatuses.Count -gt 0) { $failedSerials = @($failedStatuses.serialNumber) -join ', ' throw "Autopilot import failed for: $failedSerials. Review deviceErrorCode and deviceErrorName in the service response." } |