From 0420108a6f8c9ecfbfa9cb34b2396231234490ad Mon Sep 17 00:00:00 2001 From: John Dewey Date: Wed, 11 Jan 2017 22:18:00 -0800 Subject: [PATCH] Implemented a login subcommand (#711) * Cleaned up interpolation coverage When moving code over from Docker, there was uncovered code. Deleted the code, since I don't see a valid reason we would encounter the raise. * Implemented a login subcommand Ported v1 login to v2. Fixes: #704 --- doc/source/usage.rst | 9 ++- molecule/command/__init__.py | 2 +- molecule/command/login.py | 115 ++++++++++++++++++++++++++++++ molecule/driver/base.py | 31 +++++++- molecule/driver/dockr.py | 17 ++--- molecule/interpolation.py | 2 - molecule/shell.py | 2 +- test/functional/test_docker.py | 13 ++++ test/unit/command/test_login.py | 122 ++++++++++++++++++++++++++++++++ test/unit/driver/test_dockr.py | 8 +++ 10 files changed, 304 insertions(+), 17 deletions(-) create mode 100644 molecule/command/login.py create mode 100644 test/unit/command/test_login.py diff --git a/doc/source/usage.rst b/doc/source/usage.rst index 837a2039c..a268121a4 100644 --- a/doc/source/usage.rst +++ b/doc/source/usage.rst @@ -10,7 +10,7 @@ Install Molecule using pip: $ pip install molecule --pre -.. warn:: +.. warning:: This install method is not yet available. We will cut a pre-release candidate once ready. @@ -195,6 +195,13 @@ Lint :undoc-members: :members: execute +Login +^^^^^ + +.. autoclass:: molecule.command.login.Login + :undoc-members: + :members: execute + Status ^^^^^^ diff --git a/molecule/command/__init__.py b/molecule/command/__init__.py index 37e20dc88..3135ee00a 100644 --- a/molecule/command/__init__.py +++ b/molecule/command/__init__.py @@ -31,7 +31,7 @@ from molecule.command import idempotence # noqa from molecule.command import init # noqa from molecule.command import lint # noqa -# from molecule.command import login # noqa +from molecule.command import login # noqa from molecule.command import status # noqa from molecule.command import syntax # noqa from molecule.command import test # noqa diff --git a/molecule/command/login.py b/molecule/command/login.py new file mode 100644 index 000000000..33fda285d --- /dev/null +++ b/molecule/command/login.py @@ -0,0 +1,115 @@ +# Copyright (c) 2015-2017 Cisco Systems, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import fcntl +import os +import pexpect +import signal +import struct +import sys +import termios + +import click + +from molecule import util +from molecule.command import base + + +class Login(base.Base): + def execute(self): + """ + Execute the actions necessary to perform a `molecule login` and + returns None. + + >>> molecule login --host hotname --scenario-name foo + + :return: None + """ + if not self._config.state.created: + msg = 'Instances not created. Please create instances first.' + util.print_error(msg) + util.sysexit() + + hosts = [d['name'] for d in self._config.platforms_with_scenario_name] + hostname = self._get_hostname(hosts) + self._get_login(hostname) + + def _get_hostname(self, hosts): + hostname = self._config.command_args.get('host') + match = [x for x in hosts if x.startswith(hostname)] + if len(match) == 0: + msg = ("There are no hosts that match '{}'. You " + 'can only login to valid hosts.').format(hostname) + util.print_error(msg) + util.sysexit() + elif len(match) != 1: + # If there are multiple matches, but one of them is an exact string + # match, assume this is the one they're looking for and use it. + if hostname in match: + match = [hostname, ] + else: + msg = ("There are {} hosts that match '{}'. You " + 'can only login to one at a time.\n\n' + 'Available hosts:\n{}'.format( + len(match), hostname, '\n'.join(sorted(hosts)))) + util.print_error(msg) + util.sysexit() + + return match[0] + + def _get_login(self, hostname): # pragma: no cover + login_cmd = self._config.driver.login_cmd_template + login_args = self._config.driver.login_args(hostname) + + lines, columns = os.popen('stty size', 'r').read().split() + dimensions = (int(lines), int(columns)) + self._pt = pexpect.spawn( + '/usr/bin/env ' + login_cmd.format(*login_args), + dimensions=dimensions) + signal.signal(signal.SIGWINCH, self._sigwinch_passthrough) + self._pt.interact() + + def _sigwinch_passthrough(self, sig, data): # pragma: no cover + TIOCGWINSZ = 1074295912 # assume + if 'TIOCGWINSZ' in dir(termios): + TIOCGWINSZ = termios.TIOCGWINSZ + s = struct.pack('HHHH', 0, 0, 0, 0) + a = struct.unpack('HHHH', + fcntl.ioctl(sys.stdout.fileno(), TIOCGWINSZ, s)) + self._pt.setwinsize(a[0], a[1]) + + +@click.command() +@click.pass_context +@click.option('--host', required=True, help='Host to access.') +@click.option( + '--scenario-name', required=True, help='Name of the scenario to target.') +def login(ctx, host, scenario_name): # pragma: no cover + """ Log in to one instance. """ + args = ctx.obj.get('args') + command_args = { + 'subcommand': __name__, + 'host': host, + 'scenario_name': scenario_name + } + + for config in base.get_configs(args, command_args): + l = Login(config) + l.execute() diff --git a/molecule/driver/base.py b/molecule/driver/base.py index 08a7f7b06..d9a9f0150 100644 --- a/molecule/driver/base.py +++ b/molecule/driver/base.py @@ -36,13 +36,40 @@ def __init__(self, config): @abc.abstractproperty def testinfra_options(self): """ - Returns the kwargs used when invoking the testinfra validator, and - returns a dict. + Returns a Testinfra specific options dict. :returns: dict """ pass # pragma: no cover + @abc.abstractproperty + def connection_options(self): + """ + Returns a driver specific connection options dict. + + :returns: str + """ + pass # pragma: no cover + + @abc.abstractproperty + def login_cmd_template(self): + """ + Returns the command string to login to a host. + + :returns: str + """ + pass # pragma: no cover + + @abc.abstractmethod + def login_args(self, instance_name): + """ + Returns the arguments used in the login command and returns a list. + + :param instance_name: A string containing the instance to login to. + :returns: list + """ + pass # pragma: no cover + @abc.abstractmethod def status(self): """ diff --git a/molecule/driver/dockr.py b/molecule/driver/dockr.py index 059f9cca2..c61be9c7c 100644 --- a/molecule/driver/dockr.py +++ b/molecule/driver/dockr.py @@ -48,22 +48,19 @@ def __init__(self, config): @property def testinfra_options(self): - """ - Returns a Testinfra specific options dict. - - :returns: dict - """ return {'connection': 'docker'} @property def connection_options(self): - """ - Returns a driver specific connection options dict. - - :returns: str - """ return {'ansible_connection': 'docker'} + @property + def login_cmd_template(self): + return 'docker exec -ti {} bash' + + def login_args(self, instance): + return [instance] + def status(self): Status = collections.namedtuple('Status', ['name', 'state', 'driver']) status_list = [] diff --git a/molecule/interpolation.py b/molecule/interpolation.py index 19e5859b5..8d1f8f00c 100644 --- a/molecule/interpolation.py +++ b/molecule/interpolation.py @@ -83,7 +83,5 @@ def convert(mo): return self.delimiter if mo.group('invalid') is not None: self._invalid(mo) - raise ValueError('Unrecognized named group in pattern', - self.pattern) return self.pattern.sub(convert, self.template) diff --git a/molecule/shell.py b/molecule/shell.py index 5eabb7b7e..18be852e1 100644 --- a/molecule/shell.py +++ b/molecule/shell.py @@ -49,7 +49,7 @@ def cli(ctx, debug): # pragma: no cover cli.add_command(command.idempotence.idempotence) cli.add_command(command.init.init) cli.add_command(command.lint.lint) -# cli.add_command(command.login.login) +cli.add_command(command.login.login) cli.add_command(command.status.status) cli.add_command(command.syntax.syntax) cli.add_command(command.test.test) diff --git a/test/functional/test_docker.py b/test/functional/test_docker.py index 5828c5b0f..f0dbacf15 100644 --- a/test/functional/test_docker.py +++ b/test/functional/test_docker.py @@ -21,6 +21,7 @@ import os import re +import pexpect import pytest import sh @@ -88,6 +89,18 @@ def test_command_lint(with_scenario): sh.molecule('lint') +@pytest.mark.parametrize( + 'with_scenario', ['docker'], indirect=['with_scenario']) +def test_command_login(with_scenario): + sh.molecule('create') + + child = pexpect.spawn( + 'molecule login --host instance --scenario-name default') + child.expect('.*instance-[12].*') + # If the test returns and doesn't hang it succeeded. + child.sendline('exit') + + @pytest.mark.parametrize( 'with_scenario', ['docker'], indirect=['with_scenario']) def test_command_status(with_scenario): diff --git a/test/unit/command/test_login.py b/test/unit/command/test_login.py new file mode 100644 index 000000000..3154cc031 --- /dev/null +++ b/test/unit/command/test_login.py @@ -0,0 +1,122 @@ +# Copyright (c) 2015-2017 Cisco Systems, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import pytest + +from molecule import config +from molecule.command import login + + +@pytest.fixture +def login_instance(molecule_file, platforms_data): + configs = [platforms_data] + c = config.Config( + molecule_file, + args={}, + command_args={'host': 'instance-1'}, + configs=configs) + c.state.change_state('created', True) + + return login.Login(c) + + +def test_execute(mocker, login_instance): + m = mocker.patch('molecule.command.login.Login._get_login') + login_instance.execute() + + m.assert_called_once_with('instance-1-default') + + +def test_execute_raises_when_not_converged(patched_print_error, + login_instance): + login_instance._config.state.change_state('created', False) + with pytest.raises(SystemExit) as e: + login_instance.execute() + + assert 1 == e.value.code + + msg = 'Instances not created. Please create instances first.' + patched_print_error.assert_called_with(msg) + + +def test_get_hostname_does_not_match(molecule_file, patched_print_error): + c = config.Config(molecule_file, command_args={'host': 'invalid'}) + l = login.Login(c) + hosts = ['instance-1'] + + with pytest.raises(SystemExit) as e: + l._get_hostname(hosts) + + assert 1 == e.value.code + + msg = ("There are no hosts that match 'invalid'. You " + 'can only login to valid hosts.') + patched_print_error.assert_called_once_with(msg) + + +def test_get_hostname_exact_match_with_one_host(molecule_file): + c = config.Config(molecule_file, command_args={'host': 'instance-1'}) + l = login.Login(c) + hosts = ['instance-1'] + + assert 'instance-1' == l._get_hostname(hosts) + + +def test_get_hostname_partial_match_with_one_host(molecule_file): + c = config.Config(molecule_file, command_args={'host': 'inst'}) + l = login.Login(c) + hosts = ['instance-1'] + + assert 'instance-1' == l._get_hostname(hosts) + + +def test_get_hostname_exact_match_with_multiple_hosts(molecule_file): + c = config.Config(molecule_file, command_args={'host': 'instance-1'}) + l = login.Login(c) + hosts = ['instance-1', 'instance-2'] + + assert 'instance-1' == l._get_hostname(hosts) + + +def test_get_hostname_partial_match_with_multiple_hosts(molecule_file): + c = config.Config(molecule_file, command_args={'host': 'foo'}) + l = login.Login(c) + hosts = ['foo', 'fooo'] + + assert 'foo' == l._get_hostname(hosts) + + +def test_get_hostname_partial_match_with_multiple_hosts_raises( + molecule_file, patched_print_error): + c = config.Config(molecule_file, command_args={'host': 'inst'}) + l = login.Login(c) + hosts = ['instance-1', 'instance-2'] + + with pytest.raises(SystemExit) as e: + l._get_hostname(hosts) + + assert 1 == e.value.code + + msg = ("There are 2 hosts that match 'inst'. " + 'You can only login to one at a time.\n\n' + 'Available hosts:\n' + 'instance-1\n' + 'instance-2') + patched_print_error.assert_called_once_with(msg) diff --git a/test/unit/driver/test_dockr.py b/test/unit/driver/test_dockr.py index 248481348..7dfc11fa6 100644 --- a/test/unit/driver/test_dockr.py +++ b/test/unit/driver/test_dockr.py @@ -59,6 +59,14 @@ def test_options_property(docker_instance): assert {} == docker_instance.options +def test_login_cmd_template(docker_instance): + assert 'docker exec -ti {} bash' == docker_instance.login_cmd_template + + +def test_login_args(docker_instance): + assert ['foo'] == docker_instance.login_args('foo') + + def test_status(mocker, docker_instance): def side_effect(filters): instance_name = filters['name']