feat: Implement dynamic tenant configuration watching and updating.
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 5m59s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 12s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 44s

This commit is contained in:
Zuev
2026-03-13 02:00:24 +03:00
parent bad1215341
commit 59caa9d6cc
6 changed files with 339 additions and 93 deletions

View File

@@ -3,8 +3,10 @@ package com.magistr.app;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication(exclude = {DataSourceAutoConfiguration.class}) @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
@EnableScheduling
public class Application { public class Application {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -1,28 +1,16 @@
package com.magistr.app.config; package com.magistr.app.config;
import com.magistr.app.config.tenant.TenantConfig; 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 com.magistr.app.config.tenant.TenantRoutingDataSource;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner; import org.springframework.boot.CommandLineRunner;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component; 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 * Делегирует инициализацию в TenantConfigWatcher.initDatabaseForTenant().
* - init.sql создаёт все таблицы + admin (admin/admin) + тестовые данные
*/ */
@Component @Component
public class DataInitializer implements CommandLineRunner { public class DataInitializer implements CommandLineRunner {
@@ -30,63 +18,22 @@ public class DataInitializer implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(DataInitializer.class); private static final Logger log = LoggerFactory.getLogger(DataInitializer.class);
private final TenantRoutingDataSource routingDataSource; 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.routingDataSource = routingDataSource;
this.dataSource = dataSource; this.configWatcher = configWatcher;
} }
@Override @Override
public void run(String... args) { public void run(String... args) {
log.info("Initializing databases for {} tenant(s)...", routingDataSource.getTenantConfigs().size());
for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) { for (TenantConfig tenant : routingDataSource.getTenantConfigs().values()) {
String domain = tenant.getDomain(); configWatcher.initDatabaseForTenant(tenant);
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();
}
}
} }
/** log.info("Database initialization complete");
* Проверяет, существует ли таблица '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"));
}
// Выполняем SQL
try (Connection conn = dataSource.getConnection();
Statement stmt = conn.createStatement()) {
stmt.execute(sql);
}
} }
} }

View File

@@ -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<TenantConfig> 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<String> 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();
}
}
}

View File

@@ -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<TenantConfig> 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<TenantConfig> newTenants) {
Map<String, TenantConfig> current = routingDataSource.getTenantConfigs();
Set<String> 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);
}
}
}

View File

@@ -2,7 +2,6 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@@ -24,7 +23,7 @@ import java.util.*;
/** /**
* Конфигурация мультитенантного DataSource. * Конфигурация мультитенантного DataSource.
* Загружает тенанты из JSON-файла и регистрирует TenantInterceptor. * Загружает тенанты из JSON-файла (mounted ConfigMap).
* *
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД * Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
* как заглушку, чтобы Spring JPA мог инициализироваться. * как заглушку, чтобы Spring JPA мог инициализироваться.
@@ -51,7 +50,7 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
public DataSource dataSource() { public DataSource dataSource() {
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource(); TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
// Загружаем тенантов из JSON // Загружаем тенантов из JSON (read-only ConfigMap mount)
List<TenantConfig> tenants = loadTenantsFromFile(); List<TenantConfig> tenants = loadTenantsFromFile();
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант // Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
@@ -147,18 +146,4 @@ public class TenantDataSourceConfig implements WebMvcConfigurer {
return new ArrayList<>(); return new ArrayList<>();
} }
} }
/**
* Сохраняет текущую конфигурацию тенантов в JSON-файл.
*/
public void saveTenantsToFile(TenantRoutingDataSource routingDataSource) {
try {
ObjectMapper mapper = new ObjectMapper();
List<TenantConfig> 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());
}
}
} }

View File

@@ -1,8 +1,9 @@
package com.magistr.app.controller; 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.TenantConfig;
import com.magistr.app.config.tenant.TenantConfigWatcher;
import com.magistr.app.config.tenant.TenantContext; import com.magistr.app.config.tenant.TenantContext;
import com.magistr.app.config.tenant.TenantDataSourceConfig;
import com.magistr.app.config.tenant.TenantRoutingDataSource; import com.magistr.app.config.tenant.TenantRoutingDataSource;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@@ -15,18 +16,26 @@ import java.util.Map;
/** /**
* API управления подключениями к базам данных (тенантами). * API управления подключениями к базам данных (тенантами).
* Доступно только для ADMIN. * Доступно только для ADMIN.
*
* При добавлении/удалении тенанта:
* 1. Обновляется in-memory DataSource (мгновенно на этом поде)
* 2. Обновляется K8s ConfigMap (через ConfigMapUpdater)
* 3. Другие поды подхватят изменения через TenantConfigWatcher (~30 сек)
*/ */
@RestController @RestController
@RequestMapping("/api/database") @RequestMapping("/api/database")
public class DatabaseController { public class DatabaseController {
private final TenantRoutingDataSource routingDataSource; private final TenantRoutingDataSource routingDataSource;
private final TenantDataSourceConfig dataSourceConfig; private final ConfigMapUpdater configMapUpdater;
private final TenantConfigWatcher configWatcher;
public DatabaseController(TenantRoutingDataSource routingDataSource, public DatabaseController(TenantRoutingDataSource routingDataSource,
TenantDataSourceConfig dataSourceConfig) { ConfigMapUpdater configMapUpdater,
TenantConfigWatcher configWatcher) {
this.routingDataSource = routingDataSource; this.routingDataSource = routingDataSource;
this.dataSourceConfig = dataSourceConfig; this.configMapUpdater = configMapUpdater;
this.configWatcher = configWatcher;
} }
/** /**
@@ -45,7 +54,7 @@ public class DatabaseController {
result.put("configured", config != null); result.put("configured", config != null);
if (config != null) { if (config != null) {
result.put("name", config.getName()); result.put("name", config.getName());
result.put("url", maskPassword(config.getUrl())); result.put("url", config.getUrl());
} }
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
@@ -61,7 +70,7 @@ public class DatabaseController {
Map<String, Object> tenant = new HashMap<>(); Map<String, Object> tenant = new HashMap<>();
tenant.put("name", config.getName()); tenant.put("name", config.getName());
tenant.put("domain", config.getDomain()); tenant.put("domain", config.getDomain());
tenant.put("url", maskPassword(config.getUrl())); tenant.put("url", config.getUrl());
tenant.put("username", config.getUsername()); tenant.put("username", config.getUsername());
tenant.put("connected", routingDataSource.testConnection(config.getDomain())); tenant.put("connected", routingDataSource.testConnection(config.getDomain()));
result.add(tenant); result.add(tenant);
@@ -90,13 +99,18 @@ public class DatabaseController {
} }
if (routingDataSource.hasTenant(config.getDomain())) { if (routingDataSource.hasTenant(config.getDomain())) {
// Обновляем существующий
routingDataSource.removeTenant(config.getDomain()); routingDataSource.removeTenant(config.getDomain());
} }
try { try {
// 1. Добавить в in-memory (мгновенно на этом поде)
routingDataSource.addTenant(config); routingDataSource.addTenant(config);
dataSourceConfig.saveTenantsToFile(routingDataSource);
// 2. Инициализировать БД (init.sql) если нужно
configWatcher.initDatabaseForTenant(config);
// 3. Обновить K8s ConfigMap (другие поды подхватят через ~30 сек)
persistToConfigMap();
result.put("success", true); result.put("success", true);
result.put("message", "Тенант '" + config.getDomain() + "' добавлен"); result.put("message", "Тенант '" + config.getDomain() + "' добавлен");
@@ -122,7 +136,7 @@ public class DatabaseController {
} }
routingDataSource.removeTenant(domain); routingDataSource.removeTenant(domain);
dataSourceConfig.saveTenantsToFile(routingDataSource); persistToConfigMap();
result.put("success", true); result.put("success", true);
result.put("message", "Тенант '" + domain + "' удалён"); result.put("message", "Тенант '" + domain + "' удалён");
@@ -154,8 +168,14 @@ public class DatabaseController {
return ResponseEntity.ok(result); return ResponseEntity.ok(result);
} }
private String maskPassword(String url) { /**
// Маскируем пароль в JDBC URL, если нужно * Сохраняет текущий список тенантов в K8s ConfigMap.
return url; */
private void persistToConfigMap() {
List<TenantConfig> tenants = new ArrayList<>(routingDataSource.getTenantConfigs().values());
boolean ok = configMapUpdater.updateTenantsConfig(tenants);
if (ok) {
configWatcher.refreshHash(); // Чтобы watcher не перезагрузил те же данные
}
} }
} }