# 🏭 Инфраструктура ## 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)