Fix CI workflows. Extend make commands. Fix Nomad deploy doc.
Some checks failed
ci / Lint & typecheck (push) Successful in 33s
ci / Unit tests (push) Successful in 1m7s
ci / Integration tests (push) Failing after 25s

This commit is contained in:
febux 2026-09-13 23:54:06 +03:00
parent 3e67a966b7
commit 6f22b0368a
4 changed files with 222 additions and 44 deletions

View file

@ -8,36 +8,92 @@ name: ci
on: on:
push: push:
branches: ["**"] branches: [master]
pull_request: pull_request:
branches: [master]
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
test: lint:
runs-on: docker name: Lint & typecheck
container: runs-on: ubuntu-latest
image: python:3.14-trixie
steps: steps:
- name: Checkout (sha-pinned, no node actions needed) - uses: actions/checkout@v4
run: |
git init -q .
git remote add origin ${{ gitea.server_url }}/${{ gitea.repository }}.git
git fetch --depth 1 origin ${{ gitea.sha }}
git checkout -q FETCH_HEAD
- name: Install uv - name: Setup uv
run: pip install --quiet uv uses: astral-sh/setup-uv@v8.3.2
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Install Python
run: uv python install
- name: Sync dev dependencies - name: Sync dev dependencies
run: uv sync --group dev --frozen run: uv sync --group dev --frozen
- name: Lint (ruff) - name: Ruff check
run: | run: uv run ruff check src tests
uv run ruff check src tests
uv run ruff format --check src tests
uv run isort --check-only src tests
- name: Typecheck (ty) - name: Ruff format check
run: uv run ruff format --check src tests
- name: Ty type check
run: uv run ty check src run: uv run ty check src
- name: Unit tests test-unit:
run: uv run pytest -m "not integration" tests/unit name: Unit tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v8.3.2
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Install Python
run: uv python install
- name: Sync dev dependencies
run: uv sync --group dev --frozen
- name: Run unit tests with coverage
run: uv run pytest --cov=src/contract_check --cov-branch --cov-report=term-missing --cov-fail-under=50 -m "not integration" tests/unit
# Integration tests.
test-integration:
name: Integration tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup uv
uses: astral-sh/setup-uv@v8.3.2
with:
enable-cache: true
cache-dependency-glob: uv.lock
- name: Install Python
run: uv python install
- name: Sync dev dependencies
run: uv sync --group dev --frozen
# No --wait: minio-init-test is a one-shot container that exits (0) after
# creating the bucket, and --wait treats any exited container as failure.
# Health polling is done by the `infra` fixture in
# tests/integration/conftest.py; this step just pre-pulls the images.
- name: Start test infrastructure (postgres/redis/rabbitmq/minio)
run: docker compose -p contract-check-test -f docker-compose.test.yml up -d
- name: Run integration tests
run: uv run pytest -m integration
- name: Teardown infrastructure
if: always()
run: docker compose -p contract-check-test -f docker-compose.test.yml down -v

View file

@ -11,7 +11,9 @@
api api-logs bot bot-up bot-down bot-logs bot-ps worker-extract worker-analyze worker-notify \ api api-logs bot bot-up bot-down bot-logs bot-ps worker-extract worker-analyze worker-notify \
bot-remote-up bot-remote-down bot-remote-logs bot-remote-ps bot-webhook-path \ bot-remote-up bot-remote-down bot-remote-logs bot-remote-ps bot-webhook-path \
seed-token jwt-secret jwt-token jwt-verify health shell-api shell-bot \ seed-token jwt-secret jwt-token jwt-verify health shell-api shell-bot \
shell-db admin-promote admin-list clean dev dev-obs stop stop-obs shell-db admin-promote admin-list \
nomad-validate nomad-plan nomad-deploy nomad-status nomad-logs nomad-scale nomad-revert \
clean dev dev-obs stop stop-obs
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Help # Help
@ -299,6 +301,36 @@ shell-api: ## Open shell inside API container
shell-bot: ## Open shell inside bot container shell-bot: ## Open shell inside bot container
docker compose --profile bot exec bot /bin/sh docker compose --profile bot exec bot /bin/sh
# ─────────────────────────────────────────────────────────────────────────────
# Nomad (production app services; full runbook: deploy/nomad/README.md)
# CLI env (NOMAD_ADDR/CACERT/TOKEN) is sourced from /root/.nomadrc on the VPS.
# ─────────────────────────────────────────────────────────────────────────────
NOMAD_JOB := deploy/nomad/contract-check.nomad.hcl
NOMAD_RUN := bash -c '. /root/.nomadrc 2>/dev/null; IMAGE_TAG=$${IMAGE_TAG:-manual} nomad "$$@"' --
nomad-validate: ## Validate the Nomad job file
@$(NOMAD_RUN) job validate $(NOMAD_JOB)
nomad-plan: ## Dry-run diff of the next deployment
@$(NOMAD_RUN) job plan $(NOMAD_JOB)
nomad-deploy: ## Deploy to Nomad (usage: IMAGE_TAG=<git-sha> make nomad-deploy)
@$(NOMAD_RUN) job run $(NOMAD_JOB)
nomad-status: ## Job status: groups, allocations, deployments
@$(NOMAD_RUN) job status contract-check
nomad-logs: ## Tail a group's logs (usage: make nomad-logs G=api)
@alloc=$$($(NOMAD_RUN) job allocs -json contract-check | python3 -c \
"import json,sys; a=[x for x in json.load(sys.stdin) if x['TaskGroup']=='$(G)' and x['ClientStatus']=='running']; print(a[0]['ID'] if a else '')"); \
[ -n "$$alloc" ] && $(NOMAD_RUN) alloc logs -f $$alloc || echo "no running alloc in group $(G)"
nomad-scale: ## Scale a group (usage: make nomad-scale G=worker-extract N=3)
@$(NOMAD_RUN) job scale contract-check $(G) $(N)
nomad-revert: ## Revert job to a prior version (usage: make nomad-revert V=2)
@$(NOMAD_RUN) job revert contract-check $(V)
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────
# Cleanup # Cleanup
# ───────────────────────────────────────────────────────────────────────────── # ─────────────────────────────────────────────────────────────────────────────

View file

@ -148,13 +148,22 @@ nomad acl bootstrap
export NOMAD_TOKEN=<management-secret-id> export NOMAD_TOKEN=<management-secret-id>
``` ```
Lost the management token? The 400 error from bootstrap tells you the reset Lost the management token? The API reset path (`ResetIndex` in the
index; use it to mint a fresh management token (invalidates the old one): bootstrap call) does not work on this version. Since the failure window
here is initial setup — the cluster is EMPTY (no jobs, no variables) — do a
state reset (wipes raft: jobs, tokens, variables):
```bash ```bash
nomad acl bootstrap -reset-index <index-from-error> sudo systemctl stop nomad
# ensure acl { enabled = true } in /etc/nomad.d/nomad.hcl
sudo rm -rf /var/lib/nomad/*
sudo systemctl start nomad
sleep 5 && nomad acl bootstrap # fresh management token — save it
``` ```
On a POPULATED cluster never do this — keep the bootstrap SecretID in a
password manager from day one.
Handy: keep the CLI env in a root-only file and source it on demand Handy: keep the CLI env in a root-only file and source it on demand
(never into .bashrc — tokens shouldn't leak to every shell): (never into .bashrc — tokens shouldn't leak to every shell):
@ -195,29 +204,33 @@ substitute real credentials where the compose `.env` deviates from
defaults: defaults:
```bash ```bash
# NOTE: -in=json requires the {"Items": {...}} envelope — a flat key map
# is rejected with "variable missing required Items object".
nomad var put -in=json nomad/jobs/contract-check - <<'EOF' nomad var put -in=json nomad/jobs/contract-check - <<'EOF'
{ {
"registry_host": "p2gnl.mu-dungeon.xyz", "Items": {
"registry_owner": "admin-git", "registry_host": "p2gnl.mu-dungeon.xyz",
"registry_user": "admin-git", "registry_owner": "admin-git",
"registry_token": "<forgejo token with write:package scope>", "registry_user": "admin-git",
"registry_token": "<forgejo token with write:package scope>",
"database_url": "postgresql+asyncpg://contract_check:<POSTGRES_PASSWORD>@172.17.0.1:15432/contract_check", "database_url": "postgresql+asyncpg://contract_check:<POSTGRES_PASSWORD>@172.17.0.1:15432/contract_check",
"redis_url": "redis://172.17.0.1:17379/0", "redis_url": "redis://172.17.0.1:17379/0",
"rabbitmq_url": "amqp://contract_check:<RABBITMQ_PASS>@172.17.0.1:5672/", "rabbitmq_url": "amqp://contract_check:<RABBITMQ_PASS>@172.17.0.1:5672/",
"s3_endpoint_url": "http://172.17.0.1:9000", "s3_endpoint_url": "http://172.17.0.1:9000",
"s3_access_key": "<S3_ACCESS_KEY>", "s3_access_key": "<S3_ACCESS_KEY>",
"s3_secret_key": "<S3_SECRET_KEY>", "s3_secret_key": "<S3_SECRET_KEY>",
"s3_bucket": "contract-check-docs", "s3_bucket": "contract-check-docs",
"jwt_secret": "<openssl rand -hex 32 reuse the value from compose .env>", "jwt_secret": "<openssl rand -hex 32 reuse the value from compose .env>",
"telegram_bot_token": "<TELEGRAM_BOT_TOKEN>", "telegram_bot_token": "<TELEGRAM_BOT_TOKEN>",
"ollama_api_key": "<OLLAMA_API_KEY>", "ollama_api_key": "<OLLAMA_API_KEY>",
"yandexgpt_api_key": "<YANDEXGPT_API_KEY or empty>", "yandexgpt_api_key": "<YANDEXGPT_API_KEY or empty>",
"smtp_host": "<SMTP_HOST or empty>", "smtp_host": "<SMTP_HOST or empty>",
"smtp_username": "<SMTP_USERNAME or empty>", "smtp_username": "<SMTP_USERNAME or empty>",
"smtp_password": "<SMTP_PASSWORD or empty>", "smtp_password": "<SMTP_PASSWORD or empty>",
"metrics_bearer_token": "<METRICS_BEARER_TOKEN or empty>" "metrics_bearer_token": "<METRICS_BEARER_TOKEN or empty>"
}
} }
EOF EOF

View file

@ -22,6 +22,7 @@
12. [Troubleshooting](#12-troubleshooting) 12. [Troubleshooting](#12-troubleshooting)
13. [Edge proxy (Nginx + certbot)](#13-edge-proxy-nginx--certbot) 13. [Edge proxy (Nginx + certbot)](#13-edge-proxy-nginx--certbot)
14. [Telegram-бот на отдельном сервере](#14-telegram-бот-на-отдельном-сервере) 14. [Telegram-бот на отдельном сервере](#14-telegram-бот-на-отдельном-сервере)
15. [Nomad: app-сервисы в продакшене](#15-nomad-app-сервисы-в-продакшене)
--- ---
@ -888,6 +889,79 @@ make bot-remote-ps
--- ---
## 15. Nomad: app-сервисы в продакшене
Полный runbook (установка агента, TLS, ACL, секреты, firewall):
[deploy/nomad/README.md](../deploy/nomad/README.md).
**Разделение ответственности на VPS:**
| Слой | Чем управляется |
|---|---|
| `api` + 5 воркеров (`extract/analyze/prescreen/billing/notify`) | **Nomad** (job `contract-check`, одиночный агент server+client) |
| postgres, redis, rabbitmq, minio | docker compose (профиль infra) |
| edge-каскад: system nginx → compose nginx | без изменений (§13, `deploy/vps/`) |
| наблюдаемость (Vector/Promtail + …) | docker compose, скрейпит docker-сокет — контейнеры Nomad видит автоматически |
Задачи Nomad ходят в инфраструктуру compose через хостовый шлюз docker0
`172.17.0.1` по опубликованным портам (15432/17379/5672/9000).
### 15.1 CI/CD
Push в `main` (Forgejo) → `.forgejo/workflows/deploy.yml`:
1. сборка 6 образов из `srv/*/Dockerfile`;
2. push в registry `p2gnl.mu-dungeon.xyz/admin-git/contract-check-<name>:<git-sha>`;
3. `nomad job run` (образ-тег подставляется CLI-шаблоном `IMAGE_TAG`).
Деплой неуспешен в CI, если health-checks не прошли — на стороне Nomad
сработает `auto_revert` (откат на предыдущую версию job). Воркеры
обновляются canary-стратегией (новая версия рядом со старой); api — rolling
(`max_parallel=1`, секундный разрыв на деплой).
### 15.2 Повседневные операции
```bash
make nomad-status # группы, аллокации, деплои
make nomad-logs G=api # хвост логов группы
make nomad-scale G=worker-extract N=3 # масштабирование воркера
make nomad-revert V=<version> # ручной откат
```
Web UI (логи/exec/scale/deployments): `ssh -L 4646:127.0.0.1:4646 <vps>`
`https://localhost:4646/ui` (сертификат self-signed — предупреждение норма).
### 15.3 Секреты
| Где | Что |
|---|---|
| Nomad Variables `nomad/jobs/contract-check` | connection strings, ключи LLM/SMTP/JWT, токен registry (pull) |
| Forgejo repo secrets | `REGISTRY_TOKEN` (push, write:package), `NOMAD_TOKEN` (CI ACL), `NOMAD_CACERT` (CA-сертификат) |
| Forgejo repo variables | `NOMAD_ADDR_HOST` (публичный IP VPS с Nomad) |
Обновление секрета: `nomad var put ...` → контейнеры перезапустятся
(`change_mode = restart`).
### 15.4 Отказоустойчивость (одиночный сервер)
Агент упал → контейнеры продолжают работать (docker их не убивает);
недоступны только деплои/скейлинг до `systemctl restart nomad`. Состояние —
в `/var/lib/nomad` (raft).
### 15.5 Порядок перевода со compose на Nomad
1. Воркеры по одному: остановить compose-сервис → убедиться, что группа
Nomad поднялась и потребители вернулись в очереди RabbitMQ
(`rabbitmqctl list_queues name consumers`);
2. `api`: поднять группу Nomad (порт 18000) → проверить `/healthz`
переключить `deploy/nginx/templates/contract-check-http.conf.template`
(`set $api http://api:8000``http://<compose-gateway>:18000`) →
`docker compose --profile services down`;
3. Профиль `infra` (postgres/redis/rabbitmq/minio), edge и наблюдаемость
не трогать.
---
## Чек-лист перед production ## Чек-лист перед production
- [ ] `.env` заполнен, `.env.example` не содержит реальных секретов - [ ] `.env` заполнен, `.env.example` не содержит реальных секретов
@ -902,6 +976,9 @@ make bot-remote-ps
- [ ] B2B-ключ создаётся через `/api/v1/b2b/keys` с user JWT - [ ] B2B-ключ создаётся через `/api/v1/b2b/keys` с user JWT
- [ ] Backup cron настроен - [ ] Backup cron настроен
- [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP) - [ ] Firewall: открыты только 443 (nginx), 22 (ssh), 15672 (RabbitMQ mgmt, restrict IP)
- [ ] Nomad: агент healthy (`nomad server members` / `nomad node status`), 4646 открыт только IP Forgejo, 4647/4648 закрыты
- [ ] Nomad Variables `nomad/jobs/contract-check` заполнены (включая registry-токен)
- [ ] Forgejo secrets заданы: `REGISTRY_TOKEN`, `NOMAD_TOKEN`, `NOMAD_CACERT` + переменная `NOMAD_ADDR_HOST`
- [ ] Если бот на отдельном сервере: `API_URL` указывает на центральный API, `BOT_SERVICE_TOKEN` засеян, ботовый сервер имеет outbound HTTPS - [ ] Если бот на отдельном сервере: `API_URL` указывает на центральный API, `BOT_SERVICE_TOKEN` засеян, ботовый сервер имеет outbound HTTPS
- [ ] Если бот в webhook-режиме: `BOT_UPDATE_MODE=webhook`, `BOT_WEBHOOK_PUBLIC_BASE_URL`/`BOT_WEBHOOK_SECRET_TOKEN` заданы, edge маршрутизирует `BOT_WEBHOOK_PATH` на бота, webhook-порт не опубликован в интернет - [ ] Если бот в webhook-режиме: `BOT_UPDATE_MODE=webhook`, `BOT_WEBHOOK_PUBLIC_BASE_URL`/`BOT_WEBHOOK_SECRET_TOKEN` заданы, edge маршрутизирует `BOT_WEBHOOK_PATH` на бота, webhook-порт не опубликован в интернет