Add support to configure network_cli terminal related options (#60086)

* Add support for configurable terminal plugin options

Fixes #59404

*  Add terminal options to support platform specific login menu
*  Add terminal options to support configurable options for stdout
   and stderr regex list

* Fix CI failures

* Fix CI issues

* Fix review comments and add integration test

* Fix sanity test failures

* Fix review comments

* Fix integration test case

* Fix integration test failure

* Add support to configure terminal related options

Fixes https://github.com/ansible/ansible/issues/59404

*  Add network_cli configurable options to support platform specific login menu
*  Add network_cli configurable options to support configurable options for stdout
   and stderr regex list

* Fix review comment

* Fix review comment
This commit is contained in:
Ganesh Nalawade 2019-08-19 18:56:20 +05:30 committed by GitHub
parent 446dcb7c96
commit 49736b6b27
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 251 additions and 15 deletions

View file

@ -561,6 +561,46 @@ To make this a permanent change, add the following to your ``ansible.cfg`` file:
connect_retry_timeout = 30
Timeout issue due to platform specific login menu with ``network_cli`` connection type
--------------------------------------------------------------------------------------
In Ansible 2.9 and later, the network_cli connection plugin configuration options are added
to handle the platform specific login menu. These options can be set as group/host or tasks
variables.
Example: Handle single login menu prompts with host variables
.. code-block:: console
$cat host_vars/<hostname>.yaml
---
ansible_terminal_initial_prompt:
- "Connect to a host"
ansible_terminal_initial_answer:
- "3"
Example: Handle remote host multiple login menu prompts with host variables
.. code-block:: console
$cat host_vars/<inventory-hostname>.yaml
---
ansible_terminal_initial_prompt:
- "Press any key to enter main menu"
- "Connect to a host"
ansible_terminal_initial_answer:
- "\\r"
- "3"
ansible_terminal_initial_prompt_checkall: True
To handle multiple login menu prompts:
* The values of ``ansible_terminal_initial_prompt`` and ``ansible_terminal_initial_answer`` should be a list.
* The prompt sequence should match the answer sequence.
* The value of ``ansible_terminal_initial_prompt_checkall`` should be set to ``True``.
.. note:: If all the prompts in sequence are not received from remote host at the time connection initialization it will result in a timeout.
Playbook issues
===============
@ -757,3 +797,52 @@ To make this a global setting, add the following to your ``ansible.cfg`` file:
buffer_read_timeout = 2
This timer delay per command executed on remote host can be disabled by setting the value to zero.
Task failure due to mismatched error regex within command response using ``network_cli`` connection type
--------------------------------------------------------------------------------------------------------
In Ansible 2.9 and later, the network_cli connection plugin configuration options are added
to handle the stdout and stderr regex to identify if the command execution response consist
of a normal response or an error response. These options can be set group/host variables or as
tasks variables.
Example: For mismatched error response
.. code-block:: yaml
- name: fetch logs from remote host
ios_command:
commands:
- show logging
Playbook run output:
.. code-block:: console
TASK [first fetch logs] ********************************************************
fatal: [ios01]: FAILED! => {
"changed": false,
"msg": "RF Name:\r\n\r\n <--nsip-->
\"IPSEC-3-REPLAY_ERROR: Test log\"\r\n*Aug 1 08:36:18.483: %SYS-7-USERLOG_DEBUG:
Message from tty578(user id: ansible): test\r\nan-ios-02#"}
Suggestions to resolve:
Modify the error regex for individual task.
.. code-block:: yaml
- name: fetch logs from remote host
ios_command:
commands:
- show logging
vars:
ansible_terminal_stderr_re:
- pattern: 'connection timed out'
flags: 're.I'
The terminal plugin regex options ``ansible_terminal_stderr_re`` and ``ansible_terminal_stdout_re`` have
``pattern`` and ``flags`` as keys. The value of the ``flags`` key should be a value that is accepted by
the ``re.compile`` python method.

View file

@ -191,6 +191,65 @@ options:
- name: ANSIBLE_PERSISTENT_LOG_MESSAGES
vars:
- name: ansible_persistent_log_messages
terminal_stdout_re:
type: list
elements: dict
description:
- A single regex pattern or a sequence of patterns along with optional flags
to match the command prompt from the received response chunk. This option
accepts C(pattern) and C(flags) keys. The value of C(pattern) is a python
regex pattern to match the response and the value of C(flags) is the value
accepted by I(flags) argument of I(re.compile) python method to control
the way regex is matched with the response, for example I('re.I').
vars:
- name: ansible_terminal_stdout_re
terminal_stderr_re:
type: list
elements: dict
description:
- This option provides the regex pattern and optional flags to match the
error string from the received response chunk. This option
accepts C(pattern) and C(flags) keys. The value of C(pattern) is a python
regex pattern to match the response and the value of C(flags) is the value
accepted by I(flags) argument of I(re.compile) python method to control
the way regex is matched with the response, for example I('re.I').
vars:
- name: ansible_terminal_stderr_re
terminal_initial_prompt:
type: list
description:
- A single regex pattern or a sequence of patterns to evaluate the expected
prompt at the time of initial login to the remote host.
vars:
- name: ansible_terminal_initial_prompt
terminal_initial_answer:
type: list
description:
- The answer to reply with if the C(terminal_initial_prompt) is matched. The value can be a single answer
or a list of answers for multiple terminal_initial_prompt. In case the login menu has
multiple prompts the sequence of the prompt and excepted answer should be in same order and the value
of I(terminal_prompt_checkall) should be set to I(True) if all the values in C(terminal_initial_prompt) are
expected to be matched and set to I(False) if any one login prompt is to be matched.
vars:
- name: ansible_terminal_initial_answer
terminal_initial_prompt_checkall:
type: boolean
description:
- By default the value is set to I(False) and any one of the prompts mentioned in C(terminal_initial_prompt)
option is matched it won't check for other prompts. When set to I(True) it will check for all the prompts
mentioned in C(terminal_initial_prompt) option in the given order and all the prompts
should be received from remote host if not it will result in timeout.
default: False
vars:
- name: ansible_terminal_initial_prompt_checkall
terminal_inital_prompt_newline:
type: boolean
description:
- This boolean flag, that when set to I(True) will send newline in the response if any of values
in I(terminal_initial_prompt) is matched.
default: True
vars:
- name: ansible_terminal_initial_prompt_newline
"""
import getpass
@ -338,11 +397,12 @@ class Connection(NetworkConnectionBase):
self.queue_message('vvvv', 'loaded terminal plugin for network_os %s' % self._network_os)
self.receive(
prompts=self._terminal.terminal_initial_prompt,
answer=self._terminal.terminal_initial_answer,
newline=self._terminal.terminal_inital_prompt_newline
)
terminal_initial_prompt = self.get_option('terminal_initial_prompt') or self._terminal.terminal_initial_prompt
terminal_initial_answer = self.get_option('terminal_initial_answer') or self._terminal.terminal_initial_answer
newline = self.get_option('terminal_inital_prompt_newline') or self._terminal.terminal_inital_prompt_newline
check_all = self.get_option('terminal_initial_prompt_checkall') or False
self.receive(prompts=terminal_initial_prompt, answer=terminal_initial_answer, newline=newline, check_all=check_all)
self.queue_message('vvvv', 'firing event: on_open_shell()')
self._terminal.on_open_shell()
@ -386,6 +446,10 @@ class Connection(NetworkConnectionBase):
command_prompt_matched = False
matched_prompt_window = window_count = 0
# set terminal regex values for command prompt and errors in response
self._terminal_stderr_re = self._get_terminal_std_re('terminal_stderr_re')
self._terminal_stdout_re = self._get_terminal_std_re('terminal_stdout_re')
cache_socket_timeout = self._ssh_shell.gettimeout()
command_timeout = self.get_option('persistent_command_timeout')
self._validate_timeout_value(command_timeout, "persistent_command_timeout")
@ -463,8 +527,10 @@ class Connection(NetworkConnectionBase):
if prompt_len != answer_len:
raise AnsibleConnectionFailure("Number of prompts (%s) is not same as that of answers (%s)" % (prompt_len, answer_len))
try:
self._history.append(command)
self._ssh_shell.sendall(b'%s\r' % command)
cmd = b'%s\r' % command
self._history.append(cmd)
self._ssh_shell.sendall(cmd)
self._log_messages('send command: %s' % cmd)
if sendonly:
return
response = self.receive(command, prompt, answer, newline, prompt_retry_check, check_all)
@ -513,7 +579,7 @@ class Connection(NetworkConnectionBase):
single_prompt = True
if not isinstance(answer, list):
answer = [answer]
prompts_regex = [re.compile(r, re.I) for r in prompts]
prompts_regex = [re.compile(to_bytes(r), re.I) for r in prompts]
for index, regex in enumerate(prompts_regex):
match = regex.search(resp)
if match:
@ -557,13 +623,14 @@ class Connection(NetworkConnectionBase):
'''
errored_response = None
is_error_message = False
for regex in self._terminal.terminal_stderr_re:
for regex in self._terminal_stderr_re:
if regex.search(response):
is_error_message = True
# Check if error response ends with command prompt if not
# receive it buffered prompt
for regex in self._terminal.terminal_stdout_re:
for regex in self._terminal_stdout_re:
match = regex.search(response)
if match:
errored_response = response
@ -573,7 +640,7 @@ class Connection(NetworkConnectionBase):
break
if not is_error_message:
for regex in self._terminal.terminal_stdout_re:
for regex in self._terminal_stdout_re:
match = regex.search(response)
if match:
self._matched_pattern = regex.pattern
@ -604,3 +671,23 @@ class Connection(NetworkConnectionBase):
self.close()
self._connect()
self.close()
def _get_terminal_std_re(self, option):
terminal_std_option = self.get_option(option)
terminal_std_re = []
if terminal_std_option:
for item in terminal_std_option:
if "pattern" not in item:
raise AnsibleConnectionFailure("'pattern' is a required key for option '%s',"
" received option value is %s" % (option, item))
pattern = br"%s" % to_bytes(item['pattern'])
flag = item.get('flags', 0)
if flag:
flag = getattr(re, flag.split('.')[1])
terminal_std_re.append(re.compile(pattern, flag))
else:
# To maintain backward compatibility
terminal_std_re = getattr(self._terminal, option)
return terminal_std_re

View file

@ -0,0 +1,59 @@
---
- debug: msg="START cli/error_regex.yaml on connection={{ ansible_connection }}"
- block:
- name: clear logs 1
cli_command: &clear_logs
command: clear logging
prompt:
- Clear logging buffer
answer:
- "\r"
ignore_errors: True
- name: send log with error regex match 1
cli_command: &send_logs
command: "send log 'IPSEC-3-REPLAY_ERROR: test log_1'"
ignore_errors: True
- name: fetch logs without command specific error regex
ios_command:
commands:
- show logging
register: result
ignore_errors: True
- name: ensure task fails due to mismatched regex
assert:
that:
- "result.failed == true"
- name: pause to avoid rate limiting
pause:
seconds: 10
- name: clear logs 2
cli_command: *clear_logs
ignore_errors: True
- name: send log with error regex match 2
cli_command: *send_logs
ignore_errors: True
- name: fetch logs with command specific error regex
ios_command:
commands:
- show logging
register: result
vars:
ansible_terminal_stderr_re:
- pattern: 'connection timed out'
flags: 're.I'
- name: ensure task with modified error regex is success
assert:
that:
- "result.failed == false"
when: ansible_connection == 'network_cli'
- debug: msg="END cli/error_regex.yaml on connection={{ ansible_connection }}"

View file

@ -111,15 +111,16 @@ class TestConnectionClass(unittest.TestCase):
self.assertEqual(out, b'command response')
mock_send.assert_called_with(command=b'command')
@patch("ansible.plugins.connection.network_cli.Connection._get_terminal_std_re")
@patch("ansible.plugins.connection.network_cli.Connection._connect")
def test_network_cli_send(self, mocked_connect):
def test_network_cli_send(self, mocked_connect, mocked_terminal_re):
pc = PlayContext()
pc.network_os = 'ios'
conn = connection_loader.get('network_cli', pc, '/dev/null')
mock__terminal = MagicMock()
mock__terminal.terminal_stdout_re = [re.compile(b'device#')]
mock__terminal.terminal_stderr_re = [re.compile(b'^ERROR')]
mocked_terminal_re.side_effect = [[re.compile(b'^ERROR')], [re.compile(b'device#')]]
conn._terminal = mock__terminal
mock__shell = MagicMock()
@ -139,7 +140,7 @@ class TestConnectionClass(unittest.TestCase):
mock__shell.reset_mock()
mock__shell.recv.side_effect = [b"ERROR: error message device#"]
mocked_terminal_re.side_effect = [[re.compile(b'^ERROR')], [re.compile(b'device#')]]
with self.assertRaises(AnsibleConnectionFailure) as exc:
conn.send(b'command')
self.assertEqual(str(exc.exception), 'ERROR: error message device#')