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:
parent
f6080c5271
commit
7be1d3d0c2
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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 %}
|
||||
|
|
117
satori/shell.py
117
satori/shell.py
|
@ -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__":
|
||||
|
|
199
satori/ssh.py
199
satori/ssh.py
|
@ -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:
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"""."""
|
||||
|
||||
|
||||
def get_systeminfo(resource, config):
|
||||
def get_systeminfo(resource, config, interactive=False):
|
||||
"""."""
|
||||
return {'facter': 'is better'}
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
"""."""
|
||||
|
||||
|
||||
def get_systeminfo(resource, config):
|
||||
def get_systeminfo(resource, config, interactive=False):
|
||||
"""."""
|
||||
return {'ohai': 'there!'}
|
||||
|
|
|
@ -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
|
|
@ -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()
|
|
@ -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
|
@ -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()
|
Loading…
Reference in New Issue