ops: alembic baseline, pre-commit, TIGER cleanup, pg_dump scripts

- backend/alembic/* — alembic infra (env.py, script.py.mako). versions/ empty
  for now; first migration goes in Stage 2a when models are finalized.
- backend/Dockerfile: bake alembic.ini + alembic/ into the image so
  `docker compose exec backend alembic upgrade head` works in prod.
- backend/db/init/99_drop_unused_extensions.sql + bind-mount in both compose
  files: drops postgis-image's TIGER/topology/fuzzystrmatch on fresh volumes.
- .pre-commit-config.yaml + pre-commit in dev deps: ruff/prettier on commit
  to stop CI failures like UP035 from leaking out.
- ops/backup.sh, ops/restore.sh: pg_dump cron script with optional Selectel S3
  upload. 7-day local retention. Restore guard: requires RESTORE_CONFIRM=yes.
- Makefile: new targets `make migration NAME=...`, `make pre-commit-install`.
- backend/.env.example: SENTRY_DSN comment with sentry.io reference.
This commit is contained in:
lekss361 2026-04-26 13:08:51 +03:00
parent 68e95aa1ce
commit 4f034bd86b
16 changed files with 448 additions and 13 deletions

39
.pre-commit-config.yaml Normal file
View file

@ -0,0 +1,39 @@
# Local pre-commit hooks. Install once with:
# pip install pre-commit
# pre-commit install
#
# After install: every `git commit` runs these checks against staged files.
# Failures abort the commit.
repos:
# Generic hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
exclude: ^backend/alembic/script.py.mako$
- id: check-toml
- id: check-added-large-files
args: ["--maxkb=512"]
- id: check-merge-conflict
- id: detect-private-key
# Python — ruff (lint + format) on backend/
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.7.4
hooks:
- id: ruff
args: [--fix]
files: ^backend/
- id: ruff-format
files: ^backend/
# Frontend — prettier on TS/TSX/JSON
- repo: https://github.com/pre-commit/mirrors-prettier
rev: v4.0.0-alpha.8
hooks:
- id: prettier
files: ^frontend/.*\.(ts|tsx|js|jsx|json|css|md)$
exclude: ^frontend/(node_modules|\.next|package-lock\.json)/

View file

@ -1,15 +1,17 @@
.PHONY: help up down logs backend-shell migrate test lint typecheck codegen .PHONY: help up down logs backend-shell migrate migration test lint typecheck codegen pre-commit-install
help: help:
@echo "make up - start all services (Postgres, Redis, backend, frontend)" @echo "make up - start all services (Postgres, Redis, backend, frontend)"
@echo "make down - stop all services" @echo "make down - stop all services"
@echo "make logs - tail logs from all services" @echo "make logs - tail logs from all services"
@echo "make backend-shell - shell into backend container" @echo "make backend-shell - shell into backend container"
@echo "make migrate - run alembic upgrade head" @echo "make migrate - run alembic upgrade head"
@echo "make test - run backend pytest" @echo "make migration NAME=... - autogenerate a new alembic migration"
@echo "make lint - ruff check" @echo "make test - run backend pytest"
@echo "make typecheck - mypy on core modules" @echo "make lint - ruff check"
@echo "make codegen - regenerate frontend TS types from backend OpenAPI" @echo "make typecheck - mypy on core modules"
@echo "make codegen - regenerate frontend TS types from backend OpenAPI"
@echo "make pre-commit-install - install local git hooks (runs ruff/prettier on commit)"
up: up:
docker compose up -d --build docker compose up -d --build
@ -26,6 +28,10 @@ backend-shell:
migrate: migrate:
docker compose exec backend alembic upgrade head docker compose exec backend alembic upgrade head
migration:
@if [ -z "$(NAME)" ]; then echo "Usage: make migration NAME=add_parcel_table"; exit 1; fi
docker compose exec backend alembic revision --autogenerate -m "$(NAME)"
test: test:
cd backend && uv run pytest -q cd backend && uv run pytest -q
@ -37,3 +43,6 @@ typecheck:
codegen: codegen:
cd frontend && npm run codegen cd frontend && npm run codegen
pre-commit-install:
pip install pre-commit && pre-commit install

View file

@ -1,10 +1,15 @@
# Hostname-related defaults assume Docker Compose context (this is the recommended way to run). # Hostname-related defaults assume Docker Compose context (recommended).
# For native development without Docker, swap "postgres" → "localhost" and "redis" → "localhost". # For native dev without Docker, swap "postgres" → "localhost" and "redis" → "localhost".
DATABASE_URL=postgresql+psycopg://gendesign:gendesign@postgres:5432/gendesign DATABASE_URL=postgresql+psycopg://gendesign:gendesign@postgres:5432/gendesign
REDIS_URL=redis://redis:6379/0 REDIS_URL=redis://redis:6379/0
CORS_ORIGINS=["http://localhost:3000"] CORS_ORIGINS=["http://localhost:3000"]
ENVIRONMENT=dev ENVIRONMENT=dev
# Sentry: create a project at https://sentry.io/, paste the DSN here.
# Empty string = Sentry disabled. main.py initializes only if non-empty.
SENTRY_DSN= SENTRY_DSN=
# External APIs (Stage 2)
ROSREESTR_PKK_BASE_URL=https://pkk.rosreestr.ru/api/features/1 ROSREESTR_PKK_BASE_URL=https://pkk.rosreestr.ru/api/features/1
OVERPASS_URL=https://overpass-api.de/api/interpreter OVERPASS_URL=https://overpass-api.de/api/interpreter

View file

@ -24,6 +24,8 @@ COPY pyproject.toml uv.lock* ./
RUN if [ -f uv.lock ]; then uv sync --frozen --no-dev; else uv sync --no-dev; fi RUN if [ -f uv.lock ]; then uv sync --frozen --no-dev; else uv sync --no-dev; fi
COPY app ./app COPY app ./app
COPY alembic.ini ./
COPY alembic ./alembic
# ---- runner ---- # ---- runner ----
@ -51,6 +53,8 @@ WORKDIR /app
COPY --from=builder --chown=app:app /app/.venv /app/.venv COPY --from=builder --chown=app:app /app/.venv /app/.venv
COPY --from=builder --chown=app:app /app/app /app/app COPY --from=builder --chown=app:app /app/app /app/app
COPY --from=builder --chown=app:app /app/alembic.ini /app/alembic.ini
COPY --from=builder --chown=app:app /app/alembic /app/alembic
USER app USER app

43
backend/alembic.ini Normal file
View file

@ -0,0 +1,43 @@
# Alembic config.
# Database URL is loaded from app.core.config.settings (which reads .env)
# in alembic/env.py — do NOT hardcode it here.
[alembic]
script_location = alembic
prepend_sys_path = .
version_path_separator = os
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(slug)s
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

62
backend/alembic/env.py Normal file
View file

@ -0,0 +1,62 @@
"""Alembic env. Loads DB URL from app settings, registers all SQLAlchemy
models so autogenerate can detect schema drift."""
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from app.core.config import settings
from app.core.db import Base
# Import models so they register on Base.metadata.
# Add new model modules here as they appear.
from app.models import parcel # noqa: F401
config = context.config
# Inject runtime DB URL.
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Generate SQL without a live DB connection."""
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations against a live DB."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
compare_server_default=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View file

@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View file

View file

@ -0,0 +1,23 @@
-- Drops postgis-image extras that we don't use.
--
-- The postgis/postgis:16-3.4 base image's own init (10_postgis.sh) installs:
-- postgis, postgis_topology, fuzzystrmatch, postgis_tiger_geocoder
--
-- We only need `postgis` proper. The TIGER geocoder is a US Census Bureau dataset
-- (~35 tables, ~10 MB) that's irrelevant for РФ-region work. Topology and
-- fuzzystrmatch are unused too.
--
-- This script lives in /docker-entrypoint-initdb.d/ and runs ONCE on a fresh
-- volume, alphabetically AFTER 10_postgis.sh — so it sees a fully-extended DB
-- and trims the parts we don't need.
--
-- IF EXISTS + CASCADE: idempotent, won't fail if extension is already gone.
DROP EXTENSION IF EXISTS postgis_tiger_geocoder CASCADE;
DROP EXTENSION IF EXISTS postgis_topology CASCADE;
DROP EXTENSION IF EXISTS fuzzystrmatch CASCADE;
-- Empty schemas the dropped extensions left behind.
DROP SCHEMA IF EXISTS tiger CASCADE;
DROP SCHEMA IF EXISTS tiger_data CASCADE;
DROP SCHEMA IF EXISTS topology CASCADE;

View file

@ -34,6 +34,7 @@ dev = [
"ruff>=0.5.0", "ruff>=0.5.0",
"mypy>=1.10.0", "mypy>=1.10.0",
"types-redis>=4.6.0", "types-redis>=4.6.0",
"pre-commit>=3.7.0",
] ]
[tool.uv] [tool.uv]

100
backend/uv.lock generated
View file

@ -227,6 +227,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
] ]
[[package]]
name = "cfgv"
version = "3.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
]
[[package]] [[package]]
name = "click" name = "click"
version = "8.3.3" version = "8.3.3"
@ -351,6 +360,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" }, { url = "https://files.pythonhosted.org/packages/21/0e/8459ca4413e1a21a06c97d134bfaf18adfd27cea068813dc0faae06cbf00/cssselect2-0.9.0-py3-none-any.whl", hash = "sha256:6a99e5f91f9a016a304dd929b0966ca464bcfda15177b6fb4a118fc0fb5d9563", size = 15453, upload-time = "2026-02-12T17:16:38.317Z" },
] ]
[[package]]
name = "distlib"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
]
[[package]] [[package]]
name = "et-xmlfile" name = "et-xmlfile"
version = "2.0.0" version = "2.0.0"
@ -407,6 +425,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" },
] ]
[[package]]
name = "filelock"
version = "3.29.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" },
]
[[package]] [[package]]
name = "fonttools" name = "fonttools"
version = "4.62.1" version = "4.62.1"
@ -486,6 +513,7 @@ dependencies = [
[package.dev-dependencies] [package.dev-dependencies]
dev = [ dev = [
{ name = "mypy" }, { name = "mypy" },
{ name = "pre-commit" },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-asyncio" }, { name = "pytest-asyncio" },
{ name = "ruff" }, { name = "ruff" },
@ -520,6 +548,7 @@ requires-dist = [
[package.metadata.requires-dev] [package.metadata.requires-dev]
dev = [ dev = [
{ name = "mypy", specifier = ">=1.10.0" }, { name = "mypy", specifier = ">=1.10.0" },
{ name = "pre-commit", specifier = ">=3.7.0" },
{ name = "pytest", specifier = ">=8.0.0" }, { name = "pytest", specifier = ">=8.0.0" },
{ name = "pytest-asyncio", specifier = ">=0.23.0" }, { name = "pytest-asyncio", specifier = ">=0.23.0" },
{ name = "ruff", specifier = ">=0.5.0" }, { name = "ruff", specifier = ">=0.5.0" },
@ -661,6 +690,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
] ]
[[package]]
name = "identify"
version = "2.6.19"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" },
]
[[package]] [[package]]
name = "idna" name = "idna"
version = "3.13" version = "3.13"
@ -890,6 +928,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
] ]
[[package]]
name = "nodeenv"
version = "1.10.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" },
]
[[package]] [[package]]
name = "numpy" name = "numpy"
version = "2.4.4" version = "2.4.4"
@ -1102,6 +1149,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" },
] ]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@ -1111,6 +1167,22 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
] ]
[[package]]
name = "pre-commit"
version = "4.6.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cfgv" },
{ name = "identify" },
{ name = "nodeenv" },
{ name = "pyyaml" },
{ name = "virtualenv" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" },
]
[[package]] [[package]]
name = "prompt-toolkit" name = "prompt-toolkit"
version = "3.0.52" version = "3.0.52"
@ -1470,6 +1542,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
] ]
[[package]]
name = "python-discovery"
version = "1.2.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "filelock" },
{ name = "platformdirs" },
]
sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
]
[[package]] [[package]]
name = "python-dotenv" name = "python-dotenv"
version = "1.2.2" version = "1.2.2"
@ -1997,6 +2082,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" }, { url = "https://files.pythonhosted.org/packages/03/ff/7c0c86c43b3cbb927e0ccc0255cb4057ceba4799cd44ae95174ce8e8b5b2/vine-5.1.0-py3-none-any.whl", hash = "sha256:40fdf3c48b2cfe1c38a49e9ae2da6fda88e4794c810050a728bd7413811fb1dc", size = 9636, upload-time = "2023-11-05T08:46:51.205Z" },
] ]
[[package]]
name = "virtualenv"
version = "21.2.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "distlib" },
{ name = "filelock" },
{ name = "platformdirs" },
{ name = "python-discovery" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" },
]
[[package]] [[package]]
name = "watchfiles" name = "watchfiles"
version = "1.1.1" version = "1.1.1"

View file

@ -27,6 +27,7 @@ services:
- "127.0.0.1:5432:5432" - "127.0.0.1:5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./backend/db/init:/docker-entrypoint-initdb.d:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"]
interval: 5s interval: 5s

View file

@ -9,6 +9,7 @@ services:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
- ./backend/db/init:/docker-entrypoint-initdb.d:ro
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-gendesign}"] test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-gendesign}"]
interval: 5s interval: 5s

73
ops/backup.sh Normal file
View file

@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Daily Postgres backup script. Designed to run from cron on the prod VM.
#
# Usage (one-time setup on VM):
# sudo cp /opt/gendesign/ops/backup.sh /usr/local/bin/gendesign-backup
# sudo chmod +x /usr/local/bin/gendesign-backup
# crontab -e
# # Run daily at 03:30 Moscow time
# 30 3 * * * /usr/local/bin/gendesign-backup >> /var/log/gendesign-backup.log 2>&1
#
# Optional S3 upload — set these env vars in /etc/default/gendesign-backup
# (or as `Environment=` in a systemd timer). Without them, dumps stay local only.
#
# S3_ENDPOINT=https://s3.ru-1.storage.selcloud.ru # Selectel S3
# S3_BUCKET=gendesign-backups
# S3_ACCESS_KEY=...
# S3_SECRET_KEY=...
#
# Retention: deletes local dumps older than 7 days. S3 retention policy should
# be configured at the bucket level (lifecycle rule), not in this script.
set -euo pipefail
# --- config ---
COMPOSE_DIR=/opt/gendesign
COMPOSE_FILE=docker-compose.prod.yml
LOCAL_BACKUP_DIR=/opt/gendesign/backups
RETENTION_DAYS=7
# Optional S3 env. Loaded from /etc/default/gendesign-backup if present.
[[ -f /etc/default/gendesign-backup ]] && source /etc/default/gendesign-backup
# --- run ---
mkdir -p "$LOCAL_BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
DUMP_FILE="${LOCAL_BACKUP_DIR}/gendesign_${TIMESTAMP}.sql.gz"
cd "$COMPOSE_DIR"
# Read DB user/name from running compose env. Falls back to "gendesign" if env empty.
DB_USER=$(docker compose -f "$COMPOSE_FILE" exec -T postgres printenv POSTGRES_USER 2>/dev/null | tr -d '\r' || true)
DB_NAME=$(docker compose -f "$COMPOSE_FILE" exec -T postgres printenv POSTGRES_DB 2>/dev/null | tr -d '\r' || true)
DB_USER=${DB_USER:-gendesign}
DB_NAME=${DB_NAME:-gendesign}
echo "[$(date -Is)] Dumping ${DB_NAME} as ${DB_USER}${DUMP_FILE}"
docker compose -f "$COMPOSE_FILE" exec -T postgres \
pg_dump --clean --if-exists -U "$DB_USER" "$DB_NAME" \
| gzip -9 > "$DUMP_FILE"
DUMP_SIZE=$(stat -c '%s' "$DUMP_FILE")
echo "[$(date -Is)] Dump complete: ${DUMP_SIZE} bytes"
# --- optional S3 upload ---
if [[ -n "${S3_ENDPOINT:-}" && -n "${S3_BUCKET:-}" && -n "${S3_ACCESS_KEY:-}" && -n "${S3_SECRET_KEY:-}" ]]; then
echo "[$(date -Is)] Uploading to s3://${S3_BUCKET}/"
docker run --rm \
-e AWS_ACCESS_KEY_ID="$S3_ACCESS_KEY" \
-e AWS_SECRET_ACCESS_KEY="$S3_SECRET_KEY" \
-v "$LOCAL_BACKUP_DIR":/backup:ro \
amazon/aws-cli:latest \
--endpoint-url "$S3_ENDPOINT" \
s3 cp "/backup/$(basename "$DUMP_FILE")" "s3://${S3_BUCKET}/"
echo "[$(date -Is)] S3 upload OK"
else
echo "[$(date -Is)] S3 vars not set — backup stays local only"
fi
# --- prune old local dumps ---
find "$LOCAL_BACKUP_DIR" -name 'gendesign_*.sql.gz' -mtime "+${RETENTION_DAYS}" -print -delete
echo "[$(date -Is)] Backup done."

43
ops/restore.sh Normal file
View file

@ -0,0 +1,43 @@
#!/usr/bin/env bash
# Restore a Postgres dump produced by backup.sh.
#
# Usage:
# ops/restore.sh /opt/gendesign/backups/gendesign_20260427_033000.sql.gz
#
# Refuses to run unless RESTORE_CONFIRM=yes is set. This is destructive —
# the dump uses --clean --if-exists, so it DROPs existing tables before recreating.
#
# Example:
# RESTORE_CONFIRM=yes ops/restore.sh ./gendesign_20260427_033000.sql.gz
set -euo pipefail
if [[ "${RESTORE_CONFIRM:-no}" != "yes" ]]; then
echo "Refusing to restore — set RESTORE_CONFIRM=yes to proceed."
echo "This will DROP existing tables and recreate from the dump."
exit 1
fi
DUMP_FILE="${1:-}"
if [[ -z "$DUMP_FILE" || ! -f "$DUMP_FILE" ]]; then
echo "Usage: $0 /path/to/dump.sql.gz"
exit 2
fi
COMPOSE_DIR=/opt/gendesign
COMPOSE_FILE=docker-compose.prod.yml
cd "$COMPOSE_DIR"
DB_USER=$(docker compose -f "$COMPOSE_FILE" exec -T postgres printenv POSTGRES_USER 2>/dev/null | tr -d '\r' || true)
DB_NAME=$(docker compose -f "$COMPOSE_FILE" exec -T postgres printenv POSTGRES_DB 2>/dev/null | tr -d '\r' || true)
DB_USER=${DB_USER:-gendesign}
DB_NAME=${DB_NAME:-gendesign}
echo "Restoring $DUMP_FILE${DB_NAME} as ${DB_USER}"
gunzip -c "$DUMP_FILE" | \
docker compose -f "$COMPOSE_FILE" exec -T postgres \
psql -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1
echo "Restore complete."

View file

@ -50,4 +50,9 @@ pat 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
echo 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| docker login ghcr.io -u lekss361 --password-stdin echo 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| docker login ghcr.io -u lekss361 --password-stdin
ssh -N -L 15432:localhost:5432 gendesign
Host: localhost
Port: 15432
Database: gendesign
Username: gendesign
Password: 2J2SBPMKuS998fiwhtQqDhMI