AzureConnectedMachineDscv3.psm1
|
# Copyright (c) Microsoft Corporation. # Licensed under the MIT License. Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' #region Enums enum Ensure { Absent Present } enum ArcConfigMode { Monitor Full } #endregion Enums #region Helper functions # Maps class property names to the azcmagent configuration property names. $script:PropertyMap = @{ IncomingConnectionsEnabled = 'incomingconnections.enabled' GuestConfigurationEnabled = 'guestconfiguration.enabled' ExtensionsEnabled = 'extensions.enabled' ExtensionAllowlist = 'extensions.allowlist' ExtensionBlocklist = 'extensions.blocklist' ConfigMode = 'config.mode' ProxyUrl = 'proxy.url' } <# .SYNOPSIS Resolves the azcmagent executable path from PATH. #> function Find-AzcmAgentCommand { [CmdletBinding()] [OutputType([string])] param () $command = Get-Command -Name 'azcmagent' -ErrorAction SilentlyContinue if ($null -eq $command) { return $null } return $command.Source } <# .SYNOPSIS Normalizes list-like values to a trimmed, deduplicated, sorted string array. #> function ConvertTo-NormalizedStringList { [CmdletBinding()] [OutputType([string[]])] param ( [Parameter()] [AllowNull()] [object] $Value ) if ($null -eq $Value) { return @() } $items = @() if ($Value -is [string]) { $stringValue = $Value.Trim() # azcmagent can return lists in bracket notation, e.g. # [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 nullable boolean. #> function ConvertTo-NormalizedBoolean { [CmdletBinding()] [OutputType([System.Nullable[bool]])] param ( [Parameter()] [AllowNull()] [object] $Value ) if ($null -eq $Value) { return $null } if ($Value -is [bool]) { return $Value } $candidate = [string] $Value if ([string]::IsNullOrWhiteSpace($candidate)) { return $null } $parsed = $false if ([bool]::TryParse($candidate, [ref] $parsed)) { return $parsed } throw "Invalid boolean value '$candidate'." } <# .SYNOPSIS Normalizes and validates the config mode value. #> function ConvertTo-NormalizedConfigMode { [CmdletBinding()] [OutputType([System.Nullable[ArcConfigMode]])] param ( [Parameter()] [AllowNull()] [object] $Value ) if ($null -eq $Value) { return $null } if ($Value -is [ArcConfigMode]) { return $Value } $mode = ([string] $Value).Trim() if ([string]::IsNullOrWhiteSpace($mode)) { return $null } $parsed = [ArcConfigMode] 0 if ([System.Enum]::TryParse([ArcConfigMode], $mode, $true, [ref] $parsed)) { return $parsed } throw "Invalid ConfigMode value '$Value'. Supported values are 'Monitor' and 'Full'." } <# .SYNOPSIS Formats a value into azcmagent config set argument text. #> function Join-ConfigValue { [CmdletBinding()] [OutputType([string])] param ( [Parameter()] [AllowNull()] [object] $Value ) if ($Value -is [bool]) { return $Value.ToString().ToLowerInvariant() } if ($Value -is [ArcConfigMode]) { 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 and returns trimmed output. #> function Invoke-AzcmAgent { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $AgentPath, [Parameter(Mandatory = $true)] [string[]] $Arguments, [Parameter()] [switch] $IgnoreExitCode ) if ([string]::IsNullOrWhiteSpace($AgentPath)) { throw 'The azcmagent executable was not found. Install the Azure Connected Machine agent first.' } $output = & $AgentPath @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 Reads a single azcmagent configuration property value. #> function Get-ConfigPropertyValue { [CmdletBinding()] [OutputType([string])] param ( [Parameter(Mandatory = $true)] [AllowEmptyString()] [string] $AgentPath, [Parameter(Mandatory = $true)] [string] $PropertyName ) $rawValue = Invoke-AzcmAgent -AgentPath $AgentPath -Arguments @('config', 'get', $PropertyName) if ([string]::IsNullOrWhiteSpace($rawValue)) { return $null } return $rawValue } <# .SYNOPSIS Compares two list-like values for logical equality. #> function Test-StringListEquality { [CmdletBinding()] [OutputType([bool])] param ( [Parameter()] [AllowNull()] [object] $Left, [Parameter()] [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) } #endregion Helper functions #region DSC resource class [DscResource()] class AzureArcAgentConfiguration { # Singleton key. This resource configures the machine-wide Azure Arc agent, # so only a single instance is supported. [DscProperty(Key)] [ValidateSet('Yes')] [string] $IsSingleInstance [DscProperty()] [Nullable[bool]] $IncomingConnectionsEnabled [DscProperty()] [Nullable[bool]] $GuestConfigurationEnabled [DscProperty()] [Nullable[bool]] $ExtensionsEnabled [DscProperty()] [string[]] $ExtensionAllowlist [DscProperty()] [string[]] $ExtensionBlocklist [DscProperty()] [Nullable[ArcConfigMode]] $ConfigMode [DscProperty()] [string] $ProxyUrl # Read-only: reports whether the azcmagent executable was found. [DscProperty(NotConfigurable)] [bool] $AgentInstalled <# Returns the current state of the Azure Arc agent configuration. #> [AzureArcAgentConfiguration] Get() { $currentState = [AzureArcAgentConfiguration]::new() $currentState.IsSingleInstance = 'Yes' $agentPath = Find-AzcmAgentCommand if ([string]::IsNullOrWhiteSpace($agentPath)) { $currentState.AgentInstalled = $false return $currentState } $currentState.AgentInstalled = $true $currentState.IncomingConnectionsEnabled = ConvertTo-NormalizedBoolean -Value ( Get-ConfigPropertyValue -AgentPath $agentPath -PropertyName 'incomingconnections.enabled') $currentState.GuestConfigurationEnabled = ConvertTo-NormalizedBoolean -Value ( Get-ConfigPropertyValue -AgentPath $agentPath -PropertyName 'guestconfiguration.enabled') $currentState.ExtensionsEnabled = ConvertTo-NormalizedBoolean -Value ( Get-ConfigPropertyValue -AgentPath $agentPath -PropertyName 'extensions.enabled') $currentState.ExtensionAllowlist = ConvertTo-NormalizedStringList -Value ( Get-ConfigPropertyValue -AgentPath $agentPath -PropertyName 'extensions.allowlist') $currentState.ExtensionBlocklist = ConvertTo-NormalizedStringList -Value ( Get-ConfigPropertyValue -AgentPath $agentPath -PropertyName 'extensions.blocklist') $currentState.ConfigMode = ConvertTo-NormalizedConfigMode -Value ( Get-ConfigPropertyValue -AgentPath $agentPath -PropertyName 'config.mode') $currentState.ProxyUrl = Get-ConfigPropertyValue -AgentPath $agentPath -PropertyName 'proxy.url' return $currentState } <# Determines whether the current state matches the desired state. #> [bool] Test() { $currentState = $this.Get() if (-not $currentState.AgentInstalled) { return $false } foreach ($property in $this.GetConfiguredProperties().Keys) { if ($property -in @('ExtensionAllowlist', 'ExtensionBlocklist')) { if (-not (Test-StringListEquality -Left $currentState.$property -Right $this.$property)) { return $false } continue } if ($currentState.$property -cne $this.$property) { return $false } } return $true } <# Applies the desired state to the Azure Arc agent configuration. #> [void] Set() { $agentPath = Find-AzcmAgentCommand if ([string]::IsNullOrWhiteSpace($agentPath)) { throw 'The azcmagent executable was not found. Install the Azure Connected Machine agent first.' } $currentState = $this.Get() foreach ($property in $this.GetConfiguredProperties().Keys) { $currentValue = $currentState.$property $desiredValue = $this.$property $propertyName = $script:PropertyMap[$property] $needsUpdate = $false if ($property -in @('ExtensionAllowlist', 'ExtensionBlocklist')) { $needsUpdate = -not (Test-StringListEquality -Left $currentValue -Right $desiredValue) } else { $needsUpdate = $currentValue -cne $desiredValue } if (-not $needsUpdate) { continue } if ($property -eq 'ConfigMode' -and $desiredValue -eq [ArcConfigMode]::Full) { # azcmagent requires transitioning through 'monitor' before 'full'. $this.SetConfigProperty($agentPath, $propertyName, 'monitor') $this.SetConfigProperty($agentPath, $propertyName, 'full') } else { $this.SetConfigProperty($agentPath, $propertyName, $desiredValue) } } } #region Private helpers <# Returns a hashtable of the configurable properties the user has set (non-null), keyed by property name. #> hidden [hashtable] GetConfiguredProperties() { $configured = @{} foreach ($property in $script:PropertyMap.Keys) { $value = $this.$property if ($null -ne $value) { $configured[$property] = $value } } return $configured } hidden [void] SetConfigProperty([string] $AgentPath, [string] $PropertyName, [object] $Value) { $valueText = Join-ConfigValue -Value $Value $null = Invoke-AzcmAgent -AgentPath $AgentPath -Arguments @('config', 'set', $PropertyName, $valueText) } #endregion Private helpers } #endregion DSC resource class |