Skip to content

Commit b1ff6e6

Browse files
authored
Merge pull request #1476 from claestom/sql-arc-lt-policy-v2
Improve usability of Arc SQL license type policy sample
2 parents 6c6eba3 + 3159341 commit b1ff6e6

File tree

4 files changed

+177
-57
lines changed

4 files changed

+177
-57
lines changed

samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/README.md

Lines changed: 96 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
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`).
44

5-
## What Is In This Repo
5+
## What Is In This Folder
66

77
- `policy/azurepolicy.json`: Custom policy definition (DeployIfNotExists).
88
- `scripts/deployment.ps1`: Creates/updates the policy definition and policy assignment.
@@ -21,77 +21,144 @@ Parameter reference:
2121

2222
| Parameter | Required | Default | Allowed values | Description |
2323
|---|---|---|---|---|
24-
| `ManagementGroupId` | Yes | N/A | Any valid management group ID | Scope where the policy definition is created. |
24+
| `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. |
2525
| `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. |
2626
| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, policy assignment scope is the subscription. |
2727
| `TargetLicenseType` | Yes | N/A | `Paid`, `PAYG` | Target `LicenseType` value to enforce. |
2828
| `LicenseTypesToOverwrite` | No | All | `Unspecified`, `Paid`, `PAYG`, `LicenseOnly` | Select which current license states are eligible for update. Use `Unspecified` to include resources with no `LicenseType` configured. |
2929

3030
Definition and assignment creation:
3131

32-
1. Clone the repo.
32+
1. Download the required files.
3333

3434
```powershell
35-
git clone https://github.com/microsoft/sql-server-samples.git
36-
cd sql-server-samples/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance
35+
# Optional: create and enter a local working directory
36+
mkdir sql-arc-lt-compliance
37+
cd sql-arc-lt-compliance
3738
```
3839

40+
```powershell
41+
$baseUrl = "https://raw.githubusercontent.com/microsoft/sql-server-samples/master/samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance"
42+
43+
New-Item -ItemType Directory -Path policy, scripts -Force | Out-Null
44+
45+
curl -sLo policy/azurepolicy.json "$baseUrl/policy/azurepolicy.json"
46+
curl -sLo scripts/deployment.ps1 "$baseUrl/scripts/deployment.ps1"
47+
curl -sLo scripts/start-remediation.ps1 "$baseUrl/scripts/start-remediation.ps1"
48+
```
49+
50+
> **Note:** On Windows PowerShell 5.1, `curl` is an alias for `Invoke-WebRequest`. Use `curl.exe` instead, or run the commands in PowerShell 7+.
51+
3952
2. Login to Azure.
4053

4154
```powershell
4255
Connect-AzAccount
4356
```
4457

58+
3. Set your variables. Only `TargetLicenseType` is required — all others are optional.
59+
4560
```powershell
46-
# Example: target both platforms (default)
47-
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")
61+
# ── Required ──
62+
$TargetLicenseType = "PAYG" # "Paid" or "PAYG"
63+
64+
# ── Optional (uncomment to override defaults) ──
65+
# $ManagementGroupId = "<management-group-id>" # Default: tenant root management group
66+
# $SubscriptionId = "<subscription-id>" # Default: policy assigned at management group scope
67+
# $ExtensionType = "Both" # "Windows", "Linux", or "Both" (default)
68+
# $LicenseTypesToOverwrite = @("Unspecified","Paid","PAYG","LicenseOnly") # Default: all
69+
```
70+
71+
4. Run the deployment.
4872

49-
# Example: target only Linux
50-
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -ExtensionType "Linux" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")
73+
```powershell
74+
# Minimal — uses defaults for management group, platform, and overwrite targets
75+
.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType
76+
77+
# With subscription scope
78+
.\scripts\deployment.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId
79+
80+
# With all options
81+
.\scripts\deployment.ps1 `
82+
-ManagementGroupId $ManagementGroupId `
83+
-SubscriptionId $SubscriptionId `
84+
-ExtensionType $ExtensionType `
85+
-TargetLicenseType $TargetLicenseType `
86+
-LicenseTypesToOverwrite $LicenseTypesToOverwrite
5187
```
52-
The first example (without `-ExtensionType`) will:
53-
* Create/update a single policy definition and assignment covering **both** Windows and Linux.
54-
* Assign that policy at the specified subscription scope.
55-
* Enforce LicenseType = PAYG.
56-
* Update only resources where current `LicenseType` is `Paid`.
5788

58-
The second example creates a Linux-specific definition and assignment, with platform-tailored naming.
89+
This will:
90+
* Create/update the policy definition at the management group scope.
91+
* Create/assign the policy (at subscription scope when `-SubscriptionId` is provided, otherwise at management group scope).
92+
* Target the selected `ExtensionType` platform(s) — `Both` by default covers Windows and Linux.
93+
* Enforce the selected `TargetLicenseType` on resources matching the `LicenseTypesToOverwrite` filter.
5994

60-
Scenario examples:
95+
**Scenario examples:**
6196

6297
```powershell
63-
# Target Paid, both Linux and Windows, but only for resources with missing LicenseType or LicenseOnly (do not target PAYG)
64-
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -TargetLicenseType "Paid" -LicenseTypesToOverwrite @("Unspecified","LicenseOnly")
98+
# Move all Paid licenses to PAYG, both platforms
99+
.\scripts\deployment.ps1 -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")
65100
66-
# Target PAYG, but only where current LicenseType is Paid (do not target missing or LicenseOnly)
67-
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -ExtensionType "Linux" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")
101+
# Set missing and LicenseOnly to Paid, skip resources already on PAYG
102+
.\scripts\deployment.ps1 -TargetLicenseType "Paid" -LicenseTypesToOverwrite @("Unspecified","LicenseOnly")
68103
69-
# Overwrite all known existing LicenseType values (Paid, PAYG, LicenseOnly), but not missing
70-
.\scripts\deployment.ps1 -ManagementGroupId "<management-group-id>" -ExtensionType "Linux" -TargetLicenseType "Paid" -LicenseTypesToOverwrite @("Paid","PAYG","LicenseOnly")
104+
# Linux only — move Paid to PAYG at a specific subscription
105+
.\scripts\deployment.ps1 -ExtensionType "Linux" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -LicenseTypesToOverwrite @("Paid")
71106
```
72107

73-
Note: `scripts/deployment.ps1` automatically grants required roles to the policy assignment managed identity at assignment scope, preventing common `PolicyAuthorizationFailed` errors during DeployIfNotExists deployments.
108+
> **Note:** `deployment.ps1` automatically grants required roles to the policy assignment managed identity at assignment scope, preventing common `PolicyAuthorizationFailed` errors during DeployIfNotExists deployments.
74109
75110
## Start Remediation
76111

77112
Parameter reference:
78113

79114
| Parameter | Required | Default | Allowed values | Description |
80115
|---|---|---|---|---|
81-
| `ManagementGroupId` | Yes | N/A | Any valid management group ID | Used to resolve the policy definition/assignment naming context. |
116+
| `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. |
82117
| `ExtensionType` | No | `Both` | `Windows`, `Linux`, `Both` | Must match the platform used for the assignment. When `Both` (default), remediates the combined assignment. |
83118
| `SubscriptionId` | No | Not set | Any valid subscription ID | If provided, remediation runs at subscription scope. |
84119
| `TargetLicenseType` | Yes | N/A | `Paid`, `PAYG` | Must match the assignment target license type. |
85120
| `GrantMissingPermissions` | No | `false` | Switch (`present`/`not present`) | If set, checks and assigns missing required roles before remediation. |
86121

122+
1. Set your variables. `TargetLicenseType` is required and must match the value used during deployment — all others are optional.
123+
87124
```powershell
88-
# Example: remediate both platforms (default)
89-
.\scripts\start-remediation.ps1 -ManagementGroupId "<management-group-id>" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -GrantMissingPermissions
125+
# ── Required ──
126+
$TargetLicenseType = "PAYG" # Must match the deployment target
90127
91-
# Example: remediate only Linux
92-
.\scripts\start-remediation.ps1 -ManagementGroupId "<management-group-id>" -ExtensionType "Linux" -SubscriptionId "<subscription-id>" -TargetLicenseType "PAYG" -GrantMissingPermissions
128+
# ── Optional (uncomment to override defaults) ──
129+
# $ManagementGroupId = "<management-group-id>" # Default: tenant root management group
130+
# $SubscriptionId = "<subscription-id>" # Default: remediation runs at management group scope
131+
# $ExtensionType = "Both" # Must match the platform used for deployment
93132
```
94133

134+
2. Run the remediation.
135+
136+
```powershell
137+
# Minimal — uses defaults for management group and platform
138+
.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -GrantMissingPermissions
139+
140+
# With subscription scope
141+
.\scripts\start-remediation.ps1 -TargetLicenseType $TargetLicenseType -SubscriptionId $SubscriptionId -GrantMissingPermissions
142+
143+
# With all options
144+
.\scripts\start-remediation.ps1 `
145+
-ManagementGroupId $ManagementGroupId `
146+
-ExtensionType $ExtensionType `
147+
-SubscriptionId $SubscriptionId `
148+
-TargetLicenseType $TargetLicenseType `
149+
-GrantMissingPermissions
150+
```
151+
152+
> **Note:** Use `-GrantMissingPermissions` to automatically check and assign any missing required roles before remediation starts.
153+
154+
## Recurring Billing Consent (PAYG)
155+
156+
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).
157+
158+
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).
159+
160+
> **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.
161+
95162
## Managed Identity And Roles
96163

97164
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:
110177

111178
- Re-run `scripts/deployment.ps1` (default behavior assigns `Resource Policy Contributor` automatically).
112179
- Re-run `scripts/deployment.ps1` (default behavior assigns required roles automatically).
113-
- Run `scripts/start-remediation.ps1 -GrantMissingPermissions` (checks and assigns missing required roles before remediation).
114-
115-
## Screenshots
116-
117-
![overview](./docs/screenshots/overview.png)
118-
![pre-policy](./docs/screenshots/pre-policy.png)
119-
![remediation](./docs/screenshots/remediation.png)
120-
![postpolicy](./docs/screenshots/post-policy.png)
180+
- Run `scripts/start-remediation.ps1 -GrantMissingPermissions` (checks and assigns missing required roles before remediation).

samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/policy/azurepolicy.json

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
{
2-
"displayName": "Set Arc-enabled SQL Server license type to 'License With Software Assurance'",
2+
"displayName": "Configure Arc-enabled SQL Server license type",
33
"policyType": "Custom",
44
"mode": "Indexed",
5-
"description": "This policy sets the license type for Arc-enabled SQL Server to 'License With Software Assurance'. ",
5+
"description": "This policy configures the license type for Arc-enabled SQL Server extensions to a specified target value.",
66
"metadata": {
77
"category": ""
88
},
@@ -112,6 +112,26 @@
112112
{
113113
"value": "[length(intersection(field('Microsoft.HybridCompute/machines/extensions/settings'), createObject('LicenseType', parameters('targetLicenseType'))))]",
114114
"equals": 1
115+
},
116+
{
117+
"anyOf": [
118+
{
119+
"value": "[parameters('targetLicenseType')]",
120+
"notEquals": "PAYG"
121+
},
122+
{
123+
"allOf": [
124+
{
125+
"value": "[parameters('targetLicenseType')]",
126+
"equals": "PAYG"
127+
},
128+
{
129+
"field": "Microsoft.HybridCompute/machines/extensions/settings",
130+
"ContainsKey": "ConsentToRecurringPAYG"
131+
}
132+
]
133+
}
134+
]
115135
}
116136
]
117137
},
@@ -156,6 +176,10 @@
156176
{
157177
"value": "[contains(parameters('licenseTypesToOverwrite'), 'PAYG')]",
158178
"equals": false
179+
},
180+
{
181+
"field": "Microsoft.HybridCompute/machines/extensions/settings",
182+
"ContainsKey": "ConsentToRecurringPAYG"
159183
}
160184
]
161185
},
@@ -214,14 +238,29 @@
214238
"metadata": {
215239
"description": "LicenseType value to set on the extension."
216240
}
241+
},
242+
"consentTimestamp": {
243+
"type": "string",
244+
"defaultValue": "[utcNow('yyyy-MM-ddTHH:mm:ssZ')]",
245+
"metadata": {
246+
"description": "UTC timestamp for recurring PAYG consent. Auto-generated at deployment time."
247+
}
217248
}
218249
},
219250
"functions": [],
220251
"variables": {
221252
"vmExtensionPublisher": "Microsoft.AzureData",
222-
"licenseSettings": {
253+
"baseSettings": {
223254
"LicenseType": "[parameters('targetLicenseType')]"
224-
}
255+
},
256+
"paygConsentSettings": {
257+
"LicenseType": "[parameters('targetLicenseType')]",
258+
"ConsentToRecurringPAYG": {
259+
"Consented": true,
260+
"ConsentTimestamp": "[parameters('consentTimestamp')]"
261+
}
262+
},
263+
"licenseSettings": "[if(equals(parameters('targetLicenseType'), 'PAYG'), variables('paygConsentSettings'), variables('baseSettings'))]"
225264
},
226265
"resources": [
227266
{

samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/deployment.ps1

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
param(
2-
[Parameter(Mandatory = $true)]
2+
[Parameter(Mandatory = $false)]
33
[ValidateNotNullOrEmpty()]
44
[string]$ManagementGroupId,
55

@@ -23,6 +23,11 @@ param(
2323
[switch]$SkipManagedIdentityRoleAssignment
2424
)
2525

26+
if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) {
27+
$ManagementGroupId = (Get-AzContext).Tenant.Id
28+
Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId"
29+
}
30+
2631
$AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId"
2732

2833
if ($PSBoundParameters.ContainsKey('SubscriptionId')) {
@@ -55,14 +60,9 @@ else {
5560
$PolicyDefinitionName = "activate-sql-arc-$LicenseToken-$PlatformToken"
5661
$PolicyAssignmentName = "sql-arc-$LicenseToken-$PlatformToken"
5762

58-
if ($TargetLicenseType -eq 'PAYG') {
59-
$PolicyDefinitionDisplayName = "Arc-enabled SQL Server ($PlatformLabel) license type to 'Pay-as-you-go'"
60-
$PolicyAssignmentDisplayName = "Arc-enabled SQL Server ($PlatformLabel) license type to 'Pay-as-you-go'"
61-
}
62-
else {
63-
$PolicyDefinitionDisplayName = "Set Arc-enabled SQL Server ($PlatformLabel) license type to 'License With Software Assurance'"
64-
$PolicyAssignmentDisplayName = "Set Arc-enabled SQL Server ($PlatformLabel) license type to 'License With Software Assurance'"
65-
}
63+
$LicenseTypeLabel = if ($TargetLicenseType -eq 'PAYG') { 'Pay-as-you-go' } else { 'License With Software Assurance' }
64+
$PolicyDefinitionDisplayName = "Configure Arc-enabled SQL Server ($PlatformLabel) license type to '$LicenseTypeLabel'"
65+
$PolicyAssignmentDisplayName = "Configure Arc-enabled SQL Server ($PlatformLabel) license type to '$LicenseTypeLabel'"
6666

6767
#Create policy definition
6868
New-AzPolicyDefinition `
@@ -109,13 +109,29 @@ if (-not $SkipManagedIdentityRoleAssignment) {
109109
-ErrorAction SilentlyContinue
110110

111111
if (-not $existingRole) {
112-
New-AzRoleAssignment `
113-
-ObjectId $principalId `
114-
-RoleDefinitionName $requiredRoleName `
115-
-Scope $AssignmentScope `
116-
-ErrorAction Stop | Out-Null
117-
118-
Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope."
112+
$maxRetries = 5
113+
$retryDelay = 10
114+
for ($i = 1; $i -le $maxRetries; $i++) {
115+
try {
116+
New-AzRoleAssignment `
117+
-ObjectId $principalId `
118+
-RoleDefinitionName $requiredRoleName `
119+
-Scope $AssignmentScope `
120+
-ErrorAction Stop | Out-Null
121+
122+
Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope."
123+
break
124+
}
125+
catch {
126+
if ($_.Exception.Message -match 'Conflict') {
127+
Write-Output "Assigned '$requiredRoleName' to policy assignment identity ($principalId) at scope $AssignmentScope (confirmed after retry)."
128+
break
129+
}
130+
if ($i -eq $maxRetries) { throw }
131+
Write-Output "Waiting ${retryDelay}s for identity replication before assigning '$requiredRoleName' ($i/$maxRetries)..."
132+
Start-Sleep -Seconds $retryDelay
133+
}
134+
}
119135
}
120136
else {
121137
Write-Output "Policy assignment identity already has '$requiredRoleName' at scope $AssignmentScope."

samples/manage/azure-arc-enabled-sql-server/compliance/arc-sql-license-type-compliance/scripts/start-remediation.ps1

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
param(
2-
[Parameter(Mandatory = $true)]
2+
[Parameter(Mandatory = $false)]
33
[ValidateNotNullOrEmpty()]
44
[string]$ManagementGroupId,
55

@@ -31,6 +31,11 @@ param(
3131
[switch]$GrantMissingPermissions
3232
)
3333

34+
if (-not $PSBoundParameters.ContainsKey('ManagementGroupId')) {
35+
$ManagementGroupId = (Get-AzContext).Tenant.Id
36+
Write-Output "ManagementGroupId not specified. Using tenant root management group: $ManagementGroupId"
37+
}
38+
3439
$AssignmentScope = "/providers/Microsoft.Management/managementGroups/$ManagementGroupId"
3540

3641
if ($PSBoundParameters.ContainsKey('SubscriptionId')) {

0 commit comments

Comments
 (0)