---
title: План платформы Kubernetes + Terraform + ArgoCD + Gitea CI/CD
---

# План развёртывания тестового и мульти-контурного окружения Sova

> **Контекст:** сейчас проект живёт в Docker Compose (`environments/`, `infrastructure/`, `local/`, `monitoring/`, заготовки `jenkins/`). Цель — **три изолированных сервера** (test / stage / prod), на каждом — свой k3s-кластер с **Terraform**, **ArgoCD**, **мониторингом**, **CI/CD через Gitea** (центральный Git/Registry на test-сервере или отдельно) и **системой тегов** для promotion между контурами.
>
> **Prod PostgreSQL:** master + sync replica, автоматический failover — только prod, не test (§7.6).
>
> **As-is prod (без K8s):** одна VM — **12 GB RAM, 6 vCPU, 216 GB disk** (nginx + PHP + PostgreSQL + Redis на одной машине). Это **нижняя граница**, не целевой размер; при миграции в k3s + HA PG ресурсы увеличиваются (§3).
>
> Дополняет корневой [`DEPLOYMENT_PLAN.md`](../../DEPLOYMENT_PLAN.md) (там акцент на Managed K8s в облаке). **Этот документ** — практический план под сценарий «self-hosted k3s + Gitea + **отдельный сервер на контур**».

---

## 0. TL;DR — что получится в итоге

```mermaid
flowchart TB
  subgraph devs [Разработчики]
    Dev[Push / Tag в Gitea]
  end

  subgraph srv_test [Сервер TEST]
    subgraph k3s_test [k3s test]
      GIT[Gitea + Registry + CI]
      ARGOt[ArgoCD test]
      BT[backend test]
      AT[adminPanel test]
      PGt[(PG single)]
      MOCK[sova-mocks]
    end
  end

  subgraph srv_stage [Сервер STAGE]
    subgraph k3s_stage [k3s stage]
      BS[backend stage]
      AS[adminPanel stage]
      PGs[(PG single)]
    end
  end

  subgraph srv_prod [Серверы PROD]
    subgraph k3s_prod [k3s prod apps]
      BP[backend prod]
      AP[adminPanel prod]
    end
    subgraph pg_ha [PostgreSQL HA 2 nodes]
      PG1[(Primary)]
      PG2[(Replica sync)]
      PG1 <-->|WAL sync| PG2
      RW[PgBouncer rw]
    end
  end

  Dev --> GIT
  GIT --> ARGOt
  GIT -->|promote| k3s_stage
  GIT -->|promote| k3s_prod
  BT --> PGt
  BT --> MOCK
  BS --> PGs
  BP --> RW
  RW --> PG1
  RW -.->|failover| PG2
```

**Браузерные точки входа** (плейсхолдеры до выдачи реального домена):

| Сервис | URL (плейсхолдер) | Сервер / контур |
|--------|-------------------|-----------------|
| Gitea | `https://git.sova.dev` | **test** (центральный Git) |
| ArgoCD test | `https://argocd.test.sova.dev` | test |
| ArgoCD stage | `https://argocd.stage.sova.dev` | stage |
| ArgoCD prod | `https://argocd.sova.dev` | prod |
| Grafana | per-env `grafana.{test,stage,sova}.dev` | каждый сервер |
| backend **test** | `https://api.test.sova.dev` | test |
| adminPanel **test** | `https://admin.test.sova.dev` | test |
| backend **stage** | `https://api.stage.sova.dev` | stage |
| adminPanel **stage** | `https://admin.stage.sova.dev` | stage |
| backend **prod** | `https://api.sova.dev` | prod |
| adminPanel **prod** | `https://admin.sova.dev` | prod |

> Когда выдадут домен — замените `sova.dev` одной командой по репозиторию `sova-deploy` и Terraform DNS-модулю.

---

## 1. Анализ текущего состояния (as-is)

### 1.1. Что уже есть в репозитории

| Каталог | Содержимое | Готовность к K8s |
|---------|------------|------------------|
| `infrastructure/` | Dockerfile nginx (VTS), PHP 8.4/8.2, nextjs, pgsql 14, redis | **База для образов**, но prod-Dockerfile приложений не готовы |
| `environments/` | Layered Docker Compose: apps, dbs, dev, monitoring, jenkins | **Эталон** портов, доменов, зависимостей |
| `local/` | Изолированный `sova-local`: PG16, MySQL Bitrix, nginx :8081/:8082, admin :3211 | **Эталон** env-переменных и smoke-тестов |
| `monitoring/` | Prometheus scrape config, Grafana provisioning, community dashboards | **Перенос** в kube-prometheus-stack values |
| `jenkins/` | 2 одинаковых Jenkinsfile (backend/cabinet), пустой frontend | **Не переносить as-is** — заменить Gitea Actions |
| `scripts/` | cron hourly/daily, certbot, local-smoke | cron → **CronJob**; certbot → **cert-manager** |

### 1.2. Текущая архитектура Docker Compose

```mermaid
flowchart LR
  subgraph public [public-network]
    Nginx[nginx :80/:443]
    Next[nextjs :3001]
    Grafana[grafana :3000]
  end

  subgraph internal [internal-network]
    PHP84[php84 backend]
    PHP82[php82 cabinet]
    PG[(pgsql :5432)]
    Redis[(redis)]
    Prom[prometheus]
  end

  Internet --> Nginx
  Nginx -->|api.sovamed.ru| PHP84
  Nginx -->|cabinet.*| PHP82
  Nginx -->|adm.* static| AdminDist
  Nginx -->|dev.sovamed.ru| Next
  PHP84 --> PG
  PHP84 --> Redis
  Prom --> Nginx
```

### 1.3. Приложения и приоритет миграции

| Приложение | Стек | Prod-домен сегодня | Фаза K8s |
|------------|------|--------------------|----------|
| **backend** | Symfony 7.3, PHP 8.4 | `api.sovamed.ru` | **Фаза 1 (test)** |
| **adminPanel** | React 19, Vite | `adm.sovamed.ru` | **Фаза 1 (test)** |
| cabinet | Symfony 5.4, PHP 8.2 | `cabinet.sovamed.ru` | Фаза 2 |
| sovamed | Next.js | `dev.sovamed.ru` | Фаза 2 |
| kiosk, widgetDoctors | Static SPA | `cdn.*`, `dev.wmtmed.ru` | Фаза 3 |

### 1.4. Критические пробелы (блокеры)

1. `apps/backend/Dockerfile` — **пустой** (нужен multistage build).
2. `apps/adminPanel/Dockerfile` — **отсутствует**.
3. Jenkins pipelines — **scaffold** с `registry.example.com`, shared library не в репо.
4. PostgreSQL prod использует **pg_cron, mysql_fdw, firebird_fdw** — проверить нужность в test.
5. Bitrix MySQL — cabinet/backend зависят; для test можно mock/отдельный pod MySQL.
6. `apps/` может быть **gitignored** в infra-репо — CI должен собирать из **отдельных Gitea-реп**.

### 1.5. Prod as-is (без Kubernetes)

Текущая prod-VM (Docker Compose / bare services на одной машине):

| Ресурс | Значение сегодня | Комментарий |
|--------|------------------|-------------|
| RAM | **12 GB** | nginx + PHP-FPM + PostgreSQL + Redis + мониторинг |
| CPU | **6 vCPU** | |
| Disk | **216 GB** | PG data + логи + образы Docker |
| K8s | **нет** | миграция = новый sizing + HA PG |

Это **baseline**, не целевая конфигурация. При переходе на k3s добавляется overhead платформы (~1.5–2 GB RAM), а для HA PostgreSQL нужен **второй сервер** (§7.6).

---

## 2. Целевая архитектура (to-be)

### 2.1. Принципы

1. **Infrastructure as Code:** Terraform поднимает сервер/OS/k3s; Helm + ArgoCD — всё внутри кластера.
2. **GitOps:** единственный источник истины деплоя — репозиторий `sova-deploy` (не `kubectl apply` руками).
3. **Immutable artifacts:** образы по тегам в Container Registry; rollback = смена тега в Git.
4. **Контуры изолированы:** test / stage / prod — **разные физические серверы**, разные БД, разные secrets.
5. **k3s single-node на каждом сервере** на старте; prod apps и prod PG HA могут быть на **2–3 серверах** (§3.4, §7.6).
6. **Prod PostgreSQL:** primary + sync replica + auto failover; test/stage — один инстанс PG достаточно.

### 2.2. Слои платформы

```mermaid
flowchart TB
  subgraph L0 [L0 — Сервер]
    OS[Ubuntu 22.04/24.04 LTS]
    TF[Terraform: provisioning]
  end

  subgraph L1 [L1 — Kubernetes]
    K3s[k3s single-node]
    CNI[Flannel / built-in]
    Storage[local-path-provisioner]
  end

  subgraph L2 [L2 — Platform]
    ING[ingress-nginx]
    CM[cert-manager]
    ARGO[ArgoCD]
    MON[kube-prometheus-stack]
    LOKI[Loki + Promtail]
    SS[sealed-secrets]
  end

  subgraph L3 [L3 — DevOps]
    GIT[Gitea]
    RUN[Gitea Actions Runner]
    REG[Registry: gitea built-in или Harbor]
  end

  subgraph L4 [L4 — Apps per env]
    APP[backend + adminPanel Helm]
    DB[PostgreSQL + MySQL Bitrix + Redis per env]
    MOCK[Mock/stub layer test-only]
    CRON[CronJobs Symfony console]
  end

  L0 --> L1 --> L2 --> L3 --> L4
```

### 2.3. Namespace map

| Namespace | Назначение |
|-----------|------------|
| `kube-system` | k3s системные компоненты |
| `ingress` | ingress-nginx |
| `cert-manager` | cert-manager |
| `argocd` | ArgoCD |
| `monitoring` | Prometheus, Grafana, Alertmanager, Loki |
| `sealed-secrets` | controller |
| `gitea` | Gitea + runner (опционально вынести на VM) |
| `sova-test` | приложения test |
| `sova-stage` | приложения stage |
| `sova-prod` | приложения prod (когда будет готов) |
| `sova-data-test` | PostgreSQL (main + cabinet), MySQL Bitrix, Redis test |
| `sova-mocks` | WireMock MIS, Mailpit, captcha/calltouch/bitrix-http stubs (только test) |

> Полная матрица внешних зависимостей backend и стратегия test/stage/prod: **[Backend: внешние сервисы](./backend-external-services.md)**.

---

## 3. Серверы и ресурсы (test / stage / prod — разные машины)

### 3.1. Топология

| Сервер | Роль | k3s | PostgreSQL |
|--------|------|-----|------------|
| **test** | QA, mocks, центральный Gitea/Registry/CI | single-node | **1 инстанс** |
| **stage** | pre-prod, sandbox-интеграции | single-node | **1 инстанс** |
| **prod-app** | backend, adminPanel, Redis, MySQL Bitrix | single-node | клиент → HA |
| **prod-db-1** | PG **primary** (sync) | нет | primary |
| **prod-db-2** | PG **replica** (sync standby) | нет | replica |
| **prod-db-witness** | **etcd quorum only** | нет | — |

**Минимум prod HA:** prod-app + **2 db-VM** + **1 witness-VM** (1 vCPU / 1 GB). Gitea/Registry на **test**; stage/prod **pull** образов (§9.3).

> **Witness нельзя** ставить на prod-app — см. §7.6.3 и §19.

### 3.2. Sizing: test-сервер

| Ресурс | Минимум | Рекомендуется |
|--------|---------|---------------|
| CPU | 4 vCPU | **8 vCPU** |
| RAM | 16 GB | **32 GB** |
| Disk | 150 GB SSD | **200+ GB NVMe** |
| OS | Ubuntu 22.04/24.04 LTS | |
| Сеть | Публичный IP, 80/443/22 | |
| DNS | A-запись `*.test.sova.dev` → IP test-сервера | |

**Бюджет диска (200 GB):** registry ~50 GB (retention!), Loki ~15 GB, PG test ~30 GB, остальное — apps/k8s.

| Компонент | RAM |
|-----------|-----|
| k3s + Gitea + Registry + runner | ~2.5 GB |
| monitoring lite + Loki | ~2.5 GB |
| PG + MySQL + Redis test | ~2 GB |
| backend + adminPanel | ~1.5 GB |
| **Итого** | **~9 GB** + 30% → **16 GB min** |

### 3.3. Sizing: stage-сервер

| Ресурс | Минимум | Рекомендуется |
|--------|---------|---------------|
| CPU | 4 vCPU | **6 vCPU** |
| RAM | 12 GB | **16 GB** |
| Disk | 100 GB | **150 GB** |

Без Gitea/Registry. Один PostgreSQL.

### 3.4. Sizing: prod (baseline as-is: 12 GB / 6 CPU / 216 GB)

Текущая prod-VM — всё на одной машине **без k8s** (§1.5). Целевая prod с HA **не умещается** в 12 GB на одном диске с k8s.

#### Вариант A — рекомендуемый (prod-app + 2× prod-db)

| Сервер | CPU | RAM | Disk | Нагрузка |
|--------|-----|-----|------|----------|
| **prod-app** | **8 vCPU** | **16 GB** | **150 GB** | k3s, apps, Redis, MySQL Bitrix, ingress, ArgoCD |
| **prod-db-1** | **4 vCPU** | **12 GB** | **150 GB NVMe** | PG primary + Patroni |
| **prod-db-2** | **4 vCPU** | **12 GB** | **150 GB NVMe** | PG sync replica |

**Итого prod:** ~16 vCPU, ~40 GB RAM (vs 6 / 12 GB сегодня).

#### Вариант B — промежуточный (2 сервера, replica позже)

| Сервер | CPU | RAM | Disk |
|--------|-----|-----|------|
| **prod-app** | 8 vCPU | 16 GB | 150 GB |
| **prod-db-1** | 6 vCPU | 16 GB | 216 GB (как сейчас) |

⚠️ Без **prod-db-2** HA **нет** — только подготовка; replica обязательна до cutover prod в k8s.

#### Диск prod-db

PG data 80–120 GB, WAL 20 GB, pg_dump 30 GB, запас 40+ GB. MySQL Bitrix / Redis — на **prod-app**.

### 3.5. Terraform per environment

Модули: `test-server`, `stage-server`, `prod-app`, `prod-db-1`, `prod-db-2`. Remote state вне серверов (§5.1). Firewall: prod-app → prod-db:5432; replication prod-db-1 ↔ prod-db-2.

---

## 4. Структура репозиториев в Gitea

Разделить **код приложений**, **инфраструктуру Terraform**, **манифесты деплоя**:

```text
gitea.sova.dev/
├── sova-backend          # apps/backend (Symfony)
├── sova-adminpanel       # apps/adminPanel (React)
├── sova-cabinet          # позже
├── sova-infra            # этот монорепо: infrastructure/, monitoring/, terraform/
├── sova-deploy           # Helm charts + ArgoCD Applications + values per env
└── sova-platform         # опционально: только Terraform + bootstrap scripts
```

### 4.1. `sova-deploy` — GitOps репозиторий (главный для ArgoCD)

```text
sova-deploy/
├── README.md
├── apps/
│   ├── backend/
│   │   ├── Chart.yaml
│   │   ├── values.yaml              # defaults
│   │   ├── values-test.yaml
│   │   ├── values-stage.yaml
│   │   ├── values-prod.yaml
│   │   └── templates/
│   │       ├── deployment.yaml      # pod: nginx + php-fpm sidecar
│   │       ├── service.yaml
│   │       ├── ingress.yaml
│   │       ├── hpa.yaml
│   │       ├── migrate-job.yaml     # PreSync hook
│   │       ├── cronjobs.yaml        # upload:doctors, bitrix-update-reviews
│   │       └── externalsecret.yaml
│   └── adminpanel/
│       ├── Chart.yaml
│       ├── values-test.yaml
│       └── templates/...
├── data/
│   ├── postgresql/                    # test/stage only: Helm values (Bitnami/CNPG 1 instance)
│   └── redis/                         # test/stage: Helm values
│   # prod PostgreSQL — вне K8s; конфиги в sova-platform (§4.2), не здесь
├── argocd/
│   ├── projects/sova-project.yaml
│   ├── app-of-apps.yaml
│   └── apps/
│       ├── platform-ingress.yaml
│       ├── platform-cert-manager.yaml
│       ├── platform-monitoring.yaml
│       ├── platform-argocd.yaml
│       ├── backend-test.yaml
│       ├── adminpanel-test.yaml
│       ├── backend-stage.yaml
│       └── ...
└── environments/
    ├── test/
    │   └── kustomization.yaml       # optional Kustomize overlay
    ├── stage/
    └── prod/
```

### 4.2. `sova-infra` / `sova-platform` — Terraform и bare-metal

> **ArgoCD (`sova-deploy`)** применяет только Kubernetes-манифесты и Helm. **Patroni, HAProxy, Keepalived, etcd** на prod-db-VM — **не** в `sova-deploy`, а здесь.

```text
sova-platform/
├── terraform/
│   ├── modules/
│   │   ├── prod-db/                 # Patroni + etcd + HAProxy + VIP (SSH, provider-agnostic)
│   │   ├── k3s/                     # установка k3s на test/stage/prod-app
│   │   ├── server/                  # опционально: VM через API провайдера
│   │   ├── dns/
│   │   └── backup/
│   ├── envs/
│   │   ├── test-server/
│   │   ├── stage-server/
│   │   ├── prod-app/
│   │   └── prod-db/                 # 2× PG + witness (см. terraform/envs/prod-db в монорепо)
│   └── global/
│       └── versions.tf
├── ansible/
│   ├── inventory/prod-db/
│   └── playbooks/
│       ├── patroni-cluster.yml      # альтернатива remote-exec в Terraform
│       ├── haproxy-keepalived.yml
│       └── templates/               # patroni.yml, haproxy.cfg, keepalived.conf
├── bootstrap/
│   ├── 01-hardening.sh
│   ├── 02-install-k3s.sh
│   └── 03-install-helm-charts.sh
```

В монорепо уже есть стартовый модуль: [`terraform/modules/prod-db`](../../terraform/modules/prod-db/README.md).

---

## 5. Terraform: пошаговая реализация

### 5.1. Providers и remote state (критично)

**Проблема «курицы и яйца»:** если Terraform state лежит на том же сервере, что и k3s/Gitea/MinIO, при падении сервера вы не сможете восстановить инфраструктуру — state недоступен вместе с кластером.

**Правило:** state для **базовой инфраструктуры** (VPS/k3s bootstrap) хранится **только снаружи** кластера и сервера:

| Вариант | Когда использовать |
|---------|-------------------|
| **Yandex Object Storage** (S3-compatible) | Основной вариант для RU |
| AWS S3 / Backblaze B2 | Если уже есть аккаунт |
| **Terraform Cloud / HCP Terraform** | Managed backend, locking из коробки |
| Приватный Git **без секретов** | Только как временный fallback; locking вручную |

**Не использовать** как единственный backend: Gitea на том же сервере, MinIO внутри k3s, локальный файл на VPS без репликации.

```hcl
# terraform/envs/test-server/backend.tf
terraform {
  required_version = ">= 1.6"
  required_providers {
    null    = { source = "hashicorp/null", version = "~> 3.2" }
    helm    = { source = "hashicorp/helm", version = "~> 2.12" }
    kubectl = { source = "alekc/kubectl", version = "~> 2.0" }
  }

  backend "s3" {
    bucket   = "sova-terraform-state"
    key      = "envs/test-server/terraform.tfstate"
    region   = "ru-central1"
    endpoints = { s3 = "https://storage.yandexcloud.net" }

    skip_credentials_validation = true
    skip_region_validation      = true
    skip_requesting_account_id  = true
  }
}
```

Credentials для backend (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` для Yandex S3) — в CI/CD secrets или локально в `~/.aws/credentials`, **не** в state file.

> State для **Helm releases внутри уже работающего k3s** можно держать в том же remote backend (отдельный `key`), но recovery-сценарий всегда начинается с внешнего state bootstrap-модуля.

### 5.2. Модуль `server` (существующий VPS)

```hcl
# terraform/modules/k3s-single-node/main.tf
variable "server_ip" {}
variable "ssh_user" { default = "root" }
variable "k3s_version" { default = "v1.30.4+k3s1" }
variable "cluster_domain" { default = "sova.dev" }

resource "null_resource" "k3s_install" {
  connection {
    type        = "ssh"
    host        = var.server_ip
    user        = var.ssh_user
    private_key = file(var.ssh_private_key_path)
  }

  provisioner "remote-exec" {
    script = "${path.module}/scripts/install-k3s.sh"
    environment = {
      K3S_VERSION     = var.k3s_version
      CLUSTER_DOMAIN  = var.cluster_domain
    }
  }
}
```

Скрипт `install-k3s.sh` должен:

1. Отключить swap, настроить `br_netfilter`, iptables.
2. Установить k3s: `curl -sfL https://get.k3s.io | INSTALL_K3S_VERSION=... sh -`.
3. Положить kubeconfig в `/root/.kube/config`.
4. Установить `kubectl`, `helm`.
5. Включить **Traefik disable** (`--disable traefik`) — используем ingress-nginx.
6. Включить **local-path-provisioner** (default в k3s).

### 5.3. Platform через Terraform Helm releases

**Ingress на bare metal (k3s):** не нужны NodePort + iptables + внешний nginx на хосте. k3s включает **ServiceLB (Klipper LoadBalancer)** — при `type: LoadBalancer` порты **80 и 443** автоматически биндятся на IP сервера.

```hcl
resource "helm_release" "ingress_nginx" {
  name             = "ingress-nginx"
  repository       = "https://kubernetes.github.io/ingress-nginx"
  chart            = "ingress-nginx"
  namespace        = "ingress"
  create_namespace = true

  set {
    name  = "controller.service.type"
    value = "LoadBalancer"   # k3s ServiceLB → 80/443 на IP ноды
  }
  set {
    name  = "controller.service.externalTrafficPolicy"
    value = "Local"          # сохранить client IP для rate-limit / logs
  }
}
```

**Альтернатива** (если ServiceLB недоступен): `DaemonSet` + `hostNetwork: true` — ingress слушает 80/443 напрямую на каждой ноде.

Traefik в k3s **отключён** при установке (`--disable traefik`) — единственный edge ingress.

> MetalLB и NodePort 30080/30443 — **не нужны** на single-node k3s с ServiceLB.

### 5.4. Terraform outputs

```hcl
output "kubeconfig_path" { value = "/root/.kube/config" }
output "argocd_url" { value = "https://argocd.sova.dev" }
output "gitea_url" { value = "https://git.sova.dev" }
```

### 5.5. Порядок apply

```mermaid
flowchart TD
  A[terraform apply: k3s install] --> B[helm: sealed-secrets]
  B --> C[helm: cert-manager + ClusterIssuer]
  C --> D[helm: ingress-nginx]
  D --> E[helm: argocd]
  E --> F[helm: kube-prometheus-stack]
  F --> G[helm: gitea]
  G --> H[push sova-deploy → argocd app-of-apps]
  H --> I[ArgoCD sync platform + apps]
```

---

## 6. Platform layer (детально)

### 6.1. ingress-nginx

- Аналог текущего `infrastructure/nginx` edge.
- **k3s ServiceLB:** chart с `controller.service.type: LoadBalancer` — порты 80/443 на IP сервера без NodePort/iptables (см. §5.3).
- Один IngressClass `nginx` для всех env.
- Анnotations для rate-limit, body-size (upload файлов backend).
- Метрики: ServiceMonitor → Prometheus (замена scrape `nginx:88` из Docker).

**Маппинг доменов test:**

| Ingress host | Service | Порт |
|--------------|---------|------|
| `api.test.sova.dev` | `backend-test` | 80 |
| `admin.test.sova.dev` | `adminpanel-test` | 80 |
| `argocd.sova.dev` | `argocd-server` | 80 |
| `grafana.sova.dev` | `kube-prometheus-grafana` | 80 |
| `git.sova.dev` | `gitea-http` | 3000 |

### 6.2. cert-manager

```yaml
# ClusterIssuer Let's Encrypt staging (для отладки)
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-staging
spec:
  acme:
    server: https://acme-staging-v02.api.letsencrypt.org/directory
    email: devops@sova.dev
    privateKeySecretRef:
      name: letsencrypt-staging
    solvers:
      - http01:
          ingress:
            class: nginx
```

После проверки — production issuer. **До выдачи домена** используйте:

- self-signed ClusterIssuer + импорт CA в браузер;
- или **nip.io** / **sslip.io**: `api.test.192-168-1-100.sslip.io`.

### 6.3. ArgoCD

Установка через Helm chart `argo/argo-cd`:

- Ingress: `argocd.sova.dev`
- SSO: позже через Gitea OAuth
- Репозитории: `sova-deploy` (SSH deploy key read-only)
- **App of Apps** паттерн (см. раздел 9)
- Image Updater (опционально): автобамп тегов — или только CI меняет values

```yaml
# argocd/app-of-apps.yaml
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: sova-root
  namespace: argocd
spec:
  project: default
  source:
    repoURL: git@gitea.sova.dev:sova/sova-deploy.git
    targetRevision: main
    path: argocd/apps
  destination:
    server: https://kubernetes.default.svc
    namespace: argocd
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
```

### 6.4. Sealed Secrets / External Secrets

Секреты **не коммитить** открытым текстом.

| Секрет | Ключи (из backend `.env`) |
|--------|---------------------------|
| `backend-env-test` | `DATABASE_URL`, `REDIS_URL`, `APP_SECRET`, `JWT_*`, `MIS_URL`, ... |
| `backend-env-stage` | те же, другие значения |
| `registry-credentials` | pull from Gitea Container Registry |

Workflow:

1. `kubeseal` → зашифрованный SealedSecret в Git.
2. ArgoCD применяет → controller расшифровывает в обычный Secret.

**Бэкап master key Sealed Secrets (критично для DR):**

При пересоздании кластера controller **генерирует новый** приватный ключ. Все SealedSecret из Git перестанут расшифровываться, если ключ не восстановить **до** sync ArgoCD.

```bash
# Сразу после установки controller (Фаза 1/2):
kubectl get secret -n kube-system \
  -l sealedsecrets.bitnami.com/sealed-secrets-key \
  -o yaml > sealed-secrets-master-key-BACKUP.yaml

# Сохранить в менеджер паролей / offline vault — НЕ в публичный Git
```

**При восстановлении кластера:**

1. Установить sealed-secrets controller.
2. Применить backup Secret **до** деплоя приложений с SealedSecrets.
3. Проверить: `kubectl get sealedsecret -A` → статус `Synced`.
4. Только потом включать ArgoCD sync apps.

Ротация ключа: [официальный re-encryption flow](https://github.com/bitnami-labs/sealed-secrets#sealing-key-renewal) — планировать раз в год, не при каждом rebuild.

---

## 7. Data layer (PostgreSQL, Redis, Bitrix MySQL)

### 7.1. PostgreSQL по контурам

| Контур | Топология | Решение |
|--------|-----------|---------|
| **test** | 1 инстанс | Bitnami chart или CNPG `instances: 1` |
| **stage** | 1 инстанс | То же, отдельный сервер |
| **prod** | **primary + sync replica + auto failover** | §7.6 |

| Вариант (test/stage) | Плюсы | Минусы |
|---------|-------|--------|
| **A. Helm Bitnami PostgreSQL** | Просто, как в Compose | RAM, бэкапы сами |
| **B. CloudNativePG** `instances: 1` | Единый оператор с prod | Сложнее на test |
| **C. Managed PG в облаке** | Надёжно | Доп. cost |

**Рекомендация test/stage:** Bitnami PostgreSQL chart (`pg-test`, `pg-stage`).

### 7.2. Схема БД test

```sql
-- Аналог local/postgres/init
CREATE DATABASE sova_backend_test;
CREATE DATABASE sova_cabinet_test;  -- когда подключим cabinet
CREATE USER sova_test WITH PASSWORD '...';
GRANT ALL ON DATABASE sova_backend_test TO sova_test;
```

Миграции: Symfony `doctrine:migrations:migrate` через ArgoCD **PreSync Job** (см. §7.2.1).

### 7.2.1. Миграции БД и откат (подводные камни)

**PreSync Job** — правильный паттерн, но с ограничениями:

| Риск | Что делать |
|------|------------|
| Тяжёлая миграция (индекс на миллионах строк, >5 мин) | ArgoCD sync timeout — увеличить `timeout.reconciliation` / `syncOptions` для Job; тестировать миграции на копии prod |
| PreSync Job упал | Деплой **останавливается** — это ожидаемо и правильно |
| **Rollback ArgoCD** | Откатывает только **stateless** манифесты (Deployment, ConfigMap). **Схема БД не откатывается автоматически** |
| Откат после failed migration | `doctrine:migrations:execute --down VERSION` вручную или restore PG из snapshot |

```yaml
# argocd Application — фрагмент syncPolicy
syncPolicy:
  syncOptions:
    - CreateNamespace=true
  hooks:
    - type: PreSync
      # Job: php bin/console doctrine:migrations:migrate --no-interaction
      # activeDeadlineSeconds: 600  # для тяжёлых миграций
```

**Правило:** rollback в ArgoCD = откат **кода**, не **данных**. Для test допустим `pg_dump` перед каждым major release; для prod — обязателен backup + documented down-migration.

### 7.3. Redis

Bitnami Redis chart, auth enabled (`REDIS_PASSWORD` → Secret).

### 7.4. Bitrix MySQL (обязательно для полного backend test)

Backend читает Bitrix **напрямую через MySQL** (`BitrixService`, sync отзывов) и через **SQL views** в PostgreSQL (`view_news`, `view_promo`, … → таблицы `b_iblock_*`).

**Test-контур — новый инстанс**, не prod Bitrix:

- Helm `bitnami/mysql` в `sova-data-test`;
- init SQL из `local/mysql-bitrix/init/` (seed);
- `DATABASE_BITRIX_URL` в SealedSecret;
- опционально **mysql_fdw** в test-PG (как на prod) — см. [§1.3 в backend-external-services.md](./backend-external-services.md).

### 7.5. Внешние HTTP-сервисы и заглушки (test)

Backend вызывает MIS (Инфоклиника), SmartCaptcha, почту, Calltouch, Bitrix HTTP (картинки). **SMS и почта в test — заглушки**; **БД — свои инстансы**; остальное — по матрице ниже.

| Сервис | Test | Stage | Prod |
|--------|------|-------|------|
| MIS / widget API | Mock в `sova-mocks` | Sandbox или mock | Prod MIS |
| SMS (sms.ru, sms4b) | Noop (код пока не вызывается) | Noop | Live |
| Почта | Mailpit / `null://null` | Sandbox SMTP | Live SMTP |
| SmartCaptcha | Mock (always OK) | Mock | Yandex API |
| Calltouch | Noop (в коде вызов закомментирован) | Noop | Live |
| Bitrix HTTP (images) | Static mock nginx | Stage site | Prod |

**Реализация mock-слоя:**

1. Namespace `sova-mocks` — Helm releases: `mis-mock` (WireMock + JSON fixtures), `mailpit`, `captcha-mock`, `bitrix-http-mock`.
2. Env backend test: `MIS_URL`, `BITRIX_URL`, `SMARTCAPTCHA_URL`, `MAILER_DSN` → Cluster DNS (`*.sova-mocks.svc.cluster.local`).
3. Репозиторий `sova-mocks` (рядом с `sova-deploy`) — fixtures для расписания и anonymous-reserve.
4. **Техдолг до выката:** hardcoded `https://widget.sovamed.ru` в `UploadFilialsCommand` / `UploadPrice*` — mock должен отвечать на те же пути или команды рефакторятся на env.

Подробности, env-фрагмент SealedSecret и чек-лист: **[backend-external-services.md](./backend-external-services.md)**.

### 7.6. PostgreSQL HA — prod only (master + replica, sync, failover)

> **Только prod.** Test и stage — один PG-инстанс (§7.1). MySQL Bitrix, Redis — **не** входят в эту схему.

#### 7.6.1. Что значит «сквозная запись в две БД»

**Не делать:** двойной `INSERT` из Symfony в два `DATABASE_URL` — race conditions, split-brain, невозможность транзакций.

**Правильно:** **синхронная streaming-репликация PostgreSQL**:

1. Backend пишет **только в primary** (один connection string).
2. Primary перед `COMMIT` **ждёт подтверждения от sync replica**, что WAL записан (`synchronous_commit = on`).
3. Данные **гарантированно** на обоих узлах после успешного commit.
4. При падении primary **Patroni / CNPG / Managed PG** promote replica → новый primary за **30–120 сек**.
5. Backend переподключается через **стабильный endpoint** (PgBouncer, VIP, CNPG `-rw` Service) — не напрямую на IP одной VM.

```mermaid
sequenceDiagram
  participant BE as backend pod
  participant RW as PgBouncer rw
  participant P as PG Primary
  participant R as PG Replica sync

  BE->>RW: INSERT / COMMIT
  RW->>P: SQL
  P->>R: WAL stream sync
  R-->>P: flush ack
  P-->>RW: COMMIT ok
  RW-->>BE: ok

  Note over P: primary down
  R->>R: Patroni promote
  BE->>RW: reconnect
  RW->>R: new primary
```

#### 7.6.2. Варианты реализации (prod)

| Вариант | Где крутится | Плюсы | Минусы |
|---------|--------------|-------|--------|
| **A. Patroni + etcd** на **2 db-VM** | prod-db-1, prod-db-2 (вне k3s) | Изолированные IOPS, привычный ops, extensions (pg_cron, mysql_fdw) | etcd 3-й узел или witness |
| **B. CloudNativePG** | operator на prod-app k3s, PG pods anti-affinity на 2 db-VM | GitOps-native, auto failover | PG в k8s + storage class; extensions проверить |
| **C. Yandex Managed PG HA** | managed cloud | Меньше ops | cost, сеть до prod-app, extensions |

**Рекомендация для Sova:** **Вариант A (Patroni на 2 db-VM)** — prod уже на VM с PG; extensions (`pg_cron`, `mysql_fdw`, `firebird_fdw`) критичны; apps на k3s **prod-app** подключаются к Patroni VIP.

**Terraform-модуль (provider-agnostic, SSH):** [`terraform/modules/prod-db`](../../terraform/modules/prod-db/README.md) + окружение [`terraform/envs/prod-db`](../../terraform/envs/prod-db/). Серверы заказываются у любого хостера (Selectel и т.д.), модуль **не** вызывает API провайдера.

#### 7.6.3. Patroni topology (рекомендуемый)

```text
prod-db-1 (192.168.x.10)          prod-db-2 (192.168.x.11)
┌─────────────────────┐          ┌─────────────────────┐
│ PostgreSQL 16       │  sync    │ PostgreSQL 16       │
│ Patroni             │◄────────►│ Patroni             │
│ role: primary       │  WAL     │ role: replica       │
└──────────┬──────────┘          └──────────┬──────────┘
           │                                 │
           └──────────┬──────────────────────┘
                      │
              ┌───────▼────────┐
              │ HAProxy / VIP  │  :5432 rw → primary only
              │ (или PgBouncer)│  :5433 ro → replica (опц.)
              └───────▲────────┘
                      │
              prod-app k3s (backend pods)
```

**etcd / DCS (критично):** quorum **3 узла** — `prod-db-1`, `prod-db-2` + **отдельная witness-VM** (1 vCPU / 1 GB RAM, без k3s, без PostgreSQL).

| Размещение witness | Вердикт |
|--------------------|---------|
| **Отдельная крошечная VM** | ✅ Рекомендуется |
| **prod-app** (k3s) | ❌ **Запрещено** — ребут/обновление k3s → потеря etcd quorum → Patroni **demote primary** → БД read-only |
| Только 2 db-узла без 3-го | ❌ Нет quorum при падении одного db |

**Если witness на prod-app неизбежен** (не рекомендуется): перед любым обслуживанием prod-app — `patronictl pause` на кластере; после — `patronictl resume`. Зафиксировано в §19.

Альтернатива bare VM: Patroni с **Kubernetes DCS** только если PG внутри k8s (CloudNativePG, §7.6.4).

**PostgreSQL config (фрагмент):**

```ini
# postgresql.conf — primary
synchronous_commit = on
synchronous_standby_names = 'FIRST 1 (prod-db-2)'
wal_level = replica
max_wal_senders = 5
```

```ini
# postgresql.conf — replica
hot_standby = on
```

#### 7.6.4. CloudNativePG (альтернатива, если PG в GitOps)

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: sova-pg-prod
  namespace: sova-data-prod
spec:
  instances: 2
  primaryUpdateStrategy: unsupervised
  postgresql:
    parameters:
      synchronous_commit: "on"
  minSyncReplicas: 1
  maxSyncReplicas: 1
  storage:
    size: 120Gi
  affinity:
    enablePodAntiAffinity: true
    topologyKey: kubernetes.io/hostname
```

Backend `DATABASE_URL`:

```text
postgresql://sova:***@sova-pg-prod-rw.sova-data-prod.svc:5432/sova_backend
```

`-rw` Service переключается на новый primary после failover. **CNPG pods должны быть на разных физических нодах** — для этого 2 db-VM как worker nodes или dedicated node pool.

#### 7.6.5. Backend connection и PgBouncer (prod)

**Два endpoint — два режима pooler** (типичная боль Symfony + PgBouncer):

| Порт | Режим PgBouncer | Кто подключается | Зачем |
|------|-----------------|------------------|-------|
| **6432** | `pool_mode = transaction` | `DATABASE_URL` backend (php-fpm, API) | Короткие HTTP-запросы, высокая плотность соединений |
| **5432** | `session` **или** напрямую PostgreSQL/VIP | PreSync migrate Job, Symfony Messenger worker, `doctrine:migrations:*` | Advisory locks, prepared statements, long transactions |

```yaml
# SealedSecret backend-env-prod (фрагмент)
DATABASE_URL: postgresql://sova_prod:***@pg-prod.sova.internal:6432/sova_backend?sslmode=prefer
# API / php-fpm → PgBouncer transaction mode

DATABASE_MIGRATE_URL: postgresql://sova_prod:***@pg-prod.sova.internal:5432/sova_backend?sslmode=prefer
# migrate-job.yaml и messenger worker → session mode или VIP минуя transaction pooler
```

```yaml
# apps/backend/templates/migrate-job.yaml — фрагмент
env:
  - name: DATABASE_URL
    valueFrom:
      secretKeyRef:
        name: backend-env
        key: DATABASE_MIGRATE_URL   # не 6432 transaction pool
```

`MESSENGER_TRANSPORT_DSN=doctrine://default` при Doctrine transport — worker Deployment тоже на **5432 / session**, не на 6432.

**VIP:** `pg-prod.sova.internal` → Keepalived VIP на db-узлах (HAProxy). PgBouncer можно co-locate на db-VM или prod-app — оба порта слушают на одном DNS.

Symfony + Doctrine: **не один DSN на всё**. Rollback / failover — см. §7.6.6; `pdo_pgsql` reconnect после switchover.

**PHP-FPM:** короткий `statement_timeout`; при `connection lost` — 503 на 1–2 запроса во время switchover; readiness probe проверяет PG rw.

#### 7.6.6. Failover и RPO/RTO

| Метрика | Sync replication | Async (не для prod Sova) |
|---------|------------------|--------------------------|
| **RPO** (потеря данных) | **0** при 1 sync standby | секунды–минуты WAL |
| **RTO** (время восстановления) | **1–3 мин** (Patroni + k8s rollout) | то же |
| Commit latency | +1–5 ms (RTT до replica) | ниже |

При падении **обоих** db-узлов — restore из **WAL-G / pg_dump** в Object Storage (§7.6.7).

#### 7.6.7. Бэкапы prod PG (дополнение к репликации)

Реплика **не заменяет** backup (удаление таблицы реплицируется на standby).

| Метод | Частота | Хранение |
|-------|---------|----------|
| `pg_dump -Fc` CronJob / systemd timer | daily | S3 / Yandex Object Storage 30d |
| WAL archiving (pgBackRest / WAL-G) | continuous | S3, PITR |
| Тест restore | monthly | на stage PG |

#### 7.6.8. Миграция с текущей prod-VM (12 GB / single PG)

```mermaid
flowchart LR
  A[Prod VM today single PG] --> B[Поднять prod-db-1 + prod-db-2 Patroni]
  B --> C[pg_basebackup / logical dump restore]
  C --> D[Sync replica catch-up]
  D --> E[Cutover DATABASE_URL на VIP]
  E --> F[prod-app k3s backend]
  A --> F
```

1. Поднять **prod-db-1**, **prod-db-2** (§3.4).
2. `pg_dump` с текущей VM → restore на Patroni primary.
3. Подключить sync replica, дождаться `pg_stat_replication.sync_state = sync`.
4. Короткое окно maintenance: stop writes на старой VM → final sync → switch `DATABASE_URL` на VIP.
5. Поднять **prod-app** k3s, направить traffic на новый backend.
6. Старую VM держать read-only **7 дней** как fallback.

#### 7.6.9. Мониторинг и алерты (prod PG)

| Alert | Expr / check |
|-------|----------------|
| Replication lag | `pg_replication_lag_seconds > 10` |
| Sync standby lost | `sync_standby_names` пуст, только async |
| Patroni no leader | `patroni_master == 0` |
| Disk > 80% | node filesystem on db-VM |

Grafana dashboard: postgres_exporter на **обоих** db-узлах.

#### 7.6.10. Чек-лист prod PG HA

- [ ] 2 db-VM в разных зонах / стойках (если возможно)
- [ ] `synchronous_commit = on`, `minSyncReplicas: 1` или Patroni sync
- [ ] Backend **не** использует IP одной ноды — только VIP / `-rw`
- [ ] etcd quorum: **3 узла**, witness на **отдельной** VM (не prod-app)
- [ ] Failover drill на stage **не подходит** (1 PG) — отдельный drill на prod-db или ephemeral cluster
- [ ] pg_dump + WAL в S3, restore проверен
- [ ] Extensions `pg_cron`, `mysql_fdw` работают на primary после migrate

---

## 8. Приложения: образы и Helm

### 8.1. backend — Dockerfile (создать в `sova-backend`)

Опираться на `infrastructure/php-8.4/Dockerfile` + план из `DEPLOYMENT_PLAN.md` §4.1.

**Pod layout (sidecar):**

```mermaid
flowchart LR
  subgraph pod [Pod backend-test-xxx]
    NG[nginx container :8080]
    PHP[php-fpm container :9000]
    NG -->|fastcgi 127.0.0.1:9000| PHP
  end
  ING[Ingress] --> NG
  PHP --> PG[(PostgreSQL)]
  PHP --> MY[(MySQL Bitrix)]
  PHP --> RD[(Redis)]
  PHP --> MIS[Mock MIS test-only]
```

> **Prod:** PostgreSQL **вне** pod — подключение к Patroni VIP (§7.6). На test/stage — PG в кластере или на том же сервере.

Конфиг nginx для sidecar — упрощённый из `infrastructure/nginx/prod/conf.d` для `api.sovamed.ru` (только `/public` + fastcgi).

### 8.2. adminPanel — Dockerfile

Multistage: `node:24-alpine` build → `nginx:alpine` runtime.

**Runtime env без пересборки:**

```javascript
// public/env.js — подменяется через ConfigMap + initContainer
window.__ENV__ = {
  API_BASE_URL: "https://api.test.sova.dev"
};
```

### 8.3. CronJobs (миграция из `scripts/`)

| Docker/cron | K8s CronJob | Расписание |
|-------------|-------------|------------|
| `scripts/cron.hourly.sh` → upload:doctors, upload:deps | `backend-sync-doctors` | `0 1 * * *` |
| `scripts/cron.oncyday.sh` → bitrix-update-reviews | `backend-sync-reviews` | `0 3 * * *` |
| `ClearScheduleCacheCommand` | `backend-clear-schedule-cache` | `0 */6 * * *` |

**CronJobs и внешние зависимости:** sync-команды (`upload:doctors`, `bitrix-update-reviews`, `upload:news`, …) требуют **test MySQL Bitrix** и **mock MIS**. На первом выкате test включить только `ClearScheduleCacheCommand`; остальные — после готовности mock-слоя (см. [backend-external-services.md §6](./backend-external-services.md)).

```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: backend-upload-doctors
  namespace: sova-test
spec:
  schedule: "0 1 * * *"
  timeZone: "Europe/Moscow"
  concurrencyPolicy: Forbid   # не запускать вторую копию, пока первая не завершилась
  successfulJobsHistoryLimit: 3
  failedJobsHistoryLimit: 3
  jobTemplate:
    spec:
      activeDeadlineSeconds: 3600   # kill зависшую job через 1 ч
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: console
              image: registry.sova.dev/sova/backend:{{ .Values.image.tag }}
              command: ["php", "bin/console", "upload:doctors"]
              envFrom:
                - secretRef: { name: backend-env }
```

> `concurrencyPolicy: Forbid` обязателен для sync-кронов (`bitrix-update-reviews`, `upload:*`), иначе при зависшей БД параллельные job'ы начнут мешать друг другу.

---

## 9. CI/CD через Gitea Actions

### 9.1. Почему Gitea Actions, а не Jenkins

| Jenkins (as-is) | Gitea Actions |
|-----------------|---------------|
| Scaffold, shared libs отсутствуют | YAML в репозитории приложения |
| Отдельный контейнер + docker.sock | Runner in K8s с **Kaniko/Buildah** (k3s = containerd, не docker.sock) |
| Не в `make dev` | Native интеграция с Gitea |

### 9.2. Gitea setup

1. Helm chart `gitea/gitea` в namespace `gitea`.
2. Ingress `git.sova.dev`, TLS via cert-manager.
3. Включить **Actions** (`ENABLED_ACTIONS=true`).
4. Зарегистрировать **act_runner** (Kubernetes executor).

**act_runner: ресурсы и сборка образов**

k3s использует **containerd**, монтировать `docker.sock` в runner **нельзя**. Для build/push образов:

| Инструмент | Когда |
|------------|-------|
| **Kaniko** | Сборка в pod без privileged; push в Gitea CR |
| **Buildah** | Альнатива Kaniko, rootless |
| Runner на **отдельной VM** | Если CI нагрузка критична для prod pods |

```yaml
# act_runner в K8s — обязательные limits (иначе npm/composer install «положит» кластер)
resources:
  requests:
    cpu: "500m"
    memory: "1Gi"
  limits:
    cpu: "2"
    memory: "4Gi"
```

Рекомендация: **отдельный node pool / taint** `ci=true:NoSchedule` для runner pods, когда появится второй сервер. На single-node — limits + `priorityClassName` ниже, чем у backend.

```yaml
# gitea-values.yaml (фрагмент)
gitea:
  config:
    actions:
      ENABLED: true
    repository:
      DEFAULT_BRANCH: main
service:
  http:
    port: 3000
ingress:
  enabled: true
  hosts:
    - host: git.sova.dev
```

### 9.3. Container Registry

**Вариант A:** встроенный Gitea Container Registry (`git.sova.dev/sova/backend`).

**Вариант B:** Harbor (тяжелее, но удобнее для retention policies).

**Retention policy (обязательно с первого дня):**

CI пушит образ на каждый тег (`backend-v*-test`). PHP + Node образы быстро съедают **200 GB** диска.

| Мера | Настройка |
|------|-----------|
| Gitea CR cleanup | Admin → Packages → retention: хранить последние N тегов per repo |
| CronJob в кластере | Удалять untagged manifests старше 7 дней |
| CI | Не пушить `:latest` на каждый commit — только semver-теги |

```yaml
# Пример CronJob registry-gc (Harbor / или скрипт к Gitea API)
# DELETE untagged + keep last 10 tags per image
```

**Pull образов на stage/prod (центральный Registry на test-сервере):**

Gitea Container Registry живёт на **test** (`git.sova.dev`). k3s на stage/prod **по умолчанию не может** pull приватных образов.

1. Gitea → Settings → Applications → **Generate token** (read:package) для robot `sova-pull`.
2. На **stage** и **prod** создать Secret (или SealedSecret в `sova-deploy`):

```yaml
apiVersion: v1
kind: Secret
metadata:
  name: gitea-registry-secret
  namespace: sova-stage   # или sova-prod
type: kubernetes.io/dockerconfigjson
data:
  .dockerconfigjson: <base64 docker config for git.sova.dev>
```

3. В `values-stage.yaml` / `values-prod.yaml`:

```yaml
imagePullSecrets:
  - name: gitea-registry-secret

image:
  repository: git.sova.dev/sova/backend
  tag: backend-v1.0.0-stage
```

4. Firewall: stage/prod-app → test-server **443** (Registry API).

См. чек-лист §20 (stage/prod).

### 9.4. Pipeline backend (`.gitea/workflows/build.yml`)

```yaml
name: backend-ci-cd
on:
  push:
    tags:
      - 'backend-v*'
  pull_request:
    branches: [main]

env:
  REGISTRY: git.sova.dev
  IMAGE: git.sova.dev/sova/backend

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Setup PHP
        uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          extensions: pdo_pgsql, redis, intl, zip, gd
      - run: composer install --prefer-dist
      - run: composer phpunit
      - run: composer audit

  build-and-push:
    needs: test
    if: startsWith(github.ref, 'refs/tags/backend-v')
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Parse tag
        id: meta
        run: |
          TAG="${GITHUB_REF#refs/tags/}"
          echo "full_tag=$TAG" >> $GITHUB_OUTPUT
          # backend-v1.2.3-test → env=test, version=1.2.3
          echo "env=$(echo $TAG | sed -E 's/backend-v([0-9.]+)-([a-z]+)/\2/')" >> $GITHUB_OUTPUT
          echo "version=$(echo $TAG | sed -E 's/backend-v([0-9.]+).*/\1/')" >> $GITHUB_OUTPUT
      - name: Docker login
        run: echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login $REGISTRY -u sova-ci --password-stdin
      - name: Build
        run: |
          docker build -f Dockerfile \
            -t $IMAGE:${{ steps.meta.outputs.full_tag }} \
            -t $IMAGE:${{ steps.meta.outputs.version }} \
            .
      - name: Push
        run: |
          docker push $IMAGE:${{ steps.meta.outputs.full_tag }}
          docker push $IMAGE:${{ steps.meta.outputs.version }}

  deploy-gitops:
    needs: build-and-push
    runs-on: ubuntu-latest
    steps:
      - name: Bump image tag in sova-deploy (with retry)
        env:
          DEPLOY_KEY: ${{ secrets.SOVA_DEPLOY_KEY }}
        run: |
          git clone git@gitea.sova.dev:sova/sova-deploy.git
          cd sova-deploy
          ENV="${{ steps.meta.outputs.env }}"
          TAG="${{ steps.meta.outputs.full_tag }}"
          git config user.email "ci-bot@sova.dev"
          git config user.name "sova-ci"

          MAX_RETRIES=5
          for attempt in $(seq 1 $MAX_RETRIES); do
            git pull --rebase origin main
            yq -i ".image.tag = \"${TAG}\"" "apps/backend/values-${ENV}.yaml"
            git add "apps/backend/values-${ENV}.yaml"
            git diff --cached --quiet && { echo "No changes"; exit 0; }
            git commit -m "chore(backend): bump ${ENV} to ${TAG}"
            if git push origin main; then
              echo "Push OK on attempt ${attempt}"
              exit 0
            fi
            echo "Push failed (race?), retry ${attempt}/${MAX_RETRIES}..."
            git reset --hard HEAD~1
            sleep $((attempt * 2))
          done
          echo "Failed to push after ${MAX_RETRIES} attempts"
          exit 1
```

> **Гонка состояний:** если два пайплайна пушат в `sova-deploy` одновременно, второй получит `non-fast-forward`. Цикл `pull --rebase` + retry обязателен (см. выше). Альтернатива — [gitops-promoter](https://github.com/argoproj-labs/gitops-promoter) или ArgoCD Image Updater без git commit из CI.

> ArgoCD подхватит изменение в `sova-deploy` за ~3 мин (poll) или мгновенно через webhook.

### 9.5. Pipeline adminPanel

Аналогично, tag pattern: `adminpanel-v1.0.0-test`.

Build-arg `VITE_API_BASE_URL` **не использовать** если перешли на runtime `env.js`.

---

## 10. Система тегирования и promotion

### 10.1. Формат тегов (SemVer + контур)

```text
{app}-v{MAJOR.MINOR.PATCH}-{env}

Примеры:
  backend-v1.4.2-test      → деплой в sova-test
  backend-v1.4.2-stage     → деплой в sova-stage
  backend-v1.4.2-prod      → деплой в sova-prod
  adminpanel-v2.0.0-test
```

**Правила:**

| Действие | Кто | Тег |
|----------|-----|-----|
| Feature готова к QA | разработчик | `backend-v1.5.0-test` |
| QA passed → stage | тимлид / CI manual | `backend-v1.5.0-stage` |
| Stage passed → prod | release manager | `backend-v1.5.0-prod` |
| Hotfix prod | release manager | `backend-v1.4.3-prod` |

### 10.2. Promotion flow (без пересборки образа)

```mermaid
sequenceDiagram
  participant Dev as Developer
  participant Gitea as Gitea CI
  participant CR as Container Registry
  participant Deploy as sova-deploy repo
  participant Argo as ArgoCD

  Dev->>Gitea: tag backend-v1.5.0-test
  Gitea->>Gitea: test + build
  Gitea->>CR: push backend:backend-v1.5.0-test
  Gitea->>Deploy: values-test.yaml tag=bump
  Argo->>Deploy: poll / webhook
  Argo->>Argo: sync sova-test

  Note over Dev,Argo: После QA — promotion без rebuild
  Dev->>Gitea: tag backend-v1.5.0-stage
  Gitea->>CR: retag same digest OR promote manifest
  Gitea->>Deploy: values-stage.yaml tag=bump
  Argo->>Argo: sync sova-stage
```

**Promotion policy:**

1. **Test:** автоматически по тегу `*-test`.
2. **Stage:** автоматически по тегу `*-stage` **или** manual approval job в CI.
3. **Prod:** только manual approval + тег `*-prod` + ArgoCD project restriction.

### 10.3. ArgoCD Projects — RBAC по контурам

```yaml
# argocd/projects/sova-project.yaml
apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: sova
  namespace: argocd
spec:
  destinations:
    - namespace: sova-test
      server: https://kubernetes.default.svc
    - namespace: sova-stage
      server: https://kubernetes.default.svc
    - namespace: sova-prod
      server: https://kubernetes.default.svc
  sourceRepos:
    - git@gitea.sova.dev:sova/sova-deploy.git
  roles:
    - name: test-deployer
      policies:
        - p, proj:sova:test-deployer, applications, sync, sova/backend-test, allow
    - name: prod-deployer
      policies:
        - p, proj:sova:prod-deployer, applications, sync, sova/backend-prod, allow
```

### 10.4. Rollback

1. **ArgoCD UI:** History → Rollback (откат Git commit в sova-deploy).
2. **Или:** новый тег с предыдущей версией `backend-v1.4.1-test`.
3. **Или:** `kubectl rollout undo` — аварийно, но GitOps desync.

**Важно:** rollback откатывает **Deployment/ConfigMap**, но **не миграции БД**. Если новая версия применила `doctrine:migrations:migrate`, откат кода оставит схему «впереди» кода — возможны runtime-ошибки. Варианты:

- `php bin/console doctrine:migrations:execute --down 'VersionXXX'` вручную;
- restore PostgreSQL из snapshot (test);
- forward-fix: новый релиз с совместимой миграцией.

См. §7.2.1.

---

## 11. Мониторинг по контурам

### 11.1. Замена текущего `monitoring/`

| Docker Compose | Kubernetes |
|----------------|------------|
| `prom/prometheus` | Prometheus Operator (kube-prometheus-stack) |
| `grafana/grafana-enterprise` | Grafana subchart |
| `node-exporter` | DaemonSet (встроен) |
| `php-fpm-exporter` | ServiceMonitor на backend pods |
| nginx `:88/metrics` | ServiceMonitor на ingress-nginx |
| rules `*.rules` (отсутствуют) | **PrometheusRule** CR — создать |

### 11.2. Labels для multi-env

Все ServiceMonitor / Pod labels:

```yaml
labels:
  app: backend
  env: test          # test | stage | prod
  team: sova
```

Grafana dashboards — переменная `$env` для фильтрации.

### 11.3. Минимальный набор алертов

```yaml
# PrometheusRule — backend-down
groups:
  - name: sova-apps
    rules:
      - alert: BackendDown
        expr: kube_deployment_status_replicas_available{deployment="backend", namespace=~"sova-.*"} == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Backend недоступен в {{ $labels.namespace }}"
      - alert: HighErrorRate
        expr: rate(nginx_ingress_controller_requests{status=~"5..", ingress=~"backend.*"}[5m]) > 0.1
        for: 5m
        labels:
          severity: warning
```

### 11.4. Логи (Loki)

- Promtail DaemonSet собирает логи всех pods в `sova-*` namespaces.
- Grafana datasource Loki + Explore.
- Замена `docker logs` для отладки.

**Retention и диск (обязательно):** без лимитов Loki съест оставшееся место на NVMe.

```yaml
# kube-prometheus-stack / loki values (фрагмент)
loki:
  limits_config:
    retention_period: 168h          # 7 дней
  compactor:
    retention_enabled: true
  storage:
    # local-path PVC с явным sizeLimit
    size: 10Gi
```

На single-node 200 GB: Loki **≤10–20 GB**, Prometheus **≤15 GB**, registry **≤50 GB** — остальное под PG и образы.

### 11.5. External monitoring (Bitrix server)

Сохранить scrape `192.168.2.11` из `monitoring/prometheus/prometheus.yml` как **additionalScrapeConfigs** в Helm values — если Bitrix-сервер доступен из k8s network.

---

## 12. Пошаговый план внедрения (фазы)

### Фаза 0 — Подготовка (1–2 дня)

- [ ] Заказ/получение сервера, SSH доступ, публичный IP
- [ ] **Remote Terraform state:** bucket Yandex Object Storage / S3 / Terraform Cloud (§5.1)
- [ ] Создать организацию/репы в Gitea (или поднять Gitea временно через docker на сервере)
- [ ] Зафиксировать плейсхолдер домена `sova.dev` в `/etc/hosts` для локальной проверки
- [ ] Написать production Dockerfile для backend и adminPanel
- [ ] Вынести `apps/` в отдельные Gitea-репозитории (если ещё не сделано)

### Фаза 1 — Bootstrap сервера + k3s (1 день)

- [ ] Terraform: `terraform apply` модуль k3s (state во **внешнем** backend)
- [ ] Проверка: `kubectl get nodes` → Ready
- [ ] Sealed-secrets controller
- [ ] **Бэкап Sealed Secrets master key** → offline vault (§6.4)

### Фаза 2 — Platform (2–3 дня)

- [ ] cert-manager + ClusterIssuer (staging)
- [ ] ingress-nginx с `type: LoadBalancer` (k3s ServiceLB, §5.3)
- [ ] ArgoCD + Ingress `argocd.sova.dev`
- [ ] kube-prometheus-stack + Grafana `grafana.sova.dev`
- [ ] Loki stack с **retention 7d** и лимитом PVC (§11.4)

### Фаза 3 — Gitea + Registry + CI (2–3 дня)

- [ ] Gitea Helm + `git.sova.dev`
- [ ] act_runner registered (Kaniko/Buildah, **requests/limits**, §9.2)
- [ ] **Registry retention policy** — cleanup старых тегов (§9.3)
- [ ] Создать repos: sova-backend, sova-adminpanel, sova-deploy, sova-platform
- [ ] CI secrets: REGISTRY_PASSWORD, SOVA_DEPLOY_KEY
- [ ] Первый успешный pipeline: build hello-world image
- [ ] deploy-gitops с **retry** на git push (§9.4)

### Фаза 4 — Test contour (3–5 дней)

- [ ] PostgreSQL (main + cabinet) + MySQL Bitrix + Redis в `sova-data-test`
- [ ] Mock-слой в `sova-mocks`: MIS (WireMock), Mailpit, captcha-mock
- [ ] SealedSecrets для `backend-env-test` (все URL → test DB + mocks, см. [backend-external-services.md §5.2](./backend-external-services.md))
- [ ] Helm chart backend + adminpanel
- [ ] ArgoCD Applications: backend-test, adminpanel-test, mocks-test
- [ ] Tag `backend-v0.1.0-test` → end-to-end deploy
- [ ] Smoke: login JWT, `/news/list`, anonymous-reserve через mock MIS + mock captcha
- [ ] CronJobs: сначала `ClearScheduleCacheCommand`; sync — после mock/Bitrix seed

### Фаза 5 — Stage contour (2–3 дня, **отдельный сервер**)

- [ ] Terraform: `stage-server` — k3s single-node (§3.3)
- [ ] PostgreSQL **single instance** на stage-сервере
- [ ] Pull образов с Gitea Registry (test-сервера)
- [ ] values-stage.yaml, ArgoCD apps на stage
- [ ] Tag `backend-v0.1.0-stage`

### Фаза 6 — Prod contour (**prod-app + 2× prod-db**, §3.4, §7.6)

- [ ] Terraform: `prod-db-1`, `prod-db-2` — Patroni + sync replication + HAProxy VIP
- [ ] Terraform: `prod-app` — k3s, apps **без** PG pod
- [ ] Миграция данных с текущей prod-VM (§7.6.8)
- [ ] `DATABASE_URL` → VIP / PgBouncer, **не** IP одной db-VM
- [ ] Failover drill: stop primary → backend восстанавливает запись < 3 min
- [ ] pg_dump + WAL → Object Storage; restore drill
- [ ] Production ClusterIssuer, HPA, PDB на prod-app
- [ ] On-call alerts (PG replication, Patroni leader, disk)

### Фаза 7 — Расширение приложений

- [ ] cabinet (php82 chart)
- [ ] sovamed (nextjs deployment)
- [ ] kiosk static site

---

## 13. Маппинг Docker Compose → Kubernetes

| Compose service | K8s resource | Примечание |
|-----------------|--------------|------------|
| nginx + php84 | Deployment backend (sidecar) | один pod |
| adminPanel dist | Deployment adminpanel | nginx static |
| pgsql | StatefulSet / Helm postgresql | per env (main + cabinet DB) |
| mysql-bitrix | Helm mysql | per env, seed `local/mysql-bitrix/init/` |
| redis | Helm redis | per env |
| mock HTTP (local dead URLs) | Deployments в `sova-mocks` | test only: MIS, captcha, mailpit |
| nextjs | Deployment sovamed | port 3001 |
| prometheus + grafana | kube-prometheus-stack | |
| jenkins | **удалить** | Gitea Actions |
| cron scripts | CronJob | |
| certbot | cert-manager | |
| internal-network | NetworkPolicy | deny cross-env |

---

## 14. NetworkPolicy (изоляция контуров)

Политики **разные per contour**. Пример ниже — **test** (БД внутри k8s). **Prod** — PostgreSQL **вне** кластера на prod-db-VM (§7.6).

### 14.1. Test (`sova-test`)

```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-ingress-only
  namespace: sova-test
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes: [Ingress, Egress]
  ingress:
    - from:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: ingress
  egress:
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: sova-data-test
      ports:
        - port: 5432
        - port: 3306
        - port: 6379
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: sova-mocks
      ports:
        - port: 8080
        - port: 1025
        - port: 8025
    - to:                          # DNS
        - namespaceSelector: {}
      ports:
        - port: 53
          protocol: UDP
    # Stage: egress :443 к whitelist (MIS sandbox, SMTP). Prod: см. §14.2.
```

### 14.2. Prod (`sova-prod`) — PostgreSQL вне k8s

На prod **нельзя** копировать test-политику с `namespaceSelector: sova-data-test` — backend не достучится до Patroni VIP.

```yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: backend-egress-prod
  namespace: sova-prod
spec:
  podSelector:
    matchLabels:
      app: backend
  policyTypes: [Egress]
  egress:
    # PostgreSQL HA — VIP и/или прямые IP db-VM (private VLAN)
    - to:
        - ipBlock:
            cidr: 10.0.1.20/32   # Keepalived VIP (pg-prod.sova.internal)
        - ipBlock:
            cidr: 10.0.1.10/32   # prod-db-1
        - ipBlock:
            cidr: 10.0.1.11/32   # prod-db-2
      ports:
        - port: 5432           # session / migrate / messenger
        - port: 6432           # PgBouncer transaction (если на db-VM)
    # MySQL Bitrix, Redis — на prod-app (in-cluster Service) или ipBlock
    - to:
        - namespaceSelector:
            matchLabels:
              kubernetes.io/metadata.name: sova-data-prod
      ports:
        - port: 3306
        - port: 6379
    # Registry на test-сервере
    - to:
        - ipBlock:
            cidr: 203.0.113.5/32  # IP test-сервера (git.sova.dev)
      ports:
        - port: 443
    # MIS, SMTP, captcha — prod whitelist
    - to:
        - ipBlock:
            cidr: 0.0.0.0/0
            except:
              - 10.0.0.0/8
              - 172.16.0.0/12
              - 192.168.0.0/16
      ports:
        - port: 443
    - to: {}
      ports:
        - port: 53
          protocol: UDP
```

> IP подставить из Terraform tfvars / private VLAN Selectel. DNS egress обязателен.

### 14.3. Stage

Как test (БД in-cluster) **плюс** egress `:443` к test Registry и sandbox-интеграциям.

---

## 15. Безопасность

1. **SSH:** только ключи, fail2ban, нестандартный порт опционально.
2. **k3s API:** не expose 6443 публично; kubectl через SSH tunnel.
3. **Secrets:** SealedSecrets, rotation раз в квартал.
4. **Registry:** private, robot account для CI.
5. **ArgoCD:** admin password → SSO Gitea; project RBAC.
6. **Pod security:** `runAsNonRoot`, drop capabilities, readOnlyRootFilesystem где возможно.
7. **Backup:** pg_dump nightly → S3/MinIO; etcd snapshot k3s.

---

## 16. Runbook: первый деплой backend test

```bash
# 1. На dev машине — создать тег
git tag backend-v0.1.0-test
git push origin backend-v0.1.0-test

# 2. CI (Gitea Actions) автоматически:
#    - тесты
#    - docker build + push git.sova.dev/sova/backend:backend-v0.1.0-test
#    - commit в sova-deploy apps/backend/values-test.yaml

# 3. ArgoCD (или дождаться auto-sync)
argocd app sync backend-test

# 4. Проверка
curl -s https://api.test.sova.dev/ | jq .
curl -s https://api.test.sova.dev/news/list?page=1&perPage=2

# 5. Миграции (если PreSync hook не настроен)
kubectl -n sova-test exec -it deploy/backend -- php bin/console doctrine:migrations:status
```

---

## 17. Runbook: promotion test → stage

```bash
# Вариант A — новый тег (рекомендуется)
git tag backend-v0.1.0-stage
git push origin backend-v0.1.0-stage

# Вариант B — manual bump без rebuild (same image digest)
cd sova-deploy
yq -i '.image.tag = "backend-v0.1.0-test"' apps/backend/values-stage.yaml
git commit -am "promote backend 0.1.0 test→stage"
git push
argocd app sync backend-stage
```

---

## 18. Связь с локальной разработкой

| Локально (`make local-up`) | Test K8s |
|----------------------------|----------|
| http://localhost:8081 | https://api.test.sova.dev |
| http://localhost:3211 | https://admin.test.sova.dev |
| `local/.env.local` | SealedSecret `backend-env-test` |
| `mock-*.local` (dead URLs) | Services в `sova-mocks` с реальными ответами |
| `make local-smoke` | CI job `smoke-test-test` после deploy |
| postgres :15432 | pg-test.sova-data-test:5432 |
| mysql-bitrix :13306 | mysql-bitrix-test.sova-data-test:3306 |

Разработчик **не деплоит руками** — только теги и MR в Gitea.

---

## 19. Риски и mitigations

| Риск | Mitigation |
|------|------------|
| Terraform state на упавшем сервере | Remote backend **вне** k3s (§5.1) |
| Sealed Secrets key потерян при DR | Бэкап master key в offline vault (§6.4) |
| Registry заполнил диск | Retention policy + CronJob GC (§9.3) |
| CI runner «положил» кластер | requests/limits, Kaniko, taint node для CI (§9.2) |
| Loki заполнил диск | retention 7d + PVC limit (§11.4) |
| Гонка CI в sova-deploy | pull --rebase + retry loop (§9.4) |
| Rollback не откатывает миграции | §7.2.1, manual down-migration или PG restore |
| PG single point of failure на prod | **2 db-VM**, sync replication + Patroni (§7.6) |
| Commit latency из-за sync replica | RTT db-1↔db-2 < 2 ms (один DC); мониторить lag |
| Failover 503 на API | PgBouncer + pod restart policy; health checks |
| **Ребут prod-app demote PG** | Witness **не** на prod-app; иначе `patronictl pause` перед обслуживанием (§7.6.3) |
| etcd witness на prod-app | Split-brain / read-only БД при restart k3s → **отдельная witness-VM** |
| stage/prod не pull образов | `imagePullSecrets` + firewall к test Registry (§9.3) |
| PgBouncer ломает migrations | `DATABASE_MIGRATE_URL` на :5432 session, API на :6432 transaction (§7.6.5) |
| Prod 12 GB RAM недостаточно для k3s+HA | §3.4: prod-app 16 GB, db nodes отдельно |
| Один сервер — SPOF | Бэкапы; test/stage/prod **разные** серверы |
| RAM не хватит на prod-app | §3.4: min 16 GB для k3s + apps; PG на отдельных db-VM |
| Bitrix MySQL недоступен из k8s | **Test:** свой MySQL в `sova-data-test`; prod — VPN/peering |
| Вызов prod MIS/SMS из test | NetworkPolicy + env только на `sova-mocks`; без egress :443 в test |
| Hardcoded widget.sovamed.ru в cron | Рефакторинг на env или fixtures на mock |
| Пустой backend Dockerfile | **Блокер фазы 0** |
| Gitea + мониторинг + apps на одном CPU | Мониторинг «lite» values, 30s scrape |
| Domain не готов | sslip.io / /etc/hosts / mkcert |

---

## 20. Чек-лист «готовность test-контура»

- [ ] `kubectl get nodes` — Ready
- [ ] `argocd.sova.dev` открывается, apps Synced
- [ ] `git.sova.dev` — репы созданы, CI runner online
- [ ] `grafana.sova.dev` — dashboards показывают backend pods
- [ ] `api.test.sova.dev/news/list` — 200 JSON
- [ ] `admin.test.sova.dev/login` — SPA грузится, API calls работают
- [ ] Tag `backend-v*-test` → auto deploy < 10 min
- [ ] Rollback через ArgoCD проверен
- [ ] Mock MIS: `/api/reservation/intervals` и `anonymous-reserve` — 200
- [ ] Mailpit: письмо из `/service/sendmail` видно в UI (Basic Auth или port-forward)
- [ ] Backend **не** резолвит prod `widget.sovamed.ru` / sms.ru (проверка egress)
- [ ] CronJob `ClearScheduleCacheCommand` отработал; sync CronJobs — по готовности mock/Bitrix
- [ ] Sealed Secrets master key **забэкаплен** offline (не только в кластере)
- [ ] Terraform state в **внешнем** S3/TFC
- [ ] Registry retention настроен, диск < 70% занят
- [ ] Loki retention 7d, PVC в пределах лимита
- [ ] Secrets не лежат в Git plaintext (только SealedSecrets)

### Чек-лист stage/prod (дополнительно)

- [ ] `imagePullSecrets` (`gitea-registry-secret`) в `sova-stage` / `sova-prod` — pull с test Registry (§9.3)
- [ ] `values-*.yaml`: `imagePullSecrets` + `image.repository: git.sova.dev/sova/...`
- [ ] NetworkPolicy prod: egress `ipBlock` к Patroni VIP / prod-db IP (§14.2), не только namespace

### Чек-лист prod (дополнительно)

- [ ] Patroni: 2 узла, `sync_state = sync`
- [ ] Failover drill: primary stop → writes OK через VIP
- [ ] `DATABASE_URL` (6432 transaction) и `DATABASE_MIGRATE_URL` (5432 session) в SealedSecret
- [ ] Witness etcd на **отдельной** VM, не на prod-app
- [ ] pg_dump restore проверен

---

## 21. Дальнейшее развитие

1. **Read replica** для тяжёлых отчётов (ro endpoint `:5433`) — не путать с sync standby.
2. **Managed K8s** (YC/Selectel) для prod-app — PG HA может остаться на Patroni db-VM.
3. **Cabinet + sovamed** — новые Helm charts по образцу backend.
4. **Service Mesh** — только при необходимости mTLS между сервисами.
5. **Preview environments** — ArgoCD Application per MR (на test-сервере).

---

## 22. Связанные документы

- [**Terraform: prod-db HA**](../../terraform/modules/prod-db/README.md) — Patroni + etcd + VIP, SSH-only
- [`DEPLOYMENT_PLAN.md`](../../DEPLOYMENT_PLAN.md) — детали Dockerfile, Yandex Cloud variant
- [`docs/infrastructure/docker.md`](./docker.md) — текущий Docker Compose (если есть)
- [`docs/apps/backend-scenarios/`](../apps/backend-scenarios/index.md) — бизнес-потоки backend
- [`docs/flows.md`](../flows.md) — потоки данных
- [`monitoring/prometheus/prometheus.yml`](../../monitoring/prometheus/prometheus.yml) — текущие scrape targets

---

*Документ версии 1.1. Контуры test/stage/prod — **разные серверы**. Prod PG: sync HA (§7.6). Плейсхолдер домена: `sova.dev`.*
