From 8aeba78b54e1bfa40ef173565f7d18b99024a320 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 11:26:47 -0700 Subject: [PATCH 01/48] Fix auth-context reuse and Az.Accounts version check in modify-azure-sql-license-type.ps1 - Connect-Azure now reuses an existing valid Az/CLI session matching the target tenant instead of always forcing re-login, preventing hangs in non-interactive contexts. - Module presence check now verifies Az.Accounts >= 4.2.0 directly instead of checking for the Az meta-package, avoiding false negatives and unnecessary/conflicting reinstalls. - Includes pre-existing manage-payg-transition.ps1 fixes: removed unused Force_Start_On_Resources param usage, corrected Arc script download URL path, and cleaned up wrapper argument line-continuation formatting. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-azure-sql-license-type.ps1 | 71 +++++++++++-------- .../manage-payg-transition.ps1 | 32 ++++++--- 2 files changed, 64 insertions(+), 39 deletions(-) diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index f6f7a73191..cb168760f2 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -108,28 +108,41 @@ function Connect-Azure { } Write-Verbose "Environment detected: $envType" - # 2) Ensure Az.PowerShell context - Write-Output "Not connected to Azure PowerShell. Running Connect-AzAccount..." - if ($UseManagedIdentity -or $envType -eq 'AzureAutomation') { - $ctx = Connect-AzAccount -Tenant $TenantId -Identity -ErrorAction Stop | Out-Null + # 2) Ensure Az.PowerShell context - reuse an existing, already-authenticated context for the + # requested tenant instead of forcing a fresh interactive/managed-identity login every run. + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account -and $currentCtx.Tenant.Id -eq $TenantId) { + Write-Output "Already connected to Azure PowerShell as: $($currentCtx.Account) (tenant $TenantId). Reusing existing context." } else { - $ctx = Connect-AzAccount -Tenant $TenantId -ErrorAction Stop | Out-Null + Write-Output "Not connected to Azure PowerShell for tenant $TenantId. Running Connect-AzAccount..." + if ($UseManagedIdentity -or $envType -eq 'AzureAutomation') { + $ctx = Connect-AzAccount -Tenant $TenantId -Identity -ErrorAction Stop + } + else { + $ctx = Connect-AzAccount -Tenant $TenantId -ErrorAction Stop + } + Write-Output "Connected to Azure PowerShell as: $($ctx.Context.Account)" } - Write-Output "Connected to Azure PowerShell as: $($ctx.Account)" - # 3) Sync Azure CLI if available + # 3) Sync Azure CLI if available - reuse an existing az CLI session for the same tenant when possible. if (Get-Command az -ErrorAction SilentlyContinue) { - Write-Output "Running az login..." - if ($UseManagedIdentity -or $envType -eq 'AzureAutomation') { - az login --tenant $TenantId --identity | Out-Null + $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 { - az login --tenant $TenantId | Out-Null + 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 } - $acct = az account show --output json | ConvertFrom-Json + Write-Output "Azure CLI logged in as: $($acct.user.name)" } - Write-Output "Azure CLI logged in as: $($acct.user.name)" } @@ -170,26 +183,28 @@ if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { Install-PackageProvider -Name NuGet -Force } -# Check if Az module is installed -$installedModule = Get-InstalledModule -Name Az -ErrorAction SilentlyContinue - -if (-not $installedModule) { - Write-Output "Az module not found. Installing latest version..." - Install-Module -Name Az -Scope CurrentUser -Repository PSGallery -Force +# Check if the required Az.Accounts module (at the minimum version this script needs) is already +# available. Checking Get-InstalledModule -Name "Az" only detects the "Az" meta-package and false +# -positives as "not found" when the individual Az.* modules were installed some other way (e.g. +# preinstalled on the machine, installed individually, or via a package manager). That mismatch +# triggered an unnecessary "Install-Module -Name Az -Force", which fails/hangs when the modules are +# already loaded/in use. Instead, check directly for the module/version this script actually needs. +$requiredAzAccountsVersion = [version]"4.2.0" +$azAccountsAvailable = Get-Module -ListAvailable -Name Az.Accounts | + Where-Object { $_.Version -ge $requiredAzAccountsVersion } | + Sort-Object Version -Descending | + Select-Object -First 1 + +if (-not $azAccountsAvailable) { + Write-Output "Az.Accounts module (>= $requiredAzAccountsVersion) not found. Installing latest version..." + Install-Module -Name Az.Accounts -MinimumVersion $requiredAzAccountsVersion -Scope CurrentUser -Repository PSGallery -Force } else { - # Get the latest version available in the PSGallery - $latestVersion = (Find-Module -Name Az -Repository PSGallery).Version - if ($installedModule.Version -lt $latestVersion) { - Write-Output "Az module is outdated. Updating to latest version..." - Update-Module -Name Az -Force - } else { - Write-Output "Az module is already up to date. No action needed." - } + Write-Output "Az.Accounts module $($azAccountsAvailable.Version) already satisfies the minimum required version ($requiredAzAccountsVersion). No action needed." } # Import Az.Accounts with minimum version requirement try { - Import-Module Az.Accounts -MinimumVersion 4.2.0 -Force + Import-Module Az.Accounts -MinimumVersion $requiredAzAccountsVersion -Force Write-Output "Az.Accounts module imported successfully." } catch { Write-Error "Failed to import Az.Accounts: $_" diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 7125808a17..a101bd5733 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -78,13 +78,12 @@ $scriptUrls = @{ Azure = @{ URL = "https://raw.githubusercontent.com/$($environment)/$($git)/refs/heads/master/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1" Args = @{ - Force_Start_On_Resources = $true SubId = [string]$targetSubscription ResourceGroup = [string]$targetResourceGroup } } Arc = @{ - URL = "https://raw.githubusercontent.com/$($environment)/$($git)/refs/heads/master/samples/manage/azure-hybrid-benefit/modify-license-type/modify-arc-sql-license-type.ps1" + URL = "https://raw.githubusercontent.com/$($environment)/$($git)/refs/heads/master/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1" Args =@{ LicenseType= "PAYG" Force = $true @@ -172,7 +171,6 @@ $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "Resour $nextline2 = if(($null -ne $targetSubscription -and $targetSubscription -ne "")){"``"} $wrapper += @" `$RunbookArg =@{ - Force_Start_On_Resources = `$true $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "ResourceGroup= '$targetResourceGroup'" }) $(if ($null -ne $targetSubscription -and $targetSubscription -ne "") { "SubId= '$targetSubscription'" }) @@ -198,12 +196,18 @@ if($RunMode -eq "Single") { $fileName = Split-Path $scriptUrls.Arc.URL -Leaf $dest = Join-Path $downloadFolder $fileName - - $wrapper +="$dest ``" + $lines = @("$dest") foreach ($arg in $scriptUrls.Arc.Args.Keys) { if ("" -ne $scriptUrls.Arc.Args[$arg]) { - $wrapper+="-$($arg)='$($scriptUrls.Arc.Args[$arg])'" - } + $lines += "-$($arg) '$($scriptUrls.Arc.Args[$arg])'" + } + } + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($i -lt $lines.Count - 1) { + $wrapper += "$($lines[$i]) ``" + } else { + $wrapper += $lines[$i] + } } } @@ -211,12 +215,18 @@ if($RunMode -eq "Single") { $fileName = Split-Path $scriptUrls.Azure.URL -Leaf $dest = Join-Path $downloadFolder $fileName - - $wrapper +="$dest ``" + $lines = @("$dest") foreach ($arg in $scriptUrls.Azure.Args.Keys) { if ("" -ne $scriptUrls.Azure.Args[$arg]) { - $wrapper+="-$($arg)='$($scriptUrls.Azure.Args[$arg])'" - } + $lines += "-$($arg) '$($scriptUrls.Azure.Args[$arg])'" + } + } + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($i -lt $lines.Count - 1) { + $wrapper += "$($lines[$i]) ``" + } else { + $wrapper += $lines[$i] + } } } From 9e08b8a82d64f6bdf563be4145db83513d08ac1c Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 11:45:38 -0700 Subject: [PATCH 02/48] Fix RunMode Single: download scripts before invoking (were referenced but never fetched) Live end-to-end testing of RunMode Single revealed the Arc/Azure sub-scripts were never downloaded before being invoked via the generated wrapper, causing 'term not recognized' errors. Invoke-RestMethod download calls (matching Invoke-RemoteScript's Scheduled-mode logic) are now added before building the wrapper lines for both Arc and Azure targets. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition/manage-payg-transition.ps1 | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index a101bd5733..e0c64833eb 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -196,6 +196,9 @@ if($RunMode -eq "Single") { $fileName = Split-Path $scriptUrls.Arc.URL -Leaf $dest = Join-Path $downloadFolder $fileName + Write-Host "Downloading $($scriptUrls.Arc.URL) to $dest..." + Invoke-RestMethod -Uri $scriptUrls.Arc.URL -OutFile $dest + $lines = @("$dest") foreach ($arg in $scriptUrls.Arc.Args.Keys) { if ("" -ne $scriptUrls.Arc.Args[$arg]) { @@ -215,6 +218,9 @@ if($RunMode -eq "Single") { $fileName = Split-Path $scriptUrls.Azure.URL -Leaf $dest = Join-Path $downloadFolder $fileName + Write-Host "Downloading $($scriptUrls.Azure.URL) to $dest..." + Invoke-RestMethod -Uri $scriptUrls.Azure.URL -OutFile $dest + $lines = @("$dest") foreach ($arg in $scriptUrls.Azure.Args.Keys) { if ("" -ne $scriptUrls.Azure.Args[$arg]) { From 8168ebe5b5318d75308d9c5bca4fbb412870ed79 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 11:52:57 -0700 Subject: [PATCH 03/48] Add test plan documenting validation of payg-transition fixes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/TESTPLAN.md | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 samples/manage/manage-payg-transition/TESTPLAN.md diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md new file mode 100644 index 0000000000..c676e50b21 --- /dev/null +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -0,0 +1,61 @@ +# 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`). + +## 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 | Attempted against `rajpobuddy` RG Arc resources | ⚠️ Blocked — no Arc-extension-based resource (`Microsoft.AzureData` extension with `LicenseType != PAYG`) currently exists in the RG to exercise a real flip. The KQL query executed without error and returned 0 matches, confirming no regression in query logic, but an actual AHUB→PAYG transition was not exercised for Arc. | +| 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) | + +## Cleanup + +- All temporary test artifacts (generated wrapper scripts, downloaded sub-scripts, + CSV reports, a local test harness copy of the orchestrator script used to bypass + `raw.githubusercontent.com` during local-only testing) were removed after each run. +- `rajpoTest` was left in `PAYG` state at the end of testing. + +## Known gaps / follow-ups + +- Live Arc-target transition (test #6) should be re-validated once a suitable + Arc SQL Server resource with a non-PAYG `Microsoft.AzureData` extension is available. +- `RunMode Scheduled` was validated via code review and log output only, not via an + actual Windows Scheduled Task registration/execution. From aa91ad1614e8f90d7e02fa080787830d729dc830 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 11:59:18 -0700 Subject: [PATCH 04/48] Document required Azure RBAC permissions in TESTPLAN.md Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/TESTPLAN.md | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index c676e50b21..e1cd0ac669 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -59,3 +59,25 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c Arc SQL Server resource with a non-PAYG `Microsoft.AzureData` extension is available. - `RunMode Scheduled` was validated via code review and log output only, not via an actual Windows Scheduled Task registration/execution. + +## Required permissions + +Derived from every Azure CLI/PowerShell call made by the two scripts: + +| 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 | + +**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 3ba2007d148bdc1a023bf2cc479c9bf3e444bf04 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 12:13:09 -0700 Subject: [PATCH 05/48] Add detailed per-resource-type RBAC permissions breakdown to README Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/manage/manage-payg-transition/README.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index 62c614f0cd..82bca9ec4a 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -20,6 +20,19 @@ If not specified, all subscriptions your role has access to are scanned. - 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. +### Detailed permissions by resource type + +*Contributor* is a superset of everything below and is the simplest option. If you +prefer a least-privilege role assignment instead, the dependent scripts require: + +| Resource type modified | Built-in role needed | +|---|---| +| 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* | + --- # Launching the script From 330ab7d2b810ce1a851cf138e01184ad7006f88b Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 12:53:26 -0700 Subject: [PATCH 06/48] Make manage-payg-transition.ps1 self-contained; add -TargetLicenseType parameter - Embedded the full logic of modify-azure-sql-license-type.ps1, modify-arc-sql-license-type.ps1, and set-azurerunbook.ps1 directly in this script. No external downloads (raw.githubusercontent.com) are required to run it anymore; the embedded content is materialized to local files at runtime (required for local invocation and Azure Automation runbook import), replacing the old Invoke-RestMethod download step. - Added -TargetLicenseType parameter (PAYG [default] or AHUB) to control which license model resources are transitioned to. Internally translated to the vocabulary each embedded script expects: - Azure SQL resources: LicenseIncluded (PAYG) / BasePrice (AHUB) - Arc SQL Server: PAYG / LicenseOnly (AHUB-equivalent) Previously the Arc transition target was hardcoded to PAYG only. - Updated README to describe the self-contained behavior and the new parameter. Verified via live end-to-end testing against rajpoTest (SQL VM): - Default (-TargetLicenseType PAYG): AHUB -> PAYG succeeded, no downloads. - -TargetLicenseType AHUB: PAYG -> AHUB succeeded, no downloads. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/README.md | 18 +- .../manage-payg-transition.ps1 | 1710 ++++++++++++++++- 2 files changed, 1650 insertions(+), 78 deletions(-) diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index 82bca9ec4a..81b9547639 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -45,6 +45,7 @@ The script accepts the following command line parameters: |`-ResourceGroup` |``|*Optional*: Limits the scope of transition to a specified resource| |`-RunAt` |`YYYY-MM-DD HH:MM:SS` |*Optional*: Sets the transition time in UTC time zone. E.g. 2025-05-01 14:00:00 means May 1, 2025 at 2pm UTC time. If not specified, the transition will be executed immediately.| |`-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`.| |`-AutomationAccount`| ``|*Required* if `-RunAt` is specified. The script will automatically create an automation account with this name unless one with this name alreday exists. It will be used for the “General” runbook import operation. | |`-Location`|``|*Required* if `-RunAt` is specified. Azure region for the “General” runbook import operation.| |`-ExclusionTag`|``|*Optional*. Specifies the tag name and value to exclude the tagged offline VMs from the forced activation during the transition | @@ -57,15 +58,24 @@ Get-AzSubscription | Export-Csv .\mysubscriptions.csv -NoTypeInformation ## How It Works -- This script internally runs the following scripts +- This script is **fully self-contained**: the logic of the following three scripts is + embedded directly in `manage-payg-transition.ps1` and requires no external downloads + to run: `set-azurerunbook.ps1` - imports & publishes the helper runbook that and run if a scheduled execution is selected. - `modify-azure-sql-license-type.ps1` - configures the Azure SQL resources to pay-as-you-go + `modify-azure-sql-license-type.ps1` - configures the Azure SQL resources - `modify-license-type.ps1` configures the existing Arc SQL resources to pay-as-you-go + `modify-arc-sql-license-type.ps1` - configures the existing Arc SQL resources -- The dependent scripts are downloaded to `.\PaygTransitionDownloads\`. It is created automatically if doesn't exist. The downloaded scripts are refreshed automatically on each run to ensure that the up-to-date version is used. +- At runtime, the embedded content of each script is written to local files under + `.\manage-payg-transition\` (created automatically if it doesn't exist) so it can be + invoked as a normal PowerShell script / imported as an Azure Automation runbook. No + network calls to GitHub are made to fetch these dependent scripts. +- Use `-TargetLicenseType` (`PAYG` by default, or `AHUB`) to control which license + model resources are transitioned to. This value is translated internally to the + vocabulary each embedded script expects (e.g. `LicenseIncluded`/`BasePrice` for Azure + SQL resources, `PAYG`/`LicenseOnly` for Arc SQL Server). - The offline Azure VMs will be reactivated for a brief period to change the configuration. If the VM should not be recativated, use `-ExclusionTag` option. - The subscriptions in scope of the transition will be automatically tagged with `ArcSQLServerExtensionDeployment:PAYG` to ensure that the furure SQL Servers onboarded to Azure Arc are configured to use the pay-as-you-go subscription. For details, see [Manage automatic connection for SQL Server enabled by Azure Arc](https://learn.microsoft.com/sql/sql-server/azure-arc/manage-autodeploy). diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index e0c64833eb..58093eab30 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -3,9 +3,12 @@ Schedules or executes pay-transition operations for Azure and/or Arc. .DESCRIPTION - Depending on parameters, this script either: - - Downloads and runs the Azure and/or Arc pay-transition scripts once, or - - Registers a Windows Scheduled Task to invoke itself daily at 2 AM. + This script is fully self-contained: the Azure SQL, Arc SQL, and Azure Automation + runbook-registration logic are embedded directly in this file (no external + downloads are required to run it). Depending on parameters, this script either: + - Executes the Azure and/or Arc pay-transition logic once, or + - Registers a scheduled Azure Automation runbook to invoke itself + on a recurring basis. .PARAMETER Target Which environment(s) to process: @@ -15,13 +18,23 @@ .PARAMETER RunMode Whether to run immediately or schedule recurring runs: - - Single : Download & invoke once, then exit. - - Scheduled : Create or update the scheduled task calling this script daily. + - Single : Run once, then exit. + - Scheduled : Create or update the scheduled Automation runbook calling this + logic daily. + +.PARAMETER TargetLicenseType + The license type to transition resources to: + - PAYG (default) : Pay-as-you-go / consumption-based licensing. + - AHUB : Azure Hybrid Benefit / License-only (bring-your-own-license). .EXAMPLE - # Run immediately for both Azure and Arc + # Run immediately for both Azure and Arc, transitioning to PAYG .\manage-payg-transition.ps1 -Target Both -RunMode Single +.EXAMPLE + # Run immediately for both Azure and Arc, transitioning back to AHUB + .\manage-payg-transition.ps1 -Target Both -RunMode Single -TargetLicenseType AHUB + .EXAMPLE # Schedule daily runs for Azure only .\manage-payg-transition.ps1 -Target Azure -RunMode Scheduled @@ -39,6 +52,10 @@ param( [Parameter(Mandatory = $false, Position=2)] [bool]$cleanDownloads=$false, + [Parameter (Mandatory= $false)] + [ValidateSet("PAYG","AHUB", IgnoreCase=$false)] + [string] $TargetLicenseType="PAYG", + [Parameter (Mandatory= $false)] [ValidateSet("Yes","No", IgnoreCase=$false)] [string] $UsePcoreLicense="No", @@ -58,34 +75,1585 @@ param( [Parameter(Mandatory=$true)] [string]$Location=$null ) -$git = "sql-server-samples" -$environment = "microsoft" -if($null -ne $env:MYAPP_ENV) { - $git = "arc-sql-dashboard" - $environment = $env:MYAPP_ENV + +# Translate the simplified -TargetLicenseType switch into the vocabulary each +# embedded script expects: +# - modify-azure-sql-license-type.ps1 expects "LicenseIncluded" (PAYG) or "BasePrice" (AHUB). +# - modify-arc-sql-license-type.ps1 expects "PAYG" or "LicenseOnly" (AHUB-equivalent for Arc). +$azureLicenseType = if ($TargetLicenseType -eq "PAYG") { "LicenseIncluded" } else { "BasePrice" } +$arcLicenseType = if ($TargetLicenseType -eq "PAYG") { "PAYG" } else { "LicenseOnly" } + +# === Embedded dependency scripts (materialized to disk at runtime; nothing is downloaded) === +$EmbeddedScripts = @{} +$EmbeddedScripts['Azure'] = @' +<# +.SYNOPSIS + Updates the license type for Azure SQL resources (SQL DBs, Elastic Pools, Managed Instances, Instance Pools, SQL VMs) + to a specified model ("LicenseIncluded" or "BasePrice"). + +.DESCRIPTION + The script updates Azure SQL License types across subscriptions by modifying the license settings for a variety of SQL resources. It supports processing resources in one of the following ways: + The script processes several types of Azure SQL resources including: + + SQL Virtual Machines (SQL VMs) + SQL Managed Instances + 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 + +.PARAMETER SubId + A single subscription ID or a CSV file name containing a list of subscriptions. + +.PARAMETER ResourceGroup + Optional. Limit the scope to a specific resource group. + +.PARAMETER LicenseType + Optional. License type to set. Allowed values: "LicenseIncluded" (default) or "BasePrice". + +.PARAMETER ExclusionTags + Optional. If specified, excludes the resources that have this tag assigned. + +.PARAMETER TenantId + Optional. If specified, this tenant id to log in both PowerShell and CLI. Otherwise, the current login context is used. + +.PARAMETER ReportOnly + Optional. If true, generates a csv file with the list of resources that are to be modified, but doesn't make the actual change. + +.PARAMETER UseManagedIdentity + Optional. If true, logs in both PowerShell and CLI using managed identity. Required to run the script as a runbook. + +.PARAMETER ResourceName + Optional. If specified, only updates resources related to this name: + - For SQL Server: Updates all databases under the specified server + - For SQL Managed Instance: Updates the specified instance + - For SQL VM: Updates the specified VM +#> + +param ( + [Parameter(Mandatory = $false)] + [string] $SubId, + + [Parameter(Mandatory = $false)] + [string] $ResourceGroup, + + [Parameter(Mandatory = $false)] + [ValidateSet("LicenseIncluded", "BasePrice", IgnoreCase = $false)] + [string] $LicenseType = "LicenseIncluded", + + [Parameter (Mandatory= $false)] + [object] $ExclusionTags, + + [Parameter (Mandatory= $false)] + [string] $TenantId, + + [Parameter (Mandatory= $false)] + [switch] $ReportOnly, + + [Parameter (Mandatory= $false)] + [switch] $UseManagedIdentity, + + [Parameter (Mandatory= $false)] + [string] $ResourceName +) + + +Start-Transcript -Path "$env:TEMP\modify-azure-sql-license-type.log" +$scriptStartTime = Get-Date +Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" + +# Suppress unnecessary logging output +$VerbosePreference = "SilentlyContinue" +$DebugPreference = "SilentlyContinue" +$ProgressPreference = "SilentlyContinue" +$InformationPreference = "SilentlyContinue" +$WarningPreference = "SilentlyContinue" + +function Connect-Azure { + [CmdletBinding()] + param( + [Parameter (Mandatory= $true)] + [string] $TenantId, + + [Parameter (Mandatory= $false)] + [switch]$UseManagedIdentity + ) + + # 1) Detect environment + $envType = "Local" + if ($env:AZUREPS_HOST_ENVIRONMENT -and $env:AZUREPS_HOST_ENVIRONMENT -like 'cloud-shell*') { + $envType = "CloudShell" + } + elseif (($env:AZUREPS_HOST_ENVIRONMENT -and $env:AZUREPS_HOST_ENVIRONMENT -like 'AzureAutomation*') -or $PSPrivateMetadata.JobId) { + $envType = "AzureAutomation" + $UseManagedIdentity=$true + } + Write-Verbose "Environment detected: $envType" + + # 2) Ensure Az.PowerShell context - reuse an existing, already-authenticated context for the + # requested tenant instead of forcing a fresh interactive/managed-identity login every run. + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account -and $currentCtx.Tenant.Id -eq $TenantId) { + Write-Output "Already connected to Azure PowerShell as: $($currentCtx.Account) (tenant $TenantId). Reusing existing context." + } + else { + Write-Output "Not connected to Azure PowerShell for tenant $TenantId. Running Connect-AzAccount..." + if ($UseManagedIdentity -or $envType -eq 'AzureAutomation') { + $ctx = Connect-AzAccount -Tenant $TenantId -Identity -ErrorAction Stop + } + else { + $ctx = Connect-AzAccount -Tenant $TenantId -ErrorAction Stop + } + 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 (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)" + } +} + + +# Initialize final status and report counters. +$finalStatus = @() + +# Convert to hashtable explicitly +$tagTable = @{} +if($ExclusionTags){ + if($ExclusionTags.GetType().Name -eq "Hashtable"){ + $tagTable = $ExclusionTags + }else{ + ($ExclusionTags | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + $tagTable[$_.Name] = $_.Value + } + } } + +if (-not $TenantId) { + $TenantId = (Get-AzContext).Tenant.Id + Write-Output "No TenantId provided. Using current context TenantId: $TenantId" +} else { + Write-Output "Using provided TenantId: $TenantId" +} + +# Ensure connection with both PowerShell and CLI. Use V1 login. +Update-AzConfig -LoginExperienceV2 Off +if ($UseManagedIdentity) { + Connect-Azure ($TenantId, $UseManagedIdentity) +}else{ + Connect-Azure ($TenantId) +} + +# Ensure the required modules are imported + +# Ensure NuGet provider is available +if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -Force +} + +# Check if the required Az.Accounts module (at the minimum version this script needs) is already +# available. Checking Get-InstalledModule -Name "Az" only detects the "Az" meta-package and false +# -positives as "not found" when the individual Az.* modules were installed some other way (e.g. +# preinstalled on the machine, installed individually, or via a package manager). That mismatch +# triggered an unnecessary "Install-Module -Name Az -Force", which fails/hangs when the modules are +# already loaded/in use. Instead, check directly for the module/version this script actually needs. +$requiredAzAccountsVersion = [version]"4.2.0" +$azAccountsAvailable = Get-Module -ListAvailable -Name Az.Accounts | + Where-Object { $_.Version -ge $requiredAzAccountsVersion } | + Sort-Object Version -Descending | + Select-Object -First 1 + +if (-not $azAccountsAvailable) { + Write-Output "Az.Accounts module (>= $requiredAzAccountsVersion) not found. Installing latest version..." + Install-Module -Name Az.Accounts -MinimumVersion $requiredAzAccountsVersion -Scope CurrentUser -Repository PSGallery -Force +} else { + Write-Output "Az.Accounts module $($azAccountsAvailable.Version) already satisfies the minimum required version ($requiredAzAccountsVersion). No action needed." +} + +# Import Az.Accounts with minimum version requirement +try { + Import-Module Az.Accounts -MinimumVersion $requiredAzAccountsVersion -Force + Write-Output "Az.Accounts module imported successfully." +} catch { + Write-Error "Failed to import Az.Accounts: $_" + 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: $_" +} + +# Map License Types for SQL VMs: LicenseIncluded -> PAYG, BasePrice -> AHUB. +$SqlVmLicenseType = if ($LicenseType -eq "LicenseIncluded") { "PAYG" } else { "AHUB" } + +# Modified resources array +$modifiedResources = @() + +# Determine the subscriptions to process: CSV file, single subscription, or all accessible subscriptions. +if ($SubId -like "*.csv") { + $subscriptions = Import-Csv $SubId +}elseif($SubId -ne "") { + Write-Output "Passed Subscription $($SubId)" + $subscriptions = Get-AzSubscription -SubscriptionId $SubId +}else { + $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } +} + +# Build resource group filter if specified. +$rgFilter = if ($ResourceGroup) { "resourceGroup=='$ResourceGroup'" } else { "" } +$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 + + # --- 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" + } + + # Add name filter if ResourceName specified + if ($ResourceName) { + $sqlVmQuery += " && name=='$ResourceName'" + } + + # Add tags filter if specified + if ($tagsFilter) { + $sqlVmQuery += " $tagsFilter" + } + + $sqlVmQuery += "].{name:name, resourceGroup:resourceGroup, sqlServerLicenseType:sqlServerLicenseType, type:type, id:id, Location:location}" + + Write-Output "Seeking SQL Virtual Machines with filter $sqlVmQuery..." + $sqlVMs = az sql vm list --query $sqlVmQuery -o json | ConvertFrom-Json + $sqlVmsToUpdate = [System.Collections.ArrayList]::new() + 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) { + + if($null -ne (az vm list --query "[?name=='$($sqlvm.name)' && resourceGroup=='$($sqlvm.resourceGroup)' $tagsFilter]")) + { + $vmStatus = az 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 | ConvertFrom-Json + if (($vmStatus.PowerState -eq "VM running") -and ($sqlvm.sqlServerLicenseType -ne "DR")) { + + # Collect data before modification + $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 + # Cores + } + + + if (-not $ReportOnly) { + Write-Output "Updating SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' to license type '$SqlVmLicenseType'..." + $result = az sql vm update -n $sqlvm.name -g $sqlvm.resourceGroup --license-type $SqlVmLicenseType -o json | ConvertFrom-Json + $finalStatus += $result + } + } + } + else { + Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' Skipping because of tags..." + } + } + if($sqlVmsToUpdate.Count -eq 0) { + Write-Output "No SQL VMs found to start that require 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() + try { + + + # Build Managed Instance query + $miRunningQuery = "[?licenseType!='${LicenseType}' && state=='Ready'" + + # Add resource group filter if specified + if ($rgFilter) { + $miRunningQuery += " && $rgFilter" + } + + # Add name filter if ResourceName specified + if ($ResourceName) { + $miRunningQuery += " && name=='$ResourceName'" + } + + # Add tags filter if specified + if ($tagsFilter) { + $miRunningQuery += " $tagsFilter" + } + + $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..." + $runningMIs = az sql mi list --query $miRunningQuery -o json | ConvertFrom-Json + if($runningMIs.Count -eq 0) { + Write-Output "No SQL Managed Instances found that require a license update." + } else { + Write-Output "Found $($runningMIs.Count) SQL Managed Instances that require a license update." + } + foreach ($mi in $runningMIs) { + + # Collect data before modification + $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 + } + + if (-not $ReportOnly) { + Write-Output "Updating SQL Managed Instance '$($mi.name)' in RG '$($mi.resourceGroup)' to license type '$LicenseType'..." + $result = az sql mi update --name $mi.name --resource-group $mi.resourceGroup --license-type $LicenseType -o json | ConvertFrom-Json + $finalStatus += $result + } + } + } + catch { + Write-Error "An error occurred while updating SQL Managed Instances: $_" + } + + # --- 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 + } + + # 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 = "[]" + } + + # Output the query for debugging + Write-Output "SQL Server query: $serverQuery" + + # Get all servers first as a fallback in case the query fails + $allServers = az sql server list -o json | ConvertFrom-Json + Write-Output "Found a total of $($allServers.Count) SQL Servers in subscription" + + # Now try the filtered query + $servers = az sql server list --query "$serverQuery" -o json | ConvertFrom-Json + + # 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:" + $allServers | ForEach-Object { + Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + } + + # Use all servers if no specific resource name was provided + if (-not $ResourceName) { + Write-Output "Proceeding with all SQL Servers since no specific ResourceName was provided." + $servers = $allServers + } + } else { + Write-Output "Found $($servers.Count) SQL Servers matching the criteria." + $servers | ForEach-Object { + Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + } + } + + # 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 + $allDbs = az sql db list --resource-group $server.resourceGroup --server $server.name -o json | ConvertFrom-Json + 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 + try { + $dbs = az sql db list --resource-group $server.resourceGroup --server $server.name --query "$dbQuery" -o json | ConvertFrom-Json + + 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." + } else { + Write-Output "Found $($dbs.Count) SQL Databases on Server $($server.name) that require a license update:" + $dbs | ForEach-Object { + Write-Output " - $($_.name) (Current license: $($_.licenseType))" + } + + foreach ($db in $dbs) { + # Collect data before modification + $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 + } + + if (-not $ReportOnly) { + Write-Output "Updating SQL Database '$($db.name)' on server '$($server.name)' to license type '$LicenseType'..." + try { + $result = az sql db update --name $db.name --server $server.name --resource-group $server.resourceGroup --set licenseType=$LicenseType -o json | ConvertFrom-Json + if ($result) { + Write-Output "Successfully updated database '$($db.name)' license to '$LicenseType'" + $finalStatus += $result + } else { + Write-Output "Failed to update database '$($db.name)' license. No result returned." + } + } catch { + Write-Output "Error updating database '$($db.name)': $_" + } + } + } + } + } catch { + Write-Output "Error querying databases on server '$($server.name)': $_" + } + + # Update Elastic Pools with similar improved error handling + try { + Write-Output "Scanning Elastic Pools on server '$($server.name)'..." + + # First check if there are any elastic pools + $allPools = az sql elastic-pool list --resource-group $server.resourceGroup --server $server.name --only-show-errors -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue + + if ($null -eq $allPools -or $allPools.Count -eq 0) { + Write-Output "No Elastic Pools found on server '$($server.name)'." + } 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" + + $elasticPools = az sql elastic-pool list --resource-group $server.resourceGroup --server $server.name --query "$elasticPoolQuery" --only-show-errors -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue + + if ($null -eq $elasticPools -or $elasticPools.Count -eq 0) { + Write-Output "No Elastic Pools found on Server $($server.name) that require a license update." + } else { + Write-Output "Found $($elasticPools.Count) Elastic Pools on Server $($server.name) that require a license update:" + $elasticPools | ForEach-Object { + Write-Output " - $($_.name) (Current license: $($_.licenseType))" + } + + foreach ($pool in $elasticPools) { + # Collect data before modification + $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 + } + + if (-not $ReportOnly) { + Write-Output "Updating Elastic Pool '$($pool.name)' on server '$($server.name)' to license type '$LicenseType'..." + try { + $result = az sql elastic-pool update --name $pool.name --server $server.name --resource-group $server.resourceGroup --set licenseType=$LicenseType --only-show-errors -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($result) { + Write-Output "Successfully updated elastic pool '$($pool.name)' license to '$LicenseType'" + $finalStatus += $result + } else { + Write-Output "Failed to update elastic pool '$($pool.name)' license. No result returned." + } + } catch { + Write-Output "Error updating elastic pool '$($pool.name)': $_" + } + } + } + } + } + } catch { + Write-Output "Error processing Elastic Pools on server '$($server.name)': $_" + } + } + } catch { + Write-Output "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" + } + + # 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}" + + $instancePools = az sql instance-pool list --query $instancePoolsQuery -o json 2>$null | ConvertFrom-Json + $poolsToUpdate = $instancePools | Where-Object { $_.licenseType -ne $LicenseType } + 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." + } + foreach ($pool in $poolsToUpdate) { + + # Collect data before modification + $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 + } + if (-not $ReportOnly) { + Write-Output "Updating SQL Instance Pool '$($pool.name)' in RG '$($pool.resourceGroup)' to license type '$LicenseType'..." + $result = az sql instance-pool update --name $pool.name --resource-group $pool.resourceGroup --license-type $LicenseType -o json | ConvertFrom-Json + $finalStatus += $result + } + } + } + catch { + 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..." + Set-AzContext -Subscription $sub.id | Out-Null + Get-AzDataFactoryV2 | + 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 + $_.LicenseType -ne $LicenseType -and + ([string]::IsNullOrEmpty($ResourceName) -or $_.Name -eq $ResourceName) + } + + if ($IRs.Count -eq 0) { + Write-Output "No matching integration runtimes found." + } else { + $IRs | ForEach-Object { + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($_.Id -split '/')[2] + ResourceName = $_.Name + ResourceType = "Microsoft.DataFactory/factories/integrationRuntimes" + Status = $_.State + OriginalLicenseType = $_.LicenseType + ResourceGroup = $df.ResourceGroupName + Location = $df.Location + } + + if (-not $ReportOnly) { + if (-not [string]::IsNullOrEmpty($ResourceName) -and $_.State -ne "Stopped") { + Write-Output "ADF Integration Service '$($_.Name)' is not in stopped state" + } else { + $result = Set-AzDataFactoryV2IntegrationRuntime -ResourceGroupName $df.ResourceGroupName -DataFactoryName $df.DataFactoryName -Name $_.Name -LicenseType $LicenseType -Force + $finalStatus += $result + Write-Output "-- DataFactory '$($df.DataFactoryName)' integration runtime updated to license type $LicenseType" + } + } + } + } + } + } + catch { + Write-Error "An error occurred while updating DataFactory SSIS Integration Runtimes: $_" + } + + } + catch { + Write-Error "An error occurred while processing subscription '$($sub.name)': $_" + } +} + +$scriptEndTime = Get-Date +$totalDuration = $scriptEndTime - $scriptStartTime + +# --- Final Report --- +Write-Output "`n===== Final Report =====" +Write-Output "Script started at: $scriptStartTime" +Write-Output "Script ended at: $scriptEndTime" +Write-Output "Total duration: $($totalDuration.ToString())" + +# Export modified resource data to CSV +if ($modifiedResources.Count -gt 0) { + $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" + $modifiedResources | Export-Csv -Path $csvPath -NoTypeInformation + Write-Output "CSV report saved to: $csvPath" +} else { + Write-Output "No resources were marked for modification. No CSV generated." +} + +Write-Output "Azure SQL Update Script completed" + +$scriptEndTime = Get-Date +$executionDuration = $scriptEndTime - $scriptStartTime +Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" +Stop-Transcript +'@ + +$EmbeddedScripts['Arc'] = @' + +<# +.SYNOPSIS + Updates the license type for Azure Arc SQL resources to a specified license and license related options. + +.DESCRIPTION + The script updates the license related settings of the SQL extension resources in a specified Entra ID tenant. You can specify a particular subscription, resource group or an individual connected machine. + You can also provide a list of subscriptions as a .CSV file. + By default, all subscriptions in your current tenant id are scanned. + +.VERSION + 3.0.5 - Initial version. + +.PARAMETER SubId + A single subscription ID or a CSV file name containing a list of subscriptions. + +.PARAMETER ResourceGroup + Optional. Limit the scope to a specific resource group. + +.PARAMETER MachineName + Optional. A single machine name or a CSV file name containing a list of machine names. + +.PARAMETER LicenseType + Optional. License type to set. Allowed values: "PAYG", "Paid" or "LicenseOnly" + +.PARAMETER ConsentToRecurringPAYG + Optional. Consents to enabling the recurring PAYG billing. LicenseType must be "PAYG". Applies to CSP subscriptions only. + +.PARAMETER UsePcoreLicense + Optional. Opts in to use unlimited virtualization license if the value is "Yes", or opts out if the value is "No". To opt in, the license type must be "Paid" or "PAYG" + +.PARAMETER EnableESU + Optional. Enables the ESU policy if the value is "Yes" or disables it if the value is "No". To enable, the license type must be "Paid" or "PAYG" + +.PARAMETER Force + Optional. Forces the change of the license type to the specified value on all installed extensions. If not forced, the changes will apply only to the extensions where the license type is undefined. + +.PARAMETER ExclusionTags + Optional. If specified, excludes the resources that have this tag assigned. + +.PARAMETER TenantId + Optional. If specified, this tenant id to log in both PowerShell and CLI. Otherwise, the current login context is used. + +.PARAMETER ReportOnly + Optional. If true, generates a csv file with the list of resources that are to be modified, but doesn't make the actual change. + +.PARAMETER UseManagedIdentity + Optional. If true, logs in both PowerShell and CLI using managed identity. Required to run the script as a runbook. + +#> + +param ( + [Parameter (Mandatory=$false)] + [string] $SubId, + + [Parameter (Mandatory= $false)] + [string] $ResourceGroup, + + [Parameter (Mandatory= $false)] + [string] $MachineName, + + [Parameter (Mandatory= $false)] + [ValidateSet("PAYG","Paid","LicenseOnly", IgnoreCase=$false)] + [string] $LicenseType, + + [Parameter (Mandatory= $false)] + [ValidateSet("Yes","No", IgnoreCase=$false)] + [string] $ConsentToRecurringPAYG, + + [Parameter (Mandatory= $false)] + [ValidateSet("Yes","No", IgnoreCase=$false)] + [string] $UsePcoreLicense, + + [Parameter (Mandatory= $false)] + [ValidateSet("Yes","No", IgnoreCase=$false)] + [string] $EnableESU, + + [Parameter (Mandatory= $false)] + [switch] $Force, + + [Parameter (Mandatory= $false)] + [object] $ExclusionTags, + + [Parameter (Mandatory= $false)] + [string] $TenantId, + + [Parameter (Mandatory= $false)] + [switch] $ReportOnly, + + [Parameter (Mandatory= $false)] + [switch] $UseManagedIdentity, + [Parameter (Mandatory= $false)] + [int] $batchSize = 500 +) + +Start-Transcript -Path ".\modify-arc-sql-license-type.log" +$scriptStartTime = Get-Date +Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" + + +function Connect-Azure { + [CmdletBinding()] + param( + [Parameter(Mandatory=$false)] + [string] $TenantId = $null, + + [Parameter(Mandatory=$false)] + [switch] $UseManagedIdentity + ) + + # 1) Detect host environment + $envType = 'Local' + if ($env:AZUREPS_HOST_ENVIRONMENT -like 'cloud-shell*') { + $envType = 'CloudShell' + } + elseif (($env:AZUREPS_HOST_ENVIRONMENT -like 'AzureAutomation*') -or $PSPrivateMetadata.JobId) { + $envType = 'AzureAutomation' + $UseManagedIdentity = $true + } + Write-Output "Environment detected: $envType" + + # 2) Ensure Az.PowerShell context. Use login V1 + Update-AzConfig -LoginExperienceV2 Off + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account) { + if ($TenantId) { + if ($currentCtx.Tenant.Id -eq $TenantId) { + Write-Output "Already in Az tenant $TenantId" + } + else { + Write-Output "Switching Az context to tenant $TenantId without re-authentication" + $newContext = Set-AzContext -Tenant $TenantId -ErrorAction SilentlyContinue + if($null -eq $newContext -or $newContext.TenantId -ne $TenantId) + { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + } + } + else { + Write-Output "Using existing Az context: Tenant $($currentCtx.Tenant.Id)" + } + } + else { + Write-Output "Not connected to Azure PowerShell. Running Connect-AzAccount..." + if ($UseManagedIdentity) { + if ($TenantId) { + Connect-AzAccount -Identity -Tenant $TenantId | Out-Null + } + else { + Connect-AzAccount -Identity -ErrorAction Stop | Out-Null + } + } + else { + if ($TenantId) { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + else { + Connect-AzAccount | Out-Null + } + } + $ctx = Get-AzContext + Write-Output "Connected to Az PowerShell as: $($ctx.Account) in tenant $($ctx.Tenant.Id)" + } +} + + +# Convert to hashtable explicitly +$tagTable = @{} +if($null -ne $ExclusionTags){ + if($ExclusionTags.GetType().Name -eq "Hashtable"){ + $tagTable = $ExclusionTags + }else{ + ($ExclusionTags | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + $tagTable[$_.Name] = $_.Value + } + } +} +# Ensure connection with both PowerShell and CLI. +if($UseManagedIdentity -or $envType -eq 'AzureAutomation') { + if ($TenantId) { + Connect-Azure -TenantId $TenantId -UseManagedIdentity $UseManagedIdentity + } else { + Connect-Azure -UseManagedIdentity $UseManagedIdentity + } +} else { + if ($TenantId) { + Connect-Azure -TenantId $TenantId + } else { + Connect-Azure + } +} + +$context = Get-AzContext -ErrorAction SilentlyContinue +Write-Output "Connected to Azure as: $($context.Account)" + +if (-not $TenantId) { + $TenantId = $context.Tenant.Id + Write-Output "No TenantId provided. Using current context TenantId: $TenantId" +} else { + Write-Output "Using provided TenantId: $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" +} + +$modifiedResources = @() + +if ($SubId -like "*.csv") { + $subscriptions = Import-Csv $SubId +}elseif($SubId -ne "") { + Write-Output "Passed Subscription $($SubId)" + $subscriptions = Get-AzSubscription -SubscriptionId $SubId +}else { + $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } +} + +# Handle MachineName input (single or CSV) +$machineNames = @() +if ($MachineName) { + if ($MachineName -like "*.csv") { + try { + $machines = Import-Csv $MachineName + foreach ($m in $machines) { + if ($m.MachineName) { + $machineNames += $m.MachineName + } + } + Write-Output "Loaded $($machineNames.Count) machine names from CSV." + } catch { + Write-Error "Failed to import machine names from CSV: $_" + exit 1 + } + } else { + $machineNames += $MachineName + } +} + +Write-Host ([Environment]::NewLine + "-- Scanning subscriptions --") + +foreach ($sub in $subscriptions) { + if ($sub.State -ne "Enabled") {continue} + + try { + Set-AzContext -SubscriptionId $sub.Id #Removed TenantID by Sunil + }catch { + write-host "Invalid subscription: $($sub.Id)" + {continue} + } + + Write-Output "Collecting list of resources to update" + + $query = " + resources + | where subscriptionId =~ '$($sub.Id)' + | where type == 'microsoft.hybridcompute/machines' + | where properties.detectedProperties.mssqldiscovered == 'true'" + if ($ResourceGroup) { + $query += " + | where resourceGroup =~ '$ResourceGroup'" + } + + if ($machineNames.Count -gt 0) { + $machineFilter = ($machineNames | ForEach-Object { "'$_'" }) -join ", " + $query += "| where name in~ ($machineFilter)" + } + + $query += " + | extend machineId = tolower(tostring(id)) + | project machineId, machineName = tolower(name) + | join kind= inner ( + resources + | where subscriptionId =~ '$($sub.Id)' + | where type == 'microsoft.hybridcompute/machines/extensions' + | where properties.publisher =~ 'Microsoft.AzureData' + | where properties.provisioningState == 'Succeeded' + | where properties.settings.LicenseType!='$LicenseType' + | extend extensionName = name + | extend extensionPublisher = properties.publisher + | extend extensionType = properties.type + | parse id with '/subscriptions/' subscriptionId '/resourceGroups/' resourceGroup '/providers/Microsoft.HybridCompute/machines/' machineNameRaw '/extensions/' extensionName + | extend machineName = tolower(machineNameRaw) + ) on `$left.machineName == `$right.machineName + | project machineName, extensionName, resourceGroup, location, subscriptionId, extensionPublisher, extensionType + | order by machineName asc" + + $skipToken = $null + + Write-Output $query + + Write-Output "Found $($resources.Count) resource(s) to update" + $allResults = [System.Collections.Generic.List[PSObject]]::new() + do{ + $resources = Search-AzGraph -Query "$($query)" -First $batchSize -SkipToken $skipToken + $allResults.AddRange($resources) + $skipToken = $resources.SkipToken + }while($skipToken) + + + $count = $allResults.Count + + + while($count -gt 0) { + $count-=1 + $setID = @{ + MachineName = $allResults[$count].MachineName + Name = $allResults[$count].extensionName + ResourceGroup = $allResults[$count].resourceGroup + Location = $allResults[$count].location + SubscriptionId = $allResults[$count].subscriptionId + Publisher = $allResults[$count].extensionPublisher + ExtensionType = $allResults[$count].extensionType + } + + write-Output " MachineName - $($setID.MachineName)" + write-Output " ResourceGroup - $($setID.ResourceGroup)" + write-Output " Location - $($setID.Location)" + write-Output " SubscriptionId - $($setID.SubscriptionId)" + write-Output " ExtensionType - $($setID.ExtensionType)" + + # Get connected machine info + $sqlvm = Get-AzConnectedMachine -Name $setID.MachineName -ResourceGroup $setID.ResourceGroup | Select-Object Name, Tags, Status + + + $excludedByTags = $false + foreach ($tag in $tagTable.Keys){ + if($sqlvm.Tags.ContainsKey($tag)) + { + if($sqlvm.Tags[$tag] -eq $tagTable[$tag]){ + $excludedByTags=$true + $value = $tagTable[$tag] + write-Output "Exclusion tag $($tag):$value. Skipping..." + Break; + } + } + } + if(!$excludedByTags){ + + + $WriteSettings = $false + $ext = Get-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -MachineName $setID.MachineName + + # Collect data before modification + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = $setID.SubscriptionId + ResourceName = $setID.MachineName + ResourceType = $setID.ExtensionType + Status = $sqlvm.Status + OriginalLicenseType = $ext.Setting["LicenseType"] + ResourceGroup = $setID.ResourceGroup + Location = $setID.Location + # Cores + } + + if($ext.ProvisioningState -ne "Succeeded") { + write-Output "Extension is not in a valid state. Skipping..." + {continue} + } else { + $LO_Allowed = (!$ext.Setting["enableExtendedSecurityUpdates"] -and !$EnableESU) -or ($EnableESU -eq "No") + + if ($LicenseType) { + if (($LicenseType -eq "LicenseOnly") -and !$LO_Allowed) { + write-Output "ESU must be disabled before license type can be set to $($LicenseType)" + } else { + if ($ext.Setting["LicenseType"]) { + if ($Force) { + $ext.Setting["LicenseType"] = $LicenseType + $WriteSettings = $true + } + } else { + $ext.Setting["LicenseType"] = $LicenseType + $WriteSettings = $true + } + } + } + + if ($EnableESU) { + if (($ext.Setting["LicenseType"] -in ("Paid","PAYG")) -or ($EnableESU -eq "No")) { + $ext.Setting["enableExtendedSecurityUpdates"] = ($EnableESU -eq "Yes") + $ext.Setting["esuLastUpdatedTimestamp"] = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $WriteSettings = $true + } else { + write-Output "The configured license type does not support ESUs" + } + } + + if ($UsePcoreLicense) { + if (($ext.Setting["LicenseType"] -in ("Paid","PAYG")) -or ($UsePcoreLicense -eq "No")) { + $ext.Setting["UsePhysicalCoreLicense"] = @{ + "IsApplied" = ($UsePcoreLicense -eq "Yes"); + "LastUpdatedTimestamp" = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } + $WriteSettings = $true + } else { + write-Output "The configured license type does not support ESUs" + } + } + + # Add or update ConsentToRecurringPAYG setting if applicable + if ($ConsentToRecurringPAYG -eq "Yes") { + $isPayg = ($LicenseType -eq "PAYG") -or ($ext.Setting["LicenseType"] -eq "PAYG") + if ($isPayg) { + if (-not $ext.Setting.ContainsKey("ConsentToRecurringPAYG") -or -not $ext.Setting["ConsentToRecurringPAYG"]["Consented"]) { + $ext.Setting["ConsentToRecurringPAYG"] = @{ + "Consented" = $true; + "ConsentTimestamp" = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + } + $WriteSettings = $true + } + } + } + + write-Output " Write Settings - $($WriteSettings)" + + if (-not $ReportOnly) { + If ($WriteSettings) { + try { + $settings = @{} + foreach ($h in $ext.Setting.Keys) { + $settings[$h]=$($ext.Setting[$h]) + } + Set-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -Location $setID.Location -MachineName $setID.MachineName -Publisher $setID.Publisher -ExtensionType $setID.ExtensionType -Setting $settings -NoWait # -ErrorAction SilentlyContinue | Out-Null + Write-Output "Updated -- Resource group: [$($setID.ResourceGroup)], Connected machine: [$($setID.MachineName)]" + } catch { + write-Output "The request to modify the extension object failed with the following error:" + continue + } + } + } else { + Write-Output "ReportOnly mode enabled. Skipping modification for: $($setID.MachineName)" + } + } + + } + } +} + +# Export modified resource data to CSV +if ($modifiedResources.Count -gt 0) { + $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" + $modifiedResources | Export-Csv -Path $csvPath -NoTypeInformation + Write-Output "CSV report saved to: $csvPath" +} else { + Write-Output "No resources were marked for modification. No CSV generated." +} + +write-Output "Arc SQL Update Script completed" + +$scriptEndTime = Get-Date +$executionDuration = $scriptEndTime - $scriptStartTime +Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" +Stop-Transcript + +'@ + +$EmbeddedScripts['General'] = @' +<# +.SYNOPSIS + Creates or uses an Azure Automation account and imports a runbook. + +.DESCRIPTION + This script: + - Connects to Azure (PowerShell + CLI). + - Creates the resource group if it doesn't exist. + - Creates the Automation account (with system identity) if it doesn't exist. + - Assigns a set of built‑in roles to that managed identity. + - Imports or updates the specified runbook, publishes it. + - Creates a daily schedule (if missing) and links it to the runbook. + - Starts a one‑off job of the runbook. + +.PARAMETER ResourceGroupName + The resource group in which to create/use the Automation account. + +.PARAMETER AutomationAccountName + The Automation account name. + +.PARAMETER Location + Azure region for the RG and account (e.g. "EastUS"). + +.PARAMETER RunbookName + The name under which to import/publish the runbook. + +.PARAMETER RunbookPath + Full path to the local .ps1 runbook file. + +.PARAMETER RunbookType + Runbook type: "PowerShell", "PowerShell72", "PowerShellWorkflow", "Graph", "Python2", or "Python3". + Default: "PowerShell72". + +.PARAMETER targetResourceGroup + (Optional) Resource group passed into the runbook as a parameter. + +.PARAMETER targetSubscription + (Optional) Subscription ID passed into the runbook as a parameter. +#> + +param( + [Parameter(Mandatory)][string]$ResourceGroupName, + [Parameter(Mandatory)][string]$AutomationAccountName, + [Parameter(Mandatory)][string]$Location, + [Parameter(Mandatory)][string]$RunbookName, + [Parameter(Mandatory)][string]$RunbookPath, + [Parameter()][Hashtable]$RunbookArg, + [ValidateSet("PowerShell","PowerShell72","PowerShellWorkflow","Graph","Python2","Python3")] + [string]$RunbookType = "PowerShell72", + [string]$targetResourceGroup, + [string]$targetSubscription +) +# Suppress unnecessary logging output +$VerbosePreference = "SilentlyContinue" +$DebugPreference = "SilentlyContinue" +$ProgressPreference = "SilentlyContinue" +$InformationPreference = "SilentlyContinue" +$WarningPreference = "SilentlyContinue" +$context = $null +# Define role assignments to apply +$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"}, + @{RoleName = "Reader"; Description = "For read resources in the subscription"} +) +function Connect-Azure { + try { + Write-Output "Testing if it is connected to Azure." + # Attempt to retrieve the current Azure context + $context = Get-AzContext -ErrorAction SilentlyContinue + + if ($null -eq $context -or $null -eq $context.Account) { + Write-Output "Not connected to Azure. Executing Connect-AzAccount..." + if($UseManageIdentity){ + Connect-AzAccount -Identity -ErrorAction Stop | Out-Null + } else { + Connect-AzAccount -ErrorAction Stop | Out-Null + } + $context = Get-AzContext + Write-Output "Connected to Azure as: $($context.Account)" + } + else { + Write-Output "Already connected to Azure as: $($context.Account)" + } + } + 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( + [Parameter(Mandatory)][string]$SubscriptionId, + [Parameter(Mandatory)][string]$ResourceGroupName, + [Parameter(Mandatory)][string]$AutomationAccountName + ) + + + # List of modules to import from PSGallery + $modules = @( + 'AzureAD', + 'Az.Accounts', + 'Az.ConnectedMachine', + 'Az.ResourceGraph' + ) + try { + $existing = Get-AzAutomationModule -ResourceGroupName $ResourceGroupName ` + -AutomationAccountName $AutomationAccountName -Name $mod -ErrorAction SilentlyContinue + if ($existing) { + Write-Output "Removing existing Automation module '$mod'..." -ForegroundColor Magenta + Remove-AzAutomationModule -ResourceGroupName $ResourceGroupName ` + -AutomationAccountName $AutomationAccountName -Name $mod -Force + Write-Output " → Removed '$mod'." -ForegroundColor Green + } + } + catch { + Write-Warning "Could not check/remove existing module '$mod': $_" + } + + foreach ($mod in $modules) { + # Remove existing module from Automation account, if present + try { + $existing = Get-AzAutomationModule -ResourceGroupName $ResourceGroupName ` + -AutomationAccountName $AutomationAccountName -Name $mod -ErrorAction SilentlyContinue + if ($existing) { + Write-Output "Removing existing Automation module '$mod'..." -ForegroundColor Magenta + Remove-AzAutomationModule -ResourceGroupName $ResourceGroupName ` + -AutomationAccountName $AutomationAccountName -Name $mod -Force + Write-Output " → Removed '$mod'." -ForegroundColor Green + } + } + catch { + Write-Warning "Could not check/remove existing module '$mod': $_" + } + Write-Output "Resolving latest version for module '$mod' from PowerShell Gallery..." -ForegroundColor Yellow + try { + $info = Find-Module -Name $mod -Repository PSGallery -ErrorAction Stop + $version = $info.Version.ToString() + $contentUri = "https://www.powershellgallery.com/api/v2/package/$mod/$version" + Write-Output "Importing '$mod' version $version into Automation account..." -ForegroundColor Cyan + Import-AzAutomationModule ` + -ResourceGroupName $ResourceGroupName ` + -AutomationAccountName $AutomationAccountName ` + -Name $mod ` + -ContentLinkUri $contentUri ` + -RuntimeVersion 5.1 ` + -ErrorAction Stop | Out-Null + + Import-AzAutomationModule ` + -ResourceGroupName $ResourceGroupName ` + -AutomationAccountName $AutomationAccountName ` + -Name $mod ` + -ContentLinkUri $contentUri ` + -RuntimeVersion 7.2 ` + -ErrorAction Stop | Out-Null + + Write-Output " → Queued '$mod' v$version for import." -ForegroundColor Green + } + catch { + Write-Error "Failed to import module '$mod': $_" + } + } + + Write-Output "All specified modules have been queued for import. Check the Automation account in the portal for status." -ForegroundColor Cyan + } +# Connect to Azure. +Write-Output "Connecting to Azure..." +Connect-Azure +$context = Get-AzContext -ErrorAction Stop +if ($null -ne $targetSubscription -and $targetSubscription -ne $context.Subscription.Id -and $targetSubscription -ne "") { + $context = Set-AzContext -Subscription $targetSubscription -ErrorAction Stop +} + +# Check if the resource group exists; if not, create it. +if (-not (Get-AzResourceGroup -Name $ResourceGroupName -ErrorAction SilentlyContinue)) { + Write-Output "Creating Resource Group '$ResourceGroupName' in region '$Location'..." + New-AzResourceGroup -Name $ResourceGroupName -Location $Location | Out-Null +} +else { + Write-Output "Resource Group '$ResourceGroupName' already exists." +} + +# Check if the Automation Account exists; if not, create it. +$automationAccount = Get-AzAutomationAccount -ResourceGroupName $ResourceGroupName -Name $AutomationAccountName -ErrorAction SilentlyContinue +if ($null -eq $automationAccount) { + Write-Output "Automation Account '$AutomationAccountName' not found. Creating it..." + $automationAccount = New-AzAutomationAccount -Name $AutomationAccountName -ResourceGroupName $ResourceGroupName -Location $Location -AssignSystemIdentity +} else { + Write-Output "Automation Account '$AutomationAccountName' already exists." +} +if (-not (Get-AzAutomationModule -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name 'Az.ResourceGraph')) { + Import-AzAutomationModule ` + -ResourceGroupName $ResourceGroupName ` + -AutomationAccountName $AutomationAccountName ` + -Name 'Az.ResourceGraph' ` + -ContentLinkUri "https://www.powershellgallery.com/packages/Az.ResourceGraph/1.2.0" + -ErrorAction Stop +} +LoadAzModules -SubscriptionId $context.Subscription.Id -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName +# Assign roles to the Automation Account's system-assigned managed identity. +$principalId = $automationAccount.Identity.PrincipalId +$Scope = "/subscriptions/$($context.Subscription.Id)" +Write-Output $principalId +if ($null -eq $principalId) { + Write-Output "The Automation Account does not have a system-assigned managed identity enabled." -ForegroundColor Yellow + exit +} else { + Write-Output "Automation Account Object ID (PrincipalId): $principalId" -ForegroundColor Green + foreach ($assignment in $roleAssignments) { + $roleName = $assignment.RoleName + + try { + if($null -eq (Get-AzRoleAssignment -ObjectId $principalId -RoleDefinitionName $roleName -Scope $Scope)) { + Write-Output "Assigning role '$roleName' to Managed Identity '$AutomationAccountName' at scope '$Scope'..." -ForegroundColor Yellow + New-AzRoleAssignment -ObjectId $principalId -RoleDefinitionName $roleName -Scope "/subscriptions/$($context.Subscription.Id)" -ErrorAction Stop | Out-Null + Write-Output "Role '$roleName' assigned successfully." -ForegroundColor Green + continue + } + + } + catch { + Write-Error "Failed to assign role '$roleName': $_" + } + } +} +$downloadFolder = './PayTransitionDownloads/' +# Import the runbook into the Automation Account. +if ((Get-AzAutomationRunbook -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $RunbookName -ErrorAction SilentlyContinue)) { + Write-Output "Removing old Runbook '$RunbookName' from Automation Account '$AutomationAccountName'..." + Remove-AzAutomationRunbook -AutomationAccountName $AutomationAccountName -Name $RunbookName -ResourceGroupName $ResourceGroupName -Force -ErrorAction SilentlyContinue | Out-Null +} +if (-not (Get-AzAutomationRunbook -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $RunbookName -ErrorAction SilentlyContinue)) { + Write-Output "Importing Runbook '$RunbookName' from file '$RunbookPath' into Automation Account '$AutomationAccountName'..." + Import-AzAutomationRunbook -AutomationAccountName $AutomationAccountName ` + -Name $RunbookName ` + -ResourceGroupName $ResourceGroupName ` + -Path "$($downloadFolder)$($RunbookPath)" ` + -Type $RunbookType ` + -Force ` + -Published ` + -LogProgress $True | Out-Null + } + + +# Create a daily schedule for the runbook (if it doesn't exist). +$ScheduleName = "$($RunbookName)_defaultschedule" +if (-not (Get-AzAutomationSchedule -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $ScheduleName -ErrorAction SilentlyContinue)) { + Remove-AzAutomationSchedule -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $ScheduleName -ErrorAction SilentlyContinue -Force | Out-Null +} +if (-not (Get-AzAutomationSchedule -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $ScheduleName -ErrorAction SilentlyContinue)) { + Write-Output "Creating schedule '$ScheduleName'..." + # Set the schedule to start 5 minutes from now and expire in one year, with daily frequency. + New-AzAutomationSchedule ` + -ResourceGroupName $ResourceGroupName ` + -AutomationAccountName $AutomationAccountName ` + -Name $ScheduleName ` + -StartTime (Get-Date).AddDays(1)` + -WeekInterval 1 ` + -DaysOfWeek @([System.DayOfWeek]::Monday..[System.DayOfWeek]::Sunday) ` + -TimeZone 'UTC' ` + -Description 'Default schedule for runbook' | Out-Null +} + + +# Link the schedule to the runbook, including the sample parameters. +Write-Output "Assigning schedule '$ScheduleName' to runbook '$RunbookName' with sample parameters..." +Register-AzAutomationScheduledRunbook ` + -AutomationAccountName $AutomationAccountName ` + -ResourceGroupName $ResourceGroupName ` + -RunbookName $RunbookName ` + -ScheduleName $ScheduleName ` + -Parameters $RunbookArg | Out-Null + +Start-AzAutomationRunbook ` + -ResourceGroupName $ResourceGroupName ` + -AutomationAccountName $AutomationAccountName ` + -Name $RunbookName ` + -Parameters $RunbookArg ` + -ErrorAction SilentlyContinue | Out-Null + +Write-Output "Runbook '$RunbookName' has been imported and published successfully." + +'@ + # === Configuration === -$scriptUrls = @{ +# NOTE: The Azure SQL, Arc SQL, and Automation-runbook logic below is embedded directly +# (see the $EmbeddedScripts hashtable above) - nothing is downloaded from the internet. +$scriptFiles = @{ General = @{ - URL = "https://raw.githubusercontent.com/$($environment)/$($git)/refs/heads/master/samples/manage/azure-hybrid-benefit/modify-license-type/set-azurerunbook.ps1" - Args = @{ - ResourceGroupName= "'$($AutomationAccResourceGroupName)'" - AutomationAccountName= $AutomationAccountName - Location= $Location - targetResourceGroup= $targetResourceGroup - targetSubscription= $targetSubscription} - } + FileName = "set-azurerunbook.ps1" + } Azure = @{ - URL = "https://raw.githubusercontent.com/$($environment)/$($git)/refs/heads/master/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1" + FileName = "modify-azure-sql-license-type.ps1" Args = @{ + LicenseType = $azureLicenseType SubId = [string]$targetSubscription ResourceGroup = [string]$targetResourceGroup } } Arc = @{ - URL = "https://raw.githubusercontent.com/$($environment)/$($git)/refs/heads/master/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1" + FileName = "modify-arc-sql-license-type.ps1" Args =@{ - LicenseType= "PAYG" + LicenseType= $arcLicenseType Force = $true UsePcoreLicense=[string]$UsePcoreLicense SubId = [string]$targetSubscription @@ -93,18 +1661,34 @@ $scriptUrls = @{ } } } -# Define a dedicated download folder +# Define a dedicated work folder for materializing the embedded scripts to disk. +# (Azure Automation runbook import and local invocation both require an actual file +# on disk; they cannot consume an in-memory string/function directly.) $downloadFolder = './manage-payg-transition/' # Ensure destination folder exists if (-not (Test-Path $downloadFolder)) { Write-Host "Creating folder: $downloadFolder" New-Item -Path $downloadFolder -ItemType Directory -Force | Out-Null } -# Helper to download a script and invoke it -function Invoke-RemoteScript { + +# Writes the embedded script content for the given key (Arc/Azure/General) to a +# local file and returns its path. Replaces the old "download from GitHub" step. +function Write-EmbeddedScript { param( [Parameter(Mandatory)] - [string]$Url, + [ValidateSet("Arc","Azure","General")] + [string]$Key + ) + $fileName = $scriptFiles[$Key].FileName + $dest = Join-Path $downloadFolder $fileName + Write-Host "Writing embedded script '$fileName' to $dest..." + Set-Content -Path $dest -Value $EmbeddedScripts[$Key] -Encoding UTF8 + return $dest +} + +# Helper to materialize the General runbook script and invoke it (Scheduled mode) +function Invoke-RemoteScript { + param( [Parameter(Mandatory)] [ValidateSet("Arc","Azure","Both")] [string]$Target, @@ -112,12 +1696,7 @@ function Invoke-RemoteScript { [ValidateSet("Single","Scheduled")] [string]$RunMode ) - $fileName = Split-Path $Url -Leaf - $dest = Join-Path $downloadFolder $fileName - - - Write-Host "Downloading $Url to $dest..." - Invoke-RestMethod -Uri $Url -OutFile $dest + $dest = Write-EmbeddedScript -Key General $scriptname = $dest $wrapper = @() @@ -130,21 +1709,14 @@ function Invoke-RemoteScript { "@ if($Target -eq "Both" -or $Target -eq "Arc") { - $supportfileName = Split-Path $scriptUrls.Arc.URL -Leaf - $supportdest = Join-Path $downloadFolder $supportfileName - Write-Host "Downloading $($scriptUrls.Arc.URL) to $supportdest..." - Invoke-RestMethod -Uri $scriptUrls.Arc.URL -OutFile $supportdest - - $supportfileName = Split-Path $scriptUrls.Azure.URL -Leaf - $supportdest = Join-Path $downloadFolder $supportfileName - Write-Host "Downloading $scriptUrls.Azure.URL to $supportdest..." - Invoke-RestMethod -Uri $scriptUrls.Azure.URL -OutFile $supportdest + $null = Write-EmbeddedScript -Key Arc + $null = Write-EmbeddedScript -Key Azure - $nextline = if(($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") -or ($null -ne $targetSubscription -and $targetSubscription -ne "")) {"``"} - $nextline2 = if(($null -ne $targetSubscription -and $targetSubscription -ne "")){"``"} + $nextline = if(($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") -or ($null -ne $targetSubscription -and $targetSubscription -ne "")) {"`` "} + $nextline2 = if(($null -ne $targetSubscription -and $targetSubscription -ne "")){"`` "} $wrapper += @" `$RunbookArg =@{ -LicenseType= 'PAYG' +LicenseType= '$arcLicenseType' Force = `$true $(if ($null -ne $UsePcoreLicense) { "UsePcoreLicense='$UsePcoreLicense'" } else { "" }) $(if ($null -ne $targetSubscription -and $targetSubscription -ne "") { "SubId='$targetSubscription'" }) @@ -152,7 +1724,7 @@ $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "Resour } $scriptname -ResourceGroupName `$ResourceGroupName -AutomationAccountName `$AutomationAccountName -Location `$Location -RunbookName 'ModifyLicenseTypeArc' `` - -RunbookPath '$(Split-Path $scriptUrls.Arc.URL -Leaf)' `` + -RunbookPath '$($scriptFiles.Arc.FileName)' `` -RunbookArg `$RunbookArg $($nextline) $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "-targetResourceGroup `$targetResourceGroup $nextline2" }) $(if ($null -ne $targetSubscription -and $targetSubscription -ne "") { "-targetSubscription `$targetSubscription" }) @@ -162,22 +1734,20 @@ $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "Resour if($Target -eq "Both" -or $Target -eq "Azure") { - $supportfileName = Split-Path $scriptUrls.Azure.URL -Leaf - $supportdest = Join-Path $downloadFolder $supportfileName - Write-Host "Downloading $($scriptUrls.Azure.URL) to $supportdest..." - Invoke-RestMethod -Uri $scriptUrls.Azure.URL -OutFile $supportdest + $null = Write-EmbeddedScript -Key Azure - $nextline = if(($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") -or ($null -ne $targetSubscription -and $targetSubscription -ne "")) {"``"} - $nextline2 = if(($null -ne $targetSubscription -and $targetSubscription -ne "")){"``"} + $nextline = if(($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") -or ($null -ne $targetSubscription -and $targetSubscription -ne "")) {"`` "} + $nextline2 = if(($null -ne $targetSubscription -and $targetSubscription -ne "")){"`` "} $wrapper += @" `$RunbookArg =@{ + LicenseType= '$azureLicenseType' $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "ResourceGroup= '$targetResourceGroup'" }) $(if ($null -ne $targetSubscription -and $targetSubscription -ne "") { "SubId= '$targetSubscription'" }) } $scriptname -ResourceGroupName `$ResourceGroupName -AutomationAccountName `$AutomationAccountName -Location `$Location -RunbookName 'ModifyLicenseTypeAzure' `` - -RunbookPath '$(Split-Path $scriptUrls.Azure.URL -Leaf)'`` + -RunbookPath '$($scriptFiles.Azure.FileName)' `` -RunbookArg `$RunbookArg $($nextline) $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "-targetResourceGroup `$targetResourceGroup $nextline2" }) $(if ($null -ne $targetSubscription -and $targetSubscription -ne "") { "-targetSubscription `$targetSubscription" }) @@ -189,20 +1759,16 @@ $scriptname -ResourceGroupName `$ResourceGroupName -AutomationAccountName `$ .\runnow.ps1 } -# === Single run: download & invoke the appropriate script(s) === +# === Single run: materialize & invoke the appropriate script(s) === if($RunMode -eq "Single") { $wrapper = @() if ($Target -eq "Both" -or $Target -eq "Arc") { - $fileName = Split-Path $scriptUrls.Arc.URL -Leaf - $dest = Join-Path $downloadFolder $fileName - - Write-Host "Downloading $($scriptUrls.Arc.URL) to $dest..." - Invoke-RestMethod -Uri $scriptUrls.Arc.URL -OutFile $dest + $dest = Write-EmbeddedScript -Key Arc $lines = @("$dest") - foreach ($arg in $scriptUrls.Arc.Args.Keys) { - if ("" -ne $scriptUrls.Arc.Args[$arg]) { - $lines += "-$($arg) '$($scriptUrls.Arc.Args[$arg])'" + foreach ($arg in $scriptFiles.Arc.Args.Keys) { + if ("" -ne $scriptFiles.Arc.Args[$arg]) { + $lines += "-$($arg) '$($scriptFiles.Arc.Args[$arg])'" } } for ($i = 0; $i -lt $lines.Count; $i++) { @@ -215,16 +1781,12 @@ if($RunMode -eq "Single") { } if ($Target -eq "Both" -or $Target -eq "Azure") { - $fileName = Split-Path $scriptUrls.Azure.URL -Leaf - $dest = Join-Path $downloadFolder $fileName - - Write-Host "Downloading $($scriptUrls.Azure.URL) to $dest..." - Invoke-RestMethod -Uri $scriptUrls.Azure.URL -OutFile $dest + $dest = Write-EmbeddedScript -Key Azure $lines = @("$dest") - foreach ($arg in $scriptUrls.Azure.Args.Keys) { - if ("" -ne $scriptUrls.Azure.Args[$arg]) { - $lines += "-$($arg) '$($scriptUrls.Azure.Args[$arg])'" + foreach ($arg in $scriptFiles.Azure.Args.Keys) { + if ("" -ne $scriptFiles.Azure.Args[$arg]) { + $lines += "-$($arg) '$($scriptFiles.Azure.Args[$arg])'" } } for ($i = 0; $i -lt $lines.Count; $i++) { @@ -242,12 +1804,12 @@ if($RunMode -eq "Single") { Write-Host "Single run completed." }else{ Write-Host "Run 'Scheduled'." - Invoke-RemoteScript -Url $scriptUrls.General.URL -Target $Target -RunMode $RunMode + Invoke-RemoteScript -Target $Target -RunMode $RunMode } -# === Cleanup downloaded files & folder === +# === Cleanup materialized files & folder === if($cleanDownloads -eq $true) { if (Test-Path $downloadFolder) { - Write-Host "Cleaning up downloaded scripts in $downloadFolder..." + Write-Host "Cleaning up materialized scripts in $downloadFolder..." try { Remove-Item -Path $downloadFolder -Recurse -Force Write-Host "Cleanup successful: removed $downloadFolder" @@ -256,4 +1818,4 @@ if($cleanDownloads -eq $true) { Write-Warning "Cleanup failed: $_" } } -} \ No newline at end of file +} From 02239d920f3ce119ecf0319f802cf011bf82a7c8 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 12:54:04 -0700 Subject: [PATCH 07/48] Update TESTPLAN.md with self-contained script and TargetLicenseType test results Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/manage/manage-payg-transition/TESTPLAN.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index e1cd0ac669..6c72bf2539 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -23,6 +23,17 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c `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 @@ -45,6 +56,8 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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 | ## Cleanup From a557024d0b402c51aac2364eb2217d118c7f9dad Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 14:06:32 -0700 Subject: [PATCH 08/48] Fix Scheduled mode runbook import path mismatch The embedded/standalone set-azurerunbook.ps1 hardcoded './PayTransitionDownloads/' as the folder prefix for -RunbookPath during Import-AzAutomationRunbook, but the outer manage-payg-transition.ps1 materializes embedded scripts to './manage-payg-transition/'. This mismatch caused every -RunMode Scheduled invocation to fail because the runbook file could never be found at the computed path. Found via rubber-duck agent review. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/manage-payg-transition.ps1 | 2 +- samples/manage/manage-payg-transition/set-azurerunbook.ps1 | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 58093eab30..a0c6bbfa33 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -1576,7 +1576,7 @@ if ($null -eq $principalId) { } } } -$downloadFolder = './PayTransitionDownloads/' +$downloadFolder = './manage-payg-transition/' # Import the runbook into the Automation Account. if ((Get-AzAutomationRunbook -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $RunbookName -ErrorAction SilentlyContinue)) { Write-Output "Removing old Runbook '$RunbookName' from Automation Account '$AutomationAccountName'..." diff --git a/samples/manage/manage-payg-transition/set-azurerunbook.ps1 b/samples/manage/manage-payg-transition/set-azurerunbook.ps1 index 150dbb2a0a..0800b658be 100644 --- a/samples/manage/manage-payg-transition/set-azurerunbook.ps1 +++ b/samples/manage/manage-payg-transition/set-azurerunbook.ps1 @@ -240,7 +240,7 @@ if ($null -eq $principalId) { } } } -$downloadFolder = './PayTransitionDownloads/' +$downloadFolder = './manage-payg-transition/' # Import the runbook into the Automation Account. if ((Get-AzAutomationRunbook -ResourceGroupName $ResourceGroupName -AutomationAccountName $AutomationAccountName -Name $RunbookName -ErrorAction SilentlyContinue)) { Write-Output "Removing old Runbook '$RunbookName' from Automation Account '$AutomationAccountName'..." From 75d45b44aea5969782de595dd58470597e994c3d Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 14:27:21 -0700 Subject: [PATCH 09/48] Make Target and RunMode optional with defaults (Both/Single) - -Target now defaults to 'Both' instead of being mandatory. - -RunMode now defaults to 'Single' instead of being mandatory. - Updated docstring/examples to reflect the new defaults. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index a0c6bbfa33..576f194dd2 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -14,13 +14,13 @@ Which environment(s) to process: - Arc - Azure - - Both + - Both (default) .PARAMETER RunMode Whether to run immediately or schedule recurring runs: - - Single : Run once, then exit. - - Scheduled : Create or update the scheduled Automation runbook calling this - logic daily. + - Single (default) : Run once, then exit. + - Scheduled : Create or update the scheduled Automation runbook calling this + logic daily. .PARAMETER TargetLicenseType The license type to transition resources to: @@ -28,26 +28,30 @@ - AHUB : Azure Hybrid Benefit / License-only (bring-your-own-license). .EXAMPLE - # Run immediately for both Azure and Arc, transitioning to PAYG - .\manage-payg-transition.ps1 -Target Both -RunMode Single + # Run immediately for both Azure and Arc, transitioning to PAYG (all defaults) + .\manage-payg-transition.ps1 -AutomationAccResourceGroupName myRG -Location eastus + +.EXAMPLE + # Run immediately for both Azure and Arc, transitioning to PAYG (explicit) + .\manage-payg-transition.ps1 -Target Both -RunMode Single -AutomationAccResourceGroupName myRG -Location eastus .EXAMPLE # Run immediately for both Azure and Arc, transitioning back to AHUB - .\manage-payg-transition.ps1 -Target Both -RunMode Single -TargetLicenseType AHUB + .\manage-payg-transition.ps1 -Target Both -RunMode Single -TargetLicenseType AHUB -AutomationAccResourceGroupName myRG -Location eastus .EXAMPLE # Schedule daily runs for Azure only - .\manage-payg-transition.ps1 -Target Azure -RunMode Scheduled + .\manage-payg-transition.ps1 -Target Azure -RunMode Scheduled -AutomationAccResourceGroupName myRG -Location eastus #> param( - [Parameter(Mandatory, Position=0)] + [Parameter(Mandatory = $false, Position=0)] [ValidateSet("Arc","Azure","Both")] - [string]$Target, + [string]$Target="Both", - [Parameter(Mandatory, Position=1)] + [Parameter(Mandatory = $false, Position=1)] [ValidateSet("Single","Scheduled")] - [string]$RunMode, + [string]$RunMode="Single", [Parameter(Mandatory = $false, Position=2)] [bool]$cleanDownloads=$false, From cb150e53dfe9acf00e62d638e005dd8b83b54ba9 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 14:36:33 -0700 Subject: [PATCH 10/48] Only require AutomationAccResourceGroupName/Location for Scheduled mode These parameters are only actually used by the Azure Automation account setup path (RunMode Scheduled). They were previously mandatory unconditionally, which forced -RunMode Single (one-time run) callers to supply unused values. Made both optional in the param block and added a runtime check that requires them only when -RunMode is 'Scheduled', failing fast with a clear error otherwise. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 35 +++++++++++++++---- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 576f194dd2..e958915d35 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -27,20 +27,29 @@ - PAYG (default) : Pay-as-you-go / consumption-based licensing. - AHUB : Azure Hybrid Benefit / License-only (bring-your-own-license). +.PARAMETER AutomationAccResourceGroupName + Required only when -RunMode is 'Scheduled'. Resource group for the Azure + Automation Account that will host the recurring runbook. Not needed/used for + -RunMode Single. + +.PARAMETER Location + Required only when -RunMode is 'Scheduled'. Azure region for the Automation + Account/resource group. Not needed/used for -RunMode Single. + .EXAMPLE # Run immediately for both Azure and Arc, transitioning to PAYG (all defaults) - .\manage-payg-transition.ps1 -AutomationAccResourceGroupName myRG -Location eastus + .\manage-payg-transition.ps1 .EXAMPLE # Run immediately for both Azure and Arc, transitioning to PAYG (explicit) - .\manage-payg-transition.ps1 -Target Both -RunMode Single -AutomationAccResourceGroupName myRG -Location eastus + .\manage-payg-transition.ps1 -Target Both -RunMode Single .EXAMPLE # Run immediately for both Azure and Arc, transitioning back to AHUB - .\manage-payg-transition.ps1 -Target Both -RunMode Single -TargetLicenseType AHUB -AutomationAccResourceGroupName myRG -Location eastus + .\manage-payg-transition.ps1 -Target Both -RunMode Single -TargetLicenseType AHUB .EXAMPLE - # Schedule daily runs for Azure only + # Schedule daily runs for Azure only (AutomationAccResourceGroupName/Location required in this mode) .\manage-payg-transition.ps1 -Target Azure -RunMode Scheduled -AutomationAccResourceGroupName myRG -Location eastus #> @@ -70,16 +79,28 @@ param( [Parameter(Mandatory=$false)] [string]$targetSubscription=$null, - [Parameter(Mandatory=$true)] - [string]$AutomationAccResourceGroupName, + [Parameter(Mandatory=$false)] + [string]$AutomationAccResourceGroupName=$null, [Parameter(Mandatory=$false)] [string]$AutomationAccountName="aaccAzureArcSQLLicenseType", - [Parameter(Mandatory=$true)] + [Parameter(Mandatory=$false)] [string]$Location=$null ) +# -AutomationAccResourceGroupName and -Location are only actually used by the +# Azure Automation setup path (RunMode Scheduled). Only require them in that mode, +# so a one-time -RunMode Single run doesn't need an Automation Account at all. +if ($RunMode -eq "Scheduled") { + if ([string]::IsNullOrWhiteSpace($AutomationAccResourceGroupName)) { + throw "-AutomationAccResourceGroupName is required when -RunMode is 'Scheduled'." + } + if ([string]::IsNullOrWhiteSpace($Location)) { + throw "-Location is required when -RunMode is 'Scheduled'." + } +} + # Translate the simplified -TargetLicenseType switch into the vocabulary each # embedded script expects: # - modify-azure-sql-license-type.ps1 expects "LicenseIncluded" (PAYG) or "BasePrice" (AHUB). From a5d4fed94ff8e6c18728caa5fd3ca555d4e19199 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 14:44:18 -0700 Subject: [PATCH 11/48] Fix -Force switch being passed as '-Force ''True''' in Single-mode wrapper The Single-mode command-line generator emitted boolean arg values (e.g. the hardcoded Force = $true for Arc) as a separate quoted token: '-Force 'True''. Since -Force is a [switch] parameter in the embedded script, PowerShell does not bind that trailing 'True' token to the switch; instead it gets consumed as an unbound positional argument, which binds to the script's first positional parameter (-SubId). This caused failures such as: 'Subscription True was not found in tenant ... Please verify that the subscription exists in this tenant.' Fixed by detecting boolean-valued args and emitting them as a bare switch (e.g. '-Force') with no value when true, and omitting them entirely when false, instead of always emitting '-ArgName '''''. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage-payg-transition.ps1 | 17 +++++++++++++---- 1 file changed, 13 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 e958915d35..d6adaec46e 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -1792,8 +1792,14 @@ if($RunMode -eq "Single") { $lines = @("$dest") foreach ($arg in $scriptFiles.Arc.Args.Keys) { - if ("" -ne $scriptFiles.Arc.Args[$arg]) { - $lines += "-$($arg) '$($scriptFiles.Arc.Args[$arg])'" + $val = $scriptFiles.Arc.Args[$arg] + if ($val -is [bool]) { + # Switch parameters (e.g. -Force) take no value; PowerShell would + # otherwise bind a literal 'True'/'False' token to the next + # positional parameter instead of the switch. + if ($val) { $lines += "-$($arg)" } + } elseif ("" -ne $val) { + $lines += "-$($arg) '$($val)'" } } for ($i = 0; $i -lt $lines.Count; $i++) { @@ -1810,8 +1816,11 @@ if($RunMode -eq "Single") { $lines = @("$dest") foreach ($arg in $scriptFiles.Azure.Args.Keys) { - if ("" -ne $scriptFiles.Azure.Args[$arg]) { - $lines += "-$($arg) '$($scriptFiles.Azure.Args[$arg])'" + $val = $scriptFiles.Azure.Args[$arg] + if ($val -is [bool]) { + if ($val) { $lines += "-$($arg)" } + } elseif ("" -ne $val) { + $lines += "-$($arg) '$($val)'" } } for ($i = 0; $i -lt $lines.Count; $i++) { From 98460c0aa62adbbd7d95c00e4f9f40e3e6983808 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 14:58:27 -0700 Subject: [PATCH 12/48] Add -TenantId/-ReportOnly pass-through and fix misleading resource count The manage-payg-transition.ps1 wrapper exposed no -TenantId or -ReportOnly parameters, so operators could not target a specific tenant explicitly or perform a safe dry run. The embedded scripts already accept both; only the wrapper pass-through was missing. Wired through for Single and Scheduled modes. Also fixed a genuine bug in modify-arc-sql-license-type.ps1: the 'Found N resource(s) to update' message read $resources.Count before $resources was populated by the paging loop, so it always reported 0 even when resources were found. Moved the message after the loop and switched it to $allResults.Count. Applied to both the standalone script and the copy embedded in manage-payg-transition.ps1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-arc-sql-license-type.ps1 | 3 +- .../manage-payg-transition.ps1 | 31 ++++++++++++++++++- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 index 41ef0d1242..450bbf2059 100644 --- a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 @@ -305,7 +305,6 @@ foreach ($sub in $subscriptions) { Write-Output $query - Write-Output "Found $($resources.Count) resource(s) to update" $allResults = [System.Collections.Generic.List[PSObject]]::new() do{ $resources = Search-AzGraph -Query "$($query)" -First $batchSize -SkipToken $skipToken @@ -313,6 +312,8 @@ foreach ($sub in $subscriptions) { $skipToken = $resources.SkipToken }while($skipToken) + Write-Output "Found $($allResults.Count) resource(s) to update" + $count = $allResults.Count diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index d6adaec46e..c66a81b520 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -27,6 +27,16 @@ - PAYG (default) : Pay-as-you-go / consumption-based licensing. - AHUB : Azure Hybrid Benefit / License-only (bring-your-own-license). +.PARAMETER TenantId + Azure AD tenant to operate against. If omitted, the tenant of the current + Az PowerShell context ((Get-AzContext).Tenant.Id) is used. Specify this + explicitly to avoid accidentally running against whichever tenant happens + to be selected in the current session. + +.PARAMETER ReportOnly + Perform a read-only dry run: discover and report the resources that would be + changed, without modifying any license types. + .PARAMETER AutomationAccResourceGroupName Required only when -RunMode is 'Scheduled'. Resource group for the Azure Automation Account that will host the recurring runbook. Not needed/used for @@ -48,6 +58,10 @@ # Run immediately for both Azure and Arc, transitioning back to AHUB .\manage-payg-transition.ps1 -Target Both -RunMode Single -TargetLicenseType AHUB +.EXAMPLE + # Dry run against a specific tenant - reports what would change, modifies nothing + .\manage-payg-transition.ps1 -TenantId 'd1623670-9777-4399-aaf6-01d87b84ef1d' -ReportOnly + .EXAMPLE # Schedule daily runs for Azure only (AutomationAccResourceGroupName/Location required in this mode) .\manage-payg-transition.ps1 -Target Azure -RunMode Scheduled -AutomationAccResourceGroupName myRG -Location eastus @@ -79,6 +93,12 @@ param( [Parameter(Mandatory=$false)] [string]$targetSubscription=$null, + [Parameter(Mandatory=$false)] + [string]$TenantId=$null, + + [Parameter(Mandatory=$false)] + [switch]$ReportOnly, + [Parameter(Mandatory=$false)] [string]$AutomationAccResourceGroupName=$null, @@ -1191,7 +1211,6 @@ foreach ($sub in $subscriptions) { Write-Output $query - Write-Output "Found $($resources.Count) resource(s) to update" $allResults = [System.Collections.Generic.List[PSObject]]::new() do{ $resources = Search-AzGraph -Query "$($query)" -First $batchSize -SkipToken $skipToken @@ -1199,6 +1218,8 @@ foreach ($sub in $subscriptions) { $skipToken = $resources.SkipToken }while($skipToken) + Write-Output "Found $($allResults.Count) resource(s) to update" + $count = $allResults.Count @@ -1673,6 +1694,8 @@ $scriptFiles = @{ LicenseType = $azureLicenseType SubId = [string]$targetSubscription ResourceGroup = [string]$targetResourceGroup + TenantId = [string]$TenantId + ReportOnly = [bool]$ReportOnly } } Arc = @{ @@ -1683,6 +1706,8 @@ $scriptFiles = @{ UsePcoreLicense=[string]$UsePcoreLicense SubId = [string]$targetSubscription ResourceGroup = [string]$targetResourceGroup + TenantId = [string]$TenantId + ReportOnly = [bool]$ReportOnly } } } @@ -1744,6 +1769,8 @@ function Invoke-RemoteScript { LicenseType= '$arcLicenseType' Force = `$true $(if ($null -ne $UsePcoreLicense) { "UsePcoreLicense='$UsePcoreLicense'" } else { "" }) +$(if ($null -ne $TenantId -and $TenantId -ne "") { "TenantId='$TenantId'" }) +$(if ($ReportOnly) { "ReportOnly=`$true" }) $(if ($null -ne $targetSubscription -and $targetSubscription -ne "") { "SubId='$targetSubscription'" }) $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "ResourceGroup='$targetResourceGroup'" }) } @@ -1766,6 +1793,8 @@ $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "Resour $wrapper += @" `$RunbookArg =@{ LicenseType= '$azureLicenseType' + $(if ($null -ne $TenantId -and $TenantId -ne "") { "TenantId= '$TenantId'" }) + $(if ($ReportOnly) { "ReportOnly= `$true" }) $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "ResourceGroup= '$targetResourceGroup'" }) $(if ($null -ne $targetSubscription -and $targetSubscription -ne "") { "SubId= '$targetSubscription'" }) From b24e8862dfeea851453f49d78b1a43af857d19ac Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 15:25:27 -0700 Subject: [PATCH 13/48] Report actual per-resource update outcome in Arc license CSV Set-AzConnectedMachineExtension is called with -NoWait and previously had no -ErrorAction, so service-side failures (e.g. 'An extension of type ... is still processing. Only one instance of an extension may be in progress at a time') surfaced as non-terminating errors. The catch block was never entered, and the script printed 'Updated -- ...' for resources that had in fact failed. Add -ErrorAction Stop so those errors are caught, and record the real outcome per resource in the CSV via new UpdateResult/UpdateError columns (NotAttempted / RequestSubmitted / Failed). The error text is now included in the console message as well. Applied to both the standalone modify-arc-sql-license-type.ps1 and the copy embedded in manage-payg-transition.ps1; verified the two blocks are identical. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-arc-sql-license-type.ps1 | 26 +++++++++++++++---- .../manage-payg-transition.ps1 | 26 +++++++++++++++---- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 index 450bbf2059..aa929fc2f8 100644 --- a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 @@ -358,8 +358,11 @@ foreach ($sub in $subscriptions) { $WriteSettings = $false $ext = Get-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -MachineName $setID.MachineName - # Collect data before modification - $modifiedResources += [PSCustomObject]@{ + # Collect data before modification. UpdateResult/UpdateError are populated + # after the actual Set-AzConnectedMachineExtension call below (or left as + # "NotAttempted" if the resource was skipped) so the CSV/console output + # reflects what actually happened, not just what was intended. + $resourceRecord = [PSCustomObject]@{ TenantID = $TenantId SubID = $setID.SubscriptionId ResourceName = $setID.MachineName @@ -368,8 +371,11 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $ext.Setting["LicenseType"] ResourceGroup = $setID.ResourceGroup Location = $setID.Location + UpdateResult = "NotAttempted" + UpdateError = "" # Cores } + $modifiedResources += $resourceRecord if($ext.ProvisioningState -ne "Succeeded") { write-Output "Extension is not in a valid state. Skipping..." @@ -437,11 +443,21 @@ foreach ($sub in $subscriptions) { $settings = @{} foreach ($h in $ext.Setting.Keys) { $settings[$h]=$($ext.Setting[$h]) - } - Set-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -Location $setID.Location -MachineName $setID.MachineName -Publisher $setID.Publisher -ExtensionType $setID.ExtensionType -Setting $settings -NoWait # -ErrorAction SilentlyContinue | Out-Null + } + # -ErrorAction Stop is required here: Set-AzConnectedMachineExtension + # can emit a non-terminating error (e.g. "An extension of type ... is + # still processing. Only one instance of an extension may be in + # progress at a time...") which, combined with -NoWait, would otherwise + # be printed to the console and then fall through to the "Updated" + # success message below without ever entering the catch block. + Set-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -Location $setID.Location -MachineName $setID.MachineName -Publisher $setID.Publisher -ExtensionType $setID.ExtensionType -Setting $settings -NoWait -ErrorAction Stop Write-Output "Updated -- Resource group: [$($setID.ResourceGroup)], Connected machine: [$($setID.MachineName)]" + $resourceRecord.UpdateResult = "RequestSubmitted" } catch { - write-Output "The request to modify the extension object failed with the following error:" + $errorMessage = $_.Exception.Message + Write-Output "The request to modify the extension object for [$($setID.MachineName)] failed with the following error: $errorMessage" + $resourceRecord.UpdateResult = "Failed" + $resourceRecord.UpdateError = $errorMessage continue } } diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index c66a81b520..a3022a57bc 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -1264,8 +1264,11 @@ foreach ($sub in $subscriptions) { $WriteSettings = $false $ext = Get-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -MachineName $setID.MachineName - # Collect data before modification - $modifiedResources += [PSCustomObject]@{ + # Collect data before modification. UpdateResult/UpdateError are populated + # after the actual Set-AzConnectedMachineExtension call below (or left as + # "NotAttempted" if the resource was skipped) so the CSV/console output + # reflects what actually happened, not just what was intended. + $resourceRecord = [PSCustomObject]@{ TenantID = $TenantId SubID = $setID.SubscriptionId ResourceName = $setID.MachineName @@ -1274,8 +1277,11 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $ext.Setting["LicenseType"] ResourceGroup = $setID.ResourceGroup Location = $setID.Location + UpdateResult = "NotAttempted" + UpdateError = "" # Cores } + $modifiedResources += $resourceRecord if($ext.ProvisioningState -ne "Succeeded") { write-Output "Extension is not in a valid state. Skipping..." @@ -1343,11 +1349,21 @@ foreach ($sub in $subscriptions) { $settings = @{} foreach ($h in $ext.Setting.Keys) { $settings[$h]=$($ext.Setting[$h]) - } - Set-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -Location $setID.Location -MachineName $setID.MachineName -Publisher $setID.Publisher -ExtensionType $setID.ExtensionType -Setting $settings -NoWait # -ErrorAction SilentlyContinue | Out-Null + } + # -ErrorAction Stop is required here: Set-AzConnectedMachineExtension + # can emit a non-terminating error (e.g. "An extension of type ... is + # still processing. Only one instance of an extension may be in + # progress at a time...") which, combined with -NoWait, would otherwise + # be printed to the console and then fall through to the "Updated" + # success message below without ever entering the catch block. + Set-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -Location $setID.Location -MachineName $setID.MachineName -Publisher $setID.Publisher -ExtensionType $setID.ExtensionType -Setting $settings -NoWait -ErrorAction Stop Write-Output "Updated -- Resource group: [$($setID.ResourceGroup)], Connected machine: [$($setID.MachineName)]" + $resourceRecord.UpdateResult = "RequestSubmitted" } catch { - write-Output "The request to modify the extension object failed with the following error:" + $errorMessage = $_.Exception.Message + Write-Output "The request to modify the extension object for [$($setID.MachineName)] failed with the following error: $errorMessage" + $resourceRecord.UpdateResult = "Failed" + $resourceRecord.UpdateError = $errorMessage continue } } From 50224d99bee6a0955568481781c00579cf611ded Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 15:31:56 -0700 Subject: [PATCH 14/48] Correct README parameters to match the actual script The parameter table and all three examples documented parameters that do not exist on manage-payg-transition.ps1 (-SubId, -ResourceGroup, -RunAt, -AutomationAccount, -ExclusionTag). Every documented example would have failed if a user copied it. Replaced with the real parameter names (-targetSubscription, -targetResourceGroup, -RunMode, -AutomationAccResourceGroupName, -AutomationAccountName) and documented the previously undocumented -Target, -TenantId, -ReportOnly, -cleanDownloads. Also: - Added a dry-run example and documented -ReportOnly as the recommended first step. - Documented implicit tenant selection and the new UpdateResult/UpdateError CSV columns. - Noted that already-converged resources are excluded by design (idempotent re-runs) and that Disconnected/Expired Arc agents cannot be updated. - Fixed Cloud Shell auth step to use Connect-AzAccount instead of Connect-AzureAD. Verified every parameter appearing in a README example resolves against the script's AST parameter block. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/README.md | 82 +++++++++++++------ 1 file changed, 58 insertions(+), 24 deletions(-) diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index 81b9547639..cc42fd1d43 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -41,19 +41,24 @@ The script accepts the following command line parameters: | **Parameter**                                         | **Value**                                                                       | **Description** | |:--|:--|:--| -|`-SubId`|`` *or* ``|*Optional*: Subscription id or a .csv file with the list of subscriptions1. If not specified all subscriptions will be transitioned.| -|`-ResourceGroup` |``|*Optional*: Limits the scope of transition to a specified resource| -|`-RunAt` |`YYYY-MM-DD HH:MM:SS` |*Optional*: Sets the transition time in UTC time zone. E.g. 2025-05-01 14:00:00 means May 1, 2025 at 2pm UTC time. If not specified, the transition will be executed immediately.| +|`-Target`|`Arc`, `Azure`, `Both`|*Optional*. Which environment(s) to process. Defaults to `Both`.| +|`-RunMode`|`Single`, `Scheduled`|*Optional*. `Single` runs once and exits. `Scheduled` registers a recurring Azure Automation runbook. Defaults to `Single`.| +|`-targetSubscription`|``|*Optional*: Subscription id to limit the scope of the transition. If not specified, all subscriptions in the tenant will be transitioned.| +|`-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.| |`-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`.| -|`-AutomationAccount`| ``|*Required* if `-RunAt` is specified. The script will automatically create an automation account with this name unless one with this name alreday exists. It will be used for the “General” runbook import operation. | -|`-Location`|``|*Required* if `-RunAt` is specified. Azure region for the “General” runbook import operation.| -|`-ExclusionTag`|``|*Optional*. Specifies the tag name and value to exclude the tagged offline VMs from the forced activation during the transition | +|`-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`.| -1You can create a .csv file using the following command and then edit to remove the subscriptions you don't want to scan. -```PowerShell -Get-AzSubscription | Export-Csv .\mysubscriptions.csv -NoTypeInformation -``` +> [!NOTE] +> The script does not expose a `-SubId` parameter; use `-targetSubscription`. Scoping to a +> list of subscriptions from a `.csv` file is supported by the underlying +> `modify-azure-sql-license-type.ps1` / `modify-arc-sql-license-type.ps1` scripts when they +> are run directly, but is not passed through by this wrapper. ## How It Works @@ -76,7 +81,24 @@ Get-AzSubscription | Export-Csv .\mysubscriptions.csv -NoTypeInformation model resources are transitioned to. This value is translated internally to the vocabulary each embedded script expects (e.g. `LicenseIncluded`/`BasePrice` for Azure SQL resources, `PAYG`/`LicenseOnly` for Arc SQL Server). -- The offline Azure VMs will be reactivated for a brief period to change the configuration. If the VM should not be recativated, use `-ExclusionTag` option. +- Use `-ReportOnly` to perform a read-only dry run first. The script discovers and reports + every resource it would change (and writes a `ModifiedResources_.csv` report) + without modifying any license types. This is the recommended way to confirm the blast + radius before a real run. +- The script selects the tenant from `-TenantId` if supplied; otherwise it falls back to the + tenant of your current Az PowerShell context. Run `Get-AzContext` first, or pass + `-TenantId` explicitly, to be certain which tenant will be affected. +- Resources that already have the target license type are excluded from discovery by design, + so re-running the script is safe and idempotent. A converged environment correctly reports + `Found 0 resource(s) to update`. +- 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. +- Each run writes a `ModifiedResources_.csv` report. For Arc resources the + `UpdateResult` column records the actual per-resource outcome (`RequestSubmitted`, + `Failed`, or `NotAttempted`), and `UpdateError` carries the service error text when a + change was rejected. - The subscriptions in scope of the transition will be automatically tagged with `ArcSQLServerExtensionDeployment:PAYG` to ensure that the furure SQL Servers onboarded to Azure Arc are configured to use the pay-as-you-go subscription. For details, see [Manage automatic connection for SQL Server enabled by Azure Arc](https://learn.microsoft.com/sql/sql-server/azure-arc/manage-autodeploy). ## Example 1 @@ -85,29 +107,41 @@ Switch all machines to pay-as-you-go in a single subscription immediately and us ```powershell .\manage-payg-transition.ps1 ` - -SubId "00000000-0000-0000-0000-000000000000" ` - -UsePcoreLicense Yes + -targetSubscription "00000000-0000-0000-0000-000000000000" ` + -UsePcoreLicense Yes +```` + +## Example 2 + +Preview (dry run) what would change across an entire tenant, without modifying anything. This is the recommended first step before any real run. + +```powershell +.\manage-payg-transition.ps1 ` + -TenantId "00000000-0000-0000-0000-000000000000" ` + -ReportOnly ```` -## Example 2 +## Example 3 -Switch all machines to pay-as-you-go in subscriptions listed in MySusbcriptions.csv immediately without using unlimited virtualization. Exclude the VMs that tagged with `DoNotActivate:True` +Switch the machines in a single resource group back to Azure Hybrid Benefit (AHUB). ```powershell .\manage-payg-transition.ps1 ` --SubId MySubscription.csv --ExclusionTag DoNotActivate:True + -targetSubscription "00000000-0000-0000-0000-000000000000" ` + -targetResourceGroup "MyResourceGroup" ` + -TargetLicenseType AHUB ```` -## Example 3 +## Example 4 -Switch all machines to pay-as-you-go in *all* subscriptions on May 1, 2025 at 0:00 using an automation account `MyAutomation` in `EatUS` region. +Schedule a recurring daily transition for Azure resources only, using an automation account in the `EastUS` region. ```powershell .\manage-payg-transition.ps1 ` - -SubId "00000000-0000-0000-0000-000000000000" ` - -RunAt "2025-05-01 00:00:00" - -AutomationAccount MyAutomation + -Target Azure ` + -RunMode Scheduled ` + -AutomationAccResourceGroupName "MyAutomationRG" ` + -AutomationAccountName "MyAutomation" ` -Location "EastUS" ``` # Running the script using Cloud Shell @@ -116,10 +150,10 @@ This option is recommended because Cloud shell has the Azure PowerShell modules 1. Launch the [Cloud Shell](https://shell.azure.com/). For details, [read more about PowerShell in Cloud Shell](https://aka.ms/pscloudshell/docs). -1. Connect to Azure AD. You must specify `` if you have access to more than one AAD tenants. +1. Connect to Azure. You must specify `` if you have access to more than one AAD tenant. ```console - Connect-AzureAD -TenantID + Connect-AzAccount -TenantId ``` 1. Upload the script to your cloud shell using the following command: From 49be41b5b3e45590ae64b1f4a09fb0bd6f25efc4 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 15:33:13 -0700 Subject: [PATCH 15/48] Ignore runtime artifacts generated by manage-payg-transition.ps1 Each run materializes the embedded dependency scripts into ./manage-payg-transition/, writes a generated invocation wrapper to runnow.ps1, and emits a ModifiedResources_.csv report plus a transcript log. These were previously left untracked in the sample folder and could easily be committed by accident. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/manage/manage-payg-transition/.gitignore | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 samples/manage/manage-payg-transition/.gitignore diff --git a/samples/manage/manage-payg-transition/.gitignore b/samples/manage/manage-payg-transition/.gitignore new file mode 100644 index 0000000000..69a6aae3c6 --- /dev/null +++ b/samples/manage/manage-payg-transition/.gitignore @@ -0,0 +1,8 @@ +# Artifacts generated at runtime by manage-payg-transition.ps1. +# The embedded dependency scripts are materialized into ./manage-payg-transition/, +# the generated invocation wrapper is written to runnow.ps1, and each run emits a +# CSV report plus a PowerShell transcript. None of these belong in source control. +manage-payg-transition/ +runnow.ps1 +ModifiedResources_*.csv +*.log From ad512d3c10489984817a0a90e2a09f100ea75f55 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 15:44:30 -0700 Subject: [PATCH 16/48] Update TESTPLAN with this session's test results and fixes Resolves the previously blocked Arc test case (#6) - Single/Arc has now been executed end-to-end against real Arc-enabled machines. Adds cases #12-#19 covering the -Force switch-binding fix, -TenantId/-ReportOnly pass-through, the always-zero resource count fix, isolated-folder self-containment, embedded vs standalone sync, truthful Arc update outcomes, idempotent re-runs, and the README parameter cross-check. Refreshes Known gaps: Scheduled mode still never executed end-to-end; Disconnected/Expired Arc agents cannot be updated; arcdata SqlServerInstances with hostType 'Azure Virtual Machine' are read-only mirrors. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/TESTPLAN.md | 45 +++++++++++++++---- 1 file changed, 36 insertions(+), 9 deletions(-) diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 6c72bf2539..82b8b07c47 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -52,26 +52,53 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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 | Attempted against `rajpobuddy` RG Arc resources | ⚠️ Blocked — no Arc-extension-based resource (`Microsoft.AzureData` extension with `LicenseType != PAYG`) currently exists in the RG to exercise a real flip. The KQL query executed without error and returned 0 matches, confirming no regression in query logic, but an actual AHUB→PAYG transition was not exercised for Arc. | +| 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. | ## Cleanup -- All temporary test artifacts (generated wrapper scripts, downloaded sub-scripts, - CSV reports, a local test harness copy of the orchestrator script used to bypass - `raw.githubusercontent.com` during local-only testing) were removed after each run. -- `rajpoTest` was left in `PAYG` state at the end of testing. +- 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 and `abhisqlmi` in `LicenseIncluded` at the + user's explicit request (for portal verification); they were deliberately **not** reverted. ## Known gaps / follow-ups -- Live Arc-target transition (test #6) should be re-validated once a suitable - Arc SQL Server resource with a non-PAYG `Microsoft.AzureData` extension is available. -- `RunMode Scheduled` was validated via code review and log output only, not via an - actual Windows Scheduled Task registration/execution. +- `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. ## Required permissions From d04e711d248863d3c1c443bb87ff35ef0ac56754 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 16:09:01 -0700 Subject: [PATCH 17/48] Guard Stop-Transcript so a failed Start-Transcript doesn't error Both license scripts called Start-Transcript unconditionally and matched it with an unguarded Stop-Transcript. If transcription never started -- because the log path is not writable, or because the host does not support transcription (Azure Automation runbooks do not) -- the script ended with: Stop-Transcript: An error occurred stopping transcription: The host is not currently transcribing. The run itself had already succeeded, so this surfaced a spurious failure at the very end of an otherwise clean execution. Track whether transcription actually started and only stop it in that case, warning instead of throwing if the start fails. Applied to modify-arc-sql-license-type.ps1, modify-azure-sql-license-type.ps1, and both copies embedded in manage-payg-transition.ps1. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-arc-sql-license-type.ps1 | 16 +++++++-- .../modify-azure-sql-license-type.ps1 | 16 +++++++-- .../manage-payg-transition.ps1 | 36 +++++++++++++++---- 3 files changed, 58 insertions(+), 10 deletions(-) diff --git a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 index aa929fc2f8..5c3a1af6d9 100644 --- a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 @@ -93,7 +93,17 @@ param ( [int] $batchSize = 500 ) -Start-Transcript -Path ".\modify-arc-sql-license-type.log" +# Transcription is not available in every host (for example Azure Automation +# runbooks) and can also fail if the log path is not writable. Track whether it +# actually started so the matching Stop-Transcript at the end of the script does +# not throw "The host is not currently transcribing". +$transcriptStarted = $false +try { + Start-Transcript -Path ".\modify-arc-sql-license-type.log" -ErrorAction Stop | Out-Null + $transcriptStarted = $true +} catch { + Write-Warning "Unable to start transcript logging: $($_.Exception.Message) Continuing without a transcript." +} $scriptStartTime = Get-Date Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" @@ -485,4 +495,6 @@ $scriptEndTime = Get-Date $executionDuration = $scriptEndTime - $scriptStartTime Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" -Stop-Transcript +if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } +} diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index cb168760f2..9fbc50c937 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -76,7 +76,17 @@ param ( ) -Start-Transcript -Path "$env:TEMP\modify-azure-sql-license-type.log" +# Transcription is not available in every host (for example Azure Automation +# runbooks) and can also fail if the log path is not writable. Track whether it +# actually started so the matching Stop-Transcript at the end of the script does +# not throw "The host is not currently transcribing". +$transcriptStarted = $false +try { + Start-Transcript -Path "$env:TEMP\modify-azure-sql-license-type.log" -ErrorAction Stop | Out-Null + $transcriptStarted = $true +} catch { + Write-Warning "Unable to start transcript logging: $($_.Exception.Message) Continuing without a transcript." +} $scriptStartTime = Get-Date Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" @@ -767,4 +777,6 @@ $scriptEndTime = Get-Date $executionDuration = $scriptEndTime - $scriptStartTime Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" -Stop-Transcript \ No newline at end of file +if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } +} \ No newline at end of file diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index a3022a57bc..058f1b897a 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -209,7 +209,17 @@ param ( ) -Start-Transcript -Path "$env:TEMP\modify-azure-sql-license-type.log" +# Transcription is not available in every host (for example Azure Automation +# runbooks) and can also fail if the log path is not writable. Track whether it +# actually started so the matching Stop-Transcript at the end of the script does +# not throw "The host is not currently transcribing". +$transcriptStarted = $false +try { + Start-Transcript -Path "$env:TEMP\modify-azure-sql-license-type.log" -ErrorAction Stop | Out-Null + $transcriptStarted = $true +} catch { + Write-Warning "Unable to start transcript logging: $($_.Exception.Message) Continuing without a transcript." +} $scriptStartTime = Get-Date Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" @@ -900,8 +910,10 @@ $scriptEndTime = Get-Date $executionDuration = $scriptEndTime - $scriptStartTime Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" -Stop-Transcript -'@ +if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } +} +'@ $EmbeddedScripts['Arc'] = @' @@ -999,7 +1011,17 @@ param ( [int] $batchSize = 500 ) -Start-Transcript -Path ".\modify-arc-sql-license-type.log" +# Transcription is not available in every host (for example Azure Automation +# runbooks) and can also fail if the log path is not writable. Track whether it +# actually started so the matching Stop-Transcript at the end of the script does +# not throw "The host is not currently transcribing". +$transcriptStarted = $false +try { + Start-Transcript -Path ".\modify-arc-sql-license-type.log" -ErrorAction Stop | Out-Null + $transcriptStarted = $true +} catch { + Write-Warning "Unable to start transcript logging: $($_.Exception.Message) Continuing without a transcript." +} $scriptStartTime = Get-Date Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" @@ -1391,8 +1413,10 @@ $scriptEndTime = Get-Date $executionDuration = $scriptEndTime - $scriptStartTime Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" -Stop-Transcript - +if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } +} + '@ $EmbeddedScripts['General'] = @' From a11b335ab359f78b95d7c86d354eb7284d102e8d Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 16:09:24 -0700 Subject: [PATCH 18/48] Add TESTPLAN case #20 for the Stop-Transcript guard Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/manage/manage-payg-transition/TESTPLAN.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 82b8b07c47..66405508b7 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -67,6 +67,8 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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). | + ## Cleanup - All temporary test artifacts (generated wrapper scripts, materialized sub-scripts, From 0a35c4fcca98f7791db35f1e3113dc61ce332715 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 16:19:09 -0700 Subject: [PATCH 19/48] Don't widen scope when -ResourceGroup matches no SQL servers If the SQL server query returned nothing, the script fell back to scanning every server in the subscription whenever -ResourceName was absent -- even when -ResourceGroup had been supplied. The elastic pool query is not resource-group filtered (it only filters on licenseType and tags), so a real run could have modified elastic pools on servers entirely outside the requested resource group. Observed live: a run scoped to 'rajpobuddy' went on to scan three servers in PT_Bugbash_Databases, BinujRg and TestServersSi6ci1WestEuropeRG. No pools existed there, so nothing was wrongly modified, but the exposure was real. Only fall back to all servers when neither -ResourceName nor -ResourceGroup was specified; otherwise skip database/elastic pool processing and say so. Also fix two misleading messages that made a successful dry run look like a no-op: - The SQL VM path printed nothing in ReportOnly mode, so a resource found and recorded in the CSV appeared to be silently ignored. It now reports the transition it would have made. - 'No SQL VMs found to start that require a license update' printed on every run because is never populated. Reworded to describe what it actually means. Applied to the standalone script and re-synced the embedded copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-azure-sql-license-type.ps1 | 22 +++++++--- .../manage-payg-transition.ps1 | 44 ++++++++++++------- 2 files changed, 43 insertions(+), 23 deletions(-) diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index 9fbc50c937..a4762db64d 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -339,7 +339,9 @@ foreach ($sub in $subscriptions) { } - if (-not $ReportOnly) { + if ($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'..." $result = az sql vm update -n $sqlvm.name -g $sqlvm.resourceGroup --license-type $SqlVmLicenseType -o json | ConvertFrom-Json $finalStatus += $result @@ -351,7 +353,7 @@ foreach ($sub in $subscriptions) { } } if($sqlVmsToUpdate.Count -eq 0) { - Write-Output "No SQL VMs found to start that require a license update." + 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." } @@ -485,11 +487,19 @@ foreach ($sub in $subscriptions) { $allServers | ForEach-Object { Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" } - - # Use all servers if no specific resource name was provided - if (-not $ResourceName) { - Write-Output "Proceeding with all SQL Servers since no specific ResourceName was provided." + + # Only fall back to scanning every server in the subscription when the + # caller did not restrict the scope. Falling back while -ResourceGroup + # (or -ResourceName) was supplied would silently widen the blast radius + # far beyond what was asked for: the elastic pool query below is not + # 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." $servers = $allServers + } else { + 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." diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 058f1b897a..e31ac71b7c 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -127,10 +127,10 @@ if ($RunMode -eq "Scheduled") { # - modify-arc-sql-license-type.ps1 expects "PAYG" or "LicenseOnly" (AHUB-equivalent for Arc). $azureLicenseType = if ($TargetLicenseType -eq "PAYG") { "LicenseIncluded" } else { "BasePrice" } $arcLicenseType = if ($TargetLicenseType -eq "PAYG") { "PAYG" } else { "LicenseOnly" } - -# === Embedded dependency scripts (materialized to disk at runtime; nothing is downloaded) === -$EmbeddedScripts = @{} -$EmbeddedScripts['Azure'] = @' + +# === Embedded dependency scripts (materialized to disk at runtime; nothing is downloaded) === +$EmbeddedScripts = @{} +$EmbeddedScripts['Azure'] = @' <# .SYNOPSIS Updates the license type for Azure SQL resources (SQL DBs, Elastic Pools, Managed Instances, Instance Pools, SQL VMs) @@ -472,7 +472,9 @@ foreach ($sub in $subscriptions) { } - if (-not $ReportOnly) { + if ($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'..." $result = az sql vm update -n $sqlvm.name -g $sqlvm.resourceGroup --license-type $SqlVmLicenseType -o json | ConvertFrom-Json $finalStatus += $result @@ -484,7 +486,7 @@ foreach ($sub in $subscriptions) { } } if($sqlVmsToUpdate.Count -eq 0) { - Write-Output "No SQL VMs found to start that require a license update." + 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." } @@ -618,11 +620,19 @@ foreach ($sub in $subscriptions) { $allServers | ForEach-Object { Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" } - - # Use all servers if no specific resource name was provided - if (-not $ResourceName) { - Write-Output "Proceeding with all SQL Servers since no specific ResourceName was provided." + + # Only fall back to scanning every server in the subscription when the + # caller did not restrict the scope. Falling back while -ResourceGroup + # (or -ResourceName) was supplied would silently widen the blast radius + # far beyond what was asked for: the elastic pool query below is not + # 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." $servers = $allServers + } else { + 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." @@ -914,8 +924,8 @@ if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } } '@ - -$EmbeddedScripts['Arc'] = @' + +$EmbeddedScripts['Arc'] = @' <# .SYNOPSIS @@ -1417,9 +1427,9 @@ if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } } -'@ - -$EmbeddedScripts['General'] = @' +'@ + +$EmbeddedScripts['General'] = @' <# .SYNOPSIS Creates or uses an Azure Automation account and imports a runbook. @@ -1718,8 +1728,8 @@ Start-AzAutomationRunbook ` -ErrorAction SilentlyContinue | Out-Null Write-Output "Runbook '$RunbookName' has been imported and published successfully." - -'@ + +'@ # === Configuration === # NOTE: The Azure SQL, Arc SQL, and Automation-runbook logic below is embedded directly From e8492fe149f48470922ce458fbd4d76d5841e41e Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 16:34:10 -0700 Subject: [PATCH 20/48] Add TESTPLAN cases #21-#22 for scope-escape fix and verified end-to-end PAYG run Case #21 records the scope-escape regression test: with -targetResourceGroup set but no SQL Servers in that group, the script no longer falls back to scanning every server in the subscription (which could have modified out-of-scope elastic pools, since that query has no resource-group filter). Case #22 records the final real run, with the resulting license type verified out-of-band via 'az sql vm show' and Search-AzGraph rather than trusting the script's own log output. Also documents two newly-noted gaps: fixed transcript log paths overwrite prior runs, and the Azure-side CSV lacks the UpdateResult/UpdateError columns. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/TESTPLAN.md | 259 +++++++++--------- 1 file changed, 134 insertions(+), 125 deletions(-) diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 66405508b7..ce96dcba4f 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -1,125 +1,134 @@ -# 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). | - -## 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 and `abhisqlmi` in `LicenseIncluded` at the - user's explicit request (for portal verification); they were deliberately **not** reverted. - -## Known gaps / follow-ups - -- `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. - -## Required permissions - -Derived from every Azure CLI/PowerShell call made by the two scripts: - -| 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 | - -**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. +# 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. | + +## 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 `PAYG` state (following the final end-to-end run, test #22) and + `abhisqlmi` in `LicenseIncluded` at the user's explicit request (for portal + verification); they were deliberately **not** reverted. + +## Known gaps / follow-ups + +- `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 does not carry the `UpdateResult`/`UpdateError` columns added to + the Arc-side report in test #17, and `az sql vm update` results are not inspected, so an + Azure-side failure is not reflected in the report. + +## Required permissions + +Derived from every Azure CLI/PowerShell call made by the two scripts: + +| 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 | + +**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 d919be69ae1cc9da6253e074616f6421d625e6be Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 17:46:59 -0700 Subject: [PATCH 21/48] Fix DataFactory license update selecting non-SSIS integration runtimes The integration runtime filter selected any runtime with Type 'Managed' whose LicenseType differed from the target. The default AutoResolveIntegrationRuntime is also Type 'Managed' (it is the data-flow runtime, not an SSIS-IR) and has a null LicenseType, so 'null -ne LicenseIncluded' selected it. The subsequent Set-AzDataFactoryV2IntegrationRuntime round-tripped the full payload and the service rejected it with: Conflict / DataFactoryPropertyUpdateNotSupported Updating property managedVirtualNetwork is not supported The filter now also requires a non-empty LicenseType, which only SSIS integration runtimes carry. The failure was additionally reported as a success: the cmdlet had no -ErrorAction Stop, so the Conflict was non-terminating, the enclosing catch never fired, and the script printed '-- DataFactory ... updated to license type LicenseIncluded' directly after two error blocks. Each runtime is now updated inside its own try/catch with -ErrorAction Stop. Applies the same result-checking to the SQL VM path, which piped 'az sql vm update' straight into ConvertFrom-Json without checking LASTEXITCODE and appended its CSV row before the attempt. Rows are now projected onto an explicit column set at export time, because Export-Csv derives its header from the first object only and would otherwise drop UpdateResult/UpdateError depending on which section emitted first. Adds TESTPLAN cases #23-#26 and re-syncs the embedded copy in the self-contained orchestrator. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-azure-sql-license-type.ps1 | 98 +++++++++++++------ .../manage/manage-payg-transition/TESTPLAN.md | 19 ++-- .../manage-payg-transition.ps1 | 98 +++++++++++++------ 3 files changed, 153 insertions(+), 62 deletions(-) diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index a4762db64d..0630b667d4 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -324,8 +324,28 @@ foreach ($sub in $subscriptions) { { $vmStatus = az 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 | ConvertFrom-Json if (($vmStatus.PowerState -eq "VM running") -and ($sqlvm.sqlServerLicenseType -ne "DR")) { - - # Collect data before modification + + $vmResult = "NotAttempted" + $vmError = "" + + if ($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'..." + $azOutput = az sql vm update -n $sqlvm.name -g $sqlvm.resourceGroup --license-type $SqlVmLicenseType -o json 2>&1 + if ($LASTEXITCODE -ne 0) { + $vmResult = "Failed" + $vmError = ($azOutput | Out-String).Trim() + Write-Warning "Failed to update SQL VM '$($sqlvm.name)': $vmError" + } else { + $result = $azOutput | ConvertFrom-Json + $finalStatus += $result + $vmResult = "Updated" + Write-Output "-- SQL VM '$($sqlvm.name)' updated to license type '$SqlVmLicenseType'" + } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($sqlvm.id -split '/')[2] @@ -335,17 +355,10 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $sqlvm.sqlServerLicenseType ResourceGroup = $sqlvm.resourceGroup Location = $sqlvm.Location + UpdateResult = $vmResult + UpdateError = $vmError # Cores } - - - if ($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'..." - $result = az sql vm update -n $sqlvm.name -g $sqlvm.resourceGroup --license-type $SqlVmLicenseType -o json | ConvertFrom-Json - $finalStatus += $result - } } } else { @@ -721,33 +734,55 @@ foreach ($sub in $subscriptions) { 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) } - if ($IRs.Count -eq 0) { - Write-Output "No matching integration runtimes found." + 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 (-not $ReportOnly) { + if (-not [string]::IsNullOrEmpty($ResourceName) -and $ir.State -ne "Stopped") { + Write-Output "ADF Integration Service '$($ir.Name)' is not in stopped state" + $irResult = "SkippedNotStopped" + } 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 = ($_.Id -split '/')[2] - ResourceName = $_.Name + SubID = ($ir.Id -split '/')[2] + ResourceName = $ir.Name ResourceType = "Microsoft.DataFactory/factories/integrationRuntimes" - Status = $_.State - OriginalLicenseType = $_.LicenseType + Status = $ir.State + OriginalLicenseType = $ir.LicenseType ResourceGroup = $df.ResourceGroupName Location = $df.Location - } - - if (-not $ReportOnly) { - if (-not [string]::IsNullOrEmpty($ResourceName) -and $_.State -ne "Stopped") { - Write-Output "ADF Integration Service '$($_.Name)' is not in stopped state" - } else { - $result = Set-AzDataFactoryV2IntegrationRuntime -ResourceGroupName $df.ResourceGroupName -DataFactoryName $df.DataFactoryName -Name $_.Name -LicenseType $LicenseType -Force - $finalStatus += $result - Write-Output "-- DataFactory '$($df.DataFactoryName)' integration runtime updated to license type $LicenseType" - } + UpdateResult = $irResult + UpdateError = $irError } } } @@ -775,7 +810,14 @@ Write-Output "Total duration: $($totalDuration.ToString())" # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" - $modifiedResources | Export-Csv -Path $csvPath -NoTypeInformation + # Export-Csv derives its header from the first object only, so rows built by + # different sections (some of which carry UpdateResult/UpdateError) are projected + # onto one consistent schema to avoid silently dropping columns. + $csvColumns = @('TenantID','SubID','ResourceName','ResourceType','Status', + 'OriginalLicenseType','ResourceGroup','Location','UpdateResult','UpdateError') + $modifiedResources | + Select-Object -Property $csvColumns | + Export-Csv -Path $csvPath -NoTypeInformation Write-Output "CSV report saved to: $csvPath" } else { Write-Output "No resources were marked for modification. No CSV generated." diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index ce96dcba4f..5febf7fb81 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -69,6 +69,10 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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`. | ## Cleanup @@ -78,9 +82,9 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c - 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 `PAYG` state (following the final end-to-end run, test #22) and - `abhisqlmi` in `LicenseIncluded` at the user's explicit request (for portal - verification); they were deliberately **not** reverted. +- `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. ## Known gaps / follow-ups @@ -107,9 +111,12 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c 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 does not carry the `UpdateResult`/`UpdateError` columns added to - the Arc-side report in test #17, and `az sql vm update` results are not inspected, so an - Azure-side failure is not reflected in the report. +- The Azure-side CSV report now carries `UpdateResult`/`UpdateError` for SQL VM and + DataFactory integration runtime rows (tests #24–#26), but the Managed Instance, SQL + Database, elastic pool and instance pool sections still append their rows *before* the + update attempt and do not inspect the `az ... update` exit code, so a failure in those + sections is still recorded as though it succeeded. Extending the same pattern to them is + the remaining work. ## Required permissions diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index e31ac71b7c..94aca6ef8f 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -457,8 +457,28 @@ foreach ($sub in $subscriptions) { { $vmStatus = az 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 | ConvertFrom-Json if (($vmStatus.PowerState -eq "VM running") -and ($sqlvm.sqlServerLicenseType -ne "DR")) { - - # Collect data before modification + + $vmResult = "NotAttempted" + $vmError = "" + + if ($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'..." + $azOutput = az sql vm update -n $sqlvm.name -g $sqlvm.resourceGroup --license-type $SqlVmLicenseType -o json 2>&1 + if ($LASTEXITCODE -ne 0) { + $vmResult = "Failed" + $vmError = ($azOutput | Out-String).Trim() + Write-Warning "Failed to update SQL VM '$($sqlvm.name)': $vmError" + } else { + $result = $azOutput | ConvertFrom-Json + $finalStatus += $result + $vmResult = "Updated" + Write-Output "-- SQL VM '$($sqlvm.name)' updated to license type '$SqlVmLicenseType'" + } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($sqlvm.id -split '/')[2] @@ -468,17 +488,10 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $sqlvm.sqlServerLicenseType ResourceGroup = $sqlvm.resourceGroup Location = $sqlvm.Location + UpdateResult = $vmResult + UpdateError = $vmError # Cores } - - - if ($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'..." - $result = az sql vm update -n $sqlvm.name -g $sqlvm.resourceGroup --license-type $SqlVmLicenseType -o json | ConvertFrom-Json - $finalStatus += $result - } } } else { @@ -854,33 +867,55 @@ foreach ($sub in $subscriptions) { 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) } - if ($IRs.Count -eq 0) { - Write-Output "No matching integration runtimes found." + 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 (-not $ReportOnly) { + if (-not [string]::IsNullOrEmpty($ResourceName) -and $ir.State -ne "Stopped") { + Write-Output "ADF Integration Service '$($ir.Name)' is not in stopped state" + $irResult = "SkippedNotStopped" + } 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 = ($_.Id -split '/')[2] - ResourceName = $_.Name + SubID = ($ir.Id -split '/')[2] + ResourceName = $ir.Name ResourceType = "Microsoft.DataFactory/factories/integrationRuntimes" - Status = $_.State - OriginalLicenseType = $_.LicenseType + Status = $ir.State + OriginalLicenseType = $ir.LicenseType ResourceGroup = $df.ResourceGroupName Location = $df.Location - } - - if (-not $ReportOnly) { - if (-not [string]::IsNullOrEmpty($ResourceName) -and $_.State -ne "Stopped") { - Write-Output "ADF Integration Service '$($_.Name)' is not in stopped state" - } else { - $result = Set-AzDataFactoryV2IntegrationRuntime -ResourceGroupName $df.ResourceGroupName -DataFactoryName $df.DataFactoryName -Name $_.Name -LicenseType $LicenseType -Force - $finalStatus += $result - Write-Output "-- DataFactory '$($df.DataFactoryName)' integration runtime updated to license type $LicenseType" - } + UpdateResult = $irResult + UpdateError = $irError } } } @@ -908,7 +943,14 @@ Write-Output "Total duration: $($totalDuration.ToString())" # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" - $modifiedResources | Export-Csv -Path $csvPath -NoTypeInformation + # Export-Csv derives its header from the first object only, so rows built by + # different sections (some of which carry UpdateResult/UpdateError) are projected + # onto one consistent schema to avoid silently dropping columns. + $csvColumns = @('TenantID','SubID','ResourceName','ResourceType','Status', + 'OriginalLicenseType','ResourceGroup','Location','UpdateResult','UpdateError') + $modifiedResources | + Select-Object -Property $csvColumns | + Export-Csv -Path $csvPath -NoTypeInformation Write-Output "CSV report saved to: $csvPath" } else { Write-Output "No resources were marked for modification. No CSV generated." From 3123e4c977184b8ead555385051f30b02a4b9edf Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 18:08:24 -0700 Subject: [PATCH 22/48] Report update outcomes for all Azure SQL resource types The result-checking added for SQL VMs covered only one of five 'az ... update' call sites. Managed Instances, SQL Databases, elastic pools and instance pools still piped the CLI output straight into ConvertFrom-Json without inspecting LASTEXITCODE, and appended their CSV row before the attempt, so a failed update was recorded identically to a successful one. The elastic pool path was the worst case: az sql elastic-pool update ... 2>\ | ConvertFrom-Json -ErrorAction SilentlyContinue which discarded the service error text entirely and reported only 'No result returned', giving no indication of why the update failed. All five paths now route through a shared Invoke-AzCliLicenseUpdate helper that checks LASTEXITCODE, surfaces the real service error, and returns a result object used to populate UpdateResult/UpdateError. The helper initially wrote its success message with Write-Output from inside the function, which in PowerShell merges into the return value: callers received a two-element array rather than the result object, and the message never reached the transcript. The helper is now silent on the success stream and each caller logs its own line. Adds TESTPLAN cases #27-#28 and re-syncs the embedded copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-azure-sql-license-type.ps1 | 155 +++++++++++------- .../manage/manage-payg-transition/TESTPLAN.md | 13 +- .../manage-payg-transition.ps1 | 155 +++++++++++------- 3 files changed, 199 insertions(+), 124 deletions(-) diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index 0630b667d4..6e58789463 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -155,8 +155,39 @@ function Connect-Azure { } } +<# +.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. +#> +function Invoke-AzCliLicenseUpdate { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description + ) + + $output = & az @Arguments 2>&1 + + if ($LASTEXITCODE -ne 0) { + $message = ($output | Out-String).Trim() + Write-Warning "Failed to update $Description`: $message" + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message } + } + + $parsed = $null + try { $parsed = $output | ConvertFrom-Json } catch { $parsed = $output } + # 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 = "" } +} + -# Initialize final status and report counters. $finalStatus = @() # Convert to hashtable explicitly @@ -332,17 +363,10 @@ foreach ($sub in $subscriptions) { 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'..." - $azOutput = az sql vm update -n $sqlvm.name -g $sqlvm.resourceGroup --license-type $SqlVmLicenseType -o json 2>&1 - if ($LASTEXITCODE -ne 0) { - $vmResult = "Failed" - $vmError = ($azOutput | Out-String).Trim() - Write-Warning "Failed to update SQL VM '$($sqlvm.name)': $vmError" - } else { - $result = $azOutput | ConvertFrom-Json - $finalStatus += $result - $vmResult = "Updated" - Write-Output "-- SQL VM '$($sqlvm.name)' updated to license type '$SqlVmLicenseType'" - } + $update = Invoke-AzCliLicenseUpdate -Description "SQL VM '$($sqlvm.name)'" -Arguments @( + 'sql','vm','update','-n',$sqlvm.name,'-g',$sqlvm.resourceGroup,'--license-type',$SqlVmLicenseType,'-o','json') + if ($update.Success) { $finalStatus += $update.Result; $vmResult = "Updated"; Write-Output "-- SQL VM '$($sqlvm.name)' updated to license type '$SqlVmLicenseType'" } + else { $vmResult = "Failed"; $vmError = $update.ErrorMessage } } # Collect data after the attempt so the recorded outcome is accurate @@ -408,8 +432,19 @@ foreach ($sub in $subscriptions) { Write-Output "Found $($runningMIs.Count) SQL Managed Instances that require a license update." } foreach ($mi in $runningMIs) { - - # Collect data before modification + + $miResult = "NotAttempted" + $miError = "" + + if (-not $ReportOnly) { + 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)'" -Arguments @( + 'sql','mi','update','--name',$mi.name,'--resource-group',$mi.resourceGroup,'--license-type',$LicenseType,'-o','json') + if ($update.Success) { $finalStatus += $update.Result; $miResult = "Updated"; Write-Output "-- SQL Managed Instance '$($mi.name)' updated to license type '$LicenseType'" } + else { $miResult = "Failed"; $miError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($mi.id -split '/')[2] @@ -419,12 +454,8 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $mi.licenseType ResourceGroup = $mi.resourceGroup Location = $mi.location - } - - if (-not $ReportOnly) { - Write-Output "Updating SQL Managed Instance '$($mi.name)' in RG '$($mi.resourceGroup)' to license type '$LicenseType'..." - $result = az sql mi update --name $mi.name --resource-group $mi.resourceGroup --license-type $LicenseType -o json | ConvertFrom-Json - $finalStatus += $result + UpdateResult = $miResult + UpdateError = $miError } } } @@ -560,7 +591,19 @@ foreach ($sub in $subscriptions) { } foreach ($db in $dbs) { - # Collect data before modification + + $dbResult = "NotAttempted" + $dbError = "" + + if (-not $ReportOnly) { + 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)'" -Arguments @( + 'sql','db','update','--name',$db.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'-o','json') + if ($update.Success) { $finalStatus += $update.Result; $dbResult = "Updated"; Write-Output "-- SQL Database '$($db.name)' updated to license type '$LicenseType'" } + else { $dbResult = "Failed"; $dbError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($db.id -split '/')[2] @@ -570,21 +613,8 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $db.licenseType ResourceGroup = $db.resourceGroup Location = $db.location - } - - if (-not $ReportOnly) { - Write-Output "Updating SQL Database '$($db.name)' on server '$($server.name)' to license type '$LicenseType'..." - try { - $result = az sql db update --name $db.name --server $server.name --resource-group $server.resourceGroup --set licenseType=$LicenseType -o json | ConvertFrom-Json - if ($result) { - Write-Output "Successfully updated database '$($db.name)' license to '$LicenseType'" - $finalStatus += $result - } else { - Write-Output "Failed to update database '$($db.name)' license. No result returned." - } - } catch { - Write-Output "Error updating database '$($db.name)': $_" - } + UpdateResult = $dbResult + UpdateError = $dbError } } } @@ -627,7 +657,19 @@ foreach ($sub in $subscriptions) { } foreach ($pool in $elasticPools) { - # Collect data before modification + + $poolResult = "NotAttempted" + $poolError = "" + + if (-not $ReportOnly) { + 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)'" -Arguments @( + 'sql','elastic-pool','update','--name',$pool.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'--only-show-errors','-o','json') + if ($update.Success) { $finalStatus += $update.Result; $poolResult = "Updated"; Write-Output "-- Elastic Pool '$($pool.name)' updated to license type '$LicenseType'" } + else { $poolResult = "Failed"; $poolError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($pool.id -split '/')[2] @@ -637,21 +679,8 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $pool.licenseType ResourceGroup = $pool.resourceGroup Location = $pool.location - } - - if (-not $ReportOnly) { - Write-Output "Updating Elastic Pool '$($pool.name)' on server '$($server.name)' to license type '$LicenseType'..." - try { - $result = az sql elastic-pool update --name $pool.name --server $server.name --resource-group $server.resourceGroup --set licenseType=$LicenseType --only-show-errors -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($result) { - Write-Output "Successfully updated elastic pool '$($pool.name)' license to '$LicenseType'" - $finalStatus += $result - } else { - Write-Output "Failed to update elastic pool '$($pool.name)' license. No result returned." - } - } catch { - Write-Output "Error updating elastic pool '$($pool.name)': $_" - } + UpdateResult = $poolResult + UpdateError = $poolError } } } @@ -696,8 +725,19 @@ foreach ($sub in $subscriptions) { Write-Output "Found $($poolsToUpdate.Count) SQL Instance Pools that require a license update." } foreach ($pool in $poolsToUpdate) { - - # Collect data before modification + + $ipResult = "NotAttempted" + $ipError = "" + + if (-not $ReportOnly) { + 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)'" -Arguments @( + 'sql','instance-pool','update','--name',$pool.name,'--resource-group',$pool.resourceGroup,'--license-type',$LicenseType,'-o','json') + if ($update.Success) { $finalStatus += $update.Result; $ipResult = "Updated"; Write-Output "-- SQL Instance Pool '$($pool.name)' updated to license type '$LicenseType'" } + else { $ipResult = "Failed"; $ipError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($pool.id -split '/')[2] @@ -707,11 +747,8 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $pool.licenseType ResourceGroup = $pool.resourceGroup Location = $pool.location - } - if (-not $ReportOnly) { - Write-Output "Updating SQL Instance Pool '$($pool.name)' in RG '$($pool.resourceGroup)' to license type '$LicenseType'..." - $result = az sql instance-pool update --name $pool.name --resource-group $pool.resourceGroup --license-type $LicenseType -o json | ConvertFrom-Json - $finalStatus += $result + UpdateResult = $ipResult + UpdateError = $ipError } } } diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 5febf7fb81..306e334011 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -73,6 +73,8 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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`). | ## Cleanup @@ -111,12 +113,11 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c 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 SQL VM and - DataFactory integration runtime rows (tests #24–#26), but the Managed Instance, SQL - Database, elastic pool and instance pool sections still append their rows *before* the - update attempt and do not inspect the `az ... update` exit code, so a failure in those - sections is still recorded as though it succeeded. Extending the same pattern to them is - the remaining work. +- 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. ## Required permissions diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 94aca6ef8f..0d9f069808 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -288,8 +288,39 @@ function Connect-Azure { } } +<# +.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. +#> +function Invoke-AzCliLicenseUpdate { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description + ) + + $output = & az @Arguments 2>&1 + + if ($LASTEXITCODE -ne 0) { + $message = ($output | Out-String).Trim() + Write-Warning "Failed to update $Description`: $message" + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message } + } + + $parsed = $null + try { $parsed = $output | ConvertFrom-Json } catch { $parsed = $output } + # 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 = "" } +} + -# Initialize final status and report counters. $finalStatus = @() # Convert to hashtable explicitly @@ -465,17 +496,10 @@ foreach ($sub in $subscriptions) { 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'..." - $azOutput = az sql vm update -n $sqlvm.name -g $sqlvm.resourceGroup --license-type $SqlVmLicenseType -o json 2>&1 - if ($LASTEXITCODE -ne 0) { - $vmResult = "Failed" - $vmError = ($azOutput | Out-String).Trim() - Write-Warning "Failed to update SQL VM '$($sqlvm.name)': $vmError" - } else { - $result = $azOutput | ConvertFrom-Json - $finalStatus += $result - $vmResult = "Updated" - Write-Output "-- SQL VM '$($sqlvm.name)' updated to license type '$SqlVmLicenseType'" - } + $update = Invoke-AzCliLicenseUpdate -Description "SQL VM '$($sqlvm.name)'" -Arguments @( + 'sql','vm','update','-n',$sqlvm.name,'-g',$sqlvm.resourceGroup,'--license-type',$SqlVmLicenseType,'-o','json') + if ($update.Success) { $finalStatus += $update.Result; $vmResult = "Updated"; Write-Output "-- SQL VM '$($sqlvm.name)' updated to license type '$SqlVmLicenseType'" } + else { $vmResult = "Failed"; $vmError = $update.ErrorMessage } } # Collect data after the attempt so the recorded outcome is accurate @@ -541,8 +565,19 @@ foreach ($sub in $subscriptions) { Write-Output "Found $($runningMIs.Count) SQL Managed Instances that require a license update." } foreach ($mi in $runningMIs) { - - # Collect data before modification + + $miResult = "NotAttempted" + $miError = "" + + if (-not $ReportOnly) { + 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)'" -Arguments @( + 'sql','mi','update','--name',$mi.name,'--resource-group',$mi.resourceGroup,'--license-type',$LicenseType,'-o','json') + if ($update.Success) { $finalStatus += $update.Result; $miResult = "Updated"; Write-Output "-- SQL Managed Instance '$($mi.name)' updated to license type '$LicenseType'" } + else { $miResult = "Failed"; $miError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($mi.id -split '/')[2] @@ -552,12 +587,8 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $mi.licenseType ResourceGroup = $mi.resourceGroup Location = $mi.location - } - - if (-not $ReportOnly) { - Write-Output "Updating SQL Managed Instance '$($mi.name)' in RG '$($mi.resourceGroup)' to license type '$LicenseType'..." - $result = az sql mi update --name $mi.name --resource-group $mi.resourceGroup --license-type $LicenseType -o json | ConvertFrom-Json - $finalStatus += $result + UpdateResult = $miResult + UpdateError = $miError } } } @@ -693,7 +724,19 @@ foreach ($sub in $subscriptions) { } foreach ($db in $dbs) { - # Collect data before modification + + $dbResult = "NotAttempted" + $dbError = "" + + if (-not $ReportOnly) { + 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)'" -Arguments @( + 'sql','db','update','--name',$db.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'-o','json') + if ($update.Success) { $finalStatus += $update.Result; $dbResult = "Updated"; Write-Output "-- SQL Database '$($db.name)' updated to license type '$LicenseType'" } + else { $dbResult = "Failed"; $dbError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($db.id -split '/')[2] @@ -703,21 +746,8 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $db.licenseType ResourceGroup = $db.resourceGroup Location = $db.location - } - - if (-not $ReportOnly) { - Write-Output "Updating SQL Database '$($db.name)' on server '$($server.name)' to license type '$LicenseType'..." - try { - $result = az sql db update --name $db.name --server $server.name --resource-group $server.resourceGroup --set licenseType=$LicenseType -o json | ConvertFrom-Json - if ($result) { - Write-Output "Successfully updated database '$($db.name)' license to '$LicenseType'" - $finalStatus += $result - } else { - Write-Output "Failed to update database '$($db.name)' license. No result returned." - } - } catch { - Write-Output "Error updating database '$($db.name)': $_" - } + UpdateResult = $dbResult + UpdateError = $dbError } } } @@ -760,7 +790,19 @@ foreach ($sub in $subscriptions) { } foreach ($pool in $elasticPools) { - # Collect data before modification + + $poolResult = "NotAttempted" + $poolError = "" + + if (-not $ReportOnly) { + 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)'" -Arguments @( + 'sql','elastic-pool','update','--name',$pool.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'--only-show-errors','-o','json') + if ($update.Success) { $finalStatus += $update.Result; $poolResult = "Updated"; Write-Output "-- Elastic Pool '$($pool.name)' updated to license type '$LicenseType'" } + else { $poolResult = "Failed"; $poolError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($pool.id -split '/')[2] @@ -770,21 +812,8 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $pool.licenseType ResourceGroup = $pool.resourceGroup Location = $pool.location - } - - if (-not $ReportOnly) { - Write-Output "Updating Elastic Pool '$($pool.name)' on server '$($server.name)' to license type '$LicenseType'..." - try { - $result = az sql elastic-pool update --name $pool.name --server $server.name --resource-group $server.resourceGroup --set licenseType=$LicenseType --only-show-errors -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue - if ($result) { - Write-Output "Successfully updated elastic pool '$($pool.name)' license to '$LicenseType'" - $finalStatus += $result - } else { - Write-Output "Failed to update elastic pool '$($pool.name)' license. No result returned." - } - } catch { - Write-Output "Error updating elastic pool '$($pool.name)': $_" - } + UpdateResult = $poolResult + UpdateError = $poolError } } } @@ -829,8 +858,19 @@ foreach ($sub in $subscriptions) { Write-Output "Found $($poolsToUpdate.Count) SQL Instance Pools that require a license update." } foreach ($pool in $poolsToUpdate) { - - # Collect data before modification + + $ipResult = "NotAttempted" + $ipError = "" + + if (-not $ReportOnly) { + 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)'" -Arguments @( + 'sql','instance-pool','update','--name',$pool.name,'--resource-group',$pool.resourceGroup,'--license-type',$LicenseType,'-o','json') + if ($update.Success) { $finalStatus += $update.Result; $ipResult = "Updated"; Write-Output "-- SQL Instance Pool '$($pool.name)' updated to license type '$LicenseType'" } + else { $ipResult = "Failed"; $ipError = $update.ErrorMessage } + } + + # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId SubID = ($pool.id -split '/')[2] @@ -840,11 +880,8 @@ foreach ($sub in $subscriptions) { OriginalLicenseType = $pool.licenseType ResourceGroup = $pool.resourceGroup Location = $pool.location - } - if (-not $ReportOnly) { - Write-Output "Updating SQL Instance Pool '$($pool.name)' in RG '$($pool.resourceGroup)' to license type '$LicenseType'..." - $result = az sql instance-pool update --name $pool.name --resource-group $pool.resourceGroup --license-type $LicenseType -o json | ConvertFrom-Json - $finalStatus += $result + UpdateResult = $ipResult + UpdateError = $ipError } } } From b7dfa99e6cea2c486a5e77d4ba9de3ef188ffea1 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 18:30:29 -0700 Subject: [PATCH 23/48] Document synchronous Azure vs asynchronous Arc update completion The report's UpdateResult column does not mean the same thing on both paths, and nothing said so. Azure CLI update calls are synchronous: no --no-wait is passed, so the CLI polls the underlying PUT to a terminal state before returning. Measured on a SQL VM: the call returned after 126s carrying provisioningState 'Succeeded', and the new license type was immediately readable. 'Updated' therefore means committed. The Arc path uses Set-AzConnectedMachineExtension -NoWait and never polls, so 'RequestSubmitted' means only that the service accepted the request. The agent-side push can still fail afterwards without the script observing it. Documents the distinction in the README, adds a Resource Graph query for confirming the real Arc end state, and records TESTPLAN cases #29-#30 along with the option of an opt-in -WaitForCompletion switch. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../manage/manage-payg-transition/README.md | 26 +++++++++++++++++++ .../manage/manage-payg-transition/TESTPLAN.md | 13 ++++++++++ 2 files changed, 39 insertions(+) diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index cc42fd1d43..1d3af4be38 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -99,6 +99,32 @@ The script accepts the following command line parameters: `UpdateResult` column records the actual per-resource outcome (`RequestSubmitted`, `Failed`, or `NotAttempted`), and `UpdateError` carries the service error text when a change was rejected. +- **Azure and Arc resources complete differently, and this affects how you read the report:** + - **Azure SQL resources** (SQL VM, Managed Instance, database, elastic pool, instance pool, + SSIS integration runtime) are updated **synchronously**. The script waits for the + underlying `PUT` to reach a terminal state before continuing, so `UpdateResult = Updated` + means the change is committed and immediately readable. A single SQL VM update typically + takes ~2 minutes for this reason. + - **Arc-connected machines** are updated **asynchronously** (`Set-AzConnectedMachineExtension + -NoWait`). The script only submits the request; it does not wait for the Arc agent to + apply the setting. `UpdateResult = RequestSubmitted` therefore means *"the service accepted + the request"*, **not** *"the license type has changed"*. The push can still fail afterwards + on the machine itself. + - To confirm the Arc-side outcome, re-query the extensions after the agents have had time to + report back — for example: + + ```powershell + Search-AzGraph -Query @" + resources + | where type =~ 'microsoft.hybridcompute/machines/extensions' + | where properties.type in~ ('WindowsAgent.SqlServer','LinuxAgent.SqlServer') + | project name = split(id,'/')[8], licenseType = properties.settings.LicenseType, + state = properties.provisioningState + "@ + ``` + + Re-running the transition script is also safe: already-converged machines are excluded by + the discovery query, so a second run reports only whatever genuinely still needs changing. - The subscriptions in scope of the transition will be automatically tagged with `ArcSQLServerExtensionDeployment:PAYG` to ensure that the furure SQL Servers onboarded to Azure Arc are configured to use the pay-as-you-go subscription. For details, see [Manage automatic connection for SQL Server enabled by Azure Arc](https://learn.microsoft.com/sql/sql-server/azure-arc/manage-autodeploy). ## Example 1 diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 306e334011..d2a23d4944 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -75,6 +75,8 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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. | ## Cleanup @@ -118,6 +120,17 @@ 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 synchronous; Arc updates are not.** The Azure CLI polls the underlying + `PUT` to a terminal state before returning (measured: `az sql vm update` took 126 s and + returned `provisioningState: Succeeded`, with the new value immediately readable), so + `UpdateResult = Updated` is trustworthy. The Arc path deliberately uses + `Set-AzConnectedMachineExtension -NoWait`, so `UpdateResult = RequestSubmitted` confirms only + that the request was **accepted** — the agent-side push can still fail afterwards and the + script will never learn of it. This is why the Arc value is named `RequestSubmitted` rather + than `Updated`. Confirming Arc outcomes requires a follow-up Resource Graph query (documented + in the README) or a re-run. Adding an opt-in `-WaitForCompletion` switch that polls each + extension's `provisioningState` would close this, at the cost of serialising what is + currently a fast fan-out across potentially hundreds of machines. ## Required permissions From 015e554c8f34d7df551d91358340949f1e79ed0e Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 18:37:16 -0700 Subject: [PATCH 24/48] Add opt-in -WaitForCompletion switch for Arc extension updates Arc extension updates are submitted with -NoWait, so the report could only ever record 'RequestSubmitted' - the service accepted the request, which says nothing about whether the agent applied it. Confirming the real outcome previously required a separate Resource Graph query. Adds -WaitForCompletion (with -WaitTimeoutSeconds, default 300) which polls each extension to a terminal provisioning state and records Succeeded/Failed/TimedOut instead. The helper also compares the applied LicenseType against the requested value, so a 'Succeeded' provisioning state carrying the wrong license is reported as a failure rather than a success. Polling backs off 5->30s. A timeout is deliberately not treated as a failure: the agent may still apply the setting after the script stops waiting, so the outcome is recorded as inconclusive. The switch is opt-in because it serialises what is otherwise a fast parallel fan-out across potentially hundreds of machines. Default behaviour is unchanged. Verified live against sqltvm: reported Succeeded in 79.6s, confirmed independently via Search-AzGraph (LicenseType LicenseOnly, provisioningState Succeeded). Also verified inert under -ReportOnly and that the wrapper emits it as a bare switch. Adds TESTPLAN cases #31-#32. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-arc-sql-license-type.ps1 | 109 ++++++++++++++++ .../manage/manage-payg-transition/README.md | 10 +- .../manage/manage-payg-transition/TESTPLAN.md | 6 +- .../manage-payg-transition.ps1 | 123 +++++++++++++++++- 4 files changed, 242 insertions(+), 6 deletions(-) diff --git a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 index 5c3a1af6d9..97dbae8ba2 100644 --- a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 @@ -47,6 +47,19 @@ .PARAMETER UseManagedIdentity Optional. If true, logs in both PowerShell and CLI using managed identity. Required to run the script as a runbook. +.PARAMETER WaitForCompletion + Optional. If specified, waits for each submitted extension update to reach a terminal + provisioning state and reports the confirmed outcome, instead of returning as soon as the + request is accepted. Extension updates are normally submitted with -NoWait, so by default + the report records "RequestSubmitted", which means the service accepted the request - not + that the Arc agent has applied it. Use this switch when you need confirmed results; + it makes the run substantially slower because each machine is polled individually. + +.PARAMETER WaitTimeoutSeconds + Optional. Maximum number of seconds to wait per resource when -WaitForCompletion is used. + Defaults to 300. Reaching the timeout is not treated as a failure: the outcome is recorded + as "TimedOut" because the update may still be applied by the agent afterwards. + #> param ( @@ -89,6 +102,13 @@ param ( [Parameter (Mandatory= $false)] [switch] $UseManagedIdentity, + + [Parameter (Mandatory= $false)] + [switch] $WaitForCompletion, + + [Parameter (Mandatory= $false)] + [int] $WaitTimeoutSeconds = 300, + [Parameter (Mandatory= $false)] [int] $batchSize = 500 ) @@ -107,6 +127,79 @@ try { $scriptStartTime = Get-Date Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" +<# +.SYNOPSIS + Polls an Arc machine extension until its provisioning state is terminal. +.DESCRIPTION + Extension updates are submitted with -NoWait, so the service accepting the request says + nothing about whether the Arc agent applied it. When -WaitForCompletion is used this + polls the extension and reports the confirmed outcome. + + A timeout is deliberately NOT reported as a failure: the agent may still apply the + setting after the script gives up, so the run is recorded as inconclusive rather than + unsuccessful. +#> +function Wait-ArcExtensionProvisioning { + param( + [Parameter(Mandatory = $true)][string]$ResourceGroupName, + [Parameter(Mandatory = $true)][string]$MachineName, + [Parameter(Mandatory = $true)][string]$ExtensionName, + [Parameter(Mandatory = $true)][string]$ExpectedLicenseType, + [Parameter(Mandatory = $true)][int]$TimeoutSeconds + ) + + $terminalStates = @('Succeeded', 'Failed', 'Canceled') + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $delay = 5 + $lastState = 'Unknown' + + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds $delay + try { + $current = Get-AzConnectedMachineExtension -ResourceGroupName $ResourceGroupName ` + -MachineName $MachineName -Name $ExtensionName -ErrorAction Stop + } catch { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = $_.Exception.Message; State = 'Unknown' } + } + + $lastState = "$($current.ProvisioningState)" + + if ($terminalStates -contains $lastState) { + if ($lastState -ne 'Succeeded') { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = "Extension provisioning state is '$lastState'."; State = $lastState } + } + + # A 'Succeeded' provisioning state only means the extension settings were written. + # Confirm the value actually reflects the requested license type. + $applied = $null + if ($null -ne $current.Setting) { + try { $applied = "$($current.Setting['LicenseType'])" } catch { $applied = $null } + } + + if ([string]::IsNullOrEmpty($applied)) { + return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } + } + if ($applied -eq $ExpectedLicenseType) { + return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } + } + return [PSCustomObject]@{ + Result = 'Failed' + ErrorMessage = "Extension reported '$lastState' but LicenseType is '$applied' instead of '$ExpectedLicenseType'." + State = $lastState + } + } + + # Back off gradually to avoid hammering the API on slow agents. + if ($delay -lt 30) { $delay = [Math]::Min(30, $delay * 2) } + } + + return [PSCustomObject]@{ + Result = 'TimedOut' + ErrorMessage = "Did not reach a terminal provisioning state within $TimeoutSeconds seconds (last state: '$lastState'). The update may still be applied by the agent." + State = $lastState + } +} + function Connect-Azure { [CmdletBinding()] @@ -463,6 +556,22 @@ foreach ($sub in $subscriptions) { Set-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -Location $setID.Location -MachineName $setID.MachineName -Publisher $setID.Publisher -ExtensionType $setID.ExtensionType -Setting $settings -NoWait -ErrorAction Stop Write-Output "Updated -- Resource group: [$($setID.ResourceGroup)], Connected machine: [$($setID.MachineName)]" $resourceRecord.UpdateResult = "RequestSubmitted" + + if ($WaitForCompletion) { + Write-Output " Waiting for the extension update on [$($setID.MachineName)] to complete (timeout ${WaitTimeoutSeconds}s)..." + $wait = Wait-ArcExtensionProvisioning -ResourceGroupName $setID.ResourceGroup ` + -MachineName $setID.MachineName -ExtensionName $setID.Name ` + -ExpectedLicenseType "$($settings['LicenseType'])" -TimeoutSeconds $WaitTimeoutSeconds + + $resourceRecord.UpdateResult = $wait.Result + $resourceRecord.UpdateError = $wait.ErrorMessage + + switch ($wait.Result) { + 'Succeeded' { Write-Output " Confirmed -- [$($setID.MachineName)] provisioning state '$($wait.State)'." } + 'TimedOut' { Write-Warning "Timed out waiting for [$($setID.MachineName)]: $($wait.ErrorMessage)" } + default { Write-Warning "The extension update for [$($setID.MachineName)] did not succeed: $($wait.ErrorMessage)" } + } + } } catch { $errorMessage = $_.Exception.Message Write-Output "The request to modify the extension object for [$($setID.MachineName)] failed with the following error: $errorMessage" diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index 1d3af4be38..1184922cfc 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -110,8 +110,8 @@ The script accepts the following command line parameters: apply the setting. `UpdateResult = RequestSubmitted` therefore means *"the service accepted the request"*, **not** *"the license type has changed"*. The push can still fail afterwards on the machine itself. - - To confirm the Arc-side outcome, re-query the extensions after the agents have had time to - report back — for example: + - To confirm the Arc-side outcome, either pass `-WaitForCompletion` (see below) or re-query + the extensions after the agents have had time to report back — for example: ```powershell Search-AzGraph -Query @" @@ -125,6 +125,12 @@ The script accepts the following command line parameters: Re-running the transition script is also safe: already-converged machines are excluded by the discovery query, so a second run reports only whatever genuinely still needs changing. + - Alternatively, pass `-WaitForCompletion` to poll each Arc extension until it reaches a + terminal provisioning state. The report then records the confirmed outcome (`Succeeded`, + `Failed`, or `TimedOut`) instead of `RequestSubmitted`. This is opt-in because it + serialises what is otherwise a fast parallel fan-out, so it is considerably slower on + large estates. A `TimedOut` result is inconclusive, not a failure — the agent may still + apply the setting afterwards. - The subscriptions in scope of the transition will be automatically tagged with `ArcSQLServerExtensionDeployment:PAYG` to ensure that the furure SQL Servers onboarded to Azure Arc are configured to use the pay-as-you-go subscription. For details, see [Manage automatic connection for SQL Server enabled by Azure Arc](https://learn.microsoft.com/sql/sql-server/azure-arc/manage-autodeploy). ## Example 1 diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index d2a23d4944..bc1218bd2a 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -77,6 +77,8 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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`. | ## Cleanup @@ -128,9 +130,7 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c that the request was **accepted** — the agent-side push can still fail afterwards and the script will never learn of it. This is why the Arc value is named `RequestSubmitted` rather than `Updated`. Confirming Arc outcomes requires a follow-up Resource Graph query (documented - in the README) or a re-run. Adding an opt-in `-WaitForCompletion` switch that polls each - extension's `provisioningState` would close this, at the cost of serialising what is - currently a fast fan-out across potentially hundreds of machines. + in the README), a re-run, or the opt-in `-WaitForCompletion` switch added in test #31. ## Required permissions diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 0d9f069808..2ddf19df5d 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -37,6 +37,14 @@ Perform a read-only dry run: discover and report the resources that would be changed, without modifying any license types. +.PARAMETER WaitForCompletion + Applies to Arc-connected machines only. Arc extension updates are submitted + asynchronously, so by default the report records 'RequestSubmitted', meaning the + service accepted the request rather than that the agent applied it. Use this switch + to poll each extension until it reaches a terminal state and report the confirmed + outcome. Azure SQL resources are always updated synchronously and are unaffected. + This makes the run substantially slower on large estates. + .PARAMETER AutomationAccResourceGroupName Required only when -RunMode is 'Scheduled'. Resource group for the Azure Automation Account that will host the recurring runbook. Not needed/used for @@ -99,6 +107,9 @@ param( [Parameter(Mandatory=$false)] [switch]$ReportOnly, + [Parameter(Mandatory=$false)] + [switch]$WaitForCompletion, + [Parameter(Mandatory=$false)] [string]$AutomationAccResourceGroupName=$null, @@ -1054,6 +1065,19 @@ $EmbeddedScripts['Arc'] = @' .PARAMETER UseManagedIdentity Optional. If true, logs in both PowerShell and CLI using managed identity. Required to run the script as a runbook. +.PARAMETER WaitForCompletion + Optional. If specified, waits for each submitted extension update to reach a terminal + provisioning state and reports the confirmed outcome, instead of returning as soon as the + request is accepted. Extension updates are normally submitted with -NoWait, so by default + the report records "RequestSubmitted", which means the service accepted the request - not + that the Arc agent has applied it. Use this switch when you need confirmed results; + it makes the run substantially slower because each machine is polled individually. + +.PARAMETER WaitTimeoutSeconds + Optional. Maximum number of seconds to wait per resource when -WaitForCompletion is used. + Defaults to 300. Reaching the timeout is not treated as a failure: the outcome is recorded + as "TimedOut" because the update may still be applied by the agent afterwards. + #> param ( @@ -1096,6 +1120,13 @@ param ( [Parameter (Mandatory= $false)] [switch] $UseManagedIdentity, + + [Parameter (Mandatory= $false)] + [switch] $WaitForCompletion, + + [Parameter (Mandatory= $false)] + [int] $WaitTimeoutSeconds = 300, + [Parameter (Mandatory= $false)] [int] $batchSize = 500 ) @@ -1114,6 +1145,79 @@ try { $scriptStartTime = Get-Date Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" +<# +.SYNOPSIS + Polls an Arc machine extension until its provisioning state is terminal. +.DESCRIPTION + Extension updates are submitted with -NoWait, so the service accepting the request says + nothing about whether the Arc agent applied it. When -WaitForCompletion is used this + polls the extension and reports the confirmed outcome. + + A timeout is deliberately NOT reported as a failure: the agent may still apply the + setting after the script gives up, so the run is recorded as inconclusive rather than + unsuccessful. +#> +function Wait-ArcExtensionProvisioning { + param( + [Parameter(Mandatory = $true)][string]$ResourceGroupName, + [Parameter(Mandatory = $true)][string]$MachineName, + [Parameter(Mandatory = $true)][string]$ExtensionName, + [Parameter(Mandatory = $true)][string]$ExpectedLicenseType, + [Parameter(Mandatory = $true)][int]$TimeoutSeconds + ) + + $terminalStates = @('Succeeded', 'Failed', 'Canceled') + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $delay = 5 + $lastState = 'Unknown' + + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds $delay + try { + $current = Get-AzConnectedMachineExtension -ResourceGroupName $ResourceGroupName ` + -MachineName $MachineName -Name $ExtensionName -ErrorAction Stop + } catch { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = $_.Exception.Message; State = 'Unknown' } + } + + $lastState = "$($current.ProvisioningState)" + + if ($terminalStates -contains $lastState) { + if ($lastState -ne 'Succeeded') { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = "Extension provisioning state is '$lastState'."; State = $lastState } + } + + # A 'Succeeded' provisioning state only means the extension settings were written. + # Confirm the value actually reflects the requested license type. + $applied = $null + if ($null -ne $current.Setting) { + try { $applied = "$($current.Setting['LicenseType'])" } catch { $applied = $null } + } + + if ([string]::IsNullOrEmpty($applied)) { + return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } + } + if ($applied -eq $ExpectedLicenseType) { + return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } + } + return [PSCustomObject]@{ + Result = 'Failed' + ErrorMessage = "Extension reported '$lastState' but LicenseType is '$applied' instead of '$ExpectedLicenseType'." + State = $lastState + } + } + + # Back off gradually to avoid hammering the API on slow agents. + if ($delay -lt 30) { $delay = [Math]::Min(30, $delay * 2) } + } + + return [PSCustomObject]@{ + Result = 'TimedOut' + ErrorMessage = "Did not reach a terminal provisioning state within $TimeoutSeconds seconds (last state: '$lastState'). The update may still be applied by the agent." + State = $lastState + } +} + function Connect-Azure { [CmdletBinding()] @@ -1470,6 +1574,22 @@ foreach ($sub in $subscriptions) { Set-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -Location $setID.Location -MachineName $setID.MachineName -Publisher $setID.Publisher -ExtensionType $setID.ExtensionType -Setting $settings -NoWait -ErrorAction Stop Write-Output "Updated -- Resource group: [$($setID.ResourceGroup)], Connected machine: [$($setID.MachineName)]" $resourceRecord.UpdateResult = "RequestSubmitted" + + if ($WaitForCompletion) { + Write-Output " Waiting for the extension update on [$($setID.MachineName)] to complete (timeout ${WaitTimeoutSeconds}s)..." + $wait = Wait-ArcExtensionProvisioning -ResourceGroupName $setID.ResourceGroup ` + -MachineName $setID.MachineName -ExtensionName $setID.Name ` + -ExpectedLicenseType "$($settings['LicenseType'])" -TimeoutSeconds $WaitTimeoutSeconds + + $resourceRecord.UpdateResult = $wait.Result + $resourceRecord.UpdateError = $wait.ErrorMessage + + switch ($wait.Result) { + 'Succeeded' { Write-Output " Confirmed -- [$($setID.MachineName)] provisioning state '$($wait.State)'." } + 'TimedOut' { Write-Warning "Timed out waiting for [$($setID.MachineName)]: $($wait.ErrorMessage)" } + default { Write-Warning "The extension update for [$($setID.MachineName)] did not succeed: $($wait.ErrorMessage)" } + } + } } catch { $errorMessage = $_.Exception.Message Write-Output "The request to modify the extension object for [$($setID.MachineName)] failed with the following error: $errorMessage" @@ -1505,7 +1625,6 @@ Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss')) if ($transcriptStarted) { try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } } - '@ $EmbeddedScripts['General'] = @' @@ -1837,6 +1956,7 @@ $scriptFiles = @{ ResourceGroup = [string]$targetResourceGroup TenantId = [string]$TenantId ReportOnly = [bool]$ReportOnly + WaitForCompletion = [bool]$WaitForCompletion } } } @@ -1900,6 +2020,7 @@ Force = `$true $(if ($null -ne $UsePcoreLicense) { "UsePcoreLicense='$UsePcoreLicense'" } else { "" }) $(if ($null -ne $TenantId -and $TenantId -ne "") { "TenantId='$TenantId'" }) $(if ($ReportOnly) { "ReportOnly=`$true" }) +$(if ($WaitForCompletion) { "WaitForCompletion=`$true" }) $(if ($null -ne $targetSubscription -and $targetSubscription -ne "") { "SubId='$targetSubscription'" }) $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "ResourceGroup='$targetResourceGroup'" }) } From 2e61bc059ed2e6e2c4b9750163bf557caa78dcf4 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 19:00:03 -0700 Subject: [PATCH 25/48] Make non-blocking submission the default for Azure SQL updates Azure SQL updates previously always blocked until the operation completed, so a large estate was processed serially. They are now submitted with --no-wait and reported as 'RequestSubmitted'; passing -WaitForCompletion omits the flag so the CLI polls to a terminal state and the result is reported as 'Updated'. This matches the Arc path, which has always used -NoWait, giving one consistent default across resource types. Two resource types cannot honour this and are documented rather than faked: 'az sql vm update' and Set-AzDataFactoryV2IntegrationRuntime expose no --no-wait/-AsJob equivalent. A generic 'az resource update/patch' fallback does not support --no-wait either, and a direct ARM PATCH on a SQL VM is rejected with MissingPatchParameters (approved values: tags, additionalVmPatch), so the license type cannot be set by a lightweight non-blocking call. Hand-rolling a full PUT was judged worse than documenting the limitation. The helper only appends --no-wait for commands that accept it, and tolerates the empty response body those calls return. Adds TESTPLAN cases #33-#35 and re-syncs the embedded copy. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-azure-sql-license-type.ps1 | 79 ++++++++++++--- .../manage/manage-payg-transition/README.md | 69 +++++++------- .../manage/manage-payg-transition/TESTPLAN.md | 23 +++-- .../manage-payg-transition.ps1 | 95 +++++++++++++++---- 4 files changed, 190 insertions(+), 76 deletions(-) diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index 6e58789463..522fed7429 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -46,6 +46,16 @@ - For SQL Server: Updates all databases under the specified server - For SQL Managed Instance: Updates the specified instance - For SQL VM: Updates the specified VM + +.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 + than that the change has been applied. + + Note: 'az sql vm update' and Set-AzDataFactoryV2IntegrationRuntime provide no asynchronous + option, so SQL virtual machines and SSIS integration runtimes always wait regardless of + this switch and always report "Updated". #> param ( @@ -70,6 +80,9 @@ param ( [Parameter (Mandatory= $false)] [switch] $UseManagedIdentity, + + [Parameter (Mandatory= $false)] + [switch] $WaitForCompletion, [Parameter (Mandatory= $false)] [string] $ResourceName @@ -164,27 +177,49 @@ function Connect-Azure { 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, so it is + always synchronous regardless of -WaitForCompletion. #> function Invoke-AzCliLicenseUpdate { param( [Parameter(Mandatory = $true)][string[]]$Arguments, - [Parameter(Mandatory = $true)][string]$Description + [Parameter(Mandatory = $true)][string]$Description, + [switch]$SupportsNoWait ) - $output = & az @Arguments 2>&1 + $effectiveArgs = @($Arguments) + $submittedOnly = $false + if ($SupportsNoWait -and -not $WaitForCompletion) { + $effectiveArgs += '--no-wait' + $submittedOnly = $true + } + + $output = & az @effectiveArgs 2>&1 if ($LASTEXITCODE -ne 0) { $message = ($output | Out-String).Trim() Write-Warning "Failed to update $Description`: $message" - return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message } + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message; Submitted = $submittedOnly } } + # --no-wait produces no output, so only attempt to parse when something came back. $parsed = $null - try { $parsed = $output | ConvertFrom-Json } catch { $parsed = $output } + $raw = ($output | Out-String).Trim() + if (-not [string]::IsNullOrWhiteSpace($raw)) { + try { $parsed = $raw | ConvertFrom-Json } catch { $parsed = $raw } + } + # 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 = "" } + return [PSCustomObject]@{ Success = $true; Result = $parsed; ErrorMessage = ""; Submitted = $submittedOnly } } @@ -363,6 +398,8 @@ foreach ($sub in $subscriptions) { 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'..." + # 'az sql vm update' offers no --no-wait option, so this call is + # always synchronous regardless of -WaitForCompletion. $update = Invoke-AzCliLicenseUpdate -Description "SQL VM '$($sqlvm.name)'" -Arguments @( 'sql','vm','update','-n',$sqlvm.name,'-g',$sqlvm.resourceGroup,'--license-type',$SqlVmLicenseType,'-o','json') if ($update.Success) { $finalStatus += $update.Result; $vmResult = "Updated"; Write-Output "-- SQL VM '$($sqlvm.name)' updated to license type '$SqlVmLicenseType'" } @@ -438,9 +475,13 @@ foreach ($sub in $subscriptions) { if (-not $ReportOnly) { 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)'" -Arguments @( + $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') - if ($update.Success) { $finalStatus += $update.Result; $miResult = "Updated"; Write-Output "-- SQL Managed Instance '$($mi.name)' updated to license type '$LicenseType'" } + if ($update.Success) { + $finalStatus += $update.Result + $miResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Managed Instance '$($mi.name)': $miResult (license type '$LicenseType')" + } else { $miResult = "Failed"; $miError = $update.ErrorMessage } } @@ -597,9 +638,13 @@ foreach ($sub in $subscriptions) { if (-not $ReportOnly) { 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)'" -Arguments @( + $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') - if ($update.Success) { $finalStatus += $update.Result; $dbResult = "Updated"; Write-Output "-- SQL Database '$($db.name)' updated to license type '$LicenseType'" } + if ($update.Success) { + $finalStatus += $update.Result + $dbResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Database '$($db.name)': $dbResult (license type '$LicenseType')" + } else { $dbResult = "Failed"; $dbError = $update.ErrorMessage } } @@ -663,9 +708,13 @@ foreach ($sub in $subscriptions) { if (-not $ReportOnly) { 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)'" -Arguments @( + $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') - if ($update.Success) { $finalStatus += $update.Result; $poolResult = "Updated"; Write-Output "-- Elastic Pool '$($pool.name)' updated to license type '$LicenseType'" } + if ($update.Success) { + $finalStatus += $update.Result + $poolResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- Elastic Pool '$($pool.name)': $poolResult (license type '$LicenseType')" + } else { $poolResult = "Failed"; $poolError = $update.ErrorMessage } } @@ -731,9 +780,13 @@ foreach ($sub in $subscriptions) { if (-not $ReportOnly) { 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)'" -Arguments @( + $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') - if ($update.Success) { $finalStatus += $update.Result; $ipResult = "Updated"; Write-Output "-- SQL Instance Pool '$($pool.name)' updated to license type '$LicenseType'" } + if ($update.Success) { + $finalStatus += $update.Result + $ipResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Instance Pool '$($pool.name)': $ipResult (license type '$LicenseType')" + } else { $ipResult = "Failed"; $ipError = $update.ErrorMessage } } diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index 1184922cfc..c0e07d3d96 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -47,6 +47,7 @@ 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`. SQL virtual machines and SSIS integration runtimes always wait (no asynchronous option exists for them). 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`.| @@ -95,42 +96,40 @@ The script accepts the following command line parameters: 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. -- Each run writes a `ModifiedResources_.csv` report. For Arc resources the - `UpdateResult` column records the actual per-resource outcome (`RequestSubmitted`, - `Failed`, or `NotAttempted`), and `UpdateError` carries the service error text when a +- 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. -- **Azure and Arc resources complete differently, and this affects how you read the report:** - - **Azure SQL resources** (SQL VM, Managed Instance, database, elastic pool, instance pool, - SSIS integration runtime) are updated **synchronously**. The script waits for the - underlying `PUT` to reach a terminal state before continuing, so `UpdateResult = Updated` - means the change is committed and immediately readable. A single SQL VM update typically - takes ~2 minutes for this reason. - - **Arc-connected machines** are updated **asynchronously** (`Set-AzConnectedMachineExtension - -NoWait`). The script only submits the request; it does not wait for the Arc agent to - apply the setting. `UpdateResult = RequestSubmitted` therefore means *"the service accepted - the request"*, **not** *"the license type has changed"*. The push can still fail afterwards - on the machine itself. - - To confirm the Arc-side outcome, either pass `-WaitForCompletion` (see below) or re-query - the extensions after the agents have had time to report back — for example: - - ```powershell - Search-AzGraph -Query @" - resources - | where type =~ 'microsoft.hybridcompute/machines/extensions' - | where properties.type in~ ('WindowsAgent.SqlServer','LinuxAgent.SqlServer') - | project name = split(id,'/')[8], licenseType = properties.settings.LicenseType, - state = properties.provisioningState - "@ - ``` - - Re-running the transition script is also safe: already-converged machines are excluded by - the discovery query, so a second run reports only whatever genuinely still needs changing. - - Alternatively, pass `-WaitForCompletion` to poll each Arc extension until it reaches a - terminal provisioning state. The report then records the confirmed outcome (`Succeeded`, - `Failed`, or `TimedOut`) instead of `RequestSubmitted`. This is opt-in because it - serialises what is otherwise a fast parallel fan-out, so it is considerably slower on - large estates. A `TimedOut` result is inconclusive, not a failure — the agent may still - apply the setting afterwards. +- **By default the script does not wait for changes to finish.** Updates are submitted + asynchronously and the report records `RequestSubmitted`, which means *"the service accepted + the request"* — **not** *"the license type has changed"*. Pass `-WaitForCompletion` to wait + for each change to reach a terminal state and report a confirmed outcome instead. + + | Resource | Default | With `-WaitForCompletion` | + |---|---|---| + | SQL Managed Instance, database, elastic pool, instance pool | `--no-wait`, 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 | + + SQL virtual machines and SSIS integration runtimes are exceptions because `az sql vm update` + and `Set-AzDataFactoryV2IntegrationRuntime` expose no asynchronous option. A single SQL VM + update therefore still takes roughly two minutes. + + For Arc, a `TimedOut` result is inconclusive rather than a failure — the agent may still + apply the setting after the script stops waiting. +- To confirm outcomes after a default (non-waiting) run, either re-run the script — already + converged resources are excluded by discovery, so a second run reports only what genuinely + still needs changing — or query the current state directly. For Arc: + + ```powershell + Search-AzGraph -Query @" + resources + | where type =~ 'microsoft.hybridcompute/machines/extensions' + | where properties.type in~ ('WindowsAgent.SqlServer','LinuxAgent.SqlServer') + | project name = split(id,'/')[8], licenseType = properties.settings.LicenseType, + state = properties.provisioningState + "@ + ``` - The subscriptions in scope of the transition will be automatically tagged with `ArcSQLServerExtensionDeployment:PAYG` to ensure that the furure SQL Servers onboarded to Azure Arc are configured to use the pay-as-you-go subscription. For details, see [Manage automatic connection for SQL Server enabled by Azure Arc](https://learn.microsoft.com/sql/sql-server/azure-arc/manage-autodeploy). ## Example 1 diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index bc1218bd2a..aa1d8f4123 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -79,6 +79,9 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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 | ⚠️ Documented limitation. `az sql vm update` and `Set-AzDataFactoryV2IntegrationRuntime` expose no `--no-wait`/`-AsJob` equivalent, so SQL virtual machines and SSIS integration runtimes remain synchronous regardless of the switch. 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`, so the license type cannot be changed by a lightweight non-blocking call. Rather than hand-rolling a full `PUT` with the complete resource body, the two exceptions are documented in the README parameter table and behaviour matrix. | +| 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`. | ## Cleanup @@ -122,15 +125,17 @@ 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 synchronous; Arc updates are not.** The Azure CLI polls the underlying - `PUT` to a terminal state before returning (measured: `az sql vm update` took 126 s and - returned `provisioningState: Succeeded`, with the new value immediately readable), so - `UpdateResult = Updated` is trustworthy. The Arc path deliberately uses - `Set-AzConnectedMachineExtension -NoWait`, so `UpdateResult = RequestSubmitted` confirms only - that the request was **accepted** — the agent-side push can still fail afterwards and the - script will never learn of it. This is why the Arc value is named `RequestSubmitted` rather - than `Updated`. Confirming Arc outcomes requires a follow-up Resource Graph query (documented - in the README), a re-run, or the opt-in `-WaitForCompletion` switch added in test #31. +- **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. ## Required permissions diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 2ddf19df5d..8d126c8c6c 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -38,12 +38,14 @@ changed, without modifying any license types. .PARAMETER WaitForCompletion - Applies to Arc-connected machines only. Arc extension updates are submitted - asynchronously, so by default the report records 'RequestSubmitted', meaning the - service accepted the request rather than that the agent applied it. Use this switch - to poll each extension until it reaches a terminal state and report the confirmed - outcome. Azure SQL resources are always updated synchronously and are unaffected. - This makes the run substantially slower on large estates. + Wait for each license change to reach a terminal state and report the confirmed + outcome. By default updates are submitted asynchronously and the report records + '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 + 'az sql vm update' and Set-AzDataFactoryV2IntegrationRuntime provide 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 @@ -190,6 +192,16 @@ $EmbeddedScripts['Azure'] = @' - For SQL Server: Updates all databases under the specified server - For SQL Managed Instance: Updates the specified instance - For SQL VM: Updates the specified VM + +.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 + than that the change has been applied. + + Note: 'az sql vm update' and Set-AzDataFactoryV2IntegrationRuntime provide no asynchronous + option, so SQL virtual machines and SSIS integration runtimes always wait regardless of + this switch and always report "Updated". #> param ( @@ -214,6 +226,9 @@ param ( [Parameter (Mandatory= $false)] [switch] $UseManagedIdentity, + + [Parameter (Mandatory= $false)] + [switch] $WaitForCompletion, [Parameter (Mandatory= $false)] [string] $ResourceName @@ -308,27 +323,49 @@ function Connect-Azure { 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, so it is + always synchronous regardless of -WaitForCompletion. #> function Invoke-AzCliLicenseUpdate { param( [Parameter(Mandatory = $true)][string[]]$Arguments, - [Parameter(Mandatory = $true)][string]$Description + [Parameter(Mandatory = $true)][string]$Description, + [switch]$SupportsNoWait ) - $output = & az @Arguments 2>&1 + $effectiveArgs = @($Arguments) + $submittedOnly = $false + if ($SupportsNoWait -and -not $WaitForCompletion) { + $effectiveArgs += '--no-wait' + $submittedOnly = $true + } + + $output = & az @effectiveArgs 2>&1 if ($LASTEXITCODE -ne 0) { $message = ($output | Out-String).Trim() Write-Warning "Failed to update $Description`: $message" - return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message } + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message; Submitted = $submittedOnly } } + # --no-wait produces no output, so only attempt to parse when something came back. $parsed = $null - try { $parsed = $output | ConvertFrom-Json } catch { $parsed = $output } + $raw = ($output | Out-String).Trim() + if (-not [string]::IsNullOrWhiteSpace($raw)) { + try { $parsed = $raw | ConvertFrom-Json } catch { $parsed = $raw } + } + # 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 = "" } + return [PSCustomObject]@{ Success = $true; Result = $parsed; ErrorMessage = ""; Submitted = $submittedOnly } } @@ -507,6 +544,8 @@ foreach ($sub in $subscriptions) { 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'..." + # 'az sql vm update' offers no --no-wait option, so this call is + # always synchronous regardless of -WaitForCompletion. $update = Invoke-AzCliLicenseUpdate -Description "SQL VM '$($sqlvm.name)'" -Arguments @( 'sql','vm','update','-n',$sqlvm.name,'-g',$sqlvm.resourceGroup,'--license-type',$SqlVmLicenseType,'-o','json') if ($update.Success) { $finalStatus += $update.Result; $vmResult = "Updated"; Write-Output "-- SQL VM '$($sqlvm.name)' updated to license type '$SqlVmLicenseType'" } @@ -582,9 +621,13 @@ foreach ($sub in $subscriptions) { if (-not $ReportOnly) { 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)'" -Arguments @( + $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') - if ($update.Success) { $finalStatus += $update.Result; $miResult = "Updated"; Write-Output "-- SQL Managed Instance '$($mi.name)' updated to license type '$LicenseType'" } + if ($update.Success) { + $finalStatus += $update.Result + $miResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Managed Instance '$($mi.name)': $miResult (license type '$LicenseType')" + } else { $miResult = "Failed"; $miError = $update.ErrorMessage } } @@ -741,9 +784,13 @@ foreach ($sub in $subscriptions) { if (-not $ReportOnly) { 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)'" -Arguments @( + $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') - if ($update.Success) { $finalStatus += $update.Result; $dbResult = "Updated"; Write-Output "-- SQL Database '$($db.name)' updated to license type '$LicenseType'" } + if ($update.Success) { + $finalStatus += $update.Result + $dbResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Database '$($db.name)': $dbResult (license type '$LicenseType')" + } else { $dbResult = "Failed"; $dbError = $update.ErrorMessage } } @@ -807,9 +854,13 @@ foreach ($sub in $subscriptions) { if (-not $ReportOnly) { 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)'" -Arguments @( + $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') - if ($update.Success) { $finalStatus += $update.Result; $poolResult = "Updated"; Write-Output "-- Elastic Pool '$($pool.name)' updated to license type '$LicenseType'" } + if ($update.Success) { + $finalStatus += $update.Result + $poolResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- Elastic Pool '$($pool.name)': $poolResult (license type '$LicenseType')" + } else { $poolResult = "Failed"; $poolError = $update.ErrorMessage } } @@ -875,9 +926,13 @@ foreach ($sub in $subscriptions) { if (-not $ReportOnly) { 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)'" -Arguments @( + $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') - if ($update.Success) { $finalStatus += $update.Result; $ipResult = "Updated"; Write-Output "-- SQL Instance Pool '$($pool.name)' updated to license type '$LicenseType'" } + if ($update.Success) { + $finalStatus += $update.Result + $ipResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Instance Pool '$($pool.name)': $ipResult (license type '$LicenseType')" + } else { $ipResult = "Failed"; $ipError = $update.ErrorMessage } } @@ -1944,6 +1999,7 @@ $scriptFiles = @{ ResourceGroup = [string]$targetResourceGroup TenantId = [string]$TenantId ReportOnly = [bool]$ReportOnly + WaitForCompletion = [bool]$WaitForCompletion } } Arc = @{ @@ -2045,6 +2101,7 @@ $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "Resour LicenseType= '$azureLicenseType' $(if ($null -ne $TenantId -and $TenantId -ne "") { "TenantId= '$TenantId'" }) $(if ($ReportOnly) { "ReportOnly= `$true" }) + $(if ($WaitForCompletion) { "WaitForCompletion= `$true" }) $(if ($null -ne $targetResourceGroup -and $targetResourceGroup -ne "") { "ResourceGroup= '$targetResourceGroup'" }) $(if ($null -ne $targetSubscription -and $targetSubscription -ne "") { "SubId= '$targetSubscription'" }) From a9ea2c162cf52a29ad0811551121b7401d6151e4 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 19:25:19 -0700 Subject: [PATCH 26/48] Make SQL VM license updates asynchronous by default 'az sql vm update' has no --no-wait option and blocks for roughly two minutes per VM, so SQL virtual machines were the one Azure resource type that ignored the async-by-default behaviour introduced with -WaitForCompletion. 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. Add Invoke-SqlVmLicenseUpdate, which submits the change to ARM directly by reading the resource and writing it back with only sqlServerLicenseType changed. ARM accepts the request and returns an Azure-AsyncOperation header without waiting, so the call completes in seconds. The synchronous CLI path is retained for -WaitForCompletion and as an automatic fallback on failure. Verified live against rajpoTest: default run reported RequestSubmitted in 49.7s and the change was confirmed in Azure; -WaitForCompletion reported Updated in 189.3s. Re-synced the embedded orchestrator copy and corrected the README behaviour matrix and TESTPLAN case #34, which wrongly recorded the SQL VM limitation as unavoidable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-azure-sql-license-type.ps1 | 87 ++++++++++++++++--- .../manage/manage-payg-transition/README.md | 16 ++-- .../manage/manage-payg-transition/TESTPLAN.md | 4 +- .../manage-payg-transition.ps1 | 87 ++++++++++++++++--- 4 files changed, 168 insertions(+), 26 deletions(-) diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index 522fed7429..89e923b41e 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -53,9 +53,10 @@ --no-wait and reports "RequestSubmitted", meaning the service accepted the request rather than that the change has been applied. - Note: 'az sql vm update' and Set-AzDataFactoryV2IntegrationRuntime provide no asynchronous - option, so SQL virtual machines and SSIS integration runtimes always wait regardless of - this switch and always report "Updated". + 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. #> param ( @@ -184,8 +185,8 @@ function Connect-Azure { 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, so it is - always synchronous regardless of -WaitForCompletion. + Set for commands that accept --no-wait. 'az sql vm update' does not; SQL VMs are + submitted asynchronously through Invoke-SqlVmLicenseUpdate instead. #> function Invoke-AzCliLicenseUpdate { param( @@ -223,6 +224,71 @@ function Invoke-AzCliLicenseUpdate { } +<# +.SYNOPSIS + Updates the license type of a SQL virtual machine, asynchronously by default. +.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. +#> +function Invoke-SqlVmLicenseUpdate { + param( + [Parameter(Mandatory = $true)][string]$ResourceId, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$ResourceGroup, + [Parameter(Mandatory = $true)][string]$LicenseType + ) + + $cliArguments = @('sql','vm','update','-n',$Name,'-g',$ResourceGroup,'--license-type',$LicenseType,'-o','json') + + if ($WaitForCompletion) { + return Invoke-AzCliLicenseUpdate -Description "SQL VM '$Name'" -Arguments $cliArguments + } + + $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 } + } + + 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 + } +} + + $finalStatus = @() # Convert to hashtable explicitly @@ -398,11 +464,12 @@ foreach ($sub in $subscriptions) { 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'..." - # 'az sql vm update' offers no --no-wait option, so this call is - # always synchronous regardless of -WaitForCompletion. - $update = Invoke-AzCliLicenseUpdate -Description "SQL VM '$($sqlvm.name)'" -Arguments @( - 'sql','vm','update','-n',$sqlvm.name,'-g',$sqlvm.resourceGroup,'--license-type',$SqlVmLicenseType,'-o','json') - if ($update.Success) { $finalStatus += $update.Result; $vmResult = "Updated"; Write-Output "-- SQL VM '$($sqlvm.name)' updated 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 } } diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index c0e07d3d96..d2444f5bfe 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -47,7 +47,7 @@ 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`. SQL virtual machines and 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`. SSIS integration runtimes always wait (no asynchronous option exists for them). 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`.| @@ -108,12 +108,18 @@ The script accepts the following command line parameters: |---|---|---| | SQL Managed Instance, database, elastic pool, instance pool | `--no-wait`, 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 | + | SQL virtual machine | direct ARM request, reports `RequestSubmitted` | `az sql vm update` waits, reports `Updated` | | **SSIS integration runtime** | **always waits**, reports `Updated` | same | - SQL virtual machines and SSIS integration runtimes are exceptions because `az sql vm update` - and `Set-AzDataFactoryV2IntegrationRuntime` expose no asynchronous option. A single SQL VM - update therefore still takes roughly two minutes. + SSIS integration runtimes are the one exception, 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. 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 aa1d8f4123..31a56e64ce 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -80,8 +80,10 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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 | ⚠️ Documented limitation. `az sql vm update` and `Set-AzDataFactoryV2IntegrationRuntime` expose no `--no-wait`/`-AsJob` equivalent, so SQL virtual machines and SSIS integration runtimes remain synchronous regardless of the switch. 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`, so the license type cannot be changed by a lightweight non-blocking call. Rather than hand-rolling a full `PUT` with the complete resource body, the two exceptions are documented in the README parameter table and behaviour matrix. | +| 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. | ## Cleanup diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 8d126c8c6c..3ed83c1e84 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -199,9 +199,10 @@ $EmbeddedScripts['Azure'] = @' --no-wait and reports "RequestSubmitted", meaning the service accepted the request rather than that the change has been applied. - Note: 'az sql vm update' and Set-AzDataFactoryV2IntegrationRuntime provide no asynchronous - option, so SQL virtual machines and SSIS integration runtimes always wait regardless of - this switch and always report "Updated". + 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. #> param ( @@ -330,8 +331,8 @@ function Connect-Azure { 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, so it is - always synchronous regardless of -WaitForCompletion. + Set for commands that accept --no-wait. 'az sql vm update' does not; SQL VMs are + submitted asynchronously through Invoke-SqlVmLicenseUpdate instead. #> function Invoke-AzCliLicenseUpdate { param( @@ -369,6 +370,71 @@ function Invoke-AzCliLicenseUpdate { } +<# +.SYNOPSIS + Updates the license type of a SQL virtual machine, asynchronously by default. +.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. +#> +function Invoke-SqlVmLicenseUpdate { + param( + [Parameter(Mandatory = $true)][string]$ResourceId, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$ResourceGroup, + [Parameter(Mandatory = $true)][string]$LicenseType + ) + + $cliArguments = @('sql','vm','update','-n',$Name,'-g',$ResourceGroup,'--license-type',$LicenseType,'-o','json') + + if ($WaitForCompletion) { + return Invoke-AzCliLicenseUpdate -Description "SQL VM '$Name'" -Arguments $cliArguments + } + + $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 } + } + + 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 + } +} + + $finalStatus = @() # Convert to hashtable explicitly @@ -544,11 +610,12 @@ foreach ($sub in $subscriptions) { 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'..." - # 'az sql vm update' offers no --no-wait option, so this call is - # always synchronous regardless of -WaitForCompletion. - $update = Invoke-AzCliLicenseUpdate -Description "SQL VM '$($sqlvm.name)'" -Arguments @( - 'sql','vm','update','-n',$sqlvm.name,'-g',$sqlvm.resourceGroup,'--license-type',$SqlVmLicenseType,'-o','json') - if ($update.Success) { $finalStatus += $update.Result; $vmResult = "Updated"; Write-Output "-- SQL VM '$($sqlvm.name)' updated 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 } } From d4a86203dfa7ee6aafeafb38b44f64f4ceb4e79a Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 19:38:59 -0700 Subject: [PATCH 27/48] Stop reporting unchanged Arc license types as successful updates When an Arc machine already carries a LicenseType it is only overwritten if -Force is supplied. That is intended, but the run was silent about it: the unrelated ConsentToRecurringPAYG block could still set WriteSettings, so the script wrote other settings and printed 'Updated' and 'Confirmed ... Succeeded' for a license type that had not changed. The wait helper could not detect this because it is passed the unmodified settings value, so expected and applied matched. Found while restoring sqltvm, which reported success yet remained LicenseOnly. Emit an explicit warning naming the current and requested license types and pointing at -Force. -Force semantics are unchanged. Also harden Wait-ArcExtensionProvisioning against a stale terminal state. Updates are submitted with -NoWait, so the first poll can observe the previous operation's 'Succeeded' before the new one begins, with the old license type still in place, which was reported as a hard failure. A terminal state carrying an unexpected license type is now inconclusive and polling continues; the mismatch is only returned as Failed if it survives to the timeout. The observed state sequence Updating -> Creating -> Succeeded over ~2.5 minutes confirms the race window is real. Verified live: without -Force the run now reports 'Write Settings - False' and no success line; with -Force sqltvm was restored to PAYG and independently confirmed via Get-AzConnectedMachineExtension. Embedded Arc block re-synced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-arc-sql-license-type.ps1 | 23 +++++++++++++++---- .../manage/manage-payg-transition/TESTPLAN.md | 5 ++++ .../manage-payg-transition.ps1 | 23 +++++++++++++++---- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 index 97dbae8ba2..5582f50b2b 100644 --- a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 @@ -152,6 +152,7 @@ function Wait-ArcExtensionProvisioning { $deadline = (Get-Date).AddSeconds($TimeoutSeconds) $delay = 5 $lastState = 'Unknown' + $mismatch = $null while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds $delay @@ -182,17 +183,22 @@ function Wait-ArcExtensionProvisioning { if ($applied -eq $ExpectedLicenseType) { return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } } - return [PSCustomObject]@{ - Result = 'Failed' - ErrorMessage = "Extension reported '$lastState' but LicenseType is '$applied' instead of '$ExpectedLicenseType'." - State = $lastState - } + + # 'Succeeded' with the wrong license type is ambiguous: the update was submitted + # with -NoWait, so this may still be the *previous* operation's terminal state read + # before the new one started. Keep polling rather than failing on that race; the + # mismatch is only reported if it survives to the deadline. + $mismatch = "Extension reported '$lastState' but LicenseType is '$applied' instead of '$ExpectedLicenseType'." } # Back off gradually to avoid hammering the API on slow agents. if ($delay -lt 30) { $delay = [Math]::Min(30, $delay * 2) } } + if ($mismatch) { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = $mismatch; State = $lastState } + } + return [PSCustomObject]@{ Result = 'TimedOut' ErrorMessage = "Did not reach a terminal provisioning state within $TimeoutSeconds seconds (last state: '$lastState'). The update may still be applied by the agent." @@ -495,6 +501,13 @@ foreach ($sub in $subscriptions) { $ext.Setting["LicenseType"] = $LicenseType $WriteSettings = $true } + elseif ("$($ext.Setting['LicenseType'])" -ne $LicenseType) { + # The machine already carries a license type and -Force was not + # supplied, so it is deliberately left alone. Say so explicitly: + # other settings may still be written below, and without this the + # run would report "Updated" for a license type that never changed. + Write-Warning "[$($setID.MachineName)] LicenseType is '$($ext.Setting['LicenseType'])' and was NOT changed to '$LicenseType'. Re-run with -Force to overwrite an existing license type." + } } else { $ext.Setting["LicenseType"] = $LicenseType $WriteSettings = $true diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 31a56e64ce..5fdcdaba25 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -84,6 +84,8 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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. | ## Cleanup @@ -96,6 +98,9 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c - `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`. ## Known gaps / follow-ups diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 3ed83c1e84..2c26ce4c0d 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -1292,6 +1292,7 @@ function Wait-ArcExtensionProvisioning { $deadline = (Get-Date).AddSeconds($TimeoutSeconds) $delay = 5 $lastState = 'Unknown' + $mismatch = $null while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds $delay @@ -1322,17 +1323,22 @@ function Wait-ArcExtensionProvisioning { if ($applied -eq $ExpectedLicenseType) { return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } } - return [PSCustomObject]@{ - Result = 'Failed' - ErrorMessage = "Extension reported '$lastState' but LicenseType is '$applied' instead of '$ExpectedLicenseType'." - State = $lastState - } + + # 'Succeeded' with the wrong license type is ambiguous: the update was submitted + # with -NoWait, so this may still be the *previous* operation's terminal state read + # before the new one started. Keep polling rather than failing on that race; the + # mismatch is only reported if it survives to the deadline. + $mismatch = "Extension reported '$lastState' but LicenseType is '$applied' instead of '$ExpectedLicenseType'." } # Back off gradually to avoid hammering the API on slow agents. if ($delay -lt 30) { $delay = [Math]::Min(30, $delay * 2) } } + if ($mismatch) { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = $mismatch; State = $lastState } + } + return [PSCustomObject]@{ Result = 'TimedOut' ErrorMessage = "Did not reach a terminal provisioning state within $TimeoutSeconds seconds (last state: '$lastState'). The update may still be applied by the agent." @@ -1635,6 +1641,13 @@ foreach ($sub in $subscriptions) { $ext.Setting["LicenseType"] = $LicenseType $WriteSettings = $true } + elseif ("$($ext.Setting['LicenseType'])" -ne $LicenseType) { + # The machine already carries a license type and -Force was not + # supplied, so it is deliberately left alone. Say so explicitly: + # other settings may still be written below, and without this the + # run would report "Updated" for a license type that never changed. + Write-Warning "[$($setID.MachineName)] LicenseType is '$($ext.Setting['LicenseType'])' and was NOT changed to '$LicenseType'. Re-run with -Force to overwrite an existing license type." + } } else { $ext.Setting["LicenseType"] = $LicenseType $WriteSettings = $true From 5f9a214c5382f78455a8f19c272dbe6b4d398f9d Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 21:21:02 -0700 Subject: [PATCH 28/48] Stop treating failed discovery queries as empty results Discovery calls piped az straight into ConvertFrom-Json with no exit-code check. The Azure CLI signals failure through \0 rather than by throwing, so a failed query yielded \ and every caller read that as 'no resources found'. A transient failure was therefore indistinguishable from an empty result: the run printed 'Found a total of 0 databases' and 'No SQL Databases found ... that require a license update' and moved on, silently skipping resources that needed transitioning while still looking clean. Observed in a real run where az sql db list returned ResourceGroupNotFound for three servers. The errors were spurious: all four resource groups exist, one server succeeded in the same resource group where another failed, and all three listed their databases correctly on retry. Route every discovery path through a new Invoke-AzCliQuery helper (SQL VM list, VM power state, MI list, server list, database list, elastic pool list, instance pool list). It checks the exit code, surfaces the service error as a warning and normalises the value to an array. Callers that cannot proceed without the data now skip with an explicit warning instead of reporting zero. This is the same bug class already fixed for the update paths. Verified: helper unit-tested across success, failure and empty; a re-run now reports 1 database each for demo731 and sqldbinlinemigtestserver instead of 0. Embedded orchestrator copy re-synced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-azure-sql-license-type.ps1 | 118 ++++++++++++++++-- .../manage/manage-payg-transition/TESTPLAN.md | 9 ++ .../manage-payg-transition.ps1 | 118 ++++++++++++++++-- 3 files changed, 225 insertions(+), 20 deletions(-) diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index 89e923b41e..44c13bc343 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -224,6 +224,49 @@ function Invoke-AzCliLicenseUpdate { } +<# +.SYNOPSIS + Runs a read-only Azure CLI query and reports failures instead of silently returning nothing. +.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. +#> +function Invoke-AzCliQuery { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description + ) + + $output = & az @Arguments 2>&1 + + if ($LASTEXITCODE -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 { $parsed = $raw | ConvertFrom-Json } + catch { + Write-Warning "Unable to parse the response for $Description`: $($_.Exception.Message)" + return [PSCustomObject]@{ Success = $false; Value = @(); ErrorMessage = $_.Exception.Message } + } + + # 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. @@ -443,7 +486,11 @@ foreach ($sub in $subscriptions) { $sqlVmQuery += "].{name:name, resourceGroup:resourceGroup, sqlServerLicenseType:sqlServerLicenseType, type:type, id:id, Location:location}" Write-Output "Seeking SQL Virtual Machines with filter $sqlVmQuery..." - $sqlVMs = az sql vm list --query $sqlVmQuery -o json | ConvertFrom-Json + $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) { Write-Output "No SQL VMs found that require a license update." @@ -454,7 +501,16 @@ foreach ($sub in $subscriptions) { if($null -ne (az vm list --query "[?name=='$($sqlvm.name)' && resourceGroup=='$($sqlvm.resourceGroup)' $tagsFilter]")) { - $vmStatus = az 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 | ConvertFrom-Json + $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." + continue + } + $vmStatus = $vmStatusQuery.Value | Select-Object -First 1 if (($vmStatus.PowerState -eq "VM running") -and ($sqlvm.sqlServerLicenseType -ne "DR")) { $vmResult = "NotAttempted" @@ -529,7 +585,11 @@ foreach ($sub in $subscriptions) { $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..." - $runningMIs = az sql mi list --query $miRunningQuery -o json | ConvertFrom-Json + $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 if($runningMIs.Count -eq 0) { Write-Output "No SQL Managed Instances found that require a license update." } else { @@ -626,11 +686,21 @@ foreach ($sub in $subscriptions) { Write-Output "SQL Server query: $serverQuery" # Get all servers first as a fallback in case the query fails - $allServers = az sql server list -o json | ConvertFrom-Json + $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 - $servers = az sql server list --query "$serverQuery" -o json | ConvertFrom-Json + $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 + } # Verify if we got any results if ($null -eq $servers -or $servers.Count -eq 0) { @@ -666,7 +736,13 @@ foreach ($sub in $subscriptions) { Write-Output "Scanning SQL Databases on server '$($server.name)' in resource group '$($server.resourceGroup)'..." # First get all databases to check if any exist - $allDbs = az sql db list --resource-group $server.resourceGroup --server $server.name -o json | ConvertFrom-Json + $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 @@ -686,7 +762,13 @@ foreach ($sub in $subscriptions) { # Get databases with error handling try { - $dbs = az sql db list --resource-group $server.resourceGroup --server $server.name --query "$dbQuery" -o json | ConvertFrom-Json + $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." @@ -739,7 +821,14 @@ foreach ($sub in $subscriptions) { Write-Output "Scanning Elastic Pools on server '$($server.name)'..." # First check if there are any elastic pools - $allPools = az sql elastic-pool list --resource-group $server.resourceGroup --server $server.name --only-show-errors -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue + $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)'." @@ -758,7 +847,12 @@ foreach ($sub in $subscriptions) { Write-Output "Elastic Pool query: $elasticPoolQuery" - $elasticPools = az sql elastic-pool list --resource-group $server.resourceGroup --server $server.name --query "$elasticPoolQuery" --only-show-errors -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue + $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." @@ -833,7 +927,11 @@ foreach ($sub in $subscriptions) { $instancePoolsQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:status}" - $instancePools = az sql instance-pool list --query $instancePoolsQuery -o json 2>$null | ConvertFrom-Json + $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." + } + $instancePools = $instancePoolsQueryResult.Value $poolsToUpdate = $instancePools | Where-Object { $_.licenseType -ne $LicenseType } if($poolsToUpdate.Count -eq 0) { Write-Output "No SQL Instance Pools found that require a license update." diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 5fdcdaba25..4772384ff8 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -86,6 +86,8 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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. | ## Cleanup @@ -104,6 +106,13 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c ## 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 — the only candidates in reach belong to other teams (`dfurman-rg`, + `rsetlem-azuresqldb`) and were deliberately left alone. - `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 diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 2c26ce4c0d..d0c9a217c0 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -370,6 +370,49 @@ function Invoke-AzCliLicenseUpdate { } +<# +.SYNOPSIS + Runs a read-only Azure CLI query and reports failures instead of silently returning nothing. +.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. +#> +function Invoke-AzCliQuery { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description + ) + + $output = & az @Arguments 2>&1 + + if ($LASTEXITCODE -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 { $parsed = $raw | ConvertFrom-Json } + catch { + Write-Warning "Unable to parse the response for $Description`: $($_.Exception.Message)" + return [PSCustomObject]@{ Success = $false; Value = @(); ErrorMessage = $_.Exception.Message } + } + + # 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. @@ -589,7 +632,11 @@ foreach ($sub in $subscriptions) { $sqlVmQuery += "].{name:name, resourceGroup:resourceGroup, sqlServerLicenseType:sqlServerLicenseType, type:type, id:id, Location:location}" Write-Output "Seeking SQL Virtual Machines with filter $sqlVmQuery..." - $sqlVMs = az sql vm list --query $sqlVmQuery -o json | ConvertFrom-Json + $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) { Write-Output "No SQL VMs found that require a license update." @@ -600,7 +647,16 @@ foreach ($sub in $subscriptions) { if($null -ne (az vm list --query "[?name=='$($sqlvm.name)' && resourceGroup=='$($sqlvm.resourceGroup)' $tagsFilter]")) { - $vmStatus = az 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 | ConvertFrom-Json + $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." + continue + } + $vmStatus = $vmStatusQuery.Value | Select-Object -First 1 if (($vmStatus.PowerState -eq "VM running") -and ($sqlvm.sqlServerLicenseType -ne "DR")) { $vmResult = "NotAttempted" @@ -675,7 +731,11 @@ foreach ($sub in $subscriptions) { $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..." - $runningMIs = az sql mi list --query $miRunningQuery -o json | ConvertFrom-Json + $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 if($runningMIs.Count -eq 0) { Write-Output "No SQL Managed Instances found that require a license update." } else { @@ -772,11 +832,21 @@ foreach ($sub in $subscriptions) { Write-Output "SQL Server query: $serverQuery" # Get all servers first as a fallback in case the query fails - $allServers = az sql server list -o json | ConvertFrom-Json + $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 - $servers = az sql server list --query "$serverQuery" -o json | ConvertFrom-Json + $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 + } # Verify if we got any results if ($null -eq $servers -or $servers.Count -eq 0) { @@ -812,7 +882,13 @@ foreach ($sub in $subscriptions) { Write-Output "Scanning SQL Databases on server '$($server.name)' in resource group '$($server.resourceGroup)'..." # First get all databases to check if any exist - $allDbs = az sql db list --resource-group $server.resourceGroup --server $server.name -o json | ConvertFrom-Json + $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 @@ -832,7 +908,13 @@ foreach ($sub in $subscriptions) { # Get databases with error handling try { - $dbs = az sql db list --resource-group $server.resourceGroup --server $server.name --query "$dbQuery" -o json | ConvertFrom-Json + $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." @@ -885,7 +967,14 @@ foreach ($sub in $subscriptions) { Write-Output "Scanning Elastic Pools on server '$($server.name)'..." # First check if there are any elastic pools - $allPools = az sql elastic-pool list --resource-group $server.resourceGroup --server $server.name --only-show-errors -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue + $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)'." @@ -904,7 +993,12 @@ foreach ($sub in $subscriptions) { Write-Output "Elastic Pool query: $elasticPoolQuery" - $elasticPools = az sql elastic-pool list --resource-group $server.resourceGroup --server $server.name --query "$elasticPoolQuery" --only-show-errors -o json 2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue + $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." @@ -979,7 +1073,11 @@ foreach ($sub in $subscriptions) { $instancePoolsQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:status}" - $instancePools = az sql instance-pool list --query $instancePoolsQuery -o json 2>$null | ConvertFrom-Json + $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." + } + $instancePools = $instancePoolsQueryResult.Value $poolsToUpdate = $instancePools | Where-Object { $_.licenseType -ne $LicenseType } if($poolsToUpdate.Count -eq 0) { Write-Output "No SQL Instance Pools found that require a license update." From 496c6b4569b705cdac016f9ca6d8d96c52f741de Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 21:26:52 -0700 Subject: [PATCH 29/48] Skip a subscription when its CLI context cannot be selected Every az call in the Azure script is scoped by the CLI's active subscription, but 'az account set --subscription' was issued without checking \0 at either call site. If the switch failed, the whole subscription loop body, including the update calls, would have continued against whichever subscription was selected beforehand, so resources in an unintended subscription could have been modified. The primary call site now warns and moves to the next subscription. The mid-loop re-selection throws instead, which the enclosing handler catches so SQL Server, database and elastic pool processing is skipped without aborting the run. Audited the remaining unchecked 'az ... | ConvertFrom-Json' pipes: the az tag list one sits inside a commented-out block and the rest are az account show login probes that are already guarded. Regression-verified with a ReportOnly run: all 6 servers and their per-server database counts are still enumerated correctly. Embedded copy re-synced. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-azure-sql-license-type.ps1 | 11 +++++++++++ samples/manage/manage-payg-transition/TESTPLAN.md | 1 + .../manage-payg-transition/manage-payg-transition.ps1 | 11 +++++++++++ 3 files changed, 23 insertions(+) diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index 44c13bc343..d0c1e40e0a 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -460,6 +460,13 @@ foreach ($sub in $subscriptions) { 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 + # 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." + continue + } # --- Section: Update SQL Virtual Machines --- try { @@ -643,6 +650,10 @@ foreach ($sub in $subscriptions) { 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 diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 4772384ff8..d1bcc028b3 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -88,6 +88,7 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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. | ## Cleanup diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index d0c9a217c0..8cf93930ce 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -606,6 +606,13 @@ foreach ($sub in $subscriptions) { 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 + # 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." + continue + } # --- Section: Update SQL Virtual Machines --- try { @@ -789,6 +796,10 @@ foreach ($sub in $subscriptions) { 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 From 26754361456209fac6b7259488d43cdd8e850c56 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 21:35:09 -0700 Subject: [PATCH 30/48] Record verification of a clean run and the SSIS filter at scale Adds test cases 43 and 44 covering a run that completed with no errors after the discovery-query and subscription-context fixes. Every 'nothing found' claim in that run was checked against Azure rather than taken at face value: the two real databases are already at the target license type and the rest are master databases with a null licenseType; the subscription's single SQL VM and single managed instance are both already at their target, so they are correctly excluded by the filters rather than skipped; and there are no elastic pools, instance pools or Arc machines. Case 44 validates the DataFactory SSIS filter at a scale not previously tested: 22 integration runtimes across 8 factories, all with an empty LicenseType, including 5 of type Managed. Under the original filter all 22 would have been selected and the Managed ones would have failed with the same managedVirtualNetwork Conflict that motivated the fix. Also narrows the async coverage gap note: bhrout-mi exists but is already at the target and belongs to another team, so it was left alone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/manage/manage-payg-transition/TESTPLAN.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index d1bcc028b3..2996a7a081 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -90,6 +90,9 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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. | + ## Cleanup - All temporary test artifacts (generated wrapper scripts, materialized sub-scripts, @@ -112,8 +115,11 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c **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 — the only candidates in reach belong to other teams (`dfurman-rg`, - `rsetlem-azuresqldb`) and were deliberately left alone. + 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`). - `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 From 85a33726f1d654deef5c8100de4b0a2735ee3156 Mon Sep 17 00:00:00 2001 From: pochiraju Date: Fri, 28 Aug 2026 21:45:37 -0700 Subject: [PATCH 31/48] Document folder-scoped merge verification (test case 45) Verified via 'git archive' that a merge containing only the manage-payg-transition folder delivers a self-sufficient orchestrator: it materializes both sub-scripts from its embedded here-strings and runs end-to-end with the sibling scripts absent, and the materialized copies are byte-identical to the fixed standalone scripts. Records the caveat that a folder-only merge would leave the standalone modify-azure-sql-license-type.ps1 and modify-arc-sql-license-type.ps1 stale on master and break the embedded-vs-standalone sync invariant. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/manage/manage-payg-transition/TESTPLAN.md | 1 + 1 file changed, 1 insertion(+) diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index 2996a7a081..bee2d6ae17 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -92,6 +92,7 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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. | ## Cleanup From b4a49a99e1eacfe29a5fad4ee29995f220c7d611 Mon Sep 17 00:00:00 2001 From: pochiraju Date: Sun, 30 Aug 2026 18:38:36 -0700 Subject: [PATCH 32/48] Fix Arc SQL Server AHUB mapping to Paid instead of LicenseOnly Map -TargetLicenseType AHUB to 'Paid' (License with Software Assurance / Azure Hybrid Benefit) for Arc SQL Server rather than 'LicenseOnly' (perpetual without SA), allowing ESU to remain enabled and correctly aligning with AHB. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- samples/manage/manage-payg-transition/README.md | 2 +- .../manage-payg-transition/manage-payg-transition.ps1 | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index d2444f5bfe..fb6797405d 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -81,7 +81,7 @@ The script accepts the following command line parameters: - Use `-TargetLicenseType` (`PAYG` by default, or `AHUB`) to control which license model resources are transitioned to. This value is translated internally to the vocabulary each embedded script expects (e.g. `LicenseIncluded`/`BasePrice` for Azure - SQL resources, `PAYG`/`LicenseOnly` for Arc SQL Server). + SQL resources, `PAYG`/`Paid` for Arc SQL Server). - Use `-ReportOnly` to perform a read-only dry run first. The script discovers and reports every resource it would change (and writes a `ModifiedResources_.csv` report) without modifying any license types. This is the recommended way to confirm the blast diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 8cf93930ce..23193dde44 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -25,7 +25,7 @@ .PARAMETER TargetLicenseType The license type to transition resources to: - PAYG (default) : Pay-as-you-go / consumption-based licensing. - - AHUB : Azure Hybrid Benefit / License-only (bring-your-own-license). + - AHUB : Azure Hybrid Benefit (bring-your-own-license with Software Assurance). .PARAMETER TenantId Azure AD tenant to operate against. If omitted, the tenant of the current @@ -137,9 +137,9 @@ if ($RunMode -eq "Scheduled") { # Translate the simplified -TargetLicenseType switch into the vocabulary each # embedded script expects: # - modify-azure-sql-license-type.ps1 expects "LicenseIncluded" (PAYG) or "BasePrice" (AHUB). -# - modify-arc-sql-license-type.ps1 expects "PAYG" or "LicenseOnly" (AHUB-equivalent for Arc). +# - modify-arc-sql-license-type.ps1 expects "PAYG" or "Paid" (AHUB/SA for Arc). $azureLicenseType = if ($TargetLicenseType -eq "PAYG") { "LicenseIncluded" } else { "BasePrice" } -$arcLicenseType = if ($TargetLicenseType -eq "PAYG") { "PAYG" } else { "LicenseOnly" } +$arcLicenseType = if ($TargetLicenseType -eq "PAYG") { "PAYG" } else { "Paid" } # === Embedded dependency scripts (materialized to disk at runtime; nothing is downloaded) === $EmbeddedScripts = @{} From 6d53eabb47a64a2cbc0acc54b083ec5dd8a6e7e3 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Mon, 31 Aug 2026 08:34:13 -0700 Subject: [PATCH 33/48] Add execution outcome summary and failure root cause reporting - Implemented Format-ExecutionOutcomeSummary across Azure SQL, Arc SQL, and manage-payg-transition scripts. - Summarizes qualified, updated, failed, and skipped resource counts grouped by friendly SQL resource type. - Added detailed Failure & Skip Root Causes table reporting resource name, resource group, outcome, and diagnostic root cause reason. - Updated TESTPLAN.md with test case #46 documenting the outcome summary and verification. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-arc-sql-license-type.ps1 | 137 +++++++- .../modify-azure-sql-license-type.ps1 | 177 +++++++++- .../manage/manage-payg-transition/TESTPLAN.md | 1 + .../manage-payg-transition.ps1 | 314 +++++++++++++++++- 4 files changed, 611 insertions(+), 18 deletions(-) diff --git a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 index 5582f50b2b..5713bf8625 100644 --- a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 @@ -207,6 +207,99 @@ function Wait-ArcExtensionProvisioning { } +function Format-ExecutionOutcomeSummary { + param( + [Parameter(Mandatory = $false)] + [array]$TrackedResources = @(), + [Parameter(Mandatory = $false)] + [bool]$IsReportOnly = $false + ) + + Write-Output "`n========================================================================" + Write-Output " EXECUTION OUTCOME SUMMARY " + Write-Output "========================================================================" + + if ($TrackedResources.Count -eq 0) { + Write-Output "No resources qualified for license transition or modification." + Write-Output "========================================================================`n" + return + } + + $friendlyTypes = [ordered]@{ + "Microsoft.Sql/virtualMachines" = "SQL Virtual Machines" + "Microsoft.Sql/servers/databases" = "SQL Databases" + "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)" + "LinuxAgent.SqlServer" = "Arc SQL Server Extension (Linux)" + } + + $grouped = $TrackedResources | Group-Object -Property ResourceType + + $summaryRows = @() + foreach ($grp in $grouped) { + $rType = $grp.Name + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + + $totalQualified = $grp.Count + $updatedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = ($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count + + $summaryRows += [PSCustomObject]@{ + "Resource Type" = $friendlyName + "Qualified" = $totalQualified + "Updated" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount + } + } + + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + + # Check for failures and skips + $issues = $TrackedResources | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") -or $_.UpdateResult -like "Skipped*" } + + Write-Output "------------------------------------------------------------------------" + Write-Output " FAILURE & SKIP ROOT CAUSES " + Write-Output "------------------------------------------------------------------------" + + if ($issues.Count -eq 0) { + Write-Output "No failures or skipped resources encountered." + } else { + $issueRows = @() + foreach ($item in $issues) { + $rType = $item.ResourceType + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + $cause = if (-not [string]::IsNullOrWhiteSpace($item.UpdateError)) { + $item.UpdateError + } elseif ($item.UpdateResult -eq "SkippedTags") { + "Resource matched exclusion tags." + } elseif ($item.UpdateResult -eq "SkippedInvalidState") { + "Extension is not in a valid/Succeeded state." + } elseif ($item.UpdateResult -eq "SkippedNoChangeNeeded") { + "No changes were needed or -Force was not specified to overwrite existing license type." + } else { + "Outcome: $($item.UpdateResult)" + } + + $issueRows += [PSCustomObject]@{ + "Resource Name" = $item.ResourceName + "Resource Group" = $item.ResourceGroup + "Resource Type" = $friendlyName + "Outcome" = $item.UpdateResult + "Root Cause" = $cause + } + } + $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + } + Write-Output "========================================================================`n" +} + function Connect-Azure { [CmdletBinding()] param( @@ -461,7 +554,21 @@ foreach ($sub in $subscriptions) { } } } - if(!$excludedByTags){ + if($excludedByTags){ + $resourceRecord = [PSCustomObject]@{ + TenantID = $TenantId + SubID = $setID.SubscriptionId + ResourceName = $setID.MachineName + ResourceType = $setID.ExtensionType + Status = $sqlvm.Status + OriginalLicenseType = "Unknown" + ResourceGroup = $setID.ResourceGroup + Location = $setID.Location + UpdateResult = "SkippedTags" + UpdateError = "Matched exclusion tag $($tag):$value" + } + $modifiedResources += $resourceRecord + } else { $WriteSettings = $false @@ -488,13 +595,17 @@ foreach ($sub in $subscriptions) { if($ext.ProvisioningState -ne "Succeeded") { write-Output "Extension is not in a valid state. Skipping..." - {continue} + $resourceRecord.UpdateResult = "SkippedInvalidState" + $resourceRecord.UpdateError = "Extension provisioning state is '$($ext.ProvisioningState)' (expected 'Succeeded')" + continue } else { $LO_Allowed = (!$ext.Setting["enableExtendedSecurityUpdates"] -and !$EnableESU) -or ($EnableESU -eq "No") if ($LicenseType) { if (($LicenseType -eq "LicenseOnly") -and !$LO_Allowed) { write-Output "ESU must be disabled before license type can be set to $($LicenseType)" + $resourceRecord.UpdateResult = "Failed" + $resourceRecord.UpdateError = "ESU must be disabled before license type can be set to $LicenseType" } else { if ($ext.Setting["LicenseType"]) { if ($Force) { @@ -507,6 +618,8 @@ foreach ($sub in $subscriptions) { # other settings may still be written below, and without this the # run would report "Updated" for a license type that never changed. Write-Warning "[$($setID.MachineName)] LicenseType is '$($ext.Setting['LicenseType'])' and was NOT changed to '$LicenseType'. Re-run with -Force to overwrite an existing license type." + $resourceRecord.UpdateResult = "SkippedNoForce" + $resourceRecord.UpdateError = "Machine carries LicenseType '$($ext.Setting['LicenseType'])'. Re-run with -Force to overwrite." } } else { $ext.Setting["LicenseType"] = $LicenseType @@ -592,9 +705,13 @@ foreach ($sub in $subscriptions) { $resourceRecord.UpdateError = $errorMessage continue } + } elseif ($resourceRecord.UpdateResult -eq "NotAttempted") { + $resourceRecord.UpdateResult = "SkippedNoChangeNeeded" + $resourceRecord.UpdateError = "No configuration changes were required." } } else { Write-Output "ReportOnly mode enabled. Skipping modification for: $($setID.MachineName)" + $resourceRecord.UpdateResult = "ReportOnly" } } @@ -602,6 +719,18 @@ foreach ($sub in $subscriptions) { } } +# --- Final Report --- +$scriptEndTime = Get-Date +$executionDuration = $scriptEndTime - $scriptStartTime + +Write-Output "`n===== Final Report =====" +Write-Output "Script started at: $scriptStartTime" +Write-Output "Script ended at: $scriptEndTime" +Write-Output "Total duration: $($executionDuration.ToString())" + +# Print execution outcome summary and failure/skip root causes +Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) + # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" @@ -613,8 +742,8 @@ if ($modifiedResources.Count -gt 0) { write-Output "Arc SQL Update Script completed" -$scriptEndTime = Get-Date -$executionDuration = $scriptEndTime - $scriptStartTime +Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" if ($transcriptStarted) { diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index d0c1e40e0a..3976fa4e0c 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -332,6 +332,99 @@ function Invoke-SqlVmLicenseUpdate { } +function Format-ExecutionOutcomeSummary { + param( + [Parameter(Mandatory = $false)] + [array]$TrackedResources = @(), + [Parameter(Mandatory = $false)] + [bool]$IsReportOnly = $false + ) + + Write-Output "`n========================================================================" + Write-Output " EXECUTION OUTCOME SUMMARY " + Write-Output "========================================================================" + + if ($TrackedResources.Count -eq 0) { + Write-Output "No resources qualified for license transition or modification." + Write-Output "========================================================================`n" + return + } + + $friendlyTypes = [ordered]@{ + "Microsoft.Sql/virtualMachines" = "SQL Virtual Machines" + "Microsoft.Sql/servers/databases" = "SQL Databases" + "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)" + } + + $grouped = $TrackedResources | Group-Object -Property ResourceType + + $summaryRows = @() + foreach ($grp in $grouped) { + $rType = $grp.Name + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + + $totalQualified = $grp.Count + $updatedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = ($grp.Group | Where-Object { $_.UpdateResult -eq "Failed" }).Count + $skippedCount = ($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" }).Count + + $summaryRows += [PSCustomObject]@{ + "Resource Type" = $friendlyName + "Qualified" = $totalQualified + "Updated" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount + } + } + + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + + # Check for failures and skips + $issues = $TrackedResources | Where-Object { $_.UpdateResult -eq "Failed" -or $_.UpdateResult -like "Skipped*" } + + Write-Output "------------------------------------------------------------------------" + Write-Output " FAILURE & SKIP ROOT CAUSES " + Write-Output "------------------------------------------------------------------------" + + if ($issues.Count -eq 0) { + Write-Output "No failures or skipped resources encountered." + } else { + $issueRows = @() + foreach ($item in $issues) { + $rType = $item.ResourceType + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + $cause = if (-not [string]::IsNullOrWhiteSpace($item.UpdateError)) { + $item.UpdateError + } elseif ($item.UpdateResult -eq "SkippedNotRunning") { + "Underlying VM is deallocated / stopped. Azure requires the VM to be running to update license type." + } elseif ($item.UpdateResult -eq "SkippedDR") { + "Resource has Disaster Recovery (DR) license configured." + } elseif ($item.UpdateResult -eq "SkippedTags") { + "Resource matched exclusion tags." + } elseif ($item.UpdateResult -eq "SkippedNotStopped") { + "Integration Runtime is not in stopped state." + } else { + "Unknown reason ($($item.UpdateResult))" + } + + $issueRows += [PSCustomObject]@{ + "Resource Name" = $item.ResourceName + "Resource Group" = $item.ResourceGroup + "Resource Type" = $friendlyName + "Outcome" = $item.UpdateResult + "Root Cause" = $cause + } + } + $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + } + Write-Output "========================================================================`n" +} + $finalStatus = @() # Convert to hashtable explicitly @@ -515,6 +608,18 @@ foreach ($sub in $subscriptions) { # 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 } $vmStatus = $vmStatusQuery.Value | Select-Object -First 1 @@ -524,6 +629,7 @@ foreach ($sub in $subscriptions) { $vmError = "" 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'..." @@ -551,9 +657,51 @@ foreach ($sub in $subscriptions) { # 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)" + } + } + 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" + } + } } else { Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' Skipping because of tags..." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.id -split '/')[2] + ResourceName = $sqlvm.name + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = "SkippedTags" + OriginalLicenseType = $sqlvm.sqlServerLicenseType + ResourceGroup = $sqlvm.resourceGroup + Location = $sqlvm.Location + UpdateResult = "SkippedTags" + UpdateError = "Excluded by tags filter" + } } } if($sqlVmsToUpdate.Count -eq 0) { @@ -607,7 +755,10 @@ foreach ($sub in $subscriptions) { $miResult = "NotAttempted" $miError = "" - if (-not $ReportOnly) { + 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')." + } 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') @@ -796,7 +947,10 @@ foreach ($sub in $subscriptions) { $dbResult = "NotAttempted" $dbError = "" - if (-not $ReportOnly) { + 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')." + } 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') @@ -878,7 +1032,10 @@ foreach ($sub in $subscriptions) { $poolResult = "NotAttempted" $poolError = "" - if (-not $ReportOnly) { + 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')." + } 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') @@ -954,7 +1111,10 @@ foreach ($sub in $subscriptions) { $ipResult = "NotAttempted" $ipError = "" - if (-not $ReportOnly) { + 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')." + } 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') @@ -1018,10 +1178,14 @@ foreach ($sub in $subscriptions) { $irResult = "NotAttempted" $irError = "" - if (-not $ReportOnly) { + 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 { @@ -1073,6 +1237,9 @@ Write-Output "Script started at: $scriptStartTime" Write-Output "Script ended at: $scriptEndTime" Write-Output "Total duration: $($totalDuration.ToString())" +# Print execution outcome summary and failure/skip root causes +Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) + # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index bee2d6ae17..c02bde0699 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -93,6 +93,7 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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 summarizing: Resource Type, count of Qualified resources matching target conditions, count of Updated (or `Would Update` / `ReportOnly`), Failed, and Skipped. Directly beneath the summary table, a detailed `FAILURE & SKIP ROOT CAUSES` breakdown displays the Resource Name, Resource Group, Resource Type, 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.` | ## Cleanup diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 23193dde44..fdb4f9ad9c 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -478,6 +478,99 @@ function Invoke-SqlVmLicenseUpdate { } +function Format-ExecutionOutcomeSummary { + param( + [Parameter(Mandatory = $false)] + [array]$TrackedResources = @(), + [Parameter(Mandatory = $false)] + [bool]$IsReportOnly = $false + ) + + Write-Output "`n========================================================================" + Write-Output " EXECUTION OUTCOME SUMMARY " + Write-Output "========================================================================" + + if ($TrackedResources.Count -eq 0) { + Write-Output "No resources qualified for license transition or modification." + Write-Output "========================================================================`n" + return + } + + $friendlyTypes = [ordered]@{ + "Microsoft.Sql/virtualMachines" = "SQL Virtual Machines" + "Microsoft.Sql/servers/databases" = "SQL Databases" + "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)" + } + + $grouped = $TrackedResources | Group-Object -Property ResourceType + + $summaryRows = @() + foreach ($grp in $grouped) { + $rType = $grp.Name + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + + $totalQualified = $grp.Count + $updatedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = ($grp.Group | Where-Object { $_.UpdateResult -eq "Failed" }).Count + $skippedCount = ($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" }).Count + + $summaryRows += [PSCustomObject]@{ + "Resource Type" = $friendlyName + "Qualified" = $totalQualified + "Updated" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount + } + } + + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + + # Check for failures and skips + $issues = $TrackedResources | Where-Object { $_.UpdateResult -eq "Failed" -or $_.UpdateResult -like "Skipped*" } + + Write-Output "------------------------------------------------------------------------" + Write-Output " FAILURE & SKIP ROOT CAUSES " + Write-Output "------------------------------------------------------------------------" + + if ($issues.Count -eq 0) { + Write-Output "No failures or skipped resources encountered." + } else { + $issueRows = @() + foreach ($item in $issues) { + $rType = $item.ResourceType + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + $cause = if (-not [string]::IsNullOrWhiteSpace($item.UpdateError)) { + $item.UpdateError + } elseif ($item.UpdateResult -eq "SkippedNotRunning") { + "Underlying VM is deallocated / stopped. Azure requires the VM to be running to update license type." + } elseif ($item.UpdateResult -eq "SkippedDR") { + "Resource has Disaster Recovery (DR) license configured." + } elseif ($item.UpdateResult -eq "SkippedTags") { + "Resource matched exclusion tags." + } elseif ($item.UpdateResult -eq "SkippedNotStopped") { + "Integration Runtime is not in stopped state." + } else { + "Unknown reason ($($item.UpdateResult))" + } + + $issueRows += [PSCustomObject]@{ + "Resource Name" = $item.ResourceName + "Resource Group" = $item.ResourceGroup + "Resource Type" = $friendlyName + "Outcome" = $item.UpdateResult + "Root Cause" = $cause + } + } + $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + } + Write-Output "========================================================================`n" +} + $finalStatus = @() # Convert to hashtable explicitly @@ -661,6 +754,18 @@ foreach ($sub in $subscriptions) { # 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 } $vmStatus = $vmStatusQuery.Value | Select-Object -First 1 @@ -670,6 +775,7 @@ foreach ($sub in $subscriptions) { $vmError = "" 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'..." @@ -697,9 +803,51 @@ foreach ($sub in $subscriptions) { # 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)" + } + } + 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" + } + } } else { Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' Skipping because of tags..." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.id -split '/')[2] + ResourceName = $sqlvm.name + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = "SkippedTags" + OriginalLicenseType = $sqlvm.sqlServerLicenseType + ResourceGroup = $sqlvm.resourceGroup + Location = $sqlvm.Location + UpdateResult = "SkippedTags" + UpdateError = "Excluded by tags filter" + } } } if($sqlVmsToUpdate.Count -eq 0) { @@ -753,7 +901,10 @@ foreach ($sub in $subscriptions) { $miResult = "NotAttempted" $miError = "" - if (-not $ReportOnly) { + 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')." + } 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') @@ -942,7 +1093,10 @@ foreach ($sub in $subscriptions) { $dbResult = "NotAttempted" $dbError = "" - if (-not $ReportOnly) { + 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')." + } 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') @@ -1024,7 +1178,10 @@ foreach ($sub in $subscriptions) { $poolResult = "NotAttempted" $poolError = "" - if (-not $ReportOnly) { + 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')." + } 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') @@ -1100,7 +1257,10 @@ foreach ($sub in $subscriptions) { $ipResult = "NotAttempted" $ipError = "" - if (-not $ReportOnly) { + 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')." + } 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') @@ -1164,10 +1324,14 @@ foreach ($sub in $subscriptions) { $irResult = "NotAttempted" $irError = "" - if (-not $ReportOnly) { + 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 { @@ -1219,6 +1383,9 @@ Write-Output "Script started at: $scriptStartTime" Write-Output "Script ended at: $scriptEndTime" Write-Output "Total duration: $($totalDuration.ToString())" +# Print execution outcome summary and failure/skip root causes +Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) + # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" @@ -1456,6 +1623,99 @@ function Wait-ArcExtensionProvisioning { } +function Format-ExecutionOutcomeSummary { + param( + [Parameter(Mandatory = $false)] + [array]$TrackedResources = @(), + [Parameter(Mandatory = $false)] + [bool]$IsReportOnly = $false + ) + + Write-Output "`n========================================================================" + Write-Output " EXECUTION OUTCOME SUMMARY " + Write-Output "========================================================================" + + if ($TrackedResources.Count -eq 0) { + Write-Output "No resources qualified for license transition or modification." + Write-Output "========================================================================`n" + return + } + + $friendlyTypes = [ordered]@{ + "Microsoft.Sql/virtualMachines" = "SQL Virtual Machines" + "Microsoft.Sql/servers/databases" = "SQL Databases" + "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)" + "LinuxAgent.SqlServer" = "Arc SQL Server Extension (Linux)" + } + + $grouped = $TrackedResources | Group-Object -Property ResourceType + + $summaryRows = @() + foreach ($grp in $grouped) { + $rType = $grp.Name + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + + $totalQualified = $grp.Count + $updatedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = ($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count + + $summaryRows += [PSCustomObject]@{ + "Resource Type" = $friendlyName + "Qualified" = $totalQualified + "Updated" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount + } + } + + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + + # Check for failures and skips + $issues = $TrackedResources | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") -or $_.UpdateResult -like "Skipped*" } + + Write-Output "------------------------------------------------------------------------" + Write-Output " FAILURE & SKIP ROOT CAUSES " + Write-Output "------------------------------------------------------------------------" + + if ($issues.Count -eq 0) { + Write-Output "No failures or skipped resources encountered." + } else { + $issueRows = @() + foreach ($item in $issues) { + $rType = $item.ResourceType + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + $cause = if (-not [string]::IsNullOrWhiteSpace($item.UpdateError)) { + $item.UpdateError + } elseif ($item.UpdateResult -eq "SkippedTags") { + "Resource matched exclusion tags." + } elseif ($item.UpdateResult -eq "SkippedInvalidState") { + "Extension is not in a valid/Succeeded state." + } elseif ($item.UpdateResult -eq "SkippedNoChangeNeeded") { + "No changes were needed or -Force was not specified to overwrite existing license type." + } else { + "Outcome: $($item.UpdateResult)" + } + + $issueRows += [PSCustomObject]@{ + "Resource Name" = $item.ResourceName + "Resource Group" = $item.ResourceGroup + "Resource Type" = $friendlyName + "Outcome" = $item.UpdateResult + "Root Cause" = $cause + } + } + $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + } + Write-Output "========================================================================`n" +} + function Connect-Azure { [CmdletBinding()] param( @@ -1710,7 +1970,21 @@ foreach ($sub in $subscriptions) { } } } - if(!$excludedByTags){ + if($excludedByTags){ + $resourceRecord = [PSCustomObject]@{ + TenantID = $TenantId + SubID = $setID.SubscriptionId + ResourceName = $setID.MachineName + ResourceType = $setID.ExtensionType + Status = $sqlvm.Status + OriginalLicenseType = "Unknown" + ResourceGroup = $setID.ResourceGroup + Location = $setID.Location + UpdateResult = "SkippedTags" + UpdateError = "Matched exclusion tag $($tag):$value" + } + $modifiedResources += $resourceRecord + } else { $WriteSettings = $false @@ -1737,13 +2011,17 @@ foreach ($sub in $subscriptions) { if($ext.ProvisioningState -ne "Succeeded") { write-Output "Extension is not in a valid state. Skipping..." - {continue} + $resourceRecord.UpdateResult = "SkippedInvalidState" + $resourceRecord.UpdateError = "Extension provisioning state is '$($ext.ProvisioningState)' (expected 'Succeeded')" + continue } else { $LO_Allowed = (!$ext.Setting["enableExtendedSecurityUpdates"] -and !$EnableESU) -or ($EnableESU -eq "No") if ($LicenseType) { if (($LicenseType -eq "LicenseOnly") -and !$LO_Allowed) { write-Output "ESU must be disabled before license type can be set to $($LicenseType)" + $resourceRecord.UpdateResult = "Failed" + $resourceRecord.UpdateError = "ESU must be disabled before license type can be set to $LicenseType" } else { if ($ext.Setting["LicenseType"]) { if ($Force) { @@ -1756,6 +2034,8 @@ foreach ($sub in $subscriptions) { # other settings may still be written below, and without this the # run would report "Updated" for a license type that never changed. Write-Warning "[$($setID.MachineName)] LicenseType is '$($ext.Setting['LicenseType'])' and was NOT changed to '$LicenseType'. Re-run with -Force to overwrite an existing license type." + $resourceRecord.UpdateResult = "SkippedNoForce" + $resourceRecord.UpdateError = "Machine carries LicenseType '$($ext.Setting['LicenseType'])'. Re-run with -Force to overwrite." } } else { $ext.Setting["LicenseType"] = $LicenseType @@ -1841,9 +2121,13 @@ foreach ($sub in $subscriptions) { $resourceRecord.UpdateError = $errorMessage continue } + } elseif ($resourceRecord.UpdateResult -eq "NotAttempted") { + $resourceRecord.UpdateResult = "SkippedNoChangeNeeded" + $resourceRecord.UpdateError = "No configuration changes were required." } } else { Write-Output "ReportOnly mode enabled. Skipping modification for: $($setID.MachineName)" + $resourceRecord.UpdateResult = "ReportOnly" } } @@ -1851,6 +2135,18 @@ foreach ($sub in $subscriptions) { } } +# --- Final Report --- +$scriptEndTime = Get-Date +$executionDuration = $scriptEndTime - $scriptStartTime + +Write-Output "`n===== Final Report =====" +Write-Output "Script started at: $scriptStartTime" +Write-Output "Script ended at: $scriptEndTime" +Write-Output "Total duration: $($executionDuration.ToString())" + +# Print execution outcome summary and failure/skip root causes +Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) + # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" @@ -1862,8 +2158,8 @@ if ($modifiedResources.Count -gt 0) { write-Output "Arc SQL Update Script completed" -$scriptEndTime = Get-Date -$executionDuration = $scriptEndTime - $scriptStartTime +Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" if ($transcriptStarted) { From 46993031d5bcc5eeea65c57e8b1f02abd2cbcf13 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Mon, 31 Aug 2026 08:53:03 -0700 Subject: [PATCH 34/48] Fix outcome summary Updated count matching across all update result statuses - Wrapped Where-Object in @(...) to guarantee integer count in PowerShell. - Added 'RequestSubmitted' and 'Succeeded' to the Azure script summary matching set. - Verified live table formatting now correctly displays Updated counts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-arc-sql-license-type.ps1 | 6 +++--- .../modify-azure-sql-license-type.ps1 | 6 +++--- .../manage-payg-transition.ps1 | 12 ++++++------ 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 index 5713bf8625..4fea5884c6 100644 --- a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 @@ -246,9 +246,9 @@ function Format-ExecutionOutcomeSummary { $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } $totalQualified = $grp.Count - $updatedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count - $failedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count - $skippedCount = ($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count + $updatedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count $summaryRows += [PSCustomObject]@{ "Resource Type" = $friendlyName diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index 3976fa4e0c..b9299d1965 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -369,9 +369,9 @@ function Format-ExecutionOutcomeSummary { $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } $totalQualified = $grp.Count - $updatedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "SubmittedAsync", "ReportOnly") }).Count - $failedCount = ($grp.Group | Where-Object { $_.UpdateResult -eq "Failed" }).Count - $skippedCount = ($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" }).Count + $updatedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count $summaryRows += [PSCustomObject]@{ "Resource Type" = $friendlyName diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index fdb4f9ad9c..7a230c7bc8 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -515,9 +515,9 @@ function Format-ExecutionOutcomeSummary { $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } $totalQualified = $grp.Count - $updatedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "SubmittedAsync", "ReportOnly") }).Count - $failedCount = ($grp.Group | Where-Object { $_.UpdateResult -eq "Failed" }).Count - $skippedCount = ($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" }).Count + $updatedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count $summaryRows += [PSCustomObject]@{ "Resource Type" = $friendlyName @@ -1662,9 +1662,9 @@ function Format-ExecutionOutcomeSummary { $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } $totalQualified = $grp.Count - $updatedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count - $failedCount = ($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count - $skippedCount = ($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count + $updatedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count $summaryRows += [PSCustomObject]@{ "Resource Type" = $friendlyName From d8c296aeda7c79af28ecbd38b2c878dad976fe07 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Mon, 31 Aug 2026 09:07:33 -0700 Subject: [PATCH 35/48] Consolidate outcome summary at end of execution and update table schema - Renamed 'Updated' column to 'Updated or RequestSubmitted'. - Set first column header to 'ResourceType' and sorted table by ResourceType. - Consolidated summary output to print once at the very end of orchestrator execution instead of separate intermediate summaries. - Updated sub-scripts to support -NoSummary when called by orchestrator. - Updated TESTPLAN.md test case #46. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-arc-sql-license-type.ps1 | 42 +++- .../modify-azure-sql-license-type.ps1 | 44 +++- .../manage/manage-payg-transition/TESTPLAN.md | 2 +- .../manage-payg-transition.ps1 | 223 ++++++++++++++++-- 4 files changed, 268 insertions(+), 43 deletions(-) diff --git a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 index 4fea5884c6..86e6cd4512 100644 --- a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 @@ -110,7 +110,10 @@ param ( [int] $WaitTimeoutSeconds = 300, [Parameter (Mandatory= $false)] - [int] $batchSize = 500 + [int] $batchSize = 500, + + [Parameter (Mandatory= $false)] + [switch] $NoSummary ) # Transcription is not available in every host (for example Azure Automation @@ -251,14 +254,16 @@ function Format-ExecutionOutcomeSummary { $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count $summaryRows += [PSCustomObject]@{ - "Resource Type" = $friendlyName - "Qualified" = $totalQualified - "Updated" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } - "Failed" = $failedCount - "Skipped" = $skippedCount + "ResourceType" = $friendlyName + "Qualified" = $totalQualified + "Updated or RequestSubmitted" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount } } + $summaryRows = $summaryRows | Sort-Object -Property ResourceType + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output # Check for failures and skips @@ -290,11 +295,12 @@ function Format-ExecutionOutcomeSummary { $issueRows += [PSCustomObject]@{ "Resource Name" = $item.ResourceName "Resource Group" = $item.ResourceGroup - "Resource Type" = $friendlyName + "ResourceType" = $friendlyName "Outcome" = $item.UpdateResult "Root Cause" = $cause } } + $issueRows = $issueRows | Sort-Object -Property ResourceType, "Resource Name" $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output } Write-Output "========================================================================`n" @@ -728,8 +734,24 @@ Write-Output "Script started at: $scriptStartTime" Write-Output "Script ended at: $scriptEndTime" Write-Output "Total duration: $($executionDuration.ToString())" -# Print execution outcome summary and failure/skip root causes -Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +# Export tracked resources for orchestrator if running in orchestrated mode +if (Test-Path variable:global:PaygTrackedResources) { + $global:PaygTrackedResources += $modifiedResources +} +$trackedOutPath = Join-Path (Get-Location) "manage-payg-transition\tracked_arc.json" +if ($modifiedResources.Count -gt 0) { + try { + $parentDir = Split-Path $trackedOutPath -Parent + if (Test-Path $parentDir) { + $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 + } + } catch {} +} + +if (-not $NoSummary) { + # Print execution outcome summary and failure/skip root causes + Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +} # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { @@ -742,8 +764,6 @@ if ($modifiedResources.Count -gt 0) { write-Output "Arc SQL Update Script completed" -Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" -Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" if ($transcriptStarted) { diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index b9299d1965..cfe979b571 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -86,7 +86,10 @@ param ( [switch] $WaitForCompletion, [Parameter (Mandatory= $false)] - [string] $ResourceName + [string] $ResourceName, + + [Parameter (Mandatory= $false)] + [switch] $NoSummary ) @@ -359,6 +362,8 @@ function Format-ExecutionOutcomeSummary { "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)" + "LinuxAgent.SqlServer" = "Arc SQL Server Extension (Linux)" } $grouped = $TrackedResources | Group-Object -Property ResourceType @@ -374,18 +379,20 @@ function Format-ExecutionOutcomeSummary { $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count $summaryRows += [PSCustomObject]@{ - "Resource Type" = $friendlyName - "Qualified" = $totalQualified - "Updated" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } - "Failed" = $failedCount - "Skipped" = $skippedCount + "ResourceType" = $friendlyName + "Qualified" = $totalQualified + "Updated or RequestSubmitted" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount } } + $summaryRows = $summaryRows | Sort-Object -Property ResourceType + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output # Check for failures and skips - $issues = $TrackedResources | Where-Object { $_.UpdateResult -eq "Failed" -or $_.UpdateResult -like "Skipped*" } + $issues = $TrackedResources | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") -or $_.UpdateResult -like "Skipped*" } Write-Output "------------------------------------------------------------------------" Write-Output " FAILURE & SKIP ROOT CAUSES " @@ -415,11 +422,12 @@ function Format-ExecutionOutcomeSummary { $issueRows += [PSCustomObject]@{ "Resource Name" = $item.ResourceName "Resource Group" = $item.ResourceGroup - "Resource Type" = $friendlyName + "ResourceType" = $friendlyName "Outcome" = $item.UpdateResult "Root Cause" = $cause } } + $issueRows = $issueRows | Sort-Object -Property ResourceType, "Resource Name" $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output } Write-Output "========================================================================`n" @@ -1237,8 +1245,24 @@ Write-Output "Script started at: $scriptStartTime" Write-Output "Script ended at: $scriptEndTime" Write-Output "Total duration: $($totalDuration.ToString())" -# Print execution outcome summary and failure/skip root causes -Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +# Export tracked resources for orchestrator if running in orchestrated mode +if (Test-Path variable:global:PaygTrackedResources) { + $global:PaygTrackedResources += $modifiedResources +} +$trackedOutPath = Join-Path (Get-Location) "manage-payg-transition\tracked_azure.json" +if ($modifiedResources.Count -gt 0) { + try { + $parentDir = Split-Path $trackedOutPath -Parent + if (Test-Path $parentDir) { + $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 + } + } catch {} +} + +if (-not $NoSummary) { + # Print execution outcome summary and failure/skip root causes + Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +} # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md index c02bde0699..143ed12c1a 100644 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ b/samples/manage/manage-payg-transition/TESTPLAN.md @@ -93,7 +93,7 @@ against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7c | 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 summarizing: Resource Type, count of Qualified resources matching target conditions, count of Updated (or `Would Update` / `ReportOnly`), Failed, and Skipped. Directly beneath the summary table, a detailed `FAILURE & SKIP ROOT CAUSES` breakdown displays the Resource Name, Resource Group, Resource Type, 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.` | +| 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.` | ## Cleanup diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 7a230c7bc8..4a6026fd98 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -232,7 +232,10 @@ param ( [switch] $WaitForCompletion, [Parameter (Mandatory= $false)] - [string] $ResourceName + [string] $ResourceName, + + [Parameter (Mandatory= $false)] + [switch] $NoSummary ) @@ -505,6 +508,8 @@ function Format-ExecutionOutcomeSummary { "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)" + "LinuxAgent.SqlServer" = "Arc SQL Server Extension (Linux)" } $grouped = $TrackedResources | Group-Object -Property ResourceType @@ -520,18 +525,20 @@ function Format-ExecutionOutcomeSummary { $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count $summaryRows += [PSCustomObject]@{ - "Resource Type" = $friendlyName - "Qualified" = $totalQualified - "Updated" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } - "Failed" = $failedCount - "Skipped" = $skippedCount + "ResourceType" = $friendlyName + "Qualified" = $totalQualified + "Updated or RequestSubmitted" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount } } + $summaryRows = $summaryRows | Sort-Object -Property ResourceType + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output # Check for failures and skips - $issues = $TrackedResources | Where-Object { $_.UpdateResult -eq "Failed" -or $_.UpdateResult -like "Skipped*" } + $issues = $TrackedResources | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") -or $_.UpdateResult -like "Skipped*" } Write-Output "------------------------------------------------------------------------" Write-Output " FAILURE & SKIP ROOT CAUSES " @@ -561,11 +568,12 @@ function Format-ExecutionOutcomeSummary { $issueRows += [PSCustomObject]@{ "Resource Name" = $item.ResourceName "Resource Group" = $item.ResourceGroup - "Resource Type" = $friendlyName + "ResourceType" = $friendlyName "Outcome" = $item.UpdateResult "Root Cause" = $cause } } + $issueRows = $issueRows | Sort-Object -Property ResourceType, "Resource Name" $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output } Write-Output "========================================================================`n" @@ -1383,8 +1391,24 @@ Write-Output "Script started at: $scriptStartTime" Write-Output "Script ended at: $scriptEndTime" Write-Output "Total duration: $($totalDuration.ToString())" -# Print execution outcome summary and failure/skip root causes -Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +# Export tracked resources for orchestrator if running in orchestrated mode +if (Test-Path variable:global:PaygTrackedResources) { + $global:PaygTrackedResources += $modifiedResources +} +$trackedOutPath = Join-Path (Get-Location) "manage-payg-transition\tracked_azure.json" +if ($modifiedResources.Count -gt 0) { + try { + $parentDir = Split-Path $trackedOutPath -Parent + if (Test-Path $parentDir) { + $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 + } + } catch {} +} + +if (-not $NoSummary) { + # Print execution outcome summary and failure/skip root causes + Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +} # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { @@ -1526,7 +1550,10 @@ param ( [int] $WaitTimeoutSeconds = 300, [Parameter (Mandatory= $false)] - [int] $batchSize = 500 + [int] $batchSize = 500, + + [Parameter (Mandatory= $false)] + [switch] $NoSummary ) # Transcription is not available in every host (for example Azure Automation @@ -1667,14 +1694,16 @@ function Format-ExecutionOutcomeSummary { $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count $summaryRows += [PSCustomObject]@{ - "Resource Type" = $friendlyName - "Qualified" = $totalQualified - "Updated" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } - "Failed" = $failedCount - "Skipped" = $skippedCount + "ResourceType" = $friendlyName + "Qualified" = $totalQualified + "Updated or RequestSubmitted" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount } } + $summaryRows = $summaryRows | Sort-Object -Property ResourceType + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output # Check for failures and skips @@ -1706,11 +1735,12 @@ function Format-ExecutionOutcomeSummary { $issueRows += [PSCustomObject]@{ "Resource Name" = $item.ResourceName "Resource Group" = $item.ResourceGroup - "Resource Type" = $friendlyName + "ResourceType" = $friendlyName "Outcome" = $item.UpdateResult "Root Cause" = $cause } } + $issueRows = $issueRows | Sort-Object -Property ResourceType, "Resource Name" $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output } Write-Output "========================================================================`n" @@ -2144,8 +2174,24 @@ Write-Output "Script started at: $scriptStartTime" Write-Output "Script ended at: $scriptEndTime" Write-Output "Total duration: $($executionDuration.ToString())" -# Print execution outcome summary and failure/skip root causes -Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +# Export tracked resources for orchestrator if running in orchestrated mode +if (Test-Path variable:global:PaygTrackedResources) { + $global:PaygTrackedResources += $modifiedResources +} +$trackedOutPath = Join-Path (Get-Location) "manage-payg-transition\tracked_arc.json" +if ($modifiedResources.Count -gt 0) { + try { + $parentDir = Split-Path $trackedOutPath -Parent + if (Test-Path $parentDir) { + $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 + } + } catch {} +} + +if (-not $NoSummary) { + # Print execution outcome summary and failure/skip root causes + Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +} # Export modified resource data to CSV if ($modifiedResources.Count -gt 0) { @@ -2158,8 +2204,6 @@ if ($modifiedResources.Count -gt 0) { write-Output "Arc SQL Update Script completed" -Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" -Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" if ($transcriptStarted) { @@ -2469,6 +2513,110 @@ Write-Output "Runbook '$RunbookName' has been imported and published successfull '@ +function Format-ExecutionOutcomeSummary { + param( + [Parameter(Mandatory = $false)] + [array]$TrackedResources = @(), + [Parameter(Mandatory = $false)] + [bool]$IsReportOnly = $false + ) + + Write-Output "`n========================================================================" + Write-Output " EXECUTION OUTCOME SUMMARY " + Write-Output "========================================================================" + + if ($TrackedResources.Count -eq 0) { + Write-Output "No resources qualified for license transition or modification." + Write-Output "========================================================================`n" + return + } + + $friendlyTypes = [ordered]@{ + "Microsoft.Sql/virtualMachines" = "SQL Virtual Machines" + "Microsoft.Sql/servers/databases" = "SQL Databases" + "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)" + "LinuxAgent.SqlServer" = "Arc SQL Server Extension (Linux)" + } + + $grouped = $TrackedResources | Group-Object -Property ResourceType + + $summaryRows = @() + foreach ($grp in $grouped) { + $rType = $grp.Name + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + + $totalQualified = $grp.Count + $updatedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count + + $summaryRows += [PSCustomObject]@{ + "ResourceType" = $friendlyName + "Qualified" = $totalQualified + "Updated or RequestSubmitted" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount + } + } + + $summaryRows = $summaryRows | Sort-Object -Property ResourceType + + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + + # Check for failures and skips + $issues = $TrackedResources | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") -or $_.UpdateResult -like "Skipped*" } + + Write-Output "------------------------------------------------------------------------" + Write-Output " FAILURE & SKIP ROOT CAUSES " + Write-Output "------------------------------------------------------------------------" + + if ($issues.Count -eq 0) { + Write-Output "No failures or skipped resources encountered." + } else { + $issueRows = @() + foreach ($item in $issues) { + $rType = $item.ResourceType + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + $cause = if (-not [string]::IsNullOrWhiteSpace($item.UpdateError)) { + $item.UpdateError + } elseif ($item.UpdateResult -eq "SkippedNotRunning") { + "Underlying VM is deallocated / stopped. Azure requires the VM to be running to update license type." + } elseif ($item.UpdateResult -eq "SkippedDR") { + "Resource has Disaster Recovery (DR) license configured." + } elseif ($item.UpdateResult -eq "SkippedTags") { + "Resource matched exclusion tags." + } elseif ($item.UpdateResult -eq "SkippedNotStopped") { + "Integration Runtime is not in stopped state." + } elseif ($item.UpdateResult -eq "SkippedInvalidState") { + "Extension is not in a valid/Succeeded state." + } elseif ($item.UpdateResult -eq "SkippedNoChangeNeeded") { + "No changes were needed or -Force was not specified to overwrite existing license type." + } elseif ($item.UpdateResult -eq "SkippedNoForce") { + "Machine carries an existing license type. Re-run with -Force to overwrite." + } else { + "Outcome: $($item.UpdateResult)" + } + + $issueRows += [PSCustomObject]@{ + "Resource Name" = $item.ResourceName + "Resource Group" = $item.ResourceGroup + "ResourceType" = $friendlyName + "Outcome" = $item.UpdateResult + "Root Cause" = $cause + } + } + $issueRows = $issueRows | Sort-Object -Property ResourceType, "Resource Name" + $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + } + Write-Output "========================================================================`n" +} + # === Configuration === # NOTE: The Azure SQL, Arc SQL, and Automation-runbook logic below is embedded directly # (see the $EmbeddedScripts hashtable above) - nothing is downloaded from the internet. @@ -2485,6 +2633,7 @@ $scriptFiles = @{ TenantId = [string]$TenantId ReportOnly = [bool]$ReportOnly WaitForCompletion = [bool]$WaitForCompletion + NoSummary = $true } } Arc = @{ @@ -2498,6 +2647,7 @@ $scriptFiles = @{ TenantId = [string]$TenantId ReportOnly = [bool]$ReportOnly WaitForCompletion = [bool]$WaitForCompletion + NoSummary = $true } } } @@ -2654,8 +2804,39 @@ if($RunMode -eq "Single") { } $wrapper | Out-File -FilePath './runnow.ps1' -Encoding UTF8 + + $global:PaygTrackedResources = @() .\runnow.ps1 + # Gather tracked resources from global tracking and/or exported json files + $combinedTracked = @() + if ($global:PaygTrackedResources -and $global:PaygTrackedResources.Count -gt 0) { + $combinedTracked += $global:PaygTrackedResources + } + + Get-ChildItem -Path $downloadFolder -Filter "tracked_*.json" -ErrorAction SilentlyContinue | ForEach-Object { + try { + $jsonItems = Get-Content -Raw -Path $_.FullName | ConvertFrom-Json + if ($jsonItems) { + $combinedTracked += @($jsonItems) + } + } catch {} + } + + # Dedup resources if needed by (SubID, ResourceGroup, ResourceName, ResourceType) + $deduped = @() + $seen = @{} + foreach ($item in $combinedTracked) { + $key = "$($item.SubID)/$($item.ResourceGroup)/$($item.ResourceName)/$($item.ResourceType)" + if (-not $seen.ContainsKey($key)) { + $seen[$key] = $true + $deduped += $item + } + } + + # Print single unified outcome summary at the very end of execution + Format-ExecutionOutcomeSummary -TrackedResources $deduped -IsReportOnly ([bool]$ReportOnly) + Write-Host "Single run completed." }else{ Write-Host "Run 'Scheduled'." From 2d2d81773c0491f120d6728dd63cf86ce5cddc86 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Mon, 31 Aug 2026 09:23:32 -0700 Subject: [PATCH 36/48] Fix stale tracking file persistence in manage-payg-transition outcome summary - Clean stale tracked_*.json files before starting Single run and after aggregating summary. - Ensure sub-scripts remove tracked_*.json if no resources were marked for modification so prior runs do not contaminate subsequent runs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../modify-arc-sql-license-type.ps1 | 6 ++++++ .../modify-azure-sql-license-type.ps1 | 6 ++++++ .../manage-payg-transition.ps1 | 21 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 index 86e6cd4512..dbf737d9d1 100644 --- a/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1 @@ -746,6 +746,12 @@ if ($modifiedResources.Count -gt 0) { $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 } } catch {} +} else { + try { + if (Test-Path $trackedOutPath) { + Remove-Item -Path $trackedOutPath -Force -ErrorAction SilentlyContinue + } + } catch {} } if (-not $NoSummary) { diff --git a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 index cfe979b571..3402a4d778 100644 --- a/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 +++ b/samples/manage/azure-hybrid-benefit/modify-license-type/modify-azure-sql-license-type.ps1 @@ -1257,6 +1257,12 @@ if ($modifiedResources.Count -gt 0) { $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 } } catch {} +} else { + try { + if (Test-Path $trackedOutPath) { + Remove-Item -Path $trackedOutPath -Force -ErrorAction SilentlyContinue + } + } catch {} } if (-not $NoSummary) { diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 4a6026fd98..500ea581b2 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -1403,6 +1403,12 @@ if ($modifiedResources.Count -gt 0) { $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 } } catch {} +} else { + try { + if (Test-Path $trackedOutPath) { + Remove-Item -Path $trackedOutPath -Force -ErrorAction SilentlyContinue + } + } catch {} } if (-not $NoSummary) { @@ -2186,6 +2192,12 @@ if ($modifiedResources.Count -gt 0) { $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 } } catch {} +} else { + try { + if (Test-Path $trackedOutPath) { + Remove-Item -Path $trackedOutPath -Force -ErrorAction SilentlyContinue + } + } catch {} } if (-not $NoSummary) { @@ -2806,6 +2818,10 @@ if($RunMode -eq "Single") { $wrapper | Out-File -FilePath './runnow.ps1' -Encoding UTF8 $global:PaygTrackedResources = @() + # Clean any stale tracking files from previous runs + if (Test-Path $downloadFolder) { + Get-ChildItem -Path $downloadFolder -Filter "tracked_*.json" -ErrorAction SilentlyContinue | Remove-Item -Force + } .\runnow.ps1 # Gather tracked resources from global tracking and/or exported json files @@ -2834,6 +2850,11 @@ if($RunMode -eq "Single") { } } + # Clean up tracking json files after aggregation + if (Test-Path $downloadFolder) { + Get-ChildItem -Path $downloadFolder -Filter "tracked_*.json" -ErrorAction SilentlyContinue | Remove-Item -Force + } + # Print single unified outcome summary at the very end of execution Format-ExecutionOutcomeSummary -TrackedResources $deduped -IsReportOnly ([bool]$ReportOnly) From 10ed4b46367dee07bacf381847ab64db3197823c Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Mon, 31 Aug 2026 12:54:34 -0700 Subject: [PATCH 37/48] Add uncommitted local scratch scripts (modify-arc-sql-license-type.ps1, modify-azure-sql-license-type.ps1, runnow.ps1) --- .../modify-arc-sql-license-type.ps1 | 777 ++++++++++ .../modify-azure-sql-license-type.ps1 | 1297 +++++++++++++++++ runnow.ps1 | 16 + 3 files changed, 2090 insertions(+) create mode 100644 manage-payg-transition/modify-arc-sql-license-type.ps1 create mode 100644 manage-payg-transition/modify-azure-sql-license-type.ps1 create mode 100644 runnow.ps1 diff --git a/manage-payg-transition/modify-arc-sql-license-type.ps1 b/manage-payg-transition/modify-arc-sql-license-type.ps1 new file mode 100644 index 0000000000..735403873f --- /dev/null +++ b/manage-payg-transition/modify-arc-sql-license-type.ps1 @@ -0,0 +1,777 @@ + +<# +.SYNOPSIS + Updates the license type for Azure Arc SQL resources to a specified license and license related options. + +.DESCRIPTION + The script updates the license related settings of the SQL extension resources in a specified Entra ID tenant. You can specify a particular subscription, resource group or an individual connected machine. + You can also provide a list of subscriptions as a .CSV file. + By default, all subscriptions in your current tenant id are scanned. + +.VERSION + 3.0.5 - Initial version. + +.PARAMETER SubId + A single subscription ID or a CSV file name containing a list of subscriptions. + +.PARAMETER ResourceGroup + Optional. Limit the scope to a specific resource group. + +.PARAMETER MachineName + Optional. A single machine name or a CSV file name containing a list of machine names. + +.PARAMETER LicenseType + Optional. License type to set. Allowed values: "PAYG", "Paid" or "LicenseOnly" + +.PARAMETER ConsentToRecurringPAYG + Optional. Consents to enabling the recurring PAYG billing. LicenseType must be "PAYG". Applies to CSP subscriptions only. + +.PARAMETER UsePcoreLicense + Optional. Opts in to use unlimited virtualization license if the value is "Yes", or opts out if the value is "No". To opt in, the license type must be "Paid" or "PAYG" + +.PARAMETER EnableESU + Optional. Enables the ESU policy if the value is "Yes" or disables it if the value is "No". To enable, the license type must be "Paid" or "PAYG" + +.PARAMETER Force + Optional. Forces the change of the license type to the specified value on all installed extensions. If not forced, the changes will apply only to the extensions where the license type is undefined. + +.PARAMETER ExclusionTags + Optional. If specified, excludes the resources that have this tag assigned. + +.PARAMETER TenantId + Optional. If specified, this tenant id to log in both PowerShell and CLI. Otherwise, the current login context is used. + +.PARAMETER ReportOnly + Optional. If true, generates a csv file with the list of resources that are to be modified, but doesn't make the actual change. + +.PARAMETER UseManagedIdentity + Optional. If true, logs in both PowerShell and CLI using managed identity. Required to run the script as a runbook. + +.PARAMETER WaitForCompletion + Optional. If specified, waits for each submitted extension update to reach a terminal + provisioning state and reports the confirmed outcome, instead of returning as soon as the + request is accepted. Extension updates are normally submitted with -NoWait, so by default + the report records "RequestSubmitted", which means the service accepted the request - not + that the Arc agent has applied it. Use this switch when you need confirmed results; + it makes the run substantially slower because each machine is polled individually. + +.PARAMETER WaitTimeoutSeconds + Optional. Maximum number of seconds to wait per resource when -WaitForCompletion is used. + Defaults to 300. Reaching the timeout is not treated as a failure: the outcome is recorded + as "TimedOut" because the update may still be applied by the agent afterwards. + +#> + +param ( + [Parameter (Mandatory=$false)] + [string] $SubId, + + [Parameter (Mandatory= $false)] + [string] $ResourceGroup, + + [Parameter (Mandatory= $false)] + [string] $MachineName, + + [Parameter (Mandatory= $false)] + [ValidateSet("PAYG","Paid","LicenseOnly", IgnoreCase=$false)] + [string] $LicenseType, + + [Parameter (Mandatory= $false)] + [ValidateSet("Yes","No", IgnoreCase=$false)] + [string] $ConsentToRecurringPAYG, + + [Parameter (Mandatory= $false)] + [ValidateSet("Yes","No", IgnoreCase=$false)] + [string] $UsePcoreLicense, + + [Parameter (Mandatory= $false)] + [ValidateSet("Yes","No", IgnoreCase=$false)] + [string] $EnableESU, + + [Parameter (Mandatory= $false)] + [switch] $Force, + + [Parameter (Mandatory= $false)] + [object] $ExclusionTags, + + [Parameter (Mandatory= $false)] + [string] $TenantId, + + [Parameter (Mandatory= $false)] + [switch] $ReportOnly, + + [Parameter (Mandatory= $false)] + [switch] $UseManagedIdentity, + + [Parameter (Mandatory= $false)] + [switch] $WaitForCompletion, + + [Parameter (Mandatory= $false)] + [int] $WaitTimeoutSeconds = 300, + + [Parameter (Mandatory= $false)] + [int] $batchSize = 500, + + [Parameter (Mandatory= $false)] + [switch] $NoSummary +) + +# Transcription is not available in every host (for example Azure Automation +# runbooks) and can also fail if the log path is not writable. Track whether it +# actually started so the matching Stop-Transcript at the end of the script does +# not throw "The host is not currently transcribing". +$transcriptStarted = $false +try { + Start-Transcript -Path ".\modify-arc-sql-license-type.log" -ErrorAction Stop | Out-Null + $transcriptStarted = $true +} catch { + Write-Warning "Unable to start transcript logging: $($_.Exception.Message) Continuing without a transcript." +} +$scriptStartTime = Get-Date +Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" + +<# +.SYNOPSIS + Polls an Arc machine extension until its provisioning state is terminal. +.DESCRIPTION + Extension updates are submitted with -NoWait, so the service accepting the request says + nothing about whether the Arc agent applied it. When -WaitForCompletion is used this + polls the extension and reports the confirmed outcome. + + A timeout is deliberately NOT reported as a failure: the agent may still apply the + setting after the script gives up, so the run is recorded as inconclusive rather than + unsuccessful. +#> +function Wait-ArcExtensionProvisioning { + param( + [Parameter(Mandatory = $true)][string]$ResourceGroupName, + [Parameter(Mandatory = $true)][string]$MachineName, + [Parameter(Mandatory = $true)][string]$ExtensionName, + [Parameter(Mandatory = $true)][string]$ExpectedLicenseType, + [Parameter(Mandatory = $true)][int]$TimeoutSeconds + ) + + $terminalStates = @('Succeeded', 'Failed', 'Canceled') + $deadline = (Get-Date).AddSeconds($TimeoutSeconds) + $delay = 5 + $lastState = 'Unknown' + $mismatch = $null + + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds $delay + try { + $current = Get-AzConnectedMachineExtension -ResourceGroupName $ResourceGroupName ` + -MachineName $MachineName -Name $ExtensionName -ErrorAction Stop + } catch { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = $_.Exception.Message; State = 'Unknown' } + } + + $lastState = "$($current.ProvisioningState)" + + if ($terminalStates -contains $lastState) { + if ($lastState -ne 'Succeeded') { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = "Extension provisioning state is '$lastState'."; State = $lastState } + } + + # A 'Succeeded' provisioning state only means the extension settings were written. + # Confirm the value actually reflects the requested license type. + $applied = $null + if ($null -ne $current.Setting) { + try { $applied = "$($current.Setting['LicenseType'])" } catch { $applied = $null } + } + + if ([string]::IsNullOrEmpty($applied)) { + return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } + } + if ($applied -eq $ExpectedLicenseType) { + return [PSCustomObject]@{ Result = 'Succeeded'; ErrorMessage = ''; State = $lastState } + } + + # 'Succeeded' with the wrong license type is ambiguous: the update was submitted + # with -NoWait, so this may still be the *previous* operation's terminal state read + # before the new one started. Keep polling rather than failing on that race; the + # mismatch is only reported if it survives to the deadline. + $mismatch = "Extension reported '$lastState' but LicenseType is '$applied' instead of '$ExpectedLicenseType'." + } + + # Back off gradually to avoid hammering the API on slow agents. + if ($delay -lt 30) { $delay = [Math]::Min(30, $delay * 2) } + } + + if ($mismatch) { + return [PSCustomObject]@{ Result = 'Failed'; ErrorMessage = $mismatch; State = $lastState } + } + + return [PSCustomObject]@{ + Result = 'TimedOut' + ErrorMessage = "Did not reach a terminal provisioning state within $TimeoutSeconds seconds (last state: '$lastState'). The update may still be applied by the agent." + State = $lastState + } +} + + +function Format-ExecutionOutcomeSummary { + param( + [Parameter(Mandatory = $false)] + [array]$TrackedResources = @(), + [Parameter(Mandatory = $false)] + [bool]$IsReportOnly = $false + ) + + Write-Output "`n========================================================================" + Write-Output " EXECUTION OUTCOME SUMMARY " + Write-Output "========================================================================" + + if ($TrackedResources.Count -eq 0) { + Write-Output "No resources qualified for license transition or modification." + Write-Output "========================================================================`n" + return + } + + $friendlyTypes = [ordered]@{ + "Microsoft.Sql/virtualMachines" = "SQL Virtual Machines" + "Microsoft.Sql/servers/databases" = "SQL Databases" + "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)" + "LinuxAgent.SqlServer" = "Arc SQL Server Extension (Linux)" + } + + $grouped = $TrackedResources | Group-Object -Property ResourceType + + $summaryRows = @() + foreach ($grp in $grouped) { + $rType = $grp.Name + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + + $totalQualified = $grp.Count + $updatedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count + + $summaryRows += [PSCustomObject]@{ + "ResourceType" = $friendlyName + "Qualified" = $totalQualified + "Updated or RequestSubmitted" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount + } + } + + $summaryRows = $summaryRows | Sort-Object -Property ResourceType + + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + + # Check for failures and skips + $issues = $TrackedResources | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") -or $_.UpdateResult -like "Skipped*" } + + Write-Output "------------------------------------------------------------------------" + Write-Output " FAILURE & SKIP ROOT CAUSES " + Write-Output "------------------------------------------------------------------------" + + if ($issues.Count -eq 0) { + Write-Output "No failures or skipped resources encountered." + } else { + $issueRows = @() + foreach ($item in $issues) { + $rType = $item.ResourceType + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + $cause = if (-not [string]::IsNullOrWhiteSpace($item.UpdateError)) { + $item.UpdateError + } elseif ($item.UpdateResult -eq "SkippedTags") { + "Resource matched exclusion tags." + } elseif ($item.UpdateResult -eq "SkippedInvalidState") { + "Extension is not in a valid/Succeeded state." + } elseif ($item.UpdateResult -eq "SkippedNoChangeNeeded") { + "No changes were needed or -Force was not specified to overwrite existing license type." + } else { + "Outcome: $($item.UpdateResult)" + } + + $issueRows += [PSCustomObject]@{ + "Resource Name" = $item.ResourceName + "Resource Group" = $item.ResourceGroup + "ResourceType" = $friendlyName + "Outcome" = $item.UpdateResult + "Root Cause" = $cause + } + } + $issueRows = $issueRows | Sort-Object -Property ResourceType, "Resource Name" + $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + } + Write-Output "========================================================================`n" +} + +function Connect-Azure { + [CmdletBinding()] + param( + [Parameter(Mandatory=$false)] + [string] $TenantId = $null, + + [Parameter(Mandatory=$false)] + [switch] $UseManagedIdentity + ) + + # 1) Detect host environment + $envType = 'Local' + if ($env:AZUREPS_HOST_ENVIRONMENT -like 'cloud-shell*') { + $envType = 'CloudShell' + } + elseif (($env:AZUREPS_HOST_ENVIRONMENT -like 'AzureAutomation*') -or $PSPrivateMetadata.JobId) { + $envType = 'AzureAutomation' + $UseManagedIdentity = $true + } + Write-Output "Environment detected: $envType" + + # 2) Ensure Az.PowerShell context. Use login V1 + Update-AzConfig -LoginExperienceV2 Off + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account) { + if ($TenantId) { + if ($currentCtx.Tenant.Id -eq $TenantId) { + Write-Output "Already in Az tenant $TenantId" + } + else { + Write-Output "Switching Az context to tenant $TenantId without re-authentication" + $newContext = Set-AzContext -Tenant $TenantId -ErrorAction SilentlyContinue + if($null -eq $newContext -or $newContext.TenantId -ne $TenantId) + { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + } + } + else { + Write-Output "Using existing Az context: Tenant $($currentCtx.Tenant.Id)" + } + } + else { + Write-Output "Not connected to Azure PowerShell. Running Connect-AzAccount..." + if ($UseManagedIdentity) { + if ($TenantId) { + Connect-AzAccount -Identity -Tenant $TenantId | Out-Null + } + else { + Connect-AzAccount -Identity -ErrorAction Stop | Out-Null + } + } + else { + if ($TenantId) { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + else { + Connect-AzAccount | Out-Null + } + } + $ctx = Get-AzContext + Write-Output "Connected to Az PowerShell as: $($ctx.Account) in tenant $($ctx.Tenant.Id)" + } +} + + +# Convert to hashtable explicitly +$tagTable = @{} +if($null -ne $ExclusionTags){ + if($ExclusionTags.GetType().Name -eq "Hashtable"){ + $tagTable = $ExclusionTags + }else{ + ($ExclusionTags | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + $tagTable[$_.Name] = $_.Value + } + } +} +# Ensure connection with both PowerShell and CLI. +if($UseManagedIdentity -or $envType -eq 'AzureAutomation') { + if ($TenantId) { + Connect-Azure -TenantId $TenantId -UseManagedIdentity $UseManagedIdentity + } else { + Connect-Azure -UseManagedIdentity $UseManagedIdentity + } +} else { + if ($TenantId) { + Connect-Azure -TenantId $TenantId + } else { + Connect-Azure + } +} + +$context = Get-AzContext -ErrorAction SilentlyContinue +Write-Output "Connected to Azure as: $($context.Account)" + +if (-not $TenantId) { + $TenantId = $context.Tenant.Id + Write-Output "No TenantId provided. Using current context TenantId: $TenantId" +} else { + Write-Output "Using provided TenantId: $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" +} + +$modifiedResources = @() + +if ($SubId -like "*.csv") { + $subscriptions = Import-Csv $SubId +}elseif($SubId -ne "") { + Write-Output "Passed Subscription $($SubId)" + $subscriptions = Get-AzSubscription -SubscriptionId $SubId +}else { + $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } +} + +# Handle MachineName input (single or CSV) +$machineNames = @() +if ($MachineName) { + if ($MachineName -like "*.csv") { + try { + $machines = Import-Csv $MachineName + foreach ($m in $machines) { + if ($m.MachineName) { + $machineNames += $m.MachineName + } + } + Write-Output "Loaded $($machineNames.Count) machine names from CSV." + } catch { + Write-Error "Failed to import machine names from CSV: $_" + exit 1 + } + } else { + $machineNames += $MachineName + } +} + +Write-Host ([Environment]::NewLine + "-- Scanning subscriptions --") + +foreach ($sub in $subscriptions) { + if ($sub.State -ne "Enabled") {continue} + + try { + Set-AzContext -SubscriptionId $sub.Id #Removed TenantID by Sunil + }catch { + write-host "Invalid subscription: $($sub.Id)" + {continue} + } + + Write-Output "Collecting list of resources to update" + + $query = " + resources + | where subscriptionId =~ '$($sub.Id)' + | where type == 'microsoft.hybridcompute/machines' + | where properties.detectedProperties.mssqldiscovered == 'true'" + if ($ResourceGroup) { + $query += " + | where resourceGroup =~ '$ResourceGroup'" + } + + if ($machineNames.Count -gt 0) { + $machineFilter = ($machineNames | ForEach-Object { "'$_'" }) -join ", " + $query += "| where name in~ ($machineFilter)" + } + + $query += " + | extend machineId = tolower(tostring(id)) + | project machineId, machineName = tolower(name) + | join kind= inner ( + resources + | where subscriptionId =~ '$($sub.Id)' + | where type == 'microsoft.hybridcompute/machines/extensions' + | where properties.publisher =~ 'Microsoft.AzureData' + | where properties.provisioningState == 'Succeeded' + | where properties.settings.LicenseType!='$LicenseType' + | extend extensionName = name + | extend extensionPublisher = properties.publisher + | extend extensionType = properties.type + | parse id with '/subscriptions/' subscriptionId '/resourceGroups/' resourceGroup '/providers/Microsoft.HybridCompute/machines/' machineNameRaw '/extensions/' extensionName + | extend machineName = tolower(machineNameRaw) + ) on `$left.machineName == `$right.machineName + | project machineName, extensionName, resourceGroup, location, subscriptionId, extensionPublisher, extensionType + | order by machineName asc" + + $skipToken = $null + + Write-Output $query + + $allResults = [System.Collections.Generic.List[PSObject]]::new() + do{ + $resources = Search-AzGraph -Query "$($query)" -First $batchSize -SkipToken $skipToken + $allResults.AddRange($resources) + $skipToken = $resources.SkipToken + }while($skipToken) + + Write-Output "Found $($allResults.Count) resource(s) to update" + + + $count = $allResults.Count + + + while($count -gt 0) { + $count-=1 + $setID = @{ + MachineName = $allResults[$count].MachineName + Name = $allResults[$count].extensionName + ResourceGroup = $allResults[$count].resourceGroup + Location = $allResults[$count].location + SubscriptionId = $allResults[$count].subscriptionId + Publisher = $allResults[$count].extensionPublisher + ExtensionType = $allResults[$count].extensionType + } + + write-Output " MachineName - $($setID.MachineName)" + write-Output " ResourceGroup - $($setID.ResourceGroup)" + write-Output " Location - $($setID.Location)" + write-Output " SubscriptionId - $($setID.SubscriptionId)" + write-Output " ExtensionType - $($setID.ExtensionType)" + + # Get connected machine info + $sqlvm = Get-AzConnectedMachine -Name $setID.MachineName -ResourceGroup $setID.ResourceGroup | Select-Object Name, Tags, Status + + + $excludedByTags = $false + foreach ($tag in $tagTable.Keys){ + if($sqlvm.Tags.ContainsKey($tag)) + { + if($sqlvm.Tags[$tag] -eq $tagTable[$tag]){ + $excludedByTags=$true + $value = $tagTable[$tag] + write-Output "Exclusion tag $($tag):$value. Skipping..." + Break; + } + } + } + if($excludedByTags){ + $resourceRecord = [PSCustomObject]@{ + TenantID = $TenantId + SubID = $setID.SubscriptionId + ResourceName = $setID.MachineName + ResourceType = $setID.ExtensionType + Status = $sqlvm.Status + OriginalLicenseType = "Unknown" + ResourceGroup = $setID.ResourceGroup + Location = $setID.Location + UpdateResult = "SkippedTags" + UpdateError = "Matched exclusion tag $($tag):$value" + } + $modifiedResources += $resourceRecord + } else { + + + $WriteSettings = $false + $ext = Get-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -MachineName $setID.MachineName + + # Collect data before modification. UpdateResult/UpdateError are populated + # after the actual Set-AzConnectedMachineExtension call below (or left as + # "NotAttempted" if the resource was skipped) so the CSV/console output + # reflects what actually happened, not just what was intended. + $resourceRecord = [PSCustomObject]@{ + TenantID = $TenantId + SubID = $setID.SubscriptionId + ResourceName = $setID.MachineName + ResourceType = $setID.ExtensionType + Status = $sqlvm.Status + OriginalLicenseType = $ext.Setting["LicenseType"] + ResourceGroup = $setID.ResourceGroup + Location = $setID.Location + UpdateResult = "NotAttempted" + UpdateError = "" + # Cores + } + $modifiedResources += $resourceRecord + + if($ext.ProvisioningState -ne "Succeeded") { + write-Output "Extension is not in a valid state. Skipping..." + $resourceRecord.UpdateResult = "SkippedInvalidState" + $resourceRecord.UpdateError = "Extension provisioning state is '$($ext.ProvisioningState)' (expected 'Succeeded')" + continue + } else { + $LO_Allowed = (!$ext.Setting["enableExtendedSecurityUpdates"] -and !$EnableESU) -or ($EnableESU -eq "No") + + if ($LicenseType) { + if (($LicenseType -eq "LicenseOnly") -and !$LO_Allowed) { + write-Output "ESU must be disabled before license type can be set to $($LicenseType)" + $resourceRecord.UpdateResult = "Failed" + $resourceRecord.UpdateError = "ESU must be disabled before license type can be set to $LicenseType" + } else { + if ($ext.Setting["LicenseType"]) { + if ($Force) { + $ext.Setting["LicenseType"] = $LicenseType + $WriteSettings = $true + } + elseif ("$($ext.Setting['LicenseType'])" -ne $LicenseType) { + # The machine already carries a license type and -Force was not + # supplied, so it is deliberately left alone. Say so explicitly: + # other settings may still be written below, and without this the + # run would report "Updated" for a license type that never changed. + Write-Warning "[$($setID.MachineName)] LicenseType is '$($ext.Setting['LicenseType'])' and was NOT changed to '$LicenseType'. Re-run with -Force to overwrite an existing license type." + $resourceRecord.UpdateResult = "SkippedNoForce" + $resourceRecord.UpdateError = "Machine carries LicenseType '$($ext.Setting['LicenseType'])'. Re-run with -Force to overwrite." + } + } else { + $ext.Setting["LicenseType"] = $LicenseType + $WriteSettings = $true + } + } + } + + if ($EnableESU) { + if (($ext.Setting["LicenseType"] -in ("Paid","PAYG")) -or ($EnableESU -eq "No")) { + $ext.Setting["enableExtendedSecurityUpdates"] = ($EnableESU -eq "Yes") + $ext.Setting["esuLastUpdatedTimestamp"] = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + $WriteSettings = $true + } else { + write-Output "The configured license type does not support ESUs" + } + } + + if ($UsePcoreLicense) { + if (($ext.Setting["LicenseType"] -in ("Paid","PAYG")) -or ($UsePcoreLicense -eq "No")) { + $ext.Setting["UsePhysicalCoreLicense"] = @{ + "IsApplied" = ($UsePcoreLicense -eq "Yes"); + "LastUpdatedTimestamp" = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + } + $WriteSettings = $true + } else { + write-Output "The configured license type does not support ESUs" + } + } + + # Add or update ConsentToRecurringPAYG setting if applicable + if ($ConsentToRecurringPAYG -eq "Yes") { + $isPayg = ($LicenseType -eq "PAYG") -or ($ext.Setting["LicenseType"] -eq "PAYG") + if ($isPayg) { + if (-not $ext.Setting.ContainsKey("ConsentToRecurringPAYG") -or -not $ext.Setting["ConsentToRecurringPAYG"]["Consented"]) { + $ext.Setting["ConsentToRecurringPAYG"] = @{ + "Consented" = $true; + "ConsentTimestamp" = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ssZ') + } + $WriteSettings = $true + } + } + } + + write-Output " Write Settings - $($WriteSettings)" + + if (-not $ReportOnly) { + If ($WriteSettings) { + try { + $settings = @{} + foreach ($h in $ext.Setting.Keys) { + $settings[$h]=$($ext.Setting[$h]) + } + # -ErrorAction Stop is required here: Set-AzConnectedMachineExtension + # can emit a non-terminating error (e.g. "An extension of type ... is + # still processing. Only one instance of an extension may be in + # progress at a time...") which, combined with -NoWait, would otherwise + # be printed to the console and then fall through to the "Updated" + # success message below without ever entering the catch block. + Set-AzConnectedMachineExtension -Name $setID.Name -ResourceGroupName $setID.ResourceGroup -Location $setID.Location -MachineName $setID.MachineName -Publisher $setID.Publisher -ExtensionType $setID.ExtensionType -Setting $settings -NoWait -ErrorAction Stop + Write-Output "Updated -- Resource group: [$($setID.ResourceGroup)], Connected machine: [$($setID.MachineName)]" + $resourceRecord.UpdateResult = "RequestSubmitted" + + if ($WaitForCompletion) { + Write-Output " Waiting for the extension update on [$($setID.MachineName)] to complete (timeout ${WaitTimeoutSeconds}s)..." + $wait = Wait-ArcExtensionProvisioning -ResourceGroupName $setID.ResourceGroup ` + -MachineName $setID.MachineName -ExtensionName $setID.Name ` + -ExpectedLicenseType "$($settings['LicenseType'])" -TimeoutSeconds $WaitTimeoutSeconds + + $resourceRecord.UpdateResult = $wait.Result + $resourceRecord.UpdateError = $wait.ErrorMessage + + switch ($wait.Result) { + 'Succeeded' { Write-Output " Confirmed -- [$($setID.MachineName)] provisioning state '$($wait.State)'." } + 'TimedOut' { Write-Warning "Timed out waiting for [$($setID.MachineName)]: $($wait.ErrorMessage)" } + default { Write-Warning "The extension update for [$($setID.MachineName)] did not succeed: $($wait.ErrorMessage)" } + } + } + } catch { + $errorMessage = $_.Exception.Message + Write-Output "The request to modify the extension object for [$($setID.MachineName)] failed with the following error: $errorMessage" + $resourceRecord.UpdateResult = "Failed" + $resourceRecord.UpdateError = $errorMessage + continue + } + } elseif ($resourceRecord.UpdateResult -eq "NotAttempted") { + $resourceRecord.UpdateResult = "SkippedNoChangeNeeded" + $resourceRecord.UpdateError = "No configuration changes were required." + } + } else { + Write-Output "ReportOnly mode enabled. Skipping modification for: $($setID.MachineName)" + $resourceRecord.UpdateResult = "ReportOnly" + } + } + + } + } +} + +# --- Final Report --- +$scriptEndTime = Get-Date +$executionDuration = $scriptEndTime - $scriptStartTime + +Write-Output "`n===== Final Report =====" +Write-Output "Script started at: $scriptStartTime" +Write-Output "Script ended at: $scriptEndTime" +Write-Output "Total duration: $($executionDuration.ToString())" + +# Export tracked resources for orchestrator if running in orchestrated mode +if (Test-Path variable:global:PaygTrackedResources) { + $global:PaygTrackedResources += $modifiedResources +} +$trackedOutPath = Join-Path (Get-Location) "manage-payg-transition\tracked_arc.json" +if ($modifiedResources.Count -gt 0) { + try { + $parentDir = Split-Path $trackedOutPath -Parent + if (Test-Path $parentDir) { + $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 + } + } catch {} +} else { + try { + if (Test-Path $trackedOutPath) { + Remove-Item -Path $trackedOutPath -Force -ErrorAction SilentlyContinue + } + } catch {} +} + +if (-not $NoSummary) { + # Print execution outcome summary and failure/skip root causes + Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +} + +# Export modified resource data to CSV +if ($modifiedResources.Count -gt 0) { + $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" + $modifiedResources | Export-Csv -Path $csvPath -NoTypeInformation + Write-Output "CSV report saved to: $csvPath" +} else { + Write-Output "No resources were marked for modification. No CSV generated." +} + +write-Output "Arc SQL Update Script completed" + +Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" +if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } +} diff --git a/manage-payg-transition/modify-azure-sql-license-type.ps1 b/manage-payg-transition/modify-azure-sql-license-type.ps1 new file mode 100644 index 0000000000..6c61b8826f --- /dev/null +++ b/manage-payg-transition/modify-azure-sql-license-type.ps1 @@ -0,0 +1,1297 @@ +<# +.SYNOPSIS + Updates the license type for Azure SQL resources (SQL DBs, Elastic Pools, Managed Instances, Instance Pools, SQL VMs) + to a specified model ("LicenseIncluded" or "BasePrice"). + +.DESCRIPTION + The script updates Azure SQL License types across subscriptions by modifying the license settings for a variety of SQL resources. It supports processing resources in one of the following ways: + The script processes several types of Azure SQL resources including: + + SQL Virtual Machines (SQL VMs) + SQL Managed Instances + 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 + +.PARAMETER SubId + A single subscription ID or a CSV file name containing a list of subscriptions. + +.PARAMETER ResourceGroup + Optional. Limit the scope to a specific resource group. + +.PARAMETER LicenseType + Optional. License type to set. Allowed values: "LicenseIncluded" (default) or "BasePrice". + +.PARAMETER ExclusionTags + Optional. If specified, excludes the resources that have this tag assigned. + +.PARAMETER TenantId + Optional. If specified, this tenant id to log in both PowerShell and CLI. Otherwise, the current login context is used. + +.PARAMETER ReportOnly + Optional. If true, generates a csv file with the list of resources that are to be modified, but doesn't make the actual change. + +.PARAMETER UseManagedIdentity + Optional. If true, logs in both PowerShell and CLI using managed identity. Required to run the script as a runbook. + +.PARAMETER ResourceName + Optional. If specified, only updates resources related to this name: + - For SQL Server: Updates all databases under the specified server + - For SQL Managed Instance: Updates the specified instance + - For SQL VM: Updates the specified VM + +.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 + 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. +#> + +param ( + [Parameter(Mandatory = $false)] + [string] $SubId, + + [Parameter(Mandatory = $false)] + [string] $ResourceGroup, + + [Parameter(Mandatory = $false)] + [ValidateSet("LicenseIncluded", "BasePrice", IgnoreCase = $false)] + [string] $LicenseType = "LicenseIncluded", + + [Parameter (Mandatory= $false)] + [object] $ExclusionTags, + + [Parameter (Mandatory= $false)] + [string] $TenantId, + + [Parameter (Mandatory= $false)] + [switch] $ReportOnly, + + [Parameter (Mandatory= $false)] + [switch] $UseManagedIdentity, + + [Parameter (Mandatory= $false)] + [switch] $WaitForCompletion, + + [Parameter (Mandatory= $false)] + [string] $ResourceName, + + [Parameter (Mandatory= $false)] + [switch] $NoSummary +) + + +# Transcription is not available in every host (for example Azure Automation +# runbooks) and can also fail if the log path is not writable. Track whether it +# actually started so the matching Stop-Transcript at the end of the script does +# not throw "The host is not currently transcribing". +$transcriptStarted = $false +try { + Start-Transcript -Path "$env:TEMP\modify-azure-sql-license-type.log" -ErrorAction Stop | Out-Null + $transcriptStarted = $true +} catch { + Write-Warning "Unable to start transcript logging: $($_.Exception.Message) Continuing without a transcript." +} +$scriptStartTime = Get-Date +Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" + +# Suppress unnecessary logging output +$VerbosePreference = "SilentlyContinue" +$DebugPreference = "SilentlyContinue" +$ProgressPreference = "SilentlyContinue" +$InformationPreference = "SilentlyContinue" +$WarningPreference = "SilentlyContinue" + +function Connect-Azure { + [CmdletBinding()] + param( + [Parameter (Mandatory= $true)] + [string] $TenantId, + + [Parameter (Mandatory= $false)] + [switch]$UseManagedIdentity + ) + + # 1) Detect environment + $envType = "Local" + if ($env:AZUREPS_HOST_ENVIRONMENT -and $env:AZUREPS_HOST_ENVIRONMENT -like 'cloud-shell*') { + $envType = "CloudShell" + } + elseif (($env:AZUREPS_HOST_ENVIRONMENT -and $env:AZUREPS_HOST_ENVIRONMENT -like 'AzureAutomation*') -or $PSPrivateMetadata.JobId) { + $envType = "AzureAutomation" + $UseManagedIdentity=$true + } + Write-Verbose "Environment detected: $envType" + + # 2) Ensure Az.PowerShell context - reuse an existing, already-authenticated context for the + # requested tenant instead of forcing a fresh interactive/managed-identity login every run. + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account -and $currentCtx.Tenant.Id -eq $TenantId) { + Write-Output "Already connected to Azure PowerShell as: $($currentCtx.Account) (tenant $TenantId). Reusing existing context." + } + else { + Write-Output "Not connected to Azure PowerShell for tenant $TenantId. Running Connect-AzAccount..." + if ($UseManagedIdentity -or $envType -eq 'AzureAutomation') { + $ctx = Connect-AzAccount -Tenant $TenantId -Identity -ErrorAction Stop + } + else { + $ctx = Connect-AzAccount -Tenant $TenantId -ErrorAction Stop + } + 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 (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)" + } +} + +<# +.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. +#> +function Invoke-AzCliLicenseUpdate { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description, + [switch]$SupportsNoWait + ) + + $effectiveArgs = @($Arguments) + $submittedOnly = $false + if ($SupportsNoWait -and -not $WaitForCompletion) { + $effectiveArgs += '--no-wait' + $submittedOnly = $true + } + + $output = & az @effectiveArgs 2>&1 + + if ($LASTEXITCODE -ne 0) { + $message = ($output | Out-String).Trim() + Write-Warning "Failed to update $Description`: $message" + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $message; 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 } + } + + # 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. +.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. +#> +function Invoke-AzCliQuery { + param( + [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][string]$Description + ) + + $output = & az @Arguments 2>&1 + + if ($LASTEXITCODE -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 { $parsed = $raw | ConvertFrom-Json } + catch { + Write-Warning "Unable to parse the response for $Description`: $($_.Exception.Message)" + return [PSCustomObject]@{ Success = $false; Value = @(); ErrorMessage = $_.Exception.Message } + } + + # 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. +.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. +#> +function Invoke-SqlVmLicenseUpdate { + param( + [Parameter(Mandatory = $true)][string]$ResourceId, + [Parameter(Mandatory = $true)][string]$Name, + [Parameter(Mandatory = $true)][string]$ResourceGroup, + [Parameter(Mandatory = $true)][string]$LicenseType + ) + + $cliArguments = @('sql','vm','update','-n',$Name,'-g',$ResourceGroup,'--license-type',$LicenseType,'-o','json') + + if ($WaitForCompletion) { + return Invoke-AzCliLicenseUpdate -Description "SQL VM '$Name'" -Arguments $cliArguments + } + + $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 } + } + + 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 + } +} + + +function Format-ExecutionOutcomeSummary { + param( + [Parameter(Mandatory = $false)] + [array]$TrackedResources = @(), + [Parameter(Mandatory = $false)] + [bool]$IsReportOnly = $false + ) + + Write-Output "`n========================================================================" + Write-Output " EXECUTION OUTCOME SUMMARY " + Write-Output "========================================================================" + + if ($TrackedResources.Count -eq 0) { + Write-Output "No resources qualified for license transition or modification." + Write-Output "========================================================================`n" + return + } + + $friendlyTypes = [ordered]@{ + "Microsoft.Sql/virtualMachines" = "SQL Virtual Machines" + "Microsoft.Sql/servers/databases" = "SQL Databases" + "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)" + "LinuxAgent.SqlServer" = "Arc SQL Server Extension (Linux)" + } + + $grouped = $TrackedResources | Group-Object -Property ResourceType + + $summaryRows = @() + foreach ($grp in $grouped) { + $rType = $grp.Name + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + + $totalQualified = $grp.Count + $updatedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Updated", "RequestSubmitted", "Succeeded", "SubmittedAsync", "ReportOnly") }).Count + $failedCount = @($grp.Group | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") }).Count + $skippedCount = @($grp.Group | Where-Object { $_.UpdateResult -like "Skipped*" -or $_.UpdateResult -eq "NotAttempted" }).Count + + $summaryRows += [PSCustomObject]@{ + "ResourceType" = $friendlyName + "Qualified" = $totalQualified + "Updated or RequestSubmitted" = if ($IsReportOnly) { "$updatedCount (ReportOnly)" } else { $updatedCount } + "Failed" = $failedCount + "Skipped" = $skippedCount + } + } + + $summaryRows = $summaryRows | Sort-Object -Property ResourceType + + $summaryRows | Format-Table -AutoSize | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + + # Check for failures and skips + $issues = $TrackedResources | Where-Object { $_.UpdateResult -in @("Failed", "TimedOut") -or $_.UpdateResult -like "Skipped*" } + + Write-Output "------------------------------------------------------------------------" + Write-Output " FAILURE & SKIP ROOT CAUSES " + Write-Output "------------------------------------------------------------------------" + + if ($issues.Count -eq 0) { + Write-Output "No failures or skipped resources encountered." + } else { + $issueRows = @() + foreach ($item in $issues) { + $rType = $item.ResourceType + $friendlyName = if ($friendlyTypes.Contains($rType)) { $friendlyTypes[$rType] } else { $rType } + $cause = if (-not [string]::IsNullOrWhiteSpace($item.UpdateError)) { + $item.UpdateError + } elseif ($item.UpdateResult -eq "SkippedNotRunning") { + "Underlying VM is deallocated / stopped. Azure requires the VM to be running to update license type." + } elseif ($item.UpdateResult -eq "SkippedDR") { + "Resource has Disaster Recovery (DR) license configured." + } elseif ($item.UpdateResult -eq "SkippedTags") { + "Resource matched exclusion tags." + } elseif ($item.UpdateResult -eq "SkippedNotStopped") { + "Integration Runtime is not in stopped state." + } else { + "Unknown reason ($($item.UpdateResult))" + } + + $issueRows += [PSCustomObject]@{ + "Resource Name" = $item.ResourceName + "Resource Group" = $item.ResourceGroup + "ResourceType" = $friendlyName + "Outcome" = $item.UpdateResult + "Root Cause" = $cause + } + } + $issueRows = $issueRows | Sort-Object -Property ResourceType, "Resource Name" + $issueRows | Format-Table -AutoSize -Wrap | Out-String | ForEach-Object { $_.TrimEnd() } | Write-Output + } + Write-Output "========================================================================`n" +} + +$finalStatus = @() + +# Convert to hashtable explicitly +$tagTable = @{} +if($ExclusionTags){ + if($ExclusionTags.GetType().Name -eq "Hashtable"){ + $tagTable = $ExclusionTags + }else{ + ($ExclusionTags | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + $tagTable[$_.Name] = $_.Value + } + } +} + +if (-not $TenantId) { + $TenantId = (Get-AzContext).Tenant.Id + Write-Output "No TenantId provided. Using current context TenantId: $TenantId" +} else { + Write-Output "Using provided TenantId: $TenantId" +} + +# Ensure connection with both PowerShell and CLI. Use V1 login. +Update-AzConfig -LoginExperienceV2 Off +if ($UseManagedIdentity) { + Connect-Azure ($TenantId, $UseManagedIdentity) +}else{ + Connect-Azure ($TenantId) +} + +# Ensure the required modules are imported + +# Ensure NuGet provider is available +if (-not (Get-PackageProvider -Name NuGet -ErrorAction SilentlyContinue)) { + Install-PackageProvider -Name NuGet -Force +} + +# Check if the required Az.Accounts module (at the minimum version this script needs) is already +# available. Checking Get-InstalledModule -Name "Az" only detects the "Az" meta-package and false +# -positives as "not found" when the individual Az.* modules were installed some other way (e.g. +# preinstalled on the machine, installed individually, or via a package manager). That mismatch +# triggered an unnecessary "Install-Module -Name Az -Force", which fails/hangs when the modules are +# already loaded/in use. Instead, check directly for the module/version this script actually needs. +$requiredAzAccountsVersion = [version]"4.2.0" +$azAccountsAvailable = Get-Module -ListAvailable -Name Az.Accounts | + Where-Object { $_.Version -ge $requiredAzAccountsVersion } | + Sort-Object Version -Descending | + Select-Object -First 1 + +if (-not $azAccountsAvailable) { + Write-Output "Az.Accounts module (>= $requiredAzAccountsVersion) not found. Installing latest version..." + Install-Module -Name Az.Accounts -MinimumVersion $requiredAzAccountsVersion -Scope CurrentUser -Repository PSGallery -Force +} else { + Write-Output "Az.Accounts module $($azAccountsAvailable.Version) already satisfies the minimum required version ($requiredAzAccountsVersion). No action needed." +} + +# Import Az.Accounts with minimum version requirement +try { + Import-Module Az.Accounts -MinimumVersion $requiredAzAccountsVersion -Force + Write-Output "Az.Accounts module imported successfully." +} catch { + Write-Error "Failed to import Az.Accounts: $_" + 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: $_" +} + +# Map License Types for SQL VMs: LicenseIncluded -> PAYG, BasePrice -> AHUB. +$SqlVmLicenseType = if ($LicenseType -eq "LicenseIncluded") { "PAYG" } else { "AHUB" } + +# Modified resources array +$modifiedResources = @() + +# Determine the subscriptions to process: CSV file, single subscription, or all accessible subscriptions. +if ($SubId -like "*.csv") { + $subscriptions = Import-Csv $SubId +}elseif($SubId -ne "") { + Write-Output "Passed Subscription $($SubId)" + $subscriptions = Get-AzSubscription -SubscriptionId $SubId +}else { + $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } +} + +# Build resource group filter if specified. +$rgFilter = if ($ResourceGroup) { "resourceGroup=='$ResourceGroup'" } else { "" } +$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 + # 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." + 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" + } + + # Add name filter if ResourceName specified + if ($ResourceName) { + $sqlVmQuery += " && name=='$ResourceName'" + } + + # Add tags filter if specified + if ($tagsFilter) { + $sqlVmQuery += " $tagsFilter" + } + + $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) { + 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) { + + 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 + } + $vmStatus = $vmStatusQuery.Value | Select-Object -First 1 + if (($vmStatus.PowerState -eq "VM running") -and ($sqlvm.sqlServerLicenseType -ne "DR")) { + + $vmResult = "NotAttempted" + $vmError = "" + + 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 } + } + + # 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)" + } + } + 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" + } + } + } + else { + Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' Skipping because of tags..." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.id -split '/')[2] + ResourceName = $sqlvm.name + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = "SkippedTags" + OriginalLicenseType = $sqlvm.sqlServerLicenseType + ResourceGroup = $sqlvm.resourceGroup + Location = $sqlvm.Location + UpdateResult = "SkippedTags" + UpdateError = "Excluded by tags filter" + } + } + } + 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() + try { + + + # Build Managed Instance query + $miRunningQuery = "[?licenseType!='${LicenseType}' && state=='Ready'" + + # Add resource group filter if specified + if ($rgFilter) { + $miRunningQuery += " && $rgFilter" + } + + # Add name filter if ResourceName specified + if ($ResourceName) { + $miRunningQuery += " && name=='$ResourceName'" + } + + # Add tags filter if specified + if ($tagsFilter) { + $miRunningQuery += " $tagsFilter" + } + + $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 + if($runningMIs.Count -eq 0) { + Write-Output "No SQL Managed Instances found that require a license update." + } else { + Write-Output "Found $($runningMIs.Count) SQL Managed Instances that require a license update." + } + foreach ($mi in $runningMIs) { + + $miResult = "NotAttempted" + $miError = "" + + 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')." + } 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') + if ($update.Success) { + $finalStatus += $update.Result + $miResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Managed Instance '$($mi.name)': $miResult (license type '$LicenseType')" + } + else { $miResult = "Failed"; $miError = $update.ErrorMessage } + } + + # 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 + UpdateResult = $miResult + UpdateError = $miError + } + } + } + catch { + Write-Error "An error occurred while updating SQL Managed Instances: $_" + } + + # --- 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 = "[]" + } + + # 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 + } + + # 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:" + $allServers | ForEach-Object { + Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + } + + # Only fall back to scanning every server in the subscription when the + # caller did not restrict the scope. Falling back while -ResourceGroup + # (or -ResourceName) was supplied would silently widen the blast radius + # far beyond what was asked for: the elastic pool query below is not + # 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." + $servers = $allServers + } else { + 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." + $servers | ForEach-Object { + Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + } + } + + # 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 + 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." + } else { + Write-Output "Found $($dbs.Count) SQL Databases on Server $($server.name) that require a license update:" + $dbs | ForEach-Object { + Write-Output " - $($_.name) (Current license: $($_.licenseType))" + } + + foreach ($db in $dbs) { + + $dbResult = "NotAttempted" + $dbError = "" + + 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')." + } 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') + if ($update.Success) { + $finalStatus += $update.Result + $dbResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Database '$($db.name)': $dbResult (license type '$LicenseType')" + } + else { $dbResult = "Failed"; $dbError = $update.ErrorMessage } + } + + # 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 + UpdateResult = $dbResult + UpdateError = $dbError + } + } + } + } catch { + Write-Output "Error querying databases on server '$($server.name)': $_" + } + + # Update Elastic Pools with similar improved error handling + 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)'." + } 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." + } else { + Write-Output "Found $($elasticPools.Count) Elastic Pools on Server $($server.name) that require a license update:" + $elasticPools | ForEach-Object { + Write-Output " - $($_.name) (Current license: $($_.licenseType))" + } + + foreach ($pool in $elasticPools) { + + $poolResult = "NotAttempted" + $poolError = "" + + 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')." + } 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') + if ($update.Success) { + $finalStatus += $update.Result + $poolResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- Elastic Pool '$($pool.name)': $poolResult (license type '$LicenseType')" + } + else { $poolResult = "Failed"; $poolError = $update.ErrorMessage } + } + + # 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 + UpdateResult = $poolResult + UpdateError = $poolError + } + } + } + } + } catch { + Write-Output "Error processing Elastic Pools on server '$($server.name)': $_" + } + } + } catch { + Write-Output "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" + } + + # 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." + } + $instancePools = $instancePoolsQueryResult.Value + $poolsToUpdate = $instancePools | Where-Object { $_.licenseType -ne $LicenseType } + 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." + } + foreach ($pool in $poolsToUpdate) { + + $ipResult = "NotAttempted" + $ipError = "" + + 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')." + } 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') + if ($update.Success) { + $finalStatus += $update.Result + $ipResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } + Write-Output "-- SQL Instance Pool '$($pool.name)': $ipResult (license type '$LicenseType')" + } + else { $ipResult = "Failed"; $ipError = $update.ErrorMessage } + } + + # 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 + UpdateResult = $ipResult + UpdateError = $ipError + } + } + } + catch { + 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..." + Set-AzContext -Subscription $sub.id | Out-Null + Get-AzDataFactoryV2 | + 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) + } + + 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)': $_" + } +} + +$scriptEndTime = Get-Date +$totalDuration = $scriptEndTime - $scriptStartTime + +# --- Final Report --- +Write-Output "`n===== Final Report =====" +Write-Output "Script started at: $scriptStartTime" +Write-Output "Script ended at: $scriptEndTime" +Write-Output "Total duration: $($totalDuration.ToString())" + +# Export tracked resources for orchestrator if running in orchestrated mode +if (Test-Path variable:global:PaygTrackedResources) { + $global:PaygTrackedResources += $modifiedResources +} +$trackedOutPath = Join-Path (Get-Location) "manage-payg-transition\tracked_azure.json" +if ($modifiedResources.Count -gt 0) { + try { + $parentDir = Split-Path $trackedOutPath -Parent + if (Test-Path $parentDir) { + $modifiedResources | ConvertTo-Json -Depth 5 | Set-Content -Path $trackedOutPath -Encoding UTF8 + } + } catch {} +} else { + try { + if (Test-Path $trackedOutPath) { + Remove-Item -Path $trackedOutPath -Force -ErrorAction SilentlyContinue + } + } catch {} +} + +if (-not $NoSummary) { + # Print execution outcome summary and failure/skip root causes + Format-ExecutionOutcomeSummary -TrackedResources $modifiedResources -IsReportOnly ([bool]$ReportOnly) +} + +# Export modified resource data to CSV +if ($modifiedResources.Count -gt 0) { + $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" + # Export-Csv derives its header from the first object only, so rows built by + # different sections (some of which carry UpdateResult/UpdateError) are projected + # onto one consistent schema to avoid silently dropping columns. + $csvColumns = @('TenantID','SubID','ResourceName','ResourceType','Status', + 'OriginalLicenseType','ResourceGroup','Location','UpdateResult','UpdateError') + $modifiedResources | + Select-Object -Property $csvColumns | + Export-Csv -Path $csvPath -NoTypeInformation + Write-Output "CSV report saved to: $csvPath" +} else { + Write-Output "No resources were marked for modification. No CSV generated." +} + +Write-Output "Azure SQL Update Script completed" + +$scriptEndTime = Get-Date +$executionDuration = $scriptEndTime - $scriptStartTime +Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" +if ($transcriptStarted) { + try { Stop-Transcript | Out-Null } catch { Write-Warning "Unable to stop transcript logging: $($_.Exception.Message)" } +} diff --git a/runnow.ps1 b/runnow.ps1 new file mode 100644 index 0000000000..2740f7681e --- /dev/null +++ b/runnow.ps1 @@ -0,0 +1,16 @@ +.\manage-payg-transition\modify-arc-sql-license-type.ps1 ` +-UsePcoreLicense 'No' ` +-ReportOnly ` +-TenantId '72f988bf-86f1-41af-91ab-2d7cd011db47' ` +-NoSummary ` +-LicenseType 'PAYG' ` +-SubId 'a5082b19-8a6e-4bc5-8fdd-8ef39dfebc39' ` +-ResourceGroup 'rajpoArcEUSUSP' ` +-Force +.\manage-payg-transition\modify-azure-sql-license-type.ps1 ` +-LicenseType 'LicenseIncluded' ` +-ResourceGroup 'rajpoArcEUSUSP' ` +-SubId 'a5082b19-8a6e-4bc5-8fdd-8ef39dfebc39' ` +-TenantId '72f988bf-86f1-41af-91ab-2d7cd011db47' ` +-NoSummary ` +-ReportOnly From 7b8fe75778674cb6154d022727ad7cfdfa5b6acc Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Tue, 1 Sep 2026 08:34:59 -0700 Subject: [PATCH 38/48] 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 39/48] 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 40/48] 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 41/48] 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 42/48] 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 43/48] 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 44/48] 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 45/48] 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 46/48] 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 47/48] 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 48/48] 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)"