Задача Егора 2ч.

This commit is contained in:
2026-07-17 01:27:02 +03:00
parent 3f685d3e22
commit 3d798c13e3
20 changed files with 2003 additions and 275 deletions

View File

@@ -1190,16 +1190,20 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
него используется нормализованный `domain`.
**Логика:**
1. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
2. Открывает соединение и явно проверяет его готовность.
3. Выполняет Flyway-валидацию и миграции tenant-БД.
4. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
1. До мутации разбирает и при необходимости полностью применяет текущую mounted-проекцию
tenant-конфигурации как безопасный baseline; ошибка подготовки возвращает `503`.
2. Создаёт временный HikariCP pool, ещё не доступный маршрутизатору.
3. Открывает соединение и явно проверяет его готовность.
4. Выполняет Flyway-валидацию и миграции tenant-БД.
5. Читает актуальный `tenants-secret`, применяет только upsert запрошенного `domain` и
выполняет условный `PUT` с прочитанным Kubernetes `resourceVersion`.
5. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
6. При конфликте повторно читает Secret и заново применяет свою мутацию с ограниченным
retry/backoff; неизменившаяся конфигурация не записывается повторно.
6. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
7. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
7. Одной атомарной публикацией заменяет связку `TenantConfig + DataSource`.
8. Передаёт прежний pool на отложенное закрытие после завершения активных запросов
либо по истечении защитного таймаута.
9. Возвращает внутри backend `TenantLifecycleMutationResult` с подтверждённой
`TenantSecretUpdateReceipt` для согласования mounted-проекции.
Backend соединяется с Kubernetes API только через проверенный service-account CA и
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
@@ -1236,6 +1240,15 @@ HTTP-статусы: `400` для некорректного payload и `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`
Тест подключения к произвольной БД (без регистрации тенанта).

View File

@@ -81,7 +81,7 @@ sequenceDiagram
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
| `TenantConfigWatcher` | Периодически перечитывает `tenants.json`, сравнивает полный нормализованный снимок и повторяет неудачную синхронизацию с ограниченным backoff |
| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации |
| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA |
| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе |
@@ -128,10 +128,11 @@ sequenceDiagram
### Жизненный цикл тенанта
1. **Добавление или обновление через API:** `TenantLifecycleService` нормализует конфигурацию,
создаёт непубликуемый HikariCP candidate, проверяет реальное соединение и выполняет
Flyway. Затем `TenantConfigStore` читает актуальный Secret и применяет к нему только
upsert запрошенного домена.
1. **Добавление или обновление через API:** перед мутацией `TenantConfigWatcher` разбирает,
нормализует и при необходимости полностью применяет текущую mounted-проекцию как baseline.
Ошибка подготовки baseline прерывает API-операцию. Затем `TenantLifecycleService` создаёт
непубликуемый HikariCP candidate, проверяет реальное соединение, выполняет Flyway, а
`TenantConfigStore` читает актуальный Secret и применяет к нему только upsert домена.
2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего
`tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект
условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное
@@ -140,6 +141,9 @@ sequenceDiagram
не отправляет лишний `PUT`.
3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным
чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`.
Успешный lifecycle возвращает внутренний `TenantLifecycleMutationResult` с конфигурацией
и `TenantSecretUpdateReceipt`, содержащей признаки `persisted`/`changed`, а также
семантические снимки `previousTenants` и `committedTenants`.
4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret
закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена
только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей
@@ -151,7 +155,10 @@ sequenceDiagram
а прежний Hikari pool закрывается после завершения активных подключений либо по истечении
настраиваемого grace timeout.
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 только
удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
@@ -171,13 +178,25 @@ monitor по-прежнему не считается межподовой бл
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
завершает персистенцию безопасным отказом.
`TenantConfigWatcher` сравнивает содержимое смонтированного из Secret `tenants.json` по
SHA-256, а не по `String.hashCode()`, чтобы изменение файла не пропускалось из-за
32-битной коллизии. На текущем этапе watcher активирует новые домены и удаляет отсутствующие,
но ещё не заменяет подключение существующего домена при изменении его URL или credentials.
Полная синхронизация такого изменения относится к проблеме №13; до её выполнения новое
значение сбрасывает readiness этого tenant в состояние ожидания, а pod не должен считаться
готовым по старому подключению.
`TenantConfigWatcher` использует SHA-256 содержимого `tenants.json` как идентификатор файловой
ревизии, но решения о запаздывающих проекциях принимает по нормализованным семантическим
снимкам `name`/`domain`/`url`/`username`/`password`. Хеш становится последним применённым
только после успешного разбора и полного lifecycle sync. Ошибка сохраняет прежний хеш,
снимает readiness и запускает экспоненциальные повторы через 30300 секунд; другая неудачная
файловая ревизия получает собственную попытку без ожидания 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

View File

@@ -16,6 +16,9 @@
```
frontend/
├── package.json # Dependency-free проверки frontend через node:test
├── tests/
│ └── dashboard-conflicts.test.mjs # Регрессии дат и состояний проверки конфликтов
├── index.html # 🔐 Страница авторизации (общая)
├── script.js # Логика авторизации
├── style.css # Стили страницы авторизации
@@ -34,6 +37,7 @@ frontend/
│ ├── js/
│ │ ├── main.js # Инициализация, маршрутизация, навигация
│ │ ├── api.js # HTTP-обёртка (fetch + Authorization)
│ │ ├── dashboard-conflicts.js # Чистые функции дат, загрузки и состояний Red Zone
│ │ ├── utils.js # Утилиты
│ │ ├── otel.js # OpenTelemetry (клиентская телеметрия, только прод)
│ │ └── views/ # Модули представлений
@@ -125,6 +129,7 @@ frontend/
| 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` |
| `users` | CRUD пользователей | `/api/users` |
| `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`.
- Вкладка `teacher-requests` показывает pending-заявки кафедр на создание преподавателей. Администратор может скорректировать кафедру, логин, ФИО и должность, задать пароль минимум 8 символов, затем одобрить заявку через `/api/teacher-requests/{id}/approve` или отклонить её через `/api/teacher-requests/{id}/reject`. Для роли `ADMIN` счётчик pending-заявок выводится в пункте меню «Заявки» и рядом с заголовком страницы, чтобы очередь была видна без открытия вкладки.
- Вкладка `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:0003:00, первые дни месяца, переход года, полную/частичную/не выполненную загрузку и запрет ложного зелёного статуса.
Команды выполняются из каталога `frontend/`:
```bash
npm test # unit-тесты дат, загрузки кафедр и UI-состояний
npm run check # синтаксис модулей дашборда + unit-тесты
```
---
## Аутентификация (Frontend)
### Страница входа (`/index.html`)

View File

@@ -126,16 +126,24 @@ env:
снимают readiness; резервная H2-БД не считается готовой tenant-БД.
При добавлении тенанта через API:
1. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
2. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
1. `TenantConfigWatcher` до мутации разбирает и при необходимости применяет текущую
mounted-проекцию как безопасный baseline; ошибка подготовки прерывает операцию
2. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
3. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
только upsert запрошенного домена
3. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
4. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации
4. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
5. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
подтверждённой повторным чтением персистенции
5. старый pool перестаёт принимать новые запросы и закрывается после drain/grace timeout
6. `TenantConfigWatcher` на остальных pod видит обновлённый файл и каждые 30 секунд
применяет добавление или удаление домена
6. старый pool перестаёт принимать новые запросы и передаётся на закрытие после
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 только
remove указанного домена. Число попыток записи ограничено тремя, задержка между повторами
@@ -147,10 +155,17 @@ backend восстанавливает прежний снимок только
Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое
состояние принимается без квитанции, допускающей небезопасный автоматический откат.
Watcher пока не заменяет подключение существующего домена при изменении URL или credentials.
Такое изменение сбрасывает readiness, но полное применение на другом pod относится к
проблеме №13. До её исправления нельзя считать watcher механизмом полной синхронизации всех
полей `TenantConfig`.
Watcher подтверждает SHA-256 файловой ревизии только после полного успешного применения
снимка. При ошибке прежний применённый хеш сохраняется, readiness снимается, а синхронизация
повторяется с экспоненциальной задержкой от 30 до 300 секунд.
Защита от задержки 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.
Поскольку реализация использует HTTP `GET` и `PUT`, минимальная Role должна разрешать только