Skip to content

Best Practices

Matthew Casperson edited this page Nov 23, 2023 · 8 revisions

This page is a collection of common snippets and practices to apply to Powershell step templates.

Powershell

Validate properties

The properties defined against a step template have limited ability to define validation rules, so the scripts should validate these parameters before running any other logic.

$global:AzureAppConfigRetrievalMethod = $OctopusParameters["AzFunction.SetAppSettings.FromAzAppConfig.RetrievalMethod"]
if ([string]::IsNullOrWhiteSpace($global:AzureAppConfigRetrievalMethod)) {
    throw "Required parameter AzFunction.SetAppSettings.FromAzAppConfig.RetrievalMethod not specified"
}

Test CLI tools exist

The script should validate that any required CLI tools are available:

function Test-ForAzCLI() {
    $oldPreference = $ErrorActionPreference
    $ErrorActionPreference = "Stop"
    try {
        return Get-Command "az"
    }
    catch {
        return $false
    }
    finally {
        $ErrorActionPreference = $oldPreference
    }
}

Capturing stderr rather than printing errors

Octopus prints all text stream to stderr as an error. This is arguably not the correct implementation, but isn't likely to be changed. This function allows a command to be run capturing all the output as a string:

Function Invoke-CustomCommand
{
    Param (
        $commandPath,
        $commandArguments,
        $workingDir = (Get-Location),
        $path = @()
    )

    $path += $env:PATH
    $newPath = $path -join [IO.Path]::PathSeparator

    $pinfo = New-Object System.Diagnostics.ProcessStartInfo
    $pinfo.FileName = $commandPath
    $pinfo.WorkingDirectory = $workingDir
    $pinfo.RedirectStandardError = $true
    $pinfo.RedirectStandardOutput = $true
    $pinfo.UseShellExecute = $false
    $pinfo.Arguments = $commandArguments
    $pinfo.EnvironmentVariables["PATH"] = $newPath
    $p = New-Object System.Diagnostics.Process
    $p.StartInfo = $pinfo
    $p.Start() | Out-Null

    # Capture output during process execution so we don't hang
    # if there is too much output.
    # Microsoft documents a C# solution here:
    # https://learn.microsoft.com/en-us/dotnet/api/system.diagnostics.processstartinfo.redirectstandardoutput?view=net-7.0&redirectedfrom=MSDN#remarks
    # This code is based on https://stackoverflow.com/a/74748844
    $stdOut = [System.Text.StringBuilder]::new()
    $stdErr = [System.Text.StringBuilder]::new()
    do
    {
        if (!$p.StandardOutput.EndOfStream)
        {
            $stdOut.AppendLine($p.StandardOutput.ReadLine())
        }
        if (!$p.StandardError.EndOfStream)
        {
            $stdErr.AppendLine($p.StandardError.ReadLine())
        }

        Start-Sleep -Milliseconds 10
    }
    while (-not $p.HasExited)

    # Capture any standard output generated between our last poll and process end.
    while (!$p.StandardOutput.EndOfStream)
    {
        $stdOut.AppendLine($p.StandardOutput.ReadLine())
    }

    # Capture any error output generated between our last poll and process end.
    while (!$p.StandardError.EndOfStream)
    {
        $stdErr.AppendLine($p.StandardError.ReadLine())
    }

    $p.WaitForExit()

    $executionResults = [pscustomobject]@{
        StdOut = $stdOut.ToString()
        StdErr = $stdErr.ToString()
        ExitCode = $p.ExitCode
    }

    return $executionResults

}

function Write-Results
{
    [cmdletbinding()]
    param (
        [Parameter(Mandatory=$True,ValuefromPipeline=$True)]
        $results
    )

    if (![String]::IsNullOrWhiteSpace($results.StdOut))
    {
        Write-Verbose $results.StdOut
    }
}

Detecting OS

Only later versions of PowerShell exposed variables $IsWindows and $IsLinux. This polyfill makes those variables available for all scripts:

if ($null -eq $IsWindows) {
    Write-Host "Determining Operating System..."
    $IsWindows = ([System.Environment]::OSVersion.Platform -eq "Win32NT")
    $IsLinux = ([System.Environment]::OSVersion.Platform -eq "Unix")
}

Triming null strings

Sometimes you want a trimmed string, or null if the string was null, because $null.Trim() will throw an error:

function Format-StringAsNullOrTrimmed {
    [cmdletbinding()]
    param (
        [Parameter(ValuefromPipeline=$True)]
        $input
    )

    if ([string]::IsNullOrWhitespace($input)) {
        return $null
    }

    return $input.Trim()
}

Prefer [string]::IsNullOrWhiteSpace()

Most scripts treat $null, empty strings, and whitespace strings as falsy. Unless you have a specific need to distinguish between these types of strings, prefer the use of the [string]::IsNullOrWhiteSpace() check over checks like $myString -eq "" or $myString -eq $null.

Note output variables in the log

When a step creates output variables, print them to the log. This makes it easy to copy and paste the correct syntax:

$StepName = $OctopusParameters["Octopus.Step.Name"]
Write-Host "Created output variable: ##{Octopus.Action[$StepName].Output.AppSettingsJson}"
Clone this wiki locally