Задача Егора
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. |
|
||||
| 7 | исправлено и проверено | Контроллер сравнивал новые слоты только с сохранёнными правилами, не проверял роль преподавателя и enum формата, а нечётные часы приводили к перерасходу генератора. | `ScheduleRuleService` валидирует весь кандидат до мутации, попарно проверяет дубли/ресурсы, требует `TEACHER`, `Очно`/`Онлайн` и чётные часы; строка семестра сериализует запись. V4 закрепляет чётность и точный дубль в БД. | 23 service/MockMvc/Flyway/PostgreSQL tests; полный backend: 102/0/0/0. |
|
||||
| 8 | исправлено и проверено | `saveGrid` удалял данные до полной валидации и ловил исключение внутри transactional controller-метода, поэтому Spring коммитил delete и уже обработанные строки. | `AcademicCalendarGridService` полностью валидирует и строит replacement до delete, атомарно заменяет строки и очищает кэш через `afterCommit`; контроллер стал HTTP-адаптером. | 8 unit + 1 PostgreSQL proxy test; fingerprint старой сетки после отказа неизменен; полный backend 111/0/0/0. |
|
||||
| 9 | в работе | При update старый pool закрывается до создания нового; Hikari допускает ленивый нерабочий pool, Flyway проглатывает ошибку, а отказ Secret-персистенции оставляет локальное состояние изменённым. | Реализуются prepare/validate/migrate кандидата, persistence до atomic swap и компенсация Secret при ошибке активации. | Готовятся unit и PostgreSQL lifecycle tests для credentials/migration/persistence/swap. |
|
||||
| 10 | не начато | Два pod перезаписывают целый tenant-документ без `resourceVersion`; TCP probes не отражают готовность tenant-БД. | Optimistic locking с retry; отдельные liveness/readiness semantics. | Конкурентные PATCH и health tests; Kustomize-проверка probes. |
|
||||
| 9 | исправлено и проверено | При update старый pool закрывался до создания нового; Hikari допускал ленивый нерабочий pool, Flyway проглатывал ошибку, а отказ persistence мог сочетаться с изменённым локальным состоянием. | `TenantLifecycleService` выполняет prepare → connection validation → Flyway → Secret persistence → atomic snapshot swap; candidate закрывается при отказе, прежний route сохраняется, Secret компенсируется, старый pool закрывается после drain. | 18 целевых unit/MockMvc/PostgreSQL tests; create/update, credentials, invalid connection, Flyway, persistence, swap, HTTP `503` и in-flight connection. Полный backend: 132/0/0/0. |
|
||||
| 10 | требуется внешнее действие | Два pod перезаписывали целый tenant-документ без `resourceVersion`; TCP probes не отражали готовность tenant-БД. | Backend применяет доменные upsert/remove через GET → условный PUT по `resourceVersion`, ограниченный retry и безопасную компенсацию; Actuator разделяет process-only liveness и readiness обязательных tenant-БД. Оператору остаются RBAC `get/update`, mount без `subPath`, `TENANTS_CONFIG_REQUIRED=true` и HTTP probes в отсутствующем `../k8s`. | 64 целевых теста; полный backend 169/0/0/0; LF-хеши V1–V4 совпадают. Kustomize не выполнен: `../k8s` отсутствует. |
|
||||
| 11 | не начато | Sunday вычисляется повторным мутированием даты; ошибки запросов превращаются в пустые результаты и зелёный статус. | Чистая local-date утилита и три явных состояния проверки. | 2026-07-02, переход года, частичная/полная ошибка API. |
|
||||
| 12 | не начато | Access JWT хранится в Web Storage; origin исполняет удалённые модули; CSP допускает несовместимые сценарии. | Access JWT только в памяти, восстановление через HttpOnly refresh-cookie; локальные pinned assets; строгий CSP и удаление inline JS. | Login/refresh/logout/reload tests; статический аудит CSP/storage/imports. |
|
||||
| 13 | не начато | Watcher сравнивает только домены и фиксирует hash до успешной полной синхронизации. | Нормализованное сравнение полного `TenantConfig`, hash после успеха, bounded backoff. | Изменение URL/credentials и retry после ошибки. |
|
||||
@@ -261,18 +261,100 @@
|
||||
| Контейнерный полный `mvn test` | 111 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
|
||||
| `git diff --check` | Успешно. |
|
||||
|
||||
### № 9 — безопасная замена tenant DataSource
|
||||
|
||||
- `DatabaseController` делегирует create/update/delete в единый `TenantLifecycleService` и
|
||||
возвращает lifecycle-ошибки как русский безопасный `503`, а не ложный `200` или raw
|
||||
JDBC/Flyway message.
|
||||
- Новый Hikari candidate не публикуется в маршрутизации до явного `getConnection()` /
|
||||
`isValid(5)`, успешного Flyway и подтверждённой доменной мутации в актуальном
|
||||
`tenants-secret`.
|
||||
- `TenantRoutingDataSource` одной volatile-публикацией заменяет неизменяемый снимок
|
||||
`TenantConfig + DataSource`; прежняя конфигурация и pool остаются рабочими при ошибке
|
||||
credentials, миграции, persistence или доатомарной активации.
|
||||
- Неуспешная persistence не меняет локальный route. Неопределённый результат записи сначала
|
||||
сверяется повторным чтением и не даёт права на опасный откат. Если swap/remove отказывает
|
||||
после подтверждённой записи, прежний снимок восстанавливается только при точном совпадении
|
||||
`resourceVersion`; более новое изменение другого pod не перезаписывается.
|
||||
- После успешного swap старый pool не закрывается синхронно: `RetiredTenantPoolService`
|
||||
прекращает новые маршруты, сохраняет уже выданные соединения и закрывает Hikari после
|
||||
drain либо grace timeout.
|
||||
- Unit-тесты дополнены отдельной create-веткой, восстановлением снимка без нового domain,
|
||||
`Connection.isValid(false)` и MockMvc-проверкой `503` без технических деталей.
|
||||
- PostgreSQL 16.3/Testcontainers-тест подтверждает сохранность старого route при неверном
|
||||
пароле, повреждённом Flyway checksum и отказе persistence, работоспособность in-flight
|
||||
соединения во время swap и последующий drain старого pool.
|
||||
- `docs/API.md`, `docs/ARCHITECTURE.md` и `docs/INFRASTRUCTURE.md` синхронизированы через
|
||||
AutoUpdateDocs; исправлено устаревшее описание порядка «in-memory до Secret».
|
||||
|
||||
Фактические проверки этапа:
|
||||
|
||||
| Команда | Результат |
|
||||
|---|---|
|
||||
| Контейнерный целевой `mvn -Dtest=TenantLifecycleServiceTest,TenantLifecyclePostgreSqlIntegrationTest,TenantRoutingDataSourceTest,DatabaseControllerTest test` | 18 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
|
||||
| Контейнерный полный `mvn test` | 132 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
|
||||
| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. |
|
||||
|
||||
### № 10 — межподовая координация tenant-конфигурации и health probes
|
||||
|
||||
- Введён `TenantConfigStore` с явными операциями upsert/remove. Kubernetes-реализация перед
|
||||
каждой мутацией читает актуальный `tenants-secret`, нормализует домены и отправляет полный
|
||||
объект условным `PUT` с прочитанным `metadata.resourceVersion`.
|
||||
- `409`, `408`, `429`, `5xx` и транспортные ошибки обрабатываются ограниченно: максимум три
|
||||
попытки с возрастающей задержкой и повторным применением только собственной доменной
|
||||
мутации к свежему состоянию. Идемпотентная операция не выполняет `PUT`.
|
||||
- Успех записи и компенсации больше не определяется одним HTTP 2xx: backend разбирает
|
||||
возвращённый Secret, сравнивает `tenants.json` с ожидаемым и использует `resourceVersion`
|
||||
только проверенного объекта. Пустой, неполный или неоднозначный ответ сверяется повторным
|
||||
`GET` без выдачи квитанции, разрешающей небезопасный откат.
|
||||
- Компенсация выполняется только для подтверждённой собственной версии. Если текущий Secret
|
||||
уже получил другой `resourceVersion`, откат прекращается и не затирает изменение другого
|
||||
pod. Отсутствующий `data.tenants.json`, повторяющиеся домены и Kubernetes mode без
|
||||
ServiceAccount token обрабатываются fail-closed.
|
||||
- Добавлен Spring Boot Actuator. `/actuator/health/liveness` зависит только от жизнеспособности
|
||||
процесса; `/actuator/health/readiness` требует непустой набор обязательных tenant, успешную
|
||||
миграцию и свежую успешную проверку каждого соединения. H2-заглушка, ошибка обязательного
|
||||
конфига, migration failure, timeout, недоступность или просроченная проверка дают `503`.
|
||||
- `TenantDatabaseHealthMonitor` проверяет tenant-БД ограниченно-параллельно в фоне; endpoint
|
||||
читает только атомарный кэш. Общий deadline отменяет лишь незавершённые задачи, уже
|
||||
завершившиеся результаты не теряются и получают фактическое время проверки. Scheduler
|
||||
имеет два потока, поэтому долгий health-pass не блокирует watcher.
|
||||
- `TenantDataSourceConfig` и watcher поддерживают `TENANTS_CONFIG_REQUIRED=true`: отсутствие,
|
||||
пустой список или ошибка чтения production-файла фиксируют configuration failure, а H2 не
|
||||
маскирует отказ. Actuator endpoints исключены из tenant-interceptor и не раскрывают
|
||||
components, домены, JDBC URL или credentials.
|
||||
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и
|
||||
`docs/INFRASTRUCTURE.md`. Ограничение watcher по обновлению URL/credentials существующего
|
||||
домена явно оставлено проблеме № 13.
|
||||
- В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись
|
||||
и Kustomize не запускался. Для production обязательны directory mount без `subPath`,
|
||||
`TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness /
|
||||
readiness probes; применение и rollout остаются внешними действиями оператора.
|
||||
|
||||
Фактические проверки этапа:
|
||||
|
||||
| Команда | Результат |
|
||||
|---|---|
|
||||
| `KubernetesTenantSecretUpdaterTest` | 21 тест, 0 failures, 0 errors, 0 skipped; два конкурентных updater, CAS conflict/retry, 408/5xx reconciliation, проверка 2xx и безопасная компенсация. |
|
||||
| Health/startup target (`TenantDataSourceConfigTest`, registry/monitor/indicator, Actuator, MVC exclusion) | 19 тестов, 0 failures, 0 errors, 0 skipped. |
|
||||
| Lifecycle/controller/watcher target | 24 теста, 0 failures, 0 errors, 0 skipped; PostgreSQL lifecycle отдельно повторён успешно после единичной Docker-сетевой флуктуации. |
|
||||
| Контейнерный полный `mvn test` | 169 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS; 40 suites. |
|
||||
| `git diff --check` | Успешно. |
|
||||
| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. |
|
||||
| `kubectl kustomize ../k8s` | Не выполнено: внешний каталог `../k8s` отсутствует в текущем workspace. |
|
||||
|
||||
## Точка продолжения
|
||||
|
||||
Текущий этап: **№ 9 — безопасная замена tenant DataSource**.
|
||||
Текущий этап: **№ 13 — полная синхронизация изменённых tenant credentials/URL watcher-ом**.
|
||||
|
||||
Следующая операция:
|
||||
|
||||
1. проверить актуальные create/update потоки `DatabaseController`, swap/remove API
|
||||
`TenantRoutingDataSource`, Flyway и персистенцию tenant-конфигурации;
|
||||
2. определить порядок prepare → connection validation → Flyway → persistence → atomic swap,
|
||||
не закрывающий рабочий pool до полной готовности кандидата;
|
||||
3. добавить компенсирующий rollback для ошибок персистенции/активации и явные русские
|
||||
ошибки без ложного `200`;
|
||||
4. покрыть неверные credentials, ошибку миграции и ошибку персистенции тестами, доказывающими
|
||||
сохранность старого подключения;
|
||||
5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 9.
|
||||
1. сравнивать полную нормализованную активную конфигурацию с persisted snapshot, а не только
|
||||
набор доменов;
|
||||
2. при изменении URL/username/password выполнять prepare → verify → Flyway → atomic swap без
|
||||
повторной записи Secret;
|
||||
3. считать hash применённым только после полной успешной синхронизации и не подтверждать
|
||||
произвольное содержимое mount через `refreshHash()` после API-мутации;
|
||||
4. добавить bounded retry/backoff и тесты изменения credentials, восстановления прежнего
|
||||
файла после ошибки и межподового merged snapshot;
|
||||
5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 13.
|
||||
|
||||
@@ -27,6 +27,12 @@
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Стандартные liveness/readiness probes Kubernetes -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- JPA + PostgreSQL -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -8,6 +11,7 @@ import org.springframework.stereotype.Service;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLParameters;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
@@ -19,19 +23,29 @@ import java.nio.file.Path;
|
||||
import java.security.KeyStore;
|
||||
import java.security.cert.Certificate;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Обновляет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
|
||||
* Атомарно изменяет Kubernetes Secret с конфигурацией тенантов через Kubernetes REST API.
|
||||
*
|
||||
* <p>Работает только внутри Kubernetes pod и использует service-account token и
|
||||
* service-account CA. В локальной разработке персистенция пропускается.</p>
|
||||
* <p>Каждая мутация сначала читает актуальный Secret, применяет изменение к полученному
|
||||
* списку и отправляет полный объект обратно условным {@code PUT} с
|
||||
* {@code metadata.resourceVersion}. При конфликте операция повторно читает актуальное
|
||||
* состояние и заново применяет только свою мутацию.</p>
|
||||
*/
|
||||
@Service
|
||||
public class KubernetesTenantSecretUpdater {
|
||||
public class KubernetesTenantSecretUpdater implements TenantConfigStore {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(KubernetesTenantSecretUpdater.class);
|
||||
|
||||
@@ -43,6 +57,15 @@ public class KubernetesTenantSecretUpdater {
|
||||
Path.of("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt");
|
||||
private static final String DEFAULT_API_BASE = "https://kubernetes.default.svc";
|
||||
private static final String DEFAULT_SECRET_NAME = "tenants-secret";
|
||||
private static final int DEFAULT_MAX_ATTEMPTS = 3;
|
||||
private static final Duration DEFAULT_RETRY_DELAY = Duration.ofMillis(50);
|
||||
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
|
||||
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10);
|
||||
private static final Pattern DOMAIN_PATTERN = Pattern.compile(
|
||||
"[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?"
|
||||
);
|
||||
private static final TypeReference<List<TenantConfig>> TENANT_LIST_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final Path tokenPath;
|
||||
@@ -51,6 +74,10 @@ public class KubernetesTenantSecretUpdater {
|
||||
private final String apiBase;
|
||||
private final String secretName;
|
||||
private final boolean runningInKubernetes;
|
||||
private final int maxAttempts;
|
||||
private final Duration retryDelay;
|
||||
|
||||
private volatile HttpClient httpClient;
|
||||
|
||||
public KubernetesTenantSecretUpdater() {
|
||||
this(
|
||||
@@ -69,79 +96,572 @@ public class KubernetesTenantSecretUpdater {
|
||||
String apiBase,
|
||||
String secretName,
|
||||
ObjectMapper objectMapper) {
|
||||
this.tokenPath = tokenPath;
|
||||
this.namespacePath = namespacePath;
|
||||
this.caPath = caPath;
|
||||
this.apiBase = apiBase.endsWith("/") ? apiBase.substring(0, apiBase.length() - 1) : apiBase;
|
||||
this.secretName = secretName;
|
||||
this.objectMapper = objectMapper;
|
||||
this.runningInKubernetes = Files.exists(tokenPath);
|
||||
this(
|
||||
tokenPath,
|
||||
namespacePath,
|
||||
caPath,
|
||||
apiBase,
|
||||
secretName,
|
||||
objectMapper,
|
||||
DEFAULT_MAX_ATTEMPTS,
|
||||
DEFAULT_RETRY_DELAY
|
||||
);
|
||||
}
|
||||
|
||||
KubernetesTenantSecretUpdater(Path tokenPath,
|
||||
Path namespacePath,
|
||||
Path caPath,
|
||||
String apiBase,
|
||||
String secretName,
|
||||
ObjectMapper objectMapper,
|
||||
int maxAttempts,
|
||||
Duration retryDelay) {
|
||||
this(
|
||||
tokenPath,
|
||||
namespacePath,
|
||||
caPath,
|
||||
apiBase,
|
||||
secretName,
|
||||
objectMapper,
|
||||
maxAttempts,
|
||||
retryDelay,
|
||||
detectKubernetesEnvironment(tokenPath)
|
||||
);
|
||||
}
|
||||
|
||||
KubernetesTenantSecretUpdater(Path tokenPath,
|
||||
Path namespacePath,
|
||||
Path caPath,
|
||||
String apiBase,
|
||||
String secretName,
|
||||
ObjectMapper objectMapper,
|
||||
int maxAttempts,
|
||||
Duration retryDelay,
|
||||
boolean kubernetesEnvironment) {
|
||||
this.tokenPath = Objects.requireNonNull(tokenPath, "Путь к ServiceAccount token не задан");
|
||||
this.namespacePath = Objects.requireNonNull(namespacePath, "Путь к namespace не задан");
|
||||
this.caPath = Objects.requireNonNull(caPath, "Путь к ServiceAccount CA не задан");
|
||||
this.apiBase = normalizeApiBase(apiBase);
|
||||
this.secretName = requireText(secretName, "Имя tenant Secret не задано");
|
||||
this.objectMapper = Objects.requireNonNull(objectMapper, "ObjectMapper не задан");
|
||||
if (maxAttempts < 1) {
|
||||
throw new IllegalArgumentException("Количество попыток должно быть положительным");
|
||||
}
|
||||
if (retryDelay == null || retryDelay.isNegative()) {
|
||||
throw new IllegalArgumentException("Задержка повтора не может быть отрицательной");
|
||||
}
|
||||
this.maxAttempts = maxAttempts;
|
||||
this.retryDelay = retryDelay;
|
||||
this.runningInKubernetes = kubernetesEnvironment || Files.exists(tokenPath);
|
||||
|
||||
if (!runningInKubernetes) {
|
||||
log.info("Приложение запущено вне Kubernetes — обновление tenant Secret будет пропущено");
|
||||
log.info("Приложение запущено вне Kubernetes — постоянное изменение tenant Secret отключено");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сохраняет полный список тенантов в ключе {@code tenants.json} Kubernetes Secret.
|
||||
*
|
||||
* @return {@code true}, если Secret обновлён или приложение запущено вне Kubernetes
|
||||
*/
|
||||
public boolean updateTenantsConfig(List<TenantConfig> tenants) {
|
||||
@Override
|
||||
public TenantSecretUpdateReceipt upsertTenant(TenantConfig tenant) {
|
||||
TenantConfig requested = TenantSecretUpdateReceipt.copyOf(
|
||||
Objects.requireNonNull(tenant, "Конфигурация тенанта не задана")
|
||||
);
|
||||
String domain = normalizeDomain(requested.getDomain());
|
||||
requested.setDomain(domain);
|
||||
|
||||
if (!runningInKubernetes) {
|
||||
log.warn("Приложение запущено вне Kubernetes, персистенция tenant Secret пропущена");
|
||||
return true;
|
||||
log.warn("Приложение запущено вне Kubernetes, постоянное добавление тенанта пропущено");
|
||||
return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of(requested));
|
||||
}
|
||||
|
||||
try {
|
||||
String token = Files.readString(tokenPath).trim();
|
||||
String namespace = Files.readString(namespacePath).trim();
|
||||
if (token.isBlank() || namespace.isBlank()) {
|
||||
throw new IllegalStateException("ServiceAccount token или namespace не настроены");
|
||||
return mutate(tenants -> {
|
||||
List<TenantConfig> updated = new ArrayList<>(tenants);
|
||||
updated.removeIf(existing -> domain.equals(existing.getDomain()));
|
||||
updated.add(requested);
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public TenantSecretUpdateReceipt removeTenant(String domain) {
|
||||
String normalizedDomain = normalizeDomain(domain);
|
||||
if (!runningInKubernetes) {
|
||||
log.warn("Приложение запущено вне Kubernetes, постоянное удаление тенанта пропущено");
|
||||
return new TenantSecretUpdateReceipt(false, false, null, List.of(), List.of());
|
||||
}
|
||||
|
||||
return mutate(tenants -> {
|
||||
List<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);
|
||||
String encodedTenants = Base64.getEncoder()
|
||||
.encodeToString(tenantsJson.getBytes(StandardCharsets.UTF_8));
|
||||
String patchBody = objectMapper.writeValueAsString(Map.of(
|
||||
"data", Map.of("tenants.json", encodedTenants)
|
||||
));
|
||||
if (!committedVersion.equals(current.resourceVersion())) {
|
||||
log.warn("Компенсация tenant Secret пропущена: после исходной операции Secret уже изменён");
|
||||
return TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE;
|
||||
}
|
||||
if (!sameTenants(current.tenants(), receipt.committedTenants())) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Содержимое tenant Secret не соответствует resourceVersion из квитанции"
|
||||
);
|
||||
}
|
||||
|
||||
HttpResponse<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(
|
||||
"%s/api/v1/namespaces/%s/secrets/%s",
|
||||
apiBase,
|
||||
namespace,
|
||||
secretName
|
||||
));
|
||||
HttpRequest request = HttpRequest.newBuilder()
|
||||
.uri(uri)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.header("Content-Type", "application/strategic-merge-patch+json")
|
||||
.method("PATCH", HttpRequest.BodyPublishers.ofString(patchBody))
|
||||
.build();
|
||||
|
||||
HttpResponse<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;
|
||||
return new RequestContext(token, uri);
|
||||
} catch (TenantConfigPersistenceException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось безопасно обновить tenant Secret: errorType={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали ошибки tenant Secret", e);
|
||||
return false;
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Не удалось прочитать параметры ServiceAccount для tenant Secret",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private HttpRequest.Builder requestBuilder(RequestContext context) {
|
||||
return HttpRequest.newBuilder()
|
||||
.uri(context.uri())
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.header("Authorization", "Bearer " + context.token())
|
||||
.header("Accept", "application/json");
|
||||
}
|
||||
|
||||
private synchronized HttpClient secureClient() {
|
||||
if (httpClient == null) {
|
||||
try {
|
||||
httpClient = createSecureClient(caPath);
|
||||
} catch (Exception e) {
|
||||
throw new TenantConfigPersistenceException(
|
||||
"Не удалось настроить защищённое соединение с Kubernetes API",
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
return httpClient;
|
||||
}
|
||||
|
||||
HttpClient createSecureClient(Path serviceAccountCaPath) throws Exception {
|
||||
CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
|
||||
Collection<? extends Certificate> certificates;
|
||||
@@ -171,8 +691,102 @@ public class KubernetesTenantSecretUpdater {
|
||||
sslParameters.setEndpointIdentificationAlgorithm("HTTPS");
|
||||
|
||||
return HttpClient.newBuilder()
|
||||
.connectTimeout(CONNECT_TIMEOUT)
|
||||
.sslContext(sslContext)
|
||||
.sslParameters(sslParameters)
|
||||
.build();
|
||||
}
|
||||
|
||||
private boolean prepareRetry(int attempt, String operation) {
|
||||
if (attempt >= maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
log.warn("Временная ошибка tenant Secret, операция будет повторена: операция={}, попытка={}/{}",
|
||||
operation,
|
||||
attempt,
|
||||
maxAttempts);
|
||||
try {
|
||||
long delayMillis = Math.multiplyExact(retryDelay.toMillis(), attempt);
|
||||
if (delayMillis > 0) {
|
||||
Thread.sleep(delayMillis);
|
||||
}
|
||||
return true;
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new TenantConfigPersistenceException("Ожидание повтора tenant Secret прервано", e);
|
||||
} catch (ArithmeticException e) {
|
||||
throw new TenantConfigPersistenceException("Некорректная задержка повтора tenant Secret", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String encodeTenants(List<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.databind.ObjectMapper;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.magistr.app.service.TenantLifecycleService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -29,16 +30,23 @@ public class TenantConfigWatcher {
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
||||
|
||||
private final TenantLifecycleService tenantLifecycleService;
|
||||
private final TenantReadinessRegistry readinessRegistry;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@Value("${app.tenants.config-path:tenants.json}")
|
||||
private String tenantsConfigPath;
|
||||
|
||||
@Value("${app.tenants.config-required:false}")
|
||||
private boolean tenantsConfigRequired;
|
||||
|
||||
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
||||
private String lastConfigHash = "";
|
||||
private boolean configurationUnavailable;
|
||||
|
||||
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService) {
|
||||
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
|
||||
TenantReadinessRegistry readinessRegistry) {
|
||||
this.tenantLifecycleService = tenantLifecycleService;
|
||||
this.readinessRegistry = readinessRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,12 +60,21 @@ public class TenantConfigWatcher {
|
||||
private void watchForChangesSerialized() {
|
||||
try {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (!file.exists()) return;
|
||||
if (!file.exists()) {
|
||||
if (tenantsConfigRequired) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
if (!configurationUnavailable) {
|
||||
log.error("Обязательный файл конфигурации тенантов недоступен");
|
||||
}
|
||||
configurationUnavailable = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
String hash = configHash(content);
|
||||
|
||||
if (hash.equals(lastConfigHash)) {
|
||||
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||
return; // Ничего не изменилось
|
||||
}
|
||||
|
||||
@@ -65,8 +82,11 @@ public class TenantConfigWatcher {
|
||||
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
||||
syncTenants(newTenants);
|
||||
lastConfigHash = hash;
|
||||
configurationUnavailable = false;
|
||||
|
||||
} catch (Exception e) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
|
||||
log.debug("Технические детали синхронизации конфига тенантов", e);
|
||||
}
|
||||
@@ -85,8 +105,13 @@ public class TenantConfigWatcher {
|
||||
if (file.exists()) {
|
||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
||||
lastConfigHash = configHash(content);
|
||||
} else if (tenantsConfigRequired) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
configurationUnavailable = true;
|
||||
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали обновления хеша тенантов", e);
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.magistr.app.service.TenantDatabaseMigrationService;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.slf4j.Logger;
|
||||
@@ -18,13 +19,12 @@ import org.springframework.transaction.PlatformTransactionManager;
|
||||
import jakarta.persistence.EntityManagerFactory;
|
||||
import javax.sql.DataSource;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.sql.Connection;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Конфигурация мультитенантного DataSource.
|
||||
* Загружает тенанты из JSON-файла (mounted ConfigMap).
|
||||
* Загружает тенанты из JSON-файла (смонтированный Kubernetes Secret).
|
||||
*
|
||||
* Если нет ни одного настроенного тенанта — создаёт H2 in-memory БД
|
||||
* как заглушку, чтобы Spring JPA мог инициализироваться.
|
||||
@@ -37,6 +37,9 @@ public class TenantDataSourceConfig {
|
||||
@Value("${app.tenants.config-path:tenants.json}")
|
||||
private String tenantsConfigPath;
|
||||
|
||||
@Value("${app.tenants.config-required:false}")
|
||||
private boolean tenantsConfigRequired;
|
||||
|
||||
@Value("${spring.datasource.url:}")
|
||||
private String defaultDbUrl;
|
||||
|
||||
@@ -48,11 +51,13 @@ public class TenantDataSourceConfig {
|
||||
|
||||
@Bean
|
||||
@Primary
|
||||
public DataSource dataSource(TenantDatabaseMigrationService migrationService) {
|
||||
public DataSource dataSource(TenantDatabaseMigrationService migrationService,
|
||||
TenantReadinessRegistry readinessRegistry) {
|
||||
TenantRoutingDataSource routingDataSource = new TenantRoutingDataSource();
|
||||
|
||||
// Загружаем тенантов из JSON (read-only ConfigMap mount)
|
||||
List<TenantConfig> tenants = loadTenantsFromFile();
|
||||
// Загружаем тенантов из JSON (read-only Secret mount)
|
||||
TenantConfigLoadResult loadResult = loadTenantsFromFile();
|
||||
List<TenantConfig> tenants = new ArrayList<>(loadResult.tenants());
|
||||
|
||||
// Если нет тенантов и есть дефолтный datasource — создаём "default" тенант
|
||||
if (tenants.isEmpty() && defaultDbUrl != null && !defaultDbUrl.isBlank()) {
|
||||
@@ -63,9 +68,15 @@ public class TenantDataSourceConfig {
|
||||
log.info("Конфигурация тенантов отсутствует, используется default DataSource");
|
||||
}
|
||||
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
readinessRegistry.replaceDesired(tenants);
|
||||
if (loadResult.failed() || (tenantsConfigRequired && loadResult.tenants().isEmpty())) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
}
|
||||
|
||||
// Регистрируем тенантов
|
||||
for (TenantConfig tenant : tenants) {
|
||||
registerPreparedTenant(routingDataSource, migrationService, tenant);
|
||||
registerPreparedTenant(routingDataSource, migrationService, readinessRegistry, tenant, true);
|
||||
}
|
||||
|
||||
// Если всё ещё нет ни одного тенанта — H2 in-memory заглушка
|
||||
@@ -79,7 +90,13 @@ public class TenantDataSourceConfig {
|
||||
"jdbc:h2:mem:placeholder;DB_CLOSE_DELAY=-1",
|
||||
"sa", ""
|
||||
);
|
||||
if (!registerPreparedTenant(routingDataSource, migrationService, h2Fallback)) {
|
||||
if (!registerPreparedTenant(
|
||||
routingDataSource,
|
||||
migrationService,
|
||||
readinessRegistry,
|
||||
h2Fallback,
|
||||
false
|
||||
)) {
|
||||
throw new IllegalStateException("Не удалось создать резервный H2 DataSource");
|
||||
}
|
||||
}
|
||||
@@ -118,31 +135,43 @@ public class TenantDataSourceConfig {
|
||||
return new JpaTransactionManager(emf);
|
||||
}
|
||||
|
||||
private List<TenantConfig> loadTenantsFromFile() {
|
||||
private TenantConfigLoadResult loadTenantsFromFile() {
|
||||
File file = new File(tenantsConfigPath);
|
||||
if (!file.exists()) {
|
||||
log.info("Файл конфигурации тенантов не найден");
|
||||
return new ArrayList<>();
|
||||
if (tenantsConfigRequired) {
|
||||
log.error("Обязательный файл конфигурации тенантов не найден");
|
||||
} else {
|
||||
log.info("Файл конфигурации тенантов не найден");
|
||||
}
|
||||
return new TenantConfigLoadResult(List.of(), tenantsConfigRequired);
|
||||
}
|
||||
|
||||
try {
|
||||
ObjectMapper mapper = new ObjectMapper();
|
||||
List<TenantConfig> list = mapper.readValue(file, new TypeReference<>() {});
|
||||
if (list == null) {
|
||||
throw new IllegalArgumentException("Корневое значение конфигурации тенантов должно быть массивом");
|
||||
}
|
||||
log.info("Загружено конфигураций тенантов: {}", list.size());
|
||||
return list;
|
||||
} catch (IOException e) {
|
||||
return new TenantConfigLoadResult(List.copyOf(list), false);
|
||||
} catch (Exception e) {
|
||||
log.error("Не удалось прочитать конфигурацию тенантов: errorType={}",
|
||||
e.getClass().getSimpleName());
|
||||
log.debug("Технические детали чтения конфигурации тенантов", e);
|
||||
return new ArrayList<>();
|
||||
return new TenantConfigLoadResult(List.of(), true);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean registerPreparedTenant(TenantRoutingDataSource routingDataSource,
|
||||
TenantDatabaseMigrationService migrationService,
|
||||
TenantConfig tenant) {
|
||||
TenantReadinessRegistry readinessRegistry,
|
||||
TenantConfig tenant,
|
||||
boolean requiredForReadiness) {
|
||||
HikariDataSource candidate = null;
|
||||
boolean activated = false;
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationStarted(tenant);
|
||||
}
|
||||
try {
|
||||
candidate = routingDataSource.prepareTenantDataSource(tenant);
|
||||
try (Connection connection = candidate.getConnection()) {
|
||||
@@ -155,10 +184,17 @@ public class TenantDataSourceConfig {
|
||||
}
|
||||
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(tenant, candidate);
|
||||
activated = true;
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationSucceeded(tenant);
|
||||
readinessRegistry.markConnectivity(tenant.getDomain(), true);
|
||||
}
|
||||
closeDataSource(previous == null ? null : previous.dataSource());
|
||||
log.info("Tenant-БД '{}' проверена и активирована", tenant.getDomain());
|
||||
return true;
|
||||
} catch (Exception startupFailure) {
|
||||
if (requiredForReadiness) {
|
||||
readinessRegistry.markMigrationFailed(tenant);
|
||||
}
|
||||
log.error("Не удалось безопасно активировать tenant-БД '{}': errorType={}",
|
||||
tenant.getDomain(), startupFailure.getClass().getSimpleName());
|
||||
log.debug("Технические детали startup lifecycle tenant-БД", startupFailure);
|
||||
@@ -181,4 +217,7 @@ public class TenantDataSourceConfig {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private record TenantConfigLoadResult(List<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
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
registry.addInterceptor(tenantInterceptor()).addPathPatterns("/**");
|
||||
registry.addInterceptor(tenantInterceptor())
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns("/actuator/**");
|
||||
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;
|
||||
|
||||
import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater;
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantConfigPersistenceException;
|
||||
import com.magistr.app.config.tenant.TenantConfigStore;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.config.tenant.TenantSecretCompensationResult;
|
||||
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
@@ -31,18 +35,21 @@ public class TenantLifecycleService {
|
||||
);
|
||||
|
||||
private final TenantRoutingDataSource routingDataSource;
|
||||
private final KubernetesTenantSecretUpdater tenantSecretUpdater;
|
||||
private final TenantConfigStore tenantConfigStore;
|
||||
private final TenantDatabaseMigrationService migrationService;
|
||||
private final RetiredTenantPoolService retiredPoolService;
|
||||
private final TenantReadinessRegistry readinessRegistry;
|
||||
|
||||
public TenantLifecycleService(TenantRoutingDataSource routingDataSource,
|
||||
KubernetesTenantSecretUpdater tenantSecretUpdater,
|
||||
TenantConfigStore tenantConfigStore,
|
||||
TenantDatabaseMigrationService migrationService,
|
||||
RetiredTenantPoolService retiredPoolService) {
|
||||
RetiredTenantPoolService retiredPoolService,
|
||||
TenantReadinessRegistry readinessRegistry) {
|
||||
this.routingDataSource = routingDataSource;
|
||||
this.tenantSecretUpdater = tenantSecretUpdater;
|
||||
this.tenantConfigStore = tenantConfigStore;
|
||||
this.migrationService = migrationService;
|
||||
this.retiredPoolService = retiredPoolService;
|
||||
this.readinessRegistry = readinessRegistry;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,16 +83,17 @@ public class TenantLifecycleService {
|
||||
candidate = prepareCandidate(config);
|
||||
verifyConnection(candidate);
|
||||
migrateCandidate(candidate);
|
||||
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
|
||||
TenantSecretUpdateReceipt receipt = persistUpsert(config);
|
||||
|
||||
TenantRoutingDataSource.TenantState previous;
|
||||
try {
|
||||
previous = routingDataSource.swapTenant(config, candidate);
|
||||
} catch (RuntimeException activationFailure) {
|
||||
throw activationFailureAfterCompensation(oldConfigs, activationFailure);
|
||||
throw activationFailureAfterCompensation(receipt, activationFailure);
|
||||
}
|
||||
|
||||
activated = true;
|
||||
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config);
|
||||
retireDataSource(previous == null ? null : previous.dataSource());
|
||||
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
|
||||
return copyConfig(config);
|
||||
@@ -109,7 +117,7 @@ public class TenantLifecycleService {
|
||||
|
||||
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
|
||||
desiredConfigs.remove(domain);
|
||||
persistDesiredWithCompensation(desiredConfigs, oldConfigs);
|
||||
TenantSecretUpdateReceipt receipt = persistRemove(domain);
|
||||
|
||||
TenantRoutingDataSource.TenantState removed;
|
||||
try {
|
||||
@@ -118,9 +126,10 @@ public class TenantLifecycleService {
|
||||
throw new IllegalStateException("Локальное состояние тенанта отсутствует");
|
||||
}
|
||||
} catch (RuntimeException removalFailure) {
|
||||
throw removalFailureAfterCompensation(oldConfigs, removalFailure);
|
||||
throw removalFailureAfterCompensation(receipt, removalFailure);
|
||||
}
|
||||
|
||||
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null);
|
||||
retireDataSource(removed.dataSource());
|
||||
log.info("Lifecycle тенанта '{}' удалён", domain);
|
||||
return copyConfig(existing);
|
||||
@@ -142,6 +151,12 @@ public class TenantLifecycleService {
|
||||
.map(TenantConfig::getDomain)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
readinessRegistry.replaceDesired(configs);
|
||||
if (desiredDomains.size() != configs.size()) {
|
||||
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
|
||||
}
|
||||
|
||||
for (TenantConfig config : configs) {
|
||||
if (!routingDataSource.hasTenant(config.getDomain())) {
|
||||
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
|
||||
@@ -162,13 +177,19 @@ public class TenantLifecycleService {
|
||||
private void activatePersistedConfig(TenantConfig config) {
|
||||
HikariDataSource candidate = null;
|
||||
boolean activated = false;
|
||||
readinessRegistry.markMigrationStarted(config);
|
||||
try {
|
||||
candidate = prepareCandidate(config);
|
||||
verifyConnection(candidate);
|
||||
migrateCandidate(candidate);
|
||||
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate);
|
||||
activated = true;
|
||||
readinessRegistry.markMigrationSucceeded(config);
|
||||
readinessRegistry.markConnectivity(config.getDomain(), true);
|
||||
retireDataSource(previous == null ? null : previous.dataSource());
|
||||
} catch (RuntimeException activationFailure) {
|
||||
readinessRegistry.markMigrationFailed(config);
|
||||
throw activationFailure;
|
||||
} finally {
|
||||
if (!activated) {
|
||||
closeCandidateDataSource(candidate);
|
||||
@@ -208,63 +229,117 @@ public class TenantLifecycleService {
|
||||
}
|
||||
}
|
||||
|
||||
private void persistDesired(Map<String, TenantConfig> desiredConfigs) {
|
||||
boolean persisted;
|
||||
private TenantSecretUpdateReceipt persistUpsert(TenantConfig config) {
|
||||
try {
|
||||
persisted = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(desiredConfigs));
|
||||
return requireReceipt(tenantConfigStore.upsertTenant(copyConfig(config)));
|
||||
} catch (TenantConfigPersistenceException persistenceFailure) {
|
||||
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||
throw new TenantLifecycleException(persistenceFailure.getMessage(), persistenceFailure);
|
||||
} catch (RuntimeException persistenceFailure) {
|
||||
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure);
|
||||
}
|
||||
if (!persisted) {
|
||||
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов");
|
||||
}
|
||||
}
|
||||
|
||||
private void persistDesiredWithCompensation(Map<String, TenantConfig> desiredConfigs,
|
||||
Map<String, TenantConfig> oldConfigs) {
|
||||
private TenantSecretUpdateReceipt persistRemove(String domain) {
|
||||
try {
|
||||
persistDesired(desiredConfigs);
|
||||
} catch (TenantLifecycleException persistenceFailure) {
|
||||
boolean restored = restorePersistedSnapshot(oldConfigs);
|
||||
String message = restored
|
||||
? "Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена"
|
||||
: "Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret";
|
||||
throw new TenantLifecycleException(message, persistenceFailure);
|
||||
return requireReceipt(tenantConfigStore.removeTenant(domain));
|
||||
} catch (TenantConfigPersistenceException persistenceFailure) {
|
||||
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||
throw new TenantLifecycleException(persistenceFailure.getMessage(), persistenceFailure);
|
||||
} catch (RuntimeException persistenceFailure) {
|
||||
logTechnicalFailure("Персистенция tenant Secret завершилась ошибкой", persistenceFailure);
|
||||
throw new TenantLifecycleException("Не удалось сохранить конфигурацию тенантов", persistenceFailure);
|
||||
}
|
||||
}
|
||||
|
||||
private TenantLifecycleException activationFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
|
||||
RuntimeException activationFailure) {
|
||||
boolean restored = restorePersistedSnapshot(oldConfigs);
|
||||
private TenantSecretUpdateReceipt requireReceipt(TenantSecretUpdateReceipt receipt) {
|
||||
if (receipt == null) {
|
||||
throw new TenantConfigPersistenceException("Хранилище конфигурации тенантов не подтвердило операцию");
|
||||
}
|
||||
return receipt;
|
||||
}
|
||||
|
||||
private TenantLifecycleException activationFailureAfterCompensation(TenantSecretUpdateReceipt receipt,
|
||||
RuntimeException activationFailure) {
|
||||
TenantSecretCompensationResult compensation = compensate(receipt);
|
||||
logTechnicalFailure("Активация подготовленного tenant pool завершилась ошибкой", activationFailure);
|
||||
String message = restored
|
||||
? "Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена"
|
||||
: "Не удалось активировать подключение тенанта; требуется проверить tenant Secret";
|
||||
String message = compensationMessage(
|
||||
"Не удалось активировать подключение тенанта",
|
||||
compensation
|
||||
);
|
||||
return new TenantLifecycleException(message, activationFailure);
|
||||
}
|
||||
|
||||
private TenantLifecycleException removalFailureAfterCompensation(Map<String, TenantConfig> oldConfigs,
|
||||
RuntimeException removalFailure) {
|
||||
boolean restored = restorePersistedSnapshot(oldConfigs);
|
||||
private TenantLifecycleException removalFailureAfterCompensation(TenantSecretUpdateReceipt receipt,
|
||||
RuntimeException removalFailure) {
|
||||
TenantSecretCompensationResult compensation = compensate(receipt);
|
||||
logTechnicalFailure("Локальное удаление тенанта завершилось ошибкой", removalFailure);
|
||||
String message = restored
|
||||
? "Не удалось отключить тенанта; сохранённая конфигурация восстановлена"
|
||||
: "Не удалось отключить тенанта; требуется проверить tenant Secret";
|
||||
String message = compensationMessage("Не удалось отключить тенанта", compensation);
|
||||
return new TenantLifecycleException(message, removalFailure);
|
||||
}
|
||||
|
||||
private boolean restorePersistedSnapshot(Map<String, TenantConfig> oldConfigs) {
|
||||
private TenantSecretCompensationResult compensate(TenantSecretUpdateReceipt receipt) {
|
||||
try {
|
||||
boolean restored = tenantSecretUpdater.updateTenantsConfig(sortedConfigs(oldConfigs));
|
||||
if (!restored) {
|
||||
log.error("Не удалось компенсирующе восстановить tenant Secret");
|
||||
TenantSecretCompensationResult result = tenantConfigStore.compensate(receipt);
|
||||
if (result == null) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
log.error("Хранилище конфигурации не вернуло результат компенсации tenant Secret");
|
||||
return null;
|
||||
}
|
||||
return restored;
|
||||
if (result == TenantSecretCompensationResult.RESTORED && receipt.persisted()) {
|
||||
readinessRegistry.clearConfigurationFailure();
|
||||
readinessRegistry.replaceDesired(receipt.previousTenants());
|
||||
}
|
||||
if (result == TenantSecretCompensationResult.NOT_REQUIRED && receipt.persisted()) {
|
||||
readinessRegistry.replaceDesired(receipt.committedTenants());
|
||||
}
|
||||
if (result == TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE
|
||||
|| (result == TenantSecretCompensationResult.NOT_REQUIRED && receipt.persisted())) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
}
|
||||
return result;
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
readinessRegistry.markConfigurationFailure();
|
||||
logTechnicalFailure("Компенсирующее восстановление tenant Secret завершилось ошибкой",
|
||||
compensationFailure);
|
||||
return false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String compensationMessage(String prefix, TenantSecretCompensationResult result) {
|
||||
if (result == TenantSecretCompensationResult.RESTORED) {
|
||||
return prefix + "; сохранённая конфигурация восстановлена";
|
||||
}
|
||||
if (result == TenantSecretCompensationResult.NOT_REQUIRED) {
|
||||
return prefix + "; tenant Secret не требовал восстановления";
|
||||
}
|
||||
if (result == TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE) {
|
||||
return prefix + "; tenant Secret уже изменён другим экземпляром, автоматический откат пропущен";
|
||||
}
|
||||
return prefix + "; требуется проверить tenant Secret";
|
||||
}
|
||||
|
||||
private void publishSuccessfulMutation(TenantSecretUpdateReceipt receipt,
|
||||
List<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;
|
||||
}
|
||||
|
||||
private boolean isH2Placeholder(TenantConfig config) {
|
||||
return config != null
|
||||
&& "H2 Placeholder".equals(config.getName())
|
||||
&& config.getUrl() != null
|
||||
&& config.getUrl().trim().startsWith("jdbc:h2:mem:placeholder");
|
||||
}
|
||||
|
||||
private TenantConfig validateAndNormalize(TenantConfig config) {
|
||||
if (config == null) {
|
||||
throw new IllegalArgumentException("Конфигурация тенанта обязательна");
|
||||
|
||||
@@ -13,6 +13,19 @@ spring.jpa.open-in-view=false
|
||||
|
||||
# Мультитенантность
|
||||
app.tenants.config-path=${TENANTS_CONFIG_PATH:tenants.json}
|
||||
app.tenants.config-required=${TENANTS_CONFIG_REQUIRED:false}
|
||||
|
||||
# Служебные liveness/readiness probes для Kubernetes
|
||||
management.endpoints.web.exposure.include=health
|
||||
management.endpoint.health.probes.enabled=true
|
||||
management.endpoint.health.group.liveness.include=livenessState
|
||||
management.endpoint.health.group.readiness.include=readinessState,tenantReadiness
|
||||
management.endpoint.health.show-details=never
|
||||
management.endpoint.health.show-components=never
|
||||
management.health.db.enabled=false
|
||||
|
||||
# Фоновая проверка БД не должна задерживать watcher tenant Secret
|
||||
spring.task.scheduling.pool.size=2
|
||||
|
||||
# JWT авторизация
|
||||
app.jwt.secret=${JWT_SECRET:}
|
||||
|
||||
@@ -39,7 +39,10 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
@Testcontainers
|
||||
@SpringBootTest(
|
||||
classes = RefreshTokenServiceConcurrencyIntegrationTest.TestApplication.class,
|
||||
properties = "app.jwt.refresh-ttl=7d"
|
||||
properties = {
|
||||
"app.jwt.refresh-ttl=7d",
|
||||
"management.endpoint.health.validate-group-membership=false"
|
||||
}
|
||||
)
|
||||
class RefreshTokenServiceConcurrencyIntegrationTest {
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.node.ObjectNode;
|
||||
import okhttp3.mockwebserver.Dispatcher;
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
@@ -13,50 +16,440 @@ import org.junit.jupiter.api.io.TempDir;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class KubernetesTenantSecretUpdaterTest {
|
||||
|
||||
private static final TypeReference<List<TenantConfig>> TENANT_LIST_TYPE = new TypeReference<>() {
|
||||
};
|
||||
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
|
||||
@TempDir
|
||||
Path tempDirectory;
|
||||
|
||||
@Test
|
||||
void updatesSecretThroughTrustedCaWithoutSendingPlainTenantJson() throws Exception {
|
||||
void readsCurrentSecretAndUsesConditionalPutWithoutPlainCredentials() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
HeldCertificate serverCertificate = serverCertificate(ca, "localhost");
|
||||
TenantConfig tenant = tenant("Тестовый университет", "test", "пароль-" + UUID.randomUUID());
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate)) {
|
||||
server.enqueue(new MockResponse().setResponseCode(200));
|
||||
TestFiles files = testFiles(ca);
|
||||
String rawPassword = UUID.randomUUID().toString();
|
||||
TenantConfig tenant = new TenantConfig("Университет", "test", "jdbc:test", "user", rawPassword);
|
||||
KubernetesTenantSecretUpdater updater = updater(files, server);
|
||||
server.enqueue(secretResponse("17", List.of()));
|
||||
server.enqueue(secretResponse("18", List.of(tenant)));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server);
|
||||
|
||||
boolean updated = updater.updateTenantsConfig(List.of(tenant));
|
||||
TenantSecretUpdateReceipt receipt = updater.upsertTenant(tenant);
|
||||
|
||||
assertThat(updated).isTrue();
|
||||
RecordedRequest request = server.takeRequest(2, TimeUnit.SECONDS);
|
||||
assertThat(request).isNotNull();
|
||||
assertThat(request.getPath()).isEqualTo("/api/v1/namespaces/magistr/secrets/tenants-secret");
|
||||
String body = request.getBody().readUtf8();
|
||||
assertThat(body).doesNotContain(rawPassword);
|
||||
assertThat(receipt.persisted()).isTrue();
|
||||
assertThat(receipt.changed()).isTrue();
|
||||
assertThat(receipt.committedResourceVersion()).isEqualTo("18");
|
||||
assertThat(domains(receipt.previousTenants())).isEmpty();
|
||||
assertThat(domains(receipt.committedTenants())).containsExactly("test");
|
||||
|
||||
JsonNode patch = objectMapper.readTree(body);
|
||||
String decoded = new String(
|
||||
Base64.getDecoder().decode(patch.path("data").path("tenants.json").asText()),
|
||||
StandardCharsets.UTF_8
|
||||
);
|
||||
assertThat(decoded).contains("\"domain\" : \"test\"");
|
||||
RecordedRequest get = server.takeRequest(2, TimeUnit.SECONDS);
|
||||
RecordedRequest put = server.takeRequest(2, TimeUnit.SECONDS);
|
||||
assertThat(get).isNotNull();
|
||||
assertThat(get.getMethod()).isEqualTo("GET");
|
||||
assertThat(get.getPath()).isEqualTo("/api/v1/namespaces/magistr/secrets/tenants-secret");
|
||||
assertThat(get.getHeader("Authorization")).isEqualTo("Bearer test-service-account-token");
|
||||
|
||||
assertThat(put).isNotNull();
|
||||
assertThat(put.getMethod()).isEqualTo("PUT");
|
||||
assertThat(put.getHeader("Content-Type")).startsWith("application/json");
|
||||
String body = put.getBody().readUtf8();
|
||||
assertThat(body).doesNotContain(tenant.getPassword());
|
||||
JsonNode update = objectMapper.readTree(body);
|
||||
assertThat(update.path("metadata").path("resourceVersion").asText()).isEqualTo("17");
|
||||
assertThat(update.path("data").path("other.key").asText()).isEqualTo("cHJlc2VydmU=");
|
||||
assertThat(domains(decodeTenants(update))).containsExactly("test");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipsPutWhenUpsertIsIdempotent() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
TenantConfig tenant = tenant("Университет", "same", "секрет");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.enqueue(secretResponse("5", List.of(tenant)));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server);
|
||||
|
||||
TenantSecretUpdateReceipt receipt = updater.upsertTenant(tenant);
|
||||
|
||||
assertThat(receipt.persisted()).isTrue();
|
||||
assertThat(receipt.changed()).isFalse();
|
||||
assertThat(receipt.committedResourceVersion()).isEqualTo("5");
|
||||
assertThat(server.getRequestCount()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void removeTenantKeepsOtherCurrentTenants() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a");
|
||||
TenantConfig beta = tenant("Бета", "beta", "секрет-b");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.enqueue(secretResponse("7", List.of(alpha, beta)));
|
||||
server.enqueue(secretResponse("8", List.of(beta)));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server);
|
||||
|
||||
TenantSecretUpdateReceipt receipt = updater.removeTenant("alpha");
|
||||
|
||||
assertThat(receipt.changed()).isTrue();
|
||||
assertThat(domains(receipt.previousTenants())).containsExactly("alpha", "beta");
|
||||
assertThat(domains(receipt.committedTenants())).containsExactly("beta");
|
||||
server.takeRequest(2, TimeUnit.SECONDS);
|
||||
RecordedRequest put = server.takeRequest(2, TimeUnit.SECONDS);
|
||||
assertThat(domains(decodeTenants(objectMapper.readTree(put.getBody().readUtf8()))))
|
||||
.containsExactly("beta");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void retriesConflictAgainstFreshResourceVersion() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a");
|
||||
TenantConfig beta = tenant("Бета", "beta", "секрет-b");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.enqueue(secretResponse("1", List.of()));
|
||||
server.enqueue(new MockResponse().setResponseCode(409));
|
||||
server.enqueue(secretResponse("2", List.of(alpha)));
|
||||
server.enqueue(secretResponse("3", List.of(alpha, beta)));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO);
|
||||
|
||||
TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta);
|
||||
|
||||
assertThat(receipt.committedResourceVersion()).isEqualTo("3");
|
||||
assertThat(domains(receipt.previousTenants())).containsExactly("alpha");
|
||||
assertThat(domains(receipt.committedTenants())).containsExactly("alpha", "beta");
|
||||
assertThat(server.getRequestCount()).isEqualTo(4);
|
||||
|
||||
server.takeRequest(2, TimeUnit.SECONDS);
|
||||
server.takeRequest(2, TimeUnit.SECONDS);
|
||||
server.takeRequest(2, TimeUnit.SECONDS);
|
||||
RecordedRequest finalPut = server.takeRequest(2, TimeUnit.SECONDS);
|
||||
JsonNode body = objectMapper.readTree(finalPut.getBody().readUtf8());
|
||||
assertThat(body.path("metadata").path("resourceVersion").asText()).isEqualTo("2");
|
||||
assertThat(domains(decodeTenants(body))).containsExactly("alpha", "beta");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void stopsAfterBoundedNumberOfConflicts() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
for (int attempt = 1; attempt <= 3; attempt++) {
|
||||
server.enqueue(secretResponse(String.valueOf(attempt), List.of()));
|
||||
server.enqueue(new MockResponse().setResponseCode(409));
|
||||
}
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO);
|
||||
|
||||
assertThatThrownBy(() -> updater.upsertTenant(tenant("Тест", "test", "секрет")))
|
||||
.isInstanceOf(TenantConfigPersistenceException.class)
|
||||
.hasMessageContaining("конкурентных");
|
||||
assertThat(server.getRequestCount()).isEqualTo(6);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcilesRetryablePutResponseWithoutCreatingUnsafeCompensationReceipt() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a");
|
||||
TenantConfig beta = tenant("Бета", "beta", "секрет-b");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.enqueue(secretResponse("20", List.of(alpha)));
|
||||
server.enqueue(new MockResponse().setResponseCode(503));
|
||||
server.enqueue(secretResponse("21", List.of(alpha, beta)));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO);
|
||||
|
||||
TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta);
|
||||
|
||||
assertThat(receipt.changed()).isFalse();
|
||||
assertThat(receipt.committedResourceVersion()).isEqualTo("21");
|
||||
assertThat(domains(receipt.committedTenants())).containsExactly("alpha", "beta");
|
||||
assertThat(updater.compensate(receipt))
|
||||
.isEqualTo(TenantSecretCompensationResult.NOT_REQUIRED);
|
||||
assertThat(server.getRequestCount()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reconcilesRequestTimeoutWithoutCreatingUnsafeCompensationReceipt() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a");
|
||||
TenantConfig beta = tenant("Бета", "beta", "секрет-b");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.enqueue(secretResponse("24", List.of(alpha)));
|
||||
server.enqueue(new MockResponse().setResponseCode(408));
|
||||
server.enqueue(secretResponse("25", List.of(alpha, beta)));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO);
|
||||
|
||||
TenantSecretUpdateReceipt receipt = updater.upsertTenant(beta);
|
||||
|
||||
assertThat(receipt.persisted()).isTrue();
|
||||
assertThat(receipt.changed()).isFalse();
|
||||
assertThat(receipt.committedResourceVersion()).isEqualTo("25");
|
||||
assertThat(domains(receipt.committedTenants())).containsExactly("alpha", "beta");
|
||||
assertThat(server.getRequestCount()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsSuccessfulMutationResponseWithDifferentTenantContent() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a");
|
||||
TenantConfig beta = tenant("Бета", "beta", "секрет-b");
|
||||
TenantConfig gamma = tenant("Гамма", "gamma", "секрет-c");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.enqueue(secretResponse("26", List.of(alpha)));
|
||||
server.enqueue(secretResponse("27", List.of(alpha, gamma)));
|
||||
server.enqueue(secretResponse("27", List.of(alpha, gamma)));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO);
|
||||
|
||||
assertThatThrownBy(() -> updater.upsertTenant(beta))
|
||||
.isInstanceOf(TenantConfigPersistenceException.class)
|
||||
.hasMessageContaining("не подтвердил сохранённое содержимое");
|
||||
assertThat(server.getRequestCount()).isEqualTo(3);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void normalizesDomainsBeforeMergeAndRejectsCaseInsensitiveDuplicates() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
TenantConfig upperAlpha = tenant("Альфа", " ALPHA ", "секрет-a");
|
||||
TenantConfig lowerAlpha = tenant("Другая Альфа", "alpha", "секрет-b");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.enqueue(secretResponse("22", List.of(upperAlpha, lowerAlpha)));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO);
|
||||
|
||||
assertThatThrownBy(() -> updater.upsertTenant(tenant("Бета", "beta", "секрет-c")))
|
||||
.isInstanceOf(TenantConfigPersistenceException.class)
|
||||
.hasMessageContaining("повторяющиеся домены");
|
||||
assertThat(server.getRequestCount()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void failsClosedWhenTenantsKeyIsMissing() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
ObjectNode secret = objectMapper.createObjectNode();
|
||||
secret.put("apiVersion", "v1");
|
||||
secret.put("kind", "Secret");
|
||||
secret.putObject("metadata").put("resourceVersion", "23");
|
||||
secret.putObject("data");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.enqueue(new MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(objectMapper.writeValueAsString(secret)));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO);
|
||||
|
||||
assertThatThrownBy(() -> updater.upsertTenant(tenant("Бета", "beta", "секрет")))
|
||||
.isInstanceOf(TenantConfigPersistenceException.class)
|
||||
.hasMessageContaining("data.tenants.json");
|
||||
assertThat(server.getRequestCount()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void terminalKubernetesErrorIsNotRetried() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.enqueue(new MockResponse().setResponseCode(403));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 3, Duration.ZERO);
|
||||
|
||||
assertThatThrownBy(() -> updater.upsertTenant(tenant("Бета", "beta", "секрет")))
|
||||
.isInstanceOf(TenantConfigPersistenceException.class)
|
||||
.hasMessageContaining("HTTP 403");
|
||||
assertThat(server.getRequestCount()).isEqualTo(1);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void twoConcurrentUpdatersPreserveBothMutations() throws Exception {
|
||||
HeldCertificate ca = certificateAuthority("Доверенный тестовый CA");
|
||||
TenantConfig alpha = tenant("Альфа", "alpha", "секрет-a");
|
||||
TenantConfig beta = tenant("Бета", "beta", "секрет-b");
|
||||
CasDispatcher dispatcher = new CasDispatcher(List.of(), true);
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate(ca, "localhost"))) {
|
||||
server.setDispatcher(dispatcher);
|
||||
TestFiles files = testFiles(ca);
|
||||
KubernetesTenantSecretUpdater first = updater(files, server, 4, Duration.ZERO);
|
||||
KubernetesTenantSecretUpdater second = updater(files, server, 4, Duration.ZERO);
|
||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
||||
try {
|
||||
Future<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
|
||||
void rejectsServerSignedByDifferentCa() throws Exception {
|
||||
HeldCertificate trustedCa = certificateAuthority("Доверенный тестовый CA");
|
||||
@@ -64,10 +457,11 @@ class KubernetesTenantSecretUpdaterTest {
|
||||
HeldCertificate serverCertificate = serverCertificate(untrustedCa, "localhost");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate)) {
|
||||
server.enqueue(new MockResponse().setResponseCode(200));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(trustedCa), server);
|
||||
server.enqueue(secretResponse("1", List.of()));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(trustedCa), server, 1, Duration.ZERO);
|
||||
|
||||
assertThat(updater.updateTenantsConfig(List.of())).isFalse();
|
||||
assertThatThrownBy(() -> updater.upsertTenant(tenant("Тест", "test", "секрет")))
|
||||
.isInstanceOf(TenantConfigPersistenceException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,10 +471,11 @@ class KubernetesTenantSecretUpdaterTest {
|
||||
HeldCertificate serverCertificate = serverCertificate(ca, "not-localhost.invalid");
|
||||
|
||||
try (MockWebServer server = httpsServer(serverCertificate)) {
|
||||
server.enqueue(new MockResponse().setResponseCode(200));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server);
|
||||
server.enqueue(secretResponse("1", List.of()));
|
||||
KubernetesTenantSecretUpdater updater = updater(testFiles(ca), server, 1, Duration.ZERO);
|
||||
|
||||
assertThat(updater.updateTenantsConfig(List.of())).isFalse();
|
||||
assertThatThrownBy(() -> updater.upsertTenant(tenant("Тест", "test", "секрет")))
|
||||
.isInstanceOf(TenantConfigPersistenceException.class);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,21 +489,33 @@ class KubernetesTenantSecretUpdaterTest {
|
||||
files.caPath(),
|
||||
"https://localhost:1",
|
||||
"tenants-secret",
|
||||
objectMapper
|
||||
objectMapper,
|
||||
1,
|
||||
Duration.ZERO
|
||||
);
|
||||
|
||||
assertThat(updater.updateTenantsConfig(List.of())).isFalse();
|
||||
assertThatThrownBy(() -> updater.upsertTenant(tenant("Тест", "test", "секрет")))
|
||||
.isInstanceOf(TenantConfigPersistenceException.class)
|
||||
.hasMessageContaining("защищённое соединение");
|
||||
}
|
||||
|
||||
private KubernetesTenantSecretUpdater updater(TestFiles files, MockWebServer server) {
|
||||
String apiBase = server.url("/").toString();
|
||||
return updater(files, server, 3, Duration.ofMillis(5));
|
||||
}
|
||||
|
||||
private KubernetesTenantSecretUpdater updater(TestFiles files,
|
||||
MockWebServer server,
|
||||
int maxAttempts,
|
||||
Duration retryDelay) {
|
||||
return new KubernetesTenantSecretUpdater(
|
||||
files.tokenPath(),
|
||||
files.namespacePath(),
|
||||
files.caPath(),
|
||||
apiBase,
|
||||
server.url("/").toString(),
|
||||
"tenants-secret",
|
||||
objectMapper
|
||||
objectMapper,
|
||||
maxAttempts,
|
||||
retryDelay
|
||||
);
|
||||
}
|
||||
|
||||
@@ -122,6 +529,58 @@ class KubernetesTenantSecretUpdaterTest {
|
||||
return new TestFiles(tokenPath, namespacePath, caPath);
|
||||
}
|
||||
|
||||
private TenantConfig tenant(String name, String domain, String password) {
|
||||
return new TenantConfig(
|
||||
name,
|
||||
domain,
|
||||
"jdbc:postgresql://db/" + domain,
|
||||
"user-" + domain,
|
||||
password
|
||||
);
|
||||
}
|
||||
|
||||
private MockResponse secretResponse(String resourceVersion, List<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) {
|
||||
return new HeldCertificate.Builder()
|
||||
.certificateAuthority(1)
|
||||
@@ -149,6 +608,68 @@ class KubernetesTenantSecretUpdaterTest {
|
||||
return server;
|
||||
}
|
||||
|
||||
private final class CasDispatcher extends Dispatcher {
|
||||
private final Object monitor = new Object();
|
||||
private final CountDownLatch firstReads;
|
||||
private final AtomicInteger initialReadCount = new AtomicInteger();
|
||||
private final AtomicInteger conflicts = new AtomicInteger();
|
||||
private List<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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.magistr.app.config.tenant;
|
||||
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.magistr.app.service.RetiredTenantPoolService;
|
||||
import com.magistr.app.service.TenantDatabaseMigrationService;
|
||||
import com.magistr.app.service.TenantLifecycleService;
|
||||
@@ -103,11 +104,15 @@ class TenantConfigWatcherTest {
|
||||
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
|
||||
TenantLifecycleService lifecycleService = new TenantLifecycleService(
|
||||
routingDataSource,
|
||||
mock(KubernetesTenantSecretUpdater.class),
|
||||
mock(TenantConfigStore.class),
|
||||
mock(TenantDatabaseMigrationService.class),
|
||||
mock(RetiredTenantPoolService.class)
|
||||
mock(RetiredTenantPoolService.class),
|
||||
mock(TenantReadinessRegistry.class)
|
||||
);
|
||||
TenantConfigWatcher watcher = new TenantConfigWatcher(
|
||||
lifecycleService,
|
||||
mock(TenantReadinessRegistry.class)
|
||||
);
|
||||
TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService);
|
||||
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
|
||||
CountDownLatch apiMutationReachedRefreshWindow = new CountDownLatch(1);
|
||||
CountDownLatch allowApiRefresh = new CountDownLatch(1);
|
||||
@@ -148,7 +153,10 @@ class TenantConfigWatcherTest {
|
||||
invocation.<Runnable>getArgument(0).run();
|
||||
return null;
|
||||
}).when(lifecycleService).executeSerialized(any(Runnable.class));
|
||||
TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService);
|
||||
TenantConfigWatcher watcher = new TenantConfigWatcher(
|
||||
lifecycleService,
|
||||
mock(TenantReadinessRegistry.class)
|
||||
);
|
||||
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
|
||||
return watcher;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@SpringBootTest(
|
||||
classes = AcademicCalendarGridServiceIntegrationTest.TestApplication.class,
|
||||
properties = "spring.jpa.open-in-view=false"
|
||||
properties = {
|
||||
"spring.jpa.open-in-view=false",
|
||||
"management.endpoint.health.validate-group-membership=false"
|
||||
}
|
||||
)
|
||||
class AcademicCalendarGridServiceIntegrationTest {
|
||||
|
||||
|
||||
@@ -32,7 +32,8 @@ import static org.assertj.core.api.Assertions.assertThat;
|
||||
classes = PostgreSqlAdvisoryLockIntegrationTest.TestApplication.class,
|
||||
properties = {
|
||||
"spring.flyway.enabled=false",
|
||||
"spring.jpa.hibernate.ddl-auto=none"
|
||||
"spring.jpa.hibernate.ddl-auto=none",
|
||||
"management.endpoint.health.validate-group-membership=false"
|
||||
}
|
||||
)
|
||||
class PostgreSqlAdvisoryLockIntegrationTest {
|
||||
|
||||
@@ -45,7 +45,10 @@ import static org.mockito.Mockito.verify;
|
||||
@Testcontainers
|
||||
@SpringBootTest(
|
||||
classes = ScheduleRuleServiceConcurrencyIntegrationTest.TestApplication.class,
|
||||
properties = "spring.jpa.open-in-view=false"
|
||||
properties = {
|
||||
"spring.jpa.open-in-view=false",
|
||||
"management.endpoint.health.validate-group-membership=false"
|
||||
}
|
||||
)
|
||||
class ScheduleRuleServiceConcurrencyIntegrationTest {
|
||||
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater;
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantConfigPersistenceException;
|
||||
import com.magistr.app.config.tenant.TenantConfigStore;
|
||||
import com.magistr.app.config.tenant.TenantContext;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -18,6 +21,7 @@ import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
@@ -42,7 +46,8 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
||||
|
||||
private TenantRoutingDataSource routingDataSource;
|
||||
private TenantDatabaseMigrationService migrationService;
|
||||
private KubernetesTenantSecretUpdater tenantSecretUpdater;
|
||||
private TenantConfigStore tenantConfigStore;
|
||||
private TenantReadinessRegistry readinessRegistry;
|
||||
private TenantLifecycleService lifecycleService;
|
||||
private RetiredTenantPoolService retiredPoolService;
|
||||
private HikariDataSource oldPool;
|
||||
@@ -56,13 +61,15 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
||||
|
||||
routingDataSource = new TenantRoutingDataSource();
|
||||
migrationService = new TenantDatabaseMigrationService();
|
||||
tenantSecretUpdater = mock(KubernetesTenantSecretUpdater.class);
|
||||
tenantConfigStore = mock(TenantConfigStore.class);
|
||||
readinessRegistry = new TenantReadinessRegistry();
|
||||
retiredPoolService = new RetiredTenantPoolService(Duration.ofSeconds(2), Duration.ofMillis(25));
|
||||
lifecycleService = new TenantLifecycleService(
|
||||
routingDataSource,
|
||||
tenantSecretUpdater,
|
||||
tenantConfigStore,
|
||||
migrationService,
|
||||
retiredPoolService
|
||||
retiredPoolService,
|
||||
readinessRegistry
|
||||
);
|
||||
|
||||
oldConfig = config("Старый tenant", jdbcUrl(OLD_DATABASE), POSTGRES.getPassword());
|
||||
@@ -77,6 +84,9 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
||||
|
||||
oldPool = routingDataSource.prepareTenantDataSource(oldConfig);
|
||||
assertThat(routingDataSource.swapTenant(oldConfig, oldPool)).isNull();
|
||||
readinessRegistry.replaceDesired(List.of(oldConfig));
|
||||
readinessRegistry.markMigrationSucceeded(oldConfig);
|
||||
readinessRegistry.markConnectivity(DOMAIN, true);
|
||||
assertOldState();
|
||||
}
|
||||
|
||||
@@ -105,10 +115,10 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
||||
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(wrongCredentials))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось подключиться к базе данных тенанта");
|
||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
||||
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||
assertOldState();
|
||||
|
||||
reset(tenantSecretUpdater);
|
||||
reset(tenantConfigStore);
|
||||
TenantConfig badChecksum = config(
|
||||
"Повреждённый Flyway",
|
||||
jdbcUrl(BAD_CHECKSUM_DATABASE),
|
||||
@@ -117,11 +127,13 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
||||
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(badChecksum))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
||||
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||
assertOldState();
|
||||
|
||||
reset(tenantSecretUpdater);
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true);
|
||||
reset(tenantConfigStore);
|
||||
when(tenantConfigStore.upsertTenant(any())).thenThrow(
|
||||
new TenantConfigPersistenceException("Не удалось сохранить конфигурацию тенантов")
|
||||
);
|
||||
TenantConfig validCandidate = config(
|
||||
"Новый tenant",
|
||||
jdbcUrl(VALID_DATABASE),
|
||||
@@ -129,12 +141,19 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
||||
);
|
||||
assertThatThrownBy(() -> lifecycleService.addOrUpdateTenant(validCandidate))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов");
|
||||
verify(tenantConfigStore).upsertTenant(any());
|
||||
verify(tenantConfigStore, never()).compensate(any());
|
||||
assertOldState();
|
||||
|
||||
reset(tenantSecretUpdater);
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true);
|
||||
reset(tenantConfigStore);
|
||||
when(tenantConfigStore.upsertTenant(any())).thenReturn(new TenantSecretUpdateReceipt(
|
||||
true,
|
||||
true,
|
||||
"2",
|
||||
List.of(oldConfig),
|
||||
List.of(validCandidate)
|
||||
));
|
||||
TenantConfig saved;
|
||||
try (Connection inFlightOldConnection = oldPool.getConnection()) {
|
||||
saved = lifecycleService.addOrUpdateTenant(validCandidate);
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.magistr.app.service;
|
||||
|
||||
import com.magistr.app.config.tenant.KubernetesTenantSecretUpdater;
|
||||
import com.magistr.app.config.tenant.TenantConfig;
|
||||
import com.magistr.app.config.tenant.TenantConfigPersistenceException;
|
||||
import com.magistr.app.config.tenant.TenantConfigStore;
|
||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||
import com.magistr.app.config.tenant.TenantSecretCompensationResult;
|
||||
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
|
||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -16,6 +20,8 @@ import org.mockito.quality.Strictness;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.SQLException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -35,12 +41,14 @@ class TenantLifecycleServiceTest {
|
||||
@Mock
|
||||
private TenantRoutingDataSource routingDataSource;
|
||||
@Mock
|
||||
private KubernetesTenantSecretUpdater tenantSecretUpdater;
|
||||
private TenantConfigStore tenantConfigStore;
|
||||
@Mock
|
||||
private TenantDatabaseMigrationService migrationService;
|
||||
@Mock
|
||||
private RetiredTenantPoolService retiredPoolService;
|
||||
@Mock
|
||||
private TenantReadinessRegistry readinessRegistry;
|
||||
@Mock
|
||||
private HikariDataSource candidate;
|
||||
@Mock
|
||||
private HikariDataSource oldDataSource;
|
||||
@@ -56,9 +64,10 @@ class TenantLifecycleServiceTest {
|
||||
void setUp() throws Exception {
|
||||
service = new TenantLifecycleService(
|
||||
routingDataSource,
|
||||
tenantSecretUpdater,
|
||||
tenantConfigStore,
|
||||
migrationService,
|
||||
retiredPoolService
|
||||
retiredPoolService,
|
||||
readinessRegistry
|
||||
);
|
||||
oldAlpha = config("Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||
beta = config("Beta", "beta", "jdbc:beta", "beta-user", "beta-password");
|
||||
@@ -70,7 +79,11 @@ class TenantLifecycleServiceTest {
|
||||
when(routingDataSource.prepareTenantDataSource(any(TenantConfig.class))).thenReturn(candidate);
|
||||
when(candidate.getConnection()).thenReturn(candidateConnection);
|
||||
when(candidateConnection.isValid(5)).thenReturn(true);
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true);
|
||||
when(tenantConfigStore.upsertTenant(any(TenantConfig.class)))
|
||||
.thenAnswer(invocation -> upsertReceipt(invocation.getArgument(0)));
|
||||
when(tenantConfigStore.removeTenant(any(String.class))).thenAnswer(invocation ->
|
||||
removeReceipt(invocation.getArgument(0)));
|
||||
when(tenantConfigStore.compensate(any())).thenReturn(TenantSecretCompensationResult.RESTORED);
|
||||
when(routingDataSource.swapTenant(any(TenantConfig.class), any()))
|
||||
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
|
||||
}
|
||||
@@ -85,13 +98,14 @@ class TenantLifecycleServiceTest {
|
||||
|
||||
assertConfig(saved, "Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
ArgumentCaptor<TenantConfig> preparedConfig = ArgumentCaptor.forClass(TenantConfig.class);
|
||||
ArgumentCaptor<List<TenantConfig>> persistedConfigs = listCaptor();
|
||||
ArgumentCaptor<TenantConfig> persistedConfig = ArgumentCaptor.forClass(TenantConfig.class);
|
||||
InOrder order = inOrder(
|
||||
routingDataSource,
|
||||
candidate,
|
||||
candidateConnection,
|
||||
migrationService,
|
||||
tenantSecretUpdater,
|
||||
tenantConfigStore,
|
||||
readinessRegistry,
|
||||
retiredPoolService
|
||||
);
|
||||
order.verify(routingDataSource).snapshotTenantConfigs();
|
||||
@@ -100,19 +114,63 @@ class TenantLifecycleServiceTest {
|
||||
order.verify(candidateConnection).isValid(5);
|
||||
order.verify(candidateConnection).close();
|
||||
order.verify(migrationService).migrate(candidate);
|
||||
order.verify(tenantSecretUpdater).updateTenantsConfig(persistedConfigs.capture());
|
||||
order.verify(tenantConfigStore).upsertTenant(persistedConfig.capture());
|
||||
order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
|
||||
order.verify(readinessRegistry).replaceDesired(any());
|
||||
order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));
|
||||
order.verify(readinessRegistry).markConnectivity("alpha", true);
|
||||
order.verify(retiredPoolService).retire(oldDataSource);
|
||||
|
||||
assertConfig(preparedConfig.getValue(),
|
||||
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
assertThat(persistedConfigs.getValue()).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
assertConfig(persistedConfigs.getValue().get(0),
|
||||
assertConfig(persistedConfig.getValue(),
|
||||
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
verify(candidate, never()).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void addsNewTenantWithoutRetiringExistingPool() {
|
||||
Map<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
|
||||
void closesCandidateAndKeepsOldPoolWhenCredentialsAreWrong() throws Exception {
|
||||
when(candidate.getConnection()).thenThrow(new SQLException("тестовая ошибка аутентификации"));
|
||||
@@ -123,11 +181,27 @@ class TenantLifecycleServiceTest {
|
||||
|
||||
verify(candidate).close();
|
||||
verify(migrationService, never()).migrate(any());
|
||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
||||
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesCandidateAndDoesNotPersistWhenConnectionIsInvalid() throws Exception {
|
||||
when(candidateConnection.isValid(5)).thenReturn(false);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("База данных тенанта не подтвердила готовность подключения");
|
||||
|
||||
verify(candidateConnection).close();
|
||||
verify(candidate).close();
|
||||
verify(migrationService, never()).migrate(any());
|
||||
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesCandidateAndKeepsOldStateWhenFlywayFails() {
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
|
||||
@@ -138,134 +212,141 @@ class TenantLifecycleServiceTest {
|
||||
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
||||
|
||||
verify(candidate).close();
|
||||
verify(tenantSecretUpdater, never()).updateTenantsConfig(any());
|
||||
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void closesCandidateAndRestoresOldSnapshotWhenPersistenceReturnsFalse() {
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, true);
|
||||
void persistenceConflictDoesNotStartBlindCompensation() {
|
||||
when(tenantConfigStore.upsertTenant(any())).thenThrow(new TenantConfigPersistenceException(
|
||||
"Конфигурация тенантов одновременно изменяется другим экземпляром; повторите запрос"
|
||||
));
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
|
||||
.hasMessage("Конфигурация тенантов одновременно изменяется другим экземпляром; повторите запрос");
|
||||
|
||||
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture());
|
||||
assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
assertConfig(snapshots.getAllValues().get(1).get(0),
|
||||
"Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||
verify(tenantConfigStore, never()).compensate(any());
|
||||
verify(candidate).close();
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void restoresOldSnapshotWhenFirstPersistenceThrows() {
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any()))
|
||||
.thenThrow(new IllegalStateException("неопределённый исход PATCH"))
|
||||
.thenReturn(true);
|
||||
void unknownPersistenceFailureUsesSafeMessageAndDoesNotCompensate() {
|
||||
when(tenantConfigStore.upsertTenant(any()))
|
||||
.thenThrow(new IllegalStateException("неопределённый технический результат PUT"));
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; прежняя конфигурация восстановлена");
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов");
|
||||
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
|
||||
verify(tenantConfigStore, never()).compensate(any());
|
||||
verify(candidate).close();
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsDesynchronizationWhenPersistenceAndCompensationFail() {
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(false, false);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось сохранить конфигурацию тенантов; требуется проверить tenant Secret");
|
||||
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(any());
|
||||
verify(candidate).close();
|
||||
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensatesPersistedSnapshotWhenActivationFails() {
|
||||
void compensatesOnlyCommittedReceiptWhenActivationFails() {
|
||||
TenantSecretUpdateReceipt receipt = upsertReceipt(newAlpha());
|
||||
when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt);
|
||||
when(routingDataSource.swapTenant(any(), any()))
|
||||
.thenThrow(new IllegalStateException("тестовый отказ активации"));
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось активировать подключение тенанта; сохранённая конфигурация восстановлена");
|
||||
|
||||
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture());
|
||||
assertThat(snapshots.getAllValues().get(0)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
assertConfig(snapshots.getAllValues().get(0).get(0),
|
||||
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
assertConfig(snapshots.getAllValues().get(1).get(0),
|
||||
"Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||
verify(tenantConfigStore).compensate(receipt);
|
||||
verify(candidate).close();
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsSecretDesynchronizationWhenActivationAndCompensationFail() {
|
||||
void concurrentChangeSkipsUnsafeCompensationAndMarksReadinessFailed() {
|
||||
TenantSecretUpdateReceipt receipt = upsertReceipt(newAlpha());
|
||||
when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt);
|
||||
when(tenantConfigStore.compensate(receipt))
|
||||
.thenReturn(TenantSecretCompensationResult.SKIPPED_CONCURRENT_CHANGE);
|
||||
when(routingDataSource.swapTenant(any(), any()))
|
||||
.thenThrow(new IllegalStateException("тестовый отказ активации"));
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, false);
|
||||
|
||||
assertThatThrownBy(() -> service.addOrUpdateTenant(newAlpha()))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось активировать подключение тенанта; требуется проверить tenant Secret");
|
||||
.hasMessage("Не удалось активировать подключение тенанта; tenant Secret уже изменён "
|
||||
+ "другим экземпляром, автоматический откат пропущен");
|
||||
|
||||
verify(readinessRegistry).markConfigurationFailure();
|
||||
verify(candidate).close();
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void removalPersistsDesiredStateBeforeAtomicRemovalAndClosesRemovedPool() {
|
||||
void removalPersistsOperationBeforeAtomicRemovalAndClosesRemovedPool() {
|
||||
when(routingDataSource.removeTenantAtomically("alpha"))
|
||||
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
|
||||
|
||||
TenantConfig removed = service.removeTenant(" ALPHA ");
|
||||
|
||||
assertConfig(removed, "Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||
ArgumentCaptor<List<TenantConfig>> desired = listCaptor();
|
||||
InOrder order = inOrder(routingDataSource, tenantSecretUpdater, retiredPoolService);
|
||||
InOrder order = inOrder(routingDataSource, tenantConfigStore, readinessRegistry, retiredPoolService);
|
||||
order.verify(routingDataSource).snapshotTenantConfigs();
|
||||
order.verify(tenantSecretUpdater).updateTenantsConfig(desired.capture());
|
||||
order.verify(tenantConfigStore).removeTenant("alpha");
|
||||
order.verify(routingDataSource).removeTenantAtomically("alpha");
|
||||
order.verify(readinessRegistry).replaceDesired(any());
|
||||
order.verify(retiredPoolService).retire(oldDataSource);
|
||||
assertThat(desired.getValue()).extracting(TenantConfig::getDomain).containsExactly("beta");
|
||||
}
|
||||
|
||||
@Test
|
||||
void removalFailureRestoresOldPersistedSnapshotAndKeepsPoolOpen() {
|
||||
void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() {
|
||||
TenantSecretUpdateReceipt receipt = removeReceipt("alpha");
|
||||
when(tenantConfigStore.removeTenant("alpha")).thenReturn(receipt);
|
||||
when(routingDataSource.removeTenantAtomically("alpha"))
|
||||
.thenThrow(new IllegalStateException("тестовый отказ удаления"));
|
||||
when(tenantSecretUpdater.updateTenantsConfig(any())).thenReturn(true, true);
|
||||
|
||||
assertThatThrownBy(() -> service.removeTenant("alpha"))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось отключить тенанта; сохранённая конфигурация восстановлена");
|
||||
|
||||
ArgumentCaptor<List<TenantConfig>> snapshots = listCaptor();
|
||||
verify(tenantSecretUpdater, org.mockito.Mockito.times(2)).updateTenantsConfig(snapshots.capture());
|
||||
assertThat(snapshots.getAllValues().get(0)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("beta");
|
||||
assertThat(snapshots.getAllValues().get(1)).extracting(TenantConfig::getDomain)
|
||||
.containsExactly("alpha", "beta");
|
||||
verify(tenantConfigStore).compensate(receipt);
|
||||
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||
}
|
||||
|
||||
@Test
|
||||
void persistedTenantBecomesReadyOnlyAfterMigrationAndSwap() {
|
||||
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
|
||||
when(routingDataSource.hasTenant("gamma")).thenReturn(false);
|
||||
when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null);
|
||||
|
||||
service.synchronizeFromPersistedConfig(List.of(gamma));
|
||||
|
||||
InOrder order = inOrder(readinessRegistry, migrationService, routingDataSource);
|
||||
order.verify(readinessRegistry).clearConfigurationFailure();
|
||||
order.verify(readinessRegistry).replaceDesired(any());
|
||||
order.verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class));
|
||||
order.verify(migrationService).migrate(candidate);
|
||||
order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
|
||||
order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));
|
||||
order.verify(readinessRegistry).markConnectivity("gamma", true);
|
||||
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedPersistedMigrationLeavesTenantNotReadyForWatcherRetry() {
|
||||
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
|
||||
when(routingDataSource.hasTenant("gamma")).thenReturn(false);
|
||||
org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
|
||||
.when(migrationService).migrate(candidate);
|
||||
|
||||
assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(gamma)))
|
||||
.isInstanceOf(TenantLifecycleException.class)
|
||||
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
||||
|
||||
verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class));
|
||||
verify(readinessRegistry).markMigrationFailed(any(TenantConfig.class));
|
||||
verify(readinessRegistry, never()).markMigrationSucceeded(any(TenantConfig.class));
|
||||
verify(candidate).close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsDnsLabelBoundariesAndRejectsTooLongOrDottedDomain() {
|
||||
String maxLengthDomain = "a".repeat(63);
|
||||
@@ -295,6 +376,36 @@ class TenantLifecycleServiceTest {
|
||||
return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||
}
|
||||
|
||||
private TenantSecretUpdateReceipt upsertReceipt(TenantConfig tenant) {
|
||||
Map<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,
|
||||
String domain,
|
||||
String url,
|
||||
@@ -316,8 +427,4 @@ class TenantLifecycleServiceTest {
|
||||
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
|
||||
|
||||
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
|
||||
Все прикладные эндпоинты имеют префикс `/api/`. Служебные проверки Kubernetes доступны
|
||||
под `/actuator/health/`. Ответы возвращаются в формате JSON.
|
||||
|
||||
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
|
||||
|
||||
@@ -18,6 +19,53 @@
|
||||
|
||||
---
|
||||
|
||||
## Служебные проверки состояния
|
||||
|
||||
Эти endpoints предназначены для Kubernetes kubelet, не требуют bearer-токен и не зависят
|
||||
от tenant-домена в заголовке `Host`. Из Actuator наружу опубликован только `health`, а
|
||||
состав компонентов, домены, JDBC URL и credentials в ответах скрыты.
|
||||
|
||||
### `GET /actuator/health/liveness`
|
||||
|
||||
Проверяет только жизнеспособность процесса. Недоступность tenant-БД не меняет liveness и
|
||||
не должна создавать цикл перезапусков pod.
|
||||
|
||||
**Ответ работающего процесса (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "UP"
|
||||
}
|
||||
```
|
||||
|
||||
### `GET /actuator/health/readiness`
|
||||
|
||||
Разрешает направлять трафик в pod только после успешных миграций и свежей успешной
|
||||
проверки соединения со всеми обязательными tenant-БД. Пустая конфигурация, H2-заглушка,
|
||||
ошибка чтения tenant-конфигурации, незавершённая или неуспешная миграция, недоступное либо
|
||||
просроченное соединение делают pod неготовым.
|
||||
|
||||
**Готов (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "UP"
|
||||
}
|
||||
```
|
||||
|
||||
**Не готов (503):**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "DOWN"
|
||||
}
|
||||
```
|
||||
|
||||
`UP` и `DOWN` — стандартные машинные значения протокола Spring Boot Actuator, а не
|
||||
пользовательские сообщения интерфейса.
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация
|
||||
|
||||
### `POST /api/auth/login`
|
||||
@@ -1090,6 +1138,8 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
|
||||
## Управление тенантами (Базы данных)
|
||||
|
||||
Все endpoints раздела доступны только пользователю с ролью `ADMIN`.
|
||||
|
||||
### `GET /api/database/status`
|
||||
|
||||
Статус текущего подключения (определяется по домену запроса).
|
||||
@@ -1107,11 +1157,23 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
|
||||
### `GET /api/database/tenants`
|
||||
|
||||
Список всех тенантов.
|
||||
Список всех тенантов. Пароль в ответ не включается.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "СВФУ",
|
||||
"domain": "swsu",
|
||||
"url": "jdbc:postgresql://db-host:5432/swsu_db",
|
||||
"username": "dbuser",
|
||||
"connected": true
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### `POST /api/database/tenants`
|
||||
|
||||
Добавление нового тенанта.
|
||||
Создание нового или обновление существующего тенанта по `domain`.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1123,19 +1185,56 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
||||
}
|
||||
```
|
||||
|
||||
`domain` приводится к нижнему регистру и должен быть одной DNS-меткой длиной от 1 до
|
||||
63 символов. `url` обязателен и должен начинаться с `jdbc:`. Если `name` пуст, вместо
|
||||
него используется нормализованный `domain`.
|
||||
|
||||
**Логика:**
|
||||
1. Создаёт HikariCP пул для нового тенанта
|
||||
2. Запускает Flyway миграции на его БД
|
||||
3. Обновляет внешний Kubernetes Secret `tenants-secret`
|
||||
1. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
|
||||
2. Открывает соединение и явно проверяет его готовность.
|
||||
3. Выполняет Flyway-валидацию и миграции tenant-БД.
|
||||
4. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
|
||||
выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`.
|
||||
5. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
|
||||
retry/backoff; неизменившаяся конфигурация не записывается повторно.
|
||||
6. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
|
||||
7. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
|
||||
либо по истечении защитного таймаута.
|
||||
|
||||
Backend соединяется с Kubernetes API только через проверенный service-account CA и
|
||||
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
|
||||
не возвращается как успешная. Значения credentials никогда не включаются в ответ или лог
|
||||
Kubernetes updater.
|
||||
|
||||
При ошибке credentials, проверки соединения, Flyway или сохранения Secret временный pool
|
||||
закрывается, а прежнее подключение продолжает обслуживать запросы. Если Kubernetes
|
||||
подтвердил запись, но последующая локальная активация завершилась ошибкой, backend
|
||||
восстанавливает прежний снимок только пока Secret сохраняет `resourceVersion` этой записи.
|
||||
Более новое изменение другого pod не перезаписывается. Неопределённый сетевой результат
|
||||
сначала сверяется повторным чтением и не создаёт основания для небезопасной компенсации.
|
||||
Ошибка lifecycle возвращается как безопасный русский ответ без JDBC/Flyway details:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"message": "Не удалось выполнить миграции базы данных тенанта"
|
||||
}
|
||||
```
|
||||
|
||||
HTTP-статусы: `400` для некорректного payload и `503` для ошибки подключения, миграции,
|
||||
персистенции или активации.
|
||||
|
||||
Вне Kubernetes обновление Secret пропускается, поэтому добавленный через API tenant живёт
|
||||
только до перезапуска процесса. Для постоянной локальной конфигурации используется
|
||||
неотслеживаемый файл `backend/tenants.json`.
|
||||
|
||||
### `DELETE /api/database/tenants/{domain}`
|
||||
|
||||
Удаление тенанта.
|
||||
Удаление тенанта. Backend читает актуальный Secret, удаляет только запрошенный `domain` и
|
||||
выполняет условный `PUT` по `resourceVersion`, затем атомарно исключает tenant из локальной
|
||||
маршрутизации и передаёт pool на отложенное закрытие. При отказе локального удаления
|
||||
компенсация также допускается только для подтверждённой версии и не затирает более новое
|
||||
изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `503`.
|
||||
|
||||
### `POST /api/database/test`
|
||||
|
||||
@@ -1166,5 +1265,8 @@ Kubernetes updater.
|
||||
| `200` | Успех |
|
||||
| `400` | Ошибка валидации или некорректные параметры запроса |
|
||||
| `401` | Неверные учётные данные |
|
||||
| `403` | Недостаточно прав для операции |
|
||||
| `404` | Ресурс / тенант не найден |
|
||||
| `409` | Конфликт бизнес-инвариантов или конкурентного изменения |
|
||||
| `500` | Внутренняя ошибка сервера |
|
||||
| `503` | Внешняя БД или обязательная инфраструктура временно недоступна |
|
||||
|
||||
@@ -78,11 +78,18 @@ sequenceDiagram
|
||||
|-------|-----------|
|
||||
| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант |
|
||||
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
|
||||
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
|
||||
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
|
||||
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
||||
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
||||
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
|
||||
| `KubernetesTenantSecretUpdater` | Обновляет Kubernetes Secret с tenant-конфигурацией через API, проверяя TLS по service-account CA |
|
||||
| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации |
|
||||
| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA |
|
||||
| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе |
|
||||
| `TenantDatabaseMigrationService` | Запускает Flyway для подготовленного, ещё не опубликованного pool |
|
||||
| `RetiredTenantPoolService` | После swap дожидается завершения активных подключений старого pool и закрывает его |
|
||||
| `TenantReadinessRegistry` | Хранит атомарный снимок обязательных tenant, миграций и свежести проверок соединения |
|
||||
| `TenantDatabaseHealthMonitor` | Ограниченно-параллельно проверяет соединения с обязательными tenant-БД в фоне |
|
||||
| `TenantReadinessHealthIndicator` | Включает агрегированную готовность tenant-БД в Actuator readiness group |
|
||||
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
|
||||
|
||||
### Определение тенанта
|
||||
@@ -121,16 +128,84 @@ sequenceDiagram
|
||||
|
||||
### Жизненный цикл тенанта
|
||||
|
||||
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет `tenants-secret`
|
||||
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет смонтированный `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
|
||||
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет `tenants-secret`
|
||||
1. **Добавление или обновление через API:** `TenantLifecycleService` нормализует конфигурацию,
|
||||
создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет
|
||||
Flyway. Затем `TenantConfigStore` читает актуальный Secret и применяет к нему только
|
||||
upsert запрошенного домена.
|
||||
2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего
|
||||
`tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект
|
||||
условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное
|
||||
состояние читается заново, а собственная мутация повторно применяется к нему. Число
|
||||
попыток ограничено тремя, задержка между попытками возрастает; идемпотентная операция
|
||||
не отправляет лишний `PUT`.
|
||||
3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным
|
||||
чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`.
|
||||
4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret
|
||||
закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена
|
||||
только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей
|
||||
`resourceVersion`. Если другой pod уже записал более новую версию, автоматический откат
|
||||
пропускается. Неопределённый сетевой результат сначала сверяется повторным `GET` и не
|
||||
создаёт квитанцию для небезопасной компенсации. HTTP-граница получает русское безопасное
|
||||
сообщение без JDBC/Flyway details.
|
||||
5. **Drain старого pool:** после успешного swap новые запросы сразу используют candidate,
|
||||
а прежний Hikari pool закрывается после завершения активных подключений либо по истечении
|
||||
настраиваемого grace timeout.
|
||||
6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный
|
||||
`tenants.json` и применяет добавление/удаление под тем же локальным lifecycle-monitor.
|
||||
7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только
|
||||
удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant
|
||||
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
|
||||
ограничения компенсации по `resourceVersion`.
|
||||
|
||||
Сериализация lifecycle и атомарный снимок защищают запросы внутри одного backend-процесса.
|
||||
Между pod потерянное обновление предотвращает optimistic locking Kubernetes: каждый конфликт
|
||||
заставляет заново прочитать Secret и повторно применить только свою доменную мутацию. Локальный
|
||||
monitor по-прежнему не считается межподовой блокировкой.
|
||||
|
||||
Вне Kubernetes `KubernetesTenantSecretUpdater` работает как runtime-only адаптер: Secret и
|
||||
локальный `tenants.json` не изменяются. Постоянная локальная конфигурация задаётся файлом до
|
||||
запуска приложения.
|
||||
|
||||
Для обращения к Kubernetes API backend загружает
|
||||
`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку
|
||||
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
|
||||
завершает персистенцию безопасным отказом.
|
||||
|
||||
`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
|
||||
`TenantConfigWatcher` сравнивает содержимое смонтированного из Secret `tenants.json` по
|
||||
SHA-256, а не по `String.hashCode()`, чтобы изменение файла не пропускалось из-за
|
||||
32-битной коллизии. На текущем этапе watcher активирует новые домены и удаляет отсутствующие,
|
||||
но ещё не заменяет подключение существующего домена при изменении его URL или credentials.
|
||||
Полная синхронизация такого изменения относится к проблеме №13; до её выполнения новое
|
||||
значение сбрасывает readiness этого tenant в состояние ожидания, а pod не должен считаться
|
||||
готовым по старому подключению.
|
||||
|
||||
### Liveness и readiness
|
||||
|
||||
Spring Boot Actuator публикует две независимые служебные группы:
|
||||
|
||||
- `GET /actuator/health/liveness` включает только `livenessState` приложения. Отказ любой
|
||||
tenant-БД не переводит процесс в liveness failure и не создаёт restart storm;
|
||||
- `GET /actuator/health/readiness` включает `readinessState` и `tenantReadiness`. Ответ имеет
|
||||
статус `UP` только если задан непустой набор обязательных tenant, миграция каждого успешно
|
||||
завершена и для каждого есть свежая успешная проверка соединения.
|
||||
|
||||
`TenantDataSourceConfig` регистрирует обязательный набор до startup-активации и сохраняет
|
||||
неуспешную миграцию как readiness failure. H2-заглушка позволяет инициализировать Spring JPA,
|
||||
но никогда не делает приложение готовым к production-трафику. Ошибка чтения обязательного
|
||||
`tenants.json` также фиксируется отдельно от состояния процесса.
|
||||
|
||||
`TenantDatabaseHealthMonitor` по умолчанию запускается через секунду после старта, затем
|
||||
проверяет соединения каждые 10 секунд пакетами до четырёх параллельных задач. Таймаут пакета
|
||||
составляет 6 секунд: по его истечении отменяются только незавершённые задачи, а уже полученные
|
||||
результаты сохраняются с фактическим временем завершения проверки. Результат старше 30 секунд
|
||||
считается просроченным. Запоздалый результат привязан к HMAC-fingerprint конкретной конфигурации
|
||||
и не может отметить новую конфигурацию готовой; исходные credentials в fingerprint и
|
||||
health-ответ не попадают. Планировщик использует два потока, поэтому ожидающий health-pass не
|
||||
задерживает периодическое чтение tenant Secret.
|
||||
|
||||
Tenant interceptor исключает `/actuator/**`, а authorization interceptor применяется только
|
||||
к `/api/**`. При этом наружу опубликован только endpoint `health`, его details и components
|
||||
скрыты: kubelet получает минимальный `UP`/`DOWN` без доменов и реквизитов tenant-БД.
|
||||
|
||||
### Fallback при отсутствии тенантов
|
||||
|
||||
@@ -138,6 +213,9 @@ sequenceDiagram
|
||||
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
|
||||
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
|
||||
|
||||
Для production обязательность смонтированной конфигурации задаётся
|
||||
`TENANTS_CONFIG_REQUIRED=true`; отсутствие или ошибка чтения файла снимают readiness.
|
||||
|
||||
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
|
||||
|
||||
---
|
||||
|
||||
@@ -59,7 +59,9 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
|
||||
|
||||
### Расположение конфигурации
|
||||
|
||||
Файлы Kubernetes манифестов: `../k8s/`
|
||||
Ожидаемое расположение production-манифестов: `../k8s/`. В текущей рабочей копии этот
|
||||
внешний каталог недоступен, поэтому приведённые ниже изменения являются обязательным
|
||||
runbook для оператора, а не утверждением о применённом или проверенном состоянии кластера.
|
||||
|
||||
### Ключевые ресурсы
|
||||
|
||||
@@ -92,21 +94,146 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
|
||||
| `otel-postgres-secret` | Credentials PostgreSQL receivers для OTel Collector |
|
||||
| `gitea-registry` | Доступ Kubernetes к registry |
|
||||
|
||||
Secret `tenants-secret` монтируется в pod backend по пути `/config/tenants.json`.
|
||||
Backend ожидает ключ `tenants.json` из Secret `tenants-secret` по пути
|
||||
`/config/tenants.json`. Чтобы kubelet доставлял последующие обновления Secret, необходимо
|
||||
монтировать каталог `/config`, а не отдельный файл через `subPath`:
|
||||
|
||||
```yaml
|
||||
volumeMounts:
|
||||
- name: tenants-config
|
||||
mountPath: /config
|
||||
readOnly: true
|
||||
|
||||
volumes:
|
||||
- name: tenants-config
|
||||
secret:
|
||||
secretName: tenants-secret
|
||||
optional: false
|
||||
items:
|
||||
- key: tenants.json
|
||||
path: tenants.json
|
||||
```
|
||||
|
||||
В production для backend также требуется:
|
||||
|
||||
```yaml
|
||||
env:
|
||||
- name: TENANTS_CONFIG_REQUIRED
|
||||
value: "true"
|
||||
```
|
||||
|
||||
При обязательной конфигурации отсутствие, пустой список или ошибка разбора `tenants.json`
|
||||
снимают readiness; резервная H2-БД не считается готовой tenant-БД.
|
||||
|
||||
При добавлении тенанта через API:
|
||||
1. `DatabaseController` обновляет in-memory DataSource
|
||||
2. `KubernetesTenantSecretUpdater` обновляет Secret через Kubernetes API
|
||||
3. `TenantConfigWatcher` на остальных pod подхватывает смонтированное изменение (каждые 30 сек)
|
||||
1. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
|
||||
2. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
|
||||
только upsert запрошенного домена
|
||||
3. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
|
||||
при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации
|
||||
4. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
|
||||
подтверждённой повторным чтением персистенции
|
||||
5. старый pool перестаёт принимать новые запросы и закрывается после drain/grace timeout
|
||||
6. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд
|
||||
применяет добавление или удаление домена
|
||||
|
||||
Удаление использует тот же persistence-first порядок и применяет к актуальному Secret только
|
||||
remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами
|
||||
возрастает, а идемпотентное состояние не записывается повторно.
|
||||
|
||||
Если после подтверждённой записи Secret локальная активация или удаление завершаются ошибкой,
|
||||
backend восстанавливает прежний снимок только пока Secret сохраняет `resourceVersion` этой
|
||||
записи. Более новое межподовое изменение останавливает компенсацию и не перезаписывается.
|
||||
Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое
|
||||
состояние принимается без квитанции, допускающей небезопасный автоматический откат.
|
||||
|
||||
Watcher пока не заменяет подключение существующего домена при изменении URL или credentials.
|
||||
Такое изменение сбрасывает readiness, но полное применение на другом pod относится к
|
||||
проблеме №13. До её исправления нельзя считать watcher механизмом полной синхронизации всех
|
||||
полей `TenantConfig`.
|
||||
|
||||
Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
|
||||
RBAC ограничен объектом `tenants-secret`. Скрипт `../k8s/deploy.sh` проверяет наличие
|
||||
объектов и ключей до rollout, не читая и не печатая их значения.
|
||||
Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только
|
||||
Kubernetes verbs `get` и `update` для одного существующего Secret:
|
||||
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: backend-tenant-secret
|
||||
namespace: magistr
|
||||
rules:
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
resourceNames: ["tenants-secret"]
|
||||
verbs: ["get", "update"]
|
||||
```
|
||||
|
||||
`patch`, `list`, `watch`, `create` и `delete` текущему Kubernetes-клиенту не нужны. RoleBinding
|
||||
должен связывать эту Role с фактическим `spec.template.spec.serviceAccountName` backend в
|
||||
namespace `magistr`. Secret должен существовать заранее и не иметь `immutable: true`.
|
||||
|
||||
Создание, ротация, отзыв скомпрометированных значений и очистка истории описаны в
|
||||
[`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md). Эти действия требуют полномочий оператора и
|
||||
не выполняются автоматически.
|
||||
|
||||
### Liveness и readiness probes backend
|
||||
|
||||
TCP probe подтверждает только открытый порт и не отражает готовность обязательных tenant-БД.
|
||||
В production Deployment необходимо использовать раздельные HTTP probes:
|
||||
|
||||
```yaml
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/liveness
|
||||
port: 8080
|
||||
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /actuator/health/readiness
|
||||
port: 8080
|
||||
```
|
||||
|
||||
Liveness зависит только от состояния процесса. Readiness возвращает `200 UP` после успешных
|
||||
миграций и свежей успешной проверки всех обязательных tenant-БД, иначе — `503 DOWN` и pod
|
||||
исключается из приёма трафика без перезапуска. Actuator не публикует components, домены,
|
||||
JDBC URL или credentials.
|
||||
|
||||
Параметры `initialDelaySeconds`, `periodSeconds`, `timeoutSeconds` и `failureThreshold` нужно
|
||||
согласовать с текущим Deployment; замена механизма probe не подтверждает автоматически
|
||||
корректность этих порогов.
|
||||
|
||||
### Внешние действия для production Kubernetes
|
||||
|
||||
Каталог `../k8s/` не изменялся и не проверялся в рамках текущей рабочей копии. Оператору после
|
||||
получения доступа к нему необходимо:
|
||||
|
||||
1. обновить backend Deployment: directory mount без `subPath`,
|
||||
`TENANTS_CONFIG_REQUIRED=true` и две HTTP probes;
|
||||
2. добавить либо сузить Role до `get`/`update` одного `tenants-secret` и проверить RoleBinding
|
||||
фактического ServiceAccount;
|
||||
3. убедиться, что внешний `tenants-secret` существует, содержит непустой ключ `tenants.json`
|
||||
и допускает обновление;
|
||||
4. отрендерить манифесты и выполнить серверную проверку без применения:
|
||||
|
||||
```bash
|
||||
kubectl kustomize ../k8s >/dev/null
|
||||
kubectl apply --dry-run=server -k ../k8s
|
||||
```
|
||||
|
||||
5. отдельно проверить полномочия фактического ServiceAccount:
|
||||
|
||||
```bash
|
||||
BACKEND_SERVICE_ACCOUNT='укажите-фактический-service-account'
|
||||
kubectl auth can-i get secret/tenants-secret \
|
||||
--as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr
|
||||
kubectl auth can-i update secret/tenants-secret \
|
||||
--as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr
|
||||
```
|
||||
|
||||
Применение манифестов, rollout и проверка реальных pod являются отдельными внешними
|
||||
операциями и без разрешения автоматически не выполняются.
|
||||
|
||||
### Обновление backend
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user