Skip to content

Scripting standard

Standard scope

Applies to: all HCS-governed repos, base rules; scope overlays noted inline where applicable Domain: scripting Status: Active

PowerShell 7+ is the default and required language for all first-party HCS automation and orchestration. No PS 5.1 compatibility shims — #Requires -Version 7.0 everywhere.

Bash is permitted only as a scoped exception: Azure CLI wrapper scripts in CI, and only in the tierpoint-prodtech and azurelocal scopes. Such scripts must pass ShellCheck and must read from the same config source as the PowerShell scripts in that repo (see Config-driven scripting). Bash is the exception, not an alternative — PowerShell 7+ remains the default for everything else, including in those two scopes. All other scopes are PowerShell-only.


Mandatory header

Every script starts with this block — no exceptions:

powershell
#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
powershell
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:

powershell
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:

powershell
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.

ColorUse for
CyanSteps and progress — "Loading secrets...", "Creating resource group..."
GreenSuccess — "Done.", "3 variables loaded."
YellowWarnings and dry-run notices
RedErrors
powershell
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.

powershell
# 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.

powershell
$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.
powershell
# === 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, 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.

The config contract (base rule)

Every repo that ships config-driven scripts satisfies this contract, regardless of scope:

  • One YAML file is the single source of truth for environment-specific values. No value is defined in more than one place.
  • Keys use snake_case. Never camelCase in YAML files.
  • Secrets are referenced, never embedded — use the keyvault:// URI scheme (see Key Vault reference resolution) so no secret value ever lands in the file.
  • An example/template file is committed; the real file is gitignored. The example carries every key with placeholder values and documents each one.
  • Missing config auto-bootstraps from the template — a script that cannot find its config copies the bundled example into place, tells the operator to fill it in, then fails cleanly.

The exact filename and path are scope-specific and are documented per scope, not forced to a single canonical name. The filename is load-bearing in each scope's existing automation, so the base standard fixes the contract above and leaves the path to the scope overlay (for example, config/variables.yml, config/variables/variables.yml, or configs/infrastructure.yml in different scopes). Follow the path documented for the scope you are working in; do not rename an existing repo's config file to match another scope.

powershell
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:

powershell
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"
}

Module dependencies

Scripts intended to run in automation pipelines must gracefully handle missing PowerShell modules. Do not assume the build agent will have every required module pre-installed.

  • Check if the required module is available before importing.
  • If it is missing, automatically install it using Install-Module -Force -Scope CurrentUser to ensure it installs successfully on CI agents without requiring administrative privileges or interrupting the pipeline.
powershell
if (-not (Get-Module -Name PSSQLite -ListAvailable)) {
    Write-Host "Installing PSSQLite module for pipeline run..." -ForegroundColor Cyan
    Install-Module -Name PSSQLite -Force -Scope CurrentUser
}
Import-Module PSSQLite

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.

powershell
<#
.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"
    )
#>
FieldDescription
ScriptVersionSemantic version. Bump patch for fixes, minor for new parameters, major for breaking changes.
TaskReferencePath under the playbook that owns this script. Links script to documentation.
DocumentationRefMarkdown file path for the authoritative documentation.
LastUpdatedISO 8601 date.
ChangeLogArray 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:

powershell
$script:logFile = Join-Path $env:TEMP "hcs-$(Split-Path $PSCommandPath -LeafBase)-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"
powershell
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:

yaml
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:

powershell
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:

powershell
# 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:

powershell
# 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.
powershell
# 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.

Copyright © Hybrid Cloud Solutions LLC — Kristopher Turner