Repository: provectus/kafka-ui
Issue: Support incremental config update for brokers
Labels: type/enhancement
scope/backend

### Describe the solution you'd like

Implement endpoint 
PUT /api/clusters/{clusterName}/brokers/{id}/configs/{name}:
on call do incremental alter config for certain broker


Issue comments:

Pull request review comments:

Patch that resolved the issue:
diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/BrokersController.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/BrokersController.java
index 0b34efdbad1..d7cfd579125 100644
--- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/BrokersController.java
+++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/controller/BrokersController.java
@@ -3,6 +3,7 @@
 import com.provectus.kafka.ui.api.BrokersApi;
 import com.provectus.kafka.ui.model.Broker;
 import com.provectus.kafka.ui.model.BrokerConfig;
+import com.provectus.kafka.ui.model.BrokerConfigItem;
 import com.provectus.kafka.ui.model.BrokerLogdirUpdate;
 import com.provectus.kafka.ui.model.BrokerMetrics;
 import com.provectus.kafka.ui.model.BrokersLogdirs;
@@ -61,4 +62,16 @@ public Mono<ResponseEntity<Void>> updateBrokerTopicPartitionLogDir(
         .flatMap(bld -> clusterService.updateBrokerLogDir(clusterName, id, bld))
         .map(ResponseEntity::ok);
   }
+
+  @Override
+  public Mono<ResponseEntity<Void>> updateBrokerConfigByName(String clusterName,
+                                                             Integer id,
+                                                             String name,
+                                                             Mono<BrokerConfigItem> brokerConfig,
+                                                             ServerWebExchange exchange) {
+    return brokerConfig
+        .flatMap(bci -> clusterService.updateBrokerConfigByName(
+            clusterName, id, name, bci.getValue()))
+        .map(ResponseEntity::ok);
+  }
 }
diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/ErrorCode.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/ErrorCode.java
index f87fccbaaf9..87147389048 100644
--- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/ErrorCode.java
+++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/ErrorCode.java
@@ -22,7 +22,8 @@ public enum ErrorCode {
   CONNECT_NOT_FOUND(4010, HttpStatus.NOT_FOUND),
   KSQLDB_NOT_FOUND(4011, HttpStatus.NOT_FOUND),
   DIR_NOT_FOUND(4012, HttpStatus.BAD_REQUEST),
-  TOPIC_OR_PARTITION_NOT_FOUND(4013, HttpStatus.BAD_REQUEST);
+  TOPIC_OR_PARTITION_NOT_FOUND(4013, HttpStatus.BAD_REQUEST),
+  INVALID_REQUEST(4014, HttpStatus.BAD_REQUEST);
 
   static {
     // codes uniqueness check
diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/InvalidRequestApiException.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/InvalidRequestApiException.java
new file mode 100644
index 00000000000..0ca84803b37
--- /dev/null
+++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/exception/InvalidRequestApiException.java
@@ -0,0 +1,13 @@
+package com.provectus.kafka.ui.exception;
+
+public class InvalidRequestApiException extends CustomBaseException {
+
+  public InvalidRequestApiException(String message) {
+    super(message);
+  }
+
+  @Override
+  public ErrorCode getErrorCode() {
+    return ErrorCode.INVALID_REQUEST;
+  }
+}
diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/ClusterService.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/ClusterService.java
index b75c7a62552..37938f9c73c 100644
--- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/ClusterService.java
+++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/ClusterService.java
@@ -386,4 +386,12 @@ public Mono<Void> updateBrokerLogDir(
     return Mono.justOrEmpty(clustersStorage.getClusterByName(clusterName))
         .flatMap(c -> kafkaService.updateBrokerLogDir(c, id, brokerLogDir));
   }
+
+  public Mono<Void> updateBrokerConfigByName(String clusterName,
+                                             Integer id,
+                                             String name,
+                                             String value) {
+    return Mono.justOrEmpty(clustersStorage.getClusterByName(clusterName))
+        .flatMap(c -> kafkaService.updateBrokerConfigByName(c, id, name, value));
+  }
 }
\ No newline at end of file
diff --git a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaService.java b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaService.java
index 8dc5c3aed37..13ec1e87c64 100644
--- a/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaService.java
+++ b/kafka-ui-api/src/main/java/com/provectus/kafka/ui/service/KafkaService.java
@@ -1,6 +1,7 @@
 package com.provectus.kafka.ui.service;
 
 import com.provectus.kafka.ui.exception.IllegalEntityStateException;
+import com.provectus.kafka.ui.exception.InvalidRequestApiException;
 import com.provectus.kafka.ui.exception.LogDirNotFoundApiException;
 import com.provectus.kafka.ui.exception.NotFoundException;
 import com.provectus.kafka.ui.exception.TopicMetadataException;
@@ -76,6 +77,7 @@
 import org.apache.kafka.common.TopicPartition;
 import org.apache.kafka.common.TopicPartitionReplica;
 import org.apache.kafka.common.config.ConfigResource;
+import org.apache.kafka.common.errors.InvalidRequestException;
 import org.apache.kafka.common.errors.LogDirNotFoundException;
 import org.apache.kafka.common.errors.TimeoutException;
 import org.apache.kafka.common.errors.UnknownTopicOrPartitionException;
@@ -966,4 +968,27 @@ private Mono<Void> updateBrokerLogDir(ExtendedAdminClient adminMono,
             e -> Mono.error(new LogDirNotFoundApiException()))
         .doOnError(log::error);
   }
+
+  public Mono<Void> updateBrokerConfigByName(KafkaCluster cluster,
+                                             Integer broker,
+                                             String name,
+                                             String value) {
+    return getOrCreateAdminClient(cluster)
+        .flatMap(ac -> updateBrokerConfigByName(ac, broker, name, value));
+  }
+
+  private Mono<Void> updateBrokerConfigByName(ExtendedAdminClient admin,
+                                              Integer broker,
+                                              String name,
+                                              String value) {
+    ConfigResource cr = new ConfigResource(ConfigResource.Type.BROKER, String.valueOf(broker));
+    AlterConfigOp op = new AlterConfigOp(new ConfigEntry(name, value), AlterConfigOp.OpType.SET);
+
+    return Mono.just(admin)
+        .map(a -> a.getAdminClient().incrementalAlterConfigs(Map.of(cr, List.of(op))))
+        .flatMap(result -> ClusterUtil.toMono(result.all()))
+        .onErrorResume(InvalidRequestException.class,
+            e -> Mono.error(new InvalidRequestApiException(e.getMessage())))
+        .doOnError(log::error);
+  }
 }
diff --git a/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml b/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml
index 7fc50d5358e..9d126efaf29 100644
--- a/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml
+++ b/kafka-ui-contract/src/main/resources/swagger/kafka-ui-api.yaml
@@ -106,6 +106,37 @@ paths:
         404:
           description: Not found
 
+  /api/clusters/{clusterName}/brokers/{id}/configs/{name}:
+    put:
+      tags:
+        - Brokers
+      summary: updateBrokerConfigByName
+      operationId: updateBrokerConfigByName
+      parameters:
+        - name: clusterName
+          in: path
+          required: true
+          schema:
+            type: string
+        - name: id
+          in: path
+          required: true
+          schema:
+            type: integer
+        - name: name
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              $ref: '#/components/schemas/BrokerConfigItem'
+      responses:
+        200:
+          description: OK
+
   /api/clusters/{clusterName}/metrics:
     get:
       tags:
@@ -2400,6 +2431,12 @@ components:
         - totalReplicationFactor
         - topicName
 
+    BrokerConfigItem:
+      type: object
+      properties:
+        value:
+          type: string
+
     BrokerConfig:
       type: object
       properties:


Test patch:
diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/ConfigTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/ConfigTest.java
new file mode 100644
index 00000000000..5ae55b56ac3
--- /dev/null
+++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/ConfigTest.java
@@ -0,0 +1,79 @@
+package com.provectus.kafka.ui.service;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import com.provectus.kafka.ui.AbstractBaseTest;
+import com.provectus.kafka.ui.model.BrokerConfig;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient;
+import org.springframework.core.ParameterizedTypeReference;
+import org.springframework.test.context.ContextConfiguration;
+import org.springframework.test.web.reactive.server.WebTestClient;
+
+@ContextConfiguration(initializers = {AbstractBaseTest.Initializer.class})
+@AutoConfigureWebTestClient(timeout = "60000")
+public class ConfigTest extends AbstractBaseTest {
+
+  @Autowired
+  private WebTestClient webTestClient;
+
+  @Test
+  public void testAlterConfig() throws Exception {
+    String name = "background.threads";
+
+    Optional<BrokerConfig> bc = getConfig(name);
+    assertThat(bc.isPresent()).isTrue();
+    assertThat(bc.get().getValue()).isEqualTo("10");
+
+    webTestClient.put()
+        .uri("/api/clusters/{clusterName}/brokers/{id}/configs/{name}", LOCAL, 1, name)
+        .bodyValue(Map.of(
+            "name", name,
+            "value", "5"
+            )
+        )
+        .exchange()
+        .expectStatus().isOk();
+
+    // Without sleep it returns old config so we need to wait a little bit
+    Thread.sleep(1000);
+
+    Optional<BrokerConfig> bcc = getConfig(name);
+    assertThat(bcc.isPresent()).isTrue();
+    assertThat(bcc.get().getValue()).isEqualTo("5");
+  }
+
+  @Test
+  public void testAlterReadonlyConfig() {
+    String name = "log.dirs";
+
+    webTestClient.put()
+        .uri("/api/clusters/{clusterName}/brokers/{id}/configs/{name}", LOCAL, 1, name)
+        .bodyValue(Map.of(
+            "name", name,
+            "value", "/var/lib/kafka2"
+            )
+        )
+        .exchange()
+        .expectStatus().isBadRequest();
+  }
+
+  private Optional<BrokerConfig> getConfig(String name) {
+    List<BrokerConfig> configs = webTestClient.get()
+        .uri("/api/clusters/{clusterName}/brokers/{id}/configs", LOCAL, 1)
+        .exchange()
+        .expectStatus().isOk()
+        .expectBody(new ParameterizedTypeReference<List<BrokerConfig>>() {
+        })
+        .returnResult()
+        .getResponseBody();
+
+    return configs.stream()
+        .filter(c -> c.getName().equals(name))
+        .findAny();
+  }
+}
diff --git a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/LogDirsTest.java b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/LogDirsTest.java
index 5412086a579..2abac1e61ee 100644
--- a/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/LogDirsTest.java
+++ b/kafka-ui-api/src/test/java/com/provectus/kafka/ui/service/LogDirsTest.java
@@ -5,7 +5,6 @@
 import com.provectus.kafka.ui.AbstractBaseTest;
 import com.provectus.kafka.ui.exception.LogDirNotFoundApiException;
 import com.provectus.kafka.ui.exception.TopicOrPartitionNotFoundException;
-import com.provectus.kafka.ui.model.BrokerLogdirUpdate;
 import com.provectus.kafka.ui.model.BrokerTopicLogdirs;
 import com.provectus.kafka.ui.model.BrokersLogdirs;
 import com.provectus.kafka.ui.model.ErrorResponse;