OctopusDSCHelpers.ps1

# Module contains shared code for OctopusDSC

function Get-OctopusServerExePath {
    $installLocation = (Get-ItemProperty -path "HKLM:\Software\Octopus\OctopusServer" -ErrorAction SilentlyContinue).InstallLocation

    if ($installLocation -ne $null) {
        return "$installLocation\Octopus.Server.exe"
    }

    return "$($env:ProgramFiles)\Octopus Deploy\Octopus\Octopus.Server.exe"
}

function Get-TentacleExePath {
    $installLocation = (Get-ItemProperty -path "HKLM:\Software\Octopus\Tentacle" -ErrorAction SilentlyContinue).InstallLocation

    if ($installLocation -ne $null) {
        return "$installLocation\Tentacle.exe"
    }

    return "$($env:ProgramFiles)\Octopus Deploy\Tentacle\Tentacle.exe"
}

function Get-ODSCParameter($parameters) {
    # unfortunately $PSBoundParameters doesn't contain parameters that weren't supplied (because the default value was okay)
    # credit to https://www.briantist.com/how-to/splatting-psboundparameters-default-values-optional-parameters/
    $params = @{}
    foreach ($h in $parameters.GetEnumerator()) {
        $key = $h.Key
        $var = Get-Variable -Name $key -ErrorAction SilentlyContinue
        if ($null -ne $var) {
            $val = Get-Variable -Name $key -ErrorAction Stop  | Select-Object -ExpandProperty Value -ErrorAction Stop
            $params[$key] = $val
        }
    }
    return $params
}

function Test-GetODSCParameter {
    param(
        $Name,
        $Ensure,
        $DefaultValue = 'default'
    )
   return (Get-ODSCParameter $MyInvocation.MyCommand.Parameters)
}


function Invoke-WebClient($url, $OutFile) {
    $downloader = new-object System.Net.WebClient
    $downloader.DownloadFile($url, $OutFile)
}

function Request-File {
    [CmdletBinding()]
    param (
        [string]$url,
        [string]$saveAs
    )

    $retry = $true
    $retryCount = 0
    $maxRetries = 5
    $downloadFile = $true

    [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12, [System.Net.SecurityProtocolType]::Tls11, [System.Net.SecurityProtocolType]::Tls

    Write-Verbose "Checking to see if we have an installer at $saveas"
    if(Test-Path $saveAs)
    {
        # check if we already have a matching file on disk
        Write-Verbose "Local file exists"
        $localHash = Get-FileHash $saveAs -Algorithm SHA256 | Select -Expand hash
        Write-Verbose "Local SHA256 hash: $localHash"
        $remoteHash = (Invoke-WebRequest -uri $url -Method Head -UseBasicParsing | select -expand headers).GetEnumerator()  | ? { $_.Key -eq "x-amz-meta-sha256" } | select -expand value
        Write-Verbose "Remote SHA256 hash: $remoteHash"
        $downloadFile = ($localHash -ne $remoteHash)
    }
    else
    {
        Write-Verbose "No local installer found"
    }

    if($downloadFile)
    {
        while ($retry) {
            Write-Verbose "Downloading $url to $saveAs"

            try {
                Invoke-WebClient -Url $url -OutFile $saveAs
                $retry = $false
            }
            catch {
                Write-Verbose "Failed to download $url"

                $ex = $_.Exception
                while($null -ne $ex)
                {
                    Write-Verbose "Got Exception '$($ex.Message)'."
                    $ex = $ex.InnerException
                }

                Write-Verbose "Retrying up to $maxRetries times."

                if ($retryCount -gt $maxRetries) {
                    # rethrow the inner exception if we've retried enough times
                    throw $_.Exception.InnerException
                }
                $retryCount = $retryCount + 1
                Start-Sleep -Seconds 1
            }
        }
    }
    else
    {
        Write-Verbose "Local file and remote file hashes match. We already have the file, skipping download."
    }
}

function Invoke-AndAssert {
    param ($block)
    & $block | Write-Verbose
    if ($LASTEXITCODE -ne 0 -and $null -ne $LASTEXITCODE) {
        throw "Command returned exit code $LASTEXITCODE"
    }
}

function Write-Log {
    param (
        [string] $message
    )

    $timestamp = ([System.DateTime]::UTCNow).ToString("yyyy'-'MM'-'dd'T'HH':'mm':'ss")
    Write-Verbose "[$timestamp] $message"
}

Function Invoke-WithRetries {
    [CmdletBinding()]
    param(
        [scriptblock]$ScriptBlock,
        [int]$MaxRetries = 10,
        [int]$IntervalInMilliseconds = 200
    )

    $backoff = 1
    $retrycount = 0
    $returnvalue = $null
    while ($null -eq $returnvalue -and $retrycount -lt $MaxRetries) {
        try {
            $returnvalue = Invoke-Command $ScriptBlock
            if ($null -ne $LastExitCode -and $LastExitCode -ne 0) {
                throw "Command exited with exit code $LastExitCode"
            }
        }
        catch
        {
            if($error)
            {
                Write-Verbose ($error | Select-Object -first 1)
            }
            else {
                Write-Verbose ("Invoke-WithRetries threw an exception: " + ($_| Out-String))
            }
            Write-Verbose "We have tried $retrycount times, sleeping for $($backoff * $IntervalInMilliseconds) milliseconds and trying again."
            Start-Sleep -MilliSeconds ($backoff * $IntervalInMilliseconds)
            $backoff = $backoff + $backoff
            $retrycount++
        }
    }

    return $returnvalue
}

function Get-MaskedOutput {
    [CmdletBinding()]
    param($arguments)

    $singleAsterixArgs = "--masterkey|--license|--licence|--trust|--password|--remove-trust|--apikey|--pw|--pfx-password|--proxyPassword";
    $connectionStringArgs = "--connectionstring";

    # Scrub sensitive values
    for($x=0; $x -lt $arguments.count; $x++) {
        if($arguments[$x] -match $singleAsterixArgs) {
            $arguments[$x+1] = "**********"
        } elseif($arguments[$x] -match $connectionStringArgs) {
            $arguments[$x+1] = $arguments[$x+1] -replace "(password|pwd)=[^;|`"]*", "`$1=********"
        }
    }
    return $arguments
}

function Write-VerboseWithMaskedCommand ($exePath, $cmdArgs) {
    $copiedarguments = @() # hack to pass a copy of the array, not a reference
    $copiedarguments += $cmdArgs
    $maskedarguments = Get-MaskedOutput $copiedarguments
    Write-Verbose "Executing command '$exePath $($maskedarguments -join ' ')'"
}

function Invoke-OctopusServerCommand ($cmdArgs) {
    $octopusServerExePath = Get-OctopusServerExePath
    Write-VerboseWithMaskedCommand $octopusServerExePath $cmdArgs

    $LASTEXITCODE = 0
    $output = & $octopusServerExePath $cmdArgs 2>&1

    Write-CommandOutput $output
    if (($null -ne $LASTEXITCODE) -and ($LASTEXITCODE -ne 0)) {
        Write-Error "Command returned exit code $LASTEXITCODE. Aborting."
        throw "Command returned exit code $LASTEXITCODE. Aborting."
    }
    Write-Verbose "done."
}

function Test-TentacleExecutableExists {
    $tentacleExePath =  Get-TentacleExePath
    return (test-path $tentacleExePath)
}

function Invoke-TentacleCommand ($cmdArgs) {
    $tentacleExePath =  Get-TentacleExePath
    Write-VerboseWithMaskedCommand $tentacleExePath $cmdArgs

    $LASTEXITCODE = 0
    $output = & $tentacleExePath $cmdArgs 2>&1

    Write-CommandOutput $output
    if (($null -ne $LASTEXITCODE) -and ($LASTEXITCODE -ne 0)) {
        Write-Error "Command returned exit code $LASTEXITCODE. Aborting."
        throw "Command returned exit code $LASTEXITCODE. Aborting."
    }
    Write-Verbose "done."
    return $output
}

function Write-CommandOutput {
    param (
        [string] $output
    )

    if ($output -eq "") { return }

    Write-Verbose ""
    #this isn't quite working
    foreach ($line in $output.Trim().Split("`n")) {
        Write-Verbose $line
    }
    Write-Verbose ""
}

function Get-ServerConfiguration($instanceName) {
    $octopusServerExePath = Get-OctopusServerExePath
    $rawConfig = & $octopusServerExePath show-configuration --format=json-hierarchical --noconsolelogging --console --instance $instanceName

    # handle a specific error where an exception in registry migration finds its way into the json-hierarchical output
    # Refer to Issue #179 (https://github.com/OctopusDeploy/OctopusDSC/issues/179)
    # wrapped in retries to catch transient issues in json output
    $config = Invoke-WithRetries {
        if(Test-ValidJson $rawConfig) {
            return $rawConfig | ConvertFrom-Json
        } else {
            Write-Warning "Invalid json encountered in show-configuration; attempting to clean up."
            $cleanedUpConfig = Get-CleanedJson $rawConfig
            if(Test-ValidJson $cleanedUpConfig ) {
                return $cleanedUpConfig | ConvertFrom-Json
            } else {
                throw "Attempted to cleanup bad JSON and failed."
            }
        }
    } -MaxRetries 3 -IntervalInMilliseconds 100

    $plainTextMasterKey = & $octopusServerExePath show-master-key --noconsolelogging --console --instance $instanceName

    $encryptedMasterKey = New-Object SecureString
    $plainTextMasterKey.ToCharArray() | Foreach-Object { $encryptedMasterKey.AppendChar($_) }
    $config | Add-Member -NotePropertyName "OctopusMasterKey" -NotePropertyValue (New-Object System.Management.Automation.PSCredential ("ignored", $encryptedMasterKey))

    return $config
}

function Get-TentacleConfiguration($instanceName)
{
    $tentacleExePath =  Get-TentacleExePath
    $rawConfig = & $tentacleExePath show-configuration --instance $instanceName
    $config = $rawConfig | ConvertFrom-Json
    return $config
}

function Test-ValidJson
{
    param($string)
    try {
        $string | ConvertFrom-Json | Out-Null
        return $true
    }
    catch {
        return $false
    }
}

# Operates on a very specific json output failure. See issue #179 (https://github.com/OctopusDeploy/OctopusDSC/issues/179)
function Get-CleanedJson
{
    [CmdletBinding()]
    param($jsonstring)
    $jsonstart = $jsonstring.IndexOf("{")
    Write-Verbose "Found start of JSON at character $jsonstart"
    $extractedjson = $jsonstring.Substring($jsonstart, $jsonstring.length - $jsonstart)
    $dumpedstring = $jsonstring.substring(0, $jsonstart)
    Write-Warning ("stripped extra content from JSON configuration string`r`n`r`n" + (Get-MaskedOutput $dumpedstring))

    return $extractedjson
}