diff --git a/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/README.md b/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/README.md index cb6ce7b66..d49534415 100644 --- a/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/README.md +++ b/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/README.md @@ -2,7 +2,7 @@ This repo deploys and remediates a custom Azure Policy that configures and enforces Arc-enabled SQL Server extension `LicenseType` to a selected target value (for example `Paid` or `PAYG`). -## What Is In This Repo +## What Is In This Folder - `policy/azurepolicy.json`: Custom policy definition (DeployIfNotExists). - `scripts/deployment.ps1`: Creates/updates the policy definition and policy assignment. @@ -21,7 +21,7 @@ Parameter reference: | Parameter | Required | Default | Allowed values | Description | |---|---|---|---|---| -| `ManagementGroupId` | Yes | N/A | Any valid management group ID | Scope where the policy definition is created. | +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Scope where the policy definition is created. Defaults to the tenant root management group when not specified. | | `ExtensionType` | No | `Both` | `Windows`, `Linux`, `Both` | Targets the Arc SQL extension platform. When `Both` (default), a single policy definition and assignment covers both platforms. When a specific type is selected, the naming and scope are tailored to that platform. | | `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, policy assignment scope is the subscription. | | `TargetLicenseType` | Yes | N/A | `Paid`, `PAYG` | Target `LicenseType` value to enforce. | @@ -29,48 +29,83 @@ Parameter reference: Definition and assignment creation: -1. Clone the repo. +1. Download the required files. ```powershell -git clone https://github.com/microsoft/sql-server-samples.git -cd sql-server-samples/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance +# Optional: create and enter a local working directory +mkdir sql-arc-lt-compliance +cd sql-arc-lt-compliance ``` +```powershell +$baseUrl = "https://raw.githubusercontent.com/microsoft/sql-server-samples/master/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance" + +New-Item -ItemType Directory -Path policy, scripts -Force | Out-Null + +curl -sLo policy/azurepolicy.json "$baseUrl/policy/azurepolicy.json" +curl -sLo scripts/deployment.ps1 "$baseUrl/scripts/deployment.ps1" +curl -sLo scripts/start-remediation.ps1 "$baseUrl/scripts/start-remediation.ps1" +``` + +> **Note:** On Windows PowerShell 5.1, `curl` is an alias for `Invoke-WebRequest`. Use `curl.exe` instead, or run the commands in PowerShell 7+. + 2. Login to Azure. ```powershell Connect-AzAccount ``` +3. Set your variables. Only `TargetLicenseType` is required — all others are optional. + ```powershell -# Example: target both platforms (default) -.\scripts\deployment.ps1 -ManagementGroupId "" -SubscriptionId "" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid") +# ── Required ── +$TargetLicenseType = "PAYG" # "Paid" or "PAYG" + +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: policy assigned at management group scope +# $ExtensionType = "Both" # "Windows", "Linux", or "Both" (default) +# $LicenseTypesToOverwrite = @("Unspecified","Paid","PAYG","LicenseOnly") # Default: all +``` + +4. Run the deployment. -# Example: target only Linux -.\scripts\deployment.ps1 -ManagementGroupId "" -ExtensionType "Linux" -SubscriptionId "" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid") +```powershell +# Minimal — uses defaults for management group, platform, and overwrite targets +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType + +# With subscription scope +.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId + +# With all options +.\scripts\deployment.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -SubscriptionId $SubscriptionId ` + -ExtensionType $ExtensionType ` + -TargetLicenseType $TargetLicenseType ` + -LicenseTypesToOverwrite $LicenseTypesToOverwrite ``` -The first example (without `-ExtensionType`) will: -* Create/update a single policy definition and assignment covering **both** Windows and Linux. -* Assign that policy at the specified subscription scope. -* Enforce LicenseType = PAYG. -* Update only resources where current `LicenseType` is `Paid`. -The second example creates a Linux-specific definition and assignment, with platform-tailored naming. +This will: +* Create/update the policy definition at the management group scope. +* Create/assign the policy (at subscription scope when `-SubscriptionId` is provided, otherwise at management group scope). +* Target the selected `ExtensionType` platform(s) — `Both` by default covers Windows and Linux. +* Enforce the selected `TargetLicenseType` on resources matching the `LicenseTypesToOverwrite` filter. -Scenario examples: +**Scenario examples:** ```powershell -# Target Paid, both Linux and Windows, but only for resources with missing LicenseType or LicenseOnly (do not target PAYG) -.\scripts\deployment.ps1 -ManagementGroupId "" -TargetLicenseType "Paid" -LicenseTypesToOverwrite @("Unspecified","LicenseOnly") +# Move all Paid licenses to PAYG, both platforms +.\scripts\deployment.ps1 -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid") -# Target PAYG, but only where current LicenseType is Paid (do not target missing or LicenseOnly) -.\scripts\deployment.ps1 -ManagementGroupId "" -ExtensionType "Linux" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid") +# Set missing and LicenseOnly to Paid, skip resources already on PAYG +.\scripts\deployment.ps1 -TargetLicenseType "Paid" -LicenseTypesToOverwrite @("Unspecified","LicenseOnly") -# Overwrite all known existing LicenseType values (Paid, PAYG, LicenseOnly), but not missing -.\scripts\deployment.ps1 -ManagementGroupId "" -ExtensionType "Linux" -TargetLicenseType "Paid" -LicenseTypesToOverwrite @("Paid","PAYG","LicenseOnly") +# Linux only — move Paid to PAYG at a specific subscription +.\scripts\deployment.ps1 -ExtensionType "Linux" -SubscriptionId "" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid") ``` -Note: `scripts/deployment.ps1` automatically grants required roles to the policy assignment managed identity at assignment scope, preventing common `PolicyAuthorizationFailed` errors during DeployIfNotExists deployments. +> **Note:** `deployment.ps1` automatically grants required roles to the policy assignment managed identity at assignment scope, preventing common `PolicyAuthorizationFailed` errors during DeployIfNotExists deployments. ## Start Remediation @@ -78,20 +113,52 @@ Parameter reference: | Parameter | Required | Default | Allowed values | Description | |---|---|---|---|---| -| `ManagementGroupId` | Yes | N/A | Any valid management group ID | Used to resolve the policy definition/assignment naming context. | +| `ManagementGroupId` | No | Tenant root group | Any valid management group ID | Used to resolve the policy definition/assignment naming context. Defaults to the tenant root management group when not specified. | | `ExtensionType` | No | `Both` | `Windows`, `Linux`, `Both` | Must match the platform used for the assignment. When `Both` (default), remediates the combined assignment. | | `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, remediation runs at subscription scope. | | `TargetLicenseType` | Yes | N/A | `Paid`, `PAYG` | Must match the assignment target license type. | | `GrantMissingPermissions` | No | `false` | Switch (`present`/`not present`) | If set, checks and assigns missing required roles before remediation. | +1. Set your variables. `TargetLicenseType` is required and must match the value used during deployment — all others are optional. + ```powershell -# Example: remediate both platforms (default) -.\scripts\start-remediation.ps1 -ManagementGroupId "" -SubscriptionId "" -TargetLicenseType "PAYG" -GrantMissingPermissions +# ── Required ── +$TargetLicenseType = "PAYG" # Must match the deployment target -# Example: remediate only Linux -.\scripts\start-remediation.ps1 -ManagementGroupId "" -ExtensionType "Linux" -SubscriptionId "" -TargetLicenseType "PAYG" -GrantMissingPermissions +# ── Optional (uncomment to override defaults) ── +# $ManagementGroupId = "" # Default: tenant root management group +# $SubscriptionId = "" # Default: remediation runs at management group scope +# $ExtensionType = "Both" # Must match the platform used for deployment ``` +2. Run the remediation. + +```powershell +# Minimal — uses defaults for management group and platform +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -GrantMissingPermissions + +# With subscription scope +.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId -GrantMissingPermissions + +# With all options +.\scripts\start-remediation.ps1 ` + -ManagementGroupId $ManagementGroupId ` + -ExtensionType $ExtensionType ` + -SubscriptionId $SubscriptionId ` + -TargetLicenseType $TargetLicenseType ` + -GrantMissingPermissions +``` + +> **Note:** Use `-GrantMissingPermissions` to automatically check and assign any missing required roles before remediation starts. + +## Recurring Billing Consent (PAYG) + +When `TargetLicenseType` is set to `PAYG`, the policy automatically includes `ConsentToRecurringPAYG` in the extension settings with `Consented: true` and a UTC timestamp. This is required for recurring pay-as-you-go billing as described in the [Microsoft documentation](https://learn.microsoft.com/en-us/sql/sql-server/azure-arc/manage-pay-as-you-go-transition?view=sql-server-ver17#recurring-billing-consent). + +The policy also checks for `ConsentToRecurringPAYG` in its compliance evaluation — resources with `LicenseType: PAYG` but missing the consent property are flagged as non-compliant and remediated. This applies both when transitioning to PAYG and for existing PAYG extensions that predate the consent requirement (backward compatibility). + +> **Note:** Once `ConsentToRecurringPAYG` is set on an extension, it cannot be removed — this is enforced by the Azure resource provider. When transitioning away from PAYG, the policy changes `LicenseType` but leaves the consent property in place. + ## Managed Identity And Roles The policy assignment is created with `-IdentityType SystemAssigned`. Azure creates a managed identity on the assignment and uses it to apply DeployIfNotExists changes during enforcement and remediation. @@ -110,11 +177,4 @@ Use one of these options: - Re-run `scripts/deployment.ps1` (default behavior assigns `Resource Policy Contributor` automatically). - Re-run `scripts/deployment.ps1` (default behavior assigns required roles automatically). -- Run `scripts/start-remediation.ps1 -GrantMissingPermissions` (checks and assigns missing required roles before remediation). - -## Screenshots - -![overview](./docs/screenshots/overview.png) -![pre-policy](./docs/screenshots/pre-policy.png) -![remediation](./docs/screenshots/remediation.png) -![postpolicy](./docs/screenshots/post-policy.png) \ No newline at end of file +- Run `scripts/start-remediation.ps1 -GrantMissingPermissions` (checks and assigns missing required roles before remediation). \ No newline at end of file diff --git a/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/policy/azurepolicy.json b/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/policy/azurepolicy.json index 5450371d0..c6d6f5075 100644 --- a/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/policy/azurepolicy.json +++ b/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/policy/azurepolicy.json @@ -1,8 +1,8 @@ { - "displayName": "Set Arc-enabled SQL Server license type to 'License With Software Assurance'", + "displayName": "Configure Arc-enabled SQL Server license type", "policyType": "Custom", "mode": "Indexed", - "description": "This policy sets the license type for Arc-enabled SQL Server to 'License With Software Assurance'. ", + "description": "This policy configures the license type for Arc-enabled SQL Server extensions to a specified target value.", "metadata": { "category": "" }, @@ -112,6 +112,26 @@ { "value": "[length(intersection(field('Microsoft.HybridCompute/machines/extensions/settings'), createObject('LicenseType', parameters('targetLicenseType'))))]", "equals": 1 + }, + { + "anyOf": [ + { + "value": "[parameters('targetLicenseType')]", + "notEquals": "PAYG" + }, + { + "allOf": [ + { + "value": "[parameters('targetLicenseType')]", + "equals": "PAYG" + }, + { + "field": "Microsoft.HybridCompute/machines/extensions/settings", + "ContainsKey": "ConsentToRecurringPAYG" + } + ] + } + ] } ] }, @@ -156,6 +176,10 @@ { "value": "[contains(parameters('licenseTypesToOverwrite'), 'PAYG')]", "equals": false + }, + { + "field": "Microsoft.HybridCompute/machines/extensions/settings", + "ContainsKey": "ConsentToRecurringPAYG" } ] }, @@ -214,14 +238,29 @@ "metadata": { "description": "LicenseType value to set on the extension." } + }, + "consentTimestamp": { + "type": "string", + "defaultValue": "[utcNow('yyyy-MM-ddTHH:mm:ssZ')]", + "metadata": { + "description": "UTC timestamp for recurring PAYG consent. Auto-generated at deployment time." + } } }, "functions": [], "variables": { "vmExtensionPublisher": "Microsoft.AzureData", - "licenseSettings": { + "baseSettings": { "LicenseType": "[parameters('targetLicenseType')]" - } + }, + "paygConsentSettings": { + "LicenseType": "[parameters('targetLicenseType')]", + "ConsentToRecurringPAYG": { + "Consented": true, + "ConsentTimestamp": "[parameters('consentTimestamp')]" + } + }, + "licenseSettings": "[if(equals(parameters('targetLicenseType'), 'PAYG'), variables('paygConsentSettings'), variables('baseSettings'))]" }, "resources": [ { diff --git a/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/deployment.ps1 b/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/deployment.ps1 index 98aab3392..a9b4e4475 100644 --- a/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/deployment.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/deployment.ps1 @@ -1,5 +1,5 @@ param( - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$ManagementGroupId, @@ -23,6 +23,11 @@ param( [switch]$SkipManagedIdentityRoleAssignment ) +if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" +} + $AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" if ($PSBoundParameters.ContainsKey('SubscriptionId')) { @@ -55,14 +60,9 @@ else { $PolicyDefinitionName = "activate-sql-arc-$LicenseToken-$PlatformToken" $PolicyAssignmentName = "sql-arc-$LicenseToken-$PlatformToken" -if ($TargetLicenseType -eq 'PAYG') { - $PolicyDefinitionDisplayName = "Arc-enabled SQL Server ($PlatformLabel) license type to 'Pay-as-you-go'" - $PolicyAssignmentDisplayName = "Arc-enabled SQL Server ($PlatformLabel) license type to 'Pay-as-you-go'" -} -else { - $PolicyDefinitionDisplayName = "Set Arc-enabled SQL Server ($PlatformLabel) license type to 'License With Software Assurance'" - $PolicyAssignmentDisplayName = "Set Arc-enabled SQL Server ($PlatformLabel) license type to 'License With Software Assurance'" -} +$LicenseTypeLabel = if ($TargetLicenseType -eq 'PAYG') { 'Pay-as-you-go' } else { 'License With Software Assurance' } +$PolicyDefinitionDisplayName = "Configure Arc-enabled SQL Server ($PlatformLabel) license type to '$LicenseTypeLabel'" +$PolicyAssignmentDisplayName = "Configure Arc-enabled SQL Server ($PlatformLabel) license type to '$LicenseTypeLabel'" #Create policy definition New-AzPolicyDefinition ` @@ -109,13 +109,29 @@ if (-not $SkipManagedIdentityRoleAssignment) { -ErrorAction SilentlyContinue if (-not $existingRole) { - New-AzRoleAssignment ` - -ObjectId $principalId ` - -RoleDefinitionName $requiredRoleName ` - -Scope $AssignmentScope ` - -ErrorAction Stop | Out-Null - - Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope." + $maxRetries = 5 + $retryDelay = 10 + for ($i = 1; $i -le $maxRetries; $i++) { + try { + New-AzRoleAssignment ` + -ObjectId $principalId ` + -RoleDefinitionName $requiredRoleName ` + -Scope $AssignmentScope ` + -ErrorAction Stop | Out-Null + + Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope." + break + } + catch { + if ($_.Exception.Message -match 'Conflict') { + Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope (confirmed after retry)." + break + } + if ($i -eq $maxRetries) { throw } + Write-Output "Waiting ${retryDelay}s for identity replication before assigning '$requiredRoleName' ($i/$maxRetries)..." + Start-Sleep -Seconds $retryDelay + } + } } else { Write-Output "Policy assignment identity already has '$requiredRoleName' at scope $AssignmentScope." diff --git a/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/start-remediation.ps1 b/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/start-remediation.ps1 index b2f895c0b..1ea317104 100644 --- a/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/start-remediation.ps1 +++ b/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/start-remediation.ps1 @@ -1,5 +1,5 @@ param( - [Parameter(Mandatory = $true)] + [Parameter(Mandatory = $false)] [ValidateNotNullOrEmpty()] [string]$ManagementGroupId, @@ -31,6 +31,11 @@ param( [switch]$GrantMissingPermissions ) +if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) { + $ManagementGroupId = (Get-AzContext).Tenant.Id + Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId" +} + $AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId" if ($PSBoundParameters.ContainsKey('SubscriptionId')) {