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 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 конфигурации, поэтому + * запоздалый результат для старых реквизитов не изменит состояние новой конфигурации.

+ */ +@Component +public class TenantReadinessRegistry { + + private static final String FINGERPRINT_ALGORITHM = "HmacSHA256"; + private static final Pattern DOMAIN_PATTERN = Pattern.compile( + "[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?" + ); + + private final AtomicReference state = new AtomicReference<>(RegistryState.empty()); + private final Clock clock; + private final byte[] fingerprintKey; + + public TenantReadinessRegistry() { + this(Clock.systemUTC(), randomFingerprintKey()); + } + + TenantReadinessRegistry(Clock clock, byte[] fingerprintKey) { + this.clock = Objects.requireNonNull(clock, "Часы реестра готовности обязательны"); + if (fingerprintKey == null || fingerprintKey.length < 16) { + throw new IllegalArgumentException("Ключ fingerprint должен содержать не менее 16 байт"); + } + this.fingerprintKey = fingerprintKey.clone(); + } + + /** + * Полностью заменяет список обязательных конфигураций. + * Состояние неизменившегося tenant сохраняется, изменившегося — сбрасывается. + */ + public void replaceDesired(List desired) { + boolean missingDesired = desired == null; + List safeDesired = desired == null ? List.of() : new ArrayList<>(desired); + mutate(current -> { + Map replacement = new LinkedHashMap<>(); + boolean invalidConfiguration = missingDesired; + + for (TenantConfig config : safeDesired) { + NormalizedConfig normalized = normalize(config); + if (normalized == null || replacement.containsKey(normalized.domain())) { + invalidConfiguration = true; + continue; + } + + String fingerprint = fingerprint(normalized); + TenantStatus previous = current.tenants().get(normalized.domain()); + if (previous != null && previous.fingerprint().equals(fingerprint)) { + replacement.put(normalized.domain(), previous); + } else { + replacement.put(normalized.domain(), TenantStatus.pending( + normalized.domain(), fingerprint, normalized.h2Placeholder(), clock.instant())); + } + } + + return new RegistryState(replacement, + current.configurationFailure() || invalidConfiguration); + }); + } + + /** Удаляет tenant из обязательного набора. */ + public void removeDesired(String domain) { + String normalizedDomain = normalizeDomain(domain); + mutate(current -> { + if (!current.tenants().containsKey(normalizedDomain)) { + return current; + } + Map updated = new LinkedHashMap<>(current.tenants()); + updated.remove(normalizedDomain); + return new RegistryState(updated, current.configurationFailure()); + }); + } + + public void markMigrationStarted(TenantConfig config) { + updateByConfig(config, MigrationState.IN_PROGRESS, ConnectivityState.UNKNOWN); + } + + public void markMigrationSucceeded(TenantConfig config) { + updateByConfig(config, MigrationState.SUCCEEDED, ConnectivityState.UNKNOWN); + } + + public void markMigrationFailed(TenantConfig config) { + updateByConfig(config, MigrationState.FAILED, ConnectivityState.UNKNOWN); + } + + public void markMigrationStarted(String domain) { + updateExisting(domain, MigrationState.IN_PROGRESS, ConnectivityState.UNKNOWN); + } + + public void markMigrationSucceeded(String domain) { + updateExisting(domain, MigrationState.SUCCEEDED, ConnectivityState.UNKNOWN); + } + + public void markMigrationFailed(String domain) { + updateExisting(domain, MigrationState.FAILED, ConnectivityState.UNKNOWN); + } + + /** + * Сохраняет результат проверки текущей конфигурации tenant. + */ + public boolean markConnectivity(String domain, boolean connected) { + String normalizedDomain = normalizeDomain(domain); + TenantStatus current = state.get().tenants().get(normalizedDomain); + if (current == null) { + return false; + } + return markConnectivity(normalizedDomain, current.fingerprint(), connected, clock.instant()); + } + + /** + * Сохраняет результат только если fingerprint всё ещё соответствует проверенной конфигурации. + */ + public boolean markConnectivity(String domain, + String expectedFingerprint, + boolean connected, + Instant checkedAt) { + String normalizedDomain = normalizeDomain(domain); + Objects.requireNonNull(expectedFingerprint, "Fingerprint проверяемой конфигурации обязателен"); + Objects.requireNonNull(checkedAt, "Время проверки соединения обязательно"); + + AtomicReference applied = new AtomicReference<>(false); + mutate(current -> { + applied.set(false); + TenantStatus previous = current.tenants().get(normalizedDomain); + if (previous == null || !previous.fingerprint().equals(expectedFingerprint) + || previous.migrationState() != MigrationState.SUCCEEDED + || previous.h2Placeholder()) { + return current; + } + + if (previous.connectivityCheckedAt() != null + && checkedAt.isBefore(previous.connectivityCheckedAt())) { + return current; + } + + Map updated = new LinkedHashMap<>(current.tenants()); + updated.put(normalizedDomain, previous.withConnectivity( + connected ? ConnectivityState.AVAILABLE : ConnectivityState.UNAVAILABLE, + checkedAt, + clock.instant())); + applied.set(true); + return new RegistryState(updated, current.configurationFailure()); + }); + return applied.get(); + } + + /** Отмечает ошибку чтения или разбора общей конфигурации без сохранения её текста. */ + public void markConfigurationFailure() { + mutate(current -> new RegistryState(current.tenants(), true)); + } + + public void clearConfigurationFailure() { + mutate(current -> new RegistryState(current.tenants(), false)); + } + + /** Возвращает безопасные задания для фоновой проверки соединений. */ + public List connectivityTargets() { + List targets = new ArrayList<>(); + state.get().tenants().values().stream() + .filter(status -> status.migrationState() == MigrationState.SUCCEEDED) + .filter(status -> !status.h2Placeholder()) + .forEach(status -> targets.add(new ConnectivityTarget(status.domain(), status.fingerprint()))); + return List.copyOf(targets); + } + + /** Формирует агрегированную готовность на текущий момент. */ + public AggregateReadiness aggregate(Duration maxStaleness) { + Objects.requireNonNull(maxStaleness, "Максимальный возраст проверки обязателен"); + if (maxStaleness.isNegative() || maxStaleness.isZero()) { + throw new IllegalArgumentException("Максимальный возраст проверки должен быть положительным"); + } + + RegistryState snapshot = state.get(); + Instant assessedAt = clock.instant(); + Instant oldestAllowed = assessedAt.minus(maxStaleness); + int ready = 0; + int migrationFailures = 0; + int migrationPending = 0; + int staleConnectivity = 0; + int unavailableConnectivity = 0; + int unsupported = 0; + + for (TenantStatus status : snapshot.tenants().values()) { + if (status.h2Placeholder()) { + unsupported++; + continue; + } + if (status.migrationState() == MigrationState.FAILED) { + migrationFailures++; + continue; + } + if (status.migrationState() != MigrationState.SUCCEEDED) { + migrationPending++; + continue; + } + if (status.connectivityCheckedAt() == null + || status.connectivityCheckedAt().isBefore(oldestAllowed)) { + staleConnectivity++; + continue; + } + if (status.connectivityState() != ConnectivityState.AVAILABLE) { + unavailableConnectivity++; + continue; + } + ready++; + } + + int desiredCount = snapshot.tenants().size(); + boolean isReady = !snapshot.configurationFailure() + && desiredCount > 0 + && unsupported == 0 + && ready == desiredCount; + return new AggregateReadiness( + isReady, + isReady ? "ГОТОВ" : "НЕ ГОТОВ", + desiredCount, + ready, + desiredCount - ready, + migrationPending, + migrationFailures, + unavailableConnectivity, + staleConnectivity, + unsupported, + snapshot.configurationFailure(), + assessedAt + ); + } + + /** Неизменяемый диагностический снимок для внутренних тестов и интеграции. */ + public Map snapshot() { + return state.get().tenants(); + } + + private void updateByConfig(TenantConfig config, + MigrationState migrationState, + ConnectivityState connectivityState) { + NormalizedConfig normalized = normalize(config); + if (normalized == null) { + markConfigurationFailure(); + return; + } + String fingerprint = fingerprint(normalized); + mutate(current -> { + TenantStatus previous = current.tenants().get(normalized.domain()); + TenantStatus base = previous != null && previous.fingerprint().equals(fingerprint) + ? previous + : TenantStatus.pending(normalized.domain(), fingerprint, normalized.h2Placeholder(), clock.instant()); + Map updated = new LinkedHashMap<>(current.tenants()); + updated.put(normalized.domain(), base.withMigration(migrationState, connectivityState, clock.instant())); + return new RegistryState(updated, current.configurationFailure()); + }); + } + + private void updateExisting(String domain, + MigrationState migrationState, + ConnectivityState connectivityState) { + String normalizedDomain = normalizeDomain(domain); + mutate(current -> { + TenantStatus previous = current.tenants().get(normalizedDomain); + if (previous == null) { + return current; + } + Map updated = new LinkedHashMap<>(current.tenants()); + updated.put(normalizedDomain, previous.withMigration(migrationState, connectivityState, clock.instant())); + return new RegistryState(updated, current.configurationFailure()); + }); + } + + private void mutate(UnaryOperator mutation) { + RegistryState current; + RegistryState updated; + do { + current = state.get(); + updated = Objects.requireNonNull(mutation.apply(current), "Новое состояние реестра обязательно"); + if (updated == current) { + return; + } + } while (!state.compareAndSet(current, updated)); + } + + private NormalizedConfig normalize(TenantConfig config) { + if (config == null || config.getDomain() == null || config.getDomain().isBlank() + || config.getUrl() == null || config.getUrl().isBlank()) { + return null; + } + String domain = config.getDomain().trim().toLowerCase(Locale.ROOT); + String url = config.getUrl().trim(); + if (!DOMAIN_PATTERN.matcher(domain).matches()) { + return null; + } + String name = config.getName() == null || config.getName().isBlank() + ? domain + : config.getName().trim(); + return new NormalizedConfig( + name, + domain, + url, + config.getUsername(), + config.getPassword(), + url.startsWith("jdbc:h2:") + ); + } + + private String normalizeDomain(String domain) { + if (domain == null || domain.isBlank()) { + throw new IllegalArgumentException("Домен tenant не может быть пустым"); + } + return domain.trim().toLowerCase(Locale.ROOT); + } + + private String fingerprint(NormalizedConfig normalized) { + try { + Mac mac = Mac.getInstance(FINGERPRINT_ALGORITHM); + mac.init(new SecretKeySpec(fingerprintKey, FINGERPRINT_ALGORITHM)); + updateFingerprint(mac, normalized.name()); + updateFingerprint(mac, normalized.domain()); + updateFingerprint(mac, normalized.url()); + updateFingerprint(mac, normalized.username()); + updateFingerprint(mac, normalized.password()); + return Base64.getUrlEncoder().withoutPadding().encodeToString(mac.doFinal()); + } catch (GeneralSecurityException cryptoFailure) { + throw new IllegalStateException("Не удалось сформировать безопасный fingerprint конфигурации", cryptoFailure); + } + } + + private void updateFingerprint(Mac mac, String value) { + byte[] bytes = value == null ? new byte[0] : value.getBytes(StandardCharsets.UTF_8); + mac.update(ByteBuffer.allocate(Integer.BYTES).putInt(bytes.length).array()); + mac.update(bytes); + } + + private static byte[] randomFingerprintKey() { + byte[] key = new byte[32]; + new SecureRandom().nextBytes(key); + return key; + } + + public enum MigrationState { + PENDING, + IN_PROGRESS, + SUCCEEDED, + FAILED + } + + public enum ConnectivityState { + UNKNOWN, + AVAILABLE, + UNAVAILABLE + } + + public record ConnectivityTarget(String domain, String fingerprint) { + public ConnectivityTarget { + Objects.requireNonNull(domain, "Домен проверки обязателен"); + Objects.requireNonNull(fingerprint, "Fingerprint проверки обязателен"); + } + } + + public record TenantStatus(String domain, + String fingerprint, + MigrationState migrationState, + ConnectivityState connectivityState, + Instant connectivityCheckedAt, + boolean h2Placeholder, + Instant changedAt) { + public TenantStatus { + Objects.requireNonNull(domain, "Домен состояния обязателен"); + Objects.requireNonNull(fingerprint, "Fingerprint состояния обязателен"); + Objects.requireNonNull(migrationState, "Состояние миграции обязательно"); + Objects.requireNonNull(connectivityState, "Состояние соединения обязательно"); + Objects.requireNonNull(changedAt, "Время изменения состояния обязательно"); + } + + private static TenantStatus pending(String domain, + String fingerprint, + boolean h2Placeholder, + Instant changedAt) { + return new TenantStatus(domain, fingerprint, MigrationState.PENDING, + ConnectivityState.UNKNOWN, null, h2Placeholder, changedAt); + } + + private TenantStatus withMigration(MigrationState newMigrationState, + ConnectivityState newConnectivityState, + Instant now) { + return new TenantStatus(domain, fingerprint, newMigrationState, + newConnectivityState, null, h2Placeholder, now); + } + + private TenantStatus withConnectivity(ConnectivityState newConnectivityState, + Instant checkedAt, + Instant now) { + return new TenantStatus(domain, fingerprint, migrationState, + newConnectivityState, checkedAt, h2Placeholder, now); + } + } + + public record AggregateReadiness(boolean ready, + String status, + int desiredTenants, + int readyTenants, + int unavailableTenants, + int pendingMigrations, + int failedMigrations, + int unavailableConnections, + int staleConnections, + int unsupportedTenants, + boolean configurationFailure, + Instant assessedAt) { + public AggregateReadiness { + Objects.requireNonNull(status, "Текст готовности обязателен"); + Objects.requireNonNull(assessedAt, "Время оценки готовности обязательно"); + } + } + + private record NormalizedConfig(String name, + String domain, + String url, + String username, + String password, + boolean h2Placeholder) { + } + + private record RegistryState(Map tenants, boolean configurationFailure) { + private RegistryState { + tenants = Collections.unmodifiableMap(new LinkedHashMap<>(tenants)); + } + + private static RegistryState empty() { + return new RegistryState(Map.of(), false); + } + } +} diff --git a/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java b/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java index ed3c26f..4de1592 100644 --- a/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java +++ b/backend/src/main/java/com/magistr/app/service/TenantLifecycleService.java @@ -1,8 +1,12 @@ package com.magistr.app.service; -import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater; import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantConfigPersistenceException; +import com.magistr.app.config.tenant.TenantConfigStore; import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.config.tenant.TenantSecretCompensationResult; +import com.magistr.app.config.tenant.TenantSecretUpdateReceipt; +import com.magistr.app.config.tenant.health.TenantReadinessRegistry; import com.zaxxer.hikari.HikariDataSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -31,18 +35,21 @@ public class TenantLifecycleService { ); private final TenantRoutingDataSource routingDataSource; - private final KubernetesTenantSecretUpdater tenantSecretUpdater; + private final TenantConfigStore tenantConfigStore; private final TenantDatabaseMigrationService migrationService; private final RetiredTenantPoolService retiredPoolService; + private final TenantReadinessRegistry readinessRegistry; public TenantLifecycleService(TenantRoutingDataSource routingDataSource, - KubernetesTenantSecretUpdater tenantSecretUpdater, + TenantConfigStore tenantConfigStore, TenantDatabaseMigrationService migrationService, - RetiredTenantPoolService retiredPoolService) { + RetiredTenantPoolService retiredPoolService, + TenantReadinessRegistry readinessRegistry) { this.routingDataSource = routingDataSource; - this.tenantSecretUpdater = tenantSecretUpdater; + this.tenantConfigStore = tenantConfigStore; this.migrationService = migrationService; this.retiredPoolService = retiredPoolService; + this.readinessRegistry = readinessRegistry; } /** @@ -76,16 +83,17 @@ public class TenantLifecycleService { candidate = prepareCandidate(config); verifyConnection(candidate); migrateCandidate(candidate); - persistDesiredWithCompensation(desiredConfigs, oldConfigs); + TenantSecretUpdateReceipt receipt = persistUpsert(config); TenantRoutingDataSource.TenantState previous; try { previous = routingDataSource.swapTenant(config, candidate); } catch (RuntimeException activationFailure) { - throw activationFailureAfterCompensation(oldConfigs, activationFailure); + throw activationFailureAfterCompensation(receipt, activationFailure); } activated = true; + publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config); retireDataSource(previous == null ? null : previous.dataSource()); log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain()); return copyConfig(config); @@ -109,7 +117,7 @@ public class TenantLifecycleService { Map desiredConfigs = new LinkedHashMap<>(oldConfigs); desiredConfigs.remove(domain); - persistDesiredWithCompensation(desiredConfigs, oldConfigs); + TenantSecretUpdateReceipt receipt = persistRemove(domain); TenantRoutingDataSource.TenantState removed; try { @@ -118,9 +126,10 @@ public class TenantLifecycleService { throw new IllegalStateException("Локальное состояние тенанта отсутствует"); } } catch (RuntimeException removalFailure) { - throw removalFailureAfterCompensation(oldConfigs, removalFailure); + throw removalFailureAfterCompensation(receipt, removalFailure); } + publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null); retireDataSource(removed.dataSource()); log.info("Lifecycle тенанта '{}' удалён", domain); return copyConfig(existing); @@ -142,6 +151,12 @@ public class TenantLifecycleService { .map(TenantConfig::getDomain) .collect(Collectors.toSet()); + readinessRegistry.clearConfigurationFailure(); + readinessRegistry.replaceDesired(configs); + if (desiredDomains.size() != configs.size()) { + throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов"); + } + for (TenantConfig config : configs) { if (!routingDataSource.hasTenant(config.getDomain())) { log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain()); @@ -162,13 +177,19 @@ public class TenantLifecycleService { private void activatePersistedConfig(TenantConfig config) { HikariDataSource candidate = null; boolean activated = false; + readinessRegistry.markMigrationStarted(config); try { candidate = prepareCandidate(config); verifyConnection(candidate); migrateCandidate(candidate); TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate); activated = true; + readinessRegistry.markMigrationSucceeded(config); + readinessRegistry.markConnectivity(config.getDomain(), true); retireDataSource(previous == null ? null : previous.dataSource()); + } catch (RuntimeException activationFailure) { + readinessRegistry.markMigrationFailed(config); + throw activationFailure; } finally { if (!activated) { closeCandidateDataSource(candidate); @@ -208,63 +229,117 @@ public class TenantLifecycleService { } } - private void persistDesired(Map desiredConfigs) { - boolean persisted; + private TenantSecretUpdateReceipt persistUpsert(TenantConfig config) { try { - persisted = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(desiredConfigs)); + return requireReceipt(tenantConfigStore.upsertTenant(copyConfig(config))); + } catch (TenantConfigPersistenceException persistenceFailure) { + logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure); + throw new TenantLifecycleException(persistenceFailure.getMessage(), persistenceFailure); } catch (RuntimeException persistenceFailure) { logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure); throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure); } - if (!persisted) { - throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов"); - } } - private void persistDesiredWithCompensation(Map desiredConfigs, - Map oldConfigs) { + private TenantSecretUpdateReceipt persistRemove(String domain) { try { - persistDesired(desiredConfigs); - } catch (TenantLifecycleException persistenceFailure) { - boolean restored = restorePersistedSnapshot(oldConfigs); - String message = restored - ? "Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена" - : "Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret"; - throw new TenantLifecycleException(message, persistenceFailure); + return requireReceipt(tenantConfigStore.removeTenant(domain)); + } catch (TenantConfigPersistenceException persistenceFailure) { + logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure); + throw new TenantLifecycleException(persistenceFailure.getMessage(), persistenceFailure); + } catch (RuntimeException persistenceFailure) { + logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure); + throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure); } } - private TenantLifecycleException activationFailureAfterCompensation(Map oldConfigs, - RuntimeException activationFailure) { - boolean restored = restorePersistedSnapshot(oldConfigs); + private TenantSecretUpdateReceipt requireReceipt(TenantSecretUpdateReceipt receipt) { + if (receipt == null) { + throw new TenantConfigPersistenceException("Хранилище конфигурации тенантов не подтвердило операцию"); + } + return receipt; + } + + private TenantLifecycleException activationFailureAfterCompensation(TenantSecretUpdateReceipt receipt, + RuntimeException activationFailure) { + TenantSecretCompensationResult compensation = compensate(receipt); logTechnicalFailure("Активация подготовленного tenant pool завершилась ошибкой", activationFailure); - String message = restored - ? "Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена" - : "Не удалось активировать подключение тенанта; требуется проверить tenant Secret"; + String message = compensationMessage( + "Не удалось активировать подключение тенанта", + compensation + ); return new TenantLifecycleException(message, activationFailure); } - private TenantLifecycleException removalFailureAfterCompensation(Map oldConfigs, - RuntimeException removalFailure) { - boolean restored = restorePersistedSnapshot(oldConfigs); + private TenantLifecycleException removalFailureAfterCompensation(TenantSecretUpdateReceipt receipt, + RuntimeException removalFailure) { + TenantSecretCompensationResult compensation = compensate(receipt); logTechnicalFailure("Локальное удаление тенанта завершилось ошибкой", removalFailure); - String message = restored - ? "Не удалось отключить тенанта; сохранённая конфигурация восстановлена" - : "Не удалось отключить тенанта; требуется проверить tenant Secret"; + String message = compensationMessage("Не удалось отключить тенанта", compensation); return new TenantLifecycleException(message, removalFailure); } - private boolean restorePersistedSnapshot(Map oldConfigs) { + private TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt) { try { - boolean restored = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(oldConfigs)); - if (!restored) { - log.error("Не удалось компенсирующе восстановить tenant Secret"); + TenantSecretCompensationResult result = tenantConfigStore.compensate(receipt); + if (result == null) { + readinessRegistry.markConfigurationFailure(); + log.error("Хранилище конфигурации не вернуло результат компенсации tenant Secret"); + return null; } - return restored; + if (result == TenantSecretCompensationResult.RESTORED && receipt.persisted()) { + readinessRegistry.clearConfigurationFailure(); + readinessRegistry.replaceDesired(receipt.previousTenants()); + } + if (result == TenantSecretCompensationResult.NOT_REQUIRED && receipt.persisted()) { + readinessRegistry.replaceDesired(receipt.committedTenants()); + } + if (result == TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE + || (result == TenantSecretCompensationResult.NOT_REQUIRED && receipt.persisted())) { + readinessRegistry.markConfigurationFailure(); + } + return result; } catch (RuntimeException compensationFailure) { + readinessRegistry.markConfigurationFailure(); logTechnicalFailure("Компенсирующее восстановление tenant Secret завершилось ошибкой", compensationFailure); - return false; + return null; + } + } + + private String compensationMessage(String prefix, TenantSecretCompensationResult result) { + if (result == TenantSecretCompensationResult.RESTORED) { + return prefix + "; сохранённая конфигурация восстановлена"; + } + if (result == TenantSecretCompensationResult.NOT_REQUIRED) { + return prefix + "; tenant Secret не требовал восстановления"; + } + if (result == TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE) { + return prefix + "; tenant Secret уже изменён другим экземпляром, автоматический откат пропущен"; + } + return prefix + "; требуется проверить tenant Secret"; + } + + private void publishSuccessfulMutation(TenantSecretUpdateReceipt receipt, + List localDesired, + TenantConfig migratedConfig) { + try { + List canonicalDesired = receipt.persisted() + ? receipt.committedTenants() + : localDesired.stream() + .filter(config -> !isH2Placeholder(config)) + .toList(); + if (receipt.persisted()) { + readinessRegistry.clearConfigurationFailure(); + } + readinessRegistry.replaceDesired(canonicalDesired); + if (migratedConfig != null) { + readinessRegistry.markMigrationSucceeded(migratedConfig); + readinessRegistry.markConnectivity(migratedConfig.getDomain(), true); + } + } catch (RuntimeException healthFailure) { + readinessRegistry.markConfigurationFailure(); + logTechnicalFailure("Не удалось обновить состояние readiness после tenant lifecycle", healthFailure); } } @@ -277,6 +352,13 @@ public class TenantLifecycleService { return sorted; } + private boolean isH2Placeholder(TenantConfig config) { + return config != null + && "H2 Placeholder".equals(config.getName()) + && config.getUrl() != null + && config.getUrl().trim().startsWith("jdbc:h2:mem:placeholder"); + } + private TenantConfig validateAndNormalize(TenantConfig config) { if (config == null) { throw new IllegalArgumentException("Конфигурация тенанта обязательна"); diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index 9ceec05..b87931a 100755 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -13,6 +13,19 @@ spring.jpa.open-in-view=false # Мультитенантность app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json} +app.tenants.config-required=${TENANTS_CONFIG_REQUIRED:false} + +# Служебные liveness/readiness probes для Kubernetes +management.endpoints.web.exposure.include=health +management.endpoint.health.probes.enabled=true +management.endpoint.health.group.liveness.include=livenessState +management.endpoint.health.group.readiness.include=readinessState,tenantReadiness +management.endpoint.health.show-details=never +management.endpoint.health.show-components=never +management.health.db.enabled=false + +# Фоновая проверка БД не должна задерживать watcher tenant Secret +spring.task.scheduling.pool.size=2 # JWT авторизация app.jwt.secret=${JWT_SECRET:} diff --git a/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceConcurrencyIntegrationTest.java b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceConcurrencyIntegrationTest.java index 039b53c..ea5e02b 100644 --- a/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceConcurrencyIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/config/auth/RefreshTokenServiceConcurrencyIntegrationTest.java @@ -39,7 +39,10 @@ import static org.assertj.core.api.Assertions.assertThat; @Testcontainers @SpringBootTest( classes = RefreshTokenServiceConcurrencyIntegrationTest.TestApplication.class, - properties = "app.jwt.refresh-ttl=7d" + properties = { + "app.jwt.refresh-ttl=7d", + "management.endpoint.health.validate-group-membership=false" + } ) class RefreshTokenServiceConcurrencyIntegrationTest { diff --git a/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java b/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java index afea29b..2db3531 100644 --- a/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java +++ b/backend/src/test/java/com/magistr/app/config/tenant/KubernetesTenantSecretUpdaterTest.java @@ -1,7 +1,10 @@ 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 okhttp3.mockwebserver.Dispatcher; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; import okhttp3.mockwebserver.RecordedRequest; @@ -13,50 +16,440 @@ import org.junit.jupiter.api.io.TempDir; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; import java.util.Base64; import java.util.List; import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; class KubernetesTenantSecretUpdaterTest { + private static final TypeReference> TENANT_LIST_TYPE = new TypeReference<>() { + }; + private final ObjectMapper objectMapper = new ObjectMapper(); @TempDir Path tempDirectory; @Test - void updatesSecretThroughTrustedCaWithoutSendingPlainTenantJson() throws Exception { + void readsCurrentSecretAndUsesConditionalPutWithoutPlainCredentials() throws Exception { HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); HeldCertificate serverCertificate = serverCertificate(ca, "localhost"); + TenantConfig tenant = tenant("Тестовый университет", "test", "пароль-" + UUID.randomUUID()); try (MockWebServer server = httpsServer(serverCertificate)) { - server.enqueue(new MockResponse().setResponseCode(200)); - TestFiles files = testFiles(ca); - String rawPassword = UUID.randomUUID().toString(); - TenantConfig tenant = new TenantConfig("Университет", "test", "jdbc:test", "user", rawPassword); - KubernetesTenantSecretUpdater updater = updater(files, server); + server.enqueue(secretResponse("17", List.of())); + server.enqueue(secretResponse("18", List.of(tenant))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server); - boolean updated = updater.updateTenantsConfig(List.of(tenant)); + TenantSecretUpdateReceipt receipt = updater.upsertTenant(tenant); - assertThat(updated).isTrue(); - RecordedRequest request = server.takeRequest(2, TimeUnit.SECONDS); - assertThat(request).isNotNull(); - assertThat(request.getPath()).isEqualTo("/api/v1/namespaces/magistr/secrets/tenants-secret"); - String body = request.getBody().readUtf8(); - assertThat(body).doesNotContain(rawPassword); + assertThat(receipt.persisted()).isTrue(); + assertThat(receipt.changed()).isTrue(); + assertThat(receipt.committedResourceVersion()).isEqualTo("18"); + assertThat(domains(receipt.previousTenants())).isEmpty(); + assertThat(domains(receipt.committedTenants())).containsExactly("test"); - JsonNode patch = objectMapper.readTree(body); - String decoded = new String( - Base64.getDecoder().decode(patch.path("data").path("tenants.json").asText()), - StandardCharsets.UTF_8 - ); - assertThat(decoded).contains("\"domain\" : \"test\""); + RecordedRequest get = server.takeRequest(2, TimeUnit.SECONDS); + RecordedRequest put = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(get).isNotNull(); + assertThat(get.getMethod()).isEqualTo("GET"); + assertThat(get.getPath()).isEqualTo("/api/v1/namespaces/magistr/secrets/tenants-secret"); + assertThat(get.getHeader("Authorization")).isEqualTo("Bearer test-service-account-token"); + + assertThat(put).isNotNull(); + assertThat(put.getMethod()).isEqualTo("PUT"); + assertThat(put.getHeader("Content-Type")).startsWith("application/json"); + String body = put.getBody().readUtf8(); + assertThat(body).doesNotContain(tenant.getPassword()); + JsonNode update = objectMapper.readTree(body); + assertThat(update.path("metadata").path("resourceVersion").asText()).isEqualTo("17"); + assertThat(update.path("data").path("other.key").asText()).isEqualTo("cHJlc2VydmU="); + assertThat(domains(decodeTenants(update))).containsExactly("test"); } } + @Test + void skipsPutWhenUpsertIsIdempotent() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig tenant = tenant("Университет", "same", "секрет"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("5", List.of(tenant))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server); + + TenantSecretUpdateReceipt receipt = updater.upsertTenant(tenant); + + assertThat(receipt.persisted()).isTrue(); + assertThat(receipt.changed()).isFalse(); + assertThat(receipt.committedResourceVersion()).isEqualTo("5"); + assertThat(server.getRequestCount()).isEqualTo(1); + } + } + + @Test + void removeTenantKeepsOtherCurrentTenants() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("7", List.of(alpha, beta))); + server.enqueue(secretResponse("8", List.of(beta))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server); + + TenantSecretUpdateReceipt receipt = updater.removeTenant("alpha"); + + assertThat(receipt.changed()).isTrue(); + assertThat(domains(receipt.previousTenants())).containsExactly("alpha", "beta"); + assertThat(domains(receipt.committedTenants())).containsExactly("beta"); + server.takeRequest(2, TimeUnit.SECONDS); + RecordedRequest put = server.takeRequest(2, TimeUnit.SECONDS); + assertThat(domains(decodeTenants(objectMapper.readTree(put.getBody().readUtf8())))) + .containsExactly("beta"); + } + } + + @Test + void retriesConflictAgainstFreshResourceVersion() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("1", List.of())); + server.enqueue(new MockResponse().setResponseCode(409)); + server.enqueue(secretResponse("2", List.of(alpha))); + server.enqueue(secretResponse("3", List.of(alpha, beta))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta); + + assertThat(receipt.committedResourceVersion()).isEqualTo("3"); + assertThat(domains(receipt.previousTenants())).containsExactly("alpha"); + assertThat(domains(receipt.committedTenants())).containsExactly("alpha", "beta"); + assertThat(server.getRequestCount()).isEqualTo(4); + + server.takeRequest(2, TimeUnit.SECONDS); + server.takeRequest(2, TimeUnit.SECONDS); + server.takeRequest(2, TimeUnit.SECONDS); + RecordedRequest finalPut = server.takeRequest(2, TimeUnit.SECONDS); + JsonNode body = objectMapper.readTree(finalPut.getBody().readUtf8()); + assertThat(body.path("metadata").path("resourceVersion").asText()).isEqualTo("2"); + assertThat(domains(decodeTenants(body))).containsExactly("alpha", "beta"); + } + } + + @Test + void stopsAfterBoundedNumberOfConflicts() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + for (int attempt = 1; attempt <= 3; attempt++) { + server.enqueue(secretResponse(String.valueOf(attempt), List.of())); + server.enqueue(new MockResponse().setResponseCode(409)); + } + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + assertThatThrownBy(() -> updater.upsertTenant(tenant("Тест", "test", "секрет"))) + .isInstanceOf(TenantConfigPersistenceException.class) + .hasMessageContaining("конкурентных"); + assertThat(server.getRequestCount()).isEqualTo(6); + } + } + + @Test + void reconcilesRetryablePutResponseWithoutCreatingUnsafeCompensationReceipt() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("20", List.of(alpha))); + server.enqueue(new MockResponse().setResponseCode(503)); + server.enqueue(secretResponse("21", List.of(alpha, beta))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta); + + assertThat(receipt.changed()).isFalse(); + assertThat(receipt.committedResourceVersion()).isEqualTo("21"); + assertThat(domains(receipt.committedTenants())).containsExactly("alpha", "beta"); + assertThat(updater.compensate(receipt)) + .isEqualTo(TenantSecretCompensationResult.NOT_REQUIRED); + assertThat(server.getRequestCount()).isEqualTo(3); + } + } + + @Test + void reconcilesRequestTimeoutWithoutCreatingUnsafeCompensationReceipt() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("24", List.of(alpha))); + server.enqueue(new MockResponse().setResponseCode(408)); + server.enqueue(secretResponse("25", List.of(alpha, beta))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta); + + assertThat(receipt.persisted()).isTrue(); + assertThat(receipt.changed()).isFalse(); + assertThat(receipt.committedResourceVersion()).isEqualTo("25"); + assertThat(domains(receipt.committedTenants())).containsExactly("alpha", "beta"); + assertThat(server.getRequestCount()).isEqualTo(3); + } + } + + @Test + void rejectsSuccessfulMutationResponseWithDifferentTenantContent() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + TenantConfig gamma = tenant("Гамма", "gamma", "секрет-c"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("26", List.of(alpha))); + server.enqueue(secretResponse("27", List.of(alpha, gamma))); + server.enqueue(secretResponse("27", List.of(alpha, gamma))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + assertThatThrownBy(() -> updater.upsertTenant(beta)) + .isInstanceOf(TenantConfigPersistenceException.class) + .hasMessageContaining("не подтвердил сохранённое содержимое"); + assertThat(server.getRequestCount()).isEqualTo(3); + } + } + + @Test + void normalizesDomainsBeforeMergeAndRejectsCaseInsensitiveDuplicates() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig upperAlpha = tenant("Альфа", " ALPHA ", "секрет-a"); + TenantConfig lowerAlpha = tenant("Другая Альфа", "alpha", "секрет-b"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("22", List.of(upperAlpha, lowerAlpha))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + assertThatThrownBy(() -> updater.upsertTenant(tenant("Бета", "beta", "секрет-c"))) + .isInstanceOf(TenantConfigPersistenceException.class) + .hasMessageContaining("повторяющиеся домены"); + assertThat(server.getRequestCount()).isEqualTo(1); + } + } + + @Test + void failsClosedWhenTenantsKeyIsMissing() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + ObjectNode secret = objectMapper.createObjectNode(); + secret.put("apiVersion", "v1"); + secret.put("kind", "Secret"); + secret.putObject("metadata").put("resourceVersion", "23"); + secret.putObject("data"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(objectMapper.writeValueAsString(secret))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + assertThatThrownBy(() -> updater.upsertTenant(tenant("Бета", "beta", "секрет"))) + .isInstanceOf(TenantConfigPersistenceException.class) + .hasMessageContaining("data.tenants.json"); + assertThat(server.getRequestCount()).isEqualTo(1); + } + } + + @Test + void terminalKubernetesErrorIsNotRetried() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(new MockResponse().setResponseCode(403)); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + assertThatThrownBy(() -> updater.upsertTenant(tenant("Бета", "beta", "секрет"))) + .isInstanceOf(TenantConfigPersistenceException.class) + .hasMessageContaining("HTTP 403"); + assertThat(server.getRequestCount()).isEqualTo(1); + } + } + + @Test + void twoConcurrentUpdatersPreserveBothMutations() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + CasDispatcher dispatcher = new CasDispatcher(List.of(), true); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.setDispatcher(dispatcher); + TestFiles files = testFiles(ca); + KubernetesTenantSecretUpdater first = updater(files, server, 4, Duration.ZERO); + KubernetesTenantSecretUpdater second = updater(files, server, 4, Duration.ZERO); + ExecutorService executor = Executors.newFixedThreadPool(2); + try { + Future firstResult = + executor.submit(() -> first.upsertTenant(alpha)); + Future secondResult = + executor.submit(() -> second.upsertTenant(beta)); + + assertThat(firstResult.get(10, TimeUnit.SECONDS).changed()).isTrue(); + assertThat(secondResult.get(10, TimeUnit.SECONDS).changed()).isTrue(); + } finally { + executor.shutdownNow(); + } + + assertThat(domains(dispatcher.currentTenants())).containsExactly("alpha", "beta"); + assertThat(dispatcher.conflictCount()).isGreaterThanOrEqualTo(1); + } + } + + @Test + void compensationRestoresOnlyVersionCommittedByOperation() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("30", List.of(alpha))); + server.enqueue(secretResponse("31", List.of(alpha, beta))); + server.enqueue(secretResponse("31", List.of(alpha, beta))); + server.enqueue(secretResponse("32", List.of(alpha))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta); + TenantSecretCompensationResult result = updater.compensate(receipt); + + assertThat(result).isEqualTo(TenantSecretCompensationResult.RESTORED); + assertThat(server.getRequestCount()).isEqualTo(4); + server.takeRequest(2, TimeUnit.SECONDS); + server.takeRequest(2, TimeUnit.SECONDS); + server.takeRequest(2, TimeUnit.SECONDS); + RecordedRequest compensationPut = server.takeRequest(2, TimeUnit.SECONDS); + JsonNode body = objectMapper.readTree(compensationPut.getBody().readUtf8()); + assertThat(body.path("metadata").path("resourceVersion").asText()).isEqualTo("31"); + assertThat(domains(decodeTenants(body))).containsExactly("alpha"); + } + } + + @Test + void compensationDoesNotOverwriteNewerConcurrentChange() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + TenantConfig gamma = tenant("Гамма", "gamma", "секрет-c"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("40", List.of(alpha))); + server.enqueue(secretResponse("41", List.of(alpha, beta))); + server.enqueue(secretResponse("42", List.of(alpha, beta, gamma))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta); + TenantSecretCompensationResult result = updater.compensate(receipt); + + assertThat(result).isEqualTo(TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE); + assertThat(server.getRequestCount()).isEqualTo(3); + } + } + + @Test + void compensationDoesNotRetryAfterPutLosesResourceVersionRace() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("50", List.of(alpha))); + server.enqueue(secretResponse("51", List.of(alpha, beta))); + server.enqueue(secretResponse("51", List.of(alpha, beta))); + server.enqueue(new MockResponse().setResponseCode(409)); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta); + TenantSecretCompensationResult result = updater.compensate(receipt); + + assertThat(result).isEqualTo(TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE); + assertThat(server.getRequestCount()).isEqualTo(4); + } + } + + @Test + void compensationDoesNotTrustSuccessfulResponseWithDifferentTenantContent() throws Exception { + HeldCertificate ca = certificateAuthority("Доверенный тестовый CA"); + TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a"); + TenantConfig beta = tenant("Бета", "beta", "секрет-b"); + TenantConfig gamma = tenant("Гамма", "gamma", "секрет-c"); + + try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) { + server.enqueue(secretResponse("60", List.of(alpha))); + server.enqueue(secretResponse("61", List.of(alpha, beta))); + server.enqueue(secretResponse("61", List.of(alpha, beta))); + server.enqueue(secretResponse("62", List.of(alpha, gamma))); + server.enqueue(secretResponse("62", List.of(alpha, gamma))); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO); + + TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta); + TenantSecretCompensationResult result = updater.compensate(receipt); + + assertThat(result).isEqualTo(TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE); + assertThat(server.getRequestCount()).isEqualTo(5); + } + } + + @Test + void localModeIsNoopAndDoesNotRequireKubernetesCa() { + Path missingToken = tempDirectory.resolve("missing-token"); + KubernetesTenantSecretUpdater updater = new KubernetesTenantSecretUpdater( + missingToken, + tempDirectory.resolve("missing-namespace"), + tempDirectory.resolve("missing-ca"), + "https://localhost:1", + "tenants-secret", + objectMapper + ); + + TenantSecretUpdateReceipt receipt = updater.upsertTenant(tenant("Локальный", "local", "секрет")); + + assertThat(receipt.persisted()).isFalse(); + assertThat(receipt.changed()).isFalse(); + assertThat(updater.compensate(receipt)) + .isEqualTo(TenantSecretCompensationResult.NOT_REQUIRED); + } + + @Test + void kubernetesModeFailsClosedWhenServiceAccountTokenIsMissing() { + KubernetesTenantSecretUpdater updater = new KubernetesTenantSecretUpdater( + tempDirectory.resolve("missing-kubernetes-token"), + tempDirectory.resolve("missing-kubernetes-namespace"), + tempDirectory.resolve("missing-kubernetes-ca"), + "https://localhost:1", + "tenants-secret", + objectMapper, + 1, + Duration.ZERO, + true + ); + + assertThatThrownBy(() -> updater.upsertTenant(tenant("Тест", "test", "секрет"))) + .isInstanceOf(TenantConfigPersistenceException.class) + .hasMessageContaining("ServiceAccount"); + } + @Test void rejectsServerSignedByDifferentCa() throws Exception { HeldCertificate trustedCa = certificateAuthority("Доверенный тестовый CA"); @@ -64,10 +457,11 @@ class KubernetesTenantSecretUpdaterTest { HeldCertificate serverCertificate = serverCertificate(untrustedCa, "localhost"); try (MockWebServer server = httpsServer(serverCertificate)) { - server.enqueue(new MockResponse().setResponseCode(200)); - KubernetesTenantSecretUpdater updater = updater(testFiles(trustedCa), server); + server.enqueue(secretResponse("1", List.of())); + KubernetesTenantSecretUpdater updater = updater(testFiles(trustedCa), server, 1, Duration.ZERO); - assertThat(updater.updateTenantsConfig(List.of())).isFalse(); + assertThatThrownBy(() -> updater.upsertTenant(tenant("Тест", "test", "секрет"))) + .isInstanceOf(TenantConfigPersistenceException.class); } } @@ -77,10 +471,11 @@ class KubernetesTenantSecretUpdaterTest { HeldCertificate serverCertificate = serverCertificate(ca, "not-localhost.invalid"); try (MockWebServer server = httpsServer(serverCertificate)) { - server.enqueue(new MockResponse().setResponseCode(200)); - KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server); + server.enqueue(secretResponse("1", List.of())); + KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 1, Duration.ZERO); - assertThat(updater.updateTenantsConfig(List.of())).isFalse(); + assertThatThrownBy(() -> updater.upsertTenant(tenant("Тест", "test", "секрет"))) + .isInstanceOf(TenantConfigPersistenceException.class); } } @@ -94,21 +489,33 @@ class KubernetesTenantSecretUpdaterTest { files.caPath(), "https://localhost:1", "tenants-secret", - objectMapper + objectMapper, + 1, + Duration.ZERO ); - assertThat(updater.updateTenantsConfig(List.of())).isFalse(); + assertThatThrownBy(() -> updater.upsertTenant(tenant("Тест", "test", "секрет"))) + .isInstanceOf(TenantConfigPersistenceException.class) + .hasMessageContaining("защищённое соединение"); } private KubernetesTenantSecretUpdater updater(TestFiles files, MockWebServer server) { - String apiBase = server.url("/").toString(); + return updater(files, server, 3, Duration.ofMillis(5)); + } + + private KubernetesTenantSecretUpdater updater(TestFiles files, + MockWebServer server, + int maxAttempts, + Duration retryDelay) { return new KubernetesTenantSecretUpdater( files.tokenPath(), files.namespacePath(), files.caPath(), - apiBase, + server.url("/").toString(), "tenants-secret", - objectMapper + objectMapper, + maxAttempts, + retryDelay ); } @@ -122,6 +529,58 @@ class KubernetesTenantSecretUpdaterTest { return new TestFiles(tokenPath, namespacePath, caPath); } + private TenantConfig tenant(String name, String domain, String password) { + return new TenantConfig( + name, + domain, + "jdbc:postgresql://db/" + domain, + "user-" + domain, + password + ); + } + + private MockResponse secretResponse(String resourceVersion, List tenants) { + return new MockResponse() + .setResponseCode(200) + .setHeader("Content-Type", "application/json") + .setBody(secretJson(resourceVersion, tenants)); + } + + private String secretJson(String resourceVersion, List tenants) { + try { + ObjectNode secret = objectMapper.createObjectNode(); + secret.put("apiVersion", "v1"); + secret.put("kind", "Secret"); + ObjectNode metadata = secret.putObject("metadata"); + metadata.put("name", "tenants-secret"); + metadata.put("namespace", "magistr"); + metadata.put("resourceVersion", resourceVersion); + ObjectNode data = secret.putObject("data"); + data.put("tenants.json", encodeTenants(tenants)); + data.put("other.key", "cHJlc2VydmU="); + return objectMapper.writeValueAsString(secret); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private String encodeTenants(List tenants) throws Exception { + return Base64.getEncoder().encodeToString( + objectMapper.writeValueAsBytes(tenants) + ); + } + + private List decodeTenants(JsonNode secret) throws Exception { + byte[] decoded = Base64.getDecoder().decode( + secret.path("data").path("tenants.json").asText() + ); + return objectMapper.readValue(decoded, TENANT_LIST_TYPE); + } + + private List domains(List tenants) { + return tenants.stream().map(TenantConfig::getDomain).sorted().toList(); + } + private HeldCertificate certificateAuthority(String commonName) { return new HeldCertificate.Builder() .certificateAuthority(1) @@ -149,6 +608,68 @@ class KubernetesTenantSecretUpdaterTest { return server; } + private final class CasDispatcher extends Dispatcher { + private final Object monitor = new Object(); + private final CountDownLatch firstReads; + private final AtomicInteger initialReadCount = new AtomicInteger(); + private final AtomicInteger conflicts = new AtomicInteger(); + private List currentTenants; + private int resourceVersion = 1; + + private CasDispatcher(List initialTenants, boolean synchronizeFirstReads) { + this.currentTenants = new ArrayList<>(initialTenants); + this.firstReads = synchronizeFirstReads ? new CountDownLatch(2) : null; + } + + @Override + public MockResponse dispatch(RecordedRequest request) throws InterruptedException { + if ("GET".equals(request.getMethod())) { + MockResponse snapshot; + synchronized (monitor) { + snapshot = secretResponse(String.valueOf(resourceVersion), currentTenants); + } + if (firstReads != null && initialReadCount.incrementAndGet() <= 2) { + firstReads.countDown(); + if (!firstReads.await(5, TimeUnit.SECONDS)) { + return new MockResponse().setResponseCode(500); + } + } + return snapshot; + } + + if ("PUT".equals(request.getMethod())) { + try { + JsonNode body = objectMapper.readTree(request.getBody().readUtf8()); + int expectedVersion = body.path("metadata").path("resourceVersion").asInt(-1); + List requestedTenants = decodeTenants(body); + synchronized (monitor) { + if (expectedVersion != resourceVersion) { + conflicts.incrementAndGet(); + return new MockResponse().setResponseCode(409); + } + currentTenants = new ArrayList<>(requestedTenants); + resourceVersion++; + return secretResponse(String.valueOf(resourceVersion), currentTenants); + } + } catch (Exception e) { + return new MockResponse().setResponseCode(400); + } + } + + return new MockResponse().setResponseCode(405); + } + + private List currentTenants() { + synchronized (monitor) { + return new ArrayList<>(currentTenants); + } + } + + private int conflictCount() { + return conflicts.get(); + } + } + private record TestFiles(Path tokenPath, Path namespacePath, Path caPath) { } } diff --git a/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java index 7c9409f..79349c1 100644 --- a/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java +++ b/backend/src/test/java/com/magistr/app/config/tenant/TenantConfigWatcherTest.java @@ -1,5 +1,6 @@ package com.magistr.app.config.tenant; +import com.magistr.app.config.tenant.health.TenantReadinessRegistry; import com.magistr.app.service.RetiredTenantPoolService; import com.magistr.app.service.TenantDatabaseMigrationService; import com.magistr.app.service.TenantLifecycleService; @@ -103,11 +104,15 @@ class TenantConfigWatcherTest { TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class); TenantLifecycleService lifecycleService = new TenantLifecycleService( routingDataSource, - mock(KubernetesTenantSecretUpdater.class), + mock(TenantConfigStore.class), mock(TenantDatabaseMigrationService.class), - mock(RetiredTenantPoolService.class) + mock(RetiredTenantPoolService.class), + mock(TenantReadinessRegistry.class) + ); + TenantConfigWatcher watcher = new TenantConfigWatcher( + lifecycleService, + mock(TenantReadinessRegistry.class) ); - TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService); ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString()); CountDownLatch apiMutationReachedRefreshWindow = new CountDownLatch(1); CountDownLatch allowApiRefresh = new CountDownLatch(1); @@ -148,7 +153,10 @@ class TenantConfigWatcherTest { invocation.getArgument(0).run(); return null; }).when(lifecycleService).executeSerialized(any(Runnable.class)); - TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService); + TenantConfigWatcher watcher = new TenantConfigWatcher( + lifecycleService, + mock(TenantReadinessRegistry.class) + ); ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString()); return watcher; } diff --git a/backend/src/test/java/com/magistr/app/config/tenant/TenantDataSourceConfigTest.java b/backend/src/test/java/com/magistr/app/config/tenant/TenantDataSourceConfigTest.java new file mode 100644 index 0000000..2392ecc --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/TenantDataSourceConfigTest.java @@ -0,0 +1,93 @@ +package com.magistr.app.config.tenant; + +import com.magistr.app.config.tenant.health.TenantReadinessRegistry; +import com.magistr.app.service.TenantDatabaseMigrationService; +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class TenantDataSourceConfigTest { + + private static final Duration MAX_CONNECTIVITY_AGE = Duration.ofSeconds(30); + + @TempDir + Path tempDir; + + private final List createdDataSources = new ArrayList<>(); + + @AfterEach + void closeDataSources() { + createdDataSources.forEach(routingDataSource -> routingDataSource + .getTenantDataSource("default") + .filter(HikariDataSource.class::isInstance) + .map(HikariDataSource.class::cast) + .ifPresent(HikariDataSource::close)); + } + + @Test + @DisplayName("Обязательный отсутствующий tenants.json переводит readiness в состояние DOWN") + void requiredMissingConfigMarksConfigurationFailure() { + TenantReadinessRegistry registry = new TenantReadinessRegistry(); + + createDataSource(tempDir.resolve("missing-tenants.json"), true, registry); + + TenantReadinessRegistry.AggregateReadiness readiness = registry.aggregate(MAX_CONNECTIVITY_AGE); + assertThat(readiness.configurationFailure()).isTrue(); + assertThat(readiness.ready()).isFalse(); + } + + @Test + @DisplayName("Невалидный JSON переводит readiness в состояние DOWN") + void invalidJsonMarksConfigurationFailure() throws IOException { + Path configPath = tempDir.resolve("tenants.json"); + Files.writeString(configPath, "{невалидный-json"); + TenantReadinessRegistry registry = new TenantReadinessRegistry(); + + createDataSource(configPath, false, registry); + + TenantReadinessRegistry.AggregateReadiness readiness = registry.aggregate(MAX_CONNECTIVITY_AGE); + assertThat(readiness.configurationFailure()).isTrue(); + assertThat(readiness.ready()).isFalse(); + } + + @Test + @DisplayName("Необязательный отсутствующий tenants.json не создаёт ошибку конфигурации") + void optionalMissingConfigDoesNotMarkConfigurationFailure() { + TenantReadinessRegistry registry = new TenantReadinessRegistry(); + + createDataSource(tempDir.resolve("missing-tenants.json"), false, registry); + + TenantReadinessRegistry.AggregateReadiness readiness = registry.aggregate(MAX_CONNECTIVITY_AGE); + assertThat(readiness.configurationFailure()).isFalse(); + } + + private void createDataSource(Path configPath, + boolean configRequired, + TenantReadinessRegistry registry) { + TenantDataSourceConfig config = new TenantDataSourceConfig(); + ReflectionTestUtils.setField(config, "tenantsConfigPath", configPath.toString()); + ReflectionTestUtils.setField(config, "tenantsConfigRequired", configRequired); + ReflectionTestUtils.setField(config, "defaultDbUrl", ""); + ReflectionTestUtils.setField(config, "defaultDbUsername", ""); + ReflectionTestUtils.setField(config, "defaultDbPassword", ""); + + TenantRoutingDataSource routingDataSource = (TenantRoutingDataSource) config.dataSource( + mock(TenantDatabaseMigrationService.class), + registry + ); + createdDataSources.add(routingDataSource); + } +} diff --git a/backend/src/test/java/com/magistr/app/config/tenant/TenantWebMvcConfigTest.java b/backend/src/test/java/com/magistr/app/config/tenant/TenantWebMvcConfigTest.java new file mode 100644 index 0000000..5929e2f --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/TenantWebMvcConfigTest.java @@ -0,0 +1,41 @@ +package com.magistr.app.config.tenant; + +import com.magistr.app.config.auth.AuthorizationInterceptor; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.util.AntPathMatcher; +import org.springframework.web.servlet.handler.MappedInterceptor; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class TenantWebMvcConfigTest { + + @Test + @DisplayName("Actuator probes исключены из проверки домена тенанта") + void actuatorProbesAreExcludedFromTenantInterceptor() { + TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class); + AuthorizationInterceptor authorizationInterceptor = mock(AuthorizationInterceptor.class); + TenantWebMvcConfig config = new TenantWebMvcConfig(routingDataSource, authorizationInterceptor); + ExposedInterceptorRegistry registry = new ExposedInterceptorRegistry(); + + config.addInterceptors(registry); + + MappedInterceptor tenantInterceptor = (MappedInterceptor) registry.interceptors().get(0); + AntPathMatcher pathMatcher = new AntPathMatcher(); + + assertThat(tenantInterceptor.matches("/actuator/health/liveness", pathMatcher)).isFalse(); + assertThat(tenantInterceptor.matches("/actuator/health/readiness", pathMatcher)).isFalse(); + assertThat(tenantInterceptor.matches("/api/schedule", pathMatcher)).isTrue(); + } + + private static final class ExposedInterceptorRegistry extends InterceptorRegistry { + + private List interceptors() { + return getInterceptors(); + } + } +} diff --git a/backend/src/test/java/com/magistr/app/config/tenant/health/HealthProbeEndpointIntegrationTest.java b/backend/src/test/java/com/magistr/app/config/tenant/health/HealthProbeEndpointIntegrationTest.java new file mode 100644 index 0000000..8293bac --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/health/HealthProbeEndpointIntegrationTest.java @@ -0,0 +1,106 @@ +package com.magistr.app.config.tenant.health; + +import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantRoutingDataSource; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration; +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Clock; +import java.time.Duration; +import java.util.List; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest( + classes = HealthProbeEndpointIntegrationTest.ProbeApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.MOCK, + properties = { + "management.endpoints.web.exposure.include=health", + "management.endpoint.health.probes.enabled=true", + "management.endpoint.health.group.liveness.include=livenessState", + "management.endpoint.health.group.readiness.include=readinessState,tenantReadiness", + "management.endpoint.health.show-details=never", + "management.endpoint.health.show-components=never" + } +) +@AutoConfigureMockMvc +class HealthProbeEndpointIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private TenantReadinessRegistry readinessRegistry; + + @Test + void отказTenantБдСнимаетReadinessНоНеLiveness() throws Exception { + TenantConfig tenant = new TenantConfig( + "Тестовый университет", + "test", + "jdbc:postgresql://database/test", + "user", + "secret" + ); + readinessRegistry.replaceDesired(List.of(tenant)); + readinessRegistry.markMigrationFailed(tenant); + + mockMvc.perform(get("/actuator/health/readiness").header("Host", "10.20.30.40")) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.status").value("DOWN")) + .andExpect(jsonPath("$.components").doesNotExist()); + + mockMvc.perform(get("/actuator/health/liveness").header("Host", "10.20.30.40")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")) + .andExpect(jsonPath("$.components").doesNotExist()); + + readinessRegistry.markMigrationSucceeded(tenant); + readinessRegistry.markConnectivity(tenant.getDomain(), true); + + mockMvc.perform(get("/actuator/health/readiness").header("Host", "10.20.30.40")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("UP")); + } + + @SpringBootConfiguration + @EnableAutoConfiguration(exclude = { + DataSourceAutoConfiguration.class, + HibernateJpaAutoConfiguration.class + }) + static class ProbeApplication { + + @Bean + TenantReadinessRegistry tenantReadinessRegistry() { + return new TenantReadinessRegistry(); + } + + @Bean + TenantDatabaseHealthMonitor tenantDatabaseHealthMonitor(TenantReadinessRegistry registry) { + return new TenantDatabaseHealthMonitor( + registry, + new TenantRoutingDataSource(), + 1, + Duration.ofSeconds(1), + Duration.ofSeconds(30), + Clock.systemUTC() + ); + } + + @Bean("tenantReadiness") + TenantReadinessHealthIndicator tenantReadiness( + TenantReadinessRegistry registry, + TenantDatabaseHealthMonitor monitor) { + return new TenantReadinessHealthIndicator(registry, monitor); + } + } +} diff --git a/backend/src/test/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitorTest.java b/backend/src/test/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitorTest.java new file mode 100644 index 0000000..b31c83a --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/health/TenantDatabaseHealthMonitorTest.java @@ -0,0 +1,264 @@ +package com.magistr.app.config.tenant.health; + +import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantRoutingDataSource; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TenantDatabaseHealthMonitorTest { + + private static final byte[] KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8); + private static final Instant FAST_COMPLETED_AT = Instant.parse("2026-07-15T10:00:01Z"); + private static final Instant TIMEOUT_RECORDED_AT = Instant.parse("2026-07-15T10:00:02Z"); + private static final Instant SLOW_COMPLETED_AT = Instant.parse("2026-07-15T10:00:03Z"); + + @Test + void проверяетTenantПараллельноНоНеПревышаетЛимит() { + TenantReadinessRegistry registry = registry(); + List tenants = new ArrayList<>(); + for (int index = 0; index < 6; index++) { + tenants.add(config("tenant-" + index)); + } + registry.replaceDesired(tenants); + tenants.forEach(registry::markMigrationSucceeded); + + ControlledRoutingDataSource routing = new ControlledRoutingDataSource(60, true); + TenantDatabaseHealthMonitor monitor = monitor(registry, routing, 2, Duration.ofSeconds(1)); + try { + TenantDatabaseHealthMonitor.RefreshSummary summary = monitor.refreshNow(); + + assertEquals(6, summary.requested()); + assertEquals(6, summary.available()); + assertEquals(0, summary.unavailable()); + assertTrue(routing.maxActive.get() >= 2); + assertTrue(routing.maxActive.get() <= 2); + assertTrue(registry.aggregate(Duration.ofSeconds(30)).ready()); + } finally { + monitor.close(); + } + } + + @Test + void превышениеТаймаутаОтмечаетСоединениеНедоступным() { + TenantReadinessRegistry registry = registry(); + TenantConfig tenant = config("slow"); + registry.replaceDesired(List.of(tenant)); + registry.markMigrationSucceeded(tenant); + + ControlledRoutingDataSource routing = new ControlledRoutingDataSource(300, true); + TenantDatabaseHealthMonitor monitor = monitor(registry, routing, 1, Duration.ofMillis(25)); + try { + TenantDatabaseHealthMonitor.RefreshSummary summary = monitor.refreshNow(); + + assertEquals(1, summary.requested()); + assertEquals(0, summary.available()); + assertEquals(1, summary.unavailable()); + assertFalse(registry.aggregate(Duration.ofSeconds(30)).ready()); + } finally { + monitor.close(); + } + } + + @Test + void таймаутПервогоTenantНеОтменяетУжеЗавершившийсяВторой() { + TenantReadinessRegistry registry = registry(); + TenantConfig slow = config("slow"); + TenantConfig fast = config("fast"); + registry.replaceDesired(List.of(slow, fast)); + registry.markMigrationSucceeded(slow); + registry.markMigrationSucceeded(fast); + + CompletionAwareClock clock = new CompletionAwareClock(); + SlowFirstRoutingDataSource routing = new SlowFirstRoutingDataSource(clock); + TenantDatabaseHealthMonitor monitor = new TenantDatabaseHealthMonitor( + registry, + routing, + 2, + Duration.ofMillis(200), + Duration.ofSeconds(30), + clock); + try { + TenantDatabaseHealthMonitor.RefreshSummary summary = monitor.refreshNow(); + + assertEquals(2, summary.requested()); + assertEquals(1, summary.available()); + assertEquals(1, summary.unavailable()); + + TenantReadinessRegistry.TenantStatus slowStatus = registry.snapshot().get("slow"); + TenantReadinessRegistry.TenantStatus fastStatus = registry.snapshot().get("fast"); + assertEquals(TenantReadinessRegistry.ConnectivityState.UNAVAILABLE, + slowStatus.connectivityState()); + assertEquals(TIMEOUT_RECORDED_AT, slowStatus.connectivityCheckedAt()); + assertEquals(TenantReadinessRegistry.ConnectivityState.AVAILABLE, + fastStatus.connectivityState()); + assertEquals(FAST_COMPLETED_AT, fastStatus.connectivityCheckedAt()); + } finally { + monitor.close(); + } + } + + @Test + void неПроверяетTenantДоУспехаМиграцииИХ2() { + TenantReadinessRegistry registry = registry(); + TenantConfig ready = config("ready"); + TenantConfig pending = config("pending"); + TenantConfig h2 = new TenantConfig("h2", "h2", "jdbc:h2:mem:test", "sa", ""); + registry.replaceDesired(List.of(ready, pending, h2)); + registry.markMigrationSucceeded(ready); + registry.markMigrationStarted(pending); + registry.markMigrationSucceeded(h2); + + ControlledRoutingDataSource routing = new ControlledRoutingDataSource(0, true); + TenantDatabaseHealthMonitor monitor = monitor(registry, routing, 3, Duration.ofSeconds(1)); + try { + TenantDatabaseHealthMonitor.RefreshSummary summary = monitor.refreshNow(); + + assertEquals(1, summary.requested()); + assertEquals(1, routing.total.get()); + assertFalse(registry.aggregate(Duration.ofSeconds(30)).ready()); + } finally { + monitor.close(); + } + } + + @Test + void сообщаетНастроенныйMaxStaleness() { + TenantReadinessRegistry registry = registry(); + TenantDatabaseHealthMonitor monitor = new TenantDatabaseHealthMonitor( + registry, + new ControlledRoutingDataSource(0, true), + 1, + Duration.ofSeconds(1), + Duration.ofSeconds(17), + Clock.systemUTC()); + try { + assertEquals(Duration.ofSeconds(17), monitor.maxStaleness()); + assertEquals(1, monitor.parallelism()); + } finally { + monitor.close(); + } + } + + private TenantReadinessRegistry registry() { + return new TenantReadinessRegistry(Clock.systemUTC(), KEY); + } + + private TenantDatabaseHealthMonitor monitor(TenantReadinessRegistry registry, + TenantRoutingDataSource routing, + int parallelism, + Duration timeout) { + return new TenantDatabaseHealthMonitor( + registry, + routing, + parallelism, + timeout, + Duration.ofSeconds(30), + Clock.systemUTC()); + } + + private TenantConfig config(String domain) { + return new TenantConfig(domain, domain, "jdbc:postgresql://db/" + domain, "user", "secret"); + } + + private static final class ControlledRoutingDataSource extends TenantRoutingDataSource { + private final long delayMillis; + private final boolean result; + private final AtomicInteger active = new AtomicInteger(); + private final AtomicInteger maxActive = new AtomicInteger(); + private final AtomicInteger total = new AtomicInteger(); + + private ControlledRoutingDataSource(long delayMillis, boolean result) { + this.delayMillis = delayMillis; + this.result = result; + } + + @Override + public boolean testConnection(String domain) { + total.incrementAndGet(); + int current = active.incrementAndGet(); + maxActive.accumulateAndGet(current, Math::max); + try { + if (delayMillis > 0) { + Thread.sleep(delayMillis); + } + return result; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } finally { + active.decrementAndGet(); + } + } + } + + private static final class SlowFirstRoutingDataSource extends TenantRoutingDataSource { + private final CompletionAwareClock clock; + private final CountDownLatch fastCompleted = new CountDownLatch(1); + + private SlowFirstRoutingDataSource(CompletionAwareClock clock) { + this.clock = clock; + } + + @Override + public boolean testConnection(String domain) { + clock.bind(domain); + if ("fast".equals(domain)) { + fastCompleted.countDown(); + return true; + } + + try { + if (!fastCompleted.await(5, TimeUnit.SECONDS)) { + return false; + } + Thread.sleep(1_000); + return true; + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + return false; + } + } + } + + private static final class CompletionAwareClock extends Clock { + private final ThreadLocal currentDomain = new ThreadLocal<>(); + + private void bind(String domain) { + currentDomain.set(domain); + } + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return switch (String.valueOf(currentDomain.get())) { + case "fast" -> FAST_COMPLETED_AT; + case "slow" -> SLOW_COMPLETED_AT; + default -> TIMEOUT_RECORDED_AT; + }; + } + } +} diff --git a/backend/src/test/java/com/magistr/app/config/tenant/health/TenantReadinessHealthIndicatorTest.java b/backend/src/test/java/com/magistr/app/config/tenant/health/TenantReadinessHealthIndicatorTest.java new file mode 100644 index 0000000..2a1613a --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/health/TenantReadinessHealthIndicatorTest.java @@ -0,0 +1,69 @@ +package com.magistr.app.config.tenant.health; + +import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantRoutingDataSource; +import org.junit.jupiter.api.Test; +import org.springframework.boot.actuate.health.Status; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TenantReadinessHealthIndicatorTest { + + private static final Instant NOW = Instant.parse("2026-07-15T10:00:00Z"); + + @Test + void зарегистрированПодИменемTenantReadiness() { + Component annotation = TenantReadinessHealthIndicator.class.getAnnotation(Component.class); + + assertEquals("tenantReadiness", annotation.value()); + } + + @Test + void возвращаетТолькоАгрегированныеДанные() { + Clock clock = Clock.fixed(NOW, ZoneOffset.UTC); + TenantReadinessRegistry registry = new TenantReadinessRegistry( + clock, + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8)); + TenantConfig tenant = new TenantConfig( + "Secret university", + "alpha", + "jdbc:postgresql://private-host/private-db", + "private-user", + "private-password"); + registry.replaceDesired(List.of(tenant)); + registry.markMigrationSucceeded(tenant); + registry.markConnectivity("alpha", true); + + TenantDatabaseHealthMonitor monitor = new TenantDatabaseHealthMonitor( + registry, + new TenantRoutingDataSource(), + 1, + Duration.ofSeconds(1), + Duration.ofSeconds(30), + clock); + try { + TenantReadinessHealthIndicator indicator = new TenantReadinessHealthIndicator(registry, monitor); + TenantReadinessRegistry.AggregateReadiness readiness = indicator.readiness(); + + assertEquals(Status.UP, indicator.health().getStatus()); + assertTrue(readiness.ready()); + assertEquals("ГОТОВ", readiness.status()); + assertEquals(1, readiness.desiredTenants()); + assertEquals(1, readiness.readyTenants()); + assertEquals(0, readiness.unavailableTenants()); + assertFalse(readiness.configurationFailure()); + } finally { + monitor.close(); + } + } +} diff --git a/backend/src/test/java/com/magistr/app/config/tenant/health/TenantReadinessRegistryTest.java b/backend/src/test/java/com/magistr/app/config/tenant/health/TenantReadinessRegistryTest.java new file mode 100644 index 0000000..5ab66ca --- /dev/null +++ b/backend/src/test/java/com/magistr/app/config/tenant/health/TenantReadinessRegistryTest.java @@ -0,0 +1,166 @@ +package com.magistr.app.config.tenant.health; + +import com.magistr.app.config.tenant.TenantConfig; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class TenantReadinessRegistryTest { + + private static final Instant NOW = Instant.parse("2026-07-15T10:00:00Z"); + private static final Clock CLOCK = Clock.fixed(NOW, ZoneOffset.UTC); + private static final byte[] KEY = "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8); + + @Test + void пустойСписокИH2НеДолжныБытьГотовы() { + TenantReadinessRegistry registry = registry(); + + assertFalse(registry.aggregate(Duration.ofSeconds(30)).ready()); + + TenantConfig h2 = config("default", "jdbc:h2:mem:placeholder", "sa", "секрет"); + registry.replaceDesired(List.of(h2)); + registry.markMigrationSucceeded(h2); + + assertFalse(registry.markConnectivity("default", true)); + TenantReadinessRegistry.AggregateReadiness readiness = registry.aggregate(Duration.ofSeconds(30)); + assertFalse(readiness.ready()); + assertEquals(1, readiness.unsupportedTenants()); + } + + @Test + void готовностьТребуетУспешнуюМиграциюИСвежееСоединение() { + TenantReadinessRegistry registry = registry(); + TenantConfig tenant = config("alpha", "jdbc:postgresql://db/alpha", "user", "secret"); + + registry.replaceDesired(List.of(tenant)); + registry.markMigrationStarted(tenant); + assertFalse(registry.aggregate(Duration.ofSeconds(30)).ready()); + + registry.markMigrationSucceeded(tenant); + assertFalse(registry.aggregate(Duration.ofSeconds(30)).ready()); + + assertTrue(registry.markConnectivity("alpha", true)); + TenantReadinessRegistry.AggregateReadiness readiness = registry.aggregate(Duration.ofSeconds(30)); + assertTrue(readiness.ready()); + assertEquals("ГОТОВ", readiness.status()); + assertEquals(1, readiness.readyTenants()); + } + + @Test + void устаревшаяПроверкаИFingerprintСтаройКонфигурацииИгнорируются() { + TenantReadinessRegistry registry = registry(); + TenantConfig first = config("alpha", "jdbc:postgresql://db/alpha", "user", "first-secret"); + registry.replaceDesired(List.of(first)); + registry.markMigrationSucceeded(first); + TenantReadinessRegistry.ConnectivityTarget oldTarget = registry.connectivityTargets().get(0); + + TenantConfig changed = config("alpha", "jdbc:postgresql://db/alpha", "user", "second-secret"); + registry.replaceDesired(List.of(changed)); + registry.markMigrationSucceeded(changed); + TenantReadinessRegistry.ConnectivityTarget currentTarget = registry.connectivityTargets().get(0); + + assertNotEquals(oldTarget.fingerprint(), currentTarget.fingerprint()); + assertFalse(registry.markConnectivity("alpha", oldTarget.fingerprint(), true, NOW)); + assertTrue(registry.markConnectivity("alpha", currentTarget.fingerprint(), true, NOW)); + assertFalse(registry.markConnectivity( + "alpha", currentTarget.fingerprint(), false, NOW.minusSeconds(1))); + assertTrue(registry.aggregate(Duration.ofSeconds(30)).ready()); + } + + @Test + void эквивалентнаяНормализованнаяКонфигурацияНеСбрасываетГотовность() { + TenantReadinessRegistry registry = registry(); + TenantConfig raw = new TenantConfig( + " Университет ", + " ALPHA ", + " jdbc:postgresql://db/alpha ", + "user", + "secret" + ); + TenantConfig normalized = new TenantConfig( + "Университет", + "alpha", + "jdbc:postgresql://db/alpha", + "user", + "secret" + ); + + registry.replaceDesired(List.of(raw)); + registry.markMigrationSucceeded(raw); + registry.markConnectivity("alpha", true); + String before = registry.snapshot().get("alpha").fingerprint(); + + registry.replaceDesired(List.of(normalized)); + + assertEquals(before, registry.snapshot().get("alpha").fingerprint()); + assertTrue(registry.aggregate(Duration.ofSeconds(30)).ready()); + } + + @Test + void fingerprintНеРаскрываетРеквизиты() { + TenantReadinessRegistry registry = registry(); + TenantConfig tenant = config( + "secret-domain", + "jdbc:postgresql://private-host/private-db", + "private-user", + "private-password"); + + registry.replaceDesired(List.of(tenant)); + String fingerprint = registry.snapshot().get("secret-domain").fingerprint(); + + assertFalse(fingerprint.contains("private-host")); + assertFalse(fingerprint.contains("private-user")); + assertFalse(fingerprint.contains("private-password")); + assertFalse(fingerprint.contains("secret-domain")); + } + + @Test + void явнаяОшибкаКонфигурацииСохраняетсяДоЯвнойОчистки() { + TenantReadinessRegistry registry = registry(); + TenantConfig tenant = config("alpha", "jdbc:postgresql://db/alpha", "user", "secret"); + + registry.markConfigurationFailure(); + registry.replaceDesired(List.of(tenant)); + registry.markMigrationSucceeded(tenant); + registry.markConnectivity("alpha", true); + + assertTrue(registry.aggregate(Duration.ofSeconds(30)).configurationFailure()); + assertFalse(registry.aggregate(Duration.ofSeconds(30)).ready()); + + registry.clearConfigurationFailure(); + assertTrue(registry.aggregate(Duration.ofSeconds(30)).ready()); + } + + @Test + void просроченнаяПроверкаДелаетСервисНеготовым() { + TenantReadinessRegistry registry = registry(); + TenantConfig tenant = config("alpha", "jdbc:postgresql://db/alpha", "user", "secret"); + registry.replaceDesired(List.of(tenant)); + registry.markMigrationSucceeded(tenant); + TenantReadinessRegistry.ConnectivityTarget target = registry.connectivityTargets().get(0); + + registry.markConnectivity("alpha", target.fingerprint(), true, NOW.minusSeconds(31)); + + TenantReadinessRegistry.AggregateReadiness readiness = registry.aggregate(Duration.ofSeconds(30)); + assertFalse(readiness.ready()); + assertEquals(1, readiness.staleConnections()); + } + + private TenantReadinessRegistry registry() { + return new TenantReadinessRegistry(CLOCK, KEY); + } + + private TenantConfig config(String domain, String url, String username, String password) { + return new TenantConfig(domain, domain, url, username, password); + } +} diff --git a/backend/src/test/java/com/magistr/app/controller/DatabaseControllerTest.java b/backend/src/test/java/com/magistr/app/controller/DatabaseControllerTest.java new file mode 100644 index 0000000..62b8db0 --- /dev/null +++ b/backend/src/test/java/com/magistr/app/controller/DatabaseControllerTest.java @@ -0,0 +1,74 @@ +package com.magistr.app.controller; + +import com.magistr.app.config.tenant.TenantConfigWatcher; +import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.service.TenantLifecycleException; +import com.magistr.app.service.TenantLifecycleService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Map; +import java.util.function.Supplier; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup; + +class DatabaseControllerTest { + + private TenantLifecycleService lifecycleService; + private TenantConfigWatcher tenantConfigWatcher; + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class); + lifecycleService = mock(TenantLifecycleService.class); + tenantConfigWatcher = mock(TenantConfigWatcher.class); + mockMvc = standaloneSetup(new DatabaseController( + routingDataSource, + lifecycleService, + tenantConfigWatcher + )).build(); + } + + @Test + void returnsServiceUnavailableInsteadOfFalseSuccessWhenLifecycleFails() throws Exception { + when(lifecycleService.executeSerialized( + org.mockito.ArgumentMatchers.>>>any() + )).thenThrow(new TenantLifecycleException( + "Не удалось выполнить миграции базы данных тенанта", + new IllegalStateException("тестовые технические детали JDBC") + )); + + mockMvc.perform(post("/api/database/tenants") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "Тестовый университет", + "domain": "test", + "url": "jdbc:postgresql://db/test", + "username": "user", + "password": "password" + } + """)) + .andExpect(status().isServiceUnavailable()) + .andExpect(jsonPath("$.success").value(false)) + .andExpect(jsonPath("$.message") + .value("Не удалось выполнить миграции базы данных тенанта")) + .andExpect(jsonPath("$.message") + .value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("JDBC")))); + + verify(tenantConfigWatcher, never()).refreshHash(); + verify(lifecycleService).executeSerialized(any(Supplier.class)); + } +} diff --git a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java index e287f24..1fe0748 100644 --- a/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/AcademicCalendarGridServiceIntegrationTest.java @@ -35,7 +35,10 @@ import static org.mockito.Mockito.verify; @Testcontainers @SpringBootTest( classes = AcademicCalendarGridServiceIntegrationTest.TestApplication.class, - properties = "spring.jpa.open-in-view=false" + properties = { + "spring.jpa.open-in-view=false", + "management.endpoint.health.validate-group-membership=false" + } ) class AcademicCalendarGridServiceIntegrationTest { diff --git a/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java index 56f58c4..60df341 100644 --- a/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/PostgreSqlAdvisoryLockIntegrationTest.java @@ -32,7 +32,8 @@ import static org.assertj.core.api.Assertions.assertThat; classes = PostgreSqlAdvisoryLockIntegrationTest.TestApplication.class, properties = { "spring.flyway.enabled=false", - "spring.jpa.hibernate.ddl-auto=none" + "spring.jpa.hibernate.ddl-auto=none", + "management.endpoint.health.validate-group-membership=false" } ) class PostgreSqlAdvisoryLockIntegrationTest { diff --git a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java index 8f5bb68..f164708 100644 --- a/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/ScheduleRuleServiceConcurrencyIntegrationTest.java @@ -45,7 +45,10 @@ import static org.mockito.Mockito.verify; @Testcontainers @SpringBootTest( classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class, - properties = "spring.jpa.open-in-view=false" + properties = { + "spring.jpa.open-in-view=false", + "management.endpoint.health.validate-group-membership=false" + } ) class ScheduleRuleServiceConcurrencyIntegrationTest { diff --git a/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java b/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java index 12cfeae..97f2463 100644 --- a/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java +++ b/backend/src/test/java/com/magistr/app/service/TenantLifecyclePostgreSqlIntegrationTest.java @@ -1,9 +1,12 @@ package com.magistr.app.service; -import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater; import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantConfigPersistenceException; +import com.magistr.app.config.tenant.TenantConfigStore; import com.magistr.app.config.tenant.TenantContext; import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.config.tenant.TenantSecretUpdateReceipt; +import com.magistr.app.config.tenant.health.TenantReadinessRegistry; import com.zaxxer.hikari.HikariDataSource; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -18,6 +21,7 @@ import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.Statement; import java.time.Duration; +import java.util.List; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -42,7 +46,8 @@ class TenantLifecyclePostgreSqlIntegrationTest { private TenantRoutingDataSource routingDataSource; private TenantDatabaseMigrationService migrationService; - private KubernetesTenantSecretUpdater tenantSecretUpdater; + private TenantConfigStore tenantConfigStore; + private TenantReadinessRegistry readinessRegistry; private TenantLifecycleService lifecycleService; private RetiredTenantPoolService retiredPoolService; private HikariDataSource oldPool; @@ -56,13 +61,15 @@ class TenantLifecyclePostgreSqlIntegrationTest { routingDataSource = new TenantRoutingDataSource(); migrationService = new TenantDatabaseMigrationService(); - tenantSecretUpdater = mock(KubernetesTenantSecretUpdater.class); + tenantConfigStore = mock(TenantConfigStore.class); + readinessRegistry = new TenantReadinessRegistry(); retiredPoolService = new RetiredTenantPoolService(Duration.ofSeconds(2), Duration.ofMillis(25)); lifecycleService = new TenantLifecycleService( routingDataSource, - tenantSecretUpdater, + tenantConfigStore, migrationService, - retiredPoolService + retiredPoolService, + readinessRegistry ); oldConfig = config("Старый tenant", jdbcUrl(OLD_DATABASE), POSTGRES.getPassword()); @@ -77,6 +84,9 @@ class TenantLifecyclePostgreSqlIntegrationTest { oldPool = routingDataSource.prepareTenantDataSource(oldConfig); assertThat(routingDataSource.swapTenant(oldConfig, oldPool)).isNull(); + readinessRegistry.replaceDesired(List.of(oldConfig)); + readinessRegistry.markMigrationSucceeded(oldConfig); + readinessRegistry.markConnectivity(DOMAIN, true); assertOldState(); } @@ -105,10 +115,10 @@ class TenantLifecyclePostgreSqlIntegrationTest { assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(wrongCredentials)) .isInstanceOf(TenantLifecycleException.class) .hasMessage("Не удалось подключиться к базе данных тенанта"); - verify(tenantSecretUpdater, never()).updateTenantsConfig(any()); + verify(tenantConfigStore, never()).upsertTenant(any()); assertOldState(); - reset(tenantSecretUpdater); + reset(tenantConfigStore); TenantConfig badChecksum = config( "Повреждённый Flyway", jdbcUrl(BAD_CHECKSUM_DATABASE), @@ -117,11 +127,13 @@ class TenantLifecyclePostgreSqlIntegrationTest { assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(badChecksum)) .isInstanceOf(TenantLifecycleException.class) .hasMessage("Не удалось выполнить миграции базы данных тенанта"); - verify(tenantSecretUpdater, never()).updateTenantsConfig(any()); + verify(tenantConfigStore, never()).upsertTenant(any()); assertOldState(); - reset(tenantSecretUpdater); - when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true); + reset(tenantConfigStore); + when(tenantConfigStore.upsertTenant(any())).thenThrow( + new TenantConfigPersistenceException("Не удалось сохранить конфигурацию тенантов") + ); TenantConfig validCandidate = config( "Новый tenant", jdbcUrl(VALID_DATABASE), @@ -129,12 +141,19 @@ class TenantLifecyclePostgreSqlIntegrationTest { ); assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(validCandidate)) .isInstanceOf(TenantLifecycleException.class) - .hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена"); - verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any()); + .hasMessage("Не удалось сохранить конфигурацию тенантов"); + verify(tenantConfigStore).upsertTenant(any()); + verify(tenantConfigStore, never()).compensate(any()); assertOldState(); - reset(tenantSecretUpdater); - when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true); + reset(tenantConfigStore); + when(tenantConfigStore.upsertTenant(any())).thenReturn(new TenantSecretUpdateReceipt( + true, + true, + "2", + List.of(oldConfig), + List.of(validCandidate) + )); TenantConfig saved; try (Connection inFlightOldConnection = oldPool.getConnection()) { saved = lifecycleService.addOrUpdateTenant(validCandidate); diff --git a/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java b/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java index 5b6e7f2..d07f05e 100644 --- a/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java +++ b/backend/src/test/java/com/magistr/app/service/TenantLifecycleServiceTest.java @@ -1,8 +1,12 @@ package com.magistr.app.service; -import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater; import com.magistr.app.config.tenant.TenantConfig; +import com.magistr.app.config.tenant.TenantConfigPersistenceException; +import com.magistr.app.config.tenant.TenantConfigStore; import com.magistr.app.config.tenant.TenantRoutingDataSource; +import com.magistr.app.config.tenant.TenantSecretCompensationResult; +import com.magistr.app.config.tenant.TenantSecretUpdateReceipt; +import com.magistr.app.config.tenant.health.TenantReadinessRegistry; import com.zaxxer.hikari.HikariDataSource; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -16,6 +20,8 @@ import org.mockito.quality.Strictness; import java.sql.Connection; import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -35,12 +41,14 @@ class TenantLifecycleServiceTest { @Mock private TenantRoutingDataSource routingDataSource; @Mock - private KubernetesTenantSecretUpdater tenantSecretUpdater; + private TenantConfigStore tenantConfigStore; @Mock private TenantDatabaseMigrationService migrationService; @Mock private RetiredTenantPoolService retiredPoolService; @Mock + private TenantReadinessRegistry readinessRegistry; + @Mock private HikariDataSource candidate; @Mock private HikariDataSource oldDataSource; @@ -56,9 +64,10 @@ class TenantLifecycleServiceTest { void setUp() throws Exception { service = new TenantLifecycleService( routingDataSource, - tenantSecretUpdater, + tenantConfigStore, migrationService, - retiredPoolService + retiredPoolService, + readinessRegistry ); oldAlpha = config("Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password"); beta = config("Beta", "beta", "jdbc:beta", "beta-user", "beta-password"); @@ -70,7 +79,11 @@ class TenantLifecycleServiceTest { when(routingDataSource.prepareTenantDataSource(any(TenantConfig.class))).thenReturn(candidate); when(candidate.getConnection()).thenReturn(candidateConnection); when(candidateConnection.isValid(5)).thenReturn(true); - when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true); + when(tenantConfigStore.upsertTenant(any(TenantConfig.class))) + .thenAnswer(invocation -> upsertReceipt(invocation.getArgument(0))); + when(tenantConfigStore.removeTenant(any(String.class))).thenAnswer(invocation -> + removeReceipt(invocation.getArgument(0))); + when(tenantConfigStore.compensate(any())).thenReturn(TenantSecretCompensationResult.RESTORED); when(routingDataSource.swapTenant(any(TenantConfig.class), any())) .thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource)); } @@ -85,13 +98,14 @@ class TenantLifecycleServiceTest { assertConfig(saved, "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password"); ArgumentCaptor preparedConfig = ArgumentCaptor.forClass(TenantConfig.class); - ArgumentCaptor> persistedConfigs = listCaptor(); + ArgumentCaptor persistedConfig = ArgumentCaptor.forClass(TenantConfig.class); InOrder order = inOrder( routingDataSource, candidate, candidateConnection, migrationService, - tenantSecretUpdater, + tenantConfigStore, + readinessRegistry, retiredPoolService ); order.verify(routingDataSource).snapshotTenantConfigs(); @@ -100,19 +114,63 @@ class TenantLifecycleServiceTest { order.verify(candidateConnection).isValid(5); order.verify(candidateConnection).close(); order.verify(migrationService).migrate(candidate); - order.verify(tenantSecretUpdater).updateTenantsConfig(persistedConfigs.capture()); + order.verify(tenantConfigStore).upsertTenant(persistedConfig.capture()); order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any()); + order.verify(readinessRegistry).replaceDesired(any()); + order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class)); + order.verify(readinessRegistry).markConnectivity("alpha", true); order.verify(retiredPoolService).retire(oldDataSource); assertConfig(preparedConfig.getValue(), "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password"); - assertThat(persistedConfigs.getValue()).extracting(TenantConfig::getDomain) - .containsExactly("alpha", "beta"); - assertConfig(persistedConfigs.getValue().get(0), + assertConfig(persistedConfig.getValue(), "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password"); verify(candidate, never()).close(); } + @Test + void addsNewTenantWithoutRetiringExistingPool() { + Map createSnapshot = new LinkedHashMap<>(); + createSnapshot.put("beta", beta); + when(routingDataSource.snapshotTenantConfigs()).thenReturn(createSnapshot); + when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null); + TenantConfig gamma = config( + "Новый Gamma", " GAMMA ", " jdbc:gamma ", "gamma-user", "gamma-password" + ); + + TenantConfig saved = service.addOrUpdateTenant(gamma); + + assertConfig(saved, "Новый Gamma", "gamma", "jdbc:gamma", "gamma-user", "gamma-password"); + ArgumentCaptor persistedConfig = ArgumentCaptor.forClass(TenantConfig.class); + verify(tenantConfigStore).upsertTenant(persistedConfig.capture()); + assertThat(persistedConfig.getValue().getDomain()).isEqualTo("gamma"); + verify(routingDataSource).swapTenant(any(TenantConfig.class), any()); + verify(retiredPoolService, never()).retire(any()); + verify(candidate, never()).close(); + } + + @Test + void doesNotBlindlyCompensateWhenCreatePersistenceFails() { + Map createSnapshot = new LinkedHashMap<>(); + createSnapshot.put("beta", beta); + when(routingDataSource.snapshotTenantConfigs()).thenReturn(createSnapshot); + when(tenantConfigStore.upsertTenant(any())) + .thenThrow(new TenantConfigPersistenceException("Не удалось сохранить конфигурацию тенантов")); + TenantConfig gamma = config( + "Новый Gamma", "gamma", "jdbc:gamma", "gamma-user", "gamma-password" + ); + + assertThatThrownBy(() -> service.addOrUpdateTenant(gamma)) + .isInstanceOf(TenantLifecycleException.class) + .hasMessage("Не удалось сохранить конфигурацию тенантов"); + + verify(tenantConfigStore).upsertTenant(any()); + verify(tenantConfigStore, never()).compensate(any()); + verify(candidate).close(); + verify(routingDataSource, never()).swapTenant(any(), any()); + verify(retiredPoolService, never()).retire(any()); + } + @Test void closesCandidateAndKeepsOldPoolWhenCredentialsAreWrong() throws Exception { when(candidate.getConnection()).thenThrow(new SQLException("тестовая ошибка аутентификации")); @@ -123,11 +181,27 @@ class TenantLifecycleServiceTest { verify(candidate).close(); verify(migrationService, never()).migrate(any()); - verify(tenantSecretUpdater, never()).updateTenantsConfig(any()); + verify(tenantConfigStore, never()).upsertTenant(any()); verify(routingDataSource, never()).swapTenant(any(), any()); verify(retiredPoolService, never()).retire(oldDataSource); } + @Test + void closesCandidateAndDoesNotPersistWhenConnectionIsInvalid() throws Exception { + when(candidateConnection.isValid(5)).thenReturn(false); + + assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha())) + .isInstanceOf(TenantLifecycleException.class) + .hasMessage("База данных тенанта не подтвердила готовность подключения"); + + verify(candidateConnection).close(); + verify(candidate).close(); + verify(migrationService, never()).migrate(any()); + verify(tenantConfigStore, never()).upsertTenant(any()); + verify(routingDataSource, never()).swapTenant(any(), any()); + verify(retiredPoolService, never()).retire(any()); + } + @Test void closesCandidateAndKeepsOldStateWhenFlywayFails() { org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway")) @@ -138,134 +212,141 @@ class TenantLifecycleServiceTest { .hasMessage("Не удалось выполнить миграции базы данных тенанта"); verify(candidate).close(); - verify(tenantSecretUpdater, never()).updateTenantsConfig(any()); + verify(tenantConfigStore, never()).upsertTenant(any()); verify(routingDataSource, never()).swapTenant(any(), any()); verify(retiredPoolService, never()).retire(oldDataSource); } @Test - void closesCandidateAndRestoresOldSnapshotWhenPersistenceReturnsFalse() { - when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true); + void persistenceConflictDoesNotStartBlindCompensation() { + when(tenantConfigStore.upsertTenant(any())).thenThrow(new TenantConfigPersistenceException( + "Конфигурация тенантов одновременно изменяется другим экземпляром; повторите запрос" + )); assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha())) .isInstanceOf(TenantLifecycleException.class) - .hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена"); + .hasMessage("Конфигурация тенантов одновременно изменяется другим экземпляром; повторите запрос"); - ArgumentCaptor> snapshots = listCaptor(); - verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture()); - assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain) - .containsExactly("alpha", "beta"); - assertConfig(snapshots.getAllValues().get(1).get(0), - "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password"); + verify(tenantConfigStore, never()).compensate(any()); verify(candidate).close(); verify(routingDataSource, never()).swapTenant(any(), any()); - verify(retiredPoolService, never()).retire(oldDataSource); } @Test - void restoresOldSnapshotWhenFirstPersistenceThrows() { - when(tenantSecretUpdater.updateTenantsConfig(any())) - .thenThrow(new IllegalStateException("неопределённый исход PATCH")) - .thenReturn(true); + void unknownPersistenceFailureUsesSafeMessageAndDoesNotCompensate() { + when(tenantConfigStore.upsertTenant(any())) + .thenThrow(new IllegalStateException("неопределённый технический результат PUT")); assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha())) .isInstanceOf(TenantLifecycleException.class) - .hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена"); + .hasMessage("Не удалось сохранить конфигурацию тенантов"); - verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any()); + verify(tenantConfigStore, never()).compensate(any()); verify(candidate).close(); verify(routingDataSource, never()).swapTenant(any(), any()); - verify(retiredPoolService, never()).retire(oldDataSource); } @Test - void reportsDesynchronizationWhenPersistenceAndCompensationFail() { - when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, false); - - assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha())) - .isInstanceOf(TenantLifecycleException.class) - .hasMessage("Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret"); - - verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any()); - verify(candidate).close(); - verify(routingDataSource, never()).swapTenant(any(), any()); - verify(retiredPoolService, never()).retire(oldDataSource); - } - - @Test - void compensatesPersistedSnapshotWhenActivationFails() { + void compensatesOnlyCommittedReceiptWhenActivationFails() { + TenantSecretUpdateReceipt receipt = upsertReceipt(newAlpha()); + when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt); when(routingDataSource.swapTenant(any(), any())) .thenThrow(new IllegalStateException("тестовый отказ активации")); - when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true); assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha())) .isInstanceOf(TenantLifecycleException.class) .hasMessage("Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена"); - ArgumentCaptor> snapshots = listCaptor(); - verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture()); - assertThat(snapshots.getAllValues().get(0)).extracting(TenantConfig::getDomain) - .containsExactly("alpha", "beta"); - assertConfig(snapshots.getAllValues().get(0).get(0), - "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password"); - assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain) - .containsExactly("alpha", "beta"); - assertConfig(snapshots.getAllValues().get(1).get(0), - "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password"); + verify(tenantConfigStore).compensate(receipt); verify(candidate).close(); verify(retiredPoolService, never()).retire(oldDataSource); } @Test - void reportsSecretDesynchronizationWhenActivationAndCompensationFail() { + void concurrentChangeSkipsUnsafeCompensationAndMarksReadinessFailed() { + TenantSecretUpdateReceipt receipt = upsertReceipt(newAlpha()); + when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt); + when(tenantConfigStore.compensate(receipt)) + .thenReturn(TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE); when(routingDataSource.swapTenant(any(), any())) .thenThrow(new IllegalStateException("тестовый отказ активации")); - when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, false); assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha())) .isInstanceOf(TenantLifecycleException.class) - .hasMessage("Не удалось активировать подключение тенанта; требуется проверить tenant Secret"); + .hasMessage("Не удалось активировать подключение тенанта; tenant Secret уже изменён " + + "другим экземпляром, автоматический откат пропущен"); + verify(readinessRegistry).markConfigurationFailure(); verify(candidate).close(); - verify(retiredPoolService, never()).retire(oldDataSource); } @Test - void removalPersistsDesiredStateBeforeAtomicRemovalAndClosesRemovedPool() { + void removalPersistsOperationBeforeAtomicRemovalAndClosesRemovedPool() { when(routingDataSource.removeTenantAtomically("alpha")) .thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource)); TenantConfig removed = service.removeTenant(" ALPHA "); assertConfig(removed, "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password"); - ArgumentCaptor> desired = listCaptor(); - InOrder order = inOrder(routingDataSource, tenantSecretUpdater, retiredPoolService); + InOrder order = inOrder(routingDataSource, tenantConfigStore, readinessRegistry, retiredPoolService); order.verify(routingDataSource).snapshotTenantConfigs(); - order.verify(tenantSecretUpdater).updateTenantsConfig(desired.capture()); + order.verify(tenantConfigStore).removeTenant("alpha"); order.verify(routingDataSource).removeTenantAtomically("alpha"); + order.verify(readinessRegistry).replaceDesired(any()); order.verify(retiredPoolService).retire(oldDataSource); - assertThat(desired.getValue()).extracting(TenantConfig::getDomain).containsExactly("beta"); } @Test - void removalFailureRestoresOldPersistedSnapshotAndKeepsPoolOpen() { + void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() { + TenantSecretUpdateReceipt receipt = removeReceipt("alpha"); + when(tenantConfigStore.removeTenant("alpha")).thenReturn(receipt); when(routingDataSource.removeTenantAtomically("alpha")) .thenThrow(new IllegalStateException("тестовый отказ удаления")); - when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true); assertThatThrownBy(() -> service.removeTenant("alpha")) .isInstanceOf(TenantLifecycleException.class) .hasMessage("Не удалось отключить тенанта; сохранённая конфигурация восстановлена"); - ArgumentCaptor> snapshots = listCaptor(); - verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture()); - assertThat(snapshots.getAllValues().get(0)).extracting(TenantConfig::getDomain) - .containsExactly("beta"); - assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain) - .containsExactly("alpha", "beta"); + verify(tenantConfigStore).compensate(receipt); verify(retiredPoolService, never()).retire(oldDataSource); } + @Test + void persistedTenantBecomesReadyOnlyAfterMigrationAndSwap() { + TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password"); + when(routingDataSource.hasTenant("gamma")).thenReturn(false); + when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null); + + service.synchronizeFromPersistedConfig(List.of(gamma)); + + InOrder order = inOrder(readinessRegistry, migrationService, routingDataSource); + order.verify(readinessRegistry).clearConfigurationFailure(); + order.verify(readinessRegistry).replaceDesired(any()); + order.verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class)); + order.verify(migrationService).migrate(candidate); + order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any()); + order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class)); + order.verify(readinessRegistry).markConnectivity("gamma", true); + verify(tenantConfigStore, never()).upsertTenant(any()); + } + + @Test + void failedPersistedMigrationLeavesTenantNotReadyForWatcherRetry() { + TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password"); + when(routingDataSource.hasTenant("gamma")).thenReturn(false); + org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway")) + .when(migrationService).migrate(candidate); + + assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(gamma))) + .isInstanceOf(TenantLifecycleException.class) + .hasMessage("Не удалось выполнить миграции базы данных тенанта"); + + verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class)); + verify(readinessRegistry).markMigrationFailed(any(TenantConfig.class)); + verify(readinessRegistry, never()).markMigrationSucceeded(any(TenantConfig.class)); + verify(candidate).close(); + } + @Test void acceptsDnsLabelBoundariesAndRejectsTooLongOrDottedDomain() { String maxLengthDomain = "a".repeat(63); @@ -295,6 +376,36 @@ class TenantLifecycleServiceTest { return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password"); } + private TenantSecretUpdateReceipt upsertReceipt(TenantConfig tenant) { + Map committed = new LinkedHashMap<>(oldSnapshot); + committed.put(tenant.getDomain(), tenant); + return new TenantSecretUpdateReceipt( + true, + true, + "2", + sortedConfigs(oldSnapshot), + sortedConfigs(committed) + ); + } + + private TenantSecretUpdateReceipt removeReceipt(String domain) { + Map committed = new LinkedHashMap<>(oldSnapshot); + committed.remove(domain); + return new TenantSecretUpdateReceipt( + true, + true, + "2", + sortedConfigs(oldSnapshot), + sortedConfigs(committed) + ); + } + + private List sortedConfigs(Map configs) { + List result = new ArrayList<>(configs.values()); + result.sort(Comparator.comparing(TenantConfig::getDomain)); + return result; + } + private TenantConfig config(String name, String domain, String url, @@ -316,8 +427,4 @@ class TenantLifecycleServiceTest { assertThat(actual.getPassword()).isEqualTo(password); } - @SuppressWarnings({"unchecked", "rawtypes"}) - private ArgumentCaptor> listCaptor() { - return ArgumentCaptor.forClass((Class) List.class); - } } diff --git a/docs/API.md b/docs/API.md index b2750fa..c49d50e 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1,6 +1,7 @@ # 🔌 REST API -Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON. +Все прикладные эндпоинты имеют префикс `/api/`. Служебные проверки Kubernetes доступны +под `/actuator/health/`. Ответы возвращаются в формате JSON. Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат: @@ -18,6 +19,53 @@ --- +## Служебные проверки состояния + +Эти endpoints предназначены для Kubernetes kubelet, не требуют bearer-токен и не зависят +от tenant-домена в заголовке `Host`. Из Actuator наружу опубликован только `health`, а +состав компонентов, домены, JDBC URL и credentials в ответах скрыты. + +### `GET /actuator/health/liveness` + +Проверяет только жизнеспособность процесса. Недоступность tenant-БД не меняет liveness и +не должна создавать цикл перезапусков pod. + +**Ответ работающего процесса (200):** + +```json +{ + "status": "UP" +} +``` + +### `GET /actuator/health/readiness` + +Разрешает направлять трафик в pod только после успешных миграций и свежей успешной +проверки соединения со всеми обязательными tenant-БД. Пустая конфигурация, H2-заглушка, +ошибка чтения tenant-конфигурации, незавершённая или неуспешная миграция, недоступное либо +просроченное соединение делают pod неготовым. + +**Готов (200):** + +```json +{ + "status": "UP" +} +``` + +**Не готов (503):** + +```json +{ + "status": "DOWN" +} +``` + +`UP` и `DOWN` — стандартные машинные значения протокола Spring Boot Actuator, а не +пользовательские сообщения интерфейса. + +--- + ## Аутентификация ### `POST /api/auth/login` @@ -1090,6 +1138,8 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 ## Управление тенантами (Базы данных) +Все endpoints раздела доступны только пользователю с ролью `ADMIN`. + ### `GET /api/database/status` Статус текущего подключения (определяется по домену запроса). @@ -1107,11 +1157,23 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 ### `GET /api/database/tenants` -Список всех тенантов. +Список всех тенантов. Пароль в ответ не включается. + +```json +[ + { + "name": "СВФУ", + "domain": "swsu", + "url": "jdbc:postgresql://db-host:5432/swsu_db", + "username": "dbuser", + "connected": true + } +] +``` ### `POST /api/database/tenants` -Добавление нового тенанта. +Создание нового или обновление существующего тенанта по `domain`. ```json { @@ -1123,19 +1185,56 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0 } ``` +`domain` приводится к нижнему регистру и должен быть одной DNS-меткой длиной от 1 до +63 символов. `url` обязателен и должен начинаться с `jdbc:`. Если `name` пуст, вместо +него используется нормализованный `domain`. + **Логика:** -1. Создаёт HikariCP пул для нового тенанта -2. Запускает Flyway миграции на его БД -3. Обновляет внешний Kubernetes Secret `tenants-secret` +1. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору. +2. Открывает соединение и явно проверяет его готовность. +3. Выполняет Flyway-валидацию и миграции tenant-БД. +4. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и + выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`. +5. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным + retry/backoff; неизменившаяся конфигурация не записывается повторно. +6. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`. +7. Передаёт прежний pool на отложенное закрытие после завершения активных запросов + либо по истечении защитного таймаута. Backend соединяется с Kubernetes API только через проверенный service-account CA и hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция не возвращается как успешная. Значения credentials никогда не включаются в ответ или лог Kubernetes updater. +При ошибке credentials, проверки соединения, Flyway или сохранения Secret временный pool +закрывается, а прежнее подключение продолжает обслуживать запросы. Если Kubernetes +подтвердил запись, но последующая локальная активация завершилась ошибкой, backend +восстанавливает прежний снимок только пока Secret сохраняет `resourceVersion` этой записи. +Более новое изменение другого pod не перезаписывается. Неопределённый сетевой результат +сначала сверяется повторным чтением и не создаёт основания для небезопасной компенсации. +Ошибка lifecycle возвращается как безопасный русский ответ без JDBC/Flyway details: + +```json +{ + "success": false, + "message": "Не удалось выполнить миграции базы данных тенанта" +} +``` + +HTTP-статусы: `400` для некорректного payload и `503` для ошибки подключения, миграции, +персистенции или активации. + +Вне Kubernetes обновление Secret пропускается, поэтому добавленный через API tenant живёт +только до перезапуска процесса. Для постоянной локальной конфигурации используется +неотслеживаемый файл `backend/tenants.json`. + ### `DELETE /api/database/tenants/{domain}` -Удаление тенанта. +Удаление тенанта. Backend читает актуальный Secret, удаляет только запрошенный `domain` и +выполняет условный `PUT` по `resourceVersion`, затем атомарно исключает tenant из локальной +маршрутизации и передаёт pool на отложенное закрытие. При отказе локального удаления +компенсация также допускается только для подтверждённой версии и не затирает более новое +изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `503`. ### `POST /api/database/test` @@ -1166,5 +1265,8 @@ Kubernetes updater. | `200` | Успех | | `400` | Ошибка валидации или некорректные параметры запроса | | `401` | Неверные учётные данные | +| `403` | Недостаточно прав для операции | | `404` | Ресурс / тенант не найден | +| `409` | Конфликт бизнес-инвариантов или конкурентного изменения | | `500` | Внутренняя ошибка сервера | +| `503` | Внешняя БД или обязательная инфраструктура временно недоступна | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 03dd79c..3d493a2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -78,11 +78,18 @@ sequenceDiagram |-------|-----------| | `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант | | `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта | -| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД | +| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` | | `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы | | `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое | | `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов | -| `KubernetesTenantSecretUpdater` | Обновляет Kubernetes Secret с tenant-конфигурацией через API, проверяя TLS по service-account CA | +| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации | +| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA | +| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе | +| `TenantDatabaseMigrationService` | Запускает Flyway для подготовленного, ещё не опубликованного pool | +| `RetiredTenantPoolService` | После swap дожидается завершения активных подключений старого pool и закрывает его | +| `TenantReadinessRegistry` | Хранит атомарный снимок обязательных tenant, миграций и свежести проверок соединения | +| `TenantDatabaseHealthMonitor` | Ограниченно-параллельно проверяет соединения с обязательными tenant-БД в фоне | +| `TenantReadinessHealthIndicator` | Включает агрегированную готовность tenant-БД в Actuator readiness group | | `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` | ### Определение тенанта @@ -121,16 +128,84 @@ sequenceDiagram ### Жизненный цикл тенанта -1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет `tenants-secret` -2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет смонтированный `tenants.json` → добавляет новые / удаляет отсутствующие тенанты -3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет `tenants-secret` +1. **Добавление или обновление через API:** `TenantLifecycleService` нормализует конфигурацию, + создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет + Flyway. Затем `TenantConfigStore` читает актуальный Secret и применяет к нему только + upsert запрошенного домена. +2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего + `tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект + условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное + состояние читается заново, а собственная мутация повторно применяется к нему. Число + попыток ограничено тремя, задержка между попытками возрастает; идемпотентная операция + не отправляет лишний `PUT`. +3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным + чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`. +4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret + закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена + только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей + `resourceVersion`. Если другой pod уже записал более новую версию, автоматический откат + пропускается. Неопределённый сетевой результат сначала сверяется повторным `GET` и не + создаёт квитанцию для небезопасной компенсации. HTTP-граница получает русское безопасное + сообщение без JDBC/Flyway details. +5. **Drain старого pool:** после успешного swap новые запросы сразу используют candidate, + а прежний Hikari pool закрывается после завершения активных подключений либо по истечении + настраиваемого grace timeout. +6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный + `tenants.json` и применяет добавление/удаление под тем же локальным lifecycle-monitor. +7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только + удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant + из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же + ограничения компенсации по `resourceVersion`. + +Сериализация lifecycle и атомарный снимок защищают запросы внутри одного backend-процесса. +Между pod потерянное обновление предотвращает optimistic locking Kubernetes: каждый конфликт +заставляет заново прочитать Secret и повторно применить только свою доменную мутацию. Локальный +monitor по-прежнему не считается межподовой блокировкой. + +Вне Kubernetes `KubernetesTenantSecretUpdater` работает как runtime-only адаптер: Secret и +локальный `tenants.json` не изменяются. Постоянная локальная конфигурация задаётся файлом до +запуска приложения. Для обращения к Kubernetes API backend загружает `/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS завершает персистенцию безопасным отказом. -`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии. +`TenantConfigWatcher` сравнивает содержимое смонтированного из Secret `tenants.json` по +SHA-256, а не по `String.hashCode()`, чтобы изменение файла не пропускалось из-за +32-битной коллизии. На текущем этапе watcher активирует новые домены и удаляет отсутствующие, +но ещё не заменяет подключение существующего домена при изменении его URL или credentials. +Полная синхронизация такого изменения относится к проблеме №13; до её выполнения новое +значение сбрасывает readiness этого tenant в состояние ожидания, а pod не должен считаться +готовым по старому подключению. + +### Liveness и readiness + +Spring Boot Actuator публикует две независимые служебные группы: + +- `GET /actuator/health/liveness` включает только `livenessState` приложения. Отказ любой + tenant-БД не переводит процесс в liveness failure и не создаёт restart storm; +- `GET /actuator/health/readiness` включает `readinessState` и `tenantReadiness`. Ответ имеет + статус `UP` только если задан непустой набор обязательных tenant, миграция каждого успешно + завершена и для каждого есть свежая успешная проверка соединения. + +`TenantDataSourceConfig` регистрирует обязательный набор до startup-активации и сохраняет +неуспешную миграцию как readiness failure. H2-заглушка позволяет инициализировать Spring JPA, +но никогда не делает приложение готовым к production-трафику. Ошибка чтения обязательного +`tenants.json` также фиксируется отдельно от состояния процесса. + +`TenantDatabaseHealthMonitor` по умолчанию запускается через секунду после старта, затем +проверяет соединения каждые 10 секунд пакетами до четырёх параллельных задач. Таймаут пакета +составляет 6 секунд: по его истечении отменяются только незавершённые задачи, а уже полученные +результаты сохраняются с фактическим временем завершения проверки. Результат старше 30 секунд +считается просроченным. Запоздалый результат привязан к HMAC-fingerprint конкретной конфигурации +и не может отметить новую конфигурацию готовой; исходные credentials в fingerprint и +health-ответ не попадают. Планировщик использует два потока, поэтому ожидающий health-pass не +задерживает периодическое чтение tenant Secret. + +Tenant interceptor исключает `/actuator/**`, а authorization interceptor применяется только +к `/api/**`. При этом наружу опубликован только endpoint `health`, его details и components +скрыты: kubelet получает минимальный `UP`/`DOWN` без доменов и реквизитов tenant-БД. ### Fallback при отсутствии тенантов @@ -138,6 +213,9 @@ sequenceDiagram 1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default` 2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA +Для production обязательность смонтированной конфигурации задаётся +`TENANTS_CONFIG_REQUIRED=true`; отсутствие или ошибка чтения файла снимают readiness. + `TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках. --- diff --git a/docs/INFRASTRUCTURE.md b/docs/INFRASTRUCTURE.md index 795eb45..842df9d 100644 --- a/docs/INFRASTRUCTURE.md +++ b/docs/INFRASTRUCTURE.md @@ -59,7 +59,9 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/ ### Расположение конфигурации -Файлы Kubernetes манифестов: `../k8s/` +Ожидаемое расположение production-манифестов: `../k8s/`. В текущей рабочей копии этот +внешний каталог недоступен, поэтому приведённые ниже изменения являются обязательным +runbook для оператора, а не утверждением о применённом или проверенном состоянии кластера. ### Ключевые ресурсы @@ -92,21 +94,146 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/ | `otel-postgres-secret` | Credentials PostgreSQL receivers для OTel Collector | | `gitea-registry` | Доступ Kubernetes к registry | -Secret `tenants-secret` монтируется в pod backend по пути `/config/tenants.json`. +Backend ожидает ключ `tenants.json` из Secret `tenants-secret` по пути +`/config/tenants.json`. Чтобы kubelet доставлял последующие обновления Secret, необходимо +монтировать каталог `/config`, а не отдельный файл через `subPath`: + +```yaml +volumeMounts: + - name: tenants-config + mountPath: /config + readOnly: true + +volumes: + - name: tenants-config + secret: + secretName: tenants-secret + optional: false + items: + - key: tenants.json + path: tenants.json +``` + +В production для backend также требуется: + +```yaml +env: + - name: TENANTS_CONFIG_REQUIRED + value: "true" +``` + +При обязательной конфигурации отсутствие, пустой список или ошибка разбора `tenants.json` +снимают readiness; резервная H2-БД не считается готовой tenant-БД. При добавлении тенанта через API: -1. `DatabaseController` обновляет in-memory DataSource -2. `KubernetesTenantSecretUpdater` обновляет Secret через Kubernetes API -3. `TenantConfigWatcher` на остальных pod подхватывает смонтированное изменение (каждые 30 сек) +1. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway +2. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку + только upsert запрошенного домена +3. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`; + при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации +4. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо + подтверждённой повторным чтением персистенции +5. старый pool перестаёт принимать новые запросы и закрывается после drain/grace timeout +6. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд + применяет добавление или удаление домена + +Удаление использует тот же persistence-first порядок и применяет к актуальному Secret только +remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами +возрастает, а идемпотентное состояние не записывается повторно. + +Если после подтверждённой записи Secret локальная активация или удаление завершаются ошибкой, +backend восстанавливает прежний снимок только пока Secret сохраняет `resourceVersion` этой +записи. Более новое межподовое изменение останавливает компенсацию и не перезаписывается. +Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое +состояние принимается без квитанции, допускающей небезопасный автоматический откат. + +Watcher пока не заменяет подключение существующего домена при изменении URL или credentials. +Такое изменение сбрасывает readiness, но полное применение на другом pod относится к +проблеме №13. До её исправления нельзя считать watcher механизмом полной синхронизации всех +полей `TenantConfig`. Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server. -RBAC ограничен объектом `tenants-secret`. Скрипт `../k8s/deploy.sh` проверяет наличие -объектов и ключей до rollout, не читая и не печатая их значения. +Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только +Kubernetes verbs `get` и `update` для одного существующего Secret: + +```yaml +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: backend-tenant-secret + namespace: magistr +rules: + - apiGroups: [""] + resources: ["secrets"] + resourceNames: ["tenants-secret"] + verbs: ["get", "update"] +``` + +`patch`, `list`, `watch`, `create` и `delete` текущему Kubernetes-клиенту не нужны. RoleBinding +должен связывать эту Role с фактическим `spec.template.spec.serviceAccountName` backend в +namespace `magistr`. Secret должен существовать заранее и не иметь `immutable: true`. Создание, ротация, отзыв скомпрометированных значений и очистка истории описаны в [`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md). Эти действия требуют полномочий оператора и не выполняются автоматически. +### Liveness и readiness probes backend + +TCP probe подтверждает только открытый порт и не отражает готовность обязательных tenant-БД. +В production Deployment необходимо использовать раздельные HTTP probes: + +```yaml +livenessProbe: + httpGet: + path: /actuator/health/liveness + port: 8080 + +readinessProbe: + httpGet: + path: /actuator/health/readiness + port: 8080 +``` + +Liveness зависит только от состояния процесса. Readiness возвращает `200 UP` после успешных +миграций и свежей успешной проверки всех обязательных tenant-БД, иначе — `503 DOWN` и pod +исключается из приёма трафика без перезапуска. Actuator не публикует components, домены, +JDBC URL или credentials. + +Параметры `initialDelaySeconds`, `periodSeconds`, `timeoutSeconds` и `failureThreshold` нужно +согласовать с текущим Deployment; замена механизма probe не подтверждает автоматически +корректность этих порогов. + +### Внешние действия для production Kubernetes + +Каталог `../k8s/` не изменялся и не проверялся в рамках текущей рабочей копии. Оператору после +получения доступа к нему необходимо: + +1. обновить backend Deployment: directory mount без `subPath`, + `TENANTS_CONFIG_REQUIRED=true` и две HTTP probes; +2. добавить либо сузить Role до `get`/`update` одного `tenants-secret` и проверить RoleBinding + фактического ServiceAccount; +3. убедиться, что внешний `tenants-secret` существует, содержит непустой ключ `tenants.json` + и допускает обновление; +4. отрендерить манифесты и выполнить серверную проверку без применения: + +```bash +kubectl kustomize ../k8s >/dev/null +kubectl apply --dry-run=server -k ../k8s +``` + +5. отдельно проверить полномочия фактического ServiceAccount: + +```bash +BACKEND_SERVICE_ACCOUNT='укажите-фактический-service-account' +kubectl auth can-i get secret/tenants-secret \ + --as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr +kubectl auth can-i update secret/tenants-secret \ + --as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr +``` + +Применение манифестов, rollout и проверка реальных pod являются отдельными внешними +операциями и без разрешения автоматически не выполняются. + ### Обновление backend ```bash