Задача Егора 2ч.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# Журнал исправления проблем
|
# Журнал исправления проблем
|
||||||
|
|
||||||
Обновлено: 2026-07-15 (Europe/Moscow).
|
Обновлено: 2026-07-17 (Europe/Moscow).
|
||||||
|
|
||||||
## Область и неизменяемые данные
|
## Область и неизменяемые данные
|
||||||
|
|
||||||
@@ -56,9 +56,9 @@
|
|||||||
| 8 | исправлено и проверено | `saveGrid` удалял данные до полной валидации и ловил исключение внутри transactional controller-метода, поэтому Spring коммитил delete и уже обработанные строки. | `AcademicCalendarGridService` полностью валидирует и строит replacement до delete, атомарно заменяет строки и очищает кэш через `afterCommit`; контроллер стал HTTP-адаптером. | 8 unit + 1 PostgreSQL proxy test; fingerprint старой сетки после отказа неизменен; полный backend 111/0/0/0. |
|
| 8 | исправлено и проверено | `saveGrid` удалял данные до полной валидации и ловил исключение внутри transactional controller-метода, поэтому Spring коммитил delete и уже обработанные строки. | `AcademicCalendarGridService` полностью валидирует и строит replacement до delete, атомарно заменяет строки и очищает кэш через `afterCommit`; контроллер стал HTTP-адаптером. | 8 unit + 1 PostgreSQL proxy test; fingerprint старой сетки после отказа неизменен; полный backend 111/0/0/0. |
|
||||||
| 9 | исправлено и проверено | При update старый pool закрывался до создания нового; Hikari допускал ленивый нерабочий pool, Flyway проглатывал ошибку, а отказ 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. |
|
| 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` отсутствует. |
|
| 10 | требуется внешнее действие | Два pod перезаписывали целый tenant-документ без `resourceVersion`; TCP probes не отражали готовность tenant-БД. | Backend применяет доменные upsert/remove через GET → условный PUT по `resourceVersion`, ограниченный retry и безопасную компенсацию; Actuator разделяет process-only liveness и readiness обязательных tenant-БД. Оператору остаются RBAC `get/update`, mount без `subPath`, `TENANTS_CONFIG_REQUIRED=true` и HTTP probes в отсутствующем `../k8s`. | 64 целевых теста; полный backend 169/0/0/0; LF-хеши V1–V4 совпадают. Kustomize не выполнен: `../k8s` отсутствует. |
|
||||||
| 11 | не начато | Sunday вычисляется повторным мутированием даты; ошибки запросов превращаются в пустые результаты и зелёный статус. | Чистая local-date утилита и три явных состояния проверки. | 2026-07-02, переход года, частичная/полная ошибка API. |
|
| 11 | исправлено и проверено | Воскресенье вычислялось повторным мутированием одной даты, date-only строился через UTC, а ошибки кафедральных запросов превращались в пустой успешный результат. | `dashboard-conflicts.js` формирует локальный диапазон на отдельных объектах, загружает кафедры через `Promise.allSettled` и fail-closed различает `COMPLETE`/`PARTIAL`/`NOT_RUN`; зелёная карточка разрешена только для полного результата без конфликтов. | 12 `node:test`: 02.07.2026 00:15 Europe/Moscow, переход года, полный/частичный/нулевой успех и строгий UI-контракт; `npm run check` и синтаксис 25 JS/MJS-файлов — успешно. |
|
||||||
| 12 | не начато | Access JWT хранится в Web Storage; origin исполняет удалённые модули; CSP допускает несовместимые сценарии. | Access JWT только в памяти, восстановление через HttpOnly refresh-cookie; локальные pinned assets; строгий CSP и удаление inline JS. | Login/refresh/logout/reload tests; статический аудит CSP/storage/imports. |
|
| 12 | не начато | Access JWT хранится в Web Storage; origin исполняет удалённые модули; CSP допускает несовместимые сценарии. | Access JWT только в памяти, восстановление через HttpOnly refresh-cookie; локальные pinned assets; строгий CSP и удаление inline JS. | Login/refresh/logout/reload tests; статический аудит CSP/storage/imports. |
|
||||||
| 13 | не начато | Watcher сравнивает только домены и фиксирует hash до успешной полной синхронизации. | Нормализованное сравнение полного `TenantConfig`, hash после успеха, bounded backoff. | Изменение URL/credentials и retry после ошибки. |
|
| 13 | исправлено и проверено | Watcher сравнивал только домены, подтверждал hash до полного успеха, а API после мутации мог принять запаздывающую mounted-проекцию за актуальную. | Полное нормализованное сравнение, prepare/verify/Flyway/atomic swap без повторной записи Secret, hash-after-success, retry 30–300 секунд и semantic snapshot fence по `TenantSecretUpdateReceipt`. | 53 целевых теста, 2 PostgreSQL lifecycle-теста; полный backend 200/0/0/0. |
|
||||||
| 14 | исправлено и проверено | Kubernetes HTTP-клиент имел trust-all TLS fallback. | `KubernetesTenantSecretUpdater` загружает service-account CA, строит PKIX trust store, включает HTTPS hostname verification и не имеет небезопасного fallback. | Mock HTTPS: доверенный CA принят; чужой CA, неверный hostname и отсутствующий CA отклонены. Backend 45/0/0. |
|
| 14 | исправлено и проверено | Kubernetes HTTP-клиент имел trust-all TLS fallback. | `KubernetesTenantSecretUpdater` загружает service-account CA, строит PKIX trust store, включает HTTPS hostname verification и не имеет небезопасного fallback. | Mock HTTPS: доверенный CA принят; чужой CA, неверный hostname и отсутствующий CA отклонены. Backend 45/0/0. |
|
||||||
| 15 | не начато | Не проверяются `start < end` и пересечения; duration доверяется клиенту; используемый DEFAULT scope можно изменить. | Централизованный валидатор, backend duration, DB/concurrency protection. | Overlap/adjacent/scope/concurrency tests. |
|
| 15 | не начато | Не проверяются `start < end` и пересечения; duration доверяется клиенту; используемый DEFAULT scope можно изменить. | Централизованный валидатор, backend duration, DB/concurrency protection. | Overlap/adjacent/scope/concurrency tests. |
|
||||||
| 16 | не начато | Create/update проверяют лишь порядок дат, но не пересечения и принадлежность семестра году. | Единый calendar validator и PostgreSQL range/exclusion constraints. | Create/update + конкурентные PostgreSQL tests. |
|
| 16 | не начато | Create/update проверяют лишь порядок дат, но не пересечения и принадлежность семестра году. | Единый calendar validator и PostgreSQL range/exclusion constraints. | Create/update + конкурентные PostgreSQL tests. |
|
||||||
@@ -324,8 +324,8 @@
|
|||||||
маскирует отказ. Actuator endpoints исключены из tenant-interceptor и не раскрывают
|
маскирует отказ. Actuator endpoints исключены из tenant-interceptor и не раскрывают
|
||||||
components, домены, JDBC URL или credentials.
|
components, домены, JDBC URL или credentials.
|
||||||
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и
|
- AutoUpdateDocs синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и
|
||||||
`docs/INFRASTRUCTURE.md`. Ограничение watcher по обновлению URL/credentials существующего
|
`docs/INFRASTRUCTURE.md`. На этом этапе ограничение watcher по обновлению URL/credentials
|
||||||
домена явно оставлено проблеме № 13.
|
существующего домена было оставлено проблеме № 13 и устранено следующим этапом.
|
||||||
- В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись
|
- В текущей рабочей копии отсутствует внешний каталог `../k8s`, поэтому манифесты не менялись
|
||||||
и Kustomize не запускался. Для production обязательны directory mount без `subPath`,
|
и Kustomize не запускался. Для production обязательны directory mount без `subPath`,
|
||||||
`TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness /
|
`TENANTS_CONFIG_REQUIRED=true`, Role `get/update` для `tenants-secret` и HTTP liveness /
|
||||||
@@ -343,18 +343,92 @@
|
|||||||
| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. |
|
| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями. |
|
||||||
| `kubectl kustomize ../k8s` | Не выполнено: внешний каталог `../k8s` отсутствует в текущем workspace. |
|
| `kubectl kustomize ../k8s` | Не выполнено: внешний каталог `../k8s` отсутствует в текущем workspace. |
|
||||||
|
|
||||||
|
### № 13 — полная и повторяемая синхронизация tenant-конфигурации
|
||||||
|
|
||||||
|
- `TenantLifecycleService.synchronizeFromPersistedConfig()` валидирует весь входной список,
|
||||||
|
отклоняет нормализованные дубли до изменения runtime/readiness и сравнивает `name`, `domain`,
|
||||||
|
`url`, `username`, `password`. Новый или изменённый tenant проходит candidate prepare,
|
||||||
|
проверку соединения, Flyway и атомарный swap; persisted-путь не вызывает `TenantConfigStore`.
|
||||||
|
- Hash mounted `tenants.json` становится применённым только после успешного разбора и полной
|
||||||
|
lifecycle-синхронизации. Ошибка сохраняет прежний hash и повторяется с экспоненциальной
|
||||||
|
задержкой 30–300 секунд; новая ревизия обходит backoff. Required missing/read/empty также
|
||||||
|
наблюдаемы через readiness и русский лог без credentials, hash или fingerprint.
|
||||||
|
- Перед `POST`/`DELETE` watcher под общим reentrant monitor применяет ещё не обработанный
|
||||||
|
semantic baseline. Ошибка чтения/разбора/sync возвращает безопасный `503` до начала мутации.
|
||||||
|
`TenantLifecycleMutationResult` возвращает controller-у immutable-квитанцию персистенции,
|
||||||
|
но не раскрывает её в HTTP.
|
||||||
|
- Snapshot fence ставится только для реально сохранённой изменённой версии
|
||||||
|
(`persisted && changed`) и сравнивает нормализованные `previous/committed` snapshots, а не
|
||||||
|
сырые JSON-хеши. Поэтому H0/H1 не откатывают две быстрые мутации до H2, форматирование JSON
|
||||||
|
не влияет на решение, merged snapshot другого pod применяется, а local/no-op receipt ничего
|
||||||
|
не блокирует.
|
||||||
|
- Ошибка публикации readiness после успешного swap больше не помечает активный новый pool как
|
||||||
|
`FAILED`: повтор проверяет действующее соединение и восстанавливает readiness без второго
|
||||||
|
Flyway/swap. Старый pool передаётся на drain сразу после успешной атомарной публикации.
|
||||||
|
- PostgreSQL 16.3/Testcontainers подтверждает сохранность прежнего route при неверных
|
||||||
|
credentials и Flyway checksum, успешную замену на валидную БД, работу in-flight соединения,
|
||||||
|
drain старого pool и отсутствие повторной записи Secret в persisted-пути.
|
||||||
|
- Windows/Docker portability TLS-теста стабилизирована явным `https://localhost:<port>`,
|
||||||
|
совпадающим с SAN тестового сертификата; production hostname verification не ослаблялась.
|
||||||
|
- AutoUpdateDocs повторно синхронизировал `docs/API.md`, `docs/ARCHITECTURE.md` и
|
||||||
|
`docs/INFRASTRUCTURE.md` с финальной receipt/snapshot-схемой.
|
||||||
|
|
||||||
|
Фактические проверки этапа:
|
||||||
|
|
||||||
|
| Команда | Результат |
|
||||||
|
|---|---|
|
||||||
|
| `TenantConfigWatcherTest,TenantLifecycleServiceTest,DatabaseControllerTest,TenantRoutingDataSourceTest` | 53 теста, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
|
||||||
|
| `TenantLifecyclePostgreSqlIntegrationTest` | 2 теста, 0 failures, 0 errors, 0 skipped; реальный PostgreSQL 16.3/Testcontainers. |
|
||||||
|
| `KubernetesTenantSecretUpdaterTest` | 21 тест, 0 failures, 0 errors, 0 skipped; TLS, CAS, reconciliation и компенсация. |
|
||||||
|
| Полный `mvn -Dapi.version=1.44 test` | 200 тестов, 0 failures, 0 errors, 0 skipped, BUILD SUCCESS. |
|
||||||
|
| `git diff --check` | Успешно. |
|
||||||
|
| Повторные LF-нормализованные SHA-256 V1/V2/V3/V4 | Совпадают с ранее зафиксированными значениями; миграционные файлы не изменялись. |
|
||||||
|
|
||||||
|
### № 11 — достоверный статус проверки конфликтов на дашборде
|
||||||
|
|
||||||
|
- `formatLocalDate()` формирует `YYYY-MM-DD` из локальных компонентов `Date` без
|
||||||
|
`toISOString()`. `currentWeekDateRange()` создаёт отдельный понедельник и вычисляет
|
||||||
|
воскресенье как `понедельник + 6 дней`, поэтому начало месяца и переход года не мутируют
|
||||||
|
исходную дату повторно.
|
||||||
|
- Расписания кафедр загружаются независимо через `Promise.allSettled`. Результат хранит число
|
||||||
|
ответивших кафедр и одно из состояний: `COMPLETE`, `PARTIAL` или `NOT_RUN`; технические
|
||||||
|
причины отказов не входят в UI-модель.
|
||||||
|
- `conflictCheckPresentation()` проверяет диапазон, количество конфликтов и согласованность
|
||||||
|
счётчиков fail-closed. Зелёная карточка разрешена только для `COMPLETE`, когда проверены все
|
||||||
|
кафедры и конфликтов нет. `PARTIAL` всегда показывает предупреждение и не скрывает найденные
|
||||||
|
по доступной части конфликты, а полный отказ отображается как «Проверка не выполнена».
|
||||||
|
- Карточки используют русские безопасные тексты, экранирование, явные success/warning/error
|
||||||
|
состояния, период и число проверенных кафедр. Контейнер получил live-region и `aria-busy`,
|
||||||
|
кнопка — `type="button"`; после ошибки начальной загрузки «Перепроверить» повторно получает
|
||||||
|
справочники, а не анализирует пустой список.
|
||||||
|
- Тот же локальный formatter устранит сдвиг даты сегодняшнего расписания до 03:00 по Москве.
|
||||||
|
Остальная часть общей timezone-проблемы остаётся в объёме № 29.
|
||||||
|
- Добавлены dependency-free тесты на встроенном `node:test` и воспроизводимые npm-команды.
|
||||||
|
AutoUpdateDocs синхронизировал структуру, контракт Red Zone и команды проверки в
|
||||||
|
`docs/FRONTEND.md`.
|
||||||
|
|
||||||
|
Фактические проверки этапа:
|
||||||
|
|
||||||
|
| Команда | Результат |
|
||||||
|
|---|---|
|
||||||
|
| `npm test` в `frontend/` | 12 тестов, 12 passed, 0 failed; локальная полночь `2026-07-02 00:15 Europe/Moscow`, диапазон `2026-06-29…2026-07-05`, переход года и состояния отказов. |
|
||||||
|
| `npm run check` в `frontend/` | Синтаксис `dashboard-conflicts.js` и `dashboard.js`, затем 12 тестов — успешно. |
|
||||||
|
| Рекурсивный `node --check` для `frontend/**/*.js` и `frontend/**/*.mjs` | 25 файлов, ошибок нет. |
|
||||||
|
| Полный backend-регресс | Backend на этапе № 11 не изменялся; последний полный прогон этапа № 13 остаётся зелёным: 200/0/0/0. |
|
||||||
|
| `git diff --check` | Успешно. |
|
||||||
|
| Защищённые файлы | `BUG_REPORT.md` и существующие Flyway-миграции не изменялись. |
|
||||||
|
|
||||||
## Точка продолжения
|
## Точка продолжения
|
||||||
|
|
||||||
Текущий этап: **№ 13 — полная синхронизация изменённых tenant credentials/URL watcher-ом**.
|
Текущий этап: **№ 12 — access JWT и удалённый JavaScript в одном origin**.
|
||||||
|
|
||||||
Следующая операция:
|
Следующая операция:
|
||||||
|
|
||||||
1. сравнивать полную нормализованную активную конфигурацию с persisted snapshot, а не только
|
1. инвентаризировать `localStorage`/`sessionStorage`, refresh-cookie, удалённые `import()` и
|
||||||
набор доменов;
|
inline-скрипты во всех frontend entrypoints и HTTP/CSP-конфигурации;
|
||||||
2. при изменении URL/username/password выполнять prepare → verify → Flyway → atomic swap без
|
2. выбрать единый контракт access JWT только в памяти с восстановлением через HttpOnly
|
||||||
повторной записи Secret;
|
refresh-cookie, не оставляя токен в Web Storage после login/refresh/logout/reload;
|
||||||
3. считать hash применённым только после полной успешной синхронизации и не подтверждать
|
3. локализовать и зафиксировать внешние runtime-зависимости, затем ввести CSP без
|
||||||
произвольное содержимое mount через `refreshHash()` после API-мутации;
|
`unsafe-inline`/`unsafe-eval` и устранить несовместимые inline-вставки;
|
||||||
4. добавить bounded retry/backoff и тесты изменения credentials, восстановления прежнего
|
4. покрыть login/refresh/logout/reload тестами и выполнить статический аудит storage/imports/CSP
|
||||||
файла после ошибки и межподового merged snapshot;
|
перед AutoUpdateDocs и изменением статуса № 12.
|
||||||
5. выполнить полный прогон и AutoUpdateDocs перед изменением статуса № 13.
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.magistr.app.config.tenant;
|
|||||||
import com.fasterxml.jackson.core.type.TypeReference;
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||||
|
import com.magistr.app.service.TenantLifecycleException;
|
||||||
import com.magistr.app.service.TenantLifecycleService;
|
import com.magistr.app.service.TenantLifecycleService;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
@@ -10,24 +11,35 @@ import org.springframework.beans.factory.annotation.Value;
|
|||||||
import org.springframework.scheduling.annotation.Scheduled;
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.IOException;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.security.MessageDigest;
|
import java.security.MessageDigest;
|
||||||
import java.security.NoSuchAlgorithmException;
|
import java.security.NoSuchAlgorithmException;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
import java.util.HexFormat;
|
import java.util.HexFormat;
|
||||||
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.function.LongSupplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Периодически перечитывает tenants.json (mounted Secret).
|
* Периодически применяет конфигурацию тенантов из смонтированного tenants.json.
|
||||||
* Если Secret был обновлён через K8s API, этот компонент
|
* Все чтения и lifecycle-изменения сериализуются одним monitor'ом сервиса.
|
||||||
* подхватит изменения и синхронизирует in-memory datasource'ы.
|
|
||||||
*
|
|
||||||
* Также отвечает за инициализацию БД (init.sql) для новых тенантов.
|
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class TenantConfigWatcher {
|
public class TenantConfigWatcher {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
private static final Logger log = LoggerFactory.getLogger(TenantConfigWatcher.class);
|
||||||
|
private static final String MISSING_FAILURE_KEY = "missing";
|
||||||
|
private static final String READ_FAILURE_KEY = "read";
|
||||||
|
|
||||||
|
static final long RETRY_BASE_DELAY_MILLIS = 30_000L;
|
||||||
|
static final long RETRY_MAX_DELAY_MILLIS = 300_000L;
|
||||||
|
|
||||||
private final TenantLifecycleService tenantLifecycleService;
|
private final TenantLifecycleService tenantLifecycleService;
|
||||||
private final TenantReadinessRegistry readinessRegistry;
|
private final TenantReadinessRegistry readinessRegistry;
|
||||||
@@ -39,9 +51,23 @@ public class TenantConfigWatcher {
|
|||||||
@Value("${app.tenants.config-required:false}")
|
@Value("${app.tenants.config-required:false}")
|
||||||
private boolean tenantsConfigRequired;
|
private boolean tenantsConfigRequired;
|
||||||
|
|
||||||
// Хеш последнего прочитанного конфига — чтобы не перезагружать зря
|
/** Хеш означает только полностью применённое содержимое файла. */
|
||||||
private String lastConfigHash = "";
|
private String lastConfigHash = "";
|
||||||
|
private ConfigSnapshot lastAppliedSnapshot;
|
||||||
|
|
||||||
|
private String failedRevisionKey = "";
|
||||||
|
private int consecutiveFailures;
|
||||||
|
private long retryNotBeforeMillis;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fence хранит семантические снимки, а не байтовые представления JSON.
|
||||||
|
* Поэтому форматирование файла не может ошибочно снять или установить fence.
|
||||||
|
*/
|
||||||
|
private ConfigSnapshot expectedCommittedSnapshot;
|
||||||
|
private final Set<ConfigSnapshot> deferredSnapshots = new LinkedHashSet<>();
|
||||||
|
|
||||||
private boolean configurationUnavailable;
|
private boolean configurationUnavailable;
|
||||||
|
private LongSupplier currentTimeMillis = System::currentTimeMillis;
|
||||||
|
|
||||||
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
|
public TenantConfigWatcher(TenantLifecycleService tenantLifecycleService,
|
||||||
TenantReadinessRegistry readinessRegistry) {
|
TenantReadinessRegistry readinessRegistry) {
|
||||||
@@ -49,73 +75,135 @@ public class TenantConfigWatcher {
|
|||||||
this.readinessRegistry = readinessRegistry;
|
this.readinessRegistry = readinessRegistry;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Каждые 30 секунд проверяет, изменился ли tenants.json.
|
|
||||||
*/
|
|
||||||
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
|
@Scheduled(fixedDelay = 30_000, initialDelay = 30_000)
|
||||||
public void watchForChanges() {
|
public void watchForChanges() {
|
||||||
tenantLifecycleService.executeSerialized(this::watchForChangesSerialized);
|
tenantLifecycleService.executeSerialized(this::watchForChangesSerialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void watchForChangesSerialized() {
|
private void watchForChangesSerialized() {
|
||||||
try {
|
long now = currentTimeMillis.getAsLong();
|
||||||
File file = new File(tenantsConfigPath);
|
Path path = Path.of(tenantsConfigPath);
|
||||||
if (!file.exists()) {
|
if (!Files.exists(path)) {
|
||||||
if (tenantsConfigRequired) {
|
if (tenantsConfigRequired) {
|
||||||
readinessRegistry.markConfigurationFailure();
|
handleRequiredMissingFile(now);
|
||||||
if (!configurationUnavailable) {
|
|
||||||
log.error("Обязательный файл конфигурации тенантов недоступен");
|
|
||||||
}
|
|
||||||
configurationUnavailable = true;
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
if (isRetryDeferred(READ_FAILURE_KEY, now)) {
|
||||||
String hash = configHash(content);
|
logDeferredRetry(now);
|
||||||
|
return;
|
||||||
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
|
||||||
return; // Ничего не изменилось
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info("Обнаружено изменение tenants.json (хеш: {} -> {}), перечитываем конфиг", lastConfigHash, hash);
|
String failureKey = READ_FAILURE_KEY;
|
||||||
List<TenantConfig> newTenants = objectMapper.readValue(content, new TypeReference<>() {});
|
try {
|
||||||
syncTenants(newTenants);
|
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
lastConfigHash = hash;
|
String hash = configHash(content);
|
||||||
configurationUnavailable = false;
|
failureKey = hash;
|
||||||
|
Projection projection = parseProjection(content, hash);
|
||||||
|
|
||||||
} catch (Exception e) {
|
// Даже неизменившееся содержимое сначала разбирается: fence никогда не
|
||||||
readinessRegistry.markConfigurationFailure();
|
// принимает решение по сырым байтам до проверки структуры JSON.
|
||||||
configurationUnavailable = true;
|
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||||
log.error("Ошибка при проверке конфига тенантов: errorType={}", e.getClass().getSimpleName());
|
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
|
||||||
log.debug("Технические детали синхронизации конфига тенантов", e);
|
return;
|
||||||
|
}
|
||||||
|
if (isRetryDeferred(hash, now)) {
|
||||||
|
logDeferredRetry(now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!hash.equals(failedRevisionKey)) {
|
||||||
|
resetRetryState();
|
||||||
|
}
|
||||||
|
if (shouldDefer(projection.snapshot())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Обнаружено изменение tenants.json, выполняется синхронизация конфигурации тенантов");
|
||||||
|
syncTenants(projection.tenants());
|
||||||
|
markApplied(projection);
|
||||||
|
} catch (Exception failure) {
|
||||||
|
recordFailure(failureKey, failure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Обновляет хеш конфига после ручного обновления Secret с этого же пода.
|
* Перед API-мутацией приводит runtime к актуальной безопасной проекции файла.
|
||||||
|
* Ошибка чтения или синхронизации не скрывается: API-операция не должна начинаться
|
||||||
|
* с неизвестного baseline.
|
||||||
*/
|
*/
|
||||||
public void refreshHash() {
|
public void prepareForApiMutation() {
|
||||||
tenantLifecycleService.executeSerialized(this::refreshHashSerialized);
|
tenantLifecycleService.executeSerialized(this::prepareForApiMutationSerialized);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void refreshHashSerialized() {
|
private void prepareForApiMutationSerialized() {
|
||||||
|
Path path = Path.of(tenantsConfigPath);
|
||||||
|
if (!Files.exists(path)) {
|
||||||
|
if (!tenantsConfigRequired) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TenantLifecycleException failure = new TenantLifecycleException(
|
||||||
|
"Обязательный файл конфигурации тенантов недоступен"
|
||||||
|
);
|
||||||
|
recordFailure(MISSING_FAILURE_KEY, failure);
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
|
||||||
|
String failureKey = READ_FAILURE_KEY;
|
||||||
try {
|
try {
|
||||||
File file = new File(tenantsConfigPath);
|
String content = Files.readString(path, StandardCharsets.UTF_8);
|
||||||
if (file.exists()) {
|
String hash = configHash(content);
|
||||||
String content = new String(java.nio.file.Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8);
|
failureKey = hash;
|
||||||
lastConfigHash = configHash(content);
|
Projection projection = parseProjection(content, hash);
|
||||||
} else if (tenantsConfigRequired) {
|
|
||||||
readinessRegistry.markConfigurationFailure();
|
if (hash.equals(lastConfigHash) && !configurationUnavailable) {
|
||||||
configurationUnavailable = true;
|
clearFenceWhenExpectedSnapshotIsAlreadyApplied(projection.snapshot());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
if (shouldDefer(projection.snapshot())) {
|
||||||
readinessRegistry.markConfigurationFailure();
|
return;
|
||||||
configurationUnavailable = true;
|
|
||||||
log.warn("Не удалось обновить хеш конфига тенантов: errorType={}",
|
|
||||||
e.getClass().getSimpleName());
|
|
||||||
log.debug("Технические детали обновления хеша тенантов", e);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncTenants(projection.tenants());
|
||||||
|
markApplied(projection);
|
||||||
|
} catch (Exception failure) {
|
||||||
|
recordFailure(failureKey, failure);
|
||||||
|
if (failure instanceof TenantLifecycleException lifecycleFailure) {
|
||||||
|
throw lifecycleFailure;
|
||||||
|
}
|
||||||
|
throw new TenantLifecycleException(
|
||||||
|
"Не удалось подготовить актуальную конфигурацию тенантов перед изменением",
|
||||||
|
failure
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Устанавливает snapshot fence только после подтверждённой записи в общее
|
||||||
|
* хранилище. Локальные и no-op операции не создают ложных ожиданий от mount.
|
||||||
|
*/
|
||||||
|
public void registerSuccessfulMutation(TenantSecretUpdateReceipt receipt) {
|
||||||
|
Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
|
||||||
|
tenantLifecycleService.executeSerialized(() -> registerSuccessfulMutationSerialized(receipt));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerSuccessfulMutationSerialized(TenantSecretUpdateReceipt receipt) {
|
||||||
|
if (!receipt.persisted() || !receipt.changed()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ConfigSnapshot previous = snapshotOf(receipt.previousTenants());
|
||||||
|
ConfigSnapshot committed = snapshotOf(receipt.committedTenants());
|
||||||
|
|
||||||
|
if (lastAppliedSnapshot != null) {
|
||||||
|
deferredSnapshots.add(lastAppliedSnapshot);
|
||||||
|
}
|
||||||
|
if (expectedCommittedSnapshot != null) {
|
||||||
|
deferredSnapshots.add(expectedCommittedSnapshot);
|
||||||
|
}
|
||||||
|
deferredSnapshots.add(previous);
|
||||||
|
deferredSnapshots.remove(committed);
|
||||||
|
expectedCommittedSnapshot = committed;
|
||||||
}
|
}
|
||||||
|
|
||||||
static String configHash(String content) {
|
static String configHash(String content) {
|
||||||
@@ -127,11 +215,158 @@ public class TenantConfigWatcher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private Projection parseProjection(String content, String hash) throws IOException {
|
||||||
* Синхронизирует in-memory тенантов с конфигом из файла.
|
List<TenantConfig> tenants = objectMapper.readValue(content, new TypeReference<>() { });
|
||||||
*/
|
if (tenants == null) {
|
||||||
private void syncTenants(List<TenantConfig> newTenants) {
|
throw new IllegalArgumentException(
|
||||||
tenantLifecycleService.synchronizeFromPersistedConfig(newTenants);
|
"Корневое значение конфигурации тенантов должно быть массивом"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tenantsConfigRequired && tenants.isEmpty()) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"Обязательная конфигурация тенантов не может быть пустой"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return new Projection(hash, List.copyOf(tenants), snapshotOf(tenants));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ConfigSnapshot snapshotOf(List<TenantConfig> tenants) {
|
||||||
|
Objects.requireNonNull(tenants, "Список конфигураций тенантов не задан");
|
||||||
|
List<NormalizedTenantConfig> normalized = new ArrayList<>(tenants.size());
|
||||||
|
for (TenantConfig tenant : tenants) {
|
||||||
|
Objects.requireNonNull(tenant, "Конфигурация тенанта не задана");
|
||||||
|
String domain = normalizeDomain(tenant.getDomain());
|
||||||
|
String name = tenant.getName() == null || tenant.getName().isBlank()
|
||||||
|
? domain
|
||||||
|
: tenant.getName().trim();
|
||||||
|
String url = tenant.getUrl() == null ? null : tenant.getUrl().trim();
|
||||||
|
normalized.add(new NormalizedTenantConfig(
|
||||||
|
name,
|
||||||
|
domain,
|
||||||
|
url,
|
||||||
|
tenant.getUsername(),
|
||||||
|
tenant.getPassword()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
normalized.sort(NormalizedTenantConfig.ORDER);
|
||||||
|
return new ConfigSnapshot(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeDomain(String domain) {
|
||||||
|
return domain == null ? null : domain.trim().toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void syncTenants(List<TenantConfig> tenants) {
|
||||||
|
tenantLifecycleService.synchronizeFromPersistedConfig(tenants);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean shouldDefer(ConfigSnapshot snapshot) {
|
||||||
|
return expectedCommittedSnapshot != null && deferredSnapshots.contains(snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearFenceWhenExpectedSnapshotIsAlreadyApplied(ConfigSnapshot snapshot) {
|
||||||
|
if (snapshot.equals(expectedCommittedSnapshot)) {
|
||||||
|
clearSnapshotFence();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void markApplied(Projection projection) {
|
||||||
|
lastConfigHash = projection.hash();
|
||||||
|
lastAppliedSnapshot = projection.snapshot();
|
||||||
|
configurationUnavailable = false;
|
||||||
|
resetRetryState();
|
||||||
|
clearSnapshotFence();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handleRequiredMissingFile(long now) {
|
||||||
|
if (isRetryDeferred(MISSING_FAILURE_KEY, now)) {
|
||||||
|
logDeferredRetry(now);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
recordFailure(
|
||||||
|
MISSING_FAILURE_KEY,
|
||||||
|
new TenantLifecycleException("Обязательный файл конфигурации тенантов недоступен")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isRetryDeferred(String failureKey, long now) {
|
||||||
|
return failureKey.equals(failedRevisionKey) && now < retryNotBeforeMillis;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void logDeferredRetry(long now) {
|
||||||
|
log.debug("Повторная синхронизация tenants.json отложена ещё на {} мс",
|
||||||
|
Math.max(0L, retryNotBeforeMillis - now));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recordFailure(String failureKey, Exception failure) {
|
||||||
|
readinessRegistry.markConfigurationFailure();
|
||||||
|
configurationUnavailable = true;
|
||||||
|
long retryDelay = registerFailure(failureKey);
|
||||||
|
log.error("Ошибка синхронизации конфигурации тенантов: попытка={}, "
|
||||||
|
+ "следующий повтор не ранее чем через {} мс, errorType={}",
|
||||||
|
consecutiveFailures,
|
||||||
|
retryDelay,
|
||||||
|
failure.getClass().getSimpleName());
|
||||||
|
log.debug("Технические детали синхронизации конфигурации тенантов", failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long registerFailure(String failureKey) {
|
||||||
|
if (failureKey.equals(failedRevisionKey) && consecutiveFailures > 0) {
|
||||||
|
consecutiveFailures = Math.min(consecutiveFailures + 1, 31);
|
||||||
|
} else {
|
||||||
|
failedRevisionKey = failureKey;
|
||||||
|
consecutiveFailures = 1;
|
||||||
|
}
|
||||||
|
long delay = retryDelayMillis(consecutiveFailures);
|
||||||
|
retryNotBeforeMillis = safeAdd(currentTimeMillis.getAsLong(), delay);
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
static long retryDelayMillis(int failureCount) {
|
||||||
|
long delay = RETRY_BASE_DELAY_MILLIS;
|
||||||
|
for (int attempt = 1; attempt < failureCount && delay < RETRY_MAX_DELAY_MILLIS; attempt++) {
|
||||||
|
delay = Math.min(delay * 2, RETRY_MAX_DELAY_MILLIS);
|
||||||
|
}
|
||||||
|
return delay;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long safeAdd(long value, long increment) {
|
||||||
|
return value > Long.MAX_VALUE - increment ? Long.MAX_VALUE : value + increment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void resetRetryState() {
|
||||||
|
failedRevisionKey = "";
|
||||||
|
consecutiveFailures = 0;
|
||||||
|
retryNotBeforeMillis = 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void clearSnapshotFence() {
|
||||||
|
expectedCommittedSnapshot = null;
|
||||||
|
deferredSnapshots.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Projection(String hash, List<TenantConfig> tenants, ConfigSnapshot snapshot) {
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ConfigSnapshot(List<NormalizedTenantConfig> tenants) {
|
||||||
|
private ConfigSnapshot {
|
||||||
|
tenants = List.copyOf(tenants);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record NormalizedTenantConfig(
|
||||||
|
String name,
|
||||||
|
String domain,
|
||||||
|
String url,
|
||||||
|
String username,
|
||||||
|
String password
|
||||||
|
) {
|
||||||
|
private static final Comparator<String> NULL_SAFE_TEXT = Comparator.nullsFirst(String::compareTo);
|
||||||
|
private static final Comparator<NormalizedTenantConfig> ORDER =
|
||||||
|
Comparator.comparing(NormalizedTenantConfig::domain, NULL_SAFE_TEXT)
|
||||||
|
.thenComparing(NormalizedTenantConfig::name, NULL_SAFE_TEXT)
|
||||||
|
.thenComparing(NormalizedTenantConfig::url, NULL_SAFE_TEXT)
|
||||||
|
.thenComparing(NormalizedTenantConfig::username, NULL_SAFE_TEXT)
|
||||||
|
.thenComparing(NormalizedTenantConfig::password, NULL_SAFE_TEXT);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import com.magistr.app.config.tenant.TenantContext;
|
|||||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||||
import com.magistr.app.model.Role;
|
import com.magistr.app.model.Role;
|
||||||
import com.magistr.app.service.TenantLifecycleException;
|
import com.magistr.app.service.TenantLifecycleException;
|
||||||
|
import com.magistr.app.service.TenantLifecycleMutationResult;
|
||||||
import com.magistr.app.service.TenantLifecycleService;
|
import com.magistr.app.service.TenantLifecycleService;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
@@ -90,8 +91,11 @@ public class DatabaseController {
|
|||||||
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
|
public ResponseEntity<Map<String, Object>> addTenant(@RequestBody TenantConfig config) {
|
||||||
try {
|
try {
|
||||||
return tenantLifecycleService.executeSerialized(() -> {
|
return tenantLifecycleService.executeSerialized(() -> {
|
||||||
TenantConfig saved = tenantLifecycleService.addOrUpdateTenant(config);
|
tenantConfigWatcher.prepareForApiMutation();
|
||||||
tenantConfigWatcher.refreshHash();
|
TenantLifecycleMutationResult mutation =
|
||||||
|
tenantLifecycleService.addOrUpdateTenantWithReceipt(config);
|
||||||
|
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
|
||||||
|
TenantConfig saved = mutation.tenant();
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("success", true);
|
result.put("success", true);
|
||||||
result.put("message", "Тенант '" + saved.getDomain() + "' добавлен");
|
result.put("message", "Тенант '" + saved.getDomain() + "' добавлен");
|
||||||
@@ -111,8 +115,11 @@ public class DatabaseController {
|
|||||||
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) {
|
public ResponseEntity<Map<String, Object>> removeTenant(@PathVariable("domain") String domain) {
|
||||||
try {
|
try {
|
||||||
return tenantLifecycleService.executeSerialized(() -> {
|
return tenantLifecycleService.executeSerialized(() -> {
|
||||||
TenantConfig removed = tenantLifecycleService.removeTenant(domain);
|
tenantConfigWatcher.prepareForApiMutation();
|
||||||
tenantConfigWatcher.refreshHash();
|
TenantLifecycleMutationResult mutation =
|
||||||
|
tenantLifecycleService.removeTenantWithReceipt(domain);
|
||||||
|
tenantConfigWatcher.registerSuccessfulMutation(mutation.receipt());
|
||||||
|
TenantConfig removed = mutation.tenant();
|
||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
result.put("success", true);
|
result.put("success", true);
|
||||||
result.put("message", "Тенант '" + removed.getDomain() + "' удалён");
|
result.put("message", "Тенант '" + removed.getDomain() + "' удалён");
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.magistr.app.service;
|
||||||
|
|
||||||
|
import com.magistr.app.config.tenant.TenantConfig;
|
||||||
|
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Результат успешной lifecycle-мутации тенанта вместе с квитанцией персистенции.
|
||||||
|
*
|
||||||
|
* <p>Конфигурация копируется при создании и чтении, чтобы вызывающий код не мог изменить
|
||||||
|
* уже активированный runtime-снимок через изменяемый DTO.</p>
|
||||||
|
*/
|
||||||
|
public final class TenantLifecycleMutationResult {
|
||||||
|
|
||||||
|
private final TenantConfig tenant;
|
||||||
|
private final TenantSecretUpdateReceipt receipt;
|
||||||
|
|
||||||
|
public TenantLifecycleMutationResult(TenantConfig tenant, TenantSecretUpdateReceipt receipt) {
|
||||||
|
this.tenant = copy(Objects.requireNonNull(tenant, "Конфигурация тенанта не задана"));
|
||||||
|
this.receipt = Objects.requireNonNull(receipt, "Квитанция изменения tenant Secret не задана");
|
||||||
|
}
|
||||||
|
|
||||||
|
public TenantConfig tenant() {
|
||||||
|
return copy(tenant);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TenantSecretUpdateReceipt receipt() {
|
||||||
|
return receipt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static TenantConfig copy(TenantConfig source) {
|
||||||
|
return new TenantConfig(
|
||||||
|
source.getName(),
|
||||||
|
source.getDomain(),
|
||||||
|
source.getUrl(),
|
||||||
|
source.getUsername(),
|
||||||
|
source.getPassword()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,10 +21,9 @@ import java.util.List;
|
|||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.NoSuchElementException;
|
import java.util.NoSuchElementException;
|
||||||
import java.util.Set;
|
import java.util.Objects;
|
||||||
import java.util.regex.Pattern;
|
import java.util.regex.Pattern;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
import java.util.stream.Collectors;
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
public class TenantLifecycleService {
|
public class TenantLifecycleService {
|
||||||
@@ -72,6 +71,13 @@ public class TenantLifecycleService {
|
|||||||
* Добавляет или обновляет tenant без разрыва действующего подключения.
|
* Добавляет или обновляет tenant без разрыва действующего подключения.
|
||||||
*/
|
*/
|
||||||
public synchronized TenantConfig addOrUpdateTenant(TenantConfig requestedConfig) {
|
public synchronized TenantConfig addOrUpdateTenant(TenantConfig requestedConfig) {
|
||||||
|
return addOrUpdateTenantWithReceipt(requestedConfig).tenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Добавляет или обновляет tenant и возвращает подтверждённый результат персистенции.
|
||||||
|
*/
|
||||||
|
public synchronized TenantLifecycleMutationResult addOrUpdateTenantWithReceipt(TenantConfig requestedConfig) {
|
||||||
TenantConfig config = validateAndNormalize(requestedConfig);
|
TenantConfig config = validateAndNormalize(requestedConfig);
|
||||||
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
|
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
|
||||||
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
|
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>(oldConfigs);
|
||||||
@@ -96,7 +102,7 @@ public class TenantLifecycleService {
|
|||||||
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config);
|
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), config);
|
||||||
retireDataSource(previous == null ? null : previous.dataSource());
|
retireDataSource(previous == null ? null : previous.dataSource());
|
||||||
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
|
log.info("Lifecycle тенанта '{}' успешно применён", config.getDomain());
|
||||||
return copyConfig(config);
|
return new TenantLifecycleMutationResult(config, receipt);
|
||||||
} finally {
|
} finally {
|
||||||
if (!activated) {
|
if (!activated) {
|
||||||
closeCandidateDataSource(candidate);
|
closeCandidateDataSource(candidate);
|
||||||
@@ -108,6 +114,13 @@ public class TenantLifecycleService {
|
|||||||
* Сначала сохраняет desired-конфигурацию, затем локально отключает tenant.
|
* Сначала сохраняет desired-конфигурацию, затем локально отключает tenant.
|
||||||
*/
|
*/
|
||||||
public synchronized TenantConfig removeTenant(String requestedDomain) {
|
public synchronized TenantConfig removeTenant(String requestedDomain) {
|
||||||
|
return removeTenantWithReceipt(requestedDomain).tenant();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаляет tenant и возвращает подтверждённый результат персистенции.
|
||||||
|
*/
|
||||||
|
public synchronized TenantLifecycleMutationResult removeTenantWithReceipt(String requestedDomain) {
|
||||||
String domain = validateAndNormalizeDomain(requestedDomain);
|
String domain = validateAndNormalizeDomain(requestedDomain);
|
||||||
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
|
Map<String, TenantConfig> oldConfigs = routingDataSource.snapshotTenantConfigs();
|
||||||
TenantConfig existing = oldConfigs.get(domain);
|
TenantConfig existing = oldConfigs.get(domain);
|
||||||
@@ -132,13 +145,14 @@ public class TenantLifecycleService {
|
|||||||
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null);
|
publishSuccessfulMutation(receipt, sortedConfigs(desiredConfigs), null);
|
||||||
retireDataSource(removed.dataSource());
|
retireDataSource(removed.dataSource());
|
||||||
log.info("Lifecycle тенанта '{}' удалён", domain);
|
log.info("Lifecycle тенанта '{}' удалён", domain);
|
||||||
return copyConfig(existing);
|
return new TenantLifecycleMutationResult(existing, receipt);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Синхронизирует уже персистированный список из mounted Secret. Метод не пишет
|
* Синхронизирует уже персистированный список из mounted Secret. Метод не пишет
|
||||||
* Secret повторно и использует тот же monitor, что ручные API add/update/remove.
|
* Secret повторно и использует тот же monitor, что ручные API add/update/remove.
|
||||||
* Обновление параметров существующего domain остаётся отдельной задачей watcher №13.
|
* Для нового или изменённого домена подключение сначала полностью подготавливается,
|
||||||
|
* проверяется и мигрируется, после чего атомарно заменяет прежний runtime-снимок.
|
||||||
*/
|
*/
|
||||||
public synchronized void synchronizeFromPersistedConfig(List<TenantConfig> requestedConfigs) {
|
public synchronized void synchronizeFromPersistedConfig(List<TenantConfig> requestedConfigs) {
|
||||||
if (requestedConfigs == null) {
|
if (requestedConfigs == null) {
|
||||||
@@ -146,47 +160,55 @@ public class TenantLifecycleService {
|
|||||||
}
|
}
|
||||||
List<TenantConfig> configs = requestedConfigs.stream()
|
List<TenantConfig> configs = requestedConfigs.stream()
|
||||||
.map(this::validateAndNormalize)
|
.map(this::validateAndNormalize)
|
||||||
|
.sorted(Comparator.comparing(TenantConfig::getDomain))
|
||||||
.toList();
|
.toList();
|
||||||
Set<String> desiredDomains = configs.stream()
|
|
||||||
.map(TenantConfig::getDomain)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
|
|
||||||
readinessRegistry.clearConfigurationFailure();
|
Map<String, TenantConfig> desiredConfigs = new LinkedHashMap<>();
|
||||||
readinessRegistry.replaceDesired(configs);
|
for (TenantConfig config : configs) {
|
||||||
if (desiredDomains.size() != configs.size()) {
|
if (desiredConfigs.put(config.getDomain(), config) != null) {
|
||||||
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
|
throw new IllegalArgumentException("Конфигурация содержит повторяющиеся домены тенантов");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, TenantConfig> currentConfigs = routingDataSource.snapshotTenantConfigs();
|
||||||
|
readinessRegistry.replaceDesired(configs);
|
||||||
|
|
||||||
for (TenantConfig config : configs) {
|
for (TenantConfig config : configs) {
|
||||||
if (!routingDataSource.hasTenant(config.getDomain())) {
|
TenantConfig currentConfig = currentConfigs.get(config.getDomain());
|
||||||
|
if (currentConfig == null) {
|
||||||
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
|
log.info("Активируем нового тенанта '{}' из persisted-конфигурации", config.getDomain());
|
||||||
activatePersistedConfig(config);
|
activatePersistedConfig(config);
|
||||||
|
} else if (!sameConfig(currentConfig, config)) {
|
||||||
|
log.info("Заменяем подключение тенанта '{}' из persisted-конфигурации", config.getDomain());
|
||||||
|
activatePersistedConfig(config);
|
||||||
|
} else {
|
||||||
|
recoverPersistedReadinessIfNeeded(config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (String existingDomain : new ArrayList<>(routingDataSource.snapshotTenantConfigs().keySet())) {
|
for (String existingDomain : new ArrayList<>(currentConfigs.keySet())) {
|
||||||
if (!desiredDomains.contains(existingDomain)) {
|
if (!desiredConfigs.containsKey(existingDomain)) {
|
||||||
log.info("Отключаем тенанта '{}' — его нет в persisted-конфигурации", existingDomain);
|
log.info("Отключаем тенанта '{}' — его нет в persisted-конфигурации", existingDomain);
|
||||||
TenantRoutingDataSource.TenantState removed =
|
TenantRoutingDataSource.TenantState removed =
|
||||||
routingDataSource.removeTenantAtomically(existingDomain);
|
routingDataSource.removeTenantAtomically(existingDomain);
|
||||||
retireDataSource(removed == null ? null : removed.dataSource());
|
retireDataSource(removed == null ? null : removed.dataSource());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readinessRegistry.clearConfigurationFailure();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void activatePersistedConfig(TenantConfig config) {
|
private void activatePersistedConfig(TenantConfig config) {
|
||||||
HikariDataSource candidate = null;
|
HikariDataSource candidate = null;
|
||||||
boolean activated = false;
|
boolean activated = false;
|
||||||
|
TenantRoutingDataSource.TenantState previous;
|
||||||
readinessRegistry.markMigrationStarted(config);
|
readinessRegistry.markMigrationStarted(config);
|
||||||
try {
|
try {
|
||||||
candidate = prepareCandidate(config);
|
candidate = prepareCandidate(config);
|
||||||
verifyConnection(candidate);
|
verifyConnection(candidate);
|
||||||
migrateCandidate(candidate);
|
migrateCandidate(candidate);
|
||||||
TenantRoutingDataSource.TenantState previous = routingDataSource.swapTenant(config, candidate);
|
previous = routingDataSource.swapTenant(config, candidate);
|
||||||
activated = true;
|
activated = true;
|
||||||
readinessRegistry.markMigrationSucceeded(config);
|
|
||||||
readinessRegistry.markConnectivity(config.getDomain(), true);
|
|
||||||
retireDataSource(previous == null ? null : previous.dataSource());
|
|
||||||
} catch (RuntimeException activationFailure) {
|
} catch (RuntimeException activationFailure) {
|
||||||
readinessRegistry.markMigrationFailed(config);
|
readinessRegistry.markMigrationFailed(config);
|
||||||
throw activationFailure;
|
throw activationFailure;
|
||||||
@@ -195,6 +217,29 @@ public class TenantLifecycleService {
|
|||||||
closeCandidateDataSource(candidate);
|
closeCandidateDataSource(candidate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// После swap новый pool уже является рабочим. Ошибка публикации readiness не должна
|
||||||
|
// ошибочно помечать успешно мигрированный pool как FAILED: следующий retry восстановит
|
||||||
|
// состояние по активному подключению без повторной замены DataSource.
|
||||||
|
retireDataSource(previous == null ? null : previous.dataSource());
|
||||||
|
readinessRegistry.markMigrationSucceeded(config);
|
||||||
|
readinessRegistry.markConnectivity(config.getDomain(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void recoverPersistedReadinessIfNeeded(TenantConfig config) {
|
||||||
|
TenantReadinessRegistry.TenantStatus status =
|
||||||
|
readinessRegistry.snapshot().get(config.getDomain());
|
||||||
|
if (status == null
|
||||||
|
|| status.migrationState() == TenantReadinessRegistry.MigrationState.SUCCEEDED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!routingDataSource.testConnection(config.getDomain())) {
|
||||||
|
throw new TenantLifecycleException(
|
||||||
|
"Активное подключение тенанта не прошло проверку после предыдущей синхронизации"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
readinessRegistry.markMigrationSucceeded(config);
|
||||||
|
readinessRegistry.markConnectivity(config.getDomain(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
private HikariDataSource prepareCandidate(TenantConfig config) {
|
private HikariDataSource prepareCandidate(TenantConfig config) {
|
||||||
@@ -398,9 +443,24 @@ public class TenantLifecycleService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean sameConfig(TenantConfig first, TenantConfig second) {
|
||||||
|
TenantConfig normalizedFirst = validateAndNormalize(first);
|
||||||
|
TenantConfig normalizedSecond = validateAndNormalize(second);
|
||||||
|
return Objects.equals(normalizedFirst.getName(), normalizedSecond.getName())
|
||||||
|
&& Objects.equals(normalizedFirst.getDomain(), normalizedSecond.getDomain())
|
||||||
|
&& Objects.equals(normalizedFirst.getUrl(), normalizedSecond.getUrl())
|
||||||
|
&& Objects.equals(normalizedFirst.getUsername(), normalizedSecond.getUsername())
|
||||||
|
&& Objects.equals(normalizedFirst.getPassword(), normalizedSecond.getPassword());
|
||||||
|
}
|
||||||
|
|
||||||
private void retireDataSource(DataSource dataSource) {
|
private void retireDataSource(DataSource dataSource) {
|
||||||
if (dataSource != null) {
|
if (dataSource != null) {
|
||||||
|
try {
|
||||||
retiredPoolService.retire(dataSource);
|
retiredPoolService.retire(dataSource);
|
||||||
|
} catch (RuntimeException retirementFailure) {
|
||||||
|
logTechnicalFailure("Не удалось передать прежний tenant pool на отложенное закрытие",
|
||||||
|
retirementFailure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -511,7 +511,10 @@ class KubernetesTenantSecretUpdaterTest {
|
|||||||
files.tokenPath(),
|
files.tokenPath(),
|
||||||
files.namespacePath(),
|
files.namespacePath(),
|
||||||
files.caPath(),
|
files.caPath(),
|
||||||
server.url("/").toString(),
|
// MockWebServer на Windows может вернуть canonical loopback hostname
|
||||||
|
// kubernetes.docker.internal. Фиксируем имя из SAN тестового сертификата,
|
||||||
|
// сохраняя реальную проверку hostname в клиенте.
|
||||||
|
"https://localhost:" + server.getPort(),
|
||||||
"tenants-secret",
|
"tenants-secret",
|
||||||
objectMapper,
|
objectMapper,
|
||||||
maxAttempts,
|
maxAttempts,
|
||||||
|
|||||||
@@ -1,33 +1,35 @@
|
|||||||
package com.magistr.app.config.tenant;
|
package com.magistr.app.config.tenant;
|
||||||
|
|
||||||
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
import com.magistr.app.config.tenant.health.TenantReadinessRegistry;
|
||||||
import com.magistr.app.service.RetiredTenantPoolService;
|
import com.magistr.app.service.TenantLifecycleException;
|
||||||
import com.magistr.app.service.TenantDatabaseMigrationService;
|
|
||||||
import com.magistr.app.service.TenantLifecycleService;
|
import com.magistr.app.service.TenantLifecycleService;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.junit.jupiter.api.io.TempDir;
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.springframework.boot.test.system.CapturedOutput;
|
||||||
|
import org.springframework.boot.test.system.OutputCaptureExtension;
|
||||||
import org.springframework.test.util.ReflectionTestUtils;
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.util.concurrent.CountDownLatch;
|
import java.util.List;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.atomic.AtomicLong;
|
||||||
import java.util.concurrent.Executors;
|
import java.util.function.LongSupplier;
|
||||||
import java.util.concurrent.Future;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.mockito.ArgumentMatchers.anyList;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyList;
|
||||||
import static org.mockito.Mockito.doAnswer;
|
import static org.mockito.Mockito.doAnswer;
|
||||||
import static org.mockito.Mockito.doNothing;
|
import static org.mockito.Mockito.doNothing;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.times;
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
|
|
||||||
|
@ExtendWith(OutputCaptureExtension.class)
|
||||||
class TenantConfigWatcherTest {
|
class TenantConfigWatcherTest {
|
||||||
|
|
||||||
@TempDir
|
@TempDir
|
||||||
@@ -48,29 +50,62 @@ class TenantConfigWatcherTest {
|
|||||||
.isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
|
.isNotEqualTo(TenantConfigWatcher.configHash("tenant-b"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prepareForApiMutationSynchronizesUnappliedBaselineOnce() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
String content = json("alpha");
|
||||||
|
Files.writeString(configPath, content);
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
||||||
|
|
||||||
|
watcher.prepareForApiMutation();
|
||||||
|
watcher.prepareForApiMutation();
|
||||||
|
|
||||||
|
verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
||||||
|
.isEqualTo(TenantConfigWatcher.configHash(content));
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "lastAppliedSnapshot")).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prepareForApiMutationPropagatesReadFailure() throws Exception {
|
||||||
|
Path configPath = Files.createDirectory(tempDirectory.resolve("tenants.json"));
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(
|
||||||
|
lifecycleService,
|
||||||
|
readinessRegistry,
|
||||||
|
configPath,
|
||||||
|
new AtomicLong(1_000L)
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(watcher::prepareForApiMutation)
|
||||||
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
|
.hasMessage("Не удалось подготовить актуальную конфигурацию тенантов перед изменением");
|
||||||
|
|
||||||
|
verify(readinessRegistry).markConfigurationFailure();
|
||||||
|
verify(lifecycleService, never()).synchronizeFromPersistedConfig(anyList());
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "configurationUnavailable")).isEqualTo(true);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void advancesHashOnlyAfterSuccessfulLifecycleSynchronization() throws Exception {
|
void advancesHashOnlyAfterSuccessfulLifecycleSynchronization() throws Exception {
|
||||||
Path configPath = tempDirectory.resolve("tenants.json");
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
String content = """
|
String content = json("alpha");
|
||||||
[{
|
|
||||||
"name": "Тестовый tenant",
|
|
||||||
"domain": "alpha",
|
|
||||||
"url": "jdbc:postgresql://db/alpha",
|
|
||||||
"username": "alpha",
|
|
||||||
"password": "тестовый-пароль"
|
|
||||||
}]
|
|
||||||
""";
|
|
||||||
Files.writeString(configPath, content);
|
Files.writeString(configPath, content);
|
||||||
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
doThrow(new IllegalStateException("тестовая ошибка синхронизации"))
|
doThrow(new IllegalStateException("тестовая ошибка синхронизации"))
|
||||||
.doNothing()
|
.doNothing()
|
||||||
.when(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
.when(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
AtomicLong now = new AtomicLong(1_000L);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath, now);
|
||||||
|
|
||||||
watcher.watchForChanges();
|
watcher.watchForChanges();
|
||||||
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo("");
|
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo("");
|
||||||
|
|
||||||
|
now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS);
|
||||||
watcher.watchForChanges();
|
watcher.watchForChanges();
|
||||||
|
|
||||||
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
||||||
.isEqualTo(TenantConfigWatcher.configHash(content));
|
.isEqualTo(TenantConfigWatcher.configHash(content));
|
||||||
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
|
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
|
||||||
@@ -80,112 +115,441 @@ class TenantConfigWatcherTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void successfulHashPreventsRepeatedSynchronizationOfSameContent() throws Exception {
|
void retriesSameRevisionOnlyAfterBackoffAndResetsStateAfterSuccess() throws Exception {
|
||||||
Path configPath = tempDirectory.resolve("tenants.json");
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
String content = "[]";
|
String content = "[]";
|
||||||
Files.writeString(configPath, content);
|
Files.writeString(configPath, content);
|
||||||
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
doNothing().when(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
doThrow(new IllegalStateException("первая тестовая ошибка"))
|
||||||
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
.doThrow(new IllegalStateException("вторая тестовая ошибка"))
|
||||||
|
.doNothing()
|
||||||
|
.when(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
AtomicLong now = new AtomicLong(10_000L);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath, now);
|
||||||
|
|
||||||
watcher.watchForChanges();
|
watcher.watchForChanges();
|
||||||
watcher.watchForChanges();
|
watcher.watchForChanges();
|
||||||
|
|
||||||
verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
|
||||||
|
now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS);
|
||||||
|
watcher.watchForChanges();
|
||||||
|
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
|
||||||
|
|
||||||
|
now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS * 2 - 1);
|
||||||
|
watcher.watchForChanges();
|
||||||
|
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
|
||||||
|
|
||||||
|
now.incrementAndGet();
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(lifecycleService, times(3)).synchronizeFromPersistedConfig(anyList());
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(0);
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "retryNotBeforeMillis")).isEqualTo(0L);
|
||||||
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
||||||
.isEqualTo(TenantConfigWatcher.configHash(content));
|
.isEqualTo(TenantConfigWatcher.configHash(content));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void watcherCannotReadBetweenSerializedApiMutationAndRefreshHash() throws Exception {
|
void retryDelayGrowsExponentiallyAndIsCapped() {
|
||||||
Path configPath = tempDirectory.resolve("tenants.json");
|
assertThat(TenantConfigWatcher.retryDelayMillis(1)).isEqualTo(30_000L);
|
||||||
String content = "[]";
|
assertThat(TenantConfigWatcher.retryDelayMillis(2)).isEqualTo(60_000L);
|
||||||
Files.writeString(configPath, content);
|
assertThat(TenantConfigWatcher.retryDelayMillis(3)).isEqualTo(120_000L);
|
||||||
TenantRoutingDataSource routingDataSource = mock(TenantRoutingDataSource.class);
|
assertThat(TenantConfigWatcher.retryDelayMillis(4)).isEqualTo(240_000L);
|
||||||
TenantLifecycleService lifecycleService = new TenantLifecycleService(
|
assertThat(TenantConfigWatcher.retryDelayMillis(5)).isEqualTo(300_000L);
|
||||||
routingDataSource,
|
assertThat(TenantConfigWatcher.retryDelayMillis(31)).isEqualTo(300_000L);
|
||||||
mock(TenantConfigStore.class),
|
|
||||||
mock(TenantDatabaseMigrationService.class),
|
|
||||||
mock(RetiredTenantPoolService.class),
|
|
||||||
mock(TenantReadinessRegistry.class)
|
|
||||||
);
|
|
||||||
TenantConfigWatcher watcher = new TenantConfigWatcher(
|
|
||||||
lifecycleService,
|
|
||||||
mock(TenantReadinessRegistry.class)
|
|
||||||
);
|
|
||||||
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
|
|
||||||
CountDownLatch apiMutationReachedRefreshWindow = new CountDownLatch(1);
|
|
||||||
CountDownLatch allowApiRefresh = new CountDownLatch(1);
|
|
||||||
AtomicReference<Thread> watcherThread = new AtomicReference<>();
|
|
||||||
ExecutorService executor = Executors.newFixedThreadPool(2);
|
|
||||||
|
|
||||||
try {
|
|
||||||
Future<?> api = executor.submit(() -> lifecycleService.executeSerialized(() -> {
|
|
||||||
apiMutationReachedRefreshWindow.countDown();
|
|
||||||
await(allowApiRefresh, "Ожидание разрешения refreshHash прервано");
|
|
||||||
watcher.refreshHash();
|
|
||||||
}));
|
|
||||||
assertThat(apiMutationReachedRefreshWindow.await(5, TimeUnit.SECONDS)).isTrue();
|
|
||||||
|
|
||||||
Future<?> concurrentWatcher = executor.submit(() -> {
|
|
||||||
watcherThread.set(Thread.currentThread());
|
|
||||||
watcher.watchForChanges();
|
|
||||||
});
|
|
||||||
awaitBlocked(watcherThread);
|
|
||||||
assertThat(concurrentWatcher).isNotDone();
|
|
||||||
|
|
||||||
allowApiRefresh.countDown();
|
|
||||||
api.get(5, TimeUnit.SECONDS);
|
|
||||||
concurrentWatcher.get(5, TimeUnit.SECONDS);
|
|
||||||
|
|
||||||
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
|
||||||
.isEqualTo(TenantConfigWatcher.configHash(content));
|
|
||||||
verify(routingDataSource, never()).snapshotTenantConfigs();
|
|
||||||
} finally {
|
|
||||||
allowApiRefresh.countDown();
|
|
||||||
executor.shutdownNow();
|
|
||||||
assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void newRevisionBypassesBackoffImmediately() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
Files.writeString(configPath, "[]");
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
doThrow(new IllegalStateException("тестовая ошибка"))
|
||||||
|
.doNothing()
|
||||||
|
.when(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath, new AtomicLong(1_000L));
|
||||||
|
|
||||||
|
watcher.watchForChanges();
|
||||||
|
Files.writeString(configPath, "[ ]");
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(anyList());
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
||||||
|
.isEqualTo(TenantConfigWatcher.configHash("[ ]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void restoredLastAppliedRevisionIsReconciledAfterFailedChange() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
String firstRevision = "[]";
|
||||||
|
String failedRevision = "[ ]";
|
||||||
|
Files.writeString(configPath, firstRevision);
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
doNothing()
|
||||||
|
.doThrow(new IllegalStateException("тестовая ошибка новой ревизии"))
|
||||||
|
.doNothing()
|
||||||
|
.when(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath, new AtomicLong(1_000L));
|
||||||
|
|
||||||
|
watcher.watchForChanges();
|
||||||
|
Files.writeString(configPath, failedRevision);
|
||||||
|
watcher.watchForChanges();
|
||||||
|
Files.writeString(configPath, firstRevision);
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(lifecycleService, times(3)).synchronizeFromPersistedConfig(anyList());
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
||||||
|
.isEqualTo(TenantConfigWatcher.configHash(firstRevision));
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "configurationUnavailable")).isEqualTo(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void semanticFenceDefersStaleProjectionWithDifferentFormatting() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
String baseline = json("alpha");
|
||||||
|
Files.writeString(configPath, baseline);
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
||||||
|
watcher.prepareForApiMutation();
|
||||||
|
watcher.registerSuccessfulMutation(receipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
tenants("alpha"),
|
||||||
|
tenants("alpha", "beta")
|
||||||
|
));
|
||||||
|
|
||||||
|
Files.writeString(configPath, prettyJson("alpha"));
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash"))
|
||||||
|
.isEqualTo(TenantConfigWatcher.configHash(baseline));
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNotNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void prepareForNextMutationDoesNotApplyKnownStaleProjection() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
Files.writeString(configPath, json("alpha"));
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
||||||
|
watcher.prepareForApiMutation();
|
||||||
|
watcher.registerSuccessfulMutation(receipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
tenants("alpha"),
|
||||||
|
tenants("alpha", "beta")
|
||||||
|
));
|
||||||
|
|
||||||
|
watcher.prepareForApiMutation();
|
||||||
|
|
||||||
|
verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nonPersistedReceiptDoesNotArmFence() {
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(
|
||||||
|
lifecycleService,
|
||||||
|
tempDirectory.resolve("tenants.json")
|
||||||
|
);
|
||||||
|
|
||||||
|
watcher.registerSuccessfulMutation(receipt(
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
tenants("alpha"),
|
||||||
|
tenants("alpha", "beta")
|
||||||
|
));
|
||||||
|
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
|
||||||
|
assertThat((Iterable<?>) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistedNoOpDoesNotSuppressMergedProjection() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
Files.writeString(configPath, json("alpha"));
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
||||||
|
watcher.prepareForApiMutation();
|
||||||
|
watcher.registerSuccessfulMutation(receipt(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
tenants("alpha"),
|
||||||
|
tenants("alpha")
|
||||||
|
));
|
||||||
|
|
||||||
|
Files.writeString(configPath, json("alpha", "gamma"));
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
ArgumentCaptor<List<TenantConfig>> captor = tenantListCaptor();
|
||||||
|
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(captor.capture());
|
||||||
|
assertThat(captor.getAllValues().get(1))
|
||||||
|
.extracting(TenantConfig::getDomain)
|
||||||
|
.containsExactly("alpha", "gamma");
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoRapidMutationsDeferIntermediateProjectionUntilLatestCommit() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
Files.writeString(configPath, json("alpha"));
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
||||||
|
watcher.prepareForApiMutation();
|
||||||
|
|
||||||
|
watcher.registerSuccessfulMutation(receipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
tenants("alpha"),
|
||||||
|
tenants("alpha", "beta")
|
||||||
|
));
|
||||||
|
watcher.registerSuccessfulMutation(receipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
tenants("alpha", "beta"),
|
||||||
|
tenants("alpha", "beta", "gamma")
|
||||||
|
));
|
||||||
|
|
||||||
|
Files.writeString(configPath, json("alpha", "beta"));
|
||||||
|
watcher.watchForChanges();
|
||||||
|
verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
|
||||||
|
Files.writeString(configPath, json("alpha", "beta", "gamma"));
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
ArgumentCaptor<List<TenantConfig>> captor = tenantListCaptor();
|
||||||
|
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(captor.capture());
|
||||||
|
assertThat(captor.getAllValues().get(1))
|
||||||
|
.extracting(TenantConfig::getDomain)
|
||||||
|
.containsExactly("alpha", "beta", "gamma");
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
|
||||||
|
assertThat((Iterable<?>) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cyclicRapidMutationsClearFenceWhenLatestCommitIsAlreadyApplied() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
Files.writeString(configPath, json("alpha"));
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
||||||
|
watcher.prepareForApiMutation();
|
||||||
|
|
||||||
|
watcher.registerSuccessfulMutation(receipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
tenants("alpha"),
|
||||||
|
tenants("alpha", "beta")
|
||||||
|
));
|
||||||
|
watcher.registerSuccessfulMutation(receipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
tenants("alpha", "beta"),
|
||||||
|
tenants("alpha")
|
||||||
|
));
|
||||||
|
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
|
||||||
|
assertThat((Iterable<?>) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unknownConcurrentSnapshotSupersedesExpectedAndIsApplied() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
Files.writeString(configPath, json("alpha"));
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath);
|
||||||
|
watcher.prepareForApiMutation();
|
||||||
|
watcher.registerSuccessfulMutation(receipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
tenants("alpha"),
|
||||||
|
tenants("alpha", "beta")
|
||||||
|
));
|
||||||
|
|
||||||
|
Files.writeString(configPath, json("alpha", "gamma"));
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
ArgumentCaptor<List<TenantConfig>> captor = tenantListCaptor();
|
||||||
|
verify(lifecycleService, times(2)).synchronizeFromPersistedConfig(captor.capture());
|
||||||
|
assertThat(captor.getAllValues().get(1))
|
||||||
|
.extracting(TenantConfig::getDomain)
|
||||||
|
.containsExactly("alpha", "gamma");
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "expectedCommittedSnapshot")).isNull();
|
||||||
|
assertThat((Iterable<?>) ReflectionTestUtils.getField(watcher, "deferredSnapshots")).isEmpty();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiredMissingFileUsesBackoff() {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class);
|
||||||
|
AtomicLong now = new AtomicLong(1_000L);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, readinessRegistry, configPath, now);
|
||||||
|
ReflectionTestUtils.setField(watcher, "tenantsConfigRequired", true);
|
||||||
|
|
||||||
|
watcher.watchForChanges();
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(readinessRegistry).markConfigurationFailure();
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(1);
|
||||||
|
|
||||||
|
now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS);
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(readinessRegistry, times(2)).markConfigurationFailure();
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void readFailureUsesBackoff() throws Exception {
|
||||||
|
Path configPath = Files.createDirectory(tempDirectory.resolve("tenants.json"));
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class);
|
||||||
|
AtomicLong now = new AtomicLong(1_000L);
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, readinessRegistry, configPath, now);
|
||||||
|
|
||||||
|
watcher.watchForChanges();
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(readinessRegistry).markConfigurationFailure();
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(1);
|
||||||
|
|
||||||
|
now.addAndGet(TenantConfigWatcher.RETRY_BASE_DELAY_MILLIS);
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(readinessRegistry, times(2)).markConfigurationFailure();
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "consecutiveFailures")).isEqualTo(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void requiredEmptyConfigMarksFailureAndKeepsHashUnapplied() throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
Files.writeString(configPath, "[]");
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
TenantReadinessRegistry readinessRegistry = mock(TenantReadinessRegistry.class);
|
||||||
|
TenantConfigWatcher watcher = watcher(
|
||||||
|
lifecycleService,
|
||||||
|
readinessRegistry,
|
||||||
|
configPath,
|
||||||
|
new AtomicLong(1_000L)
|
||||||
|
);
|
||||||
|
ReflectionTestUtils.setField(watcher, "tenantsConfigRequired", true);
|
||||||
|
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
verify(readinessRegistry).markConfigurationFailure();
|
||||||
|
verify(lifecycleService, never()).synchronizeFromPersistedConfig(anyList());
|
||||||
|
assertThat(ReflectionTestUtils.getField(watcher, "lastConfigHash")).isEqualTo("");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failureLogsRetryMetadataWithoutSecretsOrFingerprints(CapturedOutput output) throws Exception {
|
||||||
|
Path configPath = tempDirectory.resolve("tenants.json");
|
||||||
|
String content = """
|
||||||
|
[{"domain":"alpha","url":"jdbc:postgresql://secret-host/db",
|
||||||
|
"username":"secret-user","password":"secret-password"}]
|
||||||
|
""";
|
||||||
|
Files.writeString(configPath, content);
|
||||||
|
TenantLifecycleService lifecycleService = mock(TenantLifecycleService.class);
|
||||||
|
doThrow(new IllegalStateException("секретные технические детали"))
|
||||||
|
.when(lifecycleService).synchronizeFromPersistedConfig(anyList());
|
||||||
|
TenantConfigWatcher watcher = watcher(lifecycleService, configPath, new AtomicLong(1_000L));
|
||||||
|
|
||||||
|
watcher.watchForChanges();
|
||||||
|
|
||||||
|
assertThat(output)
|
||||||
|
.contains("Ошибка синхронизации конфигурации тенантов")
|
||||||
|
.contains("попытка=1")
|
||||||
|
.contains("через 30000 мс")
|
||||||
|
.doesNotContain("secret-host")
|
||||||
|
.doesNotContain("secret-user")
|
||||||
|
.doesNotContain("secret-password")
|
||||||
|
.doesNotContain(TenantConfigWatcher.configHash(content))
|
||||||
|
.doesNotContain("revision=")
|
||||||
|
.doesNotContain("fingerprint")
|
||||||
|
.doesNotContain("хеш:");
|
||||||
}
|
}
|
||||||
|
|
||||||
private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService, Path configPath) {
|
private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService, Path configPath) {
|
||||||
|
return watcher(lifecycleService, configPath, new AtomicLong(1_000L));
|
||||||
|
}
|
||||||
|
|
||||||
|
private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService,
|
||||||
|
Path configPath,
|
||||||
|
AtomicLong now) {
|
||||||
|
return watcher(lifecycleService, mock(TenantReadinessRegistry.class), configPath, now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private TenantConfigWatcher watcher(TenantLifecycleService lifecycleService,
|
||||||
|
TenantReadinessRegistry readinessRegistry,
|
||||||
|
Path configPath,
|
||||||
|
AtomicLong now) {
|
||||||
doAnswer(invocation -> {
|
doAnswer(invocation -> {
|
||||||
invocation.<Runnable>getArgument(0).run();
|
invocation.<Runnable>getArgument(0).run();
|
||||||
return null;
|
return null;
|
||||||
}).when(lifecycleService).executeSerialized(any(Runnable.class));
|
}).when(lifecycleService).executeSerialized(any(Runnable.class));
|
||||||
TenantConfigWatcher watcher = new TenantConfigWatcher(
|
TenantConfigWatcher watcher = new TenantConfigWatcher(lifecycleService, readinessRegistry);
|
||||||
lifecycleService,
|
|
||||||
mock(TenantReadinessRegistry.class)
|
|
||||||
);
|
|
||||||
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
|
ReflectionTestUtils.setField(watcher, "tenantsConfigPath", configPath.toString());
|
||||||
|
ReflectionTestUtils.setField(watcher, "currentTimeMillis", (LongSupplier) now::get);
|
||||||
return watcher;
|
return watcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void await(CountDownLatch latch, String message) {
|
private TenantSecretUpdateReceipt receipt(boolean persisted,
|
||||||
try {
|
boolean changed,
|
||||||
if (!latch.await(5, TimeUnit.SECONDS)) {
|
List<TenantConfig> previous,
|
||||||
throw new IllegalStateException(message);
|
List<TenantConfig> committed) {
|
||||||
}
|
return new TenantSecretUpdateReceipt(
|
||||||
} catch (InterruptedException exception) {
|
persisted,
|
||||||
Thread.currentThread().interrupt();
|
changed,
|
||||||
throw new IllegalStateException(message, exception);
|
persisted ? "resource-version" : null,
|
||||||
}
|
previous,
|
||||||
|
committed
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void awaitBlocked(AtomicReference<Thread> threadReference) {
|
private List<TenantConfig> tenants(String... domains) {
|
||||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
|
return java.util.Arrays.stream(domains).map(this::tenant).toList();
|
||||||
while (System.nanoTime() < deadline) {
|
|
||||||
Thread thread = threadReference.get();
|
|
||||||
if (thread != null && thread.getState() == Thread.State.BLOCKED) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
Thread.sleep(10);
|
private TenantConfig tenant(String domain) {
|
||||||
} catch (InterruptedException exception) {
|
return new TenantConfig(
|
||||||
Thread.currentThread().interrupt();
|
domain,
|
||||||
throw new IllegalStateException("Ожидание блокировки watcher прервано", exception);
|
domain,
|
||||||
|
"jdbc:postgresql://db/" + domain,
|
||||||
|
domain,
|
||||||
|
"password-" + domain
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private String json(String... domains) {
|
||||||
|
return "[" + java.util.Arrays.stream(domains)
|
||||||
|
.map(domain -> "{\"name\":\"" + domain
|
||||||
|
+ "\",\"domain\":\"" + domain
|
||||||
|
+ "\",\"url\":\"jdbc:postgresql://db/" + domain
|
||||||
|
+ "\",\"username\":\"" + domain
|
||||||
|
+ "\",\"password\":\"password-" + domain + "\"}")
|
||||||
|
.collect(java.util.stream.Collectors.joining(",")) + "]";
|
||||||
}
|
}
|
||||||
throw new IllegalStateException("Watcher не заблокировался на общем lifecycle coordinator");
|
|
||||||
|
private String prettyJson(String domain) {
|
||||||
|
return """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"password": "password-%1$s",
|
||||||
|
"username": "%1$s",
|
||||||
|
"url": "jdbc:postgresql://db/%1$s",
|
||||||
|
"domain": "%1$s",
|
||||||
|
"name": "%1$s"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
""".formatted(domain);
|
||||||
|
}
|
||||||
|
|
||||||
|
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||||
|
private ArgumentCaptor<List<TenantConfig>> tenantListCaptor() {
|
||||||
|
return (ArgumentCaptor) ArgumentCaptor.forClass(List.class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,23 +1,32 @@
|
|||||||
package com.magistr.app.controller;
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
import com.magistr.app.config.tenant.TenantConfigWatcher;
|
||||||
|
import com.magistr.app.config.tenant.TenantConfig;
|
||||||
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
import com.magistr.app.config.tenant.TenantRoutingDataSource;
|
||||||
|
import com.magistr.app.config.tenant.TenantSecretUpdateReceipt;
|
||||||
import com.magistr.app.service.TenantLifecycleException;
|
import com.magistr.app.service.TenantLifecycleException;
|
||||||
|
import com.magistr.app.service.TenantLifecycleMutationResult;
|
||||||
import com.magistr.app.service.TenantLifecycleService;
|
import com.magistr.app.service.TenantLifecycleService;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.mockito.InOrder;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.function.Supplier;
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.inOrder;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
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.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
@@ -68,7 +77,128 @@ class DatabaseControllerTest {
|
|||||||
.andExpect(jsonPath("$.message")
|
.andExpect(jsonPath("$.message")
|
||||||
.value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("JDBC"))));
|
.value(org.hamcrest.Matchers.not(org.hamcrest.Matchers.containsString("JDBC"))));
|
||||||
|
|
||||||
verify(tenantConfigWatcher, never()).refreshHash();
|
verify(tenantConfigWatcher, never()).prepareForApiMutation();
|
||||||
|
verify(tenantConfigWatcher, never()).registerSuccessfulMutation(any());
|
||||||
verify(lifecycleService).executeSerialized(any(Supplier.class));
|
verify(lifecycleService).executeSerialized(any(Supplier.class));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successfulAddPreparesProjectionAndRegistersPersistenceReceipt() throws Exception {
|
||||||
|
executeSerializedActionsImmediately();
|
||||||
|
TenantConfig saved = new TenantConfig(
|
||||||
|
"Тестовый университет",
|
||||||
|
"test",
|
||||||
|
"jdbc:postgresql://db/test",
|
||||||
|
"user",
|
||||||
|
"password"
|
||||||
|
);
|
||||||
|
TenantSecretUpdateReceipt receipt = receipt(saved);
|
||||||
|
when(lifecycleService.addOrUpdateTenantWithReceipt(any(TenantConfig.class)))
|
||||||
|
.thenReturn(new TenantLifecycleMutationResult(saved, receipt));
|
||||||
|
|
||||||
|
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().isOk())
|
||||||
|
.andExpect(jsonPath("$.success").value(true));
|
||||||
|
|
||||||
|
InOrder order = inOrder(lifecycleService, tenantConfigWatcher);
|
||||||
|
order.verify(lifecycleService).executeSerialized(any(Supplier.class));
|
||||||
|
order.verify(tenantConfigWatcher).prepareForApiMutation();
|
||||||
|
order.verify(lifecycleService).addOrUpdateTenantWithReceipt(any(TenantConfig.class));
|
||||||
|
order.verify(tenantConfigWatcher).registerSuccessfulMutation(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successfulRemovalPreparesProjectionAndRegistersPersistenceReceipt() throws Exception {
|
||||||
|
executeSerializedActionsImmediately();
|
||||||
|
TenantConfig removed = new TenantConfig(
|
||||||
|
"Тестовый университет",
|
||||||
|
"test",
|
||||||
|
"jdbc:postgresql://db/test",
|
||||||
|
"user",
|
||||||
|
"password"
|
||||||
|
);
|
||||||
|
TenantSecretUpdateReceipt receipt = receipt();
|
||||||
|
when(lifecycleService.removeTenantWithReceipt("test"))
|
||||||
|
.thenReturn(new TenantLifecycleMutationResult(removed, receipt));
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/database/tenants/test"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.success").value(true));
|
||||||
|
|
||||||
|
InOrder order = inOrder(lifecycleService, tenantConfigWatcher);
|
||||||
|
order.verify(lifecycleService).executeSerialized(any(Supplier.class));
|
||||||
|
order.verify(tenantConfigWatcher).prepareForApiMutation();
|
||||||
|
order.verify(lifecycleService).removeTenantWithReceipt("test");
|
||||||
|
order.verify(tenantConfigWatcher).registerSuccessfulMutation(receipt);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void projectionReadFailurePreventsAddAndReturnsServiceUnavailable() throws Exception {
|
||||||
|
executeSerializedActionsImmediately();
|
||||||
|
doThrow(new TenantLifecycleException(
|
||||||
|
"Не удалось прочитать конфигурацию тенантов перед изменением",
|
||||||
|
new IllegalStateException("тестовая ошибка чтения")
|
||||||
|
)).when(tenantConfigWatcher).prepareForApiMutation();
|
||||||
|
|
||||||
|
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("Не удалось прочитать конфигурацию тенантов перед изменением"));
|
||||||
|
|
||||||
|
verify(lifecycleService, never()).addOrUpdateTenantWithReceipt(any());
|
||||||
|
verify(tenantConfigWatcher, never()).registerSuccessfulMutation(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void projectionReadFailurePreventsRemovalAndReturnsServiceUnavailable() throws Exception {
|
||||||
|
executeSerializedActionsImmediately();
|
||||||
|
doThrow(new TenantLifecycleException(
|
||||||
|
"Не удалось прочитать конфигурацию тенантов перед изменением",
|
||||||
|
new IllegalStateException("тестовая ошибка чтения")
|
||||||
|
)).when(tenantConfigWatcher).prepareForApiMutation();
|
||||||
|
|
||||||
|
mockMvc.perform(delete("/api/database/tenants/test"))
|
||||||
|
.andExpect(status().isServiceUnavailable())
|
||||||
|
.andExpect(jsonPath("$.success").value(false));
|
||||||
|
|
||||||
|
verify(lifecycleService, never()).removeTenantWithReceipt(any());
|
||||||
|
verify(tenantConfigWatcher, never()).registerSuccessfulMutation(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void executeSerializedActionsImmediately() {
|
||||||
|
doAnswer(invocation -> invocation.<Supplier<?>>getArgument(0).get())
|
||||||
|
.when(lifecycleService)
|
||||||
|
.executeSerialized(org.mockito.ArgumentMatchers.<Supplier<Object>>any());
|
||||||
|
}
|
||||||
|
|
||||||
|
private TenantSecretUpdateReceipt receipt(TenantConfig... tenants) {
|
||||||
|
return new TenantSecretUpdateReceipt(
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
"resource-version",
|
||||||
|
List.of(),
|
||||||
|
List.of(tenants)
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,52 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
|||||||
assertThat(currentDatabaseThroughRouter()).isEqualTo(VALID_DATABASE);
|
assertThat(currentDatabaseThroughRouter()).isEqualTo(VALID_DATABASE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void persistedConfigReplacementKeepsOldPoolOnFailureAndSwapsWithoutSecretWrite() throws Exception {
|
||||||
|
TenantConfig wrongCredentials = config(
|
||||||
|
"Неверные credentials из проекции",
|
||||||
|
jdbcUrl(OLD_DATABASE),
|
||||||
|
POSTGRES.getPassword() + "-wrong"
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> lifecycleService.synchronizeFromPersistedConfig(List.of(wrongCredentials)))
|
||||||
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
|
.hasMessage("Не удалось подключиться к базе данных тенанта");
|
||||||
|
assertOldState();
|
||||||
|
|
||||||
|
TenantConfig badChecksum = config(
|
||||||
|
"Повреждённый Flyway из проекции",
|
||||||
|
jdbcUrl(BAD_CHECKSUM_DATABASE),
|
||||||
|
POSTGRES.getPassword()
|
||||||
|
);
|
||||||
|
assertThatThrownBy(() -> lifecycleService.synchronizeFromPersistedConfig(List.of(badChecksum)))
|
||||||
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
|
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
||||||
|
assertOldState();
|
||||||
|
|
||||||
|
TenantConfig validCandidate = config(
|
||||||
|
"Новый tenant из проекции",
|
||||||
|
jdbcUrl(VALID_DATABASE),
|
||||||
|
POSTGRES.getPassword()
|
||||||
|
);
|
||||||
|
try (Connection inFlightOldConnection = oldPool.getConnection()) {
|
||||||
|
lifecycleService.synchronizeFromPersistedConfig(List.of(validCandidate));
|
||||||
|
|
||||||
|
assertThat(oldPool.isClosed()).isFalse();
|
||||||
|
try (Statement statement = inFlightOldConnection.createStatement();
|
||||||
|
ResultSet result = statement.executeQuery("SELECT current_database()")) {
|
||||||
|
assertThat(result.next()).isTrue();
|
||||||
|
assertThat(result.getString(1)).isEqualTo(OLD_DATABASE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitPoolClosed(oldPool);
|
||||||
|
assertThat(currentDatabaseThroughRouter()).isEqualTo(VALID_DATABASE);
|
||||||
|
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||||
|
verify(tenantConfigStore, never()).removeTenant(any());
|
||||||
|
verify(tenantConfigStore, never()).compensate(any());
|
||||||
|
}
|
||||||
|
|
||||||
private void awaitPoolClosed(HikariDataSource pool) {
|
private void awaitPoolClosed(HikariDataSource pool) {
|
||||||
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
|
||||||
while (!pool.isClosed() && System.nanoTime() < deadline) {
|
while (!pool.isClosed() && System.nanoTime() < deadline) {
|
||||||
@@ -218,6 +264,7 @@ class TenantLifecyclePostgreSqlIntegrationTest {
|
|||||||
private void createDatabase(String databaseName) throws Exception {
|
private void createDatabase(String databaseName) throws Exception {
|
||||||
try (Connection connection = POSTGRES.createConnection("");
|
try (Connection connection = POSTGRES.createConnection("");
|
||||||
Statement statement = connection.createStatement()) {
|
Statement statement = connection.createStatement()) {
|
||||||
|
statement.execute("DROP DATABASE IF EXISTS " + databaseName + " WITH (FORCE)");
|
||||||
statement.execute("CREATE DATABASE " + databaseName);
|
statement.execute("CREATE DATABASE " + databaseName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ import com.zaxxer.hikari.HikariDataSource;
|
|||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.Arguments;
|
||||||
|
import org.junit.jupiter.params.provider.MethodSource;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InOrder;
|
import org.mockito.InOrder;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
@@ -20,17 +23,21 @@ import org.mockito.quality.Strictness;
|
|||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Comparator;
|
import java.util.Comparator;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
import static org.mockito.Mockito.inOrder;
|
import static org.mockito.Mockito.inOrder;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -128,6 +135,25 @@ class TenantLifecycleServiceTest {
|
|||||||
verify(candidate, never()).close();
|
verify(candidate, never()).close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsConfirmedReceiptWithSuccessfulUpsert() {
|
||||||
|
TenantConfig requested = newAlpha();
|
||||||
|
TenantSecretUpdateReceipt receipt = new TenantSecretUpdateReceipt(
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
"resource-version-noop",
|
||||||
|
List.of(requested),
|
||||||
|
List.of(requested)
|
||||||
|
);
|
||||||
|
when(tenantConfigStore.upsertTenant(any())).thenReturn(receipt);
|
||||||
|
|
||||||
|
TenantLifecycleMutationResult result = service.addOrUpdateTenantWithReceipt(requested);
|
||||||
|
|
||||||
|
assertThat(result.receipt()).isSameAs(receipt);
|
||||||
|
assertConfig(result.tenant(),
|
||||||
|
"Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addsNewTenantWithoutRetiringExistingPool() {
|
void addsNewTenantWithoutRetiringExistingPool() {
|
||||||
Map<String, TenantConfig> createSnapshot = new LinkedHashMap<>();
|
Map<String, TenantConfig> createSnapshot = new LinkedHashMap<>();
|
||||||
@@ -296,6 +322,26 @@ class TenantLifecycleServiceTest {
|
|||||||
order.verify(retiredPoolService).retire(oldDataSource);
|
order.verify(retiredPoolService).retire(oldDataSource);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void returnsLocalOnlyReceiptWithSuccessfulRemoval() {
|
||||||
|
TenantSecretUpdateReceipt receipt = new TenantSecretUpdateReceipt(
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
List.of(oldAlpha),
|
||||||
|
List.of(beta)
|
||||||
|
);
|
||||||
|
when(tenantConfigStore.removeTenant("alpha")).thenReturn(receipt);
|
||||||
|
when(routingDataSource.removeTenantAtomically("alpha"))
|
||||||
|
.thenReturn(new TenantRoutingDataSource.TenantState("alpha", oldAlpha, oldDataSource));
|
||||||
|
|
||||||
|
TenantLifecycleMutationResult result = service.removeTenantWithReceipt("alpha");
|
||||||
|
|
||||||
|
assertThat(result.receipt()).isSameAs(receipt);
|
||||||
|
assertConfig(result.tenant(),
|
||||||
|
"Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "old-password");
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() {
|
void removalFailureCompensatesCommittedReceiptAndKeepsPoolOpen() {
|
||||||
TenantSecretUpdateReceipt receipt = removeReceipt("alpha");
|
TenantSecretUpdateReceipt receipt = removeReceipt("alpha");
|
||||||
@@ -314,22 +360,156 @@ class TenantLifecycleServiceTest {
|
|||||||
@Test
|
@Test
|
||||||
void persistedTenantBecomesReadyOnlyAfterMigrationAndSwap() {
|
void persistedTenantBecomesReadyOnlyAfterMigrationAndSwap() {
|
||||||
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
|
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
|
||||||
when(routingDataSource.hasTenant("gamma")).thenReturn(false);
|
|
||||||
when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null);
|
when(routingDataSource.swapTenant(any(TenantConfig.class), any())).thenReturn(null);
|
||||||
|
|
||||||
service.synchronizeFromPersistedConfig(List.of(gamma));
|
service.synchronizeFromPersistedConfig(List.of(gamma));
|
||||||
|
|
||||||
InOrder order = inOrder(readinessRegistry, migrationService, routingDataSource);
|
InOrder order = inOrder(readinessRegistry, migrationService, routingDataSource);
|
||||||
order.verify(readinessRegistry).clearConfigurationFailure();
|
|
||||||
order.verify(readinessRegistry).replaceDesired(any());
|
order.verify(readinessRegistry).replaceDesired(any());
|
||||||
order.verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class));
|
order.verify(readinessRegistry).markMigrationStarted(any(TenantConfig.class));
|
||||||
order.verify(migrationService).migrate(candidate);
|
order.verify(migrationService).migrate(candidate);
|
||||||
order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
|
order.verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
|
||||||
order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));
|
order.verify(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));
|
||||||
order.verify(readinessRegistry).markConnectivity("gamma", true);
|
order.verify(readinessRegistry).markConnectivity("gamma", true);
|
||||||
|
order.verify(readinessRegistry).clearConfigurationFailure();
|
||||||
verify(tenantConfigStore, never()).upsertTenant(any());
|
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@MethodSource("changedPersistedAlphaConfigs")
|
||||||
|
void replacesExistingPersistedTenantWhenAnyNormalizedFieldChanges(TenantConfig changedConfig) {
|
||||||
|
when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha));
|
||||||
|
|
||||||
|
service.synchronizeFromPersistedConfig(List.of(changedConfig));
|
||||||
|
|
||||||
|
ArgumentCaptor<TenantConfig> preparedConfig = ArgumentCaptor.forClass(TenantConfig.class);
|
||||||
|
verify(routingDataSource).prepareTenantDataSource(preparedConfig.capture());
|
||||||
|
verify(migrationService).migrate(candidate);
|
||||||
|
verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
|
||||||
|
verify(retiredPoolService).retire(oldDataSource);
|
||||||
|
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||||
|
verify(tenantConfigStore, never()).removeTenant(any());
|
||||||
|
assertConfig(
|
||||||
|
preparedConfig.getValue(),
|
||||||
|
changedConfig.getName().trim(),
|
||||||
|
"alpha",
|
||||||
|
changedConfig.getUrl().trim(),
|
||||||
|
changedConfig.getUsername(),
|
||||||
|
changedConfig.getPassword()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void normalizedEquivalentPersistedTenantDoesNotRecreatePool() {
|
||||||
|
when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha));
|
||||||
|
TenantConfig equivalent = config(
|
||||||
|
" Старый Alpha ",
|
||||||
|
" ALPHA ",
|
||||||
|
" jdbc:old-alpha ",
|
||||||
|
"old-user",
|
||||||
|
"old-password"
|
||||||
|
);
|
||||||
|
|
||||||
|
service.synchronizeFromPersistedConfig(List.of(equivalent));
|
||||||
|
|
||||||
|
verify(routingDataSource, never()).prepareTenantDataSource(any());
|
||||||
|
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||||
|
verify(routingDataSource, never()).removeTenantAtomically(any());
|
||||||
|
verify(migrationService, never()).migrate(any());
|
||||||
|
verify(retiredPoolService, never()).retire(any());
|
||||||
|
verify(readinessRegistry).clearConfigurationFailure();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failedPersistedReplacementKeepsOldPoolAndClosesCandidate() {
|
||||||
|
when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of("alpha", oldAlpha));
|
||||||
|
org.mockito.Mockito.doThrow(new IllegalStateException("тестовая ошибка Flyway"))
|
||||||
|
.when(migrationService).migrate(candidate);
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(newAlpha())))
|
||||||
|
.isInstanceOf(TenantLifecycleException.class)
|
||||||
|
.hasMessage("Не удалось выполнить миграции базы данных тенанта");
|
||||||
|
|
||||||
|
verify(routingDataSource, never()).swapTenant(any(), any());
|
||||||
|
verify(retiredPoolService, never()).retire(oldDataSource);
|
||||||
|
verify(candidate).close();
|
||||||
|
verify(readinessRegistry).markMigrationFailed(any(TenantConfig.class));
|
||||||
|
verify(readinessRegistry, never()).clearConfigurationFailure();
|
||||||
|
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void retryRestoresReadinessAfterPostSwapPublicationFailureWithoutSecondSwap() {
|
||||||
|
TenantConfig changed = newAlpha();
|
||||||
|
Map<String, TenantConfig> activatedSnapshot = new LinkedHashMap<>();
|
||||||
|
activatedSnapshot.put("alpha", changed);
|
||||||
|
activatedSnapshot.put("beta", beta);
|
||||||
|
when(routingDataSource.snapshotTenantConfigs())
|
||||||
|
.thenReturn(oldSnapshot, activatedSnapshot);
|
||||||
|
TenantReadinessRegistry.TenantStatus interruptedStatus = new TenantReadinessRegistry.TenantStatus(
|
||||||
|
"alpha",
|
||||||
|
"тестовый-fingerprint",
|
||||||
|
TenantReadinessRegistry.MigrationState.IN_PROGRESS,
|
||||||
|
TenantReadinessRegistry.ConnectivityState.UNKNOWN,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
Instant.EPOCH
|
||||||
|
);
|
||||||
|
when(readinessRegistry.snapshot()).thenReturn(Map.of("alpha", interruptedStatus));
|
||||||
|
when(routingDataSource.testConnection("alpha")).thenReturn(true);
|
||||||
|
doThrow(new IllegalStateException("тестовый отказ публикации readiness"))
|
||||||
|
.doNothing()
|
||||||
|
.when(readinessRegistry).markMigrationSucceeded(any(TenantConfig.class));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(changed, beta)))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
|
||||||
|
service.synchronizeFromPersistedConfig(List.of(changed, beta));
|
||||||
|
|
||||||
|
verify(routingDataSource).swapTenant(any(TenantConfig.class), any());
|
||||||
|
verify(migrationService).migrate(candidate);
|
||||||
|
verify(retiredPoolService).retire(oldDataSource);
|
||||||
|
verify(readinessRegistry, never()).markMigrationFailed(any(TenantConfig.class));
|
||||||
|
verify(routingDataSource).testConnection("alpha");
|
||||||
|
verify(readinessRegistry).markConnectivity("alpha", true);
|
||||||
|
verify(readinessRegistry).clearConfigurationFailure();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void appliesMergedPersistedSnapshotWithoutWritingSecret() {
|
||||||
|
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "gamma-user", "gamma-password");
|
||||||
|
TenantConfig newBeta = config("Новый Beta", "beta", "jdbc:new-beta", "new-beta-user", "new-beta-password");
|
||||||
|
when(routingDataSource.snapshotTenantConfigs()).thenReturn(Map.of(
|
||||||
|
"alpha", oldAlpha,
|
||||||
|
"gamma", gamma
|
||||||
|
));
|
||||||
|
when(routingDataSource.removeTenantAtomically("gamma"))
|
||||||
|
.thenReturn(new TenantRoutingDataSource.TenantState("gamma", gamma, oldDataSource));
|
||||||
|
|
||||||
|
service.synchronizeFromPersistedConfig(List.of(newAlpha(), newBeta));
|
||||||
|
|
||||||
|
verify(routingDataSource, times(2)).prepareTenantDataSource(any(TenantConfig.class));
|
||||||
|
verify(routingDataSource, times(2)).swapTenant(any(TenantConfig.class), any());
|
||||||
|
verify(routingDataSource).removeTenantAtomically("gamma");
|
||||||
|
verify(tenantConfigStore, never()).upsertTenant(any());
|
||||||
|
verify(tenantConfigStore, never()).removeTenant(any());
|
||||||
|
verify(tenantConfigStore, never()).compensate(any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void rejectsDuplicateNormalizedDomainsBeforeRuntimeOrReadinessMutation() {
|
||||||
|
TenantConfig first = config("Alpha 1", "alpha", "jdbc:alpha-1", "user", "password");
|
||||||
|
TenantConfig second = config("Alpha 2", " ALPHA ", "jdbc:alpha-2", "user", "password");
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> service.synchronizeFromPersistedConfig(List.of(first, second)))
|
||||||
|
.isInstanceOf(IllegalArgumentException.class)
|
||||||
|
.hasMessage("Конфигурация содержит повторяющиеся домены тенантов");
|
||||||
|
|
||||||
|
verify(routingDataSource, never()).snapshotTenantConfigs();
|
||||||
|
verify(routingDataSource, never()).prepareTenantDataSource(any());
|
||||||
|
verify(readinessRegistry, never()).replaceDesired(any());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void failedPersistedMigrationLeavesTenantNotReadyForWatcherRetry() {
|
void failedPersistedMigrationLeavesTenantNotReadyForWatcherRetry() {
|
||||||
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
|
TenantConfig gamma = config("Gamma", "gamma", "jdbc:gamma", "user", "password");
|
||||||
@@ -376,6 +556,23 @@ class TenantLifecycleServiceTest {
|
|||||||
return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
return config("Новый Alpha", "alpha", "jdbc:new-alpha", "new-user", "new-password");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Stream<Arguments> changedPersistedAlphaConfigs() {
|
||||||
|
return Stream.of(
|
||||||
|
Arguments.of(new TenantConfig(
|
||||||
|
"Новое имя", "alpha", "jdbc:old-alpha", "old-user", "old-password"
|
||||||
|
)),
|
||||||
|
Arguments.of(new TenantConfig(
|
||||||
|
"Старый Alpha", "alpha", "jdbc:new-alpha", "old-user", "old-password"
|
||||||
|
)),
|
||||||
|
Arguments.of(new TenantConfig(
|
||||||
|
"Старый Alpha", "alpha", "jdbc:old-alpha", "new-user", "old-password"
|
||||||
|
)),
|
||||||
|
Arguments.of(new TenantConfig(
|
||||||
|
"Старый Alpha", "alpha", "jdbc:old-alpha", "old-user", "new-password"
|
||||||
|
))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
private TenantSecretUpdateReceipt upsertReceipt(TenantConfig tenant) {
|
private TenantSecretUpdateReceipt upsertReceipt(TenantConfig tenant) {
|
||||||
Map<String, TenantConfig> committed = new LinkedHashMap<>(oldSnapshot);
|
Map<String, TenantConfig> committed = new LinkedHashMap<>(oldSnapshot);
|
||||||
committed.put(tenant.getDomain(), tenant);
|
committed.put(tenant.getDomain(), tenant);
|
||||||
|
|||||||
27
docs/API.md
27
docs/API.md
@@ -1190,16 +1190,20 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
|
|||||||
него используется нормализованный `domain`.
|
него используется нормализованный `domain`.
|
||||||
|
|
||||||
**Логика:**
|
**Логика:**
|
||||||
1. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
|
1. До мутации разбирает и при необходимости полностью применяет текущую mounted-проекцию
|
||||||
2. Открывает соединение и явно проверяет его готовность.
|
tenant-конфигурации как безопасный baseline; ошибка подготовки возвращает `503`.
|
||||||
3. Выполняет Flyway-валидацию и миграции tenant-БД.
|
2. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
|
||||||
4. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
|
3. Открывает соединение и явно проверяет его готовность.
|
||||||
|
4. Выполняет Flyway-валидацию и миграции tenant-БД.
|
||||||
|
5. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
|
||||||
выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`.
|
выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`.
|
||||||
5. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
|
6. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
|
||||||
retry/backoff; неизменившаяся конфигурация не записывается повторно.
|
retry/backoff; неизменившаяся конфигурация не записывается повторно.
|
||||||
6. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
|
7. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
|
||||||
7. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
|
8. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
|
||||||
либо по истечении защитного таймаута.
|
либо по истечении защитного таймаута.
|
||||||
|
9. Возвращает внутри backend `TenantLifecycleMutationResult` с подтверждённой
|
||||||
|
`TenantSecretUpdateReceipt` для согласования mounted-проекции.
|
||||||
|
|
||||||
Backend соединяется с Kubernetes API только через проверенный service-account CA и
|
Backend соединяется с Kubernetes API только через проверенный service-account CA и
|
||||||
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
|
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
|
||||||
@@ -1236,6 +1240,15 @@ HTTP-статусы: `400` для некорректного payload и `503` д
|
|||||||
компенсация также допускается только для подтверждённой версии и не затирает более новое
|
компенсация также допускается только для подтверждённой версии и не затирает более новое
|
||||||
изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `503`.
|
изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `503`.
|
||||||
|
|
||||||
|
Для `POST` и `DELETE` baseline готовится до API-мутации под общим lifecycle monitor.
|
||||||
|
После успеха backend использует семантические `previousTenants` и `committedTenants` из
|
||||||
|
`TenantSecretUpdateReceipt`. Fence создаётся только для реального изменения общего Secret
|
||||||
|
(`persisted=true`, `changed=true`): известные старые и промежуточные снимки временно
|
||||||
|
откладываются, ожидаемый committed-снимок применяется полностью. Persisted no-op и локальная
|
||||||
|
операция fence не создают, а неизвестный merged snapshot другого pod синхронизируется сразу.
|
||||||
|
SHA-256 файла подтверждается только после полного успешного sync; ошибки повторяются с
|
||||||
|
экспоненциальной задержкой от 30 до 300 секунд.
|
||||||
|
|
||||||
### `POST /api/database/test`
|
### `POST /api/database/test`
|
||||||
|
|
||||||
Тест подключения к произвольной БД (без регистрации тенанта).
|
Тест подключения к произвольной БД (без регистрации тенанта).
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ sequenceDiagram
|
|||||||
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
|
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
|
||||||
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
|
||||||
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
|
||||||
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
|
| `TenantConfigWatcher` | Периодически перечитывает `tenants.json`, сравнивает полный нормализованный снимок и повторяет неудачную синхронизацию с ограниченным backoff |
|
||||||
| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации |
|
| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации |
|
||||||
| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA |
|
| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA |
|
||||||
| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе |
|
| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе |
|
||||||
@@ -128,10 +128,11 @@ sequenceDiagram
|
|||||||
|
|
||||||
### Жизненный цикл тенанта
|
### Жизненный цикл тенанта
|
||||||
|
|
||||||
1. **Добавление или обновление через API:** `TenantLifecycleService` нормализует конфигурацию,
|
1. **Добавление или обновление через API:** перед мутацией `TenantConfigWatcher` разбирает,
|
||||||
создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет
|
нормализует и при необходимости полностью применяет текущую mounted-проекцию как baseline.
|
||||||
Flyway. Затем `TenantConfigStore` читает актуальный Secret и применяет к нему только
|
Ошибка подготовки baseline прерывает API-операцию. Затем `TenantLifecycleService` создаёт
|
||||||
upsert запрошенного домена.
|
непубликуемый HikariCP candidate, проверяет реальное соединение, выполняет Flyway, а
|
||||||
|
`TenantConfigStore` читает актуальный Secret и применяет к нему только upsert домена.
|
||||||
2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего
|
2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего
|
||||||
`tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект
|
`tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект
|
||||||
условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное
|
условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное
|
||||||
@@ -140,6 +141,9 @@ sequenceDiagram
|
|||||||
не отправляет лишний `PUT`.
|
не отправляет лишний `PUT`.
|
||||||
3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным
|
3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным
|
||||||
чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`.
|
чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`.
|
||||||
|
Успешный lifecycle возвращает внутренний `TenantLifecycleMutationResult` с конфигурацией
|
||||||
|
и `TenantSecretUpdateReceipt`, содержащей признаки `persisted`/`changed`, а также
|
||||||
|
семантические снимки `previousTenants` и `committedTenants`.
|
||||||
4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret
|
4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret
|
||||||
закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена
|
закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена
|
||||||
только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей
|
только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей
|
||||||
@@ -151,7 +155,10 @@ sequenceDiagram
|
|||||||
а прежний Hikari pool закрывается после завершения активных подключений либо по истечении
|
а прежний Hikari pool закрывается после завершения активных подключений либо по истечении
|
||||||
настраиваемого grace timeout.
|
настраиваемого grace timeout.
|
||||||
6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный
|
6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный
|
||||||
`tenants.json` и применяет добавление/удаление под тем же локальным lifecycle-monitor.
|
`tenants.json` и сравнивает все поля нормализованного `TenantConfig`: `name`, `domain`,
|
||||||
|
`url`, `username` и `password`. Для нового или изменённого тенанта lifecycle без повторной
|
||||||
|
записи Secret выполняет prepare → проверку соединения → Flyway → атомарный swap; отсутствующие
|
||||||
|
домены удаляются под тем же локальным monitor.
|
||||||
7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только
|
7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только
|
||||||
удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant
|
удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant
|
||||||
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
|
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
|
||||||
@@ -171,13 +178,25 @@ monitor по-прежнему не считается межподовой бл
|
|||||||
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
|
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
|
||||||
завершает персистенцию безопасным отказом.
|
завершает персистенцию безопасным отказом.
|
||||||
|
|
||||||
`TenantConfigWatcher` сравнивает содержимое смонтированного из Secret `tenants.json` по
|
`TenantConfigWatcher` использует SHA-256 содержимого `tenants.json` как идентификатор файловой
|
||||||
SHA-256, а не по `String.hashCode()`, чтобы изменение файла не пропускалось из-за
|
ревизии, но решения о запаздывающих проекциях принимает по нормализованным семантическим
|
||||||
32-битной коллизии. На текущем этапе watcher активирует новые домены и удаляет отсутствующие,
|
снимкам `name`/`domain`/`url`/`username`/`password`. Хеш становится последним применённым
|
||||||
но ещё не заменяет подключение существующего домена при изменении его URL или credentials.
|
только после успешного разбора и полного lifecycle sync. Ошибка сохраняет прежний хеш,
|
||||||
Полная синхронизация такого изменения относится к проблеме №13; до её выполнения новое
|
снимает readiness и запускает экспоненциальные повторы через 30–300 секунд; другая неудачная
|
||||||
значение сбрасывает readiness этого tenant в состояние ожидания, а pod не должен считаться
|
файловая ревизия получает собственную попытку без ожидания backoff предыдущей.
|
||||||
готовым по старому подключению.
|
|
||||||
|
После API-операции snapshot fence устанавливается только если `TenantSecretUpdateReceipt`
|
||||||
|
подтверждает реальное общее изменение: `persisted=true` и `changed=true`. Снимок до записи
|
||||||
|
и ранее ожидавшиеся committed-снимки временно считаются deferred, а новый committed-снимок —
|
||||||
|
ожидаемым. Поэтому при быстрых мутациях H0 → H1 → H2 запаздывающие H0 и H1 не откатывают
|
||||||
|
runtime, а H2 полностью синхронизируется и снимает fence. Неизвестный объединённый снимок,
|
||||||
|
которого нет среди deferred-состояний, также немедленно проходит полный sync и не теряет
|
||||||
|
изменение другого pod. Локальная персистенция (`persisted=false`) и подтверждённый no-op
|
||||||
|
(`changed=false`) fence не создают.
|
||||||
|
|
||||||
|
Если ошибка readiness произошла уже после успешного swap, следующий watcher retry видит
|
||||||
|
совпадающую активную конфигурацию, проверяет действующее соединение и восстанавливает
|
||||||
|
readiness без повторной миграции и второго swap.
|
||||||
|
|
||||||
### Liveness и readiness
|
### Liveness и readiness
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,9 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
frontend/
|
frontend/
|
||||||
|
├── package.json # Dependency-free проверки frontend через node:test
|
||||||
|
├── tests/
|
||||||
|
│ └── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
|
||||||
├── index.html # 🔐 Страница авторизации (общая)
|
├── index.html # 🔐 Страница авторизации (общая)
|
||||||
├── script.js # Логика авторизации
|
├── script.js # Логика авторизации
|
||||||
├── style.css # Стили страницы авторизации
|
├── style.css # Стили страницы авторизации
|
||||||
@@ -34,6 +37,7 @@ frontend/
|
|||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
│ │ ├── main.js # Инициализация, маршрутизация, навигация
|
||||||
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
|
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
|
||||||
|
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
|
||||||
│ │ ├── utils.js # Утилиты
|
│ │ ├── utils.js # Утилиты
|
||||||
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
|
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
|
||||||
│ │ └── views/ # Модули представлений
|
│ │ └── views/ # Модули представлений
|
||||||
@@ -125,6 +129,7 @@ frontend/
|
|||||||
|
|
||||||
| Tab | Описание | API |
|
| Tab | Описание | API |
|
||||||
|-----|----------|-----|
|
|-----|----------|-----|
|
||||||
|
| `dashboard` | Сводные метрики и проверка конфликтов текущей недели с явным статусом полноты данных | `/api/departments`, `/api/classrooms`, `/api/groups`, `/api/users/teachers`, `/api/schedule/search`, `/api/admin/time-slots` |
|
||||||
| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
|
| `teacher-requests` | Очередь заявок кафедр на создание преподавателей с редактированием перед одобрением | `/api/teacher-requests`, `/api/departments` |
|
||||||
| `users` | CRUD пользователей | `/api/users` |
|
| `users` | CRUD пользователей | `/api/users` |
|
||||||
| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
|
| `groups` | CRUD групп, мультифильтр списка по формам обучения, настройка 0/2/3 подгрупп для лабораторных и назначения графиков | `/api/groups`, `/api/subgroups` |
|
||||||
@@ -139,6 +144,7 @@ frontend/
|
|||||||
|
|
||||||
### Особенности админских вкладок
|
### Особенности админских вкладок
|
||||||
|
|
||||||
|
- Вкладка `dashboard` формирует date-only значения из локальных компонентов даты, а текущую неделю — от отдельного объекта понедельника до `понедельник + 6 дней`. Расписания кафедр загружаются независимо через `Promise.allSettled`: `COMPLETE` означает ответы всех кафедр, `PARTIAL` — только части, `NOT_RUN` — отсутствие пригодных ответов или кафедр. Зелёная карточка «Конфликты расписания не обнаружены» разрешена только для `COMPLETE` без найденных конфликтов; частичный результат всегда остаётся предупреждением, а полный отказ показывается как «Проверка не выполнена». Технические причины отказов в DOM не выводятся.
|
||||||
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, а модалка редактирования использует широкую сетку полей без внутреннего пустого скролла. Блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. После назначения графика в таблице назначений сразу выводятся дисциплины графика, сгруппированные по номерам семестров. В селекты подгрупп и назначений попадают только группы с `active=true`.
|
||||||
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
|
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
|
||||||
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
|
||||||
@@ -208,6 +214,19 @@ export const api = {
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Frontend-тесты
|
||||||
|
|
||||||
|
Регрессионные проверки frontend используют встроенный `node:test` без сторонних npm-зависимостей (Node.js 18+). Тесты `dashboard-conflicts.test.mjs` покрывают локальные даты `Europe/Moscow` в интервале 00:00–03:00, первые дни месяца, переход года, полную/частичную/не выполненную загрузку и запрет ложного зелёного статуса.
|
||||||
|
|
||||||
|
Команды выполняются из каталога `frontend/`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test # unit-тесты дат, загрузки кафедр и UI-состояний
|
||||||
|
npm run check # синтаксис модулей дашборда + unit-тесты
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Аутентификация (Frontend)
|
## Аутентификация (Frontend)
|
||||||
|
|
||||||
### Страница входа (`/index.html`)
|
### Страница входа (`/index.html`)
|
||||||
|
|||||||
@@ -126,16 +126,24 @@ env:
|
|||||||
снимают readiness; резервная H2-БД не считается готовой tenant-БД.
|
снимают readiness; резервная H2-БД не считается готовой tenant-БД.
|
||||||
|
|
||||||
При добавлении тенанта через API:
|
При добавлении тенанта через API:
|
||||||
1. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
|
1. `TenantConfigWatcher` до мутации разбирает и при необходимости применяет текущую
|
||||||
2. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
|
mounted-проекцию как безопасный baseline; ошибка подготовки прерывает операцию
|
||||||
|
2. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
|
||||||
|
3. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
|
||||||
только upsert запрошенного домена
|
только upsert запрошенного домена
|
||||||
3. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
|
4. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
|
||||||
при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации
|
при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации
|
||||||
4. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
|
5. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
|
||||||
подтверждённой повторным чтением персистенции
|
подтверждённой повторным чтением персистенции
|
||||||
5. старый pool перестаёт принимать новые запросы и закрывается после drain/grace timeout
|
6. старый pool перестаёт принимать новые запросы и передаётся на закрытие после
|
||||||
6. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд
|
drain/grace timeout
|
||||||
применяет добавление или удаление домена
|
7. lifecycle возвращает `TenantLifecycleMutationResult` с `TenantSecretUpdateReceipt`;
|
||||||
|
semantic fence регистрируется только для фактически записанного изменения
|
||||||
|
(`persisted=true`, `changed=true`)
|
||||||
|
8. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд
|
||||||
|
синхронизирует полный нормализованный `TenantConfig`, включая изменения `name`, `domain`,
|
||||||
|
`url`, `username` и `password`; новый или изменённый pool проходит проверку соединения, Flyway
|
||||||
|
и атомарный swap без повторной записи Secret
|
||||||
|
|
||||||
Удаление использует тот же persistence-first порядок и применяет к актуальному Secret только
|
Удаление использует тот же persistence-first порядок и применяет к актуальному Secret только
|
||||||
remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами
|
remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами
|
||||||
@@ -147,10 +155,17 @@ backend восстанавливает прежний снимок только
|
|||||||
Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое
|
Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое
|
||||||
состояние принимается без квитанции, допускающей небезопасный автоматический откат.
|
состояние принимается без квитанции, допускающей небезопасный автоматический откат.
|
||||||
|
|
||||||
Watcher пока не заменяет подключение существующего домена при изменении URL или credentials.
|
Watcher подтверждает SHA-256 файловой ревизии только после полного успешного применения
|
||||||
Такое изменение сбрасывает readiness, но полное применение на другом pod относится к
|
снимка. При ошибке прежний применённый хеш сохраняется, readiness снимается, а синхронизация
|
||||||
проблеме №13. До её исправления нельзя считать watcher механизмом полной синхронизации всех
|
повторяется с экспоненциальной задержкой от 30 до 300 секунд.
|
||||||
полей `TenantConfig`.
|
|
||||||
|
Защита от задержки kubelet основана не на raw hash, а на полном нормализованном снимке.
|
||||||
|
Квитанция фактической persisted-мутации задаёт `previousTenants` и `committedTenants`:
|
||||||
|
previous и промежуточные committed-снимки временно deferred, последний committed ожидается.
|
||||||
|
Поэтому последовательность H0 → H1 → H2 не откатывается при доставке H0/H1, а H2 применяется.
|
||||||
|
Неизвестный merged snapshot, не совпадающий с deferred-состояниями, применяется сразу.
|
||||||
|
Persisted no-op и локальная операция fence не создают. Если sync оборвался после swap на
|
||||||
|
публикации readiness, retry проверяет уже активное соединение и не выполняет второй swap.
|
||||||
|
|
||||||
Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
|
Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
|
||||||
Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только
|
Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только
|
||||||
|
|||||||
@@ -246,6 +246,77 @@
|
|||||||
border-color: var(--button-secondary-border);
|
border-color: var(--button-secondary-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-check-state {
|
||||||
|
--check-accent: var(--text-secondary);
|
||||||
|
--check-surface: rgba(148, 163, 184, 0.08);
|
||||||
|
--check-border: rgba(148, 163, 184, 0.22);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 0.9rem;
|
||||||
|
align-items: start;
|
||||||
|
padding: 1rem 1.1rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: linear-gradient(115deg, var(--check-surface), transparent 72%);
|
||||||
|
border: 1px solid var(--check-border);
|
||||||
|
border-left: 4px solid var(--check-accent);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-check-state--success {
|
||||||
|
--check-accent: var(--success);
|
||||||
|
--check-surface: rgba(16, 185, 129, 0.1);
|
||||||
|
--check-border: rgba(16, 185, 129, 0.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-check-state--warning {
|
||||||
|
--check-accent: var(--warning);
|
||||||
|
--check-surface: rgba(245, 158, 11, 0.1);
|
||||||
|
--check-border: rgba(245, 158, 11, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-check-state--error {
|
||||||
|
--check-accent: var(--error);
|
||||||
|
--check-surface: rgba(239, 68, 68, 0.1);
|
||||||
|
--check-border: rgba(239, 68, 68, 0.26);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-check-state__icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--check-accent);
|
||||||
|
background: var(--check-surface);
|
||||||
|
border: 1px solid var(--check-border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-check-state__body {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-check-state__body strong {
|
||||||
|
color: var(--check-accent);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-check-state__body > span {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.86rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-check-state__body small {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.76rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1200px) {
|
@media (max-width: 1200px) {
|
||||||
.dashboard-metrics-grid {
|
.dashboard-metrics-grid {
|
||||||
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
grid-template-columns: repeat(2, minmax(180px, 1fr));
|
||||||
|
|||||||
214
frontend/admin/js/dashboard-conflicts.js
Normal file
214
frontend/admin/js/dashboard-conflicts.js
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
export const CONFLICT_CHECK_STATE = Object.freeze({
|
||||||
|
COMPLETE: 'COMPLETE',
|
||||||
|
PARTIAL: 'PARTIAL',
|
||||||
|
NOT_RUN: 'NOT_RUN'
|
||||||
|
});
|
||||||
|
|
||||||
|
export const CONFLICT_CHECK_REASON = Object.freeze({
|
||||||
|
NO_DEPARTMENTS: 'NO_DEPARTMENTS',
|
||||||
|
ALL_REQUESTS_FAILED: 'ALL_REQUESTS_FAILED'
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Формирует date-only строку из локальных компонентов без UTC-конвертации. */
|
||||||
|
export function formatLocalDate(date) {
|
||||||
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||||
|
throw new TypeError('Для форматирования требуется корректная дата');
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
String(date.getFullYear()).padStart(4, '0'),
|
||||||
|
String(date.getMonth() + 1).padStart(2, '0'),
|
||||||
|
String(date.getDate()).padStart(2, '0')
|
||||||
|
].join('-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает локальные границы недели с понедельника по воскресенье включительно. */
|
||||||
|
export function currentWeekDateRange(referenceDate = new Date()) {
|
||||||
|
if (!(referenceDate instanceof Date) || Number.isNaN(referenceDate.getTime())) {
|
||||||
|
throw new TypeError('Для расчёта недели требуется корректная дата');
|
||||||
|
}
|
||||||
|
|
||||||
|
const localDate = new Date(
|
||||||
|
referenceDate.getFullYear(),
|
||||||
|
referenceDate.getMonth(),
|
||||||
|
referenceDate.getDate()
|
||||||
|
);
|
||||||
|
const dayOfWeek = localDate.getDay();
|
||||||
|
const daysFromMonday = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||||
|
const monday = addLocalDays(localDate, daysFromMonday);
|
||||||
|
const sunday = addLocalDays(monday, 6);
|
||||||
|
|
||||||
|
return {
|
||||||
|
startDate: formatLocalDate(monday),
|
||||||
|
endDate: formatLocalDate(sunday)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загружает расписания независимо и сохраняет факт каждого отказа.
|
||||||
|
* Технические причины намеренно не возвращаются в UI-модель.
|
||||||
|
*/
|
||||||
|
export async function loadDepartmentSchedules(departments, loadSchedule) {
|
||||||
|
if (!Array.isArray(departments)) {
|
||||||
|
throw new TypeError('Список кафедр должен быть массивом');
|
||||||
|
}
|
||||||
|
if (typeof loadSchedule !== 'function') {
|
||||||
|
throw new TypeError('Функция загрузки расписания обязательна');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (departments.length === 0) {
|
||||||
|
return {
|
||||||
|
state: CONFLICT_CHECK_STATE.NOT_RUN,
|
||||||
|
reason: CONFLICT_CHECK_REASON.NO_DEPARTMENTS,
|
||||||
|
lessons: [],
|
||||||
|
totalDepartmentCount: 0,
|
||||||
|
checkedDepartmentCount: 0,
|
||||||
|
failedDepartments: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(departments.map(async department => {
|
||||||
|
const lessons = await loadSchedule(department);
|
||||||
|
if (!Array.isArray(lessons)) {
|
||||||
|
throw new TypeError('API расписания вернул некорректный формат данных');
|
||||||
|
}
|
||||||
|
return lessons;
|
||||||
|
}));
|
||||||
|
|
||||||
|
const lessons = [];
|
||||||
|
const failedDepartments = [];
|
||||||
|
let checkedDepartmentCount = 0;
|
||||||
|
|
||||||
|
results.forEach((result, index) => {
|
||||||
|
if (result.status === 'fulfilled') {
|
||||||
|
checkedDepartmentCount += 1;
|
||||||
|
lessons.push(...result.value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failedDepartments.push(publicDepartment(departments[index], index));
|
||||||
|
});
|
||||||
|
|
||||||
|
const state = checkedDepartmentCount === 0
|
||||||
|
? CONFLICT_CHECK_STATE.NOT_RUN
|
||||||
|
: failedDepartments.length > 0
|
||||||
|
? CONFLICT_CHECK_STATE.PARTIAL
|
||||||
|
: CONFLICT_CHECK_STATE.COMPLETE;
|
||||||
|
|
||||||
|
return {
|
||||||
|
state,
|
||||||
|
reason: state === CONFLICT_CHECK_STATE.NOT_RUN
|
||||||
|
? CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED
|
||||||
|
: null,
|
||||||
|
lessons,
|
||||||
|
totalDepartmentCount: departments.length,
|
||||||
|
checkedDepartmentCount,
|
||||||
|
failedDepartments
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Возвращает безопасную UI-модель итогового состояния проверки. */
|
||||||
|
export function conflictCheckPresentation(scheduleLoad, conflictCount, range) {
|
||||||
|
assertPresentationInput(scheduleLoad, conflictCount, range);
|
||||||
|
|
||||||
|
const count = conflictCount;
|
||||||
|
const startDate = range.startDate;
|
||||||
|
const endDate = range.endDate;
|
||||||
|
const rangeLabel = `${startDate} — ${endDate}`;
|
||||||
|
|
||||||
|
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.NOT_RUN) {
|
||||||
|
const noDepartments = scheduleLoad.reason === CONFLICT_CHECK_REASON.NO_DEPARTMENTS;
|
||||||
|
return {
|
||||||
|
tone: 'error',
|
||||||
|
title: 'Проверка не выполнена',
|
||||||
|
description: noDepartments
|
||||||
|
? 'Нет кафедр, расписание которых можно проверить.'
|
||||||
|
: 'Не удалось получить расписание ни одной кафедры. Подтвердить отсутствие конфликтов нельзя.',
|
||||||
|
meta: `Период: ${rangeLabel}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.PARTIAL) {
|
||||||
|
return {
|
||||||
|
tone: 'warning',
|
||||||
|
title: 'Проверка выполнена частично',
|
||||||
|
description: count > 0
|
||||||
|
? 'Показаны найденные конфликты, но подтвердить полноту результата нельзя.'
|
||||||
|
: 'В доступной части расписания конфликтов не найдено, но подтвердить их отсутствие во всём расписании нельзя.',
|
||||||
|
meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из `
|
||||||
|
+ `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleLoad?.state === CONFLICT_CHECK_STATE.COMPLETE && count === 0) {
|
||||||
|
return {
|
||||||
|
tone: 'success',
|
||||||
|
title: 'Конфликты расписания не обнаружены',
|
||||||
|
description: 'Проверены накладки преподавателей и аудиторий, а также вместимость помещений.',
|
||||||
|
meta: `Проверено кафедр: ${scheduleLoad.checkedDepartmentCount} из `
|
||||||
|
+ `${scheduleLoad.totalDepartmentCount} · Период: ${rangeLabel}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addLocalDays(date, days) {
|
||||||
|
const result = new Date(date);
|
||||||
|
result.setDate(result.getDate() + days);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertPresentationInput(scheduleLoad, conflictCount, range) {
|
||||||
|
if (!Number.isInteger(conflictCount) || conflictCount < 0) {
|
||||||
|
throw new TypeError('Количество конфликтов должно быть целым неотрицательным числом');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isValidDateOnly(range?.startDate) || !isValidDateOnly(range?.endDate)
|
||||||
|
|| range.startDate > range.endDate) {
|
||||||
|
throw new TypeError('Для результата проверки требуется корректный локальный диапазон дат');
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = scheduleLoad?.totalDepartmentCount;
|
||||||
|
const checked = scheduleLoad?.checkedDepartmentCount;
|
||||||
|
if (!Number.isInteger(total) || total < 0
|
||||||
|
|| !Number.isInteger(checked) || checked < 0 || checked > total) {
|
||||||
|
throw new TypeError('Количество проверенных кафедр не соответствует результату проверки');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleLoad.state === CONFLICT_CHECK_STATE.COMPLETE) {
|
||||||
|
if (total === 0 || checked !== total) {
|
||||||
|
throw new TypeError('Полная проверка должна охватывать все кафедры');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleLoad.state === CONFLICT_CHECK_STATE.PARTIAL) {
|
||||||
|
if (checked === 0 || checked === total) {
|
||||||
|
throw new TypeError('Частичная проверка должна охватывать только часть кафедр');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN && checked === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TypeError('Неизвестное состояние проверки конфликтов');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidDateOnly(value) {
|
||||||
|
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value || '');
|
||||||
|
if (!match) return false;
|
||||||
|
|
||||||
|
const [, year, month, day] = match.map(Number);
|
||||||
|
const date = new Date(year, month - 1, day);
|
||||||
|
return date.getFullYear() === year
|
||||||
|
&& date.getMonth() === month - 1
|
||||||
|
&& date.getDate() === day;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicDepartment(department, index) {
|
||||||
|
return {
|
||||||
|
id: department?.id ?? null,
|
||||||
|
name: department?.departmentName || department?.name || `Кафедра ${index + 1}`
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { escapeHtml, showAlert, hideAlert } from '../utils.js';
|
import { escapeHtml } from '../utils.js';
|
||||||
|
import {
|
||||||
|
CONFLICT_CHECK_STATE,
|
||||||
|
conflictCheckPresentation,
|
||||||
|
currentWeekDateRange,
|
||||||
|
formatLocalDate,
|
||||||
|
loadDepartmentSchedules
|
||||||
|
} from '../dashboard-conflicts.js';
|
||||||
|
|
||||||
export async function initDashboard() {
|
export async function initDashboard() {
|
||||||
// DOM Элементы
|
// DOM Элементы
|
||||||
@@ -20,16 +27,27 @@ export async function initDashboard() {
|
|||||||
let teachers = [];
|
let teachers = [];
|
||||||
let todayLessons = [];
|
let todayLessons = [];
|
||||||
let timeSlots = [];
|
let timeSlots = [];
|
||||||
|
let dashboardDataReady = false;
|
||||||
|
|
||||||
btnRecheck.addEventListener('click', async () => {
|
btnRecheck.addEventListener('click', async () => {
|
||||||
btnRecheck.disabled = true;
|
btnRecheck.disabled = true;
|
||||||
btnRecheck.textContent = '...';
|
btnRecheck.setAttribute('aria-busy', 'true');
|
||||||
|
btnRecheck.textContent = 'Проверка…';
|
||||||
|
try {
|
||||||
|
if (dashboardDataReady) {
|
||||||
await checkConflicts();
|
await checkConflicts();
|
||||||
|
} else {
|
||||||
|
await loadData();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
btnRecheck.textContent = 'Перепроверить';
|
btnRecheck.textContent = 'Перепроверить';
|
||||||
btnRecheck.disabled = false;
|
btnRecheck.disabled = false;
|
||||||
|
btnRecheck.removeAttribute('aria-busy');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function loadData() {
|
async function loadData() {
|
||||||
|
dashboardDataReady = false;
|
||||||
try {
|
try {
|
||||||
// Загружаем базовые справочники
|
// Загружаем базовые справочники
|
||||||
[departments, classrooms, groups, teachers] = await Promise.all([
|
[departments, classrooms, groups, teachers] = await Promise.all([
|
||||||
@@ -45,7 +63,7 @@ export async function initDashboard() {
|
|||||||
metricStudents.textContent = totalStudents;
|
metricStudents.textContent = totalStudents;
|
||||||
metricTeachers.textContent = teachers.length;
|
metricTeachers.textContent = teachers.length;
|
||||||
|
|
||||||
const todayStr = new Date().toISOString().split('T')[0];
|
const todayStr = formatLocalDate(new Date());
|
||||||
|
|
||||||
// Загружаем временные слоты и сегодняшнее расписание
|
// Загружаем временные слоты и сегодняшнее расписание
|
||||||
const lessonsPromises = departments.map(d =>
|
const lessonsPromises = departments.map(d =>
|
||||||
@@ -75,10 +93,18 @@ export async function initDashboard() {
|
|||||||
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
|
metricClassrooms.textContent = `${classPercent}% (${occupiedClassroomIds.size}/${activeClassrooms.length})`;
|
||||||
|
|
||||||
updateRealtimeMonitoring();
|
updateRealtimeMonitoring();
|
||||||
|
dashboardDataReady = true;
|
||||||
await checkConflicts();
|
await checkConflicts();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Dashboard data load error", e);
|
dashboardDataReady = false;
|
||||||
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка загрузки панели: ${escapeHtml(e.message)}</div>`;
|
console.error('Не удалось загрузить данные панели', {
|
||||||
|
errorType: e?.name || 'Error'
|
||||||
|
});
|
||||||
|
conflictsContainer.innerHTML = renderCheckStateCard({
|
||||||
|
tone: 'error',
|
||||||
|
title: 'Проверка не выполнена',
|
||||||
|
description: 'Не удалось загрузить данные панели. Нажмите «Перепроверить», чтобы повторить попытку.'
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,25 +173,25 @@ export async function initDashboard() {
|
|||||||
|
|
||||||
// --- Анализ конфликтов (Red Zone) ---
|
// --- Анализ конфликтов (Red Zone) ---
|
||||||
async function checkConflicts() {
|
async function checkConflicts() {
|
||||||
|
conflictsContainer.setAttribute('aria-busy', 'true');
|
||||||
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
|
conflictsContainer.innerHTML = '<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>';
|
||||||
|
|
||||||
// Вычисляем даты начала и конца текущей недели
|
|
||||||
const curr = new Date();
|
|
||||||
const day = curr.getDay();
|
|
||||||
const diff = curr.getDate() - day + (day === 0 ? -6 : 1); // Понедельник
|
|
||||||
const monday = new Date(curr.setDate(diff));
|
|
||||||
const sunday = new Date(curr.setDate(diff + 6));
|
|
||||||
|
|
||||||
const mondayStr = monday.toISOString().split('T')[0];
|
|
||||||
const sundayStr = sunday.toISOString().split('T')[0];
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Загружаем расписание на всю неделю по кафедрам
|
const { startDate, endDate } = currentWeekDateRange(new Date());
|
||||||
const weekLessonsPromises = departments.map(d =>
|
const scheduleLoad = await loadDepartmentSchedules(
|
||||||
api.get(`/api/schedule/search?departmentId=${d.id}&startDate=${mondayStr}&endDate=${sundayStr}`).catch(() => [])
|
departments,
|
||||||
|
department => api.get(
|
||||||
|
`/api/schedule/search?departmentId=${department.id}`
|
||||||
|
+ `&startDate=${startDate}&endDate=${endDate}`
|
||||||
|
)
|
||||||
);
|
);
|
||||||
const weekLessonsArrays = await Promise.all(weekLessonsPromises);
|
|
||||||
const weekLessons = weekLessonsArrays.flat();
|
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
|
||||||
|
renderConflicts([], scheduleLoad, { startDate, endDate });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const weekLessons = scheduleLoad.lessons;
|
||||||
|
|
||||||
const conflicts = [];
|
const conflicts = [];
|
||||||
|
|
||||||
@@ -264,31 +290,40 @@ export async function initDashboard() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
renderConflicts(conflicts);
|
renderConflicts(conflicts, scheduleLoad, { startDate, endDate });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("Conflicts check error", e);
|
console.error('Не удалось выполнить проверку конфликтов', {
|
||||||
conflictsContainer.innerHTML = `<div class="form-alert error" style="display:block">Ошибка при анализе конфликтов: ${escapeHtml(e.message)}</div>`;
|
errorType: e?.name || 'Error'
|
||||||
|
});
|
||||||
|
conflictsContainer.innerHTML = renderCheckStateCard({
|
||||||
|
tone: 'error',
|
||||||
|
title: 'Проверка не выполнена',
|
||||||
|
description: 'Во время анализа произошла ошибка. Повторите проверку позже.'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
conflictsContainer.removeAttribute('aria-busy');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderConflicts(conflicts) {
|
function renderConflicts(conflicts, scheduleLoad, range) {
|
||||||
if (!conflicts.length) {
|
const stateCards = [];
|
||||||
conflictsContainer.innerHTML = `
|
const presentation = conflictCheckPresentation(scheduleLoad, conflicts.length, range);
|
||||||
<div style="background: rgba(16, 185, 129, 0.08); border: 1px solid rgba(16, 185, 129, 0.2); border-radius: 8px; padding: 1.25rem; display: flex; align-items: center; gap: 1rem; color: #10b981;">
|
|
||||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
if (scheduleLoad.state === CONFLICT_CHECK_STATE.NOT_RUN) {
|
||||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
conflictsContainer.innerHTML = renderCheckStateCard(presentation);
|
||||||
<polyline points="22 4 12 14.01 9 11.01" />
|
|
||||||
</svg>
|
|
||||||
<div>
|
|
||||||
<strong style="display: block; font-size: 0.95rem; margin-bottom: 0.15rem;">Конфликты расписания не обнаружены!</strong>
|
|
||||||
<span style="font-size: 0.85rem; opacity: 0.9;">Все правила расписания на текущую неделю согласованы, накладки преподавателей/аудиторий и переполнения отсутствуют.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
conflictsContainer.innerHTML = conflicts.map(c => {
|
if (presentation) {
|
||||||
|
stateCards.push(renderCheckStateCard(presentation));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!conflicts.length) {
|
||||||
|
conflictsContainer.innerHTML = stateCards.join('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflictCards = conflicts.map(c => {
|
||||||
const isError = c.severity === 'error';
|
const isError = c.severity === 'error';
|
||||||
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
|
const borderCol = isError ? 'var(--error, #ef4444)' : '#f59e0b';
|
||||||
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
|
const bgCol = isError ? 'rgba(239, 68, 68, 0.05)' : 'rgba(245, 158, 11, 0.05)';
|
||||||
@@ -306,6 +341,27 @@ export async function initDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
conflictsContainer.innerHTML = stateCards.join('') + conflictCards;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCheckStateCard({ tone, title, description, meta = '' }) {
|
||||||
|
const icon = tone === 'success'
|
||||||
|
? '<path d="M20 6 9 17l-5-5" />'
|
||||||
|
: tone === 'warning'
|
||||||
|
? '<path d="M12 9v4m0 4h.01" /><path d="M10.3 3.9 1.8 18a2 2 0 0 0 1.7 3h17a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z" />'
|
||||||
|
: '<circle cx="12" cy="12" r="9" /><path d="M12 8v5m0 3h.01" />';
|
||||||
|
return `
|
||||||
|
<section class="dashboard-check-state dashboard-check-state--${tone}">
|
||||||
|
<span class="dashboard-check-state__icon" aria-hidden="true">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">${icon}</svg>
|
||||||
|
</span>
|
||||||
|
<span class="dashboard-check-state__body">
|
||||||
|
<strong>${escapeHtml(title)}</strong>
|
||||||
|
<span>${escapeHtml(description)}</span>
|
||||||
|
${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
|
||||||
|
</span>
|
||||||
|
</section>
|
||||||
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Загружаем данные при старте
|
// Загружаем данные при старте
|
||||||
|
|||||||
@@ -135,10 +135,10 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
|
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts">Перепроверить</button>
|
<button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts" type="button">Перепроверить</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="conflicts-container" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
|
<div id="conflicts-container" role="status" aria-live="polite" aria-atomic="true" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">
|
||||||
<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>
|
<div class="loading-row" style="text-align: center; padding: 2rem; color: var(--text-secondary);">Анализ расписания и выявление конфликтов...</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
12
frontend/package.json
Normal file
12
frontend/package.json
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "magistr-frontend-checks",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"check": "node --check admin/js/dashboard-conflicts.js && node --check admin/js/views/dashboard.js && node --test tests/*.test.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
151
frontend/tests/dashboard-conflicts.test.mjs
Normal file
151
frontend/tests/dashboard-conflicts.test.mjs
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CONFLICT_CHECK_REASON,
|
||||||
|
CONFLICT_CHECK_STATE,
|
||||||
|
conflictCheckPresentation,
|
||||||
|
currentWeekDateRange,
|
||||||
|
formatLocalDate,
|
||||||
|
loadDepartmentSchedules
|
||||||
|
} from '../admin/js/dashboard-conflicts.js';
|
||||||
|
|
||||||
|
process.env.TZ = 'Europe/Moscow';
|
||||||
|
|
||||||
|
test('неделя для 2026-07-02 охватывает 2026-06-29…2026-07-05', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
currentWeekDateRange(new Date('2026-07-01T21:15:00Z')),
|
||||||
|
{ startDate: '2026-06-29', endDate: '2026-07-05' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('неделя корректно пересекает границу года', () => {
|
||||||
|
assert.deepEqual(
|
||||||
|
currentWeekDateRange(new Date(2026, 0, 1, 8, 30, 0)),
|
||||||
|
{ startDate: '2025-12-29', endDate: '2026-01-04' }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('date-only формируется из локальных компонентов', () => {
|
||||||
|
assert.equal(formatLocalDate(new Date('2026-01-01T21:15:00Z')), '2026-01-02');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('полный успех разрешает достоверный вывод по всем кафедрам', async () => {
|
||||||
|
const result = await loadDepartmentSchedules(
|
||||||
|
[{ id: 1 }, { id: 2 }],
|
||||||
|
async department => [{ departmentId: department.id }]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.state, CONFLICT_CHECK_STATE.COMPLETE);
|
||||||
|
assert.equal(result.checkedDepartmentCount, 2);
|
||||||
|
assert.equal(result.totalDepartmentCount, 2);
|
||||||
|
assert.deepEqual(result.lessons, [{ departmentId: 1 }, { departmentId: 2 }]);
|
||||||
|
assert.deepEqual(result.failedDepartments, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('единичный отказ даёт частичный результат, а не пустой успешный ответ', async () => {
|
||||||
|
const departments = [
|
||||||
|
{ id: 1, departmentName: 'Физика' },
|
||||||
|
{ id: 2, departmentName: 'Математика' }
|
||||||
|
];
|
||||||
|
const result = await loadDepartmentSchedules(departments, async department => {
|
||||||
|
if (department.id === 2) {
|
||||||
|
throw new Error('тестовая техническая причина');
|
||||||
|
}
|
||||||
|
return [{ departmentId: department.id }];
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.state, CONFLICT_CHECK_STATE.PARTIAL);
|
||||||
|
assert.equal(result.checkedDepartmentCount, 1);
|
||||||
|
assert.deepEqual(result.lessons, [{ departmentId: 1 }]);
|
||||||
|
assert.deepEqual(result.failedDepartments, [{ id: 2, name: 'Математика' }]);
|
||||||
|
assert.equal('reason' in result.failedDepartments[0], false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('отказ всех запросов означает, что проверка не выполнена', async () => {
|
||||||
|
const result = await loadDepartmentSchedules(
|
||||||
|
[{ id: 1 }, { id: 2 }],
|
||||||
|
async () => { throw new Error('тестовый отказ'); }
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(result.state, CONFLICT_CHECK_STATE.NOT_RUN);
|
||||||
|
assert.equal(result.reason, CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED);
|
||||||
|
assert.equal(result.checkedDepartmentCount, 0);
|
||||||
|
assert.deepEqual(result.lessons, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('отсутствие кафедр не изображается как зелёная успешная проверка', async () => {
|
||||||
|
const result = await loadDepartmentSchedules([], async () => []);
|
||||||
|
|
||||||
|
assert.equal(result.state, CONFLICT_CHECK_STATE.NOT_RUN);
|
||||||
|
assert.equal(result.reason, CONFLICT_CHECK_REASON.NO_DEPARTMENTS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('зелёная карточка разрешена только для полной проверки без конфликтов', () => {
|
||||||
|
const complete = {
|
||||||
|
state: CONFLICT_CHECK_STATE.COMPLETE,
|
||||||
|
checkedDepartmentCount: 2,
|
||||||
|
totalDepartmentCount: 2
|
||||||
|
};
|
||||||
|
const range = { startDate: '2026-06-29', endDate: '2026-07-05' };
|
||||||
|
|
||||||
|
assert.equal(conflictCheckPresentation(complete, 0, range).tone, 'success');
|
||||||
|
assert.equal(conflictCheckPresentation(complete, 1, range), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('некорректный контракт результата не может породить зелёный статус', () => {
|
||||||
|
const range = { startDate: '2026-06-29', endDate: '2026-07-05' };
|
||||||
|
|
||||||
|
assert.throws(
|
||||||
|
() => conflictCheckPresentation({
|
||||||
|
state: CONFLICT_CHECK_STATE.COMPLETE,
|
||||||
|
checkedDepartmentCount: 1,
|
||||||
|
totalDepartmentCount: 2
|
||||||
|
}, 0, range),
|
||||||
|
/Полная проверка должна охватывать все кафедры/
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => conflictCheckPresentation({
|
||||||
|
state: CONFLICT_CHECK_STATE.COMPLETE,
|
||||||
|
checkedDepartmentCount: 2,
|
||||||
|
totalDepartmentCount: 2
|
||||||
|
}, Number.NaN, range),
|
||||||
|
/Количество конфликтов должно быть целым неотрицательным числом/
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('частичная проверка без конфликтов остаётся предупреждением', () => {
|
||||||
|
const presentation = conflictCheckPresentation({
|
||||||
|
state: CONFLICT_CHECK_STATE.PARTIAL,
|
||||||
|
checkedDepartmentCount: 1,
|
||||||
|
totalDepartmentCount: 2
|
||||||
|
}, 0, { startDate: '2026-06-29', endDate: '2026-07-05' });
|
||||||
|
|
||||||
|
assert.equal(presentation.tone, 'warning');
|
||||||
|
assert.equal(presentation.title, 'Проверка выполнена частично');
|
||||||
|
assert.match(presentation.description, /во всём расписании нельзя/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('частичная проверка с конфликтами предупреждает о неполноте результата', () => {
|
||||||
|
const presentation = conflictCheckPresentation({
|
||||||
|
state: CONFLICT_CHECK_STATE.PARTIAL,
|
||||||
|
checkedDepartmentCount: 1,
|
||||||
|
totalDepartmentCount: 3
|
||||||
|
}, 2, { startDate: '2026-06-29', endDate: '2026-07-05' });
|
||||||
|
|
||||||
|
assert.equal(presentation.tone, 'warning');
|
||||||
|
assert.match(presentation.description, /Показаны найденные конфликты/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('полный отказ формирует явное состояние «Проверка не выполнена»', () => {
|
||||||
|
const presentation = conflictCheckPresentation({
|
||||||
|
state: CONFLICT_CHECK_STATE.NOT_RUN,
|
||||||
|
reason: CONFLICT_CHECK_REASON.ALL_REQUESTS_FAILED,
|
||||||
|
checkedDepartmentCount: 0,
|
||||||
|
totalDepartmentCount: 2
|
||||||
|
}, 0, { startDate: '2026-06-29', endDate: '2026-07-05' });
|
||||||
|
|
||||||
|
assert.equal(presentation.tone, 'error');
|
||||||
|
assert.equal(presentation.title, 'Проверка не выполнена');
|
||||||
|
assert.match(presentation.description, /Подтвердить отсутствие конфликтов нельзя/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user