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
|
from __future__ import print_function
|
||||||
|
|
||||||
|
import socket
|
||||||
|
|
||||||
from novaclient.v1_1 import client
|
from novaclient.v1_1 import client
|
||||||
import six
|
import six
|
||||||
|
|
||||||
|
@ -30,35 +32,63 @@ from satori import dns
|
||||||
from satori import utils
|
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."""
|
"""Run discovery and return results."""
|
||||||
results = {}
|
results = {}
|
||||||
|
if is_valid_ip_address(address):
|
||||||
|
ipaddress = address
|
||||||
|
else:
|
||||||
ipaddress = dns.resolve_hostname(address)
|
ipaddress = dns.resolve_hostname(address)
|
||||||
results['domain'] = dns.domain_info(address)
|
results['domain'] = dns.domain_info(address)
|
||||||
results['address'] = ipaddress
|
results['address'] = ipaddress
|
||||||
|
|
||||||
|
results['host'] = host = {'type': 'Undetermined'}
|
||||||
if config.username is not None:
|
if config.username is not None:
|
||||||
server = find_nova_host(ipaddress, config)
|
server = find_nova_host(ipaddress, config)
|
||||||
if server:
|
if server:
|
||||||
host = {'type': 'Nova instance'}
|
host['type'] = 'Nova instance'
|
||||||
|
|
||||||
host['uri'] = [l['href'] for l in server.links
|
host['uri'] = [l['href'] for l in server.links
|
||||||
if l['rel'] == 'self'][0]
|
if l['rel'] == 'self'][0]
|
||||||
host['name'] = server.name
|
host['name'] = server.name
|
||||||
host['id'] = server.id
|
host['id'] = server.id
|
||||||
|
|
||||||
host['addresses'] = server.addresses
|
host['addresses'] = server.addresses
|
||||||
|
if config.system_info:
|
||||||
if all([config.system_info, config.host_key]):
|
module_name = config.system_info.replace("-", "_")
|
||||||
module_name = config.system_info
|
|
||||||
if '.' not in module_name:
|
if '.' not in module_name:
|
||||||
module_name = 'satori.sysinfo.%s' % module_name
|
module_name = 'satori.sysinfo.%s' % module_name
|
||||||
system_info_module = utils.import_object(module_name)
|
system_info_module = utils.import_object(module_name)
|
||||||
result = system_info_module.get_systeminfo(host, config)
|
result = system_info_module.get_systeminfo(ipaddress, config,
|
||||||
|
interactive=interactive)
|
||||||
host['system_info'] = result
|
host['system_info'] = result
|
||||||
|
|
||||||
results['host'] = host
|
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -18,7 +18,6 @@ class SatoriException(Exception):
|
||||||
"""Parent class for Satori exceptions.
|
"""Parent class for Satori exceptions.
|
||||||
|
|
||||||
Accepts a string error message that that accept a str description.
|
Accepts a string error message that that accept a str description.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@ -35,3 +34,33 @@ class SatoriShellException(SatoriException):
|
||||||
class GetPTYRetryFailure(SatoriException):
|
class GetPTYRetryFailure(SatoriException):
|
||||||
|
|
||||||
"""Tried to re-run command with get_pty to no avail."""
|
"""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 }}
|
{{ 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 %}
|
Registrar: {{ data.domain.registrar }}{% if data.domain.nameservers %}
|
||||||
Nameservers: {% for nameserver in data.domain.nameservers %}{{nameserver}}{% if not loop.last %}, {% endif %}{% endfor %}{% endif %}{% endif %}
|
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:
|
||||||
{% if data.domain.days_until_expires %} Expires: {{ data.domain.days_until_expires }} days{% endif %}
|
|
||||||
{% if data.host %}Host:
|
|
||||||
{{ data.address }} ({{ target }}) is hosted on a {{ data.host.type }}
|
{{ data.address }} ({{ target }}) is hosted on a {{ data.host.type }}
|
||||||
Instance Information:
|
Instance Information:
|
||||||
URI: {{ data.host.uri }}
|
URI: {{ data.host.uri }}
|
||||||
Name: {{ data.host.name }}
|
Name: {{ data.host.name }}
|
||||||
ID: {{ data.host.id }}
|
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 %}
|
{{ name }}:{% for address in addresses %}
|
||||||
{{ address.addr }}{% endfor %}{% endfor %}
|
{{ address.addr }}{% endfor %}{% endfor %}
|
||||||
|
{% elif data['address'] %}Host:
|
||||||
|
ip-address: {{ data.address }}
|
||||||
{% else %}Host not found
|
{% 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 %}
|
{% endif %}
|
||||||
|
|
117
satori/shell.py
117
satori/shell.py
|
@ -36,92 +36,105 @@ from satori import errors
|
||||||
LOG = logging.getLogger(__name__)
|
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):
|
def parse_args(argv):
|
||||||
"""Parse the command line arguments."""
|
"""Parse the command line arguments."""
|
||||||
parser = argparse.ArgumentParser(description='Configuration discovery.')
|
parser = argparse.ArgumentParser(description='Configuration discovery.')
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'netloc',
|
'netloc',
|
||||||
help='Network location. E.g. https://domain.com, sub.domain.com, or '
|
help="Network location as a URL, address, or ssh-style user@address. "
|
||||||
'4.3.2.1'
|
"E.g. https://domain.com, sub.domain.com, 4.3.2.1, or root@web01. "
|
||||||
)
|
"Supplying a username before an @ without the `--system-info` "
|
||||||
openstack_group = parser.add_argument_group(
|
" argument will default `--system-info` to 'ohai-solo'."
|
||||||
'OpenStack Settings',
|
|
||||||
'Cloud credentials, settings and endpoints. If a network location is '
|
|
||||||
'found to be hosted on the tenant additional information is provided.'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#
|
||||||
|
# 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(
|
openstack_group.add_argument(
|
||||||
'--os-username',
|
'--os-username',
|
||||||
dest='username',
|
dest='username',
|
||||||
default=os.environ.get('OS_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(
|
openstack_group.add_argument(
|
||||||
'--os-password',
|
'--os-password',
|
||||||
dest='password',
|
dest='password',
|
||||||
default=os.environ.get('OS_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(
|
openstack_group.add_argument(
|
||||||
'--os-region-name',
|
'--os-region-name',
|
||||||
dest='region',
|
dest='region',
|
||||||
default=os.environ.get('OS_REGION_NAME'),
|
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(
|
openstack_group.add_argument(
|
||||||
'--os-auth-url',
|
'--os-auth-url',
|
||||||
dest='authurl',
|
dest='authurl',
|
||||||
default=os.environ.get('OS_AUTH_URL'),
|
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(
|
openstack_group.add_argument(
|
||||||
'--os-compute-api-version',
|
'--os-compute-api-version',
|
||||||
dest='compute_api_version',
|
dest='compute_api_version',
|
||||||
default=os.environ.get('OS_COMPUTE_API_VERSION', '1.1'),
|
default=os.environ.get('OS_COMPUTE_API_VERSION', '1.1'),
|
||||||
help='OpenStack Compute API version. Defaults to '
|
help="OpenStack Compute API version. Defaults to "
|
||||||
'env[OS_COMPUTE_API_VERSION] or 1.1.'
|
"env[OS_COMPUTE_API_VERSION] or 1.1."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tenant name or ID can be supplied
|
# Tenant name or ID can be supplied
|
||||||
tenant_group = openstack_group.add_mutually_exclusive_group()
|
tenant_group = openstack_group.add_mutually_exclusive_group()
|
||||||
tenant_group.add_argument(
|
tenant_group.add_argument(
|
||||||
'--os-tenant-name',
|
'--os-tenant-name',
|
||||||
dest='tenant_name',
|
dest='tenant_name',
|
||||||
default=os.environ.get('OS_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(
|
tenant_group.add_argument(
|
||||||
'--os-tenant-id',
|
'--os-tenant-id',
|
||||||
dest='tenant_id',
|
dest='tenant_id',
|
||||||
default=os.environ.get('OS_TENANT_ID'),
|
default=os.environ.get('OS_TENANT_ID'),
|
||||||
help='OpenStack Auth tenant ID. Defaults to env[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.'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
#
|
||||||
|
# Plugins
|
||||||
|
#
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--system-info',
|
'--system-info',
|
||||||
help='Mechanism to use on a Nova resource to obtain system '
|
help="Mechanism to use on a Nova resource to obtain system "
|
||||||
'information. E.g. ohai, facts, factor.'
|
"information. E.g. ohai, facts, factor."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Output formatting
|
#
|
||||||
|
# Output formatting and logging
|
||||||
|
#
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--format', '-F',
|
'--format', '-F',
|
||||||
dest='format',
|
dest='format',
|
||||||
default='text',
|
default='text',
|
||||||
help='Format for output (json or text)'
|
help="Format for output (json or text)."
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--logconfig",
|
"--logconfig",
|
||||||
help="Optional logging configuration file"
|
help="Optional logging configuration file."
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-d", "--debug",
|
"-d", "--debug",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
@ -130,21 +143,42 @@ def parse_args(argv):
|
||||||
"Log output includes source file path and line "
|
"Log output includes source file path and line "
|
||||||
"numbers."
|
"numbers."
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-v", "--verbose",
|
"-v", "--verbose",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="turn up logging to DEBUG (default is INFO)"
|
help="turn up logging to DEBUG (default is INFO)."
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"-q", "--quiet",
|
"-q", "--quiet",
|
||||||
action="store_true",
|
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)
|
config = parser.parse_args(argv)
|
||||||
|
|
||||||
if config.host_key_path:
|
if config.host_key_path:
|
||||||
config.host_key = config.host_key_path.read()
|
config.host_key = config.host_key_path.read()
|
||||||
else:
|
else:
|
||||||
|
@ -165,6 +199,16 @@ def parse_args(argv):
|
||||||
"provide all of these settings or none of them."
|
"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
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
@ -177,8 +221,9 @@ def main(argv=None):
|
||||||
sys.exit("Output format file (%s) not found or accessible. Try "
|
sys.exit("Output format file (%s) not found or accessible. Try "
|
||||||
"specifying raw JSON format using `--format json`" %
|
"specifying raw JSON format using `--format json`" %
|
||||||
get_template_path(config.format))
|
get_template_path(config.format))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
results = discovery.run(config.netloc, config)
|
results = discovery.run(config.netloc, config, interactive=True)
|
||||||
print(format_output(config.netloc, results,
|
print(format_output(config.netloc, results,
|
||||||
template_name=config.format))
|
template_name=config.format))
|
||||||
except Exception as exc: # pylint: disable=W0703
|
except Exception as exc: # pylint: disable=W0703
|
||||||
|
@ -217,7 +262,7 @@ def format_output(discovered_target, results, template_name="text"):
|
||||||
else:
|
else:
|
||||||
template = get_template(template_name)
|
template = get_template(template_name)
|
||||||
return templating.parse(template, target=discovered_target,
|
return templating.parse(template, target=discovered_target,
|
||||||
data=results)
|
data=results).strip('\n')
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|
195
satori/ssh.py
195
satori/ssh.py
|
@ -9,13 +9,25 @@
|
||||||
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
||||||
# License for the specific language governing permissions and limitations
|
# License for the specific language governing permissions and limitations
|
||||||
# under the License.
|
# under the License.
|
||||||
#
|
|
||||||
# pylint: disable=R0902, R0913
|
|
||||||
"""SSH Module for connecting to and automating remote commands.
|
"""SSH Module for connecting to and automating remote commands.
|
||||||
|
|
||||||
Supports proxying, as in `ssh -A`
|
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 ast
|
||||||
|
import getpass
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
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):
|
def connect(*args, **kwargs):
|
||||||
"""Connect to a remote device over SSH."""
|
"""Connect to a remote device over SSH."""
|
||||||
try:
|
try:
|
||||||
|
@ -61,13 +94,14 @@ class AcceptMissingHostKey(paramiko.client.MissingHostKeyPolicy):
|
||||||
client._host_keys.add(hostname, key.get_name(), key)
|
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."""
|
"""Connects to devices via SSH to execute commands."""
|
||||||
|
|
||||||
|
# pylint: disable=R0913
|
||||||
def __init__(self, host, password=None, username="root",
|
def __init__(self, host, password=None, username="root",
|
||||||
private_key=None, key_filename=None, port=22,
|
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.
|
"""Create an instance of the SSH class.
|
||||||
|
|
||||||
:param str host: The ip address or host name of the server
|
: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,
|
Conversion of booleans is also supported,
|
||||||
(.., options={'StrictHostKeyChecking': False})
|
(.., options={'StrictHostKeyChecking': False})
|
||||||
is equivalent.
|
is equivalent.
|
||||||
|
:keyword interactive: If true, prompt for password if missing.
|
||||||
"""
|
"""
|
||||||
self.password = password
|
self.password = password
|
||||||
self.host = host
|
self.host = host
|
||||||
|
@ -103,6 +138,7 @@ class SSH(paramiko.SSHClient):
|
||||||
self.options = options or {}
|
self.options = options or {}
|
||||||
self.proxy = proxy
|
self.proxy = proxy
|
||||||
self.sock = None
|
self.sock = None
|
||||||
|
self.interactive = interactive
|
||||||
|
|
||||||
if self.proxy:
|
if self.proxy:
|
||||||
if not isinstance(self.proxy, SSH):
|
if not isinstance(self.proxy, SSH):
|
||||||
|
@ -112,27 +148,6 @@ class SSH(paramiko.SSHClient):
|
||||||
|
|
||||||
super(SSH, self).__init__()
|
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
|
@classmethod
|
||||||
def get_client(cls, *args, **kwargs):
|
def get_client(cls, *args, **kwargs):
|
||||||
"""Return an ssh client object from this module."""
|
"""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)
|
LOG.debug("Remote platform info: %s", self._platform_info)
|
||||||
return self._platform_info
|
return self._platform_info
|
||||||
|
|
||||||
def connect(self, use_password=False): # pylint: disable=W0221
|
def connect_with_host_keys(self):
|
||||||
"""Attempt an SSH connection through paramiko.SSHClient.connect .
|
"""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:
|
def connect_with_password(self):
|
||||||
- private_key
|
"""Try connecting with password."""
|
||||||
- key_filename
|
LOG.debug("Trying to connect with password")
|
||||||
- any key discoverable in ~/.ssh/
|
if self.interactive and not self.password:
|
||||||
- username/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()
|
self.load_system_host_keys()
|
||||||
|
|
||||||
if self.proxy:
|
if self.proxy:
|
||||||
|
@ -176,47 +226,51 @@ class SSH(paramiko.SSHClient):
|
||||||
if self.options.get('StrictHostKeyChecking') in (False, "no"):
|
if self.options.get('StrictHostKeyChecking') in (False, "no"):
|
||||||
self.set_missing_host_key_policy(AcceptMissingHostKey())
|
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(
|
return super(SSH, self).connect(
|
||||||
self.host,
|
self.host,
|
||||||
timeout=self.timeout,
|
timeout=kwargs.pop('timeout', self.timeout),
|
||||||
port=self.port,
|
port=kwargs.pop('port', self.port),
|
||||||
username=self.username,
|
username=kwargs.pop('username', self.username),
|
||||||
pkey=pkey,
|
pkey=kwargs.pop('pkey', None),
|
||||||
sock=self.sock)
|
sock=kwargs.pop('sock', self.sock),
|
||||||
elif self.key_filename is not None and not use_password:
|
**kwargs)
|
||||||
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)
|
|
||||||
|
|
||||||
except paramiko.PasswordRequiredException as exc:
|
def connect(self): # pylint: disable=W0221
|
||||||
#Looks like we have cert issues, so try password auth if we can
|
"""Attempt an SSH connection through paramiko.SSHClient.connect.
|
||||||
if self.password and not use_password: # dont recurse twice
|
|
||||||
LOG.debug("Retrying with password credentials")
|
The order for authentication attempts is:
|
||||||
return self.connect(use_password=True)
|
- private_key
|
||||||
else:
|
- key_filename
|
||||||
raise exc
|
- 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:
|
except paramiko.BadHostKeyException as exc:
|
||||||
msg = (
|
msg = (
|
||||||
"ssh://%s@%s:%d failed: %s. You might have a bad key "
|
"ssh://%s@%s:%d failed: %s. You might have a bad key "
|
||||||
"entry on your server, but this is a security issue and "
|
"entry on your server, but this is a security issue and "
|
||||||
"won't be handled automatically. To fix this you can remove "
|
"won't be handled automatically. To fix this you can remove "
|
||||||
"the host entry for this host from the /.ssh/known_hosts file"
|
"the host entry for this host from the /.ssh/known_hosts file")
|
||||||
% (self.username, self.host, self.port, exc))
|
LOG.info(msg, self.username, self.host, self.port, exc)
|
||||||
LOG.info(msg)
|
|
||||||
raise exc
|
raise exc
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
LOG.info('ssh://%s@%s:%d failed. %s',
|
LOG.info('ssh://%s@%s:%d failed. %s',
|
||||||
|
@ -239,7 +293,6 @@ class SSH(paramiko.SSHClient):
|
||||||
LOG.debug("ssh://%s@%s:%d is up.",
|
LOG.debug("ssh://%s@%s:%d is up.",
|
||||||
self.username, self.host, self.port)
|
self.username, self.host, self.port)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
LOG.info("ssh://%s@%s:%d failed. %s",
|
LOG.info("ssh://%s@%s:%d failed. %s",
|
||||||
self.username, self.host, self.port, exc)
|
self.username, self.host, self.port, exc)
|
||||||
|
@ -324,6 +377,12 @@ class SSH(paramiko.SSHClient):
|
||||||
'stderr': stderr.read()
|
'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()
|
exit_code = chan.recv_exit_status()
|
||||||
|
|
||||||
if with_exit_code:
|
if with_exit_code:
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"""."""
|
"""."""
|
||||||
|
|
||||||
|
|
||||||
def get_systeminfo(resource, config):
|
def get_systeminfo(resource, config, interactive=False):
|
||||||
"""."""
|
"""."""
|
||||||
return {'facter': 'is better'}
|
return {'facter': 'is better'}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
"""."""
|
"""."""
|
||||||
|
|
||||||
|
|
||||||
def get_systeminfo(resource, config):
|
def get_systeminfo(resource, config, interactive=False):
|
||||||
"""."""
|
"""."""
|
||||||
return {'ohai': 'there!'}
|
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]
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.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