From e96a71866d54caa708eed4332c2e9c4c8ea1d073 Mon Sep 17 00:00:00 2001 From: Luke Murphy Date: Fri, 27 Sep 2019 22:00:44 +0200 Subject: [PATCH] Initialise bootstrapping of repository Missing tests but most of the copy/pasta is done. --- LICENSE | 21 ++ README.rst | 73 ++++ molecule_hetzner/__init__.py | 0 .../driver/hetznercloud/cookiecutter.json | 5 + .../INSTALL.rst | 23 ++ .../{{cookiecutter.scenario_name}}/create.yml | 90 +++++ .../destroy.yml | 59 ++++ .../playbook.yml | 7 + .../prepare.yml | 11 + molecule_hetzner/hetznercloud.py | 174 +++++++++ pyproject.toml | 11 + pytest.ini | 12 + setup.cfg | 88 +++++ setup.py | 330 ++++++++++++++++++ tox.ini | 102 ++++++ 15 files changed, 1006 insertions(+) create mode 100644 LICENSE create mode 100644 README.rst create mode 100644 molecule_hetzner/__init__.py create mode 100644 molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/cookiecutter.json create mode 100644 molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst create mode 100644 molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml create mode 100644 molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml create mode 100644 molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml create mode 100644 molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml create mode 100644 molecule_hetzner/hetznercloud.py create mode 100644 pyproject.toml create mode 100644 pytest.ini create mode 100644 setup.cfg create mode 100644 setup.py create mode 100644 tox.ini diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e723cc4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 Luke Murphy + +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. diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..05ce58a --- /dev/null +++ b/README.rst @@ -0,0 +1,73 @@ +***************************** +Molecule Hetzner Cloud Plugin +***************************** + +.. image:: https://badge.fury.io/py/molecule-hetznercloud.svg + :target: https://badge.fury.io/py/molecule-hetznercloud + :alt: PyPI Package + +.. image:: https://img.shields.io/travis/com/pycontribs/molecule-hetznercloud/master.svg?label=Linux%20builds%20%40%20Travis%20CI + :target: https://travis-ci.com/pycontribs/molecule-hetznercloud + +.. image:: https://img.shields.io/badge/code%20style-black-000000.svg + :target: https://github.com/python/black + :alt: Python Black Code Style + +.. image:: https://img.shields.io/badge/Code%20of%20Conduct-Ansible-silver.svg + :target: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html + :alt: Ansible Code of Conduct + +.. image:: https://img.shields.io/badge/Mailing%20lists-Ansible-orange.svg + :target: https://docs.ansible.com/ansible/latest/community/communication.html#mailing-list-information + :alt: Ansible mailing lists + +.. image:: https://img.shields.io/badge/license-MIT-brightgreen.svg + :target: LICENSE + :alt: Repository License + +Molecule Hetzner Cloud driver plugin. + +Documentation +============= + +> https://molecule.readthedocs.io + +.. _get-involved: + +Get Involved +============ + +* Join us in the ``#ansible-molecule`` channel on `Freenode`_. +* Join the discussion in `molecule-users Forum`_. +* Join the community working group by checking the `wiki`_. +* Want to know about releases, subscribe to `ansible-announce list`_. +* For the full list of Ansible email Lists, IRC channels see the + `communication page`_. + +.. _`Freenode`: https://freenode.net +.. _`molecule-users Forum`: https://groups.google.com/forum/#!forum/molecule-users +.. _`wiki`: https://github.com/ansible/community/wiki/Molecule +.. _`ansible-announce list`: https://groups.google.com/group/ansible-announce +.. _`communication page`: https://docs.ansible.com/ansible/latest/community/communication.html + +.. _authors: + +Authors +======= + +Molecule Hetzner Cloud Plugin was created by Luke Murphy based on code from Molecule. + +.. _license: + +License +======= + +The `MIT`_ License. + +.. _`MIT`: https://github.com/ansible/molecule/blob/master/LICENSE + +The logo is licensed under the `Creative Commons NoDerivatives 4.0 License`_. + +If you have some other use in mind, contact us. + +.. _`Creative Commons NoDerivatives 4.0 License`: https://creativecommons.org/licenses/by-nd/4.0/ diff --git a/molecule_hetzner/__init__.py b/molecule_hetzner/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/cookiecutter.json b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/cookiecutter.json new file mode 100644 index 0000000..2ec6fb2 --- /dev/null +++ b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/cookiecutter.json @@ -0,0 +1,5 @@ +{ + "molecule_directory": "molecule", + "role_name": "OVERRIDDEN", + "scenario_name": "OVERRIDDEN" +} diff --git a/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst new file mode 100644 index 0000000..7e64cca --- /dev/null +++ b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst @@ -0,0 +1,23 @@ +*************************************** +Hetzner Cloud driver installation guide +*************************************** + +Requirements +============ + +* Ansible >= 2.8 +* ``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[hetznercloud]' diff --git a/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml new file mode 100644 index 0000000..9d909b6 --- /dev/null +++ b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml @@ -0,0 +1,90 @@ +--- +{% raw -%} +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + ssh_port: 22 + ssh_user: root + 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 }}" + force: true + register: generated_ssh_key + + - name: Register the SSH key name + set_fact: + ssh_key_name: "molecule-generated-{{ 12345 | random | to_uuid }}" + + - name: Register SSH key for test instance(s) + hcloud_ssh_key: + name: "{{ ssh_key_name }}" + public_key: "{{ generated_ssh_key.ssh_public_key }}" + state: present + + - name: Create molecule instance(s) + hcloud_server: + name: "{{ item.name }}" + server_type: "{{ item.server_type }}" + ssh_keys: + - "{{ ssh_key_name }}" + 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.hcloud_server.name }}", + 'ssh_key_name': "{{ ssh_key_name }}", + 'address': "{{ item.hcloud_server.ipv4_address }}", + '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_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml new file mode 100644 index 0000000..3fcb17d --- /dev/null +++ b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml @@ -0,0 +1,59 @@ +--- +{% raw -%} +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + tasks: + - name: Populate the instance config + block: + - name: Populate instance config from file + 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.instance }}" + api_token: "{{ lookup('env', 'HCLOUD_TOKEN') }}" + 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 }}" + + - name: Remove registered SSH key + hcloud_ssh_key: + name: "{{ instance_conf[0].ssh_key_name }}" + state: absent + when: + - not skip_instances + - instance_conf # must contain at least one instance + + # 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_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml new file mode 100644 index 0000000..fecf1c8 --- /dev/null +++ b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/playbook.yml @@ -0,0 +1,7 @@ +--- +- name: Converge + hosts: all + tasks: + - name: "Include {{ cookiecutter.role_name }}" + include_role: + name: "{{ cookiecutter.role_name }}" diff --git a/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/prepare.yml new file mode 100644 index 0000000..babe225 --- /dev/null +++ b/molecule_hetzner/cookiecutter/scenario/driver/hetznercloud/{{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_hetzner/hetznercloud.py b/molecule_hetzner/hetznercloud.py new file mode 100644 index 0000000..922d668 --- /dev/null +++ b/molecule_hetzner/hetznercloud.py @@ -0,0 +1,174 @@ +# 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 + +from molecule import logger, util +from molecule.api import Driver +from molecule.util import lru_cache +from molecule.util import sysexit_with_message + +log = logger.get_logger(__name__) + + +class HetznerCloud(Driver): + """ + The class responsible for managing `Hetzner Cloud`_ instances. + `Hetzner Cloud`_ 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 only available in Ansible >= 2.8. + + .. _`hcloud_server module`: https://docs.ansible.com/ansible/devel/modules/hcloud_server_module.html#hcloud-server-module + + .. code-block:: yaml + + driver: + name: hetznercloud + platforms: + - name: instance + server_type: cx11 + image: debian-9 + + .. code-block:: bash + + $ pip install 'molecule[hetznercloud]' + + Change the options passed to the ssh client. + + .. code-block:: yaml + + driver: + name: hetznercloud + ssh_connection_options: + - '-o ControlPath=~/.ansible/cp/%r@%h-%p' + + .. important:: + + The Hetzner Cloud driver implementation uses the Parmiko transport + provided by Ansible to avoid issues of connection hanging and + indefinite polling as experienced with the default OpenSSh based + transport. + + .. 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: hetznercloud + safe_files: + - foo + """ # noqa + + def __init__(self, config=None): + super(HetznerCloud, self).__init__(config) + self._name = 'hetznercloud' + + @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 + ) + + @lru_cache() + def sanity_checks(self): + """Hetzner Cloud driver sanity checks.""" + + log.info("Sanity checks: '{}'".format(self._name)) + + try: + import hcloud # noqa + except ImportError: + msg = ( + 'Missing Hetzner Cloud driver dependency. Please ' + "install via 'molecule[hetznercloud]' or refer to " + 'your INSTALL.rst driver documentation file' + ) + sysexit_with_message(msg) + + if 'HCLOUD_TOKEN' not in os.environ: + msg = ( + 'Missing Hetzner Cloud API token. Please expose ' + 'the HCLOUD_TOKEN environment variable with your ' + 'account API token value' + ) + sysexit_with_message(msg) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e3a8c8a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[build-system] +requires = [ + "setuptools >= 41.0.0", + "setuptools_scm >= 1.15.0", + "setuptools_scm_git_archive >= 1.0", + "wheel", +] +build-backend = "setuptools.build_meta" + +[tool.black] +skip-string-normalization = true diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..a77f53d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,12 @@ +[pytest] +addopts = -v -rxXs --doctest-modules --durations 10 --cov=molecule --cov-report term-missing:skip-covered --cov-report xml -m "not molecule" +doctest_optionflags = ALLOW_UNICODE ELLIPSIS +junit_suite_name = molecule_test_suite +norecursedirs = dist doc build .tox .eggs +filterwarnings = + # remove once https://github.com/cookiecutter/cookiecutter/pull/1127 is released + ignore::DeprecationWarning:cookiecutter + # remove once https://github.com/pytest-dev/pytest-cov/issues/327 is released + ignore::pytest.PytestWarning:pytest_cov + # remove once https://bitbucket.org/astanin/python-tabulate/issues/174/invalid-escape-sequence + ignore::DeprecationWarning:tabulate diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..be9099d --- /dev/null +++ b/setup.cfg @@ -0,0 +1,88 @@ +[aliases] +dists = clean --all sdist bdist_wheel + +[bdist_wheel] +universal = 1 + +[metadata] +name = molecule-hetznercloud +url = https://github.com/pycontribs/molecule-hetznercloud +project_urls = + Bug Tracker = https://github.com/pycontribs/molecule-hetznercloud/issues + Release Management = https://github.com/pycontribs/molecule-hetznercloud/projects + CI: Travis = https://travis-ci.com/pycontribs/molecule-hetznercloud + Source Code = https://github.com/pycontribs/molecule-hetznercloud +description = Hetznercloud Molecule Plugin :: run molecule tests with hetznercloud as verifier +long_description = file: README.rst +long_description_content_type = text/x-rst +author = Luke Murphy +author_email = lukewm@riseup.net +maintainer = Luke Murphy +maintainer_email = lukewm@riseup.net +license = MIT +license_file = LICENSE +classifiers = + Development Status :: 5 - Production/Stable + + Environment :: Console + + Intended Audience :: Developers + Intended Audience :: Information Technology + Intended Audience :: System Administrators + + License :: OSI Approved :: MIT License + + Natural Language :: English + + Operating System :: OS Independent + + Programming Language :: Python :: 2 + Programming Language :: Python :: 2.7 + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + + Topic :: System :: Systems Administration + Topic :: Utilities +keywords = + ansible + roles + testing + molecule + plugin + hetznercloud + verifier + +[options] +use_scm_version = True +python_requires = >=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.* +packages = find: +include_package_data = True +zip_safe = False +setup_requires = + setuptools_scm >= 1.15.0 + setuptools_scm_git_archive >= 1.0 +install_requires = + molecule >= 3.0a3 + pyyaml >= 5.1, < 6 + backports.shutil_which ; python_version<"3.3" + +[options.extras_require] +test = + flake8>=3.6.0, < 4 + mock>=3.0.5, < 4 + pytest>=4.6.3, < 5 + pytest-cov>=2.7.1, < 3 + pytest-helpers-namespace>=2019.1.8, < 2020 + pytest-mock>=1.10.4, < 2 + pytest-verbose-parametrize>=1.7.0, < 2 + pytest-xdist>=1.29.0, < 2 + hcloud>=1.2.1 + +[options.entry_points] +molecule.verifier = + hetznercloud = molecule_hetznercloud.hetznercloud:hetznercloud + +[options.packages.find] +where = . diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..b79ae27 --- /dev/null +++ b/setup.py @@ -0,0 +1,330 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import setuptools + +HAS_DIST_INFO_CMD = False +try: + import setuptools.command.dist_info + + HAS_DIST_INFO_CMD = True +except ImportError: + """Setuptools version is too old.""" + + +ALL_STRING_TYPES = tuple(map(type, ("", b"", u""))) +MIN_NATIVE_SETUPTOOLS_VERSION = 34, 4, 0 +"""Minimal setuptools having good read_configuration implementation.""" + +RUNTIME_SETUPTOOLS_VERSION = tuple(map(int, setuptools.__version__.split("."))) +"""Setuptools imported now.""" + +READ_CONFIG_SHIM_NEEDED = RUNTIME_SETUPTOOLS_VERSION < MIN_NATIVE_SETUPTOOLS_VERSION + + +def str_if_nested_or_str(s): + """Turn input into a native string if possible.""" + if isinstance(s, ALL_STRING_TYPES): + return str(s) + if isinstance(s, (list, tuple)): + return type(s)(map(str_if_nested_or_str, s)) + if isinstance(s, (dict,)): + return stringify_dict_contents(s) + return s + + +def stringify_dict_contents(dct): + """Turn dict keys and values into native strings.""" + return {str_if_nested_or_str(k): str_if_nested_or_str(v) for k, v in dct.items()} + + +if not READ_CONFIG_SHIM_NEEDED: + from setuptools.config import read_configuration, ConfigOptionsHandler + import setuptools.config + import setuptools.dist + + # Set default value for 'use_scm_version' + setattr(setuptools.dist.Distribution, "use_scm_version", False) + + # Attach bool parser to 'use_scm_version' option + class ShimConfigOptionsHandler(ConfigOptionsHandler): + """Extension class for ConfigOptionsHandler.""" + + @property + def parsers(self): + """Return an option mapping with default data type parsers.""" + _orig_parsers = super(ShimConfigOptionsHandler, self).parsers + return dict(use_scm_version=self._parse_bool, **_orig_parsers) + + def parse_section_packages__find(self, section_options): + find_kwargs = super( + ShimConfigOptionsHandler, self + ).parse_section_packages__find(section_options) + return stringify_dict_contents(find_kwargs) + + setuptools.config.ConfigOptionsHandler = ShimConfigOptionsHandler +else: + """This is a shim for setuptools": operator.gt, + "<": operator.lt, + ">=": operator.ge, + "<=": operator.le, + "==": operator.eq, + "!=": operator.ne, + "": operator.eq, + }.items(), + key=lambda i: len(i[0]), + reverse=True, + ) + ) + + def is_decimal(s): + return type(u"")(s).isdecimal() + + conditions = map(str.strip, python_requires.split(",")) + for c in conditions: + for op_sign, op_func in sorted_operators_map: + if not c.startswith(op_sign): + continue + raw_ver = itertools.takewhile( + is_decimal, c[len(op_sign) :].strip().split(".") + ) + ver = tuple(map(int, raw_ver)) + yield op_func, ver + break + + def validate_required_python_or_fail(python_requires=None): + if python_requires is None: + return + + python_version = sys.version_info + preds = parse_predicates(python_requires) + for op, v in preds: + py_ver_slug = python_version[: max(len(v), 3)] + condition_matches = op(py_ver_slug, v) + if not condition_matches: + raise RuntimeError( + "requires Python '{}' but the running Python is {}".format( + python_requires, ".".join(map(str, python_version[:3])) + ) + ) + + def verify_required_python_runtime(s): + @functools.wraps(s) + def sw(**attrs): + try: + validate_required_python_or_fail(attrs.get("python_requires")) + except RuntimeError as re: + sys.exit("{} {!s}".format(attrs["name"], re)) + return s(**attrs) + + return sw + + setuptools.setup = ignore_unknown_options(setuptools.setup) + setuptools.setup = verify_required_python_runtime(setuptools.setup) + + try: + from configparser import ConfigParser, NoSectionError + except ImportError: + from ConfigParser import ConfigParser, NoSectionError + + ConfigParser.read_file = ConfigParser.readfp + + def maybe_read_files(d): + """Read files if the string starts with `file:` marker.""" + FILE_FUNC_MARKER = "file:" + + d = d.strip() + if not d.startswith(FILE_FUNC_MARKER): + return d + descs = [] + for fname in map(str.strip, str(d[len(FILE_FUNC_MARKER) :]).split(",")): + with io.open(fname, encoding="utf-8") as f: + descs.append(f.read()) + return "".join(descs) + + def cfg_val_to_list(v): + """Turn config val to list and filter out empty lines.""" + return list(filter(bool, map(str.strip, str(v).strip().splitlines()))) + + def cfg_val_to_dict(v): + """Turn config val to dict and filter out empty lines.""" + return dict( + map( + lambda l: list(map(str.strip, l.split("=", 1))), + filter(bool, map(str.strip, str(v).strip().splitlines())), + ) + ) + + def cfg_val_to_primitive(v): + """Parse primitive config val to appropriate data type.""" + return json.loads(v.strip().lower()) + + def read_configuration(filepath): + """Read metadata and options from setup.cfg located at filepath.""" + cfg = ConfigParser() + with io.open(filepath, encoding="utf-8") as f: + cfg.read_file(f) + + md = dict(cfg.items("metadata")) + for list_key in "classifiers", "keywords", "project_urls": + try: + md[list_key] = cfg_val_to_list(md[list_key]) + except KeyError: + pass + try: + md["long_description"] = maybe_read_files(md["long_description"]) + except KeyError: + pass + opt = dict(cfg.items("options")) + for list_key in "include_package_data", "use_scm_version", "zip_safe": + try: + opt[list_key] = cfg_val_to_primitive(opt[list_key]) + except KeyError: + pass + for list_key in "scripts", "install_requires", "setup_requires": + try: + opt[list_key] = cfg_val_to_list(opt[list_key]) + except KeyError: + pass + try: + opt["package_dir"] = cfg_val_to_dict(opt["package_dir"]) + except KeyError: + pass + try: + opt_package_data = dict(cfg.items("options.package_data")) + if not opt_package_data.get("", "").strip(): + opt_package_data[""] = opt_package_data["*"] + del opt_package_data["*"] + except (KeyError, NoSectionError): + opt_package_data = {} + try: + opt_extras_require = dict(cfg.items("options.extras_require")) + opt["extras_require"] = {} + for k, v in opt_extras_require.items(): + opt["extras_require"][k] = cfg_val_to_list(v) + except NoSectionError: + pass + opt["package_data"] = {} + for k, v in opt_package_data.items(): + opt["package_data"][k] = cfg_val_to_list(v) + try: + opt_exclude_package_data = dict(cfg.items("options.exclude_package_data")) + if ( + not opt_exclude_package_data.get("", "").strip() + and "*" in opt_exclude_package_data + ): + opt_exclude_package_data[""] = opt_exclude_package_data["*"] + del opt_exclude_package_data["*"] + except NoSectionError: + pass + else: + opt["exclude_package_data"] = {} + for k, v in opt_exclude_package_data.items(): + opt["exclude_package_data"][k] = cfg_val_to_list(v) + cur_pkgs = opt.get("packages", "").strip() + if "\n" in cur_pkgs: + opt["packages"] = cfg_val_to_list(opt["packages"]) + elif cur_pkgs.startswith("find:"): + opt_packages_find = stringify_dict_contents( + dict(cfg.items("options.packages.find")) + ) + opt["packages"] = setuptools.find_packages(**opt_packages_find) + return {"metadata": md, "options": opt} + + +def cut_local_version_on_upload(version): + """Generate a PEP440 local version if uploading to PyPI.""" + import os + import setuptools_scm.version # only present during setup time + + IS_PYPI_UPLOAD = os.getenv("PYPI_UPLOAD") == "true" # set in tox.ini + return ( + "" + if IS_PYPI_UPLOAD + else setuptools_scm.version.get_local_node_and_date(version) + ) + + +if HAS_DIST_INFO_CMD: + + class patched_dist_info(setuptools.command.dist_info.dist_info): + def run(self): + self.egg_base = str_if_nested_or_str(self.egg_base) + return setuptools.command.dist_info.dist_info.run(self) + + +declarative_setup_params = read_configuration("setup.cfg") +"""Declarative metadata and options as read by setuptools.""" + + +setup_params = {} +"""Explicit metadata for passing into setuptools.setup() call.""" + +setup_params = dict(setup_params, **declarative_setup_params["metadata"]) +setup_params = dict(setup_params, **declarative_setup_params["options"]) + +if HAS_DIST_INFO_CMD: + setup_params["cmdclass"] = {"dist_info": patched_dist_info} + +setup_params["use_scm_version"] = {"local_scheme": cut_local_version_on_upload} + +# Patch incorrectly decoded package_dir option +# ``egg_info`` demands native strings failing with unicode under Python 2 +# Ref https://github.com/pypa/setuptools/issues/1136 +setup_params = stringify_dict_contents(setup_params) + + +if __name__ == "__main__": + setuptools.setup(**setup_params) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..1c830ab --- /dev/null +++ b/tox.ini @@ -0,0 +1,102 @@ +[tox] +minversion = 3.12.0 +envlist = + lint + check + py{27,35,36,37}-ansible{27,28,29} + doc + devel +skipdist = True +skip_missing_interpreters = True +isolated_build = True + +[testenv] +# Hotfix for https://github.com/pypa/pip/issues/6434 +# Resolved by https://github.com/tox-dev/tox/issues/1276 +install_command = + python -c 'import subprocess, sys; pip_inst_cmd = sys.executable, "-m", "pip", "install"; subprocess.check_call(pip_inst_cmd + ("pip<19.1", )); subprocess.check_call(pip_inst_cmd + tuple(sys.argv[1:]))' {opts} {packages} +usedevelop = True +passenv = * +setenv = + ANSIBLE_CALLABLE_WHITELIST={env:ANSIBLE_CALLABLE_WHITELIST:timer,profile_roles} + PYTHONDONTWRITEBYTECODE=1 +deps = + ansible27: ansible>=2.7,<2.8 + ansible28: ansible>=2.8,<2.9 + ansible29: ansible>=2.9.0b1,<2.10 + devel: ansible>=2.9.0b1 + devel: docker +extras = + test +commands = + pip check + devel: pip install -e "git+https://github.com/ansible/molecule.git#egg=molecule" + python -m pytest {posargs} +whitelist_externals = + find + sh + +[testenv:lint] +commands = + # to run a single linter you can do "pre-commit run flake8" + python -m pre_commit run {posargs:--all} +deps = pre-commit>=1.18.1 +extras = +skip_install = true +usedevelop = false + +[testenv:check] +envdir = {toxworkdir}/py36-ansible28 +deps = + {[testenv]deps} + collective.checkdocs==0.2 + twine==1.14.0 +usedevelop = False +commands = + python -m pytest --collect-only + python -m setup checkdocs check --metadata --restructuredtext --strict --verbose + python -m twine check .tox/dist/* + +[testenv:build-docker] +platform = ^darwin|^linux +usedevelop = False +skip_install = True +deps = + setuptools_scm==3.3.3 + packaging # pyup: ignore +commands_pre = +commands = + python ./utils/build-docker.py +whitelist_externals = + sh + +[testenv:build-dists-local] +description = + Generate dists which may be not ready + for upload to PyPI because of + containing PEP440 local version part +usedevelop = false +skip_install = true +deps = + pep517 >= 0.5.0 +setenv = +commands = + python -m pep517.build \ + --source \ + --binary \ + --out-dir {toxinidir}/dist/ \ + {toxinidir} + +[testenv:build-dists] +description = Generate dists ready for upload to PyPI +usedevelop = {[testenv:build-dists-local]usedevelop} +skip_install = {[testenv:build-dists-local]skip_install} +deps = {[testenv:build-dists-local]deps} +setenv = + PYPI_UPLOAD = true +commands = + rm -rfv {toxinidir}/dist/ + {[testenv:build-dists-local]commands} +whitelist_externals = + rm + {[testenv]whitelist_externals}