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

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

### Сервисы

```yaml
services:
  backend:     # Spring Boot (Java 17), внутренний порт 8080
  frontend:    # Apache httpd, внутренний порт 80
  db:          # PostgreSQL 16.3, внутренний порт 5432
```

### Сеть

Compose сам создаёт изолированную bridge-сеть `magistr` для приложения и подключает backend
с frontend к существующей внешней Docker-сети `proxy`. Caddy из `../сaddy-proxy/` также
подключён к `proxy`: на `localhost` он отправляет `/api/*` непосредственно в `backend:8080`,
а остальные пути — в `frontend:80`. Контейнеры Magistr не публикуют порты на хосте;
PostgreSQL доступен только во внутренней сети `magistr`.

Перед первым запуском создайте общую сеть и поднимите Caddy:

```bash
docker network inspect proxy >/dev/null 2>&1 || docker network create proxy
docker compose -f ../сaddy-proxy/compose.yaml up -d
docker compose up -d --build
```

Для `https://localhost` Caddy выпускает сертификат своим локальным CA. На CachyOS/Arch
корневой сертификат устанавливается в системное хранилище через `trust anchor`; точная
команда приведена в `STARTUP_GUIDE.md`.

Данные PostgreSQL сохраняются в именованном томе `postgres_data`. Обычный
`docker compose down` не удаляет их; явный `docker compose down -v` выполняет полный сброс.

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

Файл `.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
JWT_REFRESH_CLEANUP_RETENTION=30d
LOGIN_RATE_MAX_FAILURES=5
LOGIN_RATE_ATTEMPT_WINDOW=15m
LOGIN_RATE_BASE_BLOCK_DURATION=1m
LOGIN_RATE_MAX_BLOCK_DURATION=15m
LOGIN_AUDIT_RETENTION=90d
TRUSTED_PROXY_CIDRS=172.16.0.0/12
BUSINESS_TIME_ZONE=Europe/Moscow
TZ=Europe/Moscow
OTEL_SDK_DISABLED=true
```

Начальный шаблон находится в `.env.example`: скопируйте его в игнорируемый Git файл `.env`
и заполните пустые секреты. `POSTGRES_PASSWORD` и `JWT_SECRET` обязательны для
`docker compose up` и не хранятся в `compose.yaml`; JWT должен быть случайным секретом длиной
минимум 32 байта (`openssl rand -base64 48`). `POSTGRES_DB` одновременно задаёт создаваемую
БД и входит в `SPRING_DATASOURCE_URL`, поэтому произвольное локальное имя остаётся
согласованным. Все JWT TTL/cleanup-переменные и параметры защиты входа передаются
backend-контейнеру явно. `TRUSTED_PROXY_CIDRS` должен содержать только сеть фактического
reverse proxy: заголовок `X-Forwarded-For` от остальных источников backend игнорирует.
`BUSINESS_TIME_ZONE` задаёт правила календарных бизнес-дат, а `TZ` и
`JAVA_TOOL_OPTIONS=-Duser.timezone=...` фиксируют timezone JVM и контейнера. Абсолютные
timestamps при этом всегда передаются между Java и PostgreSQL в UTC.
Поскольку локальный Compose не запускает OpenTelemetry Collector, SDK по умолчанию отключён;
при подключённом Collector задайте `OTEL_SDK_DISABLED=false` и его OTLP endpoint.

В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
Kubernetes `app-config` также явно задаёт `BUSINESS_TIME_ZONE=Europe/Moscow`,
`TZ=Europe/Moscow` и `JAVA_TOOL_OPTIONS=-Duser.timezone=Europe/Moscow` для backend pod.

Встроенного 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.9-eclipse-temurin-17@sha256:f58d59b...` → `mvn package`
2. OpenTelemetry Java Agent `2.28.1` загружается как фиксированный Maven-артефакт и
   проверяется по закреплённому SHA-256
3. Этап запуска: `eclipse-temurin:17-jre-alpine@sha256:02320dd4...` → `java -jar app.jar`

### Dockerfile (Frontend)

```dockerfile
FROM node:22-alpine3.23@sha256:8516dce... AS frontend-assets
RUN npm ci
RUN npm run build:vendor

FROM httpd:alpine3.23@sha256:4a15e9c...
COPY --from=frontend-assets /build/dist/vendor/ /usr/local/apache2/htdocs/vendor/
COPY security.conf /usr/local/apache2/conf/extra/magistr-security.conf
COPY proxy.conf /usr/local/apache2/conf/extra/magistr-proxy.conf
```

Все базовые образы backend/frontend и локальный PostgreSQL зафиксированы одновременно точным
tag и manifest digest. Первый этап frontend собирает зафиксированный
OpenTelemetry bundle; второй раздаёт только runtime-
файлы, без `node_modules`, тестов и build-исходников. Apache подключает `mod_headers`,
`mod_proxy` и `mod_proxy_http`; proxy сохраняет исходный `Host`, чтобы `localhost` корректно
маршрутизировался в tenant `default`. Сервер
возвращает CSP с `script-src 'self'`, `script-src-attr 'none'`, `style-src 'self'` и точными
SHA-256 для оставшихся статических style-атрибутов. `unsafe-inline` и `unsafe-eval` не
используются. Дополнительно выставляются `nosniff`, `DENY`, строгий referrer policy и
ограниченная Permissions Policy.

---

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

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

Production-манифесты находятся в `../k8s/`. Каталог расположен вне Git-корня `magistr`,
поэтому его изменения проверяются и перечисляются отдельно от `git diff` репозитория.

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

| Ресурс | Тип | Описание |
|--------|-----|----------|
| `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`
- `JWT_REFRESH_CLEANUP_ENABLED=true`
- `JWT_REFRESH_CLEANUP_RETENTION=30d`
- `JWT_REFRESH_CLEANUP_BATCH_SIZE=500`
- `JWT_REFRESH_CLEANUP_MAX_BATCHES=20`
- `JWT_REFRESH_CLEANUP_INTERVAL_MS=3600000`
- `JWT_REFRESH_CLEANUP_INITIAL_DELAY_MS=60000`

Cleanup проходит отдельно по каждой активной tenant-БД. `RETENTION` задаёт срок хранения
свежих истёкших и отозванных audit-записей, `BATCH_SIZE` и `MAX_BATCHES` ограничивают объём
одного прохода, а interval/initial delay управляют безопасным расписанием.

### Защита входа и доверенные proxy

`app-config` задаёт общий PostgreSQL rate limit для всех backend-pod:

- `LOGIN_RATE_MAX_FAILURES=5`
- `LOGIN_RATE_ATTEMPT_WINDOW=15m`
- `LOGIN_RATE_BASE_BLOCK_DURATION=1m`
- `LOGIN_RATE_MAX_BLOCK_DURATION=15m`
- `LOGIN_AUDIT_RETENTION=90d`
- `TRUSTED_PROXY_CIDRS=10.42.0.0/16`

Последнее значение соответствует стандартной pod-сети K3s, из которой Traefik обращается
к backend. Если кластер запущен с другим `cluster-cidr`, перед rollout нужно указать
фактическую сеть ingress proxy. Не следует добавлять публичные сети клиентов: backend
доверяет `X-Forwarded-For` только от непосредственного источника из этого списка.

Счётчики и audit-события находятся в каждой tenant-БД, поэтому два pod используют одно
атомарное состояние. Старая история очищается раз в сутки ограниченными `SKIP LOCKED`
пачками; параметры `LOGIN_AUDIT_CLEANUP_*` из `.env.example` позволяют изменить расписание
и максимальный объём одного прохода.

`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

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.

`../k8s/backend.yaml` монтирует каталог `/config` без `subPath`, а `app-config` задаёт
`TENANTS_CONFIG_PATH=/config/tenants.json` и `TENANTS_CONFIG_REQUIRED=true`. Отсутствующий
или невалидный обязательный Secret поэтому снимает readiness, не включая H2 fallback.

Перед production rollout оператор должен убедиться, что внешний `tenants-secret` существует,
содержит непустой ключ `tenants.json`, допускает обновление, и выполнить проверки без
применения:

```bash
kubectl kustomize ../k8s >/dev/null
kubectl apply --dry-run=server -k ../k8s
```

Отдельно проверьте полномочия фактического 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
```

Production rollout и проверка реальных pod являются внешними операциями и без отдельного
разрешения из этой рабочей копии не выполнялись.

### Ручное обновление backend и frontend

```bash
export BACKEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-backend@sha256:<64 hex>'
export FRONTEND_IMAGE_REF='gitea.zuev.company/zuev/magistr-frontend@sha256:<64 hex>'
bash ../k8s/deploy.sh apply
```

Манифесты содержат нулевой digest-sentinel. `deploy.sh` требует два реальных digest,
локально подставляет их до `kubectl apply` и строго ждёт оба rollout; прямое применение
Deployment-файлов запрещено.

---

## 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. Backend unit/component tests, frontend static/unit tests, Compose validation, shell-тест
   immutable rollout/rollback и регрессионная проверка закрепления артефактов.
2. Только после успешных gates — login, параллельная сборка и push backend/frontend с
   SHA-tag и release tag без `latest`. Сторонние Actions закреплены полными commit SHA.
3. BuildKit публикует для обоих образов максимальную provenance- и SBOM-attestation. Build
   jobs также возвращают registry digest каждого образа.
4. Отдельный обязательный job сканирует опубликованные digests закреплённым Trivy `0.63.0` и
   блокирует deploy при исправимых `HIGH`/`CRITICAL` уязвимостях.
5. Deploy job устанавливает фиксированный `kubectl v1.33.12` только после SHA-256 проверки.
   Java Agent также имеет точную версию и checksum.
6. `scripts/deploy-images.sh` принимает только `image@sha256:...`, сохраняет предыдущие
   ссылки, применяет оба digest и ждёт rollout. При отказе автоматически возвращает оба
   предыдущих образа и повторно проверяет их готовность.
7. Workflow-wide concurrency lock не допускает одновременные production deployment.

`scripts/check-artifact-pinning.sh` проверяет Dockerfile, Compose, Actions, checksum и CI
gates. При передаче `K8S_DIR=../k8s` он дополнительно требует digest у каждого production
образа Kubernetes. Внешний `otel-collector.yaml` использует официальный Collector Contrib
`0.153.0@sha256:93aad750175cbf1a973ae1c5886c3371f4d800f61be25cdd26870b8441ffe9fa`;
версия и manifest digest обновляются только вместе и проверяются до rollout.

---

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

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

```mermaid
graph LR
    Backend["Spring Boot"] -->|OTLP gRPC| Collector["OTel Collector"]
    Frontend["JS (локальный OTel bundle)"] -->|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

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

Исходные npm-пакеты имеют exact-версии и lockfile, собираются внутри frontend-образа и не
загружаются браузером со стороннего CDN.

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)
