From 7f278c6f63c90828ef0eede2de03d7cc217b3ac8 Mon Sep 17 00:00:00 2001 From: roberto Date: Tue, 3 Mar 2026 16:09:13 +0100 Subject: [PATCH] complete backend plan --- .gitea/workflows/deploy.yaml | 107 +++++++++++++++++++++++++++++------ README.md | 80 ++++++++++++++++++++++++++ app/config/settings.py | 1 + app/storage/blob_store.py | 14 +++-- docker-compose.yml | 31 ++++++++++ 5 files changed, 211 insertions(+), 22 deletions(-) diff --git a/.gitea/workflows/deploy.yaml b/.gitea/workflows/deploy.yaml index 4d100f6..4662532 100644 --- a/.gitea/workflows/deploy.yaml +++ b/.gitea/workflows/deploy.yaml @@ -1,21 +1,96 @@ -name: Deploy to Proxmox Docker -run-name: Deploying ${{ gitea.sha }} +name: Test & Deploy API +run-name: ${{ gitea.ref_name }} → Docker LXC + on: push: - branches: - - main # O il nome del tuo branch principale + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] jobs: - Deploy: - runs-on: ubuntu-latest # Questo dipende dalle label che hai dato al tuo act_runner + # ── 1. Run tests in an isolated Python container ────────────────── + test: + runs-on: ubuntu-latest + container: + image: python:3.12-slim + steps: - - name: Deploying via SSH - uses: appleboy/ssh-action@v1.0.0 - with: - host: ${{ secrets.SSH_HOST }} - username: ${{ secrets.SSH_USER }} - key: ${{ secrets.SSH_KEY }} - script: | - cd /opt/adiuva-api - git pull origin main - docker compose up -d --build \ No newline at end of file + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Install Dependencies + run: pip install --no-cache-dir -r requirements.txt + + - name: Run Linter + run: ruff check app/ tests/ + + - name: Run Tests + run: pytest tests/ -v --tb=short + + # ── 2. Deploy to Docker LXC (only main branch & tags) ───────────── + deploy: + needs: test + runs-on: ubuntu-latest + if: gitea.event_name == 'push' + + steps: + - name: Checkout Code + uses: actions/checkout@v4 + + - name: Sync to deploy directory + run: | + DEPLOY_DIR="/opt/adiuva-api" + mkdir -p "$DEPLOY_DIR" + + # Sync source, preserve .env and volumes + cp -rf app/ alembic/ alembic.ini Dockerfile docker-compose.yml requirements.txt "$DEPLOY_DIR/" + + - name: Build & restart services + run: | + cd /opt/adiuva-api + docker compose up -d --build --remove-orphans + + - name: Run database migrations + run: | + cd /opt/adiuva-api + docker compose exec -T app alembic upgrade head + + - name: Verify deployment + run: | + echo "Waiting for app to be ready..." + sleep 5 + + HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://localhost:8000/api/v1/health) + if [ "$HTTP_CODE" -eq 200 ]; then + echo "✅ API is healthy (HTTP ${HTTP_CODE})" + else + echo "❌ Health check failed (HTTP ${HTTP_CODE})" + docker compose -f /opt/adiuva-api/docker-compose.yml logs app --tail=50 + exit 1 + fi + + - name: Create Gitea Release (tags only) + if: startsWith(gitea.ref, 'refs/tags/') + run: | + GITEA_URL="http://10.0.0.119:3000" + TAG="${GITHUB_REF_NAME}" + REPO="${GITHUB_REPOSITORY}" + TOKEN="${{ gitea.token }}" + + RELEASE_ID=$(curl -sf \ + -H "Authorization: token ${TOKEN}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}" \ + | grep -o '"id":[0-9]*' | head -1 | cut -d: -f2) + + if [ -z "$RELEASE_ID" ]; then + curl -sf \ + -X POST \ + -H "Authorization: token ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{\"tag_name\":\"${TAG}\",\"name\":\"Adiuva API ${TAG}\",\"body\":\"Deployed to Docker LXC\"}" \ + "${GITEA_URL}/api/v1/repos/${REPO}/releases" + echo "✅ Release ${TAG} created" + else + echo "ℹ️ Release ${TAG} already exists (ID: ${RELEASE_ID})" + fi \ No newline at end of file diff --git a/README.md b/README.md index 164794c..bc8a849 100644 --- a/README.md +++ b/README.md @@ -194,6 +194,11 @@ This starts two services: - **app** — FastAPI server on port `8000` - **db** — PostgreSQL 16 (Alpine) on port `5432` with a persistent volume and health checks +The compose file also includes optional services for fully local deployments: + +- **minio** — S3-compatible object storage on ports `9000` (API) and `9001` (console) +- **qdrant** — Vector search engine on ports `6333` (HTTP) and `6334` (gRPC) + ### Dockerfile Details The Dockerfile uses a multi-stage build: @@ -209,6 +214,80 @@ gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 4 --timeout 120 -b 0.0 --- +## Homelab / Self-Hosted Deployment + +You can run the entire stack locally on a homelab with **no cloud dependencies except the LLM provider**. The compose file includes MinIO (S3 replacement) and Qdrant (vector store) out of the box. + +### 1. Start all services + +```bash +docker compose up -d +``` + +This starts PostgreSQL, MinIO, and Qdrant alongside the app. + +### 2. Create the MinIO bucket + +Open the MinIO console at [http://localhost:9001](http://localhost:9001) (login: `minioadmin` / `minioadmin`) and create a bucket named `adiuva`, or use the CLI: + +```bash +docker compose exec minio mc alias set local http://localhost:9000 minioadmin minioadmin +docker compose exec minio mc mb local/adiuva +``` + +### 3. Configure your `.env` + +```bash +# Database (uses the compose PostgreSQL) +DATABASE_URL=postgresql+asyncpg://postgres:postgres@db:5432/adiuva + +# S3 → MinIO +S3_BUCKET=adiuva +S3_REGION=us-east-1 +S3_ENDPOINT_URL=http://minio:9000 +AWS_ACCESS_KEY_ID=minioadmin +AWS_SECRET_ACCESS_KEY=minioadmin + +# Vector store → local Qdrant (leave PINECONE_API_KEY empty) +QDRANT_URL=http://qdrant:6333 +QDRANT_API_KEY= +PINECONE_API_KEY= + +# Billing — leave empty to stub (no Stripe needed) +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= + +# LLM — the only external service +OPENAI_API_KEY=sk-... +LLM_MODEL=gpt-4o +LLM_ROUTER_MODEL=gpt-4o-mini + +# Auth +JWT_SECRET=your-secret-here +ENV=dev +``` + +### 4. Run migrations + +```bash +docker compose exec app alembic upgrade head +``` + +### What runs where + +| Service | Runs on | Port | Notes | +|---|---|---|---| +| FastAPI app | Docker | 8000 | API server | +| PostgreSQL | Docker | 5432 | Auth, billing, metadata | +| MinIO | Docker | 9000 / 9001 | S3-compatible blob & backup storage | +| Qdrant | Docker | 6333 / 6334 | Vector search (replaces Pinecone) | +| Stripe | — | — | Stubbed when keys are empty | +| OpenAI / LLM | Cloud | — | Only external dependency | + +> **Want fully offline AI too?** Set `LLM_MODEL=ollama/llama3` and `LLM_ROUTER_MODEL=ollama/llama3`, then add an Ollama container or point at a local Ollama instance. See the [LLM provider switching](#switching-llm-providers) section. + +--- + ## Environment Variables All variables are loaded from a `.env` file via Pydantic Settings. Source: `app/config/settings.py` @@ -224,6 +303,7 @@ All variables are loaded from a `.env` file via Pydantic Settings. Source: `app/ | `STRIPE_WEBHOOK_SECRET` | `str` | `""` | Stripe webhook signature secret | | `S3_BUCKET` | `str` | `""` | S3 bucket for encrypted blobs and backups | | `S3_REGION` | `str` | `us-east-1` | AWS region | +| `S3_ENDPOINT_URL` | `str` | `""` | Custom S3 endpoint (e.g. `http://minio:9000` for MinIO). Leave empty for AWS. | | `AWS_ACCESS_KEY_ID` | `str` | `""` | AWS credentials | | `AWS_SECRET_ACCESS_KEY` | `str` | `""` | AWS credentials | | `PINECONE_API_KEY` | `str` | `""` | Pinecone API key (if set, Pinecone is used for vectors) | diff --git a/app/config/settings.py b/app/config/settings.py index ec522c2..dde8d13 100644 --- a/app/config/settings.py +++ b/app/config/settings.py @@ -14,6 +14,7 @@ class Settings(BaseSettings): S3_BUCKET: str = "" S3_REGION: str = "us-east-1" + S3_ENDPOINT_URL: str = "" AWS_ACCESS_KEY_ID: str = "" AWS_SECRET_ACCESS_KEY: str = "" diff --git a/app/storage/blob_store.py b/app/storage/blob_store.py index 48ee190..460de0b 100644 --- a/app/storage/blob_store.py +++ b/app/storage/blob_store.py @@ -23,12 +23,14 @@ class BlobStore: """ def _client(self) -> Any: - return boto3.client( - "s3", - region_name=settings.S3_REGION, - aws_access_key_id=settings.AWS_ACCESS_KEY_ID, - aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, - ) + kwargs: dict[str, Any] = { + "region_name": settings.S3_REGION, + "aws_access_key_id": settings.AWS_ACCESS_KEY_ID, + "aws_secret_access_key": settings.AWS_SECRET_ACCESS_KEY, + } + if settings.S3_ENDPOINT_URL and isinstance(settings.S3_ENDPOINT_URL, str): + kwargs["endpoint_url"] = settings.S3_ENDPOINT_URL + return boto3.client("s3", **kwargs) @staticmethod def _key(user_id: str, table: str, record_id: str) -> str: diff --git a/docker-compose.yml b/docker-compose.yml index 5d1316b..8ef0178 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,5 +34,36 @@ services: # image: redis:7-alpine # restart: unless-stopped + # ── Local S3-compatible storage (MinIO) ── + minio: + image: minio/minio:latest + command: server /data --console-address ":9001" + ports: + - "9000:9000" + - "9001:9001" + environment: + MINIO_ROOT_USER: minioadmin + MINIO_ROOT_PASSWORD: minioadmin + volumes: + - minio_data:/data + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + timeout: 5s + retries: 5 + restart: unless-stopped + + # ── Local vector store (Qdrant) ── + qdrant: + image: qdrant/qdrant:latest + ports: + - "6333:6333" + - "6334:6334" + volumes: + - qdrant_data:/qdrant/storage + restart: unless-stopped + volumes: postgres_data: + minio_data: + qdrant_data: