diff --git a/senlinclient/tests/unit/v1/test_shell.py b/senlinclient/tests/unit/v1/test_shell.py index 79cfb0af..65abfd6f 100644 --- a/senlinclient/tests/unit/v1/test_shell.py +++ b/senlinclient/tests/unit/v1/test_shell.py @@ -11,6 +11,8 @@ # under the License. import copy +import subprocess + import mock import six import testtools @@ -748,6 +750,175 @@ class ShellTest(testtools.TestCase): msg = _('Failed to delete some of the specified clusters.') self.assertEqual(msg, six.text_type(ex)) + @mock.patch('subprocess.Popen') + def test__run_script(self, mock_proc): + x_proc = mock.Mock(returncode=0) + x_stdout = 'OUTPUT' + x_stderr = 'ERROR' + x_proc.communicate.return_value = (x_stdout, x_stderr) + mock_proc.return_value = x_proc + + addr = { + 'private': [ + { + 'OS-EXT-IPS:type': 'floating', + 'version': 4, + 'addr': '1.2.3.4', + } + ] + } + output = {} + + sh._run_script('NODE_ID', addr, 'private', 'floating', 22, 'john', + False, 'identity_path', 'echo foo', '-f bar', + output=output) + mock_proc.assert_called_once_with( + ['ssh', '-4', '-p22', '-i identity_path', '-f bar', 'john@1.2.3.4', + 'echo foo'], + stdout=subprocess.PIPE) + self.assertEqual( + {'status': 'SUCCEEDED (0)', 'output': 'OUTPUT', 'error': 'ERROR'}, + output) + + def test__run_script_network_not_found(self): + addr = {'foo': 'bar'} + output = {} + + sh._run_script('NODE_ID', addr, 'private', 'floating', 22, 'john', + False, 'identity_path', 'echo foo', '-f bar', + output=output) + self.assertEqual( + {'status': 'FAILED', + 'reason': "Node 'NODE_ID' is not attached to network 'private'." + }, + output) + + def test__run_script_more_than_one_network(self): + addr = {'foo': 'bar', 'koo': 'tar'} + output = {} + + sh._run_script('NODE_ID', addr, '', 'floating', 22, 'john', + False, 'identity_path', 'echo foo', '-f bar', + output=output) + self.assertEqual( + {'status': 'FAILED', + 'reason': "Node 'NODE_ID' is attached to more than one " + "network. Please pick the network to use."}, + output) + + def test__run_script_no_network(self): + addr = {} + output = {} + + sh._run_script('NODE_ID', addr, '', 'floating', 22, 'john', + False, 'identity_path', 'echo foo', '-f bar', + output=output) + + self.assertEqual( + {'status': 'FAILED', + 'reason': "Node 'NODE_ID' is not attached to any network."}, + output) + + def test__run_script_no_matching_address(self): + addr = { + 'private': [ + { + 'OS-EXT-IPS:type': 'fixed', + 'version': 4, + 'addr': '1.2.3.4', + } + ] + } + output = {} + + sh._run_script('NODE_ID', addr, 'private', 'floating', 22, 'john', + False, 'identity_path', 'echo foo', '-f bar', + output=output) + self.assertEqual( + {'status': 'FAILED', + 'reason': "No address that would match network 'private' and " + "type 'floating' of IPv4 has been found for node " + "'NODE_ID'."}, + output) + + def test__run_script_more_than_one_address(self): + addr = { + 'private': [ + { + 'OS-EXT-IPS:type': 'fixed', + 'version': 4, + 'addr': '1.2.3.4', + }, + { + 'OS-EXT-IPS:type': 'fixed', + 'version': 4, + 'addr': '5.6.7.8', + }, + ] + } + + output = {} + + sh._run_script('NODE_ID', addr, 'private', 'fixed', 22, 'john', + False, 'identity_path', 'echo foo', '-f bar', + output=output) + self.assertEqual( + {'status': 'FAILED', + 'reason': "More than one IPv4 fixed address found."}, + output) + + @mock.patch('threading.Thread') + @mock.patch.object(sh, '_run_script') + def test_do_cluster_run(self, mock_run, mock_thread): + service = mock.Mock() + args = { + 'script': 'script_name', + 'network': 'network_name', + 'address_type': 'fixed', + 'port': 22, + 'user': 'root', + 'ipv6': False, + 'identity_file': 'identity_filename', + 'ssh_options': '-f oo', + } + args = self._make_args(args) + args.id = 'CID' + addr1 = {'addresses': 'ADDR CONTENT 1'} + addr2 = {'addresses': 'ADDR CONTENT 2'} + attributes = [ + mock.Mock(node_id='NODE1', attr_value=addr1), + mock.Mock(node_id='NODE2', attr_value=addr2) + ] + service.collect_cluster_attrs.return_value = attributes + + th1 = mock.Mock() + th2 = mock.Mock() + mock_thread.side_effect = [th1, th2] + fake_script = 'blah blah' + with mock.patch('senlinclient.v1.shell.open', + mock.mock_open(read_data=fake_script)) as mock_open: + sh.do_cluster_run(service, args) + + service.collect_cluster_attrs.assert_called_once_with( + args.id, 'details') + mock_open.assert_called_once_with('script_name', 'r') + mock_thread.assert_has_calls([ + mock.call(target=mock_run, + args=('NODE1', 'ADDR CONTENT 1', 'network_name', + 'fixed', 22, 'root', False, 'identity_filename', + 'blah blah', '-f oo'), + kwargs={'output': {}}), + mock.call(target=mock_run, + args=('NODE2', 'ADDR CONTENT 2', 'network_name', + 'fixed', 22, 'root', False, 'identity_filename', + 'blah blah', '-f oo'), + kwargs={'output': {}}) + ]) + th1.start.assert_called_once_with() + th2.start.assert_called_once_with() + th1.join.assert_called_once_with() + th2.join.assert_called_once_with() + @mock.patch.object(sh, '_show_cluster') def test_do_cluster_update(self, mock_show): service = mock.Mock() diff --git a/senlinclient/v1/shell.py b/senlinclient/v1/shell.py index 88b9f460..b8bd72c9 100644 --- a/senlinclient/v1/shell.py +++ b/senlinclient/v1/shell.py @@ -11,8 +11,13 @@ # under the License. import logging +import subprocess +import threading +import time from openstack import exceptions as sdk_exc +import six + from senlinclient.common import exc from senlinclient.common.i18n import _ from senlinclient.common.i18n import _LW @@ -22,11 +27,9 @@ logger = logging.getLogger(__name__) def show_deprecated(deprecated, recommended): - logger.warning(_LW('"%(old)s" is deprecated, ' - 'please use "%(new)s" instead.'), - {'old': deprecated, - 'new': recommended} - ) + logger.warning( + _LW('"%(old)s" is deprecated, please use "%(new)s" instead.'), + {'old': deprecated, 'new': recommended}) def do_build_info(service, args=None): @@ -593,6 +596,158 @@ def do_cluster_delete(service, args): print('Request accepted') +def _run_script(node_id, addr, net, addr_type, port, user, ipv6, identity_file, + script, options, output=None): + version = 6 if ipv6 else 4 + + # Select the network to use. + if net: + addresses = addr.get(net) + if not addresses: + output['status'] = _('FAILED') + output['reason'] = _("Node '%(node)s' is not attached to network " + "'%(net)s'.") % {'node': node_id, 'net': net} + return + else: + # network not specified + if len(addr) > 1: + output['status'] = _('FAILED') + output['reason'] = _("Node '%(node)s' is attached to more than " + "one network. Please pick the network to " + "use.") % {'node': node_id} + return + elif not addr: + output['status'] = _('FAILED') + output['reason'] = _("Node '%(node)s' is not attached to any " + "network.") % {'node': node_id} + return + else: + addresses = list(six.itervalues(addr))[0] + + # Select the address in the selected network. + # If the extension is not present, we assume the address to be floating. + matching_addresses = [] + for a in addresses: + a_type = a.get('OS-EXT-IPS:type', 'floating') + a_version = a.get('version') + if (a_version == version and a_type == addr_type): + matching_addresses.append(a.get('addr')) + + if not matching_addresses: + output['status'] = _('FAILED') + output['reason'] = _("No address that would match network '%(net)s' " + "and type '%(type)s' of IPv%(ver)s has been " + "found for node '%(node)s'." + ) % {'net': net, 'type': addr_type, + 'ver': version, 'node': node_id} + return + + if len(matching_addresses) > 1: + output['status'] = _('FAILED') + output['reason'] = _("More than one IPv%(ver)s %(type)s address " + "found.") % {'ver': version, 'type': addr_type} + return + + ip_address = str(matching_addresses[0]) + identity = '-i %s' % identity_file if identity_file else '' + + cmd = [ + 'ssh', + '-%d' % version, + '-p%d' % port, + identity, + options, + '%s@%s' % (user, ip_address), + '%s' % script + ] + logger.debug("%s" % cmd) + proc = subprocess.Popen(cmd, stdout=subprocess.PIPE) + (stdout, stderr) = proc.communicate() + while proc.returncode is None: + time.sleep(1) + if proc.returncode == 0: + output['status'] = _('SUCCEEDED (0)') + output['output'] = stdout + if stderr: + output['error'] = stderr + else: + output['status'] = _('FAILED (%d)') % proc.returncode + output['output'] = stdout + if stderr: + output['error'] = stderr + + +@utils.arg("-p", "--port", metavar="", type=int, default=22, + help=_("Optional flag to indicate the port to use (Default=22).")) +@utils.arg("-t", "--address-type", type=str, default="floating", + help=_("Optional flag to indicate which IP type to use. Possible " + "values includes 'fixed' and 'floating' (the Default).")) +@utils.arg("-n", "--network", metavar='', default='', + help=_('Network to use for the ssh.')) +@utils.arg("-6", "--ipv6", action="store_true", default=False, + help=_("Optional flag to indicate whether to use an IPv6 address " + "attached to a server. (Defaults to IPv4 address)")) +@utils.arg("-u", "--user", metavar="", default="root", + help=_("Login to use.")) +@utils.arg("-i", "--identity-file", + help=_("Private key file, same as the '-i' option to the ssh " + "command.")) +@utils.arg("-O", "--ssh-options", default="", + help=_("Extra options to pass to ssh. see: man ssh.")) +@utils.arg("-s", "--script", metavar="", required=True, + help=_("Script file to run.")) +@utils.arg("id", metavar="", + help=_('Name or ID of the cluster.')) +def do_cluster_run(service, args): + """Run shell scripts on all nodes of a cluster.""" + if '@' in args.id: + user, cluster = args.id.split('@', 1) + args.user = user + args.cluster = cluster + + try: + attributes = service.collect_cluster_attrs(args.id, 'details') + except sdk_exc.ResourceNotFound: + raise exc.CommandError(_("Cluster not found: %s") % args.id) + + script = None + try: + f = open(args.script, 'r') + script = f.read() + except Exception: + raise exc.CommandError(_("Cound not open script file: %s") % + args.script) + + tasks = dict() + for attr in attributes: + node_id = attr.node_id + addr = attr.attr_value['addresses'] + + output = dict() + th = threading.Thread( + target=_run_script, + args=(node_id, addr, args.network, args.address_type, args.port, + args.user, args.ipv6, args.identity_file, + script, args.ssh_options), + kwargs={'output': output}) + th.start() + tasks[th] = (node_id, output) + + for t in tasks: + t.join() + + for t in tasks: + node_id, result = tasks[t] + print("node: %s" % node_id) + print("status: %s" % result.get('status')) + if "reason" in result: + print("reason: %s" % result.get('reason')) + if "output" in result: + print("output:\n%s" % result.get('output')) + if "error" in result: + print("error:\n%s" % result.get('error')) + + @utils.arg('-p', '--profile', metavar='', help=_('ID of new profile to use.')) @utils.arg('-t', '--timeout', metavar='',