Skip to content

Commit 131bd61

Browse files
Merge pull request #866 from jiridanek/jd_initial_ipv6_no_macos
RHOAIENG-18459: chore(tests/containers/workbenches): listen on single-stack IPv6
2 parents 6a93e34 + 4e9b8ff commit 131bd61

File tree

2 files changed

+108
-5
lines changed

2 files changed

+108
-5
lines changed

tests/containers/conftest.py

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import logging
44
import os
5-
from typing import Iterable, TYPE_CHECKING
5+
from typing import Iterable, Callable, TYPE_CHECKING
66

77
import testcontainers.core.config
88
import testcontainers.core.container
@@ -97,3 +97,47 @@ def the_one[T](iterable: Iterable[T]) -> T:
9797
except StopIteration:
9898
return v
9999
raise ValueError("More than one element in iterable")
100+
101+
102+
@pytest.fixture(scope="function")
103+
def test_frame():
104+
class TestFrame:
105+
"""Helper class to manage resources in tests.
106+
Example:
107+
>>> import subprocess
108+
>>> import testcontainers.core.network
109+
>>>
110+
>>> def test_something(test_frame: TestFrame):
111+
>>> # this will create/destroy the network as it enters/leaves the test_frame
112+
>>> network = testcontainers.core.network.Network(...)
113+
>>> test_frame.append(network)
114+
>>>
115+
>>> # some resources require additional cleanup function
116+
>>> test_frame.append(subprocess.Popen(...), lambda p: p.kill())
117+
"""
118+
119+
def __init__(self):
120+
self.resources: list[tuple[any, callable]] = []
121+
122+
def append[T](self, resource: T, cleanup_func: Callable[[T], None] = None) -> T:
123+
"""Runs the Context manager lifecycle on the resource,
124+
without actually using the `with` structured resource management thing.
125+
126+
For some resources, the __exit__ method does not force termination.
127+
subprocess.Popen is one such resource, its __exit__ only `wait()`s.
128+
Use the cleanup_func argument to terminate resources that need it.
129+
130+
This is somewhat similar to Go's `defer`."""
131+
self.resources.append((resource, cleanup_func))
132+
return resource.__enter__()
133+
134+
def destroy(self):
135+
"""Runs __exit__() on the registered resources as a cleanup."""
136+
for resource, cleanup_func in reversed(self.resources):
137+
if cleanup_func is not None:
138+
cleanup_func(resource)
139+
resource.__exit__(None, None, None) # don't use named args, there are inconsistencies
140+
141+
t = TestFrame()
142+
yield t
143+
t.destroy()

tests/containers/workbenches/workbench_image_test.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,17 @@
33
import functools
44
import http.cookiejar
55
import logging
6+
import platform
67
import urllib.error
78
import urllib.request
89

910
import docker.errors
1011
import docker.models.images
12+
import docker.types
1113

1214
import testcontainers.core.container
15+
import testcontainers.core.docker_client
16+
import testcontainers.core.network
1317
import testcontainers.core.waiting_utils
1418

1519
import pytest
@@ -44,6 +48,55 @@ def test_image_entrypoint_starts(self, image: str, sysctls) -> None:
4448
finally:
4549
docker_utils.NotebookContainer(container).stop(timeout=0)
4650

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+
47100

48101
class WorkbenchContainer(testcontainers.core.container.DockerContainer):
49102
@functools.wraps(testcontainers.core.container.DockerContainer.__init__)
@@ -73,20 +126,26 @@ def __init__(
73126
self.with_exposed_ports(self.port)
74127

75128
@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+
"""
77133
# are we still alive?
78134
self.get_wrapped_container().reload()
79135
assert self.get_wrapped_container().status != "exited"
80136

81137
# connect
138+
host = container_host or self.get_container_host_ip()
139+
port = container_port or self.get_exposed_port(self.port)
82140
try:
83141
# if we did not enable cookies support here, with RStudio we'd end up looping and getting
84142
# HTTP 302 (i.e. `except urllib.error.HTTPError as e: assert e.code == 302`) every time
85143
cookie_jar = http.cookiejar.CookieJar()
86144
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)
90149
except urllib.error.URLError as e:
91150
raise e
92151

0 commit comments

Comments
 (0)