From 6e2897647c77910c0fc5c4306f381f1b5cd00635 Mon Sep 17 00:00:00 2001 From: Jordan Borean Date: Sat, 13 Oct 2018 08:20:00 +1000 Subject: [PATCH] win test: add http tester container to Windows tests (#46606) --- .../targets/prepare_http_tests/tasks/main.yml | 34 ++- test/integration/targets/win_uri/aliases | 2 +- .../targets/win_uri/defaults/main.yml | 3 +- .../integration/targets/win_uri/meta/main.yml | 3 + test/runner/lib/cli.py | 1 + test/runner/lib/executor.py | 53 +++- test/runner/lib/manage_ci.py | 37 ++- test/runner/setup/windows-httptester.ps1 | 227 ++++++++++++++++++ 8 files changed, 353 insertions(+), 7 deletions(-) create mode 100644 test/integration/targets/win_uri/meta/main.yml create mode 100644 test/runner/setup/windows-httptester.ps1 diff --git a/test/integration/targets/prepare_http_tests/tasks/main.yml b/test/integration/targets/prepare_http_tests/tasks/main.yml index c8c7fa99e5..e022fb9dd6 100644 --- a/test/integration/targets/prepare_http_tests/tasks/main.yml +++ b/test/integration/targets/prepare_http_tests/tasks/main.yml @@ -3,6 +3,11 @@ - set_fact: has_httptester: "{{ lookup('env', 'HTTPTESTER') != '' }}" +- name: make sure we have the ansible_os_family and ansible_distribution_version facts + setup: + gather_subset: distribution + when: ansible_facts == {} + # If we are running with access to a httptester container, grab it's cacert and install it - block: # Override hostname defaults with httptester linked names @@ -22,6 +27,16 @@ get_url: url: "http://ansible.http.tests/{{ item }}" dest: "{{ output_dir }}/{{ item }}" + when: ansible_os_family != 'Windows' + with_items: + - client.pem + - client.key + + - name: Windows - Get client cert/key + win_get_url: + url: http://ansible.http.tests/{{ item }} + dest: '{{ win_output_dir }}\{{ item }}' + when: ansible_os_family == 'Windows' with_items: - client.pem - client.key @@ -38,6 +53,12 @@ dest: "/usr/local/share/ca-certificates/ansible.crt" when: ansible_os_family == 'Debian' + - name: Windows - Retrieve test cacert + win_get_url: + url: http://ansible.http.tests/cacert.pem + dest: '{{ win_output_dir }}\cacert.pem' + when: ansible_os_family == 'Windows' + - name: Redhat - Update ca trust command: update-ca-trust extract when: ansible_os_family == 'RedHat' @@ -46,6 +67,14 @@ command: update-ca-certificates when: ansible_os_family == 'Debian' or ansible_os_family == 'Suse' + - name: Windows - Update ca trust + win_certificate_store: + path: '{{ win_output_dir }}\cacert.pem' + state: present + store_location: LocalMachine + store_name: Root + when: ansible_os_family == 'Windows' + - name: FreeBSD - Retrieve test cacert get_url: url: "http://ansible.http.tests/cacert.pem" @@ -68,4 +97,7 @@ command: /usr/local/opt/openssl/bin/c_rehash when: ansible_os_family == 'Darwin' - when: has_httptester|bool + when: + - has_httptester|bool + # skip the setup if running on Windows Server 2008 as httptester is not available + - ansible_os_family != 'Windows' or (ansible_os_family == 'Windows' and not ansible_distribution_version.startswith("6.0.")) diff --git a/test/integration/targets/win_uri/aliases b/test/integration/targets/win_uri/aliases index f88e7c8109..4f7f44e704 100644 --- a/test/integration/targets/win_uri/aliases +++ b/test/integration/targets/win_uri/aliases @@ -1,3 +1,3 @@ shippable/windows/group3 -unstable +needs/httptester skip/windows/2008 # httptester requires SSH which doesn't work with 2008 diff --git a/test/integration/targets/win_uri/defaults/main.yml b/test/integration/targets/win_uri/defaults/main.yml index 1a02bcaafe..e5ca7935c1 100644 --- a/test/integration/targets/win_uri/defaults/main.yml +++ b/test/integration/targets/win_uri/defaults/main.yml @@ -1,3 +1,2 @@ --- -test_uri_path: C:\ansible\win_uri -httpbin_host: httpbin.org +test_uri_path: '{{ win_output_dir }}\win_uri' diff --git a/test/integration/targets/win_uri/meta/main.yml b/test/integration/targets/win_uri/meta/main.yml new file mode 100644 index 0000000000..e0a6795056 --- /dev/null +++ b/test/integration/targets/win_uri/meta/main.yml @@ -0,0 +1,3 @@ +dependencies: +- prepare_win_tests +- prepare_http_tests diff --git a/test/runner/lib/cli.py b/test/runner/lib/cli.py index 3d23a1673f..f454e75c49 100644 --- a/test/runner/lib/cli.py +++ b/test/runner/lib/cli.py @@ -333,6 +333,7 @@ def parse_args(): config=WindowsIntegrationConfig) add_extra_docker_options(windows_integration, integration=False) + add_httptester_options(windows_integration, argparse) windows_integration.add_argument('--windows', metavar='VERSION', diff --git a/test/runner/lib/executor.py b/test/runner/lib/executor.py index 84aacf3615..c422022a06 100644 --- a/test/runner/lib/executor.py +++ b/test/runner/lib/executor.py @@ -60,6 +60,8 @@ from lib.util import ( from lib.docker_util import ( docker_pull, docker_run, + docker_available, + docker_rm, get_docker_container_id, get_docker_container_ip, ) @@ -507,6 +509,8 @@ def command_windows_integration(args): all_targets = tuple(walk_windows_integration_targets(include_hidden=True)) internal_targets = command_integration_filter(args, all_targets, init_callback=windows_init) instances = [] # type: list [lib.thread.WrappedThread] + use_httptester = False + httptester_id = None if args.windows: get_coverage_path(args) # initialize before starting threads @@ -533,12 +537,58 @@ def command_windows_integration(args): with open(filename, 'w') as inventory_fd: inventory_fd.write(inventory) + use_httptester = args.httptester and any('needs/httptester/' in t.aliases for t in internal_targets) + # if running under Docker delegation, the httptester may have already been started + docker_httptester = bool(os.environ.get("HTTPTESTER", False)) + + if use_httptester and not docker_available() and not docker_httptester: + display.warning('Assuming --disable-httptester since `docker` is not available.') + use_httptester = False + + if use_httptester: + if docker_httptester: + # we are running in a Docker container that is linked to the httptester container, we just need to + # forward these requests to the linked hostname + first_host = HTTPTESTER_HOSTS[0] + ssh_options = ["-R", "8080:%s:80" % first_host, "-R", "8443:%s:443" % first_host] + else: + # we are running directly and need to start the httptester container ourselves and forward the port + # from there manually set so HTTPTESTER env var is set during the run + args.inject_httptester = True + httptester_id, ssh_options = start_httptester(args) + + # to get this SSH command to run in the background we need to set to run in background (-f) and disable + # the pty allocation (-T) + ssh_options.insert(0, "-fT") + + # create a script that will continue to run in the background until the script is deleted, this will + # cleanup and close the connection + watcher_path = "ansible-test-http-watcher-%s.ps1" % time.time() + for remote in [r for r in remotes if r.version != '2008']: + manage = ManageWindowsCI(remote) + manage.upload("test/runner/setup/windows-httptester.ps1", watcher_path) + + # need to use -Command as we cannot pass an array of values with -File + script = "powershell.exe -NoProfile -Command .\\%s -Hosts %s" % (watcher_path, ", ".join(HTTPTESTER_HOSTS)) + if args.verbosity > 3: + script += " -Verbose" + manage.ssh(script, options=ssh_options, force_pty=False) + success = False try: command_integration_filtered(args, internal_targets, all_targets) success = True finally: + if use_httptester: + if httptester_id: + docker_rm(args, httptester_id) + + for remote in [r for r in remotes if r.version != '2008']: + # delete the tmp file that keeps the http-tester alive + manage = ManageWindowsCI(remote) + manage.ssh("del %s /F /Q" % watcher_path) + if args.remote_terminate == 'always' or (args.remote_terminate == 'success' and success): for instance in instances: instance.result.stop() @@ -736,7 +786,8 @@ def command_integration_filtered(args, targets, all_targets): display.warning('SSH service not responding. Waiting %d second(s) before checking again.' % seconds) time.sleep(seconds) - if args.inject_httptester: + # Windows is different as Ansible execution is done locally but the host is remote + if args.inject_httptester and not isinstance(args, WindowsIntegrationConfig): inject_httptester(args) start_at_task = args.start_at_task diff --git a/test/runner/lib/manage_ci.py b/test/runner/lib/manage_ci.py index b01688af0d..a8b64098e0 100644 --- a/test/runner/lib/manage_ci.py +++ b/test/runner/lib/manage_ci.py @@ -75,24 +75,57 @@ class ManageWindowsCI(object): raise ApplicationError('Timeout waiting for %s/%s instance %s.' % (self.core_ci.platform, self.core_ci.version, self.core_ci.instance_id)) - def ssh(self, command, options=None): + def download(self, remote, local): + """ + :type remote: str + :type local: str + """ + self.scp('%s@%s:%s' % (self.core_ci.connection.username, self.core_ci.connection.hostname, remote), local) + + def upload(self, local, remote): + """ + :type local: str + :type remote: str + """ + self.scp(local, '%s@%s:%s' % (self.core_ci.connection.username, self.core_ci.connection.hostname, remote)) + + def ssh(self, command, options=None, force_pty=True): """ :type command: str | list[str] :type options: list[str] | None + :type force_pty: bool """ if not options: options = [] + if force_pty: + options.append('-tt') if isinstance(command, list): command = ' '.join(pipes.quote(c) for c in command) run_command(self.core_ci.args, - ['ssh', '-tt', '-q'] + self.ssh_args + + ['ssh', '-q'] + self.ssh_args + options + ['-p', '22', '%s@%s' % (self.core_ci.connection.username, self.core_ci.connection.hostname)] + [command]) + def scp(self, src, dst): + """ + :type src: str + :type dst: str + """ + for dummy in range(1, 10): + try: + run_command(self.core_ci.args, + ['scp'] + self.ssh_args + + ['-P', '22', '-q', '-r', src, dst]) + return + except SubprocessError: + time.sleep(10) + + raise ApplicationError('Failed transfer: %s -> %s' % (src, dst)) + class ManageNetworkCI(object): """Manage access to a network instance provided by Ansible Core CI.""" diff --git a/test/runner/setup/windows-httptester.ps1 b/test/runner/setup/windows-httptester.ps1 new file mode 100644 index 0000000000..27abad0183 --- /dev/null +++ b/test/runner/setup/windows-httptester.ps1 @@ -0,0 +1,227 @@ +<# +.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 foward ports 8080 and 8443 to the +httptester container. + +.PARAMETER Hosts +A list of hostnames to add to the Windows hosts file for the httptester +container. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory=$true, Position=0)][String[]]$Hosts +) + +$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 +} +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://s3.amazonaws.com/ansible-ci-files/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) +}