Задача Егора

This commit is contained in:
dipatrik10
2026-07-16 22:56:43 +03:00
parent 85f61436b6
commit 3f685d3e22
33 changed files with 3777 additions and 279 deletions

View File

@@ -1,6 +1,7 @@
# 🔌 REST API
Все эндпоинты имеют префикс `/api/`. Ответы возвращаются в формате JSON.
Все прикладные эндпоинты имеют префикс `/api/`. Служебные проверки Kubernetes доступны
под `/actuator/health/`. Ответы возвращаются в формате JSON.
Необработанные ошибки проходят через единый `GlobalExceptionHandler`. Для `400`, `404` и `500` используется общий JSON-формат:
@@ -18,6 +19,53 @@
---
## Служебные проверки состояния
Эти endpoints предназначены для Kubernetes kubelet, не требуют bearer-токен и не зависят
от tenant-домена в заголовке `Host`. Из Actuator наружу опубликован только `health`, а
состав компонентов, домены, JDBC URL и credentials в ответах скрыты.
### `GET /actuator/health/liveness`
Проверяет только жизнеспособность процесса. Недоступность tenant-БД не меняет liveness и
не должна создавать цикл перезапусков pod.
**Ответ работающего процесса (200):**
```json
{
"status": "UP"
}
```
### `GET /actuator/health/readiness`
Разрешает направлять трафик в pod только после успешных миграций и свежей успешной
проверки соединения со всеми обязательными tenant-БД. Пустая конфигурация, H2-заглушка,
ошибка чтения tenant-конфигурации, незавершённая или неуспешная миграция, недоступное либо
просроченное соединение делают pod неготовым.
**Готов (200):**
```json
{
"status": "UP"
}
```
**Не готов (503):**
```json
{
"status": "DOWN"
}
```
`UP` и `DOWN` — стандартные машинные значения протокола Spring Boot Actuator, а не
пользовательские сообщения интерфейса.
---
## Аутентификация
### `POST /api/auth/login`
@@ -1090,6 +1138,8 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
## Управление тенантами (Базы данных)
Все endpoints раздела доступны только пользователю с ролью `ADMIN`.
### `GET /api/database/status`
Статус текущего подключения (определяется по домену запроса).
@@ -1107,11 +1157,23 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
### `GET /api/database/tenants`
Список всех тенантов.
Список всех тенантов. Пароль в ответ не включается.
```json
[
{
"name": "СВФУ",
"domain": "swsu",
"url": "jdbc:postgresql://db-host:5432/swsu_db",
"username": "dbuser",
"connected": true
}
]
```
### `POST /api/database/tenants`
Добавление нового тенанта.
Создание нового или обновление существующего тенанта по `domain`.
```json
{
@@ -1123,19 +1185,56 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
}
```
`domain` приводится к нижнему регистру и должен быть одной DNS-меткой длиной от 1 до
63 символов. `url` обязателен и должен начинаться с `jdbc:`. Если `name` пуст, вместо
него используется нормализованный `domain`.
**Логика:**
1. Создаёт HikariCP пул для нового тенанта
2. Запускает Flyway миграции на его БД
3. Обновляет внешний Kubernetes Secret `tenants-secret`
1. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
2. Открывает соединение и явно проверяет его готовность.
3. Выполняет Flyway-валидацию и миграции tenant-БД.
4. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`.
5. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
retry/backoff; неизменившаяся конфигурация не записывается повторно.
6. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
7. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
либо по истечении защитного таймаута.
Backend соединяется с Kubernetes API только через проверенный service-account CA и
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
не возвращается как успешная. Значения credentials никогда не включаются в ответ или лог
Kubernetes updater.
При ошибке credentials, проверки соединения, Flyway или сохранения Secret временный pool
закрывается, а прежнее подключение продолжает обслуживать запросы. Если Kubernetes
подтвердил запись, но последующая локальная активация завершилась ошибкой, backend
восстанавливает прежний снимок только пока Secret сохраняет `resourceVersion` этой записи.
Более новое изменение другого pod не перезаписывается. Неопределённый сетевой результат
сначала сверяется повторным чтением и не создаёт основания для небезопасной компенсации.
Ошибка lifecycle возвращается как безопасный русский ответ без JDBC/Flyway details:
```json
{
"success": false,
"message": "Не удалось выполнить миграции базы данных тенанта"
}
```
HTTP-статусы: `400` для некорректного payload и `503` для ошибки подключения, миграции,
персистенции или активации.
Вне Kubernetes обновление Secret пропускается, поэтому добавленный через API tenant живёт
только до перезапуска процесса. Для постоянной локальной конфигурации используется
неотслеживаемый файл `backend/tenants.json`.
### `DELETE /api/database/tenants/{domain}`
Удаление тенанта.
Удаление тенанта. Backend читает актуальный Secret, удаляет только запрошенный `domain` и
выполняет условный `PUT` по `resourceVersion`, затем атомарно исключает tenant из локальной
маршрутизации и передаёт pool на отложенное закрытие. При отказе локального удаления
компенсация также допускается только для подтверждённой версии и не затирает более новое
изменение другого pod. Неизвестный `domain` возвращает `404`, lifecycle-ошибка — `503`.
### `POST /api/database/test`
@@ -1166,5 +1265,8 @@ Kubernetes updater.
| `200` | Успех |
| `400` | Ошибка валидации или некорректные параметры запроса |
| `401` | Неверные учётные данные |
| `403` | Недостаточно прав для операции |
| `404` | Ресурс / тенант не найден |
| `409` | Конфликт бизнес-инвариантов или конкурентного изменения |
| `500` | Внутренняя ошибка сервера |
| `503` | Внешняя БД или обязательная инфраструктура временно недоступна |

View File

@@ -78,11 +78,18 @@ sequenceDiagram
|-------|-----------|
| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант |
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
| `TenantRoutingDataSource` | Наследует `AbstractRoutingDataSource`, маршрутизирует запросы к нужной БД |
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
| `KubernetesTenantSecretUpdater` | Обновляет Kubernetes Secret с tenant-конфигурацией через API, проверяя TLS по service-account CA |
| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации |
| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA |
| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе |
| `TenantDatabaseMigrationService` | Запускает Flyway для подготовленного, ещё не опубликованного pool |
| `RetiredTenantPoolService` | После swap дожидается завершения активных подключений старого pool и закрывает его |
| `TenantReadinessRegistry` | Хранит атомарный снимок обязательных tenant, миграций и свежести проверок соединения |
| `TenantDatabaseHealthMonitor` | Ограниченно-параллельно проверяет соединения с обязательными tenant-БД в фоне |
| `TenantReadinessHealthIndicator` | Включает агрегированную готовность tenant-БД в Actuator readiness group |
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
### Определение тенанта
@@ -121,16 +128,84 @@ sequenceDiagram
### Жизненный цикл тенанта
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет `tenants-secret`
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет смонтированный `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет `tenants-secret`
1. **Добавление или обновление через API:** `TenantLifecycleService` нормализует конфигурацию,
создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет
Flyway. Затем `TenantConfigStore` читает актуальный Secret и применяет к нему только
upsert запрошенного домена.
2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего
`tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект
условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное
состояние читается заново, а собственная мутация повторно применяется к нему. Число
попыток ограничено тремя, задержка между попытками возрастает; идемпотентная операция
не отправляет лишний `PUT`.
3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным
чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`.
4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret
закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена
только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей
`resourceVersion`. Если другой pod уже записал более новую версию, автоматический откат
пропускается. Неопределённый сетевой результат сначала сверяется повторным `GET` и не
создаёт квитанцию для небезопасной компенсации. HTTP-граница получает русское безопасное
сообщение без JDBC/Flyway details.
5. **Drain старого pool:** после успешного swap новые запросы сразу используют candidate,
а прежний Hikari pool закрывается после завершения активных подключений либо по истечении
настраиваемого grace timeout.
6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный
`tenants.json` и применяет добавление/удаление под тем же локальным lifecycle-monitor.
7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только
удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
ограничения компенсации по `resourceVersion`.
Сериализация lifecycle и атомарный снимок защищают запросы внутри одного backend-процесса.
Между pod потерянное обновление предотвращает optimistic locking Kubernetes: каждый конфликт
заставляет заново прочитать Secret и повторно применить только свою доменную мутацию. Локальный
monitor по-прежнему не считается межподовой блокировкой.
Вне Kubernetes `KubernetesTenantSecretUpdater` работает как runtime-only адаптер: Secret и
локальный `tenants.json` не изменяются. Постоянная локальная конфигурация задаётся файлом до
запуска приложения.
Для обращения к Kubernetes API backend загружает
`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
завершает персистенцию безопасным отказом.
`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
`TenantConfigWatcher` сравнивает содержимое смонтированного из Secret `tenants.json` по
SHA-256, а не по `String.hashCode()`, чтобы изменение файла не пропускалось из-за
32-битной коллизии. На текущем этапе watcher активирует новые домены и удаляет отсутствующие,
но ещё не заменяет подключение существующего домена при изменении его URL или credentials.
Полная синхронизация такого изменения относится к проблеме №13; до её выполнения новое
значение сбрасывает readiness этого tenant в состояние ожидания, а pod не должен считаться
готовым по старому подключению.
### Liveness и readiness
Spring Boot Actuator публикует две независимые служебные группы:
- `GET /actuator/health/liveness` включает только `livenessState` приложения. Отказ любой
tenant-БД не переводит процесс в liveness failure и не создаёт restart storm;
- `GET /actuator/health/readiness` включает `readinessState` и `tenantReadiness`. Ответ имеет
статус `UP` только если задан непустой набор обязательных tenant, миграция каждого успешно
завершена и для каждого есть свежая успешная проверка соединения.
`TenantDataSourceConfig` регистрирует обязательный набор до startup-активации и сохраняет
неуспешную миграцию как readiness failure. H2-заглушка позволяет инициализировать Spring JPA,
но никогда не делает приложение готовым к production-трафику. Ошибка чтения обязательного
`tenants.json` также фиксируется отдельно от состояния процесса.
`TenantDatabaseHealthMonitor` по умолчанию запускается через секунду после старта, затем
проверяет соединения каждые 10 секунд пакетами до четырёх параллельных задач. Таймаут пакета
составляет 6 секунд: по его истечении отменяются только незавершённые задачи, а уже полученные
результаты сохраняются с фактическим временем завершения проверки. Результат старше 30 секунд
считается просроченным. Запоздалый результат привязан к HMAC-fingerprint конкретной конфигурации
и не может отметить новую конфигурацию готовой; исходные credentials в fingerprint и
health-ответ не попадают. Планировщик использует два потока, поэтому ожидающий health-pass не
задерживает периодическое чтение tenant Secret.
Tenant interceptor исключает `/actuator/**`, а authorization interceptor применяется только
к `/api/**`. При этом наружу опубликован только endpoint `health`, его details и components
скрыты: kubelet получает минимальный `UP`/`DOWN` без доменов и реквизитов tenant-БД.
### Fallback при отсутствии тенантов
@@ -138,6 +213,9 @@ sequenceDiagram
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
Для production обязательность смонтированной конфигурации задаётся
`TENANTS_CONFIG_REQUIRED=true`; отсутствие или ошибка чтения файла снимают readiness.
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
---

View File

@@ -59,7 +59,9 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
### Расположение конфигурации
Файлы Kubernetes манифестов: `../k8s/`
Ожидаемое расположение production-манифестов: `../k8s/`. В текущей рабочей копии этот
внешний каталог недоступен, поэтому приведённые ниже изменения являются обязательным
runbook для оператора, а не утверждением о применённом или проверенном состоянии кластера.
### Ключевые ресурсы
@@ -92,21 +94,146 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
| `otel-postgres-secret` | Credentials PostgreSQL receivers для OTel Collector |
| `gitea-registry` | Доступ Kubernetes к registry |
Secret `tenants-secret` монтируется в pod backend по пути `/config/tenants.json`.
Backend ожидает ключ `tenants.json` из Secret `tenants-secret` по пути
`/config/tenants.json`. Чтобы kubelet доставлял последующие обновления Secret, необходимо
монтировать каталог `/config`, а не отдельный файл через `subPath`:
```yaml
volumeMounts:
- name: tenants-config
mountPath: /config
readOnly: true
volumes:
- name: tenants-config
secret:
secretName: tenants-secret
optional: false
items:
- key: tenants.json
path: tenants.json
```
В production для backend также требуется:
```yaml
env:
- name: TENANTS_CONFIG_REQUIRED
value: "true"
```
При обязательной конфигурации отсутствие, пустой список или ошибка разбора `tenants.json`
снимают readiness; резервная H2-БД не считается готовой tenant-БД.
При добавлении тенанта через API:
1. `DatabaseController` обновляет in-memory DataSource
2. `KubernetesTenantSecretUpdater` обновляет Secret через Kubernetes API
3. `TenantConfigWatcher` на остальных pod подхватывает смонтированное изменение (каждые 30 сек)
1. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
2. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
только upsert запрошенного домена
3. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации
4. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
подтверждённой повторным чтением персистенции
5. старый pool перестаёт принимать новые запросы и закрывается после drain/grace timeout
6. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд
применяет добавление или удаление домена
Удаление использует тот же persistence-first порядок и применяет к актуальному Secret только
remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами
возрастает, а идемпотентное состояние не записывается повторно.
Если после подтверждённой записи Secret локальная активация или удаление завершаются ошибкой,
backend восстанавливает прежний снимок только пока Secret сохраняет `resourceVersion` этой
записи. Более новое межподовое изменение останавливает компенсацию и не перезаписывается.
Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое
состояние принимается без квитанции, допускающей небезопасный автоматический откат.
Watcher пока не заменяет подключение существующего домена при изменении URL или credentials.
Такое изменение сбрасывает readiness, но полное применение на другом pod относится к
проблеме №13. До её исправления нельзя считать watcher механизмом полной синхронизации всех
полей `TenantConfig`.
Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
RBAC ограничен объектом `tenants-secret`. Скрипт `../k8s/deploy.sh` проверяет наличие
объектов и ключей до rollout, не читая и не печатая их значения.
Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только
Kubernetes verbs `get` и `update` для одного существующего Secret:
```yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: backend-tenant-secret
namespace: magistr
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["tenants-secret"]
verbs: ["get", "update"]
```
`patch`, `list`, `watch`, `create` и `delete` текущему Kubernetes-клиенту не нужны. RoleBinding
должен связывать эту Role с фактическим `spec.template.spec.serviceAccountName` backend в
namespace `magistr`. Secret должен существовать заранее и не иметь `immutable: true`.
Создание, ротация, отзыв скомпрометированных значений и очистка истории описаны в
[`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md). Эти действия требуют полномочий оператора и
не выполняются автоматически.
### Liveness и readiness probes backend
TCP probe подтверждает только открытый порт и не отражает готовность обязательных tenant-БД.
В production Deployment необходимо использовать раздельные HTTP probes:
```yaml
livenessProbe:
httpGet:
path: /actuator/health/liveness
port: 8080
readinessProbe:
httpGet:
path: /actuator/health/readiness
port: 8080
```
Liveness зависит только от состояния процесса. Readiness возвращает `200 UP` после успешных
миграций и свежей успешной проверки всех обязательных tenant-БД, иначе — `503 DOWN` и pod
исключается из приёма трафика без перезапуска. Actuator не публикует components, домены,
JDBC URL или credentials.
Параметры `initialDelaySeconds`, `periodSeconds`, `timeoutSeconds` и `failureThreshold` нужно
согласовать с текущим Deployment; замена механизма probe не подтверждает автоматически
корректность этих порогов.
### Внешние действия для production Kubernetes
Каталог `../k8s/` не изменялся и не проверялся в рамках текущей рабочей копии. Оператору после
получения доступа к нему необходимо:
1. обновить backend Deployment: directory mount без `subPath`,
`TENANTS_CONFIG_REQUIRED=true` и две HTTP probes;
2. добавить либо сузить Role до `get`/`update` одного `tenants-secret` и проверить RoleBinding
фактического ServiceAccount;
3. убедиться, что внешний `tenants-secret` существует, содержит непустой ключ `tenants.json`
и допускает обновление;
4. отрендерить манифесты и выполнить серверную проверку без применения:
```bash
kubectl kustomize ../k8s >/dev/null
kubectl apply --dry-run=server -k ../k8s
```
5. отдельно проверить полномочия фактического ServiceAccount:
```bash
BACKEND_SERVICE_ACCOUNT='укажите-фактический-service-account'
kubectl auth can-i get secret/tenants-secret \
--as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr
kubectl auth can-i update secret/tenants-secret \
--as="system:serviceaccount:magistr:${BACKEND_SERVICE_ACCOUNT}" -n magistr
```
Применение манифестов, rollout и проверка реальных pod являются отдельными внешними
операциями и без разрешения автоматически не выполняются.
### Обновление backend
```bash