From 7b8fe75778674cb6154d022727ad7cfdfa5b6acc Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Tue, 1 Sep 2026 08:34:59 -0700 Subject: [PATCH 01/11] Add retry logic for transient network errors in manage-payg-transition.ps1 - Add Invoke-AzCliArgsWithRetry/Invoke-AzCmdletWithRetry helpers that retry transient network failures (socket exhaustion, HttpRequestException, timeouts) with backoff. - Use retry wrapper in Invoke-AzCliLicenseUpdate and Invoke-AzCliQuery. - Fix DataFactory SSIS section: Set-AzContext/Get-AzDataFactoryV2/ Get-AzDataFactoryV2IntegrationRuntime now use -ErrorAction Stop + retry so a transient failure is no longer silently mistaken for 'no integration runtimes found' or left running against the wrong subscription context. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 140 +++++++++++++++--- 1 file changed, 122 insertions(+), 18 deletions(-) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 500ea581b2..db057c03a8 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -337,6 +337,72 @@ function Connect-Azure { Set for commands that accept --no-wait. 'az sql vm update' does not; SQL VMs are submitted asynchronously through Invoke-SqlVmLicenseUpdate instead. #> + +# Matches transient network failures observed in practice (e.g. Windows ephemeral +# port exhaustion - WinError 10048 - and generic HttpRequestExceptions/connection +# resets from Azure CLI or Az PowerShell). These are environment/network blips, not +# problems with the request itself, so a short retry resolves most of them instead +# of permanently marking an otherwise-valid resource update as "Failed". +$script:TransientErrorPattern = 'socket|10048|underlying connection|connection was closed|forcibly closed|timed? ?out|temporarily unavailable|An error occurred while sending the request|could not be resolved|(?&1 + if ($LASTEXITCODE -eq 0) { + return [PSCustomObject]@{ Output = $output; ExitCode = 0 } + } + + $message = ($output | Out-String).Trim() + $isTransient = $message -match $script:TransientErrorPattern + if (-not $isTransient -or $attempt -eq $MaxAttempts) { + return [PSCustomObject]@{ Output = $output; ExitCode = $LASTEXITCODE } + } + + Write-Warning "Transient network error on attempt $attempt/$MaxAttempts for $Description`: $message. Retrying in $DelaySeconds s..." + Start-Sleep -Seconds $DelaySeconds + } +} + +<# +.SYNOPSIS + Runs an Az PowerShell cmdlet (via scriptblock) and retries it on transient network errors. +.DESCRIPTION + Companion to Invoke-AzCliArgsWithRetry for Az PowerShell cmdlets, which signal failure by + throwing rather than through $LASTEXITCODE. Callers should pass -ErrorAction Stop inside the + scriptblock so failures are terminating and therefore retryable/catchable here; otherwise a + transient error is written as a non-terminating error and silently treated as an empty result + by the caller (exactly the failure mode this script's other helpers were written to avoid). +#> +function Invoke-AzCmdletWithRetry { + param( + [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock, + [Parameter(Mandatory = $true)][string]$Description, + [int]$MaxAttempts = 3, + [int]$DelaySeconds = 5 + ) + + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + return & $ScriptBlock + } + catch { + $isTransient = $_.Exception.Message -match $script:TransientErrorPattern + if (-not $isTransient -or $attempt -eq $MaxAttempts) { + throw + } + Write-Warning "Transient network error on attempt $attempt/$MaxAttempts for $Description`: $($_.Exception.Message). Retrying in $DelaySeconds s..." + Start-Sleep -Seconds $DelaySeconds + } + } +} + function Invoke-AzCliLicenseUpdate { param( [Parameter(Mandatory = $true)][string[]]$Arguments, @@ -351,9 +417,10 @@ function Invoke-AzCliLicenseUpdate { $submittedOnly = $true } - $output = & az @effectiveArgs 2>&1 + $attemptResult = Invoke-AzCliArgsWithRetry -Arguments $effectiveArgs -Description $Description + $output = $attemptResult.Output - if ($LASTEXITCODE -ne 0) { + if ($attemptResult.ExitCode -ne 0) { $message = ($output | Out-String).Trim() Write-Warning "Failed to update $Description`: $message" return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message; Submitted = $submittedOnly } @@ -392,9 +459,10 @@ function Invoke-AzCliQuery { [Parameter(Mandatory = $true)][string]$Description ) - $output = & az @Arguments 2>&1 + $attemptResult = Invoke-AzCliArgsWithRetry -Arguments $Arguments -Description $Description + $output = $attemptResult.Output - if ($LASTEXITCODE -ne 0) { + if ($attemptResult.ExitCode -ne 0) { $message = ($output | Out-String).Trim() Write-Warning "Unable to query $Description`: $message" return [PSCustomObject]@{ Success = $false; Value = @(); ErrorMessage = $message } @@ -1302,26 +1370,62 @@ foreach ($sub in $subscriptions) { # --- Section: Update DataFactory SSIS Integration Runtimes --- try { Write-Output "Processing DataFactory SSIS Integration Runtime resources..." - Set-AzContext -Subscription $sub.id | Out-Null - Get-AzDataFactoryV2 | + # -ErrorAction Stop + retry: previously a transient network blip here (e.g. WinError + # 10048 / HttpRequestException) was a non-terminating error that got printed but not + # caught, so the script silently kept running DataFactory discovery against whatever + # subscription context was already selected instead of the intended one. + Invoke-AzCmdletWithRetry -Description "Set-AzContext for subscription $($sub.id)" -ScriptBlock { + Set-AzContext -Subscription $sub.id -ErrorAction Stop | Out-Null + } + + $dataFactories = Invoke-AzCmdletWithRetry -Description "Get-AzDataFactoryV2 in subscription $($sub.id)" -ScriptBlock { + Get-AzDataFactoryV2 -ErrorAction Stop + } + + $dataFactories | Where-Object { $_.ProvisioningState -eq "Succeeded" -and ([string]::IsNullOrEmpty($ResourceGroup) -or $_.ResourceGroupName -eq $ResourceGroup) } | ForEach-Object { $df = $_ - $IRs = Get-AzDataFactoryV2IntegrationRuntime -ResourceGroupName $df.ResourceGroupName -DataFactoryName $df.DataFactoryName | - Where-Object { - $_.Type -eq "Managed" -and - $_.State -ne "Starting" -and - # Only SSIS integration runtimes carry a LicenseType. The default - # 'AutoResolveIntegrationRuntime' is also Type 'Managed' but has a null - # LicenseType; without this check it passes the filter below (since - # $null -ne $LicenseType) and the update fails with - # 'DataFactoryPropertyUpdateNotSupported: Updating property managedVirtualNetwork'. - (-not [string]::IsNullOrEmpty($_.LicenseType)) -and - $_.LicenseType -ne $LicenseType -and - ([string]::IsNullOrEmpty($ResourceName) -or $_.Name -eq $ResourceName) + $IRs = $null + try { + $IRs = Invoke-AzCmdletWithRetry -Description "Get-AzDataFactoryV2IntegrationRuntime on '$($df.DataFactoryName)'" -ScriptBlock { + Get-AzDataFactoryV2IntegrationRuntime -ResourceGroupName $df.ResourceGroupName -DataFactoryName $df.DataFactoryName -ErrorAction Stop | + Where-Object { + $_.Type -eq "Managed" -and + $_.State -ne "Starting" -and + # Only SSIS integration runtimes carry a LicenseType. The default + # 'AutoResolveIntegrationRuntime' is also Type 'Managed' but has a null + # LicenseType; without this check it passes the filter below (since + # $null -ne $LicenseType) and the update fails with + # 'DataFactoryPropertyUpdateNotSupported: Updating property managedVirtualNetwork'. + (-not [string]::IsNullOrEmpty($_.LicenseType)) -and + $_.LicenseType -ne $LicenseType -and + ([string]::IsNullOrEmpty($ResourceName) -or $_.Name -eq $ResourceName) + } + } + } + catch { + # A failed query used to be indistinguishable from "no runtimes need updating" + # (the pipeline just returned nothing), silently skipping this DataFactory. + # Report it as a failure instead so it isn't mistaken for a clean result. + $queryError = $_.Exception.Message + Write-Warning "Unable to query integration runtimes on DataFactory '$($df.DataFactoryName)': $queryError" + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = $sub.id + ResourceName = $df.DataFactoryName + ResourceType = "Microsoft.DataFactory/factories" + Status = $df.ProvisioningState + OriginalLicenseType = $null + ResourceGroup = $df.ResourceGroupName + Location = $df.Location + UpdateResult = "Failed" + UpdateError = "Failed to query integration runtimes: $queryError" + } + return } if ($null -eq $IRs -or @($IRs).Count -eq 0) { From 5c903b6db4af431d0a95dcbf347a6c1e01f2589f Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Tue, 1 Sep 2026 09:27:53 -0700 Subject: [PATCH 02/11] Retry and fail loudly on transient Get-AzSubscription errors Get-AzSubscription -SubscriptionId / Get-AzSubscription had no -ErrorAction Stop, so a transient HttpRequestException (seen repeatedly outside the dev environment) was a non-terminating error: \ stayed empty, the foreach loop over subscriptions ran zero times, and the script printed a clean-looking 'No resources were marked for modification' summary instead of surfacing the failure. Fixed in both the Arc and Azure sections: retry up to 3x with a 5s backoff, then exit 1 with a clear error if subscription resolution still fails, and abort if the resolved subscription list is empty for any other reason. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 58 +++++++++++++++++-- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index db057c03a8..c0e54e5218 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -731,13 +731,38 @@ $SqlVmLicenseType = if ($LicenseType -eq "LicenseIncluded") { "PAYG" } else { "A $modifiedResources = @() # Determine the subscriptions to process: CSV file, single subscription, or all accessible subscriptions. +# Get-AzSubscription occasionally fails with a transient HttpRequestException (e.g. network +# blips outside the usual dev environment). Previously this was a non-terminating error that +# left $subscriptions empty, so the script silently "completed" having scanned zero +# subscriptions instead of surfacing the failure. Retry a few times, then fail loudly. if ($SubId -like "*.csv") { $subscriptions = Import-Csv $SubId }elseif($SubId -ne "") { Write-Output "Passed Subscription $($SubId)" - $subscriptions = Get-AzSubscription -SubscriptionId $SubId + $subscriptions = $null + for ($subAttempt = 1; $subAttempt -le 3; $subAttempt++) { + try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -ErrorAction Stop; break } + catch { + if ($subAttempt -eq 3) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + Write-Warning "Transient error resolving subscription '$SubId' (attempt $subAttempt/3): $($_.Exception.Message). Retrying in 5s..." + Start-Sleep -Seconds 5 + } + } }else { - $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } + $subscriptions = $null + for ($subAttempt = 1; $subAttempt -le 3; $subAttempt++) { + try { $subscriptions = Get-AzSubscription -ErrorAction Stop | Where-Object { $_.TenantId -eq $tenantId }; break } + catch { + if ($subAttempt -eq 3) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + Write-Warning "Transient error listing subscriptions for tenant '$tenantId' (attempt $subAttempt/3): $($_.Exception.Message). Retrying in 5s..." + Start-Sleep -Seconds 5 + } + } +} + +if (-not $subscriptions -or @($subscriptions).Count -eq 0) { + Write-Error "No subscriptions resolved (SubId='$SubId', TenantId='$tenantId'). Aborting instead of proceeding with zero subscriptions, which would otherwise look like a clean 'nothing to update' run." + exit 1 } # Build resource group filter if specified. @@ -1981,13 +2006,38 @@ catch{ $modifiedResources = @() +# Get-AzSubscription occasionally fails with a transient HttpRequestException (e.g. network +# blips outside the usual dev environment). Previously this was a non-terminating error that +# left $subscriptions empty, so the script silently "completed" having scanned zero +# subscriptions instead of surfacing the failure. Retry a few times, then fail loudly. if ($SubId -like "*.csv") { $subscriptions = Import-Csv $SubId }elseif($SubId -ne "") { Write-Output "Passed Subscription $($SubId)" - $subscriptions = Get-AzSubscription -SubscriptionId $SubId + $subscriptions = $null + for ($subAttempt = 1; $subAttempt -le 3; $subAttempt++) { + try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -ErrorAction Stop; break } + catch { + if ($subAttempt -eq 3) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + Write-Warning "Transient error resolving subscription '$SubId' (attempt $subAttempt/3): $($_.Exception.Message). Retrying in 5s..." + Start-Sleep -Seconds 5 + } + } }else { - $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } + $subscriptions = $null + for ($subAttempt = 1; $subAttempt -le 3; $subAttempt++) { + try { $subscriptions = Get-AzSubscription -ErrorAction Stop | Where-Object { $_.TenantId -eq $tenantId }; break } + catch { + if ($subAttempt -eq 3) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + Write-Warning "Transient error listing subscriptions for tenant '$tenantId' (attempt $subAttempt/3): $($_.Exception.Message). Retrying in 5s..." + Start-Sleep -Seconds 5 + } + } +} + +if (-not $subscriptions -or @($subscriptions).Count -eq 0) { + Write-Error "No subscriptions resolved (SubId='$SubId', TenantId='$tenantId'). Aborting instead of proceeding with zero subscriptions, which would otherwise look like a clean 'nothing to update' run." + exit 1 } # Handle MachineName input (single or CSV) From bf665e662c17935786ffb4c6769dddbf85dcf3fa Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Tue, 1 Sep 2026 09:37:29 -0700 Subject: [PATCH 03/11] Extend Get-AzSubscription retry backoff to survive longer token-acquisition blips Real-world runs showed the Arc script exhausting 3 retries (5s each, ~10s total) on 'Unable to acquire token ... An error occurred while sending the request' immediately before the very next (Azure) invocation succeeded with no retry needed at all, confirming these are short-lived (10-30s) transient blips rather than a hard block. Increased to 5 attempts with increasing backoff (5/10/20/30/30s, ~95s total) in both the Arc and Azure sections so the script has a realistic chance to ride out the blip instead of exiting before it clears. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 44 +++++++++++-------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index c0e54e5218..34efa6fd93 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -734,28 +734,32 @@ $modifiedResources = @() # Get-AzSubscription occasionally fails with a transient HttpRequestException (e.g. network # blips outside the usual dev environment). Previously this was a non-terminating error that # left $subscriptions empty, so the script silently "completed" having scanned zero -# subscriptions instead of surfacing the failure. Retry a few times, then fail loudly. +# subscriptions instead of surfacing the failure. Retry a few times with increasing backoff +# (observed blips clear up within ~20-30s), then fail loudly. +$subRetryDelays = @(5, 10, 20, 30, 30) if ($SubId -like "*.csv") { $subscriptions = Import-Csv $SubId }elseif($SubId -ne "") { Write-Output "Passed Subscription $($SubId)" $subscriptions = $null - for ($subAttempt = 1; $subAttempt -le 3; $subAttempt++) { + for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -ErrorAction Stop; break } catch { - if ($subAttempt -eq 3) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } - Write-Warning "Transient error resolving subscription '$SubId' (attempt $subAttempt/3): $($_.Exception.Message). Retrying in 5s..." - Start-Sleep -Seconds 5 + if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + $delay = $subRetryDelays[$subAttempt - 1] + Write-Warning "Transient error resolving subscription '$SubId' (attempt $subAttempt/$($subRetryDelays.Count)): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay } } }else { $subscriptions = $null - for ($subAttempt = 1; $subAttempt -le 3; $subAttempt++) { + for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { try { $subscriptions = Get-AzSubscription -ErrorAction Stop | Where-Object { $_.TenantId -eq $tenantId }; break } catch { - if ($subAttempt -eq 3) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } - Write-Warning "Transient error listing subscriptions for tenant '$tenantId' (attempt $subAttempt/3): $($_.Exception.Message). Retrying in 5s..." - Start-Sleep -Seconds 5 + if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + $delay = $subRetryDelays[$subAttempt - 1] + Write-Warning "Transient error listing subscriptions for tenant '$tenantId' (attempt $subAttempt/$($subRetryDelays.Count)): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay } } } @@ -2009,28 +2013,32 @@ $modifiedResources = @() # Get-AzSubscription occasionally fails with a transient HttpRequestException (e.g. network # blips outside the usual dev environment). Previously this was a non-terminating error that # left $subscriptions empty, so the script silently "completed" having scanned zero -# subscriptions instead of surfacing the failure. Retry a few times, then fail loudly. +# subscriptions instead of surfacing the failure. Retry a few times with increasing backoff +# (observed blips clear up within ~20-30s), then fail loudly. +$subRetryDelays = @(5, 10, 20, 30, 30) if ($SubId -like "*.csv") { $subscriptions = Import-Csv $SubId }elseif($SubId -ne "") { Write-Output "Passed Subscription $($SubId)" $subscriptions = $null - for ($subAttempt = 1; $subAttempt -le 3; $subAttempt++) { + for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -ErrorAction Stop; break } catch { - if ($subAttempt -eq 3) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } - Write-Warning "Transient error resolving subscription '$SubId' (attempt $subAttempt/3): $($_.Exception.Message). Retrying in 5s..." - Start-Sleep -Seconds 5 + if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + $delay = $subRetryDelays[$subAttempt - 1] + Write-Warning "Transient error resolving subscription '$SubId' (attempt $subAttempt/$($subRetryDelays.Count)): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay } } }else { $subscriptions = $null - for ($subAttempt = 1; $subAttempt -le 3; $subAttempt++) { + for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { try { $subscriptions = Get-AzSubscription -ErrorAction Stop | Where-Object { $_.TenantId -eq $tenantId }; break } catch { - if ($subAttempt -eq 3) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } - Write-Warning "Transient error listing subscriptions for tenant '$tenantId' (attempt $subAttempt/3): $($_.Exception.Message). Retrying in 5s..." - Start-Sleep -Seconds 5 + if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + $delay = $subRetryDelays[$subAttempt - 1] + Write-Warning "Transient error listing subscriptions for tenant '$tenantId' (attempt $subAttempt/$($subRetryDelays.Count)): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay } } } From 455392f25abcc75b4d0f9f469d2fc2f5eccd69ee Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Tue, 1 Sep 2026 09:42:34 -0700 Subject: [PATCH 04/11] Scope Get-AzSubscription to the requested tenant only Get-AzSubscription was being called without -TenantId, so for a multi-tenant signed-in account (guest access to other tenants), it fanned out and tried to acquire a token for *every* tenant the account belongs to in order to resolve the subscription -> tenant mapping. This produced unrelated 'Authentication failed against tenant ... conditional access ... MFA' warnings for guest tenants even when a specific -TenantId was passed to the script, and made the whole call more fragile since it depended on tenants the user never intended to touch. Added -TenantId \ to both Get-AzSubscription call sites (single-subscription and list-all) in both the Arc and Azure sections, scoping resolution strictly to the requested tenant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 34efa6fd93..ec4568926c 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -743,7 +743,12 @@ if ($SubId -like "*.csv") { Write-Output "Passed Subscription $($SubId)" $subscriptions = $null for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { - try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -ErrorAction Stop; break } + # -TenantId scopes resolution to the requested tenant only. Without it, Get-AzSubscription + # fans out and tries to acquire a token for *every* tenant the signed-in account belongs + # to (including unrelated guest tenants requiring MFA/conditional access), which is why + # unrelated 'Authentication failed against tenant ...' warnings were showing up even + # though a specific -TenantId was passed to the script. + try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -TenantId $TenantId -ErrorAction Stop; break } catch { if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } $delay = $subRetryDelays[$subAttempt - 1] @@ -754,7 +759,7 @@ if ($SubId -like "*.csv") { }else { $subscriptions = $null for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { - try { $subscriptions = Get-AzSubscription -ErrorAction Stop | Where-Object { $_.TenantId -eq $tenantId }; break } + try { $subscriptions = Get-AzSubscription -TenantId $TenantId -ErrorAction Stop; break } catch { if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } $delay = $subRetryDelays[$subAttempt - 1] @@ -2022,7 +2027,12 @@ if ($SubId -like "*.csv") { Write-Output "Passed Subscription $($SubId)" $subscriptions = $null for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { - try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -ErrorAction Stop; break } + # -TenantId scopes resolution to the requested tenant only. Without it, Get-AzSubscription + # fans out and tries to acquire a token for *every* tenant the signed-in account belongs + # to (including unrelated guest tenants requiring MFA/conditional access), which is why + # unrelated 'Authentication failed against tenant ...' warnings were showing up even + # though a specific -TenantId was passed to the script. + try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -TenantId $TenantId -ErrorAction Stop; break } catch { if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } $delay = $subRetryDelays[$subAttempt - 1] @@ -2033,7 +2043,7 @@ if ($SubId -like "*.csv") { }else { $subscriptions = $null for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { - try { $subscriptions = Get-AzSubscription -ErrorAction Stop | Where-Object { $_.TenantId -eq $tenantId }; break } + try { $subscriptions = Get-AzSubscription -TenantId $TenantId -ErrorAction Stop; break } catch { if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } $delay = $subRetryDelays[$subAttempt - 1] From cb40f2268c1aa67729576359c80dcb2e2f252b0a Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Tue, 1 Sep 2026 09:54:55 -0700 Subject: [PATCH 05/11] Fix missing prerequisite handling (Az.ConnectedMachine, Az.ResourceGraph, az CLI) - Arc section: auto-install Az.ConnectedMachine/Az.ResourceGraph on demand (mirroring the existing Az.DataFactory pattern) instead of silently continuing after a failed Import-Module, which left Search-AzGraph undefined and crashed later with an unrelated ArgumentNullException. - Guard Search-AzGraph's result against \ before AddRange to avoid 'Value cannot be null' if the module still can't be resolved. - Azure section: fail fast with an actionable error if the 'az' CLI isn't installed, instead of letting every downstream az call fail later with a confusing 'term az is not recognized' error mid-run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 53 ++++++++++++------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index ec4568926c..bcc5b85bb5 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -316,6 +316,15 @@ function Connect-Azure { } Write-Output "Azure CLI logged in as: $($acct.user.name)" } + else { + # The rest of this script relies on the Azure CLI (Invoke-AzCliQuery / + # Invoke-AzCliLicenseUpdate) to enumerate and update SQL Servers, Databases, + # and Managed Instances. Failing loudly here - instead of letting each + # downstream 'az' call fail later with a confusing "term not recognized" + # error - saves time and gives the user a clear, actionable fix. + Write-Error "Azure CLI ('az') was not found on PATH. This script requires the Azure CLI to query and update Azure SQL resources. Install it from https://aka.ms/installazurecliwindows, restart the shell, and re-run this script." + exit 1 + } } <# @@ -1993,24 +2002,26 @@ if (-not $TenantId) { } -# Ensure the required modules are imported - -try{ - Import-Module Az.Accounts -}catch{ - Write-Output "Can't import module Az.Accounts" -} -try{ - Import-Module Az.ConnectedMachine -} -catch{ - Write-Output "Can't import module Az.ConnectedMachine" -} -try{ - Import-Module Az.ResourceGraph -} -catch{ - Write-Output "Can't import module Az.ResourceGraph" +# Ensure the required modules are installed and imported. These are hard +# dependencies (Search-AzGraph / Get-AzConnectedMachine are used later), so a +# missing module must install itself on demand and any failure must stop the +# script here with an actionable message instead of surfacing as a confusing +# "term not recognized" error deep inside the resource-scanning logic. +foreach ($requiredModule in @('Az.Accounts', 'Az.ConnectedMachine', 'Az.ResourceGraph')) { + try { + if (-not (Get-Module -ListAvailable -Name $requiredModule)) { + Write-Output "$requiredModule module not found. Installing..." + Install-Module -Name $requiredModule -Scope CurrentUser -Repository PSGallery -Force -AllowClobber -ErrorAction Stop + } + else { + Write-Output "$requiredModule module is already installed." + } + Import-Module $requiredModule -Force -ErrorAction Stop + } + catch { + Write-Error "Required module '$requiredModule' could not be installed/imported: $_. Install it manually with 'Install-Module -Name $requiredModule -Scope CurrentUser' and re-run this script." + exit 1 + } } $modifiedResources = @() @@ -2133,8 +2144,10 @@ foreach ($sub in $subscriptions) { $allResults = [System.Collections.Generic.List[PSObject]]::new() do{ - $resources = Search-AzGraph -Query "$($query)" -First $batchSize -SkipToken $skipToken - $allResults.AddRange($resources) + $resources = Search-AzGraph -Query "$($query)" -First $batchSize -SkipToken $skipToken -ErrorAction Stop + if ($resources) { + $allResults.AddRange($resources) + } $skipToken = $resources.SkipToken }while($skipToken) From 829e9cc7fcad56a06df443cc69b75f12b90c9622 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Tue, 1 Sep 2026 10:04:54 -0700 Subject: [PATCH 06/11] Auto-install Azure CLI via winget if missing before failing Attempts a silent 'winget install Microsoft.AzureCLI' when 'az' isn't on PATH, refreshing the process PATH afterwards so a fresh install can be picked up without restarting the shell. Falls back to the existing clear, actionable error (with a manual install link) if winget isn't available or the install fails. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index bcc5b85bb5..a5b0578a21 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -299,6 +299,20 @@ function Connect-Azure { } # 3) Sync Azure CLI if available - reuse an existing az CLI session for the same tenant when possible. + # If it's missing, attempt a silent self-install via winget (present on modern Windows/Server + # builds) before giving up, so a clean machine can be made to work without manual setup. + if (-not (Get-Command az -ErrorAction SilentlyContinue) -and (Get-Command winget -ErrorAction SilentlyContinue)) { + Write-Output "Azure CLI ('az') not found. Attempting to install it via winget..." + try { + winget install --id Microsoft.AzureCLI --exact --silent --accept-package-agreements --accept-source-agreements | Out-Null + } + catch { + Write-Output "winget install of Azure CLI failed: $_" + } + # Refresh PATH in this process so a newly-installed az.cmd can be found without restarting the shell. + $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') + } + if (Get-Command az -ErrorAction SilentlyContinue) { $acct = az account show --output json 2>$null | ConvertFrom-Json if ($acct -and $acct.tenantId -eq $TenantId) { From 2c175408ea7201b9f663a8088c331c9dc746454e Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Tue, 1 Sep 2026 10:07:41 -0700 Subject: [PATCH 07/11] Actually install Azure CLI instead of just detecting/reporting its absence Replaces the single winget attempt with a real self-healing install path: 1. Try 'winget install Microsoft.AzureCLI' and verify az is now resolvable. 2. If winget is unavailable or didn't work, fall back to downloading and silently running the official Azure CLI MSI (aka.ms/installazurecliwindows) via msiexec, which doesn't depend on winget being present. 3. Only after both real install attempts fail does the script report the actionable manual-install error and exit - it no longer gives up after a single unverified winget call. Adds a shared Refresh-Path helper to reload PATH from Machine/User scopes after each install attempt so a freshly installed az.cmd is found in the same process without restarting the shell. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index a5b0578a21..f00de39a9e 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -260,6 +260,12 @@ $ProgressPreference = "SilentlyContinue" $InformationPreference = "SilentlyContinue" $WarningPreference = "SilentlyContinue" +# Reloads PATH from the Machine and User scopes into this process so a tool installed +# by a child process (winget/msiexec) can be found immediately without restarting the shell. +function Refresh-Path { + $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') +} + function Connect-Azure { [CmdletBinding()] param( @@ -299,18 +305,56 @@ function Connect-Azure { } # 3) Sync Azure CLI if available - reuse an existing az CLI session for the same tenant when possible. - # If it's missing, attempt a silent self-install via winget (present on modern Windows/Server - # builds) before giving up, so a clean machine can be made to work without manual setup. - if (-not (Get-Command az -ErrorAction SilentlyContinue) -and (Get-Command winget -ErrorAction SilentlyContinue)) { - Write-Output "Azure CLI ('az') not found. Attempting to install it via winget..." - try { - winget install --id Microsoft.AzureCLI --exact --silent --accept-package-agreements --accept-source-agreements | Out-Null + # If it's missing, actually install it (not just report the problem) so a clean machine works + # without manual setup: try winget first, then fall back to a direct silent MSI install, which + # is Microsoft's documented scriptable install path and doesn't depend on winget being present. + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Output "Azure CLI ('az') not found. Attempting to install it automatically..." + $azInstalled = $false + + if (Get-Command winget -ErrorAction SilentlyContinue) { + Write-Output "Trying winget install of Microsoft.AzureCLI..." + winget install --id Microsoft.AzureCLI --exact --silent --accept-package-agreements --accept-source-agreements + Refresh-Path + if (Get-Command az -ErrorAction SilentlyContinue) { + $azInstalled = $true + Write-Output "Azure CLI installed successfully via winget." + } + else { + Write-Output "winget install did not result in a usable 'az' command (exit code $LASTEXITCODE)." + } } - catch { - Write-Output "winget install of Azure CLI failed: $_" + + if (-not $azInstalled) { + Write-Output "Trying direct MSI install of Azure CLI..." + $msiPath = Join-Path $env:TEMP "AzureCLI-$(Get-Random).msi" + try { + Invoke-WebRequest -Uri 'https://aka.ms/installazurecliwindows' -OutFile $msiPath -UseBasicParsing -ErrorAction Stop + $msiExit = (Start-Process msiexec.exe -ArgumentList "/I `"$msiPath`" /quiet /norestart" -Wait -PassThru).ExitCode + Refresh-Path + if (Get-Command az -ErrorAction SilentlyContinue) { + $azInstalled = $true + Write-Output "Azure CLI installed successfully via MSI." + } + else { + Write-Output "MSI install completed with exit code $msiExit but 'az' is still not on PATH." + } + } + catch { + Write-Output "Direct MSI install of Azure CLI failed: $_" + } + finally { + Remove-Item -Path $msiPath -ErrorAction SilentlyContinue + } + } + + if (-not $azInstalled) { + # Both automated install paths were genuinely attempted and failed (e.g. no admin rights, + # no internet access to aka.ms/PSGallery). At this point manual intervention is unavoidable, + # so fail clearly rather than let every downstream 'az' call error out confusingly later. + Write-Error "Azure CLI ('az') could not be installed automatically (winget and direct MSI install both failed or are unavailable). Install it manually from https://aka.ms/installazurecliwindows, restart the shell, and re-run this script." + exit 1 } - # Refresh PATH in this process so a newly-installed az.cmd can be found without restarting the shell. - $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') } if (Get-Command az -ErrorAction SilentlyContinue) { From d5b09d161748925ea65f03d1ef99670a7e6e76a7 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Tue, 1 Sep 2026 10:12:51 -0700 Subject: [PATCH 08/11] Connect once in the parent script and pass it to Arc/Azure children Adds a single pre-connect step in the top-level (non-embedded) part of the script for -RunMode Single: establishes the Az PowerShell context and, when targeting Azure, verifies/logs in the Azure CLI exactly once before invoking the Arc and Azure sub-scripts. Sets \ / \ so the embedded Azure script's Connect-Azure short-circuits its own Azure CLI install-check/login entirely when the parent already verified it for the same tenant, instead of repeating that work. Falls back to the original full connect logic unchanged when run standalone (e.g. as an Azure Automation runbook) where these env vars won't be set. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index f00de39a9e..3852b71f42 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -141,6 +141,46 @@ if ($RunMode -eq "Scheduled") { $azureLicenseType = if ($TargetLicenseType -eq "PAYG") { "LicenseIncluded" } else { "BasePrice" } $arcLicenseType = if ($TargetLicenseType -eq "PAYG") { "PAYG" } else { "Paid" } +# === Connect once here instead of letting each embedded script (Arc/Azure) redundantly +# re-authenticate. Az PowerShell's context and the Azure CLI's token cache are +# process-wide, so authenticating once means each embedded script's own Connect-Azure +# call finds an already-valid context/session and skips straight past its own login +# and (for the Azure CLI) its install-check, instead of repeating that work. +if ($RunMode -eq "Single") { + if (-not (Get-Module -ListAvailable -Name Az.Accounts)) { + Write-Output "Az.Accounts module not found. Installing..." + Install-Module -Name Az.Accounts -Scope CurrentUser -Repository PSGallery -Force -AllowClobber + } + Import-Module Az.Accounts -Force + + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account -and ([string]::IsNullOrWhiteSpace($TenantId) -or $currentCtx.Tenant.Id -eq $TenantId)) { + Write-Output "Already connected to Azure PowerShell as: $($currentCtx.Account) (tenant $($currentCtx.Tenant.Id)). Reusing this context for both the Arc and Azure runs below." + } + else { + Write-Output "Connecting to Azure PowerShell once for this run..." + if ($TenantId) { Connect-AzAccount -Tenant $TenantId -ErrorAction Stop | Out-Null } + else { Connect-AzAccount -ErrorAction Stop | Out-Null } + $currentCtx = Get-AzContext + } + if ([string]::IsNullOrWhiteSpace($TenantId)) { $TenantId = $currentCtx.Tenant.Id } + + # Signal to the embedded scripts (which run in this same process) that the + # connection for this tenant has already been established/validated, so their + # own Connect-Azure calls can skip repeating the work. + $env:PAYG_PRECONNECTED_TENANT = $TenantId + + if ($Target -eq "Both" -or $Target -eq "Azure") { + if (Get-Command az -ErrorAction SilentlyContinue) { + $acct = az account show --output json 2>$null | ConvertFrom-Json + if ($acct -and $acct.tenantId -eq $TenantId) { + Write-Output "Azure CLI already logged in as: $($acct.user.name) (tenant $TenantId). Reusing this session for the Azure run below." + $env:PAYG_PRECONNECTED_AZCLI = '1' + } + } + } +} + # === Embedded dependency scripts (materialized to disk at runtime; nothing is downloaded) === $EmbeddedScripts = @{} $EmbeddedScripts['Azure'] = @' @@ -308,6 +348,13 @@ function Connect-Azure { # If it's missing, actually install it (not just report the problem) so a clean machine works # without manual setup: try winget first, then fall back to a direct silent MSI install, which # is Microsoft's documented scriptable install path and doesn't depend on winget being present. + # Skip all of this entirely if the parent script already verified az CLI is installed and + # logged in for this tenant (see $env:PAYG_PRECONNECTED_AZCLI), avoiding redundant work. + if ($env:PAYG_PRECONNECTED_AZCLI -eq '1' -and $env:PAYG_PRECONNECTED_TENANT -eq $TenantId -and (Get-Command az -ErrorAction SilentlyContinue)) { + Write-Output "Azure CLI session for tenant $TenantId already verified by the parent script. Skipping redundant check." + return + } + if (-not (Get-Command az -ErrorAction SilentlyContinue)) { Write-Output "Azure CLI ('az') not found. Attempting to install it automatically..." $azInstalled = $false From 2eb212308848b055f7f5454ab6d6af4a27749e44 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 4 Sep 2026 07:23:57 -0700 Subject: [PATCH 09/11] Replace Azure CLI with Az PowerShell cmdlets in manage-payg-transition - Rewrite modify-azure-sql-license-type.ps1 (embedded) to use Az PowerShell cmdlets (Get-/Set-AzSqlVM/Instance/Database/ElasticPool/InstancePool, Set-AzDataFactoryV2IntegrationRuntime) instead of Azure CLI, addressing review feedback to drop the CLI dependency. - Add explicit Az.Sql / Az.SqlVirtualMachine module ensure/install/import logic (previously relied on implicit autoloading, which fails when the modules are not already installed). - SQL VM license updates remain fully synchronous (Update-AzSqlVM), since -NoWait/-AsJob are broken in Az.SqlVirtualMachine 2.4.0; other resource types keep the async default via -AsJob, mirroring the previous --no-wait behavior. - Update README.md to reflect CLI-free prerequisites and current synchronous/async behavior per resource type. - Update TESTPLAN.md with a new Round 2 section documenting live validation of the CLI removal across two subscriptions (SQL VM, Managed Instance, SQL Database), the Az.Sql/Az.SqlVirtualMachine regression and fix, and refresh the Known gaps / Required permissions sections for the PowerShell-only implementation. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/README.md | 31 +- .../manage/manage-payg-transition/TESTPLAN.md | 138 ++- .../manage-payg-transition.ps1 | 1049 ++++++----------- 3 files changed, 487 insertions(+), 731 deletions(-) diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index fb6797405d..ce3e866529 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -19,6 +19,13 @@ If not specified, all subscriptions your role has access to are scanned. - You must have at least a *Contributor* RBAC role in each subscription you modify. - You must have a *Tag Contributor* *Contributor* RBAC role in each subscription you modify. - You must be connected to Azure AD and logged in to your Azure account. If your account have access to multiple tenants, make sure to log in with a specific tenant ID. +- The Az PowerShell modules `Az.Accounts`, `Az.Sql`, `Az.SqlVirtualMachine`, `Az.DataFactory`, + `Az.ConnectedMachine`, and `Az.ResourceGraph` are required; the script installs any that + are missing automatically (for the current user, from the PowerShell Gallery). + +> [!NOTE] +> The Azure CLI (`az`) is **not** required. The script is implemented entirely with Az +> PowerShell cmdlets. ### Detailed permissions by resource type @@ -95,7 +102,10 @@ The script accepts the following command line parameters: - Arc-connected machines whose agent is `Disconnected` or `Expired` cannot be updated, because the extension setting must be pushed to a reachable agent. These are skipped and will be picked up on a later run once the machines reconnect. -- The offline Azure VMs will be reactivated for a brief period to change the configuration. +- SQL virtual machines that are stopped/deallocated are **skipped** rather than modified — + the underlying VM must be running for `Update-AzSqlVM` to change its license type. These + are reported with `UpdateResult = SkippedNotRunning` and picked up automatically on a later + run once the VM is started. - Each run writes a `ModifiedResources_.csv` report. The `UpdateResult` column records the per-resource outcome and `UpdateError` carries the service error text when a change was rejected. @@ -106,20 +116,21 @@ The script accepts the following command line parameters: | Resource | Default | With `-WaitForCompletion` | |---|---|---| - | SQL Managed Instance, database, elastic pool, instance pool | `--no-wait`, reports `RequestSubmitted` | waits, reports `Updated` | + | SQL Managed Instance, database, elastic pool, instance pool | `-AsJob`, reports `RequestSubmitted` | waits, reports `Updated` | | Arc-connected machine | `-NoWait`, reports `RequestSubmitted` | polls the extension, reports `Succeeded` / `Failed` / `TimedOut` | - | SQL virtual machine | direct ARM request, reports `RequestSubmitted` | `az sql vm update` waits, reports `Updated` | + | **SQL virtual machine** | **always waits**, reports `Updated` | same | | **SSIS integration runtime** | **always waits**, reports `Updated` | same | - SSIS integration runtimes are the one exception, because + SSIS integration runtimes are the one exception among the Azure-side resources, because `Set-AzDataFactoryV2IntegrationRuntime` exposes no asynchronous option. - SQL virtual machines are a special case. `az sql vm update` has no `--no-wait` option and - blocks for roughly two minutes per VM, and although `Update-AzSqlVM` advertises `-NoWait` - and `-AsJob`, both are broken in `Az.SqlVirtualMachine` 2.4.0. The script therefore submits - the change to ARM directly (read the resource, change `sqlServerLicenseType`, write it - back), which returns in seconds. If that request fails for any reason it automatically - falls back to the synchronous `az sql vm update` path. + SQL virtual machines are also always synchronous, but for a different reason: + `Update-AzSqlVM` advertises `-NoWait` and `-AsJob`, but both are broken in + `Az.SqlVirtualMachine` 2.4.0 (`-NoWait` forwards the bound parameter into `Get-AzSqlVM`, + which rejects it, and `-AsJob` throws a `NullReferenceException`). SQL VMs also cannot + reliably run this operation in the background. The script therefore always calls + `Update-AzSqlVM` synchronously and waits for it to reach a terminal state, regardless of + whether `-WaitForCompletion` was passed. For Arc, a `TimedOut` result is inconclusive rather than a failure — the agent may still apply the setting after the script stops waiting. diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 143ed12c1a..6e31a18c2c 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -95,6 +95,70 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 45 | A folder-scoped merge of `manage-payg-transition/` alone is self-sufficient | Extracted exactly the tracked contents of the folder at `HEAD` via `git archive HEAD samples/manage/manage-payg-transition` into an empty temp tree (5 files, both sibling scripts absent) and ran the orchestrator there against subscription `20d62cea` with `-ReportOnly` | ✅ Passed. Stronger than #15, which copied the working folder; this reproduces precisely what a folder-only merge would deliver. The orchestrator materialized both sub-scripts from its embedded here-strings and completed the Arc and Azure SQL passes with no missing-file, path or download errors, and the materialized copies were **byte-identical (0 differing lines by `Compare-Object`)** to the fixed standalone scripts. Confirmed no `Invoke-WebRequest`/`Invoke-RestMethod` fetch of the sub-scripts and no relative path escaping the folder — the only outbound URIs are PowerShell Gallery module links used by the Automation-Account path. **Caveat:** merging the folder alone would leave the standalone `modify-azure-sql-license-type.ps1` (+581) and `modify-arc-sql-license-type.ps1` (+167) stale on `master`, so callers invoking them directly would still hit the #40/#41 silent-skip, the #39 Arc false-success and the synchronous SQL VM update from #36, and the embedded-vs-standalone sync invariant would be broken. All 7 changed files should ship together. | | 46 | End-of-execution outcome summary and root cause reporting | Added `Format-ExecutionOutcomeSummary` across standalone and orchestrator scripts, tested with `-ReportOnly` and live executions | ✅ Passed. For each run, a structured outcome table is printed to the console/transcript at the very end of execution (unified across all resource types with no intermediate duplicate tables), summarizing: `ResourceType` (sorted alphabetically), `Qualified`, `Updated or RequestSubmitted`, `Failed`, and `Skipped`. Directly beneath the summary table, a detailed `FAILURE & SKIP ROOT CAUSES` breakdown displays the Resource Name, Resource Group, ResourceType, Outcome, and exact error/root-cause message (e.g. stopped/deallocated VM power state, ESU licensing restrictions, tag exclusions, or service errors). If no issues occurred, cleanly outputs `No failures or skipped resources encountered.` | +## Round 2: Azure CLI removal (2026-09-04) + +Requested by reviewer Travis Wright on PR #1511: replace all remaining Azure CLI (`az`) +usage in `modify-azure-sql-license-type.ps1` with native Az PowerShell cmdlets, with the +explicit requirement that SQL virtual machine license updates must **never** run +asynchronously (no `-NoWait`/`-AsJob`) because the underlying VM cannot reliably complete +that operation in the background. + +### Changes under test + +- Removed `Refresh-Path`, all Azure CLI install/login logic in `Connect-Azure`, and the + CLI helper functions (`Invoke-AzCliArgsWithRetry`, `Invoke-AzCliLicenseUpdate`, + `Invoke-AzCliQuery`). Replaced with `Invoke-AzCmdletWithRetry`, `Invoke-AzLicenseUpdate`, + and `Test-ExcludedByTags` (a PowerShell re-implementation of the previous JMESPath tag + filtering). +- `Invoke-AzLicenseUpdate` is the new shared `Set-AzSql*` update helper. It supports + `-AsJob:$submitAsync` for the resource types that accept it (Managed Instance, Database, + Elastic Pool, Instance Pool), replacing the async ARM-PUT/CLI-fallback path from + test #36. `Update-AzSqlVM` is now called synchronously and unconditionally, regardless of + `-WaitForCompletion`, because `-NoWait`/`-AsJob` are broken in `Az.SqlVirtualMachine` + 2.4.0 (documented in test #36; that finding is unaffected, only the fallback-to-CLI + half of the old behavior is gone). +- SQL VM, Managed Instance, SQL Server/Database/Elastic Pool, and Instance Pool discovery + now use `Get-AzSqlVM`, `Get-AzSqlInstance`, `Get-AzSqlServer`/`Get-AzSqlDatabase`/ + `Get-AzSqlElasticPool`, and `Get-AzSqlInstancePool` respectively, instead of `az ... list`. + Subscription context switching consolidated to a single `Set-AzContext` per subscription + loop iteration (all `az account set`/`az account show` calls removed). +- The Arc script (`modify-arc-sql-license-type.ps1`) was **not** changed in this round — + it already used only Az PowerShell cmdlets (`Search-AzGraph`, `Get-AzConnectedMachine`, + `Get-/Set-AzConnectedMachineExtension`) and never depended on the CLI. +- Added explicit ensure/install/import logic for `Az.Sql` and `Az.SqlVirtualMachine` + (previously only `Az.Accounts` and `Az.DataFactory` had this; see test #52). +- `README.md` updated to match: the async-by-default table now shows `-AsJob` instead of + `--no-wait` for Managed Instance/Database/Elastic Pool/Instance Pool, the SQL VM row now + reads "always waits", the stale "offline VMs will be reactivated" line was replaced with + the correct `SkippedNotRunning` behavior, and Prerequisites now lists the required Az + modules plus an explicit "Azure CLI is not required" note. + +### Test environment + +- Subscriptions: `20d62cea-b252-4a42-b9d6-16ad2636b3a5` (DMSInternalDevTestER) and + `6a37df99-a9de-48c4-91e5-7e6ab00b2362` (DMSBuddy), both tenant + `72f988bf-86f1-41af-91ab-2d7cd011db47`. +- Test SQL VM `payg-test-vm2` (SQL Server 2022 Standard edition, registered via + `New-AzSqlVM -LicenseType AHUB`) was provisioned in RG `deleterajpo` (Sub2) + specifically for this round, because no existing Standard/Enterprise-edition, running + SQL VM was available in either subscription (Developer/Web/Express editions cannot use + AHUB — `Update-AzSqlVM` rejects them with *"can only be converted to AHUB when the + edition ... is 'Standard' or 'Enterprise'"*). Deleted afterwards along with RG + `deleterajpo`. + +### Test cases and results + +| # | Test | Method | Result | +|---|------|--------|--------| +| 47 | Zero Azure CLI invocations remain | `Select-String -Pattern '\baz '` (word-boundary, case-sensitive) across the whole file, plus manual review of every remaining `az`-substring match | ✅ Passed. 0 matches for an actual CLI call; the only substring matches are `Az.*` PowerShell module names and comments describing what CLI logic was replaced. | +| 48 | SQL VM AHUB→PAYG, always synchronous | Live run against `payg-test-vm2` (Sub2), **without** `-WaitForCompletion` | ✅ Passed. Completed in ~65s and reported `Updated` (not `RequestSubmitted`) even though `-WaitForCompletion` was not passed, confirming the VM path never goes async. Verified live via `Get-AzSqlVM` → `SqlServerLicenseType = PAYG`. | +| 49 | SQL Managed Instance round trip | Live run against `bhrout-mi` (Sub1): PAYG→AHUB then AHUB→PAYG, both with `-WaitForCompletion` | ✅ Passed both directions; each reported `Updated` and was confirmed live via `Get-AzSqlInstance`; MI restored to its original state (`LicenseIncluded`). | +| 50 | SQL Database AHUB→PAYG then revert | Live run against `binuj_Northwind_sqlPkg` and `Northwind` on `binuj-sqldb-weu-2` (Sub2): `BasePrice`→`LicenseIncluded`, then reverted | ✅ Passed, both directions confirmed via `Get-AzSqlDatabase`. One revert attempt initially appeared incomplete because of a stale/cached read from `Get-AzSqlDatabase` immediately after the update call; re-querying a few seconds later showed the true state, and the second database was then explicitly reverted and re-confirmed. Not a script defect — a testing artifact of Azure API read-after-write latency. | +| 51 | `-ReportOnly` dry runs clean post-change | Ran against both subscriptions | ✅ Passed. No CLI, no errors, correct resource discovery in both. | +| 52 | `Az.Sql`/`Az.SqlVirtualMachine` never explicitly ensured (regression) | User ran the script live against `20d62cea` on a separate machine | ❌ Failed initially: `Get-AzSqlVM`/`Get-AzSqlInstance`/`Get-AzSqlServer`/`Get-AzSqlInstancePool` all reported *"term ... is not recognized"*. Root cause: only `Az.Accounts` and `Az.DataFactory` had ensure/install/import logic; `Az.Sql`/`Az.SqlVirtualMachine` happened to already be loaded on the original dev machine (from unrelated interactive testing), masking the gap. Fixed by adding the same ensure/install/import pattern for both modules. Re-validated via `[System.Management.Automation.Language.Parser]::ParseFile` (no syntax errors) and a re-run of the `-ReportOnly` dry run (now logs `Az.Sql module is already installed` / `Az.SqlVirtualMachine module is already installed`, correct resource discovery). | +| 53 | User live run end-to-end, post-fix | User ran `-targetSubscription 20d62cea... -TargetLicenseType AHUB` (no `-WaitForCompletion`) | ✅ Passed. Confirms the #52 fix on the user's own machine: `Az.Sql`/`Az.SqlVirtualMachine` reported "already installed", `bhrout-mi` and 2 databases (`ratruong-test-sqldb`, `TestDB`) transitioned to `BasePrice` with `RequestSubmitted` status (expected async default), and the deallocated SQL VM `rradjousql2016` was correctly reported `SkippedNotRunning`. No CLI calls, no errors. | +| 54 | RBAC-denied write correctly surfaced as a permissions issue, not a code defect | User attempted an Arc SQL Server transition against RG `arunguru-test` in subscription `77a28e80-58e1-402c-aa9e-bc2055d91a03`, got `AuthorizationFailed` on 4 machines | ✅ Confirmed genuine. `Get-AzRoleAssignment -ExpandPrincipalGroups` (including group-inherited roles) showed the user's only subscription-wide role there is *Reader*; their sole *Contributor* grant is scoped to a single Container Registry resource, not `arunguru-test` or any `Microsoft.HybridCompute/*` resource. The script's error message accurately reflects a missing `Microsoft.HybridCompute/machines/extensions/write` permission — not a script bug. | + ## Cleanup - All temporary test artifacts (generated wrapper scripts, materialized sub-scripts, @@ -109,19 +173,34 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c - The Arc machine `sqltvm` (`rajposqltvm`, tenant `d1623670`), which was switched to `LicenseOnly` during tests #31 and #39, was restored to `PAYG` and confirmed via `Get-AzConnectedMachineExtension`. +- **Round 2:** `binuj_Northwind_sqlPkg` and `Northwind` (test #50) were reverted to + `BasePrice`, confirmed via `Get-AzSqlDatabase`. `bhrout-mi` (test #49) was restored to + `LicenseIncluded`. The test SQL VM `payg-test-vm2` and RG `deleterajpo` (including a + leftover disk/NIC from an earlier, deleted `payg-test-vm1` attempt) were fully deleted + via `Remove-AzResourceGroup` and confirmed gone. A pre-existing, unrelated + `Microsoft.AzureArcData/sqlServerEsuLicenses` resource named `test` that already existed + in `deleterajpo` before this round's testing began was deleted as collateral damage of + the RG deletion; investigation via the subscription's Activity Log showed it was an + orphaned ESU license record (no backing `Microsoft.HybridCompute/machines` existed) with + no cost impact, so it was not recreated. ## Known gaps / follow-ups -- **Async coverage is partial.** The `--no-wait` / non-blocking default has been proven live - for SQL virtual machines (#36), SQL databases (#40) and Arc-connected machines (#31), but - **not** for managed instances, elastic pools or instance pools. The code path is shared - (`Invoke-AzCliLicenseUpdate -SupportsNoWait`) and the flag was verified to parse on - `sql mi update`, but no live resource of those types has actually been transitioned - asynchronously. Subscription `20d62cea` does contain one managed instance (`bhrout-mi`), - but it is already at the target license type and belongs to another team, so transitioning - it purely to exercise the code path would be a billing-affecting change to someone else's - resource; it was deliberately left alone. Elastic pools and instance pools do not exist in - any subscription reached so far (0 across all six servers in `20d62cea`). +- **Azure CLI has been fully removed as of Round 2** (see above) — this closes the + CLI-dependency concern that ran through tests #29, #33, #36 and #41/#42 in Round 1. + `Invoke-AzCliArgsWithRetry`/`Invoke-AzCliLicenseUpdate`/`Invoke-AzCliQuery` no longer + exist; every reference below to those helpers or to `az`/`--no-wait` describes Round 1 + (pre-CLI-removal) behavior and is retained for historical context only. +- **Async coverage is partial.** The non-blocking default (now `-AsJob`, previously + `--no-wait`) has been proven live for SQL databases (#40, #50) and Arc-connected machines + (#31), but **not** for managed instances (only synchronous round trips were run — #49, + and originally #8), elastic pools or instance pools. Elastic pools and instance pools do + not exist in any subscription reached so far across either round. +- **SQL virtual machines are now always synchronous** (Round 2), which is a stricter + guarantee than the "async ARM-PUT with CLI fallback" behavior tested in Round 1's #36 — + that finding (the ARM PUT working, `-NoWait`/`-AsJob` being broken in + `Az.SqlVirtualMachine` 2.4.0) is still accurate background, but the CLI-fallback half of + it no longer applies. - `RunMode Scheduled` has **never been executed end-to-end**. The runbook import-path fix (the embedded `set-azurerunbook.ps1` hardcoded `./PayTransitionDownloads/` while the orchestrator materializes to `./manage-payg-transition/`) is validated by code review @@ -150,27 +229,36 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c and instance pools are implemented and unit-verified via the shared helper (#28), but have not been observed against a genuine service-side failure on those specific resource types — only the SQL VM and DataFactory paths have been exercised end-to-end against real errors. -- **Azure updates are asynchronous by default as of test #33.** SQL Managed Instances, - databases, elastic pools and instance pools are submitted with `--no-wait` and reported as - `RequestSubmitted`; `-WaitForCompletion` restores blocking behaviour and the `Updated` - result. SQL virtual machines and SSIS integration runtimes are unavoidable exceptions - (test #34) and always wait. The Arc path has always used `-NoWait`. A consequence of the - new default is that the report no longer proves a change was applied unless - `-WaitForCompletion` was used — verify out of band or re-run, as described in the README. -- The asynchronous paths for Managed Instances, databases, elastic pools and instance pools - were verified by unit-testing argument construction and by confirming the CLI accepts - `--no-wait`, but have not been exercised against a live resource of those types: the only - candidates visible in the tenant belong to other teams and were deliberately not modified. +- **Azure updates are asynchronous by default as of test #33** (Round 1 CLI implementation: + `--no-wait`). **As of Round 2 (CLI removal), this is implemented as `Set-AzSql*` calls + with conditional `-AsJob`** for Managed Instances, databases, elastic pools and instance + pools, still reported as `RequestSubmitted`; `-WaitForCompletion` restores blocking + behaviour and the `Updated` result (re-confirmed live in test #53). SQL virtual machines + and SSIS integration runtimes remain exceptions and always wait — for SQL VMs this is now + because `-NoWait`/`-AsJob` are broken in `Az.SqlVirtualMachine` 2.4.0 (test #36, #48), + not because of any CLI limitation. The Arc path has always used `-NoWait` and is + unaffected by the CLI-removal work (it already used Az PowerShell cmdlets exclusively). + A consequence of the async default is that the report no longer proves a change was + applied unless `-WaitForCompletion` was used — verify out of band or re-run, as described + in the README. +- The asynchronous paths for Managed Instances, elastic pools and instance pools were + verified by unit-testing argument construction (both rounds), but have not been exercised + against a live resource of those types: the only Managed Instance candidates visible in + the tenant belong to other teams and were deliberately not modified, and no elastic pool + or instance pool resources exist in any subscription reached so far. The database async + path **has** now been exercised live end-to-end (test #50, `-WaitForCompletion` blocking + path; the non-blocking `-AsJob` path itself is still only unit-verified). ## Required permissions -Derived from every Azure CLI/PowerShell call made by the two scripts: +Derived from every Azure PowerShell call made by the two scripts (Round 2: no Azure CLI +calls remain in either script): | Script | Operations performed | Minimum built-in role(s) | |---|---|---| -| `modify-azure-sql-license-type.ps1` | `az sql vm/mi/db/elastic-pool/instance-pool list` and `update`; `Get-AzDataFactoryV2(IntegrationRuntime)` / `Set-AzDataFactoryV2IntegrationRuntime`; `Get-AzSubscription`; `Set-AzContext` | **SQL DB Contributor** (covers `Microsoft.SqlVirtualMachine/*`, `Microsoft.Sql/managedInstances/*`, `Microsoft.Sql/servers/databases/*`, `Microsoft.Sql/servers/elasticPools/*`, `Microsoft.Sql/instancePools/*`) **+** write access to `Microsoft.DataFactory/factories/integrationRuntimes/*` (e.g. **Data Factory Contributor**) | -| `modify-arc-sql-license-type.ps1` | `Search-AzGraph` (Azure Resource Graph query over `microsoft.hybridcompute/machines` and `.../extensions`); `Get-AzConnectedMachine`; `Get/Set-AzConnectedMachineExtension` | **Azure Connected Machine Resource Administrator** (covers `Microsoft.HybridCompute/machines/extensions/*` write) — Resource Graph read is included in any role with `Microsoft.Resources/subscriptions/resourceGroups/resources/read` (e.g. **Reader**) | -| Both | `Get-AzSubscription`, `az account show` / `az account set` | **Reader** at minimum on every subscription scanned | +| `modify-azure-sql-license-type.ps1` | `Get-/Update-AzSqlVM`; `Get-/Set-AzSqlInstance`; `Get-/Set-AzSqlDatabase`; `Get-/Set-AzSqlElasticPool`; `Get-/Set-AzSqlInstancePool`; `Get-AzDataFactoryV2(IntegrationRuntime)` / `Set-AzDataFactoryV2IntegrationRuntime`; `Get-AzSubscription`; `Set-AzContext` | **SQL DB Contributor** (covers `Microsoft.SqlVirtualMachine/*`, `Microsoft.Sql/managedInstances/*`, `Microsoft.Sql/servers/databases/*`, `Microsoft.Sql/servers/elasticPools/*`, `Microsoft.Sql/instancePools/*`) **+** write access to `Microsoft.DataFactory/factories/integrationRuntimes/*` (e.g. **Data Factory Contributor**) | +| `modify-arc-sql-license-type.ps1` | `Search-AzGraph` (Azure Resource Graph query over `microsoft.hybridcompute/machines` and `.../extensions`); `Get-AzConnectedMachine`; `Get/Set-AzConnectedMachineExtension` | **Azure Connected Machine Resource Administrator** (covers `Microsoft.HybridCompute/machines/extensions/*` write) — Resource Graph read is included in any role with `Microsoft.Resources/subscriptions/resourceGroups/resources/read` (e.g. **Reader**). Confirmed in test #54: a **Reader**-only account correctly receives `AuthorizationFailed` on the extension write. | +| Both | `Get-AzSubscription`, `Set-AzContext` | **Reader** at minimum on every subscription scanned | **Practical recommendation:** assign **Contributor** at the target subscription or resource-group scope — it is a superset of all the writes above (SQL VM/MI/DB/elastic diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 3852b71f42..53f6569c45 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -44,8 +44,9 @@ change has been applied. Exceptions: SQL virtual machines and SSIS integration runtimes always wait, because - 'az sql vm update' and Set-AzDataFactoryV2IntegrationRuntime provide no asynchronous - option. Using this switch makes runs substantially slower on large estates. + Update-AzSqlVM must not be run asynchronously for SQL VMs (see Invoke-SqlVmLicenseUpdate) + and Set-AzDataFactoryV2IntegrationRuntime provides no asynchronous option. Using this + switch makes runs substantially slower on large estates. .PARAMETER AutomationAccResourceGroupName Required only when -RunMode is 'Scheduled'. Resource group for the Azure @@ -142,10 +143,9 @@ $azureLicenseType = if ($TargetLicenseType -eq "PAYG") { "LicenseIncluded" } els $arcLicenseType = if ($TargetLicenseType -eq "PAYG") { "PAYG" } else { "Paid" } # === Connect once here instead of letting each embedded script (Arc/Azure) redundantly -# re-authenticate. Az PowerShell's context and the Azure CLI's token cache are -# process-wide, so authenticating once means each embedded script's own Connect-Azure -# call finds an already-valid context/session and skips straight past its own login -# and (for the Azure CLI) its install-check, instead of repeating that work. +# re-authenticate. Az PowerShell's context is process-wide, so authenticating once +# means each embedded script's own Connect-Azure call finds an already-valid context +# and skips straight past its own login, instead of repeating that work. if ($RunMode -eq "Single") { if (-not (Get-Module -ListAvailable -Name Az.Accounts)) { Write-Output "Az.Accounts module not found. Installing..." @@ -169,16 +169,6 @@ if ($RunMode -eq "Single") { # connection for this tenant has already been established/validated, so their # own Connect-Azure calls can skip repeating the work. $env:PAYG_PRECONNECTED_TENANT = $TenantId - - if ($Target -eq "Both" -or $Target -eq "Azure") { - if (Get-Command az -ErrorAction SilentlyContinue) { - $acct = az account show --output json 2>$null | ConvertFrom-Json - if ($acct -and $acct.tenantId -eq $TenantId) { - Write-Output "Azure CLI already logged in as: $($acct.user.name) (tenant $TenantId). Reusing this session for the Azure run below." - $env:PAYG_PRECONNECTED_AZCLI = '1' - } - } - } } # === Embedded dependency scripts (materialized to disk at runtime; nothing is downloaded) === @@ -236,13 +226,14 @@ $EmbeddedScripts['Azure'] = @' .PARAMETER WaitForCompletion Optional. If specified, waits for each update to reach a terminal state before continuing and reports the confirmed outcome ("Updated"). By default the script submits updates with - --no-wait and reports "RequestSubmitted", meaning the service accepted the request rather + -AsJob and reports "RequestSubmitted", meaning the service accepted the request rather than that the change has been applied. Note: Set-AzDataFactoryV2IntegrationRuntime provides no asynchronous option, so SSIS integration runtimes always wait regardless of this switch and always report "Updated". - SQL virtual machines are submitted asynchronously through a direct ARM request because - 'az sql vm update' has no --no-wait option; see Invoke-SqlVmLicenseUpdate. + SQL virtual machines are always updated synchronously via Update-AzSqlVM (no -AsJob/-NoWait) + because SQL VM license updates must not be submitted asynchronously; see + Invoke-SqlVmLicenseUpdate. #> param ( @@ -300,12 +291,6 @@ $ProgressPreference = "SilentlyContinue" $InformationPreference = "SilentlyContinue" $WarningPreference = "SilentlyContinue" -# Reloads PATH from the Machine and User scopes into this process so a tool installed -# by a child process (winget/msiexec) can be found immediately without restarting the shell. -function Refresh-Path { - $env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine') + ';' + [System.Environment]::GetEnvironmentVariable('Path', 'User') -} - function Connect-Azure { [CmdletBinding()] param( @@ -343,147 +328,15 @@ function Connect-Azure { } Write-Output "Connected to Azure PowerShell as: $($ctx.Context.Account)" } - - # 3) Sync Azure CLI if available - reuse an existing az CLI session for the same tenant when possible. - # If it's missing, actually install it (not just report the problem) so a clean machine works - # without manual setup: try winget first, then fall back to a direct silent MSI install, which - # is Microsoft's documented scriptable install path and doesn't depend on winget being present. - # Skip all of this entirely if the parent script already verified az CLI is installed and - # logged in for this tenant (see $env:PAYG_PRECONNECTED_AZCLI), avoiding redundant work. - if ($env:PAYG_PRECONNECTED_AZCLI -eq '1' -and $env:PAYG_PRECONNECTED_TENANT -eq $TenantId -and (Get-Command az -ErrorAction SilentlyContinue)) { - Write-Output "Azure CLI session for tenant $TenantId already verified by the parent script. Skipping redundant check." - return - } - - if (-not (Get-Command az -ErrorAction SilentlyContinue)) { - Write-Output "Azure CLI ('az') not found. Attempting to install it automatically..." - $azInstalled = $false - - if (Get-Command winget -ErrorAction SilentlyContinue) { - Write-Output "Trying winget install of Microsoft.AzureCLI..." - winget install --id Microsoft.AzureCLI --exact --silent --accept-package-agreements --accept-source-agreements - Refresh-Path - if (Get-Command az -ErrorAction SilentlyContinue) { - $azInstalled = $true - Write-Output "Azure CLI installed successfully via winget." - } - else { - Write-Output "winget install did not result in a usable 'az' command (exit code $LASTEXITCODE)." - } - } - - if (-not $azInstalled) { - Write-Output "Trying direct MSI install of Azure CLI..." - $msiPath = Join-Path $env:TEMP "AzureCLI-$(Get-Random).msi" - try { - Invoke-WebRequest -Uri 'https://aka.ms/installazurecliwindows' -OutFile $msiPath -UseBasicParsing -ErrorAction Stop - $msiExit = (Start-Process msiexec.exe -ArgumentList "/I `"$msiPath`" /quiet /norestart" -Wait -PassThru).ExitCode - Refresh-Path - if (Get-Command az -ErrorAction SilentlyContinue) { - $azInstalled = $true - Write-Output "Azure CLI installed successfully via MSI." - } - else { - Write-Output "MSI install completed with exit code $msiExit but 'az' is still not on PATH." - } - } - catch { - Write-Output "Direct MSI install of Azure CLI failed: $_" - } - finally { - Remove-Item -Path $msiPath -ErrorAction SilentlyContinue - } - } - - if (-not $azInstalled) { - # Both automated install paths were genuinely attempted and failed (e.g. no admin rights, - # no internet access to aka.ms/PSGallery). At this point manual intervention is unavoidable, - # so fail clearly rather than let every downstream 'az' call error out confusingly later. - Write-Error "Azure CLI ('az') could not be installed automatically (winget and direct MSI install both failed or are unavailable). Install it manually from https://aka.ms/installazurecliwindows, restart the shell, and re-run this script." - exit 1 - } - } - - if (Get-Command az -ErrorAction SilentlyContinue) { - $acct = az account show --output json 2>$null | ConvertFrom-Json - if ($acct -and $acct.tenantId -eq $TenantId) { - Write-Output "Azure CLI already logged in as: $($acct.user.name) (tenant $TenantId). Reusing existing session." - } - else { - Write-Output "Running az login..." - if ($UseManagedIdentity -or $envType -eq 'AzureAutomation') { - az login --tenant $TenantId --identity | Out-Null - } - else { - az login --tenant $TenantId | Out-Null - } - $acct = az account show --output json | ConvertFrom-Json - } - Write-Output "Azure CLI logged in as: $($acct.user.name)" - } - else { - # The rest of this script relies on the Azure CLI (Invoke-AzCliQuery / - # Invoke-AzCliLicenseUpdate) to enumerate and update SQL Servers, Databases, - # and Managed Instances. Failing loudly here - instead of letting each - # downstream 'az' call fail later with a confusing "term not recognized" - # error - saves time and gives the user a clear, actionable fix. - Write-Error "Azure CLI ('az') was not found on PATH. This script requires the Azure CLI to query and update Azure SQL resources. Install it from https://aka.ms/installazurecliwindows, restart the shell, and re-run this script." - exit 1 - } } -<# -.SYNOPSIS - Runs an 'az ... update' command and reports whether it actually succeeded. -.DESCRIPTION - The Azure CLI signals failure through its exit code, not through a thrown - exception, so piping its output straight into ConvertFrom-Json silently - swallows errors and makes a failed update indistinguishable from a - successful one. This wrapper checks $LASTEXITCODE and returns a result - object used to populate the UpdateResult/UpdateError columns of the report. - - By default updates are submitted with --no-wait so a large estate is not - processed serially; the caller then records "RequestSubmitted" rather than - "Updated", because the service has only accepted the request at that point. - Passing -WaitForCompletion to the script omits --no-wait, making the CLI poll - the operation to a terminal state so the outcome is confirmed. -.PARAMETER SupportsNoWait - Set for commands that accept --no-wait. 'az sql vm update' does not; SQL VMs are - submitted asynchronously through Invoke-SqlVmLicenseUpdate instead. -#> - # Matches transient network failures observed in practice (e.g. Windows ephemeral # port exhaustion - WinError 10048 - and generic HttpRequestExceptions/connection -# resets from Azure CLI or Az PowerShell). These are environment/network blips, not -# problems with the request itself, so a short retry resolves most of them instead -# of permanently marking an otherwise-valid resource update as "Failed". +# resets from Az PowerShell). These are environment/network blips, not problems with +# the request itself, so a short retry resolves most of them instead of permanently +# marking an otherwise-valid resource update as "Failed". $script:TransientErrorPattern = 'socket|10048|underlying connection|connection was closed|forcibly closed|timed? ?out|temporarily unavailable|An error occurred while sending the request|could not be resolved|(?&1 - if ($LASTEXITCODE -eq 0) { - return [PSCustomObject]@{ Output = $output; ExitCode = 0 } - } - - $message = ($output | Out-String).Trim() - $isTransient = $message -match $script:TransientErrorPattern - if (-not $isTransient -or $attempt -eq $MaxAttempts) { - return [PSCustomObject]@{ Output = $output; ExitCode = $LASTEXITCODE } - } - - Write-Warning "Transient network error on attempt $attempt/$MaxAttempts for $Description`: $message. Retrying in $DelaySeconds s..." - Start-Sleep -Seconds $DelaySeconds - } -} - <# .SYNOPSIS Runs an Az PowerShell cmdlet (via scriptblock) and retries it on transient network errors. @@ -517,149 +370,98 @@ function Invoke-AzCmdletWithRetry { } } -function Invoke-AzCliLicenseUpdate { +<# +.SYNOPSIS + Runs an Az PowerShell 'Set-AzSql*' license-type update cmdlet (via scriptblock) and reports + whether it actually succeeded. +.DESCRIPTION + Replaces the previous Azure-CLI-based updater. The caller's scriptblock invokes the + appropriate Set-AzSql* cmdlet with -ErrorAction Stop (so failures are terminating and + retried/caught here) and, when -SupportsAsJob is set and -WaitForCompletion was not passed + to the script, with -AsJob so a large estate is not processed serially. In that case the + caller records "RequestSubmitted" rather than "Updated", because the service has only + accepted the request at that point; passing -WaitForCompletion to the script omits -AsJob, + so the cmdlet blocks until the operation reaches a terminal state and the outcome is + confirmed before returning. +.PARAMETER SupportsAsJob + Set for cmdlets whose scriptblock conditionally passes -AsJob. Update-AzSqlVM does not use + this switch at all; SQL VMs are always updated synchronously (see Invoke-SqlVmLicenseUpdate). +#> +function Invoke-AzLicenseUpdate { param( - [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock, [Parameter(Mandatory = $true)][string]$Description, - [switch]$SupportsNoWait + [switch]$SupportsAsJob ) - $effectiveArgs = @($Arguments) - $submittedOnly = $false - if ($SupportsNoWait -and -not $WaitForCompletion) { - $effectiveArgs += '--no-wait' - $submittedOnly = $true - } - - $attemptResult = Invoke-AzCliArgsWithRetry -Arguments $effectiveArgs -Description $Description - $output = $attemptResult.Output + $submittedOnly = [bool]($SupportsAsJob -and -not $WaitForCompletion) - if ($attemptResult.ExitCode -ne 0) { - $message = ($output | Out-String).Trim() - Write-Warning "Failed to update $Description`: $message" - return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message; Submitted = $submittedOnly } + try { + $result = Invoke-AzCmdletWithRetry -Description $Description -ScriptBlock $ScriptBlock + return [PSCustomObject]@{ Success = $true; Result = $result; ErrorMessage = ""; Submitted = $submittedOnly } } - - # --no-wait produces no output, so only attempt to parse when something came back. - $parsed = $null - $raw = ($output | Out-String).Trim() - if (-not [string]::IsNullOrWhiteSpace($raw)) { - try { $parsed = $raw | ConvertFrom-Json } catch { $parsed = $raw } + catch { + Write-Warning "Failed to update $Description`: $($_.Exception.Message)" + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $_.Exception.Message; Submitted = $false } } - - # Note: this function must not write to the success stream. Anything emitted there - # would be merged into the return value, turning it into an array and hiding the - # message from the caller. Callers log their own success line. - return [PSCustomObject]@{ Success = $true; Result = $parsed; ErrorMessage = ""; Submitted = $submittedOnly } } - <# .SYNOPSIS - Runs a read-only Azure CLI query and reports failures instead of silently returning nothing. + Updates the license type of a SQL virtual machine, always synchronously. .DESCRIPTION - Discovery calls used to be piped straight into ConvertFrom-Json. The Azure CLI signals - failure through $LASTEXITCODE rather than by throwing, so a failed query produced $null, - which every caller then treated as "no resources found". A transient error therefore looked - exactly like an empty result and the affected resources were skipped without any indication - that they had not actually been examined. - - This wrapper checks the exit code, surfaces the real service error as a warning, and returns - the parsed value normalised to an array so callers can use .Count safely. + Update-AzSqlVM advertises -NoWait and -AsJob, but SQL VM license updates must not be + submitted asynchronously: VMs cannot reliably run this operation in the background (the + -NoWait/-AsJob switches are also broken in Az.SqlVirtualMachine 2.4.0 - -NoWait forwards the + bound parameter into Get-AzSqlVM, which rejects it, and -AsJob throws a + NullReferenceException), so this function always calls Update-AzSqlVM synchronously and + waits for it to reach a terminal state, regardless of -WaitForCompletion. #> -function Invoke-AzCliQuery { +function Invoke-SqlVmLicenseUpdate { param( - [Parameter(Mandatory = $true)][string[]]$Arguments, - [Parameter(Mandatory = $true)][string]$Description + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$ResourceGroup, + [Parameter(Mandatory = $true)][string]$LicenseType ) - $attemptResult = Invoke-AzCliArgsWithRetry -Arguments $Arguments -Description $Description - $output = $attemptResult.Output - - if ($attemptResult.ExitCode -ne 0) { - $message = ($output | Out-String).Trim() - Write-Warning "Unable to query $Description`: $message" - return [PSCustomObject]@{ Success = $false; Value = @(); ErrorMessage = $message } - } - - $raw = ($output | Out-String).Trim() - if ([string]::IsNullOrWhiteSpace($raw)) { - return [PSCustomObject]@{ Success = $true; Value = @(); ErrorMessage = "" } + try { + $result = Invoke-AzCmdletWithRetry -Description "SQL VM '$Name'" -ScriptBlock { + Update-AzSqlVM -Name $Name -ResourceGroupName $ResourceGroup -LicenseType $LicenseType -ErrorAction Stop + } + return [PSCustomObject]@{ Success = $true; Result = $result; ErrorMessage = ""; Submitted = $false } } - - try { $parsed = $raw | ConvertFrom-Json } catch { - Write-Warning "Unable to parse the response for $Description`: $($_.Exception.Message)" - return [PSCustomObject]@{ Success = $false; Value = @(); ErrorMessage = $_.Exception.Message } + Write-Warning "Failed to update SQL VM '$Name': $($_.Exception.Message)" + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $_.Exception.Message; Submitted = $false } } - - # Normalise to an array so .Count is meaningful for both single objects and empty results. - return [PSCustomObject]@{ Success = $true; Value = @($parsed); ErrorMessage = "" } } - <# .SYNOPSIS - Updates the license type of a SQL virtual machine, asynchronously by default. + Tests whether a resource's tags match any of the -ExclusionTags key/value pairs. .DESCRIPTION - 'az sql vm update' has no --no-wait option and blocks until the operation reaches a - terminal state, which for a SQL VM is typically around two minutes per resource. - Update-AzSqlVM advertises -NoWait and -AsJob but both are broken in - Az.SqlVirtualMachine 2.4.0 (-NoWait forwards the bound parameter into Get-AzSqlVM, - which rejects it; -AsJob throws a NullReferenceException). - - To honour the script's async-by-default contract this function talks to ARM directly: - it reads the resource, changes only sqlServerLicenseType and writes it back. ARM - accepts the request and returns an Azure-AsyncOperation header without waiting for the - provisioning to finish, so the call returns in seconds instead of minutes. - - When -WaitForCompletion is passed, or if the ARM round trip fails for any reason, the - original synchronous 'az sql vm update' path is used so behaviour degrades safely. + Replaces the JMESPath 'tags. != ' clauses previously embedded in each Azure + CLI --query. $Tags may be a [hashtable] (Az.Sql database/elastic-pool/instance/instance-pool + model objects) or an IDictionary of a single tag (the SQL VM model's singular .Tag property); + either shape is handled the same way. A resource is excluded when ANY exclusion tag key is + present with a matching value, mirroring the previous CLI filter's semantics. #> -function Invoke-SqlVmLicenseUpdate { +function Test-ExcludedByTags { param( - [Parameter(Mandatory = $true)][string]$ResourceId, - [Parameter(Mandatory = $true)][string]$Name, - [Parameter(Mandatory = $true)][string]$ResourceGroup, - [Parameter(Mandatory = $true)][string]$LicenseType + [Parameter(Mandatory = $false)]$Tags, + [Parameter(Mandatory = $true)][hashtable]$ExclusionTagTable ) - $cliArguments = @('sql','vm','update','-n',$Name,'-g',$ResourceGroup,'--license-type',$LicenseType,'-o','json') - - if ($WaitForCompletion) { - return Invoke-AzCliLicenseUpdate -Description "SQL VM '$Name'" -Arguments $cliArguments + if ($ExclusionTagTable.Keys.Count -eq 0 -or $null -eq $Tags) { + return $false } - $apiVersion = '2023-10-01' - $path = "$ResourceId`?api-version=$apiVersion" - - try { - $get = Invoke-AzRestMethod -Path $path -Method GET -ErrorAction Stop - if ($get.StatusCode -ne 200) { - throw "GET returned HTTP $($get.StatusCode): $($get.Content)" - } - - # Read-modify-write: the payload is the body ARM just returned with a single - # property changed, so no unrelated settings are dropped by the PUT. - $resource = $get.Content | ConvertFrom-Json - $resource.properties.sqlServerLicenseType = $LicenseType - - $put = Invoke-AzRestMethod -Path $path -Method PUT -Payload ($resource | ConvertTo-Json -Depth 30) -ErrorAction Stop - if ($put.StatusCode -ge 400) { - throw "PUT returned HTTP $($put.StatusCode): $($put.Content)" - } - - $parsed = $null - if (-not [string]::IsNullOrWhiteSpace($put.Content)) { - try { $parsed = $put.Content | ConvertFrom-Json } catch { $parsed = $put.Content } + foreach ($key in $ExclusionTagTable.Keys) { + if ($Tags.Contains($key) -and $Tags[$key] -eq $ExclusionTagTable[$key]) { + return $true } - - return [PSCustomObject]@{ Success = $true; Result = $parsed; ErrorMessage = ""; Submitted = $true } - } - catch { - Write-Warning "Asynchronous update of SQL VM '$Name' failed ($($_.Exception.Message)). Falling back to the synchronous 'az sql vm update' path." - return Invoke-AzCliLicenseUpdate -Description "SQL VM '$Name'" -Arguments $cliArguments } + return $false } @@ -763,6 +565,11 @@ function Format-ExecutionOutcomeSummary { $finalStatus = @() +# Whether Set-AzSql* license updates below are submitted with -AsJob (fire-and-forget, reported +# as "RequestSubmitted") or run synchronously to a terminal state (reported as "Updated"). SQL +# VMs never use this - see Invoke-SqlVmLicenseUpdate. +$submitAsync = -not $WaitForCompletion + # Convert to hashtable explicitly $tagTable = @{} if($ExclusionTags){ @@ -838,6 +645,33 @@ try { Write-Error "Can't import module Az.DataFactory: $_" } +# Ensure Az.Sql is available and import it (Get-/Set-AzSqlDatabase, Get-/Set-AzSqlInstance, +# Get-/Set-AzSqlElasticPool, Get-/Set-AzSqlInstancePool, Get-/Set-AzSqlServer all live here) +try { + if (-not (Get-Module -ListAvailable -Name Az.Sql)) { + Write-Output "Az.Sql module not found. Installing..." + Install-Module -Name Az.Sql -Scope CurrentUser -Repository PSGallery -Force + } else { + Write-Output "Az.Sql module is already installed." + } + Import-Module Az.Sql -Force +} catch { + Write-Error "Can't import module Az.Sql: $_" +} + +# Ensure Az.SqlVirtualMachine is available and import it (Get-/Update-AzSqlVM) +try { + if (-not (Get-Module -ListAvailable -Name Az.SqlVirtualMachine)) { + Write-Output "Az.SqlVirtualMachine module not found. Installing..." + Install-Module -Name Az.SqlVirtualMachine -Scope CurrentUser -Repository PSGallery -Force + } else { + Write-Output "Az.SqlVirtualMachine module is already installed." + } + Import-Module Az.SqlVirtualMachine -Force +} catch { + Write-Error "Can't import module Az.SqlVirtualMachine: $_" +} + # Map License Types for SQL VMs: LicenseIncluded -> PAYG, BasePrice -> AHUB. $SqlVmLicenseType = if ($LicenseType -eq "LicenseIncluded") { "PAYG" } else { "AHUB" } @@ -888,233 +722,181 @@ if (-not $subscriptions -or @($subscriptions).Count -eq 0) { exit 1 } -# Build resource group filter if specified. -$rgFilter = if ($ResourceGroup) { "resourceGroup=='$ResourceGroup'" } else { "" } +# Resource group and tag-based exclusion filtering are applied per resource type via +# Where-Object/Test-ExcludedByTags below instead of a shared JMESPath fragment. $scriptStartTime = Get-Date Write-Output "Our adventure begins at: $scriptStartTime`n" -$tagsFilter = $null -if($tagTable.Keys.Count -gt 0) { - $tagsFilter += " && " - $tagcount = $tagTable.Keys.Count - foreach ($tag in $tagTable.Keys) { - $tagcount-- - $tagsFilter += " tags.$($tag) != '$($tagTable[$tag])' " - if($tagcount -gt 0) { - $tagsFilter += " && " - } - } -} # Process each subscription. foreach ($sub in $subscriptions) { try { Write-Output "===== Entering Subscription: $($sub.name) =====" Write-Output "Switching context to subscription: $($sub.name)" - <#if($SqlVmLicenseType -eq "LicenseIncluded") { - Write-Output "SQL VM License Type: PAYG" - $ArcSQLServerExtensionDeployment = az tag list --resource-id "/subscriptions/$sub.id" --query "properties.tags.ArcSQLServerExtensionDeployment" -o json | ConvertFrom-Json - if ($ArcSQLServerExtensionDeployment -ne "LicenseIncluded") { - Write-Output "SQL VM License Type: PAYG" - az tag update --resource-id /"/subscriptions/$sub.id" --operation merge --tags ArcSQLServerExtensionDeployment=PAYG | Out-Null - } - } else { - Write-Output "SQL VM License Type: AHUB" - }#> Write-Output "License Type: $LicenseType" - az account set --subscription $sub.id - if ($LASTEXITCODE -ne 0) { - # Every az call below is scoped by the CLI's active subscription. If the switch + try { + Invoke-AzCmdletWithRetry -Description "Set-AzContext for subscription $($sub.id)" -ScriptBlock { + Set-AzContext -Subscription $sub.id -ErrorAction Stop | Out-Null + } + } + catch { + # Every cmdlet below is scoped by the current Az PowerShell context. If the switch # fails they would all silently run against whichever subscription was previously # selected, so resources in the wrong subscription could be updated. - Write-Warning "Skipping subscription '$($sub.name)' ($($sub.id)): the Azure CLI context could not be switched to it." + Write-Warning "Skipping subscription '$($sub.name)' ($($sub.id)): the Az PowerShell context could not be switched to it: $($_.Exception.Message)" continue } # --- Section: Update SQL Virtual Machines --- try { Write-Output "Seeking SQL Virtual Machines that require a license update to $SqlVmLicenseType..." - - # Build SQL VM query - $sqlVmQuery = "[?sqlServerLicenseType!='${SqlVmLicenseType}' && sqlServerLicenseType!='DR'" - - # Add resource group filter if specified - if ($rgFilter) { - $sqlVmQuery += " && $rgFilter" + + $sqlVMs = @(Invoke-AzCmdletWithRetry -Description "SQL virtual machines in subscription $($sub.id)" -ScriptBlock { + Get-AzSqlVM -ErrorAction Stop + }) + + # Mirrors the previous CLI --query filter: license mismatch, exclude 'DR', and the + # optional resource-group/name scope. + $sqlVMs = @($sqlVMs | Where-Object { $_.SqlServerLicenseType -ne $SqlVmLicenseType -and $_.SqlServerLicenseType -ne 'DR' }) + if ($ResourceGroup) { + $sqlVMs = @($sqlVMs | Where-Object { ($_.Id -split '/')[4] -eq $ResourceGroup }) } - - # Add name filter if ResourceName specified if ($ResourceName) { - $sqlVmQuery += " && name=='$ResourceName'" - } - - # Add tags filter if specified - if ($tagsFilter) { - $sqlVmQuery += " $tagsFilter" + $sqlVMs = @($sqlVMs | Where-Object { $_.Name -eq $ResourceName }) } - - $sqlVmQuery += "].{name:name, resourceGroup:resourceGroup, sqlServerLicenseType:sqlServerLicenseType, type:type, id:id, Location:location}" - Write-Output "Seeking SQL Virtual Machines with filter $sqlVmQuery..." - $sqlVmQueryResult = Invoke-AzCliQuery -Description "SQL virtual machines" -Arguments @('sql','vm','list','--query',$sqlVmQuery,'-o','json') - if (-not $sqlVmQueryResult.Success) { - Write-Warning "SQL virtual machines could not be listed, so none were assessed in this subscription. Re-run to retry." - } - $sqlVMs = $sqlVmQueryResult.Value - $sqlVmsToUpdate = [System.Collections.ArrayList]::new() - if($sqlVMs.Count -eq 0) { + if ($sqlVMs.Count -eq 0) { Write-Output "No SQL VMs found that require a license update." } else { Write-Output "Found $($sqlVMs.Count) SQL VMs that require a license update." } + foreach ($sqlvm in $sqlVMs) { + $vmResourceGroup = ($sqlvm.Id -split '/')[4] + $vmName = $sqlvm.Name - if($null -ne (az vm list --query "[?name=='$($sqlvm.name)' && resourceGroup=='$($sqlvm.resourceGroup)' $tagsFilter]")) - { - $vmStatusQuery = Invoke-AzCliQuery -Description "power state of VM '$($sqlvm.name)'" -Arguments @( - 'vm','get-instance-view','--resource-group',$sqlvm.resourceGroup,'--name',$sqlvm.name, - '--query',"{Name:name, ResourceGroup:resourceGroup, PowerState:instanceView.statuses[?starts_with(code, 'PowerState/')].displayStatus | [0]}",'-o','json') - if (-not $vmStatusQuery.Success) { - # Without a power state the VM would silently fail the "VM running" test - # below and be skipped as though it were switched off. - Write-Warning "Skipping SQL VM '$($sqlvm.name)': its power state could not be read, so it was not assessed. Re-run to retry." - $modifiedResources += [PSCustomObject]@{ - TenantID = $TenantId - SubID = ($sqlvm.id -split '/')[2] - ResourceName = $sqlvm.name - ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" - Status = "UnknownPowerState" - OriginalLicenseType = $sqlvm.sqlServerLicenseType - ResourceGroup = $sqlvm.resourceGroup - Location = $sqlvm.Location - UpdateResult = "Failed" - UpdateError = "Power state could not be read" - } - continue + if (Test-ExcludedByTags -Tags $sqlvm.Tag -ExclusionTagTable $tagTable) { + Write-Output "SQL VM '$vmName' in RG '$vmResourceGroup' Skipping because of tags..." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.Id -split '/')[2] + ResourceName = $vmName + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = "SkippedTags" + OriginalLicenseType = $sqlvm.SqlServerLicenseType + ResourceGroup = $vmResourceGroup + Location = $sqlvm.Location + UpdateResult = "SkippedTags" + UpdateError = "Excluded by tags filter" + } + continue + } + + $vmPowerState = $null + try { + $vmPowerState = Invoke-AzCmdletWithRetry -Description "power state of VM '$vmName'" -ScriptBlock { + (Get-AzVM -ResourceGroupName $vmResourceGroup -Name $vmName -Status -ErrorAction Stop).Statuses | + Where-Object { $_.Code -like 'PowerState/*' } | Select-Object -First 1 -ExpandProperty DisplayStatus + } + } + catch { + # Without a power state the VM would silently fail the "VM running" test + # below and be skipped as though it were switched off. + Write-Warning "Skipping SQL VM '$vmName': its power state could not be read, so it was not assessed. Re-run to retry." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.Id -split '/')[2] + ResourceName = $vmName + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = "UnknownPowerState" + OriginalLicenseType = $sqlvm.SqlServerLicenseType + ResourceGroup = $vmResourceGroup + Location = $sqlvm.Location + UpdateResult = "Failed" + UpdateError = "Power state could not be read" } - $vmStatus = $vmStatusQuery.Value | Select-Object -First 1 - if (($vmStatus.PowerState -eq "VM running") -and ($sqlvm.sqlServerLicenseType -ne "DR")) { + continue + } - $vmResult = "NotAttempted" - $vmError = "" + if ($vmPowerState -eq "VM running") { - if ($ReportOnly) { - $vmResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' (would change '$($sqlvm.sqlServerLicenseType)' -> '$SqlVmLicenseType')." - } else { - Write-Output "Updating SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' to license type '$SqlVmLicenseType'..." - $update = Invoke-SqlVmLicenseUpdate -ResourceId $sqlvm.id -Name $sqlvm.name -ResourceGroup $sqlvm.resourceGroup -LicenseType $SqlVmLicenseType - if ($update.Success) { - $finalStatus += $update.Result - $vmResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } - Write-Output "-- SQL VM '$($sqlvm.name)': $vmResult (license type '$SqlVmLicenseType')" - } - else { $vmResult = "Failed"; $vmError = $update.ErrorMessage } - } + $vmResult = "NotAttempted" + $vmError = "" - # Collect data after the attempt so the recorded outcome is accurate - $modifiedResources += [PSCustomObject]@{ - TenantID = $TenantId - SubID = ($sqlvm.id -split '/')[2] - ResourceName = $sqlvm.name - ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" - Status = $vmStatus.PowerState - OriginalLicenseType = $sqlvm.sqlServerLicenseType - ResourceGroup = $sqlvm.resourceGroup - Location = $sqlvm.Location - UpdateResult = $vmResult - UpdateError = $vmError - # Cores - } - } - elseif ($vmStatus.PowerState -ne "VM running") { - Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' is in '$($vmStatus.PowerState)' state (not running). Skipping update..." - $modifiedResources += [PSCustomObject]@{ - TenantID = $TenantId - SubID = ($sqlvm.id -split '/')[2] - ResourceName = $sqlvm.name - ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" - Status = $vmStatus.PowerState - OriginalLicenseType = $sqlvm.sqlServerLicenseType - ResourceGroup = $sqlvm.resourceGroup - Location = $sqlvm.Location - UpdateResult = "SkippedNotRunning" - UpdateError = "Underlying VM is in '$($vmStatus.PowerState)' state (must be running to update license)" + if ($ReportOnly) { + $vmResult = "ReportOnly" + Write-Output "ReportOnly mode enabled. Skipping modification for SQL VM '$vmName' in RG '$vmResourceGroup' (would change '$($sqlvm.SqlServerLicenseType)' -> '$SqlVmLicenseType')." + } else { + Write-Output "Updating SQL VM '$vmName' in RG '$vmResourceGroup' to license type '$SqlVmLicenseType'..." + # Always synchronous - SQL VM license updates must not be submitted via + # -AsJob/-NoWait (see Invoke-SqlVmLicenseUpdate), so this call blocks + # until the update completes regardless of -WaitForCompletion. + $update = Invoke-SqlVmLicenseUpdate -Name $vmName -ResourceGroup $vmResourceGroup -LicenseType $SqlVmLicenseType + if ($update.Success) { + $finalStatus += $update.Result + $vmResult = "Updated" + Write-Output "-- SQL VM '$vmName': $vmResult (license type '$SqlVmLicenseType')" } + else { $vmResult = "Failed"; $vmError = $update.ErrorMessage } } - elseif ($sqlvm.sqlServerLicenseType -eq "DR") { - Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' has license type 'DR'. Skipping update..." - $modifiedResources += [PSCustomObject]@{ - TenantID = $TenantId - SubID = ($sqlvm.id -split '/')[2] - ResourceName = $sqlvm.name - ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" - Status = $vmStatus.PowerState - OriginalLicenseType = $sqlvm.sqlServerLicenseType - ResourceGroup = $sqlvm.resourceGroup - Location = $sqlvm.Location - UpdateResult = "SkippedDR" - UpdateError = "SQL VM has Disaster Recovery ('DR') license type" - } + + # Collect data after the attempt so the recorded outcome is accurate + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.Id -split '/')[2] + ResourceName = $vmName + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = $vmPowerState + OriginalLicenseType = $sqlvm.SqlServerLicenseType + ResourceGroup = $vmResourceGroup + Location = $sqlvm.Location + UpdateResult = $vmResult + UpdateError = $vmError + # Cores } } else { - Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' Skipping because of tags..." + Write-Output "SQL VM '$vmName' in RG '$vmResourceGroup' is in '$vmPowerState' state (not running). Skipping update..." $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($sqlvm.id -split '/')[2] - ResourceName = $sqlvm.name + SubID = ($sqlvm.Id -split '/')[2] + ResourceName = $vmName ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" - Status = "SkippedTags" - OriginalLicenseType = $sqlvm.sqlServerLicenseType - ResourceGroup = $sqlvm.resourceGroup + Status = $vmPowerState + OriginalLicenseType = $sqlvm.SqlServerLicenseType + ResourceGroup = $vmResourceGroup Location = $sqlvm.Location - UpdateResult = "SkippedTags" - UpdateError = "Excluded by tags filter" + UpdateResult = "SkippedNotRunning" + UpdateError = "Underlying VM is in '$vmPowerState' state (must be running to update license)" } } } - if($sqlVmsToUpdate.Count -eq 0) { - Write-Output "No stopped SQL VMs needed to be started for a license update." - } else { - Write-Output "Found $($sqlVmsToUpdate.Count) to Start SQL VMs that require a license update." - } } catch { Write-Error "An error occurred while updating SQL VMs: $_" } - # --- Section: Update SQL Managed Instances (Stopped then Ready) " - $sqlMIsToUpdate = [System.Collections.ArrayList]::new() + # --- Section: Update SQL Managed Instances --- try { - - - # Build Managed Instance query - $miRunningQuery = "[?licenseType!='${LicenseType}' && state=='Ready'" - - # Add resource group filter if specified - if ($rgFilter) { - $miRunningQuery += " && $rgFilter" + $runningMIs = @(Invoke-AzCmdletWithRetry -Description "SQL Managed Instances in subscription $($sub.id)" -ScriptBlock { + Get-AzSqlInstance -ErrorAction Stop + }) + + # Mirrors the previous CLI --query filter (license mismatch, optional RG/name scope, + # tag exclusion). The 'state==Ready' pre-filter is not reproduced here: the Az.Sql + # module's managed-instance model does not expose a state/provisioning-state property + # to check it against, so a managed instance that is not actually ready is instead + # caught by Set-AzSqlInstance failing and being recorded as "Failed" below. + $runningMIs = @($runningMIs | Where-Object { $_.LicenseType -ne $LicenseType }) + if ($ResourceGroup) { + $runningMIs = @($runningMIs | Where-Object { $_.ResourceGroupName -eq $ResourceGroup }) } - - # Add name filter if ResourceName specified if ($ResourceName) { - $miRunningQuery += " && name=='$ResourceName'" - } - - # Add tags filter if specified - if ($tagsFilter) { - $miRunningQuery += " $tagsFilter" + $runningMIs = @($runningMIs | Where-Object { $_.ManagedInstanceName -eq $ResourceName }) } + $runningMIs = @($runningMIs | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) - $miRunningQuery += "].{name:name, state:state, resourceGroup:resourceGroup, licenseType:licenseType, location:location, id:id, ResourceType:type}" - - Write-Output "Processing SQL Managed Instances that are running with filter $miRunningQuery..." - $miQueryResult = Invoke-AzCliQuery -Description "SQL Managed Instances" -Arguments @('sql','mi','list','--query',$miRunningQuery,'-o','json') - if (-not $miQueryResult.Success) { - Write-Warning "SQL Managed Instances could not be listed, so none were assessed in this subscription. Re-run to retry." - } - $runningMIs = $miQueryResult.Value + Write-Output "Processing SQL Managed Instances that require a license update..." if($runningMIs.Count -eq 0) { Write-Output "No SQL Managed Instances found that require a license update." } else { @@ -1127,15 +909,16 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $miResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL Managed Instance '$($mi.name)' in RG '$($mi.resourceGroup)' (would change '$($mi.licenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Managed Instance '$($mi.ManagedInstanceName)' in RG '$($mi.ResourceGroupName)' (would change '$($mi.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating SQL Managed Instance '$($mi.name)' in RG '$($mi.resourceGroup)' to license type '$LicenseType'..." - $update = Invoke-AzCliLicenseUpdate -Description "SQL Managed Instance '$($mi.name)'" -SupportsNoWait -Arguments @( - 'sql','mi','update','--name',$mi.name,'--resource-group',$mi.resourceGroup,'--license-type',$LicenseType,'-o','json') + Write-Output "Updating SQL Managed Instance '$($mi.ManagedInstanceName)' in RG '$($mi.ResourceGroupName)' to license type '$LicenseType'..." + $update = Invoke-AzLicenseUpdate -Description "SQL Managed Instance '$($mi.ManagedInstanceName)'" -SupportsAsJob -ScriptBlock { + Set-AzSqlInstance -Name $mi.ManagedInstanceName -ResourceGroupName $mi.ResourceGroupName -LicenseType $LicenseType -AsJob:$submitAsync -Force -ErrorAction Stop + } if ($update.Success) { $finalStatus += $update.Result $miResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } - Write-Output "-- SQL Managed Instance '$($mi.name)': $miResult (license type '$LicenseType')" + Write-Output "-- SQL Managed Instance '$($mi.ManagedInstanceName)': $miResult (license type '$LicenseType')" } else { $miResult = "Failed"; $miError = $update.ErrorMessage } } @@ -1143,13 +926,13 @@ foreach ($sub in $subscriptions) { # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($mi.id -split '/')[2] - ResourceName = $mi.name - ResourceType = $mi.ResourceType - Status = $mi.state - OriginalLicenseType = $mi.licenseType - ResourceGroup = $mi.resourceGroup - Location = $mi.location + SubID = ($mi.Id -split '/')[2] + ResourceName = $mi.ManagedInstanceName + ResourceType = "Microsoft.Sql/managedInstances" + Status = "" + OriginalLicenseType = $mi.LicenseType + ResourceGroup = $mi.ResourceGroupName + Location = $mi.Location UpdateResult = $miResult UpdateError = $miError } @@ -1160,86 +943,29 @@ foreach ($sub in $subscriptions) { } # --- Section: Update SQL Databases and Elastic Pools --- - try { - Write-Output "Querying SQL Servers within this subscription..." - - # First, let's verify we're in the right subscription context - $currentSubContext = az account show --query id -o tsv - Write-Output "Currently in subscription context: $currentSubContext" - - if ($currentSubContext -ne $sub.id) { - Write-Output "Subscription context mismatch! Re-setting context..." - az account set --subscription $sub.id - if ($LASTEXITCODE -ne 0) { - Write-Warning "Could not re-select subscription '$($sub.id)'; skipping SQL Server, database and elastic pool processing to avoid querying the wrong subscription." - throw "Subscription context could not be set to '$($sub.id)'." - } - } - - # Build SQL Server query with proper JMESPath syntax - $serverQuery = "" - $filterAdded = $false - - # Start with an empty filter array - if ($rgFilter -or $ResourceName -or $tagsFilter) { - $serverQuery = "[" - - # Add resource group filter if specified - if ($rgFilter) { - $serverQuery += "?$rgFilter" - $filterAdded = $true - } - - # Add name filter if ResourceName is provided - if ($ResourceName) { - if ($filterAdded) { - $serverQuery += " && name=='$ResourceName'" - } else { - $serverQuery += "?name=='$ResourceName'" - $filterAdded = $true - } - } - - # Add tag filter if specified - if ($tagsFilter -and $filterAdded) { - $serverQuery += "$tagsFilter" - } elseif ($tagsFilter) { - $serverQuery += "?type=='Microsoft.Sql/servers'$tagsFilter" # A trick to make the tags filter work when it's the only filter - } - - $serverQuery += "]" - } else { - # No filters, get all servers - $serverQuery = "[]" + Write-Output "Querying SQL Servers within this subscription..." + + $allServers = @(Invoke-AzCmdletWithRetry -Description "SQL Servers in the subscription" -ScriptBlock { + Get-AzSqlServer -ErrorAction Stop + }) + Write-Output "Found a total of $($allServers.Count) SQL Servers in subscription" + + $servers = $allServers + if ($ResourceGroup) { + $servers = @($servers | Where-Object { $_.ResourceGroupName -eq $ResourceGroup }) } - - # Output the query for debugging - Write-Output "SQL Server query: $serverQuery" - - # Get all servers first as a fallback in case the query fails - $allServersQuery = Invoke-AzCliQuery -Description "SQL Servers in the subscription" -Arguments @('sql','server','list','-o','json') - $allServers = $allServersQuery.Value - Write-Output "Found a total of $($allServers.Count) SQL Servers in subscription" - - # Now try the filtered query - $serversQuery = Invoke-AzCliQuery -Description "SQL Servers matching the specified filters" -Arguments @('sql','server','list','--query',"$serverQuery",'-o','json') - if (-not $serversQuery.Success) { - # Distinguish a failed lookup from a genuinely empty one: falling through here - # would print "No SQL Servers found" and skip every database and elastic pool - # in the subscription as though there were nothing to do. - Write-Warning "SQL Servers could not be listed, so no databases or elastic pools were assessed in this subscription. Re-run to retry." - $servers = @() - } else { - $servers = $serversQuery.Value + if ($ResourceName) { + $servers = @($servers | Where-Object { $_.ServerName -eq $ResourceName }) } - + $servers = @($servers | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) + # Verify if we got any results - if ($null -eq $servers -or $servers.Count -eq 0) { - Write-Output "WARNING: No SQL Servers found with the specified filters." - Write-Output "Available SQL Servers in subscription:" + if ($servers.Count -eq 0) { + Write-Output "WARNING: No SQL Servers found with the specified filters." + Write-Output "Available SQL Servers in subscription:" $allServers | ForEach-Object { - Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + Write-Output " - $($_.ServerName) (Resource Group: $($_.ResourceGroupName))" } # Only fall back to scanning every server in the subscription when the @@ -1249,69 +975,41 @@ foreach ($sub in $subscriptions) { # resource-group filtered, so pools on out-of-scope servers would be # modified. if (-not $ResourceName -and -not $ResourceGroup) { - Write-Output "Proceeding with all SQL Servers since no specific ResourceName or ResourceGroup was provided." + Write-Output "Proceeding with all SQL Servers since no specific ResourceName or ResourceGroup was provided." $servers = $allServers } else { - Write-Output "Scope was explicitly restricted; not falling back to all SQL Servers. Skipping SQL Database and Elastic Pool processing." + Write-Output "Scope was explicitly restricted; not falling back to all SQL Servers. Skipping SQL Database and Elastic Pool processing." $servers = @() } } else { - Write-Output "Found $($servers.Count) SQL Servers matching the criteria." + Write-Output "Found $($servers.Count) SQL Servers matching the criteria." $servers | ForEach-Object { - Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + Write-Output " - $($_.ServerName) (Resource Group: $($_.ResourceGroupName))" } } # Process each server foreach ($server in $servers) { # Update SQL Databases - Write-Output "Scanning SQL Databases on server '$($server.name)' in resource group '$($server.resourceGroup)'..." - - # First get all databases to check if any exist - $allDbsQuery = Invoke-AzCliQuery -Description "databases on server '$($server.name)'" -Arguments @( - 'sql','db','list','--resource-group',$server.resourceGroup,'--server',$server.name,'-o','json') - if (-not $allDbsQuery.Success) { - Write-Warning "Skipping server '$($server.name)': its databases could not be listed, so they cannot be assessed. Re-run to retry." - continue - } - $allDbs = $allDbsQuery.Value - Write-Output "Found a total of $($allDbs.Count) databases on server '$($server.name)'" - - # Build database query with better error handling - $dbQuery = "[?licenseType!=null && licenseType!='$($LicenseType)'" - - # Add tags filter if specified - if ($tagsFilter) { - $dbQuery += "$tagsFilter" - } - if ($rgFilter) { - $dbQuery += " && $rgFilter" - } - - $dbQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:status}" - - Write-Output "Database query: $dbQuery" - - # Get databases with error handling + Write-Output "Scanning SQL Databases on server '$($server.ServerName)' in resource group '$($server.ResourceGroupName)'..." + try { - $dbsQuery = Invoke-AzCliQuery -Description "databases requiring an update on server '$($server.name)'" -Arguments @( - 'sql','db','list','--resource-group',$server.resourceGroup,'--server',$server.name,'--query',"$dbQuery",'-o','json') - if (-not $dbsQuery.Success) { - Write-Warning "Skipping server '$($server.name)': its databases could not be assessed for a license update. Re-run to retry." - continue - } - $dbs = $dbsQuery.Value - - if ($null -eq $dbs) { - Write-Output "No SQL Databases found on Server $($server.name) that require a license update." - } elseif ($dbs.Count -eq 0) { - Write-Output "No SQL Databases found on Server $($server.name) that require a license update." + $allDbs = @(Invoke-AzCmdletWithRetry -Description "databases on server '$($server.ServerName)'" -ScriptBlock { + Get-AzSqlDatabase -ResourceGroupName $server.ResourceGroupName -ServerName $server.ServerName -ErrorAction Stop + }) + Write-Output "Found a total of $($allDbs.Count) databases on server '$($server.ServerName)'" + + $dbs = @($allDbs | Where-Object { $null -ne $_.LicenseType -and $_.LicenseType -ne $LicenseType }) + $dbs = @($dbs | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) + + if ($dbs.Count -eq 0) { + Write-Output "No SQL Databases found on Server $($server.ServerName) that require a license update." } else { - Write-Output "Found $($dbs.Count) SQL Databases on Server $($server.name) that require a license update:" + Write-Output "Found $($dbs.Count) SQL Databases on Server $($server.ServerName) that require a license update:" $dbs | ForEach-Object { - Write-Output " - $($_.name) (Current license: $($_.licenseType))" + Write-Output " - $($_.DatabaseName) (Current license: $($_.LicenseType))" } - + foreach ($db in $dbs) { $dbResult = "NotAttempted" @@ -1319,15 +1017,16 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $dbResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL Database '$($db.name)' on server '$($server.name)' (would change '$($db.licenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Database '$($db.DatabaseName)' on server '$($server.ServerName)' (would change '$($db.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating SQL Database '$($db.name)' on server '$($server.name)' to license type '$LicenseType'..." - $update = Invoke-AzCliLicenseUpdate -Description "SQL Database '$($db.name)' on server '$($server.name)'" -SupportsNoWait -Arguments @( - 'sql','db','update','--name',$db.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'-o','json') + Write-Output "Updating SQL Database '$($db.DatabaseName)' on server '$($server.ServerName)' to license type '$LicenseType'..." + $update = Invoke-AzLicenseUpdate -Description "SQL Database '$($db.DatabaseName)' on server '$($server.ServerName)'" -SupportsAsJob -ScriptBlock { + Set-AzSqlDatabase -ResourceGroupName $server.ResourceGroupName -ServerName $server.ServerName -DatabaseName $db.DatabaseName -LicenseType $LicenseType -AsJob:$submitAsync -ErrorAction Stop + } if ($update.Success) { $finalStatus += $update.Result $dbResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } - Write-Output "-- SQL Database '$($db.name)': $dbResult (license type '$LicenseType')" + Write-Output "-- SQL Database '$($db.DatabaseName)': $dbResult (license type '$LicenseType')" } else { $dbResult = "Failed"; $dbError = $update.ErrorMessage } } @@ -1335,68 +1034,46 @@ foreach ($sub in $subscriptions) { # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($db.id -split '/')[2] - ResourceName = $db.name - ResourceType = $db.ResourceType - Status = $db.State - OriginalLicenseType = $db.licenseType - ResourceGroup = $db.resourceGroup - Location = $db.location + SubID = $sub.id + ResourceName = $db.DatabaseName + ResourceType = "Microsoft.Sql/servers/databases" + Status = $db.Status + OriginalLicenseType = $db.LicenseType + ResourceGroup = $db.ResourceGroupName + Location = $db.Location UpdateResult = $dbResult UpdateError = $dbError } } } } catch { - Write-Output "Error querying databases on server '$($server.name)': $_" + Write-Warning "Error querying databases on server '$($server.ServerName)': $_" } - # Update Elastic Pools with similar improved error handling + # Update Elastic Pools try { - Write-Output "Scanning Elastic Pools on server '$($server.name)'..." - - # First check if there are any elastic pools - $allPoolsQuery = Invoke-AzCliQuery -Description "elastic pools on server '$($server.name)'" -Arguments @( - 'sql','elastic-pool','list','--resource-group',$server.resourceGroup,'--server',$server.name,'--only-show-errors','-o','json') - if (-not $allPoolsQuery.Success) { - Write-Warning "Elastic pools on server '$($server.name)' could not be listed and were not assessed. Re-run to retry." - $allPools = @() - } else { - $allPools = $allPoolsQuery.Value - } - - if ($null -eq $allPools -or $allPools.Count -eq 0) { - Write-Output "No Elastic Pools found on server '$($server.name)'." + Write-Output "Scanning Elastic Pools on server '$($server.ServerName)'..." + + $allPools = @(Invoke-AzCmdletWithRetry -Description "elastic pools on server '$($server.ServerName)'" -ScriptBlock { + Get-AzSqlElasticPool -ResourceGroupName $server.ResourceGroupName -ServerName $server.ServerName -ErrorAction Stop + }) + + if ($allPools.Count -eq 0) { + Write-Output "No Elastic Pools found on server '$($server.ServerName)'." } else { - Write-Output "Found $($allPools.Count) total Elastic Pools on server '$($server.name)'." - - # Build elastic pool query with better formatting - $elasticPoolQuery = "[?licenseType!=null && licenseType!='$($LicenseType)'" - - # Add tags filter if specified - if ($tagsFilter) { - $elasticPoolQuery += " $tagsFilter" - } - - $elasticPoolQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:state}" - - Write-Output "Elastic Pool query: $elasticPoolQuery" - - $elasticPoolsQueryResult = Invoke-AzCliQuery -Description "elastic pools requiring an update on server '$($server.name)'" -Arguments @( - 'sql','elastic-pool','list','--resource-group',$server.resourceGroup,'--server',$server.name,'--query',"$elasticPoolQuery",'--only-show-errors','-o','json') - if (-not $elasticPoolsQueryResult.Success) { - Write-Warning "Elastic pools on server '$($server.name)' could not be assessed for a license update. Re-run to retry." - } - $elasticPools = $elasticPoolsQueryResult.Value - - if ($null -eq $elasticPools -or $elasticPools.Count -eq 0) { - Write-Output "No Elastic Pools found on Server $($server.name) that require a license update." + Write-Output "Found $($allPools.Count) total Elastic Pools on server '$($server.ServerName)'." + + $elasticPools = @($allPools | Where-Object { $null -ne $_.LicenseType -and $_.LicenseType -ne $LicenseType }) + $elasticPools = @($elasticPools | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) + + if ($elasticPools.Count -eq 0) { + Write-Output "No Elastic Pools found on Server $($server.ServerName) that require a license update." } else { - Write-Output "Found $($elasticPools.Count) Elastic Pools on Server $($server.name) that require a license update:" + Write-Output "Found $($elasticPools.Count) Elastic Pools on Server $($server.ServerName) that require a license update:" $elasticPools | ForEach-Object { - Write-Output " - $($_.name) (Current license: $($_.licenseType))" + Write-Output " - $($_.ElasticPoolName) (Current license: $($_.LicenseType))" } - + foreach ($pool in $elasticPools) { $poolResult = "NotAttempted" @@ -1404,15 +1081,16 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $poolResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for Elastic Pool '$($pool.name)' on server '$($server.name)' (would change '$($pool.licenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for Elastic Pool '$($pool.ElasticPoolName)' on server '$($server.ServerName)' (would change '$($pool.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating Elastic Pool '$($pool.name)' on server '$($server.name)' to license type '$LicenseType'..." - $update = Invoke-AzCliLicenseUpdate -Description "Elastic Pool '$($pool.name)' on server '$($server.name)'" -SupportsNoWait -Arguments @( - 'sql','elastic-pool','update','--name',$pool.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'--only-show-errors','-o','json') + Write-Output "Updating Elastic Pool '$($pool.ElasticPoolName)' on server '$($server.ServerName)' to license type '$LicenseType'..." + $update = Invoke-AzLicenseUpdate -Description "Elastic Pool '$($pool.ElasticPoolName)' on server '$($server.ServerName)'" -SupportsAsJob -ScriptBlock { + Set-AzSqlElasticPool -ResourceGroupName $server.ResourceGroupName -ServerName $server.ServerName -ElasticPoolName $pool.ElasticPoolName -LicenseType $LicenseType -AsJob:$submitAsync -ErrorAction Stop + } if ($update.Success) { $finalStatus += $update.Result $poolResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } - Write-Output "-- Elastic Pool '$($pool.name)': $poolResult (license type '$LicenseType')" + Write-Output "-- Elastic Pool '$($pool.ElasticPoolName)': $poolResult (license type '$LicenseType')" } else { $poolResult = "Failed"; $poolError = $update.ErrorMessage } } @@ -1420,13 +1098,13 @@ foreach ($sub in $subscriptions) { # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($pool.id -split '/')[2] - ResourceName = $pool.name - ResourceType = $pool.ResourceType + SubID = $sub.id + ResourceName = $pool.ElasticPoolName + ResourceType = "Microsoft.Sql/servers/elasticPools" Status = $pool.State - OriginalLicenseType = $pool.licenseType - ResourceGroup = $pool.resourceGroup - Location = $pool.location + OriginalLicenseType = $pool.LicenseType + ResourceGroup = $pool.ResourceGroupName + Location = $pool.Location UpdateResult = $poolResult UpdateError = $poolError } @@ -1434,44 +1112,36 @@ foreach ($sub in $subscriptions) { } } } catch { - Write-Output "Error processing Elastic Pools on server '$($server.name)': $_" + Write-Warning "Error processing Elastic Pools on server '$($server.ServerName)': $_" } } } catch { - Write-Output "An error occurred while processing SQL Databases or Elastic Pools: $_" + Write-Error "An error occurred while processing SQL Databases or Elastic Pools: $_" } # --- Section: Update SQL Instance Pools --- try { Write-Output "Searching for SQL Instance Pools that require a license update..." - - # Build instance pool query (skip the passive replicas) - $instancePoolsQuery = "[?licenseType!='${LicenseType}' && state=='Ready'" - - # Add resource group filter if specified - if ($rgFilter) { - $instancePoolsQuery += " && $rgFilter" + + $instancePools = @(Invoke-AzCmdletWithRetry -Description "SQL instance pools in subscription $($sub.id)" -ScriptBlock { + Get-AzSqlInstancePool -ErrorAction Stop + }) + + # Mirrors the previous CLI --query filter (license mismatch, optional RG/name scope, + # tag exclusion). The 'state==Ready' pre-filter is not reproduced here: the Az.Sql + # module's instance-pool model does not expose a state/provisioning-state property to + # check it against, so an instance pool that is not actually ready is instead caught + # by Set-AzSqlInstancePool failing and being recorded as "Failed" below. + $poolsToUpdate = @($instancePools | Where-Object { $_.LicenseType -ne $LicenseType }) + if ($ResourceGroup) { + $poolsToUpdate = @($poolsToUpdate | Where-Object { $_.ResourceGroupName -eq $ResourceGroup }) } - - # Add name filter if ResourceName specified if ($ResourceName) { - $instancePoolsQuery += " && name=='$ResourceName'" - } - - # Add tags filter if specified - if ($tagsFilter) { - $instancePoolsQuery += " $tagsFilter" - } - - $instancePoolsQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:status}" - - $instancePoolsQueryResult = Invoke-AzCliQuery -Description "SQL instance pools" -Arguments @('sql','instance-pool','list','--query',$instancePoolsQuery,'-o','json') - if (-not $instancePoolsQueryResult.Success) { - Write-Warning "SQL instance pools could not be listed, so none were assessed in this subscription. Re-run to retry." + $poolsToUpdate = @($poolsToUpdate | Where-Object { $_.Name -eq $ResourceName }) } - $instancePools = $instancePoolsQueryResult.Value - $poolsToUpdate = $instancePools | Where-Object { $_.licenseType -ne $LicenseType } - if($poolsToUpdate.Count -eq 0) { + $poolsToUpdate = @($poolsToUpdate | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) + + if ($poolsToUpdate.Count -eq 0) { Write-Output "No SQL Instance Pools found that require a license update." } else { Write-Output "Found $($poolsToUpdate.Count) SQL Instance Pools that require a license update." @@ -1483,15 +1153,16 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $ipResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL Instance Pool '$($pool.name)' in RG '$($pool.resourceGroup)' (would change '$($pool.licenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Instance Pool '$($pool.Name)' in RG '$($pool.ResourceGroupName)' (would change '$($pool.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating SQL Instance Pool '$($pool.name)' in RG '$($pool.resourceGroup)' to license type '$LicenseType'..." - $update = Invoke-AzCliLicenseUpdate -Description "SQL Instance Pool '$($pool.name)'" -SupportsNoWait -Arguments @( - 'sql','instance-pool','update','--name',$pool.name,'--resource-group',$pool.resourceGroup,'--license-type',$LicenseType,'-o','json') + Write-Output "Updating SQL Instance Pool '$($pool.Name)' in RG '$($pool.ResourceGroupName)' to license type '$LicenseType'..." + $update = Invoke-AzLicenseUpdate -Description "SQL Instance Pool '$($pool.Name)'" -SupportsAsJob -ScriptBlock { + Set-AzSqlInstancePool -Name $pool.Name -ResourceGroupName $pool.ResourceGroupName -LicenseType $LicenseType -AsJob:$submitAsync -ErrorAction Stop + } if ($update.Success) { $finalStatus += $update.Result $ipResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } - Write-Output "-- SQL Instance Pool '$($pool.name)': $ipResult (license type '$LicenseType')" + Write-Output "-- SQL Instance Pool '$($pool.Name)': $ipResult (license type '$LicenseType')" } else { $ipResult = "Failed"; $ipError = $update.ErrorMessage } } @@ -1499,13 +1170,13 @@ foreach ($sub in $subscriptions) { # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($pool.id -split '/')[2] - ResourceName = $pool.name - ResourceType = $pool.ResourceType - Status = $pool.State - OriginalLicenseType = $pool.licenseType - ResourceGroup = $pool.resourceGroup - Location = $pool.location + SubID = $sub.id + ResourceName = $pool.Name + ResourceType = "Microsoft.Sql/instancePools" + Status = "" + OriginalLicenseType = $pool.LicenseType + ResourceGroup = $pool.ResourceGroupName + Location = $pool.Location UpdateResult = $ipResult UpdateError = $ipError } @@ -2606,20 +2277,6 @@ function Connect-Azure { catch { Write-Error "An error occurred while testing the Azure connection: $_" } - # Ensure the user is logged in to Azure - try { - $account = az account show 2>$null | ConvertFrom-Json - if ($account) { - Write-Output "Logged in as: $($account.user.name)" - } - } catch { - Write-Output "Not logged in. Run 'az login'." - if($UseManageIdentity){ - az login --Identity | Out-Null - } else { - az login | Out-Null - } - } } function LoadAzModules { param( From 1709f379805f742a9aa55e49300eae46c95eb038 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 4 Sep 2026 10:33:37 -0700 Subject: [PATCH 10/11] Stop tracking TESTPLAN.md; ignore it going forward TESTPLAN.md records live test results against real Microsoft-internal subscriptions, resource names, tenant IDs, and account details. Per Travis's review feedback, remove it from source control and add it to .gitignore so this internal information is not published in the public repo. The file remains locally as a development record; it is simply no longer tracked. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/.gitignore | 5 + .../manage/manage-payg-transition/TESTPLAN.md | 273 ------------------ 2 files changed, 5 insertions(+), 273 deletions(-) delete mode 100644 samples/manage/manage-payg-transition/TESTPLAN.md diff --git a/samples/manage/manage-payg-transition/.gitignore b/samples/manage/manage-payg-transition/.gitignore index 69a6aae3c6..e249b9e746 100644 --- a/samples/manage/manage-payg-transition/.gitignore +++ b/samples/manage/manage-payg-transition/.gitignore @@ -6,3 +6,8 @@ manage-payg-transition/ runnow.ps1 ModifiedResources_*.csv *.log + +# Internal test log used during development. It records live test results against +# real Microsoft internal subscriptions, resource names, tenant/subscription IDs +# and account details, and must not be published in this public repository. +TESTPLAN.md diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md deleted file mode 100644 index 6e31a18c2c..0000000000 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ /dev/null @@ -1,273 +0,0 @@ -# Test Plan — manage-payg-transition.ps1 and modify-azure-sql-license-type.ps1 fixes - -This document records the tests performed to validate the changes on this branch, -against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7cd011db47`). - -## Changes under test - -1. **`modify-azure-sql-license-type.ps1`** - - `Connect-Azure` now reuses an existing valid Az PowerShell/CLI session for the - target tenant instead of always forcing re-login. - - Module presence check now verifies `Az.Accounts >= 4.2.0` directly instead of - checking for the `Az` meta-package (which caused false negatives and unnecessary/ - conflicting `Install-Module -Name Az -Force` calls). - -2. **`manage-payg-transition.ps1`** - - Removed unused `Force_Start_On_Resources` parameter usage. - - Fixed the Arc script download URL path - (`azure-hybrid-benefit` → `azure-arc-enabled-sql-server`). - - Reformatted wrapper argument line-continuation logic so backticks are placed - correctly regardless of how many arguments are present. - - Fixed `RunMode Single`: the Arc/Azure sub-scripts were referenced by the - generated wrapper but never downloaded first, causing - `term ... is not recognized` errors. Added the missing `Invoke-RestMethod` - download calls (mirroring the existing `Invoke-RemoteScript` logic used for - `RunMode Scheduled`). - - Made the script fully **self-contained**: embedded the complete logic of - `modify-azure-sql-license-type.ps1`, `modify-arc-sql-license-type.ps1`, and - `set-azurerunbook.ps1` directly in `manage-payg-transition.ps1`. No external - downloads from `raw.githubusercontent.com` occur anymore — the embedded - content is materialized to local files at runtime (required for local - script invocation and Azure Automation runbook import). - - Added `-TargetLicenseType` parameter (`PAYG` default, or `AHUB`) to control - which license model resources are transitioned to, translated internally to - each embedded script's own vocabulary (`LicenseIncluded`/`BasePrice` for - Azure SQL resources, `PAYG`/`LicenseOnly` for Arc SQL Server). Previously - the Arc transition target was hardcoded to `PAYG` only. - -## Test environment - -- Tenant: Microsoft (`72f988bf-86f1-41af-91ab-2d7cd011db47`) only, per requirement. -- Primary test resource: SQL Server VM `rajpoTest` - (`/subscriptions/6a37df99-a9de-48c4-91e5-7e6ab00b2362/resourceGroups/rajpobuddy/...`). -- Secondary test resource: SQL Managed Instance `abhisqlmi` - (`/subscriptions/fa58cf66-caaf-4ba9-875d-f310d3694845/resourceGroups/dms-demos-49855/...`). - -## Test cases and results - -| # | Test | Method | Result | -|---|------|--------|--------| -| 1 | Corrected Arc script URL resolves | `HEAD` request to the raw GitHub URL on `master` | ✅ 200 OK (old `azure-hybrid-benefit` path is missing/404s) | -| 2 | `Connect-Azure` reuses existing session | Ran script with an already-authenticated Az/CLI session | ✅ No re-login prompt, no hang; log shows "Reusing existing context/session" | -| 3 | `Az.Accounts` version check | Ran on a machine with `Az.Accounts 5.5.2` (not the `Az` meta-package) installed | ✅ Correctly detected as satisfying `>= 4.2.0`; no reinstall attempted | -| 4 | `RunMode Single -Target Azure`, SQL VM (AHUB→PAYG) | Reset `rajpoTest` to `AHUB`, ran `manage-payg-transition.ps1 -Target Azure -RunMode Single` end-to-end | ✅ Passed after fixing the missing-download bug; CSV report generated with exactly 1 resource (`rajpoTest`, `AHUB`→`PAYG`); final state confirmed via `az resource show` | -| 5 | `RunMode Single -Target Both` | Ran with `-Target Both` against `rajpoTest` (already PAYG) | ✅ No hangs; both Arc and Azure branches executed; correctly reported "no resources require update" (no false modification) | -| 6 | `RunMode Single -Target Arc`, real transition | Ran end-to-end against Arc-enabled machines in subscription `fbaf508b-cb61-4383-9cda-a42bfa0c7bc9` (tenant `d1623670`, AdaptiveCloudLab) | ✅ Passed (originally blocked — see note below). 12 machines transitioned to `PAYG`, including `sqltvm`, `az-sqlnode1` and `sac-mabs` (the latter two were `Paid`, confirming `-Force` works). Verified independently via `Search-AzGraph` against `microsoft.hybridcompute/machines/extensions` and via the per-resource CSV report. | -| 7 | Wrapper line-continuation formatting (Scheduled mode) | Code review of the `for` loop building `$wrapper` lines for both Arc and Azure blocks | ✅ Confirmed a trailing backtick is appended to every line except the last, for any number of arguments | -| 8 | SQL Managed Instance transition (regression, prior fixes) | Ran against `abhisqlmi` (`BasePrice` → `LicenseIncluded`) | ✅ Passed; exactly 1 resource modified out of 247 unrelated SQL Servers in the subscription | -| 9 | Azure Policy-based compliance sample (PR #1490, IaaS SQL VM variant) | End-to-end: policy definition, assignment, compliance scan, remediation against `rajpoTest` | ✅ Passed (separate from this branch's fixes, but validated as an alternate transition method during the same testing session) | -| 10 | Self-contained script: no external downloads | Ran `manage-payg-transition.ps1 -Target Azure -RunMode Single` (default `-TargetLicenseType PAYG`) against `rajpoTest` (reset to `AHUB`) | ✅ Passed; log shows only "Writing embedded script ... to ..." (local file write), no `Invoke-RestMethod`/network download calls; `rajpoTest` transitioned `AHUB`→`PAYG`, CSV report generated with exactly 1 resource | -| 11 | `-TargetLicenseType AHUB` reverse transition | Ran the same command with `-TargetLicenseType AHUB` against `rajpoTest` (now `PAYG`) | ✅ Passed; internal query correctly used `BasePrice` filter (Azure SQL vocabulary); `rajpoTest` transitioned `PAYG`→`AHUB`, CSV report generated with exactly 1 resource | -| 12 | `-Force` emitted as a bare switch | Ran `-RunMode Single` with no `-targetSubscription` and inspected the generated `runnow.ps1` | ✅ Passed after fix. Previously the generator emitted `-Force 'True'`; since `-Force` is a `[switch]` it does not consume the following token, so the orphaned `'True'` bound to the first positional parameter (`$SubId`), producing *"Subscription True was not found in tenant"*. Now emitted as a bare `-Force`. | -| 13 | `-TenantId` / `-ReportOnly` pass-through | Ran `-Target Arc -TenantId d1623670-... -targetResourceGroup rajposqltvm -TargetLicenseType AHUB -ReportOnly` | ✅ Passed; log shows "Using provided TenantId: d1623670-...", `Found 1 resource(s) to update`, "ReportOnly mode enabled. Skipping modification for: sqltvm". No resource was modified; generated `runnow.ps1` contains a bare `-ReportOnly` switch. | -| 14 | Resource count reported correctly | Same dry run as #13, before and after the fix | ✅ Passed after fix. `Found N resource(s) to update` read `$resources.Count` *before* the paging loop populated `$resources`, so it always printed `0` even when resources were found and modified. Now reads `$allResults.Count` after the loop and correctly reports `Found 1 resource(s) to update`. | -| 15 | Self-containment in an isolated folder | Copied **only** `manage-payg-transition.ps1` into an empty temp directory and ran it there with `-ReportOnly` | ✅ Passed; with zero sibling files present the script materialized `manage-payg-transition\modify-arc-sql-license-type.ps1` (19,100 B) from its embedded here-string, generated `runnow.ps1`, and produced a valid CSV report. Confirms no dependency on co-located files. | -| 16 | Embedded vs standalone Arc script in sync | `Compare-Object` between the embedded `Arc` here-string block and the standalone `modify-arc-sql-license-type.ps1` | ✅ Passed; 1 difference, a trailing blank line only — functionally identical. | -| 17 | Arc update outcome reported truthfully | Code review + dry run producing the CSV report | ✅ Passed after fix. `Set-AzConnectedMachineExtension` runs with `-NoWait` and had no `-ErrorAction`, so service-side failures (e.g. *"An extension of type ... is still processing"*) were non-terminating: the `catch` never fired and the script printed `Updated --` for resources that had actually failed. Added `-ErrorAction Stop` plus `UpdateResult`/`UpdateError` CSV columns (`NotAttempted`/`RequestSubmitted`/`Failed`). | -| 18 | Idempotent re-run | Re-ran the default (`-TargetLicenseType PAYG`) against an already-converged scope | ✅ Passed; reported `Found 0 resource(s) to update`. Resources already at the target license type are excluded by the discovery query (`properties.settings.LicenseType != ''`) by design, so repeat runs are safe. | -| 19 | README parameters match the script | Automated cross-check of every `-Param` used in a README example against the script's AST parameter block | ✅ Passed after fix. Previously 5 documented parameters did not exist (`-SubId`, `-ResourceGroup`, `-RunAt`, `-AutomationAccount`, `-ExclusionTag`), so every documented example would have failed. All 11 parameters now resolve. | - | 20 | `Stop-Transcript` no longer errors when transcription never started | Reproduced by pointing `Start-Transcript` at an unwritable path (`Z:\...`), then ran the script end-to-end | ✅ Passed after fix. Previously `Start-Transcript` could fail silently (unwritable log path, or a host that does not support transcription such as an Azure Automation runbook) and the unguarded `Stop-Transcript` at the end threw *"An error occurred stopping transcription: The host is not currently transcribing"* — surfacing a spurious failure after an otherwise successful run. Now emits `WARNING: Unable to start transcript logging: ... Continuing without a transcript.` and completes cleanly. Verified in all four copies (Arc/Azure × standalone/embedded). | -| 21 | Scope is not silently widened when `-targetResourceGroup` matches no SQL Servers | Ran `-Target Azure -targetSubscription 6a37df99-... -targetResourceGroup rajpobuddy` (an RG containing a SQL VM but **no** `Microsoft.Sql/servers`) | ✅ Passed after fix. Previously the script fell back to `$servers = $allServers` whenever the server query returned nothing and `-ResourceName` was absent, logging *"Proceeding with all SQL Servers since no specific ResourceName was provided"* and scanning 3 servers in unrelated resource groups. Because the elastic-pool query filters only on `licenseType`/tags — **with no resource-group filter** — a real (non-`ReportOnly`) run would have modified out-of-scope elastic pools; this test only escaped damage because those servers happened to have no pools. The fallback now requires **both** `-ResourceName` and `-ResourceGroup` to be absent, and the log correctly reads *"Scope was explicitly restricted; not falling back to all SQL Servers. Skipping SQL Database and Elastic Pool processing."* | -| 22 | End-to-end real (non-`ReportOnly`) run with all fixes applied | Ran `-Target Azure -RunMode Single -TenantId 72f988bf-... -targetSubscription 6a37df99-... -targetResourceGroup rajpobuddy -TargetLicenseType PAYG` against `rajpoTest` (`AHUB`) | ✅ Passed. Duration 2m22s. Transcript opened and closed cleanly (#20), the tenant was taken from `-TenantId` rather than the persisted context (#13), scope stayed inside `rajpobuddy` (#21), and the SQL VM transitioned `AHUB`→`PAYG`. **Verified independently of the script's own logging** via `az sql vm show -n rajpoTest -g rajpobuddy --query sqlServerLicenseType` → `PAYG`, and via `Search-AzGraph` across the whole resource group. Note the script prints `Updating SQL VM ...` *before* the `az sql vm update` call and does not inspect its result, so the log line alone is not proof of success — out-of-band verification is required. | -| 23 | Non-SSIS integration runtimes are no longer selected for licensing | Ran `-Target Azure -targetResourceGroup jh_adf` against `jhomerDataFactory`, whose only runtime is the default `AutoResolveIntegrationRuntime` | ✅ Passed after fix. The selection filter was `$_.Type -eq "Managed" -and $_.LicenseType -ne $LicenseType`. The default `AutoResolveIntegrationRuntime` is *also* `Type = "Managed"` (it is the data-flow/pipeline runtime, not an SSIS-IR) and has a **null** `LicenseType`, so `$null -ne 'LicenseIncluded'` evaluated true and it was selected. `Set-AzDataFactoryV2IntegrationRuntime` then round-tripped the full payload and the service rejected it with *"HTTP Status Code: Conflict / DataFactoryPropertyUpdateNotSupported / Updating property managedVirtualNetwork is not supported"*. Filter now also requires a non-empty `LicenseType`, which only SSIS integration runtimes carry. Log now reads *"No SSIS integration runtimes found on DataFactory 'jhomerDataFactory' that require a license update."* | -| 24 | DataFactory update failures reported truthfully | Same scenario as #23, before the fix | ✅ Passed after fix. Same class of bug as #17: `Set-AzDataFactoryV2IntegrationRuntime` had no `-ErrorAction Stop`, so the Conflict above was **non-terminating** — the surrounding `catch` never fired and the script still printed `-- DataFactory 'jhomerDataFactory' integration runtime updated to license type LicenseIncluded` immediately after two error blocks. Now wrapped in a per-runtime `try/catch` with `-ErrorAction Stop`, emitting a warning and recording `UpdateResult = Failed` with the service message. | -| 25 | SQL VM update result checked and recorded | Real run `-targetResourceGroup rajpobuddy -TargetLicenseType AHUB` against `rajpoTest` (`PAYG`) | ✅ Passed after fix. Previously `az sql vm update` output was piped straight to `ConvertFrom-Json` with no exit-code check, and the CSV row was appended *before* the attempt — so a failed update was recorded identically to a successful one (the gap noted in #22). Now checks `$LASTEXITCODE`, logs `-- SQL VM '' updated to license type ''` only on success, and appends the row *after* the attempt with `UpdateResult`/`UpdateError`. Verified: CSV recorded `OriginalLicenseType = PAYG`, `UpdateResult = Updated`, and `az sql vm show` independently confirmed `AHUB`. | -| 26 | CSV schema consistent across resource types | Inspected the header of a report containing a SQL VM row | ✅ Passed after fix. `Export-Csv` derives its header from the **first** object only, so once the DataFactory and SQL VM sections began emitting `UpdateResult`/`UpdateError`, a report whose first row came from any other section would have silently dropped those columns. Rows are now projected through an explicit 10-column `Select-Object` before export. Verified header: `TenantID,SubID,ResourceName,ResourceType,Status,OriginalLicenseType,ResourceGroup,Location,UpdateResult,UpdateError`. | -| 27 | All Azure resource types report update outcomes consistently | Code change plus real runs against `rajpoTest` (`AHUB`→`PAYG`→`AHUB`) | ✅ Passed. The result-checking added for SQL VMs in #25 was extended to the Managed Instance, SQL Database, elastic pool and instance pool sections, which all still piped `az ... update` into `ConvertFrom-Json` without inspecting the exit code and appended their CSV row *before* the attempt. The elastic pool section was the worst case: it used `2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue`, discarding the error text entirely and reporting only *"No result returned"*. All five `az` update paths now route through a shared `Invoke-AzCliLicenseUpdate` helper that checks `$LASTEXITCODE`, surfaces the real service error via `Write-Warning`, and returns a result object. Verified 6 row builders, 6 `UpdateResult` fields, and zero remaining raw `az ... update` pipes. | -| 28 | Helper does not corrupt its own return value | Unit-tested `Invoke-AzCliLicenseUpdate` in isolation against a succeeding and a failing `az` command | ✅ Passed after fix. The first implementation called `Write-Output "-- ... updated successfully"` inside the function; in PowerShell that merges into the **return value**, so the caller received a 2-element array instead of the result object and the message never reached the transcript. Caught during verification when the expected success line was missing from an otherwise-successful run. The helper is now silent on the success stream and each caller logs its own message. Verified both paths return `count=1`, `type=PSCustomObject`, with the failure path capturing the genuine service error (`ResourceGroupNotFound ... could not be found`). | -| 29 | Azure CLI updates block until the operation reaches a terminal state | Timed `az sql vm update` on `rajpoTest` and inspected the response body and an immediate re-read | ✅ Confirmed synchronous. The call returned after **126.1 s** with `provisioningState: Succeeded` and `sqlServerLicenseType: PAYG` in the response body, and an immediate `az sql vm show` already reported `PAYG`. The script passes no `--no-wait`, so every Azure CLI update path waits for completion and `UpdateResult = Updated` reflects a committed change. This also explains the multi-minute runtimes observed whenever a SQL VM is actually modified. | -| 30 | Arc updates are fire-and-forget by design | Code inspection of `Set-AzConnectedMachineExtension` call site | ⚠️ Confirmed **asynchronous** — documented, not a defect. The `-NoWait` flag means the script submits the extension write and moves on without waiting for the Arc agent to apply it, so `RequestSubmitted` is an accurate label and must not be read as "changed". Verified there is no polling or `Get-AzConnectedMachineExtension` follow-up anywhere in the Arc path. README now states this explicitly and gives a Resource Graph query for confirming the real end state. | -| 31 | `-WaitForCompletion` reports confirmed Arc outcomes | Live transition of `sqltvm` (`rajposqltvm`, tenant `d1623670`) with the new switch | ✅ Passed. Without the switch the report records `RequestSubmitted`; with it the script polled the extension to a terminal state and recorded `UpdateResult = Succeeded`, logging `Confirmed -- [sqltvm] provisioning state 'Succeeded'`. Run took 79.6 s. Verified independently via `Search-AzGraph`: the extension shows `LicenseType = LicenseOnly`, `provisioningState = Succeeded`, matching what the script reported. Polling backs off 5→30 s, and the helper also compares the applied `LicenseType` against the requested value, so a `Succeeded` provisioning state carrying the wrong license is reported as `Failed` rather than as success. | -| 32 | `-WaitForCompletion` is inert in `-ReportOnly` mode | Dry run with both switches against `rajposqltvm` | ✅ Passed. The switch bound correctly through the wrapper (generated `runnow.ps1` contains a bare `-WaitForCompletion`, confirming the earlier `-Force 'True'` class of bug does not recur), and no polling occurred because nothing was submitted — output was the usual `ReportOnly mode enabled. Skipping modification for: sqltvm`. | -| 33 | Asynchronous submission is the default for every resource type that supports it | Unit-tested `Invoke-AzCliLicenseUpdate` argument construction, verified CLI acceptance, and ran end-to-end | ✅ Passed. Previously Azure updates always blocked; they now submit with `--no-wait` unless `-WaitForCompletion` is passed. Verified the helper appends `--no-wait` only when the command supports it and the switch is absent (`sql db update -o json --no-wait` vs `sql db update -o json`), that the empty CLI output produced by `--no-wait` does not break result parsing, and that the real CLI accepts the flag on `sql db update` and `sql mi update` (failures return `ResourceNotFound`, not `unrecognized arguments`, confirming the flag parsed). Report values are `RequestSubmitted` when submitted asynchronously and `Updated` when waited on. | -| 34 | Resource types with no asynchronous option are identified rather than faked | `az ... --help` inspection across all five update commands, plus an ARM `PATCH` probe | ⚠️ Partly superseded by #36. `az sql vm update` and `Set-AzDataFactoryV2IntegrationRuntime` expose no `--no-wait`/`-AsJob` equivalent. A generic `az resource update`/`patch` fallback was ruled out (neither supports `--no-wait`), and a direct ARM `PATCH` against the SQL VM was rejected with `MissingPatchParameters: Approved values: tags, additionalVmPatch`. The conclusion drawn at the time — that SQL VMs must always wait — was **wrong**, because it only considered the Azure CLI and a `PATCH`; see #36. SSIS integration runtimes remain a genuine exception. | -| 35 | `-WaitForCompletion` reaches the Azure script through the wrapper | Ran the orchestrator with and without the switch and inspected the generated `runnow.ps1` | ✅ Passed. Emitted as a bare `-WaitForCompletion` (no repeat of the `-Force 'True'` binding defect from #12) and omitted entirely when not requested. The default run reported `Updated` for `rajpoTest` with the change confirmed in Azure (`PAYG`); the `-WaitForCompletion` run restored `AHUB`, also confirmed via `az sql vm show`. | -| 36 | SQL virtual machines honour async-by-default | Probed `Update-AzSqlVM -NoWait`/`-AsJob`, then a direct ARM read-modify-write, then two real runs against `rajpoTest` | ✅ Passed after fix. Corrects the mistaken conclusion in #34. `Update-AzSqlVM` **advertises** both `-NoWait` and `-AsJob` but both are broken in `Az.SqlVirtualMachine` 2.4.0: `-NoWait` forwards the bound parameter into `Get-AzSqlVM` (`A parameter cannot be found that matches parameter name 'NoWait'`) and `-AsJob` fails with `Object reference not set to an instance of an object`. A direct ARM read-modify-write **does** work — `PUT` returned HTTP 200 with an `Azure-AsyncOperation` header in **1.7 s** (versus 126 s for `az sql vm update`) and the change applied correctly. The SQL VM path now uses `Invoke-SqlVmLicenseUpdate`, which PUTs the body ARM just returned with only `sqlServerLicenseType` changed, and falls back to `az sql vm update` if the request fails or `-WaitForCompletion` is passed. Verified end-to-end: default run reported `RequestSubmitted` in **49.7 s** with no fallback warning and `az sql vm show` confirmed `PAYG`; the `-WaitForCompletion` run reported `Updated` in **189.3 s**. | -| 37 | Embedded orchestrator copy stays in sync after the SQL VM change | Re-synced `$EmbeddedScripts['Azure']` and parsed every embedded block | ✅ Passed. The standalone Azure script and the here-string embedded in `manage-payg-transition.ps1` diverged by 87 lines after the #36 edit; after re-syncing, `Compare-Object` reports 0 differences. All three embedded blocks parse cleanly (`Azure` 991 lines, `Arc` 609, `General` 299, 0 parse errors each) and `Invoke-SqlVmLicenseUpdate` is present in the orchestrator. | -| 38 | An unchanged Arc license type is never reported as a successful change | Ran the Arc script against `sqltvm` (`LicenseOnly`) with `-LicenseType PAYG -WaitForCompletion` but **without** `-Force` | ✅ Passed after fix. Found while restoring `sqltvm`: the run printed `Updated -- ... [sqltvm]` and `Confirmed -- [sqltvm] provisioning state 'Succeeded'`, yet the extension was still `LicenseOnly` afterwards. Root cause is pre-existing `-Force` semantics — when a machine already carries a `LicenseType` it is only overwritten with `-Force` — but `$WriteSettings` was set to `$true` by the unrelated `ConsentToRecurringPAYG` block, so the run wrote *other* settings and reported success for a license type that never changed. The wait helper could not catch it either, because it is passed `$settings['LicenseType']` (the **unmodified** value), so expected and applied matched. Now emits `WARNING: [sqltvm] LicenseType is 'LicenseOnly' and was NOT changed to 'PAYG'. Re-run with -Force to overwrite an existing license type.` Verified the same command now reports `Write Settings - False` with no `Updated`/`Confirmed` line. `-Force` semantics were deliberately left unchanged. | -| 39 | Arc wait helper tolerates a stale terminal provisioning state | Code fix plus a real `-Force` run against `sqltvm` | ✅ Passed. Because extension updates are submitted with `-NoWait`, the first poll can observe the **previous** operation's `Succeeded` state before the new one starts — with the old license type still in place, which the helper would have reported as a hard `Failed`. A terminal state carrying an unexpected license type is now treated as inconclusive and polling continues; the mismatch is only returned as `Failed` if it survives to the timeout. Verified live: the `-Force` run reported `Confirmed -- [sqltvm] provisioning state 'Succeeded'` and `Get-AzConnectedMachineExtension` independently confirmed `LicenseType = PAYG`, `provisioningState = Succeeded`. Observed state sequence during the earlier manual poll was `Updating → Creating → Succeeded` over ~2.5 minutes, confirming the race window is real. | -| 40 | Asynchronous SQL Database updates reach a committed state | User run of the orchestrator against subscription `20d62cea` (`DMSInternalDevTestER`) | ✅ Passed. First live exercise of the `--no-wait` path on a resource type other than a SQL VM, closing a documented gap. Two databases were submitted asynchronously — `ratruong-test-sqldb` and `TestDB`, both `BasePrice` — and each reported `RequestSubmitted`. Verified afterwards with `az sql db show`: both report `licenseType = LicenseIncluded`, confirming that `RequestSubmitted` was followed by a committed change. Managed instances, elastic pools and instance pools are still unexercised (see Known gaps). | -| 41 | A failed discovery query is never reported as "nothing to do" | Same run as #40, which emitted `ResourceGroupNotFound` for three servers, plus a follow-up ReportOnly run after the fix | ✅ Passed after fix. The most serious defect found so far. Discovery calls piped `az` straight into `ConvertFrom-Json` with no `$LASTEXITCODE` check — the same bug class fixed for *update* paths in #27, but still present in every *query* path. When `az sql db list` failed transiently the CLI printed `ERROR: (ResourceGroupNotFound)` to stderr, the variable became `$null`, and the script reported `Found a total of 0 databases` followed by `No SQL Databases found ... that require a license update`. **A failed query was indistinguishable from an empty one, so databases that needed transitioning were silently skipped while the run still looked clean.** Proved spurious: all four resource groups exist, `testdeaazuresqldbserver2` succeeded in the *same* resource group where `testdeasqlserverv1` failed, and all three servers listed their databases correctly when retried standalone. All discovery paths (SQL VM list, VM power state, MI list, server list, database list, elastic pool list, instance pool list) now route through a shared `Invoke-AzCliQuery` helper that checks the exit code, surfaces the service error as a warning, and returns the value normalised to an array. Callers that cannot proceed without the data now `continue` with an explicit skip warning instead of silently reporting zero. Unit-tested all three branches (success `Count=6`, failure `Success=False` carrying the real `ResourceGroupNotFound` text, empty `Success=True Count=0`). Re-run confirmed the counts are now correct: `demo731` and `sqldbinlinemigtestserver` report **1 database each instead of 0**, and `testdeasqlserverv1` reports 1. | -| 42 | A failed subscription context switch never causes work in the wrong subscription | Code inspection of both `az account set` call sites plus a regression run against `20d62cea` | ✅ Passed after fix. Every `az` call in the Azure script is scoped by the CLI's *active* subscription, but `az account set --subscription $sub.id` was issued without checking `$LASTEXITCODE` at either call site. If the switch failed, the whole subscription loop body — including the update calls — would have run against whichever subscription happened to be selected beforehand, so resources in an unintended subscription could have been modified. The primary call site now warns and `continue`s to the next subscription; the mid-loop re-selection (guarded by an existing context-mismatch check) throws, which the enclosing handler at L915 catches so SQL Server, database and elastic pool processing is skipped without aborting the run. Regression-verified: a `-ReportOnly` run still enumerates all 6 servers and the correct per-server database counts. Also audited the remaining unchecked `az ... | ConvertFrom-Json` pipes — the `az tag list` one is inside a commented-out block (dead code) and the rest are `az account show` login probes that are already guarded. | - -| 43 | A fully clean run reports "nothing to do" truthfully | User run of the orchestrator against `20d62cea` after the #41/#42 fixes, with every claim independently re-verified | ✅ Passed. The run completed in 1m20s with **zero errors or warnings** — the `ResourceGroupNotFound` noise from #41 is gone and every server now reports its correct database count. Each "nothing found" claim was checked against Azure rather than taken at face value: the only two real databases (`ratruong-test-sqldb`, `TestDB`) are already `LicenseIncluded` and every other database is a `master` with a null `licenseType`, correctly excluded by the `licenseType!=null` filter; the subscription's single SQL VM (`rradjousql2016`) is already `PAYG` and its single managed instance (`bhrout-mi`) is already `LicenseIncluded`, so both are correctly excluded by their filters rather than skipped; and there are 0 elastic pools, 0 instance pools and 0 Arc machines. Also confirms `No resources were marked for modification. No CSV generated.` is correct behaviour — the previous run generated a CSV precisely because it had two rows to record. | -| 44 | SSIS filter holds up against a large, varied estate | Enumerated every integration runtime in subscription `20d62cea` | ✅ Passed. Independently validates the #23 fix at a scale not previously tested: **22 integration runtimes across 8 data factories, every one with an empty `LicenseType`**, including 5 of type `Managed`. All were correctly reported as not requiring an update. Under the original filter (`$_.LicenseType -ne $LicenseType`, i.e. `$null -ne 'LicenseIncluded'` → true) all 22 would have been selected for update, and the `Managed` ones would have failed with the same `DataFactoryPropertyUpdateNotSupported: Updating property managedVirtualNetwork is not supported` Conflict seen in #23. Confirms a non-empty `LicenseType` is the correct discriminator for a genuine SSIS-IR. | - | 45 | A folder-scoped merge of `manage-payg-transition/` alone is self-sufficient | Extracted exactly the tracked contents of the folder at `HEAD` via `git archive HEAD samples/manage/manage-payg-transition` into an empty temp tree (5 files, both sibling scripts absent) and ran the orchestrator there against subscription `20d62cea` with `-ReportOnly` | ✅ Passed. Stronger than #15, which copied the working folder; this reproduces precisely what a folder-only merge would deliver. The orchestrator materialized both sub-scripts from its embedded here-strings and completed the Arc and Azure SQL passes with no missing-file, path or download errors, and the materialized copies were **byte-identical (0 differing lines by `Compare-Object`)** to the fixed standalone scripts. Confirmed no `Invoke-WebRequest`/`Invoke-RestMethod` fetch of the sub-scripts and no relative path escaping the folder — the only outbound URIs are PowerShell Gallery module links used by the Automation-Account path. **Caveat:** merging the folder alone would leave the standalone `modify-azure-sql-license-type.ps1` (+581) and `modify-arc-sql-license-type.ps1` (+167) stale on `master`, so callers invoking them directly would still hit the #40/#41 silent-skip, the #39 Arc false-success and the synchronous SQL VM update from #36, and the embedded-vs-standalone sync invariant would be broken. All 7 changed files should ship together. | -| 46 | End-of-execution outcome summary and root cause reporting | Added `Format-ExecutionOutcomeSummary` across standalone and orchestrator scripts, tested with `-ReportOnly` and live executions | ✅ Passed. For each run, a structured outcome table is printed to the console/transcript at the very end of execution (unified across all resource types with no intermediate duplicate tables), summarizing: `ResourceType` (sorted alphabetically), `Qualified`, `Updated or RequestSubmitted`, `Failed`, and `Skipped`. Directly beneath the summary table, a detailed `FAILURE & SKIP ROOT CAUSES` breakdown displays the Resource Name, Resource Group, ResourceType, Outcome, and exact error/root-cause message (e.g. stopped/deallocated VM power state, ESU licensing restrictions, tag exclusions, or service errors). If no issues occurred, cleanly outputs `No failures or skipped resources encountered.` | - -## Round 2: Azure CLI removal (2026-09-04) - -Requested by reviewer Travis Wright on PR #1511: replace all remaining Azure CLI (`az`) -usage in `modify-azure-sql-license-type.ps1` with native Az PowerShell cmdlets, with the -explicit requirement that SQL virtual machine license updates must **never** run -asynchronously (no `-NoWait`/`-AsJob`) because the underlying VM cannot reliably complete -that operation in the background. - -### Changes under test - -- Removed `Refresh-Path`, all Azure CLI install/login logic in `Connect-Azure`, and the - CLI helper functions (`Invoke-AzCliArgsWithRetry`, `Invoke-AzCliLicenseUpdate`, - `Invoke-AzCliQuery`). Replaced with `Invoke-AzCmdletWithRetry`, `Invoke-AzLicenseUpdate`, - and `Test-ExcludedByTags` (a PowerShell re-implementation of the previous JMESPath tag - filtering). -- `Invoke-AzLicenseUpdate` is the new shared `Set-AzSql*` update helper. It supports - `-AsJob:$submitAsync` for the resource types that accept it (Managed Instance, Database, - Elastic Pool, Instance Pool), replacing the async ARM-PUT/CLI-fallback path from - test #36. `Update-AzSqlVM` is now called synchronously and unconditionally, regardless of - `-WaitForCompletion`, because `-NoWait`/`-AsJob` are broken in `Az.SqlVirtualMachine` - 2.4.0 (documented in test #36; that finding is unaffected, only the fallback-to-CLI - half of the old behavior is gone). -- SQL VM, Managed Instance, SQL Server/Database/Elastic Pool, and Instance Pool discovery - now use `Get-AzSqlVM`, `Get-AzSqlInstance`, `Get-AzSqlServer`/`Get-AzSqlDatabase`/ - `Get-AzSqlElasticPool`, and `Get-AzSqlInstancePool` respectively, instead of `az ... list`. - Subscription context switching consolidated to a single `Set-AzContext` per subscription - loop iteration (all `az account set`/`az account show` calls removed). -- The Arc script (`modify-arc-sql-license-type.ps1`) was **not** changed in this round — - it already used only Az PowerShell cmdlets (`Search-AzGraph`, `Get-AzConnectedMachine`, - `Get-/Set-AzConnectedMachineExtension`) and never depended on the CLI. -- Added explicit ensure/install/import logic for `Az.Sql` and `Az.SqlVirtualMachine` - (previously only `Az.Accounts` and `Az.DataFactory` had this; see test #52). -- `README.md` updated to match: the async-by-default table now shows `-AsJob` instead of - `--no-wait` for Managed Instance/Database/Elastic Pool/Instance Pool, the SQL VM row now - reads "always waits", the stale "offline VMs will be reactivated" line was replaced with - the correct `SkippedNotRunning` behavior, and Prerequisites now lists the required Az - modules plus an explicit "Azure CLI is not required" note. - -### Test environment - -- Subscriptions: `20d62cea-b252-4a42-b9d6-16ad2636b3a5` (DMSInternalDevTestER) and - `6a37df99-a9de-48c4-91e5-7e6ab00b2362` (DMSBuddy), both tenant - `72f988bf-86f1-41af-91ab-2d7cd011db47`. -- Test SQL VM `payg-test-vm2` (SQL Server 2022 Standard edition, registered via - `New-AzSqlVM -LicenseType AHUB`) was provisioned in RG `deleterajpo` (Sub2) - specifically for this round, because no existing Standard/Enterprise-edition, running - SQL VM was available in either subscription (Developer/Web/Express editions cannot use - AHUB — `Update-AzSqlVM` rejects them with *"can only be converted to AHUB when the - edition ... is 'Standard' or 'Enterprise'"*). Deleted afterwards along with RG - `deleterajpo`. - -### Test cases and results - -| # | Test | Method | Result | -|---|------|--------|--------| -| 47 | Zero Azure CLI invocations remain | `Select-String -Pattern '\baz '` (word-boundary, case-sensitive) across the whole file, plus manual review of every remaining `az`-substring match | ✅ Passed. 0 matches for an actual CLI call; the only substring matches are `Az.*` PowerShell module names and comments describing what CLI logic was replaced. | -| 48 | SQL VM AHUB→PAYG, always synchronous | Live run against `payg-test-vm2` (Sub2), **without** `-WaitForCompletion` | ✅ Passed. Completed in ~65s and reported `Updated` (not `RequestSubmitted`) even though `-WaitForCompletion` was not passed, confirming the VM path never goes async. Verified live via `Get-AzSqlVM` → `SqlServerLicenseType = PAYG`. | -| 49 | SQL Managed Instance round trip | Live run against `bhrout-mi` (Sub1): PAYG→AHUB then AHUB→PAYG, both with `-WaitForCompletion` | ✅ Passed both directions; each reported `Updated` and was confirmed live via `Get-AzSqlInstance`; MI restored to its original state (`LicenseIncluded`). | -| 50 | SQL Database AHUB→PAYG then revert | Live run against `binuj_Northwind_sqlPkg` and `Northwind` on `binuj-sqldb-weu-2` (Sub2): `BasePrice`→`LicenseIncluded`, then reverted | ✅ Passed, both directions confirmed via `Get-AzSqlDatabase`. One revert attempt initially appeared incomplete because of a stale/cached read from `Get-AzSqlDatabase` immediately after the update call; re-querying a few seconds later showed the true state, and the second database was then explicitly reverted and re-confirmed. Not a script defect — a testing artifact of Azure API read-after-write latency. | -| 51 | `-ReportOnly` dry runs clean post-change | Ran against both subscriptions | ✅ Passed. No CLI, no errors, correct resource discovery in both. | -| 52 | `Az.Sql`/`Az.SqlVirtualMachine` never explicitly ensured (regression) | User ran the script live against `20d62cea` on a separate machine | ❌ Failed initially: `Get-AzSqlVM`/`Get-AzSqlInstance`/`Get-AzSqlServer`/`Get-AzSqlInstancePool` all reported *"term ... is not recognized"*. Root cause: only `Az.Accounts` and `Az.DataFactory` had ensure/install/import logic; `Az.Sql`/`Az.SqlVirtualMachine` happened to already be loaded on the original dev machine (from unrelated interactive testing), masking the gap. Fixed by adding the same ensure/install/import pattern for both modules. Re-validated via `[System.Management.Automation.Language.Parser]::ParseFile` (no syntax errors) and a re-run of the `-ReportOnly` dry run (now logs `Az.Sql module is already installed` / `Az.SqlVirtualMachine module is already installed`, correct resource discovery). | -| 53 | User live run end-to-end, post-fix | User ran `-targetSubscription 20d62cea... -TargetLicenseType AHUB` (no `-WaitForCompletion`) | ✅ Passed. Confirms the #52 fix on the user's own machine: `Az.Sql`/`Az.SqlVirtualMachine` reported "already installed", `bhrout-mi` and 2 databases (`ratruong-test-sqldb`, `TestDB`) transitioned to `BasePrice` with `RequestSubmitted` status (expected async default), and the deallocated SQL VM `rradjousql2016` was correctly reported `SkippedNotRunning`. No CLI calls, no errors. | -| 54 | RBAC-denied write correctly surfaced as a permissions issue, not a code defect | User attempted an Arc SQL Server transition against RG `arunguru-test` in subscription `77a28e80-58e1-402c-aa9e-bc2055d91a03`, got `AuthorizationFailed` on 4 machines | ✅ Confirmed genuine. `Get-AzRoleAssignment -ExpandPrincipalGroups` (including group-inherited roles) showed the user's only subscription-wide role there is *Reader*; their sole *Contributor* grant is scoped to a single Container Registry resource, not `arunguru-test` or any `Microsoft.HybridCompute/*` resource. The script's error message accurately reflects a missing `Microsoft.HybridCompute/machines/extensions/write` permission — not a script bug. | - -## Cleanup - -- All temporary test artifacts (generated wrapper scripts, materialized sub-scripts, - CSV reports, transcript logs, and isolated temp-folder copies of the orchestrator - used for self-containment testing) were removed after each run. -- A `.gitignore` was added to the sample folder so these runtime artifacts - (`manage-payg-transition/`, `runnow.ps1`, `ModifiedResources_*.csv`, `*.log`) - cannot be committed by accident. -- `rajpoTest` was left in `AHUB` state (following test #25) and `abhisqlmi` in - `LicenseIncluded` at the user's explicit request (for portal verification); they were - deliberately **not** reverted. -- The Arc machine `sqltvm` (`rajposqltvm`, tenant `d1623670`), which was switched to - `LicenseOnly` during tests #31 and #39, was restored to `PAYG` and confirmed via - `Get-AzConnectedMachineExtension`. -- **Round 2:** `binuj_Northwind_sqlPkg` and `Northwind` (test #50) were reverted to - `BasePrice`, confirmed via `Get-AzSqlDatabase`. `bhrout-mi` (test #49) was restored to - `LicenseIncluded`. The test SQL VM `payg-test-vm2` and RG `deleterajpo` (including a - leftover disk/NIC from an earlier, deleted `payg-test-vm1` attempt) were fully deleted - via `Remove-AzResourceGroup` and confirmed gone. A pre-existing, unrelated - `Microsoft.AzureArcData/sqlServerEsuLicenses` resource named `test` that already existed - in `deleterajpo` before this round's testing began was deleted as collateral damage of - the RG deletion; investigation via the subscription's Activity Log showed it was an - orphaned ESU license record (no backing `Microsoft.HybridCompute/machines` existed) with - no cost impact, so it was not recreated. - -## Known gaps / follow-ups - -- **Azure CLI has been fully removed as of Round 2** (see above) — this closes the - CLI-dependency concern that ran through tests #29, #33, #36 and #41/#42 in Round 1. - `Invoke-AzCliArgsWithRetry`/`Invoke-AzCliLicenseUpdate`/`Invoke-AzCliQuery` no longer - exist; every reference below to those helpers or to `az`/`--no-wait` describes Round 1 - (pre-CLI-removal) behavior and is retained for historical context only. -- **Async coverage is partial.** The non-blocking default (now `-AsJob`, previously - `--no-wait`) has been proven live for SQL databases (#40, #50) and Arc-connected machines - (#31), but **not** for managed instances (only synchronous round trips were run — #49, - and originally #8), elastic pools or instance pools. Elastic pools and instance pools do - not exist in any subscription reached so far across either round. -- **SQL virtual machines are now always synchronous** (Round 2), which is a stricter - guarantee than the "async ARM-PUT with CLI fallback" behavior tested in Round 1's #36 — - that finding (the ARM PUT working, `-NoWait`/`-AsJob` being broken in - `Az.SqlVirtualMachine` 2.4.0) is still accurate background, but the CLI-fallback half of - it no longer applies. -- `RunMode Scheduled` has **never been executed end-to-end**. The runbook import-path fix - (the embedded `set-azurerunbook.ps1` hardcoded `./PayTransitionDownloads/` while the - orchestrator materializes to `./manage-payg-transition/`) is validated by code review - and parse checks only. Confirming it requires provisioning a real Azure Automation - Account. -- 9 Arc machines in the test subscription could not be transitioned because their agents - are `Disconnected` or `Expired` (`ASRTEST`, `ASTTest`, `kerimASRvm1`, `sql2022image-Rajpo`, - `az-sqln01`, and four `Tag-TVM-sql2-*`). The extension setting can only be pushed to a - reachable agent, so these need to be re-run once the machines reconnect. One - (`Tag-TVM-sql2-fab2ee81`) also has `provisioningState = Failed` and is excluded by the - discovery query regardless. -- `microsoft.azurearcdata/SqlServerInstances` resources with `hostType = "Azure Virtual Machine"` - are read-only discovery mirrors; Azure rejects direct `licenseType` writes on them - ("must be set to 'Undefined'"). The writable resource for VM-hosted SQL is - `Microsoft.SqlVirtualMachine/SqlVirtualMachines/`. -- There is no automated check that the three embedded here-string copies stay in sync with - their standalone sources; test #16 was performed manually via `Compare-Object`. -- The generated wrapper interpolates values into single-quoted strings without escaping - embedded `'` characters. -- Both scripts write their transcript to a **fixed** path (`$env:TEMP\modify-azure-sql-license-type.log` - and `.\modify-arc-sql-license-type.log`), so every run overwrites the previous run's log. - This destroys evidence when diagnosing an earlier run; timestamped log names would be an - improvement. -- The Azure-side CSV report now carries `UpdateResult`/`UpdateError` for **all** resource - types (tests #24–#27). Failure paths for Managed Instances, SQL Databases, elastic pools - and instance pools are implemented and unit-verified via the shared helper (#28), but have - not been observed against a genuine service-side failure on those specific resource types — - only the SQL VM and DataFactory paths have been exercised end-to-end against real errors. -- **Azure updates are asynchronous by default as of test #33** (Round 1 CLI implementation: - `--no-wait`). **As of Round 2 (CLI removal), this is implemented as `Set-AzSql*` calls - with conditional `-AsJob`** for Managed Instances, databases, elastic pools and instance - pools, still reported as `RequestSubmitted`; `-WaitForCompletion` restores blocking - behaviour and the `Updated` result (re-confirmed live in test #53). SQL virtual machines - and SSIS integration runtimes remain exceptions and always wait — for SQL VMs this is now - because `-NoWait`/`-AsJob` are broken in `Az.SqlVirtualMachine` 2.4.0 (test #36, #48), - not because of any CLI limitation. The Arc path has always used `-NoWait` and is - unaffected by the CLI-removal work (it already used Az PowerShell cmdlets exclusively). - A consequence of the async default is that the report no longer proves a change was - applied unless `-WaitForCompletion` was used — verify out of band or re-run, as described - in the README. -- The asynchronous paths for Managed Instances, elastic pools and instance pools were - verified by unit-testing argument construction (both rounds), but have not been exercised - against a live resource of those types: the only Managed Instance candidates visible in - the tenant belong to other teams and were deliberately not modified, and no elastic pool - or instance pool resources exist in any subscription reached so far. The database async - path **has** now been exercised live end-to-end (test #50, `-WaitForCompletion` blocking - path; the non-blocking `-AsJob` path itself is still only unit-verified). - -## Required permissions - -Derived from every Azure PowerShell call made by the two scripts (Round 2: no Azure CLI -calls remain in either script): - -| Script | Operations performed | Minimum built-in role(s) | -|---|---|---| -| `modify-azure-sql-license-type.ps1` | `Get-/Update-AzSqlVM`; `Get-/Set-AzSqlInstance`; `Get-/Set-AzSqlDatabase`; `Get-/Set-AzSqlElasticPool`; `Get-/Set-AzSqlInstancePool`; `Get-AzDataFactoryV2(IntegrationRuntime)` / `Set-AzDataFactoryV2IntegrationRuntime`; `Get-AzSubscription`; `Set-AzContext` | **SQL DB Contributor** (covers `Microsoft.SqlVirtualMachine/*`, `Microsoft.Sql/managedInstances/*`, `Microsoft.Sql/servers/databases/*`, `Microsoft.Sql/servers/elasticPools/*`, `Microsoft.Sql/instancePools/*`) **+** write access to `Microsoft.DataFactory/factories/integrationRuntimes/*` (e.g. **Data Factory Contributor**) | -| `modify-arc-sql-license-type.ps1` | `Search-AzGraph` (Azure Resource Graph query over `microsoft.hybridcompute/machines` and `.../extensions`); `Get-AzConnectedMachine`; `Get/Set-AzConnectedMachineExtension` | **Azure Connected Machine Resource Administrator** (covers `Microsoft.HybridCompute/machines/extensions/*` write) — Resource Graph read is included in any role with `Microsoft.Resources/subscriptions/resourceGroups/resources/read` (e.g. **Reader**). Confirmed in test #54: a **Reader**-only account correctly receives `AuthorizationFailed` on the extension write. | -| Both | `Get-AzSubscription`, `Set-AzContext` | **Reader** at minimum on every subscription scanned | - -**Practical recommendation:** assign **Contributor** at the target subscription or -resource-group scope — it is a superset of all the writes above (SQL VM/MI/DB/elastic -pool/instance pool, Arc machine extensions, Data Factory integration runtimes) and -includes all required reads. For least-privilege, combine **SQL DB Contributor** + -**Azure Connected Machine Resource Administrator** (+ **Data Factory Contributor** if -SSIS Integration Runtime license updates are needed). - -**Authentication prerequisite (not an RBAC role):** the executing identity must be able -to complete `Connect-AzAccount` / `az login` for the target tenant (or use an already -authenticated session / service principal) — required by the `Connect-Azure` function -in both scripts. From e1163bff690ef8b3109d83dd139ffdf1a40219da Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 4 Sep 2026 14:00:27 -0700 Subject: [PATCH 11/11] Address Travis's PR review comments - Prompt before installing Az modules interactively; add -Force to skip prompts for non-interactive use - Remove Arc/Azure distinction wording when confirming account/tenant context; confirm interactively or require -Force - Simplify connection messages to 'Connecting to Azure...' and 'Reusing this session' - Fix 'SQL Servers' -> 'SQL servers' for Azure SQL DB logical server references - Remove stale 'mirrors the previous CLI' comments - Replace 'RG' abbreviation with 'resource group' throughout output messages - Remove SSIS/Data Factory integration runtime support entirely from the script and README - Simplify README SQL VM synchronous-behavior explanation to one sentence Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/README.md | 25 +- .../manage-payg-transition.ps1 | 261 ++++++------------ 2 files changed, 94 insertions(+), 192 deletions(-) diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index ce3e866529..7942bab980 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -19,9 +19,12 @@ If not specified, all subscriptions your role has access to are scanned. - You must have at least a *Contributor* RBAC role in each subscription you modify. - You must have a *Tag Contributor* *Contributor* RBAC role in each subscription you modify. - You must be connected to Azure AD and logged in to your Azure account. If your account have access to multiple tenants, make sure to log in with a specific tenant ID. -- The Az PowerShell modules `Az.Accounts`, `Az.Sql`, `Az.SqlVirtualMachine`, `Az.DataFactory`, +- The Az PowerShell modules `Az.Accounts`, `Az.Sql`, `Az.SqlVirtualMachine`, `Az.ConnectedMachine`, and `Az.ResourceGraph` are required; the script installs any that - are missing automatically (for the current user, from the PowerShell Gallery). + are missing automatically (for the current user, from the PowerShell Gallery). If you are + running the script interactively, it will ask for confirmation before installing a missing + module; pass `-Force` to install automatically without prompting (required for + non-interactive/unattended runs). > [!NOTE] > The Azure CLI (`az`) is **not** required. The script is implemented entirely with Az @@ -36,7 +39,6 @@ prefer a least-privilege role assignment instead, the dependent scripts require: |---|---| | SQL Server VMs, Managed Instances, Azure SQL Databases, Elastic Pools, Instance Pools | *SQL DB Contributor* | | Azure Arc-enabled SQL Server (Arc machine extensions) | *Azure Connected Machine Resource Administrator* | -| Azure Data Factory Azure-SSIS Integration Runtimes (only if present) | *Data Factory Contributor* | | Reading/enumerating subscriptions and resources (all of the above) | *Reader* (included in every role above) | | Tagging subscriptions with `ArcSQLServerExtensionDeployment:PAYG` | *Tag Contributor* | @@ -54,13 +56,14 @@ The script accepts the following command line parameters: |`-targetResourceGroup` |``|*Optional*: Limits the scope of the transition to the specified resource group.| |`-TenantId`|``|*Optional*. Azure AD tenant to operate against. If not specified, the tenant of the current Az PowerShell context (`(Get-AzContext).Tenant.Id`) is used. Specify explicitly to avoid running against whichever tenant happens to be selected in your session.| |`-ReportOnly`|*(switch)*|*Optional*. Read-only dry run: reports the resources that would be changed without modifying anything.| -|`-WaitForCompletion`|*(switch)*|*Optional*. Wait for each license change to reach a terminal state and report a confirmed outcome. By default changes are submitted asynchronously and reported as `RequestSubmitted`. SSIS integration runtimes always wait (no asynchronous option exists for them). See [How It Works](#how-it-works).| +|`-WaitForCompletion`|*(switch)*|*Optional*. Wait for each license change to reach a terminal state and report a confirmed outcome. By default changes are submitted asynchronously and reported as `RequestSubmitted`. See [How It Works](#how-it-works).| |`-UsePcoreLicense` | `Yes`, `No` |*Optional*. Passed to Arc script to control PCore licensing behavior. Set to `No` if not specified.| |`-TargetLicenseType`|`PAYG`, `AHUB`|*Optional*. License type to transition resources to. Defaults to `PAYG`.| |`-AutomationAccResourceGroupName`| ``|*Required* only if `-RunMode Scheduled`. Resource group hosting the Automation Account, created if it does not already exist. Not used by `-RunMode Single`.| |`-AutomationAccountName`| ``|*Optional*. Name of the Automation Account used in `Scheduled` mode. Defaults to `aaccAzureArcSQLLicenseType`.| |`-Location`|``|*Required* only if `-RunMode Scheduled`. Azure region for the Automation Account. Not used by `-RunMode Single`.| |`-cleanDownloads`|`$true`, `$false`|*Optional*. Removes the `.\manage-payg-transition\` working folder after the run. Defaults to `$false`.| +|`-Force`|*(switch)*|*Optional*. Skip interactive confirmation prompts (installing missing Az modules, continuing with the current Azure account/tenant context). Required for non-interactive/unattended runs, where the script will otherwise throw an error instead of prompting.| > [!NOTE] > The script does not expose a `-SubId` parameter; use `-targetSubscription`. Scoping to a @@ -119,18 +122,8 @@ The script accepts the following command line parameters: | SQL Managed Instance, database, elastic pool, instance pool | `-AsJob`, reports `RequestSubmitted` | waits, reports `Updated` | | Arc-connected machine | `-NoWait`, reports `RequestSubmitted` | polls the extension, reports `Succeeded` / `Failed` / `TimedOut` | | **SQL virtual machine** | **always waits**, reports `Updated` | same | - | **SSIS integration runtime** | **always waits**, reports `Updated` | same | - - SSIS integration runtimes are the one exception among the Azure-side resources, because - `Set-AzDataFactoryV2IntegrationRuntime` exposes no asynchronous option. - - SQL virtual machines are also always synchronous, but for a different reason: - `Update-AzSqlVM` advertises `-NoWait` and `-AsJob`, but both are broken in - `Az.SqlVirtualMachine` 2.4.0 (`-NoWait` forwards the bound parameter into `Get-AzSqlVM`, - which rejects it, and `-AsJob` throws a `NullReferenceException`). SQL VMs also cannot - reliably run this operation in the background. The script therefore always calls - `Update-AzSqlVM` synchronously and waits for it to reach a terminal state, regardless of - whether `-WaitForCompletion` was passed. + + SQL VM updates are always synchronous, regardless of `-WaitForCompletion`. For Arc, a `TimedOut` result is inconclusive rather than a failure — the agent may still apply the setting after the script stops waiting. diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 53f6569c45..67faf1162f 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -43,9 +43,8 @@ 'RequestSubmitted', meaning the service accepted the request rather than that the change has been applied. - Exceptions: SQL virtual machines and SSIS integration runtimes always wait, because - Update-AzSqlVM must not be run asynchronously for SQL VMs (see Invoke-SqlVmLicenseUpdate) - and Set-AzDataFactoryV2IntegrationRuntime provides no asynchronous option. Using this + Exception: SQL virtual machine updates are always synchronous, because Update-AzSqlVM + must not be run asynchronously for SQL VMs (see Invoke-SqlVmLicenseUpdate). Using this switch makes runs substantially slower on large estates. .PARAMETER AutomationAccResourceGroupName @@ -57,6 +56,12 @@ Required only when -RunMode is 'Scheduled'. Azure region for the Automation Account/resource group. Not needed/used for -RunMode Single. +.PARAMETER Force + Skip interactive confirmation prompts: install missing Az PowerShell modules and + proceed with the currently connected Azure account/tenant without asking first. + Required when running non-interactively (e.g. scheduled/unattended), since the + script will otherwise stop and ask for confirmation. + .EXAMPLE # Run immediately for both Azure and Arc, transitioning to PAYG (all defaults) .\manage-payg-transition.ps1 @@ -120,7 +125,10 @@ param( [string]$AutomationAccountName="aaccAzureArcSQLLicenseType", [Parameter(Mandatory=$false)] - [string]$Location=$null + [string]$Location=$null, + + [Parameter(Mandatory=$false)] + [switch]$Force ) # -AutomationAccResourceGroupName and -Location are only actually used by the @@ -135,6 +143,17 @@ if ($RunMode -eq "Scheduled") { } } +# Heuristic for "is a human watching this run right now". A real interactive console has +# UserInteractive = $true and an attached (non-redirected) console input stream; scheduled/ +# Automation/CI contexts typically fail at least one of these checks. +function Test-IsInteractiveSession { + try { + return [Environment]::UserInteractive -and -not [Console]::IsInputRedirected + } catch { + return $false + } +} + # Translate the simplified -TargetLicenseType switch into the vocabulary each # embedded script expects: # - modify-azure-sql-license-type.ps1 expects "LicenseIncluded" (PAYG) or "BasePrice" (AHUB). @@ -142,12 +161,22 @@ if ($RunMode -eq "Scheduled") { $azureLicenseType = if ($TargetLicenseType -eq "PAYG") { "LicenseIncluded" } else { "BasePrice" } $arcLicenseType = if ($TargetLicenseType -eq "PAYG") { "PAYG" } else { "Paid" } -# === Connect once here instead of letting each embedded script (Arc/Azure) redundantly -# re-authenticate. Az PowerShell's context is process-wide, so authenticating once -# means each embedded script's own Connect-Azure call finds an already-valid context -# and skips straight past its own login, instead of repeating that work. +# === Connect once here instead of letting each embedded script redundantly re-authenticate. +# Az PowerShell's context is process-wide, so authenticating once means each embedded +# script's own Connect-Azure call finds an already-valid context and skips straight past +# its own login, instead of repeating that work. if ($RunMode -eq "Single") { if (-not (Get-Module -ListAvailable -Name Az.Accounts)) { + if (-not $Force) { + if (Test-IsInteractiveSession) { + $response = Read-Host "The Az.Accounts PowerShell module is required but not installed. Install it now? (Y/N)" + if ($response -notmatch '^(y|yes)$') { + throw "Az.Accounts module is required to continue. Install it manually, or re-run with -Force to install it automatically." + } + } else { + throw "Az.Accounts module is required but not installed, and this session is not interactive. Re-run with -Force to install it automatically." + } + } Write-Output "Az.Accounts module not found. Installing..." Install-Module -Name Az.Accounts -Scope CurrentUser -Repository PSGallery -Force -AllowClobber } @@ -155,10 +184,20 @@ if ($RunMode -eq "Single") { $currentCtx = Get-AzContext -ErrorAction SilentlyContinue if ($currentCtx -and $currentCtx.Account -and ([string]::IsNullOrWhiteSpace($TenantId) -or $currentCtx.Tenant.Id -eq $TenantId)) { - Write-Output "Already connected to Azure PowerShell as: $($currentCtx.Account) (tenant $($currentCtx.Tenant.Id)). Reusing this context for both the Arc and Azure runs below." + Write-Output "Reusing this session ($($currentCtx.Account), tenant $($currentCtx.Tenant.Id))." + if (-not $Force) { + if (Test-IsInteractiveSession) { + $response = Read-Host "Continue using this account/tenant? (Y/N)" + if ($response -notmatch '^(y|yes)$') { + throw "Aborted by user. Sign in as a different account/tenant, or re-run with -Force to skip this confirmation." + } + } else { + throw "Currently connected as $($currentCtx.Account) (tenant $($currentCtx.Tenant.Id)). Re-run with -Force to proceed non-interactively, or run interactively to confirm." + } + } } else { - Write-Output "Connecting to Azure PowerShell once for this run..." + Write-Output "Connecting to Azure..." if ($TenantId) { Connect-AzAccount -Tenant $TenantId -ErrorAction Stop | Out-Null } else { Connect-AzAccount -ErrorAction Stop | Out-Null } $currentCtx = Get-AzContext @@ -188,13 +227,12 @@ $EmbeddedScripts['Azure'] = @' SQL Databases Elastic Pools SQL Instance Pools - DataFactory SSIS Integration Runtimes .VERSION 1.0.0 - Initial version. 1.0.2 - Modified to fix errors and to remove the auto-start of the offline resources. 1.0.3 - Added transcript. - 1.0.4 - Fixed RG filter for SQL DB + 1.0.4 - Fixed resource group filter for SQL DB .PARAMETER SubId A single subscription ID or a CSV file name containing a list of subscriptions. @@ -229,11 +267,9 @@ $EmbeddedScripts['Azure'] = @' -AsJob and reports "RequestSubmitted", meaning the service accepted the request rather than that the change has been applied. - Note: Set-AzDataFactoryV2IntegrationRuntime provides no asynchronous option, so SSIS - integration runtimes always wait regardless of this switch and always report "Updated". - SQL virtual machines are always updated synchronously via Update-AzSqlVM (no -AsJob/-NoWait) - because SQL VM license updates must not be submitted asynchronously; see - Invoke-SqlVmLicenseUpdate. + Note: SQL virtual machines are always updated synchronously via Update-AzSqlVM (no + -AsJob/-NoWait) regardless of this switch, because SQL VM license updates must not be + submitted asynchronously; see Invoke-SqlVmLicenseUpdate. #> param ( @@ -489,7 +525,6 @@ function Format-ExecutionOutcomeSummary { "Microsoft.Sql/servers/elasticPools" = "SQL Elastic Pools" "Microsoft.Sql/managedInstances" = "SQL Managed Instances" "Microsoft.Sql/instancePools" = "SQL Instance Pools" - "Microsoft.DataFactory/factories/integrationRuntimes" = "SSIS Integration Runtimes" "Microsoft.AzureArcData/SqlServerInstances" = "Arc SQL Server Instances" "Microsoft.HybridCompute/machines/extensions" = "Arc SQL Server (HybridCompute)" "WindowsAgent.SqlServer" = "Arc SQL Server Extension (Windows)" @@ -632,19 +667,6 @@ try { return } -# Ensure Az.DataFactory is available and import it -try { - if (-not (Get-Module -ListAvailable -Name Az.DataFactory)) { - Write-Output "Az.DataFactory module not found. Installing..." - Install-Module -Name Az.DataFactory -Scope CurrentUser -Force - } else { - Write-Output "Az.DataFactory module is already installed." - } - Import-Module Az.DataFactory -Force -} catch { - Write-Error "Can't import module Az.DataFactory: $_" -} - # Ensure Az.Sql is available and import it (Get-/Set-AzSqlDatabase, Get-/Set-AzSqlInstance, # Get-/Set-AzSqlElasticPool, Get-/Set-AzSqlInstancePool, Get-/Set-AzSqlServer all live here) try { @@ -755,8 +777,9 @@ foreach ($sub in $subscriptions) { Get-AzSqlVM -ErrorAction Stop }) - # Mirrors the previous CLI --query filter: license mismatch, exclude 'DR', and the - # optional resource-group/name scope. + # License mismatch, exclude 'DR' (Disaster Recovery secondary replicas, which are + # licensed separately and must not be overwritten), and the optional + # resource-group/name scope. $sqlVMs = @($sqlVMs | Where-Object { $_.SqlServerLicenseType -ne $SqlVmLicenseType -and $_.SqlServerLicenseType -ne 'DR' }) if ($ResourceGroup) { $sqlVMs = @($sqlVMs | Where-Object { ($_.Id -split '/')[4] -eq $ResourceGroup }) @@ -776,7 +799,7 @@ foreach ($sub in $subscriptions) { $vmName = $sqlvm.Name if (Test-ExcludedByTags -Tags $sqlvm.Tag -ExclusionTagTable $tagTable) { - Write-Output "SQL VM '$vmName' in RG '$vmResourceGroup' Skipping because of tags..." + Write-Output "SQL VM '$vmName' in resource group '$vmResourceGroup' Skipping because of tags..." $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($sqlvm.Id -split '/')[2] @@ -825,9 +848,9 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $vmResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL VM '$vmName' in RG '$vmResourceGroup' (would change '$($sqlvm.SqlServerLicenseType)' -> '$SqlVmLicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for SQL VM '$vmName' in resource group '$vmResourceGroup' (would change '$($sqlvm.SqlServerLicenseType)' -> '$SqlVmLicenseType')." } else { - Write-Output "Updating SQL VM '$vmName' in RG '$vmResourceGroup' to license type '$SqlVmLicenseType'..." + Write-Output "Updating SQL VM '$vmName' in resource group '$vmResourceGroup' to license type '$SqlVmLicenseType'..." # Always synchronous - SQL VM license updates must not be submitted via # -AsJob/-NoWait (see Invoke-SqlVmLicenseUpdate), so this call blocks # until the update completes regardless of -WaitForCompletion. @@ -856,7 +879,7 @@ foreach ($sub in $subscriptions) { } } else { - Write-Output "SQL VM '$vmName' in RG '$vmResourceGroup' is in '$vmPowerState' state (not running). Skipping update..." + Write-Output "SQL VM '$vmName' in resource group '$vmResourceGroup' is in '$vmPowerState' state (not running). Skipping update..." $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($sqlvm.Id -split '/')[2] @@ -882,11 +905,12 @@ foreach ($sub in $subscriptions) { Get-AzSqlInstance -ErrorAction Stop }) - # Mirrors the previous CLI --query filter (license mismatch, optional RG/name scope, - # tag exclusion). The 'state==Ready' pre-filter is not reproduced here: the Az.Sql - # module's managed-instance model does not expose a state/provisioning-state property - # to check it against, so a managed instance that is not actually ready is instead - # caught by Set-AzSqlInstance failing and being recorded as "Failed" below. + # Excludes running instances already at the target license type, plus the optional + # resource-group/name scope and tag exclusion. The 'state==Ready' pre-filter is not + # reproduced here: the Az.Sql module's managed-instance model does not expose a + # state/provisioning-state property to check it against, so a managed instance that + # is not actually ready is instead caught by Set-AzSqlInstance failing and being + # recorded as "Failed" below. $runningMIs = @($runningMIs | Where-Object { $_.LicenseType -ne $LicenseType }) if ($ResourceGroup) { $runningMIs = @($runningMIs | Where-Object { $_.ResourceGroupName -eq $ResourceGroup }) @@ -909,9 +933,9 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $miResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL Managed Instance '$($mi.ManagedInstanceName)' in RG '$($mi.ResourceGroupName)' (would change '$($mi.LicenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Managed Instance '$($mi.ManagedInstanceName)' in resource group '$($mi.ResourceGroupName)' (would change '$($mi.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating SQL Managed Instance '$($mi.ManagedInstanceName)' in RG '$($mi.ResourceGroupName)' to license type '$LicenseType'..." + Write-Output "Updating SQL Managed Instance '$($mi.ManagedInstanceName)' in resource group '$($mi.ResourceGroupName)' to license type '$LicenseType'..." $update = Invoke-AzLicenseUpdate -Description "SQL Managed Instance '$($mi.ManagedInstanceName)'" -SupportsAsJob -ScriptBlock { Set-AzSqlInstance -Name $mi.ManagedInstanceName -ResourceGroupName $mi.ResourceGroupName -LicenseType $LicenseType -AsJob:$submitAsync -Force -ErrorAction Stop } @@ -944,12 +968,12 @@ foreach ($sub in $subscriptions) { # --- Section: Update SQL Databases and Elastic Pools --- try { - Write-Output "Querying SQL Servers within this subscription..." + Write-Output "Querying SQL servers within this subscription..." - $allServers = @(Invoke-AzCmdletWithRetry -Description "SQL Servers in the subscription" -ScriptBlock { + $allServers = @(Invoke-AzCmdletWithRetry -Description "SQL servers in the subscription" -ScriptBlock { Get-AzSqlServer -ErrorAction Stop }) - Write-Output "Found a total of $($allServers.Count) SQL Servers in subscription" + Write-Output "Found a total of $($allServers.Count) SQL servers in subscription" $servers = $allServers if ($ResourceGroup) { @@ -962,8 +986,8 @@ foreach ($sub in $subscriptions) { # Verify if we got any results if ($servers.Count -eq 0) { - Write-Output "WARNING: No SQL Servers found with the specified filters." - Write-Output "Available SQL Servers in subscription:" + Write-Output "WARNING: No SQL servers found with the specified filters." + Write-Output "Available SQL servers in subscription:" $allServers | ForEach-Object { Write-Output " - $($_.ServerName) (Resource Group: $($_.ResourceGroupName))" } @@ -975,14 +999,14 @@ foreach ($sub in $subscriptions) { # resource-group filtered, so pools on out-of-scope servers would be # modified. if (-not $ResourceName -and -not $ResourceGroup) { - Write-Output "Proceeding with all SQL Servers since no specific ResourceName or ResourceGroup was provided." + Write-Output "Proceeding with all SQL servers since no specific ResourceName or ResourceGroup was provided." $servers = $allServers } else { - Write-Output "Scope was explicitly restricted; not falling back to all SQL Servers. Skipping SQL Database and Elastic Pool processing." + Write-Output "Scope was explicitly restricted; not falling back to all SQL servers. Skipping SQL Database and Elastic Pool processing." $servers = @() } } else { - Write-Output "Found $($servers.Count) SQL Servers matching the criteria." + Write-Output "Found $($servers.Count) SQL servers matching the criteria." $servers | ForEach-Object { Write-Output " - $($_.ServerName) (Resource Group: $($_.ResourceGroupName))" } @@ -1127,11 +1151,12 @@ foreach ($sub in $subscriptions) { Get-AzSqlInstancePool -ErrorAction Stop }) - # Mirrors the previous CLI --query filter (license mismatch, optional RG/name scope, - # tag exclusion). The 'state==Ready' pre-filter is not reproduced here: the Az.Sql - # module's instance-pool model does not expose a state/provisioning-state property to - # check it against, so an instance pool that is not actually ready is instead caught - # by Set-AzSqlInstancePool failing and being recorded as "Failed" below. + # Excludes pools already at the target license type, plus the optional + # resource-group/name scope and tag exclusion. The 'state==Ready' pre-filter is not + # reproduced here: the Az.Sql module's instance-pool model does not expose a + # state/provisioning-state property to check it against, so an instance pool that is + # not actually ready is instead caught by Set-AzSqlInstancePool failing and being + # recorded as "Failed" below. $poolsToUpdate = @($instancePools | Where-Object { $_.LicenseType -ne $LicenseType }) if ($ResourceGroup) { $poolsToUpdate = @($poolsToUpdate | Where-Object { $_.ResourceGroupName -eq $ResourceGroup }) @@ -1153,9 +1178,9 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $ipResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL Instance Pool '$($pool.Name)' in RG '$($pool.ResourceGroupName)' (would change '$($pool.LicenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Instance Pool '$($pool.Name)' in resource group '$($pool.ResourceGroupName)' (would change '$($pool.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating SQL Instance Pool '$($pool.Name)' in RG '$($pool.ResourceGroupName)' to license type '$LicenseType'..." + Write-Output "Updating SQL Instance Pool '$($pool.Name)' in resource group '$($pool.ResourceGroupName)' to license type '$LicenseType'..." $update = Invoke-AzLicenseUpdate -Description "SQL Instance Pool '$($pool.Name)'" -SupportsAsJob -ScriptBlock { Set-AzSqlInstancePool -Name $pool.Name -ResourceGroupName $pool.ResourceGroupName -LicenseType $LicenseType -AsJob:$submitAsync -ErrorAction Stop } @@ -1186,119 +1211,6 @@ foreach ($sub in $subscriptions) { Write-Error "An error occurred while updating SQL Instance Pools: $_" } - # --- Section: Update DataFactory SSIS Integration Runtimes --- - try { - Write-Output "Processing DataFactory SSIS Integration Runtime resources..." - # -ErrorAction Stop + retry: previously a transient network blip here (e.g. WinError - # 10048 / HttpRequestException) was a non-terminating error that got printed but not - # caught, so the script silently kept running DataFactory discovery against whatever - # subscription context was already selected instead of the intended one. - Invoke-AzCmdletWithRetry -Description "Set-AzContext for subscription $($sub.id)" -ScriptBlock { - Set-AzContext -Subscription $sub.id -ErrorAction Stop | Out-Null - } - - $dataFactories = Invoke-AzCmdletWithRetry -Description "Get-AzDataFactoryV2 in subscription $($sub.id)" -ScriptBlock { - Get-AzDataFactoryV2 -ErrorAction Stop - } - - $dataFactories | - Where-Object { - $_.ProvisioningState -eq "Succeeded" -and - ([string]::IsNullOrEmpty($ResourceGroup) -or $_.ResourceGroupName -eq $ResourceGroup) - } | - ForEach-Object { - $df = $_ - $IRs = $null - try { - $IRs = Invoke-AzCmdletWithRetry -Description "Get-AzDataFactoryV2IntegrationRuntime on '$($df.DataFactoryName)'" -ScriptBlock { - Get-AzDataFactoryV2IntegrationRuntime -ResourceGroupName $df.ResourceGroupName -DataFactoryName $df.DataFactoryName -ErrorAction Stop | - Where-Object { - $_.Type -eq "Managed" -and - $_.State -ne "Starting" -and - # Only SSIS integration runtimes carry a LicenseType. The default - # 'AutoResolveIntegrationRuntime' is also Type 'Managed' but has a null - # LicenseType; without this check it passes the filter below (since - # $null -ne $LicenseType) and the update fails with - # 'DataFactoryPropertyUpdateNotSupported: Updating property managedVirtualNetwork'. - (-not [string]::IsNullOrEmpty($_.LicenseType)) -and - $_.LicenseType -ne $LicenseType -and - ([string]::IsNullOrEmpty($ResourceName) -or $_.Name -eq $ResourceName) - } - } - } - catch { - # A failed query used to be indistinguishable from "no runtimes need updating" - # (the pipeline just returned nothing), silently skipping this DataFactory. - # Report it as a failure instead so it isn't mistaken for a clean result. - $queryError = $_.Exception.Message - Write-Warning "Unable to query integration runtimes on DataFactory '$($df.DataFactoryName)': $queryError" - $modifiedResources += [PSCustomObject]@{ - TenantID = $TenantId - SubID = $sub.id - ResourceName = $df.DataFactoryName - ResourceType = "Microsoft.DataFactory/factories" - Status = $df.ProvisioningState - OriginalLicenseType = $null - ResourceGroup = $df.ResourceGroupName - Location = $df.Location - UpdateResult = "Failed" - UpdateError = "Failed to query integration runtimes: $queryError" - } - return - } - - if ($null -eq $IRs -or @($IRs).Count -eq 0) { - Write-Output "No SSIS integration runtimes found on DataFactory '$($df.DataFactoryName)' that require a license update." - } else { - $IRs | ForEach-Object { - $ir = $_ - $irResult = "NotAttempted" - $irError = "" - - if ($ReportOnly) { - $irResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for DataFactory '$($df.DataFactoryName)' integration runtime '$($ir.Name)' (would change '$($ir.LicenseType)' -> '$LicenseType')." - } else { - if (-not [string]::IsNullOrEmpty($ResourceName) -and $ir.State -ne "Stopped") { - Write-Output "ADF Integration Service '$($ir.Name)' is not in stopped state" - $irResult = "SkippedNotStopped" - $irError = "Integration runtime is not in stopped state (must be stopped to update license)" - } else { - Write-Output "Updating DataFactory '$($df.DataFactoryName)' integration runtime '$($ir.Name)' to license type $LicenseType..." - try { - $result = Set-AzDataFactoryV2IntegrationRuntime -ResourceGroupName $df.ResourceGroupName -DataFactoryName $df.DataFactoryName -Name $ir.Name -LicenseType $LicenseType -Force -ErrorAction Stop - $finalStatus += $result - $irResult = "Updated" - Write-Output "-- DataFactory '$($df.DataFactoryName)' integration runtime '$($ir.Name)' updated to license type $LicenseType" - } - catch { - $irResult = "Failed" - $irError = $_.Exception.Message - Write-Warning "Failed to update integration runtime '$($ir.Name)' on DataFactory '$($df.DataFactoryName)': $irError" - } - } - } - - $modifiedResources += [PSCustomObject]@{ - TenantID = $TenantId - SubID = ($ir.Id -split '/')[2] - ResourceName = $ir.Name - ResourceType = "Microsoft.DataFactory/factories/integrationRuntimes" - Status = $ir.State - OriginalLicenseType = $ir.LicenseType - ResourceGroup = $df.ResourceGroupName - Location = $df.Location - UpdateResult = $irResult - UpdateError = $irError - } - } - } - } - } - catch { - Write-Error "An error occurred while updating DataFactory SSIS Integration Runtimes: $_" - } - } catch { Write-Error "An error occurred while processing subscription '$($sub.name)': $_" @@ -1603,7 +1515,6 @@ function Format-ExecutionOutcomeSummary { "Microsoft.Sql/servers/elasticPools" = "SQL Elastic Pools" "Microsoft.Sql/managedInstances" = "SQL Managed Instances" "Microsoft.Sql/instancePools" = "SQL Instance Pools" - "Microsoft.DataFactory/factories/integrationRuntimes" = "SSIS Integration Runtimes" "Microsoft.AzureArcData/SqlServerInstances" = "Arc SQL Server Instances" "Microsoft.HybridCompute/machines/extensions" = "Arc SQL Server (HybridCompute)" "WindowsAgent.SqlServer" = "Arc SQL Server Extension (Windows)" @@ -2206,7 +2117,7 @@ $EmbeddedScripts['General'] = @' The Automation account name. .PARAMETER Location - Azure region for the RG and account (e.g. "EastUS"). + Azure region for the resource group and account (e.g. "EastUS"). .PARAMETER RunbookName The name under which to import/publish the runbook. @@ -2248,7 +2159,6 @@ $context = $null $roleAssignments = @( @{ RoleName = "SQL DB Contributor"; Description = "For Azure SQL Databases and Azure SQL Elastic Pools" }, @{ RoleName = "SQL Managed Instance Contributor"; Description = "For Azure SQL Managed Instances and Azure SQL Instance Pools" }, - @{ RoleName = "Data Factory Contributor"; Description = "For Azure Data Factory SSIS Integration Runtimes" }, @{ RoleName = "Virtual Machine Contributor"; Description = "For SQL Servers in Azure Virtual Machines" }, @{RoleName = "SQL Server Contributor"; Description = "For Elastic-Pools in Azure Virtual Machines"}, @{RoleName = "Azure Connected Machine Resource Administrator"; Description = "For SQL Servers in Arc Virtual Machines"}, @@ -2496,7 +2406,6 @@ function Format-ExecutionOutcomeSummary { "Microsoft.Sql/servers/elasticPools" = "SQL Elastic Pools" "Microsoft.Sql/managedInstances" = "SQL Managed Instances" "Microsoft.Sql/instancePools" = "SQL Instance Pools" - "Microsoft.DataFactory/factories/integrationRuntimes" = "SSIS Integration Runtimes" "Microsoft.AzureArcData/SqlServerInstances" = "Arc SQL Server Instances" "Microsoft.HybridCompute/machines/extensions" = "Arc SQL Server (HybridCompute)" "WindowsAgent.SqlServer" = "Arc SQL Server Extension (Windows)"