IISAdministrationDsc.psm1

$script:ModuleRoot = $PSScriptRoot

enum Ensure {
    Absent
    Present
}

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

[DscResource()]
class IISConfigAttribute {
    #region DSC Properties
    <#
    The properties you can define in configuration settings.
    The [DscProperty(...)] attribute has a few possible values:
    - <empty>: Nothing specified makes this an optional property you can leave empty when defining the configuration setting.
    - Mandatory: A Property that MUST be set when defining the configuration setting.
    - Key: The property is considered as the identifier for the resource modified. It is mandatory AND there cannot be multiple configuration entries with the same value for this property!
    - NotConfigurable: ReadOnly property. Mostly used for integration into Azure Guest Configurations
 
    Example Properties:
 
    [DscProperty(Key)]
    [string]$Path
 
    [DscProperty(Mandatory)]
    [string]$Text
    #>


    [DscProperty(Mandatory, Key)]
    [string] $SectionPath
    
    [DscProperty(Key)]
    [string] $Site

    [DscProperty(Key)]
    [string] $Path

    [DscProperty(Mandatory, Key)]
    [string] $AttributeName
        
    [DscProperty(Mandatory)]
    [string] $AttributeValue

    [DscProperty()]
    [Ensure]$Ensure = 'Present'

    [DscProperty(NotConfigurable)]
    [Reason[]] $Reasons # Reserved for Azure Guest Configuration
    #endregion DSC Properties

    [object] GetValue() {
        return $this.AttributeValue | ConvertFrom-Json
    }

    [void]Set() {
        # Apply Desired State
        $currentElement = $this.GetElement()
        if ($null -eq $currentElement) { throw 'Element not found: {0} > {1} > {2}' -f $this.SectionPath, $this.Site, $this.Path }

        Set-IISConfigAttributeValue -ConfigElement $currentElement -AttributeName $this.AttributeName -AttributeValue $this.GetValue().PSObject.BaseObject
    }

    [IISConfigAttribute]Get() {
        # Return current actual state
        $currentConfig = $this.GetElement()
        $current = [IISConfigAttribute]::new()
        $current.SectionPath = $this.SectionPath
        $current.Site = $this.Site
        $current.Path = $this.Path
        $current.AttributeName = $this.AttributeName
        $current.AttributeValue = @($currentConfig.Attributes).Where{ $_.Name -eq $this.AttributeName }.Value
        $current.Ensure = 'Present'
        if (-not $currentConfig) { $current.Ensure = 'Absent' }
        return $current
    }

    [bool]Test() {
        # Check whether current state = desired state
        $current = $this.Get()

        return (
            $this.Ensure -eq $current.Ensure -and
            $this.GetValue() -eq $current.AttributeValue
        )
    }

    [object]GetElement() {
        $pathElements = $this.Path -split '/'

        $param = @{
            SectionPath = $this.SectionPath
        }
        try {
            if ($this.Site -and $this.SectionPath -notlike 'system.applicationHost/*') { $param.CommitPath = $this.Site }
            $currentConfig = Get-IISConfigSection @param
            if ($this.SectionPath -eq 'system.applicationHost/sites' -and $this.Site) {
                $collection = Get-IISConfigCollection -ConfigElement $currentConfig
                $currentConfig = Get-IISConfigCollectionElement -ConfigCollection $collection -ConfigAttribute @{ name = $this.Site }
            }
            foreach ($pathElement in $pathElements) {
                if (-not $pathElement) { continue }
                $currentConfig = Get-IISConfigElement -ConfigElement $currentConfig -ChildElementName $pathElement
            }
        }
        catch { return $null }
        return $currentConfig
    }

    [Hashtable] GetConfigurableDscProperties() {
        # This method returns a hashtable of properties with two special workarounds
        # The hashtable will not include any properties marked as "NotConfigurable"
        # Any properties with a ValidateSet of "True","False" will beconverted to Boolean type
        # The intent is to simplify splatting to functions
        # Source: https://gist.github.com/mgreenegit/e3a9b4e136fc2d510cf87e20390daa44
        $dscProperties = @{}
        foreach ($property in [IISConfigAttribute].GetProperties().Name) {
            # Checks if "NotConfigurable" attribute is set
            $notConfigurable = [IISConfigAttribute].GetProperty($property).GetCustomAttributes($false).Where({ $_ -is [System.Management.Automation.DscPropertyAttribute] }).NotConfigurable
            if (!$notConfigurable) {
                $value = $this.$property
                # Gets the list of valid values from the ValidateSet attribute
                $validateSet = [IISConfigAttribute].GetProperty($property).GetCustomAttributes($false).Where({ $_ -is [System.Management.Automation.ValidateSetAttribute] }).ValidValues
                if ($validateSet) {
                    # Workaround for boolean types
                    if ($null -eq (Compare-Object @('True', 'False') $validateSet)) {
                        $value = [System.Convert]::ToBoolean($this.$property)
                    }
                }
                # Add property to new
                $dscProperties.add($property, $value)
            }
        }
        return $dscProperties
    }
}

[DscResource()]
class IISConfigCollection {
    #region DSC Properties
    <#
    The properties you can define in configuration settings.
    The [DscProperty(...)] attribute has a few possible values:
    - <empty>: Nothing specified makes this an optional property you can leave empty when defining the configuration setting.
    - Mandatory: A Property that MUST be set when defining the configuration setting.
    - Key: The property is considered as the identifier for the resource modified. It is mandatory AND there cannot be multiple configuration entries with the same value for this property!
    - NotConfigurable: ReadOnly property. Mostly used for integration into Azure Guest Configurations
    #>

    [DscProperty(Mandatory, Key)]
    [string] $SectionPath
    
    [DscProperty(Key)]
    [string] $Site

    [DscProperty(Key)]
    [string] $Path
    
    [DscProperty(Mandatory, Key)]
    [string] $Name
    
    # PowerShell Code that will be passed to Where-Object to determine legal collection elements
    # E.g. '$false' (Empty Collection), '$_.Name -in "txt","json"' (Elements with name txt or json)
    [DscProperty(Mandatory)]
    [string] $Filter

    [DscProperty()]
    [Ensure] $Ensure = 'Present'
    
    [DscProperty(NotConfigurable)]
    [Reason[]] $Reasons # Reserved for Azure Guest Configuration
    #endregion DSC Properties

    [array] $Elements

    [void]Set() {
        # Apply Desired State
        if ($this.Test()) { return }

        $parent = $this.GetElement()
        if (-not $parent) {
            throw "Parent Element of collection $($this.Name) does not exist: '$($this.SectionPath) > $($this.Site) > $($this.Path)'"
        }

        $current = $this.Get()
        if (
            $this.Ensure -eq 'Absent' -and
            $current.Ensure -eq 'Present'
        ) {
            throw 'Deleting collections is not supported'
        }

        if (
            $this.Ensure -eq 'Present' -and
            $current.Ensure -eq 'Absent'
        ) {
            throw 'Creating collections is not supported'
        }

        try { $code = [scriptblock]::Create($this.Filter) }
        catch { throw "Invalid Filter: $($this.Filter)" }

        $collection = Get-IISConfigCollection -ConfigElement $parent -CollectionName $this.Name
        # Note: RawAttributes is a calculated property and regenerated on each request
        foreach ($element in $collection) { Add-Member -InputObject $element -MemberType NoteProperty -Name AttributesEx -Value $element.RawAttributes }

        $legalElementsAttributes = $($collection).AttributesEx | Where-Object $code
        $legalElements = $collection | Where-Object AttributesEx -in $legalElementsAttributes
        $countChanges = 0
        foreach ($illegal in $collection | Where-Object { $_ -notin $legalElements } ) {
            $collection.Remove($illegal)
            $countChanges++
        }

        if (0 -eq $countChanges) { return }
        $iis = Get-IISServerManager
        $iis.CommitChanges()
    }

    [IISConfigCollection]Get() {
        # Return current actual state
        $current = [IISConfigCollection]::new()
        $current.SectionPath = $this.SectionPath
        $current.Site = $this.Site
        $current.Path = $this.Path
        $current.Name = $this.Name
        $current.Filter = $this.Filter

        $parent = $this.GetElement()
        if (-not $parent) {
            $current.Ensure = 'Absent'
            return $current
        }

        $collection = Get-IISConfigCollection -ConfigElement $parent -CollectionName $this.Name
        if (-not $collection) {
            $current.Ensure = 'Absent'
            return $current
        }

        $current.Ensure = 'Present'
        $current.Elements = $($collection)
        return $current
    }

    [bool]Test() {
        # Check whether current state = desired state
        $collection = $this.Get()
        if ($this.Ensure -ne $collection.Ensure) { return $false }

        if (-not $this.Filter) { return $true }

        try { $code = [scriptblock]::Create($this.Filter) }
        catch { throw "Invalid Filter: $($this.Filter)" }

        # Note: RawAttributes is a calculated property and regenerated on each request
        foreach ($element in $collection.Elements) { Add-Member -InputObject $element -MemberType NoteProperty -Name AttributesEx -Value $element.RawAttributes }
        $legalElementsAttributes = $($collection.Elements).AttributesEx | Where-Object $code
        $legalElements = $collection.Elements | Where-Object AttributesEx -in $legalElementsAttributes
        if ($collection.Elements | Where-Object { $_ -notin $legalElements }) { return $false }
        return $true
    }

    [object]GetElement() {
        $pathElements = $this.Path -split '/'

        $param = @{
            SectionPath = $this.SectionPath
        }
        try {
            if ($this.Site -and $this.SectionPath -notlike 'system.applicationHost/*') { $param.CommitPath = $this.Site }
            $currentConfig = Get-IISConfigSection @param
            if ($this.SectionPath -eq 'system.applicationHost/sites' -and $this.Site) {
                $collection = Get-IISConfigCollection -ConfigElement $currentConfig
                $currentConfig = Get-IISConfigCollectionElement -ConfigCollection $collection -ConfigAttribute @{ name = $this.Site }
            }
            foreach ($pathElement in $pathElements) {
                if (-not $pathElement) { continue }
                $currentConfig = Get-IISConfigElement -ConfigElement $currentConfig -ChildElementName $pathElement
            }
        }
        catch { return $null }
        return $currentConfig
    }

    [Hashtable] GetConfigurableDscProperties() {
        # This method returns a hashtable of properties with two special workarounds
        # The hashtable will not include any properties marked as "NotConfigurable"
        # Any properties with a ValidateSet of "True","False" will beconverted to Boolean type
        # The intent is to simplify splatting to functions
        # Source: https://gist.github.com/mgreenegit/e3a9b4e136fc2d510cf87e20390daa44
        $dscProperties = @{}
        foreach ($property in [IISConfigCollection].GetProperties().Name) {
            # Checks if "NotConfigurable" attribute is set
            $notConfigurable = [IISConfigCollection].GetProperty($property).GetCustomAttributes($false).Where({ $_ -is [System.Management.Automation.DscPropertyAttribute] }).NotConfigurable
            if (!$notConfigurable) {
                $value = $this.$property
                # Gets the list of valid values from the ValidateSet attribute
                $validateSet = [IISConfigCollection].GetProperty($property).GetCustomAttributes($false).Where({ $_ -is [System.Management.Automation.ValidateSetAttribute] }).ValidValues
                if ($validateSet) {
                    # Workaround for boolean types
                    if ($null -eq (Compare-Object @('True', 'False') $validateSet)) {
                        $value = [System.Convert]::ToBoolean($this.$property)
                    }
                }
                # Add property to new
                $dscProperties.add($property, $value)
            }
        }
        return $dscProperties
    }
}

[DscResource()]
class IISConfigCollectionItem {
    #region DSC Properties
    <#
    The properties you can define in configuration settings.
    The [DscProperty(...)] attribute has a few possible values:
    - <empty>: Nothing specified makes this an optional property you can leave empty when defining the configuration setting.
    - Mandatory: A Property that MUST be set when defining the configuration setting.
    - Key: The property is considered as the identifier for the resource modified. It is mandatory AND there cannot be multiple configuration entries with the same value for this property!
    - NotConfigurable: ReadOnly property. Mostly used for integration into Azure Guest Configurations
 
    Example Properties:
 
    [DscProperty(Key)]
    [string]$Path
 
    [DscProperty(Mandatory)]
    [string]$Text
 
    [DscProperty(Mandatory)]
    [Ensure]$Ensure
    #>


    [DscProperty(Mandatory, Key)]
    [string] $SectionPath
    
    [DscProperty(Key)]
    [string] $Site

    [DscProperty(Key)]
    [string] $Path
    
    [DscProperty(Mandatory, Key)]
    [string] $CollectionName
    
    [DscProperty(Mandatory)]
    [string] $Data
    
    [DscProperty(Mandatory, Key)]
    [string]$ItemID

    [DscProperty()]
    [bool] $ExactMatch = $true
    
    # Filter condition, on whether an item in the collection is our configured element (which might need to be updated)
    # If NOT specified, all properties on each item will be compared with the entries in the hashtable defined in $Data
    [DscProperty()]
    [string] $Filter

    [DscProperty()]
    [Ensure]$Ensure = 'Present'

    [DscProperty(NotConfigurable)]
    [Reason[]] $Reasons # Reserved for Azure Guest Configuration
    #endregion DSC Properties

    [array] $MatchingItems

    [hashtable]GetData() {
        $content = $this.Data | ConvertFrom-Json
        return $content | ConvertTo-Hashtable
    }

    [void]Set() {
        # Apply Desired State
        $current = $this.Get()
        if (@($current.MatchingItems).Count -gt 1) {
            throw 'Unexpected state: More than one collection item matches the intended item!'
        }

        if ($this.Test()) { return }

        # Case: Create
        if (-not $current.MatchingItems) {
            $parent = $this.GetElement()
            $collection = Get-IISConfigCollection -ConfigElement $parent -CollectionName $this.CollectionName
            New-IISConfigCollectionElement -ConfigCollection $collection -ConfigAttribute $this.GetData()
        }

        # Case: Delete
        elseif ($this.Ensure -eq 'Absent') {
            $parent = $this.GetElement()
            $collection = Get-IISConfigCollection -ConfigElement $parent -CollectionName $this.CollectionName
            $collection.Remove($this.MatchingItems)
        }

        # Case: Update
        else {
            $dataSet = $this.GetData()
            foreach ($key in $dataSet.Keys) {
                if ($dataSet.$key -eq $this.MatchingItems.RawAttributes.$key) { continue }
                Set-IISConfigAttributeValue -ConfigElement $this.MatchingItems -AttributeName $key -AttributeValue $dataSet.$key
            }
        }

        $iis = Get-IISServerManager
        $iis.CommitChanges()
    }

    [IISConfigCollectionItem]Get() {
        # Return current actual state
        $code = {}
        if ($this.Filter) {
            try { $code = [scriptblock]::Create($this.Filter) }
            catch { throw "Invalid PowerShell Syntax in Filter! $($this.Filter)" }
        }

        $current = [IISConfigCollectionItem]::new()
        $current.SectionPath = $this.SectionPath
        $current.Site = $this.Site
        $current.Path = $this.Path
        $current.CollectionName = $this.CollectionName
        $current.ItemID = $this.ItemID
        $current.ExactMatch = $this.ExactMatch
        $current.Filter = $this.Filter

        $parent = $this.GetElement()
        $collection = Get-IISConfigCollection -ConfigElement $parent -CollectionName $this.CollectionName

        #region Match Elements
        # Matching by filter
        if ($this.Filter) {
            foreach ($element in $collection) { Add-Member -InputObject $element -MemberType NoteProperty -Name AttributesEx -Value $element.RawAttributes }
            $matchingAttributes = $($collection).AttributesEx | Where-Object $code
            $matching = $collection | Where-Object AttributesEx -In $matchingAttributes
        }

        # Match by Attribute Match
        else {
            $matching = $collection | Where-Object {
                Test-Hashtable -Intended $this.GetData() -Actual $_.RawAttributes -ExactMatch:$this.ExactMatch
            }
        }
        #endregion Match Elements

        #region Process Matches
        # Case: No match found
        if (-not $matching) {
            $current.Ensure = 'Absent'
            return $current
        }
        # Case: Exact one match found
        if (@($matching).Count -eq 1) {
            $current.Ensure = 'Present'
            $newData = @{}
            foreach ($key in $matching.RawAttributes.Keys) {
                $newData[$key] = $matching.RawAttributes.$key
            }
            $current.Data = $newData | ConvertTo-Json -Depth 99
            $current.MatchingItems = $matching
            return $current
        }
        # Case: Too many matches found
        $current.Ensure = 'Present'
        $current.MatchingItems = $matching
        return $current
        #endregion Process Matches
    }

    [bool]Test() {
        # Check whether current state = desired state
        $current = $this.Get()
        if ($current.Ensure -ne $this.Ensure) { return $false }
        if (@($current.MatchingItems).Count -gt 1) { return $false }
        if ($this.Ensure -eq 'Absent') { return $true }
        
        return Test-Hashtable -Intended $this.GetData() -Actual $current.GetData() -ExactMatch:$this.ExactMatch
    }

    [object]GetElement() {
        $pathElements = $this.Path -split '/'

        $param = @{
            SectionPath = $this.SectionPath
        }
        try {
            if ($this.Site -and $this.SectionPath -notlike 'system.applicationHost/*') { $param.CommitPath = $this.Site }
            $currentConfig = Get-IISConfigSection @param
            if ($this.SectionPath -eq 'system.applicationHost/sites' -and $this.Site) {
                $collection = Get-IISConfigCollection -ConfigElement $currentConfig
                $currentConfig = Get-IISConfigCollectionElement -ConfigCollection $collection -ConfigAttribute @{ name = $this.Site }
            }
            foreach ($pathElement in $pathElements) {
                if (-not $pathElement) { continue }
                $currentConfig = Get-IISConfigElement -ConfigElement $currentConfig -ChildElementName $pathElement
            }
        }
        catch { return $null }
        return $currentConfig
    }

    [Hashtable] GetConfigurableDscProperties() {
        # This method returns a hashtable of properties with two special workarounds
        # The hashtable will not include any properties marked as "NotConfigurable"
        # Any properties with a ValidateSet of "True","False" will beconverted to Boolean type
        # The intent is to simplify splatting to functions
        # Source: https://gist.github.com/mgreenegit/e3a9b4e136fc2d510cf87e20390daa44
        $dscProperties = @{}
        foreach ($property in [IISConfigCollectionItem].GetProperties().Name) {
            # Checks if "NotConfigurable" attribute is set
            $notConfigurable = [IISConfigCollectionItem].GetProperty($property).GetCustomAttributes($false).Where({ $_ -is [System.Management.Automation.DscPropertyAttribute] }).NotConfigurable
            if (!$notConfigurable) {
                $value = $this.$property
                # Gets the list of valid values from the ValidateSet attribute
                $validateSet = [IISConfigCollectionItem].GetProperty($property).GetCustomAttributes($false).Where({ $_ -is [System.Management.Automation.ValidateSetAttribute] }).ValidValues
                if ($validateSet) {
                    # Workaround for boolean types
                    if ($null -eq (Compare-Object @('True', 'False') $validateSet)) {
                        $value = [System.Convert]::ToBoolean($this.$property)
                    }
                }
                # Add property to new
                $dscProperties.add($property, $value)
            }
        }
        return $dscProperties
    }
}

function Compare-Hashtable {
<#
    .SYNOPSIS
    Compares a provided reference hashtable to a provided difference hashtable.
   
    .DESCRIPTION
    Compares a provided reference hashtable to a provided difference hashtable and returns the differences as specified.
   
    .PARAMETER ReferenceHashtable
    The reference hashtable to use for comparison.
     
    .PARAMETER DifferenceHashtable
    The difference hashtable to use for comparison.
 
    .PARAMETER IncludeEqual
    When specified, results are returned when the Reference and Difference are equal.
 
    .PARAMETER ExcludeDifferent
    When specified, results are not returned when the Reference and Difference are different.
     
    .EXAMPLE
    PS C:\> Compare-Hashtable -ReferenceHashtable $MyConfigReference -DifferenceHashtable (Get-Something -Config)
    
    Compares the loaded ReferenceHashtable to the result of Get-Something.
 
#>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [hashtable]
        $ReferenceHashtable,
        
        [Parameter(Mandatory = $true)]
        [hashtable]
        $DifferenceHashtable,

        [switch]
        $IncludeEqual,

        [switch]
        $ExcludeDifferent
    )

    begin {
        function New-Change {
            [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
            [CmdletBinding()]
            param (
                [hashtable]
                $Reference,

                [hashtable]
                $Difference,

                [string]
                $Name,

                [ValidateSet('==', '!=', '=>', '<=')]
                [string]
                $Direction,

                [AllowEmptyCollection()]
                [AllowEmptyString()]
                [AllowNull()]
                $RefValue,

                [AllowEmptyCollection()]
                [AllowEmptyString()]
                [AllowNull()]
                $DifValue
            )

            [PSCustomObject]@{
                PSTypeName = 'Hashtable.Comparison'
                Reference  = $Reference
                Difference = $Difference
                Name       = $Name
                Direction  = $Direction
                RefValue   = $RefValue
                DifValue   = $DifValue
            }
        }
    }

    process {
        $paramChange = @{
            Difference = $DifferenceHashtable
            Reference  = $ReferenceHashtable
        }
        $flatReference = Read-HashTable -Hashtable $ReferenceHashtable -AsHashtable
        $flatDifference = Read-HashTable -Hashtable $DifferenceHashtable -AsHashtable

        foreach ($pair in $flatReference.GetEnumerator()) {
            if (-not $ExcludeDifferent) {
                if ($flatDifference.Keys -notcontains $pair.Key) {
                    New-Change @paramChange -Name $pair.Key -Direction '=>' -RefValue $pair.Value
                    continue
                }
                if ($pair.Value -ne $flatDifference[$pair.Key] -or $flatDifference[$pair.Key] -ne $pair.Value) {
                    New-Change @paramChange -Name $pair.Key -Direction '!=' -RefValue $pair.Value -DifValue $flatDifference[$pair.Key]
                }
            }
            if ($IncludeEqual) {
                if ($pair.Value -eq $flatDifference[$pair.Key] -and $flatDifference[$pair.Key] -eq $pair.Value) {
                    New-Change @paramChange -Name $pair.Key -Direction '==' -RefValue $pair.Value -DifValue $flatDifference[$pair.Key]
                }
            }
        }

        foreach ($pair in $flatDifference.GetEnumerator()) {
            if (-not $ExcludeDifferent -and $flatReference.Keys -notcontains $pair.Key) {
                New-Change @paramChange -Name $pair.Key -Direction '<=' -DifValue $pair.Value
                continue
            }
        }
    }
}
<#
# Examle usage
 
$hash1 = @{
    Foo = 23
    Bar = 1
    Data = @{
        Answer = 42
        Name = 'Fred'
        Age = 37
    }
}
 
$hash2 = @{
    Foo = 42
    Data = @{
        Answer = 42
        Name = 'Max'
        Age = @(37,38)
    }
}
Read-HashTable -Hashtable $hash1
 
Compare-Hashtable -ReferenceHashtable $hash1 -DifferenceHashtable $hash2 | ft Name, Direction, DifValue, RefValue
Compare-Hashtable -ReferenceHashtable $hash1 -DifferenceHashtable $hash1 | ft Name, Direction, DifValue, RefValue
Compare-Hashtable -ReferenceHashtable $hash1 -DifferenceHashtable $hash1 -IncludeEqual | ft Name, Direction, DifValue, RefValue
Compare-Hashtable -ReferenceHashtable $hash1 -DifferenceHashtable $hash2 -IncludeEqual -ExcludeDifferent | ft Name, Direction, DifValue, RefValue
#>


function ConvertTo-Hashtable {
    <#
    .SYNOPSIS
        Simple Object to Hashtable conversion.
     
    .DESCRIPTION
        Simple Object to Hashtable conversion.
        Only converts at a flat level.
 
        - Hashtables are cloned
        - Other Dictionaries are translated to hashtable
        - Other items have their PSObject's properties enumerated and copied to hashtable
 
        Primitive types will not be handled gracefully and likely end in an empty hashtable.
     
    .PARAMETER InputObject
        The object to convert to hashtable.
     
    .EXAMPLE
        PS C:\> $data | ConvertTo-Hashtable
 
        Converts $data to hashtable.
    #>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param (
        [Parameter(ValueFromPipeline = $true)]
        $InputObject
    )
    process {
        if ($null -eq $InputObject) { return }

        if ($InputObject -is [hashtable]) { return $InputObject.Clone() }

        $hash = @{}
        if ($InputObject -is [System.Collections.IDictionary]) {
            foreach ($key in $InputObject.Keys) {
                $hash[$key] = $InputObject.$key
            }
        }
        else {
            foreach ($property in $InputObject.PSObject.Properties) {
                $hash[$property.Name] = $property.Value
            }
        }
        $hash
    }
}

function Read-Hashtable {
    <#
.Synopsis
    Reads a hash table and returns its contents as key/value objects.
 
.DESCRIPTION
    Reads a hash table and returns its contents as a hierarchical structure.
    Use the AsHashtable switch to return the result as a flat hashtable, rather than one object per.
 
.PARAMETER Hashtable
    The input hash table that is to be read.
 
.PARAMETER Namespace
    The namespace under which the keys should be read.
    Used to recursively resolve hashtables
 
.PARAMETER AsHashtable
    Specifies if the contents of the hash table are returned as an object
    hierarchy or as a hash table itself.
 
.EXAMPLE
    $struct = @{
        first = @{ second = '213' }
    }
    Read-Hashtable -Hashtable $struct
 
    Name Value
    ---- -----
    first.second 213
#>

    [OutputType([hashtable])]
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [hashtable]
        $Hashtable,
        
        [string]
        $Namespace,

        [switch]
        $AsHashtable
    )

    $prefix = ''
    if ($Namespace) { $prefix = "$Namespace." }

    $results = foreach ($pair in $Hashtable.GetEnumerator()) {
        $name = '{0}{1}' -f $prefix, $pair.Key
        if ($pair.Value -is [hashtable] -and $pair.Value.Count -gt 0) {
            Read-HashTable -Namespace $name -Hashtable $pair.Value
            continue
        }
        [PSCustomObject]@{
            Name  = $name
            Value = $pair.Value
        }
    }

    if (-not $AsHashtable) { return $results }

    $resultHash = @{ }
    foreach ($result in $results) {
        $resultHash[$result.Name] = $result.Value
    }
    $resultHash
}

function Test-Hashtable {
    <#
    .SYNOPSIS
        Tests, whether the provided Hashtable matches the intended one.
     
    .DESCRIPTION
        Tests, whether the provided Hashtable matches the intended one.
        Compares hashtables / dictionaries in depth, including nested hashtables.
     
    .PARAMETER Intended
        The hashtable containing the desired data.
     
    .PARAMETER Actual
        The object collected from the field that is compared to the desired state.
     
    .PARAMETER ExactMatch
        Require an exact match between the two hashtables.
        By default, the actual hashtable may contain entries in addition to the oones the intended one requires.
     
    .EXAMPLE
        PS C:\> Test-Hashtable -Intended $template -Actual $data
         
        Tests, whether the hashtable in $data has all the settings defined in $template.
        $data may contain additional settings, beyond what is defined in $template.
     
    .EXAMPLE
        PS C:\> Test-Hashtable -Intended $template -Actual $data -ExactMatch
         
        Tests, whether the hashtable in $data has all the settings defined in $template.
        $data may NOT contain additional settings, beyond what is defined in $template.
    #>

    [OutputType([bool])]
    [CmdletBinding()]
    param (
        [System.Collections.IDictionary]
        $Intended,
        
        [System.Collections.IDictionary]
        $Actual,

        [switch]
        $ExactMatch
    )
    process {
        $intendedHash = @{}
        if ($Intended -is [hashtable]) { $intendedHash = $Intended }
        else {
            foreach ($key in $Intended.Keys) { $intendedHash[$key] = $Intended[$Key] }
        }

        $actualHash = @{}
        if ($Actual -is [hashtable]) { $actualHash = $Actual }
        else {
            foreach ($key in $Actual.Keys) { $actualHash[$key] = $Actual[$Key] }
        }

        $delta = Compare-Hashtable -ReferenceHashtable $intendedHash -DifferenceHashtable $actualHash
        if (-not $delta) { return $true }
        if ($delta.Direction -contains '!=') { return $false }
        if ($delta.Direction -contains '<=' -and $ExactMatch) { return $false }
        if ($delta.Direction -contains '=>') { return $false }
        return $true
    }
}