diff --git a/.github/workflows/tox.yml b/.github/workflows/tox.yml index bd4fca6f..45c9643d 100644 --- a/.github/workflows/tox.yml +++ b/.github/workflows/tox.yml @@ -34,7 +34,7 @@ jobs: runs-on: ubuntu-22.04 needs: pre env: - PYTEST_REQPASS: 3 + PYTEST_REQPASS: 4 strategy: fail-fast: false matrix: ${{ fromJson(needs.pre.outputs.matrix) }} diff --git a/pyproject.toml b/pyproject.toml index 261081c2..b695484f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,11 +55,22 @@ test = [ "molecule[test]" ] azure = [] +docker = [ + # selinux python module is needed as least by ansible-docker modules + # and allows use of isolated (default) virtualenvs. It does not avoid need + # to install the system selinux libraries but it will provide a clear + # message when user has to do that. + 'selinux; sys_platform=="linux2"', + 'selinux; sys_platform=="linux"', + "docker >= 4.3.1", + "requests" # also required by docker +] gce = [] [project.entry-points."molecule.driver"] azure = "molecule_plugins.azure.driver:Azure" +docker = "molecule_plugins.docker.driver:Docker" gce = "molecule_plugins.gce.driver:GCE" [tool.setuptools_scm] diff --git a/requirements.yml b/requirements.yml index ac6a3656..0c5524a8 100644 --- a/requirements.yml +++ b/requirements.yml @@ -1,2 +1,6 @@ collections: - name: google.cloud + - name: community.docker + version: ">=3.0.2" + - name: ansible.posix # docker + version: ">=1.4.0" diff --git a/src/molecule_plugins/docker/__init__.py b/src/molecule_plugins/docker/__init__.py new file mode 100644 index 00000000..0901866e --- /dev/null +++ b/src/molecule_plugins/docker/__init__.py @@ -0,0 +1 @@ +"""Molecule Docker Driver.""" diff --git a/src/molecule_plugins/docker/cookiecutter/cookiecutter.json b/src/molecule_plugins/docker/cookiecutter/cookiecutter.json new file mode 100644 index 00000000..2ec6fb29 --- /dev/null +++ b/src/molecule_plugins/docker/cookiecutter/cookiecutter.json @@ -0,0 +1,5 @@ +{ + "molecule_directory": "molecule", + "role_name": "OVERRIDDEN", + "scenario_name": "OVERRIDDEN" +} diff --git a/src/molecule_plugins/docker/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml b/src/molecule_plugins/docker/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml new file mode 100644 index 00000000..fecf1c84 --- /dev/null +++ b/src/molecule_plugins/docker/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml @@ -0,0 +1,7 @@ +--- +- name: Converge + hosts: all + tasks: + - name: "Include {{ cookiecutter.role_name }}" + include_role: + name: "{{ cookiecutter.role_name }}" diff --git a/src/molecule_plugins/docker/driver.py b/src/molecule_plugins/docker/driver.py new file mode 100644 index 00000000..cce46cc0 --- /dev/null +++ b/src/molecule_plugins/docker/driver.py @@ -0,0 +1,275 @@ +# 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. +"""Docker Driver Module.""" + +from __future__ import absolute_import + +import os +from typing import Dict + +from molecule import logger +from molecule.api import Driver +from molecule.util import sysexit_with_message + +log = logger.get_logger(__name__) + + +class Docker(Driver): + """ + Docker Driver Class. + + The class responsible for managing `Docker`_ containers. `Docker`_ is + the default driver used in Molecule. + + Molecule leverages Ansible's `docker_container`_ module, by mapping + variables from ``molecule.yml`` into ``create.yml`` and ``destroy.yml``. + + Molecule leverages Ansible's `docker_network`_ module, by mapping variable + ``docker_networks`` into ``create.yml`` and ``destroy.yml``. + + .. _`docker_container`: https://docs.ansible.com/ansible/latest/modules/docker_container_module.html + .. _`docker_network`: https://docs.ansible.com/ansible/latest/modules/docker_network_module.html + .. _`Docker Security Configuration`: https://docs.docker.com/engine/reference/run/#security-configuration + .. _`Docker daemon socket options`: https://docs.docker.com/engine/reference/commandline/dockerd/#daemon-socket-option + + .. code-block:: yaml + + driver: + name: docker + platforms: + - name: instance + hostname: instance + image: image_name:tag + dockerfile: Dockerfile.j2 + pull: True|False + pre_build_image: True|False + registry: + url: registry.example.com + credentials: + username: $USERNAME + password: $PASSWORD + email: user@example.com + user: root + override_command: True|False + command: sleep infinity + tty: True|False + pid_mode: host + privileged: True|False + security_opts: + - seccomp=unconfined + cgroupns_mode: host|private + devices: + - /dev/fuse:/dev/fuse:rwm + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:ro + keep_volumes: True|False + tmpfs: + - /tmp + - /run + capabilities: + - SYS_ADMIN + sysctls: + net.core.somaxconn: 1024 + net.ipv4.tcp_syncookies: 0 + exposed_ports: + - 53/udp + - 53/tcp + published_ports: + - 0.0.0.0:8053:53/udp + - 0.0.0.0:8053:53/tcp + ulimits: + - nofile:262144:262144 + dns_servers: + - 8.8.8.8 + etc_hosts: "{'host1.example.com': '10.3.1.5'}" + docker_networks: + - name: foo + ipam_config: + - subnet: '10.3.1.0/24' + gateway: '10.3.1.254' + networks: + - name: foo + - name: bar + network_mode: host + purge_networks: true + docker_host: tcp://localhost:12376 + cacert_path: /foo/bar/ca.pem + cert_path: /foo/bar/cert.pem + key_path: /foo/bar/key.pem + tls_verify: true + env: + FOO: bar + restart_policy: on-failure + restart_retries: 1 + buildargs: + http_proxy: http://proxy.example.com:8080/ + + If specifying the `CMD`_ directive in your ``Dockerfile.j2`` or consuming a + built image which declares a ``CMD`` directive, then you must set + ``override_command: False``. Otherwise, Molecule takes care to honour the + value of the ``command`` key or uses the default of ``bash -c "while true; + do sleep 10000; done"`` to run the container until it is provisioned. + + When attempting to utilize a container image with `systemd`_ as your init + system inside the container to simulate a real machine, make sure to set + the ``privileged``, ``volumes``, ``command``, and ``env`` + values. An example using the ``centos:8`` image is below: + + .. note:: Do note that running containers in privileged mode is considerably + less secure. For details, please reference `Docker Security + Configuration`_ + + .. note:: On some systems (macOS) you might have to set ``cgroupns_mode`` to + ``host`` for `systemd` to work. See `Docker Desktop release + notes`_ for more information. + + .. _`Docker Desktop release notes`: https://docs.docker.com/desktop/release-notes/#bug-fixes-and-minor-changes-19 + + .. note:: With the environment variable ``DOCKER_HOST`` the user can bind + Molecule to a different `Docker`_ socket than the default + ``unix:///var/run/docker.sock``. ``tcp``, ``fd`` and ``ssh`` + socket types can be configured. For details, please reference + `Docker daemon socket options`_. + + .. code-block:: yaml + + platforms: + - name: instance + image: quay.io/centos/centos:stream8 + privileged: true + volumes: + - "/sys/fs/cgroup:/sys/fs/cgroup:rw" + command: "/usr/sbin/init" + tty: True + env: + container: docker + + .. code-block:: bash + + $ python3 -m pip install molecule[docker] + + When pulling from a private registry, it is the user's discretion to decide + whether to use hard-code strings or environment variables for passing + credentials to molecule. + + .. important:: + + Hard-coded credentials in ``molecule.yml`` should be avoided, instead use + `variable substitution`_. + + Provide a list of files Molecule will preserve, relative to the scenario + ephemeral directory, after any ``destroy`` subcommand execution. + + .. code-block:: yaml + + driver: + name: docker + safe_files: + - foo + + .. _`Docker`: https://www.docker.com + .. _`systemd`: https://www.freedesktop.org/wiki/Software/systemd/ + .. _`CMD`: https://docs.docker.com/engine/reference/builder/#cmd + """ # noqa + + _passed_sanity = False + + def __init__(self, config=None): + """Construct Docker.""" + super(Docker, self).__init__(config) + self._name = "docker" + + @property + def name(self): + return self._name + + @name.setter + def name(self, value): + self._name = value + + @property + def login_cmd_template(self): + return ( + "docker exec " + "-e COLUMNS={columns} " + "-e LINES={lines} " + "-e TERM=bash " + "-e TERM=xterm " + "-ti {instance} bash" + ) + + @property + def default_safe_files(self): + return [os.path.join(self._config.scenario.ephemeral_directory, "Dockerfile")] + + @property + def default_ssh_connection_options(self): + return [] + + def login_options(self, instance_name): + return {"instance": instance_name} + + def ansible_connection_options(self, instance_name): + x = {"ansible_connection": "community.docker.docker"} + if "DOCKER_HOST" in os.environ: + x["ansible_docker_extra_args"] = f"-H={os.environ['DOCKER_HOST']}" + return x + + def sanity_checks(self): + """Implement Docker driver sanity checks.""" + if self._passed_sanity: + return + + log.info("Sanity checks: '%s'", self._name) + try: + import docker + import requests + + docker_client = docker.from_env() + docker_client.ping() + except requests.exceptions.ConnectionError: + msg = ( + "Unable to contact the Docker daemon. " + "Please refer to https://docs.docker.com/config/daemon/ " + "for managing the daemon" + ) + sysexit_with_message(msg) + + self._passed_sanity = True + + def reset(self): + import docker + + client = docker.from_env() + for c in client.containers.list(filters={"label": "owner=molecule"}): + log.info("Stopping docker container %s ...", c.id) + c.stop(timeout=3) + result = client.containers.prune(filters={"label": "owner=molecule"}) + for c in result.get("ContainersDeleted") or []: + log.info("Deleted container %s", c) + for n in client.networks.list(filters={"label": "owner=molecule"}): + log.info("Removing docker network %s ...", n.name) + n.remove() + + @property + def required_collections(self) -> Dict[str, str]: + """Return collections dict containing names and versions required.""" + # https://galaxy.ansible.com/community/docker + return {"community.docker": "3.0.2", "ansible.posix": "1.4.0"} diff --git a/src/molecule_plugins/docker/playbooks/Dockerfile.j2 b/src/molecule_plugins/docker/playbooks/Dockerfile.j2 new file mode 100644 index 00000000..86175919 --- /dev/null +++ b/src/molecule_plugins/docker/playbooks/Dockerfile.j2 @@ -0,0 +1,22 @@ +# Molecule managed + +{% if item.registry is defined %} +FROM {{ item.registry.url }}/{{ item.image }} +{% else %} +FROM {{ item.image }} +{% endif %} + +{% if item.env is defined %} +{% for var, value in item.env.items() %} +{% if value %} +ENV {{ var }} {{ value }} +{% endif %} +{% endfor %} +{% endif %} + +RUN if [ $(command -v apt-get) ]; then export DEBIAN_FRONTEND=noninteractive && apt-get update && apt-get install -y python3 sudo bash ca-certificates iproute2 python3-apt aptitude && apt-get clean && rm -rf /var/lib/apt/lists/*; \ + elif [ $(command -v dnf) ]; then dnf makecache && dnf --assumeyes install /usr/bin/python3 /usr/bin/python3-config /usr/bin/dnf-3 sudo bash iproute && dnf clean all; \ + elif [ $(command -v yum) ]; then yum makecache fast && yum install -y /usr/bin/python /usr/bin/python2-config sudo yum-plugin-ovl bash iproute && sed -i 's/plugins=0/plugins=1/g' /etc/yum.conf && yum clean all; \ + elif [ $(command -v zypper) ]; then zypper refresh && zypper install -y python3 sudo bash iproute2 && zypper clean -a; \ + elif [ $(command -v apk) ]; then apk update && apk add --no-cache python3 sudo bash ca-certificates; \ + elif [ $(command -v xbps-install) ]; then xbps-install -Syu && xbps-install -y python3 sudo bash ca-certificates iproute2 && xbps-remove -O; fi diff --git a/src/molecule_plugins/docker/playbooks/create.yml b/src/molecule_plugins/docker/playbooks/create.yml new file mode 100644 index 00000000..3f5acf4a --- /dev/null +++ b/src/molecule_plugins/docker/playbooks/create.yml @@ -0,0 +1,191 @@ +--- +- name: Create + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + vars: + molecule_labels: + owner: molecule + tasks: + - name: Set async_dir for HOME env + ansible.builtin.set_fact: + ansible_async_dir: "{{ lookup('env', 'HOME') }}/.ansible_async/" + when: (lookup('env', 'HOME')) + + - name: Log into a Docker registry + community.docker.docker_login: + username: "{{ item.registry.credentials.username }}" + password: "{{ item.registry.credentials.password }}" + email: "{{ item.registry.credentials.email | default(omit) }}" + registry: "{{ item.registry.url }}" + docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" + cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" + with_items: "{{ molecule_yml.platforms }}" + when: + - item.registry is defined + - item.registry.credentials is defined + - item.registry.credentials.username is defined + no_log: true + + - name: Check presence of custom Dockerfiles + ansible.builtin.stat: + path: "{{ molecule_scenario_directory + '/' + (item.dockerfile | default('Dockerfile.j2')) }}" + loop: "{{ molecule_yml.platforms }}" + register: dockerfile_stats + + - name: Create Dockerfiles from image names + ansible.builtin.template: + # when using embedded playbooks the dockerfile is alongside them + src: "{%- if dockerfile_stats.results[i].stat.exists -%}\ + {{ molecule_scenario_directory + '/' + (item.dockerfile | default('Dockerfile.j2')) }}\ + {%- else -%}\ + {{ playbook_dir + '/Dockerfile.j2' }}\ + {%- endif -%}" + dest: "{{ molecule_ephemeral_directory }}/Dockerfile_{{ item.image | regex_replace('[^a-zA-Z0-9_]', '_') }}" + mode: "0600" + loop: "{{ molecule_yml.platforms }}" + loop_control: + index_var: i + when: not item.pre_build_image | default(false) + register: platforms + + - name: Synchronization the context + ansible.posix.synchronize: + src: "{%- if dockerfile_stats.results[i].stat.exists -%}\ + {{ molecule_scenario_directory + '/' }}\ + {%- else -%}\ + {{ playbook_dir + '/' }}\ + {%- endif -%}" + dest: "{{ molecule_ephemeral_directory }}" + rsync_opts: + - "--exclude=molecule.yml" + loop: "{{ molecule_yml.platforms }}" + loop_control: + index_var: i + when: not item.pre_build_image | default(false) + delegate_to: localhost + + - name: Discover local Docker images + community.docker.docker_image_info: + name: "molecule_local/{{ item.item.name }}" + docker_host: "{{ item.item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" + cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" + with_items: "{{ platforms.results }}" + when: + - not item.pre_build_image | default(false) + register: docker_images + + - name: Build an Ansible compatible image (new) # noqa: no-handler + when: + - platforms.changed or docker_images.results | map(attribute='images') | select('equalto', []) | list | count >= 0 + - not item.item.pre_build_image | default(false) + community.docker.docker_image: + build: + path: "{{ molecule_ephemeral_directory }}" + dockerfile: "{{ item.invocation.module_args.dest }}" + pull: "{{ item.item.pull | default(true) }}" + network: "{{ item.item.network_mode | default(omit) }}" + args: "{{ item.item.buildargs | default(omit) }}" + platform: "{{ item.item.platform | default(omit) }}" + name: "molecule_local/{{ item.item.image }}" + docker_host: "{{ item.item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" + cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" + force_source: "{{ item.item.force | default(true) }}" + source: build + with_items: "{{ platforms.results }}" + loop_control: + label: "molecule_local/{{ item.item.image }}" + no_log: false + register: result + until: result is not failed + retries: 3 + delay: 30 + + - name: Create docker network(s) + ansible.builtin.include_tasks: tasks/create_network.yml + with_items: "{{ molecule_yml.platforms | molecule_get_docker_networks(molecule_labels) }}" + loop_control: + label: "{{ item.name }}" + no_log: false + + - name: Determine the CMD directives + ansible.builtin.set_fact: + command_directives_dict: >- + {{ command_directives_dict | default({}) | + combine({item.name: item.command | default('bash -c "while true; do sleep 10000; done"')}) + }} + with_items: "{{ molecule_yml.platforms }}" + when: item.override_command | default(true) + + - name: Create molecule instance(s) + community.docker.docker_container: + name: "{{ item.name }}" + docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" + cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" + hostname: "{{ item.hostname | default(item.name) }}" + image: "{{ item.pre_build_image | default(false) | ternary('', 'molecule_local/') }}{{ item.image }}" + pull: "{{ item.pull | default(omit) }}" + memory: "{{ item.memory | default(omit) }}" + memory_swap: "{{ item.memory_swap | default(omit) }}" + state: started + recreate: false + log_driver: json-file + command: "{{ (command_directives_dict | default({}))[item.name] | default(omit) }}" + command_handling: "{{ item.command_handling | default('compatibility') }}" + user: "{{ item.user | default(omit) }}" + pid_mode: "{{ item.pid_mode | default(omit) }}" + privileged: "{{ item.privileged | default(omit) }}" + security_opts: "{{ item.security_opts | default(omit) }}" + devices: "{{ item.devices | default(omit) }}" + links: "{{ item.links | default(omit) }}" + volumes: "{{ item.volumes | default(omit) }}" + mounts: "{{ item.mounts | default(omit) }}" + tmpfs: "{{ item.tmpfs | default(omit) }}" + capabilities: "{{ item.capabilities | default(omit) }}" + sysctls: "{{ item.sysctls | default(omit) }}" + exposed_ports: "{{ item.exposed_ports | default(omit) }}" + published_ports: "{{ item.published_ports | default(omit) }}" + ulimits: "{{ item.ulimits | default(omit) }}" + networks: "{{ item.networks | default(omit) }}" + network_mode: "{{ item.network_mode | default(omit) }}" + networks_cli_compatible: "{{ item.networks_cli_compatible | default(true) }}" + purge_networks: "{{ item.purge_networks | default(omit) }}" + dns_servers: "{{ item.dns_servers | default(omit) }}" + etc_hosts: "{{ item.etc_hosts | default(omit) }}" + env: "{{ item.env | default(omit) }}" + restart_policy: "{{ item.restart_policy | default(omit) }}" + restart_retries: "{{ item.restart_retries | default(omit) }}" + tty: "{{ item.tty | default(omit) }}" + labels: "{{ molecule_labels | combine(item.labels | default({})) }}" + container_default_behavior: "{{ item.container_default_behavior | default('compatibility' if ansible_version.full is version_compare('2.10', '>=') else omit) }}" + stop_signal: "{{ item.stop_signal | default(omit) }}" + kill_signal: "{{ item.kill_signal | default(omit) }}" + cgroupns_mode: "{{ item.cgroupns_mode | default(omit) }}" + register: server + with_items: "{{ molecule_yml.platforms }}" + loop_control: + label: "{{ item.name }}" + no_log: false + async: 7200 + poll: 0 + + - name: Wait for instance(s) creation to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: docker_jobs + until: docker_jobs.finished + retries: 300 + with_items: "{{ server.results }}" diff --git a/src/molecule_plugins/docker/playbooks/destroy.yml b/src/molecule_plugins/docker/playbooks/destroy.yml new file mode 100644 index 00000000..e0a20fc4 --- /dev/null +++ b/src/molecule_plugins/docker/playbooks/destroy.yml @@ -0,0 +1,48 @@ +--- +- name: Destroy + hosts: localhost + connection: local + gather_facts: false + no_log: "{{ molecule_no_log }}" + tasks: + - name: Set async_dir for HOME env + ansible.builtin.set_fact: + ansible_async_dir: "{{ lookup('env', 'HOME') }}/.ansible_async/" + when: (lookup('env', 'HOME')) + + - name: Destroy molecule instance(s) + community.docker.docker_container: + name: "{{ item.name }}" + docker_host: "{{ item.docker_host | default(lookup('env', 'DOCKER_HOST') or 'unix://var/run/docker.sock') }}" + cacert_path: "{{ item.cacert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/ca.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + cert_path: "{{ item.cert_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/cert.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + key_path: "{{ item.key_path | default((lookup('env', 'DOCKER_CERT_PATH') + '/key.pem') if lookup('env', 'DOCKER_CERT_PATH') else omit) }}" + tls_verify: "{{ item.tls_verify | default(lookup('env', 'DOCKER_TLS_VERIFY')) or false }}" + state: absent + force_kill: "{{ item.force_kill | default(true) }}" + keep_volumes: "{{ item.keep_volumes | default(true) }}" + container_default_behavior: "{{ item.container_default_behavior | default('compatibility' if ansible_version.full is version_compare('2.10', '>=') else omit) }}" + register: server + loop: "{{ molecule_yml.platforms }}" + loop_control: + label: "{{ item.name }}" + no_log: false + async: 7200 + poll: 0 + + - name: Wait for instance(s) deletion to complete + ansible.builtin.async_status: + jid: "{{ item.ansible_job_id }}" + register: docker_jobs + until: docker_jobs.finished + retries: 300 + loop: "{{ server.results }}" + loop_control: + label: "{{ item.item.name }}" + + - name: Delete docker networks(s) + ansible.builtin.include_tasks: tasks/delete_network.yml + loop: "{{ molecule_yml.platforms | molecule_get_docker_networks() }}" + loop_control: + label: "{{ item.name }}" + no_log: false diff --git a/src/molecule_plugins/docker/playbooks/filter_plugins/get_docker_networks.py b/src/molecule_plugins/docker/playbooks/filter_plugins/get_docker_networks.py new file mode 100644 index 00000000..cd57dac3 --- /dev/null +++ b/src/molecule_plugins/docker/playbooks/filter_plugins/get_docker_networks.py @@ -0,0 +1,37 @@ +"""Embedded ansible filter used by Molecule Docker driver create playbook.""" + + +def get_docker_networks(data, labels={}): + """Get list of docker networks.""" + network_list = [] + network_names = [] + for platform in data: + if "docker_networks" in platform: + for docker_network in platform["docker_networks"]: + if "labels" not in docker_network: + docker_network["labels"] = {} + for key in labels: + docker_network["labels"][key] = labels[key] + + if "name" in docker_network: + network_list.append(docker_network) + network_names.append(docker_network["name"]) + + # If a network name is defined for a platform but is not defined in + # docker_networks, add it to the network list. + if "networks" in platform: + for network in platform["networks"]: + if "name" in network: + name = network["name"] + if name not in network_names: + network_list.append({"name": name, "labels": labels}) + return network_list + + +class FilterModule(object): + """Core Molecule filter plugins.""" + + def filters(self): + return { + "molecule_get_docker_networks": get_docker_networks, + } diff --git a/src/molecule_plugins/docker/playbooks/tasks/create_network.yml b/src/molecule_plugins/docker/playbooks/tasks/create_network.yml new file mode 100644 index 00000000..794745a0 --- /dev/null +++ b/src/molecule_plugins/docker/playbooks/tasks/create_network.yml @@ -0,0 +1,35 @@ +- name: Check if network exist + community.docker.docker_network_info: + name: "{{ item.name }}" + register: docker_netname + +- name: Create docker network(s) + community.docker.docker_network: + api_version: "{{ item.api_version | default(omit) }}" + appends: "{{ item.appends | default(omit) }}" + attachable: "{{ item.attachable | default(omit) }}" + ca_cert: "{{ item.ca_cert | default(omit) }}" + client_cert: "{{ item.client_cert | default(omit) }}" + client_key: "{{ item.client_key | default(omit) }}" + connected: "{{ item.connected | default(omit) }}" + debug: "{{ item.debug | default(omit) }}" + docker_host: "{{ item.docker_host | default(omit) }}" + driver: "{{ item.driver | default(omit) }}" + driver_options: "{{ item.driver_options | default(omit) }}" + enable_ipv6: "{{ item.enable_ipv6 | default(omit) }}" + force: "{{ item.force | default(omit) }}" + internal: "{{ item.internal | default(omit) }}" + ipam_config: "{{ item.ipam_config | default(omit) }}" + ipam_driver: "{{ item.ipam_driver | default(omit) }}" + ipam_driver_options: "{{ item.ipam_driver_options | default(omit) }}" + key_path: "{{ item.key_path | default(omit) }}" + labels: "{{ item.labels }}" + name: "{{ item.name }}" + scope: "{{ item.scope | default(omit) }}" + ssl_version: "{{ item.ssl_version | default(omit) }}" + state: "present" + timeout: "{{ item.timeout | default(omit) }}" + tls: "{{ item.tls | default(omit) }}" + tls_hostname: "{{ item.tls_hostname | default(omit) }}" + validate_certs: "{{ item.validate_certs | default(omit) }}" + when: not docker_netname.exists diff --git a/src/molecule_plugins/docker/playbooks/tasks/delete_network.yml b/src/molecule_plugins/docker/playbooks/tasks/delete_network.yml new file mode 100644 index 00000000..cf3815fa --- /dev/null +++ b/src/molecule_plugins/docker/playbooks/tasks/delete_network.yml @@ -0,0 +1,13 @@ +- name: Retrieve network info + community.docker.docker_network_info: + name: "{{ item.name }}" + register: docker_netname + +- name: Delete docker network(s) + community.docker.docker_network: + name: "{{ item.name }}" + state: "absent" + when: + - docker_netname.exists + - docker_netname.network.Labels.owner is defined + - docker_netname.network.Labels.owner == 'molecule' diff --git a/src/molecule_plugins/docker/playbooks/validate-dockerfile.yml b/src/molecule_plugins/docker/playbooks/validate-dockerfile.yml new file mode 100644 index 00000000..639eb7cc --- /dev/null +++ b/src/molecule_plugins/docker/playbooks/validate-dockerfile.yml @@ -0,0 +1,67 @@ +#!/usr/bin/env ansible-playbook +--- +- name: Validate dockerfile + hosts: localhost + connection: local + gather_facts: false + vars: + platforms: + # platforms supported as being managed by molecule/ansible, this does + # not mean molecule itself can run on them. + - image: alpine:edge + - image: quay.io/centos/centos:stream8 + - image: ubuntu:latest + - image: debian:latest + tasks: + + - name: Assure we have docker module installed + ansible.builtin.pip: + name: docker + + - name: Create temporary dockerfiles + ansible.builtin.tempfile: + prefix: "molecule-dockerfile-{{ item.image | replace('/', '-') }}" + suffix: build + register: temp_dockerfiles + with_items: "{{ platforms }}" + loop_control: + label: "{{ item.image }}" + + - name: Expand Dockerfile templates + ansible.builtin.template: + src: Dockerfile.j2 + dest: "{{ temp_dockerfiles.results[index].path }}" + mode: 0600 + register: result + with_items: "{{ platforms }}" + loop_control: + index_var: index + label: "{{ item.image }}" + + - name: Test Dockerfile template + community.docker.docker_image: + name: "{{ item.item.image }}" + build: + path: "." + dockerfile: "{{ item.dest }}" + pull: true + nocache: true + source: build + state: present + debug: true + force_source: true + with_items: "{{ result.results }}" + loop_control: + label: "{{ item.item.image }}" + register: result + + - name: Clean up temporary Dockerfile's + ansible.builtin.file: + path: "{{ item }}" + state: absent + mode: 0600 + loop: "{{ temp_dockerfiles.results | map(attribute='path') | list }}" + + - name: Display results + ansible.builtin.debug: + var: result diff --git a/test/docker/__init__.py b/test/docker/__init__.py new file mode 100644 index 00000000..bd880bdf --- /dev/null +++ b/test/docker/__init__.py @@ -0,0 +1 @@ +"""Driver tests.""" diff --git a/test/docker/conftest.py b/test/docker/conftest.py new file mode 100644 index 00000000..6db6ebe2 --- /dev/null +++ b/test/docker/conftest.py @@ -0,0 +1,9 @@ +"""Pytest Fixtures.""" +import pytest +from molecule.test.conftest import random_string, temp_dir # noqa + + +@pytest.fixture +def DRIVER(): + """Return name of the driver to be tested.""" + return "docker" diff --git a/test/docker/scenarios/env-substitution/molecule/default/converge.yml b/test/docker/scenarios/env-substitution/molecule/default/converge.yml new file mode 100644 index 00000000..aa7c05da --- /dev/null +++ b/test/docker/scenarios/env-substitution/molecule/default/converge.yml @@ -0,0 +1,5 @@ +--- +- name: Converge + hosts: all + gather_facts: false + become: false diff --git a/test/docker/scenarios/env-substitution/molecule/default/molecule.yml b/test/docker/scenarios/env-substitution/molecule/default/molecule.yml new file mode 100644 index 00000000..378b1abe --- /dev/null +++ b/test/docker/scenarios/env-substitution/molecule/default/molecule.yml @@ -0,0 +1,9 @@ +--- +driver: + name: docker +platforms: + - name: instance-${DOES_NOT_EXIST:-local} + image: ${MOLECULE_ROLE_IMAGE:-centos:7} + pre_build_image: false +provisioner: + name: ansible diff --git a/test/docker/scenarios/with-context/molecule/default/Dockerfile.j2 b/test/docker/scenarios/with-context/molecule/default/Dockerfile.j2 new file mode 100644 index 00000000..ecc11cb1 --- /dev/null +++ b/test/docker/scenarios/with-context/molecule/default/Dockerfile.j2 @@ -0,0 +1,4 @@ +FROM python:3 +COPY FOO FOO +ENTRYPOINT [] +CMD ["cat"] diff --git a/test/docker/scenarios/with-context/molecule/default/FOO b/test/docker/scenarios/with-context/molecule/default/FOO new file mode 100644 index 00000000..e69de29b diff --git a/test/docker/scenarios/with-context/molecule/default/converge.yml b/test/docker/scenarios/with-context/molecule/default/converge.yml new file mode 100644 index 00000000..aa7c05da --- /dev/null +++ b/test/docker/scenarios/with-context/molecule/default/converge.yml @@ -0,0 +1,5 @@ +--- +- name: Converge + hosts: all + gather_facts: false + become: false diff --git a/test/docker/scenarios/with-context/molecule/default/molecule.yml b/test/docker/scenarios/with-context/molecule/default/molecule.yml new file mode 100644 index 00000000..3c98de50 --- /dev/null +++ b/test/docker/scenarios/with-context/molecule/default/molecule.yml @@ -0,0 +1,9 @@ +--- +driver: + name: docker +platforms: + - name: instance + image: with-context + pre_build_image: false +provisioner: + name: ansible diff --git a/test/docker/test_driver.py b/test/docker/test_driver.py new file mode 100644 index 00000000..6b39c43e --- /dev/null +++ b/test/docker/test_driver.py @@ -0,0 +1,7 @@ +"""Unit tests.""" +from molecule import api + + +def test_driver_is_detected(DRIVER): + """Asserts that molecule recognizes the driver.""" + assert DRIVER in [str(d) for d in api.drivers()] diff --git a/test/docker/test_func.py b/test/docker/test_func.py new file mode 100644 index 00000000..ce940096 --- /dev/null +++ b/test/docker/test_func.py @@ -0,0 +1,85 @@ +"""Functional tests.""" +import os +import pathlib +import shutil +import subprocess +import pytest + +from molecule import logger +from molecule.test.conftest import change_dir_to +from molecule.util import run_command + +LOG = logger.get_logger(__name__) + + +def format_result(result: subprocess.CompletedProcess): + """Return friendly representation of completed process run.""" + return ( + f"RC: {result.returncode}\n" + + f"STDOUT: {result.stdout}\n" + + f"STDERR: {result.stderr}" + ) + + +@pytest.mark.skip(reason="broken, fix welcomed") +def test_command_init_and_test_scenario(tmp_path: pathlib.Path, DRIVER: str) -> None: + """Verify that init scenario works.""" + shutil.rmtree(tmp_path, ignore_errors=True) + tmp_path.mkdir(exist_ok=True) + + scenario_name = "default" + + with change_dir_to(tmp_path): + + scenario_directory = tmp_path / "molecule" / scenario_name + cmd = [ + "molecule", + "init", + "scenario", + "--driver-name", + DRIVER, + ] + result = run_command(cmd) + assert result.returncode == 0 + + assert scenario_directory.exists() + + # run molecule reset as this may clean some leftovers from other + # test runs and also ensure that reset works. + result = run_command(["molecule", "reset"]) # default scenario + assert result.returncode == 0 + + result = run_command(["molecule", "reset", "-s", scenario_name]) + assert result.returncode == 0 + + cmd = ["molecule", "--debug", "test", "-s", scenario_name] + result = run_command(cmd) + assert result.returncode == 0 + + +@pytest.mark.skip(reason="broken, fix welcomed") +def test_command_static_scenario() -> None: + """Validate that the scenario we included with code still works.""" + cmd = ["molecule", "test"] + + result = run_command(cmd) + assert result.returncode == 0 + + +@pytest.mark.skip(reason="broken, fix welcomed") +def test_dockerfile_with_context() -> None: + """Verify that Dockerfile.j2 with context works.""" + with change_dir_to("test/docker/scenarios/with-context"): + cmd = ["molecule", "--debug", "test"] + result = run_command(cmd) + assert result.returncode == 0 + + +@pytest.mark.skip(reason="broken, fix welcomed") +def test_env_substitution() -> None: + """Verify that env variables in molecule.yml are replaced properly.""" + os.environ["MOLECULE_ROLE_IMAGE"] = "debian:bullseye" + with change_dir_to("test/docker/scenarios/env-substitution"): + cmd = ["molecule", "--debug", "test"] + result = run_command(cmd) + assert result.returncode == 0 diff --git a/tox.ini b/tox.ini index 7ff5088c..6c8b968e 100644 --- a/tox.ini +++ b/tox.ini @@ -22,6 +22,7 @@ download = true extras = test azure + docker gce deps = py-{devel}: git+https://github.com/ansible-community/molecule.git@main#egg=molecule[test]