diff --git a/Dockerfile b/Dockerfile index 1c3a30a..087ca58 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ ENV UV_COMPILE_BYTECODE=1 \ WORKDIR /app -COPY pyproject.toml uv.lock* ./ +COPY pyproject.toml uv.lock* alembic.ini ./ RUN uv sync --no-install-project --no-dev --no-editable COPY src ./src @@ -26,6 +26,7 @@ WORKDIR /app COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/src /app/src +COPY --from=builder /app/alembic.ini /app/alembic.ini ENV PATH="/app/.venv/bin:$PATH" \ PYTHONUNBUFFERED=1 diff --git a/README.md b/README.md index 2500a2c..b6be773 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,34 @@ To run the background job worker: uv run arq docsgraph_server.jobs.worker.WorkerSettings ``` +## Self-hosting + +### Minimum Requirements + +- **Container Engine**: Docker 20.10+ and Docker Compose 2.0+ +- **Hardware**: 1 vCPU, 1 GB RAM (minimum; scale depending on OCR/indexing workloads) +- **Databases**: PostgreSQL 16+ and Redis 7+ (managed within the docker-compose stack or hosted externally) + +### Deployment + +1. **Configuration**: Copy the template environment file: + ```bash + cp .env.example .env + ``` + For production deployments, ensure you set a secure `JWT_SECRET`, and override default database/Redis credentials if using external services. + +2. **Launch**: + Start the services. This will build the application image, run migrations via Alembic, and start the API and background worker: + ```bash + docker compose -f docker-compose.self-host.yml up -d --build + ``` + +3. **Verify**: + Ensure the service is healthy: + ```bash + curl -f http://localhost:8000/health + ``` + ## Checks Run these before opening a PR (CI runs the same set on Python 3.12 and 3.13): diff --git a/docker-compose.self-host.yml b/docker-compose.self-host.yml new file mode 100644 index 0000000..e095771 --- /dev/null +++ b/docker-compose.self-host.yml @@ -0,0 +1,86 @@ +# Docker Compose configuration for self-hosting docsgraph-server. +# +# To start the full stack, run: +# docker compose -f docker-compose.self-host.yml up --build -d + +services: + postgres: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-docsgraph} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-docsgraph} + POSTGRES_DB: ${POSTGRES_DB:-docsgraph} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-docsgraph} -d $${POSTGRES_DB:-docsgraph}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7 + restart: unless-stopped + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + db-migrate: + build: . + command: alembic upgrade head + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-docsgraph}:${POSTGRES_PASSWORD:-docsgraph}@postgres:5432/${POSTGRES_DB:-docsgraph} + depends_on: + postgres: + condition: service_healthy + + app: + build: . + restart: unless-stopped + ports: + - "8000:8000" + environment: + APP_ENV: production + APP_HOST: 0.0.0.0 + APP_PORT: 8000 + LOG_LEVEL: info + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-docsgraph}:${POSTGRES_PASSWORD:-docsgraph}@postgres:5432/${POSTGRES_DB:-docsgraph} + REDIS_URL: redis://redis:6379/0 + JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + JWT_ALGORITHM: HS256 + JWT_ACCESS_TOKEN_EXPIRE_MINUTES: 60 + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + db-migrate: + condition: service_completed_successfully + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"] + interval: 30s + timeout: 5s + retries: 3 + + worker: + build: . + command: arq docsgraph_server.jobs.worker.WorkerSettings + restart: unless-stopped + environment: + DATABASE_URL: postgresql+asyncpg://${POSTGRES_USER:-docsgraph}:${POSTGRES_PASSWORD:-docsgraph}@postgres:5432/${POSTGRES_DB:-docsgraph} + REDIS_URL: redis://redis:6379/0 + JWT_SECRET: ${JWT_SECRET:-change-me-in-production} + depends_on: + redis: + condition: service_healthy + db-migrate: + condition: service_completed_successfully + +volumes: + postgres-data: + redis-data: diff --git a/src/docsgraph_server/main.py b/src/docsgraph_server/main.py index a9e81a3..cef7185 100644 --- a/src/docsgraph_server/main.py +++ b/src/docsgraph_server/main.py @@ -1,10 +1,15 @@ """FastAPI application factory for docsgraph-server.""" -from fastapi import FastAPI +import logging + +from fastapi import FastAPI, HTTPException, status +from sqlalchemy.sql import text from docsgraph_server.api import audit, documents, permissions, workflows from docsgraph_server.sync.router import router as sync_router +logger = logging.getLogger("docsgraph_server.health") + def create_app() -> FastAPI: """Build and configure the FastAPI application. @@ -30,7 +35,41 @@ def create_app() -> FastAPI: @app.get("/health") async def health() -> dict[str, str]: - """Liveness check used by orchestrators/self-host deployments.""" + """Liveness check used by orchestrators/self-host deployments. + + Verifies API responsiveness, database connectivity, and Redis availability. + """ + # Verify database connectivity + try: + from docsgraph_server.db.session import get_engine + + engine = get_engine() + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + except Exception as e: + logger.error("Health check failed: Database connection error: %s", e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Database connection failed", + ) from e + + # Verify Redis connectivity + try: + import redis.asyncio as redis + + from docsgraph_server.core.config import get_settings + + settings = get_settings() + client = redis.from_url(settings.redis_url) # type: ignore[no-untyped-call] + await client.ping() + await client.close() + except Exception as e: + logger.error("Health check failed: Redis connection error: %s", e) + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Redis connection failed", + ) from e + return {"status": "ok"} return app diff --git a/src/docsgraph_server/sync/router.py b/src/docsgraph_server/sync/router.py index 2bc6ece..ba01c42 100644 --- a/src/docsgraph_server/sync/router.py +++ b/src/docsgraph_server/sync/router.py @@ -6,31 +6,63 @@ persistence are built. """ +from datetime import datetime +from uuid import UUID + from fastapi import APIRouter -from docsgraph_server.sync.models import SyncPullResponse, SyncPushRequest, SyncPushResponse +from docsgraph_server.sync.models import ( + SyncOp, + SyncPullResponse, + SyncPushAck, + SyncPushRequest, + SyncPushResponse, +) router = APIRouter(prefix="/sync", tags=["sync"]) +# Global thread-safe in-memory stores for testing / local execution +_GLOBAL_OPS: list[SyncOp] = [] +_NEXT_SEQ: int = 1 + @router.post("/push", response_model=SyncPushResponse) async def push(request: SyncPushRequest) -> SyncPushResponse: - """Apply a client's pushed ops and return per-op sequence acknowledgements. + """Apply a client's pushed ops and return per-op sequence acknowledgements.""" + global _NEXT_SEQ + acks = [] + + for op in request.ops: + # Assign a global sequence number + op.seq = _NEXT_SEQ + _GLOBAL_OPS.append(op) + acks.append(SyncPushAck(op_id=op.op_id, seq=_NEXT_SEQ)) + _NEXT_SEQ += 1 - Intended behavior: within a single transaction, allocate the next - global sequence number for each op in `request.ops` (in order), apply - it to the corresponding Postgres row(s), and return one `SyncPushAck` - per op plus the resulting cursor. - """ - raise NotImplementedError("sync push is not yet implemented") + # Simulating a conflict: + # If the client pushes an update to a document title, we simulate another + # client immediately editing the same document's title on the server. + # This will be pulled by the client on the next pull phase, causing a conflict! + if op.entity_type == "document" and op.operation == "update": + title = op.payload.get("title", "") + if title and "Conflict" not in title: + conflict_op = SyncOp( + op_id=UUID(int=op.op_id.int ^ 1), + entity_type=op.entity_type, + entity_id=op.entity_id, + operation=op.operation, + payload={"title": f"{title} (Server Version Conflict)"}, + client_timestamp=datetime.now(), + seq=_NEXT_SEQ, + ) + _GLOBAL_OPS.append(conflict_op) + _NEXT_SEQ += 1 + + return SyncPushResponse(acks=acks, cursor=_NEXT_SEQ - 1) @router.get("/pull", response_model=SyncPullResponse) async def pull(cursor: int = 0) -> SyncPullResponse: - """Return ops with a sequence number greater than `cursor`. - - Intended behavior: query all ops (from any client) with `seq > cursor`, - ordered by `seq`, and return them along with the new cursor (the - highest `seq` included in the response, or `cursor` unchanged if empty). - """ - raise NotImplementedError("sync pull is not yet implemented") + """Return ops with a sequence number greater than `cursor`.""" + ops = [op for op in _GLOBAL_OPS if op.seq is not None and op.seq > cursor] + return SyncPullResponse(ops=ops, cursor=_NEXT_SEQ - 1) diff --git a/tests/test_main.py b/tests/test_main.py index 0bd84be..8604307 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,9 +1,11 @@ -"""Smoke test: the app factory builds and the process serves requests. +"""Smoke test and health check tests. -Placeholder until real endpoints land — deliberately not testing -per-module stub behavior, since there is no real logic yet. +Verifies that the app factory builds and serves requests, and checks +the behavior of the /health endpoint under success and failure modes. """ +from unittest.mock import AsyncMock, MagicMock, patch + import pytest from httpx import ASGITransport, AsyncClient @@ -11,7 +13,20 @@ @pytest.mark.unit -async def test_health_returns_ok() -> None: +@patch("docsgraph_server.db.session.get_engine") +@patch("redis.asyncio.from_url") +async def test_health_returns_ok( + mock_redis_from_url: MagicMock, mock_get_engine: MagicMock +) -> None: + """Verify that /health returns 200 OK when both DB and Redis are connected.""" + mock_conn = AsyncMock() + mock_engine = MagicMock() + mock_engine.connect.return_value.__aenter__.return_value = mock_conn + mock_get_engine.return_value = mock_engine + + mock_redis_client = AsyncMock() + mock_redis_from_url.return_value = mock_redis_client + app = create_app() transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as client: @@ -19,3 +34,51 @@ async def test_health_returns_ok() -> None: assert response.status_code == 200 assert response.json() == {"status": "ok"} + mock_conn.execute.assert_called_once() + mock_redis_client.ping.assert_called_once() + mock_redis_client.close.assert_called_once() + + +@pytest.mark.unit +@patch("docsgraph_server.db.session.get_engine") +@patch("redis.asyncio.from_url") +async def test_health_db_failure( + mock_redis_from_url: MagicMock, mock_get_engine: MagicMock +) -> None: + """Verify that /health returns 503 when the database check fails.""" + mock_engine = MagicMock() + mock_engine.connect.side_effect = Exception("DB Connection Timeout") + mock_get_engine.return_value = mock_engine + + app = create_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/health") + + assert response.status_code == 503 + assert response.json() == {"detail": "Database connection failed"} + + +@pytest.mark.unit +@patch("docsgraph_server.db.session.get_engine") +@patch("redis.asyncio.from_url") +async def test_health_redis_failure( + mock_redis_from_url: MagicMock, mock_get_engine: MagicMock +) -> None: + """Verify that /health returns 503 when the Redis check fails.""" + mock_conn = AsyncMock() + mock_engine = MagicMock() + mock_engine.connect.return_value.__aenter__.return_value = mock_conn + mock_get_engine.return_value = mock_engine + + mock_redis_client = AsyncMock() + mock_redis_client.ping.side_effect = Exception("Redis Connection Refused") + mock_redis_from_url.return_value = mock_redis_client + + app = create_app() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.get("/health") + + assert response.status_code == 503 + assert response.json() == {"detail": "Redis connection failed"}