From 59caa9d6cc2ac6128ebde066e60beaf3d156da2f Mon Sep 17 00:00:00 2001 From: Zuev Date: Fri, 13 Mar 2026 02:00:24 +0300 Subject: [PATCH] feat: Implement dynamic tenant configuration watching and updating. --- .../java/com/magistr/app/Application.java | 2 + .../magistr/app/config/DataInitializer.java | 75 ++------ .../app/config/tenant/ConfigMapUpdater.java | 127 ++++++++++++++ .../config/tenant/TenantConfigWatcher.java | 165 ++++++++++++++++++ .../config/tenant/TenantDataSourceConfig.java | 19 +- .../app/controller/DatabaseController.java | 44 +++-- 6 files changed, 339 insertions(+), 93 deletions(-) create mode 100644 backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java create mode 100644 backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java diff --git a/backend/src/main/java/com/magistr/app/Application.java b/backend/src/main/java/com/magistr/app/Application.java index 4165468..85ad604 100755 --- a/backend/src/main/java/com/magistr/app/Application.java +++ b/backend/src/main/java/com/magistr/app/Application.java @@ -3,8 +3,10 @@ package com.magistr.app; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) +@EnableScheduling public class Application { public static void main(String[] args) { diff --git a/backend/src/main/java/com/magistr/app/config/DataInitializer.java b/backend/src/main/java/com/magistr/app/config/DataInitializer.java index 4996ca9..06405e7 100755 --- a/backend/src/main/java/com/magistr/app/config/DataInitializer.java +++ b/backend/src/main/java/com/magistr/app/config/DataInitializer.java @@ -1,28 +1,16 @@ package com.magistr.app.config; import com.magistr.app.config.tenant.TenantConfig; -import com.magistr.app.config.tenant.TenantContext; +import com.magistr.app.config.tenant.TenantConfigWatcher; import com.magistr.app.config.tenant.TenantRoutingDataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.CommandLineRunner; -import org.springframework.core.io.ClassPathResource; import org.springframework.stereotype.Component; -import javax.sql.DataSource; -import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.sql.Connection; -import java.sql.ResultSet; -import java.sql.Statement; -import java.util.stream.Collectors; - /** - * При запуске приложения проверяет каждый тенант: - * - Если таблицы не существуют — выполняет init.sql - * - init.sql создаёт все таблицы + admin (admin/admin) + тестовые данные + * При запуске приложения инициализирует БД для каждого тенанта. + * Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant(). */ @Component public class DataInitializer implements CommandLineRunner { @@ -30,63 +18,22 @@ public class DataInitializer implements CommandLineRunner { private static final Logger log = LoggerFactory.getLogger(DataInitializer.class); private final TenantRoutingDataSource routingDataSource; - private final DataSource dataSource; + private final TenantConfigWatcher configWatcher; - public DataInitializer(TenantRoutingDataSource routingDataSource, DataSource dataSource) { + public DataInitializer(TenantRoutingDataSource routingDataSource, + TenantConfigWatcher configWatcher) { this.routingDataSource = routingDataSource; - this.dataSource = dataSource; + this.configWatcher = configWatcher; } @Override public void run(String... args) { + log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size()); + for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) { - String domain = tenant.getDomain(); - try { - TenantContext.setCurrentTenant(domain); - - if (needsInit()) { - log.info("[{}] Tables not found — executing init.sql...", domain); - executeInitSql(); - log.info("[{}] init.sql executed successfully", domain); - } else { - log.info("[{}] Tables already exist, skipping init", domain); - } - } catch (Exception e) { - log.error("[{}] Initialization failed: {}", domain, e.getMessage()); - } finally { - TenantContext.clear(); - } - } - } - - /** - * Проверяет, существует ли таблица 'users' в текущей БД тенанта. - */ - private boolean needsInit() { - try (Connection conn = dataSource.getConnection(); - ResultSet rs = conn.getMetaData().getTables(null, null, "users", new String[]{"TABLE"})) { - return !rs.next(); - } catch (Exception e) { - log.warn("Could not check tables: {}", e.getMessage()); - return true; // Если не смогли проверить — пробуем init - } - } - - /** - * Читает init.sql из classpath и выполняет его через JDBC. - */ - private void executeInitSql() throws Exception { - // Читаем SQL файл из ресурсов - String sql; - try (InputStream is = new ClassPathResource("init.sql").getInputStream(); - BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { - sql = reader.lines().collect(Collectors.joining("\n")); + configWatcher.initDatabaseForTenant(tenant); } - // Выполняем SQL - try (Connection conn = dataSource.getConnection(); - Statement stmt = conn.createStatement()) { - stmt.execute(sql); - } + log.info("Database initialization complete"); } } diff --git a/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java new file mode 100644 index 0000000..023c734 --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/ConfigMapUpdater.java @@ -0,0 +1,127 @@ +package com.magistr.app.config.tenant; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.SecureRandom; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.Map; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +/** + * Обновляет K8s ConfigMap tenants-config через Kubernetes REST API. + * + * Работает ТОЛЬКО внутри K8s пода (использует ServiceAccount token). + * При запуске вне K8s (локальная разработка) — просто логирует предупреждение. + */ +@Service +public class ConfigMapUpdater { + + private static final Logger log = LoggerFactory.getLogger(ConfigMapUpdater.class); + + private static final String TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token"; + private static final String NAMESPACE_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"; + private static final String K8S_API_BASE = "https://kubernetes.default.svc"; + private static final String CONFIGMAP_NAME = "tenants-config"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final boolean runningInK8s; + + public ConfigMapUpdater() { + this.runningInK8s = Files.exists(Path.of(TOKEN_PATH)); + if (!runningInK8s) { + log.info("Not running in K8s — ConfigMap updates will be skipped"); + } + } + + /** + * Обновляет ConfigMap tenants-config с новым списком тенантов. + * @return true если обновление успешно (или мы не в K8s) + */ + public boolean updateTenantsConfig(List tenants) { + if (!runningInK8s) { + log.warn("Not in K8s, skipping ConfigMap update"); + return true; + } + + try { + String token = Files.readString(Path.of(TOKEN_PATH)).trim(); + String namespace = Files.readString(Path.of(NAMESPACE_PATH)).trim(); + + // Формируем JSON для тенантов + String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter() + .writeValueAsString(tenants); + + // Strategic merge patch для ConfigMap + String patchBody = objectMapper.writeValueAsString(Map.of( + "data", Map.of("tenants.json", tenantsJson) + )); + + String url = String.format("%s/api/v1/namespaces/%s/configmaps/%s", + K8S_API_BASE, namespace, CONFIGMAP_NAME); + + // Создаём HttpClient с отключённой проверкой сертификатов + // (внутри кластера используется self-signed CA) + HttpClient client = createInsecureClient(); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Authorization", "Bearer " + token) + .header("Content-Type", "application/strategic-merge-patch+json") + .method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody)) + .build(); + + HttpResponse response = client.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == 200) { + log.info("ConfigMap '{}' updated successfully ({} tenants)", CONFIGMAP_NAME, tenants.size()); + return true; + } else { + log.error("Failed to update ConfigMap: HTTP {} — {}", response.statusCode(), response.body()); + return false; + } + + } catch (Exception e) { + log.error("Error updating ConfigMap: {}", e.getMessage()); + return false; + } + } + + /** + * Создаёт HttpClient, который доверяет self-signed сертификатам K8s API. + */ + private HttpClient createInsecureClient() { + try { + TrustManager[] trustAll = new TrustManager[]{ + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { return new X509Certificate[0]; } + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + public void checkServerTrusted(X509Certificate[] certs, String authType) {} + } + }; + + SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(null, trustAll, new SecureRandom()); + + return HttpClient.newBuilder() + .sslContext(sslContext) + .build(); + } catch (Exception e) { + log.warn("Failed to create insecure client, using default: {}", e.getMessage()); + return HttpClient.newHttpClient(); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java new file mode 100644 index 0000000..fa673da --- /dev/null +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java @@ -0,0 +1,165 @@ +package com.magistr.app.config.tenant; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ClassPathResource; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import javax.sql.DataSource; +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.Statement; +import java.util.*; +import java.util.stream.Collectors; + +/** + * Периодически перечитывает tenants.json (mounted ConfigMap). + * Если ConfigMap был обновлён через K8s API, этот компонент + * подхватит изменения и синхронизирует in-memory datasource'ы. + * + * Также отвечает за инициализацию БД (init.sql) для новых тенантов. + */ +@Component +public class TenantConfigWatcher { + + private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class); + + private final TenantRoutingDataSource routingDataSource; + private final DataSource dataSource; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${app.tenants.config-path:tenants.json}") + private String tenantsConfigPath; + + // Хеш последнего прочитанного конфига — чтобы не перезагружать зря + private String lastConfigHash = ""; + + public TenantConfigWatcher(TenantRoutingDataSource routingDataSource, DataSource dataSource) { + this.routingDataSource = routingDataSource; + this.dataSource = dataSource; + } + + /** + * Каждые 30 секунд проверяет, изменился ли tenants.json. + */ + @Scheduled(fixedDelay = 30_000, initialDelay = 30_000) + public void watchForChanges() { + try { + File file = new File(tenantsConfigPath); + if (!file.exists()) return; + + String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + String hash = Integer.toHexString(content.hashCode()); + + if (hash.equals(lastConfigHash)) { + return; // Ничего не изменилось + } + + log.info("Detected tenants.json change (hash: {} -> {}), reloading...", lastConfigHash, hash); + lastConfigHash = hash; + + List newTenants = objectMapper.readValue(content, new TypeReference<>() {}); + syncTenants(newTenants); + + } catch (Exception e) { + log.error("Error watching tenants config: {}", e.getMessage()); + } + } + + /** + * Обновляет хеш конфига (вызывается после ручного обновления ConfigMap с этого же пода). + */ + public void refreshHash() { + try { + File file = new File(tenantsConfigPath); + if (file.exists()) { + String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + lastConfigHash = Integer.toHexString(content.hashCode()); + } + } catch (Exception e) { + log.warn("Failed to refresh config hash: {}", e.getMessage()); + } + } + + /** + * Синхронизирует in-memory тенантов с конфигом из файла. + */ + private void syncTenants(List newTenants) { + Map current = routingDataSource.getTenantConfigs(); + Set newDomains = newTenants.stream() + .map(t -> t.getDomain().toLowerCase()) + .collect(Collectors.toSet()); + + // Добавить новые тенанты + for (TenantConfig tenant : newTenants) { + String domain = tenant.getDomain().toLowerCase(); + if (!current.containsKey(domain)) { + log.info("Adding new tenant '{}' from ConfigMap update", domain); + routingDataSource.addTenant(tenant); + // Инициализируем БД для нового тенанта + initDatabaseForTenant(tenant); + } + } + + // Удалить тенанты, которых больше нет в конфиге + for (String existingDomain : new ArrayList<>(current.keySet())) { + if (!newDomains.contains(existingDomain)) { + log.info("Removing tenant '{}' (no longer in ConfigMap)", existingDomain); + routingDataSource.removeTenant(existingDomain); + } + } + } + + /** + * Инициализирует БД нового тенанта: проверяет наличие таблиц, + * если нет — выполняет init.sql. + */ + public void initDatabaseForTenant(TenantConfig tenant) { + String domain = tenant.getDomain(); + try { + TenantContext.setCurrentTenant(domain); + + if (needsInit()) { + log.info("[{}] Tables not found — executing init.sql...", domain); + executeInitSql(); + log.info("[{}] init.sql executed successfully", domain); + } else { + log.info("[{}] Tables already exist, skipping init", domain); + } + } catch (Exception e) { + log.error("[{}] DB init failed: {}", domain, e.getMessage()); + } finally { + TenantContext.clear(); + } + } + + private boolean needsInit() { + try (Connection conn = dataSource.getConnection(); + ResultSet rs = conn.getMetaData().getTables(null, null, "users", new String[]{"TABLE"})) { + return !rs.next(); + } catch (Exception e) { + return true; + } + } + + private void executeInitSql() throws Exception { + String sql; + try (InputStream is = new ClassPathResource("init.sql").getInputStream(); + BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) { + sql = reader.lines().collect(Collectors.joining("\n")); + } + try (Connection conn = dataSource.getConnection(); + Statement stmt = conn.createStatement()) { + stmt.execute(sql); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java index 2fe64c9..a2eb55d 100755 --- a/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java +++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantDataSourceConfig.java @@ -2,7 +2,6 @@ package com.magistr.app.config.tenant; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; -import com.zaxxer.hikari.HikariDataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -24,7 +23,7 @@ import java.util.*; /** * Конфигурация мультитенантного DataSource. - * Загружает тенанты из JSON-файла и регистрирует TenantInterceptor. + * Загружает тенанты из JSON-файла (mounted ConfigMap). * * Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД * как заглушку, чтобы Spring JPA мог инициализироваться. @@ -51,7 +50,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { public DataSource dataSource() { TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource(); - // Загружаем тенантов из JSON + // Загружаем тенантов из JSON (read-only ConfigMap mount) List tenants = loadTenantsFromFile(); // Если нет тенантов и есть дефолтный datasource — создаём "default" тенант @@ -147,18 +146,4 @@ public class TenantDataSourceConfig implements WebMvcConfigurer { return new ArrayList<>(); } } - - /** - * Сохраняет текущую конфигурацию тенантов в JSON-файл. - */ - public void saveTenantsToFile(TenantRoutingDataSource routingDataSource) { - try { - ObjectMapper mapper = new ObjectMapper(); - List configs = new ArrayList<>(routingDataSource.getTenantConfigs().values()); - mapper.writerWithDefaultPrettyPrinter().writeValue(new File(tenantsConfigPath), configs); - log.info("Saved {} tenant(s) to {}", configs.size(), tenantsConfigPath); - } catch (IOException e) { - log.error("Failed to save tenants config: {}", e.getMessage()); - } - } } diff --git a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java index 74cb460..adb5727 100755 --- a/backend/src/main/java/com/magistr/app/controller/DatabaseController.java +++ b/backend/src/main/java/com/magistr/app/controller/DatabaseController.java @@ -1,8 +1,9 @@ package com.magistr.app.controller; +import com.magistr.app.config.tenant.ConfigMapUpdater; import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantConfigWatcher; import com.magistr.app.config.tenant.TenantContext; -import com.magistr.app.config.tenant.TenantDataSourceConfig; import com.magistr.app.config.tenant.TenantRoutingDataSource; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -15,18 +16,26 @@ import java.util.Map; /** * API управления подключениями к базам данных (тенантами). * Доступно только для ADMIN. + * + * При добавлении/удалении тенанта: + * 1. Обновляется in-memory DataSource (мгновенно на этом поде) + * 2. Обновляется K8s ConfigMap (через ConfigMapUpdater) + * 3. Другие поды подхватят изменения через TenantConfigWatcher (~30 сек) */ @RestController @RequestMapping("/api/database") public class DatabaseController { private final TenantRoutingDataSource routingDataSource; - private final TenantDataSourceConfig dataSourceConfig; + private final ConfigMapUpdater configMapUpdater; + private final TenantConfigWatcher configWatcher; public DatabaseController(TenantRoutingDataSource routingDataSource, - TenantDataSourceConfig dataSourceConfig) { + ConfigMapUpdater configMapUpdater, + TenantConfigWatcher configWatcher) { this.routingDataSource = routingDataSource; - this.dataSourceConfig = dataSourceConfig; + this.configMapUpdater = configMapUpdater; + this.configWatcher = configWatcher; } /** @@ -45,7 +54,7 @@ public class DatabaseController { result.put("configured", config != null); if (config != null) { result.put("name", config.getName()); - result.put("url", maskPassword(config.getUrl())); + result.put("url", config.getUrl()); } return ResponseEntity.ok(result); } @@ -61,7 +70,7 @@ public class DatabaseController { Map tenant = new HashMap<>(); tenant.put("name", config.getName()); tenant.put("domain", config.getDomain()); - tenant.put("url", maskPassword(config.getUrl())); + tenant.put("url", config.getUrl()); tenant.put("username", config.getUsername()); tenant.put("connected", routingDataSource.testConnection(config.getDomain())); result.add(tenant); @@ -90,13 +99,18 @@ public class DatabaseController { } if (routingDataSource.hasTenant(config.getDomain())) { - // Обновляем существующий routingDataSource.removeTenant(config.getDomain()); } try { + // 1. Добавить в in-memory (мгновенно на этом поде) routingDataSource.addTenant(config); - dataSourceConfig.saveTenantsToFile(routingDataSource); + + // 2. Инициализировать БД (init.sql) если нужно + configWatcher.initDatabaseForTenant(config); + + // 3. Обновить K8s ConfigMap (другие поды подхватят через ~30 сек) + persistToConfigMap(); result.put("success", true); result.put("message", "Тенант '" + config.getDomain() + "' добавлен"); @@ -122,7 +136,7 @@ public class DatabaseController { } routingDataSource.removeTenant(domain); - dataSourceConfig.saveTenantsToFile(routingDataSource); + persistToConfigMap(); result.put("success", true); result.put("message", "Тенант '" + domain + "' удалён"); @@ -154,8 +168,14 @@ public class DatabaseController { return ResponseEntity.ok(result); } - private String maskPassword(String url) { - // Маскируем пароль в JDBC URL, если нужно - return url; + /** + * Сохраняет текущий список тенантов в K8s ConfigMap. + */ + private void persistToConfigMap() { + List tenants = new ArrayList<>(routingDataSource.getTenantConfigs().values()); + boolean ok = configMapUpdater.updateTenantsConfig(tenants); + if (ok) { + configWatcher.refreshHash(); // Чтобы watcher не перезагрузил те же данные + } } }