ServerConfigurationManager.psm1

function Invoke-ServerConfiguration {
    <#
    .SYNOPSIS
        Execute all applicable configuration entries against the local computer.
     
    .DESCRIPTION
        Execute all applicable configuration entries against the local computer.
        This is the primary Server Configuration Manager command that performs the full deployment / application against the local computer.
     
    .PARAMETER RepositoryName
        The name of the PowerShell repository used as part of this workflow.
     
    .PARAMETER ContentPath
        The path to where all the Actions, Targets and Configurations are stored.
     
    .PARAMETER PassThru
        Whether the application result should be passed through to the console, rather than a simple error if it fails and nothing otherwise.
     
    .EXAMPLE
        PS C:\> Invoke-ServerConfiguration -RepositoryName $RepositoryName -ContentPath $ContentPath
 
        Execute all applicable configuration entries against the local computer.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $RepositoryName,
    
        [Parameter(Mandatory = $true)]
        [string]
        $ContentPath,

        [switch]
        $PassThru
    )
    
    begin {
        Assert-Repository -Name $RepositoryName -Cmdlet $PSCmdlet
        Assert-ContentPath -Path $ContentPath -Cmdlet $PSCmdlet
    }
    process {
        Import-Target -ContentPath $ContentPath
        Import-Action -ContentPath $ContentPath
        $targets = Resolve-Target
        $configuration = Import-Configuration -ContentPath $ContentPath -Targets $targets | Sort-Object Tier, Weight

        $deploymentState = @{ }
        $executionResult = foreach ($configurationItem in $configuration) {
            Invoke-Configuration -Config $configurationItem -RepositoryName $RepositoryName -ContentPath $ContentPath -DeploymentState $deploymentState
        }

        if ($PassThru) { return $executionResult }
        if ($failed = $executionResult | Where-Object Status -NE 'Success') {
            Write-ScmLog -EventId 666 -Type Error -Message "Invocation failed for $(($failed | Measure-Object).Count) items"
            throw "Invokation failed for $(($failed | Measure-Object).Count) items"
        }
    }
}


function Register-ScmAction {
    <#
    .SYNOPSIS
        Register an Action to the Server Configuration Manager.
     
    .DESCRIPTION
        Register an Action to the Server Configuration Manager.
        Actions are the implementing logic, that turns configuration into reality.
 
        A configuration entry might require for a PowerShell module to exist on the computer.
        The action is that makes it happen.
 
        Both scriptblocks implementing this receive a single hashtable as argument.
        The hashtable comes with the following keys:
        - Parameters: A Custom Object containing the parameters specified in the configuration entry.
        - Repository: The name of the PowerShell repository used for the SCM.
        - ContentPath: The root path to where the SCM content (such as configuration data) is at.
        - ConfigurationName: Name of the configuration setting (mostly for logging purposes)
     
    .PARAMETER Name
        The name of the Action.
     
    .PARAMETER Description
        A description of the Action, documenting what it is all about and how to use it.
     
    .PARAMETER ParametersRequired
        A list of parameters that must be specified, in order for this action to be viable.
        The name of the parameter would be the key, a description of the parameter the value.
     
    .PARAMETER ParametersOptional
        A list of parameters that may optionally be specified.
        The name of the parameter would be the key, a description of the parameter the value.
     
    .PARAMETER Validation
        Scriptblock validating, whether the desired state already exists.
     
    .PARAMETER Execution
        Scriptblock bringing the current computer into the desired state.
     
    .EXAMPLE
        PS C:\> Register-ScmAction @parameters
 
        Registers a new SCM Action.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

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

        [Parameter(Mandatory = $true)]
        [hashtable]
        $ParametersRequired,

        [Parameter(Mandatory = $true)]
        [hashtable]
        $ParametersOptional,

        [Parameter(Mandatory = $true)]
        [scriptblock]
        $Validation,

        [Parameter(Mandatory = $true)]
        [scriptblock]
        $Execution
    )
    
    process {
        $script:actions[$Name] = [pscustomobject]@{
            PSTypeName         = 'ServerConfigurationManager'
            Name               = $Name
            Description        = $Description
            ParametersRequired = $ParametersRequired
            ParametersOptional = $ParametersOptional
            Validation         = $Validation
            Execution          = $Execution
        }
    }
}

function Register-ScmTarget {
    <#
    .SYNOPSIS
        Registers a Target to the Server Configuration Manager.
     
    .DESCRIPTION
        Registers a Target to the Server Configuration Manager.
        Targets are labels linked to a scriptblock.
        A computer is considered to be targeted, if the scriptblock returns $true when run as local system on the affected system.
        The scriptblock will receive no arguments and must execute selfcontained.
 
        Configuration entries are assigned to target labels.
     
    .PARAMETER Name
        The name of the Target.
     
    .PARAMETER ScriptBlock
        The executing code that determines, whether the current computer is part of that Target.
        Should return $true if it is.
     
    .EXAMPLE
        PS C:\> Register-ScmTarget -Name MemberServer -ScriptBlock $code
 
        Registers the scriptblock $code as "MemberServer"
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true)]
        [scriptblock]
        $ScriptBlock
    )
    
    process {
        $script:targets[$Name] = [PSCustomObject]@{
            PSTypeName = 'ServerConfigurationManager.Target'
            Name = $Name
            ScriptBlock = $ScriptBlock
        }
    }
}

function Write-ScmLog {
    <#
    .SYNOPSIS
        Write a log message.
     
    .DESCRIPTION
        Write a log message.
     
    .PARAMETER Message
        The message to write.
     
    .PARAMETER Type
        What kind of message to write.
        Defaults to Information
     
    .PARAMETER EventId
        The id of the event to generate.
        Defaults to 1000
     
    .PARAMETER Source
        The source of the eventlog message.
        Defaults to 'ScmExecution'
 
    .PARAMETER ErrorRecord
        The error record to log.
     
    .EXAMPLE
        PS C:\> Write-ScmLog -Message "Starting action import"
 
        Generates the informational log entry stating that the action import is starting.
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [string]
        $Message,

        [System.Diagnostics.EventLogEntryType]
        $Type = 'Information',

        [int]
        $EventId = 1000,

        [ValidateSet('ScmLauncher', 'ScmExecution', 'ScmDebug', 'ScmAction')]
        [string]
        $Source = 'ScmExecution',

        [System.Management.Automation.ErrorRecord]
        $ErrorRecord
    )

    if ($ErrorRecord) {
        $Message += " | $ErrorRecord"
    }

    If ($Type -eq 'Error') { Write-Warning $Message }
    else { Write-Verbose $Message }
    try {
        if ($script:windowsLog) {
            $eventlog = [System.Diagnostics.EventLog]::GetEventLogs().Where{ $_.Log -eq "ServerConfigurationManager" }[0]
            $eventlog.Source = $Source
            $eventlog.WriteEntry($Message, $Type, $EventId)
        }
        else {
            foreach ($line in $Message -split "`n") {
                logger "$Source $Type $EventId $line"
            }
        }
    }
    catch {
        # Do nothing if it fails
    }

    if ($ErrorRecord) {
        $debugString = @'
Error:
Message: {0}
 
ScriptStackTrace:
{1}
 
Target: {2}
ErrorId: {3}
Category: {4}
 
Position:
{5}
 
Exception:
{6}
'@
 -f $ErrorRecord, $ErrorRecord.ScriptStackTrace, $ErrorRecord.TargetObject, $ErrorRecord.FullyQualifiedErrorId, $ErrorRecord.CategoryInfo, $ErrorRecord.InvocationInfo.PositionMessage, ($ErrorRecord.Exception | Format-List -Force | Out-String)
        Write-ScmLog -Source ScmDebug -Message $debugString -EventId 1 -Type Warning
    }
}

function Assert-ContentPath {
    <#
    .SYNOPSIS
        Ensures the specified content path is legitimate.
     
    .DESCRIPTION
        Ensures the specified content path is legitimate.
 
        A content path is legitimate when ...
        - It exists & is a folder
        - Has a child folder named "actions"
        - Has a child folder named "configuration"
        - Has a child folder named "targets"
     
    .PARAMETER Path
        The Content Path being validated.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
     
    .EXAMPLE
        PS C:\> Assert-ContentPath -Path $ContentPath -Cmdlet $PSCmdlet
 
        Throws a terminating exception if the specified path does not exist or lacks the required subfolder structure.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Path,
        
        [Parameter(Mandatory = $true)]
        $Cmdlet
    )
    
    process {
        $rootExists = Test-Path -Path $Path -PathType Container
        $actionsExists = Test-Path -Path "$Path/actions" -PathType Container
        $targetsExists = Test-Path -Path "$Path/targets" -PathType Container
        $configurationExists = Test-Path -Path "$Path/configuration" -PathType Container

        if ($rootExists -and $actionsExists -and $targetsExists -and $configurationExists) {
            return
        }

        $message = "Invalid configuration source from root '$Path': Root $rootExists | Actions $actionsExists | Targets $targetsExists | Config $configurationExists"
        Write-ScmLog -Message $message -EventId 404 -Type Error

        $exception = [System.ArgumentException]::new($message)
        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'InvalidContentPath', 'InvalidArgument', $null)
        $Cmdlet.ThrowTerminatingError($errorRecord)
    }
}

function Assert-Repository {
    <#
    .SYNOPSIS
        Asserts that the intended PSRepository used for PowerShell module access exists.
     
    .DESCRIPTION
        Asserts that the intended PSRepository used for PowerShell module access exists.
     
    .PARAMETER Name
        Name of the repository to assert.
     
    .PARAMETER Cmdlet
        The $PSCmdlet variable of the calling command.
     
    .EXAMPLE
        PS C:\> Assert-Repository -Name $RepositoryName -Cmdlet $PSCmdlet
 
        Asserts that the repository specified in $RepositoryName actually exists.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $Name,

        [Parameter(Mandatory = $true)]
        $Cmdlet
    )
    
    process {
        $repository = Get-PSRepository -Name $Name -ErrorAction Ignore
        if ($repository) {return }

        $message = "PowerShell Repository '$Name' not found!"
        Write-ScmLog -Message $message -EventId 405 -Type Error

        $exception = [System.ArgumentException]::new($message)
        $errorRecord = [System.Management.Automation.ErrorRecord]::new($exception, 'InvalidRepository', 'InvalidArgument', $Name)
        $Cmdlet.ThrowTerminatingError($errorRecord)
    }
}


function Import-Action {
    <#
    .SYNOPSIS
        Imports all configured action files.
     
    .DESCRIPTION
        Imports all configured action files.
     
    .PARAMETER ContentPath
        The path to the SCM content.
     
    .EXAMPLE
        PS C:\> Import-Action -ContentPath $ContentPath
 
        Imports all action files under the path specified in $ContentPath.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $ContentPath
    )
    
    process {
        $actionPath = Join-Path -Path $ContentPath -ChildPath 'actions'
        foreach ($file in Get-ChildItem -Path $actionPath -File -Recurse | Where-Object Extension -eq '.ps1') {
            Write-ScmLog -Message "Loading Action: $($file.BaseName) ($($file.FullName))"
            try {
                # Loading the file straight with & would shift the script scope to the file we are importing, breaking any internal calls the script performs.
                # Dotsourcing the file straight would give it direct access the function variables, adding conflict potential.
                # This way it executes in a child scope but does not shift the script scope outside of the module
                $null = & {
                    param ($File)
                    . $File.FullName
                } $file
                Write-ScmLog -Message "Loading Action: $($file.BaseName) Successful"
            }
            catch {
                Write-ScmLog -Message "Loading Action: $($file.BaseName) Failed" -Type Error -EventId 500 -ErrorRecord $_
            }
        }
    }
}

function Import-Configuration {
    <#
    .SYNOPSIS
        Import configuration PSD1 files from the configuration path.
     
    .DESCRIPTION
        Import configuration PSD1 files from the configuration path.
 
        For each applicable target it will look for a folder of the same name in the configuration folder.
        For each folder thus found it will search for config psd1 files inside of that folder and load them.
 
        See documentation for legal config file structure.
     
    .PARAMETER ContentPath
        The root path to where SCM content is stored.
        It will look in the configuration subfolder for relevant settings.
     
    .PARAMETER Targets
        The Targets that are applicable and should have configuration loaded for.
     
    .EXAMPLE
        PS C:\> Import-Configuration -ContentPath $ContentPath -Targets $targets
 
        Load all configuration settings for the determined targets from $ContentPath
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $ContentPath,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [string[]]
        $Targets
    )
    
    process {
        $configPath = Join-Path -Path $ContentPath -ChildPath 'configuration'
        foreach ($targetName in $Targets) {
            Write-ScmLog -Source ScmDebug -Message "Importing config for $targetName" -EventId 2200
            $targetPath = Join-Path -Path $configPath -ChildPath $targetName
            if (-not (Test-Path -Path $targetPath)) {
                Write-ScmLog -Source ScmDebug -Message "No config folder detected" -EventId 2201
                continue
            }

            foreach ($configFile in Get-ChildItem -Path $targetPath -Recurse -File | Where-Object Extension -EQ '.psd1') {
                Write-ScmLog -Source ScmDebug -Message "Loading Config File: $($configFile.FullName)" -EventId 2202
                try { Import-PowerShellDataFile -Path $configFile.FullName -ErrorAction Stop }
                catch {
                    Write-ScmLog -Type Warning -Message "Error loading Config File $($configFile.FullName)" -EventId 2203 -ErrorRecord $_
                }
            }
        }
    }
}


function Import-Target {
    <#
    .SYNOPSIS
        Imports all configured target files.
     
    .DESCRIPTION
        Imports all configured target files.
     
    .PARAMETER ContentPath
        The path to the SCM content.
     
    .EXAMPLE
        PS C:\> Import-Target -ContentPath $ContentPath
 
        Imports all target files under the path specified in $ContentPath.
    #>

    [CmdletBinding()]
    Param (
        [Parameter(Mandatory = $true)]
        [string]
        $ContentPath
    )
    
    process {
        $targetPath = Join-Path -Path $ContentPath -ChildPath 'targets'
        foreach ($file in Get-ChildItem -Path $targetPath -File -Recurse | Where-Object Extension -eq '.ps1') {
            Write-ScmLog -Message "Loading Target: $($file.BaseName) ($($file.FullName))"
            try { 
                # Loading the file straight with & would shift the script scope to the file we are importing, breaking any internal calls the script performs.
                # Dotsourcing the file straight would give it direct access the function variables, adding conflict potential.
                # This way it executes in a child scope but does not shift the script scope outside of the module
                $null = & {
                    param ($File)
                    . $File.FullName
                } $file
                Write-ScmLog -Message "Loading Target: $($file.BaseName) Successful"
            }
            catch {
                Write-ScmLog -Message "Loading Target: $($file.BaseName) Failed" -Type Error -EventId 501 -ErrorRecord $_
            }
        }
    }
}

function Invoke-Configuration {
    <#
    .SYNOPSIS
        Executes a configuration against the current computer.
     
    .DESCRIPTION
        Executes a configuration against the current computer.
     
    .PARAMETER Config
        The configuration object to execute.
     
    .PARAMETER RepositoryName
        The name of the PSRepository used by the Server Configuration Manager system.
        Used in Actions that need to access packages for their workflow.
     
    .PARAMETER ContentPath
        Path to the base content directory.
        Used in Actions that need additional resources.
     
    .PARAMETER DeploymentState
        Hashtable tracking the deployment state of all configuration entries as part of a full configuration invocation.
        This hashtable is used for determining whether dependencies on other Configuration entries have been met.
     
    .EXAMPLE
        PS C:\> Invoke-Configuration -Config $configurationItem -RepositoryName $RepositoryName -ContentPath $ContentPath -DeploymentState $deploymentState
 
        Executes the configuration item $configurationItem with the specified runtime metadata.
    #>

    [CmdletBinding()]
    Param (
        $Config,
        
        [string]
        $RepositoryName,
        
        [string]
        $ContentPath,
        
        [hashtable]
        $DeploymentState
    )
    
    begin {
        #region Utility Function
        function Write-Result {
            [CmdletBinding()]
            param (
                $Config,
                $DeploymentState,
                $Status,
                $Data
            )

            $DeploymentState[$Config.Name] = $Status
            [PSCustomObject]@{
                PSTypeName    = 'ServerConfigurationManager.Result'
                Name          = $Config.Name
                Configuration = $Config
                Status        = $Status
                Data          = $Data
            }
        }
        #endregion Utility Function
    }
    process {
        #region Format Validation
        if (-not $Config.Name) {
            Write-ScmLog -EventId 5000 -Type Error -Message "Invalid Configuration Entry - [Name] is missing: $Config"
            return
        }
        if (-not $Config.Action) {
            Write-ScmLog -EventId 5001 -Type Error -Message "Invalid Configuration Entry - [Action] is missing: $Config"
            return
        }
        #endregion Format Validation

        #region Parameter Validation
        Write-ScmLog -EventId 5002 -Message "Processing Configuration $($Config.Name) ($($Config.Action)) | $($Config.Target)"
        $resultDefaults = @{
            Config          = $Config
            DeploymentState = $DeploymentState
        }
        
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] Processing Parameters"
        $parameters = @{ }
        if ($Config.Parameters) {
            if ($Config.Parameters -is [Hashtable]) {
                $parameters += $Config.Parameters
            }
            else {
                foreach ($property in $Config.Parameters.PSObject.Properties) {
                    $parameters[$property.Name] = $property.Value
                }
            }
        }
        $scriptParameters = @{
            Parameters        = $parameters
            Repository        = $RepositoryName
            ContentPath       = $ContentPath
            ConfigurationName = $Config.Name
        }
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] #$($parameters.Count) Parameters found: $($parameters.Keys -join ",")"

        $actionObject = $script:actions[$Config.Action]
        if (-not $actionObject) {
            Write-ScmLog -EventId 5003 -Type Error -Message "[$($Config.Name)] Unknown Action: $($Config.Action)"
            Write-Result @resultDefaults -Status 'Unknown Action' -Data $Config.Action
            return
        }
        $missingParameters = foreach ($parameterName in $actionObject.ParametersRequired.Keys) {
            if ($parameters.Keys -contains $parameterName) { continue }
            Write-ScmLog -EventId 5004 -Type Error -Message "[$($Config.Name)] Missing required parameter: $($parameterName)"
            $parameterName
        }
        if ($missingParameters) {
            Write-Result @resultDefaults -Status 'Bad Parameters' -Data $missingParameters
            return
        }
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] All required parameters found"
        #endregion Parameter Validation

        #region Dependency Validation
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] Processing Parameters"
        if ($Config.DependsOn) {
            $missingDependencies = foreach ($dependency in $Config.DependsOn) {
                if ($DeploymentState[$dependency] -eq 'Success') { continue }
                Write-ScmLog -EventId 5005 -Type Error -Message "[$($Config.Name)] Dependency not met: $($dependency)"
                $dependency
            }

            if ($missingDependencies) {
                Write-Result @resultDefaults -Status 'Dependency not met' -Data $missingDependencies
                return
            }
        }
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] Processing Parameters - Completed"
        #endregion Dependency Validation

        #region Pre-Test
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] Executing Pre-Test"
        try { $testResult = & $actionObject.Validation $scriptParameters }
        catch {
            Write-ScmLog -EventId 5006 -Type Error -Message "[$($Config.Name)] Error executing test" -ErrorRecord $_
            Write-Result @resultDefaults -Status 'Error executing test' -Data $_
            return
        }
        if ($testResult) {
            Write-ScmLog -EventId 5007 -Message "[$($Config.Name)] Test successful, configuration already applied"
            Write-Result @resultDefaults -Status 'Success'
            return
        }
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] Executing Pre-Test - Completed"
        #endregion Pre-Test

        #region Execution
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] Executing Configuration"
        try { $null = & $actionObject.Execution $scriptParameters }
        catch {
            Write-ScmLog -EventId 5008 -Type Error -Message "[$($Config.Name)] Error executing Action $($Config.Action)" -ErrorRecord $_
            Write-Result @resultDefaults -Status 'Error executing configuration' -Data $_
            return
        }
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] Executing Configuration - Completed"
        #endregion Execution

        #region Post-Test
        Write-ScmLog -Source ScmDebug -Message "[$($Config.Name)] Executing Post-Test"
        try { $testResult = & $actionObject.Validation $scriptParameters }
        catch {
            Write-ScmLog -EventId 5009 -Type Error -Message "[$($Config.Name)] Error executing test" -ErrorRecord $_
            Write-Result @resultDefaults -Status 'Error executing test' -Data $_
            return
        }
        if ($testResult) {
            Write-ScmLog -EventId 5010 -Message "[$($Config.Name)] Test successful, configuration successfully applied"
            Write-Result @resultDefaults -Status 'Success'
            return
        }
        else {
            Write-ScmLog -EventId 5011 -Type Error -Message "[$($Config.Name)] Test failed, execution not successful"
            Write-Result @resultDefaults -Status 'Failed'
            return
        }
        #endregion Post-Test
    }
}

function Resolve-Target {
    <#
    .SYNOPSIS
        Resolve the targets that apply to the current computer.
     
    .DESCRIPTION
        Resolve the targets that apply to the current computer.
 
        Requires the current target logic to already have been imported through Import-Target.
     
    .EXAMPLE
        PS C:\> Resolve-Target
 
        Returns the names of targets the current computer is part of-
    #>

    [OutputType([string])]
    [CmdletBinding()]
    Param ()
    
    begin {
        $list = [System.Collections.ArrayList]@()
    }
    process {
        foreach ($targetObject in $script:targets.Values) {
            Write-ScmLog -Source ScmDebug -EventId 2000 -Message "Testing for target: $($targetObject.Name)"
            try {
                [bool]$result = & $targetObject.ScriptBlock
                Write-ScmLog -Source ScmDebug -EventId 2001 -Message "Test completed: $result"
                if ($result) {
                    $null = $list.Add($targetObject.Name)
                    $targetObject.Name
                }
            }
            catch {
                Write-ScmLog -Type Warning -EventId 2002 -Message "Error processing target: $($targetObject.Name)" -ErrorRecord $_
            }
        }
    }
    end {
        Write-ScmLog -EventId 2003 -Message "$($list.Count)# Targets met: $($list -join ", ")"
    }
}


$script:windowsLog = $PSVersionTable.PSVersion.Major -le 5 -or $IsWindows

if ($script:windowsLog) {
    $sources = @(
        'ScmLauncher'
        'ScmExecution'
        'ScmDebug'
        'ScmAction'
    )
    
    foreach ($source in $sources) {
        try {
            if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
                [System.Diagnostics.EventLog]::CreateEventSource($source, "ServerConfigurationManager")
            }
        }
        catch { }
    }
}

# The Actions available for configuration tasks
$script:actions = @{ }

# The Targets used to identify which configuration settings apply
$script:targets = @{ }