diff --git a/docs/MICROSERVICES_ARCHITECTURE.md b/docs/MICROSERVICES_ARCHITECTURE.md new file mode 100644 index 0000000..ba21156 --- /dev/null +++ b/docs/MICROSERVICES_ARCHITECTURE.md @@ -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 |