diff --git a/samples/manage/manage-payg-transition/.gitignore b/samples/manage/manage-payg-transition/.gitignore index 69a6aae3c6..e249b9e746 100644 --- a/samples/manage/manage-payg-transition/.gitignore +++ b/samples/manage/manage-payg-transition/.gitignore @@ -6,3 +6,8 @@ manage-payg-transition/ runnow.ps1 ModifiedResources_*.csv *.log + +# Internal test log used during development. It records live test results against +# real Microsoft internal subscriptions, resource names, tenant/subscription IDs +# and account details, and must not be published in this public repository. +TESTPLAN.md diff --git a/samples/manage/manage-payg-transition/README.md b/samples/manage/manage-payg-transition/README.md index fb6797405d..7942bab980 100644 --- a/samples/manage/manage-payg-transition/README.md +++ b/samples/manage/manage-payg-transition/README.md @@ -19,6 +19,16 @@ If not specified, all subscriptions your role has access to are scanned. - You must have at least a *Contributor* RBAC role in each subscription you modify. - You must have a *Tag Contributor* *Contributor* RBAC role in each subscription you modify. - You must be connected to Azure AD and logged in to your Azure account. If your account have access to multiple tenants, make sure to log in with a specific tenant ID. +- The Az PowerShell modules `Az.Accounts`, `Az.Sql`, `Az.SqlVirtualMachine`, + `Az.ConnectedMachine`, and `Az.ResourceGraph` are required; the script installs any that + are missing automatically (for the current user, from the PowerShell Gallery). If you are + running the script interactively, it will ask for confirmation before installing a missing + module; pass `-Force` to install automatically without prompting (required for + non-interactive/unattended runs). + +> [!NOTE] +> The Azure CLI (`az`) is **not** required. The script is implemented entirely with Az +> PowerShell cmdlets. ### Detailed permissions by resource type @@ -29,7 +39,6 @@ prefer a least-privilege role assignment instead, the dependent scripts require: |---|---| | SQL Server VMs, Managed Instances, Azure SQL Databases, Elastic Pools, Instance Pools | *SQL DB Contributor* | | Azure Arc-enabled SQL Server (Arc machine extensions) | *Azure Connected Machine Resource Administrator* | -| Azure Data Factory Azure-SSIS Integration Runtimes (only if present) | *Data Factory Contributor* | | Reading/enumerating subscriptions and resources (all of the above) | *Reader* (included in every role above) | | Tagging subscriptions with `ArcSQLServerExtensionDeployment:PAYG` | *Tag Contributor* | @@ -47,13 +56,14 @@ The script accepts the following command line parameters: |`-targetResourceGroup` |``|*Optional*: Limits the scope of the transition to the specified resource group.| |`-TenantId`|``|*Optional*. Azure AD tenant to operate against. If not specified, the tenant of the current Az PowerShell context (`(Get-AzContext).Tenant.Id`) is used. Specify explicitly to avoid running against whichever tenant happens to be selected in your session.| |`-ReportOnly`|*(switch)*|*Optional*. Read-only dry run: reports the resources that would be changed without modifying anything.| -|`-WaitForCompletion`|*(switch)*|*Optional*. Wait for each license change to reach a terminal state and report a confirmed outcome. By default changes are submitted asynchronously and reported as `RequestSubmitted`. SSIS integration runtimes always wait (no asynchronous option exists for them). See [How It Works](#how-it-works).| +|`-WaitForCompletion`|*(switch)*|*Optional*. Wait for each license change to reach a terminal state and report a confirmed outcome. By default changes are submitted asynchronously and reported as `RequestSubmitted`. See [How It Works](#how-it-works).| |`-UsePcoreLicense` | `Yes`, `No` |*Optional*. Passed to Arc script to control PCore licensing behavior. Set to `No` if not specified.| |`-TargetLicenseType`|`PAYG`, `AHUB`|*Optional*. License type to transition resources to. Defaults to `PAYG`.| |`-AutomationAccResourceGroupName`| ``|*Required* only if `-RunMode Scheduled`. Resource group hosting the Automation Account, created if it does not already exist. Not used by `-RunMode Single`.| |`-AutomationAccountName`| ``|*Optional*. Name of the Automation Account used in `Scheduled` mode. Defaults to `aaccAzureArcSQLLicenseType`.| |`-Location`|``|*Required* only if `-RunMode Scheduled`. Azure region for the Automation Account. Not used by `-RunMode Single`.| |`-cleanDownloads`|`$true`, `$false`|*Optional*. Removes the `.\manage-payg-transition\` working folder after the run. Defaults to `$false`.| +|`-Force`|*(switch)*|*Optional*. Skip interactive confirmation prompts (installing missing Az modules, continuing with the current Azure account/tenant context). Required for non-interactive/unattended runs, where the script will otherwise throw an error instead of prompting.| > [!NOTE] > The script does not expose a `-SubId` parameter; use `-targetSubscription`. Scoping to a @@ -95,7 +105,10 @@ The script accepts the following command line parameters: - Arc-connected machines whose agent is `Disconnected` or `Expired` cannot be updated, because the extension setting must be pushed to a reachable agent. These are skipped and will be picked up on a later run once the machines reconnect. -- The offline Azure VMs will be reactivated for a brief period to change the configuration. +- SQL virtual machines that are stopped/deallocated are **skipped** rather than modified — + the underlying VM must be running for `Update-AzSqlVM` to change its license type. These + are reported with `UpdateResult = SkippedNotRunning` and picked up automatically on a later + run once the VM is started. - Each run writes a `ModifiedResources_.csv` report. The `UpdateResult` column records the per-resource outcome and `UpdateError` carries the service error text when a change was rejected. @@ -106,20 +119,11 @@ The script accepts the following command line parameters: | Resource | Default | With `-WaitForCompletion` | |---|---|---| - | SQL Managed Instance, database, elastic pool, instance pool | `--no-wait`, reports `RequestSubmitted` | waits, reports `Updated` | + | SQL Managed Instance, database, elastic pool, instance pool | `-AsJob`, reports `RequestSubmitted` | waits, reports `Updated` | | Arc-connected machine | `-NoWait`, reports `RequestSubmitted` | polls the extension, reports `Succeeded` / `Failed` / `TimedOut` | - | SQL virtual machine | direct ARM request, reports `RequestSubmitted` | `az sql vm update` waits, reports `Updated` | - | **SSIS integration runtime** | **always waits**, reports `Updated` | same | - - 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. + | **SQL virtual machine** | **always waits**, reports `Updated` | same | + + SQL VM updates are always synchronous, regardless of `-WaitForCompletion`. For Arc, a `TimedOut` result is inconclusive rather than a failure — the agent may still apply the setting after the script stops waiting. diff --git a/samples/manage/manage-payg-transition/TESTPLAN.md b/samples/manage/manage-payg-transition/TESTPLAN.md deleted file mode 100644 index 143ed12c1a..0000000000 --- a/samples/manage/manage-payg-transition/TESTPLAN.md +++ /dev/null @@ -1,185 +0,0 @@ -# Test Plan — manage-payg-transition.ps1 and modify-azure-sql-license-type.ps1 fixes - -This document records the tests performed to validate the changes on this branch, -against a live Azure environment (Microsoft tenant `72f988bf-86f1-41af-91ab-2d7cd011db47`). - -## Changes under test - -1. **`modify-azure-sql-license-type.ps1`** - - `Connect-Azure` now reuses an existing valid Az PowerShell/CLI session for the - target tenant instead of always forcing re-login. - - Module presence check now verifies `Az.Accounts >= 4.2.0` directly instead of - checking for the `Az` meta-package (which caused false negatives and unnecessary/ - conflicting `Install-Module -Name Az -Force` calls). - -2. **`manage-payg-transition.ps1`** - - Removed unused `Force_Start_On_Resources` parameter usage. - - Fixed the Arc script download URL path - (`azure-hybrid-benefit` → `azure-arc-enabled-sql-server`). - - Reformatted wrapper argument line-continuation logic so backticks are placed - correctly regardless of how many arguments are present. - - Fixed `RunMode Single`: the Arc/Azure sub-scripts were referenced by the - generated wrapper but never downloaded first, causing - `term ... is not recognized` errors. Added the missing `Invoke-RestMethod` - download calls (mirroring the existing `Invoke-RemoteScript` logic used for - `RunMode Scheduled`). - - Made the script fully **self-contained**: embedded the complete logic of - `modify-azure-sql-license-type.ps1`, `modify-arc-sql-license-type.ps1`, and - `set-azurerunbook.ps1` directly in `manage-payg-transition.ps1`. No external - downloads from `raw.githubusercontent.com` occur anymore — the embedded - content is materialized to local files at runtime (required for local - script invocation and Azure Automation runbook import). - - Added `-TargetLicenseType` parameter (`PAYG` default, or `AHUB`) to control - which license model resources are transitioned to, translated internally to - each embedded script's own vocabulary (`LicenseIncluded`/`BasePrice` for - Azure SQL resources, `PAYG`/`LicenseOnly` for Arc SQL Server). Previously - the Arc transition target was hardcoded to `PAYG` only. - -## Test environment - -- Tenant: Microsoft (`72f988bf-86f1-41af-91ab-2d7cd011db47`) only, per requirement. -- Primary test resource: SQL Server VM `rajpoTest` - (`/subscriptions/6a37df99-a9de-48c4-91e5-7e6ab00b2362/resourceGroups/rajpobuddy/...`). -- Secondary test resource: SQL Managed Instance `abhisqlmi` - (`/subscriptions/fa58cf66-caaf-4ba9-875d-f310d3694845/resourceGroups/dms-demos-49855/...`). - -## Test cases and results - -| # | Test | Method | Result | -|---|------|--------|--------| -| 1 | Corrected Arc script URL resolves | `HEAD` request to the raw GitHub URL on `master` | ✅ 200 OK (old `azure-hybrid-benefit` path is missing/404s) | -| 2 | `Connect-Azure` reuses existing session | Ran script with an already-authenticated Az/CLI session | ✅ No re-login prompt, no hang; log shows "Reusing existing context/session" | -| 3 | `Az.Accounts` version check | Ran on a machine with `Az.Accounts 5.5.2` (not the `Az` meta-package) installed | ✅ Correctly detected as satisfying `>= 4.2.0`; no reinstall attempted | -| 4 | `RunMode Single -Target Azure`, SQL VM (AHUB→PAYG) | Reset `rajpoTest` to `AHUB`, ran `manage-payg-transition.ps1 -Target Azure -RunMode Single` end-to-end | ✅ Passed after fixing the missing-download bug; CSV report generated with exactly 1 resource (`rajpoTest`, `AHUB`→`PAYG`); final state confirmed via `az resource show` | -| 5 | `RunMode Single -Target Both` | Ran with `-Target Both` against `rajpoTest` (already PAYG) | ✅ No hangs; both Arc and Azure branches executed; correctly reported "no resources require update" (no false modification) | -| 6 | `RunMode Single -Target Arc`, real transition | Ran end-to-end against Arc-enabled machines in subscription `fbaf508b-cb61-4383-9cda-a42bfa0c7bc9` (tenant `d1623670`, AdaptiveCloudLab) | ✅ Passed (originally blocked — see note below). 12 machines transitioned to `PAYG`, including `sqltvm`, `az-sqlnode1` and `sac-mabs` (the latter two were `Paid`, confirming `-Force` works). Verified independently via `Search-AzGraph` against `microsoft.hybridcompute/machines/extensions` and via the per-resource CSV report. | -| 7 | Wrapper line-continuation formatting (Scheduled mode) | Code review of the `for` loop building `$wrapper` lines for both Arc and Azure blocks | ✅ Confirmed a trailing backtick is appended to every line except the last, for any number of arguments | -| 8 | SQL Managed Instance transition (regression, prior fixes) | Ran against `abhisqlmi` (`BasePrice` → `LicenseIncluded`) | ✅ Passed; exactly 1 resource modified out of 247 unrelated SQL Servers in the subscription | -| 9 | Azure Policy-based compliance sample (PR #1490, IaaS SQL VM variant) | End-to-end: policy definition, assignment, compliance scan, remediation against `rajpoTest` | ✅ Passed (separate from this branch's fixes, but validated as an alternate transition method during the same testing session) | -| 10 | Self-contained script: no external downloads | Ran `manage-payg-transition.ps1 -Target Azure -RunMode Single` (default `-TargetLicenseType PAYG`) against `rajpoTest` (reset to `AHUB`) | ✅ Passed; log shows only "Writing embedded script ... to ..." (local file write), no `Invoke-RestMethod`/network download calls; `rajpoTest` transitioned `AHUB`→`PAYG`, CSV report generated with exactly 1 resource | -| 11 | `-TargetLicenseType AHUB` reverse transition | Ran the same command with `-TargetLicenseType AHUB` against `rajpoTest` (now `PAYG`) | ✅ Passed; internal query correctly used `BasePrice` filter (Azure SQL vocabulary); `rajpoTest` transitioned `PAYG`→`AHUB`, CSV report generated with exactly 1 resource | -| 12 | `-Force` emitted as a bare switch | Ran `-RunMode Single` with no `-targetSubscription` and inspected the generated `runnow.ps1` | ✅ Passed after fix. Previously the generator emitted `-Force 'True'`; since `-Force` is a `[switch]` it does not consume the following token, so the orphaned `'True'` bound to the first positional parameter (`$SubId`), producing *"Subscription True was not found in tenant"*. Now emitted as a bare `-Force`. | -| 13 | `-TenantId` / `-ReportOnly` pass-through | Ran `-Target Arc -TenantId d1623670-... -targetResourceGroup rajposqltvm -TargetLicenseType AHUB -ReportOnly` | ✅ Passed; log shows "Using provided TenantId: d1623670-...", `Found 1 resource(s) to update`, "ReportOnly mode enabled. Skipping modification for: sqltvm". No resource was modified; generated `runnow.ps1` contains a bare `-ReportOnly` switch. | -| 14 | Resource count reported correctly | Same dry run as #13, before and after the fix | ✅ Passed after fix. `Found N resource(s) to update` read `$resources.Count` *before* the paging loop populated `$resources`, so it always printed `0` even when resources were found and modified. Now reads `$allResults.Count` after the loop and correctly reports `Found 1 resource(s) to update`. | -| 15 | Self-containment in an isolated folder | Copied **only** `manage-payg-transition.ps1` into an empty temp directory and ran it there with `-ReportOnly` | ✅ Passed; with zero sibling files present the script materialized `manage-payg-transition\modify-arc-sql-license-type.ps1` (19,100 B) from its embedded here-string, generated `runnow.ps1`, and produced a valid CSV report. Confirms no dependency on co-located files. | -| 16 | Embedded vs standalone Arc script in sync | `Compare-Object` between the embedded `Arc` here-string block and the standalone `modify-arc-sql-license-type.ps1` | ✅ Passed; 1 difference, a trailing blank line only — functionally identical. | -| 17 | Arc update outcome reported truthfully | Code review + dry run producing the CSV report | ✅ Passed after fix. `Set-AzConnectedMachineExtension` runs with `-NoWait` and had no `-ErrorAction`, so service-side failures (e.g. *"An extension of type ... is still processing"*) were non-terminating: the `catch` never fired and the script printed `Updated --` for resources that had actually failed. Added `-ErrorAction Stop` plus `UpdateResult`/`UpdateError` CSV columns (`NotAttempted`/`RequestSubmitted`/`Failed`). | -| 18 | Idempotent re-run | Re-ran the default (`-TargetLicenseType PAYG`) against an already-converged scope | ✅ Passed; reported `Found 0 resource(s) to update`. Resources already at the target license type are excluded by the discovery query (`properties.settings.LicenseType != ''`) by design, so repeat runs are safe. | -| 19 | README parameters match the script | Automated cross-check of every `-Param` used in a README example against the script's AST parameter block | ✅ Passed after fix. Previously 5 documented parameters did not exist (`-SubId`, `-ResourceGroup`, `-RunAt`, `-AutomationAccount`, `-ExclusionTag`), so every documented example would have failed. All 11 parameters now resolve. | - | 20 | `Stop-Transcript` no longer errors when transcription never started | Reproduced by pointing `Start-Transcript` at an unwritable path (`Z:\...`), then ran the script end-to-end | ✅ Passed after fix. Previously `Start-Transcript` could fail silently (unwritable log path, or a host that does not support transcription such as an Azure Automation runbook) and the unguarded `Stop-Transcript` at the end threw *"An error occurred stopping transcription: The host is not currently transcribing"* — surfacing a spurious failure after an otherwise successful run. Now emits `WARNING: Unable to start transcript logging: ... Continuing without a transcript.` and completes cleanly. Verified in all four copies (Arc/Azure × standalone/embedded). | -| 21 | Scope is not silently widened when `-targetResourceGroup` matches no SQL Servers | Ran `-Target Azure -targetSubscription 6a37df99-... -targetResourceGroup rajpobuddy` (an RG containing a SQL VM but **no** `Microsoft.Sql/servers`) | ✅ Passed after fix. Previously the script fell back to `$servers = $allServers` whenever the server query returned nothing and `-ResourceName` was absent, logging *"Proceeding with all SQL Servers since no specific ResourceName was provided"* and scanning 3 servers in unrelated resource groups. Because the elastic-pool query filters only on `licenseType`/tags — **with no resource-group filter** — a real (non-`ReportOnly`) run would have modified out-of-scope elastic pools; this test only escaped damage because those servers happened to have no pools. The fallback now requires **both** `-ResourceName` and `-ResourceGroup` to be absent, and the log correctly reads *"Scope was explicitly restricted; not falling back to all SQL Servers. Skipping SQL Database and Elastic Pool processing."* | -| 22 | End-to-end real (non-`ReportOnly`) run with all fixes applied | Ran `-Target Azure -RunMode Single -TenantId 72f988bf-... -targetSubscription 6a37df99-... -targetResourceGroup rajpobuddy -TargetLicenseType PAYG` against `rajpoTest` (`AHUB`) | ✅ Passed. Duration 2m22s. Transcript opened and closed cleanly (#20), the tenant was taken from `-TenantId` rather than the persisted context (#13), scope stayed inside `rajpobuddy` (#21), and the SQL VM transitioned `AHUB`→`PAYG`. **Verified independently of the script's own logging** via `az sql vm show -n rajpoTest -g rajpobuddy --query sqlServerLicenseType` → `PAYG`, and via `Search-AzGraph` across the whole resource group. Note the script prints `Updating SQL VM ...` *before* the `az sql vm update` call and does not inspect its result, so the log line alone is not proof of success — out-of-band verification is required. | -| 23 | Non-SSIS integration runtimes are no longer selected for licensing | Ran `-Target Azure -targetResourceGroup jh_adf` against `jhomerDataFactory`, whose only runtime is the default `AutoResolveIntegrationRuntime` | ✅ Passed after fix. The selection filter was `$_.Type -eq "Managed" -and $_.LicenseType -ne $LicenseType`. The default `AutoResolveIntegrationRuntime` is *also* `Type = "Managed"` (it is the data-flow/pipeline runtime, not an SSIS-IR) and has a **null** `LicenseType`, so `$null -ne 'LicenseIncluded'` evaluated true and it was selected. `Set-AzDataFactoryV2IntegrationRuntime` then round-tripped the full payload and the service rejected it with *"HTTP Status Code: Conflict / DataFactoryPropertyUpdateNotSupported / Updating property managedVirtualNetwork is not supported"*. Filter now also requires a non-empty `LicenseType`, which only SSIS integration runtimes carry. Log now reads *"No SSIS integration runtimes found on DataFactory 'jhomerDataFactory' that require a license update."* | -| 24 | DataFactory update failures reported truthfully | Same scenario as #23, before the fix | ✅ Passed after fix. Same class of bug as #17: `Set-AzDataFactoryV2IntegrationRuntime` had no `-ErrorAction Stop`, so the Conflict above was **non-terminating** — the surrounding `catch` never fired and the script still printed `-- DataFactory 'jhomerDataFactory' integration runtime updated to license type LicenseIncluded` immediately after two error blocks. Now wrapped in a per-runtime `try/catch` with `-ErrorAction Stop`, emitting a warning and recording `UpdateResult = Failed` with the service message. | -| 25 | SQL VM update result checked and recorded | Real run `-targetResourceGroup rajpobuddy -TargetLicenseType AHUB` against `rajpoTest` (`PAYG`) | ✅ Passed after fix. Previously `az sql vm update` output was piped straight to `ConvertFrom-Json` with no exit-code check, and the CSV row was appended *before* the attempt — so a failed update was recorded identically to a successful one (the gap noted in #22). Now checks `$LASTEXITCODE`, logs `-- SQL VM '' updated to license type ''` only on success, and appends the row *after* the attempt with `UpdateResult`/`UpdateError`. Verified: CSV recorded `OriginalLicenseType = PAYG`, `UpdateResult = Updated`, and `az sql vm show` independently confirmed `AHUB`. | -| 26 | CSV schema consistent across resource types | Inspected the header of a report containing a SQL VM row | ✅ Passed after fix. `Export-Csv` derives its header from the **first** object only, so once the DataFactory and SQL VM sections began emitting `UpdateResult`/`UpdateError`, a report whose first row came from any other section would have silently dropped those columns. Rows are now projected through an explicit 10-column `Select-Object` before export. Verified header: `TenantID,SubID,ResourceName,ResourceType,Status,OriginalLicenseType,ResourceGroup,Location,UpdateResult,UpdateError`. | -| 27 | All Azure resource types report update outcomes consistently | Code change plus real runs against `rajpoTest` (`AHUB`→`PAYG`→`AHUB`) | ✅ Passed. The result-checking added for SQL VMs in #25 was extended to the Managed Instance, SQL Database, elastic pool and instance pool sections, which all still piped `az ... update` into `ConvertFrom-Json` without inspecting the exit code and appended their CSV row *before* the attempt. The elastic pool section was the worst case: it used `2>$null | ConvertFrom-Json -ErrorAction SilentlyContinue`, discarding the error text entirely and reporting only *"No result returned"*. All five `az` update paths now route through a shared `Invoke-AzCliLicenseUpdate` helper that checks `$LASTEXITCODE`, surfaces the real service error via `Write-Warning`, and returns a result object. Verified 6 row builders, 6 `UpdateResult` fields, and zero remaining raw `az ... update` pipes. | -| 28 | Helper does not corrupt its own return value | Unit-tested `Invoke-AzCliLicenseUpdate` in isolation against a succeeding and a failing `az` command | ✅ Passed after fix. The first implementation called `Write-Output "-- ... updated successfully"` inside the function; in PowerShell that merges into the **return value**, so the caller received a 2-element array instead of the result object and the message never reached the transcript. Caught during verification when the expected success line was missing from an otherwise-successful run. The helper is now silent on the success stream and each caller logs its own message. Verified both paths return `count=1`, `type=PSCustomObject`, with the failure path capturing the genuine service error (`ResourceGroupNotFound ... could not be found`). | -| 29 | Azure CLI updates block until the operation reaches a terminal state | Timed `az sql vm update` on `rajpoTest` and inspected the response body and an immediate re-read | ✅ Confirmed synchronous. The call returned after **126.1 s** with `provisioningState: Succeeded` and `sqlServerLicenseType: PAYG` in the response body, and an immediate `az sql vm show` already reported `PAYG`. The script passes no `--no-wait`, so every Azure CLI update path waits for completion and `UpdateResult = Updated` reflects a committed change. This also explains the multi-minute runtimes observed whenever a SQL VM is actually modified. | -| 30 | Arc updates are fire-and-forget by design | Code inspection of `Set-AzConnectedMachineExtension` call site | ⚠️ Confirmed **asynchronous** — documented, not a defect. The `-NoWait` flag means the script submits the extension write and moves on without waiting for the Arc agent to apply it, so `RequestSubmitted` is an accurate label and must not be read as "changed". Verified there is no polling or `Get-AzConnectedMachineExtension` follow-up anywhere in the Arc path. README now states this explicitly and gives a Resource Graph query for confirming the real end state. | -| 31 | `-WaitForCompletion` reports confirmed Arc outcomes | Live transition of `sqltvm` (`rajposqltvm`, tenant `d1623670`) with the new switch | ✅ Passed. Without the switch the report records `RequestSubmitted`; with it the script polled the extension to a terminal state and recorded `UpdateResult = Succeeded`, logging `Confirmed -- [sqltvm] provisioning state 'Succeeded'`. Run took 79.6 s. Verified independently via `Search-AzGraph`: the extension shows `LicenseType = LicenseOnly`, `provisioningState = Succeeded`, matching what the script reported. Polling backs off 5→30 s, and the helper also compares the applied `LicenseType` against the requested value, so a `Succeeded` provisioning state carrying the wrong license is reported as `Failed` rather than as success. | -| 32 | `-WaitForCompletion` is inert in `-ReportOnly` mode | Dry run with both switches against `rajposqltvm` | ✅ Passed. The switch bound correctly through the wrapper (generated `runnow.ps1` contains a bare `-WaitForCompletion`, confirming the earlier `-Force 'True'` class of bug does not recur), and no polling occurred because nothing was submitted — output was the usual `ReportOnly mode enabled. Skipping modification for: sqltvm`. | -| 33 | Asynchronous submission is the default for every resource type that supports it | Unit-tested `Invoke-AzCliLicenseUpdate` argument construction, verified CLI acceptance, and ran end-to-end | ✅ Passed. Previously Azure updates always blocked; they now submit with `--no-wait` unless `-WaitForCompletion` is passed. Verified the helper appends `--no-wait` only when the command supports it and the switch is absent (`sql db update -o json --no-wait` vs `sql db update -o json`), that the empty CLI output produced by `--no-wait` does not break result parsing, and that the real CLI accepts the flag on `sql db update` and `sql mi update` (failures return `ResourceNotFound`, not `unrecognized arguments`, confirming the flag parsed). Report values are `RequestSubmitted` when submitted asynchronously and `Updated` when waited on. | -| 34 | Resource types with no asynchronous option are identified rather than faked | `az ... --help` inspection across all five update commands, plus an ARM `PATCH` probe | ⚠️ Partly superseded by #36. `az sql vm update` and `Set-AzDataFactoryV2IntegrationRuntime` expose no `--no-wait`/`-AsJob` equivalent. A generic `az resource update`/`patch` fallback was ruled out (neither supports `--no-wait`), and a direct ARM `PATCH` against the SQL VM was rejected with `MissingPatchParameters: Approved values: tags, additionalVmPatch`. The conclusion drawn at the time — that SQL VMs must always wait — was **wrong**, because it only considered the Azure CLI and a `PATCH`; see #36. SSIS integration runtimes remain a genuine exception. | -| 35 | `-WaitForCompletion` reaches the Azure script through the wrapper | Ran the orchestrator with and without the switch and inspected the generated `runnow.ps1` | ✅ Passed. Emitted as a bare `-WaitForCompletion` (no repeat of the `-Force 'True'` binding defect from #12) and omitted entirely when not requested. The default run reported `Updated` for `rajpoTest` with the change confirmed in Azure (`PAYG`); the `-WaitForCompletion` run restored `AHUB`, also confirmed via `az sql vm show`. | -| 36 | SQL virtual machines honour async-by-default | Probed `Update-AzSqlVM -NoWait`/`-AsJob`, then a direct ARM read-modify-write, then two real runs against `rajpoTest` | ✅ Passed after fix. Corrects the mistaken conclusion in #34. `Update-AzSqlVM` **advertises** both `-NoWait` and `-AsJob` but both are broken in `Az.SqlVirtualMachine` 2.4.0: `-NoWait` forwards the bound parameter into `Get-AzSqlVM` (`A parameter cannot be found that matches parameter name 'NoWait'`) and `-AsJob` fails with `Object reference not set to an instance of an object`. A direct ARM read-modify-write **does** work — `PUT` returned HTTP 200 with an `Azure-AsyncOperation` header in **1.7 s** (versus 126 s for `az sql vm update`) and the change applied correctly. The SQL VM path now uses `Invoke-SqlVmLicenseUpdate`, which PUTs the body ARM just returned with only `sqlServerLicenseType` changed, and falls back to `az sql vm update` if the request fails or `-WaitForCompletion` is passed. Verified end-to-end: default run reported `RequestSubmitted` in **49.7 s** with no fallback warning and `az sql vm show` confirmed `PAYG`; the `-WaitForCompletion` run reported `Updated` in **189.3 s**. | -| 37 | Embedded orchestrator copy stays in sync after the SQL VM change | Re-synced `$EmbeddedScripts['Azure']` and parsed every embedded block | ✅ Passed. The standalone Azure script and the here-string embedded in `manage-payg-transition.ps1` diverged by 87 lines after the #36 edit; after re-syncing, `Compare-Object` reports 0 differences. All three embedded blocks parse cleanly (`Azure` 991 lines, `Arc` 609, `General` 299, 0 parse errors each) and `Invoke-SqlVmLicenseUpdate` is present in the orchestrator. | -| 38 | An unchanged Arc license type is never reported as a successful change | Ran the Arc script against `sqltvm` (`LicenseOnly`) with `-LicenseType PAYG -WaitForCompletion` but **without** `-Force` | ✅ Passed after fix. Found while restoring `sqltvm`: the run printed `Updated -- ... [sqltvm]` and `Confirmed -- [sqltvm] provisioning state 'Succeeded'`, yet the extension was still `LicenseOnly` afterwards. Root cause is pre-existing `-Force` semantics — when a machine already carries a `LicenseType` it is only overwritten with `-Force` — but `$WriteSettings` was set to `$true` by the unrelated `ConsentToRecurringPAYG` block, so the run wrote *other* settings and reported success for a license type that never changed. The wait helper could not catch it either, because it is passed `$settings['LicenseType']` (the **unmodified** value), so expected and applied matched. Now emits `WARNING: [sqltvm] LicenseType is 'LicenseOnly' and was NOT changed to 'PAYG'. Re-run with -Force to overwrite an existing license type.` Verified the same command now reports `Write Settings - False` with no `Updated`/`Confirmed` line. `-Force` semantics were deliberately left unchanged. | -| 39 | Arc wait helper tolerates a stale terminal provisioning state | Code fix plus a real `-Force` run against `sqltvm` | ✅ Passed. Because extension updates are submitted with `-NoWait`, the first poll can observe the **previous** operation's `Succeeded` state before the new one starts — with the old license type still in place, which the helper would have reported as a hard `Failed`. A terminal state carrying an unexpected license type is now treated as inconclusive and polling continues; the mismatch is only returned as `Failed` if it survives to the timeout. Verified live: the `-Force` run reported `Confirmed -- [sqltvm] provisioning state 'Succeeded'` and `Get-AzConnectedMachineExtension` independently confirmed `LicenseType = PAYG`, `provisioningState = Succeeded`. Observed state sequence during the earlier manual poll was `Updating → Creating → Succeeded` over ~2.5 minutes, confirming the race window is real. | -| 40 | Asynchronous SQL Database updates reach a committed state | User run of the orchestrator against subscription `20d62cea` (`DMSInternalDevTestER`) | ✅ Passed. First live exercise of the `--no-wait` path on a resource type other than a SQL VM, closing a documented gap. Two databases were submitted asynchronously — `ratruong-test-sqldb` and `TestDB`, both `BasePrice` — and each reported `RequestSubmitted`. Verified afterwards with `az sql db show`: both report `licenseType = LicenseIncluded`, confirming that `RequestSubmitted` was followed by a committed change. Managed instances, elastic pools and instance pools are still unexercised (see Known gaps). | -| 41 | A failed discovery query is never reported as "nothing to do" | Same run as #40, which emitted `ResourceGroupNotFound` for three servers, plus a follow-up ReportOnly run after the fix | ✅ Passed after fix. The most serious defect found so far. Discovery calls piped `az` straight into `ConvertFrom-Json` with no `$LASTEXITCODE` check — the same bug class fixed for *update* paths in #27, but still present in every *query* path. When `az sql db list` failed transiently the CLI printed `ERROR: (ResourceGroupNotFound)` to stderr, the variable became `$null`, and the script reported `Found a total of 0 databases` followed by `No SQL Databases found ... that require a license update`. **A failed query was indistinguishable from an empty one, so databases that needed transitioning were silently skipped while the run still looked clean.** Proved spurious: all four resource groups exist, `testdeaazuresqldbserver2` succeeded in the *same* resource group where `testdeasqlserverv1` failed, and all three servers listed their databases correctly when retried standalone. All discovery paths (SQL VM list, VM power state, MI list, server list, database list, elastic pool list, instance pool list) now route through a shared `Invoke-AzCliQuery` helper that checks the exit code, surfaces the service error as a warning, and returns the value normalised to an array. Callers that cannot proceed without the data now `continue` with an explicit skip warning instead of silently reporting zero. Unit-tested all three branches (success `Count=6`, failure `Success=False` carrying the real `ResourceGroupNotFound` text, empty `Success=True Count=0`). Re-run confirmed the counts are now correct: `demo731` and `sqldbinlinemigtestserver` report **1 database each instead of 0**, and `testdeasqlserverv1` reports 1. | -| 42 | A failed subscription context switch never causes work in the wrong subscription | Code inspection of both `az account set` call sites plus a regression run against `20d62cea` | ✅ Passed after fix. Every `az` call in the Azure script is scoped by the CLI's *active* subscription, but `az account set --subscription $sub.id` was issued without checking `$LASTEXITCODE` at either call site. If the switch failed, the whole subscription loop body — including the update calls — would have run against whichever subscription happened to be selected beforehand, so resources in an unintended subscription could have been modified. The primary call site now warns and `continue`s to the next subscription; the mid-loop re-selection (guarded by an existing context-mismatch check) throws, which the enclosing handler at L915 catches so SQL Server, database and elastic pool processing is skipped without aborting the run. Regression-verified: a `-ReportOnly` run still enumerates all 6 servers and the correct per-server database counts. Also audited the remaining unchecked `az ... | ConvertFrom-Json` pipes — the `az tag list` one is inside a commented-out block (dead code) and the rest are `az account show` login probes that are already guarded. | - -| 43 | A fully clean run reports "nothing to do" truthfully | User run of the orchestrator against `20d62cea` after the #41/#42 fixes, with every claim independently re-verified | ✅ Passed. The run completed in 1m20s with **zero errors or warnings** — the `ResourceGroupNotFound` noise from #41 is gone and every server now reports its correct database count. Each "nothing found" claim was checked against Azure rather than taken at face value: the only two real databases (`ratruong-test-sqldb`, `TestDB`) are already `LicenseIncluded` and every other database is a `master` with a null `licenseType`, correctly excluded by the `licenseType!=null` filter; the subscription's single SQL VM (`rradjousql2016`) is already `PAYG` and its single managed instance (`bhrout-mi`) is already `LicenseIncluded`, so both are correctly excluded by their filters rather than skipped; and there are 0 elastic pools, 0 instance pools and 0 Arc machines. Also confirms `No resources were marked for modification. No CSV generated.` is correct behaviour — the previous run generated a CSV precisely because it had two rows to record. | -| 44 | SSIS filter holds up against a large, varied estate | Enumerated every integration runtime in subscription `20d62cea` | ✅ Passed. Independently validates the #23 fix at a scale not previously tested: **22 integration runtimes across 8 data factories, every one with an empty `LicenseType`**, including 5 of type `Managed`. All were correctly reported as not requiring an update. Under the original filter (`$_.LicenseType -ne $LicenseType`, i.e. `$null -ne 'LicenseIncluded'` → true) all 22 would have been selected for update, and the `Managed` ones would have failed with the same `DataFactoryPropertyUpdateNotSupported: Updating property managedVirtualNetwork is not supported` Conflict seen in #23. Confirms a non-empty `LicenseType` is the correct discriminator for a genuine SSIS-IR. | - | 45 | A folder-scoped merge of `manage-payg-transition/` alone is self-sufficient | Extracted exactly the tracked contents of the folder at `HEAD` via `git archive HEAD samples/manage/manage-payg-transition` into an empty temp tree (5 files, both sibling scripts absent) and ran the orchestrator there against subscription `20d62cea` with `-ReportOnly` | ✅ Passed. Stronger than #15, which copied the working folder; this reproduces precisely what a folder-only merge would deliver. The orchestrator materialized both sub-scripts from its embedded here-strings and completed the Arc and Azure SQL passes with no missing-file, path or download errors, and the materialized copies were **byte-identical (0 differing lines by `Compare-Object`)** to the fixed standalone scripts. Confirmed no `Invoke-WebRequest`/`Invoke-RestMethod` fetch of the sub-scripts and no relative path escaping the folder — the only outbound URIs are PowerShell Gallery module links used by the Automation-Account path. **Caveat:** merging the folder alone would leave the standalone `modify-azure-sql-license-type.ps1` (+581) and `modify-arc-sql-license-type.ps1` (+167) stale on `master`, so callers invoking them directly would still hit the #40/#41 silent-skip, the #39 Arc false-success and the synchronous SQL VM update from #36, and the embedded-vs-standalone sync invariant would be broken. All 7 changed files should ship together. | -| 46 | End-of-execution outcome summary and root cause reporting | Added `Format-ExecutionOutcomeSummary` across standalone and orchestrator scripts, tested with `-ReportOnly` and live executions | ✅ Passed. For each run, a structured outcome table is printed to the console/transcript at the very end of execution (unified across all resource types with no intermediate duplicate tables), summarizing: `ResourceType` (sorted alphabetically), `Qualified`, `Updated or RequestSubmitted`, `Failed`, and `Skipped`. Directly beneath the summary table, a detailed `FAILURE & SKIP ROOT CAUSES` breakdown displays the Resource Name, Resource Group, ResourceType, Outcome, and exact error/root-cause message (e.g. stopped/deallocated VM power state, ESU licensing restrictions, tag exclusions, or service errors). If no issues occurred, cleanly outputs `No failures or skipped resources encountered.` | - -## Cleanup - -- All temporary test artifacts (generated wrapper scripts, materialized sub-scripts, - CSV reports, transcript logs, and isolated temp-folder copies of the orchestrator - used for self-containment testing) were removed after each run. -- A `.gitignore` was added to the sample folder so these runtime artifacts - (`manage-payg-transition/`, `runnow.ps1`, `ModifiedResources_*.csv`, `*.log`) - cannot be committed by accident. -- `rajpoTest` was left in `AHUB` state (following test #25) and `abhisqlmi` in - `LicenseIncluded` at the user's explicit request (for portal verification); they were - deliberately **not** reverted. -- The Arc machine `sqltvm` (`rajposqltvm`, tenant `d1623670`), which was switched to - `LicenseOnly` during tests #31 and #39, was restored to `PAYG` and confirmed via - `Get-AzConnectedMachineExtension`. - -## Known gaps / follow-ups - -- **Async coverage is partial.** The `--no-wait` / non-blocking default has been proven live - for SQL virtual machines (#36), SQL databases (#40) and Arc-connected machines (#31), but - **not** for managed instances, elastic pools or instance pools. The code path is shared - (`Invoke-AzCliLicenseUpdate -SupportsNoWait`) and the flag was verified to parse on - `sql mi update`, but no live resource of those types has actually been transitioned - asynchronously. Subscription `20d62cea` does contain one managed instance (`bhrout-mi`), - but it is already at the target license type and belongs to another team, so transitioning - it purely to exercise the code path would be a billing-affecting change to someone else's - resource; it was deliberately left alone. Elastic pools and instance pools do not exist in - any subscription reached so far (0 across all six servers in `20d62cea`). -- `RunMode Scheduled` has **never been executed end-to-end**. The runbook import-path fix - (the embedded `set-azurerunbook.ps1` hardcoded `./PayTransitionDownloads/` while the - orchestrator materializes to `./manage-payg-transition/`) is validated by code review - and parse checks only. Confirming it requires provisioning a real Azure Automation - Account. -- 9 Arc machines in the test subscription could not be transitioned because their agents - are `Disconnected` or `Expired` (`ASRTEST`, `ASTTest`, `kerimASRvm1`, `sql2022image-Rajpo`, - `az-sqln01`, and four `Tag-TVM-sql2-*`). The extension setting can only be pushed to a - reachable agent, so these need to be re-run once the machines reconnect. One - (`Tag-TVM-sql2-fab2ee81`) also has `provisioningState = Failed` and is excluded by the - discovery query regardless. -- `microsoft.azurearcdata/SqlServerInstances` resources with `hostType = "Azure Virtual Machine"` - are read-only discovery mirrors; Azure rejects direct `licenseType` writes on them - ("must be set to 'Undefined'"). The writable resource for VM-hosted SQL is - `Microsoft.SqlVirtualMachine/SqlVirtualMachines/`. -- There is no automated check that the three embedded here-string copies stay in sync with - their standalone sources; test #16 was performed manually via `Compare-Object`. -- The generated wrapper interpolates values into single-quoted strings without escaping - embedded `'` characters. -- Both scripts write their transcript to a **fixed** path (`$env:TEMP\modify-azure-sql-license-type.log` - and `.\modify-arc-sql-license-type.log`), so every run overwrites the previous run's log. - This destroys evidence when diagnosing an earlier run; timestamped log names would be an - improvement. -- The Azure-side CSV report now carries `UpdateResult`/`UpdateError` for **all** resource - types (tests #24–#27). Failure paths for Managed Instances, SQL Databases, elastic pools - and instance pools are implemented and unit-verified via the shared helper (#28), but have - not been observed against a genuine service-side failure on those specific resource types — - only the SQL VM and DataFactory paths have been exercised end-to-end against real errors. -- **Azure updates are asynchronous by default as of test #33.** 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 - -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. diff --git a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 index 500ea581b2..67faf1162f 100644 --- a/samples/manage/manage-payg-transition/manage-payg-transition.ps1 +++ b/samples/manage/manage-payg-transition/manage-payg-transition.ps1 @@ -43,9 +43,9 @@ '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. + Exception: SQL virtual machine updates are always synchronous, because Update-AzSqlVM + must not be run asynchronously for SQL VMs (see Invoke-SqlVmLicenseUpdate). Using this + switch makes runs substantially slower on large estates. .PARAMETER AutomationAccResourceGroupName Required only when -RunMode is 'Scheduled'. Resource group for the Azure @@ -56,6 +56,12 @@ Required only when -RunMode is 'Scheduled'. Azure region for the Automation Account/resource group. Not needed/used for -RunMode Single. +.PARAMETER Force + Skip interactive confirmation prompts: install missing Az PowerShell modules and + proceed with the currently connected Azure account/tenant without asking first. + Required when running non-interactively (e.g. scheduled/unattended), since the + script will otherwise stop and ask for confirmation. + .EXAMPLE # Run immediately for both Azure and Arc, transitioning to PAYG (all defaults) .\manage-payg-transition.ps1 @@ -119,7 +125,10 @@ param( [string]$AutomationAccountName="aaccAzureArcSQLLicenseType", [Parameter(Mandatory=$false)] - [string]$Location=$null + [string]$Location=$null, + + [Parameter(Mandatory=$false)] + [switch]$Force ) # -AutomationAccResourceGroupName and -Location are only actually used by the @@ -134,6 +143,17 @@ if ($RunMode -eq "Scheduled") { } } +# Heuristic for "is a human watching this run right now". A real interactive console has +# UserInteractive = $true and an attached (non-redirected) console input stream; scheduled/ +# Automation/CI contexts typically fail at least one of these checks. +function Test-IsInteractiveSession { + try { + return [Environment]::UserInteractive -and -not [Console]::IsInputRedirected + } catch { + return $false + } +} + # Translate the simplified -TargetLicenseType switch into the vocabulary each # embedded script expects: # - modify-azure-sql-license-type.ps1 expects "LicenseIncluded" (PAYG) or "BasePrice" (AHUB). @@ -141,6 +161,55 @@ if ($RunMode -eq "Scheduled") { $azureLicenseType = if ($TargetLicenseType -eq "PAYG") { "LicenseIncluded" } else { "BasePrice" } $arcLicenseType = if ($TargetLicenseType -eq "PAYG") { "PAYG" } else { "Paid" } +# === Connect once here instead of letting each embedded script redundantly re-authenticate. +# Az PowerShell's context is process-wide, so authenticating once means each embedded +# script's own Connect-Azure call finds an already-valid context and skips straight past +# its own login, instead of repeating that work. +if ($RunMode -eq "Single") { + if (-not (Get-Module -ListAvailable -Name Az.Accounts)) { + if (-not $Force) { + if (Test-IsInteractiveSession) { + $response = Read-Host "The Az.Accounts PowerShell module is required but not installed. Install it now? (Y/N)" + if ($response -notmatch '^(y|yes)$') { + throw "Az.Accounts module is required to continue. Install it manually, or re-run with -Force to install it automatically." + } + } else { + throw "Az.Accounts module is required but not installed, and this session is not interactive. Re-run with -Force to install it automatically." + } + } + Write-Output "Az.Accounts module not found. Installing..." + Install-Module -Name Az.Accounts -Scope CurrentUser -Repository PSGallery -Force -AllowClobber + } + Import-Module Az.Accounts -Force + + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account -and ([string]::IsNullOrWhiteSpace($TenantId) -or $currentCtx.Tenant.Id -eq $TenantId)) { + Write-Output "Reusing this session ($($currentCtx.Account), tenant $($currentCtx.Tenant.Id))." + if (-not $Force) { + if (Test-IsInteractiveSession) { + $response = Read-Host "Continue using this account/tenant? (Y/N)" + if ($response -notmatch '^(y|yes)$') { + throw "Aborted by user. Sign in as a different account/tenant, or re-run with -Force to skip this confirmation." + } + } else { + throw "Currently connected as $($currentCtx.Account) (tenant $($currentCtx.Tenant.Id)). Re-run with -Force to proceed non-interactively, or run interactively to confirm." + } + } + } + else { + Write-Output "Connecting to Azure..." + if ($TenantId) { Connect-AzAccount -Tenant $TenantId -ErrorAction Stop | Out-Null } + else { Connect-AzAccount -ErrorAction Stop | Out-Null } + $currentCtx = Get-AzContext + } + if ([string]::IsNullOrWhiteSpace($TenantId)) { $TenantId = $currentCtx.Tenant.Id } + + # Signal to the embedded scripts (which run in this same process) that the + # connection for this tenant has already been established/validated, so their + # own Connect-Azure calls can skip repeating the work. + $env:PAYG_PRECONNECTED_TENANT = $TenantId +} + # === Embedded dependency scripts (materialized to disk at runtime; nothing is downloaded) === $EmbeddedScripts = @{} $EmbeddedScripts['Azure'] = @' @@ -158,13 +227,12 @@ $EmbeddedScripts['Azure'] = @' SQL Databases Elastic Pools SQL Instance Pools - DataFactory SSIS Integration Runtimes .VERSION 1.0.0 - Initial version. 1.0.2 - Modified to fix errors and to remove the auto-start of the offline resources. 1.0.3 - Added transcript. - 1.0.4 - Fixed RG filter for SQL DB + 1.0.4 - Fixed resource group filter for SQL DB .PARAMETER SubId A single subscription ID or a CSV file name containing a list of subscriptions. @@ -196,13 +264,12 @@ $EmbeddedScripts['Azure'] = @' .PARAMETER WaitForCompletion Optional. If specified, waits for each update to reach a terminal state before continuing and reports the confirmed outcome ("Updated"). By default the script submits updates with - --no-wait and reports "RequestSubmitted", meaning the service accepted the request rather + -AsJob and reports "RequestSubmitted", meaning the service accepted the request rather than that the change has been applied. - Note: Set-AzDataFactoryV2IntegrationRuntime provides no asynchronous option, so SSIS - integration runtimes always wait regardless of this switch and always report "Updated". - SQL virtual machines are submitted asynchronously through a direct ARM request because - 'az sql vm update' has no --no-wait option; see Invoke-SqlVmLicenseUpdate. + Note: SQL virtual machines are always updated synchronously via Update-AzSqlVM (no + -AsJob/-NoWait) regardless of this switch, because SQL VM license updates must not be + submitted asynchronously; see Invoke-SqlVmLicenseUpdate. #> param ( @@ -297,187 +364,140 @@ function Connect-Azure { } Write-Output "Connected to Azure PowerShell as: $($ctx.Context.Account)" } - - # 3) Sync Azure CLI if available - reuse an existing az CLI session for the same tenant when possible. - if (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)" - } } +# Matches transient network failures observed in practice (e.g. Windows ephemeral +# port exhaustion - WinError 10048 - and generic HttpRequestExceptions/connection +# resets from Az PowerShell). These are environment/network blips, not problems with +# the request itself, so a short retry resolves most of them instead of permanently +# marking an otherwise-valid resource update as "Failed". +$script:TransientErrorPattern = 'socket|10048|underlying connection|connection was closed|forcibly closed|timed? ?out|temporarily unavailable|An error occurred while sending the request|could not be resolved|(? -function Invoke-AzCliLicenseUpdate { +function Invoke-AzCmdletWithRetry { param( - [Parameter(Mandatory = $true)][string[]]$Arguments, + [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock, [Parameter(Mandatory = $true)][string]$Description, - [switch]$SupportsNoWait + [int]$MaxAttempts = 3, + [int]$DelaySeconds = 5 ) - $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 } + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + return & $ScriptBlock + } + catch { + $isTransient = $_.Exception.Message -match $script:TransientErrorPattern + if (-not $isTransient -or $attempt -eq $MaxAttempts) { + throw + } + Write-Warning "Transient network error on attempt $attempt/$MaxAttempts for $Description`: $($_.Exception.Message). Retrying in $DelaySeconds s..." + Start-Sleep -Seconds $DelaySeconds + } } - - # 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. + Runs an Az PowerShell 'Set-AzSql*' license-type update cmdlet (via scriptblock) and reports + whether it actually succeeded. .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. + Replaces the previous Azure-CLI-based updater. The caller's scriptblock invokes the + appropriate Set-AzSql* cmdlet with -ErrorAction Stop (so failures are terminating and + retried/caught here) and, when -SupportsAsJob is set and -WaitForCompletion was not passed + to the script, with -AsJob so a large estate is not processed serially. In that case the + caller records "RequestSubmitted" rather than "Updated", because the service has only + accepted the request at that point; passing -WaitForCompletion to the script omits -AsJob, + so the cmdlet blocks until the operation reaches a terminal state and the outcome is + confirmed before returning. +.PARAMETER SupportsAsJob + Set for cmdlets whose scriptblock conditionally passes -AsJob. Update-AzSqlVM does not use + this switch at all; SQL VMs are always updated synchronously (see Invoke-SqlVmLicenseUpdate). #> -function Invoke-AzCliQuery { +function Invoke-AzLicenseUpdate { param( - [Parameter(Mandatory = $true)][string[]]$Arguments, - [Parameter(Mandatory = $true)][string]$Description + [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock, + [Parameter(Mandatory = $true)][string]$Description, + [switch]$SupportsAsJob ) - $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 } - } + $submittedOnly = [bool]($SupportsAsJob -and -not $WaitForCompletion) - $raw = ($output | Out-String).Trim() - if ([string]::IsNullOrWhiteSpace($raw)) { - return [PSCustomObject]@{ Success = $true; Value = @(); ErrorMessage = "" } + try { + $result = Invoke-AzCmdletWithRetry -Description $Description -ScriptBlock $ScriptBlock + return [PSCustomObject]@{ Success = $true; Result = $result; ErrorMessage = ""; Submitted = $submittedOnly } } - - try { $parsed = $raw | ConvertFrom-Json } catch { - Write-Warning "Unable to parse the response for $Description`: $($_.Exception.Message)" - return [PSCustomObject]@{ Success = $false; Value = @(); ErrorMessage = $_.Exception.Message } + Write-Warning "Failed to update $Description`: $($_.Exception.Message)" + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $_.Exception.Message; Submitted = $false } } - - # Normalise to an array so .Count is meaningful for both single objects and empty results. - return [PSCustomObject]@{ Success = $true; Value = @($parsed); ErrorMessage = "" } } - <# .SYNOPSIS - Updates the license type of a SQL virtual machine, asynchronously by default. + Updates the license type of a SQL virtual machine, always synchronously. .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. + Update-AzSqlVM advertises -NoWait and -AsJob, but SQL VM license updates must not be + submitted asynchronously: VMs cannot reliably run this operation in the background (the + -NoWait/-AsJob switches are also broken in Az.SqlVirtualMachine 2.4.0 - -NoWait forwards the + bound parameter into Get-AzSqlVM, which rejects it, and -AsJob throws a + NullReferenceException), so this function always calls Update-AzSqlVM synchronously and + waits for it to reach a terminal state, regardless of -WaitForCompletion. #> function Invoke-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)" + $result = Invoke-AzCmdletWithRetry -Description "SQL VM '$Name'" -ScriptBlock { + Update-AzSqlVM -Name $Name -ResourceGroupName $ResourceGroup -LicenseType $LicenseType -ErrorAction Stop } + return [PSCustomObject]@{ Success = $true; Result = $result; ErrorMessage = ""; Submitted = $false } + } + catch { + Write-Warning "Failed to update SQL VM '$Name': $($_.Exception.Message)" + return [PSCustomObject]@{ Success = $false; Result = $null; ErrorMessage = $_.Exception.Message; Submitted = $false } + } +} - # 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 +<# +.SYNOPSIS + Tests whether a resource's tags match any of the -ExclusionTags key/value pairs. +.DESCRIPTION + Replaces the JMESPath 'tags. != ' clauses previously embedded in each Azure + CLI --query. $Tags may be a [hashtable] (Az.Sql database/elastic-pool/instance/instance-pool + model objects) or an IDictionary of a single tag (the SQL VM model's singular .Tag property); + either shape is handled the same way. A resource is excluded when ANY exclusion tag key is + present with a matching value, mirroring the previous CLI filter's semantics. +#> +function Test-ExcludedByTags { + param( + [Parameter(Mandatory = $false)]$Tags, + [Parameter(Mandatory = $true)][hashtable]$ExclusionTagTable + ) - $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)" - } + if ($ExclusionTagTable.Keys.Count -eq 0 -or $null -eq $Tags) { + return $false + } - $parsed = $null - if (-not [string]::IsNullOrWhiteSpace($put.Content)) { - try { $parsed = $put.Content | ConvertFrom-Json } catch { $parsed = $put.Content } + foreach ($key in $ExclusionTagTable.Keys) { + if ($Tags.Contains($key) -and $Tags[$key] -eq $ExclusionTagTable[$key]) { + return $true } - - return [PSCustomObject]@{ Success = $true; Result = $parsed; ErrorMessage = ""; Submitted = $true } - } - catch { - Write-Warning "Asynchronous update of SQL VM '$Name' failed ($($_.Exception.Message)). Falling back to the synchronous 'az sql vm update' path." - return Invoke-AzCliLicenseUpdate -Description "SQL VM '$Name'" -Arguments $cliArguments } + return $false } @@ -505,7 +525,6 @@ function Format-ExecutionOutcomeSummary { "Microsoft.Sql/servers/elasticPools" = "SQL Elastic Pools" "Microsoft.Sql/managedInstances" = "SQL Managed Instances" "Microsoft.Sql/instancePools" = "SQL Instance Pools" - "Microsoft.DataFactory/factories/integrationRuntimes" = "SSIS Integration Runtimes" "Microsoft.AzureArcData/SqlServerInstances" = "Arc SQL Server Instances" "Microsoft.HybridCompute/machines/extensions" = "Arc SQL Server (HybridCompute)" "WindowsAgent.SqlServer" = "Arc SQL Server Extension (Windows)" @@ -581,6 +600,11 @@ function Format-ExecutionOutcomeSummary { $finalStatus = @() +# Whether Set-AzSql* license updates below are submitted with -AsJob (fire-and-forget, reported +# as "RequestSubmitted") or run synchronously to a terminal state (reported as "Updated"). SQL +# VMs never use this - see Invoke-SqlVmLicenseUpdate. +$submitAsync = -not $WaitForCompletion + # Convert to hashtable explicitly $tagTable = @{} if($ExclusionTags){ @@ -643,17 +667,31 @@ try { return } -# Ensure Az.DataFactory is available and import it +# Ensure Az.Sql is available and import it (Get-/Set-AzSqlDatabase, Get-/Set-AzSqlInstance, +# Get-/Set-AzSqlElasticPool, Get-/Set-AzSqlInstancePool, Get-/Set-AzSqlServer all live here) try { - if (-not (Get-Module -ListAvailable -Name Az.DataFactory)) { - Write-Output "Az.DataFactory module not found. Installing..." - Install-Module -Name Az.DataFactory -Scope CurrentUser -Force + if (-not (Get-Module -ListAvailable -Name Az.Sql)) { + Write-Output "Az.Sql module not found. Installing..." + Install-Module -Name Az.Sql -Scope CurrentUser -Repository PSGallery -Force } else { - Write-Output "Az.DataFactory module is already installed." + Write-Output "Az.Sql module is already installed." } - Import-Module Az.DataFactory -Force + Import-Module Az.Sql -Force } catch { - Write-Error "Can't import module Az.DataFactory: $_" + Write-Error "Can't import module Az.Sql: $_" +} + +# Ensure Az.SqlVirtualMachine is available and import it (Get-/Update-AzSqlVM) +try { + if (-not (Get-Module -ListAvailable -Name Az.SqlVirtualMachine)) { + Write-Output "Az.SqlVirtualMachine module not found. Installing..." + Install-Module -Name Az.SqlVirtualMachine -Scope CurrentUser -Repository PSGallery -Force + } else { + Write-Output "Az.SqlVirtualMachine module is already installed." + } + Import-Module Az.SqlVirtualMachine -Force +} catch { + Write-Error "Can't import module Az.SqlVirtualMachine: $_" } # Map License Types for SQL VMs: LicenseIncluded -> PAYG, BasePrice -> AHUB. @@ -663,242 +701,226 @@ $SqlVmLicenseType = if ($LicenseType -eq "LicenseIncluded") { "PAYG" } else { "A $modifiedResources = @() # Determine the subscriptions to process: CSV file, single subscription, or all accessible subscriptions. +# Get-AzSubscription occasionally fails with a transient HttpRequestException (e.g. network +# blips outside the usual dev environment). Previously this was a non-terminating error that +# left $subscriptions empty, so the script silently "completed" having scanned zero +# subscriptions instead of surfacing the failure. Retry a few times with increasing backoff +# (observed blips clear up within ~20-30s), then fail loudly. +$subRetryDelays = @(5, 10, 20, 30, 30) if ($SubId -like "*.csv") { $subscriptions = Import-Csv $SubId }elseif($SubId -ne "") { Write-Output "Passed Subscription $($SubId)" - $subscriptions = Get-AzSubscription -SubscriptionId $SubId + $subscriptions = $null + for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { + # -TenantId scopes resolution to the requested tenant only. Without it, Get-AzSubscription + # fans out and tries to acquire a token for *every* tenant the signed-in account belongs + # to (including unrelated guest tenants requiring MFA/conditional access), which is why + # unrelated 'Authentication failed against tenant ...' warnings were showing up even + # though a specific -TenantId was passed to the script. + try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -TenantId $TenantId -ErrorAction Stop; break } + catch { + if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + $delay = $subRetryDelays[$subAttempt - 1] + Write-Warning "Transient error resolving subscription '$SubId' (attempt $subAttempt/$($subRetryDelays.Count)): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + } + } }else { - $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } + $subscriptions = $null + for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { + try { $subscriptions = Get-AzSubscription -TenantId $TenantId -ErrorAction Stop; break } + catch { + if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + $delay = $subRetryDelays[$subAttempt - 1] + Write-Warning "Transient error listing subscriptions for tenant '$tenantId' (attempt $subAttempt/$($subRetryDelays.Count)): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + } + } } -# Build resource group filter if specified. -$rgFilter = if ($ResourceGroup) { "resourceGroup=='$ResourceGroup'" } else { "" } +if (-not $subscriptions -or @($subscriptions).Count -eq 0) { + Write-Error "No subscriptions resolved (SubId='$SubId', TenantId='$tenantId'). Aborting instead of proceeding with zero subscriptions, which would otherwise look like a clean 'nothing to update' run." + exit 1 +} + +# Resource group and tag-based exclusion filtering are applied per resource type via +# Where-Object/Test-ExcludedByTags below instead of a shared JMESPath fragment. $scriptStartTime = Get-Date Write-Output "Our adventure begins at: $scriptStartTime`n" -$tagsFilter = $null -if($tagTable.Keys.Count -gt 0) { - $tagsFilter += " && " - $tagcount = $tagTable.Keys.Count - foreach ($tag in $tagTable.Keys) { - $tagcount-- - $tagsFilter += " tags.$($tag) != '$($tagTable[$tag])' " - if($tagcount -gt 0) { - $tagsFilter += " && " - } - } -} # Process each subscription. foreach ($sub in $subscriptions) { try { Write-Output "===== Entering Subscription: $($sub.name) =====" Write-Output "Switching context to subscription: $($sub.name)" - <#if($SqlVmLicenseType -eq "LicenseIncluded") { - Write-Output "SQL VM License Type: PAYG" - $ArcSQLServerExtensionDeployment = az tag list --resource-id "/subscriptions/$sub.id" --query "properties.tags.ArcSQLServerExtensionDeployment" -o json | ConvertFrom-Json - if ($ArcSQLServerExtensionDeployment -ne "LicenseIncluded") { - Write-Output "SQL VM License Type: PAYG" - az tag update --resource-id /"/subscriptions/$sub.id" --operation merge --tags ArcSQLServerExtensionDeployment=PAYG | Out-Null - } - } else { - Write-Output "SQL VM License Type: AHUB" - }#> Write-Output "License Type: $LicenseType" - az account set --subscription $sub.id - if ($LASTEXITCODE -ne 0) { - # Every az call below is scoped by the CLI's active subscription. If the switch + try { + Invoke-AzCmdletWithRetry -Description "Set-AzContext for subscription $($sub.id)" -ScriptBlock { + Set-AzContext -Subscription $sub.id -ErrorAction Stop | Out-Null + } + } + catch { + # Every cmdlet below is scoped by the current Az PowerShell context. If the switch # fails they would all silently run against whichever subscription was previously # selected, so resources in the wrong subscription could be updated. - Write-Warning "Skipping subscription '$($sub.name)' ($($sub.id)): the Azure CLI context could not be switched to it." + Write-Warning "Skipping subscription '$($sub.name)' ($($sub.id)): the Az PowerShell context could not be switched to it: $($_.Exception.Message)" continue } # --- Section: Update SQL Virtual Machines --- try { Write-Output "Seeking SQL Virtual Machines that require a license update to $SqlVmLicenseType..." - - # Build SQL VM query - $sqlVmQuery = "[?sqlServerLicenseType!='${SqlVmLicenseType}' && sqlServerLicenseType!='DR'" - - # Add resource group filter if specified - if ($rgFilter) { - $sqlVmQuery += " && $rgFilter" + + $sqlVMs = @(Invoke-AzCmdletWithRetry -Description "SQL virtual machines in subscription $($sub.id)" -ScriptBlock { + Get-AzSqlVM -ErrorAction Stop + }) + + # License mismatch, exclude 'DR' (Disaster Recovery secondary replicas, which are + # licensed separately and must not be overwritten), and the optional + # resource-group/name scope. + $sqlVMs = @($sqlVMs | Where-Object { $_.SqlServerLicenseType -ne $SqlVmLicenseType -and $_.SqlServerLicenseType -ne 'DR' }) + if ($ResourceGroup) { + $sqlVMs = @($sqlVMs | Where-Object { ($_.Id -split '/')[4] -eq $ResourceGroup }) } - - # Add name filter if ResourceName specified if ($ResourceName) { - $sqlVmQuery += " && name=='$ResourceName'" + $sqlVMs = @($sqlVMs | Where-Object { $_.Name -eq $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) { + if ($sqlVMs.Count -eq 0) { Write-Output "No SQL VMs found that require a license update." } else { Write-Output "Found $($sqlVMs.Count) SQL VMs that require a license update." } + foreach ($sqlvm in $sqlVMs) { + $vmResourceGroup = ($sqlvm.Id -split '/')[4] + $vmName = $sqlvm.Name - if($null -ne (az vm list --query "[?name=='$($sqlvm.name)' && resourceGroup=='$($sqlvm.resourceGroup)' $tagsFilter]")) - { - $vmStatusQuery = Invoke-AzCliQuery -Description "power state of VM '$($sqlvm.name)'" -Arguments @( - 'vm','get-instance-view','--resource-group',$sqlvm.resourceGroup,'--name',$sqlvm.name, - '--query',"{Name:name, ResourceGroup:resourceGroup, PowerState:instanceView.statuses[?starts_with(code, 'PowerState/')].displayStatus | [0]}",'-o','json') - if (-not $vmStatusQuery.Success) { - # Without a power state the VM would silently fail the "VM running" test - # below and be skipped as though it were switched off. - Write-Warning "Skipping SQL VM '$($sqlvm.name)': its power state could not be read, so it was not assessed. Re-run to retry." - $modifiedResources += [PSCustomObject]@{ - TenantID = $TenantId - SubID = ($sqlvm.id -split '/')[2] - ResourceName = $sqlvm.name - ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" - Status = "UnknownPowerState" - OriginalLicenseType = $sqlvm.sqlServerLicenseType - ResourceGroup = $sqlvm.resourceGroup - Location = $sqlvm.Location - UpdateResult = "Failed" - UpdateError = "Power state could not be read" - } - continue + if (Test-ExcludedByTags -Tags $sqlvm.Tag -ExclusionTagTable $tagTable) { + Write-Output "SQL VM '$vmName' in resource group '$vmResourceGroup' Skipping because of tags..." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.Id -split '/')[2] + ResourceName = $vmName + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = "SkippedTags" + OriginalLicenseType = $sqlvm.SqlServerLicenseType + ResourceGroup = $vmResourceGroup + Location = $sqlvm.Location + UpdateResult = "SkippedTags" + UpdateError = "Excluded by tags filter" } - $vmStatus = $vmStatusQuery.Value | Select-Object -First 1 - if (($vmStatus.PowerState -eq "VM running") -and ($sqlvm.sqlServerLicenseType -ne "DR")) { + continue + } - $vmResult = "NotAttempted" - $vmError = "" + $vmPowerState = $null + try { + $vmPowerState = Invoke-AzCmdletWithRetry -Description "power state of VM '$vmName'" -ScriptBlock { + (Get-AzVM -ResourceGroupName $vmResourceGroup -Name $vmName -Status -ErrorAction Stop).Statuses | + Where-Object { $_.Code -like 'PowerState/*' } | Select-Object -First 1 -ExpandProperty DisplayStatus + } + } + catch { + # Without a power state the VM would silently fail the "VM running" test + # below and be skipped as though it were switched off. + Write-Warning "Skipping SQL VM '$vmName': its power state could not be read, so it was not assessed. Re-run to retry." + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.Id -split '/')[2] + ResourceName = $vmName + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = "UnknownPowerState" + OriginalLicenseType = $sqlvm.SqlServerLicenseType + ResourceGroup = $vmResourceGroup + Location = $sqlvm.Location + UpdateResult = "Failed" + UpdateError = "Power state could not be read" + } + continue + } - 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 } - } + if ($vmPowerState -eq "VM running") { - # 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)" + $vmResult = "NotAttempted" + $vmError = "" + + if ($ReportOnly) { + $vmResult = "ReportOnly" + Write-Output "ReportOnly mode enabled. Skipping modification for SQL VM '$vmName' in resource group '$vmResourceGroup' (would change '$($sqlvm.SqlServerLicenseType)' -> '$SqlVmLicenseType')." + } else { + Write-Output "Updating SQL VM '$vmName' in resource group '$vmResourceGroup' to license type '$SqlVmLicenseType'..." + # Always synchronous - SQL VM license updates must not be submitted via + # -AsJob/-NoWait (see Invoke-SqlVmLicenseUpdate), so this call blocks + # until the update completes regardless of -WaitForCompletion. + $update = Invoke-SqlVmLicenseUpdate -Name $vmName -ResourceGroup $vmResourceGroup -LicenseType $SqlVmLicenseType + if ($update.Success) { + $finalStatus += $update.Result + $vmResult = "Updated" + Write-Output "-- SQL VM '$vmName': $vmResult (license type '$SqlVmLicenseType')" } + else { $vmResult = "Failed"; $vmError = $update.ErrorMessage } } - elseif ($sqlvm.sqlServerLicenseType -eq "DR") { - Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' has license type 'DR'. Skipping update..." - $modifiedResources += [PSCustomObject]@{ - TenantID = $TenantId - SubID = ($sqlvm.id -split '/')[2] - ResourceName = $sqlvm.name - ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" - Status = $vmStatus.PowerState - OriginalLicenseType = $sqlvm.sqlServerLicenseType - ResourceGroup = $sqlvm.resourceGroup - Location = $sqlvm.Location - UpdateResult = "SkippedDR" - UpdateError = "SQL VM has Disaster Recovery ('DR') license type" - } + + # Collect data after the attempt so the recorded outcome is accurate + $modifiedResources += [PSCustomObject]@{ + TenantID = $TenantId + SubID = ($sqlvm.Id -split '/')[2] + ResourceName = $vmName + ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" + Status = $vmPowerState + OriginalLicenseType = $sqlvm.SqlServerLicenseType + ResourceGroup = $vmResourceGroup + Location = $sqlvm.Location + UpdateResult = $vmResult + UpdateError = $vmError + # Cores } } else { - Write-Output "SQL VM '$($sqlvm.name)' in RG '$($sqlvm.resourceGroup)' Skipping because of tags..." + Write-Output "SQL VM '$vmName' in resource group '$vmResourceGroup' is in '$vmPowerState' state (not running). Skipping update..." $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($sqlvm.id -split '/')[2] - ResourceName = $sqlvm.name + SubID = ($sqlvm.Id -split '/')[2] + ResourceName = $vmName ResourceType = "Microsoft.SqlVirtualMachine/sqlVirtualMachines" - Status = "SkippedTags" - OriginalLicenseType = $sqlvm.sqlServerLicenseType - ResourceGroup = $sqlvm.resourceGroup + Status = $vmPowerState + OriginalLicenseType = $sqlvm.SqlServerLicenseType + ResourceGroup = $vmResourceGroup Location = $sqlvm.Location - UpdateResult = "SkippedTags" - UpdateError = "Excluded by tags filter" + UpdateResult = "SkippedNotRunning" + UpdateError = "Underlying VM is in '$vmPowerState' state (must be running to update license)" } } } - if($sqlVmsToUpdate.Count -eq 0) { - Write-Output "No stopped SQL VMs needed to be started for a license update." - } else { - Write-Output "Found $($sqlVmsToUpdate.Count) to Start SQL VMs that require a license update." - } } catch { Write-Error "An error occurred while updating SQL VMs: $_" } - # --- Section: Update SQL Managed Instances (Stopped then Ready) " - $sqlMIsToUpdate = [System.Collections.ArrayList]::new() + # --- Section: Update SQL Managed Instances --- try { - - - # Build Managed Instance query - $miRunningQuery = "[?licenseType!='${LicenseType}' && state=='Ready'" - - # Add resource group filter if specified - if ($rgFilter) { - $miRunningQuery += " && $rgFilter" + $runningMIs = @(Invoke-AzCmdletWithRetry -Description "SQL Managed Instances in subscription $($sub.id)" -ScriptBlock { + Get-AzSqlInstance -ErrorAction Stop + }) + + # Excludes running instances already at the target license type, plus the optional + # resource-group/name scope and tag exclusion. The 'state==Ready' pre-filter is not + # reproduced here: the Az.Sql module's managed-instance model does not expose a + # state/provisioning-state property to check it against, so a managed instance that + # is not actually ready is instead caught by Set-AzSqlInstance failing and being + # recorded as "Failed" below. + $runningMIs = @($runningMIs | Where-Object { $_.LicenseType -ne $LicenseType }) + if ($ResourceGroup) { + $runningMIs = @($runningMIs | Where-Object { $_.ResourceGroupName -eq $ResourceGroup }) } - - # Add name filter if ResourceName specified if ($ResourceName) { - $miRunningQuery += " && name=='$ResourceName'" - } - - # Add tags filter if specified - if ($tagsFilter) { - $miRunningQuery += " $tagsFilter" + $runningMIs = @($runningMIs | Where-Object { $_.ManagedInstanceName -eq $ResourceName }) } + $runningMIs = @($runningMIs | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) - $miRunningQuery += "].{name:name, state:state, resourceGroup:resourceGroup, licenseType:licenseType, location:location, id:id, ResourceType:type}" - - Write-Output "Processing SQL Managed Instances that are running with filter $miRunningQuery..." - $miQueryResult = Invoke-AzCliQuery -Description "SQL Managed Instances" -Arguments @('sql','mi','list','--query',$miRunningQuery,'-o','json') - if (-not $miQueryResult.Success) { - Write-Warning "SQL Managed Instances could not be listed, so none were assessed in this subscription. Re-run to retry." - } - $runningMIs = $miQueryResult.Value + Write-Output "Processing SQL Managed Instances that require a license update..." if($runningMIs.Count -eq 0) { Write-Output "No SQL Managed Instances found that require a license update." } else { @@ -911,15 +933,16 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $miResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL Managed Instance '$($mi.name)' in RG '$($mi.resourceGroup)' (would change '$($mi.licenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Managed Instance '$($mi.ManagedInstanceName)' in resource group '$($mi.ResourceGroupName)' (would change '$($mi.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating SQL Managed Instance '$($mi.name)' in RG '$($mi.resourceGroup)' to license type '$LicenseType'..." - $update = Invoke-AzCliLicenseUpdate -Description "SQL Managed Instance '$($mi.name)'" -SupportsNoWait -Arguments @( - 'sql','mi','update','--name',$mi.name,'--resource-group',$mi.resourceGroup,'--license-type',$LicenseType,'-o','json') + Write-Output "Updating SQL Managed Instance '$($mi.ManagedInstanceName)' in resource group '$($mi.ResourceGroupName)' to license type '$LicenseType'..." + $update = Invoke-AzLicenseUpdate -Description "SQL Managed Instance '$($mi.ManagedInstanceName)'" -SupportsAsJob -ScriptBlock { + Set-AzSqlInstance -Name $mi.ManagedInstanceName -ResourceGroupName $mi.ResourceGroupName -LicenseType $LicenseType -AsJob:$submitAsync -Force -ErrorAction Stop + } if ($update.Success) { $finalStatus += $update.Result $miResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } - Write-Output "-- SQL Managed Instance '$($mi.name)': $miResult (license type '$LicenseType')" + Write-Output "-- SQL Managed Instance '$($mi.ManagedInstanceName)': $miResult (license type '$LicenseType')" } else { $miResult = "Failed"; $miError = $update.ErrorMessage } } @@ -927,13 +950,13 @@ foreach ($sub in $subscriptions) { # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($mi.id -split '/')[2] - ResourceName = $mi.name - ResourceType = $mi.ResourceType - Status = $mi.state - OriginalLicenseType = $mi.licenseType - ResourceGroup = $mi.resourceGroup - Location = $mi.location + SubID = ($mi.Id -split '/')[2] + ResourceName = $mi.ManagedInstanceName + ResourceType = "Microsoft.Sql/managedInstances" + Status = "" + OriginalLicenseType = $mi.LicenseType + ResourceGroup = $mi.ResourceGroupName + Location = $mi.Location UpdateResult = $miResult UpdateError = $miError } @@ -944,86 +967,29 @@ foreach ($sub in $subscriptions) { } # --- Section: Update SQL Databases and Elastic Pools --- - try { - Write-Output "Querying SQL Servers within this subscription..." - - # First, let's verify we're in the right subscription context - $currentSubContext = az account show --query id -o tsv - Write-Output "Currently in subscription context: $currentSubContext" - - if ($currentSubContext -ne $sub.id) { - Write-Output "Subscription context mismatch! Re-setting context..." - az account set --subscription $sub.id - if ($LASTEXITCODE -ne 0) { - Write-Warning "Could not re-select subscription '$($sub.id)'; skipping SQL Server, database and elastic pool processing to avoid querying the wrong subscription." - throw "Subscription context could not be set to '$($sub.id)'." - } - } - - # Build SQL Server query with proper JMESPath syntax - $serverQuery = "" - $filterAdded = $false - - # Start with an empty filter array - if ($rgFilter -or $ResourceName -or $tagsFilter) { - $serverQuery = "[" - - # Add resource group filter if specified - if ($rgFilter) { - $serverQuery += "?$rgFilter" - $filterAdded = $true - } - - # Add name filter if ResourceName is provided - if ($ResourceName) { - if ($filterAdded) { - $serverQuery += " && name=='$ResourceName'" - } else { - $serverQuery += "?name=='$ResourceName'" - $filterAdded = $true - } - } - - # Add tag filter if specified - if ($tagsFilter -and $filterAdded) { - $serverQuery += "$tagsFilter" - } elseif ($tagsFilter) { - $serverQuery += "?type=='Microsoft.Sql/servers'$tagsFilter" # A trick to make the tags filter work when it's the only filter - } - - $serverQuery += "]" - } else { - # No filters, get all servers - $serverQuery = "[]" + Write-Output "Querying SQL servers within this subscription..." + + $allServers = @(Invoke-AzCmdletWithRetry -Description "SQL servers in the subscription" -ScriptBlock { + Get-AzSqlServer -ErrorAction Stop + }) + Write-Output "Found a total of $($allServers.Count) SQL servers in subscription" + + $servers = $allServers + if ($ResourceGroup) { + $servers = @($servers | Where-Object { $_.ResourceGroupName -eq $ResourceGroup }) } - - # Output the query for debugging - Write-Output "SQL Server query: $serverQuery" - - # Get all servers first as a fallback in case the query fails - $allServersQuery = Invoke-AzCliQuery -Description "SQL Servers in the subscription" -Arguments @('sql','server','list','-o','json') - $allServers = $allServersQuery.Value - Write-Output "Found a total of $($allServers.Count) SQL Servers in subscription" - - # Now try the filtered query - $serversQuery = Invoke-AzCliQuery -Description "SQL Servers matching the specified filters" -Arguments @('sql','server','list','--query',"$serverQuery",'-o','json') - if (-not $serversQuery.Success) { - # Distinguish a failed lookup from a genuinely empty one: falling through here - # would print "No SQL Servers found" and skip every database and elastic pool - # in the subscription as though there were nothing to do. - Write-Warning "SQL Servers could not be listed, so no databases or elastic pools were assessed in this subscription. Re-run to retry." - $servers = @() - } else { - $servers = $serversQuery.Value + if ($ResourceName) { + $servers = @($servers | Where-Object { $_.ServerName -eq $ResourceName }) } - + $servers = @($servers | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) + # Verify if we got any results - if ($null -eq $servers -or $servers.Count -eq 0) { - Write-Output "WARNING: No SQL Servers found with the specified filters." - Write-Output "Available SQL Servers in subscription:" + if ($servers.Count -eq 0) { + Write-Output "WARNING: No SQL servers found with the specified filters." + Write-Output "Available SQL servers in subscription:" $allServers | ForEach-Object { - Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + Write-Output " - $($_.ServerName) (Resource Group: $($_.ResourceGroupName))" } # Only fall back to scanning every server in the subscription when the @@ -1033,69 +999,41 @@ foreach ($sub in $subscriptions) { # resource-group filtered, so pools on out-of-scope servers would be # modified. if (-not $ResourceName -and -not $ResourceGroup) { - Write-Output "Proceeding with all SQL Servers since no specific ResourceName or ResourceGroup was provided." + Write-Output "Proceeding with all SQL servers since no specific ResourceName or ResourceGroup was provided." $servers = $allServers } else { - Write-Output "Scope was explicitly restricted; not falling back to all SQL Servers. Skipping SQL Database and Elastic Pool processing." + Write-Output "Scope was explicitly restricted; not falling back to all SQL servers. Skipping SQL Database and Elastic Pool processing." $servers = @() } } else { - Write-Output "Found $($servers.Count) SQL Servers matching the criteria." + Write-Output "Found $($servers.Count) SQL servers matching the criteria." $servers | ForEach-Object { - Write-Output " - $($_.name) (Resource Group: $($_.resourceGroup))" + Write-Output " - $($_.ServerName) (Resource Group: $($_.ResourceGroupName))" } } # Process each server foreach ($server in $servers) { # Update SQL Databases - Write-Output "Scanning SQL Databases on server '$($server.name)' in resource group '$($server.resourceGroup)'..." - - # First get all databases to check if any exist - $allDbsQuery = Invoke-AzCliQuery -Description "databases on server '$($server.name)'" -Arguments @( - 'sql','db','list','--resource-group',$server.resourceGroup,'--server',$server.name,'-o','json') - if (-not $allDbsQuery.Success) { - Write-Warning "Skipping server '$($server.name)': its databases could not be listed, so they cannot be assessed. Re-run to retry." - continue - } - $allDbs = $allDbsQuery.Value - Write-Output "Found a total of $($allDbs.Count) databases on server '$($server.name)'" - - # Build database query with better error handling - $dbQuery = "[?licenseType!=null && licenseType!='$($LicenseType)'" - - # Add tags filter if specified - if ($tagsFilter) { - $dbQuery += "$tagsFilter" - } - if ($rgFilter) { - $dbQuery += " && $rgFilter" - } - - $dbQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:status}" - - Write-Output "Database query: $dbQuery" - - # Get databases with error handling + Write-Output "Scanning SQL Databases on server '$($server.ServerName)' in resource group '$($server.ResourceGroupName)'..." + try { - $dbsQuery = Invoke-AzCliQuery -Description "databases requiring an update on server '$($server.name)'" -Arguments @( - 'sql','db','list','--resource-group',$server.resourceGroup,'--server',$server.name,'--query',"$dbQuery",'-o','json') - if (-not $dbsQuery.Success) { - Write-Warning "Skipping server '$($server.name)': its databases could not be assessed for a license update. Re-run to retry." - continue - } - $dbs = $dbsQuery.Value - - if ($null -eq $dbs) { - Write-Output "No SQL Databases found on Server $($server.name) that require a license update." - } elseif ($dbs.Count -eq 0) { - Write-Output "No SQL Databases found on Server $($server.name) that require a license update." + $allDbs = @(Invoke-AzCmdletWithRetry -Description "databases on server '$($server.ServerName)'" -ScriptBlock { + Get-AzSqlDatabase -ResourceGroupName $server.ResourceGroupName -ServerName $server.ServerName -ErrorAction Stop + }) + Write-Output "Found a total of $($allDbs.Count) databases on server '$($server.ServerName)'" + + $dbs = @($allDbs | Where-Object { $null -ne $_.LicenseType -and $_.LicenseType -ne $LicenseType }) + $dbs = @($dbs | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) + + if ($dbs.Count -eq 0) { + Write-Output "No SQL Databases found on Server $($server.ServerName) that require a license update." } else { - Write-Output "Found $($dbs.Count) SQL Databases on Server $($server.name) that require a license update:" + Write-Output "Found $($dbs.Count) SQL Databases on Server $($server.ServerName) that require a license update:" $dbs | ForEach-Object { - Write-Output " - $($_.name) (Current license: $($_.licenseType))" + Write-Output " - $($_.DatabaseName) (Current license: $($_.LicenseType))" } - + foreach ($db in $dbs) { $dbResult = "NotAttempted" @@ -1103,15 +1041,16 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $dbResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL Database '$($db.name)' on server '$($server.name)' (would change '$($db.licenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Database '$($db.DatabaseName)' on server '$($server.ServerName)' (would change '$($db.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating SQL Database '$($db.name)' on server '$($server.name)' to license type '$LicenseType'..." - $update = Invoke-AzCliLicenseUpdate -Description "SQL Database '$($db.name)' on server '$($server.name)'" -SupportsNoWait -Arguments @( - 'sql','db','update','--name',$db.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'-o','json') + Write-Output "Updating SQL Database '$($db.DatabaseName)' on server '$($server.ServerName)' to license type '$LicenseType'..." + $update = Invoke-AzLicenseUpdate -Description "SQL Database '$($db.DatabaseName)' on server '$($server.ServerName)'" -SupportsAsJob -ScriptBlock { + Set-AzSqlDatabase -ResourceGroupName $server.ResourceGroupName -ServerName $server.ServerName -DatabaseName $db.DatabaseName -LicenseType $LicenseType -AsJob:$submitAsync -ErrorAction Stop + } if ($update.Success) { $finalStatus += $update.Result $dbResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } - Write-Output "-- SQL Database '$($db.name)': $dbResult (license type '$LicenseType')" + Write-Output "-- SQL Database '$($db.DatabaseName)': $dbResult (license type '$LicenseType')" } else { $dbResult = "Failed"; $dbError = $update.ErrorMessage } } @@ -1119,68 +1058,46 @@ foreach ($sub in $subscriptions) { # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($db.id -split '/')[2] - ResourceName = $db.name - ResourceType = $db.ResourceType - Status = $db.State - OriginalLicenseType = $db.licenseType - ResourceGroup = $db.resourceGroup - Location = $db.location + SubID = $sub.id + ResourceName = $db.DatabaseName + ResourceType = "Microsoft.Sql/servers/databases" + Status = $db.Status + OriginalLicenseType = $db.LicenseType + ResourceGroup = $db.ResourceGroupName + Location = $db.Location UpdateResult = $dbResult UpdateError = $dbError } } } } catch { - Write-Output "Error querying databases on server '$($server.name)': $_" + Write-Warning "Error querying databases on server '$($server.ServerName)': $_" } - # Update Elastic Pools with similar improved error handling + # Update Elastic Pools try { - Write-Output "Scanning Elastic Pools on server '$($server.name)'..." - - # First check if there are any elastic pools - $allPoolsQuery = Invoke-AzCliQuery -Description "elastic pools on server '$($server.name)'" -Arguments @( - 'sql','elastic-pool','list','--resource-group',$server.resourceGroup,'--server',$server.name,'--only-show-errors','-o','json') - if (-not $allPoolsQuery.Success) { - Write-Warning "Elastic pools on server '$($server.name)' could not be listed and were not assessed. Re-run to retry." - $allPools = @() - } else { - $allPools = $allPoolsQuery.Value - } - - if ($null -eq $allPools -or $allPools.Count -eq 0) { - Write-Output "No Elastic Pools found on server '$($server.name)'." + Write-Output "Scanning Elastic Pools on server '$($server.ServerName)'..." + + $allPools = @(Invoke-AzCmdletWithRetry -Description "elastic pools on server '$($server.ServerName)'" -ScriptBlock { + Get-AzSqlElasticPool -ResourceGroupName $server.ResourceGroupName -ServerName $server.ServerName -ErrorAction Stop + }) + + if ($allPools.Count -eq 0) { + Write-Output "No Elastic Pools found on server '$($server.ServerName)'." } else { - Write-Output "Found $($allPools.Count) total Elastic Pools on server '$($server.name)'." - - # Build elastic pool query with better formatting - $elasticPoolQuery = "[?licenseType!=null && licenseType!='$($LicenseType)'" - - # Add tags filter if specified - if ($tagsFilter) { - $elasticPoolQuery += " $tagsFilter" - } - - $elasticPoolQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:state}" - - Write-Output "Elastic Pool query: $elasticPoolQuery" - - $elasticPoolsQueryResult = Invoke-AzCliQuery -Description "elastic pools requiring an update on server '$($server.name)'" -Arguments @( - 'sql','elastic-pool','list','--resource-group',$server.resourceGroup,'--server',$server.name,'--query',"$elasticPoolQuery",'--only-show-errors','-o','json') - if (-not $elasticPoolsQueryResult.Success) { - Write-Warning "Elastic pools on server '$($server.name)' could not be assessed for a license update. Re-run to retry." - } - $elasticPools = $elasticPoolsQueryResult.Value - - if ($null -eq $elasticPools -or $elasticPools.Count -eq 0) { - Write-Output "No Elastic Pools found on Server $($server.name) that require a license update." + Write-Output "Found $($allPools.Count) total Elastic Pools on server '$($server.ServerName)'." + + $elasticPools = @($allPools | Where-Object { $null -ne $_.LicenseType -and $_.LicenseType -ne $LicenseType }) + $elasticPools = @($elasticPools | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) + + if ($elasticPools.Count -eq 0) { + Write-Output "No Elastic Pools found on Server $($server.ServerName) that require a license update." } else { - Write-Output "Found $($elasticPools.Count) Elastic Pools on Server $($server.name) that require a license update:" + Write-Output "Found $($elasticPools.Count) Elastic Pools on Server $($server.ServerName) that require a license update:" $elasticPools | ForEach-Object { - Write-Output " - $($_.name) (Current license: $($_.licenseType))" + Write-Output " - $($_.ElasticPoolName) (Current license: $($_.LicenseType))" } - + foreach ($pool in $elasticPools) { $poolResult = "NotAttempted" @@ -1188,15 +1105,16 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $poolResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for Elastic Pool '$($pool.name)' on server '$($server.name)' (would change '$($pool.licenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for Elastic Pool '$($pool.ElasticPoolName)' on server '$($server.ServerName)' (would change '$($pool.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating Elastic Pool '$($pool.name)' on server '$($server.name)' to license type '$LicenseType'..." - $update = Invoke-AzCliLicenseUpdate -Description "Elastic Pool '$($pool.name)' on server '$($server.name)'" -SupportsNoWait -Arguments @( - 'sql','elastic-pool','update','--name',$pool.name,'--server',$server.name,'--resource-group',$server.resourceGroup,'--set',"licenseType=$LicenseType",'--only-show-errors','-o','json') + Write-Output "Updating Elastic Pool '$($pool.ElasticPoolName)' on server '$($server.ServerName)' to license type '$LicenseType'..." + $update = Invoke-AzLicenseUpdate -Description "Elastic Pool '$($pool.ElasticPoolName)' on server '$($server.ServerName)'" -SupportsAsJob -ScriptBlock { + Set-AzSqlElasticPool -ResourceGroupName $server.ResourceGroupName -ServerName $server.ServerName -ElasticPoolName $pool.ElasticPoolName -LicenseType $LicenseType -AsJob:$submitAsync -ErrorAction Stop + } if ($update.Success) { $finalStatus += $update.Result $poolResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } - Write-Output "-- Elastic Pool '$($pool.name)': $poolResult (license type '$LicenseType')" + Write-Output "-- Elastic Pool '$($pool.ElasticPoolName)': $poolResult (license type '$LicenseType')" } else { $poolResult = "Failed"; $poolError = $update.ErrorMessage } } @@ -1204,13 +1122,13 @@ foreach ($sub in $subscriptions) { # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($pool.id -split '/')[2] - ResourceName = $pool.name - ResourceType = $pool.ResourceType + SubID = $sub.id + ResourceName = $pool.ElasticPoolName + ResourceType = "Microsoft.Sql/servers/elasticPools" Status = $pool.State - OriginalLicenseType = $pool.licenseType - ResourceGroup = $pool.resourceGroup - Location = $pool.location + OriginalLicenseType = $pool.LicenseType + ResourceGroup = $pool.ResourceGroupName + Location = $pool.Location UpdateResult = $poolResult UpdateError = $poolError } @@ -1218,44 +1136,37 @@ foreach ($sub in $subscriptions) { } } } catch { - Write-Output "Error processing Elastic Pools on server '$($server.name)': $_" + Write-Warning "Error processing Elastic Pools on server '$($server.ServerName)': $_" } } } catch { - Write-Output "An error occurred while processing SQL Databases or Elastic Pools: $_" + Write-Error "An error occurred while processing SQL Databases or Elastic Pools: $_" } # --- Section: Update SQL Instance Pools --- try { Write-Output "Searching for SQL Instance Pools that require a license update..." - - # Build instance pool query (skip the passive replicas) - $instancePoolsQuery = "[?licenseType!='${LicenseType}' && state=='Ready'" - - # Add resource group filter if specified - if ($rgFilter) { - $instancePoolsQuery += " && $rgFilter" + + $instancePools = @(Invoke-AzCmdletWithRetry -Description "SQL instance pools in subscription $($sub.id)" -ScriptBlock { + Get-AzSqlInstancePool -ErrorAction Stop + }) + + # Excludes pools already at the target license type, plus the optional + # resource-group/name scope and tag exclusion. The 'state==Ready' pre-filter is not + # reproduced here: the Az.Sql module's instance-pool model does not expose a + # state/provisioning-state property to check it against, so an instance pool that is + # not actually ready is instead caught by Set-AzSqlInstancePool failing and being + # recorded as "Failed" below. + $poolsToUpdate = @($instancePools | Where-Object { $_.LicenseType -ne $LicenseType }) + if ($ResourceGroup) { + $poolsToUpdate = @($poolsToUpdate | Where-Object { $_.ResourceGroupName -eq $ResourceGroup }) } - - # Add name filter if ResourceName specified if ($ResourceName) { - $instancePoolsQuery += " && name=='$ResourceName'" - } - - # Add tags filter if specified - if ($tagsFilter) { - $instancePoolsQuery += " $tagsFilter" - } - - $instancePoolsQuery += "].{name:name, licenseType:licenseType, location:location, resourceGroup:resourceGroup, id:id, ResourceType:type, State:status}" - - $instancePoolsQueryResult = Invoke-AzCliQuery -Description "SQL instance pools" -Arguments @('sql','instance-pool','list','--query',$instancePoolsQuery,'-o','json') - if (-not $instancePoolsQueryResult.Success) { - Write-Warning "SQL instance pools could not be listed, so none were assessed in this subscription. Re-run to retry." + $poolsToUpdate = @($poolsToUpdate | Where-Object { $_.Name -eq $ResourceName }) } - $instancePools = $instancePoolsQueryResult.Value - $poolsToUpdate = $instancePools | Where-Object { $_.licenseType -ne $LicenseType } - if($poolsToUpdate.Count -eq 0) { + $poolsToUpdate = @($poolsToUpdate | Where-Object { -not (Test-ExcludedByTags -Tags $_.Tags -ExclusionTagTable $tagTable) }) + + if ($poolsToUpdate.Count -eq 0) { Write-Output "No SQL Instance Pools found that require a license update." } else { Write-Output "Found $($poolsToUpdate.Count) SQL Instance Pools that require a license update." @@ -1267,15 +1178,16 @@ foreach ($sub in $subscriptions) { if ($ReportOnly) { $ipResult = "ReportOnly" - Write-Output "ReportOnly mode enabled. Skipping modification for SQL Instance Pool '$($pool.name)' in RG '$($pool.resourceGroup)' (would change '$($pool.licenseType)' -> '$LicenseType')." + Write-Output "ReportOnly mode enabled. Skipping modification for SQL Instance Pool '$($pool.Name)' in resource group '$($pool.ResourceGroupName)' (would change '$($pool.LicenseType)' -> '$LicenseType')." } else { - Write-Output "Updating SQL Instance Pool '$($pool.name)' in RG '$($pool.resourceGroup)' to license type '$LicenseType'..." - $update = Invoke-AzCliLicenseUpdate -Description "SQL Instance Pool '$($pool.name)'" -SupportsNoWait -Arguments @( - 'sql','instance-pool','update','--name',$pool.name,'--resource-group',$pool.resourceGroup,'--license-type',$LicenseType,'-o','json') + Write-Output "Updating SQL Instance Pool '$($pool.Name)' in resource group '$($pool.ResourceGroupName)' to license type '$LicenseType'..." + $update = Invoke-AzLicenseUpdate -Description "SQL Instance Pool '$($pool.Name)'" -SupportsAsJob -ScriptBlock { + Set-AzSqlInstancePool -Name $pool.Name -ResourceGroupName $pool.ResourceGroupName -LicenseType $LicenseType -AsJob:$submitAsync -ErrorAction Stop + } if ($update.Success) { $finalStatus += $update.Result $ipResult = if ($update.Submitted) { "RequestSubmitted" } else { "Updated" } - Write-Output "-- SQL Instance Pool '$($pool.name)': $ipResult (license type '$LicenseType')" + Write-Output "-- SQL Instance Pool '$($pool.Name)': $ipResult (license type '$LicenseType')" } else { $ipResult = "Failed"; $ipError = $update.ErrorMessage } } @@ -1283,13 +1195,13 @@ foreach ($sub in $subscriptions) { # Collect data after the attempt so the recorded outcome is accurate $modifiedResources += [PSCustomObject]@{ TenantID = $TenantId - SubID = ($pool.id -split '/')[2] - ResourceName = $pool.name - ResourceType = $pool.ResourceType - Status = $pool.State - OriginalLicenseType = $pool.licenseType - ResourceGroup = $pool.resourceGroup - Location = $pool.location + SubID = $sub.id + ResourceName = $pool.Name + ResourceType = "Microsoft.Sql/instancePools" + Status = "" + OriginalLicenseType = $pool.LicenseType + ResourceGroup = $pool.ResourceGroupName + Location = $pool.Location UpdateResult = $ipResult UpdateError = $ipError } @@ -1299,83 +1211,6 @@ foreach ($sub in $subscriptions) { Write-Error "An error occurred while updating SQL Instance Pools: $_" } - # --- Section: Update DataFactory SSIS Integration Runtimes --- - try { - Write-Output "Processing DataFactory SSIS Integration Runtime resources..." - 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)': $_" @@ -1680,7 +1515,6 @@ function Format-ExecutionOutcomeSummary { "Microsoft.Sql/servers/elasticPools" = "SQL Elastic Pools" "Microsoft.Sql/managedInstances" = "SQL Managed Instances" "Microsoft.Sql/instancePools" = "SQL Instance Pools" - "Microsoft.DataFactory/factories/integrationRuntimes" = "SSIS Integration Runtimes" "Microsoft.AzureArcData/SqlServerInstances" = "Arc SQL Server Instances" "Microsoft.HybridCompute/machines/extensions" = "Arc SQL Server (HybridCompute)" "WindowsAgent.SqlServer" = "Arc SQL Server Extension (Windows)" @@ -1855,35 +1689,71 @@ if (-not $TenantId) { } -# Ensure the required modules are imported - -try{ - Import-Module Az.Accounts -}catch{ - Write-Output "Can't import module Az.Accounts" -} -try{ - Import-Module Az.ConnectedMachine -} -catch{ - Write-Output "Can't import module Az.ConnectedMachine" -} -try{ - Import-Module Az.ResourceGraph -} -catch{ - Write-Output "Can't import module Az.ResourceGraph" +# Ensure the required modules are installed and imported. These are hard +# dependencies (Search-AzGraph / Get-AzConnectedMachine are used later), so a +# missing module must install itself on demand and any failure must stop the +# script here with an actionable message instead of surfacing as a confusing +# "term not recognized" error deep inside the resource-scanning logic. +foreach ($requiredModule in @('Az.Accounts', 'Az.ConnectedMachine', 'Az.ResourceGraph')) { + try { + if (-not (Get-Module -ListAvailable -Name $requiredModule)) { + Write-Output "$requiredModule module not found. Installing..." + Install-Module -Name $requiredModule -Scope CurrentUser -Repository PSGallery -Force -AllowClobber -ErrorAction Stop + } + else { + Write-Output "$requiredModule module is already installed." + } + Import-Module $requiredModule -Force -ErrorAction Stop + } + catch { + Write-Error "Required module '$requiredModule' could not be installed/imported: $_. Install it manually with 'Install-Module -Name $requiredModule -Scope CurrentUser' and re-run this script." + exit 1 + } } $modifiedResources = @() +# Get-AzSubscription occasionally fails with a transient HttpRequestException (e.g. network +# blips outside the usual dev environment). Previously this was a non-terminating error that +# left $subscriptions empty, so the script silently "completed" having scanned zero +# subscriptions instead of surfacing the failure. Retry a few times with increasing backoff +# (observed blips clear up within ~20-30s), then fail loudly. +$subRetryDelays = @(5, 10, 20, 30, 30) if ($SubId -like "*.csv") { $subscriptions = Import-Csv $SubId }elseif($SubId -ne "") { Write-Output "Passed Subscription $($SubId)" - $subscriptions = Get-AzSubscription -SubscriptionId $SubId + $subscriptions = $null + for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { + # -TenantId scopes resolution to the requested tenant only. Without it, Get-AzSubscription + # fans out and tries to acquire a token for *every* tenant the signed-in account belongs + # to (including unrelated guest tenants requiring MFA/conditional access), which is why + # unrelated 'Authentication failed against tenant ...' warnings were showing up even + # though a specific -TenantId was passed to the script. + try { $subscriptions = Get-AzSubscription -SubscriptionId $SubId -TenantId $TenantId -ErrorAction Stop; break } + catch { + if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to resolve subscription '$SubId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + $delay = $subRetryDelays[$subAttempt - 1] + Write-Warning "Transient error resolving subscription '$SubId' (attempt $subAttempt/$($subRetryDelays.Count)): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + } + } }else { - $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $tenantId } + $subscriptions = $null + for ($subAttempt = 1; $subAttempt -le $subRetryDelays.Count; $subAttempt++) { + try { $subscriptions = Get-AzSubscription -TenantId $TenantId -ErrorAction Stop; break } + catch { + if ($subAttempt -eq $subRetryDelays.Count) { Write-Error "Failed to list subscriptions for tenant '$tenantId' after $subAttempt attempts: $($_.Exception.Message)"; exit 1 } + $delay = $subRetryDelays[$subAttempt - 1] + Write-Warning "Transient error listing subscriptions for tenant '$tenantId' (attempt $subAttempt/$($subRetryDelays.Count)): $($_.Exception.Message). Retrying in ${delay}s..." + Start-Sleep -Seconds $delay + } + } +} + +if (-not $subscriptions -or @($subscriptions).Count -eq 0) { + Write-Error "No subscriptions resolved (SubId='$SubId', TenantId='$tenantId'). Aborting instead of proceeding with zero subscriptions, which would otherwise look like a clean 'nothing to update' run." + exit 1 } # Handle MachineName input (single or CSV) @@ -1961,8 +1831,10 @@ foreach ($sub in $subscriptions) { $allResults = [System.Collections.Generic.List[PSObject]]::new() do{ - $resources = Search-AzGraph -Query "$($query)" -First $batchSize -SkipToken $skipToken - $allResults.AddRange($resources) + $resources = Search-AzGraph -Query "$($query)" -First $batchSize -SkipToken $skipToken -ErrorAction Stop + if ($resources) { + $allResults.AddRange($resources) + } $skipToken = $resources.SkipToken }while($skipToken) @@ -2245,7 +2117,7 @@ $EmbeddedScripts['General'] = @' The Automation account name. .PARAMETER Location - Azure region for the RG and account (e.g. "EastUS"). + Azure region for the resource group and account (e.g. "EastUS"). .PARAMETER RunbookName The name under which to import/publish the runbook. @@ -2287,7 +2159,6 @@ $context = $null $roleAssignments = @( @{ RoleName = "SQL DB Contributor"; Description = "For Azure SQL Databases and Azure SQL Elastic Pools" }, @{ RoleName = "SQL Managed Instance Contributor"; Description = "For Azure SQL Managed Instances and Azure SQL Instance Pools" }, - @{ RoleName = "Data Factory Contributor"; Description = "For Azure Data Factory SSIS Integration Runtimes" }, @{ RoleName = "Virtual Machine Contributor"; Description = "For SQL Servers in Azure Virtual Machines" }, @{RoleName = "SQL Server Contributor"; Description = "For Elastic-Pools in Azure Virtual Machines"}, @{RoleName = "Azure Connected Machine Resource Administrator"; Description = "For SQL Servers in Arc Virtual Machines"}, @@ -2316,20 +2187,6 @@ function Connect-Azure { catch { Write-Error "An error occurred while testing the Azure connection: $_" } - # Ensure the user is logged in to Azure - try { - $account = az account show 2>$null | ConvertFrom-Json - if ($account) { - Write-Output "Logged in as: $($account.user.name)" - } - } catch { - Write-Output "Not logged in. Run 'az login'." - if($UseManageIdentity){ - az login --Identity | Out-Null - } else { - az login | Out-Null - } - } } function LoadAzModules { param( @@ -2549,7 +2406,6 @@ function Format-ExecutionOutcomeSummary { "Microsoft.Sql/servers/elasticPools" = "SQL Elastic Pools" "Microsoft.Sql/managedInstances" = "SQL Managed Instances" "Microsoft.Sql/instancePools" = "SQL Instance Pools" - "Microsoft.DataFactory/factories/integrationRuntimes" = "SSIS Integration Runtimes" "Microsoft.AzureArcData/SqlServerInstances" = "Arc SQL Server Instances" "Microsoft.HybridCompute/machines/extensions" = "Arc SQL Server (HybridCompute)" "WindowsAgent.SqlServer" = "Arc SQL Server Extension (Windows)"