From 96c91e386d717b44cf4ecfa77490b5c75743336d Mon Sep 17 00:00:00 2001 From: Roberto Musso Date: Wed, 8 Apr 2026 23:23:14 +0200 Subject: [PATCH] remove deprecated docs --- docs/MICROSERVICES_ARCHITECTURE.md | 941 ----------------------------- 1 file changed, 941 deletions(-) delete mode 100644 docs/MICROSERVICES_ARCHITECTURE.md diff --git a/docs/MICROSERVICES_ARCHITECTURE.md b/docs/MICROSERVICES_ARCHITECTURE.md deleted file mode 100644 index 8f55953..0000000 --- a/docs/MICROSERVICES_ARCHITECTURE.md +++ /dev/null @@ -1,941 +0,0 @@ -# Adiuva — Architettura Microservizi (MVP) - -## Panoramica - -Il monolite viene suddiviso in **4 servizi MVP** + un **API Gateway (Traefik)**, orchestrati con Docker Compose su un singolo VPS raggiungibile via Cloudflare. - -> **Fuori dall'MVP**: Storage Service (S3/backup CRUD) e Plugin Service (marketplace). Verranno aggiunti come servizi indipendenti in una fase successiva. - -``` - ┌──────────────┐ - │ Cloudflare │ - │ (DNS + CDN) │ - └──────┬───────┘ - │ HTTPS / WSS - ┌──────▼───────┐ - │ Traefik │ - │ API Gateway │ - │ (routing, │ - │ TLS, rate │ - │ limiting) │ - └──────┬───────┘ - │ - ┌──────────┬───────────┼───────────┐ - │ │ │ │ - ┌─────▼────┐ ┌───▼───┐ ┌────▼────┐ ┌────▼───┐ - │ Auth │ │ Chat │ │ Agent │ │Billing │ - │ Service │ │Service│ │ Service │ │Service │ - └─────┬────┘ └───┬───┘ └────┬────┘ └────┬───┘ - │ │ │ │ - ┌─────▼──────────▼──────────▼───────────▼────┐ - │ Infrastruttura │ - │ PostgreSQL │ Redis │ Qdrant │ - └─────────────────────────────────────────────┘ -``` - ---- - -## 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`) ⭐ Real-time - -**Responsabilità**: WebSocket device connection, home chat, floating chat, memory middleware, streaming LLM responses verso il client. - -Questo servizio gestisce la **connessione persistente** con l'app Electron e le interazioni **real-time** dell'utente (chat home, floating chat). È il proprietario della WebSocket. - -| Endpoint | Tipo | -|---|---| -| `/api/v1/ws/device` | WebSocket (connessione persistente) | -| `/api/v1/chat` | POST (REST fallback) | - -**Moduli inclusi**: `deep_agent`, `memory_middleware`, `ws_context`, `device_manager` (Redis-backed), `output_formatter`, `llm`, tutti gli agent tools (`task_agent`, `project_agent`, `note_agent`, `timeline_agent`). - -**Perché separato dall'Agent Service**: Il Chat Service tiene la WebSocket aperta e risponde in tempo reale (streaming). Scalare aggiungendo repliche è semplice con sticky sessions + Redis pub/sub per il cross-instance routing dei tool_call. - -**Scaling**: 2–N repliche. Sticky cookies per le WS + Redis per cross-instance. - ---- - -### 1.3 Agent Service (`agent-service`) ⭐ Batch - -**Responsabilità**: Batch agent processing (directory scanning, file classification, entity extraction), agent setup journeys, agent configuration CRUD. - -Questo servizio gestisce i processi **long-running** e **CPU-intensive**: scansione filesystem, classificazione file con LLM, estrazione entità in batch. Non possiede la WebSocket — comunica con il device dell'utente tramite **Redis pub/sub** passando per il Chat Service. - -| Endpoint | Tipo | -|---|---| -| `/api/v1/agents/catalog` | GET | -| `/api/v1/agents/can-create` | POST | -| `/api/v1/agents/trigger` | POST | -| `/api/v1/agents/journey/start` | POST (o WS relay) | -| `/api/v1/agents/journey/message` | POST (o WS relay) | - -**Moduli inclusi**: `agent_runner`, `agent_registry`, `filesystem_agent`, `llm`. - -**Flusso tool-call cross-service** (l'Agent Service non ha la WS): - -``` -┌──────────────┐ ┌──────────────┐ ┌──────────┐ -│ Agent Service│ │ Redis │ │ Chat │ -│ (batch run) │ │ │ │ Service │ -│ │ │ │ │ (ha WS) │ -│ 1. Needs to │ PUBLISH │ │ SUBSCRIBE │ │ -│ read file ├───────────►│tool_call:u123├───────────►│ 2. Invia │ -│ from │ │ │ │ al │ -│ device │ │ │ │ device│ -│ │ │ │ │ via WS│ -│ │ SUBSCRIBE │ │ PUBLISH │ │ -│ 4. Riceve ◄────────────┤tool_result:id│◄───────────┤ 3. Device│ -│ risultato │ │ │ │ reply │ -└──────────────┘ └──────────────┘ └──────────┘ -``` - -**Scaling**: 1–N repliche. Completamente stateless, scala indipendentemente dalla chat. Ogni replica processa batch job diversi. Può essere scalato a 0 se non ci sono agent attivi (risparmio risorse). - -**Vantaggio dello split**: Se 50 utenti triggerano agenti batch contemporaneamente, il Chat Service non ne risente — le risposte real-time rimangono veloci. - ---- - -### 1.4 Billing Service (`billing-service`) - -**Responsabilità**: Stripe checkout, webhook, subscription management. - -| 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. Al prossimo token refresh il JWT conterrà il tier aggiornato. - -**Scaling**: 1 replica sufficiente. Basso traffico. - ---- - -### 1.5 Servizi esclusi dall'MVP - -I seguenti servizi verranno aggiunti post-MVP come servizi indipendenti: - -| Servizio | Responsabilità | Note | -|---|---|---| -| **Storage Service** | S3 blobs CRUD, vector ops, backup | Le funzionalità vector/embed possono restare nel Chat Service per il MVP | -| **Plugin Service** | Marketplace, install, revenue split | Feature non critica per il lancio | - ---- - -## 2. Tier Check — Dove e Come - -Il tier dell'utente (free/pro/power/team) determina rate-limiting, quote e accesso a funzionalità. Con i microservizi, **ogni servizio controlla il tier autonomamente** senza chiamare l'Auth Service. - -### Strategia: Tier nel JWT - -L'Auth Service include il `tier` come claim nel JWT al momento del login/refresh: - -```json -{ - "sub": "user_123", - "tier": "pro", - "exp": 1742515200, - "iat": 1742511600 -} -``` - -Ogni servizio: -1. Decodifica il JWT con la chiave pubblica (già lo fa per l'auth) -2. Legge `payload["tier"]` — **zero chiamate extra** -3. Applica le sue regole di enforcement localmente - -```python -# shared/auth.py — dependency FastAPI condivisa -from fastapi import Depends, HTTPException, Request -from jose import jwt - -PUBLIC_KEY = ... - -class CurrentUser: - def __init__(self, user_id: str, tier: str): - self.user_id = user_id - self.tier = tier - -async def get_current_user(request: Request) -> CurrentUser: - token = request.headers.get("Authorization", "").removeprefix("Bearer ") - payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"]) - return CurrentUser(user_id=payload["sub"], tier=payload["tier"]) - -def require_tier(*allowed_tiers: str): - """Dependency che blocca se il tier non è tra quelli ammessi.""" - async def check(user: CurrentUser = Depends(get_current_user)): - if user.tier not in allowed_tiers: - raise HTTPException(403, "Tier insufficient") - return user - return check -``` - -### Cosa succede quando il tier cambia (upgrade/downgrade)? - -``` -┌──────────┐ Stripe webhook ┌──────────┐ tier_changed ┌──────────┐ -│ Stripe │ ─────────────────►│ Billing │ ───────────────►│ Auth │ -│ │ │ Service │ (Redis pub/sub) │ Service │ -└──────────┘ └──────────┘ └────┬─────┘ - │ - UPDATE users - SET tier = 'power' - │ - Al prossimo /refresh - il JWT conterrà tier='power' -``` - -**Latenza del cambio**: Il tier si propaga al prossimo token refresh (tipicamente 15–30 min, o il client può forzare un refresh immediato dopo il checkout). Per il billing webhook, il downgrade può essere forzato invalidando il refresh token su Redis → il client è obbligato a ri-autenticarsi. - -### Dove si applica in ciascun servizio - -| Servizio | Enforcement | -|---|---| -| **Auth Service** | Nessuno (è lui che scrive il tier) | -| **Chat Service** | Rate-limit per tier (req/min), quota messaggi | -| **Agent Service** | Max agent configs, max runs/day, max concurrent batches | -| **Billing Service** | Nessuno (gestisce i tier, non li consuma) | - -### Rate-limit distribuito via Redis - -Poiché ogni servizio ha le sue repliche, il rate-limiting deve essere **condiviso** via Redis: - -```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, service: str) -> bool: - limits = {"free": 20, "pro": 60, "power": 120, "team": 200} - max_req = limits.get(tier, 20) - key = f"rate:{service}:{user_id}" - - pipe = self._redis.pipeline() - pipe.incr(key) - pipe.expire(key, 60) - count, _ = await pipe.execute() - - return count <= max_req -``` - ---- - -## 3. WebSocket con Scaling Orizzontale — Il Problema Chiave - -`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)) -``` - ---- - -## 4. Struttura Directory Proposta (MVP) - -``` -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 # DistributedRateLimiter (Redis) -│ │ └── 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 # memory_* -│ ├── routes/ -│ │ ├── device_ws.py # WS connection owner -│ │ └── chat.py # REST fallback -│ ├── core/ -│ │ ├── device_manager.py # RedisDeviceManager -│ │ ├── deep_agent.py # Home + floating chat -│ │ ├── memory_middleware.py -│ │ ├── ws_context.py -│ │ ├── output_formatter.py -│ │ └── llm.py -│ └── agents/ # Tool definitions (used by deep_agent) -│ ├── task_agent.py -│ ├── project_agent.py -│ ├── note_agent.py -│ └── timeline_agent.py -│ -├── agent-service/ -│ ├── Dockerfile -│ ├── requirements.txt -│ └── app/ -│ ├── main.py -│ ├── config.py -│ ├── db.py -│ ├── models.py # agent_run_logs, local/cloud_agent_configs -│ ├── routes/ -│ │ ├── agents.py # catalog, can-create, trigger -│ │ └── agent_setup.py # journey start/message -│ ├── core/ -│ │ ├── agent_runner.py # Batch classify → process -│ │ ├── agent_registry.py -│ │ ├── redis_executor.py # execute_on_client via Redis pub/sub -│ │ └── llm.py -│ └── agents/ -│ ├── task_agent.py # Tool definitions (batch context) -│ ├── project_agent.py -│ ├── note_agent.py -│ ├── timeline_agent.py -│ └── filesystem_agent.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 -│ -└── infra/ - ├── traefik/ - │ └── traefik.yml - ├── keys/ - │ ├── jwt_private.pem # Solo auth-service - │ └── jwt_public.pem # Tutti i servizi - └── alembic/ # Migrazioni condivise o per-servizio -``` - ---- - -## 5. Docker Compose — Configurazione MVP - -```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" - - "--entrypoints.web.http.redirections.entrypoint.to=websecure" - ports: - - "80:80" - - "443:443" - - "8080:8080" # Dashboard Traefik (disabilitare in prod) - volumes: - - /var/run/docker.sock:/var/run/docker.sock:ro - - ./infra/certs:/certs: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 - REDIS_URL: redis://redis:6379 - JWT_PRIVATE_KEY_FILE: /run/secrets/jwt_private_key - SERVICE_NAME: auth - secrets: - - jwt_private_key - - jwt_public_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 - redis: - condition: service_healthy - - # ══════════════════════════════════════════════════════════ - # Chat Service — Real-time WS + Chat (scalabile) - # ══════════════════════════════════════════════════════════ - chat-service: - build: ./chat-service - deploy: - replicas: 2 - 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 chat endpoint - - "traefik.http.routers.chat.rule=PathPrefix(`/api/v1/chat`)" - - "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 - - # ══════════════════════════════════════════════════════════ - # Agent Service — Batch processing (scalabile indipendentemente) - # ══════════════════════════════════════════════════════════ - agent-service: - build: ./agent-service - deploy: - replicas: 2 - 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: agent - secrets: - - jwt_public_key - labels: - - "traefik.enable=true" - - "traefik.http.routers.agents.rule=PathPrefix(`/api/v1/agents`)" - - "traefik.http.services.agents.loadbalancer.server.port=8000" - depends_on: - db: - condition: service_healthy - redis: - 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 - - # ══════════════════════════════════════════════════════════ - # 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 - - 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: - qdrant_data: -``` - ---- - -## 6. Configurazione Cloudflare + VPS - -### 6.1 DNS - -``` -api.tuodominio.com → A record → IP del VPS - → Proxy: ON (orange cloud) -``` - -### 6.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) | | - -### 6.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 -``` - -### 6.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 -``` - ---- - -## 7. Comunicazione Inter-Servizio - -### 7.1 Redis Pub/Sub — Event Bus - -``` -┌──────────┐ tier_changed:user_123 ┌──────────┐ -│ Billing │ ────────────────────────► │ Auth │ -│ Service │ │ Service │ -└──────────┘ └──────────┘ - -┌──────────┐ tool_call:user_123 ┌──────────┐ -│ Agent │ ────────────────────────► │ Chat │ -│ Service │ │ Service │ -│ (batch) │ ◄────────────────────────│ (ha WS) │ -└──────────┘ tool_result:{call_id} └──────────┘ -``` - -### 7.2 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** (tool-call cross-instance, tier events) -- **Redis hash** (stato condiviso: `ws:connections`, rate-limit counters) -- **PostgreSQL** (dati persistenti condivisi) - ---- - -## 8. Piano di Migrazione Incrementale (MVP) - -### Fase 1 — Preparazione (nel monolite attuale) -1. Aggiungere Redis al `docker-compose.yml` attuale -2. Migrare JWT da HS256 → RS256 (backward-compatible: accetta entrambi per un periodo) -3. Implementare `RedisDeviceManager` come drop-in replacement del singleton in-memory -4. Estrarre `shared/` con auth verification, schemas, middleware - -### Fase 2 — Auth Service (primo split) -1. Estrarre `auth.py` routes + models in `auth-service/` -2. Verificare che i JWT firmati da `auth-service` vengano validati dal monolite -3. Aggiungere Traefik e routare `/api/v1/auth/*` al nuovo servizio -4. Il monolite continua a servire tutto il resto - -### Fase 3 — Billing Service -1. Estrarre billing routes, Stripe service, tier manager -2. Configurare Redis pub/sub per `tier_changed` events -3. Routare via Traefik - -### Fase 4 — Split Chat + Agent (il più delicato) -1. Il monolite residuo contiene WS + chat + agents -2. Separare Agent Service: estrarre `agent_runner`, `agent_registry`, `agent_setup`, route `/agents/*` -3. Implementare `redis_executor.py` nell'Agent Service per tool-call via Redis -4. Il Chat Service resta proprietario della WS e sottoscrive i canali `tool_call:{user_id}` -5. Testare: trigger agent dall'Agent Service → tool_call via Redis → Chat Service → WS → device → risposta - -### Fase 5 — Scaling test -1. Scalare Chat Service a 2 repliche, verificare sticky sessions -2. Scalare Agent Service a 2 repliche, verificare batch processing distribuito -3. Monitoring (Prometheus + Grafana) per ogni servizio - ---- - -## 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 (MVP) - -| Componente | CPU | RAM | Note | -|---|---|---|---| -| Traefik | 0.25 | 128MB | | -| Auth Service ×2 | 0.25 ×2 | 128MB ×2 | Stateless, leggero | -| Chat Service ×2 | 1.0 ×2 | 1GB ×2 | WS + streaming LLM | -| Agent Service ×2 | 0.75 ×2 | 512MB ×2 | Batch LLM, CPU-bound | -| Billing Service | 0.25 | 128MB | | -| PostgreSQL | 1.0 | 1GB | | -| Redis | 0.25 | 256MB | | -| Qdrant | 0.5 | 512MB | | -| **Totale MVP** | **~5.5 vCPU** | **~5 GB** | | - -**Raccomandazione**: VPS con **8 vCPU / 16 GB RAM** per avere margine. Hetzner CPX41 (~€30/mese) o equivalente. Senza Storage/Plugin si risparmia ~1 vCPU e 512MB rispetto alla versione completa. - ---- - -## Riepilogo Architettura MVP - -| Servizio | Repliche | Proprietario di | -|---|---|---| -| **Traefik** | 1 | Routing, TLS, sticky sessions | -| **Auth Service** | 2 | JWT RS256, registrazione, login, profilo | -| **Chat Service** | 2–N | WebSocket, home/floating chat, streaming | -| **Agent Service** | 2–N | Batch processing, directory scan, agent setup | -| **Billing Service** | 1 | Stripe, subscriptions, tier management | - -| Decisione | Scelta | Motivazione | -|---|---|---| -| API Gateway | Traefik | Nativo Docker, WebSocket support, service discovery automatico | -| JWT | RS256 (asimmetrico) | Verifica distribuita senza contattare Auth Service | -| Tier check | Claim nel JWT | Ogni servizio verifica localmente, zero roundtrip | -| WebSocket scaling | Redis pub/sub + sticky cookies | Cross-instance tool-call routing | -| Chat ↔ Agent split | Servizi separati | Batch CPU-bound non impatta real-time chat | -| Agent → Device comms | Redis pub/sub via Chat Service | Agent non possiede la WS, usa un relay | -| Rate limiting | Redis contatori distribuiti | Sliding window condivisa tra repliche | -| Database | PostgreSQL condiviso | Semplicità MVP; split DB futuro facile | -| TLS | Cloudflare Origin Certificate | Zero maintenance | -| Orchestrazione | Docker Compose | Sufficiente per un singolo VPS | -| Storage / Plugin | Post-MVP | Non critici per il lancio |