Author SHA1 Message Date
lauadminandClaude Opus 5.5 2b7a92645a Add CRM stub flag, deploy/backup scripts, CI and prod runbook
CI / backend (pull_request) Successful in 3m7s
CI / frontend (pull_request) Failing after 15m55s
- CRM_USE_STUB: local runs no longer reach the live CRM. With only the
  Checkbox stub, a stub receipt would still move the real order to PACKED.
  Refused in production, same as CHECKBOX_USE_STUB.
- scripts/deploy.sh: backup, fast-forward main, build, health check and
  code rollback on failure. scripts/backup.sh: pg_dump with verification
  and 14-day rotation (used by cron and deploy.sh).
- Gitea Actions CI: ruff + pytest, oxlint + build.
- DEPLOY.md runbook; CLAUDE.md rules for safe local development.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:52:20 +03:00
lauadmin 418f6b0352 Merge pull request 'Add production compose overlay for deploy behind Nginx Proxy Manager' (#9) from feature/prod-deploy into main
Reviewed-on: #9
2026-09-25 17:24:30 +00:00
lauadminandClaude Opus 5.5 44fe648ebc Add production compose overlay for deploy behind Nginx Proxy Manager
The prod overlay drops host ports for api/frontend and attaches frontend
to the server's external `web` network (alias lux-fiscal-frontend), where
NPM terminates TLS for asist.ystyle.com.ua. migrate/api/worker now share
a single lux-fiscal-backend image instead of three identical copies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 20:23:12 +03:00
lauadmin d92c715c11 Merge pull request 'Главное меню с модулями по ролям (#7)' (#8) from feature/main-menu into main 2026-09-25 16:41:18 +00:00
15 changed files with 368 additions and 12 deletions
+4 -1
View File
@@ -56,6 +56,9 @@ CRM_API_KEY=change-me-crm-apikey
CRM_SECRET_KEY=change-me-crm-secretkey CRM_SECRET_KEY=change-me-crm-secretkey
CRM_SHOP_KEY=change-me-crm-shopkey CRM_SHOP_KEY=change-me-crm-shopkey
CRM_SID=1 CRM_SID=1
# Стаб вместо реальной CRM (локально — обязательно вместе с CHECKBOX_USE_STUB):
# иначе после стаб-чека боевой заказ уйдёт в PACKED. В production запрещено.
CRM_USE_STUB=true
# --- Nova Poshta ---------------------------------------------------------- # --- Nova Poshta ----------------------------------------------------------
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна # Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
@@ -72,4 +75,4 @@ CHECKBOX_CLIENT_VERSION=0.1.0
CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000 CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000
# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл # ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл
# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено. # прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено.
CHECKBOX_USE_STUB=false CHECKBOX_USE_STUB=true
+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+37
View File
@@ -0,0 +1,37 @@
# Проверки на каждый PR и push в main. Нужен зарегистрированный act_runner (см. DEPLOY.md).
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
backend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install -e ".[dev]"
- run: ruff check app tests
# SECRET_KEY/ENCRYPTION_KEY подставляет tests/conftest.py.
- run: pytest -q
frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
- run: npm ci
- run: npm run lint
- run: npm run build
+16 -1
View File
@@ -19,6 +19,10 @@ backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/ap
frontend/ React 19 + TypeScript + Vite — see frontend/src/ frontend/ React 19 + TypeScript + Vite — see frontend/src/
docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container) docker/ nginx.frontend.conf (SPA + /api reverse proxy to the api container)
docker-compose.yml postgres, redis, migrate (one-shot), api, worker, frontend docker-compose.yml postgres, redis, migrate (one-shot), api, worker, frontend
docker-compose.prod.yml prod overlay: no host ports, frontend joins the external `web` network (Nginx Proxy Manager)
scripts/ deploy.sh (runs on the prod server), backup.sh (pg_dump + rotation)
.gitea/workflows/ci.yml ruff + pytest, oxlint + build on every PR
DEPLOY.md production runbook — read before touching the server
``` ```
Backend and frontend are independent projects with their own dependency files (`backend/pyproject.toml`, `frontend/package.json`) — always `cd` into the right one before running tooling. Backend and frontend are independent projects with their own dependency files (`backend/pyproject.toml`, `frontend/package.json`) — always `cd` into the right one before running tooling.
@@ -72,6 +76,17 @@ docker compose ps # migrate should show Exited(0) — it's a one
`worker` runs ARQ (`arq app.worker.WorkerSettings`): cron polls Nova Poshta statuses and Checkbox ETTN receipts every minute, plus the on-demand `create_ettn_receipt` job enqueued by the API. Its Dockerfile HEALTHCHECK is explicitly disabled in `docker-compose.yml` because the worker doesn't serve HTTP; don't re-enable it without giving it something to check. `worker` runs ARQ (`arq app.worker.WorkerSettings`): cron polls Nova Poshta statuses and Checkbox ETTN receipts every minute, plus the on-demand `create_ettn_receipt` job enqueued by the API. Its Dockerfile HEALTHCHECK is explicitly disabled in `docker-compose.yml` because the worker doesn't serve HTTP; don't re-enable it without giving it something to check.
## Production and safe local development
Prod runs on `websrv` (`ssh lux-prod`, user `deploy`), `https://asist.ystyle.com.ua`, behind the server's Nginx Proxy Manager. The full runbook (deploy, rollback, backups, restore, CI runner) is in `DEPLOY.md`.
- **The local DB is a copy of prod data.** Local must never reach live systems: `.env` always has `ENVIRONMENT=local`, `CHECKBOX_USE_STUB=true` **and** `CRM_USE_STUB=true`. The Checkbox stub alone is not enough — a stub receipt counts as accepted and `sync_crm_statuses` would move the real CRM order to `PACKED`. Both factories (`get_checkbox_client`, `get_crm_client`) refuse stubs in production. After restoring a prod dump locally, overwrite `cash_registers` keys before starting `api`/`worker` (snippet in `DEPLOY.md`).
- **Workflow:** `feature/*` branch → PR in Gitea (CI green) → merge to `main` → on the server `~/lux_fiscal/scripts/deploy.sh`. `main` is always what prod runs. Never edit tracked files on the server or commit/push to `main` directly.
- **Migrations must be backward-compatible with existing data:** new columns nullable or with `server_default`; drop/rename a column only in a later release after code stopped using it (code rollback does not roll back the schema). Test a new migration locally on a fresh (sanitized) prod backup.
- **Never change prod `ENCRYPTION_KEY`** — cash register secrets in the DB are encrypted with it.
- New integrations with side effects on real systems (CRM, Checkbox, NP, anything that writes) get their own `*_USE_STUB` flag with the same production guard, selected in one factory function.
- Claude has SSH access as `deploy`, but reading/decrypting prod secrets and writing to prod `.env` is left to the user.
## Backend architecture ## Backend architecture
- **Async everywhere.** SQLAlchemy 2.0 async ORM + `asyncpg`, one DSN (`app.core.config.Settings.database_url`) used by both the app and Alembic — no separate sync driver. - **Async everywhere.** SQLAlchemy 2.0 async ORM + `asyncpg`, one DSN (`app.core.config.Settings.database_url`) used by both the app and Alembic — no separate sync driver.
@@ -128,4 +143,4 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`. - Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
- Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker. - Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
- Each cash register has its own Nova Poshta API key (`cash_registers.np_api_key_enc`, Fernet). `sync_np_statuses` polls TTNs with register keys and binds the order to the register whose key sees the TTN as its own (`orders.cash_register_id`; ownership = response contains `PhoneSender` — a foreign key gets a truncated reply without sender/`AfterpaymentOnGoodsCost`). Receipts are created from the order's register, not the default one; an unbound order is rejected. `NOVA_POSHTA_API_KEY` env is only read by migration 0008. - Each cash register has its own Nova Poshta API key (`cash_registers.np_api_key_enc`, Fernet). `sync_np_statuses` polls TTNs with register keys and binds the order to the register whose key sees the TTN as its own (`orders.cash_register_id`; ownership = response contains `PhoneSender` — a foreign key gets a truncated reply without sender/`AfterpaymentOnGoodsCost`). Receipts are created from the order's register, not the default one; an unbound order is rejected. `NOVA_POSHTA_API_KEY` env is only read by migration 0008.
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`. - ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true` together with `CRM_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()` and `services/crm/client.get_crm_client()`.
+143
View File
@@ -0,0 +1,143 @@
# Выкладка в прод
## Где что
| Что | Где |
|---|---|
| Сервер | `websrv`, `192.168.88.100`, Ubuntu; SSH: пользователь `deploy`, порт 22 (алиас `lux-prod` в `~/.ssh/config` разработчика) |
| Приложение | `https://asist.ystyle.com.ua` |
| Код | `/home/deploy/lux_fiscal` — клон `main` из Gitea (deploy key только на чтение, `~/.ssh/config` → `Host gitea` = `127.0.0.1:2222`) |
| Секреты | `/home/deploy/lux_fiscal/.env` (права 600, в git не попадает) |
| Бэкапы | `/home/deploy/backups/lux_fiscal/*.dump`, журнал — `backup.log` там же |
| TLS / домен | Nginx Proxy Manager на том же сервере (`http://192.168.88.100:81`), Proxy Host `asist.ystyle.com.ua` → `lux-fiscal-frontend:80` |
Сервисы поднимаются из двух файлов: `docker-compose.yml` + `docker-compose.prod.yml` (прод-оверлей убирает порты на хосте
и подключает `frontend` к внешней сети `web`, где живёт NPM). Во всех командах ниже:
```bash
C="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
```
## Обычная выкладка
1. Изменения попадают в `main` только через PR в Gitea; CI (`.gitea/workflows/ci.yml`) должен быть зелёным.
2. На сервере:
```bash
ssh lux-prod
~/lux_fiscal/scripts/deploy.sh
```
`deploy.sh` делает: бэкап БД (`scripts/backup.sh`) → `git fetch` + fast-forward `main` → `up -d --build --wait`
(миграции применяет одноразовый контейнер `migrate`) → проверка `/api/v1/health` через nginx фронтенда.
Если новая версия не поднялась — откатывает **код** на предыдущий коммит и печатает путь к бэкапу.
**БД автоматически не откатывается.** `--force` — пересобрать и перезапустить без новых коммитов.
После выкладки:
```bash
$C ps
$C logs --since 10m api worker | grep -iE "error|exception" | tail -20
```
## Правила, чтобы не сломать прод
- **Миграции — только совместимые с данными.** Новые колонки — `nullable` или с `server_default`. Удаление/переименование
колонки — отдельным релизом, после того как код перестал её использовать (иначе откат кода не спасёт).
- Перед мержем миграцию прогоняют локально на копии свежего прод-бэкапа (см. «Прод-данные локально»).
- `ENCRYPTION_KEY` в прод-`.env` **никогда не меняется**: им зашифрованы ключи касс в БД. Копия `.env` хранится отдельно от
сервера (менеджер паролей).
- В проде запрещены `CHECKBOX_USE_STUB=true` и `CRM_USE_STUB=true` — приложение упадёт на старте клиента.
- Руками на сервере файлы репозитория не правятся: `deploy.sh` откажется работать с грязным деревом.
- Выкладка — не в часы пик работы кассиров: во время `up` API недоступен несколько секунд, а воркер перезапускается.
## Откат
Код:
```bash
cd ~/lux_fiscal
git log --oneline -10
git checkout --detach <коммит>
$C up -d --build --wait
```
Следующий `deploy.sh` сам вернётся на `main`. Правильный путь исправления — revert-коммит через PR.
БД из бэкапа (всё, что было после бэкапа, потеряется):
```bash
cd ~/lux_fiscal
$C stop api worker
$C exec -T postgres sh -c 'pg_restore -U "$POSTGRES_USER" -d "$POSTGRES_DB" --clean --if-exists --no-owner' \
< ~/backups/lux_fiscal/<файл>.dump
$C start api worker
```
Код при этом должен соответствовать версии схемы в дампе (`alembic_version`).
## Бэкапы
- `scripts/backup.sh`: `pg_dump -Fc` → проверка `pg_restore --list` → ротация 14 дней.
- Cron пользователя `deploy` (`crontab -l`): каждый день в 03:30 UTC.
- Бэкапы лежат на том же диске, что и БД, — периодически копируйте их за пределы сервера:
```bash
scp "lux-prod:backups/lux_fiscal/*.dump" D:/Backups/lux_fiscal/
```
## Прод-данные локально
Прод-дамп содержит зашифрованные ключи касс, а локальный `.env` — ключи CRM. Чтобы локальный воркер не создавал боевые
чеки и не менял статусы заказов в CRM, локально **всегда**:
```ini
ENVIRONMENT=local
CHECKBOX_USE_STUB=true
CRM_USE_STUB=true
```
После восстановления прод-дампа в локальную БД сразу обнулите ключи касс (лицензия/PIN — фиктивные, ключ НП — `NULL`):
```bash
docker compose run --rm --no-deps -T api python - <<'EOF'
import asyncio
from sqlalchemy import text
from app.core import crypto
from app.core.config import settings
from app.db.session import engine
assert not settings.is_production
async def main():
async with engine.begin() as c:
await c.execute(text("update cash_registers set license_key_enc=:l, cashier_pin_enc=:p, np_api_key_enc=null"),
{"l": crypto.encrypt("local-stub-license"), "p": crypto.encrypt("0000")})
asyncio.run(main())
EOF
```
Зашифрованы только ключи касс, и скрипт их перезаписывает — поэтому локальный `ENCRYPTION_KEY` может быть любым.
Делайте это до первого запуска локальных `api`/`worker`.
Варнинги воркера `ettn_poll_failed … не знайдено` на прод-копии — норма: стаб Checkbox не знает ID настоящих чеков.
## CI (Gitea Actions)
`.gitea/workflows/ci.yml`: backend — `ruff check` + `pytest`; frontend — `npm run lint` + `npm run build`.
Workflow выполняется только при зарегистрированном `act_runner`:
1. Gitea → Site Administration → Actions → Runners → Create new Runner — скопировать registration token.
2. Запустить раннер контейнером рядом с Gitea (образ `gitea/act_runner`, переменные `GITEA_INSTANCE_URL`,
`GITEA_RUNNER_REGISTRATION_TOKEN`, проброс `/var/run/docker.sock`).
Сервер слабый (2.5 ГБ RAM): при выкладке во время прогона CI сборка фронтенда может упереться в память.
## Первичная установка (для справки)
1. Пользователь `deploy` в группе `docker`, SSH-ключ разработчика в `~deploy/.ssh/authorized_keys` (700/600, владелец `deploy`).
2. Deploy key `~deploy/.ssh/gitea_deploy` добавлен в Gitea (repo → Settings → Deploy Keys, только чтение);
`git clone gitea:lauadmin/lux_fiscal.git ~/lux_fiscal`.
3. `.env` из `.env.example`: `ENVIRONMENT=production`, `DEBUG=false`, `BASE_URL`/`CORS_ORIGINS` = `https://asist.ystyle.com.ua`,
сгенерированные `SECRET_KEY`/`POSTGRES_PASSWORD`, `ENCRYPTION_KEY` — тот, которым зашифрованы ключи касс в переносимой БД.
4. `$C up -d --build --wait`; на пустой БД — `$C run --rm api python -m app.cli bootstrap`.
5. NPM: Proxy Host → `lux-fiscal-frontend:80`, Let's Encrypt, Force SSL.
6. Cron: `30 3 * * * $HOME/lux_fiscal/scripts/backup.sh >> $HOME/backups/lux_fiscal/backup.log 2>&1`.
+1 -7
View File
@@ -10,13 +10,11 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import TokenError, decode_access_token from app.core.security import TokenError, decode_access_token
from app.db.models.user import User, UserRole from app.db.models.user import User, UserRole
from app.db.session import get_session from app.db.session import get_session
from app.services.checkbox.client import CheckboxClient, get_checkbox_client from app.services.checkbox.client import CheckboxClient, get_checkbox_client
from app.services.crm.client import CrmClient from app.services.crm.client import CrmClient, get_crm_client
from app.services.crm.exo_client import ExoCrmClient
from app.services.task_queue import TaskQueue, get_task_queue from app.services.task_queue import TaskQueue, get_task_queue
bearer_scheme = HTTPBearer(auto_error=False) bearer_scheme = HTTPBearer(auto_error=False)
@@ -92,10 +90,6 @@ AdminUser = Annotated[User, Depends(require_admin)]
CashierUser = Annotated[User, Depends(require_cashier)] CashierUser = Annotated[User, Depends(require_cashier)]
def get_crm_client() -> CrmClient:
return ExoCrmClient(settings)
CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)] CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)]
+3
View File
@@ -61,6 +61,9 @@ class Settings(BaseSettings):
crm_secret_key: str = "" crm_secret_key: str = ""
crm_shop_key: str = "" crm_shop_key: str = ""
crm_sid: int = 1 crm_sid: int = 1
# Стаб вместо реальной CRM: иначе локальный worker переводил бы боевые заказы
# в PACKED после стаб-чеков Checkbox. В проде запрещено.
crm_use_stub: bool = False
# --- Nova Poshta --- # --- Nova Poshta ---
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная # Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
+17
View File
@@ -2,8 +2,10 @@
from __future__ import annotations from __future__ import annotations
from functools import lru_cache
from typing import Protocol from typing import Protocol
from app.core.config import settings
from app.schemas.orders import OrderOut from app.schemas.orders import OrderOut
@@ -15,3 +17,18 @@ class CrmClient(Protocol):
async def get_orders(self, *, status: str) -> list[OrderOut]: ... async def get_orders(self, *, status: str) -> list[OrderOut]: ...
async def set_status(self, *, order_id: str, status: str) -> None: ... async def set_status(self, *, order_id: str, status: str) -> None: ...
@lru_cache
def get_crm_client() -> CrmClient:
"""Один экземпляр на процесс: стаб копит выставленные статусы в памяти."""
if settings.crm_use_stub:
if settings.is_production:
raise RuntimeError("CRM_USE_STUB=true заборонено в production")
from app.services.crm.stub_client import StubCrmClient
return StubCrmClient()
from app.services.crm.exo_client import ExoCrmClient
return ExoCrmClient(settings)
+1 -1
View File
@@ -1,4 +1,4 @@
"""Фикстурный CRM-клиент для тестов — не ходит в сеть.""" """Фикстурный CRM-клиент: тесты и локальный запуск (`CRM_USE_STUB=true`) — не ходит в сеть."""
from __future__ import annotations from __future__ import annotations
+2 -2
View File
@@ -20,7 +20,7 @@ from app.core.logging import configure_logging, get_logger
from app.db.session import SessionFactory from app.db.session import SessionFactory
from app.services import receipts as receipts_service from app.services import receipts as receipts_service
from app.services.checkbox.client import CheckboxRateLimitedError, get_checkbox_client from app.services.checkbox.client import CheckboxRateLimitedError, get_checkbox_client
from app.services.crm.exo_client import ExoCrmClient from app.services.crm.client import get_crm_client
from app.services.nova_poshta.np_client import NpTrackingClient from app.services.nova_poshta.np_client import NpTrackingClient
from app.services.orders import sync_np_statuses from app.services.orders import sync_np_statuses
@@ -35,7 +35,7 @@ async def startup(ctx: dict[str, Any]) -> None:
configure_logging() configure_logging()
ctx["np_client"] = NpTrackingClient() ctx["np_client"] = NpTrackingClient()
ctx["checkbox_client"] = get_checkbox_client() ctx["checkbox_client"] = get_checkbox_client()
ctx["crm_client"] = ExoCrmClient(settings) ctx["crm_client"] = get_crm_client()
log.info("worker_starting", environment=settings.environment) log.info("worker_starting", environment=settings.environment)
+37
View File
@@ -0,0 +1,37 @@
"""Выбор реализации CRM-клиента по `CRM_USE_STUB`."""
from __future__ import annotations
from collections.abc import Iterator
import pytest
from app.core.config import settings
from app.services.crm.client import get_crm_client
from app.services.crm.exo_client import ExoCrmClient
from app.services.crm.stub_client import StubCrmClient
@pytest.fixture(autouse=True)
def _fresh_factory() -> Iterator[None]:
get_crm_client.cache_clear()
yield
get_crm_client.cache_clear()
def test_real_client_by_default(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "crm_use_stub", False)
assert isinstance(get_crm_client(), ExoCrmClient)
def test_stub_when_enabled(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "crm_use_stub", True)
monkeypatch.setattr(settings, "environment", "local")
assert isinstance(get_crm_client(), StubCrmClient)
def test_stub_forbidden_in_production(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(settings, "crm_use_stub", True)
monkeypatch.setattr(settings, "environment", "production")
with pytest.raises(RuntimeError, match="CRM_USE_STUB"):
get_crm_client()
+21
View File
@@ -0,0 +1,21 @@
# Прод-оверлей: docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build
#
# TLS и публичный домен держит Nginx Proxy Manager сервера (внешняя сеть `web`).
# Наружу торчит только frontend — и то лишь в сеть `web`, без портов на хосте;
# api/postgres/redis доступны исключительно внутри сети compose.
services:
api:
ports: !reset []
frontend:
ports: !reset []
networks:
default:
web:
# Имя, по которому NPM проксирует: Forward Hostname = lux-fiscal-frontend, порт 80.
aliases: [lux-fiscal-frontend]
networks:
web:
external: true
+4
View File
@@ -35,6 +35,8 @@ services:
# Миграции выполняются отдельным одноразовым контейнером, а не при старте api. # Миграции выполняются отдельным одноразовым контейнером, а не при старте api.
# Иначе при нескольких репликах api они пошли бы параллельно. # Иначе при нескольких репликах api они пошли бы параллельно.
migrate: migrate:
# Один образ на migrate/api/worker, а не три одинаковых копии на диске.
image: lux-fiscal-backend:latest
build: build:
context: ./backend context: ./backend
env_file: .env env_file: .env
@@ -48,6 +50,7 @@ services:
restart: "no" restart: "no"
api: api:
image: lux-fiscal-backend:latest
build: build:
context: ./backend context: ./backend
restart: unless-stopped restart: unless-stopped
@@ -68,6 +71,7 @@ services:
- receipt_storage:/app/storage - receipt_storage:/app/storage
worker: worker:
image: lux-fiscal-backend:latest
build: build:
context: ./backend context: ./backend
restart: unless-stopped restart: unless-stopped
+25
View File
@@ -0,0 +1,25 @@
#!/bin/sh
# Бэкап прод-БД lux_fiscal: cron пользователя deploy (ежедневно) и scripts/deploy.sh (перед выкладкой).
# Дампы содержат зашифрованные ключи касс: для восстановления нужен тот же ENCRYPTION_KEY из .env.
# Последняя строка вывода — "ok <путь к дампу> <размер>", её читает deploy.sh.
set -eu
PROJECT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
BACKUP_DIR="${BACKUP_DIR:-$HOME/backups/lux_fiscal}"
KEEP_DAYS=14
umask 077
mkdir -p "$BACKUP_DIR"
cd "$PROJECT_DIR"
C="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
FILE="$BACKUP_DIR/lux_fiscal_$(date +%F_%H%M%S).dump"
$C exec -T postgres sh -c 'pg_dump -U "$POSTGRES_USER" -d "$POSTGRES_DB" -Fc' > "$FILE.tmp"
# Битый/пустой дамп не должен вытеснить ротацией хорошие.
$C exec -T postgres pg_restore --list < "$FILE.tmp" > /dev/null
mv "$FILE.tmp" "$FILE"
find "$BACKUP_DIR" -name "lux_fiscal_*.dump" -mtime +"$KEEP_DAYS" -delete
find "$BACKUP_DIR" -name "*.tmp" -mtime +1 -delete
echo "$(date -Is) ok $FILE $(du -h "$FILE" | cut -f1)"
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
# Выкладка main на прод: бэкап БД → git pull → сборка и запуск → проверка health.
# Если новая версия не поднялась — откат кода на предыдущий коммит (БД не откатывается,
# путь к свежему бэкапу печатается). Запуск на сервере: ~/lux_fiscal/scripts/deploy.sh [--force]
set -eu
cd "$(dirname "$0")/.."
C="docker compose -f docker-compose.yml -f docker-compose.prod.yml"
health() {
# Цепочка целиком: nginx фронтенда → api → БД/Redis.
$C exec -T frontend wget -qO- http://127.0.0.1/api/v1/health | grep -q '"status":"ok"'
}
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "На сервере есть незакоммиченные правки в отслеживаемых файлах — разберитесь вручную:" >&2
git status --short --untracked-files=no >&2
exit 1
fi
# PREV — то, что крутится сейчас (после неудачной выкладки это detached-коммит отката, а не main).
PREV=$(git rev-parse HEAD)
git checkout -q main
git fetch -q origin main
git merge -q --ff-only origin/main
NEW=$(git rev-parse HEAD)
if [ "$PREV" = "$NEW" ] && [ "${1:-}" != "--force" ]; then
echo "Нечего выкладывать: уже на $(git log --oneline -1). Пересобрать всё равно: --force"
exit 0
fi
echo "== Бэкап БД"
BACKUP=$(scripts/backup.sh | tail -1 | awk '{print $3}')
echo " $BACKUP"
echo "== Выкладка $(git log --oneline -1 "$PREV") -> $(git log --oneline -1 "$NEW")"
if $C up -d --build --wait --remove-orphans && health; then
echo "== Готово: $(git log --oneline -1)"
docker image prune -f --filter "label=com.docker.compose.project=lux-fiscal" > /dev/null || true
exit 0
fi
echo "!! Новая версия не поднялась — откат кода на $PREV" >&2
$C logs --tail 50 migrate api worker >&2 || true
git checkout -q --detach "$PREV"
$C up -d --build --wait --remove-orphans || true
if health; then
echo "!! Откат выполнен, работает $(git log --oneline -1). Репозиторий в detached HEAD —" >&2
echo "!! следующий deploy.sh сам вернётся на main." >&2
else
echo "!! Откат не помог: сервис не отвечает." >&2
fi
echo "!! Если новая версия успела применить миграции, а старый код с ними несовместим —" >&2
echo "!! восстановите БД из бэкапа, снятого перед выкладкой: $BACKUP (см. DEPLOY.md)." >&2
exit 1