Fix cli_command multiple prompt issue (#44922)

* Add check in network_cli to handle all prompts

* Add check_all flag to mandatory handle all the command prompt
  in prompts list. By default if any one prompt is handled
  remaining prompts are ignored.

* Fix cli_command multiple prompt issue

* If multiple prompt and answers are given as input network_cli
  handles only the first prompt that matched by default
* If a command execution results in muliple prompt the fix
  add support to set a boolean option C(check_all) to indicate
  network_cli to wait till all the prompts and answers are processed.

* Update cli_command

* Update api doc

* Fix unit test failure

* Fix CI failure

* Update network_cli

* Fix review comment
This commit is contained in:
Ganesh Nalawade 2018-08-31 20:04:12 +05:30 committed by GitHub
parent cbd54a4b2c
commit c0326aea2f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 65 additions and 33 deletions

View file

@ -45,6 +45,14 @@ options:
type: bool type: bool
default: false default: false
required: false required: false
check_all:
description:
- By default if any one of the prompts mentioned in C(prompt) option is matched it won't check
for other prompts. This boolean flag, that when set to I(True) will check for all the prompts
mentioned in C(prompt) option in the given order. If the option is set to I(True) all the prompts
should be received from remote host if not it will result in timeout.
type: bool
default: false
""" """
EXAMPLES = """ EXAMPLES = """
@ -73,9 +81,10 @@ EXAMPLES = """
- set system syslog file test any any - set system syslog file test any any
- exit - exit
- name: multiple prompt, multiple answer - name: multiple prompt, multiple answer (mandatory check for all prompts)
cli_command: cli_command:
command: "copy sftp sftp://user@host//user/test.img" command: "copy sftp sftp://user@host//user/test.img"
check_all: True
prompt: prompt:
- "Confirm download operation" - "Confirm download operation"
- "Password" - "Password"
@ -120,6 +129,7 @@ def main():
prompt=dict(type='list', required=False), prompt=dict(type='list', required=False),
answer=dict(type='list', required=False), answer=dict(type='list', required=False),
sendonly=dict(type='bool', default=False, required=False), sendonly=dict(type='bool', default=False, required=False),
check_all=dict(type='bool', default=False, required=False),
) )
required_together = [['prompt', 'answer']] required_together = [['prompt', 'answer']]
module = AnsibleModule(argument_spec=argument_spec, required_together=required_together, module = AnsibleModule(argument_spec=argument_spec, required_together=required_together,

View file

@ -95,7 +95,7 @@ class CliconfBase(AnsiblePlugin):
display.display('closing shell due to command timeout (%s seconds).' % self._connection._play_context.timeout, log_only=True) display.display('closing shell due to command timeout (%s seconds).' % self._connection._play_context.timeout, log_only=True)
self.close() self.close()
def send_command(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, prompt_retry_check=False): def send_command(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, prompt_retry_check=False, check_all=False):
"""Executes a command over the device connection """Executes a command over the device connection
This method will execute a command over the device connection and This method will execute a command over the device connection and
@ -108,14 +108,16 @@ class CliconfBase(AnsiblePlugin):
:param sendonly: Bool value that will send the command but not wait for a result. :param sendonly: Bool value that will send the command but not wait for a result.
:param newline: Bool value that will append the newline character to the command :param newline: Bool value that will append the newline character to the command
:param prompt_retry_check: Bool value for trying to detect more prompts :param prompt_retry_check: Bool value for trying to detect more prompts
:param check_all: Bool value to indicate if all the values in prompt sequence should be matched or any one of
given prompt.
:returns: The output from the device after executing the command :returns: The output from the device after executing the command
""" """
kwargs = { kwargs = {
'command': to_bytes(command), 'command': to_bytes(command),
'sendonly': sendonly, 'sendonly': sendonly,
'newline': newline, 'newline': newline,
'prompt_retry_check': prompt_retry_check 'prompt_retry_check': prompt_retry_check,
'check_all': check_all
} }
if prompt is not None: if prompt is not None:
@ -223,7 +225,7 @@ class CliconfBase(AnsiblePlugin):
pass pass
@abstractmethod @abstractmethod
def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, output=None): def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, output=None, check_all=False):
"""Execute specified command on remote device """Execute specified command on remote device
This method will retrieve the specified data and This method will retrieve the specified data and
return it to the caller as a string. return it to the caller as a string.
@ -234,9 +236,11 @@ class CliconfBase(AnsiblePlugin):
:param sendonly: bool to disable waiting for response, default is false :param sendonly: bool to disable waiting for response, default is false
:param newline: bool to indicate if newline should be added at end of answer or not :param newline: bool to indicate if newline should be added at end of answer or not
:param output: For devices that support fetching command output in different :param output: For devices that support fetching command output in different
format, this keyword argument is used to specify the output in which format, this keyword argument is used to specify the output in which
response is to be retrieved. response is to be retrieved.
:return: :param check_all: Bool value to indicate if all the values in prompt sequence should be matched or any one of
given prompt.
:return: The output from the device after executing the command
""" """
pass pass

View file

@ -153,10 +153,10 @@ class Cliconf(CliconfBase):
self.send_command('end') self.send_command('end')
return resp return resp
def get(self, command, prompt=None, answer=None, sendonly=False, output=None): def get(self, command, prompt=None, answer=None, sendonly=False, output=None, check_all=False):
if output: if output:
command = self._get_command_with_output(command, output) command = self._get_command_with_output(command, output)
return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly, check_all=check_all)
def commit(self): def commit(self):
self.send_command('commit') self.send_command('commit')

View file

@ -177,13 +177,13 @@ class Cliconf(CliconfBase):
resp['response'] = results resp['response'] = results
return resp return resp
def get(self, command=None, prompt=None, answer=None, sendonly=False, output=None): def get(self, command=None, prompt=None, answer=None, sendonly=False, output=None, check_all=False):
if not command: if not command:
raise ValueError('must provide value of command to execute') raise ValueError('must provide value of command to execute')
if output: if output:
raise ValueError("'output' value %s is not supported for get" % output) raise ValueError("'output' value %s is not supported for get" % output)
return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly) return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly, check_all=check_all)
def get_device_info(self): def get_device_info(self):
device_info = {} device_info = {}

View file

@ -149,10 +149,10 @@ class Cliconf(CliconfBase):
diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else '' diff['config_diff'] = dumps(configdiffobjs, 'commands') if configdiffobjs else ''
return diff return diff
def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, output=None): def get(self, command=None, prompt=None, answer=None, sendonly=False, newline=True, output=None, check_all=False):
if output: if output:
raise ValueError("'output' value %s is not supported for get" % output) raise ValueError("'output' value %s is not supported for get" % output)
return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly, newline=newline) return self.send_command(command=command, prompt=prompt, answer=answer, sendonly=sendonly, newline=newline, check_all=check_all)
def commit(self, comment=None, label=None, replace=None): def commit(self, comment=None, label=None, replace=None):
cmd_obj = {} cmd_obj = {}

View file

@ -123,10 +123,10 @@ class Cliconf(CliconfBase):
resp['response'] = results resp['response'] = results
return resp return resp
def get(self, command, prompt=None, answer=None, sendonly=False, output=None): def get(self, command, prompt=None, answer=None, sendonly=False, output=None, check_all=False):
if output: if output:
command = self._get_command_with_output(command, output) command = self._get_command_with_output(command, output)
return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly, check_all=check_all)
@configure @configure
def commit(self, comment=None, confirmed=False, at_time=None, synchronize=False): def commit(self, comment=None, confirmed=False, at_time=None, synchronize=False):

View file

@ -181,10 +181,10 @@ class Cliconf(CliconfBase):
resp['response'] = results resp['response'] = results
return resp return resp
def get(self, command, prompt=None, answer=None, sendonly=False, output=None): def get(self, command, prompt=None, answer=None, sendonly=False, output=None, check_all=False):
if output: if output:
command = self._get_command_with_output(command, output) command = self._get_command_with_output(command, output)
return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly, check_all=check_all)
def run_commands(self, commands=None, check_rc=True): def run_commands(self, commands=None, check_rc=True):
if commands is None: if commands is None:

View file

@ -105,14 +105,14 @@ class Cliconf(CliconfBase):
resp['request'] = requests resp['request'] = requests
return resp return resp
def get(self, command=None, prompt=None, answer=None, sendonly=False, output=None): def get(self, command=None, prompt=None, answer=None, sendonly=False, output=None, check_all=False):
if not command: if not command:
raise ValueError('must provide value of command to execute') raise ValueError('must provide value of command to execute')
if output: if output:
raise ValueError("'output' value %s is not supported for get" % output) raise ValueError("'output' value %s is not supported for get" % output)
return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly) return self.send_command(command, prompt=prompt, answer=answer, sendonly=sendonly, check_all=check_all)
def commit(self, comment=None): def commit(self, comment=None):
if comment: if comment:

View file

@ -170,6 +170,7 @@ import traceback
from ansible.errors import AnsibleConnectionFailure from ansible.errors import AnsibleConnectionFailure
from ansible.module_utils.six import BytesIO, PY3 from ansible.module_utils.six import BytesIO, PY3
from ansible.module_utils.six.moves import cPickle from ansible.module_utils.six.moves import cPickle
from ansible.module_utils.network.common.utils import to_list
from ansible.module_utils._text import to_bytes, to_text from ansible.module_utils._text import to_bytes, to_text
from ansible.playbook.play_context import PlayContext from ansible.playbook.play_context import PlayContext
from ansible.plugins.connection import NetworkConnectionBase from ansible.plugins.connection import NetworkConnectionBase
@ -337,7 +338,7 @@ class Connection(NetworkConnectionBase):
display.debug("ssh connection has been closed successfully") display.debug("ssh connection has been closed successfully")
super(Connection, self).close() super(Connection, self).close()
def receive(self, command=None, prompts=None, answer=None, newline=True, prompt_retry_check=False): def receive(self, command=None, prompts=None, answer=None, newline=True, prompt_retry_check=False, check_all=False):
''' '''
Handles receiving of output from command Handles receiving of output from command
''' '''
@ -363,13 +364,13 @@ class Connection(NetworkConnectionBase):
window_count += 1 window_count += 1
if prompts and not handled: if prompts and not handled:
handled = self._handle_prompt(window, prompts, answer, newline) handled = self._handle_prompt(window, prompts, answer, newline, False, check_all)
matched_prompt_window = window_count matched_prompt_window = window_count
elif prompts and handled and prompt_retry_check and matched_prompt_window + 1 == window_count: elif prompts and handled and prompt_retry_check and matched_prompt_window + 1 == window_count:
# check again even when handled, if same prompt repeats in next window # check again even when handled, if same prompt repeats in next window
# (like in the case of a wrong enable password, etc) indicates # (like in the case of a wrong enable password, etc) indicates
# value of answer is wrong, report this as error. # value of answer is wrong, report this as error.
if self._handle_prompt(window, prompts, answer, newline, prompt_retry_check): if self._handle_prompt(window, prompts, answer, newline, prompt_retry_check, check_all):
raise AnsibleConnectionFailure("For matched prompt '%s', answer is not valid" % self._matched_cmd_prompt) raise AnsibleConnectionFailure("For matched prompt '%s', answer is not valid" % self._matched_cmd_prompt)
if self._find_prompt(window): if self._find_prompt(window):
@ -377,16 +378,21 @@ class Connection(NetworkConnectionBase):
resp = self._strip(self._last_response) resp = self._strip(self._last_response)
return self._sanitize(resp, command) return self._sanitize(resp, command)
def send(self, command, prompt=None, answer=None, newline=True, sendonly=False, prompt_retry_check=False): def send(self, command, prompt=None, answer=None, newline=True, sendonly=False, prompt_retry_check=False, check_all=False):
''' '''
Sends the command to the device in the opened shell Sends the command to the device in the opened shell
''' '''
if check_all:
prompt_len = len(to_list(prompt))
answer_len = len(to_list(answer))
if prompt_len != answer_len:
raise AnsibleConnectionFailure("Number of prompts (%s) is not same as that of answers (%s)" % (prompt_len, answer_len))
try: try:
self._history.append(command) self._history.append(command)
self._ssh_shell.sendall(b'%s\r' % command) self._ssh_shell.sendall(b'%s\r' % command)
if sendonly: if sendonly:
return return
response = self.receive(command, prompt, answer, newline, prompt_retry_check) response = self.receive(command, prompt, answer, newline, prompt_retry_check, check_all)
return to_text(response, errors='surrogate_or_strict') return to_text(response, errors='surrogate_or_strict')
except (socket.timeout, AttributeError): except (socket.timeout, AttributeError):
display.vvvv(traceback.format_exc(), host=self._play_context.remote_addr) display.vvvv(traceback.format_exc(), host=self._play_context.remote_addr)
@ -400,7 +406,7 @@ class Connection(NetworkConnectionBase):
data = regex.sub(b'', data) data = regex.sub(b'', data)
return data return data
def _handle_prompt(self, resp, prompts, answer, newline, prompt_retry_check=False): def _handle_prompt(self, resp, prompts, answer, newline, prompt_retry_check=False, check_all=False):
''' '''
Matches the command prompt and responds Matches the command prompt and responds
@ -408,24 +414,34 @@ class Connection(NetworkConnectionBase):
:arg prompts: Sequence of byte strings that we consider prompts for input :arg prompts: Sequence of byte strings that we consider prompts for input
:arg answer: Sequence of Byte string to send back to the remote if we find a prompt. :arg answer: Sequence of Byte string to send back to the remote if we find a prompt.
A carriage return is automatically appended to this string. A carriage return is automatically appended to this string.
:returns: True if a prompt was found in ``resp``. False otherwise :param prompt_retry_check: Bool value for trying to detect more prompts
:param check_all: Bool value to indicate if all the values in prompt sequence should be matched or any one of
given prompt.
:returns: True if a prompt was found in ``resp``. If check_all is True
will True only after all the prompt in the prompts list are matched. False otherwise.
''' '''
single_prompt = False
if not isinstance(prompts, list): if not isinstance(prompts, list):
prompts = [prompts] prompts = [prompts]
single_prompt = True
if not isinstance(answer, list): if not isinstance(answer, list):
answer = [answer] answer = [answer]
prompts = [re.compile(r, re.I) for r in prompts] prompts_regex = [re.compile(r, re.I) for r in prompts]
for index, regex in enumerate(prompts): for index, regex in enumerate(prompts_regex):
match = regex.search(resp) match = regex.search(resp)
if match: if match:
# if prompt_retry_check is enabled to check if same prompt is # if prompt_retry_check is enabled to check if same prompt is
# repeated don't send answer again. # repeated don't send answer again.
if not prompt_retry_check: if not prompt_retry_check:
answer = answer[index] if len(answer) > index else answer[0] prompt_answer = answer[index] if len(answer) > index else answer[0]
self._ssh_shell.sendall(b'%s' % answer) self._ssh_shell.sendall(b'%s' % prompt_answer)
if newline: if newline:
self._ssh_shell.sendall(b'\r') self._ssh_shell.sendall(b'\r')
self._matched_cmd_prompt = match.group() self._matched_cmd_prompt = match.group()
if check_all and prompts and not single_prompt:
prompts.pop(0)
answer.pop(0)
return False
return True return True
return False return False

View file

@ -104,7 +104,8 @@ class TestPluginCLIConfNOS(unittest.TestCase):
command=command, command=command,
prompt_retry_check=False, prompt_retry_check=False,
sendonly=False, sendonly=False,
newline=True newline=True,
check_all=False
)) ))
self._mock_connection.send.assert_has_calls(send_calls) self._mock_connection.send.assert_has_calls(send_calls)

View file

@ -111,7 +111,8 @@ class TestPluginCLIConfSLXOS(unittest.TestCase):
command=command, command=command,
prompt_retry_check=False, prompt_retry_check=False,
sendonly=False, sendonly=False,
newline=True newline=True,
check_all=False
)) ))
self._mock_connection.send.assert_has_calls(send_calls) self._mock_connection.send.assert_has_calls(send_calls)