# 🏭 Инфраструктура

## Docker Compose (локальная разработка)

### Сервисы

```yaml
services:
  backend:     # Spring Boot (Java 17), порт 8080
  frontend:    # Apache httpd:alpine, порт 80
  db:          # PostgreSQL alpine3.23, порт 5432
```

### Сеть

Все сервисы работают в Docker-сети `proxy` (external). Перед первым запуском:

```bash
docker network create proxy
```

### Переменные окружения

Файл `.env` в корне проекта:

```env
POSTGRES_USER=myuser
POSTGRES_PASSWORD=replace-with-local-password
POSTGRES_DB=app_db
JWT_SECRET=replace-with-random-jwt-secret-minimum-32-bytes
JWT_ACCESS_TOKEN_TTL=15m
JWT_REFRESH_TOKEN_TTL=7d
```

`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.

Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение
останавливает запуск; профиль `production` также требует Secure refresh-cookie.

Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.

### Dockerfile (Backend)

Backend собирается через multi-stage сборку Maven:
1. Этап сборки: `maven:3.9-eclipse-temurin-17` → `mvn package`
2. Этап запуска: `eclipse-temurin:17-jre-alpine` → `java -jar app.jar`

### Dockerfile (Frontend)

```dockerfile
FROM httpd:alpine
COPY . /usr/local/apache2/htdocs/
RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
```

---

## Kubernetes (продакшн)

### Расположение конфигурации

Ожидаемое расположение production-манифестов: `../k8s/`. В текущей рабочей копии этот
внешний каталог недоступен, поэтому приведённые ниже изменения являются обязательным
runbook для оператора, а не утверждением о применённом или проверенном состоянии кластера.

### Ключевые ресурсы

| Ресурс | Тип | Описание |
|--------|-----|----------|
| `backend` | Deployment | Spring Boot приложение |
| `frontend` | Deployment | Apache httpd |
| `tenants-secret` | Внешний Secret | JSON-список tenant-подключений; значения отсутствуют в манифестах |

### JWT настройки

`app-config` задаёт TTL access/refresh-токенов и признак Secure-cookie:

- `JWT_ACCESS_TOKEN_TTL=15m`
- `JWT_REFRESH_TOKEN_TTL=7d`
- `JWT_REFRESH_COOKIE_SECURE=true`

`app-secret` задаёт `JWT_SECRET`. Этот Secret не создаётся файлами `../k8s/`: его заранее
предоставляет внешний secret manager или оператор. Deployment явно включает профиль
`production`, поэтому небезопасная cookie-конфигурация останавливает startup.

### Secrets для приложения и тенантов

Обязательные внешние объекты:

| Secret | Назначение |
|---|---|
| `app-secret` | JWT и fallback credentials backend |
| `tenants-secret` | Полный `tenants.json` с credentials tenant-БД |
| `otel-postgres-secret` | Credentials PostgreSQL receivers для OTel Collector |
| `gitea-registry` | Доступ Kubernetes к registry |

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. `TenantConfigWatcher` до мутации разбирает и при необходимости применяет текущую
   mounted-проекцию как безопасный baseline; ошибка подготовки прерывает операцию
2. `TenantLifecycleService` создаёт отдельный candidate pool, проверяет соединение и Flyway
3. `KubernetesTenantSecretUpdater` выполняет `GET` актуального Secret и применяет к списку
   только upsert запрошенного домена
4. полный объект Secret отправляется условным `PUT` с текущим `metadata.resourceVersion`;
   при `409 Conflict` выполняются повторное чтение и повторное применение своей мутации
5. `TenantRoutingDataSource` атомарно публикует candidate только после успешной либо
   подтверждённой повторным чтением персистенции
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 указанного домена. Число попыток записи ограничено тремя, задержка между повторами
возрастает, а идемпотентное состояние не записывается повторно.

Если после подтверждённой записи Secret локальная активация или удаление завершаются ошибкой,
backend восстанавливает прежний снимок только пока Secret сохраняет `resourceVersion` этой
записи. Более новое межподовое изменение останавливает компенсацию и не перезаписывается.
Неопределённый сетевой результат сначала сверяется повторным `GET`; совпавшее целевое
состояние принимается без квитанции, допускающей небезопасный автоматический откат.

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 должна разрешать только
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
kubectl rollout restart deployment backend -n magistr
```

---

## Caddy (реверс-прокси)

**Расположение:** `../caddy-proxy/` для локальной разработки, в продакшене - отдельный сервис

В продакшене Caddy обрабатывает входящий трафик для `*.zuev.company`:
- Автоматическое получение TLS-сертификатов (Let's Encrypt)
- Маршрутизация `/api/*` → backend:8080
- Маршрутизация статики → frontend:80

---

## CI/CD (Gitea Actions)

### Пайплайн сборки Docker-образов

Расположение: `.gitea/workflows/docker-build.yaml`

Основные шаги:
1. Checkout кода
2. Login в Docker Registry
3. Build + Push образов (`backend`, `frontend`)
4. Генерация меток через `docker/metadata-action`

---

## Мониторинг (SigNoz + OpenTelemetry)

### Архитектура мониторинга

```mermaid
graph LR
    Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"]
    Frontend["JS (otel.js)"] -->|OTLP HTTP| Collector
    Collector --> SigNoz["SigNoz"]
    
    Collector -->|"Метрики PostgreSQL"| PgExporter["pg_exporter"]
```

### Интеграция Backend

Backend отправляет через OpenTelemetry:
- **Логи** — через Logback + OTLP exporter
- **Трейсы** — автоинструментация Spring Boot
- **Метрики** — JVM метрики, HTTP метрики

Tenant ID добавляется в:
- MDC (логи): `MDC.put("tenant.id", tenant)`
- Span атрибуты: `Span.current().setAttribute("tenant.id", tenant)`

### Интеграция Frontend

Файл `admin/js/otel.js` — клиентская телеметрия:
- Метрики производительности страниц
- Трейсы пользовательских действий

PostgreSQL receivers collector получают endpoint и credentials только из
`otel-postgres-secret`; ConfigMap collector содержит лишь `${env:...}` ссылки.

### Дашборды SigNoz

- JVM Dashboard (Heap, GC, Threads)
- PostgreSQL Dashboard (Connections, Queries)
- HTTP Dashboard (Requests, Latency, Errors)
