mirror of https://github.com/ansible/ansible.git
Overhaul ansible-test container management.
This brings ansible-test closer to being able to support split controller/remote testing.pull/74265/head
parent
9f856a4964
commit
b752d07163
@ -0,0 +1,40 @@
|
||||
major_changes:
|
||||
- ansible-test - SSH port forwarding and redirection is now used exclusively to make container ports available on non-container hosts.
|
||||
When testing on POSIX systems this requires SSH login as root.
|
||||
Previously SSH port forwarding was combined with firewall rules or other port redirection methods, with some platforms being unsupported.
|
||||
- ansible-test - All "cloud" plugins which use containers can now be used with all POSIX and Windows hosts.
|
||||
Previously the plugins did not work with Windows at all, and support for hosts created with the ``--remote`` option was inconsistent.
|
||||
- ansible-test - Most container features are now supported under Podman.
|
||||
Previously a symbolic link for ``docker`` pointing to ``podman`` was required.
|
||||
minor_changes:
|
||||
- ansible-test - All "cloud" plugins have been refactored for more consistency.
|
||||
For those that use docker containers, management of the containers has been standardized.
|
||||
- ansible-test - All "cloud" plugins now use fixed hostnames and ports in tests.
|
||||
Previously some tests used IP addresses and/or randomly assigned ports.
|
||||
- ansible-test - The HTTP Tester has been converted to a "cloud" plugin and can now be requested using the ``cloud/httptester`` alias.
|
||||
The original ``needs/httptester`` alias is still supported for backwards compatibility.
|
||||
- ansible-test - The HTTP Tester can now be used without the ``--docker`` or `--remote`` options.
|
||||
It still requires use of the ``docker`` command to run the container.
|
||||
- ansible-test - The ``docker run`` option ``--link`` is no longer used to connect test containers.
|
||||
As a result, changes are made to the ``/etc/hosts`` file as needed on all test containers.
|
||||
Previously containers which were used with the ``--link`` option did not require changes to the ``/etc/hosts`` file.
|
||||
- ansible-test - Changes made to the ``hosts`` file on test systems are now done using an Ansible playbook for both POSIX and Windows systems.
|
||||
Changes are applied before a test target runs and are reverted after the test target finishes.
|
||||
- ansible-test - Environment variables exposed by "cloud" plugins are now available to the controller for role based tests.
|
||||
Previously only script based tests had access to the exposed environment variables.
|
||||
breaking_changes:
|
||||
- ansible-test - The ``--httptester`` option is no longer available.
|
||||
To override the container used for HTTP Tester tests, set the ``ANSIBLE_HTTP_TEST_CONTAINER`` environment variable instead.
|
||||
- ansible-test - The ``--disable-httptester`` option is no longer available.
|
||||
The HTTP Tester is no longer optional for tests that specify it.
|
||||
- ansible-test - The HTTP Tester is no longer available with the ``ansible-test shell`` command.
|
||||
Only the ``integration`` and ``windows-integration`` commands provide HTTP Tester.
|
||||
bugfixes:
|
||||
- ansible-test - Running tests in a single test run with multiple "cloud" plugins no longer results in port conflicts.
|
||||
Previously two or more containers with overlapping ports could not be used in the same test run.
|
||||
- ansible-test - Random port selection is no longer handled by ``ansible-test``, avoiding possible port conflicts.
|
||||
Previously ``ansible-test`` would, under some circumstances, use one host's available ports to determine those of another host.
|
||||
- ansible-test - The ``docker inspect`` command is now used to check for existing images instead of the ``docker images`` command.
|
||||
This resolves an issue where a ``docker pull`` would be unnecessarily executed for an image referenced by checksum.
|
||||
- ansible-test - Failure to download test results from a remote host no longer hide test failures.
|
||||
If a download failure occurs after tests fail, a warning will be issued instead.
|
@ -0,0 +1,2 @@
|
||||
cloud/acme
|
||||
shippable/generic/group1
|
@ -0,0 +1,7 @@
|
||||
- name: Verify endpoints respond
|
||||
uri:
|
||||
url: "{{ item }}"
|
||||
validate_certs: no
|
||||
with_items:
|
||||
- http://{{ acme_host }}:5000/
|
||||
- https://{{ acme_host }}:14000/dir
|
@ -0,0 +1,2 @@
|
||||
cloud/cs
|
||||
shippable/generic/group1
|
@ -0,0 +1,8 @@
|
||||
- name: Verify endpoints respond
|
||||
uri:
|
||||
url: "{{ item }}"
|
||||
validate_certs: no
|
||||
register: this
|
||||
failed_when: "this.status != 401" # authentication is required, but not provided (requests must be signed)
|
||||
with_items:
|
||||
- "{{ ansible_env.CLOUDSTACK_ENDPOINT }}"
|
@ -0,0 +1,2 @@
|
||||
cloud/foreman
|
||||
shippable/generic/group1
|
@ -0,0 +1,6 @@
|
||||
- name: Verify endpoints respond
|
||||
uri:
|
||||
url: "{{ item }}"
|
||||
validate_certs: no
|
||||
with_items:
|
||||
- http://{{ ansible_env.FOREMAN_HOST }}:{{ ansible_env.FOREMAN_PORT }}/ping
|
@ -0,0 +1,3 @@
|
||||
shippable/galaxy/group1
|
||||
shippable/galaxy/smoketest
|
||||
cloud/galaxy
|
@ -0,0 +1,25 @@
|
||||
# The pulp container has a long start up time.
|
||||
# The first task to interact with pulp needs to wait until it responds appropriately.
|
||||
- name: Wait for Pulp API
|
||||
uri:
|
||||
url: '{{ pulp_api }}/pulp/api/v3/distributions/ansible/ansible/'
|
||||
user: '{{ pulp_user }}'
|
||||
password: '{{ pulp_password }}'
|
||||
force_basic_auth: true
|
||||
register: this
|
||||
until: this is successful
|
||||
delay: 1
|
||||
retries: 60
|
||||
|
||||
- name: Verify Galaxy NG server
|
||||
uri:
|
||||
url: "{{ galaxy_ng_server }}"
|
||||
user: '{{ pulp_user }}'
|
||||
password: '{{ pulp_password }}'
|
||||
force_basic_auth: true
|
||||
|
||||
- name: Verify Pulp server
|
||||
uri:
|
||||
url: "{{ pulp_server }}"
|
||||
status_code:
|
||||
- 404 # endpoint responds without authentication
|
@ -0,0 +1,3 @@
|
||||
cloud/httptester
|
||||
windows
|
||||
shippable/windows/group1
|
@ -0,0 +1,15 @@
|
||||
- name: Verify HTTPTESTER environment variable
|
||||
assert:
|
||||
that:
|
||||
- "lookup('env', 'HTTPTESTER') == '1'"
|
||||
|
||||
- name: Verify endpoints respond
|
||||
ansible.windows.win_uri:
|
||||
url: "{{ item }}"
|
||||
validate_certs: no
|
||||
with_items:
|
||||
- http://ansible.http.tests/
|
||||
- https://ansible.http.tests/
|
||||
- https://sni1.ansible.http.tests/
|
||||
- https://fail.ansible.http.tests/
|
||||
- https://self-signed.ansible.http.tests/
|
@ -0,0 +1,2 @@
|
||||
needs/httptester # using legacy alias for testing purposes
|
||||
shippable/posix/group1
|
@ -0,0 +1,15 @@
|
||||
- name: Verify HTTPTESTER environment variable
|
||||
assert:
|
||||
that:
|
||||
- "lookup('env', 'HTTPTESTER') == '1'"
|
||||
|
||||
- name: Verify endpoints respond
|
||||
uri:
|
||||
url: "{{ item }}"
|
||||
validate_certs: no
|
||||
with_items:
|
||||
- http://ansible.http.tests/
|
||||
- https://ansible.http.tests/
|
||||
- https://sni1.ansible.http.tests/
|
||||
- https://fail.ansible.http.tests/
|
||||
- https://self-signed.ansible.http.tests/
|
@ -0,0 +1,2 @@
|
||||
cloud/nios
|
||||
shippable/generic/group1
|
@ -0,0 +1,10 @@
|
||||
- name: Verify endpoints respond
|
||||
uri:
|
||||
url: "{{ item }}"
|
||||
url_username: "{{ nios_provider.username }}"
|
||||
url_password: "{{ nios_provider.password }}"
|
||||
validate_certs: no
|
||||
register: this
|
||||
failed_when: "this.status != 404" # authentication succeeded, but the requested path was not found
|
||||
with_items:
|
||||
- https://{{ nios_provider.host }}/
|
@ -0,0 +1,3 @@
|
||||
cloud/openshift
|
||||
shippable/generic/group1
|
||||
disabled # disabled due to requirements conflict: botocore 1.20.6 has requirement urllib3<1.27,>=1.25.4, but you have urllib3 1.24.3.
|
@ -0,0 +1,6 @@
|
||||
- name: Verify endpoints respond
|
||||
uri:
|
||||
url: "{{ item }}"
|
||||
validate_certs: no
|
||||
with_items:
|
||||
- https://openshift-origin:8443/
|
@ -0,0 +1,2 @@
|
||||
cloud/vcenter
|
||||
shippable/generic/group1
|
@ -0,0 +1,6 @@
|
||||
- name: Verify endpoints respond
|
||||
uri:
|
||||
url: "{{ item }}"
|
||||
validate_certs: no
|
||||
with_items:
|
||||
- http://{{ vcenter_hostname }}:5000/ # control endpoint for the simulator
|
@ -0,0 +1,8 @@
|
||||
- hosts: all
|
||||
gather_facts: no
|
||||
tasks:
|
||||
- name: Add container hostname(s) to hosts file
|
||||
blockinfile:
|
||||
path: /etc/hosts
|
||||
block: "{{ '\n'.join(hosts_entries) }}"
|
||||
unsafe_writes: yes
|
@ -0,0 +1,9 @@
|
||||
- hosts: all
|
||||
gather_facts: no
|
||||
tasks:
|
||||
- name: Remove container hostname(s) from hosts file
|
||||
blockinfile:
|
||||
path: /etc/hosts
|
||||
block: "{{ '\n'.join(hosts_entries) }}"
|
||||
unsafe_writes: yes
|
||||
state: absent
|
@ -0,0 +1,34 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Add one or more hosts entries to the Windows hosts file.
|
||||
|
||||
.PARAMETER Hosts
|
||||
A list of hosts entries, delimited by '|'.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][String]$Hosts
|
||||
)
|
||||
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Verbose -Message "Adding host file entries"
|
||||
|
||||
$hosts_entries = $Hosts.Split('|')
|
||||
$hosts_file = "$env:SystemRoot\System32\drivers\etc\hosts"
|
||||
$hosts_file_lines = [System.IO.File]::ReadAllLines($hosts_file)
|
||||
$changed = $false
|
||||
|
||||
foreach ($entry in $hosts_entries) {
|
||||
if ($entry -notin $hosts_file_lines) {
|
||||
$hosts_file_lines += $entry
|
||||
$changed = $true
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
Write-Verbose -Message "Host file is missing entries, adding missing entries"
|
||||
[System.IO.File]::WriteAllLines($hosts_file, $hosts_file_lines)
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
- hosts: all
|
||||
gather_facts: no
|
||||
tasks:
|
||||
- name: Add container hostname(s) to hosts file
|
||||
script:
|
||||
cmd: "\"{{ playbook_dir }}/windows_hosts_prepare.ps1\" -Hosts \"{{ '|'.join(hosts_entries) }}\""
|
@ -0,0 +1,37 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Remove one or more hosts entries from the Windows hosts file.
|
||||
|
||||
.PARAMETER Hosts
|
||||
A list of hosts entries, delimited by '|'.
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][String]$Hosts
|
||||
)
|
||||
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
Write-Verbose -Message "Removing host file entries"
|
||||
|
||||
$hosts_entries = $Hosts.Split('|')
|
||||
$hosts_file = "$env:SystemRoot\System32\drivers\etc\hosts"
|
||||
$hosts_file_lines = [System.IO.File]::ReadAllLines($hosts_file)
|
||||
$changed = $false
|
||||
|
||||
$new_lines = [System.Collections.ArrayList]@()
|
||||
|
||||
foreach ($host_line in $hosts_file_lines) {
|
||||
if ($host_line -in $hosts_entries) {
|
||||
$changed = $true
|
||||
} else {
|
||||
$new_lines += $host_line
|
||||
}
|
||||
}
|
||||
|
||||
if ($changed) {
|
||||
Write-Verbose -Message "Host file has extra entries, removing extra entries"
|
||||
[System.IO.File]::WriteAllLines($hosts_file, $new_lines)
|
||||
}
|
@ -0,0 +1,6 @@
|
||||
- hosts: all
|
||||
gather_facts: no
|
||||
tasks:
|
||||
- name: Remove container hostname(s) from hosts file
|
||||
script:
|
||||
cmd: "\"{{ playbook_dir }}/windows_hosts_restore.ps1\" -Hosts \"{{ '|'.join(hosts_entries) }}\""
|
@ -1,229 +0,0 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Designed to set a Windows host to connect to the httptester container running
|
||||
on the Ansible host. This will setup the Windows host file and forward the
|
||||
local ports to use this connection. This will continue to run in the background
|
||||
until the script is deleted.
|
||||
|
||||
Run this with SSH with the -R arguments to forward ports 8080, 8443 and 8444 to the
|
||||
httptester container.
|
||||
|
||||
.PARAMETER Hosts
|
||||
A list of hostnames, delimited by '|', to add to the Windows hosts file for the
|
||||
httptester container, e.g. 'ansible.host.com|secondary.host.test'.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)][String]$Hosts
|
||||
)
|
||||
$Hosts = $Hosts.Split('|')
|
||||
|
||||
$ProgressPreference = "SilentlyContinue"
|
||||
$ErrorActionPreference = "Stop"
|
||||
$os_version = [Version](Get-Item -Path "$env:SystemRoot\System32\kernel32.dll").VersionInfo.ProductVersion
|
||||
Write-Verbose -Message "Configuring HTTP Tester on Windows $os_version for '$($Hosts -join "', '")'"
|
||||
|
||||
Function Get-PmapperRuleBytes {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Create the byte values that configures a rule in the PMapper configuration
|
||||
file. This isn't really documented but because PMapper is only used for
|
||||
Server 2008 R2 we will stick to 1 version and just live with the legacy
|
||||
work for now.
|
||||
|
||||
.PARAMETER ListenPort
|
||||
The port to listen on localhost, this will be forwarded to the host defined
|
||||
by ConnectAddress and ConnectPort.
|
||||
|
||||
.PARAMETER ConnectAddress
|
||||
The hostname or IP to map the traffic to.
|
||||
|
||||
.PARAMETER ConnectPort
|
||||
This port of ConnectAddress to map the traffic to.
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory=$true)][UInt16]$ListenPort,
|
||||
[Parameter(Mandatory=$true)][String]$ConnectAddress,
|
||||
[Parameter(Mandatory=$true)][Int]$ConnectPort
|
||||
)
|
||||
|
||||
$connect_field = "$($ConnectAddress):$ConnectPort"
|
||||
$connect_bytes = [System.Text.Encoding]::ASCII.GetBytes($connect_field)
|
||||
$data_length = [byte]($connect_bytes.Length + 6) # size of payload minus header, length, and footer
|
||||
$port_bytes = [System.BitConverter]::GetBytes($ListenPort)
|
||||
|
||||
$payload = [System.Collections.Generic.List`1[Byte]]@()
|
||||
$payload.Add([byte]16) > $null # header is \x10, means Configure Mapping rule
|
||||
$payload.Add($data_length) > $null
|
||||
$payload.AddRange($connect_bytes)
|
||||
$payload.AddRange($port_bytes)
|
||||
$payload.AddRange([byte[]]@(0, 0)) # 2 extra bytes of padding
|
||||
$payload.Add([byte]0) > $null # 0 is TCP, 1 is UDP
|
||||
$payload.Add([byte]0) > $null # 0 is Any, 1 is Internet
|
||||
$payload.Add([byte]31) > $null # footer is \x1f, means end of Configure Mapping rule
|
||||
|
||||
return ,$payload.ToArray()
|
||||
}
|
||||
|
||||
Write-Verbose -Message "Adding host file entries"
|
||||
$hosts_file = "$env:SystemRoot\System32\drivers\etc\hosts"
|
||||
$hosts_file_lines = [System.IO.File]::ReadAllLines($hosts_file)
|
||||
$changed = $false
|
||||
foreach ($httptester_host in $Hosts) {
|
||||
$host_line = "127.0.0.1 $httptester_host # ansible-test httptester"
|
||||
if ($host_line -notin $hosts_file_lines) {
|
||||
$hosts_file_lines += $host_line
|
||||
$changed = $true
|
||||
}
|
||||
}
|
||||
if ($changed) {
|
||||
Write-Verbose -Message "Host file is missing entries, adding missing entries"
|
||||
[System.IO.File]::WriteAllLines($hosts_file, $hosts_file_lines)
|
||||
}
|
||||
|
||||
# forward ports
|
||||
$forwarded_ports = @{
|
||||
80 = 8080
|
||||
443 = 8443
|
||||
444 = 8444
|
||||
}
|
||||
if ($os_version -ge [Version]"6.2") {
|
||||
Write-Verbose -Message "Using netsh to configure forwarded ports"
|
||||
foreach ($forwarded_port in $forwarded_ports.GetEnumerator()) {
|
||||
$port_set = netsh interface portproxy show v4tov4 | `
|
||||
Where-Object { $_ -match "127.0.0.1\s*$($forwarded_port.Key)\s*127.0.0.1\s*$($forwarded_port.Value)" }
|
||||
|
||||
if (-not $port_set) {
|
||||
Write-Verbose -Message "Adding netsh portproxy rule for $($forwarded_port.Key) -> $($forwarded_port.Value)"
|
||||
$add_args = @(
|
||||
"interface",
|
||||
"portproxy",
|
||||
"add",
|
||||
"v4tov4",
|
||||
"listenaddress=127.0.0.1",
|
||||
"listenport=$($forwarded_port.Key)",
|
||||
"connectaddress=127.0.0.1",
|
||||
"connectport=$($forwarded_port.Value)"
|
||||
)
|
||||
$null = netsh $add_args 2>&1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Verbose -Message "Using Port Mapper to configure forwarded ports"
|
||||
# netsh interface portproxy doesn't work on local addresses in older
|
||||
# versions of Windows. Use custom application Port Mapper to acheive the
|
||||
# same outcome
|
||||
# http://www.analogx.com/contents/download/Network/pmapper/Freeware.htm
|
||||
$s3_url = "https://ansible-ci-files.s3.amazonaws.com/ansible-test/pmapper-1.04.exe"
|
||||
|
||||
# download the Port Mapper executable to a temporary directory
|
||||
$pmapper_folder = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ([System.IO.Path]::GetRandomFileName())
|
||||
$pmapper_exe = Join-Path -Path $pmapper_folder -ChildPath pmapper.exe
|
||||
$pmapper_config = Join-Path -Path $pmapper_folder -ChildPath pmapper.dat
|
||||
New-Item -Path $pmapper_folder -ItemType Directory > $null
|
||||
|
||||
$stop = $false
|
||||
do {
|
||||
try {
|
||||
Write-Verbose -Message "Attempting download of '$s3_url'"
|
||||
(New-Object -TypeName System.Net.WebClient).DownloadFile($s3_url, $pmapper_exe)
|
||||
$stop = $true
|
||||
} catch { Start-Sleep -Second 5 }
|
||||
} until ($stop)
|
||||
|
||||
# create the Port Mapper rule file that contains our forwarded ports
|
||||
$fs = [System.IO.File]::Create($pmapper_config)
|
||||
try {
|
||||
foreach ($forwarded_port in $forwarded_ports.GetEnumerator()) {
|
||||
Write-Verbose -Message "Creating forwarded port rule for $($forwarded_port.Key) -> $($forwarded_port.Value)"
|
||||
$pmapper_rule = Get-PmapperRuleBytes -ListenPort $forwarded_port.Key -ConnectAddress 127.0.0.1 -ConnectPort $forwarded_port.Value
|
||||
$fs.Write($pmapper_rule, 0, $pmapper_rule.Length)
|
||||
}
|
||||
} finally {
|
||||
$fs.Close()
|
||||
}
|
||||
|
||||
Write-Verbose -Message "Starting Port Mapper '$pmapper_exe' in the background"
|
||||
$start_args = @{
|
||||
CommandLine = $pmapper_exe
|
||||
CurrentDirectory = $pmapper_folder
|
||||
}
|
||||
$res = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments $start_args
|
||||
if ($res.ReturnValue -ne 0) {
|
||||
$error_msg = switch($res.ReturnValue) {
|
||||
2 { "Access denied" }
|
||||
3 { "Insufficient privilege" }
|
||||
8 { "Unknown failure" }
|
||||
9 { "Path not found" }
|
||||
21 { "Invalid parameter" }
|
||||
default { "Undefined Error: $($res.ReturnValue)" }
|
||||
}
|
||||
Write-Error -Message "Failed to start pmapper: $error_msg"
|
||||
}
|
||||
$pmapper_pid = $res.ProcessId
|
||||
Write-Verbose -Message "Port Mapper PID: $pmapper_pid"
|
||||
}
|
||||
|
||||
Write-Verbose -Message "Wait for current script at '$PSCommandPath' to be deleted before running cleanup"
|
||||
$fsw = New-Object -TypeName System.IO.FileSystemWatcher
|
||||
$fsw.Path = Split-Path -Path $PSCommandPath -Parent
|
||||
$fsw.Filter = Split-Path -Path $PSCommandPath -Leaf
|
||||
$fsw.WaitForChanged([System.IO.WatcherChangeTypes]::Deleted, 3600000) > $null
|
||||
Write-Verbose -Message "Script delete or timeout reached, cleaning up Windows httptester artifacts"
|
||||
|
||||
Write-Verbose -Message "Cleanup host file entries"
|
||||
$hosts_file_lines = [System.IO.File]::ReadAllLines($hosts_file)
|
||||
$new_lines = [System.Collections.ArrayList]@()
|
||||
$changed = $false
|
||||
foreach ($host_line in $hosts_file_lines) {
|
||||
if ($host_line.EndsWith("# ansible-test httptester")) {
|
||||
$changed = $true
|
||||
continue
|
||||
}
|
||||
$new_lines.Add($host_line) > $null
|
||||
}
|
||||
if ($changed) {
|
||||
Write-Verbose -Message "Host file has extra entries, removing extra entries"
|
||||
[System.IO.File]::WriteAllLines($hosts_file, $new_lines)
|
||||
}
|
||||
|
||||
if ($os_version -ge [Version]"6.2") {
|
||||
Write-Verbose -Message "Cleanup of forwarded port configured in netsh"
|
||||
foreach ($forwarded_port in $forwarded_ports.GetEnumerator()) {
|
||||
$port_set = netsh interface portproxy show v4tov4 | `
|
||||
Where-Object { $_ -match "127.0.0.1\s*$($forwarded_port.Key)\s*127.0.0.1\s*$($forwarded_port.Value)" }
|
||||
|
||||
if ($port_set) {
|
||||
Write-Verbose -Message "Removing netsh portproxy rule for $($forwarded_port.Key) -> $($forwarded_port.Value)"
|
||||
$delete_args = @(
|
||||
"interface",
|
||||
"portproxy",
|
||||
"delete",
|
||||
"v4tov4",
|
||||
"listenaddress=127.0.0.1",
|
||||
"listenport=$($forwarded_port.Key)"
|
||||
)
|
||||
$null = netsh $delete_args 2>&1
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Write-Verbose -Message "Stopping Port Mapper executable based on pid $pmapper_pid"
|
||||
Stop-Process -Id $pmapper_pid -Force
|
||||
|
||||
# the process may not stop straight away, try multiple times to delete the Port Mapper folder
|
||||
$attempts = 1
|
||||
do {
|
||||
try {
|
||||
Write-Verbose -Message "Cleanup temporary files for Port Mapper at '$pmapper_folder' - Attempt: $attempts"
|
||||
Remove-Item -Path $pmapper_folder -Force -Recurse
|
||||
break
|
||||
} catch {
|
||||
Write-Verbose -Message "Cleanup temporary files for Port Mapper failed, waiting 5 seconds before trying again:$($_ | Out-String)"
|
||||
if ($attempts -ge 5) {
|
||||
break
|
||||
}
|
||||
$attempts += 1
|
||||
Start-Sleep -Second 5
|
||||
}
|
||||
} until ($true)
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
"""HTTP Tester plugin for integration tests."""
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
import os
|
||||
|
||||
from . import (
|
||||
CloudProvider,
|
||||
CloudEnvironment,
|
||||
CloudEnvironmentConfig,
|
||||
)
|
||||
|
||||
from ..util import (
|
||||
display,
|
||||
generate_password,
|
||||
)
|
||||
|
||||
from ..config import (
|
||||
IntegrationConfig,
|
||||
)
|
||||
|
||||
from ..containers import (
|
||||
run_support_container,
|
||||
)
|
||||
|
||||
KRB5_PASSWORD_ENV = 'KRB5_PASSWORD'
|
||||
|
||||
|
||||
class HttptesterProvider(CloudProvider):
|
||||
"""HTTP Tester provider plugin. Sets up resources before delegation."""
|
||||
def __init__(self, args): # type: (IntegrationConfig) -> None
|
||||
super(HttptesterProvider, self).__init__(args)
|
||||
|
||||
self.image = os.environ.get('ANSIBLE_HTTP_TEST_CONTAINER', 'quay.io/ansible/http-test-container:1.3.0')
|
||||
|
||||
self.uses_docker = True
|
||||
|
||||
def setup(self): # type: () -> None
|
||||
"""Setup resources before delegation."""
|
||||
super(HttptesterProvider, self).setup()
|
||||
|
||||
ports = [
|
||||
80,
|
||||
88,
|
||||
443,
|
||||
444,
|
||||
749,
|
||||
]
|
||||
|
||||
aliases = [
|
||||
'ansible.http.tests',
|
||||
'sni1.ansible.http.tests',
|
||||
'fail.ansible.http.tests',
|
||||
'self-signed.ansible.http.tests',
|
||||
]
|
||||
|
||||
descriptor = run_support_container(
|
||||
self.args,
|
||||
self.platform,
|
||||
self.image,
|
||||
'http-test-container',
|
||||
ports,
|
||||
aliases=aliases,
|
||||
start=True,
|
||||
allow_existing=True,
|
||||
cleanup=True,
|
||||
env={
|
||||
KRB5_PASSWORD_ENV: generate_password(),
|
||||
},
|
||||
)
|
||||
|
||||
descriptor.register(self.args)
|
||||
|
||||
# Read the password from the container environment.
|
||||
# This allows the tests to work when re-using an existing container.
|
||||
# The password is marked as sensitive, since it may differ from the one we generated.
|
||||
krb5_password = descriptor.details.container.env_dict()[KRB5_PASSWORD_ENV]
|
||||
display.sensitive.add(krb5_password)
|
||||
|
||||
self._set_cloud_config(KRB5_PASSWORD_ENV, krb5_password)
|
||||
|
||||
|
||||
class HttptesterEnvironment(CloudEnvironment):
|
||||
"""HTTP Tester environment plugin. Updates integration test environment after delegation."""
|
||||
def get_environment_config(self): # type: () -> CloudEnvironmentConfig
|
||||
"""Returns the cloud environment config."""
|
||||
return CloudEnvironmentConfig(
|
||||
env_vars=dict(
|
||||
HTTPTESTER='1', # backwards compatibility for tests intended to work with or without HTTP Tester
|
||||
KRB5_PASSWORD=self._get_cloud_config(KRB5_PASSWORD_ENV),
|
||||
)
|
||||
)
|
@ -0,0 +1,755 @@
|
||||
"""High level functions for working with containers."""
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
import atexit
|
||||
import contextlib
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import types as t
|
||||
|
||||
from .encoding import (
|
||||
Text,
|
||||
)
|
||||
|
||||
from .util import (
|
||||
ApplicationError,
|
||||
SubprocessError,
|
||||
display,
|
||||
get_host_ip,
|
||||
sanitize_host_name,
|
||||
)
|
||||
|
||||
from .util_common import (
|
||||
named_temporary_file,
|
||||
)
|
||||
|
||||
from .config import (
|
||||
EnvironmentConfig,
|
||||
IntegrationConfig,
|
||||
WindowsIntegrationConfig,
|
||||
)
|
||||
|
||||
from .docker_util import (
|
||||
ContainerNotFoundError,
|
||||
DockerInspect,
|
||||
docker_exec,
|
||||
docker_inspect,
|
||||
docker_pull,
|
||||
docker_rm,
|
||||
docker_run,
|
||||
docker_start,
|
||||
get_docker_command,
|
||||
get_docker_container_id,
|
||||
get_docker_host_ip,
|
||||
)
|
||||
|
||||
from .ansible_util import (
|
||||
run_playbook,
|
||||
)
|
||||
|
||||
from .core_ci import (
|
||||
SshKey,
|
||||
)
|
||||
|
||||
from .target import (
|
||||
IntegrationTarget,
|
||||
)
|
||||
|
||||
from .ssh import (
|
||||
SshConnectionDetail,
|
||||
SshProcess,
|
||||
create_ssh_port_forwards,
|
||||
create_ssh_port_redirects,
|
||||
generate_ssh_inventory,
|
||||
)
|
||||
|
||||
# information about support containers provisioned by the current ansible-test instance
|
||||
support_containers = {} # type: t.Dict[str, ContainerDescriptor]
|
||||
|
||||
|
||||
class HostType:
|
||||
"""Enum representing the types of hosts involved in running tests."""
|
||||
origin = 'origin'
|
||||
control = 'control'
|
||||
managed = 'managed'
|
||||
|
||||
|
||||
def run_support_container(
|
||||
args, # type: EnvironmentConfig
|
||||
context, # type: str
|
||||
image, # type: str
|
||||
name, # type: name
|
||||
ports, # type: t.List[int]
|
||||
aliases=None, # type: t.Optional[t.List[str]]
|
||||
start=True, # type: bool
|
||||
allow_existing=False, # type: bool
|
||||
cleanup=None, # type: t.Optional[bool]
|
||||
cmd=None, # type: t.Optional[t.List[str]]
|
||||
env=None, # type: t.Optional[t.Dict[str, str]]
|
||||
): # type: (...) -> ContainerDescriptor
|
||||
"""
|
||||
Start a container used to support tests, but not run them.
|
||||
Containers created this way will be accessible from tests.
|
||||
"""
|
||||
if name in support_containers:
|
||||
raise Exception('Container already defined: %s' % name)
|
||||
|
||||
# SSH is required for publishing ports, as well as modifying the hosts file.
|
||||
# Initializing the SSH key here makes sure it is available for use after delegation.
|
||||
SshKey(args)
|
||||
|
||||
aliases = aliases or [sanitize_host_name(name)]
|
||||
|
||||
current_container_id = get_docker_container_id()
|
||||
|
||||
publish_ports = True
|
||||
docker_command = get_docker_command().command
|
||||
|
||||
if docker_command == 'docker':
|
||||
if args.docker:
|
||||
publish_ports = False # publishing ports is not needed when test hosts are on the docker network
|
||||
|
||||
if current_container_id:
|
||||
publish_ports = False # publishing ports is pointless if already running in a docker container
|
||||
|
||||
options = ['--name', name]
|
||||
|
||||
if start:
|
||||
options.append('-d')
|
||||
|
||||
if publish_ports:
|
||||
for port in ports:
|
||||
options.extend(['-p', str(port)])
|
||||
|
||||
if env:
|
||||
for key, value in env.items():
|
||||
options.extend(['--env', '%s=%s' % (key, value)])
|
||||
|
||||
support_container_id = None
|
||||
|
||||
if allow_existing:
|
||||
try:
|
||||
container = docker_inspect(args, name)
|
||||
except ContainerNotFoundError:
|
||||
container = None
|
||||
|
||||
if container:
|
||||
support_container_id = container.id
|
||||
|
||||
if not container.running:
|
||||
display.info('Ignoring existing "%s" container which is not running.' % name, verbosity=1)
|
||||
support_container_id = None
|
||||
elif not container.image:
|
||||
display.info('Ignoring existing "%s" container which has the wrong image.' % name, verbosity=1)
|
||||
support_container_id = None
|
||||
elif publish_ports and not all(port and len(port) == 1 for port in [container.get_tcp_port(port) for port in ports]):
|
||||
display.info('Ignoring existing "%s" container which does not have the required published ports.' % name, verbosity=1)
|
||||
support_container_id = None
|
||||
|
||||
if not support_container_id:
|
||||
docker_rm(args, name)
|
||||
|
||||
if support_container_id:
|
||||
display.info('Using existing "%s" container.' % name)
|
||||
running = True
|
||||
existing = True
|
||||
else:
|
||||
display.info('Starting new "%s" container.' % name)
|
||||
docker_pull(args, image)
|
||||
support_container_id = docker_run(args, image, options, create_only=not start, cmd=cmd)
|
||||
running = start
|
||||
existing = False
|
||||
|
||||
if cleanup is None:
|
||||
cleanup = not existing
|
||||
|
||||
descriptor = ContainerDescriptor(
|
||||
image,
|
||||
context,
|
||||
name,
|
||||
support_container_id,
|
||||
ports,
|
||||
aliases,
|
||||
publish_ports,
|
||||
running,
|
||||
existing,
|
||||
cleanup,
|
||||
env,
|
||||
)
|
||||
|
||||
if not support_containers:
|
||||
atexit.register(cleanup_containers, args)
|
||||
|
||||
support_containers[name] = descriptor
|
||||
|
||||
return descriptor
|
||||
|
||||
|
||||
def get_container_database(args): # type: (EnvironmentConfig) -> ContainerDatabase
|
||||
"""Return the current container database, creating it as needed, or returning the one provided on the command line through delegation."""
|
||||
if not args.containers:
|
||||
args.containers = create_container_database(args)
|
||||
elif isinstance(args.containers, (str, bytes, Text)):
|
||||
args.containers = ContainerDatabase.from_dict(json.loads(args.containers))
|
||||
|
||||
display.info('>>> Container Database\n%s' % json.dumps(args.containers.to_dict(), indent=4, sort_keys=True), verbosity=3)
|
||||
|
||||
return args.containers
|
||||
|
||||
|
||||
class ContainerAccess:
|
||||
"""Information needed for one test host to access a single container supporting tests."""
|
||||
def __init__(self, host_ip, names, ports, forwards): # type: (str, t.List[str], t.Optional[t.List[int]], t.Optional[t.Dict[int, int]]) -> None
|
||||
# if forwards is set
|
||||
# this is where forwards are sent (it is the host that provides an indirect connection to the containers on alternate ports)
|
||||
# /etc/hosts uses 127.0.0.1 (since port redirection will be used)
|
||||
# else
|
||||
# this is what goes into /etc/hosts (it is the container's direct IP)
|
||||
self.host_ip = host_ip
|
||||
|
||||
# primary name + any aliases -- these go into the hosts file and reference the appropriate ip for the origin/control/managed host
|
||||
self.names = names
|
||||
|
||||
# ports available (set if forwards is not set)
|
||||
self.ports = ports
|
||||
|
||||
# port redirections to create through host_ip -- if not set, no port redirections will be used
|
||||
self.forwards = forwards
|
||||
|
||||
def port_map(self): # type: () -> t.List[t.Tuple[int, int]]
|
||||
"""Return a port map for accessing this container."""
|
||||
if self.forwards:
|
||||
ports = list(self.forwards.items())
|
||||
else:
|
||||
ports = [(port, port) for port in self.ports]
|
||||
|
||||
return ports
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data): # type: (t.Dict[str, t.Any]) -> ContainerAccess
|
||||
"""Return a ContainerAccess instance from the given dict."""
|
||||
forwards = data.get('forwards')
|
||||
|
||||
if forwards:
|
||||
forwards = dict((int(key), value) for key, value in forwards.items())
|
||||
|
||||
return ContainerAccess(
|
||||
host_ip=data['host_ip'],
|
||||
names=data['names'],
|
||||
ports=data.get('ports'),
|
||||
forwards=forwards,
|
||||
)
|
||||
|
||||
def to_dict(self): # type: () -> t.Dict[str, t.Any]
|
||||
"""Return a dict of the current instance."""
|
||||
value = dict(
|
||||
host_ip=self.host_ip,
|
||||
names=self.names,
|
||||
)
|
||||
|
||||
if self.ports:
|
||||
value.update(ports=self.ports)
|
||||
|
||||
if self.forwards:
|
||||
value.update(forwards=self.forwards)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class ContainerDatabase:
|
||||
"""Database of running containers used to support tests."""
|
||||
def __init__(self, data): # type: (t.Dict[str, t.Dict[str, t.Dict[str, ContainerAccess]]]) -> None
|
||||
self.data = data
|
||||
|
||||
@staticmethod
|
||||
def from_dict(data): # type: (t.Dict[str, t.Any]) -> ContainerDatabase
|
||||
"""Return a ContainerDatabase instance from the given dict."""
|
||||
return ContainerDatabase(dict((access_name,
|
||||
dict((context_name,
|
||||
dict((container_name, ContainerAccess.from_dict(container))
|
||||
for container_name, container in containers.items()))
|
||||
for context_name, containers in contexts.items()))
|
||||
for access_name, contexts in data.items()))
|
||||
|
||||
def to_dict(self): # type: () -> t.Dict[str, t.Any]
|
||||
"""Return a dict of the current instance."""
|
||||
return dict((access_name,
|
||||
dict((context_name,
|
||||
dict((container_name, container.to_dict())
|
||||
for container_name, container in containers.items()))
|
||||
for context_name, containers in contexts.items()))
|
||||
for access_name, contexts in self.data.items())
|
||||
|
||||
|
||||
def local_ssh(args): # type: (EnvironmentConfig) -> SshConnectionDetail
|
||||
"""Return SSH connection details for localhost, connecting as root to the default SSH port."""
|
||||
return SshConnectionDetail('localhost', 'localhost', None, 'root', SshKey(args).key, args.python_executable)
|
||||
|
||||
|
||||
def create_container_database(args): # type: (EnvironmentConfig) -> ContainerDatabase
|
||||
"""Create and return a container database with information necessary for all test hosts to make use of relevant support containers."""
|
||||
origin = {} # type: t.Dict[str, t.Dict[str, ContainerAccess]]
|
||||
control = {} # type: t.Dict[str, t.Dict[str, ContainerAccess]]
|
||||
managed = {} # type: t.Dict[str, t.Dict[str, ContainerAccess]]
|
||||
|
||||
for name, container in support_containers.items():
|
||||
if container.details.published_ports:
|
||||
published_access = ContainerAccess(
|
||||
host_ip=get_docker_host_ip(),
|
||||
names=container.aliases,
|
||||
ports=None,
|
||||
forwards=dict((port, published_port) for port, published_port in container.details.published_ports.items()),
|
||||
)
|
||||
else:
|
||||
published_access = None # no published access without published ports (ports are only published if needed)
|
||||
|
||||
if container.details.container_ip:
|
||||
# docker containers, and rootfull podman containers should have a container IP address
|
||||
container_access = ContainerAccess(
|
||||
host_ip=container.details.container_ip,
|
||||
names=container.aliases,
|
||||
ports=container.ports,
|
||||
forwards=None,
|
||||
)
|
||||
elif get_docker_command().command == 'podman':
|
||||
# published ports for rootless podman containers should be accessible from the host's IP
|
||||
container_access = ContainerAccess(
|
||||
host_ip=get_host_ip(),
|
||||
names=container.aliases,
|
||||
ports=None,
|
||||
forwards=dict((port, published_port) for port, published_port in container.details.published_ports.items()),
|
||||
)
|
||||
else:
|
||||
container_access = None # no container access without an IP address
|
||||
|
||||
if get_docker_container_id():
|
||||
if not container_access:
|
||||
raise Exception('Missing IP address for container: %s' % name)
|
||||
|
||||
origin_context = origin.setdefault(container.context, {})
|
||||
origin_context[name] = container_access
|
||||
elif not published_access:
|
||||
pass # origin does not have network access to the containers
|
||||
else:
|
||||
origin_context = origin.setdefault(container.context, {})
|
||||
origin_context[name] = published_access
|
||||
|
||||
if args.remote:
|
||||
pass # SSH forwarding required
|
||||
elif args.docker or get_docker_container_id():
|
||||
if container_access:
|
||||
control_context = control.setdefault(container.context, {})
|
||||
control_context[name] = container_access
|
||||
else:
|
||||
raise Exception('Missing IP address for container: %s' % name)
|
||||
else:
|
||||
if not published_access:
|
||||
raise Exception('Missing published ports for container: %s' % name)
|
||||
|
||||
control_context = control.setdefault(container.context, {})
|
||||
control_context[name] = published_access
|
||||
|
||||
data = {
|
||||
HostType.origin: origin,
|
||||
HostType.control: control,
|
||||
HostType.managed: managed,
|
||||
}
|
||||
|
||||
data = dict((key, value) for key, value in data.items() if value)
|
||||
|
||||
return ContainerDatabase(data)
|
||||
|
||||
|
||||
class SupportContainerContext:
|
||||
"""Context object for tracking information relating to access of support containers."""
|
||||
def __init__(self, containers, process): # type: (ContainerDatabase, t.Optional[SshProcess]) -> None
|
||||
self.containers = containers
|
||||
self.process = process
|
||||
|
||||
def close(self): # type: () -> None
|
||||
"""Close the process maintaining the port forwards."""
|
||||
if not self.process:
|
||||
return # forwarding not in use
|
||||
|
||||
self.process.terminate()
|
||||
|
||||
display.info('Waiting for the session SSH port forwarding process to terminate.', verbosity=1)
|
||||
|
||||
self.process.wait()
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def support_container_context(
|
||||
args, # type: EnvironmentConfig
|
||||
ssh, # type: t.Optional[SshConnectionDetail]
|
||||
): # type: (...) -> t.Optional[ContainerDatabase]
|
||||
"""Create a context manager for integration tests that use support containers."""
|
||||
if not isinstance(args, IntegrationConfig):
|
||||
yield None # containers are only used for integration tests
|
||||
return
|
||||
|
||||
containers = get_container_database(args)
|
||||
|
||||
if not containers.data:
|
||||
yield ContainerDatabase({}) # no containers are being used, return an empty database
|
||||
return
|
||||
|
||||
context = create_support_container_context(args, ssh, containers)
|
||||
|
||||
try:
|
||||
yield context.containers
|
||||
finally:
|
||||
context.close()
|
||||
|
||||
|
||||
def create_support_container_context(
|
||||
args, # type: EnvironmentConfig
|
||||
ssh, # type: t.Optional[SshConnectionDetail]
|
||||
containers, # type: ContainerDatabase
|
||||
): # type: (...) -> SupportContainerContext
|
||||
"""Context manager that provides SSH port forwards. Returns updated container metadata."""
|
||||
host_type = HostType.control
|
||||
|
||||
revised = ContainerDatabase(containers.data.copy())
|
||||
source = revised.data.pop(HostType.origin, None)
|
||||
|
||||
container_map = {} # type: t.Dict[t.Tuple[str, int], t.Tuple[str, str, int]]
|
||||
|
||||
if host_type not in revised.data:
|
||||
if not source:
|
||||
raise Exception('Missing origin container details.')
|
||||
|
||||
for context_name, context in source.items():
|
||||
for container_name, container in context.items():
|
||||
for port, access_port in container.port_map():
|
||||
container_map[(container.host_ip, access_port)] = (context_name, container_name, port)
|
||||
|
||||
if not container_map:
|
||||
return SupportContainerContext(revised, None)
|
||||
|
||||
if not ssh:
|
||||
raise Exception('The %s host was not pre-configured for container access and SSH forwarding is not available.' % host_type)
|
||||
|
||||
forwards = list(container_map.keys())
|
||||
process = create_ssh_port_forwards(args, ssh, forwards)
|
||||
result = SupportContainerContext(revised, process)
|
||||
|
||||
try:
|
||||
port_forwards = process.collect_port_forwards()
|
||||
contexts = {}
|
||||
|
||||
for forward, forwarded_port in port_forwards.items():
|
||||
access_host, access_port = forward
|
||||
context_name, container_name, container_port = container_map[(access_host, access_port)]
|
||||
container = source[context_name][container_name]
|
||||
context = contexts.setdefault(context_name, {})
|
||||
|
||||
forwarded_container = context.setdefault(container_name, ContainerAccess('127.0.0.1', container.names, None, {}))
|
||||
forwarded_container.forwards[container_port] = forwarded_port
|
||||
|
||||
display.info('Container "%s" port %d available at %s:%d is forwarded over SSH as port %d.' % (
|
||||
container_name, container_port, access_host, access_port, forwarded_port,
|
||||
), verbosity=1)
|
||||
|
||||
revised.data[host_type] = contexts
|
||||
|
||||
return result
|
||||
except Exception:
|
||||
result.close()
|
||||
raise
|
||||
|
||||
|
||||
class ContainerDescriptor:
|
||||
"""Information about a support container."""
|
||||
def __init__(self,
|
||||
image, # type: str
|
||||
context, # type: str
|
||||
name, # type: str
|
||||
container_id, # type: str
|
||||
ports, # type: t.List[int]
|
||||
aliases, # type: t.List[str]
|
||||
publish_ports, # type: bool
|
||||
running, # type: bool
|
||||
existing, # type: bool
|
||||
cleanup, # type: bool
|
||||
env, # type: t.Optional[t.Dict[str, str]]
|
||||
): # type: (...) -> None
|
||||
self.image = image
|
||||
self.context = context
|
||||
self.name = name
|
||||
self.container_id = container_id
|
||||
self.ports = ports
|
||||
self.aliases = aliases
|
||||
self.publish_ports = publish_ports
|
||||
self.running = running
|
||||
self.existing = existing
|
||||
self.cleanup = cleanup
|
||||
self.env = env
|
||||
self.details = None # type: t.Optional[SupportContainer]
|
||||
|
||||
def start(self, args): # type: (EnvironmentConfig) -> None
|
||||
"""Start the container. Used for containers which are created, but not started."""
|
||||
docker_start(args, self.name)
|
||||
|
||||
def register(self, args): # type: (EnvironmentConfig) -> SupportContainer
|
||||
"""Record the container's runtime details. Must be used after the container has been started."""
|
||||
if self.details:
|
||||
raise Exception('Container already registered: %s' % self.name)
|
||||
|
||||
try:
|
||||
container = docker_inspect(args, self.container_id)
|
||||
except ContainerNotFoundError:
|
||||
if not args.explain:
|
||||
raise
|
||||
|
||||
# provide enough mock data to keep --explain working
|
||||
container = DockerInspect(args, dict(
|
||||
Id=self.container_id,
|
||||
NetworkSettings=dict(
|
||||
IPAddress='127.0.0.1',
|
||||
Ports=dict(('%d/tcp' % port, [dict(HostPort=random.randint(30000, 40000) if self.publish_ports else port)]) for port in self.ports),
|
||||
),
|
||||
Config=dict(
|
||||
Env=['%s=%s' % (key, value) for key, value in self.env.items()] if self.env else [],
|
||||
),
|
||||
))
|
||||
|
||||
support_container_ip = container.get_ip_address()
|
||||
|
||||
if self.publish_ports:
|
||||
# inspect the support container to locate the published ports
|
||||
tcp_ports = dict((port, container.get_tcp_port(port)) for port in self.ports)
|
||||
|
||||
if any(not config or len(config) != 1 for config in tcp_ports.values()):
|
||||
raise ApplicationError('Unexpected `docker inspect` results for published TCP ports:\n%s' % json.dumps(tcp_ports, indent=4, sort_keys=True))
|
||||
|
||||
published_ports = dict((port, int(config[0]['HostPort'])) for port, config in tcp_ports.items())
|
||||
else:
|
||||
published_ports = {}
|
||||
|
||||
self.details = SupportContainer(
|
||||
container,
|
||||
support_container_ip,
|
||||
published_ports,
|
||||
)
|
||||
|
||||
return self.details
|
||||
|
||||
|
||||
class SupportContainer:
|
||||
"""Information about a running support container available for use by tests."""
|
||||
def __init__(self,
|
||||
container, # type: DockerInspect
|
||||
container_ip, # type: str
|
||||
published_ports, # type: t.Dict[int, int]
|
||||
): # type: (...) -> None
|
||||
self.container = container
|
||||
self.container_ip = container_ip
|
||||
self.published_ports = published_ports
|
||||
|
||||
|
||||
def wait_for_file(args, # type: EnvironmentConfig
|
||||
container_name, # type: str
|
||||
path, # type: str
|
||||
sleep, # type: int
|
||||
tries, # type: int
|
||||
check=None, # type: t.Optional[t.Callable[[str], bool]]
|
||||
): # type: (...) -> str
|
||||
"""Wait for the specified file to become available in the requested container and return its contents."""
|
||||
display.info('Waiting for container "%s" to provide file: %s' % (container_name, path))
|
||||
|
||||
for _iteration in range(1, tries):
|
||||
if _iteration > 1:
|
||||
time.sleep(sleep)
|
||||
|
||||
try:
|
||||
stdout = docker_exec(args, container_name, ['dd', 'if=%s' % path], capture=True)[0]
|
||||
except SubprocessError:
|
||||
continue
|
||||
|
||||
if not check or check(stdout):
|
||||
return stdout
|
||||
|
||||
raise ApplicationError('Timeout waiting for container "%s" to provide file: %s' % (container_name, path))
|
||||
|
||||
|
||||
def cleanup_containers(args): # type: (EnvironmentConfig) -> None
|
||||
"""Clean up containers."""
|
||||
for container in support_containers.values():
|
||||
if container.cleanup:
|
||||
docker_rm(args, container.container_id)
|
||||
else:
|
||||
display.notice('Remember to run `docker rm -f %s` when finished testing.' % container.name)
|
||||
|
||||
|
||||
def create_hosts_entries(context): # type: (t.Dict[str, ContainerAccess]) -> t.List[str]
|
||||
"""Return hosts entries for the specified context."""
|
||||
entries = []
|
||||
unique_id = uuid.uuid4()
|
||||
|
||||
for container in context.values():
|
||||
# forwards require port redirection through localhost
|
||||
if container.forwards:
|
||||
host_ip = '127.0.0.1'
|
||||
else:
|
||||
host_ip = container.host_ip
|
||||
|
||||
entries.append('%s %s # ansible-test %s' % (host_ip, ' '.join(container.names), unique_id))
|
||||
|
||||
return entries
|
||||
|
||||
|
||||
def create_container_hooks(
|
||||
args, # type: IntegrationConfig
|
||||
managed_connections, # type: t.Optional[t.List[SshConnectionDetail]]
|
||||
): # type: (...) -> t.Tuple[t.Optional[t.Callable[[IntegrationTarget], None]], t.Optional[t.Callable[[IntegrationTarget], None]]]
|
||||
"""Return pre and post target callbacks for enabling and disabling container access for each test target."""
|
||||
containers = get_container_database(args)
|
||||
|
||||
control_contexts = containers.data.get(HostType.control)
|
||||
|
||||
if control_contexts:
|
||||
managed_contexts = containers.data.get(HostType.managed)
|
||||
|
||||
if not managed_contexts:
|
||||
managed_contexts = create_managed_contexts(control_contexts)
|
||||
|
||||
control_type = 'posix'
|
||||
|
||||
if isinstance(args, WindowsIntegrationConfig):
|
||||
managed_type = 'windows'
|
||||
else:
|
||||
managed_type = 'posix'
|
||||
|
||||
control_state = {}
|
||||
managed_state = {}
|
||||
|
||||
control_connections = [local_ssh(args)]
|
||||
|
||||
def pre_target(target):
|
||||
forward_ssh_ports(args, control_connections, '%s_hosts_prepare.yml' % control_type, control_state, target, HostType.control, control_contexts)
|
||||
forward_ssh_ports(args, managed_connections, '%s_hosts_prepare.yml' % managed_type, managed_state, target, HostType.managed, managed_contexts)
|
||||
|
||||
def post_target(target):
|
||||
cleanup_ssh_ports(args, control_connections, '%s_hosts_restore.yml' % control_type, control_state, target, HostType.control)
|
||||
cleanup_ssh_ports(args, managed_connections, '%s_hosts_restore.yml' % managed_type, managed_state, target, HostType.managed)
|
||||
else:
|
||||
pre_target, post_target = None, None
|
||||
|
||||
return pre_target, post_target
|
||||
|
||||
|
||||
def create_managed_contexts(control_contexts): # type: (t.Dict[str, t.Dict[str, ContainerAccess]]) -> t.Dict[str, t.Dict[str, ContainerAccess]]
|
||||
"""Create managed contexts from the given control contexts."""
|
||||
managed_contexts = {}
|
||||
|
||||
for context_name, control_context in control_contexts.items():
|
||||
managed_context = managed_contexts[context_name] = {}
|
||||
|
||||
for container_name, control_container in control_context.items():
|
||||
managed_context[container_name] = ContainerAccess(control_container.host_ip, control_container.names, None, dict(control_container.port_map()))
|
||||
|
||||
return managed_contexts
|
||||
|
||||
|
||||
def forward_ssh_ports(
|
||||
args, # type: IntegrationConfig
|
||||
ssh_connections, # type: t.Optional[t.List[SshConnectionDetail]]
|
||||
playbook, # type: str
|
||||
target_state, # type: t.Dict[str, t.Tuple[t.List[str], t.List[SshProcess]]]
|
||||
target, # type: IntegrationTarget
|
||||
host_type, # type: str
|
||||
contexts, # type: t.Dict[str, t.Dict[str, ContainerAccess]]
|
||||
): # type: (...) -> None
|
||||
"""Configure port forwarding using SSH and write hosts file entries."""
|
||||
if ssh_connections is None:
|
||||
return
|
||||
|
||||
test_context = None
|
||||
|
||||
for context_name, context in contexts.items():
|
||||
context_alias = 'cloud/%s/' % context_name
|
||||
|
||||
if context_alias in target.aliases:
|
||||
test_context = context
|
||||
break
|
||||
|
||||
if not test_context:
|
||||
return
|
||||
|
||||
if not ssh_connections:
|
||||
raise Exception('The %s host was not pre-configured for container access and SSH forwarding is not available.' % host_type)
|
||||
|
||||
redirects = [] # type: t.List[t.Tuple[int, str, int]]
|
||||
messages = []
|
||||
|
||||
for container_name, container in test_context.items():
|
||||
explain = []
|
||||
|
||||
for container_port, access_port in container.port_map():
|
||||
if container.forwards:
|
||||
redirects.append((container_port, container.host_ip, access_port))
|
||||
|
||||
explain.append('%d -> %s:%d' % (container_port, container.host_ip, access_port))
|
||||
else:
|
||||
explain.append('%s:%d' % (container.host_ip, container_port))
|
||||
|
||||
if explain:
|
||||
if container.forwards:
|
||||
message = 'Port forwards for the "%s" container have been established on the %s host' % (container_name, host_type)
|
||||
else:
|
||||
message = 'Ports for the "%s" container are available on the %s host as' % (container_name, host_type)
|
||||
|
||||
messages.append('%s:\n%s' % (message, '\n'.join(explain)))
|
||||
|
||||
hosts_entries = create_hosts_entries(test_context)
|
||||
inventory = generate_ssh_inventory(ssh_connections)
|
||||
|
||||
with named_temporary_file(args, 'ssh-inventory-', '.json', None, inventory) as inventory_path:
|
||||
run_playbook(args, inventory_path, playbook, dict(hosts_entries=hosts_entries))
|
||||
|
||||
ssh_processes = [] # type: t.List[SshProcess]
|
||||
|
||||
if redirects:
|
||||
for ssh in ssh_connections:
|
||||
ssh_processes.append(create_ssh_port_redirects(args, ssh, redirects))
|
||||
|
||||
target_state[target.name] = (hosts_entries, ssh_processes)
|
||||
|
||||
for message in messages:
|
||||
display.info(message, verbosity=1)
|
||||
|
||||
|
||||
def cleanup_ssh_ports(
|
||||
args, # type: IntegrationConfig
|
||||
ssh_connections, # type: t.List[SshConnectionDetail]
|
||||
playbook, # type: str
|
||||
target_state, # type: t.Dict[str, t.Tuple[t.List[str], t.List[SshProcess]]]
|
||||
target, # type: IntegrationTarget
|
||||
host_type, # type: str
|
||||
): # type: (...) -> None
|
||||
"""Stop previously configured SSH port forwarding and remove previously written hosts file entries."""
|
||||
state = target_state.pop(target.name, None)
|
||||
|
||||
if not state:
|
||||
return
|
||||
|
||||
(hosts_entries, ssh_processes) = state
|
||||
|
||||
inventory = generate_ssh_inventory(ssh_connections)
|
||||
|
||||
with named_temporary_file(args, 'ssh-inventory-', '.json', None, inventory) as inventory_path:
|
||||
run_playbook(args, inventory_path, playbook, dict(hosts_entries=hosts_entries))
|
||||
|
||||
if ssh_processes:
|
||||
for process in ssh_processes:
|
||||
process.terminate()
|
||||
|
||||
display.info('Waiting for the %s host SSH port forwarding processs(es) to terminate.' % host_type, verbosity=1)
|
||||
|
||||
for process in ssh_processes:
|
||||
process.wait()
|
@ -0,0 +1,264 @@
|
||||
"""High level functions for working with SSH."""
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import subprocess
|
||||
|
||||
from . import types as t
|
||||
|
||||
from .encoding import (
|
||||
to_bytes,
|
||||
to_text,
|
||||
)
|
||||
|
||||
from .util import (
|
||||
ApplicationError,
|
||||
cmd_quote,
|
||||
common_environment,
|
||||
devnull,
|
||||
display,
|
||||
exclude_none_values,
|
||||
sanitize_host_name,
|
||||
)
|
||||
|
||||
from .config import (
|
||||
EnvironmentConfig,
|
||||
)
|
||||
|
||||
|
||||
class SshConnectionDetail:
|
||||
"""Information needed to establish an SSH connection to a host."""
|
||||
def __init__(self,
|
||||
name, # type: str
|
||||
host, # type: str
|
||||
port, # type: t.Optional[int]
|
||||
user, # type: str
|
||||
identity_file, # type: str
|
||||
python_interpreter=None, # type: t.Optional[str]
|
||||
shell_type=None, # type: t.Optional[str]
|
||||
): # type: (...) -> None
|
||||
self.name = sanitize_host_name(name)
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.user = user
|
||||
self.identity_file = identity_file
|
||||
self.python_interpreter = python_interpreter
|
||||
self.shell_type = shell_type
|
||||
|
||||
|
||||
class SshProcess:
|
||||
"""Wrapper around an SSH process."""
|
||||
def __init__(self, process): # type: (t.Optional[subprocess.Popen]) -> None
|
||||
self._process = process
|
||||
self.pending_forwards = None # type: t.Optional[t.Set[t.Tuple[str, int]]]
|
||||
|
||||
self.forwards = {} # type: t.Dict[t.Tuple[str, int], int]
|
||||
|
||||
def terminate(self): # type: () -> None
|
||||
"""Terminate the SSH process."""
|
||||
if not self._process:
|
||||
return # explain mode
|
||||
|
||||
# noinspection PyBroadException
|
||||
try:
|
||||
self._process.terminate()
|
||||
except Exception: # pylint: disable=broad-except
|
||||
pass
|
||||
|
||||
def wait(self): # type: () -> None
|
||||
"""Wait for the SSH process to terminate."""
|
||||
if not self._process:
|
||||
return # explain mode
|
||||
|
||||
self._process.wait()
|
||||
|
||||
def collect_port_forwards(self): # type: (SshProcess) -> t.Dict[t.Tuple[str, int], int]
|
||||
"""Collect port assignments for dynamic SSH port forwards."""
|
||||
errors = []
|
||||
|
||||
display.info('Collecting %d SSH port forward(s).' % len(self.pending_forwards), verbosity=2)
|
||||
|
||||
while self.pending_forwards:
|
||||
if self._process:
|
||||
line_bytes = self._process.stderr.readline()
|
||||
|
||||
if not line_bytes:
|
||||
if errors:
|
||||
details = ':\n%s' % '\n'.join(errors)
|
||||
else:
|
||||
details = '.'
|
||||
|
||||
raise ApplicationError('SSH port forwarding failed%s' % details)
|
||||
|
||||
line = to_text(line_bytes).strip()
|
||||
|
||||
match = re.search(r'^Allocated port (?P<src_port>[0-9]+) for remote forward to (?P<dst_host>[^:]+):(?P<dst_port>[0-9]+)$', line)
|
||||
|
||||
if not match:
|
||||
if re.search(r'^Warning: Permanently added .* to the list of known hosts\.$', line):
|
||||
continue
|
||||
|
||||
display.warning('Unexpected SSH port forwarding output: %s' % line, verbosity=2)
|
||||
|
||||
errors.append(line)
|
||||
continue
|
||||
|
||||
src_port = int(match.group('src_port'))
|
||||
dst_host = str(match.group('dst_host'))
|
||||
dst_port = int(match.group('dst_port'))
|
||||
|
||||
dst = (dst_host, dst_port)
|
||||
else:
|
||||
# explain mode
|
||||
dst = list(self.pending_forwards)[0]
|
||||
src_port = random.randint(40000, 50000)
|
||||
|
||||
self.pending_forwards.remove(dst)
|
||||
self.forwards[dst] = src_port
|
||||
|
||||
display.info('Collected %d SSH port forward(s):\n%s' % (
|
||||
len(self.forwards), '\n'.join('%s -> %s:%s' % (src_port, dst[0], dst[1]) for dst, src_port in sorted(self.forwards.items()))), verbosity=2)
|
||||
|
||||
return self.forwards
|
||||
|
||||
|
||||
def create_ssh_command(
|
||||
ssh, # type: SshConnectionDetail
|
||||
options=None, # type: t.Optional[t.Dict[str, t.Union[str, int]]]
|
||||
cli_args=None, # type: t.List[str]
|
||||
command=None, # type: t.Optional[str]
|
||||
): # type: (...) -> t.List[str]
|
||||
"""Create an SSH command using the specified options."""
|
||||
cmd = [
|
||||
'ssh',
|
||||
'-n', # prevent reading from stdin
|
||||
'-i', ssh.identity_file, # file from which the identity for public key authentication is read
|
||||
]
|
||||
|
||||
if not command:
|
||||
cmd.append('-N') # do not execute a remote command
|
||||
|
||||
if ssh.port:
|
||||
cmd.extend(['-p', str(ssh.port)]) # port to connect to on the remote host
|
||||
|
||||
if ssh.user:
|
||||
cmd.extend(['-l', ssh.user]) # user to log in as on the remote machine
|
||||
|
||||
ssh_options = dict(
|
||||
BatchMode='yes',
|
||||
ExitOnForwardFailure='yes',
|
||||
LogLevel='ERROR',
|
||||
ServerAliveCountMax=4,
|
||||
ServerAliveInterval=15,
|
||||
StrictHostKeyChecking='no',
|
||||
UserKnownHostsFile='/dev/null',
|
||||
)
|
||||
|
||||
ssh_options.update(options or {})
|
||||
|
||||
for key, value in sorted(ssh_options.items()):
|
||||
cmd.extend(['-o', '='.join([key, str(value)])])
|
||||
|
||||
cmd.extend(cli_args or [])
|
||||
cmd.append(ssh.host)
|
||||
|
||||
if command:
|
||||
cmd.append(command)
|
||||
|
||||
return cmd
|
||||
|
||||
|
||||
def run_ssh_command(
|
||||
args, # type: EnvironmentConfig
|
||||
ssh, # type: SshConnectionDetail
|
||||
options=None, # type: t.Optional[t.Dict[str, t.Union[str, int]]]
|
||||
cli_args=None, # type: t.List[str]
|
||||
command=None, # type: t.Optional[str]
|
||||
): # type: (...) -> SshProcess
|
||||
"""Run the specified SSH command, returning the created SshProcess instance created."""
|
||||
cmd = create_ssh_command(ssh, options, cli_args, command)
|
||||
env = common_environment()
|
||||
|
||||
cmd_show = ' '.join([cmd_quote(c) for c in cmd])
|
||||
display.info('Run background command: %s' % cmd_show, verbosity=1, truncate=True)
|
||||
|
||||
cmd_bytes = [to_bytes(c) for c in cmd]
|
||||
env_bytes = dict((to_bytes(k), to_bytes(v)) for k, v in env.items())
|
||||
|
||||
if args.explain:
|
||||
process = SshProcess(None)
|
||||
else:
|
||||
process = SshProcess(subprocess.Popen(cmd_bytes, env=env_bytes, bufsize=-1, stdin=devnull(), stdout=subprocess.PIPE, stderr=subprocess.PIPE))
|
||||
|
||||
return process
|
||||
|
||||
|
||||
def create_ssh_port_forwards(
|
||||
args, # type: EnvironmentConfig
|
||||
ssh, # type: SshConnectionDetail
|
||||
forwards, # type: t.List[t.Tuple[str, int]]
|
||||
): # type: (...) -> SshProcess
|
||||
"""
|
||||
Create SSH port forwards using the provided list of tuples (target_host, target_port).
|
||||
Port bindings will be automatically assigned by SSH and must be collected with a subseqent call to collect_port_forwards.
|
||||
"""
|
||||
options = dict(
|
||||
LogLevel='INFO', # info level required to get messages on stderr indicating the ports assigned to each forward
|
||||
)
|
||||
|
||||
cli_args = []
|
||||
|
||||
for forward_host, forward_port in forwards:
|
||||
cli_args.extend(['-R', ':'.join([str(0), forward_host, str(forward_port)])])
|
||||
|
||||
process = run_ssh_command(args, ssh, options, cli_args)
|
||||
process.pending_forwards = forwards
|
||||
|
||||
return process
|
||||
|
||||
|
||||
def create_ssh_port_redirects(
|
||||
args, # type: EnvironmentConfig
|
||||
ssh, # type: SshConnectionDetail
|
||||
redirects, # type: t.List[t.Tuple[int, str, int]]
|
||||
): # type: (...) -> SshProcess
|
||||
"""Create SSH port redirections using the provided list of tuples (bind_port, target_host, target_port)."""
|
||||
options = {}
|
||||
cli_args = []
|
||||
|
||||
for bind_port, target_host, target_port in redirects:
|
||||
cli_args.extend(['-R', ':'.join([str(bind_port), target_host, str(target_port)])])
|
||||
|
||||
process = run_ssh_command(args, ssh, options, cli_args)
|
||||
|
||||
return process
|
||||
|
||||
|
||||
def generate_ssh_inventory(ssh_connections): # type: (t.List[SshConnectionDetail]) -> str
|
||||
"""Return an inventory file in JSON format, created from the provided SSH connection details."""
|
||||
inventory = dict(
|
||||
all=dict(
|
||||
hosts=dict((ssh.name, exclude_none_values(dict(
|
||||
ansible_host=ssh.host,
|
||||
ansible_port=ssh.port,
|
||||
ansible_user=ssh.user,
|
||||
ansible_ssh_private_key_file=os.path.abspath(ssh.identity_file),
|
||||
ansible_connection='ssh',
|
||||
ansible_ssh_pipelining='yes',
|
||||
ansible_python_interpreter=ssh.python_interpreter,
|
||||
ansible_shell_type=ssh.shell_type,
|
||||
ansible_ssh_extra_args='-o UserKnownHostsFile=/dev/null', # avoid changing the test environment
|
||||
ansible_ssh_host_key_checking='no',
|
||||
))) for ssh in ssh_connections),
|
||||
),
|
||||
)
|
||||
|
||||
inventory_text = json.dumps(inventory, indent=4, sort_keys=True)
|
||||
|
||||
display.info('>>> SSH Inventory\n%s' % inventory_text, verbosity=3)
|
||||
|
||||
return inventory_text
|
@ -0,0 +1,518 @@
|
||||
# Copyright (c) 2020 Ansible Project
|
||||
# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause)
|
||||
|
||||
Function Get-AnsibleWindowsWebRequest {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Creates a System.Net.WebRequest object based on common URL module options in Ansible.
|
||||
|
||||
.DESCRIPTION
|
||||
Will create a WebRequest based on common input options within Ansible. This can be used manually or with
|
||||
Invoke-AnsibleWindowsWebRequest.
|
||||
|
||||
.PARAMETER Uri
|
||||
The URI to create the web request for.
|
||||
|
||||
.PARAMETER UrlMethod
|
||||
The protocol method to use, if omitted, will use the default value for the URI protocol specified.
|
||||
|
||||
.PARAMETER FollowRedirects
|
||||
Whether to follow redirect reponses. This is only valid when using a HTTP URI.
|
||||
all - Will follow all redirects
|
||||
none - Will follow no redirects
|
||||
safe - Will only follow redirects when GET or HEAD is used as the UrlMethod
|
||||
|
||||
.PARAMETER Headers
|
||||
A hashtable or dictionary of header values to set on the request. This is only valid for a HTTP URI.
|
||||
|
||||
.PARAMETER HttpAgent
|
||||
A string to set for the 'User-Agent' header. This is only valid for a HTTP URI.
|
||||
|
||||
.PARAMETER MaximumRedirection
|
||||
The maximum number of redirections that will be followed. This is only valid for a HTTP URI.
|
||||
|
||||
.PARAMETER UrlTimeout
|
||||
The timeout in seconds that defines how long to wait until the request times out.
|
||||
|
||||
.PARAMETER ValidateCerts
|
||||
Whether to validate SSL certificates, default to True.
|
||||
|
||||
.PARAMETER ClientCert
|
||||
The path to PFX file to use for X509 authentication. This is only valid for a HTTP URI. This path can either
|
||||
be a filesystem path (C:\folder\cert.pfx) or a PSPath to a credential (Cert:\CurrentUser\My\<thumbprint>).
|
||||
|
||||
.PARAMETER ClientCertPassword
|
||||
The password for the PFX certificate if required. This is only valid for a HTTP URI.
|
||||
|
||||
.PARAMETER ForceBasicAuth
|
||||
Whether to set the Basic auth header on the first request instead of when required. This is only valid for a
|
||||
HTTP URI.
|
||||
|
||||
.PARAMETER UrlUsername
|
||||
The username to use for authenticating with the target.
|
||||
|
||||
.PARAMETER UrlPassword
|
||||
The password to use for authenticating with the target.
|
||||
|
||||
.PARAMETER UseDefaultCredential
|
||||
Whether to use the current user's credentials if available. This will only work when using Become, using SSH with
|
||||
password auth, or WinRM with CredSSP or Kerberos with credential delegation.
|
||||
|
||||
.PARAMETER UseProxy
|
||||
Whether to use the default proxy defined in IE (WinINet) for the user or set no proxy at all. This should not
|
||||
be set to True when ProxyUrl is also defined.
|
||||
|
||||
.PARAMETER ProxyUrl
|
||||
An explicit proxy server to use for the request instead of relying on the default proxy in IE. This is only
|
||||
valid for a HTTP URI.
|
||||
|
||||
.PARAMETER ProxyUsername
|
||||
An optional username to use for proxy authentication.
|
||||
|
||||
.PARAMETER ProxyPassword
|
||||
The password for ProxyUsername.
|
||||
|
||||
.PARAMETER ProxyUseDefaultCredential
|
||||
Whether to use the current user's credentials for proxy authentication if available. This will only work when
|
||||
using Become, using SSH with password auth, or WinRM with CredSSP or Kerberos with credential delegation.
|
||||
|
||||
.PARAMETER Module
|
||||
The AnsibleBasic module that can be used as a backup parameter source or a way to return warnings back to the
|
||||
Ansible controller.
|
||||
|
||||
.EXAMPLE
|
||||
$spec = @{
|
||||
options = @{}
|
||||
}
|
||||
$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec, @(Get-AnsibleWindowsWebRequestSpec))
|
||||
|
||||
$web_request = Get-AnsibleWindowsWebRequest -Module $module
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
[OutputType([System.Net.WebRequest])]
|
||||
Param (
|
||||
[Alias("url")]
|
||||
[System.Uri]
|
||||
$Uri,
|
||||
|
||||
[Alias("url_method")]
|
||||
[System.String]
|
||||
$UrlMethod,
|
||||
|
||||
[Alias("follow_redirects")]
|
||||
[ValidateSet("all", "none", "safe")]
|
||||
[System.String]
|
||||
$FollowRedirects = "safe",
|
||||
|
||||
[System.Collections.IDictionary]
|
||||
$Headers,
|
||||
|
||||
[Alias("http_agent")]
|
||||
[System.String]
|
||||
$HttpAgent = "ansible-httpget",
|
||||
|
||||
[Alias("maximum_redirection")]
|
||||
[System.Int32]
|
||||
$MaximumRedirection = 50,
|
||||
|
||||
[Alias("url_timeout")]
|
||||
[System.Int32]
|
||||
$UrlTimeout = 30,
|
||||
|
||||
[Alias("validate_certs")]
|
||||
[System.Boolean]
|
||||
$ValidateCerts = $true,
|
||||
|
||||
# Credential params
|
||||
[Alias("client_cert")]
|
||||
[System.String]
|
||||
$ClientCert,
|
||||
|
||||
[Alias("client_cert_password")]
|
||||
[System.String]
|
||||
$ClientCertPassword,
|
||||
|
||||
[Alias("force_basic_auth")]
|
||||
[Switch]
|
||||
$ForceBasicAuth,
|
||||
|
||||
[Alias("url_username")]
|
||||
[System.String]
|
||||
$UrlUsername,
|
||||
|
||||
[Alias("url_password")]
|
||||
[System.String]
|
||||
$UrlPassword,
|
||||
|
||||
[Alias("use_default_credential")]
|
||||
[Switch]
|
||||
$UseDefaultCredential,
|
||||
|
||||
# Proxy params
|
||||
[Alias("use_proxy")]
|
||||
[System.Boolean]
|
||||
$UseProxy = $true,
|
||||
|
||||
[Alias("proxy_url")]
|
||||
[System.String]
|
||||
$ProxyUrl,
|
||||
|
||||
[Alias("proxy_username")]
|
||||
[System.String]
|
||||
$ProxyUsername,
|
||||
|
||||
[Alias("proxy_password")]
|
||||
[System.String]
|
||||
$ProxyPassword,
|
||||
|
||||
[Alias("proxy_use_default_credential")]
|
||||
[Switch]
|
||||
$ProxyUseDefaultCredential,
|
||||
|
||||
[ValidateScript({ $_.GetType().FullName -eq 'Ansible.Basic.AnsibleModule' })]
|
||||
[System.Object]
|
||||
$Module
|
||||
)
|
||||
|
||||
# Set module options for parameters unless they were explicitly passed in.
|
||||
if ($Module) {
|
||||
foreach ($param in $PSCmdlet.MyInvocation.MyCommand.Parameters.GetEnumerator()) {
|
||||
if ($PSBoundParameters.ContainsKey($param.Key)) {
|
||||
# Was set explicitly we want to use that value
|
||||
continue
|
||||
}
|
||||
|
||||
foreach ($alias in @($Param.Key) + $param.Value.Aliases) {
|
||||
if ($Module.Params.ContainsKey($alias)) {
|
||||
$var_value = $Module.Params.$alias -as $param.Value.ParameterType
|
||||
Set-Variable -Name $param.Key -Value $var_value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Disable certificate validation if requested
|
||||
# FUTURE: set this on ServerCertificateValidationCallback of the HttpWebRequest once .NET 4.5 is the minimum
|
||||
if (-not $ValidateCerts) {
|
||||
[System.Net.ServicePointManager]::ServerCertificateValidationCallback = { $true }
|
||||
}
|
||||
|
||||
# Enable TLS1.1/TLS1.2 if they're available but disabled (eg. .NET 4.5)
|
||||
$security_protocols = [System.Net.ServicePointManager]::SecurityProtocol -bor [System.Net.SecurityProtocolType]::SystemDefault
|
||||
if ([System.Net.SecurityProtocolType].GetMember("Tls11").Count -gt 0) {
|
||||
$security_protocols = $security_protocols -bor [System.Net.SecurityProtocolType]::Tls11
|
||||
}
|
||||
if ([System.Net.SecurityProtocolType].GetMember("Tls12").Count -gt 0) {
|
||||
$security_protocols = $security_protocols -bor [System.Net.SecurityProtocolType]::Tls12
|
||||
}
|
||||
[System.Net.ServicePointManager]::SecurityProtocol = $security_protocols
|
||||
|
||||
$web_request = [System.Net.WebRequest]::Create($Uri)
|
||||
if ($UrlMethod) {
|
||||
$web_request.Method = $UrlMethod
|
||||
}
|
||||
$web_request.Timeout = $UrlTimeout * 1000
|
||||
|
||||
if ($UseDefaultCredential -and $web_request -is [System.Net.HttpWebRequest]) {
|
||||
$web_request.UseDefaultCredentials = $true
|
||||
} elseif ($UrlUsername) {
|
||||
if ($ForceBasicAuth) {
|
||||
$auth_value = [System.Convert]::ToBase64String([System.Text.Encoding]::ASCII.GetBytes(("{0}:{1}" -f $UrlUsername, $UrlPassword)))
|
||||
$web_request.Headers.Add("Authorization", "Basic $auth_value")
|
||||
} else {
|
||||
$credential = New-Object -TypeName System.Net.NetworkCredential -ArgumentList $UrlUsername, $UrlPassword
|
||||
$web_request.Credentials = $credential
|
||||
}
|
||||
}
|
||||
|
||||
if ($ClientCert) {
|
||||
# Expecting either a filepath or PSPath (Cert:\CurrentUser\My\<thumbprint>)
|
||||
$cert = Get-Item -LiteralPath $ClientCert -ErrorAction SilentlyContinue
|
||||
if ($null -eq $cert) {
|
||||
Write-Error -Message "Client certificate '$ClientCert' does not exist" -Category ObjectNotFound
|
||||
return
|
||||
}
|
||||
|
||||
$crypto_ns = 'System.Security.Cryptography.X509Certificates'
|
||||
if ($cert.PSProvider.Name -ne 'Certificate') {
|
||||
try {
|
||||
$cert = New-Object -TypeName "$crypto_ns.X509Certificate2" -ArgumentList @(
|
||||
$ClientCert, $ClientCertPassword
|
||||
)
|
||||
} catch [System.Security.Cryptography.CryptographicException] {
|
||||
Write-Error -Message "Failed to read client certificate at '$ClientCert'" -Exception $_.Exception -Category SecurityError
|
||||
return
|
||||
}
|
||||
}
|
||||
$web_request.ClientCertificates = New-Object -TypeName "$crypto_ns.X509Certificate2Collection" -ArgumentList @(
|
||||
$cert
|
||||
)
|
||||
}
|
||||
|
||||
if (-not $UseProxy) {
|
||||
$proxy = $null
|
||||
} elseif ($ProxyUrl) {
|
||||
$proxy = New-Object -TypeName System.Net.WebProxy -ArgumentList $ProxyUrl, $true
|
||||
} else {
|
||||
$proxy = $web_request.Proxy
|
||||
}
|
||||
|
||||
# $web_request.Proxy may return $null for a FTP web request. We only set the credentials if we have an actual
|
||||
# proxy to work with, otherwise just ignore the credentials property.
|
||||
if ($null -ne $proxy) {
|
||||
if ($ProxyUseDefaultCredential) {
|
||||
# Weird hack, $web_request.Proxy returns an IWebProxy object which only gurantees the Credentials
|
||||
# property. We cannot set UseDefaultCredentials so we just set the Credentials to the
|
||||
# DefaultCredentials in the CredentialCache which does the same thing.
|
||||
$proxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials
|
||||
} elseif ($ProxyUsername) {
|
||||
$proxy.Credentials = New-Object -TypeName System.Net.NetworkCredential -ArgumentList @(
|
||||
$ProxyUsername, $ProxyPassword
|
||||
)
|
||||
} else {
|
||||
$proxy.Credentials = $null
|
||||
}
|
||||
}
|
||||
|
||||
$web_request.Proxy = $proxy
|
||||
|
||||
# Some parameters only apply when dealing with a HttpWebRequest
|
||||
if ($web_request -is [System.Net.HttpWebRequest]) {
|
||||
if ($Headers) {
|
||||
foreach ($header in $Headers.GetEnumerator()) {
|
||||
switch ($header.Key) {
|
||||
Accept { $web_request.Accept = $header.Value }
|
||||
Connection { $web_request.Connection = $header.Value }
|
||||
Content-Length { $web_request.ContentLength = $header.Value }
|
||||
Content-Type { $web_request.ContentType = $header.Value }
|
||||
Expect { $web_request.Expect = $header.Value }
|
||||
Date { $web_request.Date = $header.Value }
|
||||
Host { $web_request.Host = $header.Value }
|
||||
If-Modified-Since { $web_request.IfModifiedSince = $header.Value }
|
||||
Range { $web_request.AddRange($header.Value) }
|
||||
Referer { $web_request.Referer = $header.Value }
|
||||
Transfer-Encoding {
|
||||
$web_request.SendChunked = $true
|
||||
$web_request.TransferEncoding = $header.Value
|
||||
}
|
||||
User-Agent { continue }
|
||||
default { $web_request.Headers.Add($header.Key, $header.Value) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# For backwards compatibility we need to support setting the User-Agent if the header was set in the task.
|
||||
# We just need to make sure that if an explicit http_agent module was set then that takes priority.
|
||||
if ($Headers -and $Headers.ContainsKey("User-Agent")) {
|
||||
$options = (Get-AnsibleWindowsWebRequestSpec).options
|
||||
if ($HttpAgent -eq $options.http_agent.default) {
|
||||
$HttpAgent = $Headers['User-Agent']
|
||||
} elseif ($null -ne $Module) {
|
||||
$Module.Warn("The 'User-Agent' header and the 'http_agent' was set, using the 'http_agent' for web request")
|
||||
}
|
||||
}
|
||||
$web_request.UserAgent = $HttpAgent
|
||||
|
||||
switch ($FollowRedirects) {
|
||||
none { $web_request.AllowAutoRedirect = $false }
|
||||
safe {
|
||||
if ($web_request.Method -in @("GET", "HEAD")) {
|
||||
$web_request.AllowAutoRedirect = $true
|
||||
} else {
|
||||
$web_request.AllowAutoRedirect = $false
|
||||
}
|
||||
}
|
||||
all { $web_request.AllowAutoRedirect = $true }
|
||||
}
|
||||
|
||||
if ($MaximumRedirection -eq 0) {
|
||||
$web_request.AllowAutoRedirect = $false
|
||||
} else {
|
||||
$web_request.MaximumAutomaticRedirections = $MaximumRedirection
|
||||
}
|
||||
}
|
||||
|
||||
return $web_request
|
||||
}
|
||||
|
||||
Function Invoke-AnsibleWindowsWebRequest {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Invokes a ScriptBlock with the WebRequest.
|
||||
|
||||
.DESCRIPTION
|
||||
Invokes the ScriptBlock and handle extra information like accessing the response stream, closing those streams
|
||||
safely as well as setting common module return values.
|
||||
|
||||
.PARAMETER Module
|
||||
The Ansible.Basic module to set the return values for. This will set the following return values;
|
||||
elapsed - The total time, in seconds, that it took to send the web request and process the response
|
||||
msg - The human readable description of the response status code
|
||||
status_code - An int that is the response status code
|
||||
|
||||
.PARAMETER Request
|
||||
The System.Net.WebRequest to call. This can either be manually crafted or created with
|
||||
Get-AnsibleWindowsWebRequest.
|
||||
|
||||
.PARAMETER Script
|
||||
The ScriptBlock to invoke during the web request. This ScriptBlock should take in the params
|
||||
Param ([System.Net.WebResponse]$Response, [System.IO.Stream]$Stream)
|
||||
|
||||
This scriptblock should manage the response based on what it need to do.
|
||||
|
||||
.PARAMETER Body
|
||||
An optional Stream to send to the target during the request.
|
||||
|
||||
.PARAMETER IgnoreBadResponse
|
||||
By default a WebException will be raised for a non 2xx status code and the Script will not be invoked. This
|
||||
parameter can be set to process all responses regardless of the status code.
|
||||
|
||||
.EXAMPLE Basic module that downloads a file
|
||||
$spec = @{
|
||||
options = @{
|
||||
path = @{ type = "path"; required = $true }
|
||||
}
|
||||
}
|
||||
$module = Ansible.Basic.AnsibleModule]::Create($args, $spec, @(Get-AnsibleWindowsWebRequestSpec))
|
||||
|
||||
$web_request = Get-AnsibleWindowsWebRequest -Module $module
|
||||
|
||||
Invoke-AnsibleWindowsWebRequest -Module $module -Request $web_request -Script {
|
||||
Param ([System.Net.WebResponse]$Response, [System.IO.Stream]$Stream)
|
||||
|
||||
$fs = [System.IO.File]::Create($module.Params.path)
|
||||
try {
|
||||
$Stream.CopyTo($fs)
|
||||
$fs.Flush()
|
||||
} finally {
|
||||
$fs.Dispose()
|
||||
}
|
||||
}
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[System.Object]
|
||||
[ValidateScript({ $_.GetType().FullName -eq 'Ansible.Basic.AnsibleModule' })]
|
||||
$Module,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[System.Net.WebRequest]
|
||||
$Request,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[ScriptBlock]
|
||||
$Script,
|
||||
|
||||
[AllowNull()]
|
||||
[System.IO.Stream]
|
||||
$Body,
|
||||
|
||||
[Switch]
|
||||
$IgnoreBadResponse
|
||||
)
|
||||
|
||||
$start = Get-Date
|
||||
if ($null -ne $Body) {
|
||||
$request_st = $Request.GetRequestStream()
|
||||
try {
|
||||
$Body.CopyTo($request_st)
|
||||
$request_st.Flush()
|
||||
} finally {
|
||||
$request_st.Close()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
try {
|
||||
$web_response = $Request.GetResponse()
|
||||
} catch [System.Net.WebException] {
|
||||
# A WebResponse with a status code not in the 200 range will raise a WebException. We check if the
|
||||
# exception raised contains the actual response and continue on if IgnoreBadResponse is set. We also
|
||||
# make sure we set the status_code return value on the Module object if possible
|
||||
|
||||
if ($_.Exception.PSObject.Properties.Name -match "Response") {
|
||||
$web_response = $_.Exception.Response
|
||||
|
||||
if (-not $IgnoreBadResponse -or $null -eq $web_response) {
|
||||
$Module.Result.msg = $_.Exception.StatusDescription
|
||||
$Module.Result.status_code = $_.Exception.Response.StatusCode
|
||||
throw $_
|
||||
}
|
||||
} else {
|
||||
throw $_
|
||||
}
|
||||
}
|
||||
|
||||
if ($Request.RequestUri.IsFile) {
|
||||
# A FileWebResponse won't have these properties set
|
||||
$Module.Result.msg = "OK"
|
||||
$Module.Result.status_code = 200
|
||||
} else {
|
||||
$Module.Result.msg = $web_response.StatusDescription
|
||||
$Module.Result.status_code = $web_response.StatusCode
|
||||
}
|
||||
|
||||
$response_stream = $web_response.GetResponseStream()
|
||||
try {
|
||||
# Invoke the ScriptBlock and pass in WebResponse and ResponseStream
|
||||
&$Script -Response $web_response -Stream $response_stream
|
||||
} finally {
|
||||
$response_stream.Dispose()
|
||||
}
|
||||
} finally {
|
||||
if ($web_response) {
|
||||
$web_response.Close()
|
||||
}
|
||||
$Module.Result.elapsed = ((Get-date) - $start).TotalSeconds
|
||||
}
|
||||
}
|
||||
|
||||
Function Get-AnsibleWindowsWebRequestSpec {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Used by modules to get the argument spec fragment for AnsibleModule.
|
||||
|
||||
.EXAMPLES
|
||||
$spec = @{
|
||||
options = @{}
|
||||
}
|
||||
$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec, @(Get-AnsibleWindowsWebRequestSpec))
|
||||
|
||||
.NOTES
|
||||
The options here are reflected in the doc fragment 'ansible.windows.web_request' at
|
||||
'plugins/doc_fragments/web_request.py'.
|
||||
#>
|
||||
@{
|
||||
options = @{
|
||||
url_method = @{ type = 'str' }
|
||||
follow_redirects = @{ type = 'str'; choices = @('all', 'none', 'safe'); default = 'safe' }
|
||||
headers = @{ type = 'dict' }
|
||||
http_agent = @{ type = 'str'; default = 'ansible-httpget' }
|
||||
maximum_redirection = @{ type = 'int'; default = 50 }
|
||||
url_timeout = @{ type = 'int'; default = 30 }
|
||||
validate_certs = @{ type = 'bool'; default = $true }
|
||||
|
||||
# Credential options
|
||||
client_cert = @{ type = 'str' }
|
||||
client_cert_password = @{ type = 'str'; no_log = $true }
|
||||
force_basic_auth = @{ type = 'bool'; default = $false }
|
||||
url_username = @{ type = 'str' }
|
||||
url_password = @{ type = 'str'; no_log = $true }
|
||||
use_default_credential = @{ type = 'bool'; default = $false }
|
||||
|
||||
# Proxy options
|
||||
use_proxy = @{ type = 'bool'; default = $true }
|
||||
proxy_url = @{ type = 'str' }
|
||||
proxy_username = @{ type = 'str' }
|
||||
proxy_password = @{ type = 'str'; no_log = $true }
|
||||
proxy_use_default_credential = @{ type = 'bool'; default = $false }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$export_members = @{
|
||||
Function = "Get-AnsibleWindowsWebRequest", "Get-AnsibleWindowsWebRequestSpec", "Invoke-AnsibleWindowsWebRequest"
|
||||
}
|
||||
Export-ModuleMember @export_members
|
@ -0,0 +1,219 @@
|
||||
#!powershell
|
||||
|
||||
# Copyright: (c) 2015, Corwin Brown <corwin@corwinbrown.com>
|
||||
# Copyright: (c) 2017, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
#AnsibleRequires -CSharpUtil Ansible.Basic
|
||||
#Requires -Module Ansible.ModuleUtils.CamelConversion
|
||||
#Requires -Module Ansible.ModuleUtils.FileUtil
|
||||
#Requires -Module Ansible.ModuleUtils.Legacy
|
||||
#AnsibleRequires -PowerShell ..module_utils.WebRequest
|
||||
|
||||
$spec = @{
|
||||
options = @{
|
||||
url = @{ type = "str"; required = $true }
|
||||
content_type = @{ type = "str" }
|
||||
body = @{ type = "raw" }
|
||||
dest = @{ type = "path" }
|
||||
creates = @{ type = "path" }
|
||||
removes = @{ type = "path" }
|
||||
return_content = @{ type = "bool"; default = $false }
|
||||
status_code = @{ type = "list"; elements = "int"; default = @(200) }
|
||||
|
||||
# Defined for ease of use and backwards compatibility
|
||||
url_timeout = @{
|
||||
aliases = "timeout"
|
||||
}
|
||||
url_method = @{
|
||||
aliases = "method"
|
||||
default = "GET"
|
||||
}
|
||||
|
||||
# Defined for the alias backwards compatibility, remove once aliases are removed
|
||||
url_username = @{
|
||||
aliases = @("user", "username")
|
||||
deprecated_aliases = @(
|
||||
@{ name = "user"; date = [DateTime]::ParseExact("2022-07-01", "yyyy-MM-dd", $null); collection_name = 'ansible.windows' },
|
||||
@{ name = "username"; date = [DateTime]::ParseExact("2022-07-01", "yyyy-MM-dd", $null); collection_name = 'ansible.windows' }
|
||||
)
|
||||
}
|
||||
url_password = @{
|
||||
aliases = @("password")
|
||||
deprecated_aliases = @(
|
||||
@{ name = "password"; date = [DateTime]::ParseExact("2022-07-01", "yyyy-MM-dd", $null); collection_name = 'ansible.windows' }
|
||||
)
|
||||
}
|
||||
}
|
||||
supports_check_mode = $true
|
||||
}
|
||||
$module = [Ansible.Basic.AnsibleModule]::Create($args, $spec, @(Get-AnsibleWindowsWebRequestSpec))
|
||||
|
||||
$url = $module.Params.url
|
||||
$method = $module.Params.url_method.ToUpper()
|
||||
$content_type = $module.Params.content_type
|
||||
$body = $module.Params.body
|
||||
$dest = $module.Params.dest
|
||||
$creates = $module.Params.creates
|
||||
$removes = $module.Params.removes
|
||||
$return_content = $module.Params.return_content
|
||||
$status_code = $module.Params.status_code
|
||||
|
||||
$JSON_CANDIDATES = @('text', 'json', 'javascript')
|
||||
|
||||
$module.Result.elapsed = 0
|
||||
$module.Result.url = $url
|
||||
|
||||
Function ConvertFrom-SafeJson {
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Safely convert a JSON string to an object, this is like ConvertFrom-Json except it respect -ErrorAction.
|
||||
|
||||
.PAREMTER InputObject
|
||||
The input object string to convert from.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param (
|
||||
[Parameter(Mandatory=$true)]
|
||||
[AllowEmptyString()]
|
||||
[AllowNull()]
|
||||
[String]
|
||||
$InputObject
|
||||
)
|
||||
|
||||
if (-not $InputObject) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
# Make sure we output the actual object without unpacking with the unary comma
|
||||
,[Ansible.Basic.AnsibleModule]::FromJson($InputObject)
|
||||
} catch [System.ArgumentException] {
|
||||
Write-Error -Message "Invalid json string as input object: $($_.Exception.Message)" -Exception $_.Exception
|
||||
}
|
||||
}
|
||||
|
||||
if (-not ($method -cmatch '^[A-Z]+$')) {
|
||||
$module.FailJson("Parameter 'method' needs to be a single word in uppercase, like GET or POST.")
|
||||
}
|
||||
|
||||
if ($creates -and (Test-AnsiblePath -Path $creates)) {
|
||||
$module.Result.skipped = $true
|
||||
$module.Result.msg = "The 'creates' file or directory ($creates) already exists."
|
||||
$module.ExitJson()
|
||||
}
|
||||
|
||||
if ($removes -and -not (Test-AnsiblePath -Path $removes)) {
|
||||
$module.Result.skipped = $true
|
||||
$module.Result.msg = "The 'removes' file or directory ($removes) does not exist."
|
||||
$module.ExitJson()
|
||||
}
|
||||
|
||||
$client = Get-AnsibleWindowsWebRequest -Uri $url -Module $module
|
||||
|
||||
if ($null -ne $content_type) {
|
||||
$client.ContentType = $content_type
|
||||
}
|
||||
|
||||
$response_script = {
|
||||
param($Response, $Stream)
|
||||
|
||||
ForEach ($prop in $Response.PSObject.Properties) {
|
||||
$result_key = Convert-StringToSnakeCase -string $prop.Name
|
||||
$prop_value = $prop.Value
|
||||
# convert and DateTime values to ISO 8601 standard
|
||||
if ($prop_value -is [System.DateTime]) {
|
||||
$prop_value = $prop_value.ToString("o", [System.Globalization.CultureInfo]::InvariantCulture)
|
||||
}
|
||||
$module.Result.$result_key = $prop_value
|
||||
}
|
||||
|
||||
# manually get the headers as not all of them are in the response properties
|
||||
foreach ($header_key in $Response.Headers.GetEnumerator()) {
|
||||
$header_value = $Response.Headers[$header_key]
|
||||
$header_key = $header_key.Replace("-", "") # replace - with _ for snake conversion
|
||||
$header_key = Convert-StringToSnakeCase -string $header_key
|
||||
$module.Result.$header_key = $header_value
|
||||
}
|
||||
|
||||
# we only care about the return body if we need to return the content or create a file
|
||||
if ($return_content -or $dest) {
|
||||
# copy to a MemoryStream so we can read it multiple times
|
||||
$memory_st = New-Object -TypeName System.IO.MemoryStream
|
||||
try {
|
||||
$Stream.CopyTo($memory_st)
|
||||
|
||||
if ($return_content) {
|
||||
$memory_st.Seek(0, [System.IO.SeekOrigin]::Begin) > $null
|
||||
$content_bytes = $memory_st.ToArray()
|
||||
$module.Result.content = [System.Text.Encoding]::UTF8.GetString($content_bytes)
|
||||
if ($module.Result.ContainsKey("content_type") -and $module.Result.content_type -Match ($JSON_CANDIDATES -join '|')) {
|
||||
$json = ConvertFrom-SafeJson -InputObject $module.Result.content -ErrorAction SilentlyContinue
|
||||
if ($json) {
|
||||
$module.Result.json = $json
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($dest) {
|
||||
$memory_st.Seek(0, [System.IO.SeekOrigin]::Begin) > $null
|
||||
$changed = $true
|
||||
|
||||
if (Test-AnsiblePath -Path $dest) {
|
||||
$actual_checksum = Get-FileChecksum -path $dest -algorithm "sha1"
|
||||
|
||||
$sp = New-Object -TypeName System.Security.Cryptography.SHA1CryptoServiceProvider
|
||||
$content_checksum = [System.BitConverter]::ToString($sp.ComputeHash($memory_st)).Replace("-", "").ToLower()
|
||||
|
||||
if ($actual_checksum -eq $content_checksum) {
|
||||
$changed = $false
|
||||
}
|
||||
}
|
||||
|
||||
$module.Result.changed = $changed
|
||||
if ($changed -and (-not $module.CheckMode)) {
|
||||
$memory_st.Seek(0, [System.IO.SeekOrigin]::Begin) > $null
|
||||
$file_stream = [System.IO.File]::Create($dest)
|
||||
try {
|
||||
$memory_st.CopyTo($file_stream)
|
||||
} finally {
|
||||
$file_stream.Flush()
|
||||
$file_stream.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
$memory_st.Close()
|
||||
}
|
||||
}
|
||||
|
||||
if ($status_code -notcontains $Response.StatusCode) {
|
||||
$module.FailJson("Status code of request '$([int]$Response.StatusCode)' is not in list of valid status codes $status_code : $($Response.StatusCode)'.")
|
||||
}
|
||||
}
|
||||
|
||||
$body_st = $null
|
||||
if ($null -ne $body) {
|
||||
if ($body -is [System.Collections.IDictionary] -or $body -is [System.Collections.IList]) {
|
||||
$body_string = ConvertTo-Json -InputObject $body -Compress
|
||||
} elseif ($body -isnot [String]) {
|
||||
$body_string = $body.ToString()
|
||||
} else {
|
||||
$body_string = $body
|
||||
}
|
||||
$buffer = [System.Text.Encoding]::UTF8.GetBytes($body_string)
|
||||
|
||||
$body_st = New-Object -TypeName System.IO.MemoryStream -ArgumentList @(,$buffer)
|
||||
}
|
||||
|
||||
try {
|
||||
Invoke-AnsibleWindowsWebRequest -Module $module -Request $client -Script $response_script -Body $body_st -IgnoreBadResponse
|
||||
} catch {
|
||||
$module.FailJson("Unhandled exception occurred when sending web request. Exception: $($_.Exception.Message)", $_)
|
||||
} finally {
|
||||
if ($null -ne $body_st) {
|
||||
$body_st.Dispose()
|
||||
}
|
||||
}
|
||||
|
||||
$module.ExitJson()
|
@ -0,0 +1,155 @@
|
||||
#!/usr/bin/python
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
# Copyright: (c) 2015, Corwin Brown <corwin@corwinbrown.com>
|
||||
# Copyright: (c) 2017, Dag Wieers (@dagwieers) <dag@wieers.com>
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
DOCUMENTATION = r'''
|
||||
---
|
||||
module: win_uri
|
||||
short_description: Interacts with webservices
|
||||
description:
|
||||
- Interacts with FTP, HTTP and HTTPS web services.
|
||||
- Supports Digest, Basic and WSSE HTTP authentication mechanisms.
|
||||
- For non-Windows targets, use the M(ansible.builtin.uri) module instead.
|
||||
options:
|
||||
url:
|
||||
description:
|
||||
- Supports FTP, HTTP or HTTPS URLs in the form of (ftp|http|https)://host.domain:port/path.
|
||||
type: str
|
||||
required: yes
|
||||
content_type:
|
||||
description:
|
||||
- Sets the "Content-Type" header.
|
||||
type: str
|
||||
body:
|
||||
description:
|
||||
- The body of the HTTP request/response to the web service.
|
||||
type: raw
|
||||
dest:
|
||||
description:
|
||||
- Output the response body to a file.
|
||||
type: path
|
||||
creates:
|
||||
description:
|
||||
- A filename, when it already exists, this step will be skipped.
|
||||
type: path
|
||||
removes:
|
||||
description:
|
||||
- A filename, when it does not exist, this step will be skipped.
|
||||
type: path
|
||||
return_content:
|
||||
description:
|
||||
- Whether or not to return the body of the response as a "content" key in
|
||||
the dictionary result. If the reported Content-type is
|
||||
"application/json", then the JSON is additionally loaded into a key
|
||||
called C(json) in the dictionary results.
|
||||
type: bool
|
||||
default: no
|
||||
status_code:
|
||||
description:
|
||||
- A valid, numeric, HTTP status code that signifies success of the request.
|
||||
- Can also be comma separated list of status codes.
|
||||
type: list
|
||||
elements: int
|
||||
default: [ 200 ]
|
||||
|
||||
url_method:
|
||||
default: GET
|
||||
aliases:
|
||||
- method
|
||||
url_timeout:
|
||||
aliases:
|
||||
- timeout
|
||||
|
||||
# Following defined in the web_request fragment but the module contains deprecated aliases for backwards compatibility.
|
||||
url_username:
|
||||
description:
|
||||
- The username to use for authentication.
|
||||
- The alias I(user) and I(username) is deprecated and will be removed on
|
||||
the major release after C(2022-07-01).
|
||||
aliases:
|
||||
- user
|
||||
- username
|
||||
url_password:
|
||||
description:
|
||||
- The password for I(url_username).
|
||||
- The alias I(password) is deprecated and will be removed on the major
|
||||
release after C(2022-07-01).
|
||||
aliases:
|
||||
- password
|
||||
extends_documentation_fragment:
|
||||
- ansible.windows.web_request
|
||||
|
||||
seealso:
|
||||
- module: ansible.builtin.uri
|
||||
- module: ansible.windows.win_get_url
|
||||
author:
|
||||
- Corwin Brown (@blakfeld)
|
||||
- Dag Wieers (@dagwieers)
|
||||
'''
|
||||
|
||||
EXAMPLES = r'''
|
||||
- name: Perform a GET and Store Output
|
||||
ansible.windows.win_uri:
|
||||
url: http://example.com/endpoint
|
||||
register: http_output
|
||||
|
||||
# Set a HOST header to hit an internal webserver:
|
||||
- name: Hit a Specific Host on the Server
|
||||
ansible.windows.win_uri:
|
||||
url: http://example.com/
|
||||
method: GET
|
||||
headers:
|
||||
host: www.somesite.com
|
||||
|
||||
- name: Perform a HEAD on an Endpoint
|
||||
ansible.windows.win_uri:
|
||||
url: http://www.example.com/
|
||||
method: HEAD
|
||||
|
||||
- name: POST a Body to an Endpoint
|
||||
ansible.windows.win_uri:
|
||||
url: http://www.somesite.com/
|
||||
method: POST
|
||||
body: "{ 'some': 'json' }"
|
||||
'''
|
||||
|
||||
RETURN = r'''
|
||||
elapsed:
|
||||
description: The number of seconds that elapsed while performing the download.
|
||||
returned: always
|
||||
type: float
|
||||
sample: 23.2
|
||||
url:
|
||||
description: The Target URL.
|
||||
returned: always
|
||||
type: str
|
||||
sample: https://www.ansible.com
|
||||
status_code:
|
||||
description: The HTTP Status Code of the response.
|
||||
returned: success
|
||||
type: int
|
||||
sample: 200
|
||||
status_description:
|
||||
description: A summary of the status.
|
||||
returned: success
|
||||
type: str
|
||||
sample: OK
|
||||
content:
|
||||
description: The raw content of the HTTP response.
|
||||
returned: success and return_content is True
|
||||
type: str
|
||||
sample: '{"foo": "bar"}'
|
||||
content_length:
|
||||
description: The byte size of the response.
|
||||
returned: success
|
||||
type: int
|
||||
sample: 54447
|
||||
json:
|
||||
description: The json structure returned under content as a dictionary.
|
||||
returned: success and Content-Type is "application/json" or "application/javascript" and return_content is True
|
||||
type: dict
|
||||
sample: {"this-is-dependent": "on the actual return content"}
|
||||
'''
|
@ -1,131 +0,0 @@
|
||||
# This file is part of Ansible
|
||||
# -*- coding: utf-8 -*-
|
||||
#
|
||||
#
|
||||
# Ansible is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# Ansible is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
# Make coding more python3-ish
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
from units.compat.mock import call, patch, MagicMock
|
||||
|
||||
# docker images quay.io/ansible/centos7-test-container --format '{{json .}}'
|
||||
DOCKER_OUTPUT_MULTIPLE = """
|
||||
{"Containers":"N/A","CreatedAt":"2020-06-11 17:05:58 -0500 CDT","CreatedSince":"3 months ago","Digest":"\u003cnone\u003e","ID":"b0f914b26cc1","Repository":"quay.io/ansible/centos7-test-container","SharedSize":"N/A","Size":"556MB","Tag":"1.17.0","UniqueSize":"N/A","VirtualSize":"555.6MB"}
|
||||
{"Containers":"N/A","CreatedAt":"2020-06-11 17:05:58 -0500 CDT","CreatedSince":"3 months ago","Digest":"\u003cnone\u003e","ID":"b0f914b26cc1","Repository":"quay.io/ansible/centos7-test-container","SharedSize":"N/A","Size":"556MB","Tag":"latest","UniqueSize":"N/A","VirtualSize":"555.6MB"}
|
||||
{"Containers":"N/A","CreatedAt":"2019-04-01 19:59:39 -0500 CDT","CreatedSince":"18 months ago","Digest":"\u003cnone\u003e","ID":"dd3d10e03dd3","Repository":"quay.io/ansible/centos7-test-container","SharedSize":"N/A","Size":"678MB","Tag":"1.8.0","UniqueSize":"N/A","VirtualSize":"678MB"}
|
||||
""".lstrip() # noqa: E501
|
||||
|
||||
PODMAN_OUTPUT = """
|
||||
[
|
||||
{
|
||||
"id": "dd3d10e03dd3580de865560c3440c812a33fd7a1fca8ed8e4a1219ff3d809e3a",
|
||||
"names": [
|
||||
"quay.io/ansible/centos7-test-container:1.8.0"
|
||||
],
|
||||
"digest": "sha256:6e5d9c99aa558779715a80715e5cf0c227a4b59d95e6803c148290c5d0d9d352",
|
||||
"created": "2019-04-02T00:59:39.234584184Z",
|
||||
"size": 702761933
|
||||
},
|
||||
{
|
||||
"id": "b0f914b26cc1088ab8705413c2f2cf247306ceeea51260d64c26894190d188bd",
|
||||
"names": [
|
||||
"quay.io/ansible/centos7-test-container:latest"
|
||||
],
|
||||
"digest": "sha256:d8431aa74f60f4ff0f1bd36bc9a227bbb2066330acd8bf25e29d8614ee99e39c",
|
||||
"created": "2020-06-11T22:05:58.382459136Z",
|
||||
"size": 578513505
|
||||
}
|
||||
]
|
||||
""".lstrip()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def docker_images():
|
||||
from ansible_test._internal.docker_util import docker_images
|
||||
return docker_images
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ansible_test(ansible_test):
|
||||
import ansible_test
|
||||
return ansible_test
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subprocess_error():
|
||||
from ansible_test._internal.util import SubprocessError
|
||||
return SubprocessError
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
('returned_items_count', 'patched_dc_stdout'),
|
||||
(
|
||||
(3, (DOCKER_OUTPUT_MULTIPLE, '')),
|
||||
(2, (PODMAN_OUTPUT, '')),
|
||||
(0, ('', '')),
|
||||
),
|
||||
ids=('docker JSONL', 'podman JSON sequence', 'empty output'))
|
||||
def test_docker_images(docker_images, mocker, returned_items_count, patched_dc_stdout):
|
||||
mocker.patch(
|
||||
'ansible_test._internal.docker_util.docker_command',
|
||||
return_value=patched_dc_stdout)
|
||||
ret = docker_images('', 'quay.io/ansible/centos7-test-container')
|
||||
assert len(ret) == returned_items_count
|
||||
|
||||
|
||||
def test_podman_fallback(ansible_test, docker_images, subprocess_error, mocker):
|
||||
'''Test podman >2 && <2.2 fallback'''
|
||||
|
||||
cmd = ['docker', 'images', 'quay.io/ansible/centos7-test-container', '--format', '{{json .}}']
|
||||
docker_command_results = [
|
||||
subprocess_error(cmd, status=1, stderr='function "json" not defined'),
|
||||
(PODMAN_OUTPUT, ''),
|
||||
]
|
||||
mocker.patch(
|
||||
'ansible_test._internal.docker_util.docker_command',
|
||||
side_effect=docker_command_results)
|
||||
|
||||
ret = docker_images('', 'quay.io/ansible/centos7-test-container')
|
||||
calls = [
|
||||
call(
|
||||
'',
|
||||
['images', 'quay.io/ansible/centos7-test-container', '--format', '{{json .}}'],
|
||||
capture=True,
|
||||
always=True),
|
||||
call(
|
||||
'',
|
||||
['images', 'quay.io/ansible/centos7-test-container', '--format', 'json'],
|
||||
capture=True,
|
||||
always=True),
|
||||
]
|
||||
ansible_test._internal.docker_util.docker_command.assert_has_calls(calls)
|
||||
assert len(ret) == 2
|
||||
|
||||
|
||||
def test_podman_no_such_image(ansible_test, docker_images, subprocess_error, mocker):
|
||||
'''Test podman "no such image" error'''
|
||||
|
||||
cmd = ['docker', 'images', 'quay.io/ansible/centos7-test-container', '--format', '{{json .}}']
|
||||
exc = subprocess_error(cmd, status=1, stderr='no such image'),
|
||||
mocker.patch(
|
||||
'ansible_test._internal.docker_util.docker_command',
|
||||
side_effect=exc)
|
||||
ret = docker_images('', 'quay.io/ansible/centos7-test-container')
|
||||
assert ret == []
|
Loading…
Reference in New Issue