Skip to content

Commit

Permalink
Implemented a login subcommand (#711)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
retr0h committed Jan 12, 2017
1 parent 2b32e2f commit 0420108
Show file tree
Hide file tree
Showing 10 changed files with 304 additions and 17 deletions.
9 changes: 8 additions & 1 deletion doc/source/usage.rst
Expand Up @@ -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.
Expand Down Expand Up @@ -195,6 +195,13 @@ Lint
:undoc-members:
:members: execute

Login
^^^^^

.. autoclass:: molecule.command.login.Login
:undoc-members:
:members: execute

Status
^^^^^^

Expand Down
2 changes: 1 addition & 1 deletion molecule/command/__init__.py
Expand Up @@ -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
Expand Down
115 changes: 115 additions & 0 deletions 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()
31 changes: 29 additions & 2 deletions molecule/driver/base.py
Expand Up @@ -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):
"""
Expand Down
17 changes: 7 additions & 10 deletions molecule/driver/dockr.py
Expand Up @@ -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 = []
Expand Down
2 changes: 0 additions & 2 deletions molecule/interpolation.py
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion molecule/shell.py
Expand Up @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions test/functional/test_docker.py
Expand Up @@ -21,6 +21,7 @@
import os
import re

import pexpect
import pytest
import sh

Expand Down Expand Up @@ -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):
Expand Down
122 changes: 122 additions & 0 deletions 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)

0 comments on commit 0420108

Please sign in to comment.