diff --git a/.github/actions/create-dev-env/action.yml b/.github/actions/create-dev-env/action.yml index 1a0e6221..6def1ee6 100644 --- a/.github/actions/create-dev-env/action.yml +++ b/.github/actions/create-dev-env/action.yml @@ -14,3 +14,12 @@ runs: pip install --upgrade pip pip install --upgrade -r requirements-dev.txt shell: bash + + # We need to have a recent docker version + # More info: https://github.com/jupyter/docker-stacks/pull/2255 + # Can be removed after Docker Engine is updated + # https://github.com/actions/runner-images/issues/11766 + - name: Set Up Docker 🐳 + uses: docker/setup-docker-action@b60f85385d03ac8acfca6d9996982511d8620a19 # v4.3.0 + with: + set-host: true diff --git a/tests/by_image/base-notebook/data/check_listening.py b/tests/by_image/base-notebook/data/check_listening.py new file mode 100755 index 00000000..1ea8ce98 --- /dev/null +++ b/tests/by_image/base-notebook/data/check_listening.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import socket +import time + +import requests + + +def make_get_request() -> None: + # Give some time for server to start + finish_time = time.time() + 10 + sleep_time = 1 + while time.time() < finish_time: + time.sleep(sleep_time) + try: + resp = requests.get("http://localhost:8888/api") + resp.raise_for_status() + except requests.RequestException: + pass + resp.raise_for_status() + + +def check_addrs(family: socket.AddressFamily) -> None: + assert family in {socket.AF_INET, socket.AF_INET6} + + # https://docs.python.org/3/library/socket.html#socket.getaddrinfo + addrs = { + s[4][0] + for s in socket.getaddrinfo(host=socket.gethostname(), port=None, family=family) + } + loopback_addr = "127.0.0.1" if family == socket.AF_INET else "::1" + addrs.discard(loopback_addr) + + assert addrs, f"No external addresses found for family: {family}" + + for addr in addrs: + url = ( + f"http://{addr}:8888/api" + if family == socket.AF_INET + else f"http://[{addr}]:8888/api" + ) + r = requests.get(url) + r.raise_for_status() + assert "version" in r.json() + print(f"Successfully connected to: {url}") + + +def test_connect() -> None: + make_get_request() + + check_addrs(socket.AF_INET) + check_addrs(socket.AF_INET6) + + +if __name__ == "__main__": + test_connect() diff --git a/tests/by_image/base-notebook/test_healthcheck.py b/tests/by_image/base-notebook/test_healthcheck.py index 1f6d2a35..b8bed7de 100644 --- a/tests/by_image/base-notebook/test_healthcheck.py +++ b/tests/by_image/base-notebook/test_healthcheck.py @@ -3,6 +3,7 @@ import logging import time +import docker import pytest # type: ignore from tests.utils.get_container_health import get_health @@ -13,6 +14,7 @@ LOGGER = logging.getLogger(__name__) def get_healthy_status( container: TrackedContainer, + docker_client: docker.DockerClient, env: list[str] | None, cmd: list[str] | None, user: str | None, @@ -30,11 +32,11 @@ def get_healthy_status( while time.time() < finish_time: time.sleep(sleep_time) - status = get_health(running_container) + status = get_health(running_container, docker_client) if status == "healthy": return status - return get_health(running_container) + return get_health(running_container, docker_client) @pytest.mark.parametrize( @@ -82,11 +84,12 @@ def get_healthy_status( ) def test_healthy( container: TrackedContainer, + docker_client: docker.DockerClient, env: list[str] | None, cmd: list[str] | None, user: str | None, ) -> None: - assert get_healthy_status(container, env, cmd, user) == "healthy" + assert get_healthy_status(container, docker_client, env, cmd, user) == "healthy" @pytest.mark.parametrize( @@ -115,11 +118,12 @@ def test_healthy( ) def test_healthy_with_proxy( container: TrackedContainer, + docker_client: docker.DockerClient, env: list[str] | None, cmd: list[str] | None, user: str | None, ) -> None: - assert get_healthy_status(container, env, cmd, user) == "healthy" + assert get_healthy_status(container, docker_client, env, cmd, user) == "healthy" @pytest.mark.parametrize( @@ -138,9 +142,10 @@ def test_healthy_with_proxy( ) def test_not_healthy( container: TrackedContainer, + docker_client: docker.DockerClient, env: list[str] | None, cmd: list[str] | None, ) -> None: assert ( - get_healthy_status(container, env, cmd, user=None) != "healthy" + get_healthy_status(container, docker_client, env, cmd, user=None) != "healthy" ), "Container should not be healthy for this testcase" diff --git a/tests/by_image/base-notebook/test_ips.py b/tests/by_image/base-notebook/test_ips.py new file mode 100644 index 00000000..bd9548e7 --- /dev/null +++ b/tests/by_image/base-notebook/test_ips.py @@ -0,0 +1,50 @@ +# Copyright (c) Jupyter Development Team. +# Distributed under the terms of the Modified BSD License. +import logging +from collections.abc import Generator +from pathlib import Path +from random import randint + +import docker +import pytest # type: ignore + +from tests.utils.tracked_container import TrackedContainer + +LOGGER = logging.getLogger(__name__) +THIS_DIR = Path(__file__).parent.resolve() + + +@pytest.fixture(scope="session") +def ipv6_network(docker_client: docker.DockerClient) -> Generator[str, None, None]: + """Create a dual-stack IPv6 docker network""" + # Doesn't have to be routable since we're testing inside the container + subnet64 = "fc00:" + ":".join(hex(randint(0, 2**16))[2:] for _ in range(3)) + name = subnet64.replace(":", "-") + docker_client.networks.create( + name, + ipam=docker.types.IPAMPool( + subnet=subnet64 + "::/64", + gateway=subnet64 + "::1", + ), + enable_ipv6=True, + internal=True, + ) + yield name + docker_client.networks.get(name).remove() + + +def test_ipv46(container: TrackedContainer, ipv6_network: str) -> None: + """Check server is listening on the expected IP families""" + host_data_dir = THIS_DIR / "data" + cont_data_dir = "/home/jovyan/data" + LOGGER.info("Testing that server is listening on IPv4 and IPv6 ...") + running_container = container.run_detached( + network=ipv6_network, + volumes={str(host_data_dir): {"bind": cont_data_dir, "mode": "ro,z"}}, + tty=True, + ) + + command = ["python", f"{cont_data_dir}/check_listening.py"] + exec_result = running_container.exec_run(command) + LOGGER.info(exec_result.output.decode()) + assert exec_result.exit_code == 0 diff --git a/tests/conftest.py b/tests/conftest.py index 894ad83c..82cc467d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +import logging import os from collections.abc import Generator @@ -11,6 +12,8 @@ from urllib3.util.retry import Retry from tests.utils.tracked_container import TrackedContainer +LOGGER = logging.getLogger(__name__) + @pytest.fixture(scope="session") def http_client() -> requests.Session: @@ -25,7 +28,9 @@ def http_client() -> requests.Session: @pytest.fixture(scope="session") def docker_client() -> docker.DockerClient: """Docker client configured based on the host environment""" - return docker.from_env() + client = docker.from_env() + LOGGER.info(f"Docker client created: {client.version()}") + return client @pytest.fixture(scope="session") diff --git a/tests/utils/get_container_health.py b/tests/utils/get_container_health.py index ec5807fc..ad9e5997 100644 --- a/tests/utils/get_container_health.py +++ b/tests/utils/get_container_health.py @@ -4,7 +4,6 @@ import docker from docker.models.containers import Container -def get_health(container: Container) -> str: - api_client = docker.APIClient() - inspect_results = api_client.inspect_container(container.name) +def get_health(container: Container, client: docker.DockerClient) -> str: + inspect_results = client.api.inspect_container(container.name) return inspect_results["State"]["Health"]["Status"] # type: ignore