Безопасность довёл до ума — Cursor-генерацию переписал руками. IDOR закрыл, CSRF задушил, refresh rotation теперь как надо. HSTS на staging, ENABLE_DOCS=false, install.env recovery протестил. Backend: - jwt_denylist + auth_epoch: мгновенный revoke access JWT (logout/block/reset) - auth/admin/users: bump epoch, logout с Bearer, forgot_password skip для blocked - install_secrets: путь всегда apps/api/data/secrets/ (bootstrap из корня не ломает Docker) - seed: SEED_DEMO_USERS=false на prod/staging - тесты: jwt revoke, integration, coverage gate 90% Frontend: - logout шлёт Bearer, обработка TOKEN_REVOKED - guards TypeScript fix - E2E: blocked user → 401 сразу после block Infra: - staging/prod compose, TLS nginx, deploy-скрипты - k6 §17.2, backup/health/smoke scripts Docs: - docs/ на русском: project, security, deploy, release (старые md слили) - README короткий + план ТЗ + стандартные логины dev Код готов к плаванию. Капитан может идти писать фронт.
103 lines
3.2 KiB
Python
103 lines
3.2 KiB
Python
"""Docker entrypoint: wait for DB, reconcile Alembic state, migrate, start API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
if str(ROOT) not in sys.path:
|
|
sys.path.insert(0, str(ROOT))
|
|
|
|
from alembic import command
|
|
from alembic.config import Config
|
|
from sqlalchemy import create_engine, inspect, text
|
|
|
|
from app.core.install_secrets import ensure_install_secrets
|
|
from app.core.config import settings
|
|
|
|
INITIAL_REVISION = "20260711_0001"
|
|
HEAD_REVISION = "20260714_0005"
|
|
SCHEMA_TABLES = (
|
|
"content_pages",
|
|
"email_verification_tokens",
|
|
"password_reset_tokens",
|
|
"refresh_tokens",
|
|
"user_profiles",
|
|
"users",
|
|
)
|
|
|
|
|
|
def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0):
|
|
engine = create_engine(settings.database_url)
|
|
last_error: Exception | None = None
|
|
for _ in range(max_attempts):
|
|
try:
|
|
with engine.connect() as connection:
|
|
connection.execute(text("SELECT 1"))
|
|
return engine
|
|
except Exception as exc:
|
|
last_error = exc
|
|
time.sleep(delay_seconds)
|
|
detail = str(last_error or "unknown error")
|
|
hint = ""
|
|
if "password authentication failed" in detail or "does not exist" in detail:
|
|
hint = (
|
|
"\n\nPostgres credentials in install.env do not match the existing database volume "
|
|
"(common if docker compose ran before bootstrap_install.py).\n"
|
|
"Local dev fix — deletes Docker DB data:\n"
|
|
" docker compose --profile docker-web down -v\n"
|
|
" docker compose --profile docker-web up -d --build\n"
|
|
"See docs/deploy.md#восстановление-секретов"
|
|
)
|
|
raise RuntimeError(f"Database is unavailable: {detail}{hint}") from last_error
|
|
|
|
|
|
def current_revision(engine) -> str | None:
|
|
inspector = inspect(engine)
|
|
if "alembic_version" not in inspector.get_table_names():
|
|
return None
|
|
with engine.connect() as connection:
|
|
return connection.execute(text("SELECT version_num FROM alembic_version")).scalar()
|
|
|
|
|
|
def _reset_schema(engine) -> None:
|
|
cascade = " CASCADE" if engine.dialect.name == "postgresql" else ""
|
|
with engine.begin() as connection:
|
|
for table in SCHEMA_TABLES:
|
|
connection.execute(text(f'DROP TABLE IF EXISTS "{table}"{cascade}'))
|
|
connection.execute(text(f'DROP TABLE IF EXISTS "alembic_version"{cascade}'))
|
|
|
|
|
|
def run_migrations(engine) -> None:
|
|
config = Config("alembic.ini")
|
|
tables = set(inspect(engine).get_table_names())
|
|
revision = current_revision(engine)
|
|
schema_tables = set(SCHEMA_TABLES)
|
|
existing_schema = tables & schema_tables
|
|
|
|
if existing_schema:
|
|
if schema_tables.issubset(tables):
|
|
if revision is None:
|
|
command.stamp(config, INITIAL_REVISION)
|
|
else:
|
|
_reset_schema(engine)
|
|
|
|
command.upgrade(config, "head")
|
|
|
|
|
|
def main() -> None:
|
|
ensure_install_secrets()
|
|
engine = wait_for_database()
|
|
run_migrations(engine)
|
|
subprocess.run(
|
|
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"],
|
|
check=True,
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|