diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..cd13756 --- /dev/null +++ b/.flake8 @@ -0,0 +1,8 @@ +[flake8] +# do not add excludes for files in repo +exclude = .venv/,.tox/,dist/,build/,.eggs/ +format = pylint +# E203: https://github.com/python/black/issues/315 +ignore = E741,W503,W504,H,E501,E203 +# 88 is official black default: +max-line-length = 88 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7c9456 --- /dev/null +++ b/.gitignore @@ -0,0 +1,105 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +pip-wheel-metadata + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..89495c6 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,32 @@ +--- +repos: + - repo: https://github.com/python/black.git + rev: 19.3b0 + hooks: + - id: black + language_version: python3 + - repo: https://github.com/pre-commit/pre-commit-hooks.git + rev: v2.2.3 + hooks: + - id: end-of-file-fixer + - id: trailing-whitespace + - id: mixed-line-ending + - id: check-byte-order-marker + - id: check-executables-have-shebangs + - id: check-merge-conflict + - id: debug-statements + - repo: https://gitlab.com/pycqa/flake8.git + rev: 3.7.8 + hooks: + - id: flake8 + additional_dependencies: + - flake8-black + - flake8-mypy + language_version: python3 + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.16.0 + hooks: + - id: yamllint + files: \.(yaml|yml)$ + types: [file, yaml] + entry: yamllint --strict diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..369b9c4 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,160 @@ +--- +conditions: v1 +dist: xenial + +git: + depth: 200 + +cache: + bundler: true + pip: true + directories: + - $HOME/.cache/pre-commit + - $HOME/.pre-commit + - $HOME/.rvm + - $HOME/Library/Caches/Homebrew + +services: + - docker + +language: python + +.mixtures: # is not used by Travis CI, but helps avoid duplication + - &if-cron-or-manual-run-or-tagged + if: type IN (cron, api) OR tag IS present + - &py-27 + python: "2.7" + - &py-36 + python: "3.6" + - &py-37 + python: "3.7" + - &reset-prerequisites + addons: {} + before_install: + - pip install tox-venv tox-tags + before_script: [] + services: [] + - &lint + <<: *py-37 + <<: *reset-prerequisites + +jobs: + fast_finish: true + + include: + - <<: *lint + name: linting the code + env: + - TOXENV=lint,check + + - <<: *py-37 + env: + - TOXENV=devel + + - <<: *py-36 + <<: *if-cron-or-manual-run-or-tagged + env: + - TOXENV=devel + + - <<: *py-27 + env: + - TOXENV=devel + + - <<: *py-37 + env: + - TOXENV=ansible28 + name: py37-ansible28 + + - <<: *py-37 + env: + - TOXENV=ansible27 + name: py37-ansible27 + + - <<: *py-37 + env: + - TOXENV=ansible26 + name: py37-ansible26 + + - <<: *py-36 + <<: *if-cron-or-manual-run-or-tagged + env: + - TOXENV=ansible28 + name: py36-ansible28 + + - <<: *py-36 + <<: *if-cron-or-manual-run-or-tagged + env: + - TOXENV=ansible27 + name: py36-ansible27 + + - <<: *py-36 + <<: *if-cron-or-manual-run-or-tagged + env: + - TOXENV=ansible26 + name: py36-ansible26 + + - <<: *py-27 + env: + - TOXENV=ansible28 + name: py27-ansible28 + + - <<: *py-27 + env: + - TOXENV=ansible27 + name: py27-ansible27 + + - <<: *py-27 + env: + - TOXENV=ansible26 + name: py27-ansible26 + + - &deploy-job + <<: *py-37 + <<: *reset-prerequisites + stage: Deploy + name: Publishing current Git tagged version of dist to PyPI + if: repo == "pycontribs/molecule-goss" AND tag IS present + env: + - TOXENV=build-dists + before_deploy: + - echo > setup.py + deploy: &deploy-step + provider: pypi + user: ansible-molecule + password: + secure: > + HPBkDwncLYLEzxirPfNB88GYFWZ/wA1jMgCd4wT7SgKQlIyRSCT6KXMfoOUVMH/uwaLggoURaGtmD/qnrfXj+bG15nBCCOaIZdXzODln/PwDFAYT8ZspzRPzQOfncdk4WTsVbwpiGgpdm+TxGGBz8yedvVXiTwmwLCGC0FsAE8Wp8krNH1Kwqp3OaZeePakIbEj0UXgCTnAol3ZgVWWy+6bfDBb/aLiGXjsAIb7sY6HzwvQUr43xFO7tReaGO23mGwgIy/tNstXarwxezlw6FlGe5KJGvE/a4yAPuWg9kuJt5MqwbCJzhP38SeH4Gc6x0o6jPT/+NbvLV1ck1Qbz5gCYmXlOzucDP+P5t8E3g+zJbNbCR00k1BpG3MIEzrHeyp/hCdDYnGB3TVnlj+L4Y/yoUdCq9FJI8xHLbj/fMe5vxBMMsvDbt2JJReavi3WB2pv2nHtivkKj/Kow4FRm+Kp3WH4x5NlZDjV5gsUbwPcN1kIpb09sPZE2wYugvCTOiExt7mdd0JMMcgxuR1jGgGxqRARQeqjz14VVNsrVIIrMh8JX8u7Rl9sqmU4UuLGTxnq2cwyBmYzJ0gmDgN3TvQ96n9D2sP0YQj35v7RY5vsdYsfLucLdTfTajP9OL4Bw+ogXMJrArIq+j+7uJyTFqNdAN+Y6nX7WV0W6rp8aA1I= + distributions: dists + skip-cleanup: true + "on": + all_branches: true + + - <<: *deploy-job + if: >- # publish only pushes to master and tags; only if upstream + repo == "pycontribs/molecule-goss" AND + ( + ( + type == push AND + branch == "master" + ) OR + tag IS present + ) + name: Publishing current (unstable) Git revision of dist to Test PyPI + deploy: + <<: *deploy-step + server: https://test.pypi.org/legacy/ + +addons: + apt: + packages: + - lxc + - lxc-dev + - lxd + - lxd-client +before_install: + - pip install tox-tags +before_script: + - gem install inspec -v '~> 3' + - gem install rubocop -v '~> 0.69.0' +script: + - tox diff --git a/.yamllint b/.yamllint new file mode 100644 index 0000000..7349eb1 --- /dev/null +++ b/.yamllint @@ -0,0 +1,16 @@ +extends: default +ignore: | + *.molecule/ + molecule*/cookiecutter/ + .tox + # HACK: https://github.com/pyupio/pyup/issues/346 + .pyup.yml + +rules: + braces: + max-spaces-inside: 1 + level: error + brackets: + max-spaces-inside: 1 + level: error + line-length: disable diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6996583 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2019 PyContribs + +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..68a9c41 --- /dev/null +++ b/README.rst @@ -0,0 +1,74 @@ +******************** +Molecule Goss Plugin +******************** + +.. image:: https://badge.fury.io/py/molecule-goss.svg + :target: https://badge.fury.io/py/molecule-goss + :alt: PyPI Package + +.. image:: https://img.shields.io/travis/com/pycontribs/molecule-goss/master.svg?label=Linux%20builds%20%40%20Travis%20CI + :target: https://travis-ci.com/pycontribs/molecule-goss + +.. 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 goss is designed to allow use of goss Cloud for provisioning test +resources. + +Documentation +============= + +Read the documentation and more at 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 Goss Plugin was created by Sorin Sbarnea 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_goss/__init__.py b/molecule_goss/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/molecule_goss/cookiecutter/scenario/verifier/goss/cookiecutter.json b/molecule_goss/cookiecutter/scenario/verifier/goss/cookiecutter.json new file mode 100644 index 0000000..118a30a --- /dev/null +++ b/molecule_goss/cookiecutter/scenario/verifier/goss/cookiecutter.json @@ -0,0 +1,7 @@ +{ + "molecule_directory": "molecule", + + "role_name": "OVERRIDEN", + "scenario_name": "default", + "verifier_directory": "tests" +} diff --git a/molecule_goss/cookiecutter/scenario/verifier/goss/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/verify.yml b/molecule_goss/cookiecutter/scenario/verifier/goss/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/verify.yml new file mode 100644 index 0000000..0bfa7f1 --- /dev/null +++ b/molecule_goss/cookiecutter/scenario/verifier/goss/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/verify.yml @@ -0,0 +1,76 @@ +--- +# This is an example playbook to execute goss tests. +# Tests need distributed to the appropriate ansible host/groups +# prior to execution by `goss validate`. + +{% raw -%} +- name: Verify + hosts: all + become: true + vars: + goss_version: v0.3.7 + goss_arch: amd64 + goss_bin: /usr/local/bin/goss + goss_sha256sum: 357f5c7f2e7949b412bce44349cd32ab19eb3947255a8ac805f884cc2c326059. + goss_test_directory: /tmp/molecule/goss + goss_format: documentation + tasks: + - name: Download and install Goss + get_url: + url: "https://github.com/aelsabbahy/goss/releases/download/{{ goss_version }}/goss-linux-{{ goss_arch }}" + dest: "{{ goss_bin }}" + sha256sum: "{{ goss_sha256sum }}" + mode: 0755 + + - name: Create Molecule directory for test files + file: + path: "{{ goss_test_directory }}" + state: directory + + - name: Find Goss tests on localhost + find: + paths: "{{ lookup('env', 'MOLECULE_VERIFIER_TEST_DIRECTORY') }}" + patterns: + - "test[-.\\w]*.yml" + - "test_host_{{ ansible_hostname }}[-.\\w]*.yml" + excludes: + - "test_host_(?!{{ ansible_hostname }})[-.\\w]*.yml" + use_regex: true + delegate_to: localhost + register: test_files + changed_when: false + become: false + + - name: debug + debug: + msg: "{{ test_files.files }}" + verbosity: 3 + + - name: Copy Goss tests to remote + copy: + src: "{{ item.path }}" + dest: "{{ goss_test_directory }}/{{ item.path | basename }}" + with_items: + - "{{ test_files.files }}" + + - name: Register test files + shell: "ls {{ goss_test_directory }}/test_*.yml" + register: test_files + + - name: Execute Goss tests + command: "{{ goss_bin }} -g {{ item }} validate --format {{ goss_format }}" + register: test_results + with_items: "{{ test_files.stdout_lines }}" + failed_when: false + + - name: Display details about the Goss results + debug: + msg: "{{ item.stdout_lines }}" + with_items: "{{ test_results.results }}" + + - name: Fail when tests fail + fail: + msg: "Goss failed to validate" + when: item.rc != 0 + with_items: "{{ test_results.results }}" +{% endraw -%} diff --git a/molecule_goss/cookiecutter/scenario/verifier/goss/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/{{cookiecutter.verifier_directory}}/test_default.yml b/molecule_goss/cookiecutter/scenario/verifier/goss/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/{{cookiecutter.verifier_directory}}/test_default.yml new file mode 100644 index 0000000..7f40386 --- /dev/null +++ b/molecule_goss/cookiecutter/scenario/verifier/goss/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/{{cookiecutter.verifier_directory}}/test_default.yml @@ -0,0 +1,8 @@ +# Molecule managed + +--- +file: + /etc/hosts: + exists: true + owner: root + group: root diff --git a/molecule_goss/goss.py b/molecule_goss/goss.py new file mode 100644 index 0000000..58bf49d --- /dev/null +++ b/molecule_goss/goss.py @@ -0,0 +1,175 @@ +# Copyright (c) 2015-2018 Cisco Systems, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os + +from molecule import logger +from molecule import util +from molecule.api import Verifier + +LOG = logger.get_logger(__name__) + + +class Goss(Verifier): + """ + `Goss`_ is not the default test runner. + + `Goss`_ is a YAML based serverspec-like tool for validating a server's + configuration. `Goss`_ is `not` the default verifier used in Molecule. + + Molecule executes a playbook (`verify.yml`) located in the role's + `scenario.directory`. This playbook will copy YAML files to the instances, + and execute Goss using a community written Goss Ansible module bundled with + Molecule. + + Additional options can be passed to ``goss validate`` by modifying the + verify playbook. + + .. code-block:: yaml + + verifier: + name: goss + lint: + name: yamllint + + The testing can be disabled by setting ``enabled`` to False. + + .. code-block:: yaml + + verifier: + name: goss + enabled: False + + Environment variables can be passed to the verifier. + + .. code-block:: yaml + + verifier: + name: goss + env: + FOO: bar + + Change path to the test directory. + + .. code-block:: yaml + + verifier: + name: goss + directory: /foo/bar/ + + All files starting with test_* will be copied to all molecule hosts. + Files matching the regular expression `test_host_$instance_name[-.\\w].yml` + will only run on $instance_name. If you have 2 molecule instances, + instance1 and instance2, your test files could look like this: + + .. code-block:: bash + + test_default.yml (will run on all hosts) + test_host_instance1.yml (will run only on instance1) + test_host_instance2.yml (will run only on instance2) + + .. important:: + + Due to the nature of this verifier. Molecule does not perform options + handling in the same fashion as Testinfra. + + .. _`Goss`: https://github.com/aelsabbahy/goss + """ + + def __init__(self, config=None): + """ + Sets up the requirements to execute ``goss`` and returns None. + + :param config: An instance of a Molecule config. + :return: None + """ + super(Goss, self).__init__(config) + self.default_linter = "yamllint" + if config: + self._tests = self._get_tests() + + @property + def name(self): + return "goss" + + @property + def default_options(self): + return {} + + @property + def default_env(self): + return util.merge_dicts(os.environ.copy(), self._config.env) + + def bake(self): + pass + + def execute(self): + if not self.enabled: + msg = "Skipping, verifier is disabled." + LOG.warn(msg) + return + + if not len(self._tests) > 0: + msg = "Skipping, no tests found." + LOG.warn(msg) + return + + msg = "Executing Goss tests found in {}/...".format(self.directory) + LOG.info(msg) + + self._config.provisioner.verify() + + msg = "Verifier completed successfully." + LOG.success(msg) + + def _get_tests(self): + """ + Walk the verifier's directory for tests and returns a list. + + :return: list + """ + return [filename for filename in util.os_walk(self.directory, "test_*.yml")] + + def schema(self): + return { + 'verifier': { + 'type': 'dict', + 'schema': { + 'name': {'type': 'string', 'allowed': ['goss']}, + 'lint': { + 'type': 'dict', + 'schema': {'name': {'type': 'string', 'allowed': ['yamllint']}}, + }, + 'options': {'keysrules': {'readonly': True}}, + }, + } + } + + def template_dir(self): + p = os.path.abspath( + os.path.join( + os.path.dirname(__file__), + "cookiecutter", + "scenario", + "verifier", + self.name, + ) + ) + return p diff --git a/molecule_goss/test/conftest.py b/molecule_goss/test/conftest.py new file mode 100644 index 0000000..d905fdd --- /dev/null +++ b/molecule_goss/test/conftest.py @@ -0,0 +1,32 @@ +import contextlib # noqa F401 + +import pytest # noqa F401 + +from molecule.test.conftest import change_dir_to # noqa F401 +from molecule.test.conftest import random_string # noqa F401 +from molecule.test.conftest import run_command # noqa F401 +from molecule.test.conftest import temp_dir # noqa F401 +from molecule.test.functional.conftest import metadata_lint_update # noqa F401 +from molecule.test.unit.conftest import ( # noqa F401 + _molecule_dependency_galaxy_section_data, +) +from molecule.test.unit.conftest import _molecule_driver_section_data # noqa F401 +from molecule.test.unit.conftest import _molecule_lint_section_data # noqa F401 +from molecule.test.unit.conftest import _molecule_platforms_section_data # noqa F401 +from molecule.test.unit.conftest import _molecule_provisioner_section_data # noqa F401 +from molecule.test.unit.conftest import _molecule_scenario_section_data # noqa F401 +from molecule.test.unit.conftest import _molecule_verifier_section_data # noqa F401 +from molecule.test.unit.conftest import config_instance # noqa F401 +from molecule.test.unit.conftest import molecule_data # noqa F401 +from molecule.test.unit.conftest import molecule_directory_fixture # noqa F401 +from molecule.test.unit.conftest import ( # noqa F401 + molecule_ephemeral_directory_fixture, +) +from molecule.test.unit.conftest import molecule_file_fixture # noqa F401 +from molecule.test.unit.conftest import molecule_scenario_directory_fixture # noqa F401 +from molecule.test.unit.conftest import patched_ansible_converge # noqa F401 +from molecule.test.unit.conftest import patched_config_validate # noqa F401 +from molecule.test.unit.conftest import patched_logger_error # noqa F401 +from molecule.test.unit.conftest import patched_logger_info # noqa F401 +from molecule.test.unit.conftest import patched_logger_success # noqa F401 +from molecule.test.unit.conftest import patched_logger_warn # noqa F401 diff --git a/molecule_goss/test/functional/__init__.py b/molecule_goss/test/functional/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/molecule_goss/test/functional/test_goss.py b/molecule_goss/test/functional/test_goss.py new file mode 100644 index 0000000..22bd257 --- /dev/null +++ b/molecule_goss/test/functional/test_goss.py @@ -0,0 +1,37 @@ +import os +import sh +import pytest +from conftest import change_dir_to + + +def test_command_init_role_goss(temp_dir): + role_directory = os.path.join(temp_dir.strpath, 'test-init') + options = {'role_name': 'test-init', 'verifier_name': 'goss'} + cmd = sh.molecule.bake('init', 'role', **options) + pytest.helpers.run_command(cmd) + pytest.helpers.metadata_lint_update(role_directory) + + with change_dir_to(role_directory): + cmd = sh.molecule.bake('test') + pytest.helpers.run_command(cmd) + + +def test_command_init_scenario_goss(temp_dir): + role_directory = os.path.join(temp_dir.strpath, 'test-init') + options = {'role_name': 'test-init'} + cmd = sh.molecule.bake('init', 'role', **options) + pytest.helpers.run_command(cmd) + pytest.helpers.metadata_lint_update(role_directory) + + with change_dir_to(role_directory): + molecule_directory = pytest.helpers.molecule_directory() + scenario_directory = os.path.join(molecule_directory, 'test-scenario') + options = { + 'scenario_name': 'test-scenario', + 'role_name': 'test-init', + 'verifier_name': 'goss', + } + cmd = sh.molecule.bake('init', 'scenario', **options) + pytest.helpers.run_command(cmd) + + assert os.path.isdir(scenario_directory) diff --git a/molecule_goss/test/unit/__init__.py b/molecule_goss/test/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/molecule_goss/test/unit/test_goss_unit.py b/molecule_goss/test/unit/test_goss_unit.py new file mode 100644 index 0000000..d992947 --- /dev/null +++ b/molecule_goss/test/unit/test_goss_unit.py @@ -0,0 +1,167 @@ +# Copyright (c) 2015-2018 Cisco Systems, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to +# deal in the Software without restriction, including without limitation the +# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +# sell copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +# DEALINGS IN THE SOFTWARE. + +import os + +import pytest + +from molecule import config +from molecule_goss import goss +from molecule.verifier.lint import yamllint + + +@pytest.fixture +def _patched_ansible_verify(mocker): + m = mocker.patch('molecule.provisioner.ansible.Ansible.verify') + m.return_value = 'patched-ansible-verify-stdout' + + return m + + +@pytest.fixture +def _patched_goss_get_tests(mocker): + m = mocker.patch('molecule_goss.goss.Goss._get_tests') + m.return_value = ['foo.py', 'bar.py'] + + return m + + +@pytest.fixture +def _verifier_section_data(): + return { + 'verifier': { + 'name': 'goss', + 'env': {'FOO': 'bar'}, + 'lint': {'name': 'yamllint'}, + } + } + + +# NOTE(retr0h): The use of the `patched_config_validate` fixture, disables +# config.Config._validate from executing. Thus preventing odd side-effects +# throughout patched.assert_called unit tests. +@pytest.fixture +def _instance(_verifier_section_data, patched_config_validate, config_instance): + return goss.Goss(config_instance) + + +def test_config_private_member(_instance): + assert isinstance(_instance._config, config.Config) + + +def test_default_options_property(_instance): + assert {} == _instance.default_options + + +def test_default_env_property(_instance): + assert 'MOLECULE_FILE' in _instance.default_env + assert 'MOLECULE_INVENTORY_FILE' in _instance.default_env + assert 'MOLECULE_SCENARIO_DIRECTORY' in _instance.default_env + assert 'MOLECULE_INSTANCE_CONFIG' in _instance.default_env + + +@pytest.mark.parametrize('config_instance', ['_verifier_section_data'], indirect=True) +def test_env_property(_instance): + assert 'bar' == _instance.env['FOO'] + + +@pytest.mark.parametrize('config_instance', ['_verifier_section_data'], indirect=True) +def test_lint_property(_instance): + assert isinstance(_instance.lint, yamllint.Yamllint) + + +def test_name_property(_instance): + assert 'goss' == _instance.name + + +def test_enabled_property(_instance): + assert _instance.enabled + + +def test_directory_property(_instance): + parts = _instance.directory.split(os.path.sep) + + assert 'tests' == parts[-1] + + +@pytest.mark.parametrize('config_instance', ['_verifier_section_data'], indirect=True) +def test_options_property(_instance): + x = {} + + assert x == _instance.options + + +@pytest.mark.parametrize('config_instance', ['_verifier_section_data'], indirect=True) +def test_options_property_handles_cli_args(_instance): + _instance._config.args = {'debug': True} + x = {} + + # Does nothing. The `goss` command does not support + # a `debug` flag. + assert x == _instance.options + + +def test_bake(_instance): + assert _instance.bake() is None + + +def test_execute( + patched_logger_info, + _patched_ansible_verify, + _patched_goss_get_tests, + patched_logger_success, + _instance, +): + _instance.execute() + + _patched_ansible_verify.assert_called_once_with() + + msg = 'Executing Goss tests found in {}/...'.format(_instance.directory) + patched_logger_info.assert_called_once_with(msg) + + msg = 'Verifier completed successfully.' + patched_logger_success.assert_called_once_with(msg) + + +def test_execute_does_not_execute( + patched_ansible_converge, patched_logger_warn, _instance +): + _instance._config.config['verifier']['enabled'] = False + _instance.execute() + + assert not patched_ansible_converge.called + + msg = 'Skipping, verifier is disabled.' + patched_logger_warn.assert_called_once_with(msg) + + +def test_does_not_execute_without_tests( + patched_ansible_converge, patched_logger_warn, _instance +): + _instance.execute() + + assert not patched_ansible_converge.called + + msg = 'Skipping, no tests found.' + patched_logger_warn.assert_called_once_with(msg) + + +def test_execute_bakes(): + pass 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..41ad0bc --- /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 +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..48e2998 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,92 @@ +[aliases] +dists = clean --all sdist bdist_wheel + +[bdist_wheel] +universal = 1 + +[metadata] +name = molecule-goss +url = https://github.com/pycontribs/molecule-goss +project_urls = + Bug Tracker = https://github.com/pycontribs/molecule-goss/issues + Release Management = https://github.com/pycontribs/molecule-goss/projects + CI: Travis = https://travis-ci.com/pycontribs/molecule-goss + Source Code = https://github.com/pycontribs/molecule-goss +description = Goss Molecule Plugin :: run molecule tests with Goss as verifier +long_description = file: README.rst +long_description_content_type = text/x-rst +author = Sorin Sbarnea +author_email = sorin.sbarnea@gmail.com +maintainer = Sorin Sbarnea +maintainer_email = sorin.sbarnea@gmail.com +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 + goss + 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 + +# These are required during `setup.py` run: +setup_requires = + setuptools_scm >= 1.15.0 + setuptools_scm_git_archive >= 1.0 + +# These are required in actual runtime: +install_requires = + molecule >= 3.0a1 + pyyaml >= 5.1, < 6 + +[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 + docker + +[options.entry_points] +molecule.verifier = + goss = molecule_goss.goss:Goss + +[options.packages.find] +where = . diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bd97614 --- /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..6ada5dc --- /dev/null +++ b/tox.ini @@ -0,0 +1,116 @@ +[tox] +minversion = 3.9.0 +envlist = + lint + check + py{27,35,36,37}-ansible{26,27,28} + doc + devel +skipdist = True +skip_missing_interpreters = True +isolated_build = True + +[testenv] +# Hotfix for https://github.com/pypa/pip/issues/6434 +# Based on https://github.com/jaraco/skeleton/commit/123b0b2 +# Check https://github.com/tox-dev/tox/issues/1276 for the final solution +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 = + ansible26: ansible>=2.6,<2.7 + ansible27: ansible>=2.7,<2.8 + ansible28: ansible>=2.8,<2.9 + devel: ansible>=2.8 + 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 + +# generic environment that should cover anything that is not a linter or a unit/functional test +[testenv:check] +# reuse existing envdir to avoid increased footprint +envdir = {toxworkdir}/py36-ansible28 +deps = + {[testenv]deps} + collective.checkdocs==0.2 + twine==1.14.0 +usedevelop = False +# Ref: https://twitter.com/di_codes/status/1044358639081975813 +commands = + # verifies that pytest collection works without specific folders mentioned + python -m pytest --collect-only + # metadata validation + python -m setup checkdocs check --metadata --restructuredtext --strict --verbose + python -m twine check .tox/dist/* + +[testenv:build-docker] +# skip under Windows +platform = ^darwin|^linux +# `usedevelop = True` overrided `skip_install` instruction, it's unwanted +usedevelop = False +# don't install Molecule in this env +skip_install = True +# don't install any Python dist deps +deps = + setuptools_scm==3.3.3 + packaging # pyup: ignore +# reset pre-commands +commands_pre = +# build the docker container +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 = true` overrides `skip_install` instruction, it's unwanted +usedevelop = false +# don't install molecule itself in this env +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}