Skip to content
This repository has been archived by the owner on May 1, 2024. It is now read-only.

Commit

Permalink
ansible: add lxd_transport option (#94)
Browse files Browse the repository at this point in the history
The "lxd" transport in ansible allows us to provision our containers
without SSH, directly through "lxc exec" calls!

I had the idea for quite a while that I should explore the possibility
of plugging "lxc exec" into ansible, but I didn't know that the plug was
already there, ready to be used! Had I known that, I would have used it
from the start, it would have saved me SSH fiddling...

I've also performed some "drive by" improvements of the affected tests
by parametrizing them. I've also converted the modified test in
`test_container` so it uses the global re-usable container rather than a
new one. It's less elegant, but faster. Our integration tests are soooo
slow...
  • Loading branch information
Virgil Dupras committed Jul 16, 2017
1 parent 9b72e2f commit 7742b71
Show file tree
Hide file tree
Showing 9 changed files with 110 additions and 93 deletions.
5 changes: 4 additions & 1 deletion .travis.yml
Expand Up @@ -22,8 +22,11 @@ matrix:
allow_failures:
- python: "3.7-dev"

# We set HOME because lxc needs somewhere writable to not crash during testing.
script:
- make travis
- cp -R /home/travis/.ssh $TRAVIS_BUILD_DIR
- HOME=$TRAVIS_BUILD_DIR make travis

after_success:
- codecov
branches:
Expand Down
10 changes: 10 additions & 0 deletions docs/provisioners/ansible.rst
Expand Up @@ -84,4 +84,14 @@ This option is a hash of lists of container names. Example:
group2:
- container2
lxd_transport
=============

If this boolean option is set to ``true``, we will use ansible's ``lxd`` transport instead of the
``ssh`` one, thus bypassing ssh entirely and using ``lxc exec`` directly.

It should be noted that while very cool-sounding, this transport method comes with a couple of
drawbacks due to its incomplete support in Ansible. For example, ``synchronize`` actions don't
work.

.. _Ansible: https://www.ansible.com/
74 changes: 39 additions & 35 deletions lxdock/provisioners/ansible.py
Expand Up @@ -15,26 +15,30 @@ class AnsibleProvisioner(Provisioner):

name = 'ansible'

guest_required_packages_alpine = ['openssh', 'python', ]
guest_required_packages_arch = ['openssh', 'python', ]
guest_required_packages_centos = ['openssh-server', 'python', ]
guest_required_packages_debian = ['apt-utils', 'aptitude', 'openssh-server', 'python', ]
guest_required_packages_fedora = ['openssh-server', 'python2', ]
guest_required_packages_gentoo = ['net-misc/openssh', 'dev-lang/python', ]
guest_required_packages_opensuse = ['openSSH', 'python3-base', ]
guest_required_packages_ol = ['openssh-server', 'python', ]
guest_required_packages_ubuntu = ['apt-utils', 'aptitude', 'openssh-server', 'python', ]
guest_required_packages_alpine = ['python', ]
guest_required_packages_arch = ['python', ]
guest_required_packages_centos = ['python', ]
guest_required_packages_debian = ['apt-utils', 'aptitude', 'python', ]
guest_required_packages_fedora = ['python2', ]
guest_required_packages_gentoo = ['dev-lang/python', ]
guest_required_packages_opensuse = ['python3-base', ]
guest_required_packages_ol = ['python', ]
guest_required_packages_ubuntu = ['apt-utils', 'aptitude', 'python', ]

schema = {
Required('playbook'): IsFile(),
'ask_vault_pass': bool,
'vault_password_file': IsFile(),
'groups': {Extra: [str, ]},
'lxd_transport': bool,
}

def get_inventory(self):
def line(guest):
ip = get_ip(guest.lxd_container)
if self.options.get('lxd_transport'):
ip = guest.container.lxd_name
else:
ip = get_ip(guest.lxd_container)
return '{} ansible_host={} ansible_user=root'.format(guest.container.name, ip)

def fmtgroup(name, hosts):
Expand All @@ -56,45 +60,42 @@ def provision(self):
def setup_single(self, guest):
super().setup_single(guest)

if self.options.get('lxd_transport'):
# we don't need ssh
return

ssh_pkg_name = {
'alpine': 'openssh',
'arch': 'openssh',
'gentoo': 'net-misc/openssh',
'opensuse': 'openSSH',
}.get(guest.name, 'openssh-server')
guest.install_packages([ssh_pkg_name])

# Make sure that sshd is started
if guest.name == 'alpine':
guest.run(['rc-update', 'add', 'sshd'])
guest.run(['/etc/init.d/sshd', 'start'])
elif guest.name in {'arch', 'centos', 'fedora'}:
guest.run(['systemctl', 'enable', 'sshd'])
guest.run(['systemctl', 'start', 'sshd'])
elif guest.name == 'ol':
guest.run(['/sbin/service', 'sshd', 'start'])

# Add the current user's SSH pubkey to the container's root SSH config.
ssh_pubkey = self.host.get_ssh_pubkey()
if ssh_pubkey is not None:
guest.add_ssh_pubkey_to_root_authorized_keys(ssh_pubkey)
else:
logger.warning('SSH pubkey was not found. Provisioning tools may not work correctly...')

def setup_guest_alpine(self, guest):
# On alpine guests we have to ensure that ssd is started!
guest.run(['rc-update', 'add', 'sshd'])
guest.run(['/etc/init.d/sshd', 'start'])

def setup_guest_arch(self, guest):
# On archlinux guests we have to ensure that sshd is started!
guest.run(['systemctl', 'enable', 'sshd'])
guest.run(['systemctl', 'start', 'sshd'])

def setup_guest_centos(self, guest):
# On centos guests we have to ensure that sshd is started!
guest.run(['systemctl', 'enable', 'sshd'])
guest.run(['systemctl', 'start', 'sshd'])

def setup_guest_fedora(self, guest):
# On fedora guests we have to ensure that sshd is started!
guest.run(['systemctl', 'enable', 'sshd'])
guest.run(['systemctl', 'start', 'sshd'])

def setup_guest_ol(self, guest):
# On oracle linux guests we have to ensure that sshd is started!
guest.run(['/sbin/service', 'sshd', 'start'])

##################################
# PRIVATE METHODS AND PROPERTIES #
##################################

def _build_ansible_playbook_command_args(self, inventory_filename):
cmd_args = ['ANSIBLE_HOST_KEY_CHECKING=False', 'ansible-playbook', ]
cmd_args.extend(['--inventory-file', inventory_filename, ])

# Append the --ask-vault-pass option if applicable.
if self.options.get('ask_vault_pass'):
cmd_args.append('--ask-vault-pass')
Expand All @@ -105,6 +106,9 @@ def _build_ansible_playbook_command_args(self, inventory_filename):
cmd_args.extend([
'--vault-password-file', self.homedir_expanded_path(vault_password_file)])

if self.options.get('lxd_transport'):
cmd_args.extend(['-c', 'lxd', ])

# Append the playbook filepath and return the final command.
cmd_args.append(self.homedir_expanded_path(self.options['playbook']))
return cmd_args
1 change: 1 addition & 0 deletions lxdock/test/fakes.py
Expand Up @@ -9,6 +9,7 @@ def __init__(self, project_name='project', homedir='/foo', client=None, **option
client = Mock()
options.setdefault('name', 'fakecontainer')
super().__init__(project_name, homedir, client, **options)
self._lxd_name = "{}-{}-123".format(project_name, self.name)

def _get_container(self, create=True):
result = Mock()
Expand Down
3 changes: 0 additions & 3 deletions requirements-dev.txt
@@ -1,8 +1,5 @@
ipython>=5.1.0

# Provisioners
ansible>=2.1

# Testing
-r requirements-tests.txt

Expand Down
1 change: 1 addition & 0 deletions requirements-tests.txt
@@ -1,3 +1,4 @@
ansible>=2.3
codecov>=1.6
pytest>=3.0
pytest-cov>=1.8
Expand Down
27 changes: 15 additions & 12 deletions tests/integration/test_container.py
Expand Up @@ -73,18 +73,21 @@ def test_can_try_to_halt_a_container_that_is_already_stopped(self, persistent_co
persistent_container.halt()
assert persistent_container._container.status_code == constants.CONTAINER_STOPPED

def test_can_provision_a_container_ansible(self):
container_options = {
'name': self.containername('willprovision'), 'image': 'ubuntu/xenial', 'mode': 'pull',
'provisioning': [
{'type': 'ansible',
'playbook': os.path.join(THIS_DIR, 'fixtures/provision_with_ansible.yml'), }
],
}
container = Container('myproject', THIS_DIR, self.client, **container_options)
container.up()
container.provision()
assert container._container.files.get('/dummytest').strip() == b'dummytest'
@pytest.mark.parametrize("lxd_transport", [False, True])
def test_can_provision_a_container_ansible(self, persistent_container, lxd_transport):
# We want to make sure that barebone setup is executed on provision()
persistent_container._container.config['user.lxdock.provisioned'] = 'false'
persistent_container._container.save(wait=True)
persistent_container.options['provisioning'] = [{
'type': 'ansible',
'playbook': os.path.join(THIS_DIR, 'fixtures/provision_with_ansible.yml'),
'lxd_transport': lxd_transport,
}]
persistent_container.up()
persistent_container.provision()
assert persistent_container._container.files.get('/dummytest').strip() == b'dummytest'
persistent_container._container.execute(['rm', '/dummytest'])
del persistent_container.options['provisioning']

def test_can_provision_a_container_shell_inline(self):
container_options = {
Expand Down
80 changes: 38 additions & 42 deletions tests/unit/provisioners/test_ansible.py
@@ -1,56 +1,37 @@
import re
import unittest.mock

import pytest

from lxdock.guests import AlpineGuest, DebianGuest
from lxdock.hosts import Host
from lxdock.provisioners import AnsibleProvisioner
from lxdock.test import FakeContainer


class TestAnsibleProvisioner:
@pytest.mark.parametrize("options,expected_cmdargs", [
({}, ''),
({'vault_password_file': '.vpass'}, '--vault-password-file ./.vpass'),
({'ask_vault_pass': True}, '--ask-vault-pass'),
({'lxd_transport': True}, '-c lxd'),
])
@unittest.mock.patch('subprocess.Popen')
def test_can_run_ansible_playbooks(self, mock_popen):
host = Host()
guest = DebianGuest(FakeContainer())
lxd_state = unittest.mock.Mock()
lxd_state.network.__getitem__ = unittest.mock.MagicMock(
return_value={'addresses': [{'family': 'init', 'address': '0.0.0.0', }, ]})
guest.lxd_container.state.return_value = lxd_state
provisioner = AnsibleProvisioner('./', host, [guest], {'playbook': 'deploy.yml'})
provisioner.provision()
assert re.match(
'ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook --inventory-file /[/\w]+ '
'./deploy.yml', mock_popen.call_args[0][0])

@unittest.mock.patch('subprocess.Popen')
def test_can_run_ansible_playbooks_with_the_vault_password_file_option(self, mock_popen):
host = Host()
guest = DebianGuest(FakeContainer())
lxd_state = unittest.mock.Mock()
lxd_state.network.__getitem__ = unittest.mock.MagicMock(
return_value={'addresses': [{'family': 'init', 'address': '0.0.0.0', }, ]})
guest.lxd_container.state.return_value = lxd_state
provisioner = AnsibleProvisioner(
'./', host, [guest], {'playbook': 'deploy.yml', 'vault_password_file': '.vpass'})
provisioner.provision()
assert re.match(
'ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook --inventory-file /[/\w]+ '
'--vault-password-file ./.vpass ./deploy.yml', mock_popen.call_args[0][0])

@unittest.mock.patch('subprocess.Popen')
def test_can_run_ansible_playbooks_with_the_ask_vault_pass_option(self, mock_popen):
def test_can_run_ansible_playbooks(self, mock_popen, options, expected_cmdargs):
host = Host()
guest = DebianGuest(FakeContainer())
lxd_state = unittest.mock.Mock()
lxd_state.network.__getitem__ = unittest.mock.MagicMock(
return_value={'addresses': [{'family': 'init', 'address': '0.0.0.0', }, ]})
guest.lxd_container.state.return_value = lxd_state
provisioner = AnsibleProvisioner(
'./', host, [guest], {'playbook': 'deploy.yml', 'ask_vault_pass': True})
options['playbook'] = 'deploy.yml'
provisioner = AnsibleProvisioner('./', host, [guest], options)
provisioner.provision()
assert re.match(
'ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook --inventory-file /[/\w]+ '
'--ask-vault-pass ./deploy.yml', mock_popen.call_args[0][0])
m = re.match(
r'ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook --inventory-file /[/\w]+ '
'(.*)\s?./deploy.yml', mock_popen.call_args[0][0])
assert m
assert m.group(1).strip() == expected_cmdargs

def test_can_properly_setup_ssh_for_alpine_guests(self):
container = FakeContainer()
Expand All @@ -60,13 +41,17 @@ def test_can_properly_setup_ssh_for_alpine_guests(self):
guest = AlpineGuest(container)
provisioner = AnsibleProvisioner('./', host, [guest], {'playbook': 'deploy.yml'})
provisioner.setup()
assert lxd_container.execute.call_count == 5
assert lxd_container.execute.call_args_list[0][0] == (['apk', 'update'], )
assert lxd_container.execute.call_args_list[1][0] == \
(['apk', 'add'] + AnsibleProvisioner.guest_required_packages_alpine, )
assert lxd_container.execute.call_args_list[2][0] == (['rc-update', 'add', 'sshd'], )
assert lxd_container.execute.call_args_list[3][0] == (['/etc/init.d/sshd', 'start'], )
assert lxd_container.execute.call_args_list[4][0] == (['mkdir', '-p', '/root/.ssh'], )
EXPECTED = [
['apk', 'update'],
['apk', 'add'] + AnsibleProvisioner.guest_required_packages_alpine,
['apk', 'update'],
['apk', 'add', 'openssh'],
['rc-update', 'add', 'sshd'],
['/etc/init.d/sshd', 'start'],
['mkdir', '-p', '/root/.ssh'],
]
calls = [tup[0][0] for tup in lxd_container.execute.call_args_list]
assert calls == EXPECTED

def test_inventory_contains_groups(self):
c1 = FakeContainer(name='c1')
Expand All @@ -83,3 +68,14 @@ def test_inventory_contains_groups(self):
# we sort so that g1 will be first all the time
groups = sorted([(gname, hosts.strip()) for gname, hosts in groups])
assert sorted(groups) == [('g1', 'c1\nc2'), ('g2', 'c1')]

def test_inventory_with_lxd_transport(self):
c = FakeContainer(name='c1')
provisioner = AnsibleProvisioner(
'./',
Host(),
[DebianGuest(c)],
{'playbook': 'deploy.yml', 'lxd_transport': True}
)
inv = provisioner.get_inventory()
assert 'ansible_host={}'.format(c.lxd_name) in inv
2 changes: 2 additions & 0 deletions tox.ini
Expand Up @@ -7,8 +7,10 @@ deps =
-r{toxinidir}/requirements-tests.txt
setenv =
PYTHONPATH = {toxinidir}:{toxinidir}
passenv = HOME
commands =
py.test
whitelist_externals = lxc

[testenv:lint]
deps =
Expand Down

0 comments on commit 7742b71

Please sign in to comment.