Skip to content

Commit 762d2a2

Browse files
authored
fix(kafka): Add Kraft to Kafka containers (#611)
Following a similar strategy as several other testcontainers implementations, this PR introduces the possibility to run Kafka in KRAft mode. ```py with KafkaContainer().with_kraft() as container: # Test something with/on KRaft mode ```
1 parent 090bd0d commit 762d2a2

File tree

4 files changed

+202
-9
lines changed

4 files changed

+202
-9
lines changed

core/testcontainers/core/version.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from typing import Callable
2+
3+
from packaging.version import Version
4+
5+
6+
class ComparableVersion:
7+
def __init__(self, version):
8+
self.version = Version(version)
9+
10+
def __lt__(self, other: str):
11+
return self._apply_op(other, lambda x, y: x < y)
12+
13+
def __le__(self, other: str):
14+
return self._apply_op(other, lambda x, y: x <= y)
15+
16+
def __eq__(self, other: str):
17+
return self._apply_op(other, lambda x, y: x == y)
18+
19+
def __ne__(self, other: str):
20+
return self._apply_op(other, lambda x, y: x != y)
21+
22+
def __gt__(self, other: str):
23+
return self._apply_op(other, lambda x, y: x > y)
24+
25+
def __ge__(self, other: str):
26+
return self._apply_op(other, lambda x, y: x >= y)
27+
28+
def _apply_op(self, other: str, op: Callable[[Version, Version], bool]):
29+
other = Version(other)
30+
return op(self.version, other)

core/tests/test_version.py

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import pytest
2+
from packaging.version import InvalidVersion
3+
4+
from testcontainers.core.version import ComparableVersion
5+
6+
7+
@pytest.fixture
8+
def version():
9+
return ComparableVersion("1.0.0")
10+
11+
12+
@pytest.mark.parametrize("other_version, expected", [("0.9.0", False), ("1.0.0", False), ("1.1.0", True)])
13+
def test_lt(version, other_version, expected):
14+
assert (version < other_version) == expected
15+
16+
17+
@pytest.mark.parametrize("other_version, expected", [("0.9.0", False), ("1.0.0", True), ("1.1.0", True)])
18+
def test_le(version, other_version, expected):
19+
assert (version <= other_version) == expected
20+
21+
22+
@pytest.mark.parametrize("other_version, expected", [("0.9.0", False), ("1.0.0", True), ("1.1.0", False)])
23+
def test_eq(version, other_version, expected):
24+
assert (version == other_version) == expected
25+
26+
27+
@pytest.mark.parametrize("other_version, expected", [("0.9.0", True), ("1.0.0", False), ("1.1.0", True)])
28+
def test_ne(version, other_version, expected):
29+
assert (version != other_version) == expected
30+
31+
32+
@pytest.mark.parametrize("other_version, expected", [("0.9.0", True), ("1.0.0", False), ("1.1.0", False)])
33+
def test_gt(version, other_version, expected):
34+
assert (version > other_version) == expected
35+
36+
37+
@pytest.mark.parametrize("other_version, expected", [("0.9.0", True), ("1.0.0", True), ("1.1.0", False)])
38+
def test_ge(version, other_version, expected):
39+
assert (version >= other_version) == expected
40+
41+
42+
@pytest.mark.parametrize(
43+
"invalid_version",
44+
[
45+
"invalid",
46+
"1..0",
47+
],
48+
)
49+
def test_invalid_version_raises_error(invalid_version):
50+
with pytest.raises(InvalidVersion):
51+
ComparableVersion(invalid_version)
52+
53+
54+
@pytest.mark.parametrize(
55+
"invalid_version",
56+
[
57+
"invalid",
58+
"1..0",
59+
],
60+
)
61+
def test_comparison_with_invalid_version_raises_error(version, invalid_version):
62+
with pytest.raises(InvalidVersion):
63+
assert version < invalid_version
64+
65+
with pytest.raises(InvalidVersion):
66+
assert version <= invalid_version
67+
68+
with pytest.raises(InvalidVersion):
69+
assert version == invalid_version
70+
71+
with pytest.raises(InvalidVersion):
72+
assert version != invalid_version
73+
74+
with pytest.raises(InvalidVersion):
75+
assert version > invalid_version
76+
77+
with pytest.raises(InvalidVersion):
78+
assert version >= invalid_version

modules/kafka/testcontainers/kafka/__init__.py

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
from io import BytesIO
44
from textwrap import dedent
55

6+
from typing_extensions import Self
7+
68
from testcontainers.core.container import DockerContainer
79
from testcontainers.core.utils import raise_for_deprecated_parameter
10+
from testcontainers.core.version import ComparableVersion
811
from testcontainers.core.waiting_utils import wait_for_logs
912
from testcontainers.kafka._redpanda import RedpandaContainer
1013

@@ -26,18 +29,29 @@ class KafkaContainer(DockerContainer):
2629
2730
>>> with KafkaContainer() as kafka:
2831
... connection = kafka.get_bootstrap_server()
32+
33+
# Using KRaft protocol
34+
>>> with KafkaContainer().with_kraft() as kafka:
35+
... connection = kafka.get_bootstrap_server()
2936
"""
3037

3138
TC_START_SCRIPT = "/tc-start.sh"
39+
MIN_KRAFT_TAG = "7.0.0"
3240

3341
def __init__(self, image: str = "confluentinc/cp-kafka:7.6.0", port: int = 9093, **kwargs) -> None:
3442
raise_for_deprecated_parameter(kwargs, "port_to_expose", "port")
3543
super().__init__(image, **kwargs)
3644
self.port = port
45+
self.kraft_enabled = False
46+
self.wait_for = r".*\[KafkaServer id=\d+\] started.*"
47+
self.boot_command = ""
48+
self.cluster_id = "MkU3OEVBNTcwNTJENDM2Qk"
49+
self.listeners = f"PLAINTEXT://0.0.0.0:{self.port},BROKER://0.0.0.0:9092"
50+
self.security_protocol_map = "BROKER:PLAINTEXT,PLAINTEXT:PLAINTEXT"
51+
3752
self.with_exposed_ports(self.port)
38-
listeners = f"PLAINTEXT://0.0.0.0:{self.port},BROKER://0.0.0.0:9092"
39-
self.with_env("KAFKA_LISTENERS", listeners)
40-
self.with_env("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", "BROKER:PLAINTEXT,PLAINTEXT:PLAINTEXT")
53+
self.with_env("KAFKA_LISTENERS", self.listeners)
54+
self.with_env("KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", self.security_protocol_map)
4155
self.with_env("KAFKA_INTER_BROKER_LISTENER_NAME", "BROKER")
4256

4357
self.with_env("KAFKA_BROKER_ID", "1")
@@ -46,6 +60,74 @@ def __init__(self, image: str = "confluentinc/cp-kafka:7.6.0", port: int = 9093,
4660
self.with_env("KAFKA_LOG_FLUSH_INTERVAL_MESSAGES", "10000000")
4761
self.with_env("KAFKA_GROUP_INITIAL_REBALANCE_DELAY_MS", "0")
4862

63+
def with_kraft(self) -> Self:
64+
self._verify_min_kraft_version()
65+
self.kraft_enabled = True
66+
return self
67+
68+
def _verify_min_kraft_version(self):
69+
actual_version = self.image.split(":")[-1]
70+
71+
if ComparableVersion(actual_version) < self.MIN_KRAFT_TAG:
72+
raise ValueError(
73+
f"Provided Confluent Platform's version {actual_version} "
74+
f"is not supported in Kraft mode"
75+
f" (must be {self.MIN_KRAFT_TAG} or above)"
76+
)
77+
78+
def with_cluster_id(self, cluster_id: str) -> Self:
79+
self.cluster_id = cluster_id
80+
return self
81+
82+
def configure(self):
83+
if self.kraft_enabled:
84+
self._configure_kraft()
85+
else:
86+
self._configure_zookeeper()
87+
88+
def _configure_kraft(self) -> None:
89+
self.wait_for = r".*Kafka Server started.*"
90+
91+
self.with_env("CLUSTER_ID", self.cluster_id)
92+
self.with_env("KAFKA_NODE_ID", 1)
93+
self.with_env(
94+
"KAFKA_LISTENER_SECURITY_PROTOCOL_MAP",
95+
f"{self.security_protocol_map},CONTROLLER:PLAINTEXT",
96+
)
97+
self.with_env(
98+
"KAFKA_LISTENERS",
99+
f"{self.listeners},CONTROLLER://0.0.0.0:9094",
100+
)
101+
self.with_env("KAFKA_PROCESS_ROLES", "broker,controller")
102+
103+
network_alias = self._get_network_alias()
104+
controller_quorum_voters = f"1@{network_alias}:9094"
105+
self.with_env("KAFKA_CONTROLLER_QUORUM_VOTERS", controller_quorum_voters)
106+
self.with_env("KAFKA_CONTROLLER_LISTENER_NAMES", "CONTROLLER")
107+
108+
self.boot_command = f"""
109+
sed -i '/KAFKA_ZOOKEEPER_CONNECT/d' /etc/confluent/docker/configure
110+
echo 'kafka-storage format --ignore-formatted -t {self.cluster_id} -c /etc/kafka/kafka.properties' >> /etc/confluent/docker/configure
111+
"""
112+
113+
def _get_network_alias(self):
114+
if self._network:
115+
return next(
116+
iter(self._network_aliases or [self._network.name or self._kwargs.get("network", [])]),
117+
None,
118+
)
119+
120+
return "localhost"
121+
122+
def _configure_zookeeper(self) -> None:
123+
self.boot_command = """
124+
echo 'clientPort=2181' > zookeeper.properties
125+
echo 'dataDir=/var/lib/zookeeper/data' >> zookeeper.properties
126+
echo 'dataLogDir=/var/lib/zookeeper/log' >> zookeeper.properties
127+
zookeeper-server-start zookeeper.properties &
128+
export KAFKA_ZOOKEEPER_CONNECT='localhost:2181'
129+
"""
130+
49131
def get_bootstrap_server(self) -> str:
50132
host = self.get_container_host_ip()
51133
port = self.get_exposed_port(self.port)
@@ -59,11 +141,7 @@ def tc_start(self) -> None:
59141
dedent(
60142
f"""
61143
#!/bin/bash
62-
echo 'clientPort=2181' > zookeeper.properties
63-
echo 'dataDir=/var/lib/zookeeper/data' >> zookeeper.properties
64-
echo 'dataLogDir=/var/lib/zookeeper/log' >> zookeeper.properties
65-
zookeeper-server-start zookeeper.properties &
66-
export KAFKA_ZOOKEEPER_CONNECT='localhost:2181'
144+
{self.boot_command}
67145
export KAFKA_ADVERTISED_LISTENERS={listeners}
68146
. /etc/confluent/docker/bash-config
69147
/etc/confluent/docker/configure
@@ -78,10 +156,11 @@ def tc_start(self) -> None:
78156
def start(self, timeout=30) -> "KafkaContainer":
79157
script = KafkaContainer.TC_START_SCRIPT
80158
command = f'sh -c "while [ ! -f {script} ]; do sleep 0.1; done; sh {script}"'
159+
self.configure()
81160
self.with_command(command)
82161
super().start()
83162
self.tc_start()
84-
wait_for_logs(self, r".*\[KafkaServer id=\d+\] started.*", timeout=timeout)
163+
wait_for_logs(self, self.wait_for, timeout=timeout)
85164
return self
86165

87166
def create_file(self, content: bytes, path: str) -> None:

modules/kafka/tests/test_kafka.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ def test_kafka_producer_consumer():
88
produce_and_consume_kafka_message(container)
99

1010

11+
def test_kafka_with_kraft_producer_consumer():
12+
with KafkaContainer().with_kraft() as container:
13+
assert container.kraft_enabled
14+
produce_and_consume_kafka_message(container)
15+
16+
1117
def test_kafka_producer_consumer_custom_port():
1218
with KafkaContainer(port=9888) as container:
1319
assert container.port == 9888

0 commit comments

Comments
 (0)