Merge pull request 'Add CRM stub flag, deploy/backup scripts, CI and prod runbook' (#10) from feature/safe-local-dev into main
Reviewed-on: #10
This commit was merged in pull request #10.
This commit is contained in:
+4
-1
@@ -56,6 +56,9 @@ CRM_API_KEY=change-me-crm-apikey
|
||||
CRM_SECRET_KEY=change-me-crm-secretkey
|
||||
CRM_SHOP_KEY=change-me-crm-shopkey
|
||||
CRM_SID=1
|
||||
# Стаб вместо реальной CRM (локально — обязательно вместе с CHECKBOX_USE_STUB):
|
||||
# иначе после стаб-чека боевой заказ уйдёт в PACKED. В production запрещено.
|
||||
CRM_USE_STUB=true
|
||||
|
||||
# --- Nova Poshta ----------------------------------------------------------
|
||||
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
|
||||
@@ -72,4 +75,4 @@ CHECKBOX_CLIENT_VERSION=0.1.0
|
||||
CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000
|
||||
# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл
|
||||
# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено.
|
||||
CHECKBOX_USE_STUB=false
|
||||
CHECKBOX_USE_STUB=true
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -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
|
||||
@@ -19,6 +19,10 @@ backend/ FastAPI + SQLAlchemy (async) + Alembic + Postgres — see backend/ap
|
||||
frontend/ React 19 + TypeScript + Vite — see frontend/src/
|
||||
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.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.
|
||||
@@ -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.
|
||||
|
||||
## 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
|
||||
|
||||
- **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"}}`.
|
||||
- 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.
|
||||
- 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()`.
|
||||
|
||||
@@ -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`.
|
||||
@@ -10,13 +10,11 @@ from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.security import TokenError, decode_access_token
|
||||
from app.db.models.user import User, UserRole
|
||||
from app.db.session import get_session
|
||||
from app.services.checkbox.client import CheckboxClient, get_checkbox_client
|
||||
from app.services.crm.client import CrmClient
|
||||
from app.services.crm.exo_client import ExoCrmClient
|
||||
from app.services.crm.client import CrmClient, get_crm_client
|
||||
from app.services.task_queue import TaskQueue, get_task_queue
|
||||
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
@@ -92,10 +90,6 @@ AdminUser = Annotated[User, Depends(require_admin)]
|
||||
CashierUser = Annotated[User, Depends(require_cashier)]
|
||||
|
||||
|
||||
def get_crm_client() -> CrmClient:
|
||||
return ExoCrmClient(settings)
|
||||
|
||||
|
||||
CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)]
|
||||
|
||||
|
||||
|
||||
@@ -61,6 +61,9 @@ class Settings(BaseSettings):
|
||||
crm_secret_key: str = ""
|
||||
crm_shop_key: str = ""
|
||||
crm_sid: int = 1
|
||||
# Стаб вместо реальной CRM: иначе локальный worker переводил бы боевые заказы
|
||||
# в PACKED после стаб-чеков Checkbox. В проде запрещено.
|
||||
crm_use_stub: bool = False
|
||||
|
||||
# --- Nova Poshta ---
|
||||
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Protocol
|
||||
|
||||
from app.core.config import settings
|
||||
from app.schemas.orders import OrderOut
|
||||
|
||||
|
||||
@@ -15,3 +17,18 @@ class CrmClient(Protocol):
|
||||
async def get_orders(self, *, status: str) -> list[OrderOut]: ...
|
||||
|
||||
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,4 +1,4 @@
|
||||
"""Фикстурный CRM-клиент для тестов — не ходит в сеть."""
|
||||
"""Фикстурный CRM-клиент: тесты и локальный запуск (`CRM_USE_STUB=true`) — не ходит в сеть."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ from app.core.logging import configure_logging, get_logger
|
||||
from app.db.session import SessionFactory
|
||||
from app.services import receipts as receipts_service
|
||||
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.orders import sync_np_statuses
|
||||
|
||||
@@ -35,7 +35,7 @@ async def startup(ctx: dict[str, Any]) -> None:
|
||||
configure_logging()
|
||||
ctx["np_client"] = NpTrackingClient()
|
||||
ctx["checkbox_client"] = get_checkbox_client()
|
||||
ctx["crm_client"] = ExoCrmClient(settings)
|
||||
ctx["crm_client"] = get_crm_client()
|
||||
log.info("worker_starting", environment=settings.environment)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Executable
+25
@@ -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)"
|
||||
Executable
+56
@@ -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
|
||||
Reference in New Issue
Block a user