This is an automated email from the ASF dual-hosted git repository.

chia7712 pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/kafka.git


The following commit(s) were added to refs/heads/trunk by this push:
     new b992620602d KAFKA-20430 Add configuration to control internal topic 
creation in Kafka Connect (#20384)
b992620602d is described below

commit b992620602d72f633dcdeabed498638c899e87f8
Author: Anton Liauchuk <[email protected]>
AuthorDate: Wed Jul 29 06:04:19 2026 +0300

    KAFKA-20430 Add configuration to control internal topic creation in Kafka 
Connect (#20384)
    
    Implementation of
    
    
https://cwiki.apache.org/confluence/display/KAFKA/KIP-1209:+Add+configuration+to+control+internal+topic+creation+in+Kafka+Connect
    
    Reviewers: Ken Huang <[email protected]>, Chia-Ping Tsai
     <[email protected]>
---
 bin/connect-internal-topics.sh                     |  21 ++
 bin/windows/connect-internal-topics.bat            |  21 ++
 checkstyle/import-control.xml                      |   1 +
 .../apache/kafka/connect/runtime/WorkerConfig.java |  12 +
 .../runtime/distributed/DistributedConfig.java     |  19 ++
 .../storage/KafkaTopicBasedBackingStore.java       |  35 ++-
 .../integration/InternalTopicsIntegrationTest.java |  28 +++
 docs/getting-started/upgrade.md                    |   1 +
 .../apache/kafka/tools/ConnectInternalTopics.java  | 246 +++++++++++++++++++++
 .../kafka/tools/ConnectInternalTopicsTest.java     | 215 ++++++++++++++++++
 10 files changed, 592 insertions(+), 7 deletions(-)

diff --git a/bin/connect-internal-topics.sh b/bin/connect-internal-topics.sh
new file mode 100755
index 00000000000..3905c165c8b
--- /dev/null
+++ b/bin/connect-internal-topics.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+if [ "x$KAFKA_HEAP_OPTS" = "x" ]; then
+  export KAFKA_HEAP_OPTS="-Xms256M -Xmx2G"
+fi
+
+exec $(dirname $0)/kafka-run-class.sh 
org.apache.kafka.tools.ConnectInternalTopics "$@"
diff --git a/bin/windows/connect-internal-topics.bat 
b/bin/windows/connect-internal-topics.bat
new file mode 100644
index 00000000000..c4d9f01225f
--- /dev/null
+++ b/bin/windows/connect-internal-topics.bat
@@ -0,0 +1,21 @@
+@echo off
+rem Licensed to the Apache Software Foundation (ASF) under one or more
+rem contributor license agreements.  See the NOTICE file distributed with
+rem this work for additional information regarding copyright ownership.
+rem The ASF licenses this file to You under the Apache License, Version 2.0
+rem (the "License"); you may not use this file except in compliance with
+rem the License.  You may obtain a copy of the License at
+rem
+rem     http://www.apache.org/licenses/LICENSE-2.0
+rem
+rem Unless required by applicable law or agreed to in writing, software
+rem distributed under the License is distributed on an "AS IS" BASIS,
+rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+rem See the License for the specific language governing permissions and
+rem limitations under the License.
+
+IF ["%KAFKA_HEAP_OPTS%"] EQU [""] (
+       set KAFKA_HEAP_OPTS=-Xms256M -Xmx2G
+)
+
+"%~dp0kafka-run-class.bat" org.apache.kafka.tools.ConnectInternalTopics %*
\ No newline at end of file
diff --git a/checkstyle/import-control.xml b/checkstyle/import-control.xml
index 3a0b41cabd1..3ca56f078c7 100644
--- a/checkstyle/import-control.xml
+++ b/checkstyle/import-control.xml
@@ -308,6 +308,7 @@
     <allow pkg="org.apache.kafka.test" />
     <allow pkg="org.apache.kafka.connect.runtime" />
     <allow pkg="org.apache.kafka.connect.runtime.isolation" />
+    <allow pkg="org.apache.kafka.connect.util" />
     <allow pkg="com.fasterxml.jackson" />
     <allow pkg="org.jose4j" />
     <allow pkg="net.sourceforge.argparse4j" />
diff --git 
a/connect/runtime/src/main/java/org/apache/kafka/connect/runtime/WorkerConfig.java
 
b/connect/runtime/src/main/java/org/apache/kafka/connect/runtime/WorkerConfig.java
index 0dacbb909c3..dbc05c78563 100644
--- 
a/connect/runtime/src/main/java/org/apache/kafka/connect/runtime/WorkerConfig.java
+++ 
b/connect/runtime/src/main/java/org/apache/kafka/connect/runtime/WorkerConfig.java
@@ -410,6 +410,18 @@ public class WorkerConfig extends AbstractConfig {
         return false;
     }
 
+    /**
+     * Determine whether this worker should automatically create internal 
topics used by Connect
+     * (such as the offset, config, and status topics).
+     * The default implementation returns {@code true}. Subclasses may 
override this method
+     * to respect user-provided configuration.
+     *
+     * @return whether the worker should automatically create internal topics
+     */
+    public boolean internalTopicsCreationEnabled() {
+        return true;
+    }
+
     /**
      * @return the offset commit interval for tasks created by this worker
      */
diff --git 
a/connect/runtime/src/main/java/org/apache/kafka/connect/runtime/distributed/DistributedConfig.java
 
b/connect/runtime/src/main/java/org/apache/kafka/connect/runtime/distributed/DistributedConfig.java
index dd4ea3889d2..83a862c27a7 100644
--- 
a/connect/runtime/src/main/java/org/apache/kafka/connect/runtime/distributed/DistributedConfig.java
+++ 
b/connect/runtime/src/main/java/org/apache/kafka/connect/runtime/distributed/DistributedConfig.java
@@ -20,6 +20,7 @@ import org.apache.kafka.clients.CommonClientConfigs;
 import org.apache.kafka.clients.MetadataRecoveryStrategy;
 import org.apache.kafka.clients.producer.ProducerConfig;
 import org.apache.kafka.common.config.ConfigDef;
+import org.apache.kafka.common.config.ConfigDef.Type;
 import org.apache.kafka.common.config.ConfigException;
 import org.apache.kafka.common.config.TopicConfig;
 import org.apache.kafka.common.security.auth.SecurityProtocol;
@@ -187,6 +188,13 @@ public final class DistributedConfig extends WorkerConfig {
     public static final String CONNECT_PROTOCOL_DOC = "Compatibility mode for 
Kafka Connect Protocol";
     public static final String CONNECT_PROTOCOL_DEFAULT = 
ConnectProtocolCompatibility.SESSIONED.toString();
 
+
+    public static final String 
INTERNAL_TOPICS_AUTOMATIC_CREATION_ENABLE_CONFIG = 
"internal.topics.automatic.creation.enable";
+    private static final String INTERNAL_TOPICS_AUTOMATIC_CREATION_ENABLE_DOC 
= "Whether to automatically create internal topics used by Connect. "
+            + "This includes the offset, config, and status topics, as well as 
connector-specific offset topics "
+            + "configured via 'offsets.storage.topic' in the source connector 
configuration.";
+    public static final boolean 
INTERNAL_TOPICS_AUTOMATIC_CREATION_ENABLE_DEFAULT = true;
+
     /**
      * <code>scheduled.rebalance.max.delay.ms</code>
      */
@@ -433,6 +441,12 @@ public final class DistributedConfig extends WorkerConfig {
                     WORKER_UNSYNC_BACKOFF_MS_DEFAULT,
                     ConfigDef.Importance.MEDIUM,
                     WORKER_UNSYNC_BACKOFF_MS_DOC)
+            .define(
+                    INTERNAL_TOPICS_AUTOMATIC_CREATION_ENABLE_CONFIG,
+                    Type.BOOLEAN,
+                    INTERNAL_TOPICS_AUTOMATIC_CREATION_ENABLE_DEFAULT,
+                    ConfigDef.Importance.MEDIUM,
+                    INTERNAL_TOPICS_AUTOMATIC_CREATION_ENABLE_DOC)
             .define(OFFSET_STORAGE_TOPIC_CONFIG,
                     ConfigDef.Type.STRING,
                     ConfigDef.Importance.HIGH,
@@ -596,6 +610,11 @@ public final class DistributedConfig extends WorkerConfig {
         return true;
     }
 
+    @Override
+    public boolean internalTopicsCreationEnabled() {
+        return 
Boolean.TRUE.equals(getBoolean(INTERNAL_TOPICS_AUTOMATIC_CREATION_ENABLE_CONFIG));
+    }
+
     @Override
     public String groupId() {
         return getString(GROUP_ID_CONFIG);
diff --git 
a/connect/runtime/src/main/java/org/apache/kafka/connect/storage/KafkaTopicBasedBackingStore.java
 
b/connect/runtime/src/main/java/org/apache/kafka/connect/storage/KafkaTopicBasedBackingStore.java
index 4678311e4c7..f4215d95486 100644
--- 
a/connect/runtime/src/main/java/org/apache/kafka/connect/storage/KafkaTopicBasedBackingStore.java
+++ 
b/connect/runtime/src/main/java/org/apache/kafka/connect/storage/KafkaTopicBasedBackingStore.java
@@ -18,10 +18,13 @@ package org.apache.kafka.connect.storage;
 
 import org.apache.kafka.clients.admin.AdminClientConfig;
 import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.clients.admin.TopicDescription;
 import org.apache.kafka.clients.consumer.ConsumerRecord;
 import org.apache.kafka.common.config.TopicConfig;
 import org.apache.kafka.common.utils.Time;
+import org.apache.kafka.connect.errors.ConnectException;
 import org.apache.kafka.connect.runtime.WorkerConfig;
+import org.apache.kafka.connect.runtime.distributed.DistributedConfig;
 import org.apache.kafka.connect.util.Callback;
 import org.apache.kafka.connect.util.KafkaBasedLog;
 import org.apache.kafka.connect.util.TopicAdmin;
@@ -39,17 +42,35 @@ public abstract class KafkaTopicBasedBackingStore {
 
     Consumer<TopicAdmin> topicInitializer(String topic, NewTopic 
topicDescription, WorkerConfig config, Time time) {
         return admin -> {
-            log.debug("Creating Connect internal topic for {}", 
getTopicPurpose());
-            // Create the topic if it doesn't exist
-            Set<String> newTopics = createTopics(topicDescription, admin, 
config, time);
-            if (!newTopics.contains(topic)) {
-                // It already existed, so check that the topic cleanup policy 
is compact only and not delete
-                log.debug("Using admin client to check cleanup policy of '{}' 
topic is '{}'", topic, TopicConfig.CLEANUP_POLICY_COMPACT);
-                admin.verifyTopicCleanupPolicyOnlyCompact(topic, 
getTopicConfig(), getTopicPurpose());
+            if (config.internalTopicsCreationEnabled()) {
+                log.debug("Creating Connect internal topic for {}", 
getTopicPurpose());
+                // Create the topic if it doesn't exist
+                Set<String> newTopics = createTopics(topicDescription, admin, 
config, time);
+                if (!newTopics.contains(topic)) {
+                    verifyTopicConfig(topic, admin);
+                }
+            } else {
+                log.debug("Skipping creation of Connect internal topic for {} 
because automatic topic creation is disabled", getTopicPurpose());
+                Map<String, TopicDescription> existing = 
admin.describeTopics(topic);
+                if (existing.isEmpty()) {
+                    String msg = String.format("Topic '%s' specified via the 
'%s' property is missing." +
+                                    " The config '%s' is set to '%s', so 
automatic creation of internal topics is disabled." +
+                                    " Either enable automatic creation or 
create the topics manually before starting the worker.",
+                            topic, getTopicConfig(), 
DistributedConfig.INTERNAL_TOPICS_AUTOMATIC_CREATION_ENABLE_CONFIG, 
config.internalTopicsCreationEnabled());
+                    throw new ConnectException(msg);
+                }
+
+                verifyTopicConfig(topic, admin);
             }
         };
     }
 
+    private void verifyTopicConfig(String topic, TopicAdmin admin) {
+        // It already existed, so check that the topic cleanup policy is 
compact only and not delete
+        log.debug("Using admin client to check cleanup policy of '{}' topic is 
'{}'", topic, TopicConfig.CLEANUP_POLICY_COMPACT);
+        admin.verifyTopicCleanupPolicyOnlyCompact(topic, getTopicConfig(), 
getTopicPurpose());
+    }
+
     private Set<String> createTopics(NewTopic topicDescription, TopicAdmin 
admin, WorkerConfig config, Time time) {
         // get the prefixless default api timeout and retry backoff for topic 
creation retry configs
         AdminClientConfig adminClientConfig = new 
AdminClientConfig(config.originals());
diff --git 
a/connect/runtime/src/test/java/org/apache/kafka/connect/integration/InternalTopicsIntegrationTest.java
 
b/connect/runtime/src/test/java/org/apache/kafka/connect/integration/InternalTopicsIntegrationTest.java
index 50871fa7257..eb88ecb432c 100644
--- 
a/connect/runtime/src/test/java/org/apache/kafka/connect/integration/InternalTopicsIntegrationTest.java
+++ 
b/connect/runtime/src/test/java/org/apache/kafka/connect/integration/InternalTopicsIntegrationTest.java
@@ -249,6 +249,34 @@ public class InternalTopicsIntegrationTest {
         connect.assertions().assertAtLeastNumWorkersAreUp(1, "Worker did not 
start in time.");
     }
 
+    @Test
+    void 
testFailToStartWhenInternalTopicsAreMissingWithDisabledInternalTopicCreation() 
throws InterruptedException {
+        workerProps.put(DistributedConfig.CONFIG_TOPIC_CONFIG, 
"non-existent-config");
+        workerProps.put(DistributedConfig.OFFSET_STORAGE_TOPIC_CONFIG, 
"non-existent-offset");
+        workerProps.put(DistributedConfig.STATUS_STORAGE_TOPIC_CONFIG, 
"non-existent-status");
+        
workerProps.put(DistributedConfig.CONFIG_STORAGE_REPLICATION_FACTOR_CONFIG, 
"1");
+        
workerProps.put(DistributedConfig.OFFSET_STORAGE_REPLICATION_FACTOR_CONFIG, 
"1");
+        
workerProps.put(DistributedConfig.STATUS_STORAGE_REPLICATION_FACTOR_CONFIG, 
"1");
+        
workerProps.put(DistributedConfig.INTERNAL_TOPICS_AUTOMATIC_CREATION_ENABLE_CONFIG,
 "false");
+        connect = new 
EmbeddedConnectCluster.Builder().name("connect-cluster-1")
+                .workerProps(workerProps)
+                .numWorkers(0)
+                .numBrokers(1)
+                .brokerProps(brokerProps)
+                .build();
+        connect.start();
+
+        var worker = connect.addWorker();
+
+        assertFalse(connect.anyWorkersHealthy());
+        var herderTask = worker.herderTask();
+        assertThrows(
+                ExecutionException.class,
+                () -> herderTask.get(1, TimeUnit.MINUTES)
+        );
+        connect.assertions().assertTopicsDoNotExist("non-existent-config", 
"non-existent-offset", "non-existent-status");
+    }
+
     @Test
     public void 
testStartWhenInternalTopicsCreatedManuallyWithCompactForBrokersDefaultCleanupPolicy()
 throws InterruptedException {
         // Change the broker default cleanup policy to compact, which is good 
for Connect
diff --git a/docs/getting-started/upgrade.md b/docs/getting-started/upgrade.md
index c504d0c1630..034d157a68a 100644
--- a/docs/getting-started/upgrade.md
+++ b/docs/getting-started/upgrade.md
@@ -49,6 +49,7 @@ type: docs
   * Brokers can now record a human-readable description of each streams 
group's processing topology via a pluggable backend, retrievable through 
`Admin#describeStreamsGroups` and `kafka-streams-groups.sh --describe 
--topology`. The feature is disabled unless the new broker configuration 
`group.streams.topology.description.plugin.class` is set to a 
`StreamsGroupTopologyDescriptionPlugin` implementation; on the client side, the 
new Kafka Streams configuration `topology.description.push.ena [...]
   * The `kafka-producer-perf-test.sh` tool now supports `--record-key-range`, 
`--key-distribution`, and `--random-seed` options to control the distribution 
of record keys. Use `--key-distribution range` for sequential key assignment 
(round-robin over the key range) or `--key-distribution random` for random key 
selection. The `--random-seed` option allows reproducible benchmark runs when 
using random key distribution. For further details, please refer to 
[KIP-1299](https://cwiki.apache.or [...]
   * Share groups now support dead-letter queue functionality as outlined in 
[KIP-1191](https://cwiki.apache.org/confluence/x/fApJFg). Any records which are 
released (beyond max delivery count) or rejected by the share consumer become 
eligible for DLQ. Share group DLQ gets enabled when the Kafka feature 
`share.version` is upgraded to 2. The user can configure a DLQ topic on a share 
group by setting the dynamic config `errors.deadletterqueue.topic.name` 
(default `""`) to the name of the DL [...]
+  * Kafka Connect distributed workers now support the 
`internal.topics.automatic.creation.enable` configuration (default: `true`). 
When set to `false`, Connect will not automatically create internal topics 
(offset, config, status, and connector-specific offset topics) and will instead 
fail at startup if any of these topics are missing. A new 
`connect-internal-topics.sh` tool is also available for manually creating these 
topics. For further details, please refer to [KIP-1209](https://cwik [...]
 
 ## Upgrading to 4.3.0
 
diff --git 
a/tools/src/main/java/org/apache/kafka/tools/ConnectInternalTopics.java 
b/tools/src/main/java/org/apache/kafka/tools/ConnectInternalTopics.java
new file mode 100644
index 00000000000..a7251aaa05b
--- /dev/null
+++ b/tools/src/main/java/org/apache/kafka/tools/ConnectInternalTopics.java
@@ -0,0 +1,246 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.tools;
+
+import org.apache.kafka.common.config.AbstractConfig;
+import org.apache.kafka.common.config.ConfigDef;
+import org.apache.kafka.common.config.ConfigException;
+import org.apache.kafka.common.config.TopicConfig;
+import org.apache.kafka.common.utils.Utils;
+import org.apache.kafka.common.utils.internals.Exit;
+import org.apache.kafka.connect.runtime.distributed.DistributedConfig;
+import org.apache.kafka.connect.util.SharedTopicAdmin;
+import org.apache.kafka.connect.util.TopicAdmin;
+
+import net.sourceforge.argparse4j.ArgumentParsers;
+import net.sourceforge.argparse4j.inf.ArgumentParser;
+import net.sourceforge.argparse4j.inf.ArgumentParserException;
+import net.sourceforge.argparse4j.inf.Namespace;
+
+import java.io.IOException;
+import java.io.PrintStream;
+import java.util.HashMap;
+import java.util.Map;
+
+import static net.sourceforge.argparse4j.impl.Arguments.store;
+
+public class ConnectInternalTopics {
+
+    private static final String CREATE_COMMAND = "create";
+
+    public static void main(String[] args) {
+        Exit.exit(mainNoExit(args, System.out, System.err));
+    }
+
+    static int mainNoExit(String[] args, PrintStream out, PrintStream err) {
+        var parser = parser();
+        try {
+            var namespace = parser.parseArgs(args);
+            var workerProperties = parseWorkerProperties(parser, namespace);
+            out.println("Parsed arguments and loaded worker properties");
+            execute(parser, namespace, workerProperties, out);
+            out.println("Command executed successfully");
+            return 0;
+        } catch (ArgumentParserException e) {
+            parser.handleError(e);
+            return 1;
+        } catch (TerseException | ConfigException e) {
+            err.println(e.getMessage());
+            return 2;
+        } catch (Throwable e) {
+            err.println("Unexpected error: " + e.getMessage());
+            err.println(Utils.stackTrace(e));
+            return 3;
+        }
+    }
+
+    private static void execute(ArgumentParser parser, Namespace namespace, 
Map<String, String> workerProperties, PrintStream out) throws 
ArgumentParserException {
+        var subcommand = namespace.getString("subcommand");
+        out.println("Subcommand: " + subcommand);
+        if (subcommand == null) {
+            throw new ArgumentParserException("No subcommand specified", 
parser);
+        }
+        if (CREATE_COMMAND.equals(subcommand)) {
+            var internalTopicsConfig = new 
InternalTopicsConfig(workerProperties);
+            internalTopicsConfig.validateTopicNames();
+            out.println("Running create command for internal topics");
+            runCommand(internalTopicsConfig, out);
+        } else {
+            throw new ArgumentParserException("Unrecognized subcommand: '" + 
subcommand + "'", parser);
+        }
+    }
+
+    private static void runCommand(InternalTopicsConfig config, PrintStream 
out) {
+        var adminProps = new HashMap<>(config.originals());
+        out.println("Admin properties loaded for topic admin");
+        try (var sharedAdmin = new SharedTopicAdmin(adminProps)) {
+            createInternalTopic(sharedAdmin, buildOffsetTopicSettings(config, 
out), out);
+            createInternalTopic(sharedAdmin, buildConfigTopicSettings(config, 
out), out);
+            createInternalTopic(sharedAdmin, buildStatusTopicSettings(config, 
out), out);
+        }
+    }
+
+    private static void createInternalTopic(SharedTopicAdmin sharedAdmin, 
TopicSettings settings, PrintStream out) {
+        var topicDescription = TopicAdmin.defineTopic(settings.topicName)
+                .config(settings.topicSettings)
+                .compacted()
+                .partitions(settings.partitions)
+                .replicationFactor(settings.replicationFactor)
+                .build();
+        var created = sharedAdmin.topicAdmin().createTopics(topicDescription);
+        if (created.contains(settings.topicName)) {
+            out.println("Created internal topic: " + settings.topicName);
+        } else {
+            out.println("Internal topic already exists: " + 
settings.topicName);
+        }
+    }
+
+    private static TopicSettings buildOffsetTopicSettings(InternalTopicsConfig 
config, PrintStream out) {
+        return new TopicSettings(
+                
config.getString(DistributedConfig.OFFSET_STORAGE_TOPIC_CONFIG),
+                config.topicSettings(DistributedConfig.OFFSET_STORAGE_PREFIX, 
out),
+                
config.getInt(DistributedConfig.OFFSET_STORAGE_PARTITIONS_CONFIG),
+                
config.getShort(DistributedConfig.OFFSET_STORAGE_REPLICATION_FACTOR_CONFIG)
+        );
+    }
+
+    private static TopicSettings buildConfigTopicSettings(InternalTopicsConfig 
config, PrintStream out) {
+        return new TopicSettings(
+                config.getString(DistributedConfig.CONFIG_TOPIC_CONFIG),
+                config.topicSettings(DistributedConfig.CONFIG_STORAGE_PREFIX, 
out),
+                1,
+                
config.getShort(DistributedConfig.CONFIG_STORAGE_REPLICATION_FACTOR_CONFIG)
+        );
+    }
+
+    private static TopicSettings buildStatusTopicSettings(InternalTopicsConfig 
config, PrintStream out) {
+        return new TopicSettings(
+                
config.getString(DistributedConfig.STATUS_STORAGE_TOPIC_CONFIG),
+                config.topicSettings(DistributedConfig.STATUS_STORAGE_PREFIX, 
out),
+                
config.getInt(DistributedConfig.STATUS_STORAGE_PARTITIONS_CONFIG),
+                
config.getShort(DistributedConfig.STATUS_STORAGE_REPLICATION_FACTOR_CONFIG)
+        );
+    }
+
+    private record TopicSettings(String topicName, Map<String, Object> 
topicSettings, int partitions,
+                                short replicationFactor) {
+    }
+
+    private static Map<String, String> parseWorkerProperties(ArgumentParser 
parser, Namespace namespace) throws ArgumentParserException, TerseException {
+        String workerConfigPath = namespace.getString("worker_config");
+        if (workerConfigPath == null || workerConfigPath.isBlank()) {
+            throw new ArgumentParserException("--worker-config must be 
specified and non-blank", parser);
+        }
+
+        try {
+            return Utils.propsToStringMap(Utils.loadProps(workerConfigPath));
+        } catch (IOException e) {
+            throw new TerseException("Unable to read worker config at " + 
workerConfigPath);
+        }
+    }
+
+    private static ArgumentParser parser() {
+        var parser = 
ArgumentParsers.newArgumentParser("connect-internal-topics")
+                .defaultHelp(true)
+                .description("Manage internal topics required by Kafka Connect 
clusters (config, status, and offset topics).");
+
+        parser.addSubparsers()
+                .description("Create internal topics required for Kafka 
Connect operation using the provided worker configuration.")
+                .dest("subcommand")
+                .addParser(CREATE_COMMAND)
+                .addArgument("--worker-config")
+                .setDefault("")
+                .type(String.class)
+                .action(store())
+                .help("Path to a Connect worker configuration file. This file 
must define the internal topic names and connection information for the Kafka 
cluster.");
+
+        return parser;
+    }
+
+    private static class InternalTopicsConfig extends AbstractConfig {
+        private static final ConfigDef CONFIG_DEF = new ConfigDef()
+                .define(DistributedConfig.OFFSET_STORAGE_TOPIC_CONFIG,
+                        ConfigDef.Type.STRING,
+                        ConfigDef.Importance.HIGH,
+                        "")
+                .define(DistributedConfig.OFFSET_STORAGE_PARTITIONS_CONFIG,
+                        ConfigDef.Type.INT,
+                        25,
+                        ConfigDef.Importance.LOW,
+                        "")
+                
.define(DistributedConfig.OFFSET_STORAGE_REPLICATION_FACTOR_CONFIG,
+                        ConfigDef.Type.SHORT,
+                        (short) 3,
+                        ConfigDef.Importance.LOW,
+                        "")
+                .define(DistributedConfig.CONFIG_TOPIC_CONFIG,
+                        ConfigDef.Type.STRING,
+                        ConfigDef.Importance.HIGH,
+                        "")
+                
.define(DistributedConfig.CONFIG_STORAGE_REPLICATION_FACTOR_CONFIG,
+                        ConfigDef.Type.SHORT,
+                        (short) 3,
+                        ConfigDef.Importance.LOW,
+                        "")
+                .define(DistributedConfig.STATUS_STORAGE_TOPIC_CONFIG,
+                        ConfigDef.Type.STRING,
+                        ConfigDef.Importance.HIGH,
+                        "")
+                .define(DistributedConfig.STATUS_STORAGE_PARTITIONS_CONFIG,
+                        ConfigDef.Type.INT,
+                        5,
+                        ConfigDef.Importance.LOW,
+                        "")
+                
.define(DistributedConfig.STATUS_STORAGE_REPLICATION_FACTOR_CONFIG,
+                        ConfigDef.Type.SHORT,
+                        (short) 3,
+                        ConfigDef.Importance.LOW,
+                        "");
+
+        InternalTopicsConfig(Map<String, String> props) {
+            super(CONFIG_DEF, props, false);
+        }
+
+        void validateTopicNames() {
+            validateTopicName(DistributedConfig.OFFSET_STORAGE_TOPIC_CONFIG);
+            validateTopicName(DistributedConfig.CONFIG_TOPIC_CONFIG);
+            validateTopicName(DistributedConfig.STATUS_STORAGE_TOPIC_CONFIG);
+        }
+
+        private void validateTopicName(String config) {
+            var value = getString(config);
+            if (value == null || value.trim().isEmpty()) {
+                throw new ConfigException("Must specify non-empty value for 
required internal topic config: '" + config + "'.");
+            }
+        }
+
+        private Map<String, Object> topicSettings(String prefix, PrintStream 
out) {
+            var result = originalsWithPrefix(prefix);
+            if (DistributedConfig.CONFIG_STORAGE_PREFIX.equals(prefix) && 
result.containsKey(DistributedConfig.PARTITIONS_SUFFIX)) {
+                out.println("Ignoring '" + prefix + 
DistributedConfig.PARTITIONS_SUFFIX + "=" + 
result.get(DistributedConfig.PARTITIONS_SUFFIX) + "' setting, since config 
topic partitions is always 1");
+            }
+            var removedPolicy = 
result.remove(TopicConfig.CLEANUP_POLICY_CONFIG);
+            if (removedPolicy != null) {
+                out.println("Ignoring '" + prefix + "cleanup.policy=" + 
removedPolicy + "' setting, since compaction is always used");
+            }
+            result.remove(DistributedConfig.TOPIC_SUFFIX);
+            result.remove(DistributedConfig.REPLICATION_FACTOR_SUFFIX);
+            result.remove(DistributedConfig.PARTITIONS_SUFFIX);
+            return result;
+        }
+    }
+}
diff --git 
a/tools/src/test/java/org/apache/kafka/tools/ConnectInternalTopicsTest.java 
b/tools/src/test/java/org/apache/kafka/tools/ConnectInternalTopicsTest.java
new file mode 100644
index 00000000000..fadff2bebbd
--- /dev/null
+++ b/tools/src/test/java/org/apache/kafka/tools/ConnectInternalTopicsTest.java
@@ -0,0 +1,215 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.kafka.tools;
+
+import org.apache.kafka.clients.admin.Admin;
+import org.apache.kafka.clients.admin.NewTopic;
+import org.apache.kafka.common.config.ConfigResource;
+import org.apache.kafka.common.test.ClusterInstance;
+import org.apache.kafka.common.test.api.ClusterTest;
+import org.apache.kafka.test.TestUtils;
+
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.PrintStream;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.Map;
+import java.util.Properties;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+public class ConnectInternalTopicsTest {
+
+    private static final String CONFIG_TOPIC_NAME = "config";
+    private static final String STATUS_TOPIC_NAME = "status";
+    private static final String OFFSET_TOPIC_NAME = "offset";
+
+    @TempDir
+    Path workspace;
+
+    @ClusterTest(brokers = 3)
+    void testCreateInternalTopicsWithDefaultValues(ClusterInstance cluster) 
throws Exception {
+        var properties = new Properties();
+        properties.setProperty("bootstrap.servers", 
cluster.bootstrapServers());
+        properties.setProperty("config.storage.topic", CONFIG_TOPIC_NAME);
+        properties.setProperty("status.storage.topic", STATUS_TOPIC_NAME);
+        properties.setProperty("offset.storage.topic", OFFSET_TOPIC_NAME);
+        var workerConfigPath = 
setupWorkerConfig(workspace.resolve("worker.properties"), properties);
+        var res = runCommand("create", "--worker-config", 
workerConfigPath.toString());
+        assertEquals(0, res.returnCode);
+        try (var adminClient = cluster.admin()) {
+            waitForTopics(adminClient, Set.of(CONFIG_TOPIC_NAME, 
STATUS_TOPIC_NAME, OFFSET_TOPIC_NAME));
+            assertTopicPartitions(adminClient, CONFIG_TOPIC_NAME, 1);
+            assertTopicPartitions(adminClient, STATUS_TOPIC_NAME, 5);
+            assertTopicPartitions(adminClient, OFFSET_TOPIC_NAME, 25);
+            assertTopicReplicationFactor(adminClient, CONFIG_TOPIC_NAME, 
(short) 3);
+            assertTopicReplicationFactor(adminClient, STATUS_TOPIC_NAME, 
(short) 3);
+            assertTopicReplicationFactor(adminClient, OFFSET_TOPIC_NAME, 
(short) 3);
+        }
+    }
+
+    @ClusterTest
+    void testNoWorkerConfig() {
+        var res = runCommand("create");
+        assertNotEquals(0, res.returnCode);
+    }
+
+    @ClusterTest
+    void testWorkerConfigBlank() {
+        var res = runCommand("create", "--worker-config", "");
+        assertNotEquals(0, res.returnCode);
+    }
+
+    @ClusterTest
+    void testWorkerConfigFileDoesNotExist() {
+        var nonExistentPath = 
workspace.resolve("nonexistent-worker.properties").toString();
+        var res = runCommand("create", "--worker-config", nonExistentPath);
+        assertNotEquals(0, res.returnCode);
+        assertTrue(res.err.contains("Unable to read worker config"));
+    }
+
+    @ClusterTest(brokers = 3)
+    void testNoTopicNamesInWorkerConfig(ClusterInstance cluster) throws 
IOException {
+        var properties = new Properties();
+        properties.setProperty("bootstrap.servers", 
cluster.bootstrapServers());
+        var configPath = 
setupWorkerConfig(workspace.resolve("worker-no-topics.properties"), properties);
+        var res = runCommand("create", "--worker-config", 
configPath.toString());
+        assertNotEquals(0, res.returnCode);
+        assertEquals("Missing required configuration \"offset.storage.topic\" 
which has no default value.\n", res.err);
+    }
+
+    @ClusterTest(brokers = 3)
+    void testEmptyTopicNamesInWorkerConfig(ClusterInstance cluster) throws 
IOException {
+        var properties = new Properties();
+        properties.setProperty("bootstrap.servers", 
cluster.bootstrapServers());
+        properties.setProperty("config.storage.topic", "config");
+        properties.setProperty("status.storage.topic", "status");
+        properties.setProperty("offset.storage.topic", "");
+        var configPath = 
setupWorkerConfig(workspace.resolve("worker-empty-topics.properties"), 
properties);
+        var res = runCommand("create", "--worker-config", 
configPath.toString());
+        assertNotEquals(0, res.returnCode);
+        assertEquals("Must specify non-empty value for required internal topic 
config: 'offset.storage.topic'.\n", res.err);
+    }
+
+    @ClusterTest(brokers = 3)
+    void testTopicConfigOverrides(ClusterInstance cluster) throws Exception {
+        var properties = new Properties();
+        properties.setProperty("bootstrap.servers", 
cluster.bootstrapServers());
+        properties.setProperty("config.storage.topic", CONFIG_TOPIC_NAME);
+        properties.setProperty("config.storage.retention.ms", "1000");
+        properties.setProperty("status.storage.topic", STATUS_TOPIC_NAME);
+        properties.setProperty("status.storage.retention.ms", "2000");
+        properties.setProperty("offset.storage.topic", OFFSET_TOPIC_NAME);
+        properties.setProperty("offset.storage.retention.ms", "3000");
+        var configPath = 
setupWorkerConfig(workspace.resolve("worker-topic-overrides.properties"), 
properties);
+        var res = runCommand("create", "--worker-config", 
configPath.toString());
+        assertEquals(0, res.returnCode);
+        try (var adminClient = cluster.admin()) {
+            waitForTopics(adminClient, Set.of(CONFIG_TOPIC_NAME, 
STATUS_TOPIC_NAME, OFFSET_TOPIC_NAME));
+            assertTopicConfig(adminClient, CONFIG_TOPIC_NAME, "retention.ms", 
"1000");
+            assertTopicConfig(adminClient, STATUS_TOPIC_NAME, "retention.ms", 
"2000");
+            assertTopicConfig(adminClient, OFFSET_TOPIC_NAME, "retention.ms", 
"3000");
+            assertTopicReplicationFactor(adminClient, CONFIG_TOPIC_NAME, 
(short) 3);
+            assertTopicReplicationFactor(adminClient, STATUS_TOPIC_NAME, 
(short) 3);
+            assertTopicReplicationFactor(adminClient, OFFSET_TOPIC_NAME, 
(short) 3);
+        }
+    }
+
+    @ClusterTest(brokers = 3)
+    void testCreateMissingTopics(ClusterInstance cluster) throws Exception {
+        try (var adminClient = cluster.admin()) {
+            adminClient.createTopics(Collections.singleton(
+                    new NewTopic(CONFIG_TOPIC_NAME, 1, (short) 1)
+                            .configs(Map.of("retention.ms", "1000"))
+            ));
+            waitForTopics(adminClient, Set.of(CONFIG_TOPIC_NAME));
+        }
+        var properties = new Properties();
+        properties.setProperty("bootstrap.servers", 
cluster.bootstrapServers());
+        properties.setProperty("config.storage.topic", CONFIG_TOPIC_NAME);
+        properties.setProperty("status.storage.topic", STATUS_TOPIC_NAME);
+        properties.setProperty("offset.storage.topic", OFFSET_TOPIC_NAME);
+        var configPath = 
setupWorkerConfig(workspace.resolve("worker-partial-topics.properties"), 
properties);
+        var res = runCommand("create", "--worker-config", 
configPath.toString());
+        assertEquals(0, res.returnCode);
+        try (var adminClient = cluster.admin()) {
+            waitForTopics(adminClient, Set.of(CONFIG_TOPIC_NAME, 
STATUS_TOPIC_NAME, OFFSET_TOPIC_NAME));
+            assertTopicConfig(adminClient, CONFIG_TOPIC_NAME, "retention.ms", 
"1000");
+            assertTopicPartitions(adminClient, CONFIG_TOPIC_NAME, 1);
+            assertTopicPartitions(adminClient, STATUS_TOPIC_NAME, 5);
+            assertTopicPartitions(adminClient, OFFSET_TOPIC_NAME, 25);
+            assertTopicReplicationFactor(adminClient, CONFIG_TOPIC_NAME, 
(short) 1);
+            assertTopicReplicationFactor(adminClient, STATUS_TOPIC_NAME, 
(short) 3);
+            assertTopicReplicationFactor(adminClient, OFFSET_TOPIC_NAME, 
(short) 3);
+        }
+    }
+
+    private record CommandResult(int returnCode, String out, String err) {
+    }
+
+    private static CommandResult runCommand(String... args) {
+        var out = new ByteArrayOutputStream();
+        var err = new ByteArrayOutputStream();
+        var code = ConnectInternalTopics.mainNoExit(
+                args,
+                new PrintStream(out, true),
+                new PrintStream(err, true)
+        );
+        return new CommandResult(code, out.toString(), err.toString());
+    }
+
+    private static void assertTopicConfig(Admin admin, String topic, String 
configKey, String expectedValue) throws Exception {
+        var resource = new ConfigResource(ConfigResource.Type.TOPIC, topic);
+        var describeResult = 
admin.describeConfigs(Collections.singleton(resource));
+        var config = describeResult.all().get().get(resource);
+        assertEquals(expectedValue, config.get(configKey).value());
+    }
+
+    private static void assertTopicReplicationFactor(Admin admin, String 
topic, short expectedReplicationFactor) throws Exception {
+        var topicDescriptionFuture = 
admin.describeTopics(Collections.singleton(topic)).topicNameValues().get(topic);
+        var topicDescription = topicDescriptionFuture.get();
+        var replicationFactor = 
topicDescription.partitions().get(0).replicas().size();
+        assertEquals(expectedReplicationFactor, replicationFactor);
+    }
+
+    private static void assertTopicPartitions(Admin admin, String topic, int 
expectedPartitions) throws Exception {
+        var topicDescriptionFuture = 
admin.describeTopics(Collections.singleton(topic)).topicNameValues().get(topic);
+        var topicDescription = topicDescriptionFuture.get();
+        var partitions = topicDescription.partitions().size();
+        assertEquals(expectedPartitions, partitions);
+    }
+
+    private static void waitForTopics(Admin admin, Set<String> expectedTopics) 
throws InterruptedException {
+        TestUtils.waitForCondition(() -> 
admin.listTopics().names().get().containsAll(expectedTopics),
+                "timed out waiting for topics");
+    }
+
+    private static Path setupWorkerConfig(Path path, Properties properties) 
throws IOException {
+        path.getParent().toFile().mkdirs();
+        try (var outputStream = Files.newOutputStream(path)) {
+            properties.store(outputStream, "worker properties file");
+        }
+        return path;
+    }
+}

Reply via email to