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.
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"
}
}
Page updated on Tuesday, September 28, 2021