Skip to content

Commit 2f9139c

Browse files
feat: Add SocatContainer (#795)
Add new SocatContainer at testcontainers module that can be used along with other modules as a helper. --------- Co-authored-by: David Ankin <[email protected]>
1 parent f979525 commit 2f9139c

File tree

3 files changed

+112
-0
lines changed

3 files changed

+112
-0
lines changed

core/testcontainers/socat/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
# flake8: noqa
2+
from testcontainers.socat.socat import SocatContainer

core/testcontainers/socat/socat.py

+88
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
#
2+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
# not use this file except in compliance with the License. You may obtain
4+
# a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
# License for the specific language governing permissions and limitations
12+
# under the License.
13+
import random
14+
import socket
15+
import string
16+
from typing import Optional
17+
18+
from testcontainers.core.container import DockerContainer
19+
from testcontainers.core.waiting_utils import wait_container_is_ready
20+
21+
22+
class SocatContainer(DockerContainer):
23+
"""
24+
A container that uses socat to forward TCP connections.
25+
"""
26+
27+
def __init__(
28+
self,
29+
image: str = "alpine/socat:1.7.4.3-r0",
30+
**kwargs,
31+
) -> None:
32+
"""
33+
Initialize a new SocatContainer with the given image.
34+
35+
Args:
36+
image: The Docker image to use. Defaults to "alpine/socat:1.7.4.3-r0".
37+
**kwargs: Additional keyword arguments to pass to the DockerContainer constructor.
38+
"""
39+
# Dictionary to store targets (port -> host:port mappings)
40+
self.targets: dict[int, str] = {}
41+
42+
kwargs["entrypoint"] = "/bin/sh"
43+
44+
random_suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=8))
45+
self.with_name(f"testcontainers-socat-{random_suffix}")
46+
47+
super().__init__(image=image, **kwargs)
48+
49+
def with_target(self, exposed_port: int, host: str, internal_port: Optional[int] = None) -> "SocatContainer":
50+
"""
51+
Add a target to forward connections from the exposed port to the given host and port.
52+
53+
Args:
54+
exposed_port: The port to expose on the container.
55+
host: The host to forward connections to.
56+
internal_port: The port on the host to forward connections to. Defaults to the exposed_port if not provided.
57+
58+
Returns:
59+
Self: The container instance for chaining.
60+
"""
61+
if internal_port is None:
62+
internal_port = exposed_port
63+
64+
self.with_exposed_ports(exposed_port)
65+
self.targets[exposed_port] = f"{host}:{internal_port}"
66+
return self
67+
68+
def _configure(self) -> None:
69+
if not self.targets:
70+
return
71+
72+
socat_commands = []
73+
for port, target in self.targets.items():
74+
socat_commands.append(f"socat TCP-LISTEN:{port},fork,reuseaddr TCP:{target}")
75+
76+
command = " & ".join(socat_commands)
77+
78+
self.with_command(f'-c "{command}"')
79+
80+
def start(self) -> "SocatContainer":
81+
super().start()
82+
self._connect()
83+
return self
84+
85+
@wait_container_is_ready(OSError)
86+
def _connect(self) -> None:
87+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
88+
s.connect((self.get_container_host_ip(), int(self.get_exposed_port(next(iter(self.ports))))))

core/tests/test_socat.py

+22
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import httpx
2+
import pytest
3+
from testcontainers.core.container import DockerContainer
4+
from testcontainers.core.network import Network
5+
from testcontainers.socat.socat import SocatContainer
6+
7+
8+
def test_socat_with_helloworld():
9+
with (
10+
Network() as network,
11+
DockerContainer("testcontainers/helloworld:1.2.0")
12+
.with_exposed_ports(8080)
13+
.with_network(network)
14+
.with_network_aliases("helloworld"),
15+
SocatContainer().with_network(network).with_target(8080, "helloworld") as socat,
16+
):
17+
socat_url = f"http://{socat.get_container_host_ip()}:{socat.get_exposed_port(8080)}"
18+
19+
response = httpx.get(f"{socat_url}/ping")
20+
21+
assert response.status_code == 200
22+
assert response.content == b"PONG"

0 commit comments

Comments
 (0)