# Деплой на VPS — инструкция для агента

Пошаговый разворот GNexus Tasks (gntodo) на сервере. Рассчитана на агента:
каждый шаг — конкретная команда + ожидаемый результат; если результат другой —
см. «Диагностика» (в конце). Требования и состав стека — ТЗ 1.3 и 4; детали
правок — README «Деплой».

Состав стека (`docker-compose.yml`): `db` (PostgreSQL 17) → `api` (FastAPI, при
старте сам гоняет миграции alembic) → `web` (nginx: SPA-статика + прокси `/api/`,
`/auth/`, `/mcp/`). Порты наружу: только `web` на `${WEB_PORT:-8135}`. TLS — на
**внешнем reverse-proxy** VPS, контейнер за ним.

---

## 0. Предпосылки (проверить до начала)

```bash
docker --version && docker compose version   # ≥ 24, плагин compose
git --version
```

Домен уже указывает A-записью на сервер: `dig +short <домен>`.
Внешний reverse-proxy с TLS уже работает (наш стек за ним, см. шаг 5).
Свободный порт для `WEB_PORT` (по умолчанию 8135): `ss -tln | grep 8135` — пусто.

## 1. Код

```bash
git clone git@git.gnexus.space:root/gnexus-tasks.git /srv/gntodo && cd /srv/gntodo
# либо обновление существующего клона: git pull --ff-only
```

## 2. `.env`

```bash
cp .env.example .env
openssl rand -hex 32   # → SESSION_SECRET
```

Заполнить в `.env`:

| Переменная | Что поставить |
|---|---|
| `GAUTH_BASE_URL` | `https://auth.gnexus.space` (дефолт) |
| `GAUTH_CLIENT_ID` / `GAUTH_CLIENT_SECRET` | выдать/взять в панели SSO |
| `GAUTH_REDIRECT_URI` | `https://<домен>/auth/callback` — **обязан совпасть** с зарегистрированным в SSO; из него же выводится разрешённый Host для `/mcp` |
| `SESSION_SECRET` | вывод `openssl rand -hex 32`; **не** `change-me-*` — приложение не стартует с дефолтом |
| `MCP_TOKEN` | длинная случайная строка (`openssl rand -hex 24`); пусто — `/mcp` закрыт |
| `OLLAMA_BASE_URL` | адрес внешнего сервера Ollama, например `http://192.168.1.130:11434` |
| `OLLAMA_MODEL` | `qwen3.5:2b-q4_K_M` (дефолт) |
| `POSTGRES_PASSWORD` | `openssl rand -hex 16` (не дефолт `gntodo`) |
| `WEB_PORT` | 8135 или другой свободный |

`.env` в git не входит и не должен попадать.

## 3. Регистрация callback в SSO (ручной шаг)

В панели auth.gnexus.space зарегистрировать redirect URI
`https://<домен>/auth/callback` для client'а gntodo. Без этого шага логин
падает на этапе обмена кода. Если данных SSO нет — **остановиться и спросить
пользователя**.

## 4. Запуск

```bash
docker compose up -d --build
docker compose ps   # db — healthy, api — healthy (start_period 30s: миграции), web — Up
```

Миграции применяются автоматически при старте `api` (CMD контейнера:
`alembic upgrade head && uvicorn …`). Логи старта: `docker compose logs api | tail -30`.

## 5. Внешний reverse-proxy (TLS)

Проброс `https://<домен>` → `http://127.0.0.1:${WEB_PORT}`. Пример для nginx
на хосте:

```nginx
server {
    listen 443 ssl;
    server_name <домен>;
    # ssl_certificate / ssl_certificate_key — по правилам хоста (certbot и т.п.)

    client_max_body_size 12m;            # вложения до 10 МБ; без этого — 413

    location / {
        proxy_pass http://127.0.0.1:8135;
        proxy_set_header Host $host;     # КРИТИЧНО: Host не переписывать —
                                         # иначе /mcp ответит 421 (проверка Host)
        proxy_set_header X-Forwarded-Proto https;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_http_version 1.1;
        proxy_set_header Connection "";
    }
    # /api/ и /mcp/ — долгоживущие SSE: буферизацию отключить на внешнем
    # прокси тоже, иначе реактивность и MCP-агенты «застрянут».
    # Backend шлёт X-Accel-Buffering: no — nginx уважает его и так;
    # на других прокси отключить явно.
}
```

После правки: `nginx -t && systemctl reload nginx`.

## 6. Проверка деплоя (чеклист)

```bash
# 1. SPA и health
curl -s -o /dev/null -w "%{http_code} %{content_type}\n" https://<домен>/        # 200 text/html
curl -s https://<домен>/api/health                                               # {"status":"ok"}

# 2. SSO-редирект собран (client_id, redirect_uri — из .env)
curl -s -o /dev/null -D - https://<домен>/auth/login | grep -i ^location
#   → https://auth.gnexus.space/oauth/authorize?...

# 3. MCP: без токена 401, с токеном — 200 и mcp-session-id
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://<домен>/mcp/ \
  -H 'Content-Type: application/json' -d '{"jsonrpc":"2.0","method":"initialize"}'   # 401
curl -s -D - -o /dev/null -X POST https://<домен>/mcp/ \
  -H "Authorization: Bearer $MCP_TOKEN" -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"check","version":"0"}}}' \
  | grep -iE "^(HTTP|mcp-session)"                                                    # 200 + mcp-session-id

# 4. Логин в браузере: https://<домен> → SSO → возврат в приложение
```

Если всё зелёное — деплой готов. Пункт 4 проверяется пользователем (нужен аккаунт SSO).

## 7. MCP для ИИ-агентов

Адрес сервера — `https://<домен>/mcp/`, токен — `MCP_TOKEN` из `.env`.
Агентам раздаётся не сам токен, а инструкция; страница-инструкция для людей —
`https://<домен>/mcp-help` (как подключить, список тулов).

```bash
claude mcp add --transport http gntodo https://<домен>/mcp/ \
  --header "Authorization: Bearer <MCP_TOKEN>"
```

## 8. Обновление

```bash
git pull --ff-only
docker compose up -d --build   # миграции применит сам api при старте
docker compose ps              # все healthy
```

## 9. Бэкапы

- БД: `docker compose exec db pg_dump -U gntodo gntodo > backup-$(date +%F).sql`
  (восстановление: `cat backup.sql | docker compose exec -T db psql -U gntodo gntodo`
  в свежий volume).
- Вложения: named volume `gntodo_attachments` → путь внутри api `/data/attachments`.
- Всё вместе: остановить api (`docker compose stop api`) на время дампа.

## 10. Диагностика

| Симптом | Причина / что смотреть |
|---|---|
| `api` не стартует, в логах про session secret | `SESSION_SECRET` не заполнен или равен дефолту |
| Логин падает после SSO | `GAUTH_REDIRECT_URI` ≠ зарегистрированному в SSO; https vs http |
| `/mcp` → 421 Misdirected Request | внешний прокси не передаёт Host (нужен `proxy_set_header Host $host`), или Host ≠ домену из `GAUTH_REDIRECT_URI` |
| Сад/списки «живут» только после F5; тосты XP не приходят | SSE буферизуется: проверь `proxy_buffering off` на `/api/` у внешнего прокси |
| Вложение >1 МБ → 413 | `client_max_body_size` на внешнем прокси (в контейнерном nginx уже 20m) |
| MCP 401 при верном токене | токен не пробрался в контейнер: `docker compose exec api printenv MCP_TOKEN` |
| Детализация задач не приходит | `docker compose logs api | grep -i detail`; доступен ли Ollama: `curl $OLLAMA_BASE_URL/api/tags` с хоста api |

Логи: `docker compose logs -f api` (uvicorn), `docker compose logs web` (nginx).