From 0a541122db3a6f06e67495aadb5962f60e0e5d19 Mon Sep 17 00:00:00 2001
From: Sorin Sbarnea <ssbarnea@redhat.com>
Date: Thu, 5 Jan 2023 16:48:53 +0000
Subject: [PATCH] Assimilate docker plugin

---
 .github/workflows/tox.yml                     |   2 +-
 pyproject.toml                                |  11 +
 requirements.yml                              |   4 +
 src/molecule_plugins/docker/__init__.py       |   1 +
 .../docker/cookiecutter/cookiecutter.json     |   5 +
 .../converge.yml                              |   7 +
 src/molecule_plugins/docker/driver.py         | 275 ++++++++++++++++++
 .../docker/playbooks/Dockerfile.j2            |  22 ++
 .../docker/playbooks/create.yml               | 191 ++++++++++++
 .../docker/playbooks/destroy.yml              |  48 +++
 .../filter_plugins/get_docker_networks.py     |  37 +++
 .../docker/playbooks/tasks/create_network.yml |  35 +++
 .../docker/playbooks/tasks/delete_network.yml |  13 +
 .../docker/playbooks/validate-dockerfile.yml  |  67 +++++
 test/docker/__init__.py                       |   1 +
 test/docker/conftest.py                       |   9 +
 .../molecule/default/converge.yml             |   5 +
 .../molecule/default/molecule.yml             |   9 +
 .../molecule/default/Dockerfile.j2            |   4 +
 .../with-context/molecule/default/FOO         |   0
 .../molecule/default/converge.yml             |   5 +
 .../molecule/default/molecule.yml             |   9 +
 test/docker/test_driver.py                    |   7 +
 test/docker/test_func.py                      |  85 ++++++
 tox.ini                                       |   1 +
 25 files changed, 852 insertions(+), 1 deletion(-)
 create mode 100644 src/molecule_plugins/docker/__init__.py
 create mode 100644 src/molecule_plugins/docker/cookiecutter/cookiecutter.json
 create mode 100644 src/molecule_plugins/docker/cookiecutter/{{cookiecutter.molecule_directory}}/{{cookiecutter.scenario_name}}/converge.yml
 create mode 100644 src/molecule_plugins/docker/driver.py
 create mode 100644 src/molecule_plugins/docker/playbooks/Dockerfile.j2
 create mode 100644 src/molecule_plugins/docker/playbooks/create.yml
 create mode 100644 src/molecule_plugins/docker/playbooks/destroy.yml
 create mode 100644 src/molecule_plugins/docker/playbooks/filter_plugins/get_docker_networks.py
 create mode 100644 src/molecule_plugins/docker/playbooks/tasks/create_network.yml
 create mode 100644 src/molecule_plugins/docker/playbooks/tasks/delete_network.yml
 create mode 100644 src/molecule_plugins/docker/playbooks/validate-dockerfile.yml
 create mode 100644 test/docker/__init__.py
 create mode 100644 test/docker/conftest.py
 create mode 100644 test/docker/scenarios/env-substitution/molecule/default/converge.yml
 create mode 100644 test/docker/scenarios/env-substitution/molecule/default/molecule.yml
 create mode 100644 test/docker/scenarios/with-context/molecule/default/Dockerfile.j2
 create mode 100644 test/docker/scenarios/with-context/molecule/default/FOO
 create mode 100644 test/docker/scenarios/with-context/molecule/default/converge.yml
 create mode 100644 test/docker/scenarios/with-context/molecule/default/molecule.yml
 create mode 100644 test/docker/test_driver.py
 create mode 100644 test/docker/test_func.py

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]