From 2cb79ed79f7496b5ba3d239861789464ba41b3c4 Mon Sep 17 00:00:00 2001 From: Luke Murphy Date: Thu, 7 Mar 2019 18:22:25 +0100 Subject: [PATCH] Add Hetzner driver Closes https://github.com/ansible/molecule/issues/1791. Signed-off-by: Luke Murphy --- doc/source/configuration.rst | 8 + molecule/config.py | 3 + .../molecule.yml | 4 + .../scenario/driver/hetzner/cookiecutter.json | 5 + .../INSTALL.rst | 22 ++ .../{{cookiecutter.scenario_name}}/create.yml | 79 +++++++ .../destroy.yml | 49 +++++ .../playbook.yml | 5 + .../prepare.yml | 11 + molecule/driver/hetzner.py | 147 +++++++++++++ molecule/model/schema_v2.py | 41 ++++ setup.cfg | 2 + test/functional/conftest.py | 7 + test/functional/test_command.py | 35 ++++ test/resources/playbooks/hetzner/create.yml | 77 +++++++ test/resources/playbooks/hetzner/destroy.yml | 47 +++++ .../hetzner/molecule/default/molecule.yml | 34 +++ .../hetzner/molecule/default/playbook.yml | 5 + .../molecule/default/tests/test_default.py | 33 +++ .../hetzner/molecule/multi-node/molecule.yml | 37 ++++ .../hetzner/molecule/multi-node/playbook.yml | 20 ++ .../molecule/multi-node/tests/test_default.py | 34 +++ test/unit/cookiecutter/test_molecule.py | 1 + test/unit/driver/test_hetzner.py | 198 ++++++++++++++++++ test/unit/model/v2/test_platforms_section.py | 80 +++++++ test/unit/test_config.py | 2 + 26 files changed, 986 insertions(+) create mode 100644 molecule/cookiecutter/scenario/driver/hetzner/cookiecutter.json create mode 100644 molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst create mode 100644 molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml create mode 100644 molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml create mode 100644 molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml create mode 100644 molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml create mode 100644 molecule/driver/hetzner.py create mode 100644 test/resources/playbooks/hetzner/create.yml create mode 100644 test/resources/playbooks/hetzner/destroy.yml create mode 100644 test/scenarios/driver/hetzner/molecule/default/molecule.yml create mode 100644 test/scenarios/driver/hetzner/molecule/default/playbook.yml create mode 100644 test/scenarios/driver/hetzner/molecule/default/tests/test_default.py create mode 100644 test/scenarios/driver/hetzner/molecule/multi-node/molecule.yml create mode 100644 test/scenarios/driver/hetzner/molecule/multi-node/playbook.yml create mode 100644 test/scenarios/driver/hetzner/molecule/multi-node/tests/test_default.py create mode 100644 test/unit/driver/test_hetzner.py diff --git a/doc/source/configuration.rst b/doc/source/configuration.rst index 08e02c04f0..cb6862a96c 100644 --- a/doc/source/configuration.rst +++ b/doc/source/configuration.rst @@ -96,6 +96,14 @@ GCE .. autoclass:: molecule.driver.gce.GCE() :undoc-members: +.. _hetzner-driver: + +Hetzner +^^^^^^^ + +.. autoclass:: molecule.driver.gce.Hetzner() + :undoc-members: + .. _linode-driver: Linode diff --git a/molecule/config.py b/molecule/config.py index 7ecb334076..d1bc346fc8 100644 --- a/molecule/config.py +++ b/molecule/config.py @@ -166,6 +166,8 @@ def driver(self): driver = ec2.EC2(self) elif driver_name == 'gce': driver = gce.GCE(self) + elif driver_name == 'hetzner': + driver = gce.Hetzner(self) elif driver_name == 'linode': driver = linode.Linode(self) elif driver_name == 'lxc': @@ -494,6 +496,7 @@ def molecule_drivers(): docker.Docker(None).name, ec2.EC2(None).name, gce.GCE(None).name, + hetzner.Hetzner(None).name, linode.Linode(None).name, lxc.LXC(None).name, lxd.LXD(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..71182a6ced 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 @@ -25,6 +25,10 @@ platforms: zone: us-west1-a machine_type: f1-micro image: debian-8 +{%- elif cookiecutter.driver_name == 'hetzner' %} + - name: instance + server_type: cx11 + image: debian-9 {%- elif cookiecutter.driver_name == 'lxc' %} - name: instance {%- elif cookiecutter.driver_name == 'linode' %} diff --git a/molecule/cookiecutter/scenario/driver/hetzner/cookiecutter.json b/molecule/cookiecutter/scenario/driver/hetzner/cookiecutter.json new file mode 100644 index 0000000000..0e88865016 --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/hetzner/cookiecutter.json @@ -0,0 +1,5 @@ +{ + "molecule_directory": "molecule", + "role_name": "OVERRIDEN", + "scenario_name": "OVERRIDEN" +} diff --git a/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst b/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst new file mode 100644 index 0000000000..434feba241 --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst @@ -0,0 +1,22 @@ +********************************* +Hetzner driver installation guide +********************************* + +Requirements +============ + +* ``HCLOUD_TOKEN`` exposed in your environment + +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[hetzner]' diff --git a/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml b/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml new file mode 100644 index 0000000000..bf21b07cd2 --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml @@ -0,0 +1,79 @@ +--- +{% raw -%} +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ not (lookup('env', 'MOLECULE_DEBUG') | bool or molecule_yml.provisioner.log|default(false) | bool) }}" + vars: + ssh_port: 22 + ssh_user: "{{ lookup('env', 'USER') }}" + ssh_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + tasks: + - name: Create SSH key + user: + name: "{{ lookup('env', 'USER') }}" + generate_ssh_key: true + ssh_key_file: "{{ ssh_path }}" + register: generated_ssh_key + + - name: Create molecule instance(s) + hcloud_server: + name: "{{ item.name }}" + server_type: "{{ item.server_type }}" + ssh_keys: + - "{{ generated_ssh_key.ssh_public_key }}" + volumes: "{{ item.volumes | default(omit) }}" + image: "{{ item.image }}" + location: "{{ item.location | default(omit) }}" + datacenter: "{{ item.datacenter | default(omit) }}" + user_data: "{{ item.user_data | default(omit) }}" + api_token: "{{ lookup('env', 'HCLOUD_TOKEN') }}" + 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: hetzner_jobs + until: hetzner_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.name }}", + 'ssh_pass': "{{ item.root_password }}", + 'address': "{{ item.public_net.ipv4.ip }}", + 'user': "{{ ssh_user }}", + 'port': "{{ ssh_port }}", + 'identity_file': "{{ ssh_path }}", } + with_items: "{{ hetzner_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 + with_items: "{{ lookup('file', molecule_instance_config) | molecule_from_yaml }}" +{%- endraw %} diff --git a/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml b/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml new file mode 100644 index 0000000000..dec418628e --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml @@ -0,0 +1,49 @@ +--- +{% raw -%} +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ not (lookup('env', 'MOLECULE_DEBUG') | bool or molecule_yml.provisioner.log|default(false) | bool) }}" + 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) + hcloud_server: + name: "{{ item.name }}" + 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: hetzner_jobs + until: hetzner_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/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml b/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml new file mode 100644 index 0000000000..0997271b75 --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/hetzner/{{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/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml b/molecule/cookiecutter/scenario/driver/hetzner/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml new file mode 100644 index 0000000000..babe225095 --- /dev/null +++ b/molecule/cookiecutter/scenario/driver/hetzner/{{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 python-zipstream) + become: true + changed_when: false +{%- endraw %} diff --git a/molecule/driver/hetzner.py b/molecule/driver/hetzner.py new file mode 100644 index 0000000000..901ecc229f --- /dev/null +++ b/molecule/driver/hetzner.py @@ -0,0 +1,147 @@ +# Copyright (c) 2018-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, util +from molecule.driver import base + +log = logger.get_logger(__name__) + + +class Hetzner(base.Base): + """ + The class responsible for managing `Hetzner`_ instances. `Hetzner`_ is + **not** the default driver used in Molecule. + + Molecule leverages Ansible's `hcloud_server module`_, by mapping variables + from ``molecule.yml`` into ``create.yml`` and ``destroy.yml``. + + .. important:: + + The ``hcloud_server`` module is available in the Ansible release 2.8. + Until that release happens, you must use the Ansible development branch + to get access to the module. Please see the Ansible `pip installation`_ + documentation for further information. + + .. _`hcloud_server module`: https://docs.ansible.com/ansible/devel/modules/hcloud_server_module.html#hcloud-server-module + .. _`pip installation`: https://docs.ansible.com/ansible/latest/installation_guide/intro_installation.html#latest-releases-via-pip + .. _`Hetzner`: https://www.hetzner.com/ + + .. code-block:: yaml + + driver: + name: hetzner + platforms: + - name: instance + server_type: cx11 + image: debian-9 + + .. code-block:: bash + + $ pip install 'molecule[hetzner]' + $ pip install git+https://github.com/ansible/ansible.git@devel + + Change the options passed to the ssh client. + + .. code-block:: yaml + + driver: + name: hetzner + 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: hetzner + safe_files: + - foo + """ # noqa + + def __init__(self, config): + super(Hetzner, self).__init__(config) + self._name = 'hetzner' + + @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_ssh_pass': d['ssh_pass'], + '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['name'] == instance_name) diff --git a/molecule/model/schema_v2.py b/molecule/model/schema_v2.py index 4eccf412b9..e9131835c0 100644 --- a/molecule/model/schema_v2.py +++ b/molecule/model/schema_v2.py @@ -65,6 +65,7 @@ def pre_validate_base_schema(env, keep_string): 'docker', 'ec2', 'gce', + 'hetzner', 'linode', 'lxc', 'lxd', @@ -820,6 +821,44 @@ def pre_validate_base_schema(env, keep_string): }, } +platforms_hetzner_schema = { + 'platforms': { + 'type': 'list', + 'schema': { + 'type': 'dict', + 'schema': { + 'name': { + 'type': 'string', + 'required': True, + }, + 'server_type': { + 'type': 'string', + 'required': True, + }, + 'volumes': { + 'type': 'list', + 'schema': { + 'type': 'string', + }, + }, + 'image': { + 'type': 'string', + 'required': True, + }, + 'location': { + 'type': 'string', + }, + 'datacenter': { + 'type': 'string', + }, + 'user_data': { + 'type': 'string', + }, + }, + }, + }, +} + platforms_linode_schema = { 'platforms': { 'type': 'list', @@ -1029,6 +1068,8 @@ def validate(c): util.merge_dicts(schema, platforms_lxd_schema) elif c['driver']['name'] == 'linode': util.merge_dicts(schema, platforms_linode_schema) + elif c['driver']['name'] == 'hetzner': + util.merge_dicts(schema, platforms_hetzner_schema) # Verifier if c['verifier']['name'] == 'goss': diff --git a/setup.cfg b/setup.cfg index 3bf88e5309..e943d0f092 100644 --- a/setup.cfg +++ b/setup.cfg @@ -53,6 +53,8 @@ ec2 = boto3 gce = apache-libcloud +hetzner = + hcloud-python>=1.0.0 linode = linode-python; python_version<"3.0" lxc = diff --git a/test/functional/conftest.py b/test/functional/conftest.py index 7df0c10e09..e2c1134717 100644 --- a/test/functional/conftest.py +++ b/test/functional/conftest.py @@ -69,6 +69,7 @@ def skip_test(request, driver_name): 'docker': supports_docker, 'ec2': supports_ec2, 'gce': supports_gce, + 'hetzner': supports_hetzner, 'linode': supports_linode, 'lxc': supports_lxc, 'lxd': supports_lxd, @@ -359,6 +360,12 @@ def supports_gce(): return not IS_TRAVIS # FIXME: Travis CI +@pytest.helpers.register +def supports_hetzner(): + # FIXME: come up with an actual check + return not IS_TRAVIS # FIXME: Travis CI + + @pytest.helpers.register def supports_openstack(): # FIXME: come up with an actual check diff --git a/test/functional/test_command.py b/test/functional/test_command.py index 686d3614a0..bb5eef30ea 100644 --- a/test/functional/test_command.py +++ b/test/functional/test_command.py @@ -54,6 +54,7 @@ def driver_name(request): ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), @@ -84,6 +85,7 @@ def test_command_check(scenario_to_test, with_scenario, scenario_name): ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), @@ -114,6 +116,7 @@ def test_command_cleanup(scenario_to_test, with_scenario, scenario_name): ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), ('driver/openstack', 'openstack', 'default'), @@ -143,6 +146,7 @@ def test_command_converge(scenario_to_test, with_scenario, scenario_name): ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), @@ -171,6 +175,7 @@ def test_command_create(scenario_to_test, with_scenario, scenario_name): ('dependency', 'azure', 'ansible-galaxy'), ('dependency', 'docker', 'ansible-galaxy'), ('dependency', 'ec2', 'ansible-galaxy'), + ('dependency', 'hetzner', 'ansible-galaxy'), ('dependency', 'gce', 'ansible-galaxy'), ('dependency', 'linode', 'ansible-galaxy'), ('dependency', 'lxc', 'ansible-galaxy'), @@ -201,6 +206,7 @@ def test_command_dependency_ansible_galaxy(scenario_to_test, with_scenario, ('dependency', 'docker', 'gilt'), ('dependency', 'ec2', 'gilt'), ('dependency', 'gce', 'gilt'), + ('dependency', 'hetzner', 'gilt'), ('dependency', 'linode', 'gilt'), ('dependency', 'lxc', 'gilt'), ('dependency', 'lxd', 'gilt'), @@ -229,6 +235,7 @@ def test_command_dependency_gilt(scenario_to_test, with_scenario, ('dependency', 'docker', 'shell'), ('dependency', 'ec2', 'shell'), ('dependency', 'gce', 'shell'), + ('dependency', 'hetzner', 'shell'), ('dependency', 'linode', 'shell'), ('dependency', 'lxc', 'shell'), ('dependency', 'lxd', 'shell'), @@ -258,6 +265,7 @@ def test_command_dependency_shell(scenario_to_test, with_scenario, ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), @@ -288,6 +296,7 @@ def test_command_destroy(scenario_to_test, with_scenario, scenario_name): ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), @@ -315,6 +324,7 @@ def test_command_idempotence(scenario_to_test, with_scenario, scenario_name): ('docker'), ('ec2'), ('gce'), + ('hetzner'), ('linode'), ('lxc'), ('lxd'), @@ -354,6 +364,7 @@ def test_command_init_scenario(temp_dir, driver_name, skip_test): ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), @@ -408,6 +419,13 @@ def test_command_lint(scenario_to_test, with_scenario, scenario_name): instance gce ansible default false false instance-1 gce ansible multi-node false false instance-2 gce ansible multi-node false false +""".strip()), # noqa + ('driver/hetzner', 'hetzner', """ +Instance Name Driver Name Provisioner Name Scenario Name Created Converged +--------------- ------------- ------------------ --------------- --------- ----------- +instance hetzner ansible default false false +instance-1 hetzner ansible multi-node false false +instance-2 hetzner ansible multi-node false false """.strip()), # noqa ('driver/linode', 'linode', """ Instance Name Driver Name Provisioner Name Scenario Name Created Converged @@ -483,6 +501,11 @@ def test_command_list(scenario_to_test, with_scenario, expected): instance gce ansible default false false instance-1 gce ansible multi-node false false instance-2 gce ansible multi-node false false +""".strip()), + ('driver/hetzner', 'hetzner', """ +instance hetzner ansible default false false +instance-1 hetzner ansible multi-node false false +instance-2 hetzner ansible multi-node false false """.strip()), ('driver/linode', 'linode', """ instance linode ansible default false false @@ -565,6 +588,13 @@ def test_command_list_with_format_plain(scenario_to_test, with_scenario, 'instance-2', '.*instance-2.*', ]], 'multi-node'), + ('driver/hetzner', 'hetzner', [[ + 'instance-1', + '.*instance-1.*', + ], [ + 'instance-2', + '.*instance-2.*', + ]], 'multi-node'), ('driver/linode', 'linode', [[ 'instance', '.*instance.*', @@ -646,6 +676,7 @@ def test_command_login(scenario_to_test, with_scenario, login_args, ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), @@ -680,6 +711,7 @@ def test_command_prepare(scenario_to_test, with_scenario, scenario_name): ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), @@ -710,6 +742,7 @@ def test_command_side_effect(scenario_to_test, with_scenario, scenario_name): ('driver/docker', 'docker', 'default'), ('driver/ec2', 'ec2', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), @@ -740,6 +773,7 @@ def test_command_syntax(scenario_to_test, with_scenario, scenario_name): ('driver/docker', 'docker', None), ('driver/ec2', 'ec2', None), ('driver/gce', 'gce', None), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', None), ('driver/lxc', 'lxc', None), ('driver/lxd', 'lxd', None), @@ -768,6 +802,7 @@ def test_command_test(scenario_to_test, with_scenario, scenario_name, ('driver/azure', 'azure', 'default'), ('driver/docker', 'docker', 'default'), ('driver/gce', 'gce', 'default'), + ('driver/hetzner', 'hetzner', 'default'), ('driver/linode', 'linode', 'default'), ('driver/lxc', 'lxc', 'default'), ('driver/lxd', 'lxd', 'default'), diff --git a/test/resources/playbooks/hetzner/create.yml b/test/resources/playbooks/hetzner/create.yml new file mode 100644 index 0000000000..ccbae0b8df --- /dev/null +++ b/test/resources/playbooks/hetzner/create.yml @@ -0,0 +1,77 @@ +--- +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ not (lookup('env', 'MOLECULE_DEBUG') | bool or molecule_yml.provisioner.log|default(false) | bool) }}" + vars: + ssh_user: root + ssh_port: 22 + ssh_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + tasks: + - name: Create SSH key + user: + name: "{{ lookup('env', 'USER') }}" + generate_ssh_key: true + ssh_key_file: "{{ ssh_path }}" + register: generated_ssh_key + + - name: Create molecule instance(s) + hcloud_server: + name: "{{ item.name }}" + server_type: "{{ item.server_type }}" + ssh_keys: + - "{{ generated_ssh_key.ssh_public_key }}" + volumes: "{{ item.volumes | default(omit) }}" + image: "{{ item.image }}" + location: "{{ item.location | default(omit) }}" + datacenter: "{{ item.datacenter | default(omit) }}" + user_data: "{{ item.user_data | default(omit) }}" + api_token: "{{ lookup('env', 'HCLOUD_TOKEN') }}" + 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: hetzner_jobs + until: hetzner_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.name }}", + 'ssh_pass': "{{ item.root_password }}", + 'address': "{{ item.public_net.ipv4.ip }}", + 'user': "{{ ssh_user }}", + 'port': "{{ ssh_port }}", + 'identity_file': "{{ ssh_path }}", } + with_items: "{{ hetzner_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 + with_items: "{{ lookup('file', molecule_instance_config) | molecule_from_yaml }}" diff --git a/test/resources/playbooks/hetzner/destroy.yml b/test/resources/playbooks/hetzner/destroy.yml new file mode 100644 index 0000000000..e31adcb8b7 --- /dev/null +++ b/test/resources/playbooks/hetzner/destroy.yml @@ -0,0 +1,47 @@ +--- +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ not (lookup('env', 'MOLECULE_DEBUG') | bool or molecule_yml.provisioner.log|default(false) | bool) }}" + 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) + hcloud_server: + name: "{{ item.name }}" + 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: hetzner_jobs + until: hetzner_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 diff --git a/test/scenarios/driver/hetzner/molecule/default/molecule.yml b/test/scenarios/driver/hetzner/molecule/default/molecule.yml new file mode 100644 index 0000000000..b387a19354 --- /dev/null +++ b/test/scenarios/driver/hetzner/molecule/default/molecule.yml @@ -0,0 +1,34 @@ +--- +dependency: + name: galaxy + +driver: + name: hetzner + +lint: + name: yamllint + options: + config-file: ../../../resources/.yamllint + +platforms: + - name: instance + server_type: cx11 + image: debian-9 + +provisioner: + name: ansible + playbooks: + create: ../../../../../resources/playbooks/hetzner/create.yml + destroy: ../../../../../resources/playbooks/hetzner/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/hetzner/molecule/default/playbook.yml b/test/scenarios/driver/hetzner/molecule/default/playbook.yml new file mode 100644 index 0000000000..6cbb76ebf1 --- /dev/null +++ b/test/scenarios/driver/hetzner/molecule/default/playbook.yml @@ -0,0 +1,5 @@ +--- +- name: Converge + hosts: all + roles: + - molecule diff --git a/test/scenarios/driver/hetzner/molecule/default/tests/test_default.py b/test/scenarios/driver/hetzner/molecule/default/tests/test_default.py new file mode 100644 index 0000000000..f122cce192 --- /dev/null +++ b/test/scenarios/driver/hetzner/molecule/default/tests/test_default.py @@ -0,0 +1,33 @@ +import os + +import pytest + +import testinfra.utils.ansible_runner + +testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( + os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('all') + + +@pytest.mark.skip(reason='Scenario tests not implemented yet') +def test_hostname(host): + assert 'instance' == host.check_output('hostname -s') + + +@pytest.mark.skip(reason='Scenario tests not implemented yet') +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 + + +@pytest.mark.skip(reason='Scenario tests not implemented yet') +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/hetzner/molecule/multi-node/molecule.yml b/test/scenarios/driver/hetzner/molecule/multi-node/molecule.yml new file mode 100644 index 0000000000..4a2e54df3c --- /dev/null +++ b/test/scenarios/driver/hetzner/molecule/multi-node/molecule.yml @@ -0,0 +1,37 @@ +--- +dependency: + name: galaxy + +driver: + name: hetzner + +lint: + name: yamllint + options: + config-file: ../../../resources/.yamllint + +platforms: + - name: instance-1 + server_type: cx11 + image: debian-9 + - name: instance-2 + server_type: cx11 + image: debian-9 + +provisioner: + name: ansible + playbooks: + create: ../../../../../resources/playbooks/hetzner/create.yml + destroy: ../../../../../resources/playbooks/hetzner/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/hetzner/molecule/multi-node/playbook.yml b/test/scenarios/driver/hetzner/molecule/multi-node/playbook.yml new file mode 100644 index 0000000000..c778a79db3 --- /dev/null +++ b/test/scenarios/driver/hetzner/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/hetzner/molecule/multi-node/tests/test_default.py b/test/scenarios/driver/hetzner/molecule/multi-node/tests/test_default.py new file mode 100644 index 0000000000..5cfd954e60 --- /dev/null +++ b/test/scenarios/driver/hetzner/molecule/multi-node/tests/test_default.py @@ -0,0 +1,34 @@ +import os +import re + +import pytest +import testinfra.utils.ansible_runner + +testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( + os.environ['MOLECULE_INVENTORY_FILE']).get_hosts('all') + + +@pytest.mark.skip(reason='Scenario tests not implemented yet') +def test_hostname(host): + assert re.search(r'instance-[12]', host.check_output('hostname -s')) + + +@pytest.mark.skip(reason='Scenario tests not implemented yet') +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 + + +@pytest.mark.skip(reason='Scenario tests not implemented yet') +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..3dbed6e7d4 100644 --- a/test/unit/cookiecutter/test_molecule.py +++ b/test/unit/cookiecutter/test_molecule.py @@ -96,6 +96,7 @@ def test_vagrant_driver(temp_dir, _molecule_file, _role_directory, ('docker'), ('ec2'), ('gce'), + ('hetzner'), ('linode'), ('lxc'), ('lxd'), diff --git a/test/unit/driver/test_hetzner.py b/test/unit/driver/test_hetzner.py new file mode 100644 index 0000000000..5d67da39a4 --- /dev/null +++ b/test/unit/driver/test_hetzner.py @@ -0,0 +1,198 @@ +# Copyright (c) 2018-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 hetzner + + +@pytest.fixture +def hetzner_instance(patched_config_validate, config_instance): + return hetzner.Hetzner(config_instance) + + +def test_hetzner_config_gives_config_object(hetzner_instance): + assert isinstance(hetzner_instance._config, config.Config) + + +def test_hetzner_testinfra_options_property(hetzner_instance): + assert { + 'connection': 'ansible', + 'ansible-inventory': + hetzner_instance._config.provisioner.inventory_file + } == hetzner_instance.testinfra_options + + +def test_hetzner_name_property(hetzner_instance): + assert 'hetzner' == hetzner_instance.name + + +def test_hetzner_options_property(hetzner_instance): + assert {'managed': True} == hetzner_instance.options + + +def test_hetzner_login_cmd_template_property(hetzner_instance): + template = 'ssh {address} -l {user} -p {port} -i {identity_file}' + assert template in hetzner_instance.login_cmd_template + + +def test_hetzner_safe_files_property(hetzner_instance): + expected_safe_files = [ + os.path.join(hetzner_instance._config.scenario.ephemeral_directory, + 'instance_config.yml') + ] + + assert expected_safe_files == hetzner_instance.safe_files + + +def test_hetzner_default_safe_files_property(hetzner_instance): + expected_default_safe_files = [ + os.path.join(hetzner_instance._config.scenario.ephemeral_directory, + 'instance_config.yml') + ] + assert expected_default_safe_files == hetzner_instance.default_safe_files + + +def test_hetzner_delegated_property(hetzner_instance): + assert not hetzner_instance.delegated + + +def test_hetzner_managed_property(hetzner_instance): + assert hetzner_instance.managed + + +def test_hetzner_default_ssh_connection_options_property(hetzner_instance): + expected_options = [ + '-o UserKnownHostsFile=/dev/null', '-o ControlMaster=auto', + '-o ControlPersist=60s', '-o IdentitiesOnly=yes', + '-o StrictHostKeyChecking=no' + ] + + assert expected_options == hetzner_instance.default_ssh_connection_options + + +def test_hetzner_login_options(hetzner_instance, mocker): + target = 'molecule.driver.hetzner.hetzner._get_instance_config' + get_instance_config_patch = mocker.patch(target) + + get_instance_config_patch.return_value = { + 'instance': 'hetzner', + 'address': '172.16.0.2', + 'user': 'hetzner-admin', + 'port': 22, + 'identity_file': '/foo/bar', + } + + get_instance_config_patch = { + 'instance': 'hetzner', + 'address': '172.16.0.2', + 'user': 'hetzner-admin', + 'port': 22, + 'identity_file': '/foo/bar', + } + + assert get_instance_config_patch == hetzner_instance.login_options( + 'hetzner') + + +def test_hetzner_ansible_connection_options(hetzner_instance, mocker): + target = 'molecule.driver.hetzner.hetzner._get_instance_config' + get_instance_config_patch = mocker.patch(target) + + get_instance_config_patch.return_value = { + 'instance': 'hetzner', + 'address': '172.16.0.2', + 'user': 'hetzner-admin', + 'port': 22, + 'ssh_pass': 'foobar', + 'identity_file': '/foo/bar', + } + + get_instance_config_patch = { + 'ansible_host': + '172.16.0.2', + 'ansible_port': + 22, + 'ansible_user': + 'hetzner-admin', + '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'), + 'ansible_ssh_pass': + 'foobar', + } + + connection_options = hetzner_instance.ansible_connection_options('hetzner') + assert get_instance_config_patch == connection_options + + +def test_hetzner_instance_config_property(hetzner_instance): + instance_config_path = os.path.join( + hetzner_instance._config.scenario.ephemeral_directory, + 'instance_config.yml') + + assert instance_config_path == hetzner_instance.instance_config + + +def test_hetzner_ssh_connection_options_property(hetzner_instance): + expected_options = [ + '-o UserKnownHostsFile=/dev/null', '-o ControlMaster=auto', + '-o ControlPersist=60s', '-o IdentitiesOnly=yes', + '-o StrictHostKeyChecking=no' + ] + + assert expected_options == hetzner_instance.ssh_connection_options + + +def test_hetzner_status(mocker, hetzner_instance): + hetzner_status = hetzner_instance.status() + + assert 2 == len(hetzner_status) + + assert hetzner_status[0].instance_name == 'instance-1' + assert hetzner_status[0].driver_name == 'hetzner' + assert hetzner_status[0].provisioner_name == 'ansible' + assert hetzner_status[0].scenario_name == 'default' + assert hetzner_status[0].created == 'false' + assert hetzner_status[0].converged == 'false' + + assert hetzner_status[1].instance_name == 'instance-2' + assert hetzner_status[1].driver_name == 'hetzner' + assert hetzner_status[1].provisioner_name == 'ansible' + assert hetzner_status[1].scenario_name == 'default' + assert hetzner_status[1].created == 'false' + assert hetzner_status[1].converged == 'false' + + +def test_created(hetzner_instance): + assert 'false' == hetzner_instance._created() + + +def test_converged(hetzner_instance): + assert 'false' == hetzner_instance._converged() diff --git a/test/unit/model/v2/test_platforms_section.py b/test/unit/model/v2/test_platforms_section.py index 84c03074b3..926f2112de 100644 --- a/test/unit/model/v2/test_platforms_section.py +++ b/test/unit/model/v2/test_platforms_section.py @@ -483,6 +483,30 @@ def test_platforms_driver_name_required(_config): assert x == schema_v2.validate(_config) +@pytest.fixture +def _model_platform_hetzner_section_data(): + return { + 'driver': { + 'name': 'hetzner', + }, + 'platforms': [{ + 'name': 'instance', + 'server_type': '', + 'volumes': [], + 'image': '', + 'location': '', + 'datacenter': '', + 'user_data': '', + }] + } + + +@pytest.mark.parametrize( + '_config', ['_model_platform_hetzner_section_data'], indirect=True) +def test_platforms_hetzner(_config): + assert {} == schema_v2.validate(_config) + + @pytest.fixture def _model_platform_linode_section_data(): return { @@ -554,3 +578,59 @@ def test_platforms_linode_fields_required(_config, _required_field): }] } assert expected_config == schema_v2.validate(_config) + + +@pytest.fixture +def _model_platforms_hetzner_errors_section_data(): + return { + 'driver': { + 'name': 'hetzner', + }, + 'platforms': [{ + 'name': 0, + 'server_type': 0, + 'volumes': {}, + 'image': 0, + 'location': 0, + 'datacenter': 0, + 'user_data': 0, + }] + } + + +@pytest.mark.parametrize( + '_config', ['_model_platforms_hetzner_errors_section_data'], indirect=True) +def test_platforms_hetzner_has_errors(_config): + expected_config = { + 'platforms': [{ + 0: [{ + 'name': ['must be of string type'], + 'server_type': ['must be of integer type'], + 'volumes': ['must be of list type'], + 'image': ['must be of integer type'], + 'location': ['must be of integer type'], + 'datacenter': ['must be of integer type'], + 'user_data': ['must be of integer type'], + }], + }], + } + + assert expected_config == schema_v2.validate(_config) + + +@pytest.mark.parametrize( + '_config', ['_model_platform_hetzner_section_data'], indirect=True) +@pytest.mark.parametrize('_required_field', ( + 'server_type', + 'image', +)) +def test_platforms_hetzner_fields_required(_config, _required_field): + del _config['platforms'][0][_required_field] + expected_config = { + 'platforms': [{ + 0: [{ + _required_field: ['required field'] + }] + }] + } + assert expected_config == schema_v2.validate(_config) diff --git a/test/unit/test_config.py b/test/unit/test_config.py index ef5e26360e..588872fd77 100644 --- a/test/unit/test_config.py +++ b/test/unit/test_config.py @@ -274,6 +274,7 @@ def test_drivers_property(config_instance): 'docker', 'ec2', 'gce', + 'hetzner', 'linode', 'lxc', 'lxd', @@ -523,6 +524,7 @@ def test_molecule_drivers(): 'docker', 'ec2', 'gce', + 'hetzner', 'linode', 'lxc', 'lxd',