Skip to content

Scripting standard

All scripts across all HCS projects are PowerShell 7+. No exceptions. No Bash. No PS 5.1 compatibility shims.


Mandatory header

Every script starts with this block — no exceptions:

#Requires -Version 7.0
<#
.SYNOPSIS
    One-line description of what this script does.

.DESCRIPTION
    Longer description if needed. Explain the why, not the what.

.PARAMETER ParameterName
    What this parameter does.

.NOTES
    Author: Kristopher Turner
    Contact: kris@hybridsolutions.cloud
    Version: 1.0.0
#>

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

#Requires -Version 7.0 ensures the script refuses to run on PS 5.1 with a clear error rather than failing silently with incompatible syntax.

Set-StrictMode -Version Latest catches undefined variables, uninitialized properties, and incorrect function call syntax at runtime.

$ErrorActionPreference = 'Stop' turns all errors into terminating errors, so try/catch actually catches them.


Naming

Functions

  • Use approved PowerShell verbs only. Check with Get-Verb if unsure.
  • Format: Verb-Noun — e.g., Get-HCSSecretName, Set-HCSEnvironment, New-HCSRepo
  • Prefix nouns with HCS for functions in shared/platform scripts
  • Never use unapproved verbs like Check-, Validate-, Execute- — use Test-, Assert-, Invoke-

Parameters

  • $PascalCase for all parameters, always
  • Be explicit with parameter types where it matters: [string], [bool], [switch], [int]
  • Use [Parameter(Mandatory)] for required parameters — never use default values to mask required inputs
  • Use [ValidateSet()], [ValidateNotNullOrEmpty()], and [ValidatePattern()] to catch bad input early
function Set-HCSEnvironment {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$VaultName,

        [Parameter()]
        [ValidateSet('dev', 'stg', 'prd')]
        [string]$Environment = 'prd',

        [switch]$DryRun
    )
    ...
}

Local variables

  • $camelCase for all local variables — e.g., $secretValue, $resourceGroup, $accountName
  • Be descriptive. $secret is not $s. $resourceGroup is not $rg.

Dry run pattern

Use an explicit -DryRun switch instead of ShouldProcess. It is clearer, pipeline-safe, and easier to test:

if ($DryRun) {
    Write-Host "DRY RUN: would create secret '$SecretName' in vault '$VaultName'" -ForegroundColor Yellow
} else {
    az keyvault secret set --vault-name $VaultName --name $SecretName --value $secretValue | Out-Null
}

Always run the full logic path in dry-run mode — only skip the side-effecting calls.


Error handling

Wrap every external call in try/catch. Provide a meaningful error message that includes what failed and why a human should care:

try {
    $result = az keyvault secret show `
        --vault-name $VaultName `
        --name $SecretName `
        --query value `
        --output tsv
} catch {
    Write-Host "Failed to retrieve secret '$SecretName' from vault '$VaultName': $($_.Exception.Message)" -ForegroundColor Red
    throw
}

Use throw (not exit) to re-throw when the calling context should know about the failure.


Output conventions

Scripts communicate status exclusively through Write-Host. Never use Write-Output for status messages — Write-Output goes to the pipeline and pollutes return values.

Color Use for
Cyan Steps and progress — "Loading secrets...", "Creating resource group..."
Green Success — "Done.", "3 variables loaded."
Yellow Warnings and dry-run notices
Red Errors
Write-Host 'Loading secrets from Key Vault...' -ForegroundColor Cyan
Write-Host "Loaded: $SecretName" -ForegroundColor Green
Write-Host "Warning: '$SecretName' was empty" -ForegroundColor Yellow
Write-Host "Error: vault not found" -ForegroundColor Red

No hardcoded values

Never hardcode: - Secret values or tokens - Subscription IDs - Tenant IDs - Resource names that change per environment - File paths that are machine-specific

Use parameters, environment variables, or load from Key Vault at runtime.

# Wrong
$subId = 'be069ae1-fc96-4a07-9f8e-5994d83a817d'

# Right
$subId = $env:AZURE_SUBSCRIPTION_ID
# or
[Parameter(Mandatory)][string]$SubscriptionId

No shebang lines

Do not add #!/usr/bin/env pwsh or any shebang line to PowerShell scripts. HCS scripts run on Windows. Shebang lines are a Unix convention and add noise without benefit.


Idempotency

Every script must be safe to run more than once with no side effects. Before creating a resource, check if it already exists. Before setting a value, check if it is already correct. Prefer az resource show + conditional logic over blind az resource create.

$existing = az keyvault show --name $VaultName --query id --output tsv 2>$null
if (-not $existing) {
    Write-Host "Creating Key Vault '$VaultName'..." -ForegroundColor Cyan
    az keyvault create --name $VaultName ...
} else {
    Write-Host "Key Vault '$VaultName' already exists, skipping." -ForegroundColor Green
}

Script length and structure

  • Single purpose per script. If a script does more than one distinct thing, split it.
  • Helper functions go at the top, before the main execution block.
  • Main execution block at the bottom, clearly separated by a comment.
  • Keep the main execution block short — it should read like a procedure, with detail in functions.
# === Functions ===

function Get-HCSVaultSecret { ... }
function Test-AzLogin { ... }

# === Main ===

Test-AzLogin
$value = Get-HCSVaultSecret -VaultName $VaultName -SecretName $SecretName
Write-Host "Done." -ForegroundColor Green

Config-driven scripting

Scripts that operate on infrastructure must read their configuration from a YAML file (e.g., infrastructure.yml), not from hardcoded values or excessive parameters. Parameters are reserved for: which config file to use, credentials, target nodes, and overrides for individual values when running one-off operations.

This separates what to run (the script) from what to configure (the YAML file). The YAML file is the single source of truth for environment-specific values.

param(
    [Parameter(Mandatory)]
    [string]$ConfigPath,

    [PSCredential]$Credential,

    # YAML-overridable values — empty string / empty array = read from YAML
    [string]  $NTPServer   = '',
    [string[]]$DnsServers  = @()
)

$cfg = Get-Content -Path $ConfigPath -Raw | ConvertFrom-Yaml

# Apply overrides — parameter wins over YAML when provided
$ntpServer  = if ($NTPServer  -ne '')      { $NTPServer }  else { $cfg.identity.ntp_server }
$dnsServers = if ($DnsServers.Count -gt 0) { $DnsServers } else { $cfg.compute.dns_servers }

Config YAML keys use snake_case. Never use camelCase in YAML files.

If a config file is missing, copy the bundled example file automatically before failing:

if (-not (Test-Path $ConfigPath)) {
    $examplePath = $ConfigPath -replace '\.yml$', '.example.yml'
    if (Test-Path $examplePath) {
        Copy-Item $examplePath $ConfigPath
        Write-Host "Created '$ConfigPath' from example — fill in your values before re-running." -ForegroundColor Yellow
    }
    throw "Config file not found: $ConfigPath"
}

Script metadata tagging

Extend the mandatory .NOTES block with structured fields that link the script to its task documentation and track its version history. These fields are machine-parseable and enable tooling to cross-reference scripts against the task that owns them.

<#
.SYNOPSIS
    Configure DNS servers on cluster nodes.

.DESCRIPTION
    Sets DNS server addresses on all network adapters on target nodes.
    Reads target addresses from infrastructure.yml unless overridden by -DnsServers.

.NOTES
    ScriptVersion    = "1.2.0"
    TaskReference    = "04-cluster-deployment/phase-03-os-configuration/task-05-configure-dns"
    DocumentationRef = "docs/standards/scripting.md"
    LastUpdated      = "2026-05-08"
    UpdatedBy        = "Kristopher Turner"
    ChangeLog        = @(
        "1.2.0 - 2026-05-08 - Added -LogPath parameter; standardized Write-Log output"
        "1.1.0 - 2026-04-10 - Added -DryRun mode"
        "1.0.0 - 2026-03-10 - Initial implementation"
    )
#>
Field Description
ScriptVersion Semantic version. Bump patch for fixes, minor for new parameters, major for breaking changes.
TaskReference Path under the playbook that owns this script. Links script to documentation.
DocumentationRef Markdown file path for the authoritative documentation.
LastUpdated ISO 8601 date.
ChangeLog Array of strings. Most recent entry first.

Write-Log pattern

Use a Write-Log function in any script that runs remotely, runs as a pipeline task, or produces output that may need to be reviewed after the fact. Write-Log writes to both a log file and the console, with level-dependent colors and support for -Verbose and -Debug.

Declare $script:logFile near the top of the script, before the main block:

$script:logFile = Join-Path $env:TEMP "hcs-$(Split-Path $PSCommandPath -LeafBase)-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
function Write-Log {
    param([string]$Message, [string]$Level = 'INFO')
    $ts   = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
    $line = "[$ts] [$Level] $Message"
    $line | Out-File -FilePath $script:logFile -Append -Encoding utf8
    switch ($Level) {
        'PASS'    { Write-Host $line -ForegroundColor Green }
        'FAIL'    { Write-Host $line -ForegroundColor Red }
        'WARN'    { Write-Host $line -ForegroundColor Yellow }
        'HEADER'  { Write-Host $line -ForegroundColor Cyan }
        'VERBOSE' { Write-Verbose $line }
        'DEBUG'   { Write-Debug   $line }
        default   { Write-Host $line }
    }
}

Write-Log replaces Write-Host calls in scripts where a persistent log is needed. Scripts with no remoting and no pipeline use can continue to use plain Write-Host.


Key Vault reference resolution

Config YAML files may reference Key Vault secrets using the keyvault:// URI scheme instead of embedding secret names in script logic:

identity:
  accounts:
    local_admin_username: hcs-local-admin
    local_admin_password: keyvault://kv-hcs-vault-01/account-local-admin-password

The Resolve-KeyVaultRef function resolves these URIs at runtime. It tries the Az.KeyVault module first, then falls back to the Azure CLI:

function Resolve-KeyVaultRef {
    param([string]$KvUri)
    if ($KvUri -notmatch '^keyvault://([^/]+)/(.+)$') {
        Write-Log "Not a Key Vault URI: $KvUri" 'WARN'
        return $null
    }
    $vaultName  = $Matches[1]
    $secretName = $Matches[2]

    if (Get-Module -Name Az.KeyVault -ListAvailable -ErrorAction SilentlyContinue) {
        try {
            $secret = Get-AzKeyVaultSecret -VaultName $vaultName -Name $secretName -AsPlainText -ErrorAction Stop
            if ($secret) { Write-Log "Secret '$secretName' retrieved (Az.KeyVault)." 'PASS'; return $secret }
        } catch { Write-Log "Az.KeyVault failed: $_" 'WARN' }
    }

    try {
        $tmpErr = [System.IO.Path]::GetTempFileName()
        $val    = (& az keyvault secret show --vault-name $vaultName --name $secretName --query value --output tsv --only-show-errors 2>$tmpErr)
        $azErr  = (Get-Content $tmpErr -Raw -ErrorAction SilentlyContinue).Trim()
        Remove-Item $tmpErr -ErrorAction SilentlyContinue
        if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($val)) {
            Write-Log "Secret '$secretName' retrieved (az CLI)." 'PASS'
            return $val
        }
        $detail = if ($azErr) { ": $azErr" } else { " (exit $LASTEXITCODE)" }
        Write-Log "az CLI failed$detail." 'WARN'
    } catch { Write-Log "az CLI exception: $_" 'WARN' }

    return $null
}

Credential resolution order

Scripts that use PSRemoting must resolve credentials in a defined order. The correct account depends on whether the target node has been domain-joined.

Pre-domain — nodes not yet joined to Active Directory:

# 1. -Credential parameter (passed by caller)
# 2. Key Vault — local Administrator account
# 3. Interactive Get-Credential prompt
if (-not $Credential) {
    $adminUser    = $cfg.identity.accounts.local_admin_username
    $adminPassUri = $cfg.identity.accounts.local_admin_password
    $adminPass    = Resolve-KeyVaultRef -KvUri $adminPassUri
    if ($adminPass) {
        $Credential = New-Object PSCredential(
            $adminUser,
            (ConvertTo-SecureString $adminPass -AsPlainText -Force)
        )
        Write-Log "Credentials resolved for '$adminUser'." 'PASS'
    } else {
        Write-Log 'Key Vault unavailable — prompting for credentials.' 'WARN'
        $Credential = Get-Credential -Message 'Enter local Administrator credentials' -UserName $adminUser
    }
}

Post-domain — nodes are joined; Kerberos authentication required:

# 1. -Credential parameter (passed by caller)
# 2. Key Vault — LCM domain account
# 3. Interactive Get-Credential prompt
if (-not $Credential) {
    $lcmUser    = $cfg.identity.accounts.lcm_username
    $lcmPassUri = $cfg.identity.accounts.lcm_password
    $lcmPass    = Resolve-KeyVaultRef -KvUri $lcmPassUri
    if ($lcmPass) {
        $Credential = New-Object PSCredential(
            $lcmUser,
            (ConvertTo-SecureString $lcmPass -AsPlainText -Force)
        )
        Write-Log "Credentials resolved for '$lcmUser'." 'PASS'
    } else {
        Write-Log 'Key Vault unavailable — prompting for credentials.' 'WARN'
        $Credential = Get-Credential -Message 'Enter LCM credentials for PSRemoting' -UserName $lcmUser
    }
}

Invoke- script pattern

For automation that executes work on remote nodes via PSRemoting, use two script layers:

  1. Orchestrator (Invoke-<Task>.ps1) — runs on the operator's machine. Loads config, resolves credentials, iterates over target nodes, and calls Invoke-Command. Never does the actual work itself.
  2. Target scriptblock — the code that runs inside Invoke-Command on the remote node. Fully parameterized, no external file system access, no module imports beyond what is pre-installed on the node.
# Orchestrator — runs on operator's machine
foreach ($node in $TargetNodes) {
    Write-Log "Configuring $node..." 'HEADER'

    $result = Invoke-Command -ComputerName $node -Credential $Credential `
        -ArgumentList $node, $dnsServers, $DryRun.IsPresent `
        -ScriptBlock {
            param($NodeName, [string[]]$Dns, [bool]$IsDryRun)

            $adapter = Get-NetAdapter | Where-Object Status -eq 'Up' | Select-Object -First 1

            if ($IsDryRun) {
                "[WhatIf] $NodeName — would set DNS to: $($Dns -join ', ')"
                return
            }

            Set-DnsClientServerAddress -InterfaceIndex $adapter.ifIndex -ServerAddresses $Dns
            "DNS configured on $NodeName."
        }

    Write-Log $result 'PASS'
}

Pass -DryRun.IsPresent as a [bool] argument rather than a switch — switches are not serializable across PSRemoting boundaries.