Files
api/docs/MICROSERVICES_ARCHITECTURE.md
2026-03-20 23:48:36 +01:00

38 KiB
Raw Permalink Blame History

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.
# 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",
    )
# 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: 2N 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: 1N 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:

{
  "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
# 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 1530 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:

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

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

# 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

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

# 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

# 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 2N WebSocket, home/floating chat, streaming
Agent Service 2N 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