diff --git a/BUG_FIX_PROGRESS.md b/BUG_FIX_PROGRESS.md
index e03bdcf..430a417 100644
--- a/BUG_FIX_PROGRESS.md
+++ b/BUG_FIX_PROGRESS.md
@@ -1,6 +1,6 @@
# Журнал исправления проблем
-Обновлено: 2026-07-13 (Europe/Moscow).
+Обновлено: 2026-07-15 (Europe/Moscow).
## Область и неизменяемые данные
@@ -54,8 +54,8 @@
| 6 | исправлено и проверено | Teacher-only поиск загружал только базовые правила исходного преподавателя до применения override. | Один snapshot overrides; релевантные `newTeacher`-замены достраивают exact base occurrences по уникальным датам, затем применяются overrides, финальный teacher-фильтр и deduplicate. | 5 новых A→B/phantom/cache/dedup/regression тестов; узкий прогон 10/0/0/0, полный backend 79/0/0/0. |
| 7 | исправлено и проверено | Контроллер сравнивал новые слоты только с сохранёнными правилами, не проверял роль преподавателя и enum формата, а нечётные часы приводили к перерасходу генератора. | `ScheduleRuleService` валидирует весь кандидат до мутации, попарно проверяет дубли/ресурсы, требует `TEACHER`, `Очно`/`Онлайн` и чётные часы; строка семестра сериализует запись. V4 закрепляет чётность и точный дубль в БД. | 23 service/MockMvc/Flyway/PostgreSQL tests; полный backend: 102/0/0/0. |
| 8 | исправлено и проверено | `saveGrid` удалял данные до полной валидации и ловил исключение внутри transactional controller-метода, поэтому Spring коммитил delete и уже обработанные строки. | `AcademicCalendarGridService` полностью валидирует и строит replacement до delete, атомарно заменяет строки и очищает кэш через `afterCommit`; контроллер стал HTTP-адаптером. | 8 unit + 1 PostgreSQL proxy test; fingerprint старой сетки после отказа неизменен; полный backend 111/0/0/0. |
-| 9 | в работе | При update старый pool закрывается до создания нового; Hikari допускает ленивый нерабочий pool, Flyway проглатывает ошибку, а отказ Secret-персистенции оставляет локальное состояние изменённым. | Реализуются prepare/validate/migrate кандидата, persistence до atomic swap и компенсация Secret при ошибке активации. | Готовятся unit и PostgreSQL lifecycle tests для credentials/migration/persistence/swap. |
-| 10 | не начато | Два pod перезаписывают целый tenant-документ без `resourceVersion`; TCP probes не отражают готовность tenant-БД. | Optimistic locking с retry; отдельные liveness/readiness semantics. | Конкурентные PATCH и health tests; Kustomize-проверка probes. |
+| 9 | исправлено и проверено | При update старый pool закрывался до создания нового; Hikari допускал ленивый нерабочий pool, Flyway проглатывал ошибку, а отказ persistence мог сочетаться с изменённым локальным состоянием. | `TenantLifecycleService` выполняет prepare → connection validation → Flyway → Secret persistence → atomic snapshot swap; candidate закрывается при отказе, прежний route сохраняется, Secret компенсируется, старый pool закрывается после drain. | 18 целевых unit/MockMvc/PostgreSQL tests; create/update, credentials, invalid connection, Flyway, persistence, swap, HTTP `503` и in-flight connection. Полный backend: 132/0/0/0. |
+| 10 | требуется внешнее действие | Два pod перезаписывали целый tenant-документ без `resourceVersion`; TCP probes не отражали готовность tenant-БД. | Backend применяет доменные upsert/remove через GET → условный PUT по `resourceVersion`, ограниченный retry и безопасную компенсацию; Actuator разделяет process-only liveness и readiness обязательных tenant-БД. Оператору остаются RBAC `get/update`, mount без `subPath`, `TENANTS_CONFIG_REQUIRED=true` и HTTP probes в отсутствующем `../k8s`. | 64 целевых теста; полный backend 169/0/0/0; LF-хеши V1–V4 совпадают. Kustomize не выполнен: `../k8s` отсутствует. |
| 11 | не начато | Sunday вычисляется повторным мутированием даты; ошибки запросов превращаются в пустые результаты и зелёный статус. | Чистая local-date утилита и три явных состояния проверки. | 2026-07-02, переход года, частичная/полная ошибка API. |
| 12 | не начато | Access JWT хранится в Web Storage; origin исполняет удалённые модули; CSP допускает несовместимые сценарии. | Access JWT только в памяти, восстановление через HttpOnly refresh-cookie; локальные pinned assets; строгий CSP и удаление inline JS. | Login/refresh/logout/reload tests; статический аудит CSP/storage/imports. |
| 13 | не начато | Watcher сравнивает только домены и фиксирует hash до успешной полной синхронизации. | Нормализованное сравнение полного `TenantConfig`, hash после успеха, bounded backoff. | Изменение URL/credentials и retry после ошибки. |
@@ -261,18 +261,100 @@
| Контейнерный полный `mvn test` | 111 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
| `git diff --check` | Успешно. |
+### № 9 — безопасная замена tenant DataSource
+
+- `DatabaseController` делегирует create/update/delete в единый `TenantLifecycleService` и
+ возвращает lifecycle-ошибки как русский безопасный `503`, а не ложный `200` или raw
+ JDBC/Flyway message.
+- Новый Hikari candidate не публикуется в маршрутизации до явного `getConnection()` /
+ `isValid(5)`, успешного Flyway и подтверждённой доменной мутации в актуальном
+ `tenants-secret`.
+- `TenantRoutingDataSource` одной volatile-публикацией заменяет неизменяемый снимок
+ `TenantConfig + DataSource`; прежняя конфигурация и pool остаются рабочими при ошибке
+ credentials, миграции, persistence или доатомарной активации.
+- Неуспешная persistence не меняет локальный route. Неопределённый результат записи сначала
+ сверяется повторным чтением и не даёт права на опасный откат. Если swap/remove отказывает
+ после подтверждённой записи, прежний снимок восстанавливается только при точном совпадении
+ `resourceVersion`; более новое изменение другого pod не перезаписывается.
+- После успешного swap старый pool не закрывается синхронно: `RetiredTenantPoolService`
+ прекращает новые маршруты, сохраняет уже выданные соединения и закрывает Hikari после
+ drain либо grace timeout.
+- Unit-тесты дополнены отдельной create-веткой, восстановлением снимка без нового domain,
+ `Connection.isValid(false)` и MockMvc-проверкой `503` без технических деталей.
+- PostgreSQL 16.3/Testcontainers-тест подтверждает сохранность старого route при неверном
+ пароле, повреждённом Flyway checksum и отказе persistence, работоспособность in-flight
+ соединения во время swap и последующий drain старого pool.
+- `docs/API.md`, `docs/ARCHITECTURE.md` и `docs/INFRASTRUCTURE.md` синхронизированы через
+ AutoUpdateDocs; исправлено устаревшее описание порядка «in-memory до Secret».
+
+Фактические проверки этапа:
+
+| Команда | Результат |
+|---|---|
+| Контейнерный целевой `mvn -Dtest=TenantLifecycleServiceTest,TenantLifecyclePostgreSqlIntegrationTest,TenantRoutingDataSourceTest,DatabaseControllerTest test` | 18 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
+| Контейнерный полный `mvn test` | 132 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
+| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. |
+
+### № 10 — межподовая координация tenant-конфигурации и health probes
+
+- Введён `TenantConfigStore` с явными операциями upsert/remove. Kubernetes-реализация перед
+ каждой мутацией читает актуальный `tenants-secret`, нормализует домены и отправляет полный
+ объект условным `PUT` с прочитанным `metadata.resourceVersion`.
+- `409`, `408`, `429`, `5xx` и транспортные ошибки обрабатываются ограниченно: максимум три
+ попытки с возрастающей задержкой и повторным применением только собственной доменной
+ мутации к свежему состоянию. Идемпотентная операция не выполняет `PUT`.
+- Успех записи и компенсации больше не определяется одним HTTP 2xx: backend разбирает
+ возвращённый Secret, сравнивает `tenants.json` с ожидаемым и использует `resourceVersion`
+ только проверенного объекта. Пустой, неполный или неоднозначный ответ сверяется повторным
+ `GET` без выдачи квитанции, разрешающей небезопасный откат.
+- Компенсация выполняется только для подтверждённой собственной версии. Если текущий Secret
+ уже получил другой `resourceVersion`, откат прекращается и не затирает изменение другого
+ pod. Отсутствующий `data.tenants.json`, повторяющиеся домены и Kubernetes mode без
+ ServiceAccount token обрабатываются fail-closed.
+- Добавлен Spring Boot Actuator. `/actuator/health/liveness` зависит только от жизнеспособности
+ процесса; `/actuator/health/readiness` требует непустой набор обязательных tenant, успешную
+ миграцию и свежую успешную проверку каждого соединения. H2-заглушка, ошибка обязательного
+ конфига, migration failure, timeout, недоступность или просроченная проверка дают `503`.
+- `TenantDatabaseHealthMonitor` проверяет tenant-БД ограниченно-параллельно в фоне; endpoint
+ читает только атомарный кэш. Общий deadline отменяет лишь незавершённые задачи, уже
+ завершившиеся результаты не теряются и получают фактическое время проверки. Scheduler
+ имеет два потока, поэтому долгий health-pass не блокирует watcher.
+- `TenantDataSourceConfig` и watcher поддерживают `TENANTS_CONFIG_REQUIRED=true`: отсутствие,
+ пустой список или ошибка чтения production-файла фиксируют configuration failure, а H2 не
+ маскирует отказ. Actuator endpoints исключены из tenant-interceptor и не раскрывают
+ components, домены, JDBC URL или credentials.
+- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и
+ `docs/INFRASTRUCTURE.md`. Ограничение watcher по обновлению URL/credentials существующего
+ домена явно оставлено проблеме № 13.
+- В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись
+ и Kustomize не запускался. Для production обязательны directory mount без `subPath`,
+ `TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness /
+ readiness probes; применение и rollout остаются внешними действиями оператора.
+
+Фактические проверки этапа:
+
+| Команда | Результат |
+|---|---|
+| `KubernetesTenantSecretUpdaterTest` | 21 тест, 0 failures, 0 errors, 0 skipped; два конкурентных updater, CAS conflict/retry, 408/5xx reconciliation, проверка 2xx и безопасная компенсация. |
+| Health/startup target (`TenantDataSourceConfigTest`, registry/monitor/indicator, Actuator, MVC exclusion) | 19 тестов, 0 failures, 0 errors, 0 skipped. |
+| Lifecycle/controller/watcher target | 24 теста, 0 failures, 0 errors, 0 skipped; PostgreSQL lifecycle отдельно повторён успешно после единичной Docker-сетевой флуктуации. |
+| Контейнерный полный `mvn test` | 169 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 40 suites. |
+| `git diff --check` | Успешно. |
+| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. |
+| `kubectl kustomize ../k8s` | Не выполнено: внешний каталог `../k8s` отсутствует в текущем workspace. |
+
## Точка продолжения
-Текущий этап: **№ 9 — безопасная замена tenant DataSource**.
+Текущий этап: **№ 13 — полная синхронизация изменённых tenant credentials/URL watcher-ом**.
Следующая операция:
-1. проверить актуальные create/update потоки `DatabaseController`, swap/remove API
- `TenantRoutingDataSource`, Flyway и персистенцию tenant-конфигурации;
-2. определить порядок prepare → connection validation → Flyway → persistence → atomic swap,
- не закрывающий рабочий pool до полной готовности кандидата;
-3. добавить компенсирующий rollback для ошибок персистенции/активации и явные русские
- ошибки без ложного `200`;
-4. покрыть неверные credentials, ошибку миграции и ошибку персистенции тестами, доказывающими
- сохранность старого подключения;
-5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 9.
+1. сравнивать полную нормализованную активную конфигурацию с persisted snapshot, а не только
+ набор доменов;
+2. при изменении URL/username/password выполнять prepare → verify → Flyway → atomic swap без
+ повторной записи Secret;
+3. считать hash применённым только после полной успешной синхронизации и не подтверждать
+ произвольное содержимое mount через `refreshHash()` после API-мутации;
+4. добавить bounded retry/backoff и тесты изменения credentials, восстановления прежнего
+ файла после ошибки и межподового merged snapshot;
+5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 13.
diff --git a/backend/pom.xml b/backend/pom.xml
index 2cdbae9..4f10d8a 100755
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -27,6 +27,12 @@
spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
org.springframework.boot
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdater.java b/backend/src/main/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdater.java
index 0a6cf61..08d4ad7 100644
--- a/backend/src/main/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdater.java
+++ b/backend/src/main/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdater.java
@@ -1,6 +1,9 @@
package com.magistr.app.config.tenant;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.fasterxml.jackson.databind.node.ObjectNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
@@ -8,6 +11,7 @@ import org.springframework.stereotype.Service;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.TrustManagerFactory;
+import java.io.IOException;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
@@ -19,19 +23,29 @@ import java.nio.file.Path;
import java.security.KeyStore;
import java.security.cert.Certificate;
import java.security.cert.CertificateFactory;
+import java.time.Duration;
+import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
+import java.util.Comparator;
+import java.util.HashSet;
import java.util.List;
-import java.util.Map;
+import java.util.Locale;
+import java.util.Objects;
+import java.util.Set;
+import java.util.function.Function;
+import java.util.regex.Pattern;
/**
- * Обновляет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
+ * Атомарно изменяет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
*
- *
Работает только внутри Kubernetes pod и использует service-account token и
- * service-account CA. В локальной разработке персистенция пропускается.
+ *
Каждая мутация сначала читает актуальный Secret, применяет изменение к полученному
+ * списку и отправляет полный объект обратно условным {@code PUT} с
+ * {@code metadata.resourceVersion}. При конфликте операция повторно читает актуальное
+ * состояние и заново применяет только свою мутацию.
*/
@Service
-public class KubernetesTenantSecretUpdater {
+public class KubernetesTenantSecretUpdater implements TenantConfigStore {
private static final Logger log = LoggerFactory.getLogger(KubernetesTenantSecretUpdater.class);
@@ -43,6 +57,15 @@ public class KubernetesTenantSecretUpdater {
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
private static final String DEFAULT_API_BASE = "https://kubernetes.default.svc";
private static final String DEFAULT_SECRET_NAME = "tenants-secret";
+ private static final int DEFAULT_MAX_ATTEMPTS = 3;
+ private static final Duration DEFAULT_RETRY_DELAY = Duration.ofMillis(50);
+ private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
+ private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
+ private static final Pattern DOMAIN_PATTERN = Pattern.compile(
+ "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
+ );
+ private static final TypeReference> TENANT_LIST_TYPE = new TypeReference<>() {
+ };
private final ObjectMapper objectMapper;
private final Path tokenPath;
@@ -51,6 +74,10 @@ public class KubernetesTenantSecretUpdater {
private final String apiBase;
private final String secretName;
private final boolean runningInKubernetes;
+ private final int maxAttempts;
+ private final Duration retryDelay;
+
+ private volatile HttpClient httpClient;
public KubernetesTenantSecretUpdater() {
this(
@@ -69,79 +96,572 @@ public class KubernetesTenantSecretUpdater {
String apiBase,
String secretName,
ObjectMapper objectMapper) {
- this.tokenPath = tokenPath;
- this.namespacePath = namespacePath;
- this.caPath = caPath;
- this.apiBase = apiBase.endsWith("/") ? apiBase.substring(0, apiBase.length() - 1) : apiBase;
- this.secretName = secretName;
- this.objectMapper = objectMapper;
- this.runningInKubernetes = Files.exists(tokenPath);
+ this(
+ tokenPath,
+ namespacePath,
+ caPath,
+ apiBase,
+ secretName,
+ objectMapper,
+ DEFAULT_MAX_ATTEMPTS,
+ DEFAULT_RETRY_DELAY
+ );
+ }
+
+ KubernetesTenantSecretUpdater(Path tokenPath,
+ Path namespacePath,
+ Path caPath,
+ String apiBase,
+ String secretName,
+ ObjectMapper objectMapper,
+ int maxAttempts,
+ Duration retryDelay) {
+ this(
+ tokenPath,
+ namespacePath,
+ caPath,
+ apiBase,
+ secretName,
+ objectMapper,
+ maxAttempts,
+ retryDelay,
+ detectKubernetesEnvironment(tokenPath)
+ );
+ }
+
+ KubernetesTenantSecretUpdater(Path tokenPath,
+ Path namespacePath,
+ Path caPath,
+ String apiBase,
+ String secretName,
+ ObjectMapper objectMapper,
+ int maxAttempts,
+ Duration retryDelay,
+ boolean kubernetesEnvironment) {
+ this.tokenPath = Objects.requireNonNull(tokenPath, "Путь к ServiceAccount token не задан");
+ this.namespacePath = Objects.requireNonNull(namespacePath, "Путь к namespace не задан");
+ this.caPath = Objects.requireNonNull(caPath, "Путь к ServiceAccount CA не задан");
+ this.apiBase = normalizeApiBase(apiBase);
+ this.secretName = requireText(secretName, "Имя tenant Secret не задано");
+ this.objectMapper = Objects.requireNonNull(objectMapper, "ObjectMapper не задан");
+ if (maxAttempts < 1) {
+ throw new IllegalArgumentException("Количество попыток должно быть положительным");
+ }
+ if (retryDelay == null || retryDelay.isNegative()) {
+ throw new IllegalArgumentException("Задержка повтора не может быть отрицательной");
+ }
+ this.maxAttempts = maxAttempts;
+ this.retryDelay = retryDelay;
+ this.runningInKubernetes = kubernetesEnvironment || Files.exists(tokenPath);
if (!runningInKubernetes) {
- log.info("Приложение запущено вне Kubernetes — обновление tenant Secret будет пропущено");
+ log.info("Приложение запущено вне Kubernetes — постоянное изменение tenant Secret отключено");
}
}
- /**
- * Сохраняет полный список тенантов в ключе {@code tenants.json} Kubernetes Secret.
- *
- * @return {@code true}, если Secret обновлён или приложение запущено вне Kubernetes
- */
- public boolean updateTenantsConfig(List tenants) {
+ @Override
+ public TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant) {
+ TenantConfig requested = TenantSecretUpdateReceipt.copyOf(
+ Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")
+ );
+ String domain = normalizeDomain(requested.getDomain());
+ requested.setDomain(domain);
+
if (!runningInKubernetes) {
- log.warn("Приложение запущено вне Kubernetes, персистенция tenant Secret пропущена");
- return true;
+ log.warn("Приложение запущено вне Kubernetes, постоянное добавление тенанта пропущено");
+ return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of(requested));
}
- try {
- String token = Files.readString(tokenPath).trim();
- String namespace = Files.readString(namespacePath).trim();
- if (token.isBlank() || namespace.isBlank()) {
- throw new IllegalStateException("ServiceAccount token или namespace не настроены");
+ return mutate(tenants -> {
+ List updated = new ArrayList<>(tenants);
+ updated.removeIf(existing -> domain.equals(existing.getDomain()));
+ updated.add(requested);
+ return updated;
+ });
+ }
+
+ @Override
+ public TenantSecretUpdateReceipt removeTenant(String domain) {
+ String normalizedDomain = normalizeDomain(domain);
+ if (!runningInKubernetes) {
+ log.warn("Приложение запущено вне Kubernetes, постоянное удаление тенанта пропущено");
+ return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of());
+ }
+
+ return mutate(tenants -> {
+ List updated = new ArrayList<>(tenants);
+ updated.removeIf(existing -> normalizedDomain.equals(existing.getDomain()));
+ return updated;
+ });
+ }
+
+ @Override
+ public TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt) {
+ Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
+ if (!receipt.persisted() || !receipt.changed()) {
+ return TenantSecretCompensationResult.NOT_REQUIRED;
+ }
+ if (!runningInKubernetes) {
+ return TenantSecretCompensationResult.NOT_REQUIRED;
+ }
+
+ String committedVersion = requireText(
+ receipt.committedResourceVersion(),
+ "В квитанции отсутствует resourceVersion сохранённого tenant Secret"
+ );
+ RequestContext context = requestContext();
+ TenantConfigPersistenceException lastFailure = null;
+
+ for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+ CurrentSecret current;
+ try {
+ current = readCurrent(context);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TenantConfigPersistenceException("Компенсация tenant Secret прервана", e);
+ } catch (IOException e) {
+ lastFailure = persistenceFailure("Не удалось прочитать tenant Secret для компенсации", e);
+ if (!prepareRetry(attempt, "чтение перед компенсацией")) {
+ throw lastFailure;
+ }
+ continue;
}
- String tenantsJson = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tenants);
- String encodedTenants = Base64.getEncoder()
- .encodeToString(tenantsJson.getBytes(StandardCharsets.UTF_8));
- String patchBody = objectMapper.writeValueAsString(Map.of(
- "data", Map.of("tenants.json", encodedTenants)
- ));
+ if (!committedVersion.equals(current.resourceVersion())) {
+ log.warn("Компенсация tenant Secret пропущена: после исходной операции Secret уже изменён");
+ return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
+ }
+ if (!sameTenants(current.tenants(), receipt.committedTenants())) {
+ throw new TenantConfigPersistenceException(
+ "Содержимое tenant Secret не соответствует resourceVersion из квитанции"
+ );
+ }
+ HttpResponse response;
+ try {
+ response = putSecret(context, current, receipt.previousTenants());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TenantConfigPersistenceException("Компенсация tenant Secret прервана", e);
+ } catch (IOException e) {
+ TenantSecretCompensationResult reconciled = reconcileCompensation(context, receipt);
+ if (reconciled != null) {
+ return reconciled;
+ }
+ lastFailure = persistenceFailure("Не удалось подтвердить компенсацию tenant Secret", e);
+ if (!prepareRetry(attempt, "компенсация после сетевой ошибки")) {
+ throw lastFailure;
+ }
+ continue;
+ }
+
+ if (isSuccess(response.statusCode())) {
+ TenantSecretCompensationResult confirmed = confirmCompensation(
+ context,
+ receipt,
+ response.body()
+ );
+ if (confirmed != null) {
+ if (confirmed == TenantSecretCompensationResult.RESTORED) {
+ log.info("Конфигурация tenant Secret восстановлена: tenantCount={}",
+ receipt.previousTenants().size());
+ }
+ return confirmed;
+ }
+ lastFailure = new TenantConfigPersistenceException(
+ "Kubernetes API не подтвердил содержимое после компенсации tenant Secret"
+ );
+ if (!prepareRetry(attempt, "проверка компенсации tenant Secret")) {
+ throw lastFailure;
+ }
+ continue;
+ }
+ if (response.statusCode() == 409) {
+ log.warn("Компенсация tenant Secret пропущена: обнаружено конкурентное изменение");
+ return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
+ }
+ if (isRetryable(response.statusCode())) {
+ TenantSecretCompensationResult reconciled = reconcileCompensation(context, receipt);
+ if (reconciled != null) {
+ return reconciled;
+ }
+ lastFailure = new TenantConfigPersistenceException(
+ "Kubernetes API временно отклонил компенсацию tenant Secret: HTTP "
+ + response.statusCode()
+ );
+ if (!prepareRetry(attempt, "компенсация tenant Secret")) {
+ throw lastFailure;
+ }
+ continue;
+ }
+ throw new TenantConfigPersistenceException(
+ "Kubernetes API отклонил компенсацию tenant Secret: HTTP " + response.statusCode()
+ );
+ }
+
+ throw lastFailure == null
+ ? new TenantConfigPersistenceException("Не удалось компенсировать tenant Secret")
+ : lastFailure;
+ }
+
+ private TenantSecretUpdateReceipt mutate(Function, List> mutation) {
+ RequestContext context = requestContext();
+ TenantConfigPersistenceException lastFailure = null;
+
+ for (int attempt = 1; attempt <= maxAttempts; attempt++) {
+ CurrentSecret current;
+ try {
+ current = readCurrent(context);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TenantConfigPersistenceException("Изменение tenant Secret прервано", e);
+ } catch (IOException e) {
+ lastFailure = persistenceFailure("Не удалось прочитать актуальный tenant Secret", e);
+ if (!prepareRetry(attempt, "чтение tenant Secret")) {
+ throw lastFailure;
+ }
+ continue;
+ }
+
+ List previous = normalizeTenants(current.tenants());
+ List committed = normalizeTenants(mutation.apply(previous));
+ if (sameTenants(previous, committed)) {
+ return new TenantSecretUpdateReceipt(
+ true,
+ false,
+ current.resourceVersion(),
+ previous,
+ committed
+ );
+ }
+
+ HttpResponse response;
+ try {
+ response = putSecret(context, current, committed);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TenantConfigPersistenceException("Изменение tenant Secret прервано", e);
+ } catch (IOException e) {
+ TenantSecretUpdateReceipt reconciled = reconcileMutation(context, previous, committed);
+ if (reconciled != null) {
+ return reconciled;
+ }
+ lastFailure = persistenceFailure("Не удалось подтвердить изменение tenant Secret", e);
+ if (!prepareRetry(attempt, "изменение после сетевой ошибки")) {
+ throw lastFailure;
+ }
+ continue;
+ }
+
+ if (isSuccess(response.statusCode())) {
+ TenantSecretUpdateReceipt confirmed = confirmMutation(
+ context,
+ current,
+ previous,
+ committed,
+ response.body()
+ );
+ if (confirmed != null) {
+ log.info("Tenant Secret атомарно изменён: tenantCount={}", committed.size());
+ return confirmed;
+ }
+ throw new TenantConfigPersistenceException(
+ "Kubernetes API не подтвердил сохранённое содержимое tenant Secret"
+ );
+ }
+
+ if (response.statusCode() == 409) {
+ lastFailure = new TenantConfigPersistenceException(
+ "Исчерпаны попытки изменения tenant Secret из-за конкурентных обновлений"
+ );
+ if (!prepareRetry(attempt, "конфликт resourceVersion")) {
+ throw lastFailure;
+ }
+ continue;
+ }
+
+ if (isRetryable(response.statusCode())) {
+ TenantSecretUpdateReceipt reconciled = reconcileMutation(context, previous, committed);
+ if (reconciled != null) {
+ return reconciled;
+ }
+ lastFailure = new TenantConfigPersistenceException(
+ "Kubernetes API временно отклонил изменение tenant Secret: HTTP "
+ + response.statusCode()
+ );
+ if (!prepareRetry(attempt, "изменение tenant Secret")) {
+ throw lastFailure;
+ }
+ continue;
+ }
+
+ throw new TenantConfigPersistenceException(
+ "Kubernetes API отклонил изменение tenant Secret: HTTP " + response.statusCode()
+ );
+ }
+
+ throw lastFailure == null
+ ? new TenantConfigPersistenceException("Не удалось изменить tenant Secret")
+ : lastFailure;
+ }
+
+ private CurrentSecret readCurrent(RequestContext context) throws IOException, InterruptedException {
+ HttpRequest request = requestBuilder(context)
+ .GET()
+ .build();
+ HttpResponse response = secureClient().send(
+ request,
+ HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
+ );
+ if (response.statusCode() == 200) {
+ return parseSecret(response.body());
+ }
+ if (isRetryable(response.statusCode())) {
+ throw new RetryableApiException(
+ "Kubernetes API временно не вернул tenant Secret: HTTP " + response.statusCode()
+ );
+ }
+ throw new TenantConfigPersistenceException(
+ "Kubernetes API не вернул tenant Secret: HTTP " + response.statusCode()
+ );
+ }
+
+ private HttpResponse putSecret(RequestContext context,
+ CurrentSecret current,
+ List tenants)
+ throws IOException, InterruptedException {
+ ObjectNode body = current.secret().deepCopy();
+ ObjectNode metadata = objectNode(body, "metadata");
+ metadata.put("resourceVersion", current.resourceVersion());
+ ObjectNode data = objectNode(body, "data");
+ data.put("tenants.json", encodeTenants(tenants));
+
+ HttpRequest request = requestBuilder(context)
+ .header("Content-Type", "application/json")
+ .PUT(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
+ .build();
+ return secureClient().send(
+ request,
+ HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)
+ );
+ }
+
+ private TenantSecretUpdateReceipt reconcileMutation(RequestContext context,
+ List previous,
+ List committed) {
+ try {
+ CurrentSecret observed = readCurrent(context);
+ if (sameTenants(observed.tenants(), committed)) {
+ log.info("Изменение tenant Secret подтверждено повторным чтением: tenantCount={}",
+ committed.size());
+ return new TenantSecretUpdateReceipt(
+ true,
+ false,
+ observed.resourceVersion(),
+ previous,
+ observed.tenants()
+ );
+ }
+ return null;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TenantConfigPersistenceException("Проверка изменения tenant Secret прервана", e);
+ } catch (IOException e) {
+ log.warn("Не удалось подтвердить состояние tenant Secret повторным чтением");
+ return null;
+ }
+ }
+
+ private TenantSecretUpdateReceipt confirmMutation(RequestContext context,
+ CurrentSecret previousSecret,
+ List previous,
+ List committed,
+ String responseBody) {
+ try {
+ CurrentSecret confirmed = parseSecret(responseBody);
+ if (sameTenants(confirmed.tenants(), committed)
+ && !Objects.equals(confirmed.resourceVersion(), previousSecret.resourceVersion())) {
+ return new TenantSecretUpdateReceipt(
+ true,
+ true,
+ confirmed.resourceVersion(),
+ previous,
+ confirmed.tenants()
+ );
+ }
+ log.warn("Ответ Kubernetes API после изменения tenant Secret не совпал с ожидаемым состоянием");
+ } catch (TenantConfigPersistenceException invalidResponse) {
+ log.warn("Kubernetes API вернул неполный ответ после изменения tenant Secret, выполняется повторное чтение");
+ log.debug("Технические детали неполного ответа tenant Secret", invalidResponse);
+ }
+ return reconcileMutation(context, previous, committed);
+ }
+
+ private TenantSecretCompensationResult confirmCompensation(RequestContext context,
+ TenantSecretUpdateReceipt receipt,
+ String responseBody) {
+ try {
+ CurrentSecret confirmed = parseSecret(responseBody);
+ if (sameTenants(confirmed.tenants(), receipt.previousTenants())) {
+ return TenantSecretCompensationResult.RESTORED;
+ }
+ log.warn("Ответ Kubernetes API после компенсации tenant Secret не совпал с ожидаемым состоянием");
+ } catch (TenantConfigPersistenceException invalidResponse) {
+ log.warn("Kubernetes API вернул неполный ответ после компенсации tenant Secret, выполняется повторное чтение");
+ log.debug("Технические детали неполного ответа компенсации tenant Secret", invalidResponse);
+ }
+ return reconcileCompensation(context, receipt);
+ }
+
+ private TenantSecretCompensationResult reconcileCompensation(RequestContext context,
+ TenantSecretUpdateReceipt receipt) {
+ try {
+ CurrentSecret observed = readCurrent(context);
+ if (sameTenants(observed.tenants(), receipt.previousTenants())) {
+ log.info("Компенсация tenant Secret подтверждена повторным чтением");
+ return TenantSecretCompensationResult.RESTORED;
+ }
+ if (!Objects.equals(observed.resourceVersion(), receipt.committedResourceVersion())) {
+ log.warn("Компенсация tenant Secret остановлена из-за конкурентного изменения");
+ return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
+ }
+ return null;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TenantConfigPersistenceException("Проверка компенсации tenant Secret прервана", e);
+ } catch (IOException e) {
+ log.warn("Не удалось подтвердить компенсацию tenant Secret повторным чтением");
+ return null;
+ }
+ }
+
+ private CurrentSecret parseSecret(String responseBody) {
+ try {
+ JsonNode root = objectMapper.readTree(responseBody);
+ if (!(root instanceof ObjectNode secret)) {
+ throw new TenantConfigPersistenceException("Kubernetes API вернул некорректный tenant Secret");
+ }
+ String resourceVersion = requireText(
+ root.path("metadata").path("resourceVersion").asText(null),
+ "В tenant Secret отсутствует metadata.resourceVersion"
+ );
+ JsonNode data = root.path("data");
+ if (!data.isObject() || !data.hasNonNull("tenants.json")) {
+ throw new TenantConfigPersistenceException(
+ "В tenant Secret отсутствует обязательный ключ data.tenants.json"
+ );
+ }
+ String encoded = requireText(
+ data.path("tenants.json").asText(null),
+ "В tenant Secret ключ data.tenants.json не содержит конфигурацию"
+ );
+ byte[] decoded = Base64.getDecoder().decode(encoded);
+ List tenants = objectMapper.readValue(decoded, TENANT_LIST_TYPE);
+ return new CurrentSecret(secret, resourceVersion, normalizeTenants(tenants));
+ } catch (TenantConfigPersistenceException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new TenantConfigPersistenceException(
+ "Не удалось разобрать конфигурацию из tenant Secret",
+ e
+ );
+ }
+ }
+
+ private List normalizeTenants(List tenants) {
+ if (tenants == null) {
+ throw new TenantConfigPersistenceException("Список конфигураций в tenant Secret не задан");
+ }
+ List normalized = new ArrayList<>(tenants.size());
+ Set domains = new HashSet<>();
+ for (TenantConfig source : tenants) {
+ TenantConfig tenant = TenantSecretUpdateReceipt.copyOf(
+ Objects.requireNonNull(source, "В tenant Secret обнаружена пустая конфигурация")
+ );
+ String domain = normalizeDomain(tenant.getDomain());
+ tenant.setDomain(domain);
+ if (!domains.add(domain)) {
+ throw new TenantConfigPersistenceException(
+ "В tenant Secret обнаружены повторяющиеся домены"
+ );
+ }
+ normalized.add(tenant);
+ }
+ normalized.sort(Comparator.comparing(TenantConfig::getDomain));
+ return List.copyOf(normalized);
+ }
+
+ private boolean sameTenants(List first, List second) {
+ List left = normalizeTenants(first);
+ List right = normalizeTenants(second);
+ if (left.size() != right.size()) {
+ return false;
+ }
+ for (int i = 0; i < left.size(); i++) {
+ if (!sameTenant(left.get(i), right.get(i))) {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private boolean sameTenant(TenantConfig first, TenantConfig second) {
+ return Objects.equals(first.getName(), second.getName())
+ && Objects.equals(first.getDomain(), second.getDomain())
+ && Objects.equals(first.getUrl(), second.getUrl())
+ && Objects.equals(first.getUsername(), second.getUsername())
+ && Objects.equals(first.getPassword(), second.getPassword());
+ }
+
+ private RequestContext requestContext() {
+ try {
+ String token = requireText(
+ Files.readString(tokenPath).trim(),
+ "ServiceAccount token не настроен"
+ );
+ String namespace = requireText(
+ Files.readString(namespacePath).trim(),
+ "ServiceAccount namespace не настроен"
+ );
URI uri = URI.create(String.format(
"%s/api/v1/namespaces/%s/secrets/%s",
apiBase,
namespace,
secretName
));
- HttpRequest request = HttpRequest.newBuilder()
- .uri(uri)
- .header("Authorization", "Bearer " + token)
- .header("Content-Type", "application/strategic-merge-patch+json")
- .method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
- .build();
-
- HttpResponse response = createSecureClient(caPath)
- .send(request, HttpResponse.BodyHandlers.discarding());
- if (response.statusCode() == 200) {
- log.info("Tenant Secret успешно обновлён: tenantCount={}", tenants.size());
- return true;
- }
-
- log.error("Kubernetes API отклонил обновление tenant Secret: httpStatus={}", response.statusCode());
- return false;
- } catch (InterruptedException e) {
- Thread.currentThread().interrupt();
- log.error("Обновление tenant Secret прервано");
- log.debug("Технические детали прерывания tenant Secret", e);
- return false;
+ return new RequestContext(token, uri);
+ } catch (TenantConfigPersistenceException e) {
+ throw e;
} catch (Exception e) {
- log.error("Не удалось безопасно обновить tenant Secret: errorType={}",
- e.getClass().getSimpleName());
- log.debug("Технические детали ошибки tenant Secret", e);
- return false;
+ throw new TenantConfigPersistenceException(
+ "Не удалось прочитать параметры ServiceAccount для tenant Secret",
+ e
+ );
}
}
+ private HttpRequest.Builder requestBuilder(RequestContext context) {
+ return HttpRequest.newBuilder()
+ .uri(context.uri())
+ .timeout(REQUEST_TIMEOUT)
+ .header("Authorization", "Bearer " + context.token())
+ .header("Accept", "application/json");
+ }
+
+ private synchronized HttpClient secureClient() {
+ if (httpClient == null) {
+ try {
+ httpClient = createSecureClient(caPath);
+ } catch (Exception e) {
+ throw new TenantConfigPersistenceException(
+ "Не удалось настроить защищённое соединение с Kubernetes API",
+ e
+ );
+ }
+ }
+ return httpClient;
+ }
+
HttpClient createSecureClient(Path serviceAccountCaPath) throws Exception {
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
Collection extends Certificate> certificates;
@@ -171,8 +691,102 @@ public class KubernetesTenantSecretUpdater {
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
return HttpClient.newBuilder()
+ .connectTimeout(CONNECT_TIMEOUT)
.sslContext(sslContext)
.sslParameters(sslParameters)
.build();
}
+
+ private boolean prepareRetry(int attempt, String operation) {
+ if (attempt >= maxAttempts) {
+ return false;
+ }
+ log.warn("Временная ошибка tenant Secret, операция будет повторена: операция={}, попытка={}/{}",
+ operation,
+ attempt,
+ maxAttempts);
+ try {
+ long delayMillis = Math.multiplyExact(retryDelay.toMillis(), attempt);
+ if (delayMillis > 0) {
+ Thread.sleep(delayMillis);
+ }
+ return true;
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TenantConfigPersistenceException("Ожидание повтора tenant Secret прервано", e);
+ } catch (ArithmeticException e) {
+ throw new TenantConfigPersistenceException("Некорректная задержка повтора tenant Secret", e);
+ }
+ }
+
+ private String encodeTenants(List tenants) throws IOException {
+ String json = objectMapper.writerWithDefaultPrettyPrinter().writeValueAsString(tenants);
+ return Base64.getEncoder().encodeToString(json.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private ObjectNode objectNode(ObjectNode parent, String fieldName) {
+ JsonNode existing = parent.get(fieldName);
+ if (existing instanceof ObjectNode objectNode) {
+ return objectNode;
+ }
+ ObjectNode created = objectMapper.createObjectNode();
+ parent.set(fieldName, created);
+ return created;
+ }
+
+ private static boolean isSuccess(int statusCode) {
+ return statusCode >= 200 && statusCode < 300;
+ }
+
+ private static boolean isRetryable(int statusCode) {
+ return statusCode == 408 || statusCode == 409 || statusCode == 429 || statusCode >= 500;
+ }
+
+ private static boolean detectKubernetesEnvironment(Path tokenPath) {
+ String kubernetesServiceHost = System.getenv("KUBERNETES_SERVICE_HOST");
+ return (tokenPath != null && Files.exists(tokenPath))
+ || (kubernetesServiceHost != null && !kubernetesServiceHost.isBlank());
+ }
+
+ private static String requireText(String value, String message) {
+ if (value == null || value.isBlank()) {
+ throw new TenantConfigPersistenceException(message);
+ }
+ return value.trim();
+ }
+
+ private static String normalizeDomain(String value) {
+ String domain = requireText(value, "Домен тенанта не задан").toLowerCase(Locale.ROOT);
+ if (!DOMAIN_PATTERN.matcher(domain).matches()) {
+ throw new TenantConfigPersistenceException("Домен тенанта содержит недопустимые символы");
+ }
+ return domain;
+ }
+
+ private static String normalizeApiBase(String apiBase) {
+ String normalized = requireText(apiBase, "Адрес Kubernetes API не задан");
+ return normalized.endsWith("/")
+ ? normalized.substring(0, normalized.length() - 1)
+ : normalized;
+ }
+
+ private static TenantConfigPersistenceException persistenceFailure(String message, Exception cause) {
+ return cause instanceof TenantConfigPersistenceException persistenceException
+ ? persistenceException
+ : new TenantConfigPersistenceException(message, cause);
+ }
+
+ private record RequestContext(String token, URI uri) {
+ }
+
+ private record CurrentSecret(ObjectNode secret,
+ String resourceVersion,
+ List tenants) {
+ }
+
+ private static final class RetryableApiException extends IOException {
+ private RetryableApiException(String message) {
+ super(message);
+ }
+ }
}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigPersistenceException.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigPersistenceException.java
new file mode 100644
index 0000000..c53ba0d
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigPersistenceException.java
@@ -0,0 +1,15 @@
+package com.magistr.app.config.tenant;
+
+/**
+ * Ошибка безопасного чтения или изменения постоянной конфигурации тенантов.
+ */
+public class TenantConfigPersistenceException extends RuntimeException {
+
+ public TenantConfigPersistenceException(String message) {
+ super(message);
+ }
+
+ public TenantConfigPersistenceException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigStore.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigStore.java
new file mode 100644
index 0000000..1392120
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigStore.java
@@ -0,0 +1,13 @@
+package com.magistr.app.config.tenant;
+
+/**
+ * Хранилище конфигурации тенантов с защитой от потерянных конкурентных обновлений.
+ */
+public interface TenantConfigStore {
+
+ TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant);
+
+ TenantSecretUpdateReceipt removeTenant(String domain);
+
+ TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt);
+}
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
index a22539d..7d067ac 100755
--- a/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantConfigWatcher.java
@@ -2,6 +2,7 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
import com.magistr.app.service.TenantLifecycleService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -29,16 +30,23 @@ public class TenantConfigWatcher {
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
private final TenantLifecycleService tenantLifecycleService;
+ private final TenantReadinessRegistry readinessRegistry;
private final ObjectMapper objectMapper = new ObjectMapper();
@Value("${app.tenants.config-path:tenants.json}")
private String tenantsConfigPath;
+ @Value("${app.tenants.config-required:false}")
+ private boolean tenantsConfigRequired;
+
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
private String lastConfigHash = "";
+ private boolean configurationUnavailable;
- public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService) {
+ public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
+ TenantReadinessRegistry readinessRegistry) {
this.tenantLifecycleService = tenantLifecycleService;
+ this.readinessRegistry = readinessRegistry;
}
/**
@@ -52,12 +60,21 @@ public class TenantConfigWatcher {
private void watchForChangesSerialized() {
try {
File file = new File(tenantsConfigPath);
- if (!file.exists()) return;
+ if (!file.exists()) {
+ if (tenantsConfigRequired) {
+ readinessRegistry.markConfigurationFailure();
+ if (!configurationUnavailable) {
+ log.error("Обязательный файл конфигурации тенантов недоступен");
+ }
+ configurationUnavailable = true;
+ }
+ return;
+ }
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
String hash = configHash(content);
- if (hash.equals(lastConfigHash)) {
+ if (hash.equals(lastConfigHash) && !configurationUnavailable) {
return; // Ничего не изменилось
}
@@ -65,8 +82,11 @@ public class TenantConfigWatcher {
List newTenants = objectMapper.readValue(content, new TypeReference<>() {});
syncTenants(newTenants);
lastConfigHash = hash;
+ configurationUnavailable = false;
} catch (Exception e) {
+ readinessRegistry.markConfigurationFailure();
+ configurationUnavailable = true;
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
log.debug("Технические детали синхронизации конфига тенантов", e);
}
@@ -85,8 +105,13 @@ public class TenantConfigWatcher {
if (file.exists()) {
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
lastConfigHash = configHash(content);
+ } else if (tenantsConfigRequired) {
+ readinessRegistry.markConfigurationFailure();
+ configurationUnavailable = true;
}
} catch (Exception e) {
+ readinessRegistry.markConfigurationFailure();
+ configurationUnavailable = true;
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали обновления хеша тенантов", e);
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 134cc03..24059db 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,6 +2,7 @@ package com.magistr.app.config.tenant;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
+import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
import com.magistr.app.service.TenantDatabaseMigrationService;
import com.zaxxer.hikari.HikariDataSource;
import org.slf4j.Logger;
@@ -18,13 +19,12 @@ import org.springframework.transaction.PlatformTransactionManager;
import jakarta.persistence.EntityManagerFactory;
import javax.sql.DataSource;
import java.io.File;
-import java.io.IOException;
import java.sql.Connection;
import java.util.*;
/**
* Конфигурация мультитенантного DataSource.
- * Загружает тенанты из JSON-файла (mounted ConfigMap).
+ * Загружает тенанты из JSON-файла (смонтированный Kubernetes Secret).
*
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
* как заглушку, чтобы Spring JPA мог инициализироваться.
@@ -37,6 +37,9 @@ public class TenantDataSourceConfig {
@Value("${app.tenants.config-path:tenants.json}")
private String tenantsConfigPath;
+ @Value("${app.tenants.config-required:false}")
+ private boolean tenantsConfigRequired;
+
@Value("${spring.datasource.url:}")
private String defaultDbUrl;
@@ -48,11 +51,13 @@ public class TenantDataSourceConfig {
@Bean
@Primary
- public DataSource dataSource(TenantDatabaseMigrationService migrationService) {
+ public DataSource dataSource(TenantDatabaseMigrationService migrationService,
+ TenantReadinessRegistry readinessRegistry) {
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
- // Загружаем тенантов из JSON (read-only ConfigMap mount)
- List tenants = loadTenantsFromFile();
+ // Загружаем тенантов из JSON (read-only Secret mount)
+ TenantConfigLoadResult loadResult = loadTenantsFromFile();
+ List tenants = new ArrayList<>(loadResult.tenants());
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
@@ -63,9 +68,15 @@ public class TenantDataSourceConfig {
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
}
+ readinessRegistry.clearConfigurationFailure();
+ readinessRegistry.replaceDesired(tenants);
+ if (loadResult.failed() || (tenantsConfigRequired && loadResult.tenants().isEmpty())) {
+ readinessRegistry.markConfigurationFailure();
+ }
+
// Регистрируем тенантов
for (TenantConfig tenant : tenants) {
- registerPreparedTenant(routingDataSource, migrationService, tenant);
+ registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
}
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
@@ -79,7 +90,13 @@ public class TenantDataSourceConfig {
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
"sa", ""
);
- if (!registerPreparedTenant(routingDataSource, migrationService, h2Fallback)) {
+ if (!registerPreparedTenant(
+ routingDataSource,
+ migrationService,
+ readinessRegistry,
+ h2Fallback,
+ false
+ )) {
throw new IllegalStateException("Не удалось создать резервный H2 DataSource");
}
}
@@ -118,31 +135,43 @@ public class TenantDataSourceConfig {
return new JpaTransactionManager(emf);
}
- private List loadTenantsFromFile() {
+ private TenantConfigLoadResult loadTenantsFromFile() {
File file = new File(tenantsConfigPath);
if (!file.exists()) {
- log.info("Файл конфигурации тенантов не найден");
- return new ArrayList<>();
+ if (tenantsConfigRequired) {
+ log.error("Обязательный файл конфигурации тенантов не найден");
+ } else {
+ log.info("Файл конфигурации тенантов не найден");
+ }
+ return new TenantConfigLoadResult(List.of(), tenantsConfigRequired);
}
try {
ObjectMapper mapper = new ObjectMapper();
List list = mapper.readValue(file, new TypeReference<>() {});
+ if (list == null) {
+ throw new IllegalArgumentException("Корневое значение конфигурации тенантов должно быть массивом");
+ }
log.info("Загружено конфигураций тенантов: {}", list.size());
- return list;
- } catch (IOException e) {
+ return new TenantConfigLoadResult(List.copyOf(list), false);
+ } catch (Exception e) {
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
e.getClass().getSimpleName());
log.debug("Технические детали чтения конфигурации тенантов", e);
- return new ArrayList<>();
+ return new TenantConfigLoadResult(List.of(), true);
}
}
private boolean registerPreparedTenant(TenantRoutingDataSource routingDataSource,
TenantDatabaseMigrationService migrationService,
- TenantConfig tenant) {
+ TenantReadinessRegistry readinessRegistry,
+ TenantConfig tenant,
+ boolean requiredForReadiness) {
HikariDataSource candidate = null;
boolean activated = false;
+ if (requiredForReadiness) {
+ readinessRegistry.markMigrationStarted(tenant);
+ }
try {
candidate = routingDataSource.prepareTenantDataSource(tenant);
try (Connection connection = candidate.getConnection()) {
@@ -155,10 +184,17 @@ public class TenantDataSourceConfig {
}
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(tenant, candidate);
activated = true;
+ if (requiredForReadiness) {
+ readinessRegistry.markMigrationSucceeded(tenant);
+ readinessRegistry.markConnectivity(tenant.getDomain(), true);
+ }
closeDataSource(previous == null ? null : previous.dataSource());
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
return true;
} catch (Exception startupFailure) {
+ if (requiredForReadiness) {
+ readinessRegistry.markMigrationFailed(tenant);
+ }
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
tenant.getDomain(), startupFailure.getClass().getSimpleName());
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
@@ -181,4 +217,7 @@ public class TenantDataSourceConfig {
}
}
}
+
+ private record TenantConfigLoadResult(List tenants, boolean failed) {
+ }
}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantSecretCompensationResult.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantSecretCompensationResult.java
new file mode 100644
index 0000000..c16d42a
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantSecretCompensationResult.java
@@ -0,0 +1,10 @@
+package com.magistr.app.config.tenant;
+
+/**
+ * Результат компенсирующего изменения конфигурации тенантов.
+ */
+public enum TenantSecretCompensationResult {
+ RESTORED,
+ NOT_REQUIRED,
+ SKIPPED_CONCURRENT_CHANGE
+}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantSecretUpdateReceipt.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantSecretUpdateReceipt.java
new file mode 100644
index 0000000..852ccab
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantSecretUpdateReceipt.java
@@ -0,0 +1,95 @@
+package com.magistr.app.config.tenant;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Квитанция об атомарном изменении tenant Secret.
+ *
+ *
Снимки конфигурации копируются глубоко и сортируются по домену. Это позволяет
+ * безопасно использовать квитанцию для компенсации и проверки ожидаемого состояния.
+ */
+public final class TenantSecretUpdateReceipt {
+
+ private static final Comparator BY_DOMAIN = Comparator.comparing(
+ TenantConfig::getDomain,
+ Comparator.nullsFirst(String::compareTo)
+ );
+
+ private final boolean persisted;
+ private final boolean changed;
+ private final String committedResourceVersion;
+ private final List previousTenants;
+ private final List committedTenants;
+
+ public TenantSecretUpdateReceipt(boolean persisted,
+ boolean changed,
+ String committedResourceVersion,
+ List previousTenants,
+ List committedTenants) {
+ this.persisted = persisted;
+ this.changed = changed;
+ this.committedResourceVersion = committedResourceVersion;
+ this.previousTenants = copyAndSort(previousTenants);
+ this.committedTenants = copyAndSort(committedTenants);
+ }
+
+ public boolean persisted() {
+ return persisted;
+ }
+
+ /**
+ * Возвращает {@code true}, только если хранилище доказало владение подтверждённой
+ * записанной версией. После неоднозначного результата повторное чтение может подтвердить
+ * целевое состояние, но оставляет этот признак {@code false}, чтобы запретить опасную
+ * автоматическую компенсацию.
+ */
+ public boolean changed() {
+ return changed;
+ }
+
+ public String committedResourceVersion() {
+ return committedResourceVersion;
+ }
+
+ public List previousTenants() {
+ return copyAndSort(previousTenants);
+ }
+
+ public List committedTenants() {
+ return copyAndSort(committedTenants);
+ }
+
+ static List copyAndSort(List tenants) {
+ Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
+ List result = new ArrayList<>(tenants.size());
+ for (TenantConfig tenant : tenants) {
+ result.add(copyOf(Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")));
+ }
+ result.sort(BY_DOMAIN);
+ return List.copyOf(result);
+ }
+
+ static TenantConfig copyOf(TenantConfig source) {
+ return new TenantConfig(
+ source.getName(),
+ source.getDomain(),
+ source.getUrl(),
+ source.getUsername(),
+ source.getPassword()
+ );
+ }
+
+ @Override
+ public String toString() {
+ return "TenantSecretUpdateReceipt{" +
+ "persisted=" + persisted +
+ ", changed=" + changed +
+ ", committedResourceVersion='" + committedResourceVersion + '\'' +
+ ", previousTenantCount=" + previousTenants.size() +
+ ", committedTenantCount=" + committedTenants.size() +
+ '}';
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java b/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java
index 2473306..c2d445c 100644
--- a/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java
+++ b/backend/src/main/java/com/magistr/app/config/tenant/TenantWebMvcConfig.java
@@ -27,7 +27,9 @@ public class TenantWebMvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
- registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
+ registry.addInterceptor(tenantInterceptor())
+ .addPathPatterns("/**")
+ .excludePathPatterns("/actuator/**");
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
}
}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitor.java b/backend/src/main/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitor.java
new file mode 100644
index 0000000..9b8b281
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitor.java
@@ -0,0 +1,218 @@
+package com.magistr.app.config.tenant.health;
+
+import com.magistr.app.config.tenant.TenantRoutingDataSource;
+import jakarta.annotation.PreDestroy;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.ThreadFactory;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Фоновая проверка доступности tenant-БД с ограниченным числом параллельных задач.
+ */
+@Component
+public class TenantDatabaseHealthMonitor {
+
+ private static final Logger log = LoggerFactory.getLogger(TenantDatabaseHealthMonitor.class);
+
+ private final TenantReadinessRegistry registry;
+ private final TenantRoutingDataSource routingDataSource;
+ private final int parallelism;
+ private final Duration checkTimeout;
+ private final Duration maxStaleness;
+ private final Clock clock;
+ private final ExecutorService executor;
+ private final AtomicBoolean refreshInProgress = new AtomicBoolean(false);
+
+ public TenantDatabaseHealthMonitor(
+ TenantReadinessRegistry registry,
+ TenantRoutingDataSource routingDataSource,
+ @Value("${app.health.tenant-database.parallelism:4}") int parallelism,
+ @Value("${app.health.tenant-database.check-timeout-ms:6000}") long checkTimeoutMillis,
+ @Value("${app.health.tenant-database.max-staleness-ms:30000}") long maxStalenessMillis) {
+ this(registry, routingDataSource, parallelism,
+ Duration.ofMillis(checkTimeoutMillis),
+ Duration.ofMillis(maxStalenessMillis),
+ Clock.systemUTC());
+ }
+
+ public TenantDatabaseHealthMonitor(TenantReadinessRegistry registry,
+ TenantRoutingDataSource routingDataSource,
+ int parallelism,
+ Duration checkTimeout,
+ Duration maxStaleness,
+ Clock clock) {
+ this.registry = Objects.requireNonNull(registry, "Реестр готовности обязателен");
+ this.routingDataSource = Objects.requireNonNull(routingDataSource, "Маршрутизатор tenant-БД обязателен");
+ if (parallelism < 1 || parallelism > 32) {
+ throw new IllegalArgumentException("Параллелизм проверки должен быть от 1 до 32");
+ }
+ this.parallelism = parallelism;
+ this.checkTimeout = requirePositive(checkTimeout, "Таймаут проверки должен быть положительным");
+ this.maxStaleness = requirePositive(maxStaleness, "Максимальный возраст проверки должен быть положительным");
+ this.clock = Objects.requireNonNull(clock, "Часы монитора обязательны");
+ this.executor = Executors.newFixedThreadPool(parallelism, new HealthThreadFactory());
+ }
+
+ @Scheduled(
+ fixedDelayString = "${app.health.tenant-database.interval-ms:10000}",
+ initialDelayString = "${app.health.tenant-database.initial-delay-ms:1000}")
+ public void refreshScheduled() {
+ refreshNow();
+ }
+
+ /**
+ * Выполняет немедленный проход. Одновременно может выполняться только один проход.
+ */
+ public RefreshSummary refreshNow() {
+ if (!refreshInProgress.compareAndSet(false, true)) {
+ return new RefreshSummary(0, 0, 0, true);
+ }
+
+ try {
+ List targets = registry.connectivityTargets();
+ int available = 0;
+ int unavailable = 0;
+
+ for (int offset = 0; offset < targets.size(); offset += parallelism) {
+ int end = Math.min(offset + parallelism, targets.size());
+ List batch = targets.subList(offset, end);
+ List> checks = new ArrayList<>(batch.size());
+
+ for (TenantReadinessRegistry.ConnectivityTarget target : batch) {
+ checks.add(() -> check(target));
+ }
+
+ List> futures;
+ try {
+ futures = executor.invokeAll(checks, checkTimeout.toNanos(), TimeUnit.NANOSECONDS);
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ Instant interruptedAt = clock.instant();
+ log.warn("Пакетная проверка tenant-БД была прервана");
+ for (int remaining = offset; remaining < targets.size(); remaining++) {
+ TenantReadinessRegistry.ConnectivityTarget target = targets.get(remaining);
+ if (registry.markConnectivity(
+ target.domain(), target.fingerprint(), false, interruptedAt)) {
+ unavailable++;
+ }
+ }
+ return new RefreshSummary(targets.size(), available, unavailable, false);
+ }
+
+ for (int index = 0; index < batch.size(); index++) {
+ TenantReadinessRegistry.ConnectivityTarget target = batch.get(index);
+ CheckResult result = completedResult(target, futures.get(index));
+ boolean applied = registry.markConnectivity(
+ target.domain(), target.fingerprint(), result.connected(), result.checkedAt());
+ if (applied && result.connected()) {
+ available++;
+ } else if (applied) {
+ unavailable++;
+ }
+ }
+ }
+
+ return new RefreshSummary(targets.size(), available, unavailable, false);
+ } finally {
+ refreshInProgress.set(false);
+ }
+ }
+
+ public Duration maxStaleness() {
+ return maxStaleness;
+ }
+
+ public int parallelism() {
+ return parallelism;
+ }
+
+ @PreDestroy
+ public void close() {
+ executor.shutdownNow();
+ }
+
+ private CheckResult check(TenantReadinessRegistry.ConnectivityTarget target) {
+ try {
+ boolean connected = routingDataSource.testConnection(target.domain());
+ return new CheckResult(connected, clock.instant());
+ } catch (RuntimeException checkFailure) {
+ log.warn("Проверка tenant-БД '{}' завершилась ошибкой: errorType={}",
+ target.domain(), checkFailure.getClass().getSimpleName());
+ log.debug("Технические детали фоновой проверки tenant-БД", checkFailure);
+ return new CheckResult(false, clock.instant());
+ }
+ }
+
+ private CheckResult completedResult(TenantReadinessRegistry.ConnectivityTarget target,
+ Future future) {
+ if (future.isCancelled()) {
+ log.warn("Проверка tenant-БД '{}' превысила допустимое время", target.domain());
+ return new CheckResult(false, clock.instant());
+ }
+
+ try {
+ return future.get();
+ } catch (InterruptedException interrupted) {
+ Thread.currentThread().interrupt();
+ future.cancel(true);
+ log.warn("Проверка tenant-БД '{}' была прервана", target.domain());
+ return new CheckResult(false, clock.instant());
+ } catch (ExecutionException failure) {
+ log.warn("Не удалось проверить tenant-БД '{}': errorType={}",
+ target.domain(), failure.getCause() == null
+ ? failure.getClass().getSimpleName()
+ : failure.getCause().getClass().getSimpleName());
+ return new CheckResult(false, clock.instant());
+ }
+ }
+
+ private Duration requirePositive(Duration value, String message) {
+ Objects.requireNonNull(value, message);
+ if (value.isNegative() || value.isZero()) {
+ throw new IllegalArgumentException(message);
+ }
+ return value;
+ }
+
+ public record RefreshSummary(int requested,
+ int available,
+ int unavailable,
+ boolean skipped) {
+ }
+
+ private record CheckResult(boolean connected, Instant checkedAt) {
+ private CheckResult {
+ Objects.requireNonNull(checkedAt, "Время завершения проверки tenant-БД обязательно");
+ }
+ }
+
+ private static final class HealthThreadFactory implements ThreadFactory {
+ private final AtomicInteger sequence = new AtomicInteger();
+
+ @Override
+ public Thread newThread(Runnable task) {
+ Thread thread = new Thread(task,
+ "проверка-готовности-tenant-бд-" + sequence.incrementAndGet());
+ thread.setDaemon(true);
+ return thread;
+ }
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/health/TenantReadinessHealthIndicator.java b/backend/src/main/java/com/magistr/app/config/tenant/health/TenantReadinessHealthIndicator.java
new file mode 100644
index 0000000..c21b384
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/tenant/health/TenantReadinessHealthIndicator.java
@@ -0,0 +1,38 @@
+package com.magistr.app.config.tenant.health;
+
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.actuate.health.HealthIndicator;
+import org.springframework.stereotype.Component;
+
+import java.util.Objects;
+
+/**
+ * Агрегированный индикатор readiness без раскрытия доменов и реквизитов tenant-БД.
+ */
+@Component("tenantReadiness")
+public class TenantReadinessHealthIndicator implements HealthIndicator {
+
+ private final TenantReadinessRegistry registry;
+ private final TenantDatabaseHealthMonitor monitor;
+
+ public TenantReadinessHealthIndicator(TenantReadinessRegistry registry,
+ TenantDatabaseHealthMonitor monitor) {
+ this.registry = Objects.requireNonNull(registry, "Реестр готовности обязателен");
+ this.monitor = Objects.requireNonNull(monitor, "Монитор tenant-БД обязателен");
+ }
+
+ @Override
+ public Health health() {
+ return readiness().ready()
+ ? Health.up().build()
+ : Health.down().build();
+ }
+
+ public TenantReadinessRegistry.AggregateReadiness readiness() {
+ return registry.aggregate(monitor.maxStaleness());
+ }
+
+ public boolean isReady() {
+ return readiness().ready();
+ }
+}
diff --git a/backend/src/main/java/com/magistr/app/config/tenant/health/TenantReadinessRegistry.java b/backend/src/main/java/com/magistr/app/config/tenant/health/TenantReadinessRegistry.java
new file mode 100644
index 0000000..f29bb0e
--- /dev/null
+++ b/backend/src/main/java/com/magistr/app/config/tenant/health/TenantReadinessRegistry.java
@@ -0,0 +1,461 @@
+package com.magistr.app.config.tenant.health;
+
+import com.magistr.app.config.tenant.TenantConfig;
+import org.springframework.stereotype.Component;
+
+import javax.crypto.Mac;
+import javax.crypto.spec.SecretKeySpec;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.SecureRandom;
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.UnaryOperator;
+import java.util.regex.Pattern;
+
+/**
+ * Потокобезопасный реестр готовности обязательных tenant-БД.
+ *
+ *
Каждое изменение публикуется единым неизменяемым снимком. Результат фоновой
+ * проверки соединения привязан к непрозрачному fingerprint конфигурации, поэтому
+ * запоздалый результат для старых реквизитов не изменит состояние новой конфигурации.