From 8aeba78b54e1bfa40ef173565f7d18b99024a320 Mon Sep 17 00:00:00 2001 From: Raj Pochiraju Date: Fri, 28 Aug 2026 11:26:47 -0700 Subject: [PATCH 01/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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/37] 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