Ansible Tower - Run Template

Octopus.Script exported 2021-09-28 by kdblitz belongs to ‘Ansible’ category.

Run a workflow or job template in Ansible Tower

Parameters

When steps based on the template are included in a project’s deployment process, the parameters below can be set.

Tower Server

TowerServer = tower.example.com

The connection information of the Tower server to connect to. This can be a single host name or IP (https will be assumed) or a full specification like http://localhost:8081.

Tower Username

TowerUsername = admin

The user to connect to Tower as. Be sure this user has permissions to execute the Job/Workflow templates you are attempting to launch.

Tower Password

TowerPassword =

The password for the specified user.

OAuth Token

TowerOAuthToken =

An alternative to username/password which can be used on newer versions of Tower.

Template Type

TowerJobType =

Select the type of template to execute. Supported template types are Workflow and Job

Template Name

TowerJobTemplate =

The Job or Workflow template ID or name. If a name is specified the ID will attempt to be resolved.

Extra Vars

TowerExtraVars = ---

Extra variable to pass to the job. This can be either YAML or JSON. NOTE: prompt on launch must be set in your template for this setting to take affect.

Job Tags

TowerJobTags =

Any job tags to pass to Tower. NOTE: prompt on launch must be set in your template for this setting to take affect.

Limit

TowerLimit =

Limit field to be passed to Tower. NOTE: prompt on launch must be set in your template for this setting to take affect.

Inventory

TowerInventory =

The inventory for the job run. NOTE: prompt on launch must be set in your template for this setting to take affect.

Credential

TowerCredential =

The credentials to use for this job. NOTE: prompt on launch must be set in your template for this setting to take affect.

Import Tower Logs

TowerImportLogs =

Pull the Tower logs back into the step output

Second Between Checks

TowerSecondsBetweenChecks = 3

How many seconds to pause between checks when monitoring a job. 0 means no checks. Failure to parse this field as an integer will default to 3 seconds.

Ignore Certificate

TowerIgnoreCert =

This parameter is intended only for testing. This tells the step to ignore any https certificate presented to it from the Tower server. Please understand the ramifications before enabling this option.

Verbose

TowerVerbose =

Add additional details of what the plugin is doing.

Script body

Steps based on this template will execute the following PowerShell script.

# There have been reported issues when using the default JSON parser with Invoke-RestMethod
# on PowerShell 5. So we are going to pull in a different assembly to do the parsing for us.
# This parser appears to be more reliable.
[System.Reflection.Assembly]::LoadWithPartialName("System.Web.Extensions")
$jsonParser = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer
$jsonParser.MaxJsonLength = 104857600 #100mb as bytes, default is 2mb

function Write-AnsibleLine([String] $text) {
    # split text at ESC-char
    $ansi_colors = @(
        '[0;30m' #= @{ fg = ConsoleColor.Black }
        '[0;31m' #= @{ fg = ConsoleColor.DarkRed }
        '[0;32m' #= @{ fg = ConsoleColor.DarkGreen }
        '[0;33m' #= @{ fg = ConsoleColor.DarkYellow }
        '[0;34m' #= @{ fg = ConsoleColor.DarkBlue }
        '[0;35m' #= @{ fg = ConsoleColor.DarkMagenta }
        '[0;36m' #= @{ fg = ConsoleColor.DarkCyan }
        '[0;37m' #= @{ fg = ConsoleColor.White }
        '[0m' #= @{ fg = $null; bg = $null }
        '[1;35m' #= Magent (ansible warnings)
        '[30;1m' #= @{ fg = ConsoleColor.Grey }
        '[31;1m' #= @{ fg = ConsoleColor.Red }
        '[32;1m' #= @{ fg = ConsoleColor.Green }
        '[33;1m' #= @{ fg = ConsoleColor.Yellow }
        '[34;1m' #= @{ fg = ConsoleColor.Blue }
        '[35;1m' #= @{ fg = ConsoleColor.Magenta }
        '[36;1m' #= @{ fg = ConsoleColor.Cyan }
        '[37;1m' #= @{ fg = ConsoleColor.White }
        '[0;40m' #= @{ bg = ConsoleColor.Black }
        '[0;41m' #= @{ bg = ConsoleColor.DarkRed }
        '[0;42m' #= @{ bg = ConsoleColor.DarkGreen }
        '[0;43m' #= @{ bg = ConsoleColor.DarkYellow }
        '[0;44m' #= @{ bg = ConsoleColor.DarkBlue }
        '[0;45m' #= @{ bg = ConsoleColor.DarkMagenta }
        '[0;46m' #= @{ bg = ConsoleColor.DarkCyan }
        '[0;47m' #= @{ bg = ConsoleColor.White }
        '[40;1m' #= @{ bg = ConsoleColor.DarkGrey }
        '[41;1m' #= @{ bg = ConsoleColor.Red }
        '[42;1m' #= @{ bg = ConsoleColor.Green }
        '[43;1m' #= @{ bg = ConsoleColor.Yellow }
        '[44;1m' #= @{ bg = ConsoleColor.Blue }
        '[45;1m' #= @{ bg = ConsoleColor.Magenta }
        '[46;1m' #= @{ bg = ConsoleColor.Cyan }
        '[47;1m' #= @{ bg = ConsoleColor.White }
    )
    foreach ($segment in $text.split([char] 27)) {
        foreach($code in $ansi_colors) {
            if($segment.startswith($code)) {
                $segment = $segment.replace($code, "")
            }
        }
        Write-Host -NoNewline $segment
    }
    Write-Host ""
}
 
 
Function Resolve-Tower-Asset{
    Param($Name, $Url)
    Process {
        if($script:Verbose) { Write-Host "Resolving name $Name" }
        $object = $null
        if($Name -match '^[0-9]+$') {
            if($script:Verbose) { Write-Host "Using $Name as ID as its an int already" }
            $url = "$Url/$Name/"
            try { $object = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object]) }
            catch {
                Write-Host "Error when resolving ID for $Name"
                Write-Host $_
                return $null
            }
        } else {
           if($script:Verbose) { Write-Host "Looking up ID of name $Name" }
            $url = "$Url/?name=$Name"
            try { $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object]) }
            catch {
                Write-Host "Unable to resolve name $Name"
                Write-Host $_
                return $null
            }
            if($response.count -eq 0) {
                Write-Host "Got no results when trying to get ID for $Name"
                return $null
            } elseif($response.count -ne 1) {
                Write-Host "Did not get a unique job ID for job name $Name"
                return $null
            }
            if($script:Verbose) { Write-Host "Resolved to ID $($response.results[0].id)" }
            $object = $response.results[0]
        }
        return $object
    }
}


function Get-Auth-Headers {
    # If we did not get a TowerOAuthToken or a (TowerUsername and TowerPassword) then we can't even try to auth
    if(-not (($TowerUsername -and $TowerPassword) -or $TowerOAuthToken)) {
        Fail-Step "Please pass an OAuth Token and or a Username/Password to authenticate to Tower with"
    }

    if($TowerOAuthToken) {
        if($verbose) { Write-Host "Testing OAuth token" }
        $token_headers = @{ "Authorization" = "Bearer $TowerOAuthToken" }
        try {
            # We have to assign it to something or we get a line in the output
            $junk = $jsonParser.Deserialize((Invoke-WebRequest "$api_base/job_templates/?name=Octopus" -Method GET -Headers $token_headers -UseBasicParsing), [System.Object])
            $script:auth_headers = $token_headers
            return
        } catch {
            Write-Host "Unable to authenticate to the Tower server with OAuth token"
            Write-Host $_
        }
    }

    if(-not ($TowerUsername -and $TowerPassword)) {
        Fail-Step "No username/password to fall back on"
    }

    if($verbose) { Write-Host "Testing basic auth" }
    $pair = "${TowerUsername}:${TowerPassword}"
    $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair)
    $base64 = [System.Convert]::ToBase64String($bytes)
    $basic_auth_value = "Basic $base64"
    $headers = @{ "Authorization" = $basic_auth_value }
    try {
        # We have to assign it to something or we get a line in the output
        $junk = $jsonParser.Deserialize((Invoke-WebRequest "$api_base/job_templates/?name=Octopus" -Method GET -Headers $headers -UseBasicParsing), [System.Object])
        $script:auth_headers = $headers
    } catch {
        Write-Host $_
        Fail-Step "Username password combination failed to work"
    }

    if ($script:Verbose) { Write-Host "Attempting to get authentcation Token for $TowerUsername" }
    $body = @{
        username = $TowerUsername
        password = $TowerPassword
    } | ConvertTo-Json
    $url = "$api_base/authtoken/"
    try {
        $auth_token = $jsonParser.Deserialize((Invoke-WebRequest $url -Method POST -Headers $headers -Body $body -ContentType "application/json" -UseBasicParsing), [System.Object])
        $script:auth_headers = @{ Authorization = "Token $($auth_token.token)" }
        return
    } catch {
        if($_.Exception.Response.StatusCode -eq 404) {
            Write-Host(">>> Server does not support authtoken, try using an OAuth Token")
            Write-Host(">>> Defaulting to perpetual basic auth. This can be slow for authentication with external sources")
            return
        } else {
            Write-Host $_
            Fail-Step "Unable to authenticate to the Tower server for Auth token"
        }
    }
}

function Watch-Job-Complete {
    Param($Id)
    Process {
        $last_log_id = 0
        while($True) {
            # First log any events if the user wants them
            if($TowerImportLogs) {
                $url = "$api_base/jobs/$Id/job_events/?id__gt=$last_log_id"
                $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object])
                foreach($result in $response.results) {
                    if($last_log_id -lt $result.id) { $last_log_id = $result.id }
                    if($result.event_data -and $result.event_data.res -and $result.event_data.res.output) {
                        foreach($line in $result.event_data.res.output) {
                            Write-AnsibleLine($line)
                        }
                    } else {
                        $line = $result.stdout
                        Write-AnsibleLine($line)
                    }
                }
            }
 
            # Now check the status of the job
            $url = "$api_base/jobs/$Id/"
            $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object])
            if($response.finished) {
               $response.failed
               return
            } else {
               Start-Sleep -s $SecondsBetweenChecks
            }
        }
    }
}
 
function Watch-Workflow-Complete {
    Param($Id)
    Process {
        $workflow_node_id = 0
        while($True) {
            # Check to see if there are any jobs we need to follow
            $url = "$tower_base/api/v2/workflow_jobs/$Id/workflow_nodes/?id__gt=$workflow_node_id"
            $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object])

            # If there are no nodes whose ID is > the last one we looked at we can see if we are complete
            if($response.count -eq 0) {
                $url = "$tower_base/api/v2/workflow_jobs/$Id/"
                $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object])
                if($response.finished) {
                    $response.failed
                    return
                } else {
                    Start-Sleep -s $SecondsBetweenChecks
                }
            } else {
                foreach($result in $response.results) {
                    if($result.summary_fields.unified_job_template.unified_job_type -eq 'job') {
                        $job_id = $result.summary_fields.job.id
                        if(-not $job_id) {
                            # This is a job but it hasn't started yet, lets sleep and try again
                            Start-Sleep -s $SecondsBetweenChecks
                            break
                        }
                        if($script:Verbose) { Write-Host "Monitoring job $($result.summary_fields.job.name)" }
                        # We have to trap the return of Watch-Job-Complete
                        $junk = Watch-Job-Complete -Id $job_id
                    } else {
                        if($script:Verbose) { Write-Host "Not pulling logs for node $($result.id) which is a $($result.summary_fields.unified_job_template.unified_job_type)" }
                    }
                    $workflow_node_id = $result.id
                }
            }
        }
    }
}



##### Main Body




# Check that we got a TowerJobTemplate, without one we can't do anything
if(-not ($TowerJobType -eq "job" -or $TowerJobType -eq "workflow")) { Fail-Stop "The job type must be either job or workflow" }
if($TowerJobTemplate -eq $null -or $TowerJobTemplate -eq "") { Fail-Step "A Job Name needs to be specified" }
if($TowerJobTags -eq "") { $TowerJobTags = $null }
if($TowerExtraVars -eq "") { $TowerExtraVars = $null }
if($TowerLimit -eq "") { $TowerLimit = $null }
if($TowerInventory -eq "") { $TowerInventory = $null }
if($TowerCredential -eq "") { $Credential = $null }
if($TowerImportLogs -and $TowerImportLogs -eq "True") { $TowerImportLogs = $True } else { $TowerImportLogs = $False }
if($TowerVerbose -and $TowerVerbose -eq "True") { $Verbose = $True} else { $Verbose = $False }
if($TowerSecondsBetweenChecks) {
    try { $SecondsBetweenChecks = [int]$TowerSecondsBetweenChecks }
    catch {
        write-Host "Failed to parse $TowerSecondsBetweenChecks as integer, defaulting to 3"
        $SecondsBetweenChecks = 3
    }
} else {
    $SecondsBetweenChecks = 3
}
if($TowerTimeLimitInSeconds) {
    try { $TowerTimeLimitInSeconds = [int]$TowerTimeLimitInSeconds }
    catch {
        write-Host "Failed to parse $TowerTimeLimitInSeconds as integer, defaulting to 600"
        $TowerTimeLimitInSeconds = 600
    }
} else {
    $TowerTimeLimitInSeconds = 600
}
if($TowerIgnoreCert -and $TowerIgnoreCert -eq "True") {
    # Joyfully borrowed from Markus Kraus' post on
    # https://blog.ukotic.net/2017/08/15/could-not-establish-trust-relationship-for-the-ssltls-invoke-webrequest/
    if(-not([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy').Type) {
        Add-Type @"
            using System.Net;
            using System.Security.Cryptography.X509Certificates;
            public class TrustAllCertsPolicy : ICertificatePolicy {
                public bool CheckValidationResult(ServicePoint srvPoint, X509Certificate certificate, WebRequest request, int certificateProblem) {
                    return true;
                }
            }
"@
        [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy
        [System.Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
    }
    #endregion
}
 
if ($Verbose) { Write-Host "Beginning Ansible Tower Run on $TowerServer" }
$tower_url = [System.Uri]$TowerServer
if(-not $tower_url.Scheme) { $tower_url = [System.Uri]"https://$TowerServer" }
$tower_base = $tower_url.ToString().TrimEnd("/")
$api_base = "${tower_base}/api/v2"
 
# First handle authentication
#   If we have a TowerOAuthToken try using that
#   Else get an authentication token if we have a user name/password
$auth_headers = @{'initial' = 'Data'}
Get-Auth-Headers
 
 
# If the TowerJobTemplate is actually an ID we can just use that.
# If not we need to lookup the ID from the name
if($TowerJobType -eq 'job') {
    $template = Resolve-Tower-Asset -Name $TowerJobTemplate -Url "$api_base/job_templates"
} else {
    $template = Resolve-Tower-Asset -Name $TowerJobTemplate -Url "$api_base/workflow_job_templates"
}
if($template -eq $null) { Fail-Step "Unable to resolve the job name" }
 
if($TowerExtraVars -ne $null -and $TowerExtraVars -ne '---' -and $template.ask_variables_on_launch -eq $False) {
    Write-Warning "Extra variables defined but prompt for variables on launch is not set in tower job"
}
if($TowerLimit -ne $null -and $template.ask_limit_on_launch -eq $False) {
    Write-Warning "Limit defined but prompt for limit on launch is not set in tower job"
}
if($TowerJobTags -ne $null -and $template.ask_tags_on_launch -eq $False) {
    Write-Warning "Job Tags defined but prompt for tags on launch is not set in tower job"
}
if($TowerInventory -ne $null -and $template.ask_inventory_on_launch -eq $False) {
    Write-Warning "Inventory defined but prompt for inventory on launch is not set in tower job"
}
if($TowerCredential -ne $null -and $template.ask_credential_on_launch -eq $False) {
    Write-Warning "Credential defined but prompt for credential on launch is not set in tower job"
}
<#
// Here are some more options we may want to use/check someday
//    "ask_diff_mode_on_launch": false,
//    "ask_skip_tags_on_launch": false,
//    "ask_job_type_on_launch": false,
//    "ask_verbosity_on_launch": false,
#>
 
 
# Construct the post body
$post_body = @{}
if($TowerInventory -ne $null) {
    $inventory = Resolve-Tower-Asset -Name $TowerInventory -Url "$api_base/inventories"
    if($inventory -eq $null) { Fail-Step("Unable to resolve inventory") }
    $post_body.inventory = $inventory.id
}
 
if($TowerCredential -ne $null) {
    $credential = Resolve-Tower-Asset -Name $TowerCredential -Url "$api_base/credentials"
    if($credential -eq $null) { Fail-Step("Unable to resolve credential") }
    $post_body.credentials = @($credential.id)
}
if($TowerLimit -ne $null) { $post_body.limit = $TowerLimit }
if($TowerJobTags -ne $null) { $post_body.job_tags = $TowerJobTags }
# Older versions of Tower did not like receiveing "---" as extra vars.
if($TowerExtraVars -ne $null -and $TowerExtraVars -ne "---") { $post_body.extra_vars = $TowerExtraVars }
 
if($Verbose) { Write-Host "Requesting tower to run $TowerJobTemplate" }
if($TowerJobType -eq 'job') {
    $url = "$api_base/job_templates/$($template.id)/launch/"
} else {
    $url = "$api_base/workflow_job_templates/$($template.id)/launch/"
}
try {
    $response = Invoke-WebRequest -Uri $url -Method POST -Headers $auth_headers -Body ($post_body | ConvertTo-JSON) -ContentType "application/json" -UseBasicParsing
} catch {
    Write-Host "Failed to make request to invoke job"
    $initialError = $_
    try {
        $result = $_.Exception.Response.GetResponseStream()
        $reader = New-Object System.IO.StreamReader($result)
        $reader.BaseStream.Position = 0
        $reader.DiscardBufferedData()
        $body = $reader.ReadToEnd() | ConvertFrom-Json
        <#
            Some stuff that we might want to catch includes:
                {"extra_vars":["Must be valid JSON or YAML."]}
                {"variables_needed_to_start":["'my_var' value missing"]}
                {"credential":["Invalid pk \"999999\" - object does not exist."]}
                {"inventory":["Invalid pk \"99999999\" - object does not exist."]}
            The last two we don't really care about because we should never hit them
        #>
        if($body.extra_vars -ne $null) {
            Fail-Step "Failed to launch job: extra vars must be vailid JSON or YAML."
        } elseif($body.variables_needed_to_start -ne $null) {
            Fail-Step "Failed to launch job: $($body.variables_needed_to_start)"
        } else {
            Write-Host $body
            Fail-Step "Failed to launch job for an unknown reason"
        }
    } catch {
        Write-Host "Failed to get response body from request"
        Write-Host $initialError
    }
}


$template_id = $($response | ConvertFrom-Json).id
Write-Host("Best Guess Job URL: $tower_base/#/$($TowerJobType)s/$template_id")
 
# For whatever reason, this never fires
#$timer = new-object System.Timers.Timer
#$timer.Interval = 500 #1000 * $TowerTimeLimitInSeconds
#$action = { Fail-Step "Timed out waiting for Tower to complete tempate run. Template may still be running in Tower." }
#$tower_timer = Register-ObjectEvent -InputObject $timer -EventName Elapsed -Action $action
#$timer.AutoReset = $False
#$timer.Enabled = $True

if($TowerJobType -eq 'job') {
    $failed = Watch-Job-Complete -Id $template_id
} else {
    $failed = Watch-Workflow-Complete -Id $template_id
}

#$timer.Stop()
#Unregister-Event $tower_timer.Name


if($failed) {
    Fail-Step "Job Failed"
} else {
    Write-Host "Job Succeeded"
}

Provided under the Apache License version 2.0.

Report an issue

To use this template in Octopus Deploy, copy the JSON below and paste it into the Library → Step templates → Import dialog.

{
  "Id": "9a3d15aa-5a48-4922-9651-cf4ae75730dd",
  "Name": "Ansible Tower - Run Template",
  "Description": "Run a workflow or job template in Ansible Tower",
  "Version": 3,
  "ExportedAt": "2021-09-28T13:41:21.989Z",
  "ActionType": "Octopus.Script",
  "Author": "kdblitz",
  "Packages": [],
  "Parameters": [
    {
      "Id": "6da92f7b-3e6b-496c-9095-508556a2e69a",
      "Name": "TowerServer",
      "Label": "Tower Server",
      "HelpText": "The connection information of the Tower server to connect to. This can be a single host name or IP (https will be assumed) or a full specification like http://localhost:8081.",
      "DefaultValue": "tower.example.com",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "2e4148f9-73fd-430b-9ac4-d7aa0789b9d1",
      "Name": "TowerUsername",
      "Label": "Tower Username",
      "HelpText": "The user to connect to Tower as. Be sure this user has permissions to execute the Job/Workflow templates you are attempting to launch.",
      "DefaultValue": "admin",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "60f5667e-108a-4966-aaef-feb26e6739ad",
      "Name": "TowerPassword",
      "Label": "Tower Password",
      "HelpText": "The password for the specified user.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Sensitive"
      }
    },
    {
      "Id": "4f8baac7-0b3f-4ffe-af04-deff70484518",
      "Name": "TowerOAuthToken",
      "Label": "OAuth Token",
      "HelpText": "An alternative to username/password which can be used on newer versions of Tower.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Sensitive"
      }
    },
    {
      "Id": "ccf787b8-6589-4d59-9f0d-d964f0785252",
      "Name": "TowerJobType",
      "Label": "Template Type",
      "HelpText": "Select the type of template to execute. Supported template types are Workflow and Job",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Select",
        "Octopus.SelectOptions": "job|Job Template\nworkflow|Workflow Template"
      }
    },
    {
      "Id": "8b6c4e7c-65dd-40f8-8fa3-4371877083fb",
      "Name": "TowerJobTemplate",
      "Label": "Template Name",
      "HelpText": "The Job or Workflow template ID or name. If a name is specified the ID will attempt to be resolved.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "6a64ff26-9b22-414d-9995-d1723aafdbb1",
      "Name": "TowerExtraVars",
      "Label": "Extra Vars",
      "HelpText": "Extra variable to pass to the job. This can be either YAML or JSON.\n**NOTE:** prompt on launch must be set in your template for this setting to take affect.",
      "DefaultValue": "---",
      "DisplaySettings": {
        "Octopus.ControlType": "MultiLineText"
      }
    },
    {
      "Id": "f65ed9e0-2d50-4698-8e8e-3683ee6e0a41",
      "Name": "TowerJobTags",
      "Label": "Job Tags",
      "HelpText": "Any job tags to pass to Tower. \n**NOTE:** prompt on launch must be set in your template for this setting to take affect.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "fe800bf5-ef04-43c5-b73d-0c9ca00172a8",
      "Name": "TowerLimit",
      "Label": "Limit",
      "HelpText": "Limit field to be passed to Tower.\n**NOTE:** prompt on launch must be set in your template for this setting to take affect.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "8572d143-0ccd-4af4-b754-7df663f9e07a",
      "Name": "TowerInventory",
      "Label": "Inventory",
      "HelpText": "The inventory for the job run.\n**NOTE:** prompt on launch must be set in your template for this setting to take affect.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "08514a4e-f34c-41cb-94f4-c6ac82f46634",
      "Name": "TowerCredential",
      "Label": "Credential",
      "HelpText": "The credentials to use for this job.\n**NOTE:** prompt on launch must be set in your template for this setting to take affect.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "9691c6c0-ac17-4bcf-947d-38bbbf027ed3",
      "Name": "TowerImportLogs",
      "Label": "Import Tower Logs",
      "HelpText": "Pull the Tower logs back into the step output",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      }
    },
    {
      "Id": "7e8c6ea0-637f-4440-9669-55971bc1f825",
      "Name": "TowerSecondsBetweenChecks",
      "Label": "Second Between Checks",
      "HelpText": "How many seconds to pause between checks when monitoring a job. 0 means no checks. Failure to parse this field as an integer will default to 3 seconds.",
      "DefaultValue": "3",
      "DisplaySettings": {
        "Octopus.ControlType": "SingleLineText"
      }
    },
    {
      "Id": "7f8b11b1-3f4b-41eb-8cf2-38d4e984418e",
      "Name": "TowerIgnoreCert",
      "Label": "Ignore Certificate",
      "HelpText": "**This parameter is intended only for testing.** This tells the step to ignore any https certificate presented to it from the Tower server. Please understand the ramifications before enabling this option.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      }
    },
    {
      "Id": "4c94cabd-ef0f-4a21-8028-430b7a0465b8",
      "Name": "TowerVerbose",
      "Label": "Verbose",
      "HelpText": "Add additional details of what the plugin is doing.",
      "DefaultValue": "",
      "DisplaySettings": {
        "Octopus.ControlType": "Checkbox"
      }
    }
  ],
  "Properties": {
    "Octopus.Action.Script.ScriptSource": "Inline",
    "Octopus.Action.Script.Syntax": "PowerShell",
    "Octopus.Action.Script.ScriptBody": "# There have been reported issues when using the default JSON parser with Invoke-RestMethod\n# on PowerShell 5. So we are going to pull in a different assembly to do the parsing for us.\n# This parser appears to be more reliable.\n[System.Reflection.Assembly]::LoadWithPartialName(\"System.Web.Extensions\")\n$jsonParser = New-Object -TypeName System.Web.Script.Serialization.JavaScriptSerializer\n$jsonParser.MaxJsonLength = 104857600 #100mb as bytes, default is 2mb\n\nfunction Write-AnsibleLine([String] $text) {\n    # split text at ESC-char\n    $ansi_colors = @(\n        '[0;30m' #= @{ fg = ConsoleColor.Black }\n        '[0;31m' #= @{ fg = ConsoleColor.DarkRed }\n        '[0;32m' #= @{ fg = ConsoleColor.DarkGreen }\n        '[0;33m' #= @{ fg = ConsoleColor.DarkYellow }\n        '[0;34m' #= @{ fg = ConsoleColor.DarkBlue }\n        '[0;35m' #= @{ fg = ConsoleColor.DarkMagenta }\n        '[0;36m' #= @{ fg = ConsoleColor.DarkCyan }\n        '[0;37m' #= @{ fg = ConsoleColor.White }\n        '[0m' #= @{ fg = $null; bg = $null }\n        '[1;35m' #= Magent (ansible warnings)\n        '[30;1m' #= @{ fg = ConsoleColor.Grey }\n        '[31;1m' #= @{ fg = ConsoleColor.Red }\n        '[32;1m' #= @{ fg = ConsoleColor.Green }\n        '[33;1m' #= @{ fg = ConsoleColor.Yellow }\n        '[34;1m' #= @{ fg = ConsoleColor.Blue }\n        '[35;1m' #= @{ fg = ConsoleColor.Magenta }\n        '[36;1m' #= @{ fg = ConsoleColor.Cyan }\n        '[37;1m' #= @{ fg = ConsoleColor.White }\n        '[0;40m' #= @{ bg = ConsoleColor.Black }\n        '[0;41m' #= @{ bg = ConsoleColor.DarkRed }\n        '[0;42m' #= @{ bg = ConsoleColor.DarkGreen }\n        '[0;43m' #= @{ bg = ConsoleColor.DarkYellow }\n        '[0;44m' #= @{ bg = ConsoleColor.DarkBlue }\n        '[0;45m' #= @{ bg = ConsoleColor.DarkMagenta }\n        '[0;46m' #= @{ bg = ConsoleColor.DarkCyan }\n        '[0;47m' #= @{ bg = ConsoleColor.White }\n        '[40;1m' #= @{ bg = ConsoleColor.DarkGrey }\n        '[41;1m' #= @{ bg = ConsoleColor.Red }\n        '[42;1m' #= @{ bg = ConsoleColor.Green }\n        '[43;1m' #= @{ bg = ConsoleColor.Yellow }\n        '[44;1m' #= @{ bg = ConsoleColor.Blue }\n        '[45;1m' #= @{ bg = ConsoleColor.Magenta }\n        '[46;1m' #= @{ bg = ConsoleColor.Cyan }\n        '[47;1m' #= @{ bg = ConsoleColor.White }\n    )\n    foreach ($segment in $text.split([char] 27)) {\n        foreach($code in $ansi_colors) {\n            if($segment.startswith($code)) {\n                $segment = $segment.replace($code, \"\")\n            }\n        }\n        Write-Host -NoNewline $segment\n    }\n    Write-Host \"\"\n}\n \n \nFunction Resolve-Tower-Asset{\n    Param($Name, $Url)\n    Process {\n        if($script:Verbose) { Write-Host \"Resolving name $Name\" }\n        $object = $null\n        if($Name -match '^[0-9]+$') {\n            if($script:Verbose) { Write-Host \"Using $Name as ID as its an int already\" }\n            $url = \"$Url/$Name/\"\n            try { $object = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object]) }\n            catch {\n                Write-Host \"Error when resolving ID for $Name\"\n                Write-Host $_\n                return $null\n            }\n        } else {\n           if($script:Verbose) { Write-Host \"Looking up ID of name $Name\" }\n            $url = \"$Url/?name=$Name\"\n            try { $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object]) }\n            catch {\n                Write-Host \"Unable to resolve name $Name\"\n                Write-Host $_\n                return $null\n            }\n            if($response.count -eq 0) {\n                Write-Host \"Got no results when trying to get ID for $Name\"\n                return $null\n            } elseif($response.count -ne 1) {\n                Write-Host \"Did not get a unique job ID for job name $Name\"\n                return $null\n            }\n            if($script:Verbose) { Write-Host \"Resolved to ID $($response.results[0].id)\" }\n            $object = $response.results[0]\n        }\n        return $object\n    }\n}\n\n\nfunction Get-Auth-Headers {\n    # If we did not get a TowerOAuthToken or a (TowerUsername and TowerPassword) then we can't even try to auth\n    if(-not (($TowerUsername -and $TowerPassword) -or $TowerOAuthToken)) {\n        Fail-Step \"Please pass an OAuth Token and or a Username/Password to authenticate to Tower with\"\n    }\n\n    if($TowerOAuthToken) {\n        if($verbose) { Write-Host \"Testing OAuth token\" }\n        $token_headers = @{ \"Authorization\" = \"Bearer $TowerOAuthToken\" }\n        try {\n            # We have to assign it to something or we get a line in the output\n            $junk = $jsonParser.Deserialize((Invoke-WebRequest \"$api_base/job_templates/?name=Octopus\" -Method GET -Headers $token_headers -UseBasicParsing), [System.Object])\n            $script:auth_headers = $token_headers\n            return\n        } catch {\n            Write-Host \"Unable to authenticate to the Tower server with OAuth token\"\n            Write-Host $_\n        }\n    }\n\n    if(-not ($TowerUsername -and $TowerPassword)) {\n        Fail-Step \"No username/password to fall back on\"\n    }\n\n    if($verbose) { Write-Host \"Testing basic auth\" }\n    $pair = \"${TowerUsername}:${TowerPassword}\"\n    $bytes = [System.Text.Encoding]::ASCII.GetBytes($pair)\n    $base64 = [System.Convert]::ToBase64String($bytes)\n    $basic_auth_value = \"Basic $base64\"\n    $headers = @{ \"Authorization\" = $basic_auth_value }\n    try {\n        # We have to assign it to something or we get a line in the output\n        $junk = $jsonParser.Deserialize((Invoke-WebRequest \"$api_base/job_templates/?name=Octopus\" -Method GET -Headers $headers -UseBasicParsing), [System.Object])\n        $script:auth_headers = $headers\n    } catch {\n        Write-Host $_\n        Fail-Step \"Username password combination failed to work\"\n    }\n\n    if ($script:Verbose) { Write-Host \"Attempting to get authentcation Token for $TowerUsername\" }\n    $body = @{\n        username = $TowerUsername\n        password = $TowerPassword\n    } | ConvertTo-Json\n    $url = \"$api_base/authtoken/\"\n    try {\n        $auth_token = $jsonParser.Deserialize((Invoke-WebRequest $url -Method POST -Headers $headers -Body $body -ContentType \"application/json\" -UseBasicParsing), [System.Object])\n        $script:auth_headers = @{ Authorization = \"Token $($auth_token.token)\" }\n        return\n    } catch {\n        if($_.Exception.Response.StatusCode -eq 404) {\n            Write-Host(\">>> Server does not support authtoken, try using an OAuth Token\")\n            Write-Host(\">>> Defaulting to perpetual basic auth. This can be slow for authentication with external sources\")\n            return\n        } else {\n            Write-Host $_\n            Fail-Step \"Unable to authenticate to the Tower server for Auth token\"\n        }\n    }\n}\n\nfunction Watch-Job-Complete {\n    Param($Id)\n    Process {\n        $last_log_id = 0\n        while($True) {\n            # First log any events if the user wants them\n            if($TowerImportLogs) {\n                $url = \"$api_base/jobs/$Id/job_events/?id__gt=$last_log_id\"\n                $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object])\n                foreach($result in $response.results) {\n                    if($last_log_id -lt $result.id) { $last_log_id = $result.id }\n                    if($result.event_data -and $result.event_data.res -and $result.event_data.res.output) {\n                        foreach($line in $result.event_data.res.output) {\n                            Write-AnsibleLine($line)\n                        }\n                    } else {\n                        $line = $result.stdout\n                        Write-AnsibleLine($line)\n                    }\n                }\n            }\n \n            # Now check the status of the job\n            $url = \"$api_base/jobs/$Id/\"\n            $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object])\n            if($response.finished) {\n               $response.failed\n               return\n            } else {\n               Start-Sleep -s $SecondsBetweenChecks\n            }\n        }\n    }\n}\n \nfunction Watch-Workflow-Complete {\n    Param($Id)\n    Process {\n        $workflow_node_id = 0\n        while($True) {\n            # Check to see if there are any jobs we need to follow\n            $url = \"$tower_base/api/v2/workflow_jobs/$Id/workflow_nodes/?id__gt=$workflow_node_id\"\n            $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object])\n\n            # If there are no nodes whose ID is > the last one we looked at we can see if we are complete\n            if($response.count -eq 0) {\n                $url = \"$tower_base/api/v2/workflow_jobs/$Id/\"\n                $response = $jsonParser.Deserialize((Invoke-WebRequest $url -Method GET -Headers $script:auth_headers -UseBasicParsing), [System.Object])\n                if($response.finished) {\n                    $response.failed\n                    return\n                } else {\n                    Start-Sleep -s $SecondsBetweenChecks\n                }\n            } else {\n                foreach($result in $response.results) {\n                    if($result.summary_fields.unified_job_template.unified_job_type -eq 'job') {\n                        $job_id = $result.summary_fields.job.id\n                        if(-not $job_id) {\n                            # This is a job but it hasn't started yet, lets sleep and try again\n                            Start-Sleep -s $SecondsBetweenChecks\n                            break\n                        }\n                        if($script:Verbose) { Write-Host \"Monitoring job $($result.summary_fields.job.name)\" }\n                        # We have to trap the return of Watch-Job-Complete\n                        $junk = Watch-Job-Complete -Id $job_id\n                    } else {\n                        if($script:Verbose) { Write-Host \"Not pulling logs for node $($result.id) which is a $($result.summary_fields.unified_job_template.unified_job_type)\" }\n                    }\n                    $workflow_node_id = $result.id\n                }\n            }\n        }\n    }\n}\n\n\n\n##### Main Body\n\n\n\n\n# Check that we got a TowerJobTemplate, without one we can't do anything\nif(-not ($TowerJobType -eq \"job\" -or $TowerJobType -eq \"workflow\")) { Fail-Stop \"The job type must be either job or workflow\" }\nif($TowerJobTemplate -eq $null -or $TowerJobTemplate -eq \"\") { Fail-Step \"A Job Name needs to be specified\" }\nif($TowerJobTags -eq \"\") { $TowerJobTags = $null }\nif($TowerExtraVars -eq \"\") { $TowerExtraVars = $null }\nif($TowerLimit -eq \"\") { $TowerLimit = $null }\nif($TowerInventory -eq \"\") { $TowerInventory = $null }\nif($TowerCredential -eq \"\") { $Credential = $null }\nif($TowerImportLogs -and $TowerImportLogs -eq \"True\") { $TowerImportLogs = $True } else { $TowerImportLogs = $False }\nif($TowerVerbose -and $TowerVerbose -eq \"True\") { $Verbose = $True} else { $Verbose = $False }\nif($TowerSecondsBetweenChecks) {\n    try { $SecondsBetweenChecks = [int]$TowerSecondsBetweenChecks }\n    catch {\n        write-Host \"Failed to parse $TowerSecondsBetweenChecks as integer, defaulting to 3\"\n        $SecondsBetweenChecks = 3\n    }\n} else {\n    $SecondsBetweenChecks = 3\n}\nif($TowerTimeLimitInSeconds) {\n    try { $TowerTimeLimitInSeconds = [int]$TowerTimeLimitInSeconds }\n    catch {\n        write-Host \"Failed to parse $TowerTimeLimitInSeconds as integer, defaulting to 600\"\n        $TowerTimeLimitInSeconds = 600\n    }\n} else {\n    $TowerTimeLimitInSeconds = 600\n}\nif($TowerIgnoreCert -and $TowerIgnoreCert -eq \"True\") {\n    # Joyfully borrowed from Markus Kraus' post on\n    # https://blog.ukotic.net/2017/08/15/could-not-establish-trust-relationship-for-the-ssltls-invoke-webrequest/\n    if(-not([System.Management.Automation.PSTypeName]'TrustAllCertsPolicy').Type) {\n        Add-Type @\"\n            using System.Net;\n            using System.Security.Cryptography.X509Certificates;\n            public class TrustAllCertsPolicy : ICertificatePolicy {\n                public bool CheckValidationResult(ServicePoint srvPoint, X509Certificate certificate, WebRequest request, int certificateProblem) {\n                    return true;\n                }\n            }\n\"@\n        [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCertsPolicy\n        [System.Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12\n    }\n    #endregion\n}\n \nif ($Verbose) { Write-Host \"Beginning Ansible Tower Run on $TowerServer\" }\n$tower_url = [System.Uri]$TowerServer\nif(-not $tower_url.Scheme) { $tower_url = [System.Uri]\"https://$TowerServer\" }\n$tower_base = $tower_url.ToString().TrimEnd(\"/\")\n$api_base = \"${tower_base}/api/v2\"\n \n# First handle authentication\n#   If we have a TowerOAuthToken try using that\n#   Else get an authentication token if we have a user name/password\n$auth_headers = @{'initial' = 'Data'}\nGet-Auth-Headers\n \n \n# If the TowerJobTemplate is actually an ID we can just use that.\n# If not we need to lookup the ID from the name\nif($TowerJobType -eq 'job') {\n    $template = Resolve-Tower-Asset -Name $TowerJobTemplate -Url \"$api_base/job_templates\"\n} else {\n    $template = Resolve-Tower-Asset -Name $TowerJobTemplate -Url \"$api_base/workflow_job_templates\"\n}\nif($template -eq $null) { Fail-Step \"Unable to resolve the job name\" }\n \nif($TowerExtraVars -ne $null -and $TowerExtraVars -ne '---' -and $template.ask_variables_on_launch -eq $False) {\n    Write-Warning \"Extra variables defined but prompt for variables on launch is not set in tower job\"\n}\nif($TowerLimit -ne $null -and $template.ask_limit_on_launch -eq $False) {\n    Write-Warning \"Limit defined but prompt for limit on launch is not set in tower job\"\n}\nif($TowerJobTags -ne $null -and $template.ask_tags_on_launch -eq $False) {\n    Write-Warning \"Job Tags defined but prompt for tags on launch is not set in tower job\"\n}\nif($TowerInventory -ne $null -and $template.ask_inventory_on_launch -eq $False) {\n    Write-Warning \"Inventory defined but prompt for inventory on launch is not set in tower job\"\n}\nif($TowerCredential -ne $null -and $template.ask_credential_on_launch -eq $False) {\n    Write-Warning \"Credential defined but prompt for credential on launch is not set in tower job\"\n}\n<#\n// Here are some more options we may want to use/check someday\n//    \"ask_diff_mode_on_launch\": false,\n//    \"ask_skip_tags_on_launch\": false,\n//    \"ask_job_type_on_launch\": false,\n//    \"ask_verbosity_on_launch\": false,\n#>\n \n \n# Construct the post body\n$post_body = @{}\nif($TowerInventory -ne $null) {\n    $inventory = Resolve-Tower-Asset -Name $TowerInventory -Url \"$api_base/inventories\"\n    if($inventory -eq $null) { Fail-Step(\"Unable to resolve inventory\") }\n    $post_body.inventory = $inventory.id\n}\n \nif($TowerCredential -ne $null) {\n    $credential = Resolve-Tower-Asset -Name $TowerCredential -Url \"$api_base/credentials\"\n    if($credential -eq $null) { Fail-Step(\"Unable to resolve credential\") }\n    $post_body.credentials = @($credential.id)\n}\nif($TowerLimit -ne $null) { $post_body.limit = $TowerLimit }\nif($TowerJobTags -ne $null) { $post_body.job_tags = $TowerJobTags }\n# Older versions of Tower did not like receiveing \"---\" as extra vars.\nif($TowerExtraVars -ne $null -and $TowerExtraVars -ne \"---\") { $post_body.extra_vars = $TowerExtraVars }\n \nif($Verbose) { Write-Host \"Requesting tower to run $TowerJobTemplate\" }\nif($TowerJobType -eq 'job') {\n    $url = \"$api_base/job_templates/$($template.id)/launch/\"\n} else {\n    $url = \"$api_base/workflow_job_templates/$($template.id)/launch/\"\n}\ntry {\n    $response = Invoke-WebRequest -Uri $url -Method POST -Headers $auth_headers -Body ($post_body | ConvertTo-JSON) -ContentType \"application/json\" -UseBasicParsing\n} catch {\n    Write-Host \"Failed to make request to invoke job\"\n    $initialError = $_\n    try {\n        $result = $_.Exception.Response.GetResponseStream()\n        $reader = New-Object System.IO.StreamReader($result)\n        $reader.BaseStream.Position = 0\n        $reader.DiscardBufferedData()\n        $body = $reader.ReadToEnd() | ConvertFrom-Json\n        <#\n            Some stuff that we might want to catch includes:\n                {\"extra_vars\":[\"Must be valid JSON or YAML.\"]}\n                {\"variables_needed_to_start\":[\"'my_var' value missing\"]}\n                {\"credential\":[\"Invalid pk \\\"999999\\\" - object does not exist.\"]}\n                {\"inventory\":[\"Invalid pk \\\"99999999\\\" - object does not exist.\"]}\n            The last two we don't really care about because we should never hit them\n        #>\n        if($body.extra_vars -ne $null) {\n            Fail-Step \"Failed to launch job: extra vars must be vailid JSON or YAML.\"\n        } elseif($body.variables_needed_to_start -ne $null) {\n            Fail-Step \"Failed to launch job: $($body.variables_needed_to_start)\"\n        } else {\n            Write-Host $body\n            Fail-Step \"Failed to launch job for an unknown reason\"\n        }\n    } catch {\n        Write-Host \"Failed to get response body from request\"\n        Write-Host $initialError\n    }\n}\n\n\n$template_id = $($response | ConvertFrom-Json).id\nWrite-Host(\"Best Guess Job URL: $tower_base/#/$($TowerJobType)s/$template_id\")\n \n# For whatever reason, this never fires\n#$timer = new-object System.Timers.Timer\n#$timer.Interval = 500 #1000 * $TowerTimeLimitInSeconds\n#$action = { Fail-Step \"Timed out waiting for Tower to complete tempate run. Template may still be running in Tower.\" }\n#$tower_timer = Register-ObjectEvent -InputObject $timer -EventName Elapsed -Action $action\n#$timer.AutoReset = $False\n#$timer.Enabled = $True\n\nif($TowerJobType -eq 'job') {\n    $failed = Watch-Job-Complete -Id $template_id\n} else {\n    $failed = Watch-Workflow-Complete -Id $template_id\n}\n\n#$timer.Stop()\n#Unregister-Event $tower_timer.Name\n\n\nif($failed) {\n    Fail-Step \"Job Failed\"\n} else {\n    Write-Host \"Job Succeeded\"\n}\n"
  },
  "Category": "Ansible",
  "HistoryUrl": "https://github.com/OctopusDeploy/Library/commits/master/step-templates//opt/buildagent/work/75443764cd38076d/step-templates/ansible-tower-run-template.json",
  "Website": "/step-templates/9a3d15aa-5a48-4922-9651-cf4ae75730dd",
  "Logo": "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",
  "$Meta": {
    "Type": "ActionTemplate"
  }
}

History

Page updated on Tuesday, September 28, 2021