Repository: tox-dev/tox-docker (language: python)

Issue:
Unable to pull image from private registry
Hi !

Using tox-docker 3.x we are not able to pull image hosted on our private registry. 

Image name in the configuration looks like `private-registry:5000/namespace/image-name:1.0.0`

Look at the tox-docker code we can see that the image name is "partitionned"

```python
name, _, tag = image.partition(":")
```
in our case, `name` is `private-registry` and `tag` is `5000/namespace/image-name:1.0.0`

Fix proposal : 
```python
image_name_part = image.split(":")
tag = image_name_part.pop()
name = ":".join(image_name_part)
```

The stack

```log
Traceback (most recent call last):
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/api/client.py", line 268, in _raise_for_status
    response.raise_for_status()
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/requests/models.py", line 953, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 404 Client Error: Not Found for url: http+docker://localhost/v1.41/images/private-registry:5000/namespace/image-name:1.0.0/json

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/tox_docker/__init__.py", line 260, in tox_runtest_pre
    docker.images.get(image)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/models/images.py", line 314, in get
    return self.prepare_model(self.client.api.inspect_image(name))
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/utils/decorators.py", line 19, in wrapped
    return f(self, resource_id, *args, **kwargs)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/api/image.py", line 251, in inspect_image
    return self._result(
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/api/client.py", line 274, in _result
    self._raise_for_status(response)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/api/client.py", line 270, in _raise_for_status
    raise create_api_error_from_http_exception(e)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/errors.py", line 31, in create_api_error_from_http_exception
    raise cls(e, response=response, explanation=explanation)
docker.errors.ImageNotFound: 404 Client Error for http+docker://localhost/v1.41/images/private-registry:5000/namespace/image-name:1.0.0/json: Not Found ("no such image: private-registry:5000/namespace/image-name:1.0.0: No such image: private-registry:5000/namespace/image-name:1.0.0")

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/api/client.py", line 268, in _raise_for_status
    response.raise_for_status()
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/requests/models.py", line 953, in raise_for_status
    raise HTTPError(http_error_msg, response=self)
requests.exceptions.HTTPError: 400 Client Error: Bad Request for url: http+docker://localhost/v1.41/images/create?tag=5000%2Fnamespace%2Fimage-name%3A1.0.0&fromImage=private-registry

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/tox/session/__init__.py", line 203, in cleanup
    yield
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/tox/session/__init__.py", line 197, in runcommand
    return self.subcommand_test()
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/tox/session/__init__.py", line 225, in subcommand_test
    run_sequential(self.config, self.venv_dict)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/tox/session/commands/run/sequential.py", line 22, in run_sequential
    runtestenv(venv, config)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/tox/session/commands/run/sequential.py", line 73, in runtestenv
    config.pluginmanager.hook.tox_runtest_pre(venv=venv)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/pluggy/hooks.py", line 286, in __call__
    return self._hookexec(self, self.get_hookimpls(), kwargs)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/pluggy/manager.py", line 93, in _hookexec
    return self._inner_hookexec(hook, methods, kwargs)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/pluggy/manager.py", line 84, in <lambda>
    self._inner_hookexec = lambda hook, methods, kwargs: hook.multicall(
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/pluggy/callers.py", line 208, in _multicall
    return outcome.get_result()
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/pluggy/callers.py", line 80, in get_result
    raise ex[1].with_traceback(ex[2])
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/pluggy/callers.py", line 187, in _multicall
    res = hook_impl.function(*args)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/tox_docker/__init__.py", line 264, in tox_runtest_pre
    docker.images.pull(name, tag=tag or None)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/models/images.py", line 444, in pull
    pull_log = self.client.api.pull(
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/api/image.py", line 428, in pull
    self._raise_for_status(response)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/api/client.py", line 270, in _raise_for_status
    raise create_api_error_from_http_exception(e)
  File "/home/acme/.pyenv/versions/3.9.4/lib/python3.9/site-packages/docker/errors.py", line 31, in create_api_error_from_http_exception
    raise cls(e, response=response, explanation=explanation)
docker.errors.APIError: 400 Client Error for http+docker://localhost/v1.41/images/create?tag=5000%2Fnamespace%2Fimage-name%3A1.0.0&fromImage=private-registry: Bad Request ("invalid tag format")
```


Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 320159e..1855cc3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -9,6 +9,12 @@ repos:
       - id: trailing-whitespace
 
   # Python code formatting
+  - repo: https://github.com/myint/autoflake
+    rev: v1.4
+    hooks:
+      - id: autoflake
+        args: [--in-place, --remove-all-unused-imports, --remove-unused-variable, --exclude, 'tox_docker/__init__.py']
+
   - repo: https://github.com/asottile/seed-isort-config
     rev: v2.2.0
     hooks:
diff --git a/README.rst b/README.rst
index b0a3883..60ed68d 100644
--- a/README.rst
+++ b/README.rst
@@ -128,6 +128,18 @@ aspects of tox-docker's behavior can be changed at run-time:
     ``docker exec ...``. May be specified multiple times to leave several
     containers running.
 
+Container Naming & Parallel Runs
+--------------------------------
+
+Since version 4, tox-docker adds a suffix to the name of running containers,
+so that parallel invocations of tox may succeed (eg on a busy CI server).
+The details of the name suffix are not specified, and may change in a future
+version -- you should not rely on the details of the generated name.
+
+Even with unique container names, parallel runs may still fail, if you map a
+static exposed port number for a container (as the tox host will not let two
+processes bind the same port).
+
 Example
 -------
 
@@ -189,7 +201,7 @@ variable should be the hostname or ip address used to connect to the container.
 Version Compatibility
 ---------------------
 
-Tox-docker requires tox to be run in Python 3.6 or newer, and requires tox
+Tox-docker requires tox to be run in Python 3.7 or newer, and requires tox
 version 3.0 or newer. Older versions of tox-docker may work with older
 versions of Python or tox, but these configurations are no longer supported.
 
diff --git a/azure-pipelines.yml b/azure-pipelines.yml
index 6df7d36..ac3ad9a 100644
--- a/azure-pipelines.yml
+++ b/azure-pipelines.yml
@@ -4,14 +4,14 @@ jobs:
     vmImage: 'ubuntu-latest'
   strategy:
     matrix:
+      Python310:
+        python.version: '3.10'
       Python39:
         python.version: '3.9'
       Python38:
         python.version: '3.8'
       Python37:
         python.version: '3.7'
-      Python36:
-        python.version: '3.6'
     maxParallel: 4
 
   steps:
@@ -20,6 +20,15 @@ jobs:
       versionSpec: '$(python.version)'
       architecture: 'x64'
 
+  - script: ./ci.sh tox-4.x docker-5.x
+    displayName: 'Tox 4.x, Docker 5.x'
+
+  - script: ./ci.sh tox-4.x docker-4.x
+    displayName: 'Tox 4.x, Docker 4.x'
+
+  - script: ./ci.sh tox-4.x docker-3.x
+    displayName: 'Tox 4.x, Docker 3.x'
+
   - script: ./ci.sh tox-3.x docker-5.x
     displayName: 'Tox 3.x, Docker 5.x'
 
diff --git a/ci.sh b/ci.sh
index d57fda7..079f09f 100755
--- a/ci.sh
+++ b/ci.sh
@@ -5,6 +5,15 @@ set -x
 tox_version=$1
 docker_version=$2
 
+if [ "$tox_version" = "tox-4.x" ]; then
+    tox=tox4
+    mypy_target=mypy-tox4
+else
+    tox=tox
+    mypy_target=mypy-tox3
+fi
+
+
 # our tox.ini sets up volumes within .tox, so make sure it exists
 # before trying to run tox...
 mkdir -p .tox
@@ -12,6 +21,12 @@ mkdir -p .tox
 pip install --constraint $tox_version --constraint $docker_version -r dev-requirements.txt
 pip install --constraint $tox_version --constraint $docker_version .
 pip show tox tox-docker docker
-tox
+
+python tox_docker/tests/capture_containers_and_volumes.py .
+$tox -e integration
+python tox_docker/tests/assert_containers_and_volumes_unchanged.py .
+
+$tox -e $mypy_target
+
 echo "testing health check failure handling, an ERROR is expected:"
-tox -e healthcheck-failing 2>&1 | egrep "tox_docker.HealthCheckFailed: .* failed health check"
+$tox -e healthcheck-failing 2>&1 | grep "'toxdocker/healthcheck' (from 'healthcheck-failing') failed health check"
diff --git a/mypy.ini b/mypy.ini
new file mode 100644
index 0000000..7da2c6e
--- /dev/null
+++ b/mypy.ini
@@ -0,0 +1,5 @@
+[mypy]
+disallow_incomplete_defs = True
+disallow_untyped_defs = True
+follow_imports = silent
+ignore_missing_imports = True
diff --git a/setup.cfg b/setup.cfg
index e484f18..9bedc15 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -13,7 +13,7 @@ force_grid_wrap = false
 from_first = true
 include_trailing_comma = true
 # known_third_party is autogenereated. Do not edit.
-known_third_party =docker,py,pytest,setuptools,tox,vcversioner
+known_third_party =docker,packaging,py,pytest,setuptools,tox,vcversioner
 multi_line_output = 3
 not_skip = __init__.py
 order_by_type = false
diff --git a/setup.py b/setup.py
index 1fd9904..be1e668 100644
--- a/setup.py
+++ b/setup.py
@@ -1,4 +1,4 @@
-from setuptools import setup
+from setuptools import find_packages, setup
 
 setup(
     name="tox-docker",
@@ -9,9 +9,10 @@
     maintainer_email="dcrosta@late.am",
     install_requires=[
         "docker>=2.3.0,<6.0",
-        "tox>=3.0.0,<4.0",
+        "packaging",
+        "tox>=3.0.0,<5.0",
     ],
-    packages=["tox_docker"],
+    packages=find_packages(),
     entry_points={"tox": ["docker = tox_docker"]},
     vcversioner={"version_module_paths": ["_version.py"]},
     classifiers=[
diff --git a/tox-4.x b/tox-4.x
new file mode 100644
index 0000000..7e9b624
--- /dev/null
+++ b/tox-4.x
@@ -0,0 +1,1 @@
+--pre tox>=4.0.0a10,<5.0
diff --git a/tox.ini b/tox.ini
index 93ac2a1..5229637 100644
--- a/tox.ini
+++ b/tox.ini
@@ -11,11 +11,6 @@ commands_pre =
 commands_post =
 
 [testenv:integration]
-# commands_pre/_post only work in tox 3.4+, but at least in some
-# CI configurations they'll tell us if we've leaked resources
-commands_pre = python tox_docker/tests/capture_containers_and_volumes.py
-commands_post = python tox_docker/tests/assert_containers_and_volumes_unchanged.py
-
 # tests use these containers in various ways -- the full documentation
 # is in the test cases themselves. but briefly, two running instances
 # of the tcp-udp-test image are used for checking port mapping & linking
@@ -34,6 +29,20 @@ commands =
     py.test [] {toxinidir}/tox_docker
     python -c 'import os; os.remove(os.environ["VOLUME_DIR"] + "/healthy")'
 
+[testenv:mypy-tox3]
+skip_install = true
+deps =
+    mypy==0.910
+commands =
+    mypy --exclude tox4 {toxinidir}/tox_docker
+
+[testenv:mypy-tox4]
+skip_install = true
+deps =
+    mypy==0.910
+commands =
+    mypy --exclude tox3 {toxinidir}/tox_docker
+
 [docker:networking-one]
 image = ksdn117/tcp-udp-test
 
diff --git a/tox_docker/__init__.py b/tox_docker/__init__.py
index 2721c80..8b13609 100644
--- a/tox_docker/__init__.py
+++ b/tox_docker/__init__.py
@@ -1,419 +1,12 @@
-import os
-import re
-import socket
-import sys
-import time
-
-from docker.errors import ImageNotFound
-from docker.types import Mount
-from tox import hookimpl
-from tox.config import SectionReader
-import docker as docker_module
-import py
-
-# nanoseconds in a second; named "SECOND" so that "1.5 * SECOND" makes sense
-SECOND = 1000000000
-
-
-class HealthCheckFailed(Exception):
-    pass
-
-
-def escape_env_var(varname):
-    """
-    Convert a string to a form suitable for use as an environment variable.
-
-    The result will be all uppercase, and will have all invalid characters
-    replaced by an underscore.
-
-    The result will match the following regex: [a-zA-Z_][a-zA-Z0-9_]*
-
-    Example:
-        "my.private.registry/cat/image" will become
-        "MY_PRIVATE_REGISTRY_CAT_IMAGE"
-    """
-    varname = list(varname.upper())
-    if not varname[0].isalpha():
-        varname[0] = "_"
-    for i, c in enumerate(varname):
-        if not c.isalnum() and c != "_":
-            varname[i] = "_"
-    return "".join(varname)
-
-
-def _newaction(venv, message):
-    try:
-        # tox 3.7 and later
-        return venv.new_action(message)
-    except AttributeError:
-        return venv.session.newaction(venv, message)
-
-
-def _get_gateway_ip(container):
-    gateway = os.getenv("TOX_DOCKER_GATEWAY")
-    if gateway:
-        ip = socket.gethostbyname(gateway)
-    elif sys.platform == "darwin":
-        # https://docs.docker.com/docker-for-mac/networking/#use-cases-and-workarounds:
-        # there is no bridge network available in Docker for Mac, and exposed ports are
-        # made available on localhost (but 0.0.0.0 works just as well)
-        ip = "0.0.0.0"
-    else:
-        ip = container.attrs["NetworkSettings"]["Gateway"] or "0.0.0.0"
-    return ip
-
-
-@hookimpl  # noqa: C901
-def tox_configure(config):  # noqa: C901
-    def getfloat(reader, key):
-        val = reader.getstring(key)
-        if val is None:
-            return None
-
-        try:
-            return float(val)
-        except ValueError:
-            msg = f"{val!r} is not a number (for {key} in [{reader.section_name}])"
-            raise ValueError(msg)
-
-    def gettime(reader, key):
-        return int(getfloat(reader, key) * SECOND)
-
-    def getint(reader, key):
-        raw = getfloat(reader, key)
-        val = int(raw)
-        if val != raw:
-            msg = f"{val!r} is not an int (for {key} in [{reader.section_name}])"
-            raise ValueError(msg)
-        return val
-
-    def getenvdict(reader, key):
-        environment = {}
-        for value in reader.getlist(key):
-            envvar, _, value = value.partition("=")
-            environment[envvar] = value
-        return environment
-
-    # discover container configs
-    inipath = str(config.toxinipath)
-    iniparser = py.iniconfig.IniConfig(inipath)
-
-    container_configs = {}
-    for section in iniparser.sections:
-        if not section.startswith("docker:"):
-            continue
-
-        _, _, container_name = section.partition(":")
-        if not re.match(r"^[a-zA-Z][-_.a-zA-Z0-9]+$", container_name):
-            raise ValueError(f"{container_name!r} is not a valid container name")
-
-        # populated in the next loop
-        container_configs[container_name] = {}
-
-    # validate command line options
-    for container_name in config.option.docker_dont_stop:
-        if container_name not in container_configs:
-            raise ValueError(
-                f"Container {container_name!r} not found (from --docker-dont-stop)"
-            )
-
-    # validate tox.ini
-    for section in iniparser.sections:
-        if not section.startswith("docker:"):
-            continue
-        reader = SectionReader(section, iniparser)
-        reader.addsubstitutions(
-            distdir=config.distdir,
-            homedir=config.homedir,
-            toxinidir=config.toxinidir,
-            toxworkdir=config.toxworkdir,
-        )
-        _, _, container_name = section.partition(":")
-
-        container_configs[container_name].update(
-            {
-                "image": reader.getstring("image"),
-                "stop": container_name not in config.option.docker_dont_stop,
-            }
-        )
-
-        if reader.getstring("environment"):
-            env = getenvdict(reader, "environment")
-            container_configs[container_name]["environment"] = env
-
-        if reader.getstring("healthcheck_cmd"):
-            container_configs[container_name]["healthcheck_cmd"] = reader.getstring(
-                "healthcheck_cmd"
-            )
-        if reader.getstring("healthcheck_interval"):
-            container_configs[container_name]["healthcheck_interval"] = gettime(
-                reader, "healthcheck_interval"
-            )
-        if reader.getstring("healthcheck_timeout"):
-            container_configs[container_name]["healthcheck_timeout"] = gettime(
-                reader, "healthcheck_timeout"
-            )
-        if reader.getstring("healthcheck_start_period"):
-            container_configs[container_name]["healthcheck_start_period"] = gettime(
-                reader, "healthcheck_start_period"
-            )
-        if reader.getstring("healthcheck_retries"):
-            container_configs[container_name]["healthcheck_retries"] = getint(
-                reader, "healthcheck_retries"
-            )
-
-        if reader.getstring("ports"):
-            container_configs[container_name]["ports"] = reader.getlist("ports")
-
-        if reader.getstring("links"):
-            container_configs[container_name]["links"] = dict(
-                _validate_link_line(link_line, container_configs.keys())
-                for link_line in reader.getlist("links")
-                if link_line.strip()
-            )
-
-        if reader.getstring("volumes"):
-            container_configs[container_name]["mounts"] = [
-                _validate_volume_line(volume_line)
-                for volume_line in reader.getlist("volumes")
-                if volume_line.strip()
-            ]
-
-    config._docker_container_configs = container_configs
-
-
-def _validate_port(port_line):
-    host_port, _, container_port_proto = port_line.partition(":")
-    host_port = int(host_port)
-
-    container_port, _, protocol = container_port_proto.partition("/")
-    container_port = int(container_port)
-
-    if protocol.lower() not in ("tcp", "udp"):
-        raise ValueError("protocol is not tcp or udp")
-
-    return (host_port, container_port_proto)
-
-
-def _validate_link_line(link_line, container_names):
-    other_container_name, sep, alias = link_line.partition(":")
-    if sep and not alias:
-        raise ValueError(f"Link '{other_container_name}:' missing alias")
-    if other_container_name not in container_names:
-        raise ValueError(f"Container {other_container_name!r} not defined")
-    return other_container_name, alias or other_container_name
-
-
-def _validate_volume_line(volume_line):
-    parts = volume_line.split(":")
-    if len(parts) != 4:
-        raise ValueError(f"Volume {volume_line!r} is malformed")
-    if parts[0] != "bind":
-        raise ValueError(f"Volume {volume_line!r} type must be 'bind:'")
-    if parts[1] not in ("ro", "rw"):
-        raise ValueError(f"Volume {volume_line!r} options must be 'ro' or 'rw'")
-
-    volume_type, mode, outside, inside = parts
-    if not os.path.exists(outside):
-        raise ValueError(f"Volume source {outside!r} does not exist")
-    if not os.path.isabs(outside):
-        raise ValueError(f"Volume source {outside!r} must be an absolute path")
-    if not os.path.isabs(inside):
-        raise ValueError(f"Mount point {inside!r} must be an absolute path")
-
-    return Mount(
-        source=outside,
-        target=inside,
-        type=volume_type,
-        read_only=bool(mode == "ro"),
-    )
-
-
-@hookimpl  # noqa: C901
-def tox_runtest_pre(venv):  # noqa: C901
-    envconfig = venv.envconfig
-    if not envconfig.docker:
-        return
-
-    config = envconfig.config
-    container_configs = config._docker_container_configs
-
-    docker = docker_module.from_env(version="auto")
-    action = _newaction(venv, "docker")
-
-    seen = set()
-    for container_name in envconfig.docker:
-        if container_name not in container_configs:
-            raise ValueError(f"Missing [docker:{container_name}] in tox.ini")
-        if container_name in seen:
-            raise ValueError(f"Container {container_name!r} specified more than once")
-        seen.add(container_name)
-
-        image = container_configs[container_name]["image"]
-        name, _, tag = image.partition(":")
-
-        try:
-            docker.images.get(image)
-        except ImageNotFound:
-            action.setactivity("docker", f"pull {image!r} (from {container_name!r})")
-            with action:
-                docker.images.pull(name, tag=tag or None)
-
-    envconfig._docker_containers = {}
-    for container_name in envconfig.docker:
-        container_config = container_configs[container_name]
-        hc_cmd = container_config.get("healthcheck_cmd")
-        hc_interval = container_config.get("healthcheck_interval")
-        hc_timeout = container_config.get("healthcheck_timeout")
-        hc_retries = container_config.get("healthcheck_retries")
-        hc_start_period = container_config.get("healthcheck_start_period")
-
-        healthcheck = {}
-        if hc_cmd:
-            healthcheck["test"] = ["CMD-SHELL", hc_cmd]
-        if hc_interval:
-            healthcheck["interval"] = hc_interval
-        if hc_timeout:
-            healthcheck["timeout"] = hc_timeout
-        if hc_start_period:
-            healthcheck["start_period"] = hc_start_period
-        if hc_retries:
-            healthcheck["retries"] = hc_retries
-
-        if healthcheck == {}:
-            healthcheck = None
-
-        ports = {}
-        for port_mapping in container_config.get("ports", []):
-            host_port, container_port_proto = _validate_port(port_mapping)
-            existing_ports = set(ports.get(container_port_proto, []))
-            existing_ports.add(host_port)
-            ports[container_port_proto] = list(existing_ports)
-
-        links = {}
-        for other_container_name, alias in container_config.get("links", {}).items():
-            other_container = envconfig._docker_containers[other_container_name]
-            links[other_container.id] = alias
-
-        image = container_config["image"]
-        environment = container_config.get("environment", {})
-
-        action.setactivity("docker", f"run {image!r} (from {container_name!r})")
-        with action:
-            container = docker.containers.run(
-                image,
-                detach=True,
-                environment=environment,
-                healthcheck=healthcheck,
-                labels={"tox_docker_container_name": container_name},
-                links=links,
-                name=container_name,
-                ports=ports,
-                publish_all_ports=len(ports) == 0,
-                mounts=container_config.get("mounts", []),
-            )
-
-        envconfig._docker_containers[container_name] = container
-        container.reload()
-
-    for container_name, container in envconfig._docker_containers.items():
-        image = container.attrs["Config"]["Image"]
-        if "Health" in container.attrs["State"]:
-            action.setactivity(
-                "docker", f"health check {image!r} (from {container_name!r})"
-            )
-            with action:
-                while True:
-                    container.reload()
-                    health = container.attrs["State"]["Health"]["Status"]
-                    if health == "healthy":
-                        break
-                    elif health == "starting":
-                        time.sleep(0.1)
-                    elif health == "unhealthy":
-                        # the health check failed after its own timeout
-                        stop_containers(venv)
-                        msg = f"{image!r} (from {container_name!r}) failed health check"
-                        venv.status = msg
-                        raise HealthCheckFailed(msg)
-
-        gateway_ip = _get_gateway_ip(container)
-        for containerport, hostports in container.attrs["NetworkSettings"][
-            "Ports"
-        ].items():
-            if hostports is None:
-                # The port is exposed by the container, but not published.
-                continue
-
-            for spec in hostports:
-                if spec["HostIp"] == "0.0.0.0":
-                    hostport = spec["HostPort"]
-                    break
-            else:
-                continue
-
-            envvar = escape_env_var(f"{container_name}_HOST")
-            venv.envconfig.setenv[envvar] = gateway_ip
-
-            envvar = escape_env_var(f"{container_name}_{containerport}_PORT")
-            venv.envconfig.setenv[envvar] = hostport
-
-
-@hookimpl
-def tox_runtest_post(venv):
-    stop_containers(venv)
-
-
-@hookimpl
-def tox_cleanup(session):  # noqa: F841
-    for venv in session.existing_venvs.values():
-        stop_containers(venv)
-
-
-def stop_containers(venv):
-    envconfig = venv.envconfig
-    if not envconfig.docker:
-        return
-
-    config = envconfig.config
-    action = _newaction(venv, "docker")
-
-    for container_name, container in envconfig._docker_containers.items():
-        container_config = config._docker_container_configs[container_name]
-        if container_config["stop"]:
-            action.setactivity(
-                "docker", f"remove '{container.short_id}' (from {container_name!r})"
-            )
-            with action:
-                container.remove(v=True, force=True)
-        else:
-            action.setactivity(
-                "docker",
-                f"leave '{container.short_id}' (from {container_name!r}) running",
-            )
-            with action:
-                pass
-    envconfig._docker_containers.clear()
-
-
-@hookimpl
-def tox_addoption(parser):
-    # necessary to allow the docker= directive in testenv sections
-    parser.add_testenv_attribute(
-        name="docker",
-        type="line-list",
-        help="Name of docker images, including tag, to start before the test run",
-        default=[],
-    )
-
-    # command line flag to keep docker containers running
-    parser.add_argument(
-        "--docker-dont-stop",
-        default=[],
-        action="append",
-        metavar="CONTAINER",
-        help=(
-            "If specified, tox-docker will not stop CONTAINER after the test run. "
-            "Can be specified multiple times."
-        ),
-    )
+from packaging.version import parse, Version
+import tox
+
+tox_version = parse(tox.__version__)
+assert isinstance(tox_version, Version)
+
+if tox_version.major == 3:
+    from tox_docker.tox3.plugin import *
+elif tox_version.major == 4:
+    from tox_docker.tox4.plugin import *
+else:
+    raise RuntimeError(f"tox_docker is incomptabile with tox {tox.__version__}")
diff --git a/tox_docker/config.py b/tox_docker/config.py
new file mode 100644
index 0000000..1721a1d
--- /dev/null
+++ b/tox_docker/config.py
@@ -0,0 +1,141 @@
+from typing import Collection, Dict, Mapping, Optional
+import os
+import os.path
+import re
+
+from docker.models.containers import Container as DockerContainer
+from docker.types import Mount
+
+RunningContainers = Dict[str, DockerContainer]
+
+IMAGE_NAME = re.compile(
+    # adapted from https://stackoverflow.com/a/39672069, used under CC-BY-SA
+    r"^"
+    r"("
+    r"(?:(?=[^:\/]{1,253})(?!-)[a-zA-Z0-9-]{1,63}(?<!-)(?:\.(?!-)[a-zA-Z0-9-]{1,63}(?<!-))*(?::[0-9]{1,5})?/)?"
+    r"(?![._-])(?:[a-z0-9._-]*)(?<![._-])(?:/(?![._-])[a-z0-9._-]*(?<![._-]))*"
+    r")"
+    r"(?::((?![.-])[a-zA-Z0-9_.-]{1,128}))?"
+    r"$"
+)
+
+
+def runas_name(container_name: str, pid: Optional[int] = None) -> str:
+    """
+    Generate a name safe for use in parallel scenarios
+
+    This can be either separate invocations of tox (eg on a busy CI server)
+    or `tox -p`. The returned name will be stable for a given container name,
+    to avoid needing to maintain a mapping.
+
+    """
+    pid = pid or os.getpid()
+    return f"{container_name}-tox-{pid}"
+
+
+class Image:
+    def __init__(self, config_line: str) -> None:
+        match = IMAGE_NAME.match(config_line)
+        if not match:
+            raise ValueError(f"{config_line!r} is not a valid image name")
+        self.name, self.tag = match.groups()
+
+    def __str__(self) -> str:
+        if self.tag:
+            return f"{self.name}:{self.tag}"
+        return self.name
+
+    def __repr__(self) -> str:
+        return repr(str(self))
+
+
+class Port:
+    def __init__(self, config_line: str) -> None:
+        host_port, _, container_port_proto = config_line.partition(":")
+        container_port, _, protocol = container_port_proto.partition("/")
+
+        if protocol.lower() not in ("tcp", "udp"):
+            raise ValueError("protocol must be tcp or udp")
+        if not host_port.isdigit():
+            raise ValueError("host port must be an int")
+        if not container_port.isdigit():
+            raise ValueError("container port must be an int")
+
+        self.host_port = int(host_port)
+        self.container_port_proto = container_port_proto
+
+
+class Link:
+    def __init__(self, config_line: str) -> None:
+        target, sep, alias = config_line.partition(":")
+
+        if sep and not alias:
+            raise ValueError(f"Link '{target}:' missing alias")
+
+        self.target = runas_name(target)
+
+        # this is what the target will be known as INSIDE the
+        # container, so don't substitute the runas_name here
+        self.alias = alias or target
+
+
+class Volume:
+    def __init__(self, config_line: str) -> None:
+        parts = config_line.split(":")
+        if len(parts) != 4:
+            raise ValueError(f"Volume {config_line!r} is malformed")
+        if parts[0] != "bind":
+            raise ValueError(f"Volume {config_line!r} type must be 'bind:'")
+        if parts[1] not in ("ro", "rw"):
+            raise ValueError(f"Volume {config_line!r} options must be 'ro' or 'rw'")
+
+        volume_type, mode, outside, inside = parts
+        if not os.path.isabs(outside):
+            raise ValueError(f"Volume source {outside!r} must be an absolute path")
+        if not os.path.isabs(inside):
+            raise ValueError(f"Mount point {inside!r} must be an absolute path")
+
+        self.docker_mount = Mount(
+            source=outside,
+            target=inside,
+            type=volume_type,
+            read_only=bool(mode == "ro"),
+        )
+
+
+class ContainerConfig:
+    def __init__(
+        self,
+        name: str,
+        image: Image,
+        stop: bool,
+        environment: Optional[Mapping[str, str]] = None,
+        healthcheck_cmd: Optional[str] = None,
+        healthcheck_interval: Optional[float] = None,
+        healthcheck_timeout: Optional[float] = None,
+        healthcheck_start_period: Optional[float] = None,
+        healthcheck_retries: Optional[int] = None,
+        ports: Optional[Collection[Port]] = None,
+        links: Optional[Collection[Link]] = None,
+        volumes: Optional[Collection[Volume]] = None,
+    ) -> None:
+        self.name = name
+        self.runas_name = runas_name(name)
+        self.image = image
+        self.stop = stop
+        self.environment: Mapping[str, str] = environment or {}
+        self.ports: Collection[Port] = ports or {}
+        self.links: Collection[Link] = links or {}
+        self.mounts: Collection[Mount] = [v.docker_mount for v in volumes or ()]
+
+        self.healthcheck_cmd = healthcheck_cmd
+        self.healthcheck_interval = (
+            int(healthcheck_interval) if healthcheck_interval else None
+        )
+        self.healthcheck_timeout = (
+            int(healthcheck_timeout) if healthcheck_timeout else None
+        )
+        self.healthcheck_start_period = (
+            int(healthcheck_start_period) if healthcheck_start_period else None
+        )
+        self.healthcheck_retries = healthcheck_retries
diff --git a/tox_docker/log.py b/tox_docker/log.py
new file mode 100644
index 0000000..508839c
--- /dev/null
+++ b/tox_docker/log.py
@@ -0,0 +1,3 @@
+from typing import Callable
+
+LogFunc = Callable[[str], None]
diff --git a/tox_docker/plugin.py b/tox_docker/plugin.py
new file mode 100644
index 0000000..eec57fd
--- /dev/null
+++ b/tox_docker/plugin.py
@@ -0,0 +1,181 @@
+from typing import Dict, Iterable, List, Mapping, Optional, Tuple, Union
+import os
+import socket
+import sys
+import time
+
+from docker.errors import ImageNotFound, NotFound
+from docker.models.containers import Container
+import docker as docker_module
+
+from tox_docker.config import ContainerConfig, RunningContainers
+from tox_docker.log import LogFunc
+
+
+class HealthCheckFailed(Exception):
+    pass
+
+
+def get_gateway_ip(container: Container) -> str:
+    gateway = os.getenv("TOX_DOCKER_GATEWAY")
+    if gateway:
+        ip = socket.gethostbyname(gateway)
+    elif sys.platform == "darwin":
+        # https://docs.docker.com/docker-for-mac/networking/#use-cases-and-workarounds:
+        # there is no bridge network available in Docker for Mac, and exposed ports are
+        # made available on localhost (but 0.0.0.0 works just as well)
+        ip = "0.0.0.0"
+    else:
+        ip = container.attrs["NetworkSettings"]["Gateway"] or "0.0.0.0"
+    return ip
+
+
+def escape_env_var(varname: str) -> str:
+    """
+    Convert a string to a form suitable for use as an environment variable.
+
+    The result will be all uppercase, and will have all invalid characters
+    replaced by an underscore.
+
+    The result will match the following regex: [a-zA-Z_][a-zA-Z0-9_]*
+
+    Example:
+        "my.private.registry/cat/image" will become
+        "MY_PRIVATE_REGISTRY_CAT_IMAGE"
+    """
+    varletters = list(varname.upper())
+    if not varletters[0].isalpha():
+        varletters[0] = "_"
+    for i, c in enumerate(varletters):
+        if not c.isalnum() and c != "_":
+            varletters[i] = "_"
+    return "".join(varletters)
+
+
+def docker_pull(container_config: ContainerConfig, log: LogFunc) -> None:
+    docker = docker_module.from_env(version="auto")
+
+    try:
+        docker.images.get(container_config.image.name)
+    except ImageNotFound:
+        log(f"pull {container_config.image!r} (from {container_config.name!r})")
+        docker.images.pull(container_config.image.name, tag=container_config.image.tag)
+
+
+def docker_run(
+    container_config: ContainerConfig,
+    running_containers: RunningContainers,
+    log: LogFunc,
+) -> Container:
+    docker = docker_module.from_env(version="auto")
+
+    healthcheck: Dict[str, Union[List[str], int]] = {}
+    if container_config.healthcheck_cmd:
+        healthcheck["test"] = ["CMD-SHELL", container_config.healthcheck_cmd]
+    if container_config.healthcheck_interval:
+        healthcheck["interval"] = container_config.healthcheck_interval
+    if container_config.healthcheck_timeout:
+        healthcheck["timeout"] = container_config.healthcheck_timeout
+    if container_config.healthcheck_start_period:
+        healthcheck["start_period"] = container_config.healthcheck_start_period
+    if container_config.healthcheck_retries:
+        healthcheck["retries"] = container_config.healthcheck_retries
+
+    ports = {p.container_port_proto: p.host_port for p in container_config.ports}
+
+    links = {}
+    for link in container_config.links:
+        if link.target not in running_containers:
+            raise ValueError(
+                f"Container {link.target!r} not running; it must come before {container_config.name!r} in the docker= list"
+            )
+        other_container = running_containers[link.target]
+        links[other_container.id] = link.alias
+
+    for mount in container_config.mounts:
+        source = mount["Source"]
+        if not os.path.exists(source):
+            raise ValueError(f"Volume source {source!r} does not exist")
+
+    log(f"run {container_config.image!r} (from {container_config.name!r})")
+    container = docker.containers.run(
+        str(container_config.image),
+        name=container_config.runas_name,
+        detach=True,
+        environment=container_config.environment,
+        healthcheck=healthcheck or None,
+        links=links,
+        ports=ports,
+        publish_all_ports=len(ports) == 0,
+        mounts=container_config.mounts,
+    )
+    container.reload()  # TODO: why do we need this?
+    return container
+
+
+def docker_health_check(
+    container_config: ContainerConfig, container: Container, log: LogFunc
+) -> None:
+    docker = docker_module.from_env(version="auto")
+
+    if "Health" in container.attrs["State"]:
+        log(f"health check {container_config.image!r} (from {container_config.name!r})")
+        while True:
+            container.reload()
+            health = container.attrs["State"]["Health"]["Status"]
+            if health == "healthy":
+                break
+            elif health == "starting":
+                time.sleep(0.1)
+            elif health == "unhealthy":
+                # the health check failed after its own timeout
+                msg = f"{container_config.image!r} (from {container_config.name!r}) failed health check"
+                raise HealthCheckFailed(msg)
+
+
+def docker_stop(
+    container_config: ContainerConfig, container: Container, log: LogFunc
+) -> None:
+    if container_config.stop:
+        log(f"remove '{container.short_id}' (from {container_config.name!r})")
+        container.remove(v=True, force=True)
+    else:
+        log(f"leave '{container.short_id}' (from {container_config.name!r}) running")
+
+
+def docker_get(container_config: ContainerConfig) -> Optional[Container]:
+    docker = docker_module.from_env(version="auto")
+    try:
+        return docker.containers.get(container_config.runas_name)
+    except NotFound:
+        return None
+
+
+def stop_containers(
+    containers: Iterable[Tuple[ContainerConfig, Container]], log: LogFunc
+) -> None:
+    for container_config, container in containers:
+        docker_stop(container_config, container, log)
+
+
+def get_env_vars(
+    container_config: ContainerConfig, container: Container
+) -> Mapping[str, str]:
+    env = {}
+    gateway_ip = get_gateway_ip(container)
+    for containerport, hostports in container.attrs["NetworkSettings"]["Ports"].items():
+        if hostports is None:
+            # The port is exposed by the container, but not published.
+            continue
+
+        for spec in hostports:
+            if spec["HostIp"] == "0.0.0.0":
+                hostport = spec["HostPort"]
+                break
+        else:
+            continue
+
+        env[escape_env_var(f"{container_config.name}_HOST")] = gateway_ip
+        env[escape_env_var(f"{container_config.name}_{containerport}_PORT")] = hostport
+
+    return env
diff --git a/tox_docker/tox3/__init__.py b/tox_docker/tox3/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tox_docker/tox3/config.py b/tox_docker/tox3/config.py
new file mode 100644
index 0000000..eaa4850
--- /dev/null
+++ b/tox_docker/tox3/config.py
@@ -0,0 +1,149 @@
+from typing import Container, Dict, Mapping, Optional, Sequence
+import re
+
+from tox.config import Config, SectionReader
+from tox.venv import VirtualEnv
+import py
+
+from tox_docker.config import (
+    ContainerConfig,
+    Image,
+    Link,
+    Port,
+    RunningContainers,
+    Volume,
+)
+
+# nanoseconds in a second; named "SECOND" so that "1.5 * SECOND" makes sense
+SECOND = 1000000000
+
+
+EnvRunningContainers = Dict[VirtualEnv, RunningContainers]
+
+
+def getfloat(reader: SectionReader, key: str) -> Optional[float]:
+    val = reader.getstring(key)
+    if val is None:
+        return None
+
+    try:
+        return float(val)
+    except ValueError:
+        msg = f"{val!r} is not a number (for {key} in [{reader.section_name}])"
+        raise ValueError(msg)
+
+
+def gettime(reader: SectionReader, key: str) -> Optional[int]:
+    raw = getfloat(reader, key)
+    if raw is None:
+        return None
+
+    return int(raw * SECOND)
+
+
+def getint(reader: SectionReader, key: str) -> Optional[int]:
+    raw = getfloat(reader, key)
+    if raw is None:
+        return None
+
+    val = int(raw)
+    if val != raw:
+        msg = f"{val!r} is not an int (for {key} in [{reader.section_name}])"
+        raise ValueError(msg)
+    return val
+
+
+def getenvdict(reader: SectionReader, key: str) -> Mapping[str, str]:
+    environment = {}
+    for value in reader.getlist(key):
+        envvar, _, value = value.partition("=")
+        environment[envvar] = value
+    return environment
+
+
+def discover_container_configs(config: Config) -> Sequence[str]:
+    """
+    Read the tox.ini, and return a list of docker container configs.
+
+    """
+
+    inipath = str(config.toxinipath)
+    iniparser = py.iniconfig.IniConfig(inipath)
+
+    container_names = set()
+    for section in iniparser.sections:
+        if not section.startswith("docker:"):
+            continue
+
+        _, _, container_name = section.partition(":")
+        if not re.match(r"^[a-zA-Z][-_.a-zA-Z0-9]+$", container_name):
+            raise ValueError(f"{container_name!r} is not a valid container name")
+
+        # populated in the next loop
+        container_names.add(container_name)
+
+    return list(container_names)
+
+
+def parse_container_config(
+    config: Config, container_name: str, all_container_names: Container[str]
+) -> ContainerConfig:
+    inipath = str(config.toxinipath)
+    iniparser = py.iniconfig.IniConfig(inipath)
+
+    reader = SectionReader(f"docker:{container_name}", iniparser)
+    reader.addsubstitutions(
+        distdir=config.distdir,
+        homedir=config.homedir,
+        toxinidir=config.toxinidir,
+        toxworkdir=config.toxworkdir,
+    )
+
+    kwargs = {
+        "name": container_name,
+        "image": Image(reader.getstring("image")),
+        "stop": container_name not in config.option.docker_dont_stop,
+    }
+
+    environment = None
+    if reader.getstring("environment"):
+        environment = getenvdict(reader, "environment")
+
+    hc_cmd = hc_interval = hc_timeout = hc_start_period = hc_retries = None
+    if reader.getstring("healthcheck_cmd"):
+        hc_cmd = reader.getstring("healthcheck_cmd")
+    if reader.getstring("healthcheck_interval"):
+        hc_interval = gettime(reader, "healthcheck_interval")
+    if reader.getstring("healthcheck_timeout"):
+        hc_timeout = gettime(reader, "healthcheck_timeout")
+    if reader.getstring("healthcheck_start_period"):
+        hc_start_period = gettime(reader, "healthcheck_start_period")
+    if reader.getstring("healthcheck_retries"):
+        hc_retries = getint(reader, "healthcheck_retries")
+
+    ports = None
+    if reader.getstring("ports"):
+        ports = [Port(line) for line in reader.getlist("ports")]
+
+    links = None
+    if reader.getstring("links"):
+        links = [Link(line) for line in reader.getlist("links")]
+
+    volumes = None
+    if reader.getstring("volumes"):
+        volumes = [Volume(line) for line in reader.getlist("volumes")]
+
+    return ContainerConfig(
+        name=container_name,
+        image=Image(reader.getstring("image")),
+        stop=container_name not in config.option.docker_dont_stop,
+        environment=environment,
+        healthcheck_cmd=hc_cmd,
+        healthcheck_interval=hc_interval,
+        healthcheck_timeout=hc_timeout,
+        healthcheck_start_period=hc_start_period,
+        healthcheck_retries=hc_retries,
+        ports=ports,
+        links=links,
+        volumes=volumes,
+    )
diff --git a/tox_docker/tox3/log.py b/tox_docker/tox3/log.py
new file mode 100644
index 0000000..ac91182
--- /dev/null
+++ b/tox_docker/tox3/log.py
@@ -0,0 +1,18 @@
+from tox.venv import VirtualEnv
+
+from tox_docker.log import LogFunc
+
+
+def make_logger(venv: VirtualEnv) -> LogFunc:
+    def logger(line: str) -> None:
+        try:
+            # tox 3.7 and later
+            action = venv.new_action(line)
+        except AttributeError:
+            action = venv.session.newaction(venv, line)
+
+        action.setactivity("docker", line)
+        with action:
+            pass
+
+    return logger
diff --git a/tox_docker/tox3/plugin.py b/tox_docker/tox3/plugin.py
new file mode 100644
index 0000000..e300421
--- /dev/null
+++ b/tox_docker/tox3/plugin.py
@@ -0,0 +1,145 @@
+__all__ = (
+    "tox_configure",
+    "tox_runtest_pre",
+    "tox_runtest_post",
+    "tox_addoption",
+)
+
+from typing import Dict
+import os
+
+from tox import hookimpl
+from tox.action import Action
+from tox.config import Config, Parser
+from tox.venv import VirtualEnv
+
+from tox_docker.config import ContainerConfig, RunningContainers
+from tox_docker.plugin import (
+    docker_health_check,
+    docker_pull,
+    docker_run,
+    get_env_vars,
+    HealthCheckFailed,
+    stop_containers,
+)
+from tox_docker.tox3.config import (
+    discover_container_configs,
+    EnvRunningContainers,
+    parse_container_config,
+)
+from tox_docker.tox3.log import make_logger
+
+CONTAINER_CONFIGS: Dict[str, ContainerConfig] = {}
+ENV_CONTAINERS: EnvRunningContainers = {}
+
+
+def _newaction(venv: VirtualEnv, message: str) -> Action:
+    try:
+        # tox 3.7 and later
+        return venv.new_action(message)
+    except AttributeError:
+        return venv.session.newaction(venv, message)
+
+
+def _config_name(running_name: str) -> str:
+    # inverse of tox_docker.config.runas_name()
+    suffix = f"-tox-{os.getpid()}"
+    assert running_name.endswith(suffix)
+    return running_name[: -len(suffix)]
+
+
+@hookimpl
+def tox_configure(config: Config) -> None:
+    container_config_names = discover_container_configs(config)
+
+    # validate command line options
+    for container_name in config.option.docker_dont_stop:
+        if container_name not in container_config_names:
+            raise ValueError(
+                f"Container {container_name!r} not found (from --docker-dont-stop)"
+            )
+
+    for container_name in container_config_names:
+        CONTAINER_CONFIGS[container_name] = parse_container_config(
+            config, container_name, container_config_names
+        )
+
+
+@hookimpl
+def tox_runtest_pre(venv: VirtualEnv) -> None:
+    envconfig = venv.envconfig
+    container_names = envconfig.docker
+
+    log = make_logger(venv)
+
+    env_container_configs = []
+
+    seen = set()
+    for container_name in container_names:
+        if container_name not in CONTAINER_CONFIGS:
+            raise ValueError(f"Missing [docker:{container_name}] in tox.ini")
+        if container_name in seen:
+            raise ValueError(f"Container {container_name!r} specified more than once")
+        seen.add(container_name)
+        env_container_configs.append(CONTAINER_CONFIGS[container_name])
+
+    for container_config in env_container_configs:
+        docker_pull(container_config, log)
+
+    ENV_CONTAINERS.setdefault(venv, {})
+    running_containers = ENV_CONTAINERS[venv]
+
+    for container_config in env_container_configs:
+        container = docker_run(container_config, running_containers, log)
+        running_containers[container_config.runas_name] = container
+
+    for running_name, container in running_containers.items():
+        container_name = _config_name(running_name)
+        container_config = CONTAINER_CONFIGS[container_name]
+        try:
+            docker_health_check(container_config, container, log)
+        except HealthCheckFailed:
+            msg = f"{container_config.image!r} (from {container_name!r}) failed health check"
+            venv.status = msg
+            tox_runtest_post(venv)
+            raise
+
+    for running_name, container in running_containers.items():
+        container_name = _config_name(running_name)
+        container_config = CONTAINER_CONFIGS[container_name]
+        for key, val in get_env_vars(container_config, container).items():
+            venv.envconfig.setenv[key] = val
+
+
+@hookimpl
+def tox_runtest_post(venv: VirtualEnv) -> None:
+    env_containers: RunningContainers = ENV_CONTAINERS.get(venv, {})
+    containers_and_configs = [
+        (CONTAINER_CONFIGS[_config_name(name)], container)
+        for name, container in env_containers.items()
+    ]
+    log = make_logger(venv)
+    stop_containers(containers_and_configs, log)
+
+
+@hookimpl
+def tox_addoption(parser: Parser) -> None:
+    # necessary to allow the docker= directive in testenv sections
+    parser.add_testenv_attribute(
+        name="docker",
+        type="line-list",
+        help="Name of docker images, including tag, to start before the test run",
+        default=[],
+    )
+
+    # command line flag to keep docker containers running
+    parser.add_argument(
+        "--docker-dont-stop",
+        default=[],
+        action="append",
+        metavar="CONTAINER",
+        help=(
+            "If specified, tox-docker will not stop CONTAINER after the test run. "
+            "Can be specified multiple times."
+        ),
+    )
diff --git a/tox_docker/tox4/__init__.py b/tox_docker/tox4/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tox_docker/tox4/config.py b/tox_docker/tox4/config.py
new file mode 100644
index 0000000..8beb390
--- /dev/null
+++ b/tox_docker/tox4/config.py
@@ -0,0 +1,105 @@
+from typing import Dict, List
+
+from tox.config.sets import ConfigSet
+
+from tox_docker.config import ContainerConfig, Image, Link, Port, Volume
+
+# nanoseconds in a second; named "SECOND" so that "1.5 * SECOND" makes sense
+SECOND = 1000000000
+
+
+class MissingRequiredSetting(Exception):
+    pass
+
+
+def image_required(image: Image) -> Image:
+    if not image.name:
+        raise MissingRequiredSetting("image")
+
+    return image
+
+
+class DockerConfigSet(ConfigSet):
+    def register_config(self) -> None:
+        self.add_config(
+            keys=["image"],
+            of_type=Image,
+            default=Image(""),
+            desc="docker image to run",
+            post_process=image_required,
+        )
+        self.add_config(
+            keys=["environment"],
+            of_type=Dict[str, str],
+            default={},
+            desc="environment variables to pass to the docker container",
+        )
+        self.add_config(
+            keys=["ports"],
+            of_type=List[Port],
+            default=[],
+            desc="ports to expose",
+        )
+        self.add_config(
+            keys=["links"],
+            of_type=List[Link],
+            default=[],
+            desc="containers to link",
+        )
+        self.add_config(
+            keys=["volumes"],
+            of_type=List[Volume],
+            default=[],
+            desc="volumes to attach",
+        )
+
+        self.add_config(
+            keys=["healthcheck_cmd"],
+            of_type=str,
+            default="",
+            desc="docker healthcheck command",
+        )
+        self.add_config(
+            keys=["healthcheck_interval"],
+            of_type=float,
+            default=0,
+            desc="docker healthcheck interval",
+            post_process=lambda num: int(num * SECOND),
+        )
+        self.add_config(
+            keys=["healthcheck_timeout"],
+            of_type=float,
+            default=0,
+            desc="docker healthcheck timeout",
+            post_process=lambda num: int(num * SECOND),
+        )
+        self.add_config(
+            keys=["healthcheck_start_period"],
+            of_type=float,
+            default=0,
+            desc="docker healthcheck startup grace period",
+            post_process=lambda num: int(num * SECOND),
+        )
+        self.add_config(
+            keys=["healthcheck_retries"],
+            of_type=int,
+            default=0,
+            desc="docker healthcheck retry count",
+        )
+
+
+def parse_container_config(docker_config: DockerConfigSet) -> ContainerConfig:
+    return ContainerConfig(
+        name=docker_config.name,
+        image=docker_config["image"],
+        stop=docker_config.name not in docker_config._conf.options.docker_dont_stop,
+        environment=docker_config["environment"],
+        healthcheck_cmd=docker_config["healthcheck_cmd"],
+        healthcheck_interval=docker_config["healthcheck_interval"],
+        healthcheck_timeout=docker_config["healthcheck_timeout"],
+        healthcheck_start_period=docker_config["healthcheck_start_period"],
+        healthcheck_retries=docker_config["healthcheck_retries"],
+        ports=docker_config["ports"],
+        links=docker_config["links"],
+        volumes=docker_config["volumes"],
+    )
diff --git a/tox_docker/tox4/log.py b/tox_docker/tox4/log.py
new file mode 100644
index 0000000..e9511f2
--- /dev/null
+++ b/tox_docker/tox4/log.py
@@ -0,0 +1,5 @@
+from logging import getLogger
+
+
+def log(line: str) -> None:
+    getLogger().warning(f"docker> {line}")
diff --git a/tox_docker/tox4/plugin.py b/tox_docker/tox4/plugin.py
new file mode 100644
index 0000000..ffc53d6
--- /dev/null
+++ b/tox_docker/tox4/plugin.py
@@ -0,0 +1,130 @@
+__all__ = (
+    "tox_add_env_config",
+    "tox_add_option",
+    "tox_after_run_commands",
+    "tox_before_run_commands",
+)
+
+from typing import List
+
+from tox.config.cli.parser import ToxParser
+from tox.config.loader.section import Section
+from tox.config.sets import EnvConfigSet
+from tox.execute.api import Outcome
+from tox.plugin import impl
+from tox.session.state import State
+from tox.tox_env.api import ToxEnv
+from tox.tox_env.errors import Fail
+
+from tox_docker.config import RunningContainers
+from tox_docker.plugin import (
+    docker_get,
+    docker_health_check,
+    docker_pull,
+    docker_run,
+    get_env_vars,
+    HealthCheckFailed,
+    stop_containers,
+)
+from tox_docker.tox4.config import DockerConfigSet, parse_container_config
+from tox_docker.tox4.log import log
+
+
+@impl
+def tox_add_env_config(env_conf: EnvConfigSet, state: State) -> None:
+    def build_docker_config_set(container_name: object) -> DockerConfigSet:
+        assert isinstance(container_name, str)
+        docker_conf = state.conf.get_section_config(
+            section=Section("docker", container_name),
+            base=[],
+            of_type=DockerConfigSet,
+            for_env=None,
+        )
+        if not docker_conf.loaders:
+            raise ValueError(f"Missing [docker:{container_name}] in tox.ini")
+        return docker_conf
+
+    env_conf.add_config(
+        keys=["docker"],
+        of_type=List[DockerConfigSet],
+        default=[],
+        desc="docker image configs to load",
+        factory=build_docker_config_set,  # type: ignore
+    )
+
+
+@impl
+def tox_before_run_commands(tox_env: ToxEnv) -> None:
+    docker_confs = tox_env.conf.load("docker")
+
+    container_configs = [
+        parse_container_config(docker_conf) for docker_conf in docker_confs
+    ]
+
+    seen = set()
+    for container_config in container_configs:
+        if container_config.name in seen:
+            raise ValueError(
+                f"Container {container_config.name!r} specified more than once"
+            )
+        seen.add(container_config.name)
+
+    for container_config in container_configs:
+        docker_pull(container_config, log)
+
+    config_and_container = []
+    running_containers: RunningContainers = {}
+    for container_config in container_configs:
+        container = docker_run(container_config, running_containers, log)
+        config_and_container.append((container_config, container))
+        running_containers[container_config.runas_name] = container
+
+    for container_config, container in config_and_container:
+        try:
+            docker_health_check(container_config, container, log)
+        except HealthCheckFailed:
+            tox_env.interrupt()
+            clean_up_containers(tox_env)
+            raise Fail(
+                f"{container_config.image!r} (from {container_config.name!r}) failed health check"
+            )
+
+        tox_env.conf["set_env"].update(get_env_vars(container_config, container))
+
+
+@impl
+def tox_after_run_commands(
+    tox_env: ToxEnv, exit_code: int, outcomes: List[Outcome]
+) -> None:
+    clean_up_containers(tox_env)
+
+
+def clean_up_containers(tox_env: ToxEnv) -> None:
+    docker_confs = tox_env.conf.load("docker")
+
+    container_configs = [
+        parse_container_config(docker_conf) for docker_conf in docker_confs
+    ]
+
+    configs_and_containers = []
+    for config in container_configs:
+        container = docker_get(config)
+        if container:
+            configs_and_containers.append((config, container))
+
+    stop_containers(configs_and_containers, log)
+
+
+@impl
+def tox_add_option(parser: ToxParser) -> None:
+    # command line flag to keep docker containers running
+    parser.add_argument(
+        "--docker-dont-stop",
+        default=[],
+        action="append",
+        metavar="CONTAINER",
+        help=(
+            "If specified, tox-docker will not stop CONTAINER after the test run. "
+            "Can be specified multiple times."
+        ),
+    )


Test patch:
diff --git a/tox_docker/tests/assert_containers_and_volumes_unchanged.py b/tox_docker/tests/assert_containers_and_volumes_unchanged.py
index e149b7c..3d618ff 100644
--- a/tox_docker/tests/assert_containers_and_volumes_unchanged.py
+++ b/tox_docker/tests/assert_containers_and_volumes_unchanged.py
@@ -1,10 +1,9 @@
-import os
 import sys
 
 import docker
 
 client = docker.from_env(version="auto")
-envdir = os.environ["TOX_ENV_DIR"]
+envdir = sys.argv[1]
 
 container_ids = set(
     [container.attrs["Id"].strip() for container in client.containers.list()]
diff --git a/tox_docker/tests/capture_containers_and_volumes.py b/tox_docker/tests/capture_containers_and_volumes.py
index cbde3b1..733a24f 100644
--- a/tox_docker/tests/capture_containers_and_volumes.py
+++ b/tox_docker/tests/capture_containers_and_volumes.py
@@ -1,9 +1,9 @@
-import os
+import sys
 
 import docker
 
 client = docker.from_env(version="auto")
-envdir = os.environ["TOX_ENV_DIR"]
+envdir = sys.argv[1]
 
 container_ids = [
     container.attrs["Id"].strip() for container in client.containers.list()
diff --git a/tox_docker/tests/test_containers.py b/tox_docker/tests/test_containers.py
index 4e49e5c..c67fffd 100644
--- a/tox_docker/tests/test_containers.py
+++ b/tox_docker/tests/test_containers.py
@@ -1,9 +1,17 @@
 from tox_docker.tests.util import find_container
 
 
-def test_it_can_run_two_of_the_same_image():
+def test_it_can_run_two_of_the_same_image() -> None:
     hc_builtin = find_container("healthcheck-builtin")
     hc_custom = find_container("healthcheck-custom")
 
     assert hc_builtin.id != hc_custom.id
     assert hc_builtin.attrs["Image"] == hc_custom.attrs["Image"]
+
+
+def test_container_has_unique_runas_suffix() -> None:
+    container = find_container("healthcheck-builtin")
+    assert container.name.startswith("healthcheck-builtin")
+    assert container.name != "healthcheck-builtin"
+    # don't test the details of how we do this; the suffix could be some
+    # randomly-chosen suffix, could be PID (it is), or something else
diff --git a/tox_docker/tests/test_env_vars.py b/tox_docker/tests/test_env_vars.py
index 167bdbd..dfd6b8c 100644
--- a/tox_docker/tests/test_env_vars.py
+++ b/tox_docker/tests/test_env_vars.py
@@ -1,9 +1,9 @@
 import os
 
-from tox_docker import escape_env_var
+from tox_docker.plugin import escape_env_var
 
 
-def test_it_sets_automatic_env_vars():
+def test_it_sets_automatic_env_vars() -> None:
     # ksdn117/tcp-udp-test exposes TCP port 1234 and UDP port 5678
     assert "NETWORKING_ONE_HOST" in os.environ
     assert "NETWORKING_ONE_1234_TCP_PORT" in os.environ
@@ -14,7 +14,7 @@ def test_it_sets_automatic_env_vars():
     assert "NETWORKING_ONE_5678_UDP" not in os.environ
 
 
-def test_escape_env_var():
+def test_escape_env_var() -> None:
     assert (
         escape_env_var("my.private.registry/cat/image")
         == "MY_PRIVATE_REGISTRY_CAT_IMAGE"
diff --git a/tox_docker/tests/test_gateway.py b/tox_docker/tests/test_gateway.py
index 73a4e9e..4f1afa3 100644
--- a/tox_docker/tests/test_gateway.py
+++ b/tox_docker/tests/test_gateway.py
@@ -1,14 +1,15 @@
 from contextlib import contextmanager
+from typing import Iterator
 from unittest.mock import patch
 import sys
 
 import pytest
 
-from tox_docker import _get_gateway_ip
+from tox_docker.plugin import get_gateway_ip
 
 
 @contextmanager
-def sys_platform_as(value):
+def sys_platform_as(value: str) -> Iterator[None]:
     old_value = sys.platform
     sys.platform = value
     try:
@@ -21,29 +22,29 @@ class NotARealContainer(object):
     attrs = {"NetworkSettings": {"Gateway": "1.2.3.4"}}
 
 
-def test_gateway_ip_is_read_from_container_attrs_on_linux():
+def test_gateway_ip_is_read_from_container_attrs_on_linux() -> None:
     container = NotARealContainer()
     with sys_platform_as("linux2"):
-        assert _get_gateway_ip(container) == "1.2.3.4"
+        assert get_gateway_ip(container) == "1.2.3.4"
 
 
-def test_gateway_ip_is_zero_zero_zero_zero_on_macos():
+def test_gateway_ip_is_zero_zero_zero_zero_on_macos() -> None:
     container = NotARealContainer()
     with sys_platform_as("darwin"):
-        assert _get_gateway_ip(container) == "0.0.0.0"
+        assert get_gateway_ip(container) == "0.0.0.0"
 
 
 @pytest.mark.parametrize("sys_platform", ["linux2", "darwin"])
-def test_gateway_ip_from_env_var_overrides_attrs_on_linux(sys_platform):
+def test_gateway_ip_from_env_var_overrides_attrs_on_linux(sys_platform: str) -> None:
     container = NotARealContainer()
     with sys_platform_as(sys_platform):
         with patch.dict("os.environ", {"TOX_DOCKER_GATEWAY": "192.168.1.1"}):
-            assert _get_gateway_ip(container) == "192.168.1.1"
+            assert get_gateway_ip(container) == "192.168.1.1"
 
 
 @pytest.mark.parametrize("sys_platform", ["linux2", "darwin"])
-def test_gateway_ip_is_looked_up_from_hostname(sys_platform):
+def test_gateway_ip_is_looked_up_from_hostname(sys_platform: str) -> None:
     container = NotARealContainer()
     with sys_platform_as(sys_platform):
         with patch.dict("os.environ", {"TOX_DOCKER_GATEWAY": "localhost"}):
-            assert _get_gateway_ip(container) in {"127.0.0.1", "::1"}
+            assert get_gateway_ip(container) in {"127.0.0.1", "::1"}
diff --git a/tox_docker/tests/test_healthcheck.py b/tox_docker/tests/test_healthcheck.py
index 74a81fc..5e43652 100644
--- a/tox_docker/tests/test_healthcheck.py
+++ b/tox_docker/tests/test_healthcheck.py
@@ -5,7 +5,7 @@
 
 
 @pytest.mark.parametrize("instance", ["HEALTHCHECK_BUILTIN", "HEALTHCHECK_CUSTOM"])
-def test_the_image_is_healthy(instance):
+def test_the_image_is_healthy(instance: str) -> None:
     host = os.environ[f"{instance}_HOST"]
     port = os.environ[f"{instance}_8000_TCP_PORT"]
     url = f"http://{host}:{port}/healthy"
diff --git a/tox_docker/tests/test_images.py b/tox_docker/tests/test_images.py
new file mode 100644
index 0000000..635d810
--- /dev/null
+++ b/tox_docker/tests/test_images.py
@@ -0,0 +1,31 @@
+from tox_docker.config import Image
+
+
+def test_it_parses_name_and_tag() -> None:
+    i = Image("nginx:latest")
+    assert i.name == "nginx"
+    assert i.tag == "latest"
+
+
+def test_it_defaults_tag_to_None() -> None:
+    i = Image("nginx")
+    assert i.name == "nginx"
+    assert i.tag is None
+
+
+def test_it_parses_registry_url_into_name() -> None:
+    i = Image("docker.io/toxdocker/healthcheck:latest")
+    assert i.name == "docker.io/toxdocker/healthcheck"
+    assert i.tag == "latest"
+
+
+def test_it_allows_port_in_registry_url() -> None:
+    i = Image("private-registry:5000/namespace/image-name:1.0.0")
+    assert i.name == "private-registry:5000/namespace/image-name"
+    assert i.tag == "1.0.0"
+
+
+def test_it_allows_port_in_registry_url_without_tag() -> None:
+    i = Image("private-registry:5000/namespace/image-name")
+    assert i.name == "private-registry:5000/namespace/image-name"
+    assert i.tag is None
diff --git a/tox_docker/tests/test_links.py b/tox_docker/tests/test_links.py
index ac068d7..452abf8 100644
--- a/tox_docker/tests/test_links.py
+++ b/tox_docker/tests/test_links.py
@@ -1,10 +1,10 @@
 import pytest
 
-from tox_docker import _validate_link_line
+from tox_docker.config import Link, runas_name
 from tox_docker.tests.util import find_container
 
 
-def test_linked_containers_can_communicate():
+def test_linked_containers_can_communicate() -> None:
     outer_container = find_container("networking-two")
     # the outer container should have a link named "linked_host"
     # to the inner container, listening on port 1234
@@ -12,15 +12,14 @@ def test_linked_containers_can_communicate():
     assert exitcode == 0
 
 
-def test_validate_link_line():
-    names = {"httpd"}
-    assert _validate_link_line("httpd:apache", names) == ("httpd", "apache")
-    assert _validate_link_line("httpd", names) == ("httpd", "httpd")
+def test_link_parsing() -> None:
+    assert Link("httpd").target == runas_name("httpd")
+    assert Link("httpd").alias == "httpd"
 
+    assert Link("httpd:apache").target == runas_name("httpd")
+    assert Link("httpd:apache").alias == "apache"
 
-def test_validate_link_line_rejects_dangling_comma():
-    names = {"httpd"}
-    with pytest.raises(ValueError):
-        _validate_link_line("httpd:", names)
+
+def test_link_parsing_rejects_trailing_colon() -> None:
     with pytest.raises(ValueError):
-        _validate_link_line("httpd", set())
+        Link("httpd:")
diff --git a/tox_docker/tests/test_ports.py b/tox_docker/tests/test_ports.py
index b9417ae..182d566 100644
--- a/tox_docker/tests/test_ports.py
+++ b/tox_docker/tests/test_ports.py
@@ -2,7 +2,7 @@
 import os
 
 
-def test_exposed_ports_are_accessible_to_test_runs():
+def test_exposed_ports_are_accessible_to_test_runs() -> None:
     host = os.environ["HEALTHCHECK_BUILTIN_HOST"]
     port = os.environ["HEALTHCHECK_BUILTIN_8000_TCP_PORT"]
 
@@ -11,7 +11,7 @@ def test_exposed_ports_are_accessible_to_test_runs():
     assert b"Directory listing for /" in response.read()
 
 
-def test_it_exposes_only_specified_port():
+def test_it_exposes_only_specified_port() -> None:
     # this container remaps 1234/tcp to 2345, and hides 5678/udp
     assert os.environ["NETWORKING_TWO_1234_TCP_PORT"] == "2345"
     assert "NETWORKING_TWO_5678_UDP_PORT" not in os.environ
diff --git a/tox_docker/tests/test_tox_cleanup.py b/tox_docker/tests/test_tox_cleanup.py
deleted file mode 100644
index 08e4e2a..0000000
--- a/tox_docker/tests/test_tox_cleanup.py
+++ /dev/null
@@ -1,77 +0,0 @@
-from tox import hookspecs
-from tox.action import Action
-
-from tox_docker import tox_cleanup, tox_runtest_post
-
-
-class Session:
-    def __init__(self, config):
-        self.config = config
-        self.existing_venvs = {}
-
-
-class Config:
-    def __init__(self):
-        self._docker_container_configs = {}
-
-
-class Container:
-    def __init__(self, short_id, virtual_env):
-        self.running = True
-        self.short_id = short_id
-        env_config = virtual_env.envconfig
-        env_config._docker_containers[short_id] = self
-        env_config.config._docker_container_configs[short_id] = {"stop": True}
-
-    # noinspection PyUnusedLocal
-    def remove(self, v=False, force=False):
-        if not self.running:
-            raise ValueError("Container is not running")
-        self.running = False
-
-
-class EnvConfig:
-    def __init__(self, config):
-        self._docker_containers = {}
-        self.docker = True
-        self.config = config
-
-
-class VEnv:
-    def __init__(self, config):
-        self.envconfig = EnvConfig(config)
-
-    @classmethod
-    def new_action(cls, message):
-        return Action(
-            "docker", message, [], ".", False, False, None, None, 100, 100, 100
-        )
-
-
-def test_tox_emergency_cleanup():
-    """check if the container is stopped even if tox_runtest_post has not been called"""
-    config = Config()
-    session = Session(config)
-    virtual_env = VEnv(config)
-    session.existing_venvs["test_venv"] = virtual_env
-    container = Container("test_container", virtual_env)
-    assert container.running
-    tox_cleanup(session)
-    if hasattr(hookspecs, "tox_cleanup"):
-        assert not container.running
-    else:
-        assert container.running
-
-
-def test_tox_normal_cleanup():
-    """normal situation: tox_runtest_post has been called before tox_cleanup"""
-    config = Config()
-    session = Session(config)
-    virtual_env = VEnv(config)
-    session.existing_venvs["test_venv"] = virtual_env
-    container = Container("test_container", virtual_env)
-    assert container.running
-    tox_runtest_post(virtual_env)
-    assert not container.running
-    tox_cleanup(session)
-    assert not container.running
diff --git a/tox_docker/tests/test_volumes.py b/tox_docker/tests/test_volumes.py
index 14e0748..1828152 100644
--- a/tox_docker/tests/test_volumes.py
+++ b/tox_docker/tests/test_volumes.py
@@ -1,7 +1,7 @@
 import os
 
 
-def test_the_image_is_healthy():
+def test_the_image_is_healthy() -> None:
     # the healthcheck creates a file "healthy" in the volume from within
     # the container; this test proves it's visible outside the container,
     # and thus the bind mount worked as expected
diff --git a/tox_docker/tests/util.py b/tox_docker/tests/util.py
index fd26dd8..c5d4fff 100644
--- a/tox_docker/tests/util.py
+++ b/tox_docker/tests/util.py
@@ -1,13 +1,23 @@
+import os
+
+from docker.models.containers import Container
 import docker
 import pytest
 
+from tox_docker.config import runas_name
+
 
-def find_container(instance_name):
+def find_container(instance_name: str) -> Container:
     # TODO: refactor this as a pytest fixture
+
+    # this is running in a child-process of the tox instance which
+    # spawned the container; so we need to pass the parent pid to
+    # get the right runas_name()
+    running_name = runas_name(instance_name, pid=os.getppid())
     client = docker.from_env(version="auto")
     for container in client.containers.list():
-        labels = container.attrs["Config"].get("Labels", {})
-        if labels.get("tox_docker_container_name") == instance_name:
+        container.attrs["Config"].get("Labels", {})
+        if container.name == running_name:
             return container
 
-    pytest.fail(f"No running container with instance name {instance_name!r}")
+    pytest.fail(f"No running container with instance name {running_name!r}")