|
3 | 3 | import functools
|
4 | 4 | import http.cookiejar
|
5 | 5 | import logging
|
| 6 | +import platform |
6 | 7 | import urllib.error
|
7 | 8 | import urllib.request
|
8 | 9 |
|
9 | 10 | import docker.errors
|
10 | 11 | import docker.models.images
|
| 12 | +import docker.types |
11 | 13 |
|
12 | 14 | import testcontainers.core.container
|
| 15 | +import testcontainers.core.docker_client |
| 16 | +import testcontainers.core.network |
13 | 17 | import testcontainers.core.waiting_utils
|
14 | 18 |
|
15 | 19 | import pytest
|
@@ -44,6 +48,55 @@ def test_image_entrypoint_starts(self, image: str, sysctls) -> None:
|
44 | 48 | finally:
|
45 | 49 | docker_utils.NotebookContainer(container).stop(timeout=0)
|
46 | 50 |
|
| 51 | + def test_ipv6_only(self, image: str, test_frame): |
| 52 | + """Test that workbench image is accessible via IPv6. |
| 53 | + Workarounds for macOS will be needed, so that's why it's a separate test.""" |
| 54 | + skip_if_not_workbench_image(image) |
| 55 | + |
| 56 | + if platform.system().lower() == 'darwin': |
| 57 | + pytest.skip("Podman on macOS does not support exposing IPv6 ports," |
| 58 | + " see https://github.com/containers/podman/issues/15140") |
| 59 | + |
| 60 | + # network is made ipv6 by only defining the ipv6 subnet for it |
| 61 | + # do _not_ set the ipv6=true option, that would actually make it dual-stack |
| 62 | + # https://github.com/containers/podman/issues/22359#issuecomment-2196817604 |
| 63 | + network = testcontainers.core.network.Network(docker_network_kw={ |
| 64 | + "ipam": docker.types.IPAMConfig( |
| 65 | + pool_configs=[ |
| 66 | + docker.types.IPAMPool(subnet="fd00::/64"), |
| 67 | + ] |
| 68 | + ) |
| 69 | + }) |
| 70 | + test_frame.append(network) |
| 71 | + |
| 72 | + container = WorkbenchContainer(image=image) |
| 73 | + container.with_network(network) |
| 74 | + try: |
| 75 | + try: |
| 76 | + client = testcontainers.core.docker_client.DockerClient() |
| 77 | + rootless: bool = client.client.info()['Rootless'] |
| 78 | + # with rootful podman, --publish does not expose IPv6-only ports |
| 79 | + # see https://github.com/containers/podman/issues/14491 and friends |
| 80 | + container.start(wait_for_readiness=rootless) |
| 81 | + # check explicitly that we can connect to the ide running in the workbench |
| 82 | + if rootless: |
| 83 | + container._connect() |
| 84 | + else: |
| 85 | + # rootful containers have an IP assigned, so we can connect to that |
| 86 | + # NOTE: this is only reachable from the host machine, so remote podman won't work |
| 87 | + container.get_wrapped_container().reload() |
| 88 | + ipv6_address = (container.get_wrapped_container().attrs |
| 89 | + ["NetworkSettings"]["Networks"][network.name]["GlobalIPv6Address"]) |
| 90 | + |
| 91 | + container._connect(container_host=ipv6_address, container_port=container.port) |
| 92 | + finally: |
| 93 | + # try to grab logs regardless of whether container started or not |
| 94 | + stdout, stderr = container.get_logs() |
| 95 | + for line in stdout.splitlines() + stderr.splitlines(): |
| 96 | + logging.debug(line) |
| 97 | + finally: |
| 98 | + docker_utils.NotebookContainer(container).stop(timeout=0) |
| 99 | + |
47 | 100 |
|
48 | 101 | class WorkbenchContainer(testcontainers.core.container.DockerContainer):
|
49 | 102 | @functools.wraps(testcontainers.core.container.DockerContainer.__init__)
|
@@ -73,20 +126,26 @@ def __init__(
|
73 | 126 | self.with_exposed_ports(self.port)
|
74 | 127 |
|
75 | 128 | @testcontainers.core.waiting_utils.wait_container_is_ready(urllib.error.URLError)
|
76 |
| - def _connect(self) -> None: |
| 129 | + def _connect(self, container_host: str | None = None, container_port: int | None = None) -> None: |
| 130 | + """ |
| 131 | + :param container_host: overrides the container host IP in connection check to use direct access |
| 132 | + """ |
77 | 133 | # are we still alive?
|
78 | 134 | self.get_wrapped_container().reload()
|
79 | 135 | assert self.get_wrapped_container().status != "exited"
|
80 | 136 |
|
81 | 137 | # connect
|
| 138 | + host = container_host or self.get_container_host_ip() |
| 139 | + port = container_port or self.get_exposed_port(self.port) |
82 | 140 | try:
|
83 | 141 | # if we did not enable cookies support here, with RStudio we'd end up looping and getting
|
84 | 142 | # HTTP 302 (i.e. `except urllib.error.HTTPError as e: assert e.code == 302`) every time
|
85 | 143 | cookie_jar = http.cookiejar.CookieJar()
|
86 | 144 | opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(cookie_jar))
|
87 |
| - result = opener.open( |
88 |
| - urllib.request.Request(f"http://{self.get_container_host_ip()}:{self.get_exposed_port(self.port)}"), |
89 |
| - timeout=1) |
| 145 | + # host may be an ipv6 address, need to be careful with formatting this |
| 146 | + if ":" in host: |
| 147 | + host = f"[{host}]" |
| 148 | + result = opener.open(urllib.request.Request(f"http://{host}:{port}"), timeout=1) |
90 | 149 | except urllib.error.URLError as e:
|
91 | 150 | raise e
|
92 | 151 |
|
|
0 commit comments