diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..1766d4d5 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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)/ diff --git a/Makefile b/Makefile index 3e358f0b..4ad958fd 100644 --- a/Makefile +++ b/Makefile @@ -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: - @echo "make up - start all services (Postgres, Redis, backend, frontend)" - @echo "make down - stop all services" - @echo "make logs - tail logs from all services" - @echo "make backend-shell - shell into backend container" - @echo "make migrate - run alembic upgrade head" - @echo "make test - run backend pytest" - @echo "make lint - ruff check" - @echo "make typecheck - mypy on core modules" - @echo "make codegen - regenerate frontend TS types from backend OpenAPI" + @echo "make up - start all services (Postgres, Redis, backend, frontend)" + @echo "make down - stop all services" + @echo "make logs - tail logs from all services" + @echo "make backend-shell - shell into backend container" + @echo "make migrate - run alembic upgrade head" + @echo "make migration NAME=... - autogenerate a new alembic migration" + @echo "make test - run backend pytest" + @echo "make lint - ruff check" + @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: docker compose up -d --build @@ -26,6 +28,10 @@ backend-shell: migrate: 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: cd backend && uv run pytest -q @@ -37,3 +43,6 @@ typecheck: codegen: cd frontend && npm run codegen + +pre-commit-install: + pip install pre-commit && pre-commit install diff --git a/backend/.env.example b/backend/.env.example index a06523cd..f76fac82 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,10 +1,15 @@ -# Hostname-related defaults assume Docker Compose context (this is the recommended way to run). -# For native development without Docker, swap "postgres" → "localhost" and "redis" → "localhost". +# Hostname-related defaults assume Docker Compose context (recommended). +# For native dev without Docker, swap "postgres" → "localhost" and "redis" → "localhost". DATABASE_URL=postgresql+psycopg://gendesign:gendesign@postgres:5432/gendesign REDIS_URL=redis://redis:6379/0 CORS_ORIGINS=["http://localhost:3000"] 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= + +# External APIs (Stage 2) ROSREESTR_PKK_BASE_URL=https://pkk.rosreestr.ru/api/features/1 OVERPASS_URL=https://overpass-api.de/api/interpreter diff --git a/backend/Dockerfile b/backend/Dockerfile index cb7ef812..e4bd8542 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -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 COPY app ./app +COPY alembic.ini ./ +COPY alembic ./alembic # ---- runner ---- @@ -51,6 +53,8 @@ WORKDIR /app 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/alembic.ini /app/alembic.ini +COPY --from=builder --chown=app:app /app/alembic /app/alembic USER app diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 00000000..bab09a2f --- /dev/null +++ b/backend/alembic.ini @@ -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 diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 00000000..3b61fbfb --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 00000000..fbc4b07d --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/.gitkeep b/backend/alembic/versions/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/backend/db/init/99_drop_unused_extensions.sql b/backend/db/init/99_drop_unused_extensions.sql new file mode 100644 index 00000000..3aca18d0 --- /dev/null +++ b/backend/db/init/99_drop_unused_extensions.sql @@ -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; diff --git a/backend/pyproject.toml b/backend/pyproject.toml index e299b6b2..0242be28 100644 --- a/backend/pyproject.toml +++ b/backend/pyproject.toml @@ -34,6 +34,7 @@ dev = [ "ruff>=0.5.0", "mypy>=1.10.0", "types-redis>=4.6.0", + "pre-commit>=3.7.0", ] [tool.uv] diff --git a/backend/uv.lock b/backend/uv.lock index 4ed16373..26b15b38 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -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" }, ] +[[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]] name = "click" 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" }, ] +[[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]] name = "et-xmlfile" 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" }, ] +[[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]] name = "fonttools" version = "4.62.1" @@ -486,6 +513,7 @@ dependencies = [ [package.dev-dependencies] dev = [ { name = "mypy" }, + { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "ruff" }, @@ -520,6 +548,7 @@ requires-dist = [ [package.metadata.requires-dev] dev = [ { name = "mypy", specifier = ">=1.10.0" }, + { name = "pre-commit", specifier = ">=3.7.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.23.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" }, ] +[[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]] name = "idna" 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" }, ] +[[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]] name = "numpy" 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" }, ] +[[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]] name = "pluggy" 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" }, ] +[[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]] name = "prompt-toolkit" 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" }, ] +[[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]] name = "python-dotenv" 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" }, ] +[[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]] name = "watchfiles" version = "1.1.1" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5acea330..0817e256 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -27,6 +27,7 @@ services: - "127.0.0.1:5432:5432" volumes: - postgres_data:/var/lib/postgresql/data + - ./backend/db/init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER}"] interval: 5s diff --git a/docker-compose.yml b/docker-compose.yml index c8bb9bc5..9e435125 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data + - ./backend/db/init:/docker-entrypoint-initdb.d:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-gendesign}"] interval: 5s diff --git a/ops/backup.sh b/ops/backup.sh new file mode 100644 index 00000000..337ad755 --- /dev/null +++ b/ops/backup.sh @@ -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." diff --git a/ops/restore.sh b/ops/restore.sh new file mode 100644 index 00000000..e72ee29d --- /dev/null +++ b/ops/restore.sh @@ -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." diff --git a/sshkey.txt b/sshkey.txt index aa3cfec1..2140d4be 100644 --- a/sshkey.txt +++ b/sshkey.txt @@ -50,4 +50,9 @@ pat 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 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