Skip to content

Commit 7562f56

Browse files
committed
Add Kraft to Kafka containers
1 parent 090bd0d commit 7562f56

File tree

3 files changed

+137
-11
lines changed

3 files changed

+137
-11
lines changed
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)

modules/kafka/testcontainers/kafka/__init__.py

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1+
import base64
12
import tarfile
23
import time
4+
import uuid
35
from io import BytesIO
46
from textwrap import dedent
57

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
9-
from testcontainers.kafka._redpanda import RedpandaContainer
12+
from typing_extensions import Self
1013

1114
__all__ = [
1215
"KafkaContainer",
@@ -29,15 +32,26 @@ class KafkaContainer(DockerContainer):
2932
"""
3033

3134
TC_START_SCRIPT = "/tc-start.sh"
35+
MIN_KRAFT_TAG = "7.0.0"
3236

33-
def __init__(self, image: str = "confluentinc/cp-kafka:7.6.0", port: int = 9093, **kwargs) -> None:
37+
def __init__(
38+
self, image: str = "confluentinc/cp-kafka:7.6.0", port: int = 9093, **kwargs
39+
) -> None:
3440
raise_for_deprecated_parameter(kwargs, "port_to_expose", "port")
3541
super().__init__(image, **kwargs)
3642
self.port = port
43+
self.kraft_enabled = False
44+
self.wait_for = r".*\[KafkaServer id=\d+\] started.*"
45+
self.boot_command = ""
46+
self.cluster_id = self._random_uuid()
47+
self.listeners = f"PLAINTEXT://0.0.0.0:{self.port},BROKER://0.0.0.0:9092"
48+
self.security_protocol_map = "BROKER:PLAINTEXT,PLAINTEXT:PLAINTEXT"
49+
3750
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")
51+
self.with_env("KAFKA_LISTENERS", self.listeners)
52+
self.with_env(
53+
"KAFKA_LISTENER_SECURITY_PROTOCOL_MAP", self.security_protocol_map
54+
)
4155
self.with_env("KAFKA_INTER_BROKER_LISTENER_NAME", "BROKER")
4256

4357
self.with_env("KAFKA_BROKER_ID", "1")
@@ -46,6 +60,85 @@ 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+
@classmethod
83+
def _random_uuid(cls):
84+
uuid_value = uuid.uuid4()
85+
uuid_bytes = uuid_value.bytes
86+
base64_encoded_uuid = base64.b64encode(uuid_bytes)
87+
88+
return base64_encoded_uuid.decode()
89+
90+
def configure(self):
91+
if self.kraft_enabled:
92+
self._configure_kraft()
93+
else:
94+
self._configure_zookeeper()
95+
96+
def _configure_kraft(self) -> None:
97+
self.wait_for = r".*Kafka Server started.*"
98+
99+
self.with_env("CLUSTER_ID", self.cluster_id)
100+
self.with_env("KAFKA_NODE_ID", 1)
101+
self.with_env(
102+
"KAFKA_LISTENER_SECURITY_PROTOCOL_MAP",
103+
f"{self.security_protocol_map},CONTROLLER:PLAINTEXT",
104+
)
105+
self.with_env(
106+
"KAFKA_LISTENERS",
107+
f"{self.listeners},CONTROLLER://0.0.0.0:9094",
108+
)
109+
self.with_env("KAFKA_PROCESS_ROLES", "broker,controller")
110+
111+
network_alias = self._get_network_alias()
112+
controller_quorum_voters = f"1@{network_alias}:9094"
113+
self.with_env("KAFKA_CONTROLLER_QUORUM_VOTERS", controller_quorum_voters)
114+
self.with_env("KAFKA_CONTROLLER_LISTENER_NAMES", "CONTROLLER")
115+
116+
self.boot_command = f"""
117+
sed -i '/KAFKA_ZOOKEEPER_CONNECT/d' /etc/confluent/docker/configure
118+
echo 'kafka-storage format --ignore-formatted -t {self.cluster_id} -c /etc/kafka/kafka.properties' >> /etc/confluent/docker/configure
119+
"""
120+
121+
def _get_network_alias(self):
122+
if self._network:
123+
return next(
124+
iter(
125+
self._network_aliases
126+
or [self._network.name or self._kwargs.get("network", [])]
127+
),
128+
None,
129+
)
130+
131+
return "localhost"
132+
133+
def _configure_zookeeper(self) -> None:
134+
self.boot_command = """
135+
echo 'clientPort=2181' > zookeeper.properties
136+
echo 'dataDir=/var/lib/zookeeper/data' >> zookeeper.properties
137+
echo 'dataLogDir=/var/lib/zookeeper/log' >> zookeeper.properties
138+
zookeeper-server-start zookeeper.properties &
139+
export KAFKA_ZOOKEEPER_CONNECT='localhost:2181'
140+
"""
141+
49142
def get_bootstrap_server(self) -> str:
50143
host = self.get_container_host_ip()
51144
port = self.get_exposed_port(self.port)
@@ -59,11 +152,7 @@ def tc_start(self) -> None:
59152
dedent(
60153
f"""
61154
#!/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'
155+
{self.boot_command}
67156
export KAFKA_ADVERTISED_LISTENERS={listeners}
68157
. /etc/confluent/docker/bash-config
69158
/etc/confluent/docker/configure
@@ -78,10 +167,11 @@ def tc_start(self) -> None:
78167
def start(self, timeout=30) -> "KafkaContainer":
79168
script = KafkaContainer.TC_START_SCRIPT
80169
command = f'sh -c "while [ ! -f {script} ]; do sleep 0.1; done; sh {script}"'
170+
self.configure()
81171
self.with_command(command)
82172
super().start()
83173
self.tc_start()
84-
wait_for_logs(self, r".*\[KafkaServer id=\d+\] started.*", timeout=timeout)
174+
wait_for_logs(self, self.wait_for, timeout=timeout)
85175
return self
86176

87177
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)