Implement ohai-solo data plane discovery module

This is a prototype of a discovery module that
connects to a server and gathers information about
how it is configured.

Change-Id: Ide2d75769ae21befcab6886b9cfd12b7ef19ea8d
This commit is contained in:
Ziad Sawalha 2014-03-12 18:13:10 -05:00
parent f6080c5271
commit 7be1d3d0c2
12 changed files with 1347 additions and 673 deletions

View File

@ -23,6 +23,8 @@ Example usage:
from __future__ import print_function
import socket
from novaclient.v1_1 import client
import six
@ -30,35 +32,63 @@ from satori import dns
from satori import utils
def run(address, config):
def is_valid_ipv4_address(address):
"""Check if the address supplied is a valid IPv4 address."""
try:
socket.inet_pton(socket.AF_INET, address)
except AttributeError: # no inet_pton here, sorry
try:
socket.inet_aton(address)
except socket.error:
return False
return address.count('.') == 3
except socket.error: # not a valid address
return False
return True
def is_valid_ipv6_address(address):
"""Check if the address supplied is a valid IPv6 address."""
try:
socket.inet_pton(socket.AF_INET6, address)
except socket.error: # not a valid address
return False
return True
def is_valid_ip_address(address):
"""Check if the address supplied is a valid IP address."""
return is_valid_ipv4_address(address) or is_valid_ipv6_address(address)
def run(address, config, interactive=False):
"""Run discovery and return results."""
results = {}
ipaddress = dns.resolve_hostname(address)
results['domain'] = dns.domain_info(address)
if is_valid_ip_address(address):
ipaddress = address
else:
ipaddress = dns.resolve_hostname(address)
results['domain'] = dns.domain_info(address)
results['address'] = ipaddress
results['host'] = host = {'type': 'Undetermined'}
if config.username is not None:
server = find_nova_host(ipaddress, config)
if server:
host = {'type': 'Nova instance'}
host['type'] = 'Nova instance'
host['uri'] = [l['href'] for l in server.links
if l['rel'] == 'self'][0]
host['name'] = server.name
host['id'] = server.id
host['addresses'] = server.addresses
if all([config.system_info, config.host_key]):
module_name = config.system_info
if '.' not in module_name:
module_name = 'satori.sysinfo.%s' % module_name
system_info_module = utils.import_object(module_name)
result = system_info_module.get_systeminfo(host, config)
host['system_info'] = result
results['host'] = host
if config.system_info:
module_name = config.system_info.replace("-", "_")
if '.' not in module_name:
module_name = 'satori.sysinfo.%s' % module_name
system_info_module = utils.import_object(module_name)
result = system_info_module.get_systeminfo(ipaddress, config,
interactive=interactive)
host['system_info'] = result
return results

View File

@ -18,7 +18,6 @@ class SatoriException(Exception):
"""Parent class for Satori exceptions.
Accepts a string error message that that accept a str description.
"""
@ -35,3 +34,33 @@ class SatoriShellException(SatoriException):
class GetPTYRetryFailure(SatoriException):
"""Tried to re-run command with get_pty to no avail."""
class DiscoveryException(SatoriException):
"""Discovery exception with custom message."""
class UnsupportedPlatform(DiscoveryException):
"""Unsupported operating system or distro."""
class SystemInfoCommandMissing(DiscoveryException):
"""Command that provides system information is missing."""
class SystemInfoCommandOld(DiscoveryException):
"""Command that provides system information is outdated."""
class SystemInfoNotJson(DiscoveryException):
"""Command did not produce valid JSON."""
class SystemInfoCommandInstallFailed(DiscoveryException):
"""Failed to install package that provides system information."""

View File

@ -1,17 +1,20 @@
Address:
{% if data['address'] != target %}Address:
{{ target }} resolves to IPv4 address {{ data.address }}
{% if data.domain %}Domain: {{ data.domain.name }}
{% endif %}{% if data['domain'] %}Domain: {{ data.domain.name }}
Registrar: {{ data.domain.registrar }}{% if data.domain.nameservers %}
Nameservers: {% for nameserver in data.domain.nameservers %}{{nameserver}}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}{% endif %}
{% if data.domain.days_until_expires %} Expires: {{ data.domain.days_until_expires }} days{% endif %}
{% if data.host %}Host:
Nameservers: {% for nameserver in data.domain.nameservers %}{{nameserver}}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}{% endif %}{% if data['domain'] and data.domain.days_until_expires %} Expires: {{ data.domain.days_until_expires }} days{% endif %}{% if data['host'] and data.host.type == 'Nova instance' %}Host:
{{ data.address }} ({{ target }}) is hosted on a {{ data.host.type }}
Instance Information:
URI: {{ data.host.uri }}
Name: {{ data.host.name }}
ID: {{ data.host.id }}
ip-addresses:{% for name, addresses in data.host.addresses.iteritems() %}
ip-addresses:{% for name, addresses in data.host.addresses.items() %}
{{ name }}:{% for address in addresses %}
{{ address.addr }}{% endfor %}{% endfor %}
{% elif data['address'] %}Host:
ip-address: {{ data.address }}
{% else %}Host not found
{% endif %}{% if data['host'] and data.host.system_info %}Packages:
{% for name, package in data.host.system_info.packages.items() %} {{ name }}: {{ package.version }}
{% endfor %}
{% endif %}

View File

@ -36,92 +36,105 @@ from satori import errors
LOG = logging.getLogger(__name__)
def netloc_parser(data):
"""Parse the netloc parameter.
:returns: username, url.
"""
if data and '@' in data:
first_at = data.index('@')
return (data[0:first_at] or None), data[first_at+1:] or None
else:
return None, data or None
def parse_args(argv):
"""Parse the command line arguments."""
parser = argparse.ArgumentParser(description='Configuration discovery.')
parser.add_argument(
'netloc',
help='Network location. E.g. https://domain.com, sub.domain.com, or '
'4.3.2.1'
)
openstack_group = parser.add_argument_group(
'OpenStack Settings',
'Cloud credentials, settings and endpoints. If a network location is '
'found to be hosted on the tenant additional information is provided.'
help="Network location as a URL, address, or ssh-style user@address. "
"E.g. https://domain.com, sub.domain.com, 4.3.2.1, or root@web01. "
"Supplying a username before an @ without the `--system-info` "
" argument will default `--system-info` to 'ohai-solo'."
)
#
# Openstack Client Settings
#
openstack_group = parser.add_argument_group(
'OpenStack Settings',
"Cloud credentials, settings and endpoints. If a network location is "
"found to be hosted on the tenant additional information is provided."
)
openstack_group.add_argument(
'--os-username',
dest='username',
default=os.environ.get('OS_USERNAME'),
help='OpenStack Auth username. Defaults to env[OS_USERNAME].'
help="OpenStack Auth username. Defaults to env[OS_USERNAME]."
)
openstack_group.add_argument(
'--os-password',
dest='password',
default=os.environ.get('OS_PASSWORD'),
help='OpenStack Auth password. Defaults to env[OS_PASSWORD].'
help="OpenStack Auth password. Defaults to env[OS_PASSWORD]."
)
openstack_group.add_argument(
'--os-region-name',
dest='region',
default=os.environ.get('OS_REGION_NAME'),
help='OpenStack region. Defaults to env[OS_REGION_NAME].'
help="OpenStack region. Defaults to env[OS_REGION_NAME]."
)
openstack_group.add_argument(
'--os-auth-url',
dest='authurl',
default=os.environ.get('OS_AUTH_URL'),
help='OpenStack Auth endpoint. Defaults to env[OS_AUTH_URL].'
help="OpenStack Auth endpoint. Defaults to env[OS_AUTH_URL]."
)
openstack_group.add_argument(
'--os-compute-api-version',
dest='compute_api_version',
default=os.environ.get('OS_COMPUTE_API_VERSION', '1.1'),
help='OpenStack Compute API version. Defaults to '
'env[OS_COMPUTE_API_VERSION] or 1.1.'
help="OpenStack Compute API version. Defaults to "
"env[OS_COMPUTE_API_VERSION] or 1.1."
)
# Tenant name or ID can be supplied
tenant_group = openstack_group.add_mutually_exclusive_group()
tenant_group.add_argument(
'--os-tenant-name',
dest='tenant_name',
default=os.environ.get('OS_TENANT_NAME'),
help='OpenStack Auth tenant name. Defaults to env[OS_TENANT_NAME].'
help="OpenStack Auth tenant name. Defaults to env[OS_TENANT_NAME]."
)
tenant_group.add_argument(
'--os-tenant-id',
dest='tenant_id',
default=os.environ.get('OS_TENANT_ID'),
help='OpenStack Auth tenant ID. Defaults to env[OS_TENANT_ID].'
)
parser.add_argument(
'--host-key-path',
type=argparse.FileType('r'),
help='SSH key to access Nova resources.'
help="OpenStack Auth tenant ID. Defaults to env[OS_TENANT_ID]."
)
#
# Plugins
#
parser.add_argument(
'--system-info',
help='Mechanism to use on a Nova resource to obtain system '
'information. E.g. ohai, facts, factor.'
help="Mechanism to use on a Nova resource to obtain system "
"information. E.g. ohai, facts, factor."
)
# Output formatting
#
# Output formatting and logging
#
parser.add_argument(
'--format', '-F',
dest='format',
default='text',
help='Format for output (json or text)'
help="Format for output (json or text)."
)
parser.add_argument(
"--logconfig",
help="Optional logging configuration file"
help="Optional logging configuration file."
)
parser.add_argument(
"-d", "--debug",
action="store_true",
@ -130,21 +143,42 @@ def parse_args(argv):
"Log output includes source file path and line "
"numbers."
)
parser.add_argument(
"-v", "--verbose",
action="store_true",
help="turn up logging to DEBUG (default is INFO)"
help="turn up logging to DEBUG (default is INFO)."
)
parser.add_argument(
"-q", "--quiet",
action="store_true",
help="turn down logging to WARN (default is INFO)"
help="turn down logging to WARN (default is INFO)."
)
#
# SSH options
#
ssh_group = parser.add_argument_group(
'ssh-like Settings',
'To be used to access hosts.'
)
# ssh.py actualy handles the defaults. We're documenting it here so that
# the command-line help string is informative, but the default is set in
# ssh.py (by calling paramiko's load_system_host_keys).
ssh_group.add_argument(
"-i", "--host-key-path",
type=argparse.FileType('r'),
help="Selects a file from which the identity (private key) for public "
"key authentication is read. The default ~/.ssh/id_dsa, "
"~/.ssh/id_ecdsa and ~/.ssh/id_rsa. Supplying this without the "
"`--system-info` argument will default `--system-info` to 'ohai-solo'."
)
ssh_group.add_argument(
"-o",
metavar="ssh_options",
help="Mirrors the ssh -o option. See ssh_config(5)."
)
config = parser.parse_args(argv)
if config.host_key_path:
config.host_key = config.host_key_path.read()
else:
@ -165,6 +199,16 @@ def parse_args(argv):
"provide all of these settings or none of them."
)
username, url = netloc_parser(config.netloc)
config.netloc = url
if username:
config.host_username = username
else:
config.host_username = 'root'
if (config.host_key or config.host_username) and not config.system_info:
config.system_info = 'ohai-solo'
return config
@ -177,8 +221,9 @@ def main(argv=None):
sys.exit("Output format file (%s) not found or accessible. Try "
"specifying raw JSON format using `--format json`" %
get_template_path(config.format))
try:
results = discovery.run(config.netloc, config)
results = discovery.run(config.netloc, config, interactive=True)
print(format_output(config.netloc, results,
template_name=config.format))
except Exception as exc: # pylint: disable=W0703
@ -217,7 +262,7 @@ def format_output(discovered_target, results, template_name="text"):
else:
template = get_template(template_name)
return templating.parse(template, target=discovered_target,
data=results)
data=results).strip('\n')
if __name__ == "__main__":

View File

@ -9,13 +9,25 @@
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
# pylint: disable=R0902, R0913
"""SSH Module for connecting to and automating remote commands.
Supports proxying, as in `ssh -A`
To control the behavior of the SSH client, use the specific connect_with_*
calls. The .connect() call behaves like the ssh command and attempts a number
of connection methods, including using the curent user's ssh keys.
If interactive is set to true, the module will also prompt for a password if no
other connection methods succeeded.
Note that test_connection() calls connect(). To test a connection and control
the authentication methods used, just call connect_with_* and catch any
exceptions instead of using test_connect().
"""
import ast
import getpass
import logging
import os
import re
@ -38,6 +50,27 @@ TTY_REQUIRED = [
]
def make_pkey(private_key):
"""Return a paramiko.pkey.PKey from private key string."""
key_classes = [paramiko.rsakey.RSAKey,
paramiko.dsskey.DSSKey,
paramiko.ecdsakey.ECDSAKey, ]
keyfile = six.StringIO(private_key)
for cls in key_classes:
keyfile.seek(0)
try:
pkey = cls.from_private_key(keyfile)
except paramiko.SSHException:
continue
else:
keytype = cls
LOG.info("Valid SSH Key provided (%s)", keytype.__name__)
return pkey
raise paramiko.SSHException("Is not a valid private key")
def connect(*args, **kwargs):
"""Connect to a remote device over SSH."""
try:
@ -61,13 +94,14 @@ class AcceptMissingHostKey(paramiko.client.MissingHostKeyPolicy):
client._host_keys.add(hostname, key.get_name(), key)
class SSH(paramiko.SSHClient):
class SSH(paramiko.SSHClient): # pylint: disable=R0902
"""Connects to devices via SSH to execute commands."""
# pylint: disable=R0913
def __init__(self, host, password=None, username="root",
private_key=None, key_filename=None, port=22,
timeout=20, proxy=None, options=None):
timeout=20, proxy=None, options=None, interactive=False):
"""Create an instance of the SSH class.
:param str host: The ip address or host name of the server
@ -91,6 +125,7 @@ class SSH(paramiko.SSHClient):
Conversion of booleans is also supported,
(.., options={'StrictHostKeyChecking': False})
is equivalent.
:keyword interactive: If true, prompt for password if missing.
"""
self.password = password
self.host = host
@ -103,6 +138,7 @@ class SSH(paramiko.SSHClient):
self.options = options or {}
self.proxy = proxy
self.sock = None
self.interactive = interactive
if self.proxy:
if not isinstance(self.proxy, SSH):
@ -112,27 +148,6 @@ class SSH(paramiko.SSHClient):
super(SSH, self).__init__()
@staticmethod
def _get_pkey(private_key):
"""Return a paramiko.pkey.PKey from private key string."""
key_classes = [paramiko.rsakey.RSAKey,
paramiko.dsskey.DSSKey,
paramiko.ecdsakey.ECDSAKey, ]
keyfile = six.StringIO(private_key)
for cls in key_classes:
keyfile.seek(0)
try:
pkey = cls.from_private_key(keyfile)
except paramiko.SSHException:
continue
else:
keytype = cls
LOG.info("Valid SSH Key provided (%s)", keytype.__name__)
return pkey
raise paramiko.SSHException("Is not a valid private key")
@classmethod
def get_client(cls, *args, **kwargs):
"""Return an ssh client object from this module."""
@ -156,17 +171,52 @@ class SSH(paramiko.SSHClient):
LOG.debug("Remote platform info: %s", self._platform_info)
return self._platform_info
def connect(self, use_password=False): # pylint: disable=W0221
"""Attempt an SSH connection through paramiko.SSHClient.connect .
def connect_with_host_keys(self):
"""Try connecting with locally available keys (ex. ~/.ssh/id_rsa)."""
LOG.debug("Trying to connect with local host keys")
return self._connect(look_for_keys=True, allow_agent=False)
The order for authentication attempts is:
- private_key
- key_filename
- any key discoverable in ~/.ssh/
- username/password
def connect_with_password(self):
"""Try connecting with password."""
LOG.debug("Trying to connect with password")
if self.interactive and not self.password:
LOG.debug("Prompting for password (interactive=%s)",
self.interactive)
try:
self.password = getpass.getpass("Enter password for %s:" %
self.username)
except KeyboardInterrupt:
LOG.debug("User cancelled at password prompt")
if not self.password:
raise paramiko.PasswordRequiredException("Password not provided")
return self._connect(
password=self.password,
look_for_keys=False,
allow_agent=False)
:param use_password: Skip SSH keys when authenticating.
"""
def connect_with_key_file(self):
"""Try connecting with key file."""
LOG.debug("Trying to connect with key file")
if not self.key_filename:
raise paramiko.AuthenticationException("No key file supplied")
return self._connect(
key_filename=os.path.expanduser(self.key_filename),
look_for_keys=False,
allow_agent=False)
def connect_with_key(self):
"""Try connecting with key string."""
LOG.debug("Trying to connect with private key string")
if not self.private_key:
raise paramiko.AuthenticationException("No key supplied")
pkey = make_pkey(self.private_key)
return self._connect(
pkey=pkey,
look_for_keys=False,
allow_agent=False)
def _connect(self, **kwargs):
"""Set up client and connect to target."""
self.load_system_host_keys()
if self.proxy:
@ -176,47 +226,51 @@ class SSH(paramiko.SSHClient):
if self.options.get('StrictHostKeyChecking') in (False, "no"):
self.set_missing_host_key_policy(AcceptMissingHostKey())
try:
if self.private_key is not None and not use_password:
pkey = self._get_pkey(self.private_key)
LOG.debug("Trying supplied private key string")
return super(SSH, self).connect(
self.host,
timeout=self.timeout,
port=self.port,
username=self.username,
pkey=pkey,
sock=self.sock)
elif self.key_filename is not None and not use_password:
LOG.debug("Trying key file: %s",
os.path.expanduser(self.key_filename))
return super(SSH, self).connect(
self.host, timeout=self.timeout, port=self.port,
username=self.username,
key_filename=os.path.expanduser(self.key_filename),
sock=self.sock)
else:
return super(SSH, self).connect(
self.host, port=self.port,
username=self.username,
password=self.password,
sock=self.sock)
return super(SSH, self).connect(
self.host,
timeout=kwargs.pop('timeout', self.timeout),
port=kwargs.pop('port', self.port),
username=kwargs.pop('username', self.username),
pkey=kwargs.pop('pkey', None),
sock=kwargs.pop('sock', self.sock),
**kwargs)
except paramiko.PasswordRequiredException as exc:
#Looks like we have cert issues, so try password auth if we can
if self.password and not use_password: # dont recurse twice
LOG.debug("Retrying with password credentials")
return self.connect(use_password=True)
else:
raise exc
def connect(self): # pylint: disable=W0221
"""Attempt an SSH connection through paramiko.SSHClient.connect.
The order for authentication attempts is:
- private_key
- key_filename
- any key discoverable in ~/.ssh/
- username/password (will prompt if the password is not supplied and
interactive is true)
"""
if self.private_key:
try:
return self.connect_with_key()
except paramiko.SSHException:
pass # try next method
if self.key_filename:
try:
return self.connect_with_key_file()
except paramiko.SSHException:
pass # try next method
try:
return self.connect_with_host_keys()
except paramiko.SSHException:
pass # try next method
try:
return self.connect_with_password()
except paramiko.BadHostKeyException as exc:
msg = (
"ssh://%s@%s:%d failed: %s. You might have a bad key "
"entry on your server, but this is a security issue and "
"won't be handled automatically. To fix this you can remove "
"the host entry for this host from the /.ssh/known_hosts file"
% (self.username, self.host, self.port, exc))
LOG.info(msg)
"the host entry for this host from the /.ssh/known_hosts file")
LOG.info(msg, self.username, self.host, self.port, exc)
raise exc
except Exception as exc:
LOG.info('ssh://%s@%s:%d failed. %s',
@ -239,7 +293,6 @@ class SSH(paramiko.SSHClient):
LOG.debug("ssh://%s@%s:%d is up.",
self.username, self.host, self.port)
return True
except Exception as exc:
LOG.info("ssh://%s@%s:%d failed. %s",
self.username, self.host, self.port, exc)
@ -324,6 +377,12 @@ class SSH(paramiko.SSHClient):
'stderr': stderr.read()
}
LOG.debug("STDOUT from ssh://%s@%s:%d: %s",
self.username, self.host, self.port,
results['stdout'])
LOG.debug("STDERR from ssh://%s@%s:%d: %s",
self.username, self.host, self.port,
results['stderr'])
exit_code = chan.recv_exit_status()
if with_exit_code:

View File

@ -1,6 +1,6 @@
"""."""
def get_systeminfo(resource, config):
def get_systeminfo(resource, config, interactive=False):
"""."""
return {'facter': 'is better'}

View File

@ -1,6 +1,6 @@
"""."""
def get_systeminfo(resource, config):
def get_systeminfo(resource, config, interactive=False):
"""."""
return {'ohai': 'there!'}

121
satori/sysinfo/ohai_solo.py Normal file
View File

@ -0,0 +1,121 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""Ohai Solo Data Plane Discovery Module."""
import json
import logging
import six
from satori import errors
from satori import ssh
LOG = logging.getLogger(__name__)
if six.PY3:
def unicode(text, errors=None): # noqa
"""A hacky Python 3 version of unicode() function."""
return str(text)
def get_systeminfo(ipaddress, config, interactive=False):
"""Run data plane discovery using this module against a host.
:param ipaddress: address to the host to discover.
:param config: arguments and configuration suppplied to satori.
:keyword interactive: whether to prompt the user for information.
"""
ssh_client = ssh.connect(ipaddress, username=config.host_username,
private_key=config.host_key,
interactive=interactive)
install_remote(ssh_client)
return system_info(ssh_client)
def system_info(ssh_client):
"""Run ohai-solo on a remote system and gather the output.
:param ssh_client: :class:`ssh.SSH` instance
:returns: dict -- system information from ohai-solo
:raises: SystemInfoCommandMissing, SystemInfoCommandOld, SystemInfoNotJson
SystemInfoCommandMissing if `ohai` is not installed.
SystemInfoCommandOld if `ohai` is not the latest.
SystemInfoNotJson if `ohai` does not return valid json.
"""
output = ssh_client.remote_execute("sudo -i ohai-solo")
not_found_msgs = ["command not found", "Could not find ohai"]
if any(m in k for m in not_found_msgs
for k in list(output.values()) if isinstance(k, six.string_types)):
LOG.warning("SystemInfoCommandMissing on host: [%s]", ssh_client.host)
raise errors.SystemInfoCommandMissing("ohai-solo missing on %s",
ssh_client.host)
try:
results = json.loads(unicode(output['stdout'], errors='replace'))
except ValueError as exc:
raise errors.SystemInfoNotJson(exc)
return results
def is_debian(platform):
"""Return true if the platform is a debian-based distro."""
return platform['dist'].lower() in ['debian', 'ubuntu']
def is_fedora(platform):
"""Return true if the platform is a fedora-based distro."""
return platform['dist'].lower() in ['redhat', 'centos', 'fedora', 'el']
def install_remote(ssh_client):
"""Install ohai-solo on remote system."""
LOG.info("Installing (or updating) ohai-solo on device %s at %s:%d",
ssh_client.host, ssh_client.host, ssh_client.port)
# Download to host
command = "cd /tmp && sudo wget -N http://ohai.rax.io/install.sh"
ssh_client.remote_execute(command)
# Run install
command = "cd /tmp && bash install.sh"
output = ssh_client.remote_execute(command, with_exit_code=True)
# Be a good citizen and clean up your tmp data
command = "cd /tmp && rm install.sh"
ssh_client.remote_execute(command)
# Process install command output
if output['exit_code'] != 0:
raise errors.SystemInfoCommandInstallFailed(output['stderr'][:256])
else:
return output
def remove_remote(ssh_client):
"""Remove ohai-solo from specifc remote system.
Currently supports:
- ubuntu [10.x, 12.x]
- debian [6.x, 7.x]
- redhat [5.x, 6.x]
- centos [5.x, 6.x]
"""
platform_info = ssh_client.platform_info
if is_debian(platform_info):
remove = "sudo dpkg --purge ohai-solo"
elif is_fedora(platform_info):
remove = "sudo yum -y erase ohai-solo"
else:
raise errors.UnsupportedPlatform("Unknown distro: %s" %
platform_info['dist'])
command = "cd /tmp && %s" % remove
output = ssh_client.remote_execute(command)
return output

View File

@ -0,0 +1,83 @@
# pylint: disable=C0103,R0904
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
"""Tests for Format Templates."""
import unittest
from satori.common import templating
from satori import shell
class TestTextTemplate(unittest.TestCase):
"""Test Text Template."""
def setUp(self):
self.template = shell.get_template('text')
def test_no_data(self):
"""Handles response with no host."""
result = templating.parse(self.template, data={})
self.assertEqual(result.strip('\n'), 'Host not found')
def test_target_is_ip(self):
"""Handles response when host is just the supplied address."""
result = templating.parse(self.template, target='127.0.0.1',
data={'address': '127.0.0.1'})
self.assertEqual(result.strip('\n'),
'Host:\n ip-address: 127.0.0.1')
def test_host_not_server(self):
"""Handles response when host is not a nova instance."""
result = templating.parse(self.template, target='localhost',
data={'address': '127.0.0.1'})
self.assertEqual(result.strip('\n'),
'Address:\n localhost resolves to IPv4 address '
'127.0.0.1\nHost:\n ip-address: 127.0.0.1')
def test_host_is_nova_instance(self):
"""Handles response when host is a nova instance."""
data = {
'address': '10.1.1.45',
'host': {
'type': 'Nova instance',
'uri': 'https://servers/path',
'id': '1000B',
'name': 'x',
'addresses': {
'public': [{'type': 'ipv4', 'addr': '10.1.1.45'}]
}
}
}
result = templating.parse(self.template,
target='instance.nova.local',
data=data)
expected = """\
Address:
instance.nova.local resolves to IPv4 address 10.1.1.45
Host:
10.1.1.45 (instance.nova.local) is hosted on a Nova instance
Instance Information:
URI: https://servers/path
Name: x
ID: 1000B
ip-addresses:
public:
10.1.1.45"""
self.assertEqual(result.strip('\n'), expected)
if __name__ == '__main__':
unittest.main()

View File

@ -136,5 +136,22 @@ class TestArgParsing(utils.TestCase):
exitcodes=[0, 2]
)
def test_netloc_parser(self):
self.assertEqual(shell.netloc_parser("localhost"),
(None, 'localhost'))
def test_netloc_parser_both(self):
self.assertEqual(shell.netloc_parser("name@address"),
('name', 'address'))
def test_netloc_parser_edge(self):
self.assertEqual(shell.netloc_parser("@address"),
(None, 'address'))
self.assertEqual(shell.netloc_parser("root@"),
('root', None))
self.assertEqual(shell.netloc_parser(""),
(None, None))
if __name__ == '__main__':
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,149 @@
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
#
"""Test Ohai-Solo Plugin."""
import unittest
import mock
from satori import errors
from satori.sysinfo import ohai_solo
from satori.tests import utils
class TestOhaiSolo(utils.TestCase):
@mock.patch.object(ohai_solo, 'ssh')
@mock.patch.object(ohai_solo, 'system_info')
@mock.patch.object(ohai_solo, 'install_remote')
def test_connect_and_run(self, mock_install, mock_sysinfo, mock_ssh):
address = "123.345.678.0"
config = mock.MagicMock()
config.host_key = "foo"
config.host_username = "bar"
mock_sysinfo.return_value = {}
result = ohai_solo.get_systeminfo(address, config)
self.assertTrue(result is mock_sysinfo.return_value)
mock_install.assert_called_once_with(mock_ssh.connect.return_value)
mock_ssh.connect.assert_called_with(address, username="bar",
private_key="foo",
interactive=False)
mock_sysinfo.assert_called_with(mock_ssh.connect.return_value)
class TestOhaiInstall(utils.TestCase):
def test_install_remote_fedora(self):
mock_ssh = mock.MagicMock()
response = {'exit_code': 0, 'foo': 'bar'}
mock_ssh.remote_execute.return_value = response
result = ohai_solo.install_remote(mock_ssh)
self.assertEqual(result, response)
self.assertEqual(mock_ssh.remote_execute.call_count, 3)
mock_ssh.remote_execute.assert_has_calls([
mock.call("cd /tmp && sudo wget -N http://ohai.rax.io/install.sh"),
mock.call("cd /tmp && bash install.sh", with_exit_code=True),
mock.call("cd /tmp && rm install.sh")]
)
def test_install_remote_failed(self):
mock_ssh = mock.MagicMock()
response = {'exit_code': 1, 'stdout': "", "stderr": "FAIL"}
mock_ssh.remote_execute.return_value = response
self.assertRaises(errors.SystemInfoCommandInstallFailed,
ohai_solo.install_remote, mock_ssh)
class TestOhaiRemove(utils.TestCase):
def test_remove_remote_fedora(self):
mock_ssh = mock.MagicMock()
mock_ssh.platform_info = {
'dist': 'centos',
'version': "4",
'arch': 'xyz'
}
response = {'exit_code': 0, 'foo': 'bar'}
mock_ssh.remote_execute.return_value = response
result = ohai_solo.remove_remote(mock_ssh)
self.assertEqual(result, response)
mock_ssh.remote_execute.assert_called_once_with(
"cd /tmp && sudo yum -y erase ohai-solo")
def test_remove_remote_debian(self):
mock_ssh = mock.MagicMock()
mock_ssh.platform_info = {
'dist': 'ubuntu',
'version': "4",
'arch': 'xyz'
}
response = {'exit_code': 0, 'foo': 'bar'}
mock_ssh.remote_execute.return_value = response
result = ohai_solo.remove_remote(mock_ssh)
self.assertEqual(result, response)
mock_ssh.remote_execute.assert_called_once_with(
"cd /tmp && sudo dpkg --purge ohai-solo")
def test_remove_remote_unsupported(self):
mock_ssh = mock.MagicMock()
mock_ssh.platform_info = {'dist': 'amiga'}
self.assertRaises(errors.UnsupportedPlatform,
ohai_solo.remove_remote, mock_ssh)
class TestSystemInfo(utils.TestCase):
def test_system_info(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
'exit_code': 0,
'stdout': "{}",
'stderr': ""
}
result = ohai_solo.system_info(mock_ssh)
mock_ssh.remote_execute("sudo -i ohai-solo")
def test_system_info_bad_json(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
'exit_code': 0,
'stdout': "",
'stderr': ""
}
self.assertRaises(errors.SystemInfoNotJson, ohai_solo.system_info,
mock_ssh)
def test_system_info_command_not_found(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
'exit_code': 1,
'stdout': "",
'stderr': "ohai-solo command not found"
}
self.assertRaises(errors.SystemInfoCommandMissing,
ohai_solo.system_info, mock_ssh)
def test_system_info_could_not_find(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
'exit_code': 1,
'stdout': "",
'stderr': "Could not find ohai-solo."
}
self.assertRaises(errors.SystemInfoCommandMissing,
ohai_solo.system_info, mock_ssh)
if __name__ == "__main__":
unittest.main()