Задача Егора
This commit is contained in:
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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-БД. | Optimistic locking с retry; отдельные liveness/readiness semantics. | Конкурентные PATCH и health tests; Kustomize-проверка probes. |
|
| 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. |
|
| 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. |
|
| 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 после ошибки. |
|
| 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. |
|
| Контейнерный полный `mvn test` | 111 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
|
||||||
| `git diff --check` | Успешно. |
|
| `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
|
1. сравнивать полную нормализованную активную конфигурацию с persisted snapshot, а не только
|
||||||
`TenantRoutingDataSource`, Flyway и персистенцию tenant-конфигурации;
|
набор доменов;
|
||||||
2. определить порядок prepare → connection validation → Flyway → persistence → atomic swap,
|
2. при изменении URL/username/password выполнять prepare → verify → Flyway → atomic swap без
|
||||||
не закрывающий рабочий pool до полной готовности кандидата;
|
повторной записи Secret;
|
||||||
3. добавить компенсирующий rollback для ошибок персистенции/активации и явные русские
|
3. считать hash применённым только после полной успешной синхронизации и не подтверждать
|
||||||
ошибки без ложного `200`;
|
произвольное содержимое mount через `refreshHash()` после API-мутации;
|
||||||
4. покрыть неверные credentials, ошибку миграции и ошибку персистенции тестами, доказывающими
|
4. добавить bounded retry/backoff и тесты изменения credentials, восстановления прежнего
|
||||||
сохранность старого подключения;
|
файла после ошибки и межподового merged snapshot;
|
||||||
5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 9.
|
5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 13.
|
||||||
|
|||||||
@@ -27,6 +27,12 @@
|
|||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Стандартные liveness/readiness probes Kubernetes -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- JPA + PostgreSQL -->
|
<!-- JPA + PostgreSQL -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
package com.magistr.app.config.tenant;
|
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.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -8,6 +11,7 @@ import org.springframework.stereotype.Service;
|
|||||||
import javax.net.ssl.SSLContext;
|
import javax.net.ssl.SSLContext;
|
||||||
import javax.net.ssl.SSLParameters;
|
import javax.net.ssl.SSLParameters;
|
||||||
import javax.net.ssl.TrustManagerFactory;
|
import javax.net.ssl.TrustManagerFactory;
|
||||||
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
@@ -19,19 +23,29 @@ import java.nio.file.Path;
|
|||||||
import java.security.KeyStore;
|
import java.security.KeyStore;
|
||||||
import java.security.cert.Certificate;
|
import java.security.cert.Certificate;
|
||||||
import java.security.cert.CertificateFactory;
|
import java.security.cert.CertificateFactory;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
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.
|
||||||
*
|
*
|
||||||
* <p>Работает только внутри Kubernetes pod и использует service-account token и
|
* <p>Каждая мутация сначала читает актуальный Secret, применяет изменение к полученному
|
||||||
* service-account CA. В локальной разработке персистенция пропускается.</p>
|
* списку и отправляет полный объект обратно условным {@code PUT} с
|
||||||
|
* {@code metadata.resourceVersion}. При конфликте операция повторно читает актуальное
|
||||||
|
* состояние и заново применяет только свою мутацию.</p>
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
public class KubernetesTenantSecretUpdater {
|
public class KubernetesTenantSecretUpdater implements TenantConfigStore {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(KubernetesTenantSecretUpdater.class);
|
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");
|
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_API_BASE = "https://kubernetes.default.svc";
|
||||||
private static final String DEFAULT_SECRET_NAME = "tenants-secret";
|
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<List<TenantConfig>> TENANT_LIST_TYPE = new TypeReference<>() {
|
||||||
|
};
|
||||||
|
|
||||||
private final ObjectMapper objectMapper;
|
private final ObjectMapper objectMapper;
|
||||||
private final Path tokenPath;
|
private final Path tokenPath;
|
||||||
@@ -51,6 +74,10 @@ public class KubernetesTenantSecretUpdater {
|
|||||||
private final String apiBase;
|
private final String apiBase;
|
||||||
private final String secretName;
|
private final String secretName;
|
||||||
private final boolean runningInKubernetes;
|
private final boolean runningInKubernetes;
|
||||||
|
private final int maxAttempts;
|
||||||
|
private final Duration retryDelay;
|
||||||
|
|
||||||
|
private volatile HttpClient httpClient;
|
||||||
|
|
||||||
public KubernetesTenantSecretUpdater() {
|
public KubernetesTenantSecretUpdater() {
|
||||||
this(
|
this(
|
||||||
@@ -69,79 +96,572 @@ public class KubernetesTenantSecretUpdater {
|
|||||||
String apiBase,
|
String apiBase,
|
||||||
String secretName,
|
String secretName,
|
||||||
ObjectMapper objectMapper) {
|
ObjectMapper objectMapper) {
|
||||||
this.tokenPath = tokenPath;
|
this(
|
||||||
this.namespacePath = namespacePath;
|
tokenPath,
|
||||||
this.caPath = caPath;
|
namespacePath,
|
||||||
this.apiBase = apiBase.endsWith("/") ? apiBase.substring(0, apiBase.length() - 1) : apiBase;
|
caPath,
|
||||||
this.secretName = secretName;
|
apiBase,
|
||||||
this.objectMapper = objectMapper;
|
secretName,
|
||||||
this.runningInKubernetes = Files.exists(tokenPath);
|
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) {
|
if (!runningInKubernetes) {
|
||||||
log.info("Приложение запущено вне Kubernetes — обновление tenant Secret будет пропущено");
|
log.info("Приложение запущено вне Kubernetes — постоянное изменение tenant Secret отключено");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Override
|
||||||
* Сохраняет полный список тенантов в ключе {@code tenants.json} Kubernetes Secret.
|
public TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant) {
|
||||||
*
|
TenantConfig requested = TenantSecretUpdateReceipt.copyOf(
|
||||||
* @return {@code true}, если Secret обновлён или приложение запущено вне Kubernetes
|
Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")
|
||||||
*/
|
);
|
||||||
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
String domain = normalizeDomain(requested.getDomain());
|
||||||
|
requested.setDomain(domain);
|
||||||
|
|
||||||
if (!runningInKubernetes) {
|
if (!runningInKubernetes) {
|
||||||
log.warn("Приложение запущено вне Kubernetes, персистенция tenant Secret пропущена");
|
log.warn("Приложение запущено вне Kubernetes, постоянное добавление тенанта пропущено");
|
||||||
return true;
|
return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of(requested));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
return mutate(tenants -> {
|
||||||
String token = Files.readString(tokenPath).trim();
|
List<TenantConfig> updated = new ArrayList<>(tenants);
|
||||||
String namespace = Files.readString(namespacePath).trim();
|
updated.removeIf(existing -> domain.equals(existing.getDomain()));
|
||||||
if (token.isBlank() || namespace.isBlank()) {
|
updated.add(requested);
|
||||||
throw new IllegalStateException("ServiceAccount token или namespace не настроены");
|
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<TenantConfig> 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);
|
if (!committedVersion.equals(current.resourceVersion())) {
|
||||||
String encodedTenants = Base64.getEncoder()
|
log.warn("Компенсация tenant Secret пропущена: после исходной операции Secret уже изменён");
|
||||||
.encodeToString(tenantsJson.getBytes(StandardCharsets.UTF_8));
|
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
|
||||||
String patchBody = objectMapper.writeValueAsString(Map.of(
|
}
|
||||||
"data", Map.of("tenants.json", encodedTenants)
|
if (!sameTenants(current.tenants(), receipt.committedTenants())) {
|
||||||
));
|
throw new TenantConfigPersistenceException(
|
||||||
|
"Содержимое tenant Secret не соответствует resourceVersion из квитанции"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse<String> 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<TenantConfig>, List<TenantConfig>> 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<TenantConfig> previous = normalizeTenants(current.tenants());
|
||||||
|
List<TenantConfig> committed = normalizeTenants(mutation.apply(previous));
|
||||||
|
if (sameTenants(previous, committed)) {
|
||||||
|
return new TenantSecretUpdateReceipt(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
current.resourceVersion(),
|
||||||
|
previous,
|
||||||
|
committed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse<String> 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<String> 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<String> putSecret(RequestContext context,
|
||||||
|
CurrentSecret current,
|
||||||
|
List<TenantConfig> 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<TenantConfig> previous,
|
||||||
|
List<TenantConfig> 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<TenantConfig> previous,
|
||||||
|
List<TenantConfig> 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<TenantConfig> 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<TenantConfig> normalizeTenants(List<TenantConfig> tenants) {
|
||||||
|
if (tenants == null) {
|
||||||
|
throw new TenantConfigPersistenceException("Список конфигураций в tenant Secret не задан");
|
||||||
|
}
|
||||||
|
List<TenantConfig> normalized = new ArrayList<>(tenants.size());
|
||||||
|
Set<String> 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<TenantConfig> first, List<TenantConfig> second) {
|
||||||
|
List<TenantConfig> left = normalizeTenants(first);
|
||||||
|
List<TenantConfig> 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(
|
URI uri = URI.create(String.format(
|
||||||
"%s/api/v1/namespaces/%s/secrets/%s",
|
"%s/api/v1/namespaces/%s/secrets/%s",
|
||||||
apiBase,
|
apiBase,
|
||||||
namespace,
|
namespace,
|
||||||
secretName
|
secretName
|
||||||
));
|
));
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
return new RequestContext(token, uri);
|
||||||
.uri(uri)
|
} catch (TenantConfigPersistenceException e) {
|
||||||
.header("Authorization", "Bearer " + token)
|
throw e;
|
||||||
.header("Content-Type", "application/strategic-merge-patch+json")
|
|
||||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
|
|
||||||
.build();
|
|
||||||
|
|
||||||
HttpResponse<Void> 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;
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Не удалось безопасно обновить tenant Secret: errorType={}",
|
throw new TenantConfigPersistenceException(
|
||||||
e.getClass().getSimpleName());
|
"Не удалось прочитать параметры ServiceAccount для tenant Secret",
|
||||||
log.debug("Технические детали ошибки tenant Secret", e);
|
e
|
||||||
return false;
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
HttpClient createSecureClient(Path serviceAccountCaPath) throws Exception {
|
||||||
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
|
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
|
||||||
Collection<? extends Certificate> certificates;
|
Collection<? extends Certificate> certificates;
|
||||||
@@ -171,8 +691,102 @@ public class KubernetesTenantSecretUpdater {
|
|||||||
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
|
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
|
||||||
|
|
||||||
return HttpClient.newBuilder()
|
return HttpClient.newBuilder()
|
||||||
|
.connectTimeout(CONNECT_TIMEOUT)
|
||||||
.sslContext(sslContext)
|
.sslContext(sslContext)
|
||||||
.sslParameters(sslParameters)
|
.sslParameters(sslParameters)
|
||||||
.build();
|
.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<TenantConfig> 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<TenantConfig> tenants) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RetryableApiException extends IOException {
|
||||||
|
private RetryableApiException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package com.magistr.app.config.tenant;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||||
import com.magistr.app.service.TenantLifecycleService;
|
import com.magistr.app.service.TenantLifecycleService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -29,16 +30,23 @@ public class TenantConfigWatcher {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
||||||
|
|
||||||
private final TenantLifecycleService tenantLifecycleService;
|
private final TenantLifecycleService tenantLifecycleService;
|
||||||
|
private final TenantReadinessRegistry readinessRegistry;
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
@Value("${app.tenants.config-path:tenants.json}")
|
@Value("${app.tenants.config-path:tenants.json}")
|
||||||
private String tenantsConfigPath;
|
private String tenantsConfigPath;
|
||||||
|
|
||||||
|
@Value("${app.tenants.config-required:false}")
|
||||||
|
private boolean tenantsConfigRequired;
|
||||||
|
|
||||||
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
||||||
private String lastConfigHash = "";
|
private String lastConfigHash = "";
|
||||||
|
private boolean configurationUnavailable;
|
||||||
|
|
||||||
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService) {
|
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
|
||||||
|
TenantReadinessRegistry readinessRegistry) {
|
||||||
this.tenantLifecycleService = tenantLifecycleService;
|
this.tenantLifecycleService = tenantLifecycleService;
|
||||||
|
this.readinessRegistry = readinessRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -52,12 +60,21 @@ public class TenantConfigWatcher {
|
|||||||
private void watchForChangesSerialized() {
|
private void watchForChangesSerialized() {
|
||||||
try {
|
try {
|
||||||
File file = new File(tenantsConfigPath);
|
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 content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
String hash = configHash(content);
|
String hash = configHash(content);
|
||||||
|
|
||||||
if (hash.equals(lastConfigHash)) {
|
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||||
return; // Ничего не изменилось
|
return; // Ничего не изменилось
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +82,11 @@ public class TenantConfigWatcher {
|
|||||||
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||||
syncTenants(newTenants);
|
syncTenants(newTenants);
|
||||||
lastConfigHash = hash;
|
lastConfigHash = hash;
|
||||||
|
configurationUnavailable = false;
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
readinessRegistry.markConfigurationFailure();
|
||||||
|
configurationUnavailable = true;
|
||||||
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
|
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
|
||||||
log.debug("Технические детали синхронизации конфига тенантов", e);
|
log.debug("Технические детали синхронизации конфига тенантов", e);
|
||||||
}
|
}
|
||||||
@@ -85,8 +105,13 @@ public class TenantConfigWatcher {
|
|||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||||
lastConfigHash = configHash(content);
|
lastConfigHash = configHash(content);
|
||||||
|
} else if (tenantsConfigRequired) {
|
||||||
|
readinessRegistry.markConfigurationFailure();
|
||||||
|
configurationUnavailable = true;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
readinessRegistry.markConfigurationFailure();
|
||||||
|
configurationUnavailable = true;
|
||||||
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
|
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
|
||||||
e.getClass().getSimpleName());
|
e.getClass().getSimpleName());
|
||||||
log.debug("Технические детали обновления хеша тенантов", e);
|
log.debug("Технические детали обновления хеша тенантов", e);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.magistr.app.config.tenant;
|
|||||||
|
|
||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||||
import com.magistr.app.service.TenantDatabaseMigrationService;
|
import com.magistr.app.service.TenantDatabaseMigrationService;
|
||||||
import com.zaxxer.hikari.HikariDataSource;
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
@@ -18,13 +19,12 @@ import org.springframework.transaction.PlatformTransactionManager;
|
|||||||
import jakarta.persistence.EntityManagerFactory;
|
import jakarta.persistence.EntityManagerFactory;
|
||||||
import javax.sql.DataSource;
|
import javax.sql.DataSource;
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Конфигурация мультитенантного DataSource.
|
* Конфигурация мультитенантного DataSource.
|
||||||
* Загружает тенанты из JSON-файла (mounted ConfigMap).
|
* Загружает тенанты из JSON-файла (смонтированный Kubernetes Secret).
|
||||||
*
|
*
|
||||||
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
|
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
|
||||||
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
||||||
@@ -37,6 +37,9 @@ public class TenantDataSourceConfig {
|
|||||||
@Value("${app.tenants.config-path:tenants.json}")
|
@Value("${app.tenants.config-path:tenants.json}")
|
||||||
private String tenantsConfigPath;
|
private String tenantsConfigPath;
|
||||||
|
|
||||||
|
@Value("${app.tenants.config-required:false}")
|
||||||
|
private boolean tenantsConfigRequired;
|
||||||
|
|
||||||
@Value("${spring.datasource.url:}")
|
@Value("${spring.datasource.url:}")
|
||||||
private String defaultDbUrl;
|
private String defaultDbUrl;
|
||||||
|
|
||||||
@@ -48,11 +51,13 @@ public class TenantDataSourceConfig {
|
|||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
@Primary
|
@Primary
|
||||||
public DataSource dataSource(TenantDatabaseMigrationService migrationService) {
|
public DataSource dataSource(TenantDatabaseMigrationService migrationService,
|
||||||
|
TenantReadinessRegistry readinessRegistry) {
|
||||||
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
|
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
|
||||||
|
|
||||||
// Загружаем тенантов из JSON (read-only ConfigMap mount)
|
// Загружаем тенантов из JSON (read-only Secret mount)
|
||||||
List<TenantConfig> tenants = loadTenantsFromFile();
|
TenantConfigLoadResult loadResult = loadTenantsFromFile();
|
||||||
|
List<TenantConfig> tenants = new ArrayList<>(loadResult.tenants());
|
||||||
|
|
||||||
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
|
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
|
||||||
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
|
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
|
||||||
@@ -63,9 +68,15 @@ public class TenantDataSourceConfig {
|
|||||||
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
|
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readinessRegistry.clearConfigurationFailure();
|
||||||
|
readinessRegistry.replaceDesired(tenants);
|
||||||
|
if (loadResult.failed() || (tenantsConfigRequired && loadResult.tenants().isEmpty())) {
|
||||||
|
readinessRegistry.markConfigurationFailure();
|
||||||
|
}
|
||||||
|
|
||||||
// Регистрируем тенантов
|
// Регистрируем тенантов
|
||||||
for (TenantConfig tenant : tenants) {
|
for (TenantConfig tenant : tenants) {
|
||||||
registerPreparedTenant(routingDataSource, migrationService, tenant);
|
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
||||||
@@ -79,7 +90,13 @@ public class TenantDataSourceConfig {
|
|||||||
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
|
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
|
||||||
"sa", ""
|
"sa", ""
|
||||||
);
|
);
|
||||||
if (!registerPreparedTenant(routingDataSource, migrationService, h2Fallback)) {
|
if (!registerPreparedTenant(
|
||||||
|
routingDataSource,
|
||||||
|
migrationService,
|
||||||
|
readinessRegistry,
|
||||||
|
h2Fallback,
|
||||||
|
false
|
||||||
|
)) {
|
||||||
throw new IllegalStateException("Не удалось создать резервный H2 DataSource");
|
throw new IllegalStateException("Не удалось создать резервный H2 DataSource");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,31 +135,43 @@ public class TenantDataSourceConfig {
|
|||||||
return new JpaTransactionManager(emf);
|
return new JpaTransactionManager(emf);
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<TenantConfig> loadTenantsFromFile() {
|
private TenantConfigLoadResult loadTenantsFromFile() {
|
||||||
File file = new File(tenantsConfigPath);
|
File file = new File(tenantsConfigPath);
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
log.info("Файл конфигурации тенантов не найден");
|
if (tenantsConfigRequired) {
|
||||||
return new ArrayList<>();
|
log.error("Обязательный файл конфигурации тенантов не найден");
|
||||||
|
} else {
|
||||||
|
log.info("Файл конфигурации тенантов не найден");
|
||||||
|
}
|
||||||
|
return new TenantConfigLoadResult(List.of(), tenantsConfigRequired);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ObjectMapper mapper = new ObjectMapper();
|
ObjectMapper mapper = new ObjectMapper();
|
||||||
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
|
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
|
||||||
|
if (list == null) {
|
||||||
|
throw new IllegalArgumentException("Корневое значение конфигурации тенантов должно быть массивом");
|
||||||
|
}
|
||||||
log.info("Загружено конфигураций тенантов: {}", list.size());
|
log.info("Загружено конфигураций тенантов: {}", list.size());
|
||||||
return list;
|
return new TenantConfigLoadResult(List.copyOf(list), false);
|
||||||
} catch (IOException e) {
|
} catch (Exception e) {
|
||||||
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
|
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
|
||||||
e.getClass().getSimpleName());
|
e.getClass().getSimpleName());
|
||||||
log.debug("Технические детали чтения конфигурации тенантов", e);
|
log.debug("Технические детали чтения конфигурации тенантов", e);
|
||||||
return new ArrayList<>();
|
return new TenantConfigLoadResult(List.of(), true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean registerPreparedTenant(TenantRoutingDataSource routingDataSource,
|
private boolean registerPreparedTenant(TenantRoutingDataSource routingDataSource,
|
||||||
TenantDatabaseMigrationService migrationService,
|
TenantDatabaseMigrationService migrationService,
|
||||||
TenantConfig tenant) {
|
TenantReadinessRegistry readinessRegistry,
|
||||||
|
TenantConfig tenant,
|
||||||
|
boolean requiredForReadiness) {
|
||||||
HikariDataSource candidate = null;
|
HikariDataSource candidate = null;
|
||||||
boolean activated = false;
|
boolean activated = false;
|
||||||
|
if (requiredForReadiness) {
|
||||||
|
readinessRegistry.markMigrationStarted(tenant);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
candidate = routingDataSource.prepareTenantDataSource(tenant);
|
candidate = routingDataSource.prepareTenantDataSource(tenant);
|
||||||
try (Connection connection = candidate.getConnection()) {
|
try (Connection connection = candidate.getConnection()) {
|
||||||
@@ -155,10 +184,17 @@ public class TenantDataSourceConfig {
|
|||||||
}
|
}
|
||||||
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(tenant, candidate);
|
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(tenant, candidate);
|
||||||
activated = true;
|
activated = true;
|
||||||
|
if (requiredForReadiness) {
|
||||||
|
readinessRegistry.markMigrationSucceeded(tenant);
|
||||||
|
readinessRegistry.markConnectivity(tenant.getDomain(), true);
|
||||||
|
}
|
||||||
closeDataSource(previous == null ? null : previous.dataSource());
|
closeDataSource(previous == null ? null : previous.dataSource());
|
||||||
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
|
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
|
||||||
return true;
|
return true;
|
||||||
} catch (Exception startupFailure) {
|
} catch (Exception startupFailure) {
|
||||||
|
if (requiredForReadiness) {
|
||||||
|
readinessRegistry.markMigrationFailed(tenant);
|
||||||
|
}
|
||||||
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
|
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
|
||||||
tenant.getDomain(), startupFailure.getClass().getSimpleName());
|
tenant.getDomain(), startupFailure.getClass().getSimpleName());
|
||||||
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
|
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
|
||||||
@@ -181,4 +217,7 @@ public class TenantDataSourceConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private record TenantConfigLoadResult(List<TenantConfig> tenants, boolean failed) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат компенсирующего изменения конфигурации тенантов.
|
||||||
|
*/
|
||||||
|
public enum TenantSecretCompensationResult {
|
||||||
|
RESTORED,
|
||||||
|
NOT_REQUIRED,
|
||||||
|
SKIPPED_CONCURRENT_CHANGE
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>Снимки конфигурации копируются глубоко и сортируются по домену. Это позволяет
|
||||||
|
* безопасно использовать квитанцию для компенсации и проверки ожидаемого состояния.</p>
|
||||||
|
*/
|
||||||
|
public final class TenantSecretUpdateReceipt {
|
||||||
|
|
||||||
|
private static final Comparator<TenantConfig> 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<TenantConfig> previousTenants;
|
||||||
|
private final List<TenantConfig> committedTenants;
|
||||||
|
|
||||||
|
public TenantSecretUpdateReceipt(boolean persisted,
|
||||||
|
boolean changed,
|
||||||
|
String committedResourceVersion,
|
||||||
|
List<TenantConfig> previousTenants,
|
||||||
|
List<TenantConfig> 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<TenantConfig> previousTenants() {
|
||||||
|
return copyAndSort(previousTenants);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<TenantConfig> committedTenants() {
|
||||||
|
return copyAndSort(committedTenants);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<TenantConfig> copyAndSort(List<TenantConfig> tenants) {
|
||||||
|
Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
|
||||||
|
List<TenantConfig> 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() +
|
||||||
|
'}';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,9 @@ public class TenantWebMvcConfig implements WebMvcConfigurer {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void addInterceptors(InterceptorRegistry registry) {
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
|
registry.addInterceptor(tenantInterceptor())
|
||||||
|
.addPathPatterns("/**")
|
||||||
|
.excludePathPatterns("/actuator/**");
|
||||||
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
registry.addInterceptor(authorizationInterceptor).addPathPatterns("/api/**");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TenantReadinessRegistry.ConnectivityTarget> 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<TenantReadinessRegistry.ConnectivityTarget> batch = targets.subList(offset, end);
|
||||||
|
List<Callable<CheckResult>> checks = new ArrayList<>(batch.size());
|
||||||
|
|
||||||
|
for (TenantReadinessRegistry.ConnectivityTarget target : batch) {
|
||||||
|
checks.add(() -> check(target));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Future<CheckResult>> 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<CheckResult> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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-БД.
|
||||||
|
*
|
||||||
|
* <p>Каждое изменение публикуется единым неизменяемым снимком. Результат фоновой
|
||||||
|
* проверки соединения привязан к непрозрачному fingerprint конфигурации, поэтому
|
||||||
|
* запоздалый результат для старых реквизитов не изменит состояние новой конфигурации.</p>
|
||||||
|
*/
|
||||||
|
@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<RegistryState> 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<TenantConfig> desired) {
|
||||||
|
boolean missingDesired = desired == null;
|
||||||
|
List<TenantConfig> safeDesired = desired == null ? List.of() : new ArrayList<>(desired);
|
||||||
|
mutate(current -> {
|
||||||
|
Map<String, TenantStatus> 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<String, TenantStatus> 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<Boolean> 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<String, TenantStatus> 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<ConnectivityTarget> connectivityTargets() {
|
||||||
|
List<ConnectivityTarget> 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<String, TenantStatus> 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<String, TenantStatus> 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<String, TenantStatus> updated = new LinkedHashMap<>(current.tenants());
|
||||||
|
updated.put(normalizedDomain, previous.withMigration(migrationState, connectivityState, clock.instant()));
|
||||||
|
return new RegistryState(updated, current.configurationFailure());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void mutate(UnaryOperator<RegistryState> 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<String, TenantStatus> tenants, boolean configurationFailure) {
|
||||||
|
private RegistryState {
|
||||||
|
tenants = Collections.unmodifiableMap(new LinkedHashMap<>(tenants));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RegistryState empty() {
|
||||||
|
return new RegistryState(Map.of(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,12 @@
|
|||||||
package com.magistr.app.service;
|
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.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.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 com.zaxxer.hikari.HikariDataSource;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -31,18 +35,21 @@ public class TenantLifecycleService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private final TenantRoutingDataSource routingDataSource;
|
private final TenantRoutingDataSource routingDataSource;
|
||||||
private final KubernetesTenantSecretUpdater tenantSecretUpdater;
|
private final TenantConfigStore tenantConfigStore;
|
||||||
private final TenantDatabaseMigrationService migrationService;
|
private final TenantDatabaseMigrationService migrationService;
|
||||||
private final RetiredTenantPoolService retiredPoolService;
|
private final RetiredTenantPoolService retiredPoolService;
|
||||||
|
private final TenantReadinessRegistry readinessRegistry;
|
||||||
|
|
||||||
public TenantLifecycleService(TenantRoutingDataSource routingDataSource,
|
public TenantLifecycleService(TenantRoutingDataSource routingDataSource,
|
||||||
KubernetesTenantSecretUpdater tenantSecretUpdater,
|
TenantConfigStore tenantConfigStore,
|
||||||
TenantDatabaseMigrationService migrationService,
|
TenantDatabaseMigrationService migrationService,
|
||||||
RetiredTenantPoolService retiredPoolService) {
|
RetiredTenantPoolService retiredPoolService,
|
||||||
|
TenantReadinessRegistry readinessRegistry) {
|
||||||
this.routingDataSource = routingDataSource;
|
this.routingDataSource = routingDataSource;
|
||||||
this.tenantSecretUpdater = tenantSecretUpdater;
|
this.tenantConfigStore = tenantConfigStore;
|
||||||
this.migrationService = migrationService;
|
this.migrationService = migrationService;
|
||||||
this.retiredPoolService = retiredPoolService;
|
this.retiredPoolService = retiredPoolService;
|
||||||
|
this.readinessRegistry = readinessRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,16 +83,17 @@ public class TenantLifecycleService {
|
|||||||
candidate = prepareCandidate(config);
|
candidate = prepareCandidate(config);
|
||||||
verifyConnection(candidate);
|
verifyConnection(candidate);
|
||||||
migrateCandidate(candidate);
|
migrateCandidate(candidate);
|
||||||
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
|
TenantSecretUpdateReceipt receipt = persistUpsert(config);
|
||||||
|
|
||||||
TenantRoutingDataSource.TenantState previous;
|
TenantRoutingDataSource.TenantState previous;
|
||||||
try {
|
try {
|
||||||
previous = routingDataSource.swapTenant(config, candidate);
|
previous = routingDataSource.swapTenant(config, candidate);
|
||||||
} catch (RuntimeException activationFailure) {
|
} catch (RuntimeException activationFailure) {
|
||||||
throw activationFailureAfterCompensation(oldConfigs, activationFailure);
|
throw activationFailureAfterCompensation(receipt, activationFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
activated = true;
|
activated = true;
|
||||||
|
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config);
|
||||||
retireDataSource(previous == null ? null : previous.dataSource());
|
retireDataSource(previous == null ? null : previous.dataSource());
|
||||||
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
|
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
|
||||||
return copyConfig(config);
|
return copyConfig(config);
|
||||||
@@ -109,7 +117,7 @@ public class TenantLifecycleService {
|
|||||||
|
|
||||||
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
|
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
|
||||||
desiredConfigs.remove(domain);
|
desiredConfigs.remove(domain);
|
||||||
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
|
TenantSecretUpdateReceipt receipt = persistRemove(domain);
|
||||||
|
|
||||||
TenantRoutingDataSource.TenantState removed;
|
TenantRoutingDataSource.TenantState removed;
|
||||||
try {
|
try {
|
||||||
@@ -118,9 +126,10 @@ public class TenantLifecycleService {
|
|||||||
throw new IllegalStateException("Локальное состояние тенанта отсутствует");
|
throw new IllegalStateException("Локальное состояние тенанта отсутствует");
|
||||||
}
|
}
|
||||||
} catch (RuntimeException removalFailure) {
|
} catch (RuntimeException removalFailure) {
|
||||||
throw removalFailureAfterCompensation(oldConfigs, removalFailure);
|
throw removalFailureAfterCompensation(receipt, removalFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null);
|
||||||
retireDataSource(removed.dataSource());
|
retireDataSource(removed.dataSource());
|
||||||
log.info("Lifecycle тенанта '{}' удалён", domain);
|
log.info("Lifecycle тенанта '{}' удалён", domain);
|
||||||
return copyConfig(existing);
|
return copyConfig(existing);
|
||||||
@@ -142,6 +151,12 @@ public class TenantLifecycleService {
|
|||||||
.map(TenantConfig::getDomain)
|
.map(TenantConfig::getDomain)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
readinessRegistry.clearConfigurationFailure();
|
||||||
|
readinessRegistry.replaceDesired(configs);
|
||||||
|
if (desiredDomains.size() != configs.size()) {
|
||||||
|
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
|
||||||
|
}
|
||||||
|
|
||||||
for (TenantConfig config : configs) {
|
for (TenantConfig config : configs) {
|
||||||
if (!routingDataSource.hasTenant(config.getDomain())) {
|
if (!routingDataSource.hasTenant(config.getDomain())) {
|
||||||
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
|
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
|
||||||
@@ -162,13 +177,19 @@ public class TenantLifecycleService {
|
|||||||
private void activatePersistedConfig(TenantConfig config) {
|
private void activatePersistedConfig(TenantConfig config) {
|
||||||
HikariDataSource candidate = null;
|
HikariDataSource candidate = null;
|
||||||
boolean activated = false;
|
boolean activated = false;
|
||||||
|
readinessRegistry.markMigrationStarted(config);
|
||||||
try {
|
try {
|
||||||
candidate = prepareCandidate(config);
|
candidate = prepareCandidate(config);
|
||||||
verifyConnection(candidate);
|
verifyConnection(candidate);
|
||||||
migrateCandidate(candidate);
|
migrateCandidate(candidate);
|
||||||
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate);
|
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate);
|
||||||
activated = true;
|
activated = true;
|
||||||
|
readinessRegistry.markMigrationSucceeded(config);
|
||||||
|
readinessRegistry.markConnectivity(config.getDomain(), true);
|
||||||
retireDataSource(previous == null ? null : previous.dataSource());
|
retireDataSource(previous == null ? null : previous.dataSource());
|
||||||
|
} catch (RuntimeException activationFailure) {
|
||||||
|
readinessRegistry.markMigrationFailed(config);
|
||||||
|
throw activationFailure;
|
||||||
} finally {
|
} finally {
|
||||||
if (!activated) {
|
if (!activated) {
|
||||||
closeCandidateDataSource(candidate);
|
closeCandidateDataSource(candidate);
|
||||||
@@ -208,63 +229,117 @@ public class TenantLifecycleService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void persistDesired(Map<String, TenantConfig> desiredConfigs) {
|
private TenantSecretUpdateReceipt persistUpsert(TenantConfig config) {
|
||||||
boolean persisted;
|
|
||||||
try {
|
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) {
|
} catch (RuntimeException persistenceFailure) {
|
||||||
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||||
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure);
|
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure);
|
||||||
}
|
}
|
||||||
if (!persisted) {
|
|
||||||
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void persistDesiredWithCompensation(Map<String, TenantConfig> desiredConfigs,
|
private TenantSecretUpdateReceipt persistRemove(String domain) {
|
||||||
Map<String, TenantConfig> oldConfigs) {
|
|
||||||
try {
|
try {
|
||||||
persistDesired(desiredConfigs);
|
return requireReceipt(tenantConfigStore.removeTenant(domain));
|
||||||
} catch (TenantLifecycleException persistenceFailure) {
|
} catch (TenantConfigPersistenceException persistenceFailure) {
|
||||||
boolean restored = restorePersistedSnapshot(oldConfigs);
|
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||||
String message = restored
|
throw new TenantLifecycleException(persistenceFailure.getMessage(), persistenceFailure);
|
||||||
? "Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена"
|
} catch (RuntimeException persistenceFailure) {
|
||||||
: "Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret";
|
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||||
throw new TenantLifecycleException(message, persistenceFailure);
|
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private TenantLifecycleException activationFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
|
private TenantSecretUpdateReceipt requireReceipt(TenantSecretUpdateReceipt receipt) {
|
||||||
RuntimeException activationFailure) {
|
if (receipt == null) {
|
||||||
boolean restored = restorePersistedSnapshot(oldConfigs);
|
throw new TenantConfigPersistenceException("Хранилище конфигурации тенантов не подтвердило операцию");
|
||||||
|
}
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TenantLifecycleException activationFailureAfterCompensation(TenantSecretUpdateReceipt receipt,
|
||||||
|
RuntimeException activationFailure) {
|
||||||
|
TenantSecretCompensationResult compensation = compensate(receipt);
|
||||||
logTechnicalFailure("Активация подготовленного tenant pool завершилась ошибкой", activationFailure);
|
logTechnicalFailure("Активация подготовленного tenant pool завершилась ошибкой", activationFailure);
|
||||||
String message = restored
|
String message = compensationMessage(
|
||||||
? "Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена"
|
"Не удалось активировать подключение тенанта",
|
||||||
: "Не удалось активировать подключение тенанта; требуется проверить tenant Secret";
|
compensation
|
||||||
|
);
|
||||||
return new TenantLifecycleException(message, activationFailure);
|
return new TenantLifecycleException(message, activationFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
private TenantLifecycleException removalFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
|
private TenantLifecycleException removalFailureAfterCompensation(TenantSecretUpdateReceipt receipt,
|
||||||
RuntimeException removalFailure) {
|
RuntimeException removalFailure) {
|
||||||
boolean restored = restorePersistedSnapshot(oldConfigs);
|
TenantSecretCompensationResult compensation = compensate(receipt);
|
||||||
logTechnicalFailure("Локальное удаление тенанта завершилось ошибкой", removalFailure);
|
logTechnicalFailure("Локальное удаление тенанта завершилось ошибкой", removalFailure);
|
||||||
String message = restored
|
String message = compensationMessage("Не удалось отключить тенанта", compensation);
|
||||||
? "Не удалось отключить тенанта; сохранённая конфигурация восстановлена"
|
|
||||||
: "Не удалось отключить тенанта; требуется проверить tenant Secret";
|
|
||||||
return new TenantLifecycleException(message, removalFailure);
|
return new TenantLifecycleException(message, removalFailure);
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean restorePersistedSnapshot(Map<String, TenantConfig> oldConfigs) {
|
private TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt) {
|
||||||
try {
|
try {
|
||||||
boolean restored = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(oldConfigs));
|
TenantSecretCompensationResult result = tenantConfigStore.compensate(receipt);
|
||||||
if (!restored) {
|
if (result == null) {
|
||||||
log.error("Не удалось компенсирующе восстановить tenant Secret");
|
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) {
|
} catch (RuntimeException compensationFailure) {
|
||||||
|
readinessRegistry.markConfigurationFailure();
|
||||||
logTechnicalFailure("Компенсирующее восстановление tenant Secret завершилось ошибкой",
|
logTechnicalFailure("Компенсирующее восстановление tenant Secret завершилось ошибкой",
|
||||||
compensationFailure);
|
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<TenantConfig> localDesired,
|
||||||
|
TenantConfig migratedConfig) {
|
||||||
|
try {
|
||||||
|
List<TenantConfig> 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;
|
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) {
|
private TenantConfig validateAndNormalize(TenantConfig config) {
|
||||||
if (config == null) {
|
if (config == null) {
|
||||||
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
|
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
|
||||||
|
|||||||
@@ -13,6 +13,19 @@ spring.jpa.open-in-view=false
|
|||||||
|
|
||||||
# Мультитенантность
|
# Мультитенантность
|
||||||
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
|
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 авторизация
|
# JWT авторизация
|
||||||
app.jwt.secret=${JWT_SECRET:}
|
app.jwt.secret=${JWT_SECRET:}
|
||||||
|
|||||||
@@ -39,7 +39,10 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
@Testcontainers
|
@Testcontainers
|
||||||
@SpringBootTest(
|
@SpringBootTest(
|
||||||
classes = RefreshTokenServiceConcurrencyIntegrationTest.TestApplication.class,
|
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 {
|
class RefreshTokenServiceConcurrencyIntegrationTest {
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.magistr.app.config.tenant;
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.JsonNode;
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||||
|
import okhttp3.mockwebserver.Dispatcher;
|
||||||
import okhttp3.mockwebserver.MockResponse;
|
import okhttp3.mockwebserver.MockResponse;
|
||||||
import okhttp3.mockwebserver.MockWebServer;
|
import okhttp3.mockwebserver.MockWebServer;
|
||||||
import okhttp3.mockwebserver.RecordedRequest;
|
import okhttp3.mockwebserver.RecordedRequest;
|
||||||
@@ -13,50 +16,440 @@ import org.junit.jupiter.api.io.TempDir;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
import java.util.Base64;
|
import java.util.Base64;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.UUID;
|
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.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
class KubernetesTenantSecretUpdaterTest {
|
class KubernetesTenantSecretUpdaterTest {
|
||||||
|
|
||||||
|
private static final TypeReference<List<TenantConfig>> TENANT_LIST_TYPE = new TypeReference<>() {
|
||||||
|
};
|
||||||
|
|
||||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||||
|
|
||||||
@TempDir
|
@TempDir
|
||||||
Path tempDirectory;
|
Path tempDirectory;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void updatesSecretThroughTrustedCaWithoutSendingPlainTenantJson() throws Exception {
|
void readsCurrentSecretAndUsesConditionalPutWithoutPlainCredentials() throws Exception {
|
||||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||||
HeldCertificate serverCertificate = serverCertificate(ca, "localhost");
|
HeldCertificate serverCertificate = serverCertificate(ca, "localhost");
|
||||||
|
TenantConfig tenant = tenant("Тестовый университет", "test", "пароль-" + UUID.randomUUID());
|
||||||
|
|
||||||
try (MockWebServer server = httpsServer(serverCertificate)) {
|
try (MockWebServer server = httpsServer(serverCertificate)) {
|
||||||
server.enqueue(new MockResponse().setResponseCode(200));
|
server.enqueue(secretResponse("17", List.of()));
|
||||||
TestFiles files = testFiles(ca);
|
server.enqueue(secretResponse("18", List.of(tenant)));
|
||||||
String rawPassword = UUID.randomUUID().toString();
|
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server);
|
||||||
TenantConfig tenant = new TenantConfig("Университет", "test", "jdbc:test", "user", rawPassword);
|
|
||||||
KubernetesTenantSecretUpdater updater = updater(files, server);
|
|
||||||
|
|
||||||
boolean updated = updater.updateTenantsConfig(List.of(tenant));
|
TenantSecretUpdateReceipt receipt = updater.upsertTenant(tenant);
|
||||||
|
|
||||||
assertThat(updated).isTrue();
|
assertThat(receipt.persisted()).isTrue();
|
||||||
RecordedRequest request = server.takeRequest(2, TimeUnit.SECONDS);
|
assertThat(receipt.changed()).isTrue();
|
||||||
assertThat(request).isNotNull();
|
assertThat(receipt.committedResourceVersion()).isEqualTo("18");
|
||||||
assertThat(request.getPath()).isEqualTo("/api/v1/namespaces/magistr/secrets/tenants-secret");
|
assertThat(domains(receipt.previousTenants())).isEmpty();
|
||||||
String body = request.getBody().readUtf8();
|
assertThat(domains(receipt.committedTenants())).containsExactly("test");
|
||||||
assertThat(body).doesNotContain(rawPassword);
|
|
||||||
|
|
||||||
JsonNode patch = objectMapper.readTree(body);
|
RecordedRequest get = server.takeRequest(2, TimeUnit.SECONDS);
|
||||||
String decoded = new String(
|
RecordedRequest put = server.takeRequest(2, TimeUnit.SECONDS);
|
||||||
Base64.getDecoder().decode(patch.path("data").path("tenants.json").asText()),
|
assertThat(get).isNotNull();
|
||||||
StandardCharsets.UTF_8
|
assertThat(get.getMethod()).isEqualTo("GET");
|
||||||
);
|
assertThat(get.getPath()).isEqualTo("/api/v1/namespaces/magistr/secrets/tenants-secret");
|
||||||
assertThat(decoded).contains("\"domain\" : \"test\"");
|
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<TenantSecretUpdateReceipt> firstResult =
|
||||||
|
executor.submit(() -> first.upsertTenant(alpha));
|
||||||
|
Future<TenantSecretUpdateReceipt> 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
|
@Test
|
||||||
void rejectsServerSignedByDifferentCa() throws Exception {
|
void rejectsServerSignedByDifferentCa() throws Exception {
|
||||||
HeldCertificate trustedCa = certificateAuthority("Доверенный тестовый CA");
|
HeldCertificate trustedCa = certificateAuthority("Доверенный тестовый CA");
|
||||||
@@ -64,10 +457,11 @@ class KubernetesTenantSecretUpdaterTest {
|
|||||||
HeldCertificate serverCertificate = serverCertificate(untrustedCa, "localhost");
|
HeldCertificate serverCertificate = serverCertificate(untrustedCa, "localhost");
|
||||||
|
|
||||||
try (MockWebServer server = httpsServer(serverCertificate)) {
|
try (MockWebServer server = httpsServer(serverCertificate)) {
|
||||||
server.enqueue(new MockResponse().setResponseCode(200));
|
server.enqueue(secretResponse("1", List.of()));
|
||||||
KubernetesTenantSecretUpdater updater = updater(testFiles(trustedCa), server);
|
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");
|
HeldCertificate serverCertificate = serverCertificate(ca, "not-localhost.invalid");
|
||||||
|
|
||||||
try (MockWebServer server = httpsServer(serverCertificate)) {
|
try (MockWebServer server = httpsServer(serverCertificate)) {
|
||||||
server.enqueue(new MockResponse().setResponseCode(200));
|
server.enqueue(secretResponse("1", List.of()));
|
||||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server);
|
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(),
|
files.caPath(),
|
||||||
"https://localhost:1",
|
"https://localhost:1",
|
||||||
"tenants-secret",
|
"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) {
|
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(
|
return new KubernetesTenantSecretUpdater(
|
||||||
files.tokenPath(),
|
files.tokenPath(),
|
||||||
files.namespacePath(),
|
files.namespacePath(),
|
||||||
files.caPath(),
|
files.caPath(),
|
||||||
apiBase,
|
server.url("/").toString(),
|
||||||
"tenants-secret",
|
"tenants-secret",
|
||||||
objectMapper
|
objectMapper,
|
||||||
|
maxAttempts,
|
||||||
|
retryDelay
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +529,58 @@ class KubernetesTenantSecretUpdaterTest {
|
|||||||
return new TestFiles(tokenPath, namespacePath, caPath);
|
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<TenantConfig> tenants) {
|
||||||
|
return new MockResponse()
|
||||||
|
.setResponseCode(200)
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.setBody(secretJson(resourceVersion, tenants));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String secretJson(String resourceVersion, List<TenantConfig> 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<TenantConfig> tenants) throws Exception {
|
||||||
|
return Base64.getEncoder().encodeToString(
|
||||||
|
objectMapper.writeValueAsBytes(tenants)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TenantConfig> 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<String> domains(List<TenantConfig> tenants) {
|
||||||
|
return tenants.stream().map(TenantConfig::getDomain).sorted().toList();
|
||||||
|
}
|
||||||
|
|
||||||
private HeldCertificate certificateAuthority(String commonName) {
|
private HeldCertificate certificateAuthority(String commonName) {
|
||||||
return new HeldCertificate.Builder()
|
return new HeldCertificate.Builder()
|
||||||
.certificateAuthority(1)
|
.certificateAuthority(1)
|
||||||
@@ -149,6 +608,68 @@ class KubernetesTenantSecretUpdaterTest {
|
|||||||
return server;
|
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<TenantConfig> currentTenants;
|
||||||
|
private int resourceVersion = 1;
|
||||||
|
|
||||||
|
private CasDispatcher(List<TenantConfig> 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<TenantConfig> 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<TenantConfig> currentTenants() {
|
||||||
|
synchronized (monitor) {
|
||||||
|
return new ArrayList<>(currentTenants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int conflictCount() {
|
||||||
|
return conflicts.get();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private record TestFiles(Path tokenPath, Path namespacePath, Path caPath) {
|
private record TestFiles(Path tokenPath, Path namespacePath, Path caPath) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.magistr.app.config.tenant;
|
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.RetiredTenantPoolService;
|
||||||
import com.magistr.app.service.TenantDatabaseMigrationService;
|
import com.magistr.app.service.TenantDatabaseMigrationService;
|
||||||
import com.magistr.app.service.TenantLifecycleService;
|
import com.magistr.app.service.TenantLifecycleService;
|
||||||
@@ -103,11 +104,15 @@ class TenantConfigWatcherTest {
|
|||||||
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
|
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
|
||||||
TenantLifecycleService lifecycleService = new TenantLifecycleService(
|
TenantLifecycleService lifecycleService = new TenantLifecycleService(
|
||||||
routingDataSource,
|
routingDataSource,
|
||||||
mock(KubernetesTenantSecretUpdater.class),
|
mock(TenantConfigStore.class),
|
||||||
mock(TenantDatabaseMigrationService.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());
|
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
|
||||||
CountDownLatch apiMutationReachedRefreshWindow = new CountDownLatch(1);
|
CountDownLatch apiMutationReachedRefreshWindow = new CountDownLatch(1);
|
||||||
CountDownLatch allowApiRefresh = new CountDownLatch(1);
|
CountDownLatch allowApiRefresh = new CountDownLatch(1);
|
||||||
@@ -148,7 +153,10 @@ class TenantConfigWatcherTest {
|
|||||||
invocation.<Runnable>getArgument(0).run();
|
invocation.<Runnable>getArgument(0).run();
|
||||||
return null;
|
return null;
|
||||||
}).when(lifecycleService).executeSerialized(any(Runnable.class));
|
}).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());
|
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
|
||||||
return watcher;
|
return watcher;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TenantRoutingDataSource> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Object> interceptors() {
|
||||||
|
return getInterceptors();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<TenantConfig> 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<String> 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.<Supplier<ResponseEntity<Map<String, Object>>>>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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,7 +35,10 @@ import static org.mockito.Mockito.verify;
|
|||||||
@Testcontainers
|
@Testcontainers
|
||||||
@SpringBootTest(
|
@SpringBootTest(
|
||||||
classes = AcademicCalendarGridServiceIntegrationTest.TestApplication.class,
|
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 {
|
class AcademicCalendarGridServiceIntegrationTest {
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||||||
classes = PostgreSqlAdvisoryLockIntegrationTest.TestApplication.class,
|
classes = PostgreSqlAdvisoryLockIntegrationTest.TestApplication.class,
|
||||||
properties = {
|
properties = {
|
||||||
"spring.flyway.enabled=false",
|
"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 {
|
class PostgreSqlAdvisoryLockIntegrationTest {
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ import static org.mockito.Mockito.verify;
|
|||||||
@Testcontainers
|
@Testcontainers
|
||||||
@SpringBootTest(
|
@SpringBootTest(
|
||||||
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
|
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 {
|
class ScheduleRuleServiceConcurrencyIntegrationTest {
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package com.magistr.app.service;
|
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.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.TenantContext;
|
||||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
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 com.zaxxer.hikari.HikariDataSource;
|
||||||
import org.junit.jupiter.api.AfterEach;
|
import org.junit.jupiter.api.AfterEach;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
@@ -18,6 +21,7 @@ import java.sql.DriverManager;
|
|||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.List;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
@@ -42,7 +46,8 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
|||||||
|
|
||||||
private TenantRoutingDataSource routingDataSource;
|
private TenantRoutingDataSource routingDataSource;
|
||||||
private TenantDatabaseMigrationService migrationService;
|
private TenantDatabaseMigrationService migrationService;
|
||||||
private KubernetesTenantSecretUpdater tenantSecretUpdater;
|
private TenantConfigStore tenantConfigStore;
|
||||||
|
private TenantReadinessRegistry readinessRegistry;
|
||||||
private TenantLifecycleService lifecycleService;
|
private TenantLifecycleService lifecycleService;
|
||||||
private RetiredTenantPoolService retiredPoolService;
|
private RetiredTenantPoolService retiredPoolService;
|
||||||
private HikariDataSource oldPool;
|
private HikariDataSource oldPool;
|
||||||
@@ -56,13 +61,15 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
|||||||
|
|
||||||
routingDataSource = new TenantRoutingDataSource();
|
routingDataSource = new TenantRoutingDataSource();
|
||||||
migrationService = new TenantDatabaseMigrationService();
|
migrationService = new TenantDatabaseMigrationService();
|
||||||
tenantSecretUpdater = mock(KubernetesTenantSecretUpdater.class);
|
tenantConfigStore = mock(TenantConfigStore.class);
|
||||||
|
readinessRegistry = new TenantReadinessRegistry();
|
||||||
retiredPoolService = new RetiredTenantPoolService(Duration.ofSeconds(2), Duration.ofMillis(25));
|
retiredPoolService = new RetiredTenantPoolService(Duration.ofSeconds(2), Duration.ofMillis(25));
|
||||||
lifecycleService = new TenantLifecycleService(
|
lifecycleService = new TenantLifecycleService(
|
||||||
routingDataSource,
|
routingDataSource,
|
||||||
tenantSecretUpdater,
|
tenantConfigStore,
|
||||||
migrationService,
|
migrationService,
|
||||||
retiredPoolService
|
retiredPoolService,
|
||||||
|
readinessRegistry
|
||||||
);
|
);
|
||||||
|
|
||||||
oldConfig = config("Старый tenant", jdbcUrl(OLD_DATABASE), POSTGRES.getPassword());
|
oldConfig = config("Старый tenant", jdbcUrl(OLD_DATABASE), POSTGRES.getPassword());
|
||||||
@@ -77,6 +84,9 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
|||||||
|
|
||||||
oldPool = routingDataSource.prepareTenantDataSource(oldConfig);
|
oldPool = routingDataSource.prepareTenantDataSource(oldConfig);
|
||||||
assertThat(routingDataSource.swapTenant(oldConfig, oldPool)).isNull();
|
assertThat(routingDataSource.swapTenant(oldConfig, oldPool)).isNull();
|
||||||
|
readinessRegistry.replaceDesired(List.of(oldConfig));
|
||||||
|
readinessRegistry.markMigrationSucceeded(oldConfig);
|
||||||
|
readinessRegistry.markConnectivity(DOMAIN, true);
|
||||||
assertOldState();
|
assertOldState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,10 +115,10 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
|||||||
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(wrongCredentials))
|
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(wrongCredentials))
|
||||||
.isInstanceOf(TenantLifecycleException.class)
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
.hasMessage("Не удалось подключиться к базе данных тенанта");
|
.hasMessage("Не удалось подключиться к базе данных тенанта");
|
||||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||||
assertOldState();
|
assertOldState();
|
||||||
|
|
||||||
reset(tenantSecretUpdater);
|
reset(tenantConfigStore);
|
||||||
TenantConfig badChecksum = config(
|
TenantConfig badChecksum = config(
|
||||||
"Повреждённый Flyway",
|
"Повреждённый Flyway",
|
||||||
jdbcUrl(BAD_CHECKSUM_DATABASE),
|
jdbcUrl(BAD_CHECKSUM_DATABASE),
|
||||||
@@ -117,11 +127,13 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
|||||||
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(badChecksum))
|
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(badChecksum))
|
||||||
.isInstanceOf(TenantLifecycleException.class)
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
||||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||||
assertOldState();
|
assertOldState();
|
||||||
|
|
||||||
reset(tenantSecretUpdater);
|
reset(tenantConfigStore);
|
||||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true);
|
when(tenantConfigStore.upsertTenant(any())).thenThrow(
|
||||||
|
new TenantConfigPersistenceException("Не удалось сохранить конфигурацию тенантов")
|
||||||
|
);
|
||||||
TenantConfig validCandidate = config(
|
TenantConfig validCandidate = config(
|
||||||
"Новый tenant",
|
"Новый tenant",
|
||||||
jdbcUrl(VALID_DATABASE),
|
jdbcUrl(VALID_DATABASE),
|
||||||
@@ -129,12 +141,19 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
|||||||
);
|
);
|
||||||
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(validCandidate))
|
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(validCandidate))
|
||||||
.isInstanceOf(TenantLifecycleException.class)
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
|
.hasMessage("Не удалось сохранить конфигурацию тенантов");
|
||||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
|
verify(tenantConfigStore).upsertTenant(any());
|
||||||
|
verify(tenantConfigStore, never()).compensate(any());
|
||||||
assertOldState();
|
assertOldState();
|
||||||
|
|
||||||
reset(tenantSecretUpdater);
|
reset(tenantConfigStore);
|
||||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true);
|
when(tenantConfigStore.upsertTenant(any())).thenReturn(new TenantSecretUpdateReceipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
"2",
|
||||||
|
List.of(oldConfig),
|
||||||
|
List.of(validCandidate)
|
||||||
|
));
|
||||||
TenantConfig saved;
|
TenantConfig saved;
|
||||||
try (Connection inFlightOldConnection = oldPool.getConnection()) {
|
try (Connection inFlightOldConnection = oldPool.getConnection()) {
|
||||||
saved = lifecycleService.addOrUpdateTenant(validCandidate);
|
saved = lifecycleService.addOrUpdateTenant(validCandidate);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
package com.magistr.app.service;
|
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.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.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 com.zaxxer.hikari.HikariDataSource;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
@@ -16,6 +20,8 @@ import org.mockito.quality.Strictness;
|
|||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -35,12 +41,14 @@ class TenantLifecycleServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private TenantRoutingDataSource routingDataSource;
|
private TenantRoutingDataSource routingDataSource;
|
||||||
@Mock
|
@Mock
|
||||||
private KubernetesTenantSecretUpdater tenantSecretUpdater;
|
private TenantConfigStore tenantConfigStore;
|
||||||
@Mock
|
@Mock
|
||||||
private TenantDatabaseMigrationService migrationService;
|
private TenantDatabaseMigrationService migrationService;
|
||||||
@Mock
|
@Mock
|
||||||
private RetiredTenantPoolService retiredPoolService;
|
private RetiredTenantPoolService retiredPoolService;
|
||||||
@Mock
|
@Mock
|
||||||
|
private TenantReadinessRegistry readinessRegistry;
|
||||||
|
@Mock
|
||||||
private HikariDataSource candidate;
|
private HikariDataSource candidate;
|
||||||
@Mock
|
@Mock
|
||||||
private HikariDataSource oldDataSource;
|
private HikariDataSource oldDataSource;
|
||||||
@@ -56,9 +64,10 @@ class TenantLifecycleServiceTest {
|
|||||||
void setUp() throws Exception {
|
void setUp() throws Exception {
|
||||||
service = new TenantLifecycleService(
|
service = new TenantLifecycleService(
|
||||||
routingDataSource,
|
routingDataSource,
|
||||||
tenantSecretUpdater,
|
tenantConfigStore,
|
||||||
migrationService,
|
migrationService,
|
||||||
retiredPoolService
|
retiredPoolService,
|
||||||
|
readinessRegistry
|
||||||
);
|
);
|
||||||
oldAlpha = config("Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
oldAlpha = config("Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||||
beta = config("Beta", "beta", "jdbc:beta", "beta-user", "beta-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(routingDataSource.prepareTenantDataSource(any(TenantConfig.class))).thenReturn(candidate);
|
||||||
when(candidate.getConnection()).thenReturn(candidateConnection);
|
when(candidate.getConnection()).thenReturn(candidateConnection);
|
||||||
when(candidateConnection.isValid(5)).thenReturn(true);
|
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()))
|
when(routingDataSource.swapTenant(any(TenantConfig.class), any()))
|
||||||
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
|
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
|
||||||
}
|
}
|
||||||
@@ -85,13 +98,14 @@ class TenantLifecycleServiceTest {
|
|||||||
|
|
||||||
assertConfig(saved, "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
assertConfig(saved, "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||||
ArgumentCaptor<TenantConfig> preparedConfig = ArgumentCaptor.forClass(TenantConfig.class);
|
ArgumentCaptor<TenantConfig> preparedConfig = ArgumentCaptor.forClass(TenantConfig.class);
|
||||||
ArgumentCaptor<List<TenantConfig>> persistedConfigs = listCaptor();
|
ArgumentCaptor<TenantConfig> persistedConfig = ArgumentCaptor.forClass(TenantConfig.class);
|
||||||
InOrder order = inOrder(
|
InOrder order = inOrder(
|
||||||
routingDataSource,
|
routingDataSource,
|
||||||
candidate,
|
candidate,
|
||||||
candidateConnection,
|
candidateConnection,
|
||||||
migrationService,
|
migrationService,
|
||||||
tenantSecretUpdater,
|
tenantConfigStore,
|
||||||
|
readinessRegistry,
|
||||||
retiredPoolService
|
retiredPoolService
|
||||||
);
|
);
|
||||||
order.verify(routingDataSource).snapshotTenantConfigs();
|
order.verify(routingDataSource).snapshotTenantConfigs();
|
||||||
@@ -100,19 +114,63 @@ class TenantLifecycleServiceTest {
|
|||||||
order.verify(candidateConnection).isValid(5);
|
order.verify(candidateConnection).isValid(5);
|
||||||
order.verify(candidateConnection).close();
|
order.verify(candidateConnection).close();
|
||||||
order.verify(migrationService).migrate(candidate);
|
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(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);
|
order.verify(retiredPoolService).retire(oldDataSource);
|
||||||
|
|
||||||
assertConfig(preparedConfig.getValue(),
|
assertConfig(preparedConfig.getValue(),
|
||||||
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||||
assertThat(persistedConfigs.getValue()).extracting(TenantConfig::getDomain)
|
assertConfig(persistedConfig.getValue(),
|
||||||
.containsExactly("alpha", "beta");
|
|
||||||
assertConfig(persistedConfigs.getValue().get(0),
|
|
||||||
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||||
verify(candidate, never()).close();
|
verify(candidate, never()).close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addsNewTenantWithoutRetiringExistingPool() {
|
||||||
|
Map<String, TenantConfig> 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<TenantConfig> 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<String, TenantConfig> 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
|
@Test
|
||||||
void closesCandidateAndKeepsOldPoolWhenCredentialsAreWrong() throws Exception {
|
void closesCandidateAndKeepsOldPoolWhenCredentialsAreWrong() throws Exception {
|
||||||
when(candidate.getConnection()).thenThrow(new SQLException("тестовая ошибка аутентификации"));
|
when(candidate.getConnection()).thenThrow(new SQLException("тестовая ошибка аутентификации"));
|
||||||
@@ -123,11 +181,27 @@ class TenantLifecycleServiceTest {
|
|||||||
|
|
||||||
verify(candidate).close();
|
verify(candidate).close();
|
||||||
verify(migrationService, never()).migrate(any());
|
verify(migrationService, never()).migrate(any());
|
||||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
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
|
@Test
|
||||||
void closesCandidateAndKeepsOldStateWhenFlywayFails() {
|
void closesCandidateAndKeepsOldStateWhenFlywayFails() {
|
||||||
org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
|
org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
|
||||||
@@ -138,134 +212,141 @@ class TenantLifecycleServiceTest {
|
|||||||
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
||||||
|
|
||||||
verify(candidate).close();
|
verify(candidate).close();
|
||||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void closesCandidateAndRestoresOldSnapshotWhenPersistenceReturnsFalse() {
|
void persistenceConflictDoesNotStartBlindCompensation() {
|
||||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true);
|
when(tenantConfigStore.upsertTenant(any())).thenThrow(new TenantConfigPersistenceException(
|
||||||
|
"Конфигурация тенантов одновременно изменяется другим экземпляром; повторите запрос"
|
||||||
|
));
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||||
.isInstanceOf(TenantLifecycleException.class)
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
|
.hasMessage("Конфигурация тенантов одновременно изменяется другим экземпляром; повторите запрос");
|
||||||
|
|
||||||
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
|
verify(tenantConfigStore, never()).compensate(any());
|
||||||
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(candidate).close();
|
verify(candidate).close();
|
||||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void restoresOldSnapshotWhenFirstPersistenceThrows() {
|
void unknownPersistenceFailureUsesSafeMessageAndDoesNotCompensate() {
|
||||||
when(tenantSecretUpdater.updateTenantsConfig(any()))
|
when(tenantConfigStore.upsertTenant(any()))
|
||||||
.thenThrow(new IllegalStateException("неопределённый исход PATCH"))
|
.thenThrow(new IllegalStateException("неопределённый технический результат PUT"));
|
||||||
.thenReturn(true);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||||
.isInstanceOf(TenantLifecycleException.class)
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
|
.hasMessage("Не удалось сохранить конфигурацию тенантов");
|
||||||
|
|
||||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
|
verify(tenantConfigStore, never()).compensate(any());
|
||||||
verify(candidate).close();
|
verify(candidate).close();
|
||||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void reportsDesynchronizationWhenPersistenceAndCompensationFail() {
|
void compensatesOnlyCommittedReceiptWhenActivationFails() {
|
||||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, false);
|
TenantSecretUpdateReceipt receipt = upsertReceipt(newAlpha());
|
||||||
|
when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt);
|
||||||
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() {
|
|
||||||
when(routingDataSource.swapTenant(any(), any()))
|
when(routingDataSource.swapTenant(any(), any()))
|
||||||
.thenThrow(new IllegalStateException("тестовый отказ активации"));
|
.thenThrow(new IllegalStateException("тестовый отказ активации"));
|
||||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||||
.isInstanceOf(TenantLifecycleException.class)
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
.hasMessage("Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена");
|
.hasMessage("Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена");
|
||||||
|
|
||||||
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
|
verify(tenantConfigStore).compensate(receipt);
|
||||||
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(candidate).close();
|
verify(candidate).close();
|
||||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@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()))
|
when(routingDataSource.swapTenant(any(), any()))
|
||||||
.thenThrow(new IllegalStateException("тестовый отказ активации"));
|
.thenThrow(new IllegalStateException("тестовый отказ активации"));
|
||||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, false);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||||
.isInstanceOf(TenantLifecycleException.class)
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
.hasMessage("Не удалось активировать подключение тенанта; требуется проверить tenant Secret");
|
.hasMessage("Не удалось активировать подключение тенанта; tenant Secret уже изменён "
|
||||||
|
+ "другим экземпляром, автоматический откат пропущен");
|
||||||
|
|
||||||
|
verify(readinessRegistry).markConfigurationFailure();
|
||||||
verify(candidate).close();
|
verify(candidate).close();
|
||||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void removalPersistsDesiredStateBeforeAtomicRemovalAndClosesRemovedPool() {
|
void removalPersistsOperationBeforeAtomicRemovalAndClosesRemovedPool() {
|
||||||
when(routingDataSource.removeTenantAtomically("alpha"))
|
when(routingDataSource.removeTenantAtomically("alpha"))
|
||||||
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
|
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
|
||||||
|
|
||||||
TenantConfig removed = service.removeTenant(" ALPHA ");
|
TenantConfig removed = service.removeTenant(" ALPHA ");
|
||||||
|
|
||||||
assertConfig(removed, "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
assertConfig(removed, "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||||
ArgumentCaptor<List<TenantConfig>> desired = listCaptor();
|
InOrder order = inOrder(routingDataSource, tenantConfigStore, readinessRegistry, retiredPoolService);
|
||||||
InOrder order = inOrder(routingDataSource, tenantSecretUpdater, retiredPoolService);
|
|
||||||
order.verify(routingDataSource).snapshotTenantConfigs();
|
order.verify(routingDataSource).snapshotTenantConfigs();
|
||||||
order.verify(tenantSecretUpdater).updateTenantsConfig(desired.capture());
|
order.verify(tenantConfigStore).removeTenant("alpha");
|
||||||
order.verify(routingDataSource).removeTenantAtomically("alpha");
|
order.verify(routingDataSource).removeTenantAtomically("alpha");
|
||||||
|
order.verify(readinessRegistry).replaceDesired(any());
|
||||||
order.verify(retiredPoolService).retire(oldDataSource);
|
order.verify(retiredPoolService).retire(oldDataSource);
|
||||||
assertThat(desired.getValue()).extracting(TenantConfig::getDomain).containsExactly("beta");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void removalFailureRestoresOldPersistedSnapshotAndKeepsPoolOpen() {
|
void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() {
|
||||||
|
TenantSecretUpdateReceipt receipt = removeReceipt("alpha");
|
||||||
|
when(tenantConfigStore.removeTenant("alpha")).thenReturn(receipt);
|
||||||
when(routingDataSource.removeTenantAtomically("alpha"))
|
when(routingDataSource.removeTenantAtomically("alpha"))
|
||||||
.thenThrow(new IllegalStateException("тестовый отказ удаления"));
|
.thenThrow(new IllegalStateException("тестовый отказ удаления"));
|
||||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> service.removeTenant("alpha"))
|
assertThatThrownBy(() -> service.removeTenant("alpha"))
|
||||||
.isInstanceOf(TenantLifecycleException.class)
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
.hasMessage("Не удалось отключить тенанта; сохранённая конфигурация восстановлена");
|
.hasMessage("Не удалось отключить тенанта; сохранённая конфигурация восстановлена");
|
||||||
|
|
||||||
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
|
verify(tenantConfigStore).compensate(receipt);
|
||||||
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(retiredPoolService, never()).retire(oldDataSource);
|
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
|
@Test
|
||||||
void acceptsDnsLabelBoundariesAndRejectsTooLongOrDottedDomain() {
|
void acceptsDnsLabelBoundariesAndRejectsTooLongOrDottedDomain() {
|
||||||
String maxLengthDomain = "a".repeat(63);
|
String maxLengthDomain = "a".repeat(63);
|
||||||
@@ -295,6 +376,36 @@ class TenantLifecycleServiceTest {
|
|||||||
return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private TenantSecretUpdateReceipt upsertReceipt(TenantConfig tenant) {
|
||||||
|
Map<String, TenantConfig> 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<String, TenantConfig> committed = new LinkedHashMap<>(oldSnapshot);
|
||||||
|
committed.remove(domain);
|
||||||
|
return new TenantSecretUpdateReceipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
"2",
|
||||||
|
sortedConfigs(oldSnapshot),
|
||||||
|
sortedConfigs(committed)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TenantConfig> sortedConfigs(Map<String, TenantConfig> configs) {
|
||||||
|
List<TenantConfig> result = new ArrayList<>(configs.values());
|
||||||
|
result.sort(Comparator.comparing(TenantConfig::getDomain));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private TenantConfig config(String name,
|
private TenantConfig config(String name,
|
||||||
String domain,
|
String domain,
|
||||||
String url,
|
String url,
|
||||||
@@ -316,8 +427,4 @@ class TenantLifecycleServiceTest {
|
|||||||
assertThat(actual.getPassword()).isEqualTo(password);
|
assertThat(actual.getPassword()).isEqualTo(password);
|
||||||
}
|
}
|
||||||
|
|
||||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
|
||||||
private ArgumentCaptor<List<TenantConfig>> listCaptor() {
|
|
||||||
return ArgumentCaptor.forClass((Class) List.class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
116
docs/API.md
116
docs/API.md
@@ -1,6 +1,7 @@
|
|||||||
# 🔌 REST API
|
# 🔌 REST API
|
||||||
|
|
||||||
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
|
Все прикладные эндпоинты имеют префикс `/api/`. Служебные проверки Kubernetes доступны
|
||||||
|
под `/actuator/health/`. Ответы возвращаются в формате JSON.
|
||||||
|
|
||||||
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий 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`
|
### `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`
|
### `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`
|
### `GET /api/database/tenants`
|
||||||
|
|
||||||
Список всех тенантов.
|
Список всех тенантов. Пароль в ответ не включается.
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"name": "СВФУ",
|
||||||
|
"domain": "swsu",
|
||||||
|
"url": "jdbc:postgresql://db-host:5432/swsu_db",
|
||||||
|
"username": "dbuser",
|
||||||
|
"connected": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
### `POST /api/database/tenants`
|
### `POST /api/database/tenants`
|
||||||
|
|
||||||
Добавление нового тенанта.
|
Создание нового или обновление существующего тенанта по `domain`.
|
||||||
|
|
||||||
```json
|
```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 пул для нового тенанта
|
1. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
|
||||||
2. Запускает Flyway миграции на его БД
|
2. Открывает соединение и явно проверяет его готовность.
|
||||||
3. Обновляет внешний Kubernetes Secret `tenants-secret`
|
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 и
|
Backend соединяется с Kubernetes API только через проверенный service-account CA и
|
||||||
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
|
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
|
||||||
не возвращается как успешная. Значения credentials никогда не включаются в ответ или лог
|
не возвращается как успешная. Значения credentials никогда не включаются в ответ или лог
|
||||||
Kubernetes updater.
|
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}`
|
### `DELETE /api/database/tenants/{domain}`
|
||||||
|
|
||||||
Удаление тенанта.
|
Удаление тенанта. Backend читает актуальный Secret, удаляет только запрошенный `domain` и
|
||||||
|
выполняет условный `PUT` по `resourceVersion`, затем атомарно исключает tenant из локальной
|
||||||
|
маршрутизации и передаёт pool на отложенное закрытие. При отказе локального удаления
|
||||||
|
компенсация также допускается только для подтверждённой версии и не затирает более новое
|
||||||
|
изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `503`.
|
||||||
|
|
||||||
### `POST /api/database/test`
|
### `POST /api/database/test`
|
||||||
|
|
||||||
@@ -1166,5 +1265,8 @@ Kubernetes updater.
|
|||||||
| `200` | Успех |
|
| `200` | Успех |
|
||||||
| `400` | Ошибка валидации или некорректные параметры запроса |
|
| `400` | Ошибка валидации или некорректные параметры запроса |
|
||||||
| `401` | Неверные учётные данные |
|
| `401` | Неверные учётные данные |
|
||||||
|
| `403` | Недостаточно прав для операции |
|
||||||
| `404` | Ресурс / тенант не найден |
|
| `404` | Ресурс / тенант не найден |
|
||||||
|
| `409` | Конфликт бизнес-инвариантов или конкурентного изменения |
|
||||||
| `500` | Внутренняя ошибка сервера |
|
| `500` | Внутренняя ошибка сервера |
|
||||||
|
| `503` | Внешняя БД или обязательная инфраструктура временно недоступна |
|
||||||
|
|||||||
@@ -78,11 +78,18 @@ sequenceDiagram
|
|||||||
|-------|-----------|
|
|-------|-----------|
|
||||||
| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант |
|
| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант |
|
||||||
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
||||||
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
|
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
|
||||||
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
||||||
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
||||||
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
|
| `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` |
|
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
|
||||||
|
|
||||||
### Определение тенанта
|
### Определение тенанта
|
||||||
@@ -121,16 +128,84 @@ sequenceDiagram
|
|||||||
|
|
||||||
### Жизненный цикл тенанта
|
### Жизненный цикл тенанта
|
||||||
|
|
||||||
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет `tenants-secret`
|
1. **Добавление или обновление через API:** `TenantLifecycleService` нормализует конфигурацию,
|
||||||
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет смонтированный `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
|
создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет
|
||||||
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет `tenants-secret`
|
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 загружает
|
Для обращения к Kubernetes API backend загружает
|
||||||
`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку
|
`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку
|
||||||
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
|
цепочки и обязательную проверку 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 при отсутствии тенантов
|
### Fallback при отсутствии тенантов
|
||||||
|
|
||||||
@@ -138,6 +213,9 @@ sequenceDiagram
|
|||||||
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
||||||
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
||||||
|
|
||||||
|
Для production обязательность смонтированной конфигурации задаётся
|
||||||
|
`TENANTS_CONFIG_REQUIRED=true`; отсутствие или ошибка чтения файла снимают readiness.
|
||||||
|
|
||||||
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
|
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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 |
|
| `otel-postgres-secret` | Credentials PostgreSQL receivers для OTel Collector |
|
||||||
| `gitea-registry` | Доступ Kubernetes к registry |
|
| `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:
|
При добавлении тенанта через API:
|
||||||
1. `DatabaseController` обновляет in-memory DataSource
|
1. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
|
||||||
2. `KubernetesTenantSecretUpdater` обновляет Secret через Kubernetes API
|
2. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
|
||||||
3. `TenantConfigWatcher` на остальных pod подхватывает смонтированное изменение (каждые 30 сек)
|
только 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.
|
Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
|
||||||
RBAC ограничен объектом `tenants-secret`. Скрипт `../k8s/deploy.sh` проверяет наличие
|
Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только
|
||||||
объектов и ключей до rollout, не читая и не печатая их значения.
|
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). Эти действия требуют полномочий оператора и
|
[`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
|
### Обновление backend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
Reference in New Issue
Block a user