33from io import BytesIO
44from textwrap import dedent
55
6+ from typing_extensions import Self
7+
68from testcontainers .core .container import DockerContainer
79from testcontainers .core .utils import raise_for_deprecated_parameter
10+ from testcontainers .core .version import ComparableVersion
811from testcontainers .core .waiting_utils import wait_for_logs
912from 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 :
0 commit comments