diff --git a/.ansible-lint b/.ansible-lint new file mode 100644 index 00000000..cab3e2fa --- /dev/null +++ b/.ansible-lint @@ -0,0 +1,12 @@ +# .ansible-lint +exclude_paths: + - .cache/ # implicit unless exclude_paths is defined in config + - .github/ + - packages/**/cookiecutter/{{* + # - src/molecule_gce/cookiecutter/{{cookiecutter.molecule_directory}} + +skip_list: + # Temporary skips made during migration + - fqcn-builtins + - yaml[line-length] + - var-spacing diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..cd137568 --- /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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..6cfcf250 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +# https://docs.github.com/en/code-security/supply-chain-security/keeping-your-dependencies-updated-automatically/enabling-and-disabling-dependabot-version-updates +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: weekly + open-pull-requests-limit: 3 + labels: + - "dependencies" + - "skip-changelog" diff --git a/.github/release-drafter.yml b/.github/release-drafter.yml new file mode 100644 index 00000000..114b5fc8 --- /dev/null +++ b/.github/release-drafter.yml @@ -0,0 +1,2 @@ +# see https://github.com/ansible-community/devtools +_extends: ansible-community/devtools diff --git a/.github/workflows/ack.yml b/.github/workflows/ack.yml new file mode 100644 index 00000000..5880addd --- /dev/null +++ b/.github/workflows/ack.yml @@ -0,0 +1,9 @@ +# See https://github.com/ansible-community/devtools/blob/main/.github/workflows/ack.yml +name: ack +on: + pull_request_target: + types: [opened, labeled, unlabeled, synchronize] + +jobs: + ack: + uses: ansible-community/devtools/.github/workflows/ack.yml@main diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 00000000..e8239f70 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,12 @@ +# See https://github.com/ansible-community/devtools/blob/main/.github/workflows/push.yml +name: push +on: + push: + branches: + - main + - 'releases/**' + - 'stable/**' + +jobs: + ack: + uses: ansible-community/devtools/.github/workflows/push.yml@main diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..d63d5b60 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,48 @@ +name: release + +on: + release: + types: [published] + +jobs: + pypi: + name: Publish to PyPI registry + environment: release + runs-on: ubuntu-20.04 + + env: + FORCE_COLOR: 1 + PY_COLORS: 1 + TOXENV: packaging + TOX_PARALLEL_NO_SPINNER: 1 + + steps: + - name: Switch to using Python 3.8 by default + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install tox + run: >- + python3 -m + pip install + --user + tox + - name: Check out src from Git + uses: actions/checkout@v2 + with: + fetch-depth: 0 # needed by setuptools-scm + - name: Build dists + run: python -m tox + - name: Publish to test.pypi.org + if: >- # "create" workflows run separately from "push" & "pull_request" + github.event_name == 'release' + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.testpypi_password }} + repository_url: https://test.pypi.org/legacy/ + - name: Publish to pypi.org + if: >- # "create" workflows run separately from "push" & "pull_request" + github.event_name == 'release' + uses: pypa/gh-action-pypi-publish@master + with: + password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml new file mode 100644 index 00000000..bd0cf5cd --- /dev/null +++ b/.github/workflows/tox.yml @@ -0,0 +1,60 @@ +name: tox + +on: + push: # only publishes pushes to the main branch to TestPyPI + branches: # any integration branch but not tag + - "main" + pull_request: + +jobs: + build: + name: ${{ matrix.tox_env }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + include: + - tox_env: lint + # - tox_env: docs + - tox_env: py38 + PREFIX: PYTEST_REQPASS=3 + - tox_env: py39 + PREFIX: PYTEST_REQPASS=3 + - tox_env: py310 + PREFIX: PYTEST_REQPASS=3 + - tox_env: py310-devel + PREFIX: PYTEST_REQPASS=3 + - tox_env: packaging + + steps: + - uses: actions/checkout@v1 + - name: Install system dependencies + run: | + sudo apt-get update \ + && sudo apt-get install -y ansible \ + && ansible-doc -l | grep gce + - name: Find python version + id: py_ver + shell: python + if: ${{ contains(matrix.tox_env, 'py') }} + run: | + v = '${{ matrix.tox_env }}'.split('-')[0].lstrip('py') + print('::set-output name=version::{0}.{1}'.format(v[0],v[1:])) + # Even our lint and other envs need access to tox + - name: Install a default Python + uses: actions/setup-python@v2 + if: ${{ ! contains(matrix.tox_env, 'py') }} + # Be sure to install the version of python needed by a specific test, if necessary + - name: Set up Python version + uses: actions/setup-python@v2 + if: ${{ contains(matrix.tox_env, 'py') }} + with: + python-version: ${{ steps.py_ver.outputs.version }} + - name: Install dependencies + run: | + python -m pip install -U pip + pip install tox + - name: Run tox -e ${{ matrix.tox_env }} + run: | + echo "${{ matrix.PREFIX }} tox -e ${{ matrix.tox_env }}" + ${{ matrix.PREFIX }} tox -e ${{ matrix.tox_env }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..db21cab1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,38 @@ +--- +ci: + skip: + # https://github.com/pre-commit-ci/issues/issues/55 + - ansible-lint +default_language_version: + python: python3 +minimum_pre_commit_version: "1.14.0" +repos: + - repo: https://github.com/PyCQA/doc8.git + rev: 0.11.2 + hooks: + - id: doc8 + - repo: https://github.com/psf/black + rev: 22.6.0 + hooks: + - id: black + language_version: python3 + - repo: https://github.com/pre-commit/pre-commit-hooks.git + rev: v4.3.0 + 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://github.com/PyCQA/flake8 + rev: 4.0.1 + hooks: + - id: flake8 + additional_dependencies: + - flake8-black + - repo: https://github.com/ansible/ansible-lint.git + rev: v6.3.0 + hooks: + - id: ansible-lint diff --git a/README.md b/README.md index 1a37abbe..abc20ea7 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,12 @@ -# molecule-cloud -Collection on molecule plugins using clouds for provisioning +# molecule-plugins + +This monorepo contains the following molecule plugins: + +- azure +- gce + +Each plugin is an python package that is uploaded to pypi on release and you +can install it directly. + +Usually, there is no need to install `molecule-plugins` as this can be seen +as a meta-package, one with no content. diff --git a/build b/build new file mode 100644 index 00000000..d00d14ee --- /dev/null +++ b/build @@ -0,0 +1,8 @@ +#!/bin/bash +set -exuo pipefail + +# for DIR in packages/*; do +# echo $DIR +python3 -m build packages/* + #$DIR +# done diff --git a/build.sh b/build.sh new file mode 100755 index 00000000..f732dbec --- /dev/null +++ b/build.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -exuo pipefail + +mkdir -p dist +for DIR in packages/*; do + python3 -m build --outdir dist/ $DIR +done diff --git a/conftest.py b/conftest.py new file mode 100644 index 00000000..15531c86 --- /dev/null +++ b/conftest.py @@ -0,0 +1,85 @@ +import contextlib +import os +import random +import string + +import pytest +from molecule import config, logger, util +from molecule.scenario import ephemeral_directory + +LOG = logger.get_logger(__name__) + + +@pytest.helpers.register +def run_command(cmd, env=os.environ, log=True): + if cmd.__class__.__name__ == "Command": + if log: + cmd = _rebake_command(cmd, env) + cmd = cmd.bake(_truncate_exc=False) + return util.run_command(cmd, env=env) + + +def _rebake_command(cmd, env, out=LOG.info, err=LOG.error): + return cmd.bake(_env=env, _out=out, _err=err) + + +@pytest.fixture +def random_string(length=5): + return "".join((random.choice(string.ascii_uppercase) for _ in range(length))) + + +@contextlib.contextmanager +def change_dir_to(dir_name): + cwd = os.getcwd() + os.chdir(dir_name) + yield + os.chdir(cwd) + + +@pytest.fixture +def temp_dir(tmpdir, random_string, request): + directory = tmpdir.mkdir(random_string) + + with change_dir_to(directory.strpath): + yield directory + + +@pytest.fixture +def resources_folder_path(): + resources_folder_path = os.path.join(os.path.dirname(__file__), "resources") + return resources_folder_path + + +@pytest.helpers.register +def molecule_project_directory(): + return os.getcwd() + + +@pytest.helpers.register +def molecule_directory(): + return config.molecule_directory(molecule_project_directory()) + + +@pytest.helpers.register +def molecule_scenario_directory(): + return os.path.join(molecule_directory(), "default") + + +@pytest.helpers.register +def molecule_file(): + return get_molecule_file(molecule_scenario_directory()) + + +@pytest.helpers.register +def get_molecule_file(path): + return config.molecule_file(path) + + +@pytest.helpers.register +def molecule_ephemeral_directory(_fixture_uuid): + project_directory = "test-project-{}".format(_fixture_uuid) + scenario_name = "test-instance" + + return ephemeral_directory( + os.path.join("molecule_test", project_directory, scenario_name) + ) diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000..f8b1844b --- /dev/null +++ b/mypy.ini @@ -0,0 +1 @@ +[mypy] diff --git a/packages/molecule-azure/LICENSE b/packages/molecule-azure/LICENSE new file mode 100644 index 00000000..1a6861d2 --- /dev/null +++ b/packages/molecule-azure/LICENSE @@ -0,0 +1,22 @@ + +The MIT License (MIT) + +Copyright (c) 2019 Sorin Sbarnea + +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/packages/molecule-azure/MANIFEST.in b/packages/molecule-azure/MANIFEST.in new file mode 100644 index 00000000..1ade348b --- /dev/null +++ b/packages/molecule-azure/MANIFEST.in @@ -0,0 +1,5 @@ +include LICENSE +include README.rst + +recursive-exclude * __pycache__ +recursive-exclude * *.py[co] diff --git a/packages/molecule-azure/README.rst b/packages/molecule-azure/README.rst new file mode 100644 index 00000000..e42b9634 --- /dev/null +++ b/packages/molecule-azure/README.rst @@ -0,0 +1,75 @@ +********************* +Molecule Azure Plugin +********************* + +.. image:: https://badge.fury.io/py/molecule-azure.svg + :target: https://badge.fury.io/py/molecule-azure + :alt: PyPI Package + +.. image:: https://github.com/ansible-community/molecule-azure/workflows/tox/badge.svg + :target: https://github.com/ansible-community/molecule-azure/actions + +.. 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-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-silver.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 Azure is designed to allow use of Azure Cloud for provisioning test +resources. + +Documentation +============= + +Read the documentation and more at `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`_. + +.. _`molecule.readthedocs.io`: https://molecule.readthedocs.io/ +.. _`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 Azure Plugin was created by Sorin Sbarnea based on code from Molecule. + +.. _license: + +License +======= + +The `MIT`_ License. + +.. _`MIT`: https://github.com/ansible-community/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/packages/molecule-azure/molecule_azure/__init__.py b/packages/molecule-azure/molecule_azure/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/molecule-azure/molecule_azure/cookiecutter/cookiecutter.json b/packages/molecule-azure/molecule_azure/cookiecutter/cookiecutter.json new file mode 100644 index 00000000..2ec6fb29 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/cookiecutter/cookiecutter.json @@ -0,0 +1,5 @@ +{ + "molecule_directory": "molecule", + "role_name": "OVERRIDDEN", + "scenario_name": "OVERRIDDEN" +} diff --git a/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst b/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst new file mode 100644 index 00000000..2931b50c --- /dev/null +++ b/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/INSTALL.rst @@ -0,0 +1,22 @@ +******************************* +Azure driver installation guide +******************************* + +Requirements +============ + +* An Azure credentials rc file + +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[azure]' diff --git a/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml b/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml new file mode 100644 index 00000000..72600af4 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml @@ -0,0 +1,8 @@ +--- +- name: Converge + hosts: all + tasks: + + - name: Include tested role + include_role: + name: "{{ cookiecutter.role_name }}" diff --git a/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml b/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml new file mode 100644 index 00000000..324aa6d4 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/create.yml @@ -0,0 +1,104 @@ +--- +{% raw -%} +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + resource_group_name: molecule + location: "{{ lookup('env', 'AZURE_REGION') or 'westus' }}" + ssh_user: molecule + ssh_port: 22 + virtual_network_name: molecule_vnet + subnet_name: molecule_subnet + keypair_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + tasks: + - name: Create resource group + azure_rm_resourcegroup: + name: "{{ resource_group_name }}" + location: "{{ location }}" + + - name: Create virtual network + azure_rm_virtualnetwork: + resource_group: "{{ resource_group_name }}" + name: "{{ virtual_network_name }}" + address_prefixes: "10.10.0.0/16" + + - name: Create subnet + azure_rm_subnet: + resource_group: "{{ resource_group_name }}" + name: "{{ subnet_name }}" + address_prefix_cidr: 10.10.1.0/24 + virtual_network_name: "{{ virtual_network_name }}" + + - name: Create key pair + user: + name: "{{ lookup('env', 'USER') }}" + generate_ssh_key: true + ssh_key_file: "{{ keypair_path }}" + register: key_pair + + - name: Create molecule instance(s) + azure_rm_virtualmachine: + resource_group: "{{ resource_group_name }}" + name: "{{ item.name }}" + vm_size: Standard_A0 + admin_username: "{{ ssh_user }}" + public_ip_allocation_method: Dynamic + ssh_password_enabled: false + ssh_public_keys: + - path: "/home/{{ ssh_user }}/.ssh/authorized_keys" + key_data: "{{ key_pair.ssh_public_key }}" + image: + offer: CentOS + publisher: OpenLogic + sku: '7.4' + version: latest + 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: azure_jobs + until: azure_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.ansible_facts.azure_vm.name }}", + 'address': "{{ item.ansible_facts.azure_vm.properties.networkProfile.networkInterfaces[0].properties.ipConfigurations[0].properties.publicIPAddress.properties.ipAddress }}", + 'user': "{{ ssh_user }}", + 'port': "{{ ssh_port }}", + 'identity_file': "{{ keypair_path }}", } + with_items: "{{ azure_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/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml b/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml new file mode 100644 index 00000000..59a3f97e --- /dev/null +++ b/packages/molecule-azure/molecule_azure/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/destroy.yml @@ -0,0 +1,31 @@ +--- +{% raw -%} +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + resource_group_name: molecule + virtual_network_name: molecule_vnet + subnet_name: molecule_subnet + tasks: + - name: Destroy resource group and all associated resources + azure_rm_resourcegroup: + name: "{{ resource_group_name }}" + state: absent + force_delete_nonempty: true + register: rg + + # Mandatory configuration for Molecule to function. + + - name: Populate instance config + set_fact: + instance_conf: {} + + - name: Dump instance config + copy: + content: "{{ instance_conf | to_json | from_json | molecule_to_yaml | molecule_header }}" + dest: "{{ molecule_instance_config }}" + when: rg.changed | bool +{%- endraw %} diff --git a/packages/molecule-azure/molecule_azure/driver.py b/packages/molecule-azure/molecule_azure/driver.py new file mode 100644 index 00000000..01015f59 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/driver.py @@ -0,0 +1,148 @@ +# 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.api import Driver + +from molecule import util + +LOG = logger.get_logger(__name__) + + +class Azure(Driver): + """ + The class responsible for managing `Azure`_ instances. `Azure`_ + is ``not`` the default driver used in Molecule. + + Molecule leverages Ansible's `azure_module`_, by mapping variables + from ``molecule.yml`` into ``create.yml`` and ``destroy.yml``. + + .. _`azure_module`: https://docs.ansible.com/ansible/latest/guide_azure.html + + .. code-block:: yaml + + driver: + name: azure + platforms: + - name: instance + + .. code-block:: bash + + $ pip install 'molecule-azure' + + Change the options passed to the ssh client. + + .. code-block:: yaml + + driver: + name: azure + 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 a list of files Molecule will preserve, relative to the scenario + ephemeral directory, after any ``destroy`` subcommand execution. + + .. code-block:: yaml + + driver: + name: azure + safe_files: + - foo + + .. _`Azure`: https://azure.microsoft.com + """ # noqa + + def __init__(self, config=None): + super(Azure, self).__init__(config) + self._name = "azure" + + @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 + ) + + def sanity_checks(self): + # FIXME(decentral1se): Implement sanity checks + pass + + def template_dir(self): + """Return path to its own cookiecutterm templates. It is used by init + command in order to figure out where to load the templates from. + """ + return os.path.join(os.path.dirname(__file__), "cookiecutter") diff --git a/packages/molecule-azure/molecule_azure/test/__init__.py b/packages/molecule-azure/molecule_azure/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/molecule-azure/molecule_azure/test/functional/__init__.py b/packages/molecule-azure/molecule_azure/test/functional/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/molecule-azure/molecule_azure/test/functional/conftest.py b/packages/molecule-azure/molecule_azure/test/functional/conftest.py new file mode 100644 index 00000000..77071457 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/functional/conftest.py @@ -0,0 +1,23 @@ +# Copyright (c) 2015-2018 Cisco Systems, Inc. +# Copyright (c) 2018 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.test.conftest import * # noqa diff --git a/packages/molecule-azure/molecule_azure/test/functional/test_azure.py b/packages/molecule-azure/molecule_azure/test/functional/test_azure.py new file mode 100644 index 00000000..14d75a59 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/functional/test_azure.py @@ -0,0 +1,62 @@ +# Copyright (c) 2015-2018 Cisco Systems, Inc. +# Copyright (c) 2018 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 pytest +import os + +from molecule import logger +from molecule.util import run_command +from molecule.test.conftest import change_dir_to + +# import change_dir_to, temp_dir + +LOG = logger.get_logger(__name__) + + +def test_command_init_scenario(temp_dir): + role_directory = os.path.join(temp_dir.strpath, "test_init") + cmd = ["molecule", "init", "role", "foo.test_init"] + result = run_command(cmd) + assert result.returncode == 0 + + with change_dir_to(role_directory): + molecule_directory = pytest.helpers.molecule_directory() + scenario_directory = os.path.join(molecule_directory, "test_scenario") + cmd = [ + "molecule", + "init", + "scenario", + "test_scenario", + "--role-name", + "test_init", + "--driver-name", + "azure", + ] + result = run_command(cmd) + assert result.returncode == 0 + + assert os.path.isdir(scenario_directory) + + # temporary trick to pass on CI/CD + if "AZURE_SECRET" in os.environ: + cmd = ["molecule", "test", "-s", "test-scenario"] + result = run_command(cmd) + assert result.returncode == 0 diff --git a/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/default/converge.yml b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/default/converge.yml new file mode 100644 index 00000000..b5fd64d3 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/default/converge.yml @@ -0,0 +1,5 @@ +--- +- name: Converge + hosts: all + gather_facts: false + become: true diff --git a/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/default/molecule.yml b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/default/molecule.yml new file mode 100644 index 00000000..80fd70b0 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/default/molecule.yml @@ -0,0 +1,19 @@ +--- +dependency: + name: galaxy +driver: + name: azure +platforms: + - name: instance +provisioner: + name: ansible + config_options: + defaults: + callback_whitelist: profile_roles,profile_tasks,timer + playbooks: + create: ../../../../../resources/playbooks/azure/create.yml + destroy: ../../../../../resources/playbooks/azure/destroy.yml + env: + ANSIBLE_ROLES_PATH: ../../../../../resources/roles/ +verifier: + name: testinfra diff --git a/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/default/tests/test_default.py b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/default/tests/test_default.py new file mode 100644 index 00000000..01048f69 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/default/tests/test_default.py @@ -0,0 +1,29 @@ +import os + +import testinfra.utils.ansible_runner + +testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( + os.environ["MOLECULE_INVENTORY_FILE"] +).get_hosts("all") + + +def test_hostname(host): + assert "instance" == host.check_output("hostname -s") + + +def test_etc_molecule_directory(host): + f = host.file("/etc/molecule") + + assert f.is_directory + assert f.user == "root" + assert f.group == "root" + assert f.mode == 0o755 + + +def test_etc_molecule_ansible_hostname_file(host): + f = host.file("/etc/molecule/instance") + + assert f.is_file + assert f.user == "root" + assert f.group == "root" + assert f.mode == 0o644 diff --git a/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/converge.yml b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/converge.yml new file mode 100644 index 00000000..01875fe7 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/converge.yml @@ -0,0 +1,20 @@ +--- +- name: Converge + hosts: all + gather_facts: false + become: true + +- name: Converge + hosts: bar + gather_facts: false + become: true + +- name: Converge + hosts: foo + gather_facts: false + become: true + +- name: Converge + hosts: baz + gather_facts: false + become: true diff --git a/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/molecule.yml b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/molecule.yml new file mode 100644 index 00000000..2a2f92d5 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/molecule.yml @@ -0,0 +1,36 @@ +--- +dependency: + name: galaxy +driver: + name: azure +platforms: + - name: instance-1 + groups: + - foo + - bar + - name: instance-2 + groups: + - foo + - baz +provisioner: + name: ansible + config_options: + defaults: + callback_whitelist: profile_roles,profile_tasks,timer + playbooks: + create: ../../../../../resources/playbooks/azure/create.yml + destroy: ../../../../../resources/playbooks/azure/destroy.yml + inventory: + group_vars: + all: + resource_group_name: molecule + location: "{{ lookup('env', 'AZURE_REGION') or 'westus' }}" + ssh_user: molecule + ssh_port: 22 + virtual_network_name: molecule_vnet + subnet_name: molecule_subnet + keypair_path: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + env: + ANSIBLE_ROLES_PATH: ../../../../../resources/roles/ +verifier: + name: testinfra diff --git a/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/prepare.yml b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/prepare.yml new file mode 100644 index 00000000..ddb01fbf --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/prepare.yml @@ -0,0 +1,9 @@ +--- +- 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 diff --git a/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/tests/__init__.py b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/tests/test_default.py b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/tests/test_default.py new file mode 100644 index 00000000..57198a98 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/scenarios/driver/azure/molecule/multi-node/tests/test_default.py @@ -0,0 +1,31 @@ +import os +import re + +import testinfra.utils.ansible_runner + +testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner( + os.environ["MOLECULE_INVENTORY_FILE"] +).get_hosts("all") + + +def test_hostname(host): + assert re.search(r"instance-[12].*", host.check_output("hostname -s")) + + +def test_etc_molecule_directory(host): + f = host.file("/etc/molecule") + + assert f.is_directory + assert f.user == "root" + assert f.group == "root" + assert f.mode == 0o755 + + +def test_etc_molecule_ansible_hostname_file(host): + filename = "/etc/molecule/{}".format(host.check_output("hostname -s")) + f = host.file(filename) + + assert f.is_file + assert f.user == "root" + assert f.group == "root" + assert f.mode == 0o644 diff --git a/packages/molecule-azure/molecule_azure/test/test_driver.py b/packages/molecule-azure/molecule_azure/test/test_driver.py new file mode 100644 index 00000000..c6bb2124 --- /dev/null +++ b/packages/molecule-azure/molecule_azure/test/test_driver.py @@ -0,0 +1,5 @@ +from molecule import api + + +def test_driver_is_detected(): + assert "azure" in [str(d) for d in api.drivers()] diff --git a/packages/molecule-azure/pyproject.toml b/packages/molecule-azure/pyproject.toml new file mode 100644 index 00000000..490c397a --- /dev/null +++ b/packages/molecule-azure/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = [ + "setuptools >= 41.0.0", + "setuptools_scm >= 1.15.0", + "setuptools_scm_git_archive >= 1.0", + "wheel", +] +build-backend = "setuptools.build_meta" diff --git a/packages/molecule-azure/setup.cfg b/packages/molecule-azure/setup.cfg new file mode 100644 index 00000000..dcbd2552 --- /dev/null +++ b/packages/molecule-azure/setup.cfg @@ -0,0 +1,78 @@ +[aliases] +dists = clean --all sdist bdist_wheel + +[metadata] +name = molecule-azure +url = https://github.com/ansible-community/molecule-azure +project_urls = + Bug Tracker = https://github.com/ansible-community/molecule-azure/issues + Release Management = https://github.com/ansible-community/molecule-azure/releases + CI = https://github.com/ansible-community/molecule-azure/actions + + Documentation = https://molecule.readthedocs.io + Mailing lists = https://docs.ansible.com/ansible/latest/community/communication.html#mailing-list-information + Source Code = https://github.com/ansible-community/molecule-azure +description = Azure Molecule Plugin :: run molecule tests on Azure +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 + Framework :: Pytest + 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 :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + + Topic :: System :: Systems Administration + Topic :: Utilities + +keywords = + ansible + roles + testing + molecule + plugin + azure + +[options] +use_scm_version = True +python_requires = >=3.6 +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 plugins are not allowed to mention Ansible as a direct dependency + molecule >= 3.2.0a0 + +[options.extras_require] +test = + molecule[ansible,test] + +[options.entry_points] +molecule.driver = + azure = molecule_azure.driver:Azure + +[options.packages.find] +where = . diff --git a/packages/molecule-gce/LICENSE b/packages/molecule-gce/LICENSE new file mode 100644 index 00000000..69965836 --- /dev/null +++ b/packages/molecule-gce/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/packages/molecule-gce/README.md b/packages/molecule-gce/README.md new file mode 100644 index 00000000..13ef57b5 --- /dev/null +++ b/packages/molecule-gce/README.md @@ -0,0 +1,96 @@ +# Molecule GCE Plugin +[![PyPI Package][]][1] [![image][]][2] [![Python Black Code Style][]][3] [![Ansible Code of Conduct][]][4] [![Ansible mailing lists][]][5] [![Repository License -->][]][6] + + [PyPI Package]: https://badge.fury.io/py/molecule-gce.svg + [1]: https://badge.fury.io/py/molecule-gce + [image]: https://zuul-ci.org/gated.svg + [2]: https://dashboard.zuul.ansible.com/t/ansible/builds?project=ansible-community/molecule-gce + [Python Black Code Style]: https://img.shields.io/badge/code%20style-black-000000.svg + [3]: https://github.com/python/black + [Ansible Code of Conduct]: https://img.shields.io/badge/Code%20of%20Conduct-Ansible-silver.svg + [4]: https://docs.ansible.com/ansible/latest/community/code_of_conduct.html + [Ansible mailing lists]: https://img.shields.io/badge/Mailing%20lists-Ansible-orange.svg + [5]: https://docs.ansible.com/ansible/latest/community/communication.html#mailing-list-information + [Repository License -->]: https://img.shields.io/badge/license-MIT-brightgreen.svg + [6]: LICENSE + +Molecule GCE is designed to allow use Google Cloud Engine for +provisioning test resources. + +Please note that this driver is currently in its early stage of development. + +This plugin requires google.cloud and community.crypto collections to be present: +``` +ansible-galaxy collection install google.cloud +ansible-galaxy collection install community.crypto +``` + +# Installation and Usage + +Install molecule-gce : +``` +pip install molecule-gce +``` + +Create a new role with molecule using the GCE driver: +``` +molecule init role -d gce +``` + +Configure `/molecule/default/molecule.yml` with required parameters: + +```yaml +dependency: + name: galaxy +driver: + name: gce + project_id: my-google-cloud-platform-project-id # if not set, will default to env GCE_PROJECT_ID + region: us-central1 # REQUIRED + network_name: my-vpc # specify if other than default + subnetwork_name: my-subnet # specify if other than default + vpc_host_project: null # if you use a shared vpc, set here the vpc host project. In that case, your GCP user needs the necessary permissions in the host project, see https://cloud.google.com/vpc/docs/shared-vpc#iam_in_shared_vpc + auth_kind: serviceaccount # set to machineaccount or serviceaccount or application - if set to null will read env GCP_AUTH_KIND + service_account_email: null # set to an email associated with the project - if set to null, will default to GCP_SERVICE_ACCOUNT_EMAIL. Should not be set if using auth_kind serviceaccount. + service_account_file: /path/to/gce-sa.json # set to the path to the JSON credentials file - if set to null, will default to env GCP_SERVICE_ACCOUNT_FILE + scopes: + - "https://www.googleapis.com/auth/compute" # will default to env GCP_SCOPES, https://www.googleapis.com/auth/compute is the minimum required scope. + external_access: false # chose whether to create a public IP for the VM or not - default is private IP only + instance_os_type: linux # Either windows or linux. Will be considered linux by default. You can NOT mix Windows and Linux VMs in the same scenario. +platforms: + - name: ubuntu-instance-created-by-molecule # REQUIRED: this will be your VM name + zone: us-central1-a # Example: us-west1-b. Will default to zone b of region defined in driver (some regions do not have a zone-a) + machine_type: n1-standard-1 # If not specified, will default to n1-standard-1 + preemptible: false # If not specified, will default to false. Preemptible instances have no SLA, in case of resource shortage in the zone they might get destroyed (or not be created) without warning, and will always be terminated after 24 hours. But they cost less and will mitigate the financial consequences of a PAYG licenced VM that would be forgotten. + image: 'projects/ubuntu-os-cloud/global/images/family/ubuntu-1604-lts' # Points to an image, you can get a list of available images with command 'gcloud compute images list'. + # The expected format of this string is projects//global/images/family/ + # (see https://googlecloudplatform.github.io/compute-image-tools/daisy-automating-image-creation.html) + # Wille default to debian-10 image for os_type Linux, Windows 2019 for os_type Windows + - name: debian-instance-created-by-molecule + zone: us-central1-a + machine_type: n1-standard-2 + image: 'projects/debian-cloud/global/images/family/debian-10' + - name: n1-standard1-debian10-in-region-b + + +provisioner: + name: ansible +verifier: + name: ansible +``` + +# Get Involved + +* Join us in the ``#ansible-molecule`` channel on [Freenode](https://freenode.net). +* Join the discussion in [molecule-users Forum](https://groups.google.com/forum/#!forum/molecule-users). +* Join the community working group by checking the [wiki](https://github.com/ansible/community/wiki/Molecule). +* Want to know about releases, subscribe to [ansible-announce list](https://groups.google.com/group/ansible-announce). +* For the full list of Ansible email Lists, IRC channels see the + [communication page](https://docs.ansible.com/ansible/latest/community/communication.html). + +# License + +The [MIT](https://github.com/ansible-community/molecule-gce/blob/main/LICENSE) License. + +The logo is licensed under the [Creative Commons NoDerivatives 4.0 License](https://creativecommons.org/licenses/by-nd/4.0/). + +If you have some other use in mind, contact us. diff --git a/packages/molecule-gce/pyproject.toml b/packages/molecule-gce/pyproject.toml new file mode 100644 index 00000000..59190455 --- /dev/null +++ b/packages/molecule-gce/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = [ + "setuptools >= 42.0.2", + "setuptools_scm >= 1.15.0", + "setuptools_scm_git_archive >= 1.0", + "wheel" +] +build-backend = "setuptools.build_meta" diff --git a/packages/molecule-gce/setup.cfg b/packages/molecule-gce/setup.cfg new file mode 100644 index 00000000..3e2ed183 --- /dev/null +++ b/packages/molecule-gce/setup.cfg @@ -0,0 +1,81 @@ +[metadata] +name = molecule-gce +url = https://github.com/ansible-community/molecule-gce +project_urls = + Bug Tracker = https://github.com/ansible-community/molecule-gce/issues + Release Management = https://github.com/ansible-community/molecule-gce/projects + CI = https://github.com/ansible-community/molecule-gce/actions + Discussions = https://github.com/ansible-community/molecule/discussions + Source Code = https://github.com/ansible-community/molecule-gce +description = Molecule GCE Plugin :: run molecule tests on Google Cloud Engine +long_description = file: README.md +long_description_content_type = text/markdown +author = Ansible by Red Hat +author_email = info@ansible.com +maintainer = Ansible by Red Hat +maintainer_email = info@ansible.com +license = MIT +license_file = LICENSE +classifiers = + Development Status :: 4 - Beta + 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 :: 3 + Programming Language :: Python :: 3.6 + Programming Language :: Python :: 3.7 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + + Topic :: System :: Systems Administration + Topic :: Utilities + +keywords = + ansible + roles + testing + molecule + plugin + gce + google + +[options] +use_scm_version = True +python_requires = >=3.6 +package_dir = + = src +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 = + # do not use ceiling unless you already know that newer version breaks + # do not use pre-release versions + molecule >= 3.4.0 + pyyaml >= 5.1 + pywinrm >= 0.3.0 + google-auth >= 1.34.0 + google-api-python-client >= 2.15.0 + jinja2-ansible-filters >= 1.3.0 + pycryptodome >= 3.10.1 + +[options.extras_require] +test = + molecule[test] + +[options.entry_points] +molecule.driver = + gce = molecule_gce.driver:GCE + +[options.packages.find] +where = src diff --git a/packages/molecule-gce/src/molecule_gce/__init__.py b/packages/molecule-gce/src/molecule_gce/__init__.py new file mode 100644 index 00000000..0af417a4 --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/__init__.py @@ -0,0 +1,3 @@ +"""Plugin exports.""" + +__name__ = __name__.split("_")[-1] diff --git a/packages/molecule-gce/src/molecule_gce/cookiecutter/cookiecutter.json b/packages/molecule-gce/src/molecule_gce/cookiecutter/cookiecutter.json new file mode 100644 index 00000000..54214f7b --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/cookiecutter/cookiecutter.json @@ -0,0 +1,9 @@ +{ + "molecule_directory": "molecule", + "dependency_name": "OVERRIDDEN", + "driver_name": "OVERRIDDEN", + "provisioner_name": "OVERRIDDEN", + "scenario_name": "OVERRIDDEN", + "role_name": "OVERRIDDEN", + "verifier_name": "OVERRIDDEN" +} diff --git a/packages/molecule-gce/src/molecule_gce/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml b/packages/molecule-gce/src/molecule_gce/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml new file mode 100644 index 00000000..cc63d86b --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml @@ -0,0 +1,7 @@ +--- +- name: Converge + hosts: all + tasks: + - name: "Include {{ cookiecutter.role_name }}" + ansible.builtin.include_role: + name: "{{ cookiecutter.role_name }}" diff --git a/packages/molecule-gce/src/molecule_gce/driver.py b/packages/molecule-gce/src/molecule_gce/driver.py new file mode 100644 index 00000000..2137b095 --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/driver.py @@ -0,0 +1,176 @@ +# 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. +from typing import Dict +import os +from molecule import logger +from molecule.api import Driver + +from molecule import util + +LOG = logger.get_logger(__name__) + + +class GCE(Driver): + """ + The class responsible for managing `GCE`_ instances. `GCE`_ + is `not` the default driver used in Molecule. + + GCE is somewhat different than other cloud providers. There is not + an Ansible module for managing ssh keys. This driver assumes the developer + has deployed project wide ssh key. + + Molecule leverages Ansible's `gce_module`_, by mapping variables from + ``molecule.yml`` into ``create.yml`` and ``destroy.yml``. + + .. _`gce_module`: https://docs.ansible.com/ansible/latest/gce_module.html + + .. code-block:: yaml + + driver: + name: gce + platforms: + - name: instance + + .. code-block:: bash + + $ pip install molecule[gce] + + Change the options passed to the ssh client. + + .. code-block:: yaml + + driver: + name: gce + 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 a list of files Molecule will preserve, relative to the scenario + ephemeral directory, after any ``destroy`` subcommand execution. + + .. code-block:: yaml + + driver: + name: gce + safe_files: + - foo + + .. _`GCE`: https://cloud.google.com/compute/docs/ + """ # noqa + + def __init__(self, config=None): + super(GCE, self).__init__(config) + self._name = "gce" + + @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) + + if "instance_os_type" in d: + if d["instance_os_type"] == "linux": + return { + "ansible_user": d["user"], + "ansible_host": d["address"], + "ansible_port": d["port"], + "ansible_private_key_file": d["identity_file"], + "ansible_connection": "ssh", + "ansible_ssh_common_args": " ".join( + self.ssh_connection_options + ), + } + + if d["instance_os_type"] == "windows": + return { + "ansible_user": d["user"], + "ansible_host": d["address"], + "ansible_password": d["password"], + "ansible_port": d["port"], + "ansible_connection": "winrm", + "ansible_winrm_transport": d["winrm_transport"], + "ansible_winrm_server_cert_validation": d[ + "winrm_server_cert_validation" + ], + "ansible_become_method": "runas", + } + 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 + ) + + def sanity_checks(self): + # FIXME(decentral1se): Implement sanity checks + pass + + def template_dir(self): + """Return path to its own cookiecutterm templates. It is used by init + command in order to figure out where to load the templates from. + """ + return os.path.join(os.path.dirname(__file__), "cookiecutter") + + @property + def required_collections(self) -> Dict[str, str]: + # https://galaxy.ansible.com/google/cloud + return {"google.cloud": "1.0.2", "community.crypto": "1.8.0"} diff --git a/packages/molecule-gce/src/molecule_gce/playbooks/create.yml b/packages/molecule-gce/src/molecule_gce/playbooks/create.yml new file mode 100644 index 00000000..ed041356 --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/playbooks/create.yml @@ -0,0 +1,31 @@ +--- +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + ssh_identity_file: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + gcp_project_id: "{{ molecule_yml.driver.project_id | default(lookup('env', 'GCE_PROJECT_ID')) }}" + + tasks: + - name: Make sure if linux or windows either specified + ansible.builtin.assert: + that: + - molecule_yml.driver.instance_os_type | lower == "linux" or + molecule_yml.driver.instance_os_type | lower == "windows" + fail_msg: "instance_os_type is possible only to specify linux or windows either" + + - name: Include create_linux_instance tasks + ansible.builtin.include_tasks: tasks/create_linux_instance.yml + when: + - molecule_yml.driver.instance_os_type | lower == "linux" + + - name: Include create_windows_instance tasks + ansible.builtin.include_tasks: tasks/create_windows_instance.yml + when: + - molecule_yml.driver.instance_os_type | lower == "windows" + + handlers: + - name: Import main handler tasks + ansible.builtin.import_tasks: handlers/main.yml diff --git a/packages/molecule-gce/src/molecule_gce/playbooks/destroy.yml b/packages/molecule-gce/src/molecule_gce/playbooks/destroy.yml new file mode 100644 index 00000000..1357a6cc --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/playbooks/destroy.yml @@ -0,0 +1,38 @@ +--- +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + + tasks: + - name: Destroy molecule instance(s) + google.cloud.gcp_compute_instance: + name: "{{ item.name }}" + state: absent + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ molecule_yml.driver.project_id | default(lookup('env', 'GCE_PROJECT_ID')) }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + notify: + - "Wipe out instance config" + - "Dump instance config" + + - name: Wait for instance(s) deletion to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: server + until: server.finished + retries: 300 + delay: 10 + loop: "{{ async_results.results }}" + + handlers: + - name: Import main handler tasks + ansible.builtin.import_tasks: handlers/main.yml diff --git a/packages/molecule-gce/src/molecule_gce/playbooks/files/windows_auth.py b/packages/molecule-gce/src/molecule_gce/playbooks/files/windows_auth.py new file mode 100644 index 00000000..25204299 --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/playbooks/files/windows_auth.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python + +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import copy +import datetime +import json +import time +import argparse + +# PyCrypto library: https://pypi.python.org/pypi/pycrypto +from Crypto.Cipher import PKCS1_OAEP +from Crypto.PublicKey import RSA +from Crypto.Util.number import long_to_bytes + +# Google API Client Library for Python: +# https://developers.google.com/api-client-library/python/start/get_started +import google.auth +from googleapiclient.discovery import build + + +def GetCompute(): + """Get a compute object for communicating with the Compute Engine API.""" + credentials, project = google.auth.default() + compute = build("compute", "v1", credentials=credentials) + return compute + + +def GetInstance(compute, instance, zone, project): + """Get the data for a Google Compute Engine instance.""" + cmd = compute.instances().get(instance=instance, project=project, zone=zone) + return cmd.execute() + + +def GetKey(): + """Get an RSA key for encryption.""" + # This uses the PyCrypto library + key = RSA.generate(2048) + return key + + +def GetModulusExponentInBase64(key): + """Return the public modulus and exponent for the key in bas64 encoding.""" + mod = long_to_bytes(key.n) + exp = long_to_bytes(key.e) + + modulus = base64.b64encode(mod) + exponent = base64.b64encode(exp) + + return modulus, exponent + + +def GetExpirationTimeString(): + """Return an RFC3339 UTC timestamp for 5 minutes from now.""" + utc_now = datetime.datetime.utcnow() + # These metadata entries are one-time-use, so the expiration time does + # not need to be very far in the future. In fact, one minute would + # generally be sufficient. Five minutes allows for minor variations + # between the time on the client and the time on the server. + expire_time = utc_now + datetime.timedelta(minutes=5) + return expire_time.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def GetJsonString(user, modulus, exponent, email): + """Return the JSON string object that represents the windows-keys entry.""" + + converted_modulus = modulus.decode("utf-8") + converted_exponent = exponent.decode("utf-8") + + expire = GetExpirationTimeString() + data = { + "userName": user, + "modulus": converted_modulus, + "exponent": converted_exponent, + "email": email, + "expireOn": expire, + } + + return json.dumps(data) + + +def UpdateWindowsKeys(old_metadata, metadata_entry): + """Return updated metadata contents with the new windows-keys entry.""" + # Simply overwrites the "windows-keys" metadata entry. Production code may + # want to append new lines to the metadata value and remove any expired + # entries. + new_metadata = copy.deepcopy(old_metadata) + new_metadata["items"] = [{"key": "windows-keys", "value": metadata_entry}] + return new_metadata + + +def UpdateInstanceMetadata(compute, instance, zone, project, new_metadata): + """Update the instance metadata.""" + cmd = compute.instances().setMetadata( + instance=instance, project=project, zone=zone, body=new_metadata + ) + return cmd.execute() + + +def GetSerialPortFourOutput(compute, instance, zone, project): + """Get the output from serial port 4 from the instance.""" + # Encrypted passwords are printed to COM4 on the windows server: + port = 4 + cmd = compute.instances().getSerialPortOutput( + instance=instance, project=project, zone=zone, port=port + ) + output = cmd.execute() + return output["contents"] + + +def GetEncryptedPasswordFromSerialPort(serial_port_output, modulus): + """Find and return the correct encrypted password, based on the modulus.""" + # In production code, this may need to be run multiple times if the output + # does not yet contain the correct entry. + + converted_modulus = modulus.decode("utf-8") + + output = serial_port_output.split("\n") + for line in reversed(output): + try: + entry = json.loads(line) + if converted_modulus == entry["modulus"]: + return entry["encryptedPassword"] + except ValueError: + pass + + +def DecryptPassword(encrypted_password, key): + """Decrypt a base64 encoded encrypted password using the provided key.""" + + decoded_password = base64.b64decode(encrypted_password) + cipher = PKCS1_OAEP.new(key) + password = cipher.decrypt(decoded_password) + return password + + +def Arguments(): + # Create the parser + args = argparse.ArgumentParser(description="List the content of a folder") + + # Add the arguments + args.add_argument( + "--instance", metavar="instance", type=str, help="compute instance name" + ) + + args.add_argument("--zone", metavar="zone", type=str, help="compute zone") + + args.add_argument("--project", metavar="project", type=str, help="gcp project") + + args.add_argument("--username", metavar="username", type=str, help="username") + + args.add_argument("--email", metavar="email", type=str, help="email") + + # return arguments + return args.parse_args() + + +def main(): + config_args = Arguments() + + # Setup + compute = GetCompute() + key = GetKey() + modulus, exponent = GetModulusExponentInBase64(key) + + # Get existing metadata + instance_ref = GetInstance( + compute, config_args.instance, config_args.zone, config_args.project + ) + old_metadata = instance_ref["metadata"] + # Create and set new metadata + metadata_entry = GetJsonString( + config_args.username, modulus, exponent, config_args.email + ) + new_metadata = UpdateWindowsKeys(old_metadata, metadata_entry) + + # Get Serial output BEFORE the modification + serial_port_output = GetSerialPortFourOutput( + compute, config_args.instance, config_args.zone, config_args.project + ) + + UpdateInstanceMetadata( + compute, + config_args.instance, + config_args.zone, + config_args.project, + new_metadata, + ) + + # Get and decrypt password from serial port output + # Monitor changes from output to get the encrypted password as soon as it's generated, will wait for 30 seconds + i = 0 + new_serial_port_output = serial_port_output + while i <= 20 and serial_port_output == new_serial_port_output: + new_serial_port_output = GetSerialPortFourOutput( + compute, config_args.instance, config_args.zone, config_args.project + ) + i += 1 + time.sleep(3) + + enc_password = GetEncryptedPasswordFromSerialPort(new_serial_port_output, modulus) + + password = DecryptPassword(enc_password, key) + converted_password = password.decode("utf-8") + + # Display only the password + print(format(converted_password)) + + +if __name__ == "__main__": + main() diff --git a/packages/molecule-gce/src/molecule_gce/playbooks/handlers/main.yml b/packages/molecule-gce/src/molecule_gce/playbooks/handlers/main.yml new file mode 100644 index 00000000..c1cb55d3 --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/playbooks/handlers/main.yml @@ -0,0 +1,49 @@ +--- +- name: Populate instance config dict Linux + ansible.builtin.set_fact: + instance_conf_dict: { + 'instance': "{{ instance_info.name }}", + 'address': "{{ instance_info.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else instance_info.networkInterfaces.0.networkIP }}", + 'user': "{{ lookup('env','USER') }}", + 'port': "22", + 'identity_file': "{{ ssh_identity_file }}", + 'instance_os_type': "{{ molecule_yml.driver.instance_os_type }}" + } + loop: "{{ server.results }}" + loop_control: + loop_var: instance_info + no_log: true + register: instance_conf_dict + +- name: Populate instance config dict Windows + ansible.builtin.set_fact: + instance_conf_dict: { + 'instance': "{{ instance_info.name }}", + 'address': "{{ instance_info.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else instance_info.networkInterfaces.0.networkIP }}", + 'user': "molecule_usr", + 'password': "{{ instance_info.password }}", + 'port': "{{ instance_info.winrm_port | default(5986) }}", + 'winrm_transport': "{{ molecule_yml.driver.winrm_transport | default('ntlm') }}", + 'winrm_server_cert_validation': "{{ molecule_yml.driver.winrm_server_cert_validation | default('ignore') }}", + 'instance_os_type': "{{ molecule_yml.driver.instance_os_type }}" + } + loop: "{{ win_instances }}" + loop_control: + loop_var: instance_info + no_log: true + register: instance_conf_dict + + +- name: Wipe out instance config + ansible.builtin.set_fact: + instance_conf: {} + +- name: Convert instance config dict to a list + ansible.builtin.set_fact: + instance_conf: "{{ instance_conf_dict.results | map(attribute='ansible_facts.instance_conf_dict') | list }}" + +- name: Dump instance config + ansible.builtin.copy: + content: "{{ instance_conf }}" + dest: "{{ molecule_instance_config }}" + mode: '0600' diff --git a/packages/molecule-gce/src/molecule_gce/playbooks/tasks/create_linux_instance.yml b/packages/molecule-gce/src/molecule_gce/playbooks/tasks/create_linux_instance.yml new file mode 100644 index 00000000..f6ae0f2c --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/playbooks/tasks/create_linux_instance.yml @@ -0,0 +1,63 @@ +--- +- name: create ssh keypair + community.crypto.openssh_keypair: + comment: "{{ lookup('env','USER') }} user for Molecule" + path: "{{ ssh_identity_file }}" + register: keypair + +- name: create molecule Linux instance(s) + google.cloud.gcp_compute_instance: + state: present + name: "{{ item.name }}" + machine_type: "{{ item.machine_type | default('n1-standard-1') }}" + metadata: + ssh-keys: "{{ lookup('env','USER') }}:{{ keypair.public_key }}" + scheduling: + preemptible: "{{ item.preemptible | default(false) }}" + disks: + - auto_delete: true + boot: true + initialize_params: + disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" + source_image: "{{ item.image | default('projects/debian-cloud/global/images/family/debian-10') }}" + source_image_encryption_key: + raw_key: "{{ item.image_encryption_key | default(omit) }}" + network_interfaces: + - network: + selfLink: "https://www.googleapis.com/compute/v1/projects/{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}/global/networks/{{ molecule_yml.driver.network_name | default('default') }}" + subnetwork: + selfLink: "https://compute.googleapis.com/compute/v1/projects/{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}/regions/{{ molecule_yml.driver.region }}/subnetworks/{{ molecule_yml.driver.subnetwork_name | default('default') }}" + access_configs: "{{ [{'name': 'instance_ip', 'type': 'ONE_TO_ONE_NAT'}] if molecule_yml.driver.external_access else [] }}" + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ gcp_project_id }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + loop_control: + pause: 3 + async: 7200 + poll: 0 + +- name: Wait for instance(s) creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ async_results.results }}" + register: server + until: server.finished + retries: 300 + delay: 10 + notify: + - "Populate instance config dict Linux" + - "Convert instance config dict to a list" + - "Dump instance config" + +- name: Wait for SSH + ansible.builtin.wait_for: + port: 22 + host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" + search_regex: SSH + delay: 10 + loop: "{{ server.results }}" diff --git a/packages/molecule-gce/src/molecule_gce/playbooks/tasks/create_windows_instance.yml b/packages/molecule-gce/src/molecule_gce/playbooks/tasks/create_windows_instance.yml new file mode 100644 index 00000000..c3193f40 --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/playbooks/tasks/create_windows_instance.yml @@ -0,0 +1,72 @@ +--- +- name: create molecule Windows instance(s) + google.cloud.gcp_compute_instance: + state: present + name: "{{ item.name }}" + machine_type: "{{ item.machine_type | default('n1-standard-1') }}" + scheduling: + preemptible: "{{ item.preemptible | default(false) }}" + disks: + - auto_delete: true + boot: true + initialize_params: + disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" + source_image: "{{ item.image | default('projects/windows-cloud/global/images/family/windows-2019') }}" + source_image_encryption_key: + raw_key: "{{ item.image_encryption_key | default(omit) }}" + network_interfaces: + - network: + selfLink: "https://www.googleapis.com/compute/v1/projects/{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}/global/networks/{{ molecule_yml.driver.network_name | default('default') }}" + subnetwork: + selfLink: "https://compute.googleapis.com/compute/v1/projects/{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}/regions/{{ molecule_yml.driver.region }}/subnetworks/{{ molecule_yml.driver.subnetwork_name | default('default') }}" + access_configs: "{{ [{'name': 'instance_ip', 'type': 'ONE_TO_ONE_NAT'}] if molecule_yml.driver.external_access else [] }}" + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ gcp_project_id }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + +- name: Wait for instance(s) creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ async_results.results }}" + register: server + until: server.finished + retries: 300 + delay: 10 + notify: + - "Populate instance config dict Windows" + - "Convert instance config dict to a list" + - "Dump instance config" + +- name: Wait for WinRM + ansible.builtin.wait_for: + port: 5986 + host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" + delay: 10 + loop: "{{ server.results }}" + +- name: Prepare Windows User + ansible.builtin.script: ./files/windows_auth.py --instance {{ item.name }} --zone {{ item.zone | default(molecule_yml.driver.region + '-b') }} --project {{ gcp_project_id }} --username molecule_usr + args: + executable: python3 + environment: + GOOGLE_APPLICATION_CREDENTIALS: "{{ molecule_yml.driver.service_account_file | default(lookup('env', 'GCP_SERVICE_ACCOUNT_FILE'), true) }}" + loop: "{{ molecule_yml.platforms }}" + changed_when: + - password.rc == 0 + - password.stdout + register: password + retries: 10 + delay: 10 + +- name: Add password for instances in server list + ansible.builtin.set_fact: + win_instances: "{{ win_instances|default([]) + [dict(item[0], password=item[1].stdout_lines |last)] }}" + loop: "{{ server.results | zip(password.results) | list }}" + no_log: true diff --git a/packages/molecule-gce/src/molecule_gce/test/__init__.py b/packages/molecule-gce/src/molecule_gce/test/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/molecule-gce/src/molecule_gce/test/functional/.ansible-lint b/packages/molecule-gce/src/molecule_gce/test/functional/.ansible-lint new file mode 100644 index 00000000..c54b8ec5 --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/test/functional/.ansible-lint @@ -0,0 +1,9 @@ +# ansible-lint config for functional testing, used to bypass expected metadata +# errors in molecule-generated roles. Loaded via the metadata_lint_update +# pytest helper. For reference, see "E7xx - metadata" in: +# https://docs.ansible.com/ansible-lint/rules/default_rules.html +skip_list: + # metadata/701 - Role info should contain platforms + - '701' + # metadata/703 - Should change default metadata: " + - '703' diff --git a/packages/molecule-gce/src/molecule_gce/test/functional/__init__.py b/packages/molecule-gce/src/molecule_gce/test/functional/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/packages/molecule-gce/src/molecule_gce/test/functional/conftest.py b/packages/molecule-gce/src/molecule_gce/test/functional/conftest.py new file mode 100644 index 00000000..77071457 --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/test/functional/conftest.py @@ -0,0 +1,23 @@ +# Copyright (c) 2015-2018 Cisco Systems, Inc. +# Copyright (c) 2018 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.test.conftest import * # noqa diff --git a/packages/molecule-gce/src/molecule_gce/test/functional/test_func.py b/packages/molecule-gce/src/molecule_gce/test/functional/test_func.py new file mode 100644 index 00000000..70681c67 --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/test/functional/test_func.py @@ -0,0 +1,60 @@ +# Copyright (c) 2015-2018 Cisco Systems, Inc. +# Copyright (c) 2018 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 pytest +import os + +from molecule import logger +from molecule.util import run_command +from molecule.test.conftest import change_dir_to +from molecule.test.functional.conftest import metadata_lint_update + +LOG = logger.get_logger(__name__) +driver_name = __name__.split(".")[0].split("_")[-1] + + +@pytest.mark.xfail(reason="need to fix template path") +def test_command_init_scenario(temp_dir): + """Test init scenario with driver.""" + role_directory = os.path.join(temp_dir.strpath, "test-init") + cmd = ["molecule", "init", "role", "test-init"] + assert run_command(cmd).returncode == 0 + 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") + cmd = [ + "molecule", + "init", + "scenario", + "test-scenario", + "--role-name", + "test-init", + "--driver-name", + driver_name, + ] + assert run_command(cmd).returncode == 0 + + assert os.path.isdir(scenario_directory) + + cmd = ["molecule", "test", "-s", "test-scenario"] + assert run_command(cmd).returncode == 0 diff --git a/packages/molecule-gce/src/molecule_gce/test/test_driver.py b/packages/molecule-gce/src/molecule_gce/test/test_driver.py new file mode 100644 index 00000000..fc75b91e --- /dev/null +++ b/packages/molecule-gce/src/molecule_gce/test/test_driver.py @@ -0,0 +1,6 @@ +from molecule import api + + +def test_driver_is_detected(): + driver_name = __name__.split(".")[0].split("_")[-1] + assert driver_name in [str(d) for d in api.drivers()] diff --git a/packages/molecule-gce/test/scenarios/linux/INSTALL.md b/packages/molecule-gce/test/scenarios/linux/INSTALL.md new file mode 100644 index 00000000..39c952e0 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/INSTALL.md @@ -0,0 +1,19 @@ +# Google Cloud Engine driver installation guide + +## Requirements + +- A GCE credentials rc file + +## 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`. + +``` bash +$ pip install 'molecule_gce' +``` + + [Virtual environment]: https://virtualenv.pypa.io/en/latest/ + ['--user' flag]: https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site diff --git a/packages/molecule-gce/test/scenarios/linux/converge.yml b/packages/molecule-gce/test/scenarios/linux/converge.yml new file mode 100644 index 00000000..93dbbab5 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/converge.yml @@ -0,0 +1,7 @@ +--- +- name: Converge + hosts: all + tasks: + - name: "Include testrole" + include_role: + name: "testrole" diff --git a/packages/molecule-gce/test/scenarios/linux/create.yml b/packages/molecule-gce/test/scenarios/linux/create.yml new file mode 100644 index 00000000..1b525f9b --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/create.yml @@ -0,0 +1,60 @@ +--- +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + ssh_identity_file: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + gcp_project_id: "{{ molecule_yml.driver.project_id | default(lookup('env', 'GCE_PROJECT_ID')) }}" + + tasks: + - name: Make sure if linux or windows either specified + assert: + that: + - molecule_yml.driver.instance_os_type | lower == "linux" or + molecule_yml.driver.instance_os_type | lower == "windows" + fail_msg: "instance_os_type is possible only to specify linux or windows either" + + - name: get network info + google.cloud.gcp_compute_network_info: + filters: + - name = "{{ molecule_yml.driver.network_name | default('default') }}" + project: "{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: my_network + + - name: get subnetwork info + google.cloud.gcp_compute_subnetwork_info: + filters: + - name = "{{ molecule_yml.driver.subnetwork_name | default('default') }}" + project: "{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}" + region: "{{ molecule_yml.driver.region }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: my_subnetwork + + - name: set external access config + set_fact: + external_access_config: + - access_configs: + - name: "External NAT" + type: "ONE_TO_NAT" + when: molecule_yml.driver.external_access + + - name: Include create_linux_instance tasks + include_tasks: tasks/create_linux_instance.yml + when: + - molecule_yml.driver.instance_os_type | lower == "linux" + + - name: Include create_windows_instance tasks + include_tasks: tasks/create_windows_instance.yml + when: + - molecule_yml.driver.instance_os_type | lower == "windows" + + handlers: + - name: Import main handler tasks + import_tasks: handlers/main.yml diff --git a/packages/molecule-gce/test/scenarios/linux/destroy.yml b/packages/molecule-gce/test/scenarios/linux/destroy.yml new file mode 100644 index 00000000..deb86bb1 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/destroy.yml @@ -0,0 +1,38 @@ +--- +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + + tasks: + - name: Destroy molecule instance(s) + google.cloud.gcp_compute_instance: + name: "{{ item.name }}" + state: absent + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ molecule_yml.driver.project_id | default(lookup('env', 'GCE_PROJECT_ID')) }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + notify: + - "Wipe out instance config" + - "Dump instance config" + + - name: Wait for instance(s) deletion to complete + async_status: + jid: "{{ item.ansible_job_id }}" + register: server + until: server.finished + retries: 300 + delay: 10 + loop: "{{ async_results.results }}" + + handlers: + - name: Import main handler tasks + import_tasks: handlers/main.yml diff --git a/packages/molecule-gce/test/scenarios/linux/files/windows_auth.py b/packages/molecule-gce/test/scenarios/linux/files/windows_auth.py new file mode 100644 index 00000000..99963ff7 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/files/windows_auth.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python + +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import copy +import datetime +import json +import time +import argparse + +# PyCrypto library: https://pypi.python.org/pypi/pycrypto +from Crypto.Cipher import PKCS1_OAEP +from Crypto.PublicKey import RSA +from Crypto.Util.number import long_to_bytes + +# Google API Client Library for Python: +# https://developers.google.com/api-client-library/python/start/get_started +from oauth2client.client import GoogleCredentials +from googleapiclient.discovery import build + + +def GetCompute(): + """Get a compute object for communicating with the Compute Engine API.""" + credentials = GoogleCredentials.get_application_default() + compute = build("compute", "v1", credentials=credentials) + return compute + + +def GetInstance(compute, instance, zone, project): + """Get the data for a Google Compute Engine instance.""" + cmd = compute.instances().get(instance=instance, project=project, zone=zone) + return cmd.execute() + + +def GetKey(): + """Get an RSA key for encryption.""" + # This uses the PyCrypto library + key = RSA.generate(2048) + return key + + +def GetModulusExponentInBase64(key): + """Return the public modulus and exponent for the key in bas64 encoding.""" + mod = long_to_bytes(key.n) + exp = long_to_bytes(key.e) + + modulus = base64.b64encode(mod) + exponent = base64.b64encode(exp) + + return modulus, exponent + + +def GetExpirationTimeString(): + """Return an RFC3339 UTC timestamp for 5 minutes from now.""" + utc_now = datetime.datetime.utcnow() + # These metadata entries are one-time-use, so the expiration time does + # not need to be very far in the future. In fact, one minute would + # generally be sufficient. Five minutes allows for minor variations + # between the time on the client and the time on the server. + expire_time = utc_now + datetime.timedelta(minutes=5) + return expire_time.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def GetJsonString(user, modulus, exponent, email): + """Return the JSON string object that represents the windows-keys entry.""" + + converted_modulus = modulus.decode("utf-8") + converted_exponent = exponent.decode("utf-8") + + expire = GetExpirationTimeString() + data = { + "userName": user, + "modulus": converted_modulus, + "exponent": converted_exponent, + "email": email, + "expireOn": expire, + } + + return json.dumps(data) + + +def UpdateWindowsKeys(old_metadata, metadata_entry): + """Return updated metadata contents with the new windows-keys entry.""" + # Simply overwrites the "windows-keys" metadata entry. Production code may + # want to append new lines to the metadata value and remove any expired + # entries. + new_metadata = copy.deepcopy(old_metadata) + new_metadata["items"] = [{"key": "windows-keys", "value": metadata_entry}] + return new_metadata + + +def UpdateInstanceMetadata(compute, instance, zone, project, new_metadata): + """Update the instance metadata.""" + cmd = compute.instances().setMetadata( + instance=instance, project=project, zone=zone, body=new_metadata + ) + return cmd.execute() + + +def GetSerialPortFourOutput(compute, instance, zone, project): + """Get the output from serial port 4 from the instance.""" + # Encrypted passwords are printed to COM4 on the windows server: + port = 4 + cmd = compute.instances().getSerialPortOutput( + instance=instance, project=project, zone=zone, port=port + ) + output = cmd.execute() + return output["contents"] + + +def GetEncryptedPasswordFromSerialPort(serial_port_output, modulus): + """Find and return the correct encrypted password, based on the modulus.""" + # In production code, this may need to be run multiple times if the output + # does not yet contain the correct entry. + + converted_modulus = modulus.decode("utf-8") + + output = serial_port_output.split("\n") + for line in reversed(output): + try: + entry = json.loads(line) + if converted_modulus == entry["modulus"]: + return entry["encryptedPassword"] + except ValueError: + pass + + +def DecryptPassword(encrypted_password, key): + """Decrypt a base64 encoded encrypted password using the provided key.""" + + decoded_password = base64.b64decode(encrypted_password) + cipher = PKCS1_OAEP.new(key) + password = cipher.decrypt(decoded_password) + return password + + +def Arguments(): + # Create the parser + args = argparse.ArgumentParser(description="List the content of a folder") + + # Add the arguments + args.add_argument( + "--instance", metavar="instance", type=str, help="compute instance name" + ) + + args.add_argument("--zone", metavar="zone", type=str, help="compute zone") + + args.add_argument("--project", metavar="project", type=str, help="gcp project") + + args.add_argument("--username", metavar="username", type=str, help="username") + + args.add_argument("--email", metavar="email", type=str, help="email") + + # return arguments + return args.parse_args() + + +def main(): + config_args = Arguments() + + # Setup + compute = GetCompute() + key = GetKey() + modulus, exponent = GetModulusExponentInBase64(key) + + # Get existing metadata + instance_ref = GetInstance( + compute, config_args.instance, config_args.zone, config_args.project + ) + old_metadata = instance_ref["metadata"] + # Create and set new metadata + metadata_entry = GetJsonString( + config_args.username, modulus, exponent, config_args.email + ) + new_metadata = UpdateWindowsKeys(old_metadata, metadata_entry) + + # Get Serial output BEFORE the modification + serial_port_output = GetSerialPortFourOutput( + compute, config_args.instance, config_args.zone, config_args.project + ) + + UpdateInstanceMetadata( + compute, + config_args.instance, + config_args.zone, + config_args.project, + new_metadata, + ) + + # Get and decrypt password from serial port output + # Monitor changes from output to get the encrypted password as soon as it's generated, will wait for 30 seconds + i = 0 + new_serial_port_output = serial_port_output + while i <= 30 and serial_port_output == new_serial_port_output: + new_serial_port_output = GetSerialPortFourOutput( + compute, config_args.instance, config_args.zone, config_args.project + ) + i += 1 + time.sleep(1) + + enc_password = GetEncryptedPasswordFromSerialPort(new_serial_port_output, modulus) + + password = DecryptPassword(enc_password, key) + converted_password = password.decode("utf-8") + + # Display only the password + print(format(converted_password)) + + +if __name__ == "__main__": + main() diff --git a/packages/molecule-gce/test/scenarios/linux/handlers/main.yml b/packages/molecule-gce/test/scenarios/linux/handlers/main.yml new file mode 100644 index 00000000..12591378 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/handlers/main.yml @@ -0,0 +1,49 @@ +--- +- name: Populate instance config dict Linux + set_fact: + instance_conf_dict: { + 'instance': "{{ instance_info.name }}", + 'address': "{{ instance_info.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else instance_info.networkInterfaces.0.networkIP }}", + 'user': "{{ lookup('env','USER') }}", + 'port': "22", + 'identity_file': "{{ ssh_identity_file }}", + 'instance_os_type': "{{ molecule_yml.driver.instance_os_type }}" + } + loop: "{{ server.results }}" + loop_control: + loop_var: instance_info + no_log: true + register: instance_conf_dict + +- name: Populate instance config dict Windows + set_fact: + instance_conf_dict: { + 'instance': "{{ instance_info.name }}", + 'address': "{{ instance_info.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else instance_info.networkInterfaces.0.networkIP }}", + 'user': "molecule_usr", + 'password': "{{ instance_info.password }}", + 'port': "{{ instance_info.winrm_port | default(5986) }}", + 'winrm_transport': "{{ molecule_yml.driver.winrm_transport | default('ntlm') }}", + 'winrm_server_cert_validation': "{{ molecule_yml.driver.winrm_server_cert_validation | default('ignore') }}", + 'instance_os_type': "{{ molecule_yml.driver.instance_os_type }}" + } + loop: "{{ win_instances }}" + loop_control: + loop_var: instance_info + no_log: true + register: instance_conf_dict + + +- name: Wipe out instance config + set_fact: + instance_conf: {} + +- name: Convert instance config dict to a list + set_fact: + instance_conf: "{{ instance_conf_dict.results | map(attribute='ansible_facts.instance_conf_dict') | list }}" + +- name: Dump instance config + copy: + content: "{{ instance_conf }}" + dest: "{{ molecule_instance_config }}" + mode: '0600' diff --git a/packages/molecule-gce/test/scenarios/linux/molecule.yml b/packages/molecule-gce/test/scenarios/linux/molecule.yml new file mode 100644 index 00000000..d1c09195 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/molecule.yml @@ -0,0 +1,20 @@ +--- +dependency: + name: galaxy +driver: + name: gce + project_id: change-to-id-of-the-gcp-project # if not set, will default to env GCE_PROJECT_ID + auth_kind: null # set to machineaccount or serviceaccount or application - if set to null will read env GCP_AUTH_KIND + service_account_email: null # set to an email associated with the project - if set to null, will default to GCP_SERVICE_ACCOUNT_EMAIL. Should not be set if using auth_kind serviceaccount. + service_account_file: null # set to the path to the JSON credentials file - if set to null, will default to env GCP_SERVICE_ACCOUNT_FILE + region: us-west1 # REQUIRED. example: us-central1 + external_access: false # chose whether to create a public IP for the VM or not - default is private IP only + instance_os_type: linux # will be considered linux by default, but can be explicitely set to windows +platforms: + - name: linuxgce-createdbymolecule # is an instance name + machine_type: n1-standard-1 # define your machine type + zone: null # example: us-west1-b, will default to zone b of driver.region +provisioner: + name: ansible +verifier: + name: ansible diff --git a/packages/molecule-gce/test/scenarios/linux/prepare.yml b/packages/molecule-gce/test/scenarios/linux/prepare.yml new file mode 100644 index 00000000..7972c13d --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/prepare.yml @@ -0,0 +1,7 @@ +--- +- name: Prepare + hosts: all + gather_facts: false + tasks: + - name: Wait 600 seconds for target connection to become reachable/usable + wait_for_connection: diff --git a/packages/molecule-gce/test/scenarios/linux/requirements.yml b/packages/molecule-gce/test/scenarios/linux/requirements.yml new file mode 100644 index 00000000..808bcd77 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/requirements.yml @@ -0,0 +1,3 @@ +collections: + - name: google.cloud + source: https://galaxy.ansible.com diff --git a/packages/molecule-gce/test/scenarios/linux/tasks/create_linux_instance.yml b/packages/molecule-gce/test/scenarios/linux/tasks/create_linux_instance.yml new file mode 100644 index 00000000..bb4f6889 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/tasks/create_linux_instance.yml @@ -0,0 +1,56 @@ +--- +- name: create ssh keypair + openssh_keypair: + comment: "{{ lookup('env','USER') }} user for Molecule" + path: "{{ ssh_identity_file }}" + register: keypair + +- name: create molecule Linux instance(s) + google.cloud.gcp_compute_instance: + state: present + name: "{{ item.name }}" + machine_type: "{{ item.machine_type | default('n1-standard-1') }}" + metadata: + ssh-keys: "{{ lookup('env','USER') }}:{{ keypair.public_key }}}" + disks: + - auto_delete: true + boot: true + initialize_params: + disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" + source_image: "{{ item.image | default('projects/debian-cloud/global/images/family/debian-10') }}" + source_image_encryption_key: + raw_key: "{{ item.image_encryption_key | default(omit) }}" + network_interfaces: "{{ [ { 'network': my_network.resources.0 | default(omit), 'subnetwork': my_subnetwork.resources.0 | default(omit) } | combine(external_access_config | default([]) ) ] }}" + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ gcp_project_id }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + loop_control: + pause: 3 + async: 7200 + poll: 0 + +- name: Wait for instance(s) creation to complete + async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ async_results.results }}" + register: server + until: server.finished + retries: 300 + delay: 10 + notify: + - "Populate instance config dict Linux" + - "Convert instance config dict to a list" + - "Dump instance config" + +- name: Wait for SSH + wait_for: + port: 22 + host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" + search_regex: SSH + delay: 10 + loop: "{{ server.results }}" diff --git a/packages/molecule-gce/test/scenarios/linux/tasks/create_windows_instance.yml b/packages/molecule-gce/test/scenarios/linux/tasks/create_windows_instance.yml new file mode 100644 index 00000000..9c0fe090 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/tasks/create_windows_instance.yml @@ -0,0 +1,63 @@ +--- +- name: create molecule Windows instance(s) + google.cloud.gcp_compute_instance: + state: present + name: "{{ item.name }}" + machine_type: "{{ item.machine_type | default('n1-standard-1') }}" + disks: + - auto_delete: true + boot: true + initialize_params: + disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" + source_image: "{{ item.image | default('projects/windows-cloud/global/images/family/windows-2019') }}" + source_image_encryption_key: + raw_key: "{{ item.image_encryption_key | default(omit) }}" + network_interfaces: "{{ [ { 'network': my_network.resources.0 | default(omit), 'subnetwork': my_subnetwork.resources.0 | default(omit) } | combine(external_access_config | default([])) ] }}" + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ gcp_project_id }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + +- name: Wait for instance(s) creation to complete + async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ async_results.results }}" + register: server + until: server.finished + retries: 300 + delay: 10 + notify: + - "Populate instance config dict Windows" + - "Convert instance config dict to a list" + - "Dump instance config" + +- name: Wait for WinRM + wait_for: + port: 5986 + host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" + delay: 10 + loop: "{{ server.results }}" + +- name: Prepare Windows User + script: ./files/windows_auth.py --instance {{ item.name }} --zone {{ item.zone | default(molecule_yml.driver.region + '-b') }} --project {{ gcp_project_id }} --username molecule_usr + args: + executable: python3 + loop: "{{ molecule_yml.platforms }}" + changed_when: + - password.rc == 0 + - password.stdout + register: password + retries: 10 + delay: 10 + +- name: Add password for instances in server list + set_fact: + win_instances: "{{ win_instances|default([]) + [dict(item[0], password=item[1].stdout_lines |last)] }}" + loop: "{{ server.results | zip(password.results) | list }}" + no_log: true diff --git a/packages/molecule-gce/test/scenarios/linux/verify.yml b/packages/molecule-gce/test/scenarios/linux/verify.yml new file mode 100644 index 00000000..79044cd0 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/linux/verify.yml @@ -0,0 +1,10 @@ +--- +# This is an example playbook to execute Ansible tests. + +- name: Verify + hosts: all + gather_facts: false + tasks: + - name: Example assertion + assert: + that: true diff --git a/packages/molecule-gce/test/scenarios/windows/INSTALL.md b/packages/molecule-gce/test/scenarios/windows/INSTALL.md new file mode 100644 index 00000000..39c952e0 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/INSTALL.md @@ -0,0 +1,19 @@ +# Google Cloud Engine driver installation guide + +## Requirements + +- A GCE credentials rc file + +## 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`. + +``` bash +$ pip install 'molecule_gce' +``` + + [Virtual environment]: https://virtualenv.pypa.io/en/latest/ + ['--user' flag]: https://packaging.python.org/tutorials/installing-packages/#installing-to-the-user-site diff --git a/packages/molecule-gce/test/scenarios/windows/converge.yml b/packages/molecule-gce/test/scenarios/windows/converge.yml new file mode 100644 index 00000000..93dbbab5 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/converge.yml @@ -0,0 +1,7 @@ +--- +- name: Converge + hosts: all + tasks: + - name: "Include testrole" + include_role: + name: "testrole" diff --git a/packages/molecule-gce/test/scenarios/windows/create.yml b/packages/molecule-gce/test/scenarios/windows/create.yml new file mode 100644 index 00000000..1b525f9b --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/create.yml @@ -0,0 +1,60 @@ +--- +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + ssh_identity_file: "{{ lookup('env', 'MOLECULE_EPHEMERAL_DIRECTORY') }}/ssh_key" + gcp_project_id: "{{ molecule_yml.driver.project_id | default(lookup('env', 'GCE_PROJECT_ID')) }}" + + tasks: + - name: Make sure if linux or windows either specified + assert: + that: + - molecule_yml.driver.instance_os_type | lower == "linux" or + molecule_yml.driver.instance_os_type | lower == "windows" + fail_msg: "instance_os_type is possible only to specify linux or windows either" + + - name: get network info + google.cloud.gcp_compute_network_info: + filters: + - name = "{{ molecule_yml.driver.network_name | default('default') }}" + project: "{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: my_network + + - name: get subnetwork info + google.cloud.gcp_compute_subnetwork_info: + filters: + - name = "{{ molecule_yml.driver.subnetwork_name | default('default') }}" + project: "{{ molecule_yml.driver.vpc_host_project | default(gcp_project_id) }}" + region: "{{ molecule_yml.driver.region }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: my_subnetwork + + - name: set external access config + set_fact: + external_access_config: + - access_configs: + - name: "External NAT" + type: "ONE_TO_NAT" + when: molecule_yml.driver.external_access + + - name: Include create_linux_instance tasks + include_tasks: tasks/create_linux_instance.yml + when: + - molecule_yml.driver.instance_os_type | lower == "linux" + + - name: Include create_windows_instance tasks + include_tasks: tasks/create_windows_instance.yml + when: + - molecule_yml.driver.instance_os_type | lower == "windows" + + handlers: + - name: Import main handler tasks + import_tasks: handlers/main.yml diff --git a/packages/molecule-gce/test/scenarios/windows/destroy.yml b/packages/molecule-gce/test/scenarios/windows/destroy.yml new file mode 100644 index 00000000..deb86bb1 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/destroy.yml @@ -0,0 +1,38 @@ +--- +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + + tasks: + - name: Destroy molecule instance(s) + google.cloud.gcp_compute_instance: + name: "{{ item.name }}" + state: absent + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ molecule_yml.driver.project_id | default(lookup('env', 'GCE_PROJECT_ID')) }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + notify: + - "Wipe out instance config" + - "Dump instance config" + + - name: Wait for instance(s) deletion to complete + async_status: + jid: "{{ item.ansible_job_id }}" + register: server + until: server.finished + retries: 300 + delay: 10 + loop: "{{ async_results.results }}" + + handlers: + - name: Import main handler tasks + import_tasks: handlers/main.yml diff --git a/packages/molecule-gce/test/scenarios/windows/files/windows_auth.py b/packages/molecule-gce/test/scenarios/windows/files/windows_auth.py new file mode 100644 index 00000000..99963ff7 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/files/windows_auth.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python + +# Copyright 2015 Google Inc. All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import base64 +import copy +import datetime +import json +import time +import argparse + +# PyCrypto library: https://pypi.python.org/pypi/pycrypto +from Crypto.Cipher import PKCS1_OAEP +from Crypto.PublicKey import RSA +from Crypto.Util.number import long_to_bytes + +# Google API Client Library for Python: +# https://developers.google.com/api-client-library/python/start/get_started +from oauth2client.client import GoogleCredentials +from googleapiclient.discovery import build + + +def GetCompute(): + """Get a compute object for communicating with the Compute Engine API.""" + credentials = GoogleCredentials.get_application_default() + compute = build("compute", "v1", credentials=credentials) + return compute + + +def GetInstance(compute, instance, zone, project): + """Get the data for a Google Compute Engine instance.""" + cmd = compute.instances().get(instance=instance, project=project, zone=zone) + return cmd.execute() + + +def GetKey(): + """Get an RSA key for encryption.""" + # This uses the PyCrypto library + key = RSA.generate(2048) + return key + + +def GetModulusExponentInBase64(key): + """Return the public modulus and exponent for the key in bas64 encoding.""" + mod = long_to_bytes(key.n) + exp = long_to_bytes(key.e) + + modulus = base64.b64encode(mod) + exponent = base64.b64encode(exp) + + return modulus, exponent + + +def GetExpirationTimeString(): + """Return an RFC3339 UTC timestamp for 5 minutes from now.""" + utc_now = datetime.datetime.utcnow() + # These metadata entries are one-time-use, so the expiration time does + # not need to be very far in the future. In fact, one minute would + # generally be sufficient. Five minutes allows for minor variations + # between the time on the client and the time on the server. + expire_time = utc_now + datetime.timedelta(minutes=5) + return expire_time.strftime("%Y-%m-%dT%H:%M:%SZ") + + +def GetJsonString(user, modulus, exponent, email): + """Return the JSON string object that represents the windows-keys entry.""" + + converted_modulus = modulus.decode("utf-8") + converted_exponent = exponent.decode("utf-8") + + expire = GetExpirationTimeString() + data = { + "userName": user, + "modulus": converted_modulus, + "exponent": converted_exponent, + "email": email, + "expireOn": expire, + } + + return json.dumps(data) + + +def UpdateWindowsKeys(old_metadata, metadata_entry): + """Return updated metadata contents with the new windows-keys entry.""" + # Simply overwrites the "windows-keys" metadata entry. Production code may + # want to append new lines to the metadata value and remove any expired + # entries. + new_metadata = copy.deepcopy(old_metadata) + new_metadata["items"] = [{"key": "windows-keys", "value": metadata_entry}] + return new_metadata + + +def UpdateInstanceMetadata(compute, instance, zone, project, new_metadata): + """Update the instance metadata.""" + cmd = compute.instances().setMetadata( + instance=instance, project=project, zone=zone, body=new_metadata + ) + return cmd.execute() + + +def GetSerialPortFourOutput(compute, instance, zone, project): + """Get the output from serial port 4 from the instance.""" + # Encrypted passwords are printed to COM4 on the windows server: + port = 4 + cmd = compute.instances().getSerialPortOutput( + instance=instance, project=project, zone=zone, port=port + ) + output = cmd.execute() + return output["contents"] + + +def GetEncryptedPasswordFromSerialPort(serial_port_output, modulus): + """Find and return the correct encrypted password, based on the modulus.""" + # In production code, this may need to be run multiple times if the output + # does not yet contain the correct entry. + + converted_modulus = modulus.decode("utf-8") + + output = serial_port_output.split("\n") + for line in reversed(output): + try: + entry = json.loads(line) + if converted_modulus == entry["modulus"]: + return entry["encryptedPassword"] + except ValueError: + pass + + +def DecryptPassword(encrypted_password, key): + """Decrypt a base64 encoded encrypted password using the provided key.""" + + decoded_password = base64.b64decode(encrypted_password) + cipher = PKCS1_OAEP.new(key) + password = cipher.decrypt(decoded_password) + return password + + +def Arguments(): + # Create the parser + args = argparse.ArgumentParser(description="List the content of a folder") + + # Add the arguments + args.add_argument( + "--instance", metavar="instance", type=str, help="compute instance name" + ) + + args.add_argument("--zone", metavar="zone", type=str, help="compute zone") + + args.add_argument("--project", metavar="project", type=str, help="gcp project") + + args.add_argument("--username", metavar="username", type=str, help="username") + + args.add_argument("--email", metavar="email", type=str, help="email") + + # return arguments + return args.parse_args() + + +def main(): + config_args = Arguments() + + # Setup + compute = GetCompute() + key = GetKey() + modulus, exponent = GetModulusExponentInBase64(key) + + # Get existing metadata + instance_ref = GetInstance( + compute, config_args.instance, config_args.zone, config_args.project + ) + old_metadata = instance_ref["metadata"] + # Create and set new metadata + metadata_entry = GetJsonString( + config_args.username, modulus, exponent, config_args.email + ) + new_metadata = UpdateWindowsKeys(old_metadata, metadata_entry) + + # Get Serial output BEFORE the modification + serial_port_output = GetSerialPortFourOutput( + compute, config_args.instance, config_args.zone, config_args.project + ) + + UpdateInstanceMetadata( + compute, + config_args.instance, + config_args.zone, + config_args.project, + new_metadata, + ) + + # Get and decrypt password from serial port output + # Monitor changes from output to get the encrypted password as soon as it's generated, will wait for 30 seconds + i = 0 + new_serial_port_output = serial_port_output + while i <= 30 and serial_port_output == new_serial_port_output: + new_serial_port_output = GetSerialPortFourOutput( + compute, config_args.instance, config_args.zone, config_args.project + ) + i += 1 + time.sleep(1) + + enc_password = GetEncryptedPasswordFromSerialPort(new_serial_port_output, modulus) + + password = DecryptPassword(enc_password, key) + converted_password = password.decode("utf-8") + + # Display only the password + print(format(converted_password)) + + +if __name__ == "__main__": + main() diff --git a/packages/molecule-gce/test/scenarios/windows/handlers/main.yml b/packages/molecule-gce/test/scenarios/windows/handlers/main.yml new file mode 100644 index 00000000..12591378 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/handlers/main.yml @@ -0,0 +1,49 @@ +--- +- name: Populate instance config dict Linux + set_fact: + instance_conf_dict: { + 'instance': "{{ instance_info.name }}", + 'address': "{{ instance_info.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else instance_info.networkInterfaces.0.networkIP }}", + 'user': "{{ lookup('env','USER') }}", + 'port': "22", + 'identity_file': "{{ ssh_identity_file }}", + 'instance_os_type': "{{ molecule_yml.driver.instance_os_type }}" + } + loop: "{{ server.results }}" + loop_control: + loop_var: instance_info + no_log: true + register: instance_conf_dict + +- name: Populate instance config dict Windows + set_fact: + instance_conf_dict: { + 'instance': "{{ instance_info.name }}", + 'address': "{{ instance_info.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else instance_info.networkInterfaces.0.networkIP }}", + 'user': "molecule_usr", + 'password': "{{ instance_info.password }}", + 'port': "{{ instance_info.winrm_port | default(5986) }}", + 'winrm_transport': "{{ molecule_yml.driver.winrm_transport | default('ntlm') }}", + 'winrm_server_cert_validation': "{{ molecule_yml.driver.winrm_server_cert_validation | default('ignore') }}", + 'instance_os_type': "{{ molecule_yml.driver.instance_os_type }}" + } + loop: "{{ win_instances }}" + loop_control: + loop_var: instance_info + no_log: true + register: instance_conf_dict + + +- name: Wipe out instance config + set_fact: + instance_conf: {} + +- name: Convert instance config dict to a list + set_fact: + instance_conf: "{{ instance_conf_dict.results | map(attribute='ansible_facts.instance_conf_dict') | list }}" + +- name: Dump instance config + copy: + content: "{{ instance_conf }}" + dest: "{{ molecule_instance_config }}" + mode: '0600' diff --git a/packages/molecule-gce/test/scenarios/windows/molecule.yml b/packages/molecule-gce/test/scenarios/windows/molecule.yml new file mode 100644 index 00000000..cac86bb3 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/molecule.yml @@ -0,0 +1,20 @@ +--- +dependency: + name: galaxy +driver: + name: gce + project_id: change-to-id-of-the-gcp-project # if not set, will default to env GCE_PROJECT_ID + auth_kind: null # set to machineaccount or serviceaccount or application - if set to null will read env GCP_AUTH_KIND + service_account_email: null # set to an email associated with the project - if set to null, will default to GCP_SERVICE_ACCOUNT_EMAIL. Should not be set if using auth_kind serviceaccount. + service_account_file: null # set to the path to the JSON credentials file - if set to null, will default to env GCP_SERVICE_ACCOUNT_FILE + region: us-west1 # REQUIRED. example: us-central1 + external_access: false # chose whether to create a public IP for the VM or not - default is private IP only + instance_os_type: windows # will be considered linux by default, but can be explicitely set to windows +platforms: + - name: linuxgce-createdbymolecule # is an instance name + machine_type: n1-standard-1 # define your machine type + zone: null # example: us-west1-b, will default to zone b of driver.region +provisioner: + name: ansible +verifier: + name: ansible diff --git a/packages/molecule-gce/test/scenarios/windows/prepare.yml b/packages/molecule-gce/test/scenarios/windows/prepare.yml new file mode 100644 index 00000000..7972c13d --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/prepare.yml @@ -0,0 +1,7 @@ +--- +- name: Prepare + hosts: all + gather_facts: false + tasks: + - name: Wait 600 seconds for target connection to become reachable/usable + wait_for_connection: diff --git a/packages/molecule-gce/test/scenarios/windows/requirements.yml b/packages/molecule-gce/test/scenarios/windows/requirements.yml new file mode 100644 index 00000000..808bcd77 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/requirements.yml @@ -0,0 +1,3 @@ +collections: + - name: google.cloud + source: https://galaxy.ansible.com diff --git a/packages/molecule-gce/test/scenarios/windows/tasks/create_linux_instance.yml b/packages/molecule-gce/test/scenarios/windows/tasks/create_linux_instance.yml new file mode 100644 index 00000000..bb4f6889 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/tasks/create_linux_instance.yml @@ -0,0 +1,56 @@ +--- +- name: create ssh keypair + openssh_keypair: + comment: "{{ lookup('env','USER') }} user for Molecule" + path: "{{ ssh_identity_file }}" + register: keypair + +- name: create molecule Linux instance(s) + google.cloud.gcp_compute_instance: + state: present + name: "{{ item.name }}" + machine_type: "{{ item.machine_type | default('n1-standard-1') }}" + metadata: + ssh-keys: "{{ lookup('env','USER') }}:{{ keypair.public_key }}}" + disks: + - auto_delete: true + boot: true + initialize_params: + disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" + source_image: "{{ item.image | default('projects/debian-cloud/global/images/family/debian-10') }}" + source_image_encryption_key: + raw_key: "{{ item.image_encryption_key | default(omit) }}" + network_interfaces: "{{ [ { 'network': my_network.resources.0 | default(omit), 'subnetwork': my_subnetwork.resources.0 | default(omit) } | combine(external_access_config | default([]) ) ] }}" + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ gcp_project_id }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + loop_control: + pause: 3 + async: 7200 + poll: 0 + +- name: Wait for instance(s) creation to complete + async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ async_results.results }}" + register: server + until: server.finished + retries: 300 + delay: 10 + notify: + - "Populate instance config dict Linux" + - "Convert instance config dict to a list" + - "Dump instance config" + +- name: Wait for SSH + wait_for: + port: 22 + host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" + search_regex: SSH + delay: 10 + loop: "{{ server.results }}" diff --git a/packages/molecule-gce/test/scenarios/windows/tasks/create_windows_instance.yml b/packages/molecule-gce/test/scenarios/windows/tasks/create_windows_instance.yml new file mode 100644 index 00000000..9c0fe090 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/tasks/create_windows_instance.yml @@ -0,0 +1,63 @@ +--- +- name: create molecule Windows instance(s) + google.cloud.gcp_compute_instance: + state: present + name: "{{ item.name }}" + machine_type: "{{ item.machine_type | default('n1-standard-1') }}" + disks: + - auto_delete: true + boot: true + initialize_params: + disk_size_gb: "{{ item.disk_size_gb | default(omit) }}" + source_image: "{{ item.image | default('projects/windows-cloud/global/images/family/windows-2019') }}" + source_image_encryption_key: + raw_key: "{{ item.image_encryption_key | default(omit) }}" + network_interfaces: "{{ [ { 'network': my_network.resources.0 | default(omit), 'subnetwork': my_subnetwork.resources.0 | default(omit) } | combine(external_access_config | default([])) ] }}" + zone: "{{ item.zone | default(molecule_yml.driver.region + '-b') }}" + project: "{{ gcp_project_id }}" + scopes: "{{ molecule_yml.driver.scopes | default(['https://www.googleapis.com/auth/compute'], True) }}" + service_account_email: "{{ molecule_yml.driver.service_account_email | default (omit, true) }}" + service_account_file: "{{ molecule_yml.driver.service_account_file | default (omit, true) }}" + auth_kind: "{{ molecule_yml.driver.auth_kind | default(omit, true) }}" + register: async_results + loop: "{{ molecule_yml.platforms }}" + async: 7200 + poll: 0 + +- name: Wait for instance(s) creation to complete + async_status: + jid: "{{ item.ansible_job_id }}" + loop: "{{ async_results.results }}" + register: server + until: server.finished + retries: 300 + delay: 10 + notify: + - "Populate instance config dict Windows" + - "Convert instance config dict to a list" + - "Dump instance config" + +- name: Wait for WinRM + wait_for: + port: 5986 + host: "{{ item.networkInterfaces.0.accessConfigs.0.natIP if molecule_yml.driver.external_access else item.networkInterfaces.0.networkIP }}" + delay: 10 + loop: "{{ server.results }}" + +- name: Prepare Windows User + script: ./files/windows_auth.py --instance {{ item.name }} --zone {{ item.zone | default(molecule_yml.driver.region + '-b') }} --project {{ gcp_project_id }} --username molecule_usr + args: + executable: python3 + loop: "{{ molecule_yml.platforms }}" + changed_when: + - password.rc == 0 + - password.stdout + register: password + retries: 10 + delay: 10 + +- name: Add password for instances in server list + set_fact: + win_instances: "{{ win_instances|default([]) + [dict(item[0], password=item[1].stdout_lines |last)] }}" + loop: "{{ server.results | zip(password.results) | list }}" + no_log: true diff --git a/packages/molecule-gce/test/scenarios/windows/verify.yml b/packages/molecule-gce/test/scenarios/windows/verify.yml new file mode 100644 index 00000000..79044cd0 --- /dev/null +++ b/packages/molecule-gce/test/scenarios/windows/verify.yml @@ -0,0 +1,10 @@ +--- +# This is an example playbook to execute Ansible tests. + +- name: Verify + hosts: all + gather_facts: false + tasks: + - name: Example assertion + assert: + that: true diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..490c397a --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,8 @@ +[build-system] +requires = [ + "setuptools >= 41.0.0", + "setuptools_scm >= 1.15.0", + "setuptools_scm_git_archive >= 1.0", + "wheel", +] +build-backend = "setuptools.build_meta" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..bee30b3a --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +addopts = -v -rxXs --doctest-modules --durations 10 --cov=molecule_* --cov-report term-missing:skip-covered --no-cov-on-fail +doctest_optionflags = ALLOW_UNICODE ELLIPSIS +junit_suite_name = molecule_test_suite +norecursedirs = dist doc build .tox .eggs test/scenarios test/resources diff --git a/requirements.yml b/requirements.yml new file mode 100644 index 00000000..ac6a3656 --- /dev/null +++ b/requirements.yml @@ -0,0 +1,2 @@ +collections: + - name: google.cloud diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 00000000..3cfd6f1e --- /dev/null +++ b/setup.cfg @@ -0,0 +1,67 @@ +[metadata] +name = molecule-plugins +url = https://github.com/ansible-community/molecule-plugins +project_urls = + Bug Tracker = https://github.com/ansible-community/molecule-plugins/issues + Release Management = https://github.com/ansible-community/molecule-plugins/releases + CI = https://github.com/ansible-community/molecule-plugins/actions + + Documentation = https://molecule.readthedocs.io + Mailing lists = https://docs.ansible.com/ansible/latest/community/communication.html#mailing-list-information + Source Code = https://github.com/ansible-community/molecule-plugins +description = Molecule Plugins +long_description = file: README.md +long_description_content_type = text/markdown +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 + Framework :: Pytest + 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 :: 3 + Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.9 + Programming Language :: Python :: 3.10 + + Topic :: System :: Systems Administration + Topic :: Utilities + +keywords = + ansible + testing + molecule + plugin + +[options] +use_scm_version = True +python_requires = >=3.6 +package_dir = + = src +include_package_data = True +zip_safe = False + +# These are required in actual runtime: +install_requires = + # molecule plugins are not allowed to mention Ansible as a direct dependency + molecule >= 4.0 + molecule-azure + molecule-gce + +[options.extras_require] +test = + molecule[ansible,test] + pytest-helpers-namespace >= 2019.1.8 + +[options.packages.find] +where = src diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tox.ini b/tox.ini new file mode 100644 index 00000000..f87481cb --- /dev/null +++ b/tox.ini @@ -0,0 +1,83 @@ +# For more information about tox, see https://tox.readthedocs.io/en/latest/ +[tox] +minversion = 3.9.0 +envlist = + lint + packaging + py + py-{devel} + +# do not enable skip missing to avoid CI false positives +skip_missing_interpreters = False +isolated_build = True + +[testenv] +description = + Unit testing +usedevelop = True +# download assures tox gets newest pip, see https://github.com/tox-dev/tox/issues/791 +download = true +# sitepackages = True +extras = test +deps = + py: molecule[test] + py-{devel}: git+https://github.com/ansible-community/molecule.git@main#egg=molecule[test] + -e packages/molecule-azure + -e packages/molecule-gce +commands = + pytest --collect-only + pytest --color=yes {tty:-s} +setenv = + ANSIBLE_FORCE_COLOR={env:ANSIBLE_FORCE_COLOR:1} + ANSIBLE_INVENTORY={toxinidir}/tests/hosts.ini + ANSIBLE_CONFIG={toxinidir}/ansible.cfg + ANSIBLE_NOCOWS=1 + ANSIBLE_RETRY_FILES_ENABLED=0 + ANSIBLE_STDOUT_CALLBACK={env:ANSIBLE_STDOUT_CALLBACK:debug} + ANSIBLE_VERBOSITY={env:ANSIBLE_VERBOSITY:0} + MOLECULE_NO_LOG={env:MOLECULE_NO_LOG:0} + PIP_DISABLE_PIP_VERSION_CHECK=1 + PY_COLORS={env:PY_COLORS:1} + # pip: Avoid 2020-01-01 warnings: https://github.com/pypa/pip/issues/6207 + PYTHONWARNINGS=ignore:DEPRECATION::pip._internal.cli.base_command + PYTHONDONTWRITEBYTECODE=1 + # This should pass these args to molecule, no effect here as this is the default + # but it validates that it accepts extra params. + MOLECULE_OPTS=--destroy always +passenv = + CI + CURL_CA_BUNDLE + DOCKER_* + PYTEST_OPTIONS + REQUESTS_CA_BUNDLE + SSH_AUTH_SOCK + SSL_CERT_FILE + TOXENV + TWINE_* +whitelist_externals = + bash + twine + pytest + pre-commit + +[testenv:packaging] +usedevelop = false +skip_install = true +deps = + collective.checkdocs >= 0.2 + pep517 >= 0.5.0 + twine >= 2.0.0 + build +commands = + bash -c "rm -rf {toxinidir}/dist/ && mkdir -p {toxinidir}/dist/" + ./build.sh + twine check dist/* + +[testenv:lint] +description = Performs linting, style checks +skip_install = true +sitepackages = false +deps = + pre-commit +commands = + pre-commit run -a