Allow running satori on localhost

Still needs unittests

Change-Id: Ia6bf6f5d1e4bcadb414eb79bb9673fbc60d1c9a6
Implements: blueprint satori-localhost
This commit is contained in:
Samuel Stavinoha 2014-03-18 22:38:48 +00:00
parent 3e245d4849
commit 01ad34c89f
7 changed files with 273 additions and 57 deletions

View File

@ -1,3 +1,4 @@
ipaddress>=1.0.6 # in stdlib as of python3.3
iso8601>=0.1.9
Jinja2
# python3 branch of paramiko

152
satori/bash.py Normal file
View File

@ -0,0 +1,152 @@
# Copyright 2012-2013 OpenStack Foundation
#
# 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.
#
"""Shell classes for executing commands on a system.
Execute commands over ssh or using python subprocess module.
"""
import logging
import platform
import shlex
import subprocess
from satori import ssh
LOG = logging.getLogger(__name__)
class ShellMixin(object):
"""Handle platform detection and define execute command."""
def execute(self, command, wd=None, with_exit_code=None):
"""Execute a (shell) command on the target.
:param command: Shell command to be executed
:param with_exit_code: Include the exit_code in the return body.
:param wd: The child's current directory will be changed
to `wd` before it is executed. Note that this
directory is not considered when searching the
executable, so you can't specify the program's
path relative to this argument
"""
pass
@property
def platform_info(self):
"""Provide distro, version, architecture."""
pass
def is_debian(self):
"""Return a boolean indicating whether the system is debian based.
Uses the platform_info property.
"""
return self.platform_info['dist'].lower() in ['debian', 'ubuntu']
def is_fedora(self):
"""Return a boolean indicating whether the system in fedora based.
Uses the platform info property.
"""
return (self.platform_info['dist'].lower() in
['redhat', 'centos', 'fedora', 'el'])
class LocalShell(ShellMixin):
"""Execute shell commands on local machine."""
def __init__(self, user=None, password=None, interactive=False):
"""An interface for executing shell commands locally.
:param user: The user to execute the command as.
Defaults to the current user.
:param password: The password for `user`
:param interactive: If true, prompt for password if missing.
"""
self.user = user
self.password = password
self.interactive = interactive
# TODO(samstav): Implement handle_password_prompt for popen
@property
def platform_info(self):
"""Return distro, version, and system architecture."""
return list(platform.dist() + (platform.machine(),))
def execute(self, command, wd=None, with_exit_code=None):
"""Execute a command (containing no shell operators) locally."""
spipe = subprocess.PIPE
cmd = shlex.split(command)
LOG.debug("Executing `%s` on local machine", command)
result = subprocess.Popen(
cmd, stdout=spipe, stderr=spipe, cwd=wd)
out, err = result.communicate()
resultdict = {
'stdout': out.strip(),
'stderr': err.strip(),
}
if with_exit_code:
resultdict.update({'exit_code': result.returncode})
return resultdict
class RemoteShell(ShellMixin):
"""Execute shell commands on a remote machine over ssh."""
def __init__(self, address, **kwargs):
"""An interface for executing shell commands on remote machines.
:param str host: The ip address or host name of the server
to connect to
:param str password: A password to use for authentication
or for unlocking a private key
:param username: The username to authenticate as
:param private_key: Private SSH Key string to use
(instead of using a filename)
:param key_filename: a private key filename (path)
:param port: tcp/ip port to use (defaults to 22)
:param float timeout: an optional timeout (in seconds) for the
TCP connection
:param socket proxy: an existing SSH instance to use
for proxying
:param dict options: A dictionary used to set ssh options
(when proxying).
e.g. for `ssh -o StrictHostKeyChecking=no`,
you would provide
(.., options={'StrictHostKeyChecking': 'no'})
Conversion of booleans is also supported,
(.., options={'StrictHostKeyChecking': False})
is equivalent.
:keyword interactive: If true, prompt for password if missing.
"""
self.sshclient = ssh.connect(address, **kwargs)
self.host = self.sshclient.host
self.port = self.sshclient.port
@property
def platform_info(self):
"""Return distro, version, architecture."""
return self.sshclient.platform_info
def execute(self, command, wd=None, with_exit_code=None):
"""Execute given command over ssh."""
return self.sshclient.remote_execute(
command, wd=wd, with_exit_code=with_exit_code)

View File

@ -23,7 +23,9 @@ Example usage:
from __future__ import print_function
import ipaddress as ipaddress_module
from novaclient.v1_1 import client
from pythonwhois import shared
import six
from satori import dns
@ -37,7 +39,17 @@ def run(address, config, interactive=False):
ipaddress = address
else:
ipaddress = dns.resolve_hostname(address)
results['domain'] = dns.domain_info(address)
#TODO(sam): Use ipaddress.ip_address.is_global
# " .is_private
# " .is_unspecified
# " .is_multicast
# To determine address "type"
if not ipaddress_module.ip_address(unicode(ipaddress)).is_loopback:
try:
results['domain'] = dns.domain_info(address)
except shared.WhoisException as exc:
results['domain'] = str(exc)
results['address'] = ipaddress
results['host'] = host = {'type': 'Undetermined'}

View File

@ -340,7 +340,8 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
return False
def remote_execute(self, command, with_exit_code=False, get_pty=False):
def remote_execute(self, command, with_exit_code=False,
get_pty=False, wd=None):
"""Execute an ssh command on a remote host.
Tries cert auth first and falls back
@ -348,11 +349,20 @@ class SSH(paramiko.SSHClient): # pylint: disable=R0902
:param command: Shell command to be executed by this function.
:param with_exit_code: Include the exit_code in the return body.
:param wd: The child's current directory will be changed
to `wd` before it is executed. Note that this
directory is not considered when searching the
executable, so you can't specify the program's
path relative to this argument
:param get_pty: Request a pseudo-terminal from the server.
:returns: a dict with stdin, stdout,
and (optionally) the exit code of the call.
"""
if wd:
prefix = "cd %s && " % wd
command = prefix + command
LOG.debug("Executing '%s' on ssh://%s@%s:%s.",
command, self.username, self.host, self.port)
try:

View File

@ -10,15 +10,18 @@
# License for the specific language governing permissions and limitations
# under the License.
#
# pylint: disable=W0622
"""Ohai Solo Data Plane Discovery Module."""
import json
import logging
import ipaddress as ipaddress_module
import six
from satori import bash
from satori import errors
from satori import ssh
from satori import utils
LOG = logging.getLogger(__name__)
if six.PY3:
@ -34,17 +37,26 @@ def get_systeminfo(ipaddress, config, interactive=False):
: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)
if (ipaddress in utils.get_local_ips() or
ipaddress_module.ip_address(unicode(ipaddress)).is_loopback):
client = bash.LocalShell()
client.host = "localhost"
client.port = 0
else:
client = bash.RemoteShell(ipaddress, username=config.host_username,
private_key=config.host_key,
interactive=interactive)
install_remote(client)
return system_info(client)
def system_info(ssh_client):
def system_info(client):
"""Run ohai-solo on a remote system and gather the output.
:param ssh_client: :class:`ssh.SSH` instance
:param client: :class:`ssh.SSH` instance
:returns: dict -- system information from ohai-solo
:raises: SystemInfoCommandMissing, SystemInfoCommandOld, SystemInfoNotJson
SystemInfoMissingJson
@ -54,13 +66,13 @@ def system_info(ssh_client):
SystemInfoNotJson if `ohai` does not return valid JSON.
SystemInfoMissingJson if `ohai` does not return any JSON.
"""
output = ssh_client.remote_execute("sudo -i ohai-solo")
output = client.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)
LOG.warning("SystemInfoCommandMissing on host: [%s]", client.host)
raise errors.SystemInfoCommandMissing("ohai-solo missing on %s",
ssh_client.host)
client.host)
unicode_output = unicode(output['stdout'], errors='replace')
try:
results = json.loads(unicode_output)
@ -83,21 +95,21 @@ def is_fedora(platform):
return platform['dist'].lower() in ['redhat', 'centos', 'fedora', 'el']
def install_remote(ssh_client):
def install_remote(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)
client.host, client.host, client.port)
# Download to host
command = "cd /tmp && sudo wget -N http://ohai.rax.io/install.sh"
ssh_client.remote_execute(command)
command = "sudo wget -N http://ohai.rax.io/install.sh"
client.execute(command, wd='/tmp')
# Run install
command = "cd /tmp && bash install.sh"
output = ssh_client.remote_execute(command, with_exit_code=True)
command = "sudo bash install.sh"
output = client.execute(command, wd='/tmp', 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)
command = "sudo rm install.sh"
client.execute(command, wd='/tmp')
# Process install command output
if output['exit_code'] != 0:
@ -106,7 +118,7 @@ def install_remote(ssh_client):
return output
def remove_remote(ssh_client):
def remove_remote(client):
"""Remove ohai-solo from specifc remote system.
Currently supports:
@ -115,7 +127,7 @@ def remove_remote(ssh_client):
- redhat [5.x, 6.x]
- centos [5.x, 6.x]
"""
platform_info = ssh_client.platform_info
platform_info = client.platform_info
if is_debian(platform_info):
remove = "sudo dpkg --purge ohai-solo"
elif is_fedora(platform_info):
@ -123,8 +135,8 @@ def remove_remote(ssh_client):
else:
raise errors.UnsupportedPlatform("Unknown distro: %s" %
platform_info['dist'])
command = "cd /tmp && %s" % remove
output = ssh_client.remote_execute(command)
command = "%s" % remove
output = client.execute(command, wd='/tmp')
return output
@ -141,6 +153,6 @@ def get_json(data):
first = data.index('{')
last = data.rindex('}')
return data[first:last + 1]
except ValueError as e:
context = {"ValueError": "%s" % e}
except ValueError as exc:
context = {"ValueError": "%s" % exc}
raise errors.SystemInfoMissingJson(context)

View File

@ -23,11 +23,11 @@ from satori.tests import utils
class TestOhaiSolo(utils.TestCase):
@mock.patch.object(ohai_solo, 'ssh')
@mock.patch.object(ohai_solo, 'bash')
@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"
def test_connect_and_run(self, mock_install, mock_sysinfo, mock_bash):
address = "192.0.2.2"
config = mock.MagicMock()
config.host_key = "foo"
config.host_username = "bar"
@ -35,11 +35,14 @@ class TestOhaiSolo(utils.TestCase):
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)
mock_install.assert_called_once_with(
mock_bash.RemoteShell.return_value)
mock_bash.RemoteShell.assert_called_with(
address, username="bar",
private_key="foo",
interactive=False)
mock_sysinfo.assert_called_with(mock_bash.RemoteShell.return_value)
class TestOhaiInstall(utils.TestCase):
@ -47,20 +50,19 @@ 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
mock_ssh.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")]
)
self.assertEqual(mock_ssh.execute.call_count, 3)
mock_ssh.execute.assert_has_calls([
mock.call('sudo wget -N http://ohai.rax.io/install.sh', wd='/tmp'),
mock.call('sudo bash install.sh', wd='/tmp', with_exit_code=True),
mock.call('sudo rm install.sh', wd='/tmp')])
def test_install_remote_failed(self):
mock_ssh = mock.MagicMock()
response = {'exit_code': 1, 'stdout': "", "stderr": "FAIL"}
mock_ssh.remote_execute.return_value = response
mock_ssh.execute.return_value = response
self.assertRaises(errors.SystemInfoCommandInstallFailed,
ohai_solo.install_remote, mock_ssh)
@ -75,11 +77,11 @@ class TestOhaiRemove(utils.TestCase):
'arch': 'xyz'
}
response = {'exit_code': 0, 'foo': 'bar'}
mock_ssh.remote_execute.return_value = response
mock_ssh.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")
mock_ssh.execute.assert_called_once_with(
'sudo yum -y erase ohai-solo', wd='/tmp')
def test_remove_remote_debian(self):
mock_ssh = mock.MagicMock()
@ -89,11 +91,11 @@ class TestOhaiRemove(utils.TestCase):
'arch': 'xyz'
}
response = {'exit_code': 0, 'foo': 'bar'}
mock_ssh.remote_execute.return_value = response
mock_ssh.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")
mock_ssh.execute.assert_called_once_with(
'sudo dpkg --purge ohai-solo', wd='/tmp')
def test_remove_remote_unsupported(self):
mock_ssh = mock.MagicMock()
@ -106,27 +108,27 @@ class TestSystemInfo(utils.TestCase):
def test_system_info(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
mock_ssh.execute.return_value = {
'exit_code': 0,
'stdout': "{}",
'stderr': ""
}
ohai_solo.system_info(mock_ssh)
mock_ssh.remote_execute.assert_called_with("sudo -i ohai-solo")
mock_ssh.execute.assert_called_with("sudo -i ohai-solo")
def test_system_info_with_motd(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
mock_ssh.execute.return_value = {
'exit_code': 0,
'stdout': "Hello world\n {}",
'stderr': ""
}
ohai_solo.system_info(mock_ssh)
mock_ssh.remote_execute.assert_called_with("sudo -i ohai-solo")
mock_ssh.execute.assert_called_with("sudo -i ohai-solo")
def test_system_info_bad_json(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
mock_ssh.execute.return_value = {
'exit_code': 0,
'stdout': "{Not JSON!}",
'stderr': ""
@ -136,7 +138,7 @@ class TestSystemInfo(utils.TestCase):
def test_system_info_missing_json(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
mock_ssh.execute.return_value = {
'exit_code': 0,
'stdout': "No JSON!",
'stderr': ""
@ -146,7 +148,7 @@ class TestSystemInfo(utils.TestCase):
def test_system_info_command_not_found(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
mock_ssh.execute.return_value = {
'exit_code': 1,
'stdout': "",
'stderr': "ohai-solo command not found"
@ -156,7 +158,7 @@ class TestSystemInfo(utils.TestCase):
def test_system_info_could_not_find(self):
mock_ssh = mock.MagicMock()
mock_ssh.remote_execute.return_value = {
mock_ssh.execute.return_value = {
'exit_code': 1,
'stdout': "",
'stderr': "Could not find ohai-solo."

View File

@ -108,3 +108,30 @@ def is_valid_ipv6_address(address):
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 get_local_ips():
"""Return local ipaddress(es)."""
# pylint: disable=W0703
list1 = []
list2 = []
defaults = ["127.0.0.1", r"fe80::1%lo0"]
hostname = None
try:
hostname = socket.gethostname()
except Exception as exc:
LOG.debug("Error in gethostbyname_ex: %s", exc)
try:
_, _, addresses = socket.gethostbyname_ex(hostname)
list1 = [ip for ip in addresses]
except Exception as exc:
LOG.debug("Error in gethostbyname_ex: %s", exc)
try:
list2 = [info[4][0] for info in socket.getaddrinfo(hostname, None)]
except Exception as exc:
LOG.debug("Error in getaddrinfo: %s", exc)
return list(set(list1 + list2 + defaults))