mirror of https://github.com/ansible/ansible.git
HTTP(S) API connection plugin (#39224)
* HTTPAPI connection * Punt run_commands to cliconf or httpapi * Fake enable_mode on eapi * Pull changes to nxos * Move load_config to edit_config for future-preparedness * Don't fail on lldp disabled * Re-enable check_rc on nxos' run_commands * Reorganize nxos httpapi plugin for compatibility * draft docs for connection: httpapi * restores docs for connection:local for eapi * Add _remote_is_local to httpapipull/40348/head
parent
cc61c86049
commit
e9d7fa0418
@ -0,0 +1,303 @@
|
||||
# (c) 2018 Red Hat Inc.
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
DOCUMENTATION = """
|
||||
---
|
||||
author: Ansible Networking Team
|
||||
connection: httpapi
|
||||
short_description: Use httpapi to run command on network appliances
|
||||
description:
|
||||
- This connection plugin provides a connection to remote devices over a
|
||||
HTTP(S)-based api.
|
||||
version_added: "2.6"
|
||||
options:
|
||||
host:
|
||||
description:
|
||||
- Specifies the remote device FQDN or IP address to establish the SSH
|
||||
connection to.
|
||||
default: inventory_hostname
|
||||
vars:
|
||||
- name: ansible_host
|
||||
port:
|
||||
type: int
|
||||
description:
|
||||
- Specifies the port on the remote device to listening for connections
|
||||
when establishing the SSH connection.
|
||||
ini:
|
||||
- section: defaults
|
||||
key: remote_port
|
||||
env:
|
||||
- name: ANSIBLE_REMOTE_PORT
|
||||
vars:
|
||||
- name: ansible_port
|
||||
network_os:
|
||||
description:
|
||||
- Configures the device platform network operating system. This value is
|
||||
used to load the correct httpapi and cliconf plugins to communicate
|
||||
with the remote device
|
||||
vars:
|
||||
- name: ansible_network_os
|
||||
remote_user:
|
||||
description:
|
||||
- The username used to authenticate to the remote device when the API
|
||||
connection is first established. If the remote_user is not specified,
|
||||
the connection will use the username of the logged in user.
|
||||
- Can be configured form the CLI via the C(--user) or C(-u) options
|
||||
ini:
|
||||
- section: defaults
|
||||
key: remote_user
|
||||
env:
|
||||
- name: ANSIBLE_REMOTE_USER
|
||||
vars:
|
||||
- name: ansible_user
|
||||
password:
|
||||
description:
|
||||
- Secret used to authenticate
|
||||
vars:
|
||||
- name: ansible_password
|
||||
- name: ansible_httpapi_pass
|
||||
use_ssl:
|
||||
description:
|
||||
- Whether to connect using SSL (HTTPS) or not (HTTP)
|
||||
default: False
|
||||
vars:
|
||||
- name: ansible_httpapi_use_ssl
|
||||
timeout:
|
||||
type: int
|
||||
description:
|
||||
- Sets the connection time, in seconds, for the communicating with the
|
||||
remote device. This timeout is used as the default timeout value for
|
||||
commands when issuing a command to the network CLI. If the command
|
||||
does not return in timeout seconds, the an error is generated.
|
||||
default: 120
|
||||
become:
|
||||
type: boolean
|
||||
description:
|
||||
- The become option will instruct the CLI session to attempt privilege
|
||||
escalation on platforms that support it. Normally this means
|
||||
transitioning from user mode to C(enable) mode in the CLI session.
|
||||
If become is set to True and the remote device does not support
|
||||
privilege escalation or the privilege has already been elevated, then
|
||||
this option is silently ignored
|
||||
- Can be configured form the CLI via the C(--become) or C(-b) options
|
||||
default: False
|
||||
ini:
|
||||
section: privilege_escalation
|
||||
key: become
|
||||
env:
|
||||
- name: ANSIBLE_BECOME
|
||||
vars:
|
||||
- name: ansible_become
|
||||
become_method:
|
||||
description:
|
||||
- This option allows the become method to be specified in for handling
|
||||
privilege escalation. Typically the become_method value is set to
|
||||
C(enable) but could be defined as other values.
|
||||
default: sudo
|
||||
ini:
|
||||
section: privilege_escalation
|
||||
key: become_method
|
||||
env:
|
||||
- name: ANSIBLE_BECOME_METHOD
|
||||
vars:
|
||||
- name: ansible_become_method
|
||||
persistent_connect_timeout:
|
||||
type: int
|
||||
description:
|
||||
- Configures, in seconds, the amount of time to wait when trying to
|
||||
initially establish a persistent connection. If this value expires
|
||||
before the connection to the remote device is completed, the connection
|
||||
will fail
|
||||
default: 30
|
||||
ini:
|
||||
section: persistent_connection
|
||||
key: persistent_connect_timeout
|
||||
env:
|
||||
- name: ANSIBLE_PERSISTENT_CONNECT_TIMEOUT
|
||||
persistent_command_timeout:
|
||||
type: int
|
||||
description:
|
||||
- Configures, in seconds, the amount of time to wait for a command to
|
||||
return from the remote device. If this timer is exceeded before the
|
||||
command returns, the connection plugin will raise an exception and
|
||||
close
|
||||
default: 10
|
||||
ini:
|
||||
section: persistent_connection
|
||||
key: persistent_command_timeout
|
||||
env:
|
||||
- name: ANSIBLE_PERSISTENT_COMMAND_TIMEOUT
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from ansible import constants as C
|
||||
from ansible.errors import AnsibleConnectionFailure
|
||||
from ansible.module_utils._text import to_bytes
|
||||
from ansible.module_utils.six import PY3
|
||||
from ansible.module_utils.six.moves import cPickle
|
||||
from ansible.module_utils.urls import open_url
|
||||
from ansible.playbook.play_context import PlayContext
|
||||
from ansible.plugins.loader import cliconf_loader, connection_loader, httpapi_loader
|
||||
from ansible.plugins.connection import ConnectionBase
|
||||
from ansible.utils.path import unfrackpath
|
||||
|
||||
try:
|
||||
from __main__ import display
|
||||
except ImportError:
|
||||
from ansible.utils.display import Display
|
||||
display = Display()
|
||||
|
||||
|
||||
class Connection(ConnectionBase):
|
||||
'''Network API connection'''
|
||||
|
||||
transport = 'httpapi'
|
||||
has_pipelining = True
|
||||
force_persistence = True
|
||||
# Do not use _remote_is_local in other connections
|
||||
_remote_is_local = True
|
||||
|
||||
def __init__(self, play_context, new_stdin, *args, **kwargs):
|
||||
super(Connection, self).__init__(play_context, new_stdin, *args, **kwargs)
|
||||
|
||||
self._matched_prompt = None
|
||||
self._matched_pattern = None
|
||||
self._last_response = None
|
||||
self._history = list()
|
||||
|
||||
self._local = connection_loader.get('local', play_context, '/dev/null')
|
||||
self._local.set_options()
|
||||
|
||||
self._cliconf = None
|
||||
|
||||
self._ansible_playbook_pid = kwargs.get('ansible_playbook_pid')
|
||||
|
||||
network_os = self._play_context.network_os
|
||||
if not network_os:
|
||||
raise AnsibleConnectionFailure(
|
||||
'Unable to automatically determine host network os. Please '
|
||||
'manually configure ansible_network_os value for this host'
|
||||
)
|
||||
|
||||
self._httpapi = httpapi_loader.get(network_os, self)
|
||||
if self._httpapi:
|
||||
display.vvvv('loaded API plugin for network_os %s' % network_os, host=self._play_context.remote_addr)
|
||||
else:
|
||||
raise AnsibleConnectionFailure('unable to load API plugin for network_os %s' % network_os)
|
||||
|
||||
self._url = None
|
||||
self._auth = None
|
||||
|
||||
# reconstruct the socket_path and set instance values accordingly
|
||||
self._update_connection_state()
|
||||
|
||||
def __getattr__(self, name):
|
||||
try:
|
||||
return self.__dict__[name]
|
||||
except KeyError:
|
||||
if not name.startswith('_'):
|
||||
for plugin in (self._httpapi, self._cliconf):
|
||||
method = getattr(plugin, name, None)
|
||||
if method:
|
||||
return method
|
||||
raise AttributeError("'%s' object has no attribute '%s'" % (self.__class__.__name__, name))
|
||||
|
||||
def exec_command(self, cmd, in_data=None, sudoable=True):
|
||||
return self._local.exec_command(cmd, in_data, sudoable)
|
||||
|
||||
def put_file(self, in_path, out_path):
|
||||
return self._local.put_file(in_path, out_path)
|
||||
|
||||
def fetch_file(self, in_path, out_path):
|
||||
return self._local.fetch_file(in_path, out_path)
|
||||
|
||||
def update_play_context(self, pc_data):
|
||||
"""Updates the play context information for the connection"""
|
||||
pc_data = to_bytes(pc_data)
|
||||
if PY3:
|
||||
pc_data = cPickle.loads(pc_data, encoding='bytes')
|
||||
else:
|
||||
pc_data = cPickle.loads(pc_data)
|
||||
play_context = PlayContext()
|
||||
play_context.deserialize(pc_data)
|
||||
|
||||
messages = ['updating play_context for connection']
|
||||
if self._play_context.become is False and play_context.become is True:
|
||||
self._enable = True
|
||||
messages.append('authorizing connection')
|
||||
|
||||
elif self._play_context.become is True and not play_context.become:
|
||||
self._enable = False
|
||||
messages.append('deauthorizing connection')
|
||||
|
||||
self._play_context = play_context
|
||||
return messages
|
||||
|
||||
def _connect(self):
|
||||
if self.connected:
|
||||
return
|
||||
network_os = self._play_context.network_os
|
||||
|
||||
protocol = 'https' if self.get_option('use_ssl') else 'http'
|
||||
host = self._play_context.remote_addr
|
||||
port = self._play_context.port or 443 if protocol == 'https' else 80
|
||||
self._url = '%s://%s:%s' % (protocol, host, port)
|
||||
|
||||
self._cliconf = cliconf_loader.get(network_os, self)
|
||||
if self._cliconf:
|
||||
display.vvvv('loaded cliconf plugin for network_os %s' % network_os, host=self._play_context.remote_addr)
|
||||
else:
|
||||
display.vvvv('unable to load cliconf for network_os %s' % network_os)
|
||||
|
||||
self._connected = True
|
||||
|
||||
def _update_connection_state(self):
|
||||
'''
|
||||
Reconstruct the connection socket_path and check if it exists
|
||||
|
||||
If the socket path exists then the connection is active and set
|
||||
both the _socket_path value to the path and the _connected value
|
||||
to True. If the socket path doesn't exist, leave the socket path
|
||||
value to None and the _connected value to False
|
||||
'''
|
||||
ssh = connection_loader.get('ssh', class_only=True)
|
||||
cp = ssh._create_control_path(
|
||||
self._play_context.remote_addr, self._play_context.port,
|
||||
self._play_context.remote_user, self._play_context.connection,
|
||||
self._ansible_playbook_pid
|
||||
)
|
||||
|
||||
tmp_path = unfrackpath(C.PERSISTENT_CONTROL_PATH_DIR)
|
||||
socket_path = unfrackpath(cp % dict(directory=tmp_path))
|
||||
|
||||
if os.path.exists(socket_path):
|
||||
self._connected = True
|
||||
self._socket_path = socket_path
|
||||
|
||||
def reset(self):
|
||||
'''
|
||||
Reset the connection
|
||||
'''
|
||||
if self._socket_path:
|
||||
display.vvvv('resetting persistent connection for socket_path %s' % self._socket_path, host=self._play_context.remote_addr)
|
||||
self.close()
|
||||
display.vvvv('reset call on connection instance', host=self._play_context.remote_addr)
|
||||
|
||||
def close(self):
|
||||
if self._connected:
|
||||
self._connected = False
|
||||
|
||||
def send(self, path, data, **kwargs):
|
||||
'''
|
||||
Sends the command to the device over api
|
||||
'''
|
||||
url_kwargs = dict(url_username=self._play_context.remote_user, url_password=self._play_context.password)
|
||||
url_kwargs.update(kwargs)
|
||||
response = open_url(self._url + path, data=data, **url_kwargs)
|
||||
self._auth = response.info().get('Set-Cookie')
|
||||
|
||||
return response
|
@ -0,0 +1,158 @@
|
||||
# (c) 2018 Red Hat Inc.
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from ansible.module_utils._text import to_text
|
||||
from ansible.module_utils.network.common.utils import to_list
|
||||
from ansible.module_utils.connection import ConnectionError
|
||||
|
||||
try:
|
||||
from __main__ import display
|
||||
except ImportError:
|
||||
from ansible.utils.display import Display
|
||||
display = Display()
|
||||
|
||||
|
||||
class HttpApi:
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
|
||||
def send_request(self, data, **message_kwargs):
|
||||
if 'become' in message_kwargs:
|
||||
display.vvvv('firing event: on_become')
|
||||
# TODO ??? self._terminal.on_become(passwd=auth_pass)
|
||||
|
||||
output = message_kwargs.get('output', 'text')
|
||||
request = request_builder(data, output)
|
||||
headers = {'Content-Type': 'application/json-rpc'}
|
||||
|
||||
response = self.connection.send('/command-api', request, headers=headers, method='POST')
|
||||
response = json.loads(to_text(response.read()))
|
||||
return handle_response(response)
|
||||
|
||||
def get_prompt(self):
|
||||
# Hack to keep @enable_mode working
|
||||
return '#'
|
||||
|
||||
# Imported from module_utils
|
||||
def edit_config(self, config, commit=False, replace=False):
|
||||
"""Loads the configuration onto the remote devices
|
||||
|
||||
If the device doesn't support configuration sessions, this will
|
||||
fallback to using configure() to load the commands. If that happens,
|
||||
there will be no returned diff or session values
|
||||
"""
|
||||
session = 'ansible_%s' % int(time.time())
|
||||
result = {'session': session}
|
||||
banner_cmd = None
|
||||
banner_input = []
|
||||
|
||||
commands = ['configure session %s' % session]
|
||||
if replace:
|
||||
commands.append('rollback clean-config')
|
||||
|
||||
for command in config:
|
||||
if command.startswith('banner'):
|
||||
banner_cmd = command
|
||||
banner_input = []
|
||||
elif banner_cmd:
|
||||
if command == 'EOF':
|
||||
command = {'cmd': banner_cmd, 'input': '\n'.join(banner_input)}
|
||||
banner_cmd = None
|
||||
commands.append(command)
|
||||
else:
|
||||
banner_input.append(command)
|
||||
continue
|
||||
else:
|
||||
commands.append(command)
|
||||
|
||||
response = self.send_request(commands)
|
||||
|
||||
commands = ['configure session %s' % session, 'show session-config diffs']
|
||||
if commit:
|
||||
commands.append('commit')
|
||||
else:
|
||||
commands.append('abort')
|
||||
|
||||
response = self.send_request(commands, output='text')
|
||||
diff = response[1].strip()
|
||||
if diff:
|
||||
result['diff'] = diff
|
||||
|
||||
return result
|
||||
|
||||
def run_commands(self, commands, check_rc=True):
|
||||
"""Runs list of commands on remote device and returns results
|
||||
"""
|
||||
output = None
|
||||
queue = list()
|
||||
responses = list()
|
||||
|
||||
def run_queue(queue, output):
|
||||
response = to_list(self.send_request(queue, output=output))
|
||||
if output == 'json':
|
||||
response = [json.loads(item) for item in response]
|
||||
return response
|
||||
|
||||
for item in to_list(commands):
|
||||
cmd_output = None
|
||||
if isinstance(item, dict):
|
||||
command = item['command']
|
||||
if command.endswith('| json'):
|
||||
command = command.replace('| json', '')
|
||||
cmd_output = 'json'
|
||||
elif 'output' in item:
|
||||
cmd_output = item['output']
|
||||
else:
|
||||
command = item
|
||||
cmd_output = 'json'
|
||||
|
||||
if output and output != cmd_output:
|
||||
responses.extend(run_queue(queue, output))
|
||||
queue = list()
|
||||
|
||||
output = cmd_output or 'json'
|
||||
queue.append(command)
|
||||
|
||||
if queue:
|
||||
responses.extend(run_queue(queue, output))
|
||||
|
||||
return responses
|
||||
|
||||
def load_config(self, config, commit=False, replace=False):
|
||||
"""Loads the configuration onto the remote devices
|
||||
|
||||
If the device doesn't support configuration sessions, this will
|
||||
fallback to using configure() to load the commands. If that happens,
|
||||
there will be no returned diff or session values
|
||||
"""
|
||||
return self.edit_config(config, commit, replace)
|
||||
|
||||
|
||||
def handle_response(response):
|
||||
if 'error' in response:
|
||||
error = response['error']
|
||||
raise ConnectionError(error['message'], code=error['code'])
|
||||
|
||||
results = []
|
||||
for result in response['result']:
|
||||
if 'messages' in result:
|
||||
results.append(result['messages'][0])
|
||||
elif 'output' in result:
|
||||
results.append(result['output'].strip())
|
||||
else:
|
||||
results.append(json.dumps(result))
|
||||
|
||||
if len(results) == 1:
|
||||
return results[0]
|
||||
return results
|
||||
|
||||
|
||||
def request_builder(commands, output, reqid=None):
|
||||
params = dict(version=1, cmds=to_list(commands), format=output)
|
||||
return json.dumps(dict(jsonrpc='2.0', id=reqid, method='runCmds', params=params))
|
@ -0,0 +1,135 @@
|
||||
# (c) 2018 Red Hat Inc.
|
||||
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
|
||||
from __future__ import (absolute_import, division, print_function)
|
||||
__metaclass__ = type
|
||||
|
||||
import json
|
||||
|
||||
from ansible.module_utils._text import to_text
|
||||
from ansible.module_utils.connection import ConnectionError
|
||||
from ansible.module_utils.network.common.utils import to_list
|
||||
|
||||
try:
|
||||
from __main__ import display
|
||||
except ImportError:
|
||||
from ansible.utils.display import Display
|
||||
display = Display()
|
||||
|
||||
|
||||
class HttpApi:
|
||||
def __init__(self, connection):
|
||||
self.connection = connection
|
||||
|
||||
def _run_queue(self, queue, output):
|
||||
request = request_builder(queue, output)
|
||||
|
||||
headers = {'Content-Type': 'application/json'}
|
||||
response = self.connection.send('/ins', request, headers=headers, method='POST')
|
||||
response = json.loads(to_text(response.read()))
|
||||
return handle_response(response)
|
||||
|
||||
def send_request(self, data, **message_kwargs):
|
||||
output = None
|
||||
queue = list()
|
||||
responses = list()
|
||||
|
||||
for item in to_list(data):
|
||||
cmd_output = message_kwargs.get('output', 'json')
|
||||
if isinstance(item, dict):
|
||||
command = item['command']
|
||||
if command.endswith('| json'):
|
||||
command = command.rsplit('|', 1)[0]
|
||||
cmd_output = 'json'
|
||||
elif 'output' in item:
|
||||
cmd_output = item['output']
|
||||
else:
|
||||
command = item
|
||||
|
||||
if output and output != cmd_output:
|
||||
responses.extend(self._run_queue(queue, output))
|
||||
queue = list()
|
||||
|
||||
output = cmd_output or 'json'
|
||||
queue.append(command)
|
||||
|
||||
if queue:
|
||||
responses.extend(self._run_queue(queue, output))
|
||||
|
||||
if len(responses) == 1:
|
||||
return responses[0]
|
||||
return responses
|
||||
|
||||
# Migrated from module_utils
|
||||
def edit_config(self, command):
|
||||
responses = self.send_request(command, output='config')
|
||||
return json.dumps(responses)
|
||||
|
||||
def run_commands(self, commands, check_rc=True):
|
||||
"""Runs list of commands on remote device and returns results
|
||||
"""
|
||||
try:
|
||||
out = self.send_request(commands)
|
||||
except ConnectionError as exc:
|
||||
if check_rc:
|
||||
raise
|
||||
out = to_text(exc)
|
||||
|
||||
out = to_list(out)
|
||||
for index, response in enumerate(out):
|
||||
if response[0] == '{':
|
||||
out[index] = json.loads(response)
|
||||
return out
|
||||
|
||||
|
||||
def handle_response(response):
|
||||
results = []
|
||||
|
||||
if response['ins_api'].get('outputs'):
|
||||
for output in to_list(response['ins_api']['outputs']['output']):
|
||||
if output['code'] != '200':
|
||||
raise ConnectionError('%s: %s' % (output['input'], output['msg']))
|
||||
elif 'body' in output:
|
||||
result = output['body']
|
||||
if isinstance(result, dict):
|
||||
result = json.dumps(result)
|
||||
|
||||
results.append(result.strip())
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def request_builder(commands, output, version='1.0', chunk='0', sid=None):
|
||||
"""Encodes a NXAPI JSON request message
|
||||
"""
|
||||
output_to_command_type = {
|
||||
'text': 'cli_show_ascii',
|
||||
'json': 'cli_show',
|
||||
'bash': 'bash',
|
||||
'config': 'cli_conf'
|
||||
}
|
||||
|
||||
maybe_output = commands[0].split('|')[-1].strip()
|
||||
if maybe_output in output_to_command_type:
|
||||
command_type = output_to_command_type[maybe_output]
|
||||
commands = [command.split('|')[0].strip() for command in commands]
|
||||
else:
|
||||
try:
|
||||
command_type = output_to_command_type[output]
|
||||
except KeyError:
|
||||
msg = 'invalid format, received %s, expected one of %s' % \
|
||||
(output, ','.join(output_to_command_type.keys()))
|
||||
raise ConnectionError(msg)
|
||||
|
||||
if isinstance(commands, (list, set, tuple)):
|
||||
commands = ' ;'.join(commands)
|
||||
|
||||
msg = {
|
||||
'version': version,
|
||||
'type': command_type,
|
||||
'chunk': chunk,
|
||||
'sid': sid,
|
||||
'input': commands,
|
||||
'output_format': 'json'
|
||||
}
|
||||
return json.dumps(dict(ins_api=msg))
|
@ -1,26 +0,0 @@
|
||||
---
|
||||
- debug: msg="START cli/misc_tests.yaml on connection={{ ansible_connection }}"
|
||||
|
||||
|
||||
# test become and unbecome
|
||||
- block:
|
||||
- name: command that does require become (should fail)
|
||||
eos_command:
|
||||
commands: show running-config
|
||||
provider: "{{ eapi }}"
|
||||
become: no
|
||||
ignore_errors: yes
|
||||
register: result
|
||||
|
||||
- assert:
|
||||
that:
|
||||
- 'result.failed == true'
|
||||
- '"privileged mode required" in result.module_stderr'
|
||||
|
||||
- name: command that doesn't require become
|
||||
eos_command:
|
||||
commands: show uptime
|
||||
provider: "{{ eapi }}"
|
||||
become: no
|
||||
|
||||
when: "ansible_connection != 'local'"
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue