diff --git a/lib/ansible/modules/windows/win_wait_for.ps1 b/lib/ansible/modules/windows/win_wait_for.ps1 new file mode 100644 index 00000000000..068736c5828 --- /dev/null +++ b/lib/ansible/modules/windows/win_wait_for.ps1 @@ -0,0 +1,269 @@ +#!powershell +# This file is part of Ansible + +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +#Requires -Module Ansible.ModuleUtils.Legacy.psm1 + +$ErrorActionPreference = "Stop" + +$params = Parse-Args -arguments $args -supports_check_mode $true + +$connect_timeout = Get-AnsibleParam -obj $params -name "connect_timeout" -type "int" -default 5 +$delay = Get-AnsibleParam -obj $params -name "delay" -type "int" +$exclude_hosts = Get-AnsibleParam -obj $params -name "exclude_hosts" -type "list" +$hostname = Get-AnsibleParam -obj $params -name "host" -type "str" -default "127.0.0.1" +$path = Get-AnsibleParam -obj $params -name "path" -type "path" +$port = Get-AnsibleParam -obj $params -name "port" -type "int" +$search_regex = Get-AnsibleParam -obj $params -name "search_regex" -type "string" +$sleep = Get-AnsibleParam -obj $params -name "sleep" -type "int" -default 1 +$state = Get-AnsibleParam -obj $params -name "state" -type "str" -default "started" -validateset "present","started","stopped","absent","drained" +$timeout = Get-AnsibleParam -obj $params -name "timeout" -type "int" -default 300 + +$result = @{ + changed = $false +} + +# validate the input with the various options +if ($port -ne $null -and $path -ne $null) { + Fail-Json $result "port and path parameter can not both be passed to win_wait_for" +} +if ($exclude_hosts -ne $null -and $state -ne "drained") { + Fail-Json $result "exclude_hosts should only be with state=drained" +} +if ($path -ne $null) { + if ($state -in @("stopped","drained")) { + Fail-Json $result "state=$state should only be used for checking a port in the win_wait_for module" + } + + if ($exclude_hosts -ne $null) { + Fail-Json $result "exclude_hosts should only be used when checking a port and state=drained in the win_wait_for module" + } +} + +if ($port -ne $null) { + if ($search_regex -ne $null) { + Fail-Json $result "search_regex should by used when checking a string in a file in the win_wait_for module" + } + + if ($exclude_hosts -ne $null -and $state -ne "drained") { + Fail-Json $result "exclude_hosts should be used when state=drained in the win_wait_for module" + } +} + +Function Test-Port($hostname, $port) { + # try and resolve the IP/Host, if it fails then just use the host passed in + try { + $resolve_hostname = ([System.Net.Dns]::GetHostEntry($hostname)).HostName + } catch { + # oh well just use the IP addres + $resolve_hostname = $hostname + } + + $timeout = $connect_timeout * 1000 + $socket = New-Object -TypeName System.Net.Sockets.TcpClient + $connect = $socket.BeginConnect($resolve_hostname, $port, $null, $null) + $wait = $connect.AsyncWaitHandle.WaitOne($timeout, $false) + + if ($wait) { + try { + $socket.EndConnect($connect) | Out-Null + $valid = $true + } catch { + $valid = $false + } + } else { + $valid = $false + } + + $socket.Close() + $socket.Dispose() + + $valid +} + +Function Get-PortConnections($hostname, $port) { + $connections = @() + + $conn_info = [Net.NetworkInformation.IPGlobalProperties]::GetIPGlobalProperties() + if ($hostname -eq "0.0.0.0") { + $active_connections = $conn_info.GetActiveTcpConnections() | Where-Object { $_.LocalEndPoint.Port -eq $port } + } else { + $active_connections = $conn_info.GetActiveTcpConnections() | Where-Object { $_.LocalEndPoint.Address -eq $hostname -and $_.LocalEndPoint.Port -eq $port } + } + + if ($active_connections -ne $null) { + foreach ($active_connection in $active_connections) { + $connections += $active_connection.RemoteEndPoint.Address + } + } + + $connections +} + +$module_start = Get-Date + +if ($delay -ne $null) { + Start-Sleep -Seconds $delay +} + +$attempts = 0 +if ($path -eq $null -and $port -eq $null -and $state -eq "drained") { + Start-Sleep -Seconds $timeout +} elseif ($path -ne $null) { + if ($state -in @("present", "started")) { + # check if the file exists or string exists in file + $start_time = Get-Date + $complete = $false + while (((Get-Date) - $start_time).TotalSeconds -lt $timeout) { + $attempts += 1 + if (Test-Path -Path $path) { + if ($search_regex -eq $null) { + $complete = $true + break + } else { + $file_contents = Get-Content -Path $path -Raw + if ($file_contents -match $search_regex) { + $complete = $true + break + } + } + } + Start-Sleep -Seconds $sleep + } + + if ($complete -eq $false) { + $elapsed_seconds = ((Get-Date) - $module_start).TotalSeconds + $result.attempts = $attempts + $result.elapsed = $elapsed_seconds + if ($search_regex -eq $null) { + Fail-Json $result "timeout while waiting for file $path to be present" + } else { + Fail-Json $result "timeout while waiting for string regex $search_regex in file $path to match" + } + } + } elseif ($state -in @("absent")) { + # check if the file is deleted or string doesn't exist in file + $start_time = Get-Date + $complete = $false + while (((Get-Date) - $start_time).TotalSeconds -lt $timeout) { + $attempts += 1 + if (Test-Path -Path $path) { + if ($search_regex -ne $null) { + $file_contents = Get-Content -Path $path -Raw + if ($file_contents -notmatch $search_regex) { + $complete = $true + break + } + } + } else { + $complete = $true + break + } + + Start-Sleep -Seconds $sleep + } + + if ($complete -eq $false) { + $elapsed_seconds = ((Get-Date) - $module_start).TotalSeconds + $result.attempts = $attempts + $result.elapsed = $elapsed_seconds + if ($search_regex -eq $null) { + Fail-Json $result "timeout while waiting for file $path to be absent" + } else { + Fail-Json $result "timeout while waiting for string regex $search_regex in file $path to not match" + } + } + } +} elseif ($port -ne $null) { + if ($state -in @("started","present")) { + # check that the port is online and is listening + $start_time = Get-Date + $complete = $false + while (((Get-Date) - $start_time).TotalSeconds -lt $timeout) { + $attempts += 1 + $port_result = Test-Port -hostname $hostname -port $port + if ($port_result -eq $true) { + $complete = $true + break + } + + Start-Sleep -Seconds $sleep + } + + if ($complete -eq $false) { + $elapsed_seconds = ((Get-Date) - $module_start).TotalSeconds + $result.attempts = $attempts + $result.elapsed = $elapsed_seconds + Fail-Json $result "timeout while waiting for $($hostname):$port to start listening" + } + } elseif ($state -in @("stopped","absent")) { + # check that the port is offline and is not listening + $start_time = Get-Date + $complete = $false + while (((Get-Date) - $start_time).TotalSeconds -lt $timeout) { + $attempts += 1 + $port_result = Test-Port -hostname $hostname -port $port + if ($port_result -eq $false) { + $complete = $true + break + } + + Start-Sleep -Seconds $sleep + } + + if ($complete -eq $false) { + $elapsed_seconds = ((Get-Date) - $module_start).TotalSeconds + $result.attempts = $attempts + $result.elapsed = $elapsed_seconds + Fail-Json $result "timeout while waiting for $($hostname):$port to stop listening" + } + } elseif ($state -eq "drained") { + # check that the local port is online but has no active connections + $start_time = Get-Date + $complete = $false + while (((Get-Date) - $start_time).TotalSeconds -lt $timeout) { + $attempts += 1 + $active_connections = Get-PortConnections -hostname $hostname -port $port + if ($active_connections -eq $null) { + $complete = $true + break + } elseif ($active_connections.Count -eq 0) { + # no connections on port + $complete = $true + break + } else { + # there are listeners, check if we should ignore any hosts + if ($exclude_hosts -ne $null) { + $connection_info = $active_connections + foreach ($exclude_host in $exclude_hosts) { + try { + $exclude_ips = [System.Net.Dns]::GetHostAddresses($exclude_host) | ForEach-Object { Write-Output $_.IPAddressToString } + $connection_info = $connection_info | Where-Object { $_ -notin $exclude_ips } + } catch {} # ignore invalid hostnames + } + + if ($connection_info.Count -eq 0) { + $complete = $true + break + } + } + } + + Start-Sleep -Seconds $sleep + } + + if ($complete -eq $false) { + $elapsed_seconds = ((Get-Date) - $module_start).TotalSeconds + $result.attempts = $attempts + $result.elapsed = $elapsed_seconds + Fail-Json $result "timeout while waiting for $($hostname):$port to drain" + } + } +} + +$result.attempts = $attempts +$result.elapsed = ((Get-Date) - $module_start).TotalSeconds + +Exit-Json $result diff --git a/lib/ansible/modules/windows/win_wait_for.py b/lib/ansible/modules/windows/win_wait_for.py new file mode 100644 index 00000000000..b3313db642a --- /dev/null +++ b/lib/ansible/modules/windows/win_wait_for.py @@ -0,0 +1,142 @@ +#!/usr/bin/python +# This file is part of Ansible + +# Copyright (c) 2017 Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# this is a windows documentation stub, actual code lives in the .ps1 +# file of the same name + +ANSIBLE_METADATA = {'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community'} + + +DOCUMENTATION = r''' +--- +module: win_wait_for +version_added: '2.4' +short_description: Waits for a condition before continuing +description: +- You can wait for a set amount of time C(timeout), this is the default if + nothing is specified. +- Waiting for a port to become available is useful for when services are not + immediately available after their init scripts return which is true of + certain Java application servers. +- You can wait for a file to exist or not exist on the filesystem. +- This module can also be used to wait for a regex match string to be present + in a file. +- You can wait for active connections to be closed before continuing on a + local port. +options: + connect_timeout: + description: + - The maximum number of seconds to wait for a connection to happen before + closing and retrying. + default: 5 + delay: + description: + - The number of seconds to wait before starting to poll. + exclude_hosts: + description: + - The list of hosts or IPs to ignore when looking for active TCP + connections when C(state=drained). + host: + description: + - A resolvable hostname or IP address to wait for. + - If C(state=drained) then it will only check for connections on the IP + specified, you can use '0.0.0.0' to use all host IPs. + default: '127.0.0.1' + path: + description: + - The path to a file on the filesystem to check. + - If C(state) is present or started then it will wait until the file + exists. + - If C(state) is absent then it will wait until the file does not exist. + port: + description: + - The port number to poll on C(host). + search_regex: + description: + - Can be used to match a string in a file. + - If C(state) is present or started then it will wait until the regex + matches. + - If C(state) is absent then it will wait until the regex does not match. + - Defaults to a multiline regex. + sleep: + description: + - Number of seconds to sleep between checks. + default: 1 + state: + description: + - When checking a port, C(started) will ensure the port is open, C(stopped) + will check that is it closed and C(drained) will check for active + connections. + - When checking for a file or a search string C(present) or C(started) will + ensure that the file or string is present, C(absent) will check that the + file or search string is absent or removed. + default: started + choices: [ present, started, stopped, absent, drained ] + timeout: + description: + - The maximum number of seconds to wait for. + default: 300 +author: +- Jordan Borean (@jborean93) +''' + +EXAMPLES = r''' +- name: wait 300 seconds for port 8000 to become open on the host, don't start checking for 10 seconds + win_wait_for: + port: 8000 + delay: 10 + +- name: wait 150 seconds for port 8000 of any IP to close active connections + win_wait_for: + host: 0.0.0.0 + port: 8000 + state: drained + timeout: 150 + +- name: wait for port 8000 of any IP to close active connection, ignoring certain hosts + win_wait_for: + host: 0.0.0.0 + port: 8000 + state: drained + exclude_hosts: ['10.2.1.2', '10.2.1.3'] + +- name: wait for file C:\temp\log.txt to exist before continuing + win_wait_for: + path: C:\temp\log.txt + +- name: wait until process complete is in the file before continuing + win_wait_for: + path: C:\temp\log.txt + search_regex: process complete + +- name: wait until file if removed + win_wait_for: + path: C:\temp\log.txt + state: absent + +- name: wait until port 1234 is offline but try every 10 seconds + win_wait_for: + port: 1234 + state: absent + sleep: 10 +''' + +RETURN = r''' +attempts: + description: The number of attempts to poll the file or port before module + finishes. + returned: always + type: int + sample: 1 +elapsed: + description: The elapsed seconds between the start of poll and the end of the + module. This includes the delay if the option is set. + returned: always + type: float + sample: 2.1406487 +''' diff --git a/test/integration/targets/win_wait_for/aliases b/test/integration/targets/win_wait_for/aliases new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/integration/targets/win_wait_for/defaults/main.yml b/test/integration/targets/win_wait_for/defaults/main.yml new file mode 100644 index 00000000000..9e1155e3291 --- /dev/null +++ b/test/integration/targets/win_wait_for/defaults/main.yml @@ -0,0 +1,2 @@ +test_win_wait_for_path: C:\ansible\win_wait_for +test_win_wait_for_port: 1234 diff --git a/test/integration/targets/win_wait_for/tasks/main.yml b/test/integration/targets/win_wait_for/tasks/main.yml new file mode 100644 index 00000000000..a6272805d02 --- /dev/null +++ b/test/integration/targets/win_wait_for/tasks/main.yml @@ -0,0 +1,316 @@ +--- +- name: ensure test folder is deleted for clean slate + win_file: + path: '{{test_win_wait_for_path}}' + state: absent + +- name: ensure test folder exists + win_file: + path: '{{test_win_wait_for_path}}' + state: directory + +- name: template out the test server + win_template: + src: http-server.ps1 + dest: '{{test_win_wait_for_path}}\http-server.ps1' + +# invalid arguments +- name: fail to set port and path + win_wait_for: + path: a + port: 0 + register: fail_port_and_path + failed_when: fail_port_and_path.msg != 'port and path parameter can not both be passed to win_wait_for' + +- name: fail to set exclude_hosts when state isn't drain + win_wait_for: + port: 0 + exclude_hosts: a + state: present + register: fail_exclude_hosts_not_drained + failed_when: fail_exclude_hosts_not_drained.msg != 'exclude_hosts should only be with state=drained' + +- name: fail to set state drained with path + win_wait_for: + path: a + state: drained + register: fail_path_drained + failed_when: fail_path_drained.msg != 'state=drained should only be used for checking a port in the win_wait_for module' + +- name: fail to set exclude_hosts with path + win_wait_for: + path: a + exclude_hosts: a + register: fail_path_exclude_hosts + failed_when: fail_path_exclude_hosts.msg != 'exclude_hosts should only be with state=drained' + +- name: fail to set search_regex with port + win_wait_for: + port: 0 + search_regex: a + register: fail_port_search_regex + failed_when: fail_port_search_regex.msg != 'search_regex should by used when checking a string in a file in the win_wait_for module' + +- name: fail to set exclude_hosts with port whens tate is not drained + win_wait_for: + port: 0 + exclude_hosts: a + state: present + register: fail_port_exclude_hosts_not_drained + failed_when: fail_port_exclude_hosts_not_drained.msg != 'exclude_hosts should only be with state=drained' + +# path tests +- name: timeout while waiting for file + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: present + timeout: 5 + register: fail_timeout_file_present + ignore_errors: True + +- name: assert timeout while waiting for file + assert: + that: + - fail_timeout_file_present.msg == 'timeout while waiting for file ' + test_win_wait_for_path + '\\test.txt to be present' + - fail_timeout_file_present.attempts == 5 + - fail_timeout_file_present.elapsed > 5 + +- name: wait for file to not exist - non existing file + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: absent + register: wait_remove_no_file + +- name: assert wait for file to not exist - non existing file + assert: + that: + - wait_remove_no_file.attempts == 1 + +- name: create file for next test + win_file: + path: '{{test_win_wait_for_path}}\test.txt' + state: touch + +- name: run async task to remove file after a timeout + win_shell: Start-Sleep -Seconds 5; Remove-Item -Path '{{test_win_wait_for_path}}\test.txt' -Force + async: 30 + poll: 0 + +- name: wait for file to not exist - existing file + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: absent + register: wait_remove_existing_file + +- name: assert wait for file to not exist - existing file + assert: + that: + - wait_remove_existing_file.attempts > 1 + +- name: run async task to create file after a timeout + win_shell: Start-Sleep -Seconds 5; New-Item -Path '{{test_win_wait_for_path}}\test.txt' -Type File + async: 30 + poll: 0 + +- name: wait for file to exist - non existing file + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: present + register: wait_new_missing_file + +- name: assert wait for file to exist - non existing file + assert: + that: + - wait_new_missing_file.attempts > 1 + +- name: wait for file to exist - existing file + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: present + register: wait_new_existing_file + +- name: assert wait for file to exist - existing file + assert: + that: + - wait_new_existing_file.attempts == 1 + +- name: timeout while waiting for file to not exist + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: absent + timeout: 5 + register: fail_timeout_file_absent + ignore_errors: True + +- name: assert timeout while waiting for file to not exist + assert: + that: + - fail_timeout_file_absent.msg == 'timeout while waiting for file ' + test_win_wait_for_path + '\\test.txt to be absent' + - fail_timeout_file_absent.attempts == 5 + - fail_timeout_file_absent.elapsed > 5 + +- name: run async task to populate file contents + win_shell: Start-Sleep -Seconds 5; Set-Content -Path '{{test_win_wait_for_path}}\test.txt' -Value 'hello world`r`nfile contents`r`nEnd line' + async: 30 + poll: 0 + +- name: wait for file contents to match regex - empty file + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: present + search_regex: file c.* + register: wait_regex_match_new + +- name: assert wait for file contents to match regex - empty file + assert: + that: + - wait_regex_match_new.attempts > 1 + +- name: wait for file contents to match regex - existing file + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: present + search_regex: file c.* + register: wait_regex_match_existing + +- name: assert wait for file contents to match regex - existing file + assert: + that: + - wait_regex_match_existing.attempts == 1 + +- name: run async task to clear file contents + win_shell: Start-Sleep -Seconds 5; Set-Content -Path '{{test_win_wait_for_path}}\test.txt' -Value 'hello world`r`nother contents for file`r`nEnd line' + async: 30 + poll: 0 + +- name: wait for file content to not match regex + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: absent + search_regex: file c.* + register: wait_regex_match_absent_remove + +- name: assert wait for file content to not match regex + assert: + that: + - wait_regex_match_absent_remove.attempts > 1 + +- name: wait for file content to not match regex - existing + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: absent + search_regex: file c.* + register: wait_regex_match_absent_existing + +- name: assert wait for file content to not match regex + assert: + that: + - wait_regex_match_absent_existing.attempts == 1 + +- name: remove file to test search_regex works on missing files + win_file: + path: '{{test_win_wait_for_path}}\test.txt' + state: absent + +- name: wait for file content to not match regex - missing file + win_wait_for: + path: '{{test_win_wait_for_path}}\test.txt' + state: absent + search_regex: file c.* + register: wait_regex_match_absent_missing + +- name: assert wait for file content to not match regex - missing file + assert: + that: + - wait_regex_match_absent_missing.attempts == 1 + +# port tests +- name: timeout waiting for port to come online + win_wait_for: + port: '{{test_win_wait_for_port}}' + timeout: 5 + state: started + register: fail_timeout_port_online + ignore_errors: True + +- name: assert timeout while waiting for port to come online + assert: + that: + - "fail_timeout_port_online.msg == 'timeout while waiting for 127.0.0.1:' + test_win_wait_for_port|string + ' to start listening'" + - fail_timeout_port_online.attempts > 1 + - fail_timeout_port_online.elapsed > 5 + +- name: run async task to start web server + win_shell: Start-Sleep -Seconds 5; {{test_win_wait_for_path}}\http-server.ps1 + async: 30 + poll: 0 + +- name: wait for port to come online + win_wait_for: + port: '{{test_win_wait_for_port}}' + state: started + register: wait_for_port_to_start + +- name: assert wait for port to come online + assert: + that: + - wait_for_port_to_start.attempts > 1 + +- name: start web server + win_shell: '{{test_win_wait_for_path}}\http-server.ps1' + async: 30 + poll: 0 + +- name: wait for port that is already online + win_wait_for: + port: '{{test_win_wait_for_port}}' + state: started + register: wait_for_port_already_started + +- name: assert wait for port that is already online + assert: + that: + - wait_for_port_already_started.attempts == 1 + +- name: wait for port that is already offline + win_wait_for: + port: '{{test_win_wait_for_port}}' + state: stopped + register: wait_for_port_already_stopped + +- name: assert wait for port that is already offline + assert: + that: + - wait_for_port_already_stopped.attempts == 1 + +- name: start web server for offline port test + win_shell: '{{test_win_wait_for_path}}\http-server.ps1' + async: 30 + poll: 0 + +- name: wait for port to go offline + win_wait_for: + port: '{{test_win_wait_for_port}}' + state: stopped + register: wait_for_port_to_be_stopped + +- name: assert wait for port to go offline + assert: + that: + - wait_for_port_to_be_stopped.attempts > 1 + +- name: wait for offline port to be drained + win_wait_for: + port: '{{test_win_wait_for_port}}' + state: drained + register: wait_for_drained_port_no_port + +- name: assert wait for offline port to be drained + assert: + that: + - wait_for_drained_port_no_port.attempts == 1 + +- name: clear testing folder + win_file: + path: '{{test_win_wait_for_path}}' + state: absent diff --git a/test/integration/targets/win_wait_for/templates/http-server.ps1 b/test/integration/targets/win_wait_for/templates/http-server.ps1 new file mode 100644 index 00000000000..dd1f9818090 --- /dev/null +++ b/test/integration/targets/win_wait_for/templates/http-server.ps1 @@ -0,0 +1,22 @@ +$ErrorActionPreference = 'Stop' + +$port = {{test_win_wait_for_port}} + +$endpoint = New-Object -TypeName System.Net.IPEndPoint([System.Net.IPAddress]::Parse("0.0.0.0"), $port) +$listener = New-Object -TypeName System.Net.Sockets.TcpListener($endpoint) +$listener.Server.ReceiveTimeout = 3000 +$listener.Start() + +try { + while ($true) { + if (-not $listener.Pending()) { + Start-Sleep -Seconds 1 + } else { + $client = $listener.AcceptTcpClient() + $client.Close() + break + } + } +} finally { + $listener.Stop() +}