First product
This commit is contained in:
parent
778da9e2fc
commit
4433d7d2c4
157 changed files with 23975 additions and 0 deletions
126
scripts/bootstrap_admin.py
Normal file
126
scripts/bootstrap_admin.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.auth.security import PasswordService
|
||||
from app.db.models import User
|
||||
from app.db.session import get_session_factory
|
||||
from app.logging_config import configure_logging, parse_redact_fields
|
||||
from app.settings import settings
|
||||
|
||||
configure_logging(
|
||||
level=settings.log_level,
|
||||
log_format=settings.log_format,
|
||||
redact_fields=parse_redact_fields(settings.log_redact_fields),
|
||||
include_uvicorn_access=settings.log_uvicorn_access,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="Create or update the initial admin user.")
|
||||
parser.add_argument("--email", default=os.getenv("BOOTSTRAP_ADMIN_EMAIL"))
|
||||
parser.add_argument("--password", default=os.getenv("BOOTSTRAP_ADMIN_PASSWORD"))
|
||||
parser.add_argument(
|
||||
"--force-password",
|
||||
action="store_true",
|
||||
default=os.getenv("BOOTSTRAP_ADMIN_FORCE_PASSWORD", "0") in {"1", "true", "yes"},
|
||||
help="When user exists, replace password with provided value.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
async def bootstrap_admin(*, email: str | None, password: str | None, force_password: bool) -> int:
|
||||
if not email:
|
||||
logger.info(
|
||||
"admin.bootstrap_skipped",
|
||||
extra={"event": "admin.bootstrap_skipped"},
|
||||
)
|
||||
return 0
|
||||
if not password:
|
||||
logger.error(
|
||||
"admin.bootstrap_missing_password",
|
||||
extra={"event": "admin.bootstrap_missing_password"},
|
||||
)
|
||||
return 1
|
||||
|
||||
normalized_email = email.strip().lower()
|
||||
if not normalized_email:
|
||||
logger.error(
|
||||
"admin.bootstrap_invalid_email",
|
||||
extra={"event": "admin.bootstrap_invalid_email"},
|
||||
)
|
||||
return 1
|
||||
if len(password.strip()) < 8:
|
||||
logger.error(
|
||||
"admin.bootstrap_invalid_password",
|
||||
extra={"event": "admin.bootstrap_invalid_password"},
|
||||
)
|
||||
return 1
|
||||
|
||||
session_factory = get_session_factory()
|
||||
password_service = PasswordService()
|
||||
async with session_factory() as session:
|
||||
result = await session.execute(select(User).where(User.email == normalized_email))
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
user = User(
|
||||
email=normalized_email,
|
||||
password_hash=password_service.hash_password(password.strip()),
|
||||
is_admin=True,
|
||||
is_active=True,
|
||||
)
|
||||
session.add(user)
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"admin.bootstrap_created",
|
||||
extra={"event": "admin.bootstrap_created", "email": normalized_email},
|
||||
)
|
||||
return 0
|
||||
|
||||
changed = False
|
||||
if not user.is_admin:
|
||||
user.is_admin = True
|
||||
changed = True
|
||||
if not user.is_active:
|
||||
user.is_active = True
|
||||
changed = True
|
||||
if force_password:
|
||||
user.password_hash = password_service.hash_password(password.strip())
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"admin.bootstrap_updated",
|
||||
extra={"event": "admin.bootstrap_updated", "email": normalized_email},
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"admin.bootstrap_already_configured",
|
||||
extra={"event": "admin.bootstrap_already_configured", "email": normalized_email},
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = _build_parser()
|
||||
args = parser.parse_args()
|
||||
return asyncio.run(
|
||||
bootstrap_admin(
|
||||
email=args.email,
|
||||
password=args.password,
|
||||
force_password=args.force_password,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
22
scripts/entrypoint.sh
Executable file
22
scripts/entrypoint.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
uv run python /app/scripts/wait_for_db.py
|
||||
|
||||
if [ "${MIGRATE_ON_START:-1}" = "1" ]; then
|
||||
uv run alembic upgrade head
|
||||
fi
|
||||
|
||||
if [ "${BOOTSTRAP_ADMIN_ON_START:-0}" = "1" ]; then
|
||||
uv run python /app/scripts/bootstrap_admin.py
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
if [ "${APP_RELOAD:-0}" = "1" ]; then
|
||||
set -- uv run uvicorn app.main:app --host "${APP_HOST:-0.0.0.0}" --port "${APP_PORT:-8000}" --reload
|
||||
else
|
||||
set -- uv run uvicorn app.main:app --host "${APP_HOST:-0.0.0.0}" --port "${APP_PORT:-8000}" --workers "${UVICORN_WORKERS:-1}"
|
||||
fi
|
||||
fi
|
||||
|
||||
exec "$@"
|
||||
27
scripts/smoke_compose.sh
Executable file
27
scripts/smoke_compose.sh
Executable file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
export COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME:-scholarr-smoke}"
|
||||
export APP_PORT="${APP_PORT:-8000}"
|
||||
export APP_HOST_PORT="${APP_HOST_PORT:-18000}"
|
||||
export POSTGRES_PORT="${POSTGRES_PORT:-15432}"
|
||||
|
||||
cleanup() {
|
||||
docker compose down -v --remove-orphans
|
||||
}
|
||||
|
||||
trap cleanup EXIT
|
||||
|
||||
docker compose up -d --build
|
||||
|
||||
echo "Waiting for application health check..."
|
||||
for _ in {1..45}; do
|
||||
if curl -fsS "http://localhost:${APP_HOST_PORT}/healthz" >/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
curl -fsS "http://localhost:${APP_HOST_PORT}/healthz" >/dev/null
|
||||
|
||||
docker compose run --rm app uv run pytest tests/smoke -m "integration and smoke"
|
||||
68
scripts/wait_for_db.py
Normal file
68
scripts/wait_for_db.py
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.logging_config import configure_logging, parse_redact_fields
|
||||
from app.settings import settings
|
||||
|
||||
configure_logging(
|
||||
level=settings.log_level,
|
||||
log_format=settings.log_format,
|
||||
redact_fields=parse_redact_fields(settings.log_redact_fields),
|
||||
include_uvicorn_access=settings.log_uvicorn_access,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def can_connect(database_url: str) -> bool:
|
||||
engine = create_async_engine(database_url, pool_pre_ping=True)
|
||||
try:
|
||||
async with engine.connect() as conn:
|
||||
result = await conn.execute(text("SELECT 1"))
|
||||
return result.scalar_one() == 1
|
||||
except Exception:
|
||||
return False
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
async def wait_for_database() -> int:
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
logger.error("db.wait_missing_database_url", extra={"event": "db.wait_missing_database_url"})
|
||||
return 1
|
||||
|
||||
timeout_seconds = int(os.getenv("DB_WAIT_TIMEOUT_SECONDS", "60"))
|
||||
interval_seconds = int(os.getenv("DB_WAIT_INTERVAL_SECONDS", "2"))
|
||||
retries = max(timeout_seconds // max(interval_seconds, 1), 1)
|
||||
|
||||
for attempt in range(1, retries + 1):
|
||||
if await can_connect(database_url):
|
||||
logger.info("db.wait_ready", extra={"event": "db.wait_ready"})
|
||||
return 0
|
||||
logger.info(
|
||||
"db.wait_retry",
|
||||
extra={
|
||||
"event": "db.wait_retry",
|
||||
"attempt": attempt,
|
||||
"retries": retries,
|
||||
},
|
||||
)
|
||||
await asyncio.sleep(interval_seconds)
|
||||
|
||||
logger.error(
|
||||
"db.wait_timeout",
|
||||
extra={
|
||||
"event": "db.wait_timeout",
|
||||
"retries": retries,
|
||||
},
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(asyncio.run(wait_for_database()))
|
||||
Loading…
Add table
Add a link
Reference in a new issue