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-Verbif unsure. - Format:
Verb-Noun— e.g.,Get-HCSSecretName,Set-HCSEnvironment,New-HCSRepo - Prefix nouns with
HCSfor functions in shared/platform scripts - Never use unapproved verbs like
Check-,Validate-,Execute-— useTest-,Assert-,Invoke-
Parameters¶
$PascalCasefor 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¶
$camelCasefor all local variables — e.g.,$secretValue,$resourceGroup,$accountName- Be descriptive.
$secretis not$s.$resourceGroupis 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:
- Orchestrator (
Invoke-<Task>.ps1) — runs on the operator's machine. Loads config, resolves credentials, iterates over target nodes, and callsInvoke-Command. Never does the actual work itself. - Target scriptblock — the code that runs inside
Invoke-Commandon 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.