diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b37225a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on Keep a Changelog, and this project aims to follow Semantic Versioning. + +## [Unreleased] + +### Added + +- README setup guidance for installing DSC v3 and registering resource discovery paths. +- Parameter reference documentation for Microsoft.Azure.Arc/AgentConfiguration. +- Operation examples for export, get, test, and set. diff --git a/README.md b/README.md index 1203a7e..190a7ef 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,73 @@ # AzureConnectedMachineDsc -DSC resource for managing the state of Azure Arc for servers agent + +DSC resource for managing Azure Arc for Servers agent configuration. + +## Prerequisites + +- Windows PowerShell 7 (`pwsh`) available in `PATH` +- Azure Arc agent installed (`azcmagent` command available) +- Administrator session for `set` operations (resource requires elevated security context) + +## Install DSC v3 + +### Stable + +```powershell +winget install --id Microsoft.DSC --exact --source winget +``` + +### Preview (optional) + +```powershell +winget install --id Microsoft.DSC.Preview --exact --source winget +``` + +### Verify installation + +```powershell +dsc --version +``` + +## Make the resource discoverable + +This repository keeps the resource files in `dsc_resources`. + +You can use one of two methods: + +### Method 1 (recommended): set `DSC_RESOURCE_PATH` + +```powershell +$repoRoot = "D:\Git\AzureConnectedMachineDsc" +$env:DSC_RESOURCE_PATH = Join-Path $repoRoot 'dsc_resources' + +# Optional: persist for future sessions +[System.Environment]::SetEnvironmentVariable('DSC_RESOURCE_PATH', $env:DSC_RESOURCE_PATH, 'User') +``` + +### Method 2: copy resource files to DSC root folder + +```powershell +$repoRoot = "D:\Git\AzureConnectedMachineDsc" +$resourceSource = Join-Path $repoRoot 'dsc_resources\*' +$dscRoot = Split-Path (Get-Command dsc -ErrorAction Stop).Source -Parent + +Copy-Item -Path $resourceSource -Destination $dscRoot -Recurse -Force +``` + +## Quick validation + +```powershell +# List resources and confirm the custom type is found +dsc resource list | Select-String 'Microsoft.Azure.Arc/AgentConfiguration' + +# Get current state +'{}' | dsc resource get -r Microsoft.Azure.Arc/AgentConfiguration -f - | ConvertFrom-Json +``` + +## Run tests + +```powershell +Set-Location D:\Git\AzureConnectedMachineDsc\dsc_resources\tests +$env:DSC_RESOURCE_PATH = "D:\Git\AzureConnectedMachineDsc\dsc_resources" +Invoke-Pester -Path .\azure_arc_agent.tests.ps1 -Output Detailed +``` diff --git a/docs/azure_arc_agent-operations-examples.md b/docs/azure_arc_agent-operations-examples.md new file mode 100644 index 0000000..cc08c80 --- /dev/null +++ b/docs/azure_arc_agent-operations-examples.md @@ -0,0 +1,114 @@ +# Microsoft.Azure.Arc/AgentConfiguration operation examples + +Examples in this file are based on test behavior in dsc_resources/tests/azure_arc_agent.tests.ps1. + +## Prerequisites + +```powershell +$env:DSC_RESOURCE_PATH = "D:\Git\AzureConnectedMachineDsc\dsc_resources" +$resourceType = "Microsoft.Azure.Arc/AgentConfiguration" +``` + +## Get + +Use an empty input object to read current agent configuration. + +```powershell +'{}' | dsc resource get -r $resourceType -f - | ConvertFrom-Json +``` + +Expected fields in output include: + +- actualState.agentInstalled +- actualState.incomingConnectionsEnabled +- actualState.guestConfigurationEnabled +- actualState.extensionsEnabled +- actualState.extensionAllowlist +- actualState.extensionBlocklist +- actualState.configMode + +## Test + +Provide the desired configuration and evaluate drift. + +```powershell +$desired = @{ + incomingConnectionsEnabled = $false + guestConfigurationEnabled = $false + extensionsEnabled = $false + extensionAllowlist = @( + "Microsoft.Azure.AzureDefenderForServers/MDE.Windows" + "Microsoft.Azure.Monitor/AzureMonitorWindowsAgent" + ) + extensionBlocklist = @( + "Microsoft.Azure.Automation.HybridWorker/HybridWorkerForWindows" + "Microsoft.Azure.Automation/HybridWorkerForLinux" + "Microsoft.Azure.Extensions/CustomScript" + "Microsoft.Cplat.Core/RunCommandHandlerLinux" + "Microsoft.Cplat.Core/RunCommandHandlerWindows" + "Microsoft.Compute/CustomScriptExtension" + "Microsoft.EnterpriseCloud.Monitoring/MicrosoftMonitoringAgent" + "Microsoft.EnterpriseCloud.Monitoring/OMSAgentForLinux" + ) + configMode = "full" +} + +$desired | ConvertTo-Json -Depth 10 -Compress | + dsc resource test -r $resourceType -f - | + ConvertFrom-Json +``` + +Result includes: + +- desired properties echoed as current values for compared keys +- _inDesiredState (true or false) + +## Set + +Set applies desired values. This operation requires elevated permissions. + +```powershell +$desired = @{ + incomingConnectionsEnabled = $false + guestConfigurationEnabled = $false + extensionsEnabled = $false + extensionAllowlist = @( + "Microsoft.Azure.AzureDefenderForServers/MDE.Windows" + "Microsoft.Azure.Monitor/AzureMonitorWindowsAgent" + ) + extensionBlocklist = @( + "Microsoft.Azure.Automation.HybridWorker/HybridWorkerForWindows" + "Microsoft.Azure.Automation/HybridWorkerForLinux" + "Microsoft.Azure.Extensions/CustomScript" + "Microsoft.Cplat.Core/RunCommandHandlerLinux" + "Microsoft.Cplat.Core/RunCommandHandlerWindows" + "Microsoft.Compute/CustomScriptExtension" + "Microsoft.EnterpriseCloud.Monitoring/MicrosoftMonitoringAgent" + "Microsoft.EnterpriseCloud.Monitoring/OMSAgentForLinux" + ) + configMode = "full" +} + +$desired | ConvertTo-Json -Depth 10 -Compress | + dsc resource set -r $resourceType -f - | + ConvertFrom-Json +``` + +Expected afterState values in tests: + +- incomingConnectionsEnabled = false +- guestConfigurationEnabled = false +- extensionsEnabled = false +- extensionAllowlist contains Microsoft.Azure.AzureDefenderForServers/MDE.Windows and Microsoft.Azure.Monitor/AzureMonitorWindowsAgent +- extensionBlocklist contains Microsoft.Cplat.Core/RunCommandHandlerWindows +- configMode = full + +## Export + +Export returns current non-null properties as reusable configuration state. + +```powershell +'{}' | dsc resource export -r $resourceType -f - | ConvertFrom-Json +``` + +Typical use: pipe export output to file and use it as baseline desired state. diff --git a/docs/azure_arc_agent-parameters.md b/docs/azure_arc_agent-parameters.md new file mode 100644 index 0000000..306f831 --- /dev/null +++ b/docs/azure_arc_agent-parameters.md @@ -0,0 +1,39 @@ +# Microsoft.Azure.Arc/AgentConfiguration parameters + +This document describes all resource properties, allowed values, and usage notes for the DSC v3 resource type: + +- Microsoft.Azure.Arc/AgentConfiguration + +## Writable properties + +| Property | Type | Allowed values | Notes | +|---|---|---|---| +| incomingConnectionsEnabled | boolean or null | true, false, null | Maps to azcmagent config key incomingconnections.enabled. | +| guestConfigurationEnabled | boolean or null | true, false, null | Maps to azcmagent config key guestconfiguration.enabled. | +| extensionsEnabled | boolean or null | true, false, null | Maps to azcmagent config key extensions.enabled. | +| extensionAllowlist | array of string or null | null or list of extension names | Maps to azcmagent config key extensions.allowlist. Empty list is allowed. | +| extensionBlocklist | array of string or null | null or list of extension names | Maps to azcmagent config key extensions.blocklist. Empty list is allowed. | +| configMode | string or null | monitor, full, null | Any other value fails validation. Comparison is case-insensitive in processing. | +| proxyUrl | string or null | any string, null | Maps to azcmagent config key proxy.url. | + +## Read-only properties + +| Property | Type | Allowed values | Notes | +|---|---|---|---| +| agentInstalled | boolean or null | true, false, null | Returned by get/test/set state output. Not intended as input. | +| _inDesiredState | boolean or null | true, false, null | Returned by test output. Not intended as input. | + +## Input validation behavior + +- Unknown properties are rejected. +- Boolean properties accept boolean values and boolean-like strings convertible to true/false. +- configMode accepts only monitor or full. +- extensionAllowlist and extensionBlocklist are normalized as string lists (trimmed, deduplicated, sorted). + +## Security context requirement + +The set operation requires elevated security context. + +In resource manifest terms: + +- set.requireSecurityContext = elevated diff --git a/dsc_config_example/arc-agent-export.dsc.yaml b/dsc_config_example/arc-agent-export.dsc.yaml new file mode 100644 index 0000000..dd1fe21 --- /dev/null +++ b/dsc_config_example/arc-agent-export.dsc.yaml @@ -0,0 +1,10 @@ +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +metadata: + Microsoft.DSC: + requiredSecurityContext: current # this is the default and just used as an example indicating this config works for admins and non-admins +resources: + - name: Azure Arc - Guest Config + type: Microsoft.Azure.Arc/AgentConfiguration + properties: {} + + diff --git a/dsc_config_example/arc-agent.dsc.yaml b/dsc_config_example/arc-agent.dsc.yaml new file mode 100644 index 0000000..9bc8ab7 --- /dev/null +++ b/dsc_config_example/arc-agent.dsc.yaml @@ -0,0 +1,16 @@ +$schema: https://aka.ms/dsc/schemas/v3/bundled/config/document.json +metadata: + Microsoft.DSC: + requiredSecurityContext: current # this is the default and just used as an example indicating this config works for admins and non-admins +resources: + - name: Azure Arc - Guest Config + type: Microsoft.Azure.Arc/AgentConfiguration + properties: + guestConfigurationEnabled: true + incomingConnectionsEnabled: true + extensionAllowlist: + - Microsoft.Azure.Monitor/AzureMonitorWindowsAgent + extensionsEnabled: true + proxyUrl: http://myproxy:8080 + + diff --git a/dsc_resources/.project.data.json b/dsc_resources/.project.data.json new file mode 100644 index 0000000..ace57db --- /dev/null +++ b/dsc_resources/.project.data.json @@ -0,0 +1,11 @@ +{ + "Name": "azure_arc_agent", + "Kind": "Resource", + "SupportedPlatformOS": "Windows", + "CopyFiles": { + "Windows": [ + "azure_arc_agent.resource.ps1", + "azure_arc_agent.dsc.resource.json" + ] + } +} \ No newline at end of file diff --git a/dsc_resources/azure_arc_agent.dsc.resource.json b/dsc_resources/azure_arc_agent.dsc.resource.json new file mode 100644 index 0000000..bafda4c --- /dev/null +++ b/dsc_resources/azure_arc_agent.dsc.resource.json @@ -0,0 +1,146 @@ +{ + "$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json", + "type": "Microsoft.Azure.Arc/AgentConfiguration", + "description": "Configure Azure Arc Connected Machine agent local settings.", + "tags": [ + "Windows", + "Azure", + "Arc" + ], + "version": "0.1.0", + "get": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./azure_arc_agent.resource.ps1", + "get" + ], + "input": "stdin" + }, + "set": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./azure_arc_agent.resource.ps1", + "set" + ], + "implementsPretest": true, + "input": "stdin", + "return": "state", + "requireSecurityContext": "elevated" + }, + "test": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./azure_arc_agent.resource.ps1", + "test" + ], + "input": "stdin", + "return": "state" + }, + "export": { + "executable": "pwsh", + "args": [ + "-NoLogo", + "-NonInteractive", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-Command", + "$input | ./azure_arc_agent.resource.ps1", + "export" + ], + "input": "stdin" + }, + "exitCodes": { + "0": "Success", + "1": "Resource execution failed" + }, + "schema": { + "embedded": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": false, + "properties": { + "incomingConnectionsEnabled": { + "type": [ + "boolean", + "null" + ] + }, + "guestConfigurationEnabled": { + "type": [ + "boolean", + "null" + ] + }, + "extensionsEnabled": { + "type": [ + "boolean", + "null" + ] + }, + "extensionAllowlist": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "extensionBlocklist": { + "type": [ + "array", + "null" + ], + "items": { + "type": "string" + } + }, + "configMode": { + "type": [ + "string", + "null" + ] + }, + "proxyUrl": { + "type": [ + "string", + "null" + ] + }, + "agentInstalled": { + "type": [ + "boolean", + "null" + ], + "readOnly": true + }, + "_inDesiredState": { + "type": [ + "boolean", + "null" + ], + "readOnly": true + } + } + } + } +} \ No newline at end of file diff --git a/dsc_resources/azure_arc_agent.resource.ps1 b/dsc_resources/azure_arc_agent.resource.ps1 new file mode 100644 index 0000000..fa021d2 --- /dev/null +++ b/dsc_resources/azure_arc_agent.resource.ps1 @@ -0,0 +1,680 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +[CmdletBinding()] +param( + [Parameter(Mandatory = $true, Position = 0)] + [ValidateSet('Get', 'Set', 'Test', 'Export')] + [string]$Operation, + + [Parameter(Position = 1, ValueFromPipeline = $true)] + [AllowEmptyString()] + [string]$JsonInput +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$script:PropertyMap = @{ + incomingConnectionsEnabled = 'incomingconnections.enabled' + guestConfigurationEnabled = 'guestconfiguration.enabled' + extensionsEnabled = 'extensions.enabled' + extensionAllowlist = 'extensions.allowlist' + extensionBlocklist = 'extensions.blocklist' + configMode = 'config.mode' + proxyUrl = 'proxy.url' +} + +<# +.SYNOPSIS +Finds the azcmagent executable path. + +.DESCRIPTION +Resolves the azcmagent command from PATH and returns the resolved source path. + +.INPUTS +None. + +.OUTPUTS +System.String or System.Management.Automation.Language.NullString +Path to azcmagent when found; otherwise null. +#> +function Find-AzcmAgentCommand { + $command = Get-Command -Name 'azcmagent' -ErrorAction SilentlyContinue + if ($null -eq $command) { + return $null + } + + return $command.Source +} + +<# +.SYNOPSIS +Converts incoming resource JSON into a hashtable. + +.DESCRIPTION +Returns an empty hashtable for empty or whitespace input; otherwise parses JSON +input into a hashtable used by the resource operations. + +.PARAMETER InputObject +JSON string provided to the resource operation. + +.INPUTS +System.String + +.OUTPUTS +System.Collections.Hashtable +#> +function ConvertFrom-ResourceInput { + param( + [AllowEmptyString()] + [string]$InputObject + ) + + if ([string]::IsNullOrWhiteSpace($InputObject)) { + return @{} + } + + return $InputObject | ConvertFrom-Json -AsHashtable +} + +<# +.SYNOPSIS +Normalizes list-like values to a canonical string array. + +.DESCRIPTION +Accepts strings, collections, or null and returns a trimmed, deduplicated, +sorted array of non-empty string values. + +.PARAMETER Value +Input value to normalize. + +.INPUTS +System.Object + +.OUTPUTS +System.String[] +#> +function ConvertTo-NormalizedStringList { + param( + [AllowNull()] + [object]$Value + ) + + if ($null -eq $Value) { + return @() + } + + $items = @() + + if ($Value -is [string]) { + $stringValue = $Value.Trim() + + # azcmagent can return lists in bracket notation, for example: + # [Microsoft.Azure.Monitor/AzureMonitorWindowsAgent] or [] + if ($stringValue -match '^\[(.*)\]$') { + $stringValue = $Matches[1] + } + + if (-not [string]::IsNullOrWhiteSpace($stringValue)) { + $items = $stringValue -split ',' + } + } elseif ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + foreach ($item in $Value) { + if ($item -is [string]) { + $itemString = $item.Trim() + if ($itemString -match '^\[(.*)\]$') { + $itemString = $Matches[1] + } + + if (-not [string]::IsNullOrWhiteSpace($itemString)) { + $items += ($itemString -split ',') + } + } elseif ($null -ne $item) { + $items += [string]$item + } + } + } else { + $items = @([string]$Value) + } + + $normalizedItems = @( + $items | + ForEach-Object { $_.Trim().Trim("'").Trim('"') } | + Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | + Sort-Object -Unique + ) + + return ,$normalizedItems +} + +<# +.SYNOPSIS +Converts a value to a normalized boolean. + +.DESCRIPTION +Returns null for null input, returns boolean values unchanged, and attempts +to parse string values as booleans. + +.PARAMETER Value +Input value to normalize. + +.INPUTS +System.Object + +.OUTPUTS +System.Boolean or $null +#> +function ConvertTo-NormalizedBoolean { + param( + [AllowNull()] + [object]$Value + ) + + if ($null -eq $Value) { + return $null + } + + if ($Value -is [bool]) { + return $Value + } + + $candidate = [string]$Value + $parsed = $false + if ([bool]::TryParse($candidate, [ref]$parsed)) { + return $parsed + } + + throw "Invalid boolean value '$candidate'." +} + +<# +.SYNOPSIS +Normalizes and validates the config mode value. + +.DESCRIPTION +Converts input to lowercase and validates that it is either monitor or full. + +.PARAMETER Value +Input config mode value. + +.INPUTS +System.Object + +.OUTPUTS +System.String or $null +#> +function ConvertTo-NormalizedConfigMode { + param( + [AllowNull()] + [object]$Value + ) + + if ($null -eq $Value) { + return $null + } + + $mode = ([string]$Value).Trim().ToLowerInvariant() + if ($mode -notin @('monitor', 'full')) { + throw "Invalid configMode value '$Value'. Supported values are 'monitor' and 'full'." + } + + return $mode +} + +<# +.SYNOPSIS +Converts normalized values into azcmagent argument text. + +.DESCRIPTION +Formats booleans as lowercase text and joins enumerable values as +comma-separated strings. + +.PARAMETER Value +Value to convert for azcmagent config set. + +.INPUTS +System.Object + +.OUTPUTS +System.String +#> +function Join-ConfigValue { + param( + [AllowNull()] + [object]$Value + ) + + if ($Value -is [bool]) { + return $Value.ToString().ToLowerInvariant() + } + + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + return (($Value | ForEach-Object { [string]$_ }) -join ',') + } + + return [string]$Value +} + +<# +.SYNOPSIS +Invokes azcmagent with the provided arguments. + +.DESCRIPTION +Executes azcmagent, captures combined output, validates exit code, and returns +trimmed text output. + +.PARAMETER Arguments +Argument list passed directly to azcmagent. + +.PARAMETER IgnoreExitCode +Skips non-zero exit code validation when specified. + +.INPUTS +System.String[] + +.OUTPUTS +System.String +#> +function Invoke-AzcmAgent { + param( + [Parameter(Mandatory = $true)] + [string[]]$Arguments, + + [switch]$IgnoreExitCode + ) + + if ([string]::IsNullOrWhiteSpace($script:AzcmAgentPath)) { + throw 'The azcmagent executable was not found. Install the Azure Connected Machine agent first.' + } + + $output = & $script:AzcmAgentPath @Arguments 2>&1 + $exitCode = $LASTEXITCODE + $text = ($output | ForEach-Object { [string]$_ }) -join [Environment]::NewLine + + if (-not $IgnoreExitCode -and $exitCode -ne 0) { + throw "azcmagent $($Arguments -join ' ') failed with exit code $exitCode. $text".Trim() + } + + return $text.Trim() +} + +<# +.SYNOPSIS +Builds validated desired state from input properties. + +.DESCRIPTION +Validates supported keys and normalizes values to the internal desired-state +representation used by Test and Set operations. + +.PARAMETER InputObject +Input hashtable containing desired property values. + +.INPUTS +System.Collections.Hashtable + +.OUTPUTS +System.Collections.Hashtable +#> +function Get-DesiredState { + param( + [hashtable]$InputObject + ) + + $desiredState = @{} + + foreach ($key in $InputObject.Keys) { + switch ($key) { + 'incomingConnectionsEnabled' { + $desiredState[$key] = ConvertTo-NormalizedBoolean -Value $InputObject[$key] + } + 'guestConfigurationEnabled' { + $desiredState[$key] = ConvertTo-NormalizedBoolean -Value $InputObject[$key] + } + 'extensionsEnabled' { + $desiredState[$key] = ConvertTo-NormalizedBoolean -Value $InputObject[$key] + } + 'extensionAllowlist' { + $desiredState[$key] = ConvertTo-NormalizedStringList -Value $InputObject[$key] + } + 'extensionBlocklist' { + $desiredState[$key] = ConvertTo-NormalizedStringList -Value $InputObject[$key] + } + 'configMode' { + $desiredState[$key] = ConvertTo-NormalizedConfigMode -Value $InputObject[$key] + } + 'proxyUrl' { + $desiredState[$key] = [string]$InputObject[$key] + } + 'agentInstalled' { } + '_inDesiredState' { } + default { + throw "Unsupported property '$key'." + } + } + } + + return $desiredState +} + +<# +.SYNOPSIS +Reads a single Azure Arc agent configuration property. + +.DESCRIPTION +Invokes azcmagent config get for the requested property and returns null for +empty responses. + +.PARAMETER PropertyName +Azcmagent configuration property name. + +.INPUTS +System.String + +.OUTPUTS +System.String or $null +#> +function Get-ConfigPropertyValue { + param( + [Parameter(Mandatory = $true)] + [string]$PropertyName + ) + + $rawValue = Invoke-AzcmAgent -Arguments @('config', 'get', $PropertyName) + if ([string]::IsNullOrWhiteSpace($rawValue)) { + return $null + } + + return $rawValue +} + +<# +.SYNOPSIS +Retrieves current state from the Azure Arc agent. + +.DESCRIPTION +Returns a state object with normalized values for all resource properties. If +azcmagent is not available, returns defaults with agentInstalled set to false. + +.INPUTS +None. + +.OUTPUTS +System.Collections.Hashtable +#> +function Get-CurrentState { + $state = @{ + incomingConnectionsEnabled = $null + guestConfigurationEnabled = $null + extensionsEnabled = $null + extensionAllowlist = $null + extensionBlocklist = $null + configMode = $null + proxyUrl = $null + agentInstalled = $false + } + + if ([string]::IsNullOrWhiteSpace($script:AzcmAgentPath)) { + return $state + } + + $state.agentInstalled = $true + $state.incomingConnectionsEnabled = ConvertTo-NormalizedBoolean -Value (Get-ConfigPropertyValue -PropertyName 'incomingconnections.enabled') + $state.guestConfigurationEnabled = ConvertTo-NormalizedBoolean -Value (Get-ConfigPropertyValue -PropertyName 'guestconfiguration.enabled') + $state.extensionsEnabled = ConvertTo-NormalizedBoolean -Value (Get-ConfigPropertyValue -PropertyName 'extensions.enabled') + $state.extensionAllowlist = ConvertTo-NormalizedStringList -Value (Get-ConfigPropertyValue -PropertyName 'extensions.allowlist') + $state.extensionBlocklist = ConvertTo-NormalizedStringList -Value (Get-ConfigPropertyValue -PropertyName 'extensions.blocklist') + $state.configMode = ConvertTo-NormalizedConfigMode -Value (Get-ConfigPropertyValue -PropertyName 'config.mode') + $state.proxyUrl = Get-ConfigPropertyValue -PropertyName 'proxy.url' + + return $state +} + +<# +.SYNOPSIS +Compares two list-like values for logical equality. + +.DESCRIPTION +Normalizes both values as string arrays and compares size and content. + +.PARAMETER Left +First value to compare. + +.PARAMETER Right +Second value to compare. + +.INPUTS +System.Object + +.OUTPUTS +System.Boolean +#> +function Test-StringListEquality { + param( + [AllowNull()] + [object]$Left, + + [AllowNull()] + [object]$Right + ) + + $leftValues = @(ConvertTo-NormalizedStringList -Value $Left) + $rightValues = @(ConvertTo-NormalizedStringList -Value $Right) + + if ($leftValues.Count -ne $rightValues.Count) { + return $false + } + + return $null -eq (Compare-Object -ReferenceObject $leftValues -DifferenceObject $rightValues) +} + +<# +.SYNOPSIS +Determines whether current state matches desired state. + +.DESCRIPTION +Compares desired keys against current state and performs normalized list +comparison for allowlist and blocklist properties. + +.PARAMETER CurrentState +Current resource state. + +.PARAMETER DesiredState +Desired resource state. + +.INPUTS +System.Collections.Hashtable + +.OUTPUTS +System.Boolean +#> +function Test-InDesiredState { + param( + [hashtable]$CurrentState, + [hashtable]$DesiredState + ) + + if (-not $CurrentState.agentInstalled) { + return $false + } + + foreach ($key in $DesiredState.Keys) { + if ($key -in @('extensionAllowlist', 'extensionBlocklist')) { + if (-not (Test-StringListEquality -Left $CurrentState[$key] -Right $DesiredState[$key])) { + return $false + } + + continue + } + + if ($CurrentState[$key] -cne $DesiredState[$key]) { + return $false + } + } + + return $true +} + +<# +.SYNOPSIS +Sets a single Azure Arc agent configuration property. + +.DESCRIPTION +Converts the provided value into azcmagent argument text and invokes +azcmagent config set. + +.PARAMETER PropertyName +Azcmagent configuration property name. + +.PARAMETER Value +Value to set for the property. + +.INPUTS +System.String, System.Object + +.OUTPUTS +None. +#> +function Set-ConfigPropertyValue { + param( + [Parameter(Mandatory = $true)] + [string]$PropertyName, + + [Parameter(Mandatory = $true)] + [AllowNull()] + [object]$Value + ) + + $valueText = Join-ConfigValue -Value $Value + $null = Invoke-AzcmAgent -Arguments @('config', 'set', $PropertyName, $valueText) +} + +<# +.SYNOPSIS +Applies desired state differences to Azure Arc agent settings. + +.DESCRIPTION +Compares current and desired state and invokes azcmagent config set only for +properties that require updates. + +.PARAMETER CurrentState +Current resource state. + +.PARAMETER DesiredState +Desired resource state. + +.INPUTS +System.Collections.Hashtable + +.OUTPUTS +System.Collections.Hashtable +Updated current state after changes are applied. +#> +function Set-DesiredState { + param( + [hashtable]$CurrentState, + [hashtable]$DesiredState + ) + + if (-not $CurrentState.agentInstalled) { + throw 'The azcmagent executable was not found. Install the Azure Connected Machine agent first.' + } + + foreach ($key in $DesiredState.Keys) { + $currentValue = $CurrentState[$key] + $desiredValue = $DesiredState[$key] + $propertyName = $script:PropertyMap[$key] + + $needsUpdate = $false + if ($key -in @('extensionAllowlist', 'extensionBlocklist')) { + $needsUpdate = -not (Test-StringListEquality -Left $currentValue -Right $desiredValue) + } else { + $needsUpdate = $currentValue -cne $desiredValue + } + + if (-not $needsUpdate) { + continue + } + + if ($key -ceq 'configMode' -and $desiredValue -ceq 'full') { + Set-ConfigPropertyValue -PropertyName $propertyName -Value 'monitor' + Set-ConfigPropertyValue -PropertyName $propertyName -Value 'full' + } else { + Set-ConfigPropertyValue -PropertyName $propertyName -Value $desiredValue + } + } + + return Get-CurrentState +} + +<# +.SYNOPSIS +Builds exportable state from current configuration. + +.DESCRIPTION +Returns non-null writable properties suitable for export. Returns an empty +hashtable when the agent is not installed. + +.INPUTS +None. + +.OUTPUTS +System.Collections.Hashtable +#> +function Get-ExportState { + $currentState = Get-CurrentState + + if (-not $currentState.agentInstalled) { + return @{} + } + + $exportState = @{} + foreach ($key in $script:PropertyMap.Keys) { + $value = $currentState[$key] + if ($null -ne $value) { + $exportState[$key] = $value + } + } + + return $exportState +} + +try { + $script:AzcmAgentPath = Find-AzcmAgentCommand + $inputObject = ConvertFrom-ResourceInput -InputObject $JsonInput + + switch ($Operation) { + 'Get' { + $result = Get-CurrentState + } + 'Test' { + $desiredState = Get-DesiredState -InputObject $inputObject + $currentState = Get-CurrentState + $result = @{} + foreach ($key in $desiredState.Keys) { + if ($currentState.ContainsKey($key)) { + $result[$key] = $currentState[$key] + } + } + $result._inDesiredState = (Test-InDesiredState -CurrentState $currentState -DesiredState $desiredState) + } + 'Set' { + $desiredState = Get-DesiredState -InputObject $inputObject + $currentState = Get-CurrentState + if (Test-InDesiredState -CurrentState $currentState -DesiredState $desiredState) { + $result = $currentState + } else { + $result = Set-DesiredState -CurrentState $currentState -DesiredState $desiredState + } + } + 'Export' { + $result = Get-ExportState + } + } + + $result | ConvertTo-Json -Compress -Depth 10 +} +catch { + Write-Error $_ + exit 1 +} \ No newline at end of file diff --git a/dsc_resources/tests/azure_arc_agent.tests.ps1 b/dsc_resources/tests/azure_arc_agent.tests.ps1 new file mode 100644 index 0000000..fc8c6ac --- /dev/null +++ b/dsc_resources/tests/azure_arc_agent.tests.ps1 @@ -0,0 +1,168 @@ +# Copyright (c) Microsoft Corporation. +# Licensed under the MIT License. + +Describe 'azure_arc_agent resource tests' -Skip:(!$IsWindows) { + BeforeAll { + $resourceType = 'Microsoft.Azure.Arc/AgentConfiguration' + $originalPath = $env:PATH + $stubRoot = Join-Path $TestDrive 'stub' + $statePath = Join-Path $TestDrive 'state.json' + $logPath = Join-Path $TestDrive 'commands.log' + + New-Item -Path $stubRoot -ItemType Directory -Force | Out-Null + + $stubScript = @' +param( + [Parameter(ValueFromRemainingArguments = $true)] + [string[]]$Arguments +) + +$statePath = $env:ARC_AGENT_TEST_STATE_PATH +$logPath = $env:ARC_AGENT_TEST_LOG_PATH + +Add-Content -Path $logPath -Value ($Arguments -join ' ') + +$state = Get-Content -Path $statePath -Raw | ConvertFrom-Json -AsHashtable + +function Save-State { + $state | ConvertTo-Json -Depth 10 | Set-Content -Path $statePath +} + +function Write-StateValue { + param([object]$Value) + + if ($Value -is [bool]) { + $Value.ToString().ToLowerInvariant() + return + } + + if ($Value -is [System.Collections.IEnumerable] -and $Value -isnot [string]) { + ($Value | ForEach-Object { [string]$_ }) -join ',' + return + } + + [string]$Value +} + +if ($Arguments.Count -lt 2 -or $Arguments[0] -ne 'config') { + Write-Error 'Unsupported fake azcmagent invocation.' + exit 1 +} + +switch ($Arguments[1]) { + 'get' { + $propertyName = $Arguments[2] + Write-StateValue -Value $state[$propertyName] + exit 0 + } + 'set' { + $propertyName = $Arguments[2] + $propertyValue = $Arguments[3] + + switch ($propertyName) { + 'incomingconnections.enabled' { $state[$propertyName] = [System.Convert]::ToBoolean($propertyValue) } + 'guestconfiguration.enabled' { $state[$propertyName] = [System.Convert]::ToBoolean($propertyValue) } + 'extensions.enabled' { $state[$propertyName] = [System.Convert]::ToBoolean($propertyValue) } + 'extensions.allowlist' { $state[$propertyName] = @($propertyValue -split ',' | Where-Object { $_ }) } + 'extensions.blocklist' { $state[$propertyName] = @($propertyValue -split ',' | Where-Object { $_ }) } + 'config.mode' { $state[$propertyName] = $propertyValue } + default { + Write-Error "Unsupported property '$propertyName'." + exit 1 + } + } + + Save-State + exit 0 + } + default { + Write-Error "Unsupported fake azcmagent command '$($Arguments[1])'." + exit 1 + } +} +'@ + + Set-Content -Path (Join-Path $stubRoot 'azcmagent.ps1') -Value $stubScript + $env:ARC_AGENT_TEST_STATE_PATH = $statePath + $env:ARC_AGENT_TEST_LOG_PATH = $logPath + + function Set-TestState { + param([hashtable]$State) + + $State | ConvertTo-Json -Depth 10 | Set-Content -Path $statePath + } + + function Get-TestLog { + if (-not (Test-Path -Path $logPath)) { + return @() + } + + return Get-Content -Path $logPath + } + } + + BeforeEach { + Set-TestState -State @{ + 'incomingconnections.enabled' = $true + 'guestconfiguration.enabled' = $true + 'extensions.enabled' = $true + 'extensions.allowlist' = @('Contoso.Extension/Example') + 'extensions.blocklist' = @('Contoso.Blocked/Example') + 'config.mode' = 'monitor' + } + + Set-Content -Path $logPath -Value '' + $env:PATH = "$stubRoot$([IO.Path]::PathSeparator)$originalPath" + } + + AfterAll { + $env:PATH = $originalPath + Remove-Item Env:ARC_AGENT_TEST_STATE_PATH -ErrorAction SilentlyContinue + Remove-Item Env:ARC_AGENT_TEST_LOG_PATH -ErrorAction SilentlyContinue + } + + It 'Get returns the current azcmagent configuration' { + $out = '{}' | dsc resource get -r $resourceType -f - 2>$TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.txt -Raw) + $out.actualState.agentInstalled | Should -BeTrue + $out.actualState.incomingConnectionsEnabled | Should -BeTrue + $out.actualState.guestConfigurationEnabled | Should -BeTrue + $out.actualState.extensionsEnabled | Should -BeTrue + $out.actualState.extensionAllowlist | Should -Be @('Contoso.Extension/Example') + $out.actualState.extensionBlocklist | Should -Be @('Contoso.Blocked/Example') + $out.actualState.configMode | Should -Be 'monitor' + } + + It 'Test returns false when the current state differs from the desired defaults' { + $out = '{}' | dsc resource test -r $resourceType -f - 2>$TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.txt -Raw) + $out.inDesiredState | Should -BeFalse + } + + It 'Set applies the required Azure Arc commands in order' { + $out = '{}' | dsc resource set -r $resourceType -f - 2>$TestDrive/error.txt | ConvertFrom-Json + $LASTEXITCODE | Should -Be 0 -Because (Get-Content -Path $TestDrive/error.txt -Raw) + $out.afterState.agentInstalled | Should -BeTrue + $out.afterState.incomingConnectionsEnabled | Should -BeFalse + $out.afterState.guestConfigurationEnabled | Should -BeFalse + $out.afterState.extensionsEnabled | Should -BeFalse + $out.afterState.extensionAllowlist | Should -Be @( + 'Microsoft.Azure.AzureDefenderForServers/MDE.Windows' + 'Microsoft.Azure.Monitor/AzureMonitorWindowsAgent' + ) + $out.afterState.extensionBlocklist | Should -Contain 'Microsoft.Cplat.Core/RunCommandHandlerWindows' + $out.afterState.configMode | Should -Be 'full' + + $log = @(Get-TestLog | Where-Object { $_ }) + $log | Should -Contain 'config set incomingconnections.enabled false' + $log | Should -Contain 'config set guestconfiguration.enabled false' + $log | Should -Contain 'config set extensions.enabled false' + $log | Should -Contain 'config set extensions.allowlist Microsoft.Azure.AzureDefenderForServers/MDE.Windows,Microsoft.Azure.Monitor/AzureMonitorWindowsAgent' + $log | Should -Contain 'config set extensions.blocklist Microsoft.Azure.Automation.HybridWorker/HybridWorkerForWindows,Microsoft.Azure.Automation/HybridWorkerForLinux,Microsoft.Azure.Extensions/CustomScript,Microsoft.Cplat.Core/RunCommandHandlerLinux,Microsoft.Cplat.Core/RunCommandHandlerWindows,Microsoft.Compute/CustomScriptExtension,Microsoft.EnterpriseCloud.Monitoring/MicrosoftMonitoringAgent,Microsoft.EnterpriseCloud.Monitoring/OMSAgentForLinux' + $modeLog = @($log | Where-Object { $_ -like 'config set config.mode *' }) + $modeLog | Should -Be @( + 'config set config.mode monitor' + 'config set config.mode full' + ) + } +} \ No newline at end of file