possibile evoluzione
This commit is contained in:
879
docs/MICROSERVICES_ARCHITECTURE.md
Normal file
879
docs/MICROSERVICES_ARCHITECTURE.md
Normal file
@@ -0,0 +1,879 @@
|
|||||||
|
# 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 |
|
||||||
Reference in New Issue
Block a user