AzureConnectedMachineDsc.psm1


$script:httpserver = 'http://127.0.0.1:40342'
$script:agentstatus = '/agentstatus'
$script:metadata = '/metadata/instance'
$script:apiversion = '2019-08-15'
$script:headers = @{ Metadata = $true }
$env:PATH = $env:PATH + ";$env:ProgramFiles\AzureConnectedMachineAgent\"
function Connect-AzConnectedMachineAgent {
    param(
        [Parameter(Mandatory = $true)]
        [string]$TenantId,

        [Parameter(Mandatory = $true)]
        [string]$SubscriptionId,

        [Parameter(Mandatory = $true)]
        [string]$ResourceGroup,

        [Parameter(Mandatory = $true)]
        [string]$Location,

        $Tags,

        [Parameter(Mandatory = $true)]
        [PSCredential]$Credential
    )
    if (Test-Path "$env:ProgramFiles\AzureConnectedMachineAgent\azcmagent.exe") {

        if (Test-AzConnectedMachineAgentConnection) {
            Write-Verbose "Machine $env:COMPUTERNAME is already onboarded. Disconnecting, restarting service, and reconnecting."
            $disconnect = & azcmagent disconnect `
                --service-principal-id $Credential.UserName `
                --service-principal-secret $Credential.GetNetworkCredential().Password `
                --json
            
            $disconnect = $disconnect | ConvertFrom-Json
            
            if ($disconnect.status -eq 'failed') {
                throw "The disconnect command failed.`nMessage from azcmagent: $($disconnect.error.message)"
            }

            Restart-Service 'HIMDS' -Force
        }
        
        $agentArgs = New-Object System.Collections.ArrayList
        $agentArgs.AddRange(@(
                "connect",
                "--tenant-id", $TenantId,
                "--subscription-id", $SubscriptionId,
                "--resource-group", $ResourceGroup
                "--location", $Location
                "--service-principal-id", $Credential.UserName
                "--service-principal-secret", $Credential.GetNetworkCredential().Password
                "--silent"
                "--json"
            ))

        if ($null -ne $Tags) {
            Write-Verbose 'Attempting to register machine.'
            $agentArgs.AddRange(@("--tags", $Tags))
        }
        else {
            Write-Verbose 'Attempting to register machine. No Tags were specified.'
        }

        $connect = & azcmagent $agentArgs
        $connect = $connect | ConvertFrom-Json

        if ($connect.status -eq 'failed') {
            throw "The connection failed. Verify network, name resolution, and that an existing resource with the same name does not exist.`nMessage from azcmagent: $($connect.error.message)"
        }
    }
    else {
        throw 'The Hybrid agent is not installed.'
    }
}

function Get-AzConnectedMachineAgent {
    if (Test-AzConnectedMachineAgentService) {
        $agentstatusuri = $script:httpserver + $script:agentstatus + '?api-version=' + $script:apiversion
        $agentStatus = Invoke-WebRequest -UseBasicParsing -Uri $agentstatusuri -Headers $script:headers | ForEach-Object { $_.Content } | ConvertFrom-Json

        if ('' -ne $agentStatus.lastHeartBeat) {
            $metadataUri = $script:httpserver + $script:metadata + '?api-version=' + $script:apiversion
            $metadataInstance = Invoke-WebRequest -UseBasicParsing -Uri $metadataUri -Headers $script:headers | ForEach-Object { $_.Content } | ConvertFrom-Json | ForEach-Object { $_.compute }
        }

        if (Test-Path "$env:PROGRAMDATA\AzureConnectedMachineAgent\Config\agentconfig.json") {
            $agentConfig = Get-Content "$env:PROGRAMDATA\AzureConnectedMachineAgent\Config\agentconfig.json" | ConvertFrom-Json
        }

        $return = @{
            TenantId       = $agentConfig.TenantId
            SubscriptionID = $agentConfig.Subscriptionid
            ResourceGroup  = $agentConfig.resourceGroup
            Location       = $agentConfig.Location
            Tags           = $metadataInstance.Tags
            AgentStatus    = $agentStatus.Status
        }
        return $return
    }
    else {
        throw 'The Azure Hybrid Agent service is not running.'
    }
}

function Test-AzConnectedMachineAgent {
    param(
        [Parameter(Mandatory = $true)]
        [string]$TenantId,

        [Parameter(Mandatory = $true)]
        [string]$SubscriptionId,

        [Parameter(Mandatory = $true)]
        [string]$ResourceGroup,

        [Parameter(Mandatory = $true)]
        [string]$Location,

        $Tags
    )

    $return = $true
    $AzConnectedMachineAgent = Get-AzConnectedMachineAgent

    if ($TenantId -ne $AzConnectedMachineAgent.TenantId) {
        Write-Verbose "Expected Tenant ID $TenantID but found $($AzConnectedMachineAgent.TenantId)"
        $return = $false
    }
    if ($SubscriptionId -ne $AzConnectedMachineAgent.SubscriptionId) {
        Write-Verbose "Expected Subscription ID $SubscriptionID but found $($AzConnectedMachineAgent.SubscriptionId)"
        $return = $false
    }
    if ($ResourceGroup -ne $AzConnectedMachineAgent.ResourceGroup) {
        Write-Verbose "Expected Resource Group $ResourceGroup but found $($AzConnectedMachineAgent.ResourceGroup)"
        $return = $false
    }
    if ($Location -ne $AzConnectedMachineAgent.Location) {
        Write-Verbose "Expected Location $Location but found $($AzConnectedMachineAgent.Location)"
        $return = $false
    }
    <# Tags are not implemeted yet in HIMDS
    if ($Tags -ne $AzConnectedMachineAgent.Tags) {
        Write-Verbose "Expected Tags value to be $Tags but found value $($AzConnectedMachineAgent.Tags)"
        $return = $false
    }
    #>

    if ('Connected' -ne $AzConnectedMachineAgent.AgentStatus) {
        Write-Verbose "Expected agent status to be Connected but found $($AzConnectedMachineAgent.AgentStatus)"
        $return = $false
    }

    return $return
}

function Test-AzConnectedMachineAgentConnection {
    'Connected' -eq (Get-AzConnectedMachineAgent).AgentStatus
}

function Test-AzConnectedMachineAgentService {
    If ('Running' -eq (Get-Service | Where-Object { $_.Name -eq 'HIMDS' } | ForEach-Object { $_.Status })) {
        $HIMDSuri = $script:httpserver + $script:agentstatus + '?api-version=' + $script:apiversion
        $HIMDS = Invoke-WebRequest -UseBasicParsing -Uri $HIMDSuri -Headers $script:headers | ForEach-Object { $_.StatusCode }
        if ('200' -eq $HIMDS) {
            return $true
        }
        else { return $false }
    }
    else { return $false }
}

function Get-AzConnectedMachineAgentConfig {
    $data = Get-AzConnectedMachineAgentConfigValues
    return @{
        incomingconnections_ports  = $data | Where-Object {$_.DisplayName -eq 'incomingconnections.ports'} | ForEach-Object {$_.value}
        proxy_url                  = $data | Where-Object {$_.DisplayName -eq 'proxy.url'} | ForEach-Object {$_.value}
        extensions_allowlist       = $data | Where-Object {$_.DisplayName -eq 'extensions.allowlist'} | ForEach-Object {$_.value}
        extensions_blocklist       = $data | Where-Object {$_.DisplayName -eq 'extensions.blocklist'} | ForEach-Object {$_.value}
        proxy_bypass               = $data | Where-Object {$_.DisplayName -eq 'proxy.bypass'} | ForEach-Object {$_.value}
        guestconfiguration_enabled = $data | Where-Object {$_.DisplayName -eq 'guestconfiguration.enabled'} | ForEach-Object {$_.value}
    }
}

function Get-AzConnectedMachineAgentConfigValues {
    if (Test-Path "$env:ProgramFiles\AzureConnectedMachineAgent\azcmagent.exe") {
        Write-Verbose 'Running azcmagent config list and get to retrieve current values; return object.'

        $list = New-Object -TypeName 'PSObject'
        & azcmagent config list --json | ConvertFrom-Json | ForEach-Object { $_.LocalSettings } | ForEach-Object { Add-Member -InputObject $list -MemberType NoteProperty -Name $_.Key -Value $_.Value }

        $info = & azcmagent config info --json | ConvertFrom-Json
        for ($i = 0; $i -lt $info.Count; $i++) {
            $value = $list.($info[$i].DisplayName)
            $info[$i] | Add-Member -MemberType NoteProperty -Name 'Value' -Value $value -Force
            if (@('true', 'false') -contains $value) { $type = 'Boolean' } else {
                $type = $list.($info[$i].DisplayName).gettype().Name
            }    
            $info[$i] | Add-Member -MemberType NoteProperty -Name 'Type' -Value $type -Force
        }

        if ($LastExitCode -ne 0) {
            throw 'An error occured when getting values from azcmagent.'
        }

        return $info
    }
    else {
        throw 'The Hybrid agent is not installed.'
    }
}

function Set-AzConnectedMachineAgentConfig {
    param(
        [string] $incomingconnections_ports,
        [string] $proxy_url,
        [string] $extensions_allowlist,
        [string] $extensions_blocklist,
        [string] $proxy_bypass,
        [boolean] $guestconfiguration_enabled
    )
    $incomingconnections_ports = $incomingconnections_ports.split(',')
    $extensions_allowlist      = $extensions_allowlist.split(',')
    $extensions_blocklist      = $extensions_blocklist.split(',')
    $proxy_bypass              = $proxy_bypass.split(',')
    foreach ($parameterName in $PSBoundParameters.Keys) {
        Set-AzConnectedMachineAgentConfigValue -property ($parameterName -replace '_','.') -value $PSBoundParameters[$parameterName]
    }
}

function Set-AzConnectedMachineAgentConfigValue {
    param(
        [Parameter(Mandatory)]
        [ArgumentCompleter({ New-ConfigPropertyCompleter @args })]
        [String] $property,
        $value
    )
    if (New-ConfigValueValidation -property $property -value $value) {
        if (Test-Path "$env:ProgramFiles\AzureConnectedMachineAgent\azcmagent.exe") {
            Write-Verbose "Setting $property to $value."

            $config = & azcmagent config set $property $value --json

            if ($config.status -eq 'error') {
                throw $config.status.message
            }

            return $list
        }
        else {
            throw 'The Hybrid agent is not installed.'
        }
    }
}

function Test-AzConnectedMachineAgentConfig {
    param(
        [string[]] $incomingconnections_ports,
        [string] $proxy_url,
        [string[]] $extensions_allowlist,
        [string[]] $extensions_blocklist,
        [string[]] $proxy_bypass,
        [boolean] $guestconfiguration_enabled
    )
    $return = $true
    $params = @{}
    foreach ($param in $PSBoundParameters.Keys) {$params[$param] = $PSBoundParameters[$param]}
    foreach ($setting in (Get-AzConnectedMachineAgentConfigValues)) {
        $name = $setting.DisplayName -replace '\.','_'
        $paramValue = $params[$name]
        $value = $setting.value
        if (!$value) {$value = '[not configured]'}
        if (!$paramValue) {$paramValue = '[not configured]'}
        $verbose = "Test $name value of $value should equal $paramValue"
        Write-Verbose $verbose
        if ($value -ne $paramValue) {$return = $false}
    }
    return $return
}

function New-ConfigPropertyCompleter {
    param ( $property,
        $parameterName,
        $wordToComplete,
        $commandAst,
        $fakeBoundParameters )

    $possibleValues = (Get-AzConnectedMachineAgentConfig).DisplayName

    if ($fakeBoundParameters) {
        $return = $possibleValues | Where-Object { $_ -like "$wordToComplete*" }
    }
    else {
        $return = $possibleValues | ForEach-Object { $_ }
    }
    return $return
}

function New-ConfigValueValidation {
    param(
        [Parameter(Mandatory)]
        [string]
        $property,
        $value
    )

    $typeName = Get-AzConnectedMachineAgentConfigValues | Where-Object { $_.DisplayName -eq $property } | ForEach-Object { $_.Type }
    $return = ($value.GetType().Name -eq $typeName) -or ('Object[]' -eq $typeName -and 'String' -eq $value.GetType().Name)
    if ($false -eq $return) {
        throw "The property $property requires a value of type $typeName."
    }
    return $return
}

function Get-Reasons {
    param(
        [Parameter(Mandatory)]
        [Hashtable]
        $expectedState,
        [Parameter(Mandatory)]
        [Hashtable]
        $actualState,
        [Parameter(Mandatory)]
        [String]
        $className,
        [String]
        $reasonCode = 'Configuration'
    )
    $Reasons = @()
    foreach ($key in $expectedState.Keys) {
        if ($expectedState.$key -eq $actualState.$key) {
            $state0 += "`t`t[+] $key" + ':' + "`n`t`t`tExpected value to be `"$($expectedState.$key)`"`n`t`t`tActual value was `"$($actualState.$key)`"`n"
        }
        else {
            $state1 += "`t`t[-] $key" + ':' + "`n`t`t`tExpected value to be `"$($expectedState.$key)`"`n`t`t`tActual value was `"$($actualState.$key)`"`n"
        }
    }

    $Reason = [reason]::new()
    $Reason.code = $className + ':' + $className + ':' + $reasonCode
    $phrase = "The machine returned the following configuration details.`n"
    $phrase += "`tSettings in desired state:`n$state0"
    $phrase += "`tSettings not in desired state:`n$state1"
    $Reason.phrase = $phrase
    $Reasons += $Reason
    return $Reasons
}

class Reason {
    [DscProperty()]
    [string] $Code

    [DscProperty()]
    [string] $Phrase
}

[DscResource()]
class AzureConnectedMachineAgentDsc {

    [DscProperty(Key)]
    [Parameter(Mandatory = $true)]
    [string]$TenantId

    [DscProperty()]
    [Parameter(Mandatory = $true)]
    [string]$SubscriptionId

    [DscProperty()]
    [Parameter(Mandatory = $true)]
    [string]$ResourceGroup

    [DscProperty()]
    [Parameter(Mandatory = $true)]
    [string]$Location

    [DscProperty()]
    [string]$Tags

    [DscProperty()]
    [Parameter(Mandatory = $true)]
    [PSCredential]$Credential

    [DscProperty(NotConfigurable)]
    [Reason[]]$Reasons

    [AzureConnectedMachineAgentDsc] Get() {
        $get = Get-AzConnectedMachineAgent
        $expectedState = @{}; foreach ($param in ($this | Get-Member -type 'Properties' | ForEach-Object { $_.Name })) { if ('Reasons' -ne $param) { $expectedState.add($param, $this.$param) } }
        $get.Add('Reasons', (Get-Reasons -expectedState $expectedState -actualState $get -className $this.GetType().Name))
        return $get
    }

    [void] Set() {
        Connect-AzConnectedMachineAgent -TenantId $this.TenantId -SubscriptionId $this.SubscriptionId -ResourceGroup $this.ResourceGroup -Location $this.Location -Tags $this.Tags -Credential $this.Credential
    }

    [bool] Test() {
        $test = Test-AzConnectedMachineAgent -TenantId $this.TenantId -SubscriptionId $this.SubscriptionId -ResourceGroup $this.ResourceGroup -Location $this.Location -Tags $this.Tags
        return $test
    }
}

[DscResource()]
class AzcmagentConfig {

    [DscProperty(Key)]
    [Parameter(Mandatory = $true)]
    [ValidateNotNullOrEmpty()]
    [string]$IsSingleInstance

    [DscProperty()]
    [string[]] $incomingconnections_ports

    [DscProperty()]
    [string] $proxy_url

    [DscProperty()]
    [string[]] $extensions_allowlist

    [DscProperty()]
    [string[]] $extensions_blocklist

    [DscProperty()]
    [string[]] $proxy_bypass

    [DscProperty()]
    [boolean] $guestconfiguration_enabled

    [DscProperty(NotConfigurable)]
    [Reason[]]$Reasons

    [AzcmagentConfig] Get() {
        $get = Get-AzConnectedMachineAgentConfig
        $expectedState = @{}; foreach ($param in ($this | Get-Member -type 'Properties' | ForEach-Object { $_.Name })) { if ('Reasons' -ne $param) { $expectedState.add($param, $this.$param) } }
        $get.Add('Reasons', (Get-Reasons -expectedState $expectedState -actualState $get -className $this.GetType().Name))
        return $get
    }

    [void] Set() {
        Set-AzConnectedMachineAgentConfig @PSBoundParameters
    }

    [bool] Test() {
        $test = Test-AzConnectedMachineAgentConfig @PSBoundParameters
        return $test
    }
}