isoloom-render 1.0.0

What every Isoloom backend renders with: the backend port, the builders (HCL, Ruby, PowerShell, Dockerfile, the Ansible inventory), a VM's set-up steps, the network pieces and the containers' shared parts.
Documentation
# SPDX-License-Identifier: Apache-2.0
#
# Isoloom's configuration library for Windows: one function per step kind of the configuration
# model. A recipe dot-sources it, then calls one function per step, in order. Each function
# ensures a state behind its guard: a second apply changes nothing. Every change writes
# `isoloom: changed <what>` and marks the file $env:ISOLOOM_CHANGED names (when set), which a
# later service step reads to restart its service. A failure throws `isoloom: <why>`.

[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12

# Reports a change: its line, and the mark the restart rule reads.
function Write-IsoloomChange {
    param([Parameter(Mandatory)][string] $What)
    Write-Output "isoloom: changed $What"
    if ($env:ISOLOOM_CHANGED) {
        New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:ISOLOOM_CHANGED) | Out-Null
        Set-Content -LiteralPath $env:ISOLOOM_CHANGED -Value 'changed'
    }
}

# Whether an earlier step changed something during this apply: a service restarts then.
function Test-IsoloomRestartDue {
    return [bool]($env:ISOLOOM_CHANGED -and (Test-Path -LiteralPath $env:ISOLOOM_CHANGED))
}

# The SHA-256 of a file, in lowercase hex.
function Get-IsoloomHash {
    param([Parameter(Mandatory)][string] $Path)
    return (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant()
}

# The packages step: each Windows feature installed (a server's role or feature, or a client's
# optional feature).
function Install-IsoloomFeature {
    param([Parameter(Mandatory)][string[]] $Name)
    $server = [bool](Get-Command -Name Get-WindowsFeature -ErrorAction SilentlyContinue)
    foreach ($feature in $Name) {
        if ($server) {
            if ((Get-WindowsFeature -Name $feature).Installed) { continue }
            $result = Install-WindowsFeature -Name $feature -IncludeManagementTools
            if (-not $result.Success) { throw "isoloom: installing the feature $feature failed" }
        } else {
            if ((Get-WindowsOptionalFeature -Online -FeatureName $feature).State -eq 'Enabled') { continue }
            Enable-WindowsOptionalFeature -Online -FeatureName $feature -All -NoRestart | Out-Null
        }
        Write-IsoloomChange -What "feature $feature"
    }
}

# A file at To with From's content (To written when it differs).
function Copy-IsoloomFile {
    param([Parameter(Mandatory)][string] $From, [Parameter(Mandatory)][string] $To)
    if ((Test-Path -LiteralPath $To -PathType Leaf) -and ((Get-IsoloomHash -Path $From) -eq (Get-IsoloomHash -Path $To))) {
        return
    }
    $parent = Split-Path -Parent $To
    if ($parent) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
    Copy-Item -LiteralPath $From -Destination $To -Force
    Write-IsoloomChange -What $To
}

# The copy step: From (from the current folder) at To; a folder puts what it holds into To.
function Copy-IsoloomItem {
    param([Parameter(Mandatory)][string] $From, [Parameter(Mandatory)][string] $To)
    if (-not (Test-Path -LiteralPath $From -PathType Container)) {
        Copy-IsoloomFile -From $From -To $To
        return
    }
    $root = (Resolve-Path -LiteralPath $From).Path.TrimEnd('\', '/')
    if (-not (Test-Path -LiteralPath $To -PathType Container)) {
        New-Item -ItemType Directory -Force -Path $To | Out-Null
        Write-IsoloomChange -What $To
    }
    foreach ($entry in Get-ChildItem -LiteralPath $root -Recurse -Force) {
        $target = Join-Path -Path $To -ChildPath $entry.FullName.Substring($root.Length).TrimStart('\', '/')
        if ($entry.PSIsContainer) {
            if (Test-Path -LiteralPath $target -PathType Container) { continue }
            New-Item -ItemType Directory -Force -Path $target | Out-Null
            Write-IsoloomChange -What $target
        } else {
            Copy-IsoloomFile -From $entry.FullName -To $target
        }
    }
}

# The template step: the file Isoloom filled in (its bytes, in base64) at To.
function Install-IsoloomTemplate {
    param([Parameter(Mandatory)][string] $To, [Parameter(Mandatory)][AllowEmptyString()][string] $Base64)
    $temporary = [IO.Path]::GetTempFileName()
    try {
        [IO.File]::WriteAllBytes($temporary, [Convert]::FromBase64String($Base64))
        Copy-IsoloomFile -From $temporary -To $To
    } finally {
        Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue
    }
}

# The file step: Path made a folder, absent, or a download of Url pinned by Sha256.
function Install-IsoloomFile {
    param(
        [Parameter(Mandatory)][string] $Path,
        [Parameter(Mandatory)][ValidateSet('directory', 'absent', 'download')][string] $State,
        [string] $Url,
        [string] $Sha256
    )
    switch ($State) {
        'directory' {
            if (Test-Path -LiteralPath $Path -PathType Container) { return }
            New-Item -ItemType Directory -Force -Path $Path | Out-Null
        }
        'absent' {
            if (-not (Test-Path -LiteralPath $Path)) { return }
            Remove-Item -LiteralPath $Path -Recurse -Force
        }
        'download' {
            if ((Test-Path -LiteralPath $Path -PathType Leaf) -and ((Get-IsoloomHash -Path $Path) -eq $Sha256)) { return }
            $parent = Split-Path -Parent $Path
            if ($parent) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
            $temporary = "$Path.isoloom-download"
            Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $temporary
            $got = Get-IsoloomHash -Path $temporary
            if ($got -ne $Sha256) {
                Remove-Item -LiteralPath $temporary -Force
                throw "isoloom: $Url has SHA-256 $got, not ${Sha256}: the file changed upstream, or the pin is wrong"
            }
            Move-Item -LiteralPath $temporary -Destination $Path -Force
        }
    }
    Write-IsoloomChange -What $Path
}

# A password as a SecureString, built a character at a time.
function ConvertTo-IsoloomSecure {
    param([Parameter(Mandatory)][string] $Phrase)
    $secure = New-Object -TypeName System.Security.SecureString
    foreach ($character in $Phrase.ToCharArray()) { $secure.AppendChar($character) }
    return $secure
}

# Whether local user Name's password is Phrase.
function Test-IsoloomPhrase {
    param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string] $Phrase)
    Add-Type -AssemblyName System.DirectoryServices.AccountManagement
    $machine = New-Object -TypeName System.DirectoryServices.AccountManagement.PrincipalContext -ArgumentList 'Machine'
    try { return $machine.ValidateCredentials($Name, $Phrase) } finally { $machine.Dispose() }
}

# Local user Name is a member of local group Group.
function Add-IsoloomMember {
    param([Parameter(Mandatory)][string] $Group, [Parameter(Mandatory)][string] $Name)
    $members = Get-LocalGroupMember -Group $Group | ForEach-Object { ($_.Name -split '\\')[-1] }
    if ($members -contains $Name) { return }
    Add-LocalGroupMember -Group $Group -Member $Name
    Write-IsoloomChange -What "$Name in $Group"
}

# The user step: local user Name, with its password (Phrase, when given) and in Groups.
function Add-IsoloomUser {
    param([Parameter(Mandatory)][string] $Name, [string] $Phrase, [string[]] $Groups = @())
    if (-not (Get-LocalUser -Name $Name -ErrorAction SilentlyContinue)) {
        if ($Phrase) {
            New-LocalUser -Name $Name -Password (ConvertTo-IsoloomSecure -Phrase $Phrase) -PasswordNeverExpires | Out-Null
        } else {
            New-LocalUser -Name $Name -NoPassword | Out-Null
        }
        Write-IsoloomChange -What "user $Name"
    } elseif ($Phrase -and -not (Test-IsoloomPhrase -Name $Name -Phrase $Phrase)) {
        Set-LocalUser -Name $Name -Password (ConvertTo-IsoloomSecure -Phrase $Phrase)
        Write-IsoloomChange -What "password of $Name"
    }
    foreach ($group in $Groups) { Add-IsoloomMember -Group $group -Name $Name }
}

# The group step: local group Name.
function Add-IsoloomGroup {
    param([Parameter(Mandatory)][string] $Name)
    if (Get-LocalGroup -Name $Name -ErrorAction SilentlyContinue) { return }
    New-LocalGroup -Name $Name | Out-Null
    Write-IsoloomChange -What "group $Name"
}

# A service Isoloom defines: a startup task (as SYSTEM) running Run (a program and its
# arguments) in Directory, registered again when it changed.
function Register-IsoloomTask {
    param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][string[]] $Run, [string] $Directory)
    $arguments = ($Run | Select-Object -Skip 1 | ForEach-Object { '"' + ($_ -replace '"', '\"') + '"' }) -join ' '
    $settings = @{ Execute = $Run[0] }
    if ($arguments) { $settings.Argument = $arguments }
    if ($Directory) { $settings.WorkingDirectory = $Directory }
    $action = New-ScheduledTaskAction @settings
    $task = Get-ScheduledTask -TaskName "isoloom-$Name" -ErrorAction SilentlyContinue
    $same = $task -and $task.Actions[0].Execute -eq $action.Execute -and $task.Actions[0].Arguments -eq $action.Arguments -and
        $task.Actions[0].WorkingDirectory -eq $action.WorkingDirectory
    if ($same) { return }
    $trigger = New-ScheduledTaskTrigger -AtStartup
    Register-ScheduledTask -TaskName "isoloom-$Name" -Action $action -Trigger $trigger -User 'SYSTEM' -RunLevel Highest -Force | Out-Null
    Write-IsoloomChange -What "task isoloom-$Name"
}

# The service step for a service Isoloom defines: its startup task enabled and running, or
# disabled and stopped.
function Register-IsoloomTaskState {
    param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][bool] $Enabled)
    $task = Get-ScheduledTask -TaskName "isoloom-$Name"
    if (-not $Enabled) {
        if ($task.State -eq 'Running') { Stop-ScheduledTask -TaskName "isoloom-$Name"; Write-IsoloomChange -What "isoloom-$Name stopped" }
        if ($task.State -ne 'Disabled') { Disable-ScheduledTask -TaskName "isoloom-$Name" | Out-Null; Write-IsoloomChange -What "isoloom-$Name disabled" }
        return
    }
    if ($task.State -eq 'Disabled') { Enable-ScheduledTask -TaskName "isoloom-$Name" | Out-Null; Write-IsoloomChange -What "isoloom-$Name enabled" }
    if ((Get-ScheduledTask -TaskName "isoloom-$Name").State -ne 'Running') {
        Start-ScheduledTask -TaskName "isoloom-$Name"
        Write-IsoloomChange -What "isoloom-$Name started"
    } elseif (Test-IsoloomRestartDue) {
        Stop-ScheduledTask -TaskName "isoloom-$Name"
        Start-ScheduledTask -TaskName "isoloom-$Name"
        Write-Output "isoloom: restarted isoloom-$Name"
    }
}

# The service step: Windows service Name started automatically and running (restarted when an
# earlier step changed something), or disabled and stopped.
function Register-IsoloomService {
    param([Parameter(Mandatory)][string] $Name, [Parameter(Mandatory)][bool] $Enabled)
    $service = Get-Service -Name $Name -ErrorAction SilentlyContinue
    if (-not $service) { throw "isoloom: no Windows service ${Name}: install what provides it in an earlier step" }
    if (-not $Enabled) {
        if ($service.StartType -ne 'Disabled') { Set-Service -Name $Name -StartupType Disabled; Write-IsoloomChange -What "$Name disabled" }
        if ($service.Status -ne 'Stopped') { Stop-Service -Name $Name -Force; Write-IsoloomChange -What "$Name stopped" }
        return
    }
    if ($service.StartType -ne 'Automatic') { Set-Service -Name $Name -StartupType Automatic; Write-IsoloomChange -What "$Name enabled" }
    if ($service.Status -ne 'Running') {
        Start-Service -Name $Name
        Write-IsoloomChange -What "$Name started"
    } elseif (Test-IsoloomRestartDue) {
        Restart-Service -Name $Name -Force
        Write-Output "isoloom: restarted $Name"
    }
}

# The command step: Run (a script block's text) after its guard, unless the guard says it
# already ran: Creates (a path whose existence says so) or Unless (a check that succeeds).
function Invoke-IsoloomCommand {
    param([Parameter(Mandatory)][string] $Run, [string] $Creates, [string] $Unless)
    if ($Creates -and (Test-Path -LiteralPath $Creates)) { return }
    if ($Unless -and (Test-IsoloomScript -Text $Unless)) { return }
    if (-not (Test-IsoloomScript -Text $Run -Loud)) { throw "isoloom: the command failed: $Run" }
    Write-IsoloomChange -What 'command'
}

# Whether script Text succeeds: no error, and a native program's exit code 0. Its output is
# shown with Loud, else dropped.
function Test-IsoloomScript {
    param([Parameter(Mandatory)][string] $Text, [switch] $Loud)
    Set-Variable -Name LASTEXITCODE -Scope Global -Value 0
    try {
        if ($Loud) { & ([scriptblock]::Create($Text)) } else { & ([scriptblock]::Create($Text)) | Out-Null }
    } catch {
        if ($Loud) { Write-Error -Message $_ -ErrorAction Continue }
        return $false
    }
    return (Get-Variable -Name LASTEXITCODE -Scope Global -ValueOnly) -eq 0
}

# The firewall step: Port (with Protocol) open in Windows Firewall.
function Enable-IsoloomFirewallPort {
    param([Parameter(Mandatory)][int] $Port, [Parameter(Mandatory)][ValidateSet('TCP', 'UDP')][string] $Protocol)
    $name = "isoloom-$Port-$Protocol"
    if (Get-NetFirewallRule -Name $name -ErrorAction SilentlyContinue) { return }
    New-NetFirewallRule -Name $name -DisplayName "Isoloom $Port/$Protocol" -Direction Inbound -Action Allow -Protocol $Protocol -LocalPort $Port | Out-Null
    Write-IsoloomChange -What "firewall $Port/$Protocol"
}