From 5c93b244839684610871980e4f0cd3cce4bde2e3 Mon Sep 17 00:00:00 2001 From: bmildren Date: Tue, 9 Apr 2019 12:53:25 +0100 Subject: [PATCH] Add digitalocean driver (#1736) * Adding support for DigitalOcean driver Signed-off-by: Ben Mildren * applied requested changes Signed-off-by: Ben Mildren * removing superfluous wait for boot (we're waiting during the create) Signed-off-by: Ben Mildren * fixed linting issues Signed-off-by: Ben Mildren * more linting issues Signed-off-by: Ben Mildren * Updating no_log behaviour to be inline with recent changes and driver support functional test Signed-off-by: Ben Mildren --- doc/source/configuration.rst | 8 + molecule/config.py | 4 + .../molecule.yml | 5 + .../driver/digitalocean/cookiecutter.json | 5 + .../INSTALL.rst | 23 ++ .../{{cookiecutter.scenario_name}}/create.yml | 89 +++++++ .../destroy.yml | 51 ++++ .../playbook.yml | 5 + .../prepare.yml | 11 + molecule/driver/digitalocean.py | 138 +++++++++++ molecule/model/schema_v2.py | 1 + setup.cfg | 2 + test/functional/conftest.py | 10 + test/functional/test_command.py | 39 +++ .../playbooks/digitalocean/create.yml | 88 +++++++ .../playbooks/digitalocean/destroy.yml | 53 ++++ .../molecule/default/molecule.yml | 29 +++ .../molecule/default/playbook.yml | 5 + .../molecule/default/tests/test_default.py | 28 +++ .../molecule/multi-node/molecule.yml | 33 +++ .../molecule/multi-node/playbook.yml | 20 ++ .../molecule/multi-node/tests/test_default.py | 30 +++ test/unit/cookiecutter/test_molecule.py | 1 + test/unit/driver/test_digitalocean.py | 231 ++++++++++++++++++ test/unit/model/v2/test_driver_section.py | 10 + test/unit/test_config.py | 19 ++ 26 files changed, 938 insertions(+) create mode 100644 molecule/cookiecutter/scenario/driver/digitalocean/cookiecutter.json create mode 100644 molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst create mode 100644 molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml create mode 100644 molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml create mode 100644 molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml create mode 100644 molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml create mode 100644 molecule/driver/digitalocean.py create mode 100644 test/resources/playbooks/digitalocean/create.yml create mode 100644 test/resources/playbooks/digitalocean/destroy.yml create mode 100644 test/scenarios/driver/digitalocean/molecule/default/molecule.yml create mode 100644 test/scenarios/driver/digitalocean/molecule/default/playbook.yml create mode 100644 test/scenarios/driver/digitalocean/molecule/default/tests/test_default.py create mode 100644 test/scenarios/driver/digitalocean/molecule/multi-node/molecule.yml create mode 100644 test/scenarios/driver/digitalocean/molecule/multi-node/playbook.yml create mode 100644 test/scenarios/driver/digitalocean/molecule/multi-node/tests/test_default.py create mode 100644 test/unit/driver/test_digitalocean.py diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 08e02c04f0..7a78b63dd8 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -74,6 +74,14 @@ Delegated .. autoclass:: molecule.driver.delegated.Delegated() :undoc-members: + +DigitalOcean +^^^^^^^^^^^^ + +.. autoclass:: molecule.driver.digitalocean.DigitalOcean() + :undoc-members: + + Docker ^^^^^^ diff --git a/molecule/config.py b/molecule/config.py index 7ecb334076..f9cd15868c 100644 --- a/molecule/config.py +++ b/molecule/config.py @@ -35,6 +35,7 @@ from molecule.dependency import shell from molecule.driver import azure from molecule.driver import delegated +from molecule.driver import digitalocean from molecule.driver import docker from molecule.driver import ec2 from molecule.driver import gce @@ -160,6 +161,8 @@ def driver(self): driver = azure.Azure(self) elif driver_name == 'delegated': driver = delegated.Delegated(self) + elif driver_name == 'digitalocean': + driver = digitalocean.DigitalOcean(self) elif driver_name == 'docker': driver = docker.Docker(self) elif driver_name == 'ec2': @@ -491,6 +494,7 @@ def molecule_drivers(): return [ azure.Azure(None).name, delegated.Delegated(None).name, + digitalocean.DigitalOcean(None).name, docker.Docker(None).name, ec2.EC2(None).name, gce.GCE(None).name, diff --git a/molecule/cookiecutter/molecule/{{cookiecutter.role_name}}/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/molecule.yml b/molecule/cookiecutter/molecule/{{cookiecutter.role_name}}/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/molecule.yml index 49b300ac1f..75eb106f1f 100644 --- a/molecule/cookiecutter/molecule/{{cookiecutter.role_name}}/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/molecule.yml +++ b/molecule/cookiecutter/molecule/{{cookiecutter.role_name}}/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/molecule.yml @@ -12,6 +12,11 @@ lint: platforms: {%- if cookiecutter.driver_name == 'azure' %} - name: instance +{%- elif cookiecutter.driver_name == 'digitalocean' %} + - name: instance + region_id: nyc3 + image_id: ubuntu-18-10-x64 + size_id: s-1vcpu-1gb {%- elif cookiecutter.driver_name == 'docker' %} - name: instance image: centos:7 diff --git a/molecule/cookiecutter/scenario/driver/digitalocean/cookiecutter.json b/molecule/cookiecutter/scenario/driver/digitalocean/cookiecutter.json new file mode 100644 index 0000000000..0e88865016 --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/digitalocean/cookiecutter.json @@ -0,0 +1,5 @@ +{ + "molecule_directory": "molecule", + "role_name": "OVERRIDEN", + "scenario_name": "OVERRIDEN" +} diff --git a/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst new file mode 100644 index 0000000000..4ee9a6517b --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst @@ -0,0 +1,23 @@ +************************************** +DigitalOcean driver installation guide +************************************** + +Requirements +============ + +* ``DO_API_KEY`` or ``DO_API_TOKEN`` exposed in your environment +* Only supported on Python 2.7, due to current dependency on dopy + +Install +======= + +Please refer to the `Virtual environment`_ documentation for installation best +practices. If not using a virtual environment, please consider passing the +widely recommended `'--user' flag`_ when invoking ``pip``. + +.. _Virtual environment: https://virtualenv.pypa.io/en/latest/ +.. _'--user' flag: https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site + +.. code-block:: bash + + $ pip install 'molecule[digitalocean]' diff --git a/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml new file mode 100644 index 0000000000..3dc8744fe8 --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml @@ -0,0 +1,89 @@ +--- +{% raw -%} +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + ssh_user: root + ssh_port: 22 + + keypair_name: molecule_key + keypair_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + tasks: + - name: Create local keypair + user: + name: "{{ lookup('env', 'USER') }}" + generate_ssh_key: true + ssh_key_file: "{{ keypair_path }}" + register: local_keypair + + - name: Create remote keypair + digital_ocean_sshkey: + name: "{{ keypair_name }}" + ssh_pub_key: "{{ local_keypair.ssh_public_key }}" + state: present + register: remote_keypair + + - name: Create molecule instance(s) + digital_ocean: + command: droplet + name: "{{ item.name }}" + unique_name: true + region_id: "{{ item.region_id }}" + image_id: "{{ item.image_id }}" + size_id: "{{ item.size_id }}" + ssh_key_ids: "{{ remote_keypair.data.ssh_key.id }}" + private_networking: true + wait: true + wait_timeout: 300 + state: present + register: server + with_items: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + + - name: Wait for instance(s) creation to complete + async_status: + jid: "{{ item.ansible_job_id }}" + register: digitalocean_jobs + until: digitalocean_jobs.finished + retries: 300 + with_items: "{{ server.results }}" + + # Mandatory configuration for Molecule to function. + + - name: Populate instance config dict + set_fact: + instance_conf_dict: { + 'instance': "{{ item.droplet.name }}", + 'address': "{{ item.droplet.ip_address }}", + 'user': "{{ ssh_user }}", + 'port': "{{ ssh_port }}", + 'identity_file': "{{ keypair_path }}", + 'droplet_id': "{{ item.droplet.id }}", } + with_items: "{{ digitalocean_jobs.results }}" + register: instance_config_dict + when: server.changed | bool + + - name: Convert instance config dict to a list + set_fact: + instance_conf: "{{ instance_config_dict.results | map(attribute='ansible_facts.instance_conf_dict') | list }}" + when: server.changed | bool + + - name: Dump instance config + copy: + content: "{{ instance_conf | to_json | from_json | molecule_to_yaml | molecule_header }}" + dest: "{{ molecule_instance_config }}" + when: server.changed | bool + + - name: Wait for SSH + wait_for: + port: "{{ ssh_port }}" + host: "{{ item.address }}" + search_regex: SSH + delay: 10 + timeout: 320 + with_items: "{{ lookup('file', molecule_instance_config) | molecule_from_yaml }}" +{%- endraw %} diff --git a/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml new file mode 100644 index 0000000000..d7cf864d7e --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml @@ -0,0 +1,51 @@ +--- +{% raw -%} +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + tasks: + - block: + - name: Populate instance config + set_fact: + instance_conf: "{{ lookup('file', molecule_instance_config) | molecule_from_yaml }}" + skip_instances: false + rescue: + - name: Populate instance config when file missing + set_fact: + instance_conf: {} + skip_instances: true + + - name: Destroy molecule instance(s) + digital_ocean: + name: "{{ item.instance }}" + id: "{{ item.droplet_id }}" + state: absent + register: server + with_items: "{{ instance_conf }}" + when: not skip_instances + async: 7200 + poll: 0 + + - name: Wait for instance(s) deletion to complete + async_status: + jid: "{{ item.ansible_job_id }}" + register: digitalocean_jobs + until: digitalocean_jobs.finished + retries: 300 + with_items: "{{ server.results }}" + + + # Mandatory configuration for Molecule to function. + + - name: Populate instance config + set_fact: + instance_conf: {} + + - name: Dump instance config + copy: + content: "{{ instance_conf | molecule_to_yaml | molecule_header }}" + dest: "{{ molecule_instance_config }}" + when: server.changed | bool + {%- endraw %} diff --git a/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml new file mode 100644 index 0000000000..0997271b75 --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml @@ -0,0 +1,5 @@ +--- +- name: Converge + hosts: all + roles: + - role: {{ cookiecutter.role_name }} diff --git a/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml new file mode 100644 index 0000000000..f6eb955614 --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/digitalocean/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml @@ -0,0 +1,11 @@ +--- +{% raw -%} +- name: Prepare + hosts: all + gather_facts: false + tasks: + - name: Install python for Ansible + raw: test -e /usr/bin/python || (apt -y update && apt install -y python-minimal) + become: true + changed_when: false +{%- endraw %} diff --git a/molecule/driver/digitalocean.py b/molecule/driver/digitalocean.py new file mode 100644 index 0000000000..908582b841 --- /dev/null +++ b/molecule/driver/digitalocean.py @@ -0,0 +1,138 @@ +# Copyright (c) 2019 Red Hat, 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. + +from molecule import logger +from molecule.driver import base + +from molecule import util + +log = logger.get_logger(__name__) + + +class DigitalOcean(base.Base): + """ + This class is responsible for managing `DigitalOcean`_ instances. + `DigitalOcean`_ is **not** the default driver used in Molecule. + + Molecule leverages Ansible's `digital_ocean_module`_, by mapping variables + from ``molecule.yml`` into ``create.yml`` and ``destroy.yml``. + + .. _`digital_ocean_module`: https://docs.ansible.com/ansible/latest/modules/digital_ocean_module.html#digital-ocean-module + + .. code-block:: yaml + + driver: + name: digitalocean + platforms: + - name: instance + + .. code-block:: bash + + $ pip install 'molecule[digitalocean]' + + Change the options passed to the ssh client. + + .. code-block:: yaml + + driver: + name: digitalocean + ssh_connection_options: + -o ControlPath=~/.ansible/cp/%r@%h-%p + + .. important:: + + Molecule does not merge lists, when overriding the developer must + provide all options. + + Provide the files Molecule will preserve upon each subcommand execution. + + .. code-block:: yaml + + driver: + name: digitalocean + safe_files: + - foo + + .. _`DigitalOcean`: https://www.digitalocean.com + """ # noqa + + def __init__(self, config): + super(DigitalOcean, self).__init__(config) + self._name = 'digitalocean' + + @property + def name(self): + return self._name + + @name.setter + def name(self, value): + self._name = value + + @property + def login_cmd_template(self): + connection_options = ' '.join(self.ssh_connection_options) + + return ('ssh {{address}} ' + '-l {{user}} ' + '-p {{port}} ' + '-i {{identity_file}} ' + '{}').format(connection_options) + + @property + def default_safe_files(self): + return [ + self.instance_config, + ] + + @property + def default_ssh_connection_options(self): + return self._get_ssh_connection_options() + + def login_options(self, instance_name): + d = {'instance': instance_name} + + return util.merge_dicts(d, self._get_instance_config(instance_name)) + + def ansible_connection_options(self, instance_name): + try: + d = self._get_instance_config(instance_name) + + return { + 'ansible_user': d['user'], + 'ansible_host': d['address'], + 'ansible_port': d['port'], + 'ansible_private_key_file': d['identity_file'], + 'connection': 'ssh', + 'ansible_ssh_common_args': + ' '.join(self.ssh_connection_options), + } + except StopIteration: + return {} + except IOError: + # Instance has yet to be provisioned , therefore the + # instance_config is not on disk. + return {} + + def _get_instance_config(self, instance_name): + instance_config_dict = util.safe_load_file( + self._config.driver.instance_config) + + return next(item for item in instance_config_dict + if item['instance'] == instance_name) diff --git a/molecule/model/schema_v2.py b/molecule/model/schema_v2.py index 4eccf412b9..ec1df97e1b 100644 --- a/molecule/model/schema_v2.py +++ b/molecule/model/schema_v2.py @@ -62,6 +62,7 @@ def pre_validate_base_schema(env, keep_string): 'allowed': [ 'azure', 'delegated', + 'digitalocean', 'docker', 'ec2', 'gce', diff --git a/setup.cfg b/setup.cfg index 8b41c77baa..16959cf15b 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,6 +47,8 @@ docs = Sphinx azure = ansible[azure] +digitalocean = + dopy; python_version<"3.0" docker = docker>=2.0.0 ec2 = diff --git a/test/functional/conftest.py b/test/functional/conftest.py index 6232502283..2bafec09d5 100644 --- a/test/functional/conftest.py +++ b/test/functional/conftest.py @@ -71,6 +71,7 @@ def skip_test(request, driver_name): else "Skipped '{}' not supported") support_checks_map = { 'azure': supports_azure, + 'digitalocean': supports_digitalocean, 'docker': supports_docker, 'ec2': supports_ec2, 'gce': supports_gce, @@ -355,6 +356,15 @@ def supports_azure(): return _env_vars_exposed(env_vars) and HAS_AZURE +@pytest.helpers.register +def supports_digitalocean(): + from ansible.modules.cloud.digital_ocean.digital_ocean import HAS_DOPY + + env_vars = ('DO_API_KEY', ) + + return _env_vars_exposed(env_vars) and HAS_DOPY + + @pytest.helpers.register def supports_ec2(): from ansible.module_utils.ec2 import HAS_BOTO3 diff --git a/test/functional/test_command.py b/test/functional/test_command.py index f6b3fe4af9..4b74413ccd 100644 --- a/test/functional/test_command.py +++ b/test/functional/test_command.py @@ -51,6 +51,7 @@ def driver_name(request): 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), @@ -81,6 +82,7 @@ def test_command_check(scenario_to_test, with_scenario, scenario_name): 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), @@ -140,6 +142,7 @@ def test_command_converge(scenario_to_test, with_scenario, scenario_name): 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), @@ -169,6 +172,7 @@ def test_command_create(scenario_to_test, with_scenario, scenario_name): @pytest.mark.parametrize( 'scenario_to_test, driver_name, scenario_name', [ ('dependency', 'azure', 'ansible-galaxy'), + ('dependency', 'digitalocean', 'ansible-galaxy'), ('dependency', 'docker', 'ansible-galaxy'), ('dependency', 'ec2', 'ansible-galaxy'), ('dependency', 'gce', 'ansible-galaxy'), @@ -202,6 +206,7 @@ def test_command_dependency_ansible_galaxy(request, scenario_to_test, @pytest.mark.parametrize( 'scenario_to_test, driver_name, scenario_name', [ ('dependency', 'azure', 'gilt'), + ('dependency', 'digitalocean', 'gilt'), ('dependency', 'docker', 'gilt'), ('dependency', 'ec2', 'gilt'), ('dependency', 'gce', 'gilt'), @@ -234,6 +239,7 @@ def test_command_dependency_gilt(request, scenario_to_test, with_scenario, @pytest.mark.parametrize( 'scenario_to_test, driver_name, scenario_name', [ ('dependency', 'azure', 'shell'), + ('dependency', 'digitalocean', 'shell'), ('dependency', 'docker', 'shell'), ('dependency', 'ec2', 'shell'), ('dependency', 'gce', 'shell'), @@ -267,6 +273,7 @@ def test_command_dependency_shell(request, scenario_to_test, with_scenario, 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), @@ -297,6 +304,7 @@ def test_command_destroy(scenario_to_test, with_scenario, scenario_name): 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), @@ -324,6 +332,7 @@ def test_command_idempotence(scenario_to_test, with_scenario, scenario_name): @pytest.mark.parametrize( 'driver_name', [ ('azure'), + ('digitalocean'), ('docker'), ('ec2'), ('gce'), @@ -343,6 +352,7 @@ def test_command_init_role(temp_dir, driver_name, skip_test): @pytest.mark.parametrize( 'driver_name', [ ('azure'), + ('digitalocean'), ('docker'), ('ec2'), ('gce'), @@ -363,6 +373,7 @@ def test_command_init_scenario(temp_dir, driver_name, skip_test): 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), @@ -399,6 +410,13 @@ def test_command_lint(scenario_to_test, with_scenario, scenario_name): instance azure ansible default false false instance-1 azure ansible multi-node false false instance-2 azure ansible multi-node false false +""".strip()), # noqa + ('driver/digitalocean', 'digitalocean', """ +Instance Name Driver Name Provisioner Name Scenario Name Created Converged +--------------- ------------- ------------------ --------------- --------- ----------- +instance digitalocean ansible default false false +instance-1 digitalocean ansible multi-node false false +instance-2 digitalocean ansible multi-node false false """.strip()), # noqa ('driver/docker', 'docker', """ Instance Name Driver Name Provisioner Name Scenario Name Created Converged @@ -480,6 +498,11 @@ def test_command_list(scenario_to_test, with_scenario, expected): instance azure ansible default false false instance-1 azure ansible multi-node false false instance-2 azure ansible multi-node false false +""".strip()), + ('driver/digitalocean', 'digitalocean', """ +instance digitalocean ansible default false false +instance-1 digitalocean ansible multi-node false false +instance-2 digitalocean ansible multi-node false false """.strip()), ('driver/docker', 'docker', """ instance docker ansible default false false @@ -552,6 +575,17 @@ def test_command_list_with_format_plain(scenario_to_test, with_scenario, 'instance-2', '.*instance-2.*', ]], 'multi-node'), + ('driver/digitalocean', 'digitalocean', [[ + 'instance', + '.*instance.*', + ]], 'default'), + ('driver/digitalocean', 'digitalocean', [[ + 'instance-1', + '.*instance-1.*', + ], [ + 'instance-2', + '.*instance-2.*', + ]], 'multi-node'), ('driver/docker', 'docker', [[ 'instance', '.*instance.*', @@ -655,6 +689,7 @@ def test_command_login(scenario_to_test, with_scenario, login_args, 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), @@ -689,6 +724,7 @@ def test_command_prepare(scenario_to_test, with_scenario, scenario_name): 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), @@ -719,6 +755,7 @@ def test_command_side_effect(scenario_to_test, with_scenario, scenario_name): 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), @@ -749,6 +786,7 @@ def test_command_syntax(scenario_to_test, with_scenario, scenario_name): 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', None), ('driver/docker', 'docker', None), ('driver/ec2', 'ec2', None), ('driver/gce', 'gce', None), @@ -778,6 +816,7 @@ def test_command_test(scenario_to_test, with_scenario, scenario_name, 'scenario_to_test, driver_name, scenario_name', [ ('driver/azure', 'azure', 'default'), + ('driver/digitalocean', 'digitalocean', 'default'), ('driver/docker', 'docker', 'default'), ('driver/gce', 'gce', 'default'), ('driver/linode', 'linode', 'default'), diff --git a/test/resources/playbooks/digitalocean/create.yml b/test/resources/playbooks/digitalocean/create.yml new file mode 100644 index 0000000000..87cd29b160 --- /dev/null +++ b/test/resources/playbooks/digitalocean/create.yml @@ -0,0 +1,88 @@ +--- +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + ssh_user: root + ssh_port: 22 + + keypair_name: molecule_key + keypair_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + tasks: + - name: Create local keypair + user: + name: "{{ lookup('env', 'USER') }}" + generate_ssh_key: true + ssh_key_file: "{{ keypair_path }}" + register: local_keypair + + - name: Create remote keypair + digital_ocean_sshkey: + name: "{{ keypair_name }}" + ssh_pub_key: "{{ local_keypair.ssh_public_key }}" + state: present + register: remote_keypair + + - name: Create molecule instance(s) + digital_ocean: + command: droplet + name: "{{ item.name }}" + unique_name: true + region_id: "{{ item.region_id }}" + image_id: "{{ item.image_id }}" + size_id: "{{ item.size_id }}" + ssh_key_ids: "{{ remote_keypair.data.ssh_key.id }}" + private_networking: true + wait: true + wait_timeout: 300 + state: present + register: server + with_items: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + + - name: Wait for instance(s) creation to complete + async_status: + jid: "{{ item.ansible_job_id }}" + register: digitalocean_jobs + until: digitalocean_jobs.finished + retries: 300 + with_items: "{{ server.results }}" + + # Mandatory configuration for Molecule to function. + + - name: Populate instance config dict + set_fact: + instance_conf_dict: { + 'instance': "{{ item.droplet.name }}", + 'address': "{{ item.droplet.ip_address }}", + 'user': "{{ ssh_user }}", + 'port': "{{ ssh_port }}", + 'identity_file': "{{ keypair_path }}", + 'droplet_id': "{{ item.droplet.id }}", + 'fingerprint': "{{ remote_keypair.data.ssh_key.fingerprint }}", } + with_items: "{{ digitalocean_jobs.results }}" + register: instance_config_dict + when: server.changed | bool + + - name: Convert instance config dict to a list + set_fact: + instance_conf: "{{ instance_config_dict.results | map(attribute='ansible_facts.instance_conf_dict') | list }}" + when: server.changed | bool + + - name: Dump instance config + copy: + content: "{{ instance_conf | to_json | from_json | molecule_to_yaml | molecule_header }}" + dest: "{{ molecule_instance_config }}" + when: server.changed | bool + + - name: Wait for SSH + wait_for: + port: "{{ ssh_port }}" + host: "{{ item.address }}" + search_regex: SSH + delay: 10 + timeout: 320 + with_items: "{{ lookup('file', molecule_instance_config) | molecule_from_yaml }}" diff --git a/test/resources/playbooks/digitalocean/destroy.yml b/test/resources/playbooks/digitalocean/destroy.yml new file mode 100644 index 0000000000..b888c44338 --- /dev/null +++ b/test/resources/playbooks/digitalocean/destroy.yml @@ -0,0 +1,53 @@ +--- +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + tasks: + - block: + - name: Populate instance config + set_fact: + instance_conf: "{{ lookup('file', molecule_instance_config) | molecule_from_yaml }}" + skip_instances: false + rescue: + - name: Populate instance config when file missing + set_fact: + instance_conf: {} + skip_instances: true + + - name: Destroy molecule instance(s) + digital_ocean: + name: "{{ item.instance }}" + id: "{{ item.droplet_id }}" + state: absent + register: server + with_items: "{{ instance_conf }}" + when: not skip_instances + async: 7200 + poll: 0 + + - name: Wait for instance(s) deletion to complete + async_status: + jid: "{{ item.ansible_job_id }}" + register: digitalocean_jobs + until: digitalocean_jobs.finished + retries: 300 + with_items: "{{ server.results }}" + + - name: Delete remote keypair + digital_ocean_sshkey: + fingerprint: "{{ instance_conf[0].fingerprint }}" + state: absent + + # Mandatory configuration for Molecule to function. + + - name: Populate instance config + set_fact: + instance_conf: {} + + - name: Dump instance config + copy: + content: "{{ instance_conf | molecule_to_yaml | molecule_header }}" + dest: "{{ molecule_instance_config }}" + when: server.changed | bool diff --git a/test/scenarios/driver/digitalocean/molecule/default/molecule.yml b/test/scenarios/driver/digitalocean/molecule/default/molecule.yml new file mode 100644 index 0000000000..6049a8bed3 --- /dev/null +++ b/test/scenarios/driver/digitalocean/molecule/default/molecule.yml @@ -0,0 +1,29 @@ +--- +dependency: + name: galaxy +driver: + name: digitalocean +lint: + name: yamllint + options: + config-file: ../../../resources/.yamllint +platforms: + name: instance + region_id: nyc3 + image_id: ubuntu-18-10-x64 + size_id: s-1vcpu-1gb +provisioner: + name: ansible + playbooks: + create: ../../../../../resources/playbooks/digitalocean/create.yml + destroy: ../../../../../resources/playbooks/digitalocean/destroy.yml + env: + ANSIBLE_ROLES_PATH: ../../../../../resources/roles/ + lint: + name: ansible-lint +scenario: + name: default +verifier: + name: testinfra + lint: + name: flake8 diff --git a/test/scenarios/driver/digitalocean/molecule/default/playbook.yml b/test/scenarios/driver/digitalocean/molecule/default/playbook.yml new file mode 100644 index 0000000000..6cbb76ebf1 --- /dev/null +++ b/test/scenarios/driver/digitalocean/molecule/default/playbook.yml @@ -0,0 +1,5 @@ +--- +- name: Converge + hosts: all + roles: + - molecule diff --git a/test/scenarios/driver/digitalocean/molecule/default/tests/test_default.py b/test/scenarios/driver/digitalocean/molecule/default/tests/test_default.py new file mode 100644 index 0000000000..ea7a6f2d36 --- /dev/null +++ b/test/scenarios/driver/digitalocean/molecule/default/tests/test_default.py @@ -0,0 +1,28 @@ +import os + +import testinfra.utils.ansible_runner + +testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( + os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('all') + + +def test_hostname(host): + assert 'instance' == host.check_output('hostname -s') + + +def test_etc_molecule_directory(host): + f = host.file('/etc/molecule') + + assert f.is_directory + assert f.user == 'root' + assert f.group == 'root' + assert f.mode == 0o755 + + +def test_etc_molecule_ansible_hostname_file(host): + f = host.file('/etc/molecule/instance') + + assert f.is_file + assert f.user == 'root' + assert f.group == 'root' + assert f.mode == 0o644 diff --git a/test/scenarios/driver/digitalocean/molecule/multi-node/molecule.yml b/test/scenarios/driver/digitalocean/molecule/multi-node/molecule.yml new file mode 100644 index 0000000000..79585a8576 --- /dev/null +++ b/test/scenarios/driver/digitalocean/molecule/multi-node/molecule.yml @@ -0,0 +1,33 @@ +--- +dependency: + name: galaxy +driver: + name: digitalocean +lint: + name: yamllint + options: + config-file: ../../../resources/.yamllint +platforms: + - name: instance-1 + region_id: nyc3 + image_id: ubuntu-18-10-x64 + size_id: s-1vcpu-1gb + - name: instance-2 + region_id: nyc3 + image_id: ubuntu-18-10-x64 + size_id: s-1vcpu-1gb +provisioner: + name: ansible + playbooks: + create: ../../../../../resources/playbooks/digitalocean/create.yml + destroy: ../../../../../resources/playbooks/digitalocean/destroy.yml + env: + ANSIBLE_ROLES_PATH: ../../../../../resources/roles/ + lint: + name: ansible-lint +scenario: + name: multi-node +verifier: + name: testinfra + lint: + name: flake8 diff --git a/test/scenarios/driver/digitalocean/molecule/multi-node/playbook.yml b/test/scenarios/driver/digitalocean/molecule/multi-node/playbook.yml new file mode 100644 index 0000000000..c778a79db3 --- /dev/null +++ b/test/scenarios/driver/digitalocean/molecule/multi-node/playbook.yml @@ -0,0 +1,20 @@ +--- +- name: Converge + hosts: all + roles: + - molecule + +- name: Converge + hosts: bar + roles: + - molecule + +- name: Converge + hosts: foo + roles: + - molecule + +- name: Converge + hosts: baz + roles: + - molecule diff --git a/test/scenarios/driver/digitalocean/molecule/multi-node/tests/test_default.py b/test/scenarios/driver/digitalocean/molecule/multi-node/tests/test_default.py new file mode 100644 index 0000000000..42274d9fbc --- /dev/null +++ b/test/scenarios/driver/digitalocean/molecule/multi-node/tests/test_default.py @@ -0,0 +1,30 @@ +import os +import re + +import testinfra.utils.ansible_runner + +testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( + os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('all') + + +def test_hostname(host): + assert re.search(r'instance-[12]', host.check_output('hostname -s')) + + +def test_etc_molecule_directory(host): + f = host.file('/etc/molecule') + + assert f.is_directory + assert f.user == 'root' + assert f.group == 'root' + assert f.mode == 0o755 + + +def test_etc_molecule_ansible_hostname_file(host): + filename = '/etc/molecule/{}'.format(host.check_output('hostname -s')) + f = host.file(filename) + + assert f.is_file + assert f.user == 'root' + assert f.group == 'root' + assert f.mode == 0o644 diff --git a/test/unit/cookiecutter/test_molecule.py b/test/unit/cookiecutter/test_molecule.py index 937001cccf..aa24a6acd5 100644 --- a/test/unit/cookiecutter/test_molecule.py +++ b/test/unit/cookiecutter/test_molecule.py @@ -93,6 +93,7 @@ def test_vagrant_driver(temp_dir, _molecule_file, _role_directory, @pytest.mark.parametrize('driver', [ ('azure'), + ('digitalocean'), ('docker'), ('ec2'), ('gce'), diff --git a/test/unit/driver/test_digitalocean.py b/test/unit/driver/test_digitalocean.py new file mode 100644 index 0000000000..d79f6d1775 --- /dev/null +++ b/test/unit/driver/test_digitalocean.py @@ -0,0 +1,231 @@ +# Copyright (c) 2019 Red Hat, 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 os + +import pytest + +from molecule import config +from molecule.driver import digitalocean + + +@pytest.fixture +def _instance(patched_config_validate, config_instance): + return digitalocean.DigitalOcean(config_instance) + + +def test_config_private_member(_instance): + assert isinstance(_instance._config, config.Config) + + +def test_testinfra_options_property(_instance): + assert { + 'connection': 'ansible', + 'ansible-inventory': _instance._config.provisioner.inventory_file + } == _instance.testinfra_options + + +def test_name_property(_instance): + assert 'digitalocean' == _instance.name + + +def test_options_property(_instance): + expected_options = {'managed': True} + + assert expected_options == _instance.options + + +def test_login_cmd_template_property(_instance): + expected_ssh_command = ('ssh {address} ' + '-l {user} -p {port} -i {identity_file} ' + '-o UserKnownHostsFile=/dev/null ' + '-o ControlMaster=auto ' + '-o ControlPersist=60s ' + '-o IdentitiesOnly=yes ' + '-o StrictHostKeyChecking=no') + + assert expected_ssh_command == _instance.login_cmd_template + + +def test_safe_files_property(_instance): + expected_safe_files = [ + os.path.join(_instance._config.scenario.ephemeral_directory, + 'instance_config.yml'), + ] + + assert expected_safe_files == _instance.safe_files + + +def test_default_safe_files_property(_instance): + expected_default_safe_files = [ + os.path.join(_instance._config.scenario.ephemeral_directory, + 'instance_config.yml'), + ] + + assert expected_default_safe_files == _instance.default_safe_files + + +def test_delegated_property(_instance): + assert not _instance.delegated + + +def test_managed_property(_instance): + assert _instance.managed + + +def test_default_ssh_connection_options_property(_instance): + expected_ssh_defaults = [ + '-o UserKnownHostsFile=/dev/null', + '-o ControlMaster=auto', + '-o ControlPersist=60s', + '-o IdentitiesOnly=yes', + '-o StrictHostKeyChecking=no', + ] + + assert expected_ssh_defaults == _instance.default_ssh_connection_options + + +def test_login_options(mocker, _instance): + m = mocker.patch( + 'molecule.driver.digitalocean.DigitalOcean._get_instance_config') + m.return_value = { + 'instance': 'foo', + 'address': '172.16.0.2', + 'user': 'cloud-user', + 'port': 22, + 'identity_file': '/foo/bar', + } + + expected_login_data = { + 'instance': 'foo', + 'address': '172.16.0.2', + 'user': 'cloud-user', + 'port': 22, + 'identity_file': '/foo/bar', + } + assert expected_login_data == _instance.login_options('foo') + + +def test_ansible_connection_options(mocker, _instance): + m = mocker.patch( + 'molecule.driver.digitalocean.DigitalOcean._get_instance_config') + m.return_value = { + 'instance': 'foo', + 'address': '172.16.0.2', + 'user': 'cloud-user', + 'port': 22, + 'identity_file': '/foo/bar', + } + + expected_cnx_data = { + 'ansible_host': + '172.16.0.2', + 'ansible_port': + 22, + 'ansible_user': + 'cloud-user', + 'ansible_private_key_file': + '/foo/bar', + 'connection': + 'ssh', + 'ansible_ssh_common_args': ('-o UserKnownHostsFile=/dev/null ' + '-o ControlMaster=auto ' + '-o ControlPersist=60s ' + '-o IdentitiesOnly=yes ' + '-o StrictHostKeyChecking=no'), + } + assert expected_cnx_data == _instance.ansible_connection_options('foo') + + +def test_ansible_connection_options_handles_missing_instance_config( + mocker, _instance): + m = mocker.patch('molecule.util.safe_load_file') + m.side_effect = IOError + + assert {} == _instance.ansible_connection_options('foo') + + +def test_ansible_connection_options_handles_missing_results_key( + mocker, _instance): + m = mocker.patch('molecule.util.safe_load_file') + m.side_effect = StopIteration + + assert {} == _instance.ansible_connection_options('foo') + + +def test_instance_config_property(_instance): + expected_config_file = os.path.join( + _instance._config.scenario.ephemeral_directory, 'instance_config.yml') + + assert expected_config_file == _instance.instance_config + + +def test_ssh_connection_options_property(_instance): + expected_ssh_options = [ + '-o UserKnownHostsFile=/dev/null', + '-o ControlMaster=auto', + '-o ControlPersist=60s', + '-o IdentitiesOnly=yes', + '-o StrictHostKeyChecking=no', + ] + + assert expected_ssh_options == _instance.ssh_connection_options + + +def test_status(mocker, _instance): + result = _instance.status() + + assert 2 == len(result) + + assert result[0].instance_name == 'instance-1' + assert result[0].driver_name == 'digitalocean' + assert result[0].provisioner_name == 'ansible' + assert result[0].scenario_name == 'default' + assert result[0].created == 'false' + assert result[0].converged == 'false' + + assert result[1].instance_name == 'instance-2' + assert result[1].driver_name == 'digitalocean' + assert result[1].provisioner_name == 'ansible' + assert result[1].scenario_name == 'default' + assert result[1].created == 'false' + assert result[1].converged == 'false' + + +def test_get_instance_config(mocker, _instance): + m = mocker.patch('molecule.util.safe_load_file') + m.return_value = [{ + 'instance': 'foo', + }, { + 'instance': 'bar', + }] + + expected_instance = { + 'instance': 'foo', + } + assert expected_instance == _instance._get_instance_config('foo') + + +def test_created(_instance): + assert 'false' == _instance._created() + + +def test_converged(_instance): + assert 'false' == _instance._converged() diff --git a/test/unit/model/v2/test_driver_section.py b/test/unit/model/v2/test_driver_section.py index 0f735287e2..e3ca1db01a 100644 --- a/test/unit/model/v2/test_driver_section.py +++ b/test/unit/model/v2/test_driver_section.py @@ -276,6 +276,15 @@ def _model_driver_allows_delegated_section_data(): } +@pytest.fixture +def _model_driver_allows_digitalocean_section_data(): + return { + 'driver': { + 'name': 'digitalocean', + } + } + + @pytest.fixture def _model_driver_allows_docker_section_data(): return { @@ -344,6 +353,7 @@ def _model_driver_allows_vagrant_section_data(): '_config', [ ('_model_driver_allows_azure_section_data'), ('_model_driver_allows_delegated_section_data'), + ('_model_driver_allows_digitalocean_section_data'), ('_model_driver_allows_docker_section_data'), ('_model_driver_allows_ec2_section_data'), ('_model_driver_allows_gce_section_data'), diff --git a/test/unit/test_config.py b/test/unit/test_config.py index ef5e26360e..b015b67cf0 100644 --- a/test/unit/test_config.py +++ b/test/unit/test_config.py @@ -32,6 +32,7 @@ from molecule.dependency import shell from molecule.driver import azure from molecule.driver import delegated +from molecule.driver import digitalocean from molecule.driver import docker from molecule.driver import ec2 from molecule.driver import gce @@ -173,6 +174,22 @@ def test_driver_property_is_delegated(config_instance): assert isinstance(config_instance.driver, delegated.Delegated) +@pytest.fixture +def _config_driver_digitalocean_section_data(): + return { + 'driver': { + 'name': 'digitalocean' + }, + } + + +@pytest.mark.parametrize( + 'config_instance', ['_config_driver_digitalocean_section_data'], + indirect=True) +def test_driver_property_is_digitalocean(config_instance): + assert isinstance(config_instance.driver, digitalocean.DigitalOcean) + + @pytest.fixture def _config_driver_ec2_section_data(): return { @@ -271,6 +288,7 @@ def test_drivers_property(config_instance): x = [ 'azure', 'delegated', + 'digitalocean', 'docker', 'ec2', 'gce', @@ -520,6 +538,7 @@ def test_molecule_drivers(): x = [ 'azure', 'delegated', + 'digitalocean', 'docker', 'ec2', 'gce',