Initial commit: site monorepo with API, web, and infra.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from app.core.install_secrets import ensure_install_secrets
|
||||
|
||||
|
||||
def main() -> None:
|
||||
status = ensure_install_secrets()
|
||||
if status.created:
|
||||
print(f"Created install secrets at {status.path}")
|
||||
print(
|
||||
"\nIf docker compose was already started without install.env, reset the Postgres volume "
|
||||
"before the next start (local dev only — deletes DB data):\n"
|
||||
" docker compose --profile docker-web down -v\n"
|
||||
" docker compose --profile docker-web up -d --build"
|
||||
)
|
||||
else:
|
||||
print(f"Install secrets already exist at {status.path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,135 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,14 @@
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from app.main import app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
schema = app.openapi()
|
||||
output = Path(__file__).resolve().parent.parent / "openapi.json"
|
||||
output.write_text(json.dumps(schema, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Apply Alembic migrations."""
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
|
||||
|
||||
def main() -> None:
|
||||
config = Config("alembic.ini")
|
||||
command.upgrade(config, "head")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
API_DIR = Path(__file__).resolve().parents[1]
|
||||
os.chdir(API_DIR)
|
||||
sys.path.insert(0, str(API_DIR))
|
||||
|
||||
db_file = API_DIR / ".e2e.sqlite"
|
||||
if db_file.exists():
|
||||
db_file.unlink()
|
||||
|
||||
os.environ["DATABASE_URL"] = f"sqlite+pysqlite:///{db_file.as_posix()}"
|
||||
os.environ["EMAIL_DELIVERY_MODE"] = "memory"
|
||||
os.environ["STORAGE_MODE"] = "memory"
|
||||
os.environ["ENABLE_RATE_LIMIT"] = "false"
|
||||
os.environ["ENABLE_TEST_ROUTES"] = "true"
|
||||
web_port = os.environ.get("E2E_WEB_PORT", "5175")
|
||||
os.environ["CORS_ORIGINS"] = f'["http://127.0.0.1:{web_port}","http://localhost:{web_port}"]'
|
||||
|
||||
port = os.environ.get("E2E_API_PORT", "8001")
|
||||
|
||||
subprocess.run(
|
||||
[sys.executable, "-m", "uvicorn", "app.main:app", "--host", "127.0.0.1", "--port", port],
|
||||
cwd=API_DIR,
|
||||
check=True,
|
||||
)
|
||||
Reference in New Issue
Block a user