# 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 |