880 lines
32 KiB
Markdown
880 lines
32 KiB
Markdown
# Adiuva — Architettura Microservizi
|
||
|
||
## Panoramica
|
||
|
||
Il monolite attuale viene suddiviso in **5 servizi** + un **API Gateway**, orchestrati con Docker Compose e raggiungibili tramite dominio su Cloudflare.
|
||
|
||
```
|
||
┌──────────────┐
|
||
│ Cloudflare │
|
||
│ (DNS + CDN) │
|
||
└──────┬───────┘
|
||
│ HTTPS / WSS
|
||
┌──────▼───────┐
|
||
│ Traefik │
|
||
│ API Gateway │
|
||
│ (routing, │
|
||
│ TLS term.) │
|
||
└──────┬───────┘
|
||
│
|
||
┌──────────┬───────────┼───────────┬──────────┐
|
||
│ │ │ │ │
|
||
┌─────▼────┐ ┌───▼───┐ ┌────▼────┐ ┌────▼───┐ ┌───▼─────┐
|
||
│ Auth │ │ Chat │ │ Storage │ │Billing │ │ Plugins │
|
||
│ Service │ │Service│ │ Service │ │Service │ │ Service │
|
||
└─────┬────┘ └───┬───┘ └────┬────┘ └────┬───┘ └───┬─────┘
|
||
│ │ │ │ │
|
||
┌─────▼──────────▼──────────▼───────────▼──────────▼─────┐
|
||
│ Infrastruttura │
|
||
│ PostgreSQL │ Redis │ MinIO (S3) │ Qdrant │ (Pinecone) │
|
||
└────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 1. Suddivisione dei Servizi
|
||
|
||
### 1.1 Auth Service (`auth-service`)
|
||
|
||
**Responsabilità**: Registrazione, login, refresh token, profilo utente, encryption key.
|
||
|
||
| Endpoint originale | Metodo |
|
||
|---|---|
|
||
| `/api/v1/auth/register` | POST |
|
||
| `/api/v1/auth/login` | POST |
|
||
| `/api/v1/auth/refresh` | POST |
|
||
| `/api/v1/auth/me` | GET / PUT |
|
||
|
||
**Database**: Tabelle `users`, `refresh_tokens` (PostgreSQL condiviso, schema `auth`).
|
||
|
||
**Modifica chiave — JWT con RS256**:
|
||
Il monolite usa un `SECRET_KEY` simmetrico (HS256). Con i microservizi, passare a **RS256** (asimmetrico):
|
||
- L'Auth Service firma i JWT con la **chiave privata**.
|
||
- Tutti gli altri servizi verificano i JWT con la **chiave pubblica** senza mai contattare l'Auth Service.
|
||
- La chiave pubblica viene esposta via `GET /api/v1/auth/.well-known/jwks.json` oppure montata come volume condiviso.
|
||
|
||
```python
|
||
# auth-service/app/auth/jwt.py
|
||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||
from jose import jwt
|
||
|
||
PRIVATE_KEY = ... # Da env/secret
|
||
PUBLIC_KEY = ... # Derivata o da env
|
||
|
||
def create_access_token(user_id: str, tier: str) -> str:
|
||
return jwt.encode(
|
||
{"sub": user_id, "tier": tier, "exp": ...},
|
||
PRIVATE_KEY,
|
||
algorithm="RS256",
|
||
)
|
||
```
|
||
|
||
```python
|
||
# shared/auth.py (usato da tutti gli altri servizi)
|
||
from jose import jwt
|
||
|
||
PUBLIC_KEY = ... # Volume montato o fetched da JWKS endpoint
|
||
|
||
def verify_token(token: str) -> dict:
|
||
return jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
|
||
```
|
||
|
||
**Scaling**: 2 repliche sufficienti, stateless. Rate-limit dedicato su `/login` e `/register`.
|
||
|
||
---
|
||
|
||
### 1.2 Chat Service (`chat-service`) ⭐ Core
|
||
|
||
**Responsabilità**: WebSocket device, home chat, floating chat, agent runner, memory middleware, agent setup journeys.
|
||
|
||
| Endpoint originale | Tipo |
|
||
|---|---|
|
||
| `/api/v1/ws/device` | WebSocket |
|
||
| `/api/v1/chat` | POST (REST fallback) |
|
||
| `/api/v1/agents/catalog` | GET |
|
||
| `/api/v1/agents/can-create` | POST |
|
||
| `/api/v1/agents/trigger` | POST |
|
||
|
||
**Moduli inclusi**: `deep_agent`, `agent_runner`, `agent_registry`, `memory_middleware`, `ws_context`, `device_manager`, tutti gli agent tools (`task_agent`, `project_agent`, `note_agent`, `timeline_agent`, `filesystem_agent`).
|
||
|
||
**Questa è la bestia che deve scalare orizzontalmente** — è il servizio più CPU/memory intensive (LLM calls, tool loops, WebSocket persistenti).
|
||
|
||
---
|
||
|
||
### 1.3 Storage Service (`storage-service`)
|
||
|
||
**Responsabilità**: CRUD record crittografati su S3, vector operations, backup.
|
||
|
||
| Endpoint originale | Metodo |
|
||
|---|---|
|
||
| `/api/v1/storage/records` | POST / GET |
|
||
| `/api/v1/storage/records/{id}` | GET / PUT / DELETE |
|
||
| `/api/v1/vectors/upsert` | POST |
|
||
| `/api/v1/vectors/search` | POST |
|
||
| `/api/v1/vectors/embed` | POST |
|
||
| `/api/v1/vectors` | DELETE |
|
||
| `/api/v1/backup` | PUT / GET / DELETE |
|
||
| `/api/v1/backup/history` | GET |
|
||
|
||
**Scaling**: 2–3 repliche. I/O bound (S3, Qdrant). Stateless.
|
||
|
||
---
|
||
|
||
### 1.4 Billing Service (`billing-service`)
|
||
|
||
**Responsabilità**: Stripe checkout, webhook, subscription management, tier enforcement.
|
||
|
||
| Endpoint originale | Metodo |
|
||
|---|---|
|
||
| `/api/v1/billing/checkout` | POST |
|
||
| `/api/v1/billing/webhook` | POST |
|
||
| `/api/v1/billing/subscription` | GET / DELETE |
|
||
|
||
**Database**: Tabelle `subscriptions` (schema `billing`).
|
||
|
||
**Comunicazione inter-servizio**: Quando Stripe invia un webhook e il tier cambia, il Billing Service pubblica un evento su **Redis pub/sub** channel `tier_changed:{user_id}`. L'Auth Service aggiorna il campo `tier` nella tabella users (oppure i servizi leggono il tier direttamente dal JWT, aggiornato al prossimo refresh).
|
||
|
||
**Scaling**: 1 replica sufficiente. Basso traffico.
|
||
|
||
---
|
||
|
||
### 1.5 Plugin Service (`plugin-service`)
|
||
|
||
**Responsabilità**: Marketplace, installazione plugin, revenue split.
|
||
|
||
| Endpoint originale | Metodo |
|
||
|---|---|
|
||
| `/api/v1/plugins` | GET |
|
||
| `/api/v1/plugins/{id}` | GET |
|
||
| `/api/v1/plugins/{id}/install` | POST / DELETE |
|
||
|
||
**Database**: Tabelle `plugins`, `plugin_installations`, `revenue_events`.
|
||
|
||
**Scaling**: 1 replica. Basso traffico.
|
||
|
||
---
|
||
|
||
## 2. WebSocket con Scaling Orizzontale — Il Problema Chiave
|
||
|
||
### Il problema attuale
|
||
|
||
`DeviceConnectionManager` è un **singleton in-memory**:
|
||
|
||
```python
|
||
class DeviceConnectionManager:
|
||
def __init__(self):
|
||
self._connections: dict[str, DeviceConnection] = {} # ← In-memory!
|
||
```
|
||
|
||
Con N istanze del Chat Service, il device si connette a **una sola** istanza. Quando un'altra istanza deve inviare un `tool_call` a quel device (es. un agent trigger da un'API call), non trova la connessione.
|
||
|
||
### La soluzione: Redis Pub/Sub + Registry
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────┐
|
||
│ Redis │
|
||
│ │
|
||
│ Hash: ws:connections │
|
||
│ user_123 → instance_A │
|
||
│ user_456 → instance_B │
|
||
│ │
|
||
│ Pub/Sub channels: │
|
||
│ tool_call:{user_id} → tool call payloads │
|
||
│ tool_result:{call_id} → tool result payloads │
|
||
│ stream:{user_id} → text_chunk streaming │
|
||
└──────────────────────────────────────────────────────────────┘
|
||
|
||
Instance A (ha WS di user_123) Instance B (deve chiamare tool su user_123)
|
||
┌───────────────────────┐ ┌───────────────────────┐
|
||
│ 1. Sottoscrive a │ │ 1. Lookup Redis Hash │
|
||
│ tool_call:user_123│ │ → user_123 è su A │
|
||
│ │ │ │
|
||
│ 2. Riceve tool_call │◄─────────│ 2. PUBLISH │
|
||
│ da Redis channel │ │ tool_call:user_123 │
|
||
│ │ │ {id, action, ...} │
|
||
│ 3. Invia al device │ │ │
|
||
│ via WS │ │ 4. SUBSCRIBE │
|
||
│ │ │ tool_result:{id} │
|
||
│ 4. Device risponde │ │ │
|
||
│ tool_result │──────────│► 5. Riceve risultato │
|
||
│ │ │ │
|
||
│ 5. PUBLISH │ │ │
|
||
│ tool_result:{id} │ │ │
|
||
└───────────────────────┘ └───────────────────────┘
|
||
```
|
||
|
||
### Implementazione: `RedisDeviceManager`
|
||
|
||
```python
|
||
# chat-service/app/core/device_manager.py
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
import redis.asyncio as aioredis
|
||
from dataclasses import dataclass, field
|
||
from fastapi import WebSocket
|
||
|
||
INSTANCE_ID = os.environ.get("INSTANCE_ID", os.urandom(8).hex())
|
||
|
||
@dataclass
|
||
class LocalConnection:
|
||
ws: WebSocket
|
||
device_id: str
|
||
pending_calls: dict[str, asyncio.Future[dict]] = field(default_factory=dict)
|
||
|
||
|
||
class RedisDeviceManager:
|
||
"""Device manager backed by Redis for cross-instance communication."""
|
||
|
||
def __init__(self, redis_url: str = "redis://redis:6379"):
|
||
self._redis = aioredis.from_url(redis_url)
|
||
self._pubsub = self._redis.pubsub()
|
||
self._local: dict[str, LocalConnection] = {} # Solo connessioni locali
|
||
self._remote_futures: dict[str, asyncio.Future[dict]] = {}
|
||
|
||
async def start(self):
|
||
"""Avvia il listener Redis per tool_call in arrivo."""
|
||
asyncio.create_task(self._listen_tool_calls())
|
||
|
||
# ── Registrazione ──
|
||
|
||
async def register(self, user_id: str, device_id: str, ws: WebSocket):
|
||
# Registra localmente
|
||
self._local[user_id] = LocalConnection(ws=ws, device_id=device_id)
|
||
# Registra in Redis quale istanza ha la connessione
|
||
await self._redis.hset("ws:connections", user_id, INSTANCE_ID)
|
||
# Sottoscrivi ai tool_call per questo utente
|
||
await self._pubsub.subscribe(f"tool_call:{user_id}")
|
||
|
||
async def unregister(self, user_id: str):
|
||
conn = self._local.pop(user_id, None)
|
||
if conn:
|
||
for fut in conn.pending_calls.values():
|
||
if not fut.done():
|
||
fut.cancel()
|
||
await self._redis.hdel("ws:connections", user_id)
|
||
await self._pubsub.unsubscribe(f"tool_call:{user_id}")
|
||
|
||
# ── Presenza ──
|
||
|
||
async def is_online(self, user_id: str) -> bool:
|
||
return await self._redis.hexists("ws:connections", user_id)
|
||
|
||
# ── Tool-call round-trip (cross-instance) ──
|
||
|
||
async def execute_tool_call(self, user_id: str, payload: dict) -> dict:
|
||
"""
|
||
Invia un tool_call al device dell'utente.
|
||
Funziona sia che la WS sia locale che su un'altra istanza.
|
||
"""
|
||
call_id = payload["id"]
|
||
|
||
# Caso 1: connessione locale → invio diretto
|
||
if user_id in self._local:
|
||
conn = self._local[user_id]
|
||
loop = asyncio.get_event_loop()
|
||
fut: asyncio.Future[dict] = loop.create_future()
|
||
conn.pending_calls[call_id] = fut
|
||
await conn.ws.send_text(json.dumps({"type": "tool_call", **payload}))
|
||
return await asyncio.wait_for(fut, timeout=30.0)
|
||
|
||
# Caso 2: connessione remota → Redis pub/sub
|
||
loop = asyncio.get_event_loop()
|
||
fut = loop.create_future()
|
||
self._remote_futures[call_id] = fut
|
||
|
||
# Sottoscrivi al canale di risposta
|
||
result_channel = f"tool_result:{call_id}"
|
||
await self._pubsub.subscribe(result_channel)
|
||
|
||
# Pubblica il tool_call
|
||
await self._redis.publish(
|
||
f"tool_call:{user_id}",
|
||
json.dumps(payload),
|
||
)
|
||
|
||
try:
|
||
return await asyncio.wait_for(fut, timeout=30.0)
|
||
finally:
|
||
self._remote_futures.pop(call_id, None)
|
||
await self._pubsub.unsubscribe(result_channel)
|
||
|
||
# ── Risoluzione tool_result (da WS locale) ──
|
||
|
||
def resolve_local(self, user_id: str, call_id: str, result: dict):
|
||
conn = self._local.get(user_id)
|
||
if conn:
|
||
fut = conn.pending_calls.pop(call_id, None)
|
||
if fut and not fut.done():
|
||
fut.set_result(result)
|
||
|
||
async def resolve_and_publish(self, user_id: str, call_id: str, result: dict):
|
||
"""Chiamato quando il device locale invia un tool_result."""
|
||
self.resolve_local(user_id, call_id, result)
|
||
# Pubblica anche su Redis per l'istanza remota che aspetta
|
||
await self._redis.publish(
|
||
f"tool_result:{call_id}",
|
||
json.dumps(result),
|
||
)
|
||
|
||
# ── Listener Redis ──
|
||
|
||
async def _listen_tool_calls(self):
|
||
"""Loop che ascolta i tool_call in arrivo da altre istanze."""
|
||
async for message in self._pubsub.listen():
|
||
if message["type"] != "message":
|
||
continue
|
||
channel = message["channel"]
|
||
if isinstance(channel, bytes):
|
||
channel = channel.decode()
|
||
|
||
data = json.loads(message["data"])
|
||
|
||
if channel.startswith("tool_call:"):
|
||
# Un'altra istanza vuole che inviamo un tool_call al nostro device
|
||
user_id = channel.split(":", 1)[1]
|
||
conn = self._local.get(user_id)
|
||
if conn:
|
||
await conn.ws.send_text(json.dumps({"type": "tool_call", **data}))
|
||
|
||
elif channel.startswith("tool_result:"):
|
||
# Risposta a un tool_call che abbiamo inviato tramite Redis
|
||
call_id = channel.split(":", 1)[1]
|
||
fut = self._remote_futures.pop(call_id, None)
|
||
if fut and not fut.done():
|
||
fut.set_result(data)
|
||
|
||
# ── Stream cross-instance ──
|
||
|
||
async def publish_stream_chunk(self, user_id: str, chunk: dict):
|
||
"""Pubblica un chunk di streaming su Redis (per REST→WS relay)."""
|
||
await self._redis.publish(f"stream:{user_id}", json.dumps(chunk))
|
||
```
|
||
|
||
---
|
||
|
||
## 3. Struttura Directory Proposta
|
||
|
||
```
|
||
adiuva-api/
|
||
├── docker-compose.yml # Orchestrazione completa
|
||
├── docker-compose.dev.yml # Override per sviluppo locale
|
||
├── shared/ # Codice condiviso (montato come volume)
|
||
│ ├── auth.py # JWT verification (chiave pubblica)
|
||
│ ├── schemas.py # Pydantic schemas condivisi
|
||
│ ├── middleware/
|
||
│ │ ├── rate_limit.py
|
||
│ │ └── sanitizer.py
|
||
│ └── models/
|
||
│ └── base.py # SQLAlchemy base condivisa
|
||
│
|
||
├── auth-service/
|
||
│ ├── Dockerfile
|
||
│ ├── requirements.txt
|
||
│ └── app/
|
||
│ ├── main.py
|
||
│ ├── config.py
|
||
│ ├── db.py
|
||
│ ├── models.py # users, refresh_tokens
|
||
│ ├── routes/
|
||
│ │ └── auth.py
|
||
│ └── services/
|
||
│ ├── jwt_service.py # RS256 signing
|
||
│ └── user_service.py
|
||
│
|
||
├── chat-service/
|
||
│ ├── Dockerfile
|
||
│ ├── requirements.txt
|
||
│ └── app/
|
||
│ ├── main.py
|
||
│ ├── config.py
|
||
│ ├── db.py
|
||
│ ├── models.py # agent_run_logs, memory_*
|
||
│ ├── routes/
|
||
│ │ ├── device_ws.py
|
||
│ │ ├── chat.py
|
||
│ │ └── agents.py
|
||
│ ├── core/
|
||
│ │ ├── device_manager.py # RedisDeviceManager
|
||
│ │ ├── deep_agent.py
|
||
│ │ ├── agent_runner.py
|
||
│ │ ├── agent_registry.py
|
||
│ │ ├── memory_middleware.py
|
||
│ │ ├── ws_context.py
|
||
│ │ ├── output_formatter.py
|
||
│ │ └── llm.py
|
||
│ └── agents/
|
||
│ ├── task_agent.py
|
||
│ ├── project_agent.py
|
||
│ ├── note_agent.py
|
||
│ ├── timeline_agent.py
|
||
│ └── filesystem_agent.py
|
||
│
|
||
├── storage-service/
|
||
│ ├── Dockerfile
|
||
│ ├── requirements.txt
|
||
│ └── app/
|
||
│ ├── main.py
|
||
│ ├── config.py
|
||
│ ├── db.py
|
||
│ ├── models.py # storage_records, backup_metadata
|
||
│ ├── routes/
|
||
│ │ ├── storage.py
|
||
│ │ ├── vectors.py
|
||
│ │ └── backup.py
|
||
│ └── services/
|
||
│ ├── blob_store.py
|
||
│ └── vector_store.py
|
||
│
|
||
├── billing-service/
|
||
│ ├── Dockerfile
|
||
│ ├── requirements.txt
|
||
│ └── app/
|
||
│ ├── main.py
|
||
│ ├── config.py
|
||
│ ├── db.py
|
||
│ ├── models.py # subscriptions
|
||
│ ├── routes/
|
||
│ │ └── billing.py
|
||
│ └── services/
|
||
│ ├── stripe_service.py
|
||
│ └── tier_manager.py
|
||
│
|
||
├── plugin-service/
|
||
│ ├── Dockerfile
|
||
│ ├── requirements.txt
|
||
│ └── app/
|
||
│ ├── main.py
|
||
│ ├── config.py
|
||
│ ├── db.py
|
||
│ ├── models.py # plugins, installations, revenue
|
||
│ └── routes/
|
||
│ └── plugins.py
|
||
│
|
||
└── infra/
|
||
├── traefik/
|
||
│ └── traefik.yml
|
||
└── alembic/ # Migrazioni condivise o per-servizio
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Docker Compose — Configurazione Completa
|
||
|
||
```yaml
|
||
# docker-compose.yml
|
||
|
||
services:
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# API Gateway
|
||
# ══════════════════════════════════════════════════════════
|
||
traefik:
|
||
image: traefik:v3.2
|
||
command:
|
||
- "--api.insecure=true"
|
||
- "--providers.docker=true"
|
||
- "--providers.docker.exposedbydefault=false"
|
||
- "--entrypoints.web.address=:80"
|
||
- "--entrypoints.websecure.address=:443"
|
||
# Cloudflare gestisce TLS, Traefik riceve HTTP dal proxy
|
||
- "--entrypoints.web.http.redirections.entrypoint.to=websecure"
|
||
ports:
|
||
- "80:80"
|
||
- "443:443"
|
||
- "8080:8080" # Dashboard Traefik
|
||
volumes:
|
||
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||
restart: unless-stopped
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Auth Service (2 repliche)
|
||
# ══════════════════════════════════════════════════════════
|
||
auth-service:
|
||
build: ./auth-service
|
||
deploy:
|
||
replicas: 2
|
||
env_file: .env
|
||
environment:
|
||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
|
||
JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_private_key
|
||
SERVICE_NAME: auth
|
||
secrets:
|
||
- jwt_private_key
|
||
labels:
|
||
- "traefik.enable=true"
|
||
- "traefik.http.routers.auth.rule=PathPrefix(`/api/v1/auth`)"
|
||
- "traefik.http.services.auth.loadbalancer.server.port=8000"
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Chat Service (scalabile, N repliche)
|
||
# ══════════════════════════════════════════════════════════
|
||
chat-service:
|
||
build: ./chat-service
|
||
deploy:
|
||
replicas: 3
|
||
env_file: .env
|
||
environment:
|
||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
|
||
REDIS_URL: redis://redis:6379
|
||
JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key
|
||
SERVICE_NAME: chat
|
||
secrets:
|
||
- jwt_public_key
|
||
labels:
|
||
- "traefik.enable=true"
|
||
# REST routes
|
||
- "traefik.http.routers.chat.rule=PathPrefix(`/api/v1/chat`) || PathPrefix(`/api/v1/agents`)"
|
||
- "traefik.http.services.chat.loadbalancer.server.port=8000"
|
||
# WebSocket route con sticky session
|
||
- "traefik.http.routers.ws.rule=PathPrefix(`/api/v1/ws`)"
|
||
- "traefik.http.routers.ws.service=chat-ws"
|
||
- "traefik.http.services.chat-ws.loadbalancer.server.port=8000"
|
||
- "traefik.http.services.chat-ws.loadbalancer.sticky.cookie.name=ws_affinity"
|
||
- "traefik.http.services.chat-ws.loadbalancer.sticky.cookie.httpOnly=true"
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
redis:
|
||
condition: service_healthy
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Storage Service (2 repliche)
|
||
# ══════════════════════════════════════════════════════════
|
||
storage-service:
|
||
build: ./storage-service
|
||
deploy:
|
||
replicas: 2
|
||
env_file: .env
|
||
environment:
|
||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
|
||
JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key
|
||
SERVICE_NAME: storage
|
||
secrets:
|
||
- jwt_public_key
|
||
labels:
|
||
- "traefik.enable=true"
|
||
- "traefik.http.routers.storage.rule=PathPrefix(`/api/v1/storage`) || PathPrefix(`/api/v1/vectors`) || PathPrefix(`/api/v1/backup`)"
|
||
- "traefik.http.services.storage.loadbalancer.server.port=8000"
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Billing Service (1 replica)
|
||
# ══════════════════════════════════════════════════════════
|
||
billing-service:
|
||
build: ./billing-service
|
||
deploy:
|
||
replicas: 1
|
||
env_file: .env
|
||
environment:
|
||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
|
||
REDIS_URL: redis://redis:6379
|
||
JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key
|
||
SERVICE_NAME: billing
|
||
secrets:
|
||
- jwt_public_key
|
||
labels:
|
||
- "traefik.enable=true"
|
||
- "traefik.http.routers.billing.rule=PathPrefix(`/api/v1/billing`)"
|
||
- "traefik.http.services.billing.loadbalancer.server.port=8000"
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
redis:
|
||
condition: service_healthy
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Plugin Service (1 replica)
|
||
# ══════════════════════════════════════════════════════════
|
||
plugin-service:
|
||
build: ./plugin-service
|
||
deploy:
|
||
replicas: 1
|
||
env_file: .env
|
||
environment:
|
||
DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/adiuva
|
||
JWT_PUBLIC_KEY_FILE: /run/secrets/jwt_public_key
|
||
SERVICE_NAME: plugins
|
||
secrets:
|
||
- jwt_public_key
|
||
labels:
|
||
- "traefik.enable=true"
|
||
- "traefik.http.routers.plugins.rule=PathPrefix(`/api/v1/plugins`)"
|
||
- "traefik.http.services.plugins.loadbalancer.server.port=8000"
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
|
||
# ══════════════════════════════════════════════════════════
|
||
# Infrastruttura
|
||
# ══════════════════════════════════════════════════════════
|
||
db:
|
||
image: pgvector/pgvector:pg16
|
||
environment:
|
||
POSTGRES_USER: postgres
|
||
POSTGRES_PASSWORD: postgres
|
||
POSTGRES_DB: adiuva
|
||
volumes:
|
||
- postgres_data:/var/lib/postgresql/data
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||
interval: 5s
|
||
timeout: 5s
|
||
retries: 5
|
||
restart: unless-stopped
|
||
|
||
redis:
|
||
image: redis:7-alpine
|
||
command: redis-server --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||
volumes:
|
||
- redis_data:/data
|
||
healthcheck:
|
||
test: ["CMD", "redis-cli", "ping"]
|
||
interval: 5s
|
||
timeout: 3s
|
||
retries: 5
|
||
restart: unless-stopped
|
||
|
||
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
|
||
restart: unless-stopped
|
||
|
||
qdrant:
|
||
image: qdrant/qdrant:latest
|
||
volumes:
|
||
- qdrant_data:/qdrant/storage
|
||
restart: unless-stopped
|
||
|
||
secrets:
|
||
jwt_private_key:
|
||
file: ./infra/keys/jwt_private.pem
|
||
jwt_public_key:
|
||
file: ./infra/keys/jwt_public.pem
|
||
|
||
volumes:
|
||
postgres_data:
|
||
redis_data:
|
||
minio_data:
|
||
qdrant_data:
|
||
```
|
||
|
||
---
|
||
|
||
## 5. Configurazione Cloudflare + VPS
|
||
|
||
### 5.1 DNS
|
||
|
||
```
|
||
api.tuodominio.com → A record → IP del VPS
|
||
→ Proxy: ON (orange cloud)
|
||
```
|
||
|
||
### 5.2 Cloudflare Settings
|
||
|
||
| Setting | Valore | Motivo |
|
||
|---------|--------|--------|
|
||
| SSL/TLS mode | **Full (Strict)** | Cloudflare ↔ VPS con certificato valido |
|
||
| WebSocket | **ON** | Necessario per `/api/v1/ws/device` |
|
||
| Proxy timeout | **100s** (Enterprise) o default | Le LLM calls possono durare 30s+ |
|
||
| Under Attack Mode | Off (attivare se necessario) | |
|
||
|
||
### 5.3 TLS sul VPS
|
||
|
||
Due opzioni:
|
||
- **Opzione A (consigliata)**: Cloudflare Origin Certificate → montato in Traefik
|
||
- **Opzione B**: Let's Encrypt via Traefik (con DNS challenge Cloudflare)
|
||
|
||
```yaml
|
||
# traefik.yml — con Cloudflare Origin Certificate
|
||
entryPoints:
|
||
websecure:
|
||
address: ":443"
|
||
|
||
tls:
|
||
certificates:
|
||
- certFile: /certs/origin.pem
|
||
keyFile: /certs/origin-key.pem
|
||
```
|
||
|
||
### 5.4 Rete VPS
|
||
|
||
```bash
|
||
# UFW firewall — solo Cloudflare può raggiungere le porte 80/443
|
||
# https://www.cloudflare.com/ips/
|
||
ufw default deny incoming
|
||
ufw allow from 173.245.48.0/20 to any port 443
|
||
ufw allow from 103.21.244.0/22 to any port 443
|
||
# ... (tutti gli IP range di Cloudflare)
|
||
ufw allow ssh
|
||
ufw enable
|
||
```
|
||
|
||
---
|
||
|
||
## 6. Comunicazione Inter-Servizio
|
||
|
||
### 6.1 Pattern: Event Bus via Redis Pub/Sub
|
||
|
||
```
|
||
┌──────────┐ tier_changed:user_123 ┌──────────┐
|
||
│ Billing │ ────────────────────────► │ Auth │
|
||
│ Service │ │ Service │
|
||
└──────────┘ └──────────┘
|
||
|
||
┌──────────┐ agent_triggered:user_123 ┌──────────┐
|
||
│ Chat │ ◄──────────────────────── │ Any │
|
||
│ Service │ │ Service │
|
||
└──────────┘ └──────────┘
|
||
```
|
||
|
||
### 6.2 Pattern: HTTP Sincrono (per query semplici)
|
||
|
||
Il Chat Service può avere bisogno del tier dell'utente per il rate-limiting degli agent. Due strategie:
|
||
|
||
- **Strategia A (preferita)**: Il tier è nel JWT. All'aggiornamento, il Billing Service forza token refresh invalidando i vecchi token su Redis.
|
||
- **Strategia B**: Il Chat Service chiama `http://auth-service:8000/internal/user/{id}/tier` (rete Docker interna, non esposta).
|
||
|
||
### 6.3 Health Checks e Service Discovery
|
||
|
||
Traefik gestisce automaticamente il service discovery via Docker labels. I servizi non devono conoscersi tra loro — comunicano solo via:
|
||
- **Redis pub/sub** (eventi asincroni)
|
||
- **Redis hash** (stato condiviso, es. `ws:connections`)
|
||
- **PostgreSQL** (dati persistenti condivisi)
|
||
|
||
---
|
||
|
||
## 7. Piano di Migrazione Incrementale
|
||
|
||
### Fase 1 — Preparazione (senza rompere nulla)
|
||
1. Aggiungere Redis al `docker-compose.yml` attuale
|
||
2. Migrare JWT da HS256 → RS256 (backward-compatible: accetta entrambi)
|
||
3. Implementare `RedisDeviceManager` come drop-in replacement
|
||
4. Estrarre `shared/` con auth verification, schemas, middleware
|
||
|
||
### Fase 2 — Primo split: Auth Service
|
||
1. Estrarre `auth.py` routes + models in `auth-service/`
|
||
2. Verificare che i JWT firmati da `auth-service` vengano validati dal monolite
|
||
3. Aggiornare Traefik per routare `/api/v1/auth/*` al nuovo servizio
|
||
4. Il monolite continua a servire tutto il resto
|
||
|
||
### Fase 3 — Storage + Billing + Plugins
|
||
1. Servizi stateless e senza WebSocket → facili da estrarre
|
||
2. Estrarre uno alla volta, testare, routare via Traefik
|
||
3. Il monolite diventa sempre più magro
|
||
|
||
### Fase 4 — Chat Service (il più delicato)
|
||
1. Il monolite residuo **diventa** il Chat Service
|
||
2. Rimuovere i route migrati, tenere solo WS + chat + agents
|
||
3. Testare lo scaling a 2+ istanze con `RedisDeviceManager`
|
||
4. Verificare tool-call cross-instance
|
||
|
||
### Fase 5 — Cleanup
|
||
1. Rimuovere il monolite originale
|
||
2. CI/CD pipeline per build/push separati
|
||
3. Monitoring (Prometheus + Grafana) per ogni servizio
|
||
|
||
---
|
||
|
||
## 8. Rate Limiting Distribuito
|
||
|
||
Il middleware attuale usa un contatore in-memory per il rate-limiting. Con i microservizi:
|
||
|
||
```python
|
||
# shared/middleware/rate_limit.py
|
||
import redis.asyncio as aioredis
|
||
|
||
class DistributedRateLimiter:
|
||
def __init__(self, redis: aioredis.Redis):
|
||
self._redis = redis
|
||
|
||
async def check(self, user_id: str, tier: str) -> bool:
|
||
limits = {"free": 20, "pro": 60, "power": 120, "team": 200}
|
||
max_req = limits.get(tier, 20)
|
||
key = f"rate:{user_id}"
|
||
|
||
pipe = self._redis.pipeline()
|
||
pipe.incr(key)
|
||
pipe.expire(key, 60) # Finestra di 60 secondi
|
||
count, _ = await pipe.execute()
|
||
|
||
return count <= max_req
|
||
```
|
||
|
||
---
|
||
|
||
## 9. Monitoraggio e Logging
|
||
|
||
```yaml
|
||
# Aggiungere al docker-compose.yml
|
||
|
||
prometheus:
|
||
image: prom/prometheus:latest
|
||
volumes:
|
||
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||
restart: unless-stopped
|
||
|
||
grafana:
|
||
image: grafana/grafana:latest
|
||
ports:
|
||
- "3000:3000"
|
||
volumes:
|
||
- grafana_data:/var/lib/grafana
|
||
restart: unless-stopped
|
||
|
||
loki:
|
||
image: grafana/loki:latest
|
||
restart: unless-stopped
|
||
```
|
||
|
||
Ogni servizio espone `/metrics` (Prometheus) e scrive log strutturati (JSON) raccolti da Loki.
|
||
|
||
---
|
||
|
||
## 10. Sizing VPS Minimo Consigliato
|
||
|
||
| Componente | CPU | RAM | Note |
|
||
|---|---|---|---|
|
||
| Traefik | 0.25 | 128MB | |
|
||
| Auth Service ×2 | 0.25 ×2 | 128MB ×2 | |
|
||
| Chat Service ×2 | 1.0 ×2 | 1GB ×2 | Il più pesante (LLM calls) |
|
||
| Storage Service ×2 | 0.5 ×2 | 256MB ×2 | I/O bound |
|
||
| Billing Service | 0.25 | 128MB | |
|
||
| Plugin Service | 0.25 | 128MB | |
|
||
| PostgreSQL | 1.0 | 1GB | |
|
||
| Redis | 0.25 | 256MB | |
|
||
| Qdrant | 0.5 | 512MB | |
|
||
| MinIO | 0.25 | 256MB | |
|
||
| **Totale** | **~6 vCPU** | **~5.5 GB** | |
|
||
|
||
**Raccomandazione**: VPS con **8 vCPU / 16 GB RAM** per avere margine. Hetzner CPX41 (~€30/mese) o equivalente.
|
||
|
||
---
|
||
|
||
## Riepilogo Decisioni Architetturali
|
||
|
||
| Decisione | Scelta | Motivazione |
|
||
|---|---|---|
|
||
| API Gateway | Traefik | Nativo Docker, WebSocket support, service discovery automatico |
|
||
| JWT | RS256 (asimmetrico) | Verifica distribuita senza contattare Auth Service |
|
||
| WebSocket scaling | Redis pub/sub + registry | Cross-instance tool-call routing |
|
||
| Rate limiting | Redis contatori | Distribuito, sliding window |
|
||
| Service communication | Redis pub/sub + HTTP interno | Asincrono per eventi, sincrono per query |
|
||
| Database | PostgreSQL condiviso (un DB, schema separation opzionale) | Semplicità; split DB futuro facile |
|
||
| TLS | Cloudflare Origin Certificate | Zero maintenance, trust Cloudflare |
|
||
| Orchestrazione | Docker Compose | Sufficiente per un singolo VPS |
|