DynamicConfigOperations.java 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240
  1. package com.provectus.kafka.ui.util;
  2. import com.provectus.kafka.ui.config.ClustersProperties;
  3. import com.provectus.kafka.ui.config.WebclientProperties;
  4. import com.provectus.kafka.ui.config.auth.OAuthProperties;
  5. import com.provectus.kafka.ui.config.auth.RoleBasedAccessControlProperties;
  6. import com.provectus.kafka.ui.exception.FileUploadException;
  7. import com.provectus.kafka.ui.exception.ValidationException;
  8. import java.io.IOException;
  9. import java.nio.file.Files;
  10. import java.nio.file.Path;
  11. import java.nio.file.Paths;
  12. import java.nio.file.StandardOpenOption;
  13. import java.time.Instant;
  14. import java.util.Optional;
  15. import javax.annotation.Nullable;
  16. import lombok.Builder;
  17. import lombok.Data;
  18. import lombok.RequiredArgsConstructor;
  19. import lombok.SneakyThrows;
  20. import lombok.extern.slf4j.Slf4j;
  21. import org.springframework.beans.factory.NoSuchBeanDefinitionException;
  22. import org.springframework.boot.env.YamlPropertySourceLoader;
  23. import org.springframework.context.ApplicationContextInitializer;
  24. import org.springframework.context.ConfigurableApplicationContext;
  25. import org.springframework.core.env.CompositePropertySource;
  26. import org.springframework.core.env.PropertySource;
  27. import org.springframework.core.io.FileSystemResource;
  28. import org.springframework.http.codec.multipart.FilePart;
  29. import org.springframework.stereotype.Component;
  30. import org.yaml.snakeyaml.DumperOptions;
  31. import org.yaml.snakeyaml.Yaml;
  32. import org.yaml.snakeyaml.introspector.BeanAccess;
  33. import org.yaml.snakeyaml.introspector.Property;
  34. import org.yaml.snakeyaml.introspector.PropertyUtils;
  35. import org.yaml.snakeyaml.nodes.NodeTuple;
  36. import org.yaml.snakeyaml.nodes.Tag;
  37. import org.yaml.snakeyaml.representer.Representer;
  38. import reactor.core.publisher.Mono;
  39. @Slf4j
  40. @RequiredArgsConstructor
  41. @Component
  42. public class DynamicConfigOperations {
  43. static final String DYNAMIC_CONFIG_ENABLED_ENV_PROPERTY = "dynamic.config.enabled";
  44. static final String DYNAMIC_CONFIG_PATH_ENV_PROPERTY = "dynamic.config.path";
  45. static final String DYNAMIC_CONFIG_PATH_ENV_PROPERTY_DEFAULT = "/etc/kafkaui/dynamic_config.yaml";
  46. static final String CONFIG_RELATED_UPLOADS_DIR_PROPERTY = "config.related.uploads.dir";
  47. static final String CONFIG_RELATED_UPLOADS_DIR_DEFAULT = "/etc/kafkaui/uploads";
  48. public static ApplicationContextInitializer<ConfigurableApplicationContext> dynamicConfigPropertiesInitializer() {
  49. return appCtx ->
  50. new DynamicConfigOperations(appCtx)
  51. .loadDynamicPropertySource()
  52. .ifPresent(source -> appCtx.getEnvironment().getPropertySources().addFirst(source));
  53. }
  54. private final ConfigurableApplicationContext ctx;
  55. public boolean dynamicConfigEnabled() {
  56. return "true".equalsIgnoreCase(ctx.getEnvironment().getProperty(DYNAMIC_CONFIG_ENABLED_ENV_PROPERTY));
  57. }
  58. private Path dynamicConfigFilePath() {
  59. return Paths.get(
  60. Optional.ofNullable(ctx.getEnvironment().getProperty(DYNAMIC_CONFIG_PATH_ENV_PROPERTY))
  61. .orElse(DYNAMIC_CONFIG_PATH_ENV_PROPERTY_DEFAULT)
  62. );
  63. }
  64. @SneakyThrows
  65. public Optional<PropertySource<?>> loadDynamicPropertySource() {
  66. if (dynamicConfigEnabled()) {
  67. Path configPath = dynamicConfigFilePath();
  68. if (!Files.exists(configPath) || !Files.isReadable(configPath)) {
  69. log.warn("Dynamic config file {} doesnt exist or not readable", configPath);
  70. return Optional.empty();
  71. }
  72. var propertySource = new CompositePropertySource("dynamicProperties");
  73. new YamlPropertySourceLoader()
  74. .load("dynamicProperties", new FileSystemResource(configPath))
  75. .forEach(propertySource::addPropertySource);
  76. log.info("Dynamic config loaded from {}", configPath);
  77. return Optional.of(propertySource);
  78. }
  79. return Optional.empty();
  80. }
  81. public PropertiesStructure getCurrentProperties() {
  82. checkIfDynamicConfigEnabled();
  83. return PropertiesStructure.builder()
  84. .kafka(getNullableBean(ClustersProperties.class))
  85. .rbac(getNullableBean(RoleBasedAccessControlProperties.class))
  86. .auth(
  87. PropertiesStructure.Auth.builder()
  88. .type(ctx.getEnvironment().getProperty("auth.type"))
  89. .oauth2(getNullableBean(OAuthProperties.class))
  90. .build())
  91. .webclient(getNullableBean(WebclientProperties.class))
  92. .build();
  93. }
  94. @Nullable
  95. private <T> T getNullableBean(Class<T> clazz) {
  96. try {
  97. return ctx.getBean(clazz);
  98. } catch (NoSuchBeanDefinitionException nsbde) {
  99. return null;
  100. }
  101. }
  102. public void persist(PropertiesStructure properties) {
  103. checkIfDynamicConfigEnabled();
  104. properties.initAndValidate();
  105. String yaml = serializeToYaml(properties);
  106. writeYamlToFile(yaml, dynamicConfigFilePath());
  107. }
  108. public Mono<Path> uploadConfigRelatedFile(FilePart file) {
  109. checkIfDynamicConfigEnabled();
  110. String targetDirStr = ctx.getEnvironment()
  111. .getProperty(CONFIG_RELATED_UPLOADS_DIR_PROPERTY, CONFIG_RELATED_UPLOADS_DIR_DEFAULT);
  112. Path targetDir = Path.of(targetDirStr);
  113. if (!Files.exists(targetDir)) {
  114. try {
  115. Files.createDirectories(targetDir);
  116. } catch (IOException e) {
  117. return Mono.error(
  118. new FileUploadException("Error creating directory for uploads %s".formatted(targetDir), e));
  119. }
  120. }
  121. Path targetFilePath = targetDir.resolve(file.filename() + "-" + Instant.now().getEpochSecond());
  122. log.info("Uploading config-related file {}", targetFilePath);
  123. if (Files.exists(targetFilePath)) {
  124. log.info("File {} already exists, it will be overwritten", targetFilePath);
  125. }
  126. return file.transferTo(targetFilePath)
  127. .thenReturn(targetFilePath)
  128. .doOnError(th -> log.error("Error uploading file {}", targetFilePath, th))
  129. .onErrorMap(th -> new FileUploadException(targetFilePath, th));
  130. }
  131. private void checkIfDynamicConfigEnabled() {
  132. if (!dynamicConfigEnabled()) {
  133. throw new ValidationException(
  134. "Dynamic config change is not allowed. "
  135. + "Set dynamic.config.enabled property to 'true' to enabled it.");
  136. }
  137. }
  138. @SneakyThrows
  139. private void writeYamlToFile(String yaml, Path path) {
  140. if (Files.isDirectory(path)) {
  141. throw new ValidationException("Dynamic file path is a directory, but should be a file path");
  142. }
  143. if (!Files.exists(path.getParent())) {
  144. Files.createDirectories(path.getParent());
  145. }
  146. if (Files.exists(path) && !Files.isWritable(path)) {
  147. throw new ValidationException("File already exists and is not writable");
  148. }
  149. try {
  150. Files.writeString(
  151. path,
  152. yaml,
  153. StandardOpenOption.CREATE,
  154. StandardOpenOption.WRITE,
  155. StandardOpenOption.TRUNCATE_EXISTING // to override existing file
  156. );
  157. } catch (IOException e) {
  158. throw new ValidationException("Error writing to " + path, e);
  159. }
  160. }
  161. private String serializeToYaml(PropertiesStructure props) {
  162. //representer, that skips fields with null values
  163. Representer representer = new Representer(new DumperOptions()) {
  164. @Override
  165. protected NodeTuple representJavaBeanProperty(Object javaBean,
  166. Property property,
  167. Object propertyValue,
  168. Tag customTag) {
  169. if (propertyValue == null) {
  170. return null; // if value of property is null, ignore it.
  171. } else {
  172. return super.representJavaBeanProperty(javaBean, property, propertyValue, customTag);
  173. }
  174. }
  175. };
  176. var propertyUtils = new PropertyUtils();
  177. propertyUtils.setBeanAccess(BeanAccess.FIELD);
  178. representer.setPropertyUtils(propertyUtils);
  179. representer.addClassTag(PropertiesStructure.class, Tag.MAP); //to avoid adding class tag
  180. representer.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); //use indent instead of {}
  181. return new Yaml(representer).dump(props);
  182. }
  183. ///---------------------------------------------------------------------
  184. @Data
  185. @Builder
  186. // field name should be in sync with @ConfigurationProperties annotation
  187. public static class PropertiesStructure {
  188. private ClustersProperties kafka;
  189. private RoleBasedAccessControlProperties rbac;
  190. private Auth auth;
  191. private WebclientProperties webclient;
  192. @Data
  193. @Builder
  194. public static class Auth {
  195. String type;
  196. OAuthProperties oauth2;
  197. }
  198. public void initAndValidate() {
  199. Optional.ofNullable(kafka)
  200. .ifPresent(ClustersProperties::validateAndSetDefaults);
  201. Optional.ofNullable(rbac)
  202. .ifPresent(RoleBasedAccessControlProperties::init);
  203. Optional.ofNullable(auth)
  204. .flatMap(a -> Optional.ofNullable(a.oauth2))
  205. .ifPresent(OAuthProperties::validate);
  206. Optional.ofNullable(webclient)
  207. .ifPresent(WebclientProperties::validate);
  208. }
  209. }
  210. }