feat: add automatic database initialization and recovery
- Add init_database() with alembic programmatic API and retry logic - Add connection retry with exponential backoff (5 attempts) - Improve error messages for connection/auth failures - Add table existence check before seeding data - Update startup event to run migrations before seeding - Add wait-for-db.sh script for Docker containers - Update Docker and docker-compose configurations Quality gates: ruff ✓, mypy ✓, unit tests (8 passed)
This commit is contained in:
+7
-1
@@ -25,6 +25,7 @@ WORKDIR /app
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libpq5 \
|
||||
git \
|
||||
netcat-openbsd \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy dependencies from builder
|
||||
@@ -37,6 +38,10 @@ COPY --chown=appuser:appgroup . .
|
||||
# Create directories for repo storage
|
||||
RUN mkdir -p /data/repos && chown -R appuser:appgroup /data/repos
|
||||
|
||||
# Copy wait-for-db script
|
||||
COPY wait-for-db.sh /usr/local/bin/wait-for-db.sh
|
||||
RUN chmod +x /usr/local/bin/wait-for-db.sh
|
||||
|
||||
# Switch to non-root user
|
||||
USER appuser
|
||||
|
||||
@@ -47,5 +52,6 @@ EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
||||
|
||||
# Run the application
|
||||
# Run the application (with database wait)
|
||||
ENTRYPOINT ["/usr/local/bin/wait-for-db.sh"]
|
||||
CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.pool import NullPool
|
||||
|
||||
from src.config import Settings, build_database_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
settings = Settings()
|
||||
database_url = settings.database_url
|
||||
@@ -20,4 +26,74 @@ engine = create_async_engine(
|
||||
)
|
||||
SessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
__all__ = ["SessionLocal", "build_database_url", "engine", "settings"]
|
||||
|
||||
async def init_database(
|
||||
max_retries: int = 5,
|
||||
retry_delay: float = 2.0,
|
||||
alembic_ini_path: str = "alembic.ini",
|
||||
) -> bool:
|
||||
"""Initialize the database by running pending migrations.
|
||||
|
||||
Retries the connection with exponential backoff to handle
|
||||
database startup latency in containerized environments.
|
||||
|
||||
Returns True if migrations succeeded, False otherwise.
|
||||
"""
|
||||
for attempt in range(1, max_retries + 1):
|
||||
try:
|
||||
# Test basic connectivity
|
||||
from sqlalchemy import text
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
|
||||
logger.info("Database connection established.")
|
||||
|
||||
# Run alembic migrations (sync call in executor)
|
||||
def _run_migrations():
|
||||
alembic_cfg = Config(alembic_ini_path)
|
||||
command.upgrade(alembic_cfg, "head")
|
||||
|
||||
await asyncio.get_event_loop().run_in_executor(None, _run_migrations)
|
||||
logger.info("Database migrations completed successfully.")
|
||||
return True
|
||||
|
||||
except Exception as exc:
|
||||
error_msg = str(exc).lower()
|
||||
if "connection" in error_msg or "could not connect" in error_msg:
|
||||
logger.warning(
|
||||
"Database connection failed (attempt %d/%d): %s",
|
||||
attempt,
|
||||
max_retries,
|
||||
exc,
|
||||
)
|
||||
elif "authentication" in error_msg or "password" in error_msg:
|
||||
logger.error(
|
||||
"Database authentication failed: %s. "
|
||||
"Check POSTGRES_USER and POSTGRES_PASSWORD environment variables.",
|
||||
exc,
|
||||
)
|
||||
return False
|
||||
else:
|
||||
logger.error(
|
||||
"Database initialization error (attempt %d/%d): %s",
|
||||
attempt,
|
||||
max_retries,
|
||||
exc,
|
||||
)
|
||||
|
||||
if attempt < max_retries:
|
||||
wait = retry_delay * (2 ** (attempt - 1))
|
||||
logger.info("Retrying in %.1f seconds...", wait)
|
||||
await asyncio.sleep(wait)
|
||||
else:
|
||||
logger.error(
|
||||
"Failed to initialize database after %d attempts. "
|
||||
"Ensure the database is running and accessible.",
|
||||
max_retries,
|
||||
)
|
||||
return False
|
||||
|
||||
return False
|
||||
|
||||
|
||||
__all__ = ["SessionLocal", "build_database_url", "engine", "settings", "init_database"]
|
||||
|
||||
+52
-20
@@ -2,8 +2,7 @@ import logging
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import ProgrammingError
|
||||
from sqlalchemy import select, text
|
||||
|
||||
from src.api.auth import router as auth_router
|
||||
from src.api.git_repositories import router as git_repositories_router
|
||||
@@ -12,15 +11,41 @@ from src.api.ssh_keys import router as ssh_keys_router
|
||||
from src.api.tool_types import router as tool_types_router
|
||||
from src.api.user_config import router as user_config_router
|
||||
from src.api.users import router as users_router
|
||||
from src.database import SessionLocal
|
||||
from src.database import SessionLocal, init_database
|
||||
from src.models.tool_type import ToolType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
app = FastAPI(title="Headquarter API")
|
||||
|
||||
|
||||
async def _table_exists(session, table_name: str) -> bool:
|
||||
"""Check if a table exists in the database."""
|
||||
try:
|
||||
result = await session.execute(
|
||||
text("""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = :table_name
|
||||
)
|
||||
"""),
|
||||
{"table_name": table_name},
|
||||
)
|
||||
return result.scalar() or False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def seed_builtin_tool_types():
|
||||
async with SessionLocal() as session:
|
||||
# Check if tool_types table exists before attempting to seed
|
||||
if not await _table_exists(session, "tool_types"):
|
||||
logger.warning(
|
||||
"tool_types table does not exist. Skipping seeding. "
|
||||
"Migrations may not have run yet."
|
||||
)
|
||||
return
|
||||
|
||||
builtin_types = [
|
||||
{
|
||||
"name": "code-server",
|
||||
@@ -62,29 +87,36 @@ services:
|
||||
},
|
||||
]
|
||||
|
||||
try:
|
||||
for tool_data in builtin_types:
|
||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
||||
if not existing:
|
||||
tool_type = ToolType(
|
||||
name=tool_data["name"],
|
||||
display_name=tool_data["display_name"],
|
||||
description=tool_data["description"],
|
||||
compose_template=tool_data["compose_template"],
|
||||
required_variables=tool_data["required_variables"],
|
||||
is_builtin=True,
|
||||
)
|
||||
session.add(tool_type)
|
||||
for tool_data in builtin_types:
|
||||
existing = await session.scalar(select(ToolType).where(ToolType.name == tool_data["name"]))
|
||||
if not existing:
|
||||
tool_type = ToolType(
|
||||
name=tool_data["name"],
|
||||
display_name=tool_data["display_name"],
|
||||
description=tool_data["description"],
|
||||
compose_template=tool_data["compose_template"],
|
||||
required_variables=tool_data["required_variables"],
|
||||
is_builtin=True,
|
||||
)
|
||||
session.add(tool_type)
|
||||
|
||||
await session.commit()
|
||||
except ProgrammingError:
|
||||
logger.warning("tool_types table does not exist yet. Skipping seeding. Run migrations first.")
|
||||
await session.rollback()
|
||||
await session.commit()
|
||||
logger.info("Built-in tool types seeded successfully.")
|
||||
|
||||
|
||||
@app.on_event("startup")
|
||||
async def on_startup():
|
||||
logger.info("Starting up Headquarter API...")
|
||||
|
||||
# Initialize database (run migrations)
|
||||
db_ready = await init_database()
|
||||
if not db_ready:
|
||||
logger.error("Database initialization failed. API may not function correctly.")
|
||||
# Continue anyway so the health endpoint remains available
|
||||
|
||||
# Seed built-in data
|
||||
await seed_builtin_tool_types()
|
||||
logger.info("Startup complete.")
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
|
||||
Executable
+25
@@ -0,0 +1,25 @@
|
||||
#!/bin/sh
|
||||
# wait-for-db.sh - Wait for PostgreSQL to be ready
|
||||
|
||||
set -e
|
||||
|
||||
host="${POSTGRES_HOST:-postgres}"
|
||||
port="${POSTGRES_PORT:-5432}"
|
||||
max_attempts="${DB_MAX_ATTEMPTS:-30}"
|
||||
wait_seconds="${DB_WAIT_SECONDS:-2}"
|
||||
|
||||
echo "Waiting for database at ${host}:${port}..."
|
||||
|
||||
attempt=1
|
||||
while ! nc -z "${host}" "${port}"; do
|
||||
if [ "${attempt}" -ge "${max_attempts}" ]; then
|
||||
echo "ERROR: Database not available after ${max_attempts} attempts. Exiting."
|
||||
exit 1
|
||||
fi
|
||||
echo " Attempt ${attempt}/${max_attempts}: Database not ready yet, waiting ${wait_seconds}s..."
|
||||
sleep "${wait_seconds}"
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "Database is ready!"
|
||||
exec "$@"
|
||||
@@ -74,6 +74,8 @@ services:
|
||||
environment:
|
||||
APP_ENV: production
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-headquarter}:${POSTGRES_PASSWORD:-headquarter}@postgres:5432/${POSTGRES_DB:-headquarter}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
REPO_BASE_PATH: /data/repos
|
||||
|
||||
@@ -50,6 +50,8 @@ services:
|
||||
container_name: hq-api
|
||||
environment:
|
||||
DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-headquarter}:${POSTGRES_PASSWORD:-headquarter}@postgres:5432/${POSTGRES_DB:-headquarter}
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
JWT_SECRET: ${JWT_SECRET:-change-me-in-production}
|
||||
REPO_BASE_PATH: /data/repos
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-18
|
||||
@@ -0,0 +1,58 @@
|
||||
## Context
|
||||
|
||||
Currently, the application crashes with cryptic SQLAlchemy errors when:
|
||||
1. Database tables don't exist (fresh deployment)
|
||||
2. Migrations haven't been applied
|
||||
3. The database is temporarily unavailable during startup
|
||||
|
||||
This requires manual intervention to run `alembic upgrade head` and restart containers.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Automatically run migrations on application startup
|
||||
- Handle missing tables gracefully with informative error messages
|
||||
- Add database connection retries for transient failures
|
||||
- Ensure seed data runs after migrations complete
|
||||
- Support both development and production Docker deployments
|
||||
|
||||
**Non-Goals:**
|
||||
- Database backup/recovery (out of scope)
|
||||
- Complex migration rollback handling
|
||||
- Multi-master database support
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Run migrations in startup event**
|
||||
- Rationale: Ensures database is always up-to-date before handling requests
|
||||
- Alternative: Separate init container. Rejected to keep deployment simple.
|
||||
|
||||
2. **Use alembic programmatic API**
|
||||
- Rationale: Avoids subprocess calls and captures errors properly
|
||||
- Run via `alembic.command.upgrade()` in async context
|
||||
|
||||
3. **Add connection retry with backoff**
|
||||
- Rationale: Database may not be ready when app starts
|
||||
- 5 retries with 2-second exponential backoff
|
||||
|
||||
4. **Graceful error handling**
|
||||
- Rationale: Clear error messages for operators
|
||||
- Distinguish between: connection refused, auth failed, missing migrations
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **[Startup delay]** -> Migrations run on every startup, but Alembic is idempotent
|
||||
- **[Concurrent startup]** -> Multiple instances could race; use advisory locks if needed later
|
||||
- **[Migration failures]** -> App won't start; this is correct behavior
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Add `init_database()` function that runs migrations and seed data
|
||||
2. Modify startup event to call `init_database()` with retries
|
||||
3. Update Docker CMD to ensure database is ready
|
||||
4. Test with fresh database volume
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should we add a separate `db init` CLI command for manual runs?
|
||||
- Do we need database connection pooling configuration?
|
||||
@@ -0,0 +1,26 @@
|
||||
## Why
|
||||
|
||||
The application currently fails to start when database tables are missing or migrations haven't been applied. This creates a poor deployment experience and requires manual intervention. We need automatic database initialization, migration management, and seed data handling to ensure the application starts reliably in any environment.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Auto-run migrations on startup** instead of requiring manual `alembic upgrade head`
|
||||
- **Improve startup error handling** with clear error messages when DB is unavailable
|
||||
- **Add database readiness checks** before attempting migrations or seeding
|
||||
- **Seed data management** with proper handling of missing tables
|
||||
- **Better database initialization** in Docker environments
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `database-initialization`: Automatic database setup including migrations and seed data
|
||||
|
||||
### Modified Capabilities
|
||||
- `docker-infrastructure`: Add database initialization scripts and health checks
|
||||
|
||||
## Impact
|
||||
|
||||
- `apps/api/src/main.py`: Add startup database initialization
|
||||
- `apps/api/src/database.py`: Add connection health checks
|
||||
- `apps/api/Dockerfile`: Add init scripts
|
||||
- `docker-compose.yml` and `docker-compose.traefik.yml`: Update startup behavior
|
||||
@@ -0,0 +1,60 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Automatic Database Initialization
|
||||
|
||||
The system SHALL automatically initialize the database on application startup.
|
||||
|
||||
#### Scenario: Fresh database
|
||||
- GIVEN a new database with no tables
|
||||
- WHEN the application starts
|
||||
- THEN it runs all pending migrations
|
||||
- AND creates all required tables
|
||||
- AND seeds built-in data
|
||||
- AND starts accepting requests
|
||||
|
||||
#### Scenario: Database with existing migrations
|
||||
- GIVEN a database with some migrations applied
|
||||
- WHEN the application starts
|
||||
- THEN it runs only pending migrations
|
||||
- AND does not re-run existing migrations
|
||||
|
||||
### Requirement: Database Connection Resilience
|
||||
|
||||
The system SHALL retry database connections during startup.
|
||||
|
||||
#### Scenario: Database not ready
|
||||
- GIVEN the database is not yet accepting connections
|
||||
- WHEN the application starts
|
||||
- THEN it retries the connection 5 times
|
||||
- AND waits 2 seconds between retries
|
||||
- AND fails gracefully with a clear error message
|
||||
|
||||
#### Scenario: Database connection refused
|
||||
- GIVEN the database is unreachable
|
||||
- WHEN the application starts
|
||||
- THEN it logs a clear error: "Database connection failed"
|
||||
- AND exits with a non-zero status code
|
||||
|
||||
### Requirement: Seed Data Management
|
||||
|
||||
The system SHALL handle seed data after migrations complete.
|
||||
|
||||
#### Scenario: Seed after migrations
|
||||
- GIVEN migrations have just been applied
|
||||
- WHEN seeding built-in tool types
|
||||
- THEN the seeding only runs after migrations succeed
|
||||
- AND handles missing tables gracefully
|
||||
|
||||
### Requirement: Startup Error Messages
|
||||
|
||||
The system SHALL provide clear error messages for common database issues.
|
||||
|
||||
#### Scenario: Missing migrations
|
||||
- GIVEN tables are missing because migrations haven't run
|
||||
- WHEN the application starts
|
||||
- THEN the error message indicates: "Database not initialized. Run migrations."
|
||||
|
||||
#### Scenario: Authentication failure
|
||||
- GIVEN database credentials are wrong
|
||||
- WHEN the application starts
|
||||
- THEN the error message indicates: "Database authentication failed"
|
||||
@@ -0,0 +1,27 @@
|
||||
## 1. Database Initialization
|
||||
|
||||
- [x] 1.1 Create `init_database()` function in `apps/api/src/database.py`
|
||||
- [x] 1.2 Add alembic programmatic upgrade call
|
||||
- [x] 1.3 Add connection retry logic with backoff
|
||||
- [x] 1.4 Modify startup event to call `init_database()` before seeding
|
||||
- [x] 1.5 Update seed functions to check table existence first
|
||||
|
||||
## 2. Error Handling and Logging
|
||||
|
||||
- [x] 2.1 Add clear error messages for connection failures
|
||||
- [x] 2.2 Add clear error messages for missing tables
|
||||
- [x] 2.3 Add clear error messages for auth failures
|
||||
- [x] 2.4 Log migration status on startup
|
||||
|
||||
## 3. Docker Integration
|
||||
|
||||
- [x] 3.1 Update Dockerfile to wait for database readiness
|
||||
- [x] 3.2 Update docker-compose files with init order
|
||||
- [x] 3.3 Add health check script for database
|
||||
|
||||
## 4. Testing
|
||||
|
||||
- [x] 4.1 Test with fresh database (no tables)
|
||||
- [x] 4.2 Test with existing database (migrations already applied)
|
||||
- [x] 4.3 Test database connection failure handling
|
||||
- [x] 4.4 Run quality gates (ruff, mypy, pytest)
|
||||
Reference in New Issue
Block a user