8000 tests: factor out base nginx-proxy config and enable local testing on macOS / Darwin by buchdag · Pull Request #2570 · nginx-proxy/nginx-proxy · GitHub
[go: up one dir, main page]

Skip to content

tests: factor out base nginx-proxy config and enable local testing on macOS / Darwin #2570

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 6 commits into from
Jan 5, 2025
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 38 additions & 7 deletions test/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,50 @@ This test suite uses [pytest](http://doc.pytest.org/en/latest/). The [conftest.p

### docker_compose fixture

When using the `docker_compose` fixture in a test, pytest will try to find a yml file named after your test module filename. For instance, if your test module is `test_example.py`, then the `docker_compose` fixture will try to load a `test_example.yml` [docker compose file](https://docs.docker.com/compose/compose-file/).
When using the `docker_compose` fixture in a test, pytest will try to start the [Docker Compose](https://docs.docker.com/compose/) services corresponding to the current test module, based on the test module filename.

Once the docker compose file found, the fixture will remove all containers, run `docker compose up`, and finally your test will be executed.
By default, if your test module file is `test/test_subdir/test_example.py`, then the `docker_compose` fixture will try to load the following files, [merging them](https://docs.docker.com/reference/compose-file/merge/) in this order:

The fixture will run the _docker compose_ command with the `-f` option to load the given compose file. So you can test your docker compose file syntax by running it yourself with:
1. `test/compose.base.yml`
2. `test/test_subdir/compose.base.override.yml` (if it exists)
3. `test/test_subdir/test_example.yml`

docker compose -f test_example.yml up -d
The fixture will run the _docker compose_ command with the `-f` option to load the given compose files. So you can test your docker compose file syntax by running it yourself with:

docker compose -f test/compose.base.yml -f test/test_subdir/test_example.yml up -d

The first file contains the base configuration of the nginx-proxy container common to most tests:

```yaml
services:
nginx-proxy:
image: nginxproxy/nginx-proxy:test
container_name: nginx-proxy
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
ports:
- "80:80"
- "443:443"
```

The second optional file allow you to override this base configuration for all test modules in a subfolder.

The third file contains the services and overrides specific to a given test module.

This automatic merge can be bypassed by using a file named `test_example.base.yml` (instead of `test_example.yml`). When this file exist, it will be the only one used by the test and no merge with other compose files will automatically occur.

The `docker_compose` fixture also set the `PYTEST_MODULE_PATH` environment variable to the absolute path of the current test module directory, so it can be used to mount files or directory relatives to the current test.

In the case you are running pytest from within a docker container, the `docker_compose` fixture will make sure the container running pytest is attached to all docker networks. That way, your test will be able to reach any of them.

In your tests, you can use the `docker_compose` variable to query and command the docker daemon as it provides you with a [client from the docker python module](https://docker-py.readthedocs.io/en/4.4.4/client.html#client-reference).

Also this fixture alters the way the python interpreter resolves domain names to IP addresses in the following ways:

Any domain name containing the substring `nginx-proxy` will resolve to the IP address of the container that was created from the `nginxproxy/nginx-proxy:test` image. So all the following domain names will resolve to the nginx-proxy container in tests:
Any domain name containing the substring `nginx-proxy` will resolve to `127.0.0.1` if the tests are executed on a Darwin (macOS) system, otherwise the IP address of the container that was created from the `nginxproxy/nginx-proxy:test` image.

So, in tests, all the following domain names will resolve to either localhost or the nginx-proxy container's IP:

- `nginx-proxy`
- `nginx-proxy.com`
- `www.nginx-proxy.com`
Expand All @@ -80,14 +109,16 @@ Any domain name containing the substring `nginx-proxy` will resolve to the IP ad
- `whatever.nginx-proxyooooooo`
- ...

Any domain name ending with `XXX.container.docker` will resolve to the IP address of the XXX container.
Any domain name ending with `XXX.container.docker` will resolve to `127.0.0.1` if the tests are executed on a Darwin (macOS) system, otherwise the IP address of the container named `XXX`.

So, on a non-Darwin system:

- `web1.container.docker` will resolve to the IP address of the `web1` container
- `f00.web1.container.docker` will resolve to the IP address of the `web1` container
- `anything.whatever.web2.container.docker` will resolve to the IP address of the `web2` container

Otherwise, domain names are resoved as usual using your system DNS resolver.


### nginxproxy fixture

The `nginxproxy` fixture will provide you with a replacement for the python [requests](https://pypi.python.org/pypi/requests/) module. This replacement will just repeat up to 30 times a requests if it receives the HTTP error 404 or 502. This error occurs when you try to send queries to nginx-proxy too early after the container creation.
Expand Down
9 changes: 9 additions & 0 deletions test/compose.base.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
services:
nginx-proxy:
image: nginxproxy/nginx-proxy:test
container_name: nginx-proxy
volumes:
- /var/run/docker.sock:/tmp/docker.sock:ro
ports:
- "80:80"
- "443:443"
176 changes: 118 additions & 58 deletions test/conftest.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import contextlib
import logging
import os
import pathlib
import platform
import re
import shlex
import socket
import subprocess
import time
from io import StringIO
from typing import Iterator, List, Optional

import backoff
Expand All @@ -20,12 +23,13 @@
from requests import Response
from urllib3.util.connection import HAS_IPV6


logging.basicConfig(level=logging.INFO)
logging.getLogger('backoff').setLevel(logging.INFO)
logging.getLogger('DNS').setLevel(logging.DEBUG)
logging.getLogger('requests.packages.urllib3.connectionpool').setLevel(logging.WARN)

CA_ROOT_CERTIFICATE = os.path.join(os.path.dirname(__file__), 'certs/ca-root.crt')
CA_ROOT_CERTIFICATE = pathlib.Path(__file__).parent.joinpath("certs/ca-root.crt")
PYTEST_RUNNING_IN_CONTAINER = os.environ.get('PYTEST_RUNNING_IN_CONTAINER') == "1"
FORCE_CONTAINER_IPV6 = False # ugly global state to consider containers' IPv6 address instead of IPv4

Expand Down Expand Up @@ -71,8 +75,8 @@ class RequestsForDocker:
"""
def __init__(self):
self.session = requests.Session()
if os.path.isfile(CA_ROOT_CERTIFICATE):
self.session.verify = CA_ROOT_CERTIFICATE
if CA_ROOT_CERTIFICATE.is_file():
self.session.verify = CA_ROOT_CERTIFICATE.as_posix()

@staticmethod
def get_nginx_proxy_container() -> Container:
Expand Down Expand Up @@ -217,8 +221,8 @@ def nginx_proxy_dns_resolver(domain_name: str) -> Optional[str]:

def docker_container_dns_resolver(domain_name: str) -> Optional[str]:
"""
if domain name is of the form "XXX.container.docker" or "anything.XXX.container.docker", return the ip address of the docker container
named XXX.
if domain name is of the form "XXX.container.docker" or "anything.XXX.container.docker",
return the ip address of the docker container named XXX.

:return: IP or None
"""
Expand Down Expand Up @@ -248,7 +252,10 @@ def monkey_patch_urllib_dns_resolver():
"""
Alter the behavior of the urllib DNS resolver so that any domain name
containing substring 'nginx-proxy' will resolve to the IP address
of the container created from image 'nginxproxy/nginx-proxy:test'.
of the container created from image 'nginxproxy/nginx-proxy:test',
or to 127.0.0.1 on Darwin.

see https://docs.docker.com/desktop/features/networking/#i-want-to-connect-to-a-container-from-the-host
"""
prv_getaddrinfo = socket.getaddrinfo
dns_cache = {}
Expand All @@ -262,7 +269,12 @@ def new_getaddrinfo(*args):
pytest.skip("This system does not support IPv6")

# custom DNS resolvers
ip = nginx_proxy_dns_resolver(args[0])
ip = None
# Docker Desktop can't route traffic directly to Linux containers.
if platform.system() == "Darwin":
ip = "127.0.0.1"
if ip is None:
ip = nginx_proxy_dns_resolver(args[0])
if ip is None:
ip = docker_container_dns_resolver(args[0])
if ip is not None:
Expand Down Expand Up @@ -298,20 +310,40 @@ def get_nginx_conf_from_container(container: Container) -> bytes:
return conffile.read()


def docker_compose_up(compose_file: str):
logging.info(f'{DOCKER_COMPOSE} -f {compose_file} up -d')
def __prepare_and_execute_compose_cmd(compose_files: List[str], project_name: str, cmd: str):
"""
Prepare and execute the Docker Compose command with the provided compose files and project name.
"""
compose_cmd = StringIO()
10000 compose_cmd.write(DOCKER_COMPOSE)
compose_cmd.write(f" --project-name {project_name}")
for compose_file in compose_files:
compose_cmd.write(f" --file {compose_file}")
compose_cmd.write(f" {cmd}")

logging.info(compose_cmd.getvalue())
try:
subprocess.check_output(shlex.split(f'{DOCKER_COMPOSE} -f {compose_file} up -d'), stderr=subprocess.STDOUT)
subprocess.check_output(shlex.split(compose_cmd.getvalue()), stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
pytest.fail(f"Error while running '{DOCKER_COMPOSE} -f {compose_file} up -d':\n{e.output}", pytrace=False)
pytest.fail(f"Error while running '{compose_cmd.getvalue()}':\n{e.output}", pytrace=False)


def docker_compose_down(compose_file: str):
logging.info(f'{DOCKER_COMPOSE} -f {compose_file} down -v')
try:
subprocess.check_output(shlex.split(f'{DOCKER_COMPOSE} -f {compose_file} down -v'), stderr=subprocess.STDOUT)
except subprocess.CalledProcessError as e:
pytest.fail(f"Error while running '{DOCKER_COMPOSE} -f {compose_file} down -v':\n{e.output}", pytrace=False)
def docker_compose_up(compose_files: List[str], project_name: str):
"""
Execute compose up --detach with the provided compose files and project name.
"""
if compose_files is None or len(compose_files) == 0:
pytest.fail(f"No compose file passed to docker_compose_up", pytrace=False)
__prepare_and_execute_compose_cmd(compose_files, project_name, cmd="up --detach")


def docker_compose_down(compose_files: List[str], project_name: str):
"""
Execute compose down --volumes with the provided compose files and project name.
"""
if compose_files is None or len(compose_files) == 0:
pytest.fail(f"No compose file passed to docker_compose_up", pytrace=False)
__prepare_and_execute_compose_cmd(compose_files, project_name, cmd="down --volumes")


def wait_for_nginxproxy_to_be_ready():
Expand All @@ -330,35 +362,47 @@ def wait_for_nginxproxy_to_be_ready():


@pytest.fixture
def docker_compose_file(request: FixtureRequest) -> Iterator[Optional[str]]:
"""Fixture naming the docker compose file to consider.
def docker_compose_files(request: FixtureRequest) -> List[str]:
"""Fixture returning the docker compose files to consider:

If a YAML file exists with the same name as the test module (with the `.py` extension
replaced with `.base.yml`, ie `test_foo.py`-> `test_foo.base.yml`) and in the same
directory as the test module, use only that file.

If a YAML file exists with the same name as the test module (with the `.py` extension replaced
with `.yml` or `.yaml`), use that. Otherwise, use `docker-compose.yml` in the same directory
as the test module.
Otherwise, merge the following files in this order:

- the `compose.base.yml` file in the parent `test` directory.
- if present in the same directory as the test module, the `compose.base.override.yml` file.
- the YAML file named after the current test module (ie `test_foo.py`-> `test_foo.yml`)

Tests can override this fixture to specify a custom location.
"""
test_module_dir = os.path.dirname(request.module.__file__)
yml_file = os.path.join(test_module_dir, f"{request.module.__name__}.yml")
yaml_file = os.path.join(test_module_dir, f"{request.module.__name__}.yaml")
default_file = os.path.join(test_module_dir, 'docker-compose.yml')
compose_files: List[str] = []
test_module_path = pathlib.Path(request.module.__file__).parent

docker_compose_file = None
module_base_file = test_module_path.joinpath(f"{request.module.__name__}.base.yml")
if module_base_file.is_file():
return [module_base_file.as_posix()]

if os.path.isfile(yml_file):
docker_compose_file = yml_file
elif os.path.isfile(yaml_file):
docker_compose_file = yaml_file
elif os.path.isfile(default_file):
docker_compose_file = default_file
global_base_file = test_module_path.parent.joinpath("compose.base.yml")
if global_base_file.is_file():
compose_files.append(global_base_file.as_posix())

if docker_compose_file is None:
logging.error("Could not find any docker compose file named either '{0}.yml', '{0}.yaml' or 'docker-compose.yml'".format(request.module.__name__))
else:
logging.debug(f"using docker compose file {docker_compose_file}")
module_base_override_file = test_module_path.joinpath("compose.base.override.yml")
if module_base_override_file.is_file():
compose_files.append(module_base_override_file.as_posix())

module_compose_file = test_module_path.joinpath(f"{request.module.__name__}.yml")
if module_compose_file.is_file():
compose_files.append(module_compose_file.as_posix())

yield docker_compose_file
if not module_base_file.is_file() and not module_compose_file.is_file():
logging.error(
f"Could not find any docker compose file named '{module_base_file.name}' or '{module_compose_file.name}'"
)

logging.debug(f"using docker compose files {compose_files}")
return compose_files

def connect_to_network(network: Network) -> Optional[Network]:
Expand Down Expand Up @@ -428,30 +472,33 @@ def connect_to_all_networks() -> List[Network]:
class DockerComposer(contextlib.AbstractContextManager):
def __init__(self):
self._networks = None
self._docker_compose_file = None
self._docker_compose_files = None
self._project_name = None

def __exit__(self, *exc_info):
self._down()

def _down(self):
if self._docker_compose_file is None:
if self._docker_compose_files is None:
return
for network in self._networks:
disconnect_from_network(network)
docker_compose_down(self._docker_compose_file)
docker_compose_down(self._docker_compose_files, self._project_name)
self._docker_compose_file = None
self._project_name = None

def compose(self, docker_compose_file: Optional[str]):
if docker_compose_file == self._docker_compose_file:
def compose(self, docker_compose_files: List[str], project_name: str):
if docker_compose_files == self._docker_compose_files and project_name == self._project_name:
return
self._down()
if docker_compose_file is None:
if docker_compose_files is None or project_name is None:
return
docker_compose_up(docker_compose_file)
docker_compose_up(docker_compose_files, project_name)
self._networks = connect_to_all_networks()
wait_for_nginxproxy_to_be_ready()
time.sleep(3) # give time to containers to be ready
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not really related to this PR but why we sleep 3 seconds here?
wait_for_nginxproxy_to_be_ready() checks already to have nginx-proxy ready (real-time stream)
So that sleeps just seems to be for the 'web' container. But I don't think that needs 3 seconds.
And as we do like 90 compose up's thats already 270 seconds of waiting (4,5 min).

A good win to reduce test time I think.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's something I tried to work on, one problem is that in its current state wait_for_nginxproxy_to_be_ready() does not seem to really tell us if the proxy is actually ready or not.

def wait_for_nginxproxy_to_be_ready():
    """
    If one (and only one) container started from image nginxproxy/nginx-proxy:test is found,
    wait for its log to contain substring "Watching docker events"
    """
    containers = docker_client.containers.list(filters={"ancestor": "nginxproxy/nginx-proxy:test"})
    if len(containers) != 1:
        return
    container = containers[0]
    for line in container.logs(stream=True):
        if b"Watching docker events" in line:
            logging.debug("nginx-proxy ready")
            break
  • If docker_client.containers.list() found zero or more than one container, the function will return without any further check.
  • I'm not certain that for line in container.logs(stream=True): mean that the function will wait for the expected log line to appear, for me it will just iterate over the the log lines generated by the container up to that point, return early if it find Watching docker events or return anyway when it reach the end of the generator.
  • the appearance of the Watching docker events log line mean that docker-gen is ready, not that nginx has loaded the docker-gen generated conf, which is what we're actually testing.

We're automatically doing a 9s backoff on 404 or 503 when using the nginxproxy fixture's get() which kind of automatically wait for the containers to be ready, but not all tests are using this.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah you are right, that ready state is indeed based of docker-gen.
Changing the bytestring to something else makes the for loop block. So it will wait. But the Watching docker events is probably always there.

Adding follow=True in for line in container.logs(stream=True, follow=True): makes the generator follow new events in the stream.

self._docker_compose_file = docker_compose_file
self._docker_compose_files = docker_compose_files
self._project_name = project_name


###############################################################################
Expand All @@ -462,14 +509,14 @@ def compose(self, docker_compose_file: Optional[str]):


@pytest.fixture(scope="module")
def docker_composer() -> Iterator[DockerComposer]:
def docker_composer() -> Iterator[DockerComposer]:
with DockerComposer() as d:
yield d


@pytest.fixture
def ca_root_certificate() -> Iterator[str]:
yield CA_ROOT_CERTIFICATE
def ca_root_certificate() -> str:
return CA_ROOT_CERTIFICATE.as_posix()


@pytest.fixture
Expand All @@ -480,16 +527,29 @@ def monkey_patched_dns():


@pytest.fixture
def docker_compose(monkey_patched_dns, docker_composer, docker_compose_file) -> Iterator[DockerClient]:
"""Ensures containers described in a docker compose file are started.
def docker_compose(
request: FixtureRequest,
monkeypatch,
monkey_patched_dns,
docker_composer,
docker_compose_files
) -> Iterator[DockerClient]:
"""
Ensures containers necessary for the test module are started in a compose project,
and set the environment variable `PYTEST_MODULE_PATH` to the test module's parent folder.

A custom docker compose file name can be specified by overriding the `docker_compose_file`
fixture.
A list of custom docker compose files path can be specified by overriding
the `docker_compose_file` fixture.

Also, in the case where pytest is running from a docker container, this fixture makes sure
our container will be attached to all the docker networks.
Also, in the case where pytest is running from a docker container, this fixture
makes sure our container will be attached to all the docker networks.
"""
docker_composer.compose(docker_compose_file)
pytest_module_path = pathlib.Path(request.module.__file__).parent
monkeypatch.setenv("PYTEST_MODULE_PATH", pytest_module_path.as_posix())

project_name = request.module.__name__
docker_composer.compose(docker_compose_files, project_name)

yield docker_client


Expand All @@ -511,11 +571,11 @@ def nginxproxy() -> Iterator[RequestsForDocker]:


@pytest.fixture
def acme_challenge_path() -> Iterator[str]:
def acme_challenge_path() -> str:
"""
Provides fake Let's Encrypt ACME challenge path used in certain tests
"""
yield ".well-known/acme-challenge/test-filename"
return ".well-known/acme-challenge/test-filename"

###############################################################################
#
Expand Down
Loading
Loading
0