New major version. Interface and implementation have been completely overhauled.

The previous version included Invoke-ChecklistRequirement and Invoke-ContextRequirement, which relied on global state and held two different requirement engines with integrated logging. The new version contains a single requirement engine that outputs RequirementEvents. These events can be displayed with standard PowerShell formatters like Format-Table and Format-List. In addition, this PR introduces Format-Checklist for the human-facing interface and Format-Callstack for verbosely logging requirement execution

In addition to cleaner interfaces and implementation, Requirements now supports "DependsOn" for dependency graph execution.
chriskuech-patch-1
Chris Kuech 2019-06-12 18:18:46 -07:00 committed by GitHub
parent c0d1273ef3
commit 8dcfa31b17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 911 additions and 436 deletions

3
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,3 @@
{
"powershell.scriptAnalysis.settingsPath": "PSScriptAnalyzerSettings.psd1"
}

View File

@ -1,181 +0,0 @@
enum Status {
NotRun
Pass
Fail
}
class Log {
# We originally used [Status] values as keys instead of casting [Status] values to strings.
# We cast values to strings for now due to a bug in PSScriptAnalyzer v1.16.1
static [hashtable] $Symbols = @{
Pass = [char]8730
Fail = "X"
NotRun = " "
}
static [hashtable] $Colors = @{
Pass = "Green"
NotRun = "Yellow"
Fail = "Red"
}
static [int] $LastLineLength
static [void] WriteLine([string] $message, [Status] $status) {
$message = [Log]::FormatMessage($message, $status)
[Log]::LastLineLength = $message.Length
$color = [Log]::Colors[ [string]$status ]
Write-Host $message -ForegroundColor $color -NoNewline
}
static [void] OverwriteLine([string] $message, [Status] $status) {
Write-Host "`r$(' ' * [Log]::LastLineLength)" -NoNewline
$message = [Log]::FormatMessage($message, $status)
[Log]::LastLineLength = $message.Length
$color = [Log]::Colors[ [string]$status ]
Write-Host "`r$message" -ForegroundColor $color
}
static [string] FormatMessage([string] $message, [Status] $status) {
$symbol = [Log]::Symbols[ [string]$status ]
return "$(Get-Date -Format 'hh:mm:ss') [ $symbol ] $message"
}
static [void] WriteError([string] $message) {
Write-Host "`n$message`n" -ForegroundColor Red
exit -1
}
}
<#
.SYNOPSIS
Ensures a requirement is met.
.DESCRIPTION
This cmdlet allows for declaratively defining requirements and implementing consistent logging and idempotency around the status of the requirements.
.PARAMETER Describe
A description of the requirement that is enforced.
.PARAMETER Test
If present, 'Test' is a scriptblock that returns 'true' if the requirement is already met and the 'Set' scriptblock should not run. If not present, 'Set' will always run.
.PARAMETER Set
A scriptblock that imposes the requirement when run. If a "Test' scriptblock is not provided, 'Set' must be idempotent.
.PARAMETER Message
An error message printed if an idempotent 'Set' scriptblock fails during execution.
.EXAMPLE
# A non-idempotent 'Set' scriptblock
Invoke-ChecklistRequirement `
-Describe "'Hello world' is logged" `
-Test {Get-Content $MyLogFilePath | ? {$_ -eq "Hello world"}} `
-Set {"Hello world" >> $MyLogFilePath}
# An idempotent 'Set' scriptblock
Invoke-ChecklistRequirement `
-Describe "'Hello world' is logged" `
-Set {"Hello world" > $MyLogFilePath} `
-Message "Could not log 'Hello World'"
#>
function Invoke-ChecklistRequirement {
Param(
[Parameter(Mandatory, ParameterSetName = "ApplyIfNeeded")]
[Parameter(Mandatory, ParameterSetName = "ApplyAlways")]
[Parameter(Mandatory, ParameterSetName = "Information")]
[ValidateNotNullOrEmpty()]
[string] $Describe,
[Parameter(Mandatory, ParameterSetName = "ApplyIfNeeded")]
[ValidateNotNullOrEmpty()]
[scriptblock] $Test,
[Parameter(Mandatory, ParameterSetName = "ApplyIfNeeded")]
[Parameter(Mandatory, ParameterSetName = "ApplyAlways")]
[ValidateNotNullOrEmpty()]
[scriptblock] $Set,
[Parameter(Mandatory, ParameterSetName = "ApplyAlways")]
[ValidateNotNullOrEmpty()]
[string] $Message,
[switch] $ListRequirement
)
try {
if ($ListRequirement) {
[Log]::WriteLine("$Describe`n", [Status]::NotRun)
return
}
switch ($PSCmdlet.ParameterSetName) {
"ApplyIfNeeded" {
[Log]::WriteLine($Describe, [Status]::NotRun)
if (&$Test) {
[Log]::OverwriteLine($Describe, [Status]::Pass)
}
else {
&$Set | Out-Null
if (&$Test) {
[Log]::OverwriteLine($Describe, [Status]::Pass)
}
else {
[Log]::OverwriteLine($Describe, [Status]::Fail)
[Log]::WriteError("Requirement validation failed")
}
}
}
"ApplyAlways" {
[Log]::WriteLine($Describe, [Status]::NotRun)
if (&$Set) {
[Log]::OverwriteLine($Describe, [Status]::Pass)
}
else {
[Log]::OverwriteLine($Describe, [Status]::Fail)
[Log]::WriteError($Message)
}
}
"Information" {
[Log]::WriteLine("$Describe`n", [Status]::NotRun)
}
}
}
catch {
[Log]::OverwriteLine($Describe, [Status]::Fail)
Write-Host ""
throw $_
}
}
function Invoke-ChecklistDscRequirement {
Param(
[string]$Describe,
[string]$ResourceName,
[string]$ModuleName,
[hashtable]$Property
)
$dscParams = @{
Name = $ResourceName
ModuleName = $ModuleName
Property = $Property
}
Invoke-ChecklistRequirement `
-Describe $Describe `
-Test {Invoke-DscResource -Method "Test" @dscParams} `
-Set {Invoke-DscResource -Method "Set" @dscParams}
}

View File

@ -1,98 +0,0 @@
# TODO: (MEDIUM) Implement cmdlets wrapping the class implementation
# TODO: (MEDIUM) Break out Configuration\ConfigurationManager into Modules\ConfigurationManager
# TODO: (MEDIUM) Ensure stack traces propogate from module functions
using namespace System.Collections.Generic
$ErrorActionPreference = "Stop"
$InformationPreference = "Continue"
$LogContext = [Stack[string]]::new()
$DockerLinePrefix = " ----->"
function Write-Log {
Param(
[Parameter(Mandatory, Position=0, ParameterSetName="PushContext")]
[Parameter(Mandatory, Position=0, ParameterSetName="ExistingContext")]
[string] $Context,
[Parameter(Mandatory, Position=1, ParameterSetName="PushContext")]
[scriptblock] $ScriptBlock
)
switch ($PSCmdlet.ParameterSetName) {
"PushContext" {
$LogContext.Push($Context)
Write-Log "<begin>"
$result = &$ScriptBlock
Write-Log "<end>"
$LogContext.Pop() | Out-Null
if ($result -is [object]) {
return $result
} else {
return $null
}
}
"ExistingContext" {
$stack = $LogContext.ToArray()
[array]::Reverse($stack)
$prefix = "$DockerLinePrefix $(Get-Date -Format "hh:mm:ss") [$($stack -join " > ")]"
Write-Information "$prefix $Context"
}
}
}
function Invoke-ContextRequirement {
Param(
[string]$Name,
[scriptblock]$Test,
[scriptblock]$Set
)
try {
Write-Log $Name {
$requirementAlreadyMet = Write-Log "Test" {&$Test}
if (-not $requirementAlreadyMet) {
Write-Log "Set" {&$Set | Out-Null}
$requirementValidated = Write-Log "Test" {&$Test}
if (-not $requirementValidated) {
throw "Requirement validation failed"
}
}
}
}
catch {
Write-Error $_.Exception
}
}
function Invoke-ContextDscRequirement {
Param(
[string]$Name,
[string]$ResourceName,
[string]$ModuleName,
[hashtable]$Property
)
$dscParams = @{
Name = $ResourceName
ModuleName = $ModuleName
Property = $Property
}
Invoke-Requirement `
-Name $Name `
-Test {Invoke-DscResource -Method "Test" @dscParams} `
-Set {Invoke-DscResource -Method "Set" @dscParams}
}

View File

@ -0,0 +1,15 @@
@{
'Rules' = @{
'PSAvoidUsingCmdletAliases' = @{
'Whitelist' = @(
'?',
'%',
'foreach',
'group',
'measure',
'select',
'where'
)
}
}
}

113
README.md
View File

@ -1,3 +1,116 @@
# Requirements
Requirements is a PowerShell Gallery module for declaratively describing a system as a set of "requirements", then idempotently setting each requirement to its desired state.
## Usage
We use the term `Test` to refer to the condition that describes whether the Requirement is in its desired state. We use the term `Set` to refer to the command that a `Requirement` uses to put itself in its desired state if it is known to not be in its desired state.
### Declaring requirements
The easiest way to declare a requirement is to define it as a hashtable and let PowerShell's implicit casting handle the rest.
```powershell
$requirements = @(
@{
Name = "Resource 1"
Describe = "Resource 1 is present in the system"
Test = { $mySystem -contains 1 }
Set = { $mySystem.Add(1) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 2"
Describe = "Resource 2 is present in the system"
Test = { $mySystem -contains 2 }
Set = { $mySystem.Add(2) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 3"
Describe = "Resource 3 is present in the system"
Test = { $mySystem -contains 3 }
Set = { $mySystem.Add(3) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 4"
Describe = "Resource 4 is present in the system"
Test = { $mySystem -contains 4 }
Set = { $mySystem.Add(4) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 5"
Describe = "Resource 5 is present in the system"
Test = { $mySystem -contains 5 }
Set = { $mySystem.Add(5) | Out-Null; Start-Sleep 1 }
}
)
```
### Idempotently `Set`ting requirements
Simply pipe an array of `Requirement`s to `Invoke-Requirement`
```powershell
$requirements | Invoke-Requirement
```
### Formatting the logs
`Invoke-Requirement` will output logging events for each step in a `Requirement`'s execution lifecycle. You can capture these logs with `Format-Table` or `Format-List`, or
```powershell
$requirements | Invoke-Requirement | Format-Table
```
#### `Format-Table`
These logs were using `-Autosize` parameter, which better formats the columns, but does not support outputting as a stream.
```
Method Lifecycle Name Date
------ --------- ---- ----
Test Start Resource 1 6/12/2019 12:00:25 PM
Test Stop Resource 1 6/12/2019 12:00:25 PM
Set Start Resource 1 6/12/2019 12:00:25 PM
Set Stop Resource 1 6/12/2019 12:00:26 PM
Validate Start Resource 1 6/12/2019 12:00:26 PM
Validate Stop Resource 1 6/12/2019 12:00:26 PM
Test Start Resource 2 6/12/2019 12:00:26 PM
Test Stop Resource 2 6/12/2019 12:00:26 PM
Set Start Resource 2 6/12/2019 12:00:26 PM
...
```
#### `Format-Checklist`
`Format-Checklist` will present a live-updating checklist to the user.
![Format-Checklist output](https://raw.githubusercontent.com/microsoft/requirements/master/imgs/checklist.png)
#### `Format-Callstack`
Unlike `Format-Checklist`, `Format-Callstack` prints all log events and includes metadata. For complex use cases, you can define nested `Requirement`s (`Requirement`s that contain more `Requirement`s in their `Set` block). `Format-Callstack` will print the stack of `Requirement` names of each `Requirement` as its processed.
![Format-Callstack output](https://raw.githubusercontent.com/microsoft/requirements/master/imgs/callstack.png)
### Defining DSC Resources
If you're using Windows and PowerShell 5, you can use DSC resources with Requirements.
```PowerShell
$requirement = @{
Describe = "My Dsc Requirement"
ResourceName = "File"
ModuleName = "PSDesiredStateConfiguration"
Property = @{
Contents = "Hello World"
DestinationFile = "C:\myFile.txt"
Force = $true
}
}
New-Requirement @requirement | Invoke-Requirement | Format-Checklist
```
## Comparison to DSC
Desired State Configurations allow you to declaratively describe a configuration then let the configuration manager handle with setting the configuration to its desired state. This pattern from the outside may seem similar to Requirements, but there are crucial differences.
DSC is optimized for handling *many* configurations *asynchronously*. For example, applying a configuration in parallel to multiple nodes. In contrast, Requirements applies a *single* configuration *synchronously*. This enables usage in different scenarios, including:
* CI/CD scripts
* CLIs
* Dockerfiles
* Linux
While Requirements supports DSC resources, it does not have a hard dependency on DSC's configuration manager, so if your Requirements do not include DSC resources they will work on any platform that PowerShell Core supports.
# Contributing

View File

@ -1,60 +0,0 @@
Import-Module Pester
Import-Module .\Requirements
Describe "Invoke-ChecklistRequirement" {
It "Should write to host correctly" {
# convert Host stream to array by writing to then reading from file
$tempFile = "$env:TEMP\$(New-Guid).out.txt"
try {
Invoke-ChecklistRequirement `
-Describe "Simple Requirement" `
-Test { $script:ValidRequirementHasRun } `
-Set { $script:ValidRequirementHasRun = $true } `
*> $tempFile
$output = Get-Content $tempFile
$loggedOutput = $output | ? {$_.Trim()}
$clearedOutput = $output | ? {-not $_.Trim()}
$loggedOutput | % {$_ | Should -Match "^\d\d:\d\d:\d\d \[ . \] Simple Requirement$"}
$clearedOutput.Count | Should -Be 1
$loggedOutput.Count | Should -Be 2
} finally {
Remove-Item $tempFile
}
}
It "Should not 'Set' if in desired state" {
$script:NotSetIfInDesiredState = 0
Invoke-ChecklistRequirement `
-Describe "Simple Requirement" `
-Test { $true } `
-Set { $script:NotSetIfInDesiredState++ } `
*> $null
$script:NotSetIfInDesiredState | Should -Be 0
}
It "Should 'Set' if in desired state" {
$script:SetIfInDesiredState = 0
Invoke-ChecklistRequirement `
-Describe "Simple Requirement" `
-Test {$script:SetIfInDesiredState -gt 0} `
-Set {$script:SetIfInDesiredState++} `
*> $null
$script:SetIfInDesiredState | Should -Be 1
}
It "Should validate once set" {
$script:TestOnceSetIsTestCount = 0
$script:TestOnceSetIsSet = $false
Invoke-ChecklistRequirement `
-Describe "Simple Requirement" `
-Test {$script:TestOnceSetIsTestCount += 1; $script:TestOnceSetIsSet} `
-Set {$script:TestOnceSetIsSet = $true} `
*> $null
$script:TestOnceSetIsSet | Should -Be $true
$script:TestOnceSetIsTestCount | Should -Be 2
}
}

Binary file not shown.

View File

@ -1,97 +0,0 @@
using namespace System.Collections.Generic
$ErrorActionPreference = "Stop"
$InformationPreference = "Continue"
#$VerbosePreference = "Continue"
Import-Module "Profile"
class Resource {
[bool] Test() {
throw "Abstract method"
return $false
}
[void] Set() {
throw "Abstract method"
}
[string] ToString() {
throw "Abstract method"
return ""
}
static [void] Run([resource[]]$resources) {
foreach ($resource in $resources) {
Write-Log $resource.Name {
$pass = [bool](Write-Log "Test" {$resource.Test()})
if (-not $pass) {
Write-Log "Set" {$resource.Set()}
$pass = [bool](Write-Log "Test" {$resource.Test()})
if (-not $pass) {
Write-Log "Failed"
throw "$($resource.Name) failed to install"
}
}
}
}
}
}
class Extension : Resource {
static [string] $ConfigurationFileName = "Extensions.json"
static [string] $Container = "C:\Extensions"
static [PSCustomObject] $Configuration
[ValidateNotNullOrEmpty()]
[string] $Name
[ValidateNotNullOrEmpty()]
[string] $Path
[ValidateNotNullOrEmpty()]
[hashtable] $BaseParameters
static Extension() {
$fileName = [Extension]::ConfigurationFileName
[Extension]::Configuration = Get-MergedConfig $fileName
}
Extension([string] $name) {
$this.Name = $name
$this.Path = [Extension]::Container + "\$name"
$this.BaseParameters = @{
Service = $env:Service
FlightingRing = $env:FlightingRing
Region = $env:Region
}
$this.BaseParameters[$this.Name] = [Extension]::Configuration.($this.Name)
}
[bool] Test() {
$script = $this.Path + "\test.ps1"
$params = $this.GetParameters($script)
return &$script @params
}
[void] Set() {
$script = $this.Path + "\set.ps1"
$params = $this.GetParameters($script)
&$script @params
}
[hashtable] GetParameters([string] $script) {
$params = @{}
$supportedParameters = (Get-Command $script).ScriptBlock.Ast.ParamBlock.Parameters.Name `
| % {$_ -replace "\$"}
$this.BaseParameters.Keys `
| ? {$_ -in $supportedParameters} `
| % {$params[$_] = $this.BaseParameters[$_]}
return $params
}
}

87
example.ps1 Normal file
View File

@ -0,0 +1,87 @@
$ErrorActionPreference = "Stop"
Import-Module "$PSScriptRoot\Requirements.psd1" -Force
$requirements = @(
@{
Name = "Resource 1"
Describe = "Resource 1 is present in the system"
Test = { $mySystem -contains 1 }
Set = { $mySystem.Add(1) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 2"
Describe = "Resource 2 is present in the system"
Test = { $mySystem -contains 2 }
Set = { $mySystem.Add(2) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 3"
Describe = "Resource 3 is present in the system"
Test = { $mySystem -contains 3 }
Set = { $mySystem.Add(3) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 4"
Describe = "Resource 4 is present in the system"
Test = { $mySystem -contains 4 }
Set = { $mySystem.Add(4) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 5"
Describe = "Resource 5 is present in the system"
Test = { $mySystem -contains 5 }
Set = { $mySystem.Add(5) | Out-Null; Start-Sleep 1 }
}
)
# demo using Format-Table
$mySystem = [System.Collections.ArrayList]::new()
$requirements | Invoke-Requirement | Format-Table
# demo using Format-Checklist
$mySystem = [System.Collections.ArrayList]::new()
$requirements | Invoke-Requirement | Format-Checklist
# demo using Format-CallStack
$mySystem = [System.Collections.ArrayList]::new()
$requirements | Invoke-Requirement | Format-CallStack
# demo using Format-Callstack with nested requirements
$mySystem = [System.Collections.ArrayList]::new()
$complexRequirements = @(
@{
Name = "Resource 1"
Describe = "Resource 1 is present in the system"
Test = { $mySystem -contains 1 }
Set = { $mySystem.Add(1) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 2"
Describe = "Resource 2 is present in the system"
Test = { $mySystem -contains 3 -and $mySystem -contains 4 }
Set = {
@(
@{
Name = "Resource 3"
Describe = "Resource 3 is present in the system"
Test = { $mySystem -contains 3 }
Set = { $mySystem.Add(3) | Out-Null; Start-Sleep 1 }
},
@{
Name = "Resource 4"
Describe = "Resource 4 is present in the system"
Test = { $mySystem -contains 4 }
Set = { $mySystem.Add(4) | Out-Null; Start-Sleep 1 }
}
) | Invoke-Requirement
}
},
@{
Name = "Resource 5"
Describe = "Resource 5 is present in the system"
Test = { $mySystem -contains 5 }
Set = { $mySystem.Add(5) | Out-Null; Start-Sleep 1 }
}
)
$complexRequirements | Invoke-Requirement | Format-CallStack

BIN
imgs/callstack.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

BIN
imgs/checklist.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

48
src/core.ps1 Normal file
View File

@ -0,0 +1,48 @@
$ErrorActionPreference = "Stop"
."$PSScriptRoot\types.ps1"
# idempotently applies a requirement
function applyRequirement([Requirement]$Requirement) {
$result = $false
if ($Requirement.Test) {
[RequirementEvent]::new($Requirement, "Test", "Start")
$result = &$Requirement.Test
[RequirementEvent]::new($Requirement, "Test", "Stop", $result)
}
if (-not $result) {
if ($Requirement.Set) {
[RequirementEvent]::new($Requirement, "Set", "Start")
&$Requirement.Set
[RequirementEvent]::new($Requirement, "Set", "Stop", $result)
}
if ($Requirement.Test -and $Requirement.Set) {
[RequirementEvent]::new($Requirement, "Validate", "Start")
$result = &$Requirement.Test
[RequirementEvent]::new($Requirement, "Validate", "Stop", $result)
if (-not $result) {
Write-Error "Failed to apply Requirement '$($Requirement.Name)'"
}
}
}
}
# applies an array of requirements
function applyRequirements([Requirement[]]$Requirements) {
$Requirements | % { applyRequirement $_ }
}
# sorts an array of Requirements in topological order
function sortRequirements([Requirement[]]$Requirements) {
$stages = @()
while ($Requirements) {
$nextStages = $Requirements `
| ? { -not ($_.DependsOn | ? { $_ -notin $stages.Name }) }
if (-not $nextStages) {
throw "Could not resolve the dependencies for Requirements with names: $($Requirements.Name -join ', ')"
}
$Requirements = $Requirements | ? { $_.Name -notin $nextStages.Name }
$stages += $nextStages
}
$stages
}

136
src/core.tests.ps1 Normal file
View File

@ -0,0 +1,136 @@
."$PSScriptRoot\core.ps1"
Describe "Core" {
Context "applyRequirement" {
It "Should not 'Set' if in desired state" {
$script:NotSetIfInDesiredState = 0
applyRequirement @{
Describe = "Simple Requirement"
Test = { $true }
Set = { $script:NotSetIfInDesiredState++ }
}
$script:NotSetIfInDesiredState | Should -Be 0
}
It "Should 'Set' if not in desired state" {
$script:SetIfNotInDesiredState = 0
applyRequirement @{
Describe = "Simple Requirement"
Test = { $script:SetIfNotInDesiredState -eq 1 }
Set = { $script:SetIfNotInDesiredState++ }
}
$script:SetIfNotInDesiredState | Should -Be 1
}
It "Should validate once set" {
$script:TestOnceSetIsTestCount = 0
$script:TestOnceSetIsSet = $false
applyRequirement @{
Describe = "Simple Requirement"
Test = { $script:TestOnceSetIsTestCount += 1; $script:TestOnceSetIsSet }
Set = { $script:TestOnceSetIsSet = $true }
}
$script:TestOnceSetIsSet | Should -Be $true
$script:TestOnceSetIsTestCount | Should -Be 2
}
It "Should 'Set' if no 'Test' is provided" {
$script:SetIfNoTest = $false
applyRequirement @{
Describe = "Simple Requirement"
Set = { $script:SetIfNoTest = $true }
}
$script:SetIfNoTest | Should -BeTrue
}
It "Should not 'Test' if no 'Set' is provided" {
$script:NotTestIfNoSet = 0
applyRequirement @{
Describe = "Simple Requirement"
Test = { $script:NotTestIfNoSet++ }
}
$script:NotTestIfNoSet | Should -Be 1
}
It "Should output all log events" {
$script:SetIfNotInDesiredState = 0
$events = applyRequirement @{
Describe = "Simple Requirement"
Test = { $script:SetIfNotInDesiredState -eq 1 }
Set = { $script:SetIfNotInDesiredState++ }
}
$expectedIds = "Test", "Set", "Validate" | % { "$_-Start", "$_-Stop" }
$foundIds = $events | % { "$($_.Method)-$($_.State)" }
$expectedIds | % { $_ -in $foundIds | Should -BeTrue }
}
}
Context "applyRequirements" {
It "Should call 'Test' on each requirement" {
$script:CallTestOnEachRequirement = 0
$requirements = 1..3 | % {
@{
Name = $_
Describe = "Simple Requirement"
Test = { $script:CallTestOnEachRequirement++ % 2 }
Set = { $false }
}
}
applyRequirements $requirements
$script:CallTestOnEachRequirement | Should -Be 6
}
}
Context "sortRequirements" {
It "Should sort an array of requirements into topological order" {
$sorted = sortRequirements @(
@{
Name = "third"
Describe = "Simple Requirement"
Test = { }
Set = { }
DependsOn = "first", "second"
},
@{
Name = "first"
Describe = "Simple Requirement"
Test = { }
Set = { }
},
@{
Name = "second"
Describe = "Simple Requirement"
Test = { }
Set = { }
DependsOn = "first"
}
)
[string[]]$names = $sorted | % Name
0..($sorted.Count - 1) | % {
$i, $requirement = $_, $sorted[$_]
$requirement.DependsOn `
| % { $names.IndexOf($_) | Should -BeLessThan $i }
}
}
It "Should throw an error if there are unresolvable dependencies" {
{
sortRequirements @(
@{
Name = "third"
Describe = "Simple Requirement"
Test = { }
Set = { }
DependsOn = "first", "second"
},
@{
Name = "first"
Describe = "Simple Requirement"
Test = { }
Set = { }
},
@{
Name = "second"
Describe = "Simple Requirement"
Test = { }
Set = { }
DependsOn = "first", "third"
}
)
} | Should -Throw
}
}
}

152
src/formatters.ps1 Normal file
View File

@ -0,0 +1,152 @@
using namespace System.Collections.Generic
$ErrorActionPreference = "Stop"
."$PSScriptRoot\types.ps1"
<#
.SYNOPSIS
Formats Requirement log events as a live-updating checklist
.NOTES
Uses Write-Host
#>
function Format-Checklist {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
[CmdletBinding()]
Param(
# Logged Requirement lifecycle events
[Parameter(Mandatory, ValueFromPipeline)]
[Alias("Event")]
[RequirementEvent[]]$RequirementEvent
)
begin {
$lastDescription = ""
}
process {
$timestamp = Get-Date -Date $_.Date -Format 'hh:mm:ss'
$description = $_.Requirement.Describe
$method, $state, $result = $_.Method, $_.State, $_.Result
switch ($method) {
"Test" {
switch ($state) {
"Start" {
$symbol = " "
$color = "Yellow"
$message = "$timestamp [ $symbol ] $description"
Write-Host $message -ForegroundColor $color -NoNewline
$lastDescription = $description
}
}
}
"Validate" {
switch ($state) {
"Stop" {
switch ($result) {
$true {
$symbol = [char]8730
$color = "Green"
$message = "$timestamp [ $symbol ] $description"
Write-Host "`r$(' ' * $lastDescription.Length)" -NoNewline
Write-Host "`r$message" -ForegroundColor $color
$lastDescription = $description
}
$false {
$symbol = "X"
$color = "Red"
$message = "$timestamp [ $symbol ] $description"
Write-Host "`n$message`n" -ForegroundColor $color
$lastDescription = $description
exit -1
}
}
}
}
}
}
}
}
<#
.SYNOPSIS
Formats every log event with metadata, including a stack of requirement names when using nested Requirements
.NOTES
Uses Write-Host
#>
function Format-CallStack {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")]
[CmdletBinding()]
Param(
# Logged Requirement lifecycle events
[Parameter(Mandatory, ValueFromPipeline)]
[Alias("Event")]
[RequirementEvent[]]$RequirementEvent
)
begin {
$context = [Stack[string]]::new()
}
process {
$name = $_.Requirement.Name
$description = $_.Requirement.Describe
$method, $state, $result = $_.Method, $_.State, $_.Result
switch ($method) {
"Test" {
switch ($state) {
"Start" {
$context.Push($name)
$callstack = $context.ToArray()
[array]::Reverse($callstack)
$serialized = $callstack -join ">"
Write-Host "$($_.Date) [$serialized] BEGIN TEST $description"
}
"Stop" {
$callstack = $context.ToArray()
[array]::Reverse($callstack)
$serialized = $callstack -join ">"
Write-Host "$($_.Date) [$serialized] END TEST => $result"
$context.Pop() | Out-Null
}
}
}
"Set" {
switch ($state) {
"Start" {
$context.Push($name)
$callstack = $context.ToArray()
[array]::Reverse($callstack)
$serialized = $callstack -join ">"
Write-Host "$($_.Date) [$serialized] BEGIN SET $description"
}
"Stop" {
$callstack = $context.ToArray()
[array]::Reverse($callstack)
$serialized = $callstack -join ">"
Write-Host "$($_.Date) [$serialized] END SET"
$context.Pop() | Out-Null
}
}
}
"Validate" {
switch ($state) {
"Start" {
$context.Push($name)
$callstack = $context.ToArray()
[array]::Reverse($callstack)
$serialized = $callstack -join ">"
Write-Host "$($_.Date) [$serialized] BEGIN TEST $description"
}
"Stop" {
$callstack = $context.ToArray()
[array]::Reverse($callstack)
$serialized = $callstack -join ">"
Write-Host "$($_.Date) [$serialized] END TEST => $result"
$context.Pop() | Out-Null
}
}
}
}
}
}

78
src/formatters.test.ps1 Normal file
View File

@ -0,0 +1,78 @@
."$PSScriptRoot\formatters.ps1"
function invoke($Requirement) {
[RequirementEvent]@{
Requirement = $Requirement
Method = "Test"
State = "Start"
}
[RequirementEvent]@{
Requirement = $Requirement
Method = "Test"
State = "Stop"
Result = $false
}
[RequirementEvent]@{
Requirement = $Requirement
Method = "Set"
State = "Start"
}
[RequirementEvent]@{
Requirement = $Requirement
Method = "Set"
State = "Stop"
Result = $null
}
[RequirementEvent]@{
Requirement = $Requirement
Method = "Validate"
State = "Start"
}
[RequirementEvent]@{
Requirement = $Requirement
Method = "Validate"
State = "Stop"
Result = $true
}
}
Describe "formatters" {
Mock Get-Date { return "00:00:00" }
$script:InDesiredState = 0
$requirement = @{
Name = "simple-requirement"
Describe = "Simple Requirement"
Test = { $script:InDesiredState++ }
Set = { }
}
$events = invoke $requirement
$tempContainer = if ($env:TEMP) { $env:TEMP } else { $env:TMPDIR }
Context "Format-Table" {
$output = $events | Format-Table | Out-String
It "Should print a non-empty string" {
$output.Trim().Length | Should -BeGreaterThan 10
}
}
Context "Format-Checklist" {
$path = "$tempContainer\$(New-Guid).txt"
$events | Format-Checklist *> $path
$output = Get-Content $path -Raw
Remove-Item $path
It "Should format each line as a checklist" {
$output | Should -Match "^\d\d:\d\d:\d\d \[ . \] Simple Requirement"
}
}
Context "Format-Callstack" {
$path = "$tempContainer\$(New-Guid).txt"
$events | Format-CallStack *> $path
$output = Get-Content $path -Raw
Remove-Item $path
It "Should format each line as a callstack" {
$output | % { $_ | Should -Match "^\d\d:\d\d:\d\d \[.+\] .+" }
}
It "Should print 6 lines" {
$output.Count | Should -Be 6
}
}
}

129
src/interface.ps1 Normal file
View File

@ -0,0 +1,129 @@
$ErrorActionPreference = "Stop"
."$PSScriptRoot\core.ps1"
."$PSScriptRoot\formatters.ps1"
<#
.SYNOPSIS
Creates a new Requirement object
.OUTPUTS
The resulting Requirement
.NOTES
Dsc parameter set is unsupported due to cross-platform limitations
#>
function New-Requirement {
[Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")]
[OutputType([Requirement])]
[CmdletBinding()]
Param(
# The unique identifier for the Requirement
[Parameter(ParameterSetName = "Script")]
[Parameter(ParameterSetName = "Dsc")]
[string] $Name,
# A description of the Requirement
[Parameter(Mandatory, ParameterSetName = "Script")]
[Parameter(Mandatory, ParameterSetName = "Dsc")]
[string] $Describe,
# The Test condition that determines if the Requirement is in its desired state
[Parameter(ParameterSetName = "Script")]
[scriptblock] $Test,
# The Set condition that Sets the Requirement to its desired state
[Parameter(ParameterSetName = "Script")]
[scriptblock] $Set,
# The list of Requirement Names that must be in desired state prior to this Requirement
[Parameter(ParameterSetName = "Script")]
[Parameter(ParameterSetName = "Dsc")]
[ValidateNotNull()]
[string[]] $DependsOn = @(),
# The name of the DSC resource associated with the Requirement
[Parameter(Mandatory, ParameterSetName = "Dsc")]
[ValidateNotNullOrEmpty()]
[string]$ResourceName,
# The module containing the DSC resource
[Parameter(Mandatory, ParameterSetName = "Dsc")]
[ValidateNotNullOrEmpty()]
[string]$ModuleName,
# The properties passed through to the DSC resource
[Parameter(Mandatory, ParameterSetName = "Dsc")]
[ValidateNotNullOrEmpty()]
[hashtable]$Property
)
switch ($PSCmdlet.ParameterSetName) {
"Script" {
[Requirement]@{
Name = $Name
Describe = $Describe
Test = $Test
Set = $Set
DependsOn = $DependsOn
}
}
"Dsc" {
$dscParams = @{
Name = $ResourceName
ModuleName = $ModuleName
Property = $Property
}
[Requirement]@{
Name = $Name
Describe = $Describe
Test = { Invoke-DscResource -Method "Test" @dscParams }.GetNewClosure()
Set = { Invoke-DscResource -Method "Set" @dscParams }.GetNewClosure()
DependsOn = $DependsOn
}
}
}
}
<#
.SYNOPSIS
Sets Requirements to their desired states
.OUTPUTS
The RequirementEvents logged from each stage of the Requirement lifecycle
#>
function Invoke-Requirement {
[CmdletBinding()]
[OutputType([RequirementEvent])]
Param(
# The Requirements to put in their desired state
[Parameter(Mandatory, ValueFromPipeline)]
[Requirement[]] $Requirement
)
applyRequirements (sortRequirements $input)
}
<#
.SYNOPSIS
Tests whether a requirement is in its desired state
#>
function Test-Requirement {
[CmdletBinding()]
Param(
# The Requirement to test its desired state
[Parameter(Mandatory, ValueFromPipeline)]
[ValidateNotNullOrEmpty()]
[Requirement] $Requirement
)
&$Requirement.Test
}
<#
.SYNOPSIS
Sets the requirement to its desired state
#>
function Set-Requirement {
[CmdletBinding(SupportsShouldProcess)]
Param(
# The Requirement that sets if its in its desired state
[Parameter(Mandatory, ValueFromPipeline)]
[ValidateNotNullOrEmpty()]
[Requirement] $Requirement
)
if ($PSCmdlet.ShouldProcess($Requirement, "Set")) {
&$Requirement.Set
}
}

106
src/interface.tests.ps1 Normal file
View File

@ -0,0 +1,106 @@
$ErrorActionPreference = "Stop"
."$PSScriptRoot\interface.ps1"
$PlatformLacksDscSupport = $PSVersionTable.PSEdition -eq "Core"
if (-not $PlatformLacksDscSupport) {
$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent()
$isAdmin = $identity.groups -match "S-1-5-32-544"
if (-not $isAdmin) {
throw @"
You are running PowerShell 5 and are therefore testing DSC resources.
You must be running as admin to test DSC resources.
"@
}
}
Describe "New-Requirement" {
Context "'Script' parameter set" {
$requirement = @{
Describe = "My Requirement"
Test = { 1 }
Set = { 2 }
}
It "Should not throw" {
{ New-Requirement @requirement } | Should -Not -Throw
}
It "Should not be empty" {
New-Requirement @requirement | Should -BeTrue
}
}
Context "'Dsc' parameter set" {
It "Should not be empty" -Skip:$PlatformLacksDscSupport {
$requirement = @{
Describe = "My Dsc Requirement"
ResourceName = "File"
ModuleName = "PSDesiredStateConfiguration"
Property = @{
Contents = ""
DestinationFile = ""
}
}
New-Requirement @requirement | Should -BeTrue
}
}
}
Describe "Invoke-Requirement" {
Context "Normal Requirement" {
It "Should not error" {
$requirement = @{
Test = { 1 }
}
{ Invoke-Requirement $requirement } | Should -Not -Throw
}
}
Context "DSC Requirement" {
It "Should apply the DSC resource" -Skip:$PlatformLacksDscSupport {
$tempFilePath = "$env:TEMP\_dsctest_$(New-Guid).txt"
$content = "Hello world"
$params = @{
Name = "[file]MyFile"
Describe = "My Dsc Requirement"
ResourceName = "File"
ModuleName = "PSDesiredStateConfiguration"
Property = @{
Contents = $content
DestinationPath = $tempFilePath
Force = $true
}
}
New-Requirement @params | Invoke-Requirement
Get-Content $tempFilePath | Should -Be $content
Remove-Item $tempFilePath
}
}
}
Describe "Test-Requirement" {
It "Should not error" {
$requirement = @{
Test = { $true }
}
{ Test-Requirement $requirement } | Should -Not -Throw
}
It "Should pass through falsey values" {
$requirement = @{
Test = { $false }
}
Test-Requirement $requirement | Should -BeFalse
}
It "Should pass through truthy values" {
$requirement = @{
Test = { $true }
}
Test-Requirement $requirement | Should -BeTrue
}
}
Describe "Set-Requirement" {
It "Should not error" {
$requirement = @{
Set = { $false }
}
{ Invoke-Requirement $requirement } | Should -Not -Throw
}
}

44
src/types.ps1 Normal file
View File

@ -0,0 +1,44 @@
class Requirement {
[string] $Name
[ValidateNotNullOrEmpty()]
[string] $Describe
[scriptblock] $Test
[scriptblock] $Set
[string[]] $DependsOn = @()
[string] ToString() {
return $this.Name
}
}
enum Method {
Test
Set
Validate
}
enum LifecycleState {
Start
Stop
}
class RequirementEvent {
[datetime] $Date
[Method] $Method
[LifecycleState] $State
[object] $Result
[Requirement] $Requirement
hidden Init([Requirement]$Requirement, [Method]$Method, [LifecycleState]$State, $Result) {
$this.Date = Get-Date
$this.Method = $Method
$this.State = $State
$this.Result = $Result
$this.Requirement = $Requirement
}
RequirementEvent([Requirement]$Requirement, [Method]$Method, [LifecycleState]$State, $Result) {
$this.Init($Requirement, $Method, $State, $Result)
}
RequirementEvent([Requirement]$Requirement, [Method]$Method, [LifecycleState]$State) {
$this.Init($Requirement, $Method, $State, $null)
}
}