diff --git a/samples/manage/azure-sql-vm/modify-license-type/README.md b/samples/manage/azure-sql-vm/modify-license-type/README.md new file mode 100644 index 000000000..1c04edc2d --- /dev/null +++ b/samples/manage/azure-sql-vm/modify-license-type/README.md @@ -0,0 +1,283 @@ +--- +services: Azure SQL Virtual Machines +platforms: Azure +author: qingquanxu +ms.date: 06/11/2026 +--- + +# About this sample + +- **Applies to:** Azure SQL Virtual Machines (`Microsoft.SqlVirtualMachine/sqlVirtualMachines`) +- **Workload:** n/a +- **Programming Language:** PowerShell +- **Authors:** Qingquan Xu +- **Update history:** + + 06/11/2026 - initial version + + 06/11/2026 - added detection of the four prerequisites shown on the Azure portal SQL Server configuration page (SqlIaaSAgent extension version >= 2.0.227.1, system-assigned managed identity, Microsoft.AzureArcData RP registration, SQL Management mode = Full). Results appear in the transcript and as new CSV columns; they are informational only and do not block license / ESU updates. + + 06/11/2026 - emit Azure portal deep-links for any unmet prerequisite (recorded in CSV only). Added opt-in `-FixManagedIdentity`, `-FixArcDataRp`, `-FixManagementMode` switches that remediate the safe-to-automate prerequisites in bulk. + +# Overview + +This script provides a scaleable solution to set or change the SQL Server license type and/or enable or disable the ESU policy on Azure SQL VMs in a specified scope. + +For every in-scope VM the script also detects (read-only) the four prerequisites surfaced by the Azure portal SQL Server configuration page: + +1. `SqlIaaSAgent` extension version >= **2.0.227.1** +2. The compute VM has a **system-assigned managed identity** +3. **`Microsoft.AzureArcData`** resource provider is **Registered** on the subscription +4. SqlIaaSAgent **SQL Management mode** is `Full` + +For every unmet prerequisite the script records an **Azure portal deep-link** in the CSV report (the same blade the portal hyperlinks open), so an operator can click through and remediate exactly like they would from the portal. Three opt-in switches (`-FixManagedIdentity`, `-FixArcDataRp`, `-FixManagementMode`) let the script remediate the safe-to-automate prerequisites in bulk. The SqlIaaSAgent **extension version** is intentionally not remediated by the script because the extension auto-upgrades itself on the next operation that touches it (e.g. a license type or ESU change made by this script will already trigger an upgrade where one is needed). + +Prerequisite detection is **informational only** — it never blocks the license or ESU updates. + +You can specify a single subscription to scan, or provide a list of subscriptions as a `.csv` file. If not specified, all subscriptions your role has access to are scanned. + +The script is the Azure SQL VM counterpart of [`modify-arc-sql-license-type.ps1`](../../azure-arc-enabled-sql-server/modify-license-type/modify-arc-sql-license-type.ps1) (which targets Arc-enabled SQL Servers). It uses the same parameter conventions and reporting shape, adapted to the Azure SQL VM resource model. + +# Prerequisites + +- PowerShell **7.0+**. +- The following Az PowerShell modules: `Az.Accounts`, `Az.SqlVirtualMachine`, `Az.Compute`, `Az.ResourceGraph`. +- You must have at least the *SQL Virtual Machine Contributor* and *Virtual Machine Contributor* roles (or Contributor) on each subscription/resource you modify. +- The `SqlIaaSAgent` extension (publisher `Microsoft.SqlServer.Management`, version `2.0`) must be installed on the target VM (this is the default for any Azure SQL VM created via the Azure portal/CLI). +- You must be connected to Microsoft Entra ID and logged in to your Azure account. If your account has access to multiple tenants, make sure to log in with a specific tenant id. + +```powershell +Install-Module Az.Accounts, Az.SqlVirtualMachine, Az.Compute, Az.ResourceGraph -Scope CurrentUser +``` + +# Launching the script + +The script accepts the following command line parameters: + +| **Parameter**                               | **Value**                                   | **Description** | +|:--|:--|:--| +|`-SubId`|`subscription_id` *or* `file_name`|*Optional*: Subscription id or a `.csv` file with the list of subscriptions1. If not specified, all subscriptions are scanned.| +|`-ResourceGroup`|`resource_group_name`|*Optional*: Limits the scope to a specific resource group.| +|`-VMName`|`vm_name` *or* `file_name`|*Optional*: A single Azure SQL VM name or a `.csv` file with a list of VM names2.| +|`-LicenseType`|`PAYG`, `AHUB`, or `DR`|*Optional*: Sets `sqlServerLicenseType` to the specified value via `Update-AzSqlVM`.| +|`-EnableESU`|`Yes`, `No`|*Optional*: Enables the ESU policy if the value is `Yes` or disables it if the value is `No`. To enable, the license type must be `PAYG` or `AHUB`.| +|`-Force`||*Optional*: Forces the change of the license type to the specified value on all matching resources. Without `-Force`, the value is only set where it is currently undefined. Ignored when `-LicenseType` is not specified.| +|`-ExclusionTags`|`'{"tag1":"value1","tag2":"value2"}'`|*Optional*: If specified, excludes the resources that have any of these tags assigned.| +|`-TenantId`|`tenant_id`|*Optional*: If specified, uses this tenant id to log in. Otherwise, the current context is used.| +|`-ReportOnly`||*Optional*: If specified, generates a CSV file with the list of resources that would be modified, but does not make the actual change.| +|`-UseManagedIdentity`||*Optional*: If specified, logs in using managed identity. Required to run the script as a runbook.| +|`-FixManagedIdentity`||*Optional*: If specified (and `-ReportOnly` is not), enables a system-assigned managed identity on any VM where it is missing. Additive — existing user-assigned identities are preserved.| +|`-FixArcDataRp`||*Optional*: If specified (and `-ReportOnly` is not), registers the `Microsoft.AzureArcData` resource provider on any in-scope subscription where it is not yet `Registered`.| +|`-FixManagementMode`||*Optional*: If specified (and `-ReportOnly` is not), upgrades the SqlIaaSAgent **SQL Management mode** to `Full` for VMs where it is not already Full. Note: this re-runs the extension handler and can take several minutes per VM.| +|`-BatchSize`|`int` (default 500)|*Optional*: Page size for `Search-AzGraph`.| + +1The subscription `.csv` file must include a column **SubscriptionId**. E.g.: + +``` +"SubscriptionId" +"00000000-0000-0000-0000-000000000001" +"00000000-0000-0000-0000-000000000002" +``` + +You can generate a `.csv` file that lists only specific subscriptions. For example, the following command includes only production subscriptions (excluding dev/test): + +```powershell +$tenantId = "" +Get-AzSubscription -TenantId $tenantId | Where-Object { + $sub = $_ + $details = Get-AzSubscription -SubscriptionId $sub.Id -TenantId $tenantId + if ($details -and $details.ExtendedProperties -and $details.ExtendedProperties.SubscriptionPolices) { + $quotaId = ($details.ExtendedProperties.SubscriptionPolices | ConvertFrom-Json).quotaId + return $quotaId -notmatch 'MSDN|DEV|VS|TEST' + } + return $false +} | Select-Object @{n='SubscriptionId';e={$_.Id}} | Export-Csv .\mysubscriptions.csv -NoTypeInformation +``` + +2The VM names `.csv` file must include a column **VMName**. E.g.: + +``` +"VMName" +"sqlvm-prod-eastus-01" +"sqlvm-prod-eastus-02" +"sqlvm-stage-westus-01" +``` + +# Recommended workflow + +The script is parameter-driven (no interactive menus). If you run it with **no scope parameters**, it will scan **every Azure SQL VM in every subscription** your account has access to — that is intentional for automation/runbook scenarios, but you almost certainly want to narrow the scope when running it by hand. + +**Always preview with `-ReportOnly` first.** It produces the same CSV report and transcript, but performs no writes. + +## Scoping cheat-sheet + +| To target… | Pass… | +|---|---| +| One subscription | `-SubId ` | +| Many subscriptions | `-SubId .\subs.csv` (column `SubscriptionId`) | +| One resource group | `-SubId -ResourceGroup ` | +| One VM | `-SubId -ResourceGroup -VMName ` | +| Many VMs | `-SubId -VMName .\vms.csv` (column `VMName`) | +| Everything in tenant | *omit `-SubId`, `-ResourceGroup`, `-VMName`* — broad on purpose, intended for automation/runbooks | + +## Suggested first run + +```powershell +# 1) Preview a single VM +./modify-azure-sql-vm-license-type.ps1 -SubId -ResourceGroup -VMName -ReportOnly + +# 2) Inspect ModifiedResources_*.csv (current license type, prerequisite columns, what would change) + +# 3) Apply for real once the preview looks right +./modify-azure-sql-vm-license-type.ps1 -SubId -ResourceGroup -VMName ` + -LicenseType PAYG -EnableESU Yes -Force +``` + +## Safety guardrails to remember + +- **`-ReportOnly`** — run with this on every new scope until you trust the preview. No writes happen. +- **`-Force`** — required to *change* an existing `LicenseType`. Without `-Force`, the script only sets license type on VMs where it is currently unset (matches the Arc script's behavior). +- **`-ExclusionTags '{"env":"prod"}'`** — skip VMs that carry any of the listed tag key/value pairs. + +# Script execution examples + +## Example 1 + +Scan all subscriptions in tenant `` and list the Azure SQL VMs that would have their license type changed to `PAYG` (only those where the current value is undefined). + +```powershell +./modify-azure-sql-vm-license-type.ps1 -TenantId -LicenseType PAYG -ReportOnly +``` + +## Example 2 + +Scan subscription `` and set the license type to `AHUB` on all VMs listed in `vms.csv`, overwriting any existing value. + +```powershell +./modify-azure-sql-vm-license-type.ps1 -SubId -VMName vms.csv -LicenseType AHUB -Force +``` + +## Example 3 + +Scan resource group `` in subscription ``, set the license type to `PAYG`, and enable ESU on all VMs in the resource group. + +```powershell +./modify-azure-sql-vm-license-type.ps1 -SubId -ResourceGroup ` + -LicenseType PAYG -EnableESU Yes -Force +``` + +## Example 4 + +Set license type to `AHUB` and enable ESU on all VMs in subscription `` of tenant ``, except those with the tag `Environment:Dev`. + +```powershell +./modify-azure-sql-vm-license-type.ps1 -TenantId -SubId ` + -LicenseType AHUB -EnableESU Yes -Force -ExclusionTags '{"Environment":"Dev"}' +``` + +## Example 5 + +Disable ESU on all VMs in subscription ``. + +```powershell +./modify-azure-sql-vm-license-type.ps1 -SubId -EnableESU No +``` + +## Example 6 + +Run as an Azure Automation runbook using managed identity, setting license type to `PAYG` across the whole tenant. + +```powershell +./modify-azure-sql-vm-license-type.ps1 -LicenseType PAYG -Force -UseManagedIdentity +``` + +## Example 7 + +Set every VM in a subscription to `PAYG` with ESU enabled, and also auto-fix the two safe-to-automate prerequisites (missing system-assigned identity, unregistered `Microsoft.AzureArcData` RP). + +```powershell +./modify-azure-sql-vm-license-type.ps1 -SubId -LicenseType PAYG -EnableESU Yes -Force ` + -FixManagedIdentity -FixArcDataRp +``` + +## Example 8 + +Audit only — discover unmet prerequisites across a subscription and record the portal deep-links in the CSV, without writing anything. + +```powershell +./modify-azure-sql-vm-license-type.ps1 -SubId -ReportOnly ` + -FixManagedIdentity -FixArcDataRp -FixManagementMode +``` + +# Running the script using Cloud Shell + +This option is recommended because Cloud Shell has the Azure PowerShell modules pre-installed and you are automatically authenticated. Use the following steps to run the script in Cloud Shell. + +1. Launch the [Cloud Shell](https://shell.azure.com/) and select **PowerShell**. For details, [read more about PowerShell in Cloud Shell](https://aka.ms/pscloudshell/docs). + +2. Connect to Microsoft Entra ID. You can skip this step if you specify `` as a parameter of the script. + + ```powershell + Connect-AzAccount -TenantId + ``` + +3. Upload the script to your Cloud Shell: + + ```powershell + curl https://raw.githubusercontent.com/microsoft/sql-server-samples/master/samples/manage/azure-sql-vm/modify-license-type/modify-azure-sql-vm-license-type.ps1 -o modify-azure-sql-vm-license-type.ps1 + ``` + +4. Run the script using one of the examples above. + +> [!NOTE] +> - To paste commands into the shell, use `Ctrl-Shift-V` on Windows or `Cmd-V` on macOS. +> - The script will be uploaded directly to the home folder associated with your Cloud Shell session. + +# Running the script from a PC + +Use the following steps to run the script in a PowerShell 7 session on your PC. + +1. Install PowerShell 7 if you don't have it: . + +2. Install the required Az modules (once): + + ```powershell + Install-Module Az.Accounts, Az.SqlVirtualMachine, Az.Compute, Az.ResourceGraph -Scope CurrentUser + ``` + +3. Copy the script to your current folder: + + ```powershell + curl https://raw.githubusercontent.com/microsoft/sql-server-samples/master/samples/manage/azure-sql-vm/modify-license-type/modify-azure-sql-vm-license-type.ps1 -o modify-azure-sql-vm-license-type.ps1 + ``` + +4. Connect to Microsoft Entra ID. You can skip this step if you specify `` as a parameter of the script. + + ```powershell + Connect-AzAccount -TenantId + ``` + +5. Run the script using one of the examples above. + +# Output + +| File | Purpose | +|---|---| +| `modify-azure-sql-vm-license-type.log` | Full transcript (appended each run). | +| `ModifiedResources_.csv` | One row per reviewed VM. Columns: `TenantID`, `SubID`, `ResourceGroup`, `ResourceName`, `ResourceType`, `Location`, `OriginalLicenseType`, `TargetLicenseType`, `EsuAction`, `Mode`, `LicenseStatus`, `EsuStatus`, `Error`, plus the prerequisite columns below. | + +## Prerequisite columns (informational, do not block writes) + +| Column | Meaning | +|---|---| +| `SqlIaaSExtensionVersion` | The installed `SqlIaaSAgent` extension `typeHandlerVersion` (e.g. `2.0.227.1`). Empty when not installed. | +| `IsSqlIaaSExtensionVersionMet` | `True` when the installed version is at least `MinRequiredSqlIaaSExtensionVersion`. | +| `MinRequiredSqlIaaSExtensionVersion` | Constant minimum version (currently `2.0.227.1`, matching the Azure portal). | +| `HasSystemAssignedIdentity` | `True` when the underlying compute VM's `identity.type` contains `SystemAssigned`. | +| `IsAzureArcDataRpRegistered` | `True` when `Microsoft.AzureArcData` is `Registered` on the subscription. Cached per subscription. | +| `SqlManagementMode` | Current SqlIaaS management mode from the SQL VM resource (`Full` / `LightWeight` / `NoAgent`). | +| `IsSqlManagementModeFull` | `True` when `SqlManagementMode` equals `Full`. | +| `PrereqRemediationLinks` | Semicolon-separated `=` pairs for each unmet prerequisite. Empty when all prerequisites pass. The URLs deep-link to the same Azure portal blades the portal hyperlinks open. | +| `FixManagedIdentityResult` | Outcome of the `-FixManagedIdentity` switch: empty (not invoked), `WouldFix` (under `-ReportOnly`), `Fixed`, or `Failed: `. | +| `FixArcDataRpResult` | Outcome of the `-FixArcDataRp` switch (same vocabulary). | +| `FixManagementModeResult` | Outcome of the `-FixManagementMode` switch (same vocabulary). | diff --git a/samples/manage/azure-sql-vm/modify-license-type/modify-azure-sql-vm-license-type.ps1 b/samples/manage/azure-sql-vm/modify-license-type/modify-azure-sql-vm-license-type.ps1 new file mode 100644 index 000000000..82978207e --- /dev/null +++ b/samples/manage/azure-sql-vm/modify-license-type/modify-azure-sql-vm-license-type.ps1 @@ -0,0 +1,941 @@ +<# +.SYNOPSIS + Updates the SQL Server license type and Extended Security Updates (ESU) settings for + Azure SQL VMs (Microsoft.SqlVirtualMachine/sqlVirtualMachines). + +.DESCRIPTION + Companion script to modify-arc-sql-license-type.ps1 (which targets Arc-enabled SQL Servers). + This script targets Azure SQL VMs. For each in-scope resource it can: + + * Change the SQL Server license type (PAYG / AHUB / DR) via Update-AzSqlVM. + * Enable or disable ESU by updating the SqlIaaSAgent extension settings on the + underlying compute VM (preserving any other operator-set extension settings). + + It also detects (read-only, never gates writes) the four prerequisites surfaced by + the Azure portal SQL Server configuration page (SqlVmManagementSection.tsx): + + 1. SqlIaaSAgent extension version >= 2.0.227.1 + 2. Compute VM has a system-assigned managed identity + 3. Microsoft.AzureArcData resource provider is registered on the subscription + 4. SqlIaaSAgent SQL Management mode is 'Full' + + Per-VM prerequisite results are echoed to the transcript and added as columns on + the CSV report. They are informational only and do not block license or ESU writes. + + Scope is selected by subscription (single id or CSV file), optional resource group, + and optional VM name (single name or CSV file). Resources are discovered via Azure + Resource Graph and paged with -BatchSize. Tags listed in -ExclusionTags are skipped. + + Always writes a transcript log (modify-azure-sql-vm-license-type.log) and, when any + resources are touched, a CSV report (ModifiedResources_.csv). + +.VERSION + 1.2.0 + +.PARAMETER SubId + A single subscription ID or a .csv file with a 'SubscriptionId' column. If omitted, + every enabled subscription in the current tenant is scanned. + +.PARAMETER ResourceGroup + Optional. Limits scope to a single resource group. + +.PARAMETER VMName + Optional. A single Azure SQL VM name or a .csv file with a 'VMName' column. + +.PARAMETER LicenseType + Optional. License type to apply. Allowed values: 'PAYG', 'AHUB', 'DR'. + +.PARAMETER EnableESU + Optional. 'Yes' enables ESU, 'No' disables it. Requires LicenseType to be (or + already be) 'PAYG' or 'AHUB' when enabling. + +.PARAMETER Force + Optional. When set, updates LicenseType even on resources where it is already + populated. Without -Force, license type is only written when the current value is + empty/unset (matching the Arc script's semantics). + +.PARAMETER ExclusionTags + Optional. Hashtable or JSON object of tag key/value pairs. Resources whose tags + match any listed pair are skipped. + +.PARAMETER TenantId + Optional. Tenant id to authenticate against. Defaults to the current Az context tenant. + +.PARAMETER ReportOnly + Optional. Discover and log changes that would be made; do not call Update-AzSqlVM + or Set-AzVMExtension. + +.PARAMETER UseManagedIdentity + Optional. Authenticate using a managed identity. Required for Azure Automation + runbooks (auto-detected in that environment as well). + +.PARAMETER FixManagedIdentity + Optional. When set (and -ReportOnly is not), enables a system-assigned managed + identity on the underlying compute VM if it is missing. Additive — preserves any + existing user-assigned identities. + +.PARAMETER FixArcDataRp + Optional. When set (and -ReportOnly is not), registers the Microsoft.AzureArcData + resource provider on each in-scope subscription where it is not yet Registered. + +.PARAMETER FixManagementMode + Optional. When set (and -ReportOnly is not), upgrades the SqlIaaSAgent SQL + Management mode to 'Full' for VMs where it is not already Full. Note: this + re-runs the extension handler and can take several minutes per VM. + +.PARAMETER BatchSize + Optional. Page size for Search-AzGraph. Defaults to 500. + +.EXAMPLE + # Preview changes across the whole tenant + ./modify-azure-sql-vm-license-type.ps1 -ReportOnly + +.EXAMPLE + # Set all Azure SQL VMs in a subscription to PAYG and enable ESU + ./modify-azure-sql-vm-license-type.ps1 -SubId -LicenseType PAYG -EnableESU Yes -Force + +.EXAMPLE + # Apply to a single VM + ./modify-azure-sql-vm-license-type.ps1 -SubId -ResourceGroup myRG -VMName mySqlVm ` + -LicenseType AHUB -EnableESU Yes + +.EXAMPLE + # Bulk run from CSVs, excluding tagged resources + ./modify-azure-sql-vm-license-type.ps1 -SubId .\subscriptions.csv -VMName .\vms.csv ` + -LicenseType PAYG -ExclusionTags '{"env":"prod"}' + +.NOTES + Requires PowerShell 7+ and the following modules: + Az.Accounts, Az.SqlVirtualMachine, Az.Compute, Az.ResourceGraph + Minimum RBAC: Contributor (or SQL Virtual Machine Contributor + Virtual Machine + Contributor) on the in-scope resources. +#> + +#Requires -Version 7.0 +#Requires -Modules Az.Accounts, Az.SqlVirtualMachine, Az.Compute, Az.ResourceGraph + +[CmdletBinding()] +param ( + [Parameter(Mandatory = $false)] + [string] $SubId, + + [Parameter(Mandatory = $false)] + [string] $ResourceGroup, + + [Parameter(Mandatory = $false)] + [string] $VMName, + + [Parameter(Mandatory = $false)] + [ValidateSet('PAYG', 'AHUB', 'DR', IgnoreCase = $false)] + [string] $LicenseType, + + [Parameter(Mandatory = $false)] + [ValidateSet('Yes', 'No', IgnoreCase = $false)] + [string] $EnableESU, + + [Parameter(Mandatory = $false)] + [switch] $Force, + + [Parameter(Mandatory = $false)] + [object] $ExclusionTags, + + [Parameter(Mandatory = $false)] + [string] $TenantId, + + [Parameter(Mandatory = $false)] + [switch] $ReportOnly, + + [Parameter(Mandatory = $false)] + [switch] $UseManagedIdentity, + + [Parameter(Mandatory = $false)] + [switch] $FixManagedIdentity, + + [Parameter(Mandatory = $false)] + [switch] $FixArcDataRp, + + [Parameter(Mandatory = $false)] + [switch] $FixManagementMode, + + [Parameter(Mandatory = $false)] + [int] $BatchSize = 500 +) + +# Constants for the SqlIaaSAgent extension +$Script:ESU_EXT_PUBLISHER = 'Microsoft.SqlServer.Management' +$Script:ESU_EXT_TYPE = 'SqlIaaSAgent' +$Script:ESU_EXT_VERSION = '2.0' + +# Minimum SqlIaaSAgent version surfaced as a prerequisite by the SQL Server +# configuration UX (SqlVmUnifiedConfiguration.tsx -> minRequiredSqlIaaSVersion). +$Script:MIN_SQLIAAS_VERSION = '2.0.227.1' + +# Cache of Microsoft.AzureArcData RP registration state, keyed by subscription id. +$Script:ArcDataRpCache = @{} + +Start-Transcript -Path '.\modify-azure-sql-vm-license-type.log' -Append | Out-Null +$scriptStartTime = Get-Date +Write-Output "Script execution started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))" + +function Connect-Azure { + [CmdletBinding()] + param( + [Parameter(Mandatory = $false)] [string] $TenantId, + [Parameter(Mandatory = $false)] [switch] $UseManagedIdentity + ) + + $envType = 'Local' + if ($env:AZUREPS_HOST_ENVIRONMENT -like 'cloud-shell*') { + $envType = 'CloudShell' + } + elseif (($env:AZUREPS_HOST_ENVIRONMENT -like 'AzureAutomation*') -or $PSPrivateMetadata.JobId) { + $envType = 'AzureAutomation' + $UseManagedIdentity = $true + } + Write-Output "Environment detected: $envType" + + try { Update-AzConfig -LoginExperienceV2 Off -ErrorAction SilentlyContinue | Out-Null } + catch { Write-Verbose "Update-AzConfig not available or failed: $($_.Exception.Message)" } + + $currentCtx = Get-AzContext -ErrorAction SilentlyContinue + if ($currentCtx -and $currentCtx.Account) { + if ($TenantId) { + if ($currentCtx.Tenant.Id -eq $TenantId) { + Write-Output "Already in Az tenant $TenantId" + } + else { + Write-Output "Switching Az context to tenant $TenantId" + $newContext = Set-AzContext -Tenant $TenantId -ErrorAction SilentlyContinue + if ($null -eq $newContext -or $newContext.Tenant.Id -ne $TenantId) { + Connect-AzAccount -Tenant $TenantId | Out-Null + } + } + } + else { + Write-Output "Using existing Az context: Tenant $($currentCtx.Tenant.Id)" + } + } + else { + Write-Output 'Not connected to Azure PowerShell. Running Connect-AzAccount...' + if ($UseManagedIdentity) { + if ($TenantId) { Connect-AzAccount -Identity -Tenant $TenantId | Out-Null } + else { Connect-AzAccount -Identity -ErrorAction Stop | Out-Null } + } + else { + if ($TenantId) { Connect-AzAccount -Tenant $TenantId | Out-Null } + else { Connect-AzAccount | Out-Null } + } + $ctx = Get-AzContext + Write-Output "Connected to Az PowerShell as: $($ctx.Account) in tenant $($ctx.Tenant.Id)" + } + + return $envType +} + +function ConvertTo-TagHashtable { + param([Parameter(Mandatory = $false)] [object] $InputObject) + $result = @{} + if ($null -eq $InputObject) { return $result } + if ($InputObject -is [hashtable]) { return $InputObject } + try { + ($InputObject | ConvertFrom-Json -ErrorAction Stop).PSObject.Properties | ForEach-Object { + $result[$_.Name] = $_.Value + } + } + catch { + Write-Warning "ExclusionTags could not be parsed as JSON or hashtable; ignoring." + } + return $result +} + +function Test-ExcludedByTag { + param( + [hashtable] $ResourceTags, + [hashtable] $ExclusionTagTable + ) + if (-not $ResourceTags -or $ExclusionTagTable.Count -eq 0) { return $false } + foreach ($key in $ExclusionTagTable.Keys) { + if ($ResourceTags.ContainsKey($key) -and ($ResourceTags[$key] -eq $ExclusionTagTable[$key])) { + Write-Output " Exclusion tag $key=$($ExclusionTagTable[$key]) matched. Skipping..." + return $true + } + } + return $false +} + +function Import-CsvColumn { + param( + [Parameter(Mandatory = $true)] [string] $Path, + [Parameter(Mandatory = $true)] [string] $Column + ) + if (-not (Test-Path -LiteralPath $Path)) { + throw "CSV file not found: $Path" + } + $rows = Import-Csv -Path $Path + if (-not $rows) { return @() } + if (-not ($rows[0].PSObject.Properties.Name -contains $Column)) { + throw "CSV '$Path' is missing required column '$Column'." + } + return @($rows | ForEach-Object { $_.$Column } | Where-Object { $_ }) +} + +function Get-SqlIaasExtensionSetting { + param( + [Parameter(Mandatory = $true)] [string] $ResourceGroupName, + [Parameter(Mandatory = $true)] [string] $VMName, + [Parameter(Mandatory = $true)] [string] $ExtensionName + ) + try { + $ext = Get-AzVMExtension -ResourceGroupName $ResourceGroupName -VMName $VMName ` + -Name $ExtensionName -ErrorAction Stop + } + catch { + return $null + } + + $settings = @{} + if ($ext.Settings) { + # PublicSettings comes back as a JSON string on some platforms, a hashtable on others. + if ($ext.Settings -is [string]) { + try { + ($ext.Settings | ConvertFrom-Json).PSObject.Properties | ForEach-Object { + $settings[$_.Name] = $_.Value + } + } catch { + Write-Verbose "Could not parse SqlIaaSAgent Settings as JSON: $($_.Exception.Message)" + } + } + elseif ($ext.Settings -is [hashtable]) { + $settings = $ext.Settings.Clone() + } + else { + $ext.Settings.PSObject.Properties | ForEach-Object { + $settings[$_.Name] = $_.Value + } + } + } + return @{ Extension = $ext; Settings = $settings } +} + +function Set-EsuOnSqlVm { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param( + [Parameter(Mandatory = $true)] [string] $ResourceGroupName, + [Parameter(Mandatory = $true)] [string] $VMName, + [Parameter(Mandatory = $true)] [string] $Location, + [Parameter(Mandatory = $true)] [string] $ExtensionName, + [Parameter(Mandatory = $true)] [bool] $Enable + ) + $existing = Get-SqlIaasExtensionSetting -ResourceGroupName $ResourceGroupName -VMName $VMName -ExtensionName $ExtensionName + if ($null -eq $existing) { + throw "SqlIaaSAgent extension '$ExtensionName' not found on VM '$VMName' in RG '$ResourceGroupName'. Cannot toggle ESU." + } + + $settings = $existing.Settings + $settings['enableExtendedSecurityUpdates'] = $Enable + $settings['esuLastUpdatedTimestamp'] = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') + + if ($PSCmdlet.ShouldProcess("$ResourceGroupName/$VMName", "Set SqlIaaSAgent ESU=$Enable")) { + Set-AzVMExtension -ResourceGroupName $ResourceGroupName ` + -VMName $VMName ` + -Location $Location ` + -Name $ExtensionName ` + -Publisher $Script:ESU_EXT_PUBLISHER ` + -ExtensionType $Script:ESU_EXT_TYPE ` + -TypeHandlerVersion $Script:ESU_EXT_VERSION ` + -Settings $settings ` + -ErrorAction Stop | Out-Null + } +} + +# --------------------------------------------------------------------------- +# Prerequisite-detection helpers (mirror SqlVmManagementSection.tsx) +# --------------------------------------------------------------------------- + +function Test-VersionGreaterOrEqual { + <# + .SYNOPSIS + Returns $true if Actual >= Required. Mirrors the TSX isVersionGreaterOrEqual + helper used by the Azure portal SQL Server configuration page. + #> + param( + [Parameter(Mandatory = $false)] [string] $Actual, + [Parameter(Mandatory = $true)] [string] $Required + ) + if ([string]::IsNullOrWhiteSpace($Actual)) { return $false } + $a = $null; $r = $null + if ([Version]::TryParse($Actual, [ref] $a) -and + [Version]::TryParse($Required, [ref] $r)) { + return ($a -ge $r) + } + # Fallback: lexicographic compare of dotted segments + $aParts = $Actual.Split('.') | ForEach-Object { [int]::TryParse($_, [ref] $null) | Out-Null; [int]$_ } + $rParts = $Required.Split('.') | ForEach-Object { [int]::TryParse($_, [ref] $null) | Out-Null; [int]$_ } + for ($i = 0; $i -lt [Math]::Max($aParts.Count, $rParts.Count); $i++) { + $av = if ($i -lt $aParts.Count) { $aParts[$i] } else { 0 } + $rv = if ($i -lt $rParts.Count) { $rParts[$i] } else { 0 } + if ($av -gt $rv) { return $true } + if ($av -lt $rv) { return $false } + } + return $true +} + +function Test-AzureArcDataRpRegistered { + <# + .SYNOPSIS + Returns $true if Microsoft.AzureArcData RP is registered on the subscription. + Results are cached per subscription id for the lifetime of the script. + #> + param( + [Parameter(Mandatory = $true)] [string] $SubscriptionId + ) + if ($Script:ArcDataRpCache.ContainsKey($SubscriptionId)) { + return $Script:ArcDataRpCache[$SubscriptionId] + } + $registered = $null + try { + $rp = Get-AzResourceProvider -ProviderNamespace 'Microsoft.AzureArcData' -ErrorAction Stop | + Select-Object -First 1 + $registered = ($rp -and $rp.RegistrationState -eq 'Registered') + } + catch { + Write-Verbose "Get-AzResourceProvider failed for $SubscriptionId : $($_.Exception.Message)" + $registered = $null + } + $Script:ArcDataRpCache[$SubscriptionId] = $registered + return $registered +} + +# --------------------------------------------------------------------------- +# Portal deep-link helpers +# Mirror the navigation that SqlVmManagementSection.tsx performs via Az.openBlade. +# --------------------------------------------------------------------------- + +function Get-PortalResourceUrl { + param( + [Parameter(Mandatory = $true)] [string] $TenantId, + [Parameter(Mandatory = $true)] [string] $ResourceId, + [Parameter(Mandatory = $false)] [string] $SubBlade = '' + ) + # Friendly deep-link form: https://portal.azure.com/#@/resource[/] + $url = "https://portal.azure.com/#@$TenantId/resource$ResourceId" + if ($SubBlade) { $url += "/$SubBlade" } + return $url +} + +function Get-PrereqRemediationLink { + <# + .SYNOPSIS + Returns a hashtable of remediation hints (Url + suggested action) for any + unmet prerequisite. Mirrors the hyperlinks shown in the Azure portal. + #> + param( + [Parameter(Mandatory = $true)] [string] $TenantId, + [Parameter(Mandatory = $true)] [string] $SubscriptionId, + [Parameter(Mandatory = $true)] [string] $VmResourceId, + [Parameter(Mandatory = $true)] [string] $SqlVmResourceId, + [Parameter(Mandatory = $true)] [bool] $IsExtVersionMet, + [Parameter()] $HasSysIdentity, + [Parameter()] $ArcDataReg, + [Parameter(Mandatory = $true)] [bool] $IsMgmtFull + ) + $links = [ordered]@{} + + if (-not $IsExtVersionMet) { + $links['SqlIaaSExtensionVersion'] = [PSCustomObject]@{ + Action = "Upgrade SqlIaaSAgent extension to >= $($Script:MIN_SQLIAAS_VERSION)" + Url = Get-PortalResourceUrl -TenantId $TenantId -ResourceId $VmResourceId -SubBlade 'extensions' + } + } + if ($HasSysIdentity -eq $false) { + $links['SystemAssignedIdentity'] = [PSCustomObject]@{ + Action = 'Enable system-assigned managed identity on the VM' + Url = Get-PortalResourceUrl -TenantId $TenantId -ResourceId $VmResourceId -SubBlade 'identity' + } + } + if ($ArcDataReg -ne $true) { + $links['AzureArcDataRp'] = [PSCustomObject]@{ + Action = 'Register the Microsoft.AzureArcData resource provider on the subscription' + Url = "https://portal.azure.com/#@$TenantId/resource/subscriptions/$SubscriptionId/resourceProviders" + } + } + if (-not $IsMgmtFull) { + $links['SqlManagementMode'] = [PSCustomObject]@{ + Action = "Upgrade SQL Management mode to 'Full' (Update-AzSqlVM -SqlManagementType Full)" + # No portal blade — the portal calls upgradeSqlVmManagementMode inline. + # Deep-link to the SQL VM resource overview so the operator can take action there. + Url = Get-PortalResourceUrl -TenantId $TenantId -ResourceId $SqlVmResourceId + } + } + return $links +} + +# --------------------------------------------------------------------------- +# Auto-remediation helpers +# Each is opt-in via the corresponding -Fix* switch; each respects -ReportOnly. +# --------------------------------------------------------------------------- + +function Enable-SystemAssignedIdentity { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param( + [Parameter(Mandatory = $true)] [string] $ResourceGroupName, + [Parameter(Mandatory = $true)] [string] $VMName + ) + if (-not $PSCmdlet.ShouldProcess("$ResourceGroupName/$VMName", 'Enable SystemAssigned identity')) { return } + $vm = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $VMName -ErrorAction Stop + $currentType = if ($vm.Identity) { [string]$vm.Identity.Type } else { '' } + # Preserve existing user-assigned identities by switching to SystemAssigned,UserAssigned when needed. + $newType = if ($currentType -match 'UserAssigned') { 'SystemAssigned,UserAssigned' } else { 'SystemAssigned' } + Update-AzVM -ResourceGroupName $ResourceGroupName -VM $vm -IdentityType $newType -ErrorAction Stop | Out-Null +} + +function Register-ArcDataResourceProvider { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Low')] + param( + [Parameter(Mandatory = $true)] [string] $SubscriptionId + ) + if (-not $PSCmdlet.ShouldProcess("subscription $SubscriptionId", 'Register Microsoft.AzureArcData RP')) { return } + Register-AzResourceProvider -ProviderNamespace 'Microsoft.AzureArcData' -ErrorAction Stop | Out-Null + # Invalidate cache so subsequent VMs in the same sub see the new state. + if ($Script:ArcDataRpCache.ContainsKey($SubscriptionId)) { + $Script:ArcDataRpCache.Remove($SubscriptionId) | Out-Null + } +} + +function Set-SqlVmManagementModeFull { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + param( + [Parameter(Mandatory = $true)] [string] $ResourceGroupName, + [Parameter(Mandatory = $true)] [string] $VMName + ) + if (-not $PSCmdlet.ShouldProcess("$ResourceGroupName/$VMName", "Set SqlManagementType=Full")) { return } + Update-AzSqlVM -ResourceGroupName $ResourceGroupName -Name $VMName -SqlManagementType Full -ErrorAction Stop | Out-Null +} + +# ----------------- +# Auth + bootstrap +# ----------------- +$envType = Connect-Azure -TenantId $TenantId -UseManagedIdentity:$UseManagedIdentity + +$context = Get-AzContext -ErrorAction SilentlyContinue +if ($null -eq $context) { throw 'No Azure context after Connect-Azure.' } +Write-Output "Connected to Azure as: $($context.Account)" + +if (-not $TenantId) { + $TenantId = $context.Tenant.Id + Write-Output "No TenantId provided. Using current context TenantId: $TenantId" +} +else { + Write-Output "Using provided TenantId: $TenantId" +} + +$tagTable = ConvertTo-TagHashtable -InputObject $ExclusionTags + +# ----------------- +# Resolve scope +# ----------------- +$subscriptions = @() +if ($SubId -and ($SubId -like '*.csv')) { + $subIds = Import-CsvColumn -Path $SubId -Column 'SubscriptionId' + foreach ($s in $subIds) { + try { $subscriptions += Get-AzSubscription -SubscriptionId $s -ErrorAction Stop } + catch { Write-Warning "Subscription '$s' not accessible: $($_.Exception.Message)" } + } +} +elseif ($SubId) { + $subscriptions = Get-AzSubscription -SubscriptionId $SubId +} +else { + $subscriptions = Get-AzSubscription | Where-Object { $_.TenantId -eq $TenantId } +} + +if (-not $subscriptions -or $subscriptions.Count -eq 0) { + throw 'No subscriptions resolved for the requested scope.' +} + +$vmNames = @() +if ($VMName) { + if ($VMName -like '*.csv') { + $vmNames = Import-CsvColumn -Path $VMName -Column 'VMName' + Write-Output "Loaded $($vmNames.Count) VM name(s) from CSV." + } + else { + $vmNames = @($VMName) + } +} + +# Validate ESU/license combination up-front (cheap) +if ($EnableESU -eq 'Yes') { + $effective = if ($LicenseType) { $LicenseType } else { '' } + if ($LicenseType -eq 'DR') { + throw "ESU cannot be enabled when LicenseType is 'DR'. Use 'PAYG' or 'AHUB'." + } + Write-Output "ESU will be enabled (effective LicenseType: $effective). 'DR' resources will be skipped." +} + +# ----------------- +# Process +# ----------------- +$modifiedResources = [System.Collections.Generic.List[object]]::new() + +Write-Output ([Environment]::NewLine + '-- Scanning subscriptions --') + +foreach ($sub in $subscriptions) { + if ($sub.State -and $sub.State -ne 'Enabled') { + Write-Output "Skipping non-Enabled subscription: $($sub.Id) ($($sub.State))" + continue + } + + try { + Set-AzContext -SubscriptionId $sub.Id -ErrorAction Stop | Out-Null + } + catch { + Write-Warning "Invalid subscription: $($sub.Id) - $($_.Exception.Message)" + continue + } + + Write-Output "[$($sub.Id)] Collecting Azure SQL VMs..." + + $query = @" +resources +| where type =~ 'microsoft.sqlvirtualmachine/sqlvirtualmachines' +| where subscriptionId =~ '$($sub.Id)' +"@ + + if ($ResourceGroup) { + $query += "`n| where resourceGroup =~ '$ResourceGroup'" + } + if ($vmNames.Count -gt 0) { + $list = ($vmNames | ForEach-Object { "'$_'" }) -join ', ' + $query += "`n| where name in~ ($list)" + } + if ($LicenseType) { + $query += "`n| where isnull(properties.sqlServerLicenseType) or tostring(properties.sqlServerLicenseType) !~ '$LicenseType'" + } + $query += @" + +| extend vmIdLower = tolower(tostring(properties.virtualMachineResourceId)) +| project sqlVmName = name, resourceGroup, location, subscriptionId, + currentLicenseType = tostring(properties.sqlServerLicenseType), + sqlManagement = tostring(properties.sqlManagement), + vmIdLower, tags +| join kind=leftouter ( + resources + | where type =~ 'microsoft.compute/virtualmachines' + | extend vmIdLower = tolower(id) + | extend identityType = tostring(identity.type) + | project vmIdLower, identityType +) on vmIdLower +| join kind=leftouter ( + resources + | where type =~ 'microsoft.compute/virtualmachines/extensions' + | where properties.publisher =~ 'Microsoft.SqlServer.Management' + | where properties.type =~ 'SqlIaaSAgent' + | extend vmIdLower = tolower(substring(id, 0, indexof(id, '/extensions/'))) + | extend sqlIaasExtName = name + | extend sqlIaasVersion = tostring(properties.typeHandlerVersion) + | project vmIdLower, sqlIaasExtName, sqlIaasVersion +) on vmIdLower +| project name = sqlVmName, resourceGroup, location, subscriptionId, + currentLicenseType, sqlManagement, identityType, sqlIaasExtName, sqlIaasVersion, tags +| order by name asc +"@ + + Write-Verbose $query + + $allResults = [System.Collections.Generic.List[object]]::new() + $skipToken = $null + do { + if ($skipToken) { + $page = Search-AzGraph -Query $query -First $BatchSize -SkipToken $skipToken + } else { + $page = Search-AzGraph -Query $query -First $BatchSize + } + if ($page) { $allResults.AddRange($page) } + $skipToken = $page.SkipToken + } while ($skipToken) + + Write-Output " Found $($allResults.Count) Azure SQL VM resource(s) needing review." + + foreach ($r in $allResults) { + $rgName = $r.resourceGroup + $vmName = $r.name + $location = $r.location + $currentLT = $r.currentLicenseType + + Write-Output " -- $rgName/$vmName (location=$location, currentLicenseType=$([string]::IsNullOrEmpty($currentLT) ? '' : $currentLT))" + + # Build a hashtable view of tags (Resource Graph returns a PSCustomObject) + $resourceTags = @{} + if ($r.tags) { + $r.tags.PSObject.Properties | ForEach-Object { $resourceTags[$_.Name] = $_.Value } + } + if (Test-ExcludedByTag -ResourceTags $resourceTags -ExclusionTagTable $tagTable) { + continue + } + + # ---- Prerequisite detection (informational; never blocks apply paths) ---- + # Resource Graph only has the major.minor version (e.g. "2.0"). The full version + # (e.g. "2.0.227.1") lives in the extension instanceView, so we fetch it via ARM + # the same way the Azure portal does ($expand=instanceView). + $sqlIaasExtName = if ($r.PSObject.Properties.Name -contains 'sqlIaasExtName') { [string]$r.sqlIaasExtName } else { '' } + $identityType = if ($r.PSObject.Properties.Name -contains 'identityType') { [string]$r.identityType } else { '' } + $sqlManagementMode = if ($r.PSObject.Properties.Name -contains 'sqlManagement') { [string]$r.sqlManagement } else { '' } + + $vmResourceId = "/subscriptions/$($r.subscriptionId)/resourceGroups/$rgName/providers/Microsoft.Compute/virtualMachines/$vmName" + $sqlIaasVer = '' + if (-not [string]::IsNullOrEmpty($sqlIaasExtName)) { + try { + # Fetch the extension with $expand=instanceView to get the full version + # (mirrors the portal's getVmExtensionMetadata call). + $extPath = "$vmResourceId/extensions/$($sqlIaasExtName)?api-version=2023-09-01&`$expand=instanceView" + $resp = Invoke-AzRestMethod -Path $extPath -Method GET -ErrorAction Stop + if ($resp.StatusCode -eq 200) { + $extJson = $resp.Content | ConvertFrom-Json + $sqlIaasVer = [string]$extJson.properties.instanceView.typeHandlerVersion + } + } + catch { + Write-Verbose " Could not fetch extension instanceView for $rgName/$vmName/$sqlIaasExtName : $($_.Exception.Message)" + } + } + + $isExtVersionMet = Test-VersionGreaterOrEqual -Actual $sqlIaasVer -Required $Script:MIN_SQLIAAS_VERSION + $hasSysIdentity = ($identityType -match 'SystemAssigned') + $isMgmtFull = ($sqlManagementMode -and $sqlManagementMode.ToLower() -eq 'full') + $arcDataReg = Test-AzureArcDataRpRegistered -SubscriptionId $r.subscriptionId + + $glyph = { param($b) if ($null -eq $b) { '?' } elseif ($b) { '+' } else { '-' } } + Write-Output (" Prereqs: SqlIaaSAgent>={0} [{1}] (actual={2}) SystemAssignedMI [{3}] AzureArcDataRP [{4}] SqlManagement=Full [{5}] (actual={6})" -f ` + $Script:MIN_SQLIAAS_VERSION, + (& $glyph $isExtVersionMet), + ($sqlIaasVer | ForEach-Object { if ([string]::IsNullOrEmpty($_)) { '' } else { $_ } }), + (& $glyph $hasSysIdentity), + (& $glyph $arcDataReg), + (& $glyph $isMgmtFull), + ($sqlManagementMode | ForEach-Object { if ([string]::IsNullOrEmpty($_)) { '' } else { $_ } }) + ) + + # ---- Collect portal deep-links for unmet prereqs (written to CSV only) ---- + $sqlVmResourceId = "/subscriptions/$($r.subscriptionId)/resourceGroups/$rgName/providers/Microsoft.SqlVirtualMachine/sqlVirtualMachines/$vmName" + $remediation = Get-PrereqRemediationLink ` + -TenantId $TenantId ` + -SubscriptionId $r.subscriptionId ` + -VmResourceId $vmResourceId ` + -SqlVmResourceId $sqlVmResourceId ` + -IsExtVersionMet $isExtVersionMet ` + -HasSysIdentity $hasSysIdentity ` + -ArcDataReg $arcDataReg ` + -IsMgmtFull $isMgmtFull + + # ---- Auto-remediation (opt-in via -Fix* switches; honors -ReportOnly) ---- + $fixIdentityResult = '' + $fixArcRpResult = '' + $fixMgmtResult = '' + + if ($FixManagedIdentity.IsPresent -and $hasSysIdentity -eq $false) { + if ($ReportOnly.IsPresent) { + $fixIdentityResult = 'WouldFix' + Write-Output ' [ReportOnly] Would enable system-assigned managed identity.' + } else { + try { + Write-Output ' Enabling system-assigned managed identity...' + Enable-SystemAssignedIdentity -ResourceGroupName $rgName -VMName $vmName + $fixIdentityResult = 'Fixed' + $hasSysIdentity = $true + } + catch { + $fixIdentityResult = "Failed: $($_.Exception.Message)" + Write-Warning " Enabling system-assigned identity failed: $($_.Exception.Message)" + } + } + } + + if ($FixArcDataRp.IsPresent -and $arcDataReg -ne $true) { + if ($ReportOnly.IsPresent) { + $fixArcRpResult = 'WouldFix' + Write-Output ' [ReportOnly] Would register Microsoft.AzureArcData RP on the subscription.' + } else { + try { + Write-Output ' Registering Microsoft.AzureArcData RP on the subscription...' + Register-ArcDataResourceProvider -SubscriptionId $r.subscriptionId + $fixArcRpResult = 'Fixed' + $arcDataReg = $true + } + catch { + $fixArcRpResult = "Failed: $($_.Exception.Message)" + Write-Warning " Registering Microsoft.AzureArcData RP failed: $($_.Exception.Message)" + } + } + } + + if ($FixManagementMode.IsPresent -and -not $isMgmtFull) { + if ($ReportOnly.IsPresent) { + $fixMgmtResult = 'WouldFix' + Write-Output " [ReportOnly] Would upgrade SqlManagementType to 'Full'." + } else { + try { + Write-Output " Upgrading SqlManagementType to 'Full' (this may take several minutes)..." + Set-SqlVmManagementModeFull -ResourceGroupName $rgName -VMName $vmName + $fixMgmtResult = 'Fixed' + $isMgmtFull = $true + $sqlManagementMode = 'Full' + } + catch { + $fixMgmtResult = "Failed: $($_.Exception.Message)" + Write-Warning " Upgrading SqlManagementType failed: $($_.Exception.Message)" + } + } + } + + # Compute desired actions + $writeLicense = $false + $writeEsu = $false + $effectiveLT = $currentLT + + if ($LicenseType) { + if ([string]::IsNullOrEmpty($currentLT)) { + $writeLicense = $true + $effectiveLT = $LicenseType + } + elseif ($Force.IsPresent -and ($currentLT -ne $LicenseType)) { + $writeLicense = $true + $effectiveLT = $LicenseType + } + elseif ($currentLT -ne $LicenseType) { + Write-Output ' LicenseType differs but -Force not specified. Leaving as-is.' + } + } + + if ($EnableESU) { + $esuTargetBool = ($EnableESU -eq 'Yes') + if ($esuTargetBool -and ($effectiveLT -notin @('PAYG','AHUB'))) { + Write-Output " ESU requires LicenseType in PAYG/AHUB (effective='$effectiveLT'). Skipping ESU change." + } + else { + $writeEsu = $true + } + } + + $record = [PSCustomObject]@{ + TenantID = $TenantId + SubID = $r.subscriptionId + ResourceGroup = $rgName + ResourceName = $vmName + ResourceType = 'Microsoft.SqlVirtualMachine/sqlVirtualMachines' + Location = $location + OriginalLicenseType = $currentLT + TargetLicenseType = if ($writeLicense) { $LicenseType } else { $currentLT } + EsuAction = if ($writeEsu) { $EnableESU } else { '' } + Mode = if ($ReportOnly.IsPresent) { 'ReportOnly' } else { 'Apply' } + LicenseStatus = '' + EsuStatus = '' + Error = '' + # Prerequisite checks (informational; do not gate updates) + SqlIaaSExtensionVersion = $sqlIaasVer + IsSqlIaaSExtensionVersionMet = $isExtVersionMet + MinRequiredSqlIaaSExtensionVersion = $Script:MIN_SQLIAAS_VERSION + HasSystemAssignedIdentity = $hasSysIdentity + IsAzureArcDataRpRegistered = $arcDataReg + SqlManagementMode = $sqlManagementMode + IsSqlManagementModeFull = $isMgmtFull + # Portal deep-links for any unmet prereq (semicolon-separated key=url pairs) + PrereqRemediationLinks = (($remediation.Keys | ForEach-Object { "$_=$($remediation[$_].Url)" }) -join ' ; ') + # Auto-remediation outcomes (populated only when the corresponding -Fix* switch is set) + FixManagedIdentityResult = $fixIdentityResult + FixArcDataRpResult = $fixArcRpResult + FixManagementModeResult = $fixMgmtResult + } + + if (-not $writeLicense -and -not $writeEsu) { + Write-Output ' No changes required.' + $record.LicenseStatus = 'NoChange' + $record.EsuStatus = 'NoChange' + $modifiedResources.Add($record) + continue + } + + if ($ReportOnly.IsPresent) { + Write-Output " [ReportOnly] Would set LicenseType=$($record.TargetLicenseType), ESU=$($record.EsuAction)" + $record.LicenseStatus = if ($writeLicense) { 'WouldUpdate' } else { 'NoChange' } + $record.EsuStatus = if ($writeEsu) { 'WouldUpdate' } else { 'NoChange' } + $modifiedResources.Add($record) + continue + } + + # ----- Apply license type ----- + if ($writeLicense) { + try { + Write-Output " Setting LicenseType=$LicenseType via Update-AzSqlVM..." + Update-AzSqlVM -ResourceGroupName $rgName -Name $vmName -LicenseType $LicenseType -ErrorAction Stop | Out-Null + $record.LicenseStatus = 'Updated' + } + catch { + $msg = $_.Exception.Message + Write-Warning " Update-AzSqlVM failed for $rgName/$vmName : $msg" + $record.LicenseStatus = 'Failed' + $record.Error = $msg + $modifiedResources.Add($record) + continue + } + } + else { + $record.LicenseStatus = 'NoChange' + } + + # ----- Apply ESU ----- + if ($writeEsu) { + if ([string]::IsNullOrEmpty($sqlIaasExtName)) { + Write-Warning " ESU update skipped for $rgName/$vmName : SqlIaaSAgent extension not found" + $record.EsuStatus = 'Failed' + $record.Error = ($record.Error, 'SqlIaaSAgent extension not found') | Where-Object { $_ } -join ' | ' + } + else { + try { + Write-Output " Setting ESU=$($EnableESU) on SqlIaaSAgent extension..." + Set-EsuOnSqlVm -ResourceGroupName $rgName -VMName $vmName -Location $location -ExtensionName $sqlIaasExtName -Enable ($EnableESU -eq 'Yes') + $record.EsuStatus = 'Updated' + } + catch { + $msg = $_.Exception.Message + Write-Warning " ESU update failed for $rgName/$vmName : $msg" + $record.EsuStatus = 'Failed' + $record.Error = ($record.Error, $msg | Where-Object { $_ }) -join ' | ' + } + } + } + else { + $record.EsuStatus = 'NoChange' + } + + $modifiedResources.Add($record) + } +} + +# ----------------- +# Report +# ----------------- +if ($modifiedResources.Count -gt 0) { + $csvPath = "ModifiedResources_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv" + $modifiedResources | Export-Csv -Path $csvPath -NoTypeInformation + Write-Output "CSV report saved to: $csvPath" + + $applied = ($modifiedResources | Where-Object { $_.LicenseStatus -eq 'Updated' -or $_.EsuStatus -eq 'Updated' }).Count + $would = ($modifiedResources | Where-Object { $_.LicenseStatus -eq 'WouldUpdate' -or $_.EsuStatus -eq 'WouldUpdate' }).Count + $failed = ($modifiedResources | Where-Object { $_.LicenseStatus -eq 'Failed' -or $_.EsuStatus -eq 'Failed' }).Count + Write-Output '' + Write-Output '================ Azure SQL VM License Update Summary ================' + Write-Output "Resources reviewed: $($modifiedResources.Count)" + Write-Output "Applied changes: $applied" + Write-Output "Would change (-ReportOnly): $would" + Write-Output "Failed: $failed" +} +else { + Write-Output 'No resources matched the requested scope. No CSV generated.' +} + +$scriptEndTime = Get-Date +$executionDuration = $scriptEndTime - $scriptStartTime +Write-Output "Script execution ended at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))" +Write-Output "Total execution time: $($executionDuration.ToString('hh\:mm\:ss'))" +Stop-Transcript | Out-Null