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 { $eventlog = [System.Diagnostics.EventLog]::GetEventLogs().Where{ $_.Log -eq "ServerConfigurationManager" }[0] $eventlog.Source = $Source $eventlog.WriteEntry($Message, $Type, $EventId) } 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 ", ")" } } $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 = @{ } |