Skip to content

Commit 4e9b8ff

Browse files
committed
RHOAIENG-18459: chore(tests/containers/workbenches): listen on single-stack ipv6
This is an initial version of the test. It introduces the TestFrame utility class into the test suite. Implementation for macOS will come later, for now test only run on Linux.
1 parent 6a93e34 commit 4e9b8ff

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)