136 lines
4.3 KiB
Python
136 lines
4.3 KiB
Python
"""Docker entrypoint: wait for DB, reconcile Alembic state, migrate, start API."""
|
|
|
|
from __future__ import annotations
|
|
|
|
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
|
|
from app.core.server_logging import append_server_log_line, build_uvicorn_log_config, ensure_server_log_file
|
|
|
|
INITIAL_REVISION = "20260711_0001"
|
|
HEAD_REVISION = "20260716_0009"
|
|
SCHEMA_TABLES = (
|
|
"content_pages",
|
|
"email_verification_tokens",
|
|
"password_reset_tokens",
|
|
"refresh_tokens",
|
|
"user_profiles",
|
|
"users",
|
|
"enterprises",
|
|
"enterprise_members",
|
|
"farm_hubs",
|
|
"user_farm_access",
|
|
"hub_credentials",
|
|
"hub_pairing_sessions",
|
|
"sync_outbox",
|
|
"sync_event_log",
|
|
"sync_applied_events",
|
|
"sync_cursors",
|
|
"sync_record_state",
|
|
"sync_conflicts",
|
|
"sync_reconcile_runs",
|
|
"report_sync_state",
|
|
"zootech_component",
|
|
"zootech_recipe",
|
|
"zootech_ingredient",
|
|
)
|
|
|
|
|
|
def wait_for_database(max_attempts: int = 30, delay_seconds: float = 1.0):
|
|
if settings.database_url.startswith("sqlite"):
|
|
raise RuntimeError(
|
|
"DATABASE_URL points to SQLite — Docker API requires PostgreSQL.\n"
|
|
"Your apps/api/data/secrets/install.env was likely overwritten by a local test.\n"
|
|
"Fix:\n"
|
|
" rm apps/api/data/secrets/install.env\n"
|
|
" python apps/api/scripts/bootstrap_install.py\n"
|
|
" docker compose --profile docker-web down -v\n"
|
|
" docker compose --profile docker-web up -d --build"
|
|
)
|
|
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)
|
|
ensure_server_log_file()
|
|
append_server_log_line("API starting (docker entrypoint)")
|
|
import uvicorn
|
|
|
|
uvicorn.run(
|
|
"app.main:app",
|
|
host="0.0.0.0",
|
|
port=8000,
|
|
log_config=build_uvicorn_log_config(),
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|