Poor Man's PowerShell Provisioning

I'm a fan of "infrastructure as code", which is why I have scripts which help me to setup my own computers. Instead of relying on "heavy hitters" such as Chef, Ansible or Puppet, my Windows provisioning scripts rely on PowerShell and Chocolatey. I am aware of other tools such as Boxstarter, but I deliberately choose a more manual and bare bones approach in favor of improved error handling.

I have created three PowerShell functions which help me to keep my provisioning scripts simple and well-arranged:

Step

Several PowerShell instructions can be grouped together as a step:

function step {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,
        [Parameter(Mandatory = $true)]
        [scriptblock]$Do,
        [scriptblock]$If = { $true }
    )

    if (& $If) {
        Write-Output '============================================================'
        Write-Output "Starting step '$Name'"
        Write-Output '------------------------------------------------------------'

        $startTime = Get-Date

        & $Do

        $endTime = Get-Date
        $duration = $endTime - $startTime

        Write-Output '------------------------------------------------------------'
        Write-Output "Finished step '$Name' ($duration)"
        Write-Output '============================================================'
    }
    else {
        Write-Output '============================================================'
        Write-Output "Skipping step '$Name'"
        Write-Output '============================================================'
    }
}

Which might look like this:

step 'Configure Windows explorer' {
    $explorerKey = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Explorer\Advanced'
    # Show file extensions in explorer
    Set-ItemProperty -Path $explorerKey -Name HideFileExt -Value 0
    # Opens explorer at 'This PC' instead of 'Quick Access'
    Set-ItemProperty -Path $explorerKey -Name LaunchTo -Value 1
}

Or like this:

$configFile = 'C:\meaning.ini'

step 'Write important config file' -If {
    -not (Test-Path $configFile)
} -Do {
    Set-Content -Value 'answer = 42' -Path $configFile
}

Once

Sometimes I'd like to run a block of code once, but I don't have any decent way of checking if the block was already executed. The once function is a specialized step, which uses a "checkpoint file" to give a step the "executed just once" property:

function once {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,
        [Parameter(Mandatory = $true)]
        [scriptblock]$ScriptBlock,
        [System.IO.DirectoryInfo]$CheckpointDirectory = (Join-Path $env:ProgramData 'my.provision')
    )

    $checkpointFileName = $Name + '.checkpoint'

    foreach ($invalidFileNameChar in [System.IO.Path]::GetInvalidFileNameChars()) {
        $checkpointFileName = $checkpointFileName.Replace($invalidFileNameChar, '_')
    }

    $checkpointFile = Join-Path $CheckpointDirectory $checkpointFileName

    step $Name -If {
        -not (Test-Path $checkpointFile)
    } -Do {
        & $ScriptBlock

        New-Item -Path $checkpointFile -Force | Out-Null
    }
}

Here's an example:

once 'Write important config file' {
    Set-Content -Value 'answer = 42' -Path 'C:\meaning.ini'
}

Manual

If all automation attempts fail, I use manual as a fallback:

function manual {
    param(
        [Parameter(Mandatory = $true)]
        [string]$Name,
        [Parameter(Mandatory = $true)]
        [scriptblock]$ScriptBlock,
        [bool]$If = $true
    )

    step -Name $Name -If $If -Do {
        $nonInteractive = [bool]([Environment]::GetCommandLineArgs() -like '-noni*')

        if ($nonInteractive) {
            throw 'Cannot perform manual step in non-interactive PowerShell session'
        }

        & $ScriptBlock

        Write-Output ''
        Read-Host 'Press ENTER to continue'
    }
}

Exec

exec makes sure that I can monitor the exit code of a command line tool:

function exec {
    param(
        [Parameter(Mandatory = $true)]
        [scriptblock]$ScriptBlock,
        [int[]]$ValidExitCodes = @(0)
    )

    $global:LASTEXITCODE = 0

    & $ScriptBlock

    if (-not ($global:LASTEXITCODE -in $ValidExitCodes)) {
        throw "Invalid exit code: $($global:LASTEXITCODE)"
    }
}

Now I can call command line tools like this:

exec { 7z }
exec { git status }
exec -ValidExitCodes 0, 1, 2 { $global:LASTEXITCODE = 2 }

I can even create a wrapper function to deal with restarts if a Chocolatey package installation returns the exit code 3010:

function choco-exec {
    param(
        [Parameter(Mandatory = $true)]
        [scriptblock]$ScriptBlock,
        [switch]$ConfirmBeforeReboot
    )

    exec -ScriptBlock $ScriptBlock -ValidExitCodes @(0, 1641, 3010)

    if ($global:LASTEXITCODE -eq 3010) {
        Write-Warning "Chocolatey indicates, that a restart is necessary"
        Restart-Computer -Confirm:$ConfirmBeforeReboot
    }
}

Which I can call like this:

choco-exec { choco install git -y }
choco-exec { choco install dotnet4.7.2 -y } -ConfirmBeforeReboot

Published: 2019-01-21