diff --git a/.env.example b/.env.example index 6be118d..4f24d30 100644 --- a/.env.example +++ b/.env.example @@ -174,3 +174,12 @@ ADMIN_BOOTSTRAP_KEY=change_me_in_production # Timeout requêtes Neo4j en secondes (défaut: 30) # NEO4J_QUERY_TIMEOUT_SECONDS=30 + +# ============================================================================= +# Proxy HTTP sortant (optionnel) +# ============================================================================= +# Proxy HTTP pour les appels S3 (boto3), LLM et embeddings (httpx/openai). +# Variable CUSTOM — pas HTTP_PROXY/HTTPS_PROXY — injectée manuellement pour +# ne pas affecter toutes les autres libs Python. +# ⚠️ Non supporté pour Neo4j (driver bolt) et Qdrant (client natif). +# #PROXY_URL=http://10.185.132.250:3128 diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a6f11cb --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,100 @@ +name: CI — Build & Push + +# Déclencheurs : +# - push sur n'importe quelle branche → image taguée avec le nom de branche +# - push d'un tag v* → tags selon la convention ci-dessous +# +# Convention de tags Docker : +# ┌─────────────────────┬─────────────────────────────────────────────────────┐ +# │ Git ref │ Tags Docker produits │ +# ├─────────────────────┼─────────────────────────────────────────────────────┤ +# │ branch: main │ :main │ +# │ branch: dev │ :dev │ +# │ branch: feature/foo │ :feature-foo │ +# │ tag: v2.1.1 │ :2.1.1 :2.1 :latest (release stable) │ +# │ tag: v2.1.1-rc.1 │ :2.1.1-rc.1 (pre-release, pas d'alias) │ +# │ tag: v2.1.1-dev │ :2.1.1-dev (pre-release, pas d'alias) │ +# └─────────────────────┴─────────────────────────────────────────────────────┘ +# Règle : un tag contenant un tiret (vX.Y.Z-*) est une pré-release → +# pas de :X.Y ni de :latest pour éviter de pointer une version instable. +on: + push: + branches: + - "**" + tags: + - "v*" + +env: + REGISTRY: ghcr.io + # github.repository → "cloud-temple/graph-memory" + # Image finale → ghcr.io/cloud-temple/graph-memory: + IMAGE_NAME: ${{ github.repository }} + +jobs: + # ───────────────────────────────────────────────────────────────────────── + # Job — Build multi-arch + push GHCR + # ───────────────────────────────────────────────────────────────────────── + build: + name: Build & Push Docker Image + runs-on: ubuntu-latest + permissions: + contents: read + packages: write # Requis pour pousser vers GHCR + + steps: + - uses: actions/checkout@v4 + + # Multi-arch : amd64 natif + arm64 via QEMU (Mac M-series, Pi, etc.) + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Authentification GHCR — GITHUB_TOKEN suffit, aucun secret à configurer + - name: Log in to GHCR + uses: docker/login-action@v3 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Génération automatique des tags et labels OCI + # IS_STABLE_TAG = true ssi le ref est un tag vX.Y.Z SANS tiret (release stable). + # Les pré-releases (v2.1.1-rc.1, v2.1.1-dev, …) ont un tiret → IS_STABLE_TAG=false. + - name: Detect stable tag + id: tag_type + run: | + REF="${{ github.ref }}" + if [[ "$REF" =~ ^refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "is_stable=true" >> "$GITHUB_OUTPUT" + else + echo "is_stable=false" >> "$GITHUB_OUTPUT" + fi + + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + # Branches → tag avec le nom de branche (main, dev, feature-xxx, …) + type=ref,event=branch + # Tags semver → version complète (ex: v2.1.1 → 2.1.1 ; v2.1.1-rc.1 → 2.1.1-rc.1) + type=semver,pattern={{version}} + # :X.Y uniquement pour les releases stables (pas de tiret dans le tag) + type=semver,pattern={{major}}.{{minor}},enable=${{ steps.tag_type.outputs.is_stable }} + # :latest uniquement pour les releases stables (pas de tiret dans le tag) + type=raw,value=latest,enable=${{ steps.tag_type.outputs.is_stable }} + + - name: Build and Push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64/v8 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + # Cache BuildKit via GitHub Actions Cache → rebuilds ~3× plus rapides + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CHANGELOG.md b/CHANGELOG.md index df87429..cf9902a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,36 @@ # Changelog +## [Unreleased] + +### Added +- **PROXY_URL** — variable d'environnement optionnelle pour router les appels + S3 (boto3 SigV2 + SigV4), LLM extraction (`ExtractorService`) et embeddings + (`EmbeddingService`) via un proxy HTTP sortant. + - Injectée manuellement (`boto3.Config(proxies=...)`, `httpx.AsyncClient(proxy=...)`) + pour ne **pas** affecter les autres libs Python qui lisent automatiquement + `HTTP_PROXY` / `HTTPS_PROXY`. + - Validation fail-fast au démarrage : doit commencer par `http://` ou `https://`. + - Non supporté pour Neo4j (driver bolt) et Qdrant (client natif sans param proxy). + - Log au démarrage si active : `[StorageService] S3 requests via proxy …` + +### Fixed +- **`Dockerfile` : suppression du `HEALTHCHECK`** — Le healthcheck était hardcodé sur + le port 8002 dans l'image, provoquant l'arrêt du container (ExitCode=0 via SIGTERM + Swarm) dès que `MCP_SERVER_PORT` était différent de 8002. La responsabilité du + healthcheck appartient à l'orchestrateur (`docker-compose.yml`, Swarm stack, Kubernetes + probes) qui connaît la configuration réelle du déploiement. +- **`docker-compose.yml` : healthcheck via `python` au lieu de `curl`** — Plus de + dépendance à un binaire externe ; disponible dans toute image Python de base. + +### Changed +- **`.github/workflows/build.yml` : `:X.Y` et `:latest` réservés aux releases stables** — Les tags + pré-release (`v2.1.1-rc.1`, `v2.1.1-dev`, …) ne reçoivent plus d'alias `:X.Y` ni + `:latest`. Un step `Detect stable tag` détecte si le tag Git est de la forme + `vX.Y.Z` (sans tiret) pour activer ces alias. Conforme à la convention SemVer : + un tag avec suffixe = pré-release = ne pas pointer `latest`. + +--- + ## [2.1.2] - 2026-05-11 ### 📦 Mises à jour de dépendances (Dependabot) diff --git a/Dockerfile b/Dockerfile index e763008..3509f37 100644 --- a/Dockerfile +++ b/Dockerfile @@ -52,9 +52,9 @@ EXPOSE 8002 # Passer en utilisateur non-root USER mcp -# Healthcheck via /health endpoint (léger, pas de fork Python) -HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \ - CMD curl -sf http://localhost:8002/health -o /dev/null 2>/dev/null +# NOTE: Pas de HEALTHCHECK dans l'image — le port est configurable via MCP_SERVER_PORT. +# La responsabilité du healthcheck appartient à l'orchestrateur (docker-compose.yml, +# Swarm stack, Kubernetes liveness probe) qui connaît le port réel de déploiement. # Point d'entrée ENTRYPOINT ["python", "-m", "src.mcp_memory.server"] diff --git a/README.md b/README.md index d392a9c..345fad5 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,13 @@ Service de mémoire persistante basé sur un **graphe de connaissances** pour le Développé par **[Cloud Temple](https://www.cloud-temple.com)**. +[![CI](https://github.com/Cloud-Temple/graph-memory/actions/workflows/build.yml/badge.svg)](https://github.com/Cloud-Temple/graph-memory/actions/workflows/build.yml) +[![Docker](https://img.shields.io/badge/ghcr.io-cloud--temple%2Fgraph--memory-blue?logo=docker)](https://ghcr.io/cloud-temple/graph-memory) +[![Version](https://img.shields.io/badge/version-2.1.1-blue.svg)]() +[![License](https://img.shields.io/badge/license-Apache%202.0-green.svg)]() +[![MCP](https://img.shields.io/badge/protocol-MCP-purple.svg)]() +[![Python](https://img.shields.io/badge/python-3.11+-yellow.svg)]() +

Graph Memory — Visualisation du graphe de connaissances

@@ -275,6 +282,7 @@ cp .env.example .env | `RAG_CHUNK_LIMIT` | `8` | Nombre max de chunks retournés par Qdrant | | `CHUNK_SIZE` | `500` | Taille cible en tokens par chunk | | `CHUNK_OVERLAP` | `50` | Tokens de chevauchement entre chunks | +| `PROXY_URL` | _(aucun)_ | Proxy HTTP sortant pour S3, LLM et embeddings (ex: `http://10.185.132.250:3128`). Variable custom — pas `HTTP_PROXY` — injectée manuellement pour ne pas forcer le proxy sur toutes les libs Python. Non supporté pour Neo4j et Qdrant. | Voir `.env.example` pour la liste complète. diff --git a/docker-compose.yml b/docker-compose.yml index 969726d..dafa256 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,10 +117,12 @@ services: # Server config - MCP_SERVER_DEBUG=${MCP_SERVER_DEBUG:-false} - ADMIN_BOOTSTRAP_KEY=${ADMIN_BOOTSTRAP_KEY} + # Proxy HTTP sortant (optionnel — ne pas définir si absent) + # - PROXY_URL=${PROXY_URL:-} healthcheck: - # /health est un endpoint léger qui retourne le status du service + version. - # Avec Streamable HTTP (v1.4.0+), l'ancien endpoint /sse n'existe plus. - test: ["CMD-SHELL", "curl -sf http://localhost:8002/health -o /dev/null 2>/dev/null"] + # python est toujours disponible dans l'image — pas de dépendance à curl. + # Adapter le port si MCP_SERVER_PORT est surchargé. + test: ["CMD-SHELL", "python -c \"import urllib.request; urllib.request.urlopen('http://localhost:8002/health', timeout=5)\" 2>/dev/null"] interval: 30s timeout: 10s start_period: 10s diff --git a/src/mcp_memory/config.py b/src/mcp_memory/config.py index 167877b..3002869 100644 --- a/src/mcp_memory/config.py +++ b/src/mcp_memory/config.py @@ -8,6 +8,7 @@ from functools import lru_cache from typing import Optional +from pydantic import model_validator from pydantic_settings import BaseSettings, SettingsConfigDict @@ -105,7 +106,26 @@ class Settings(BaseSettings): extraction_timeout_seconds: int = 600 # 10 min par appel LLM (gros docs avec chain-of-thought) s3_upload_timeout_seconds: int = 60 neo4j_query_timeout_seconds: int = 30 - + + # ========================================================================= + # Proxy HTTP sortant (optionnel) + # ========================================================================= + # Variable CUSTOM — pas HTTP_PROXY/HTTPS_PROXY — pour ne pas forcer le proxy + # sur toutes les libs Python (boto3, httpx, requests…). + # Injecté manuellement dans boto3 (S3), httpx (LLM extraction + embeddings). + # Non supporté pour Neo4j et Qdrant (clients sans paramètre proxy explicite). + proxy_url: str = "" + + @model_validator(mode="after") + def _validate_proxy_url(self) -> "Settings": + """Vérifie que PROXY_URL est une URL valide si renseignée.""" + if self.proxy_url and not self.proxy_url.startswith(("http://", "https://")): + raise ValueError( + f"PROXY_URL must start with http:// or https://, " + f"got '{self.proxy_url[:50]}'" + ) + return self + @property def llmaas_base_url(self) -> str: """URL complète pour le client OpenAI (compatible OpenAI).""" diff --git a/src/mcp_memory/core/embedder.py b/src/mcp_memory/core/embedder.py index 8f047e4..870e44d 100644 --- a/src/mcp_memory/core/embedder.py +++ b/src/mcp_memory/core/embedder.py @@ -13,6 +13,7 @@ import sys from typing import Optional, List +import httpx from openai import AsyncOpenAI from openai import APIError, APITimeoutError from tenacity import retry, stop_after_attempt, wait_exponential @@ -29,16 +30,31 @@ class EmbeddingService: """ def __init__(self): - """Initialise le client OpenAI pour les embeddings.""" + """Initialise le client OpenAI pour les embeddings. + + Si PROXY_URL est définie, les appels d'embedding transitent par ce proxy + via un httpx.AsyncClient dédié. On n'utilise pas HTTP_PROXY/HTTPS_PROXY + pour ne pas affecter les autres libs Python. + """ settings = get_settings() - + + proxy_url = settings.proxy_url.strip() or None + _http_client = ( + httpx.AsyncClient(proxy=httpx.Proxy(proxy_url)) + if proxy_url + else None + ) + # Utilise le même client OpenAI que l'extracteur # L'API LLMaaS Cloud Temple est compatible OpenAI self._client = AsyncOpenAI( base_url=settings.llmaas_base_url, api_key=settings.llmaas_api_key, - timeout=60.0 + timeout=60.0, + **({"http_client": _http_client} if _http_client else {}), ) + if proxy_url: + print(f"[EmbeddingService] embedding requests via proxy {proxy_url}", file=sys.stderr) self._model = settings.llmaas_embedding_model self._dimensions = settings.llmaas_embedding_dimensions diff --git a/src/mcp_memory/core/extractor.py b/src/mcp_memory/core/extractor.py index 2f227cc..a9280fb 100644 --- a/src/mcp_memory/core/extractor.py +++ b/src/mcp_memory/core/extractor.py @@ -11,6 +11,7 @@ from typing import Optional, List from tenacity import retry, stop_after_attempt, wait_exponential +import httpx from openai import AsyncOpenAI from openai import APIError, APITimeoutError @@ -66,14 +67,29 @@ class ExtractorService: """ def __init__(self): - """Initialise le client OpenAI compatible.""" + """Initialise le client OpenAI compatible. + + Si PROXY_URL est définie, les appels LLM transitent par ce proxy + via un httpx.AsyncClient dédié. On n'utilise pas HTTP_PROXY/HTTPS_PROXY + pour ne pas affecter les autres libs Python. + """ settings = get_settings() - + + proxy_url = settings.proxy_url.strip() or None + _http_client = ( + httpx.AsyncClient(proxy=httpx.Proxy(proxy_url)) + if proxy_url + else None + ) + self._client = AsyncOpenAI( base_url=settings.llmaas_base_url, api_key=settings.llmaas_api_key, - timeout=settings.extraction_timeout_seconds + timeout=settings.extraction_timeout_seconds, + **({"http_client": _http_client} if _http_client else {}), ) + if proxy_url: + print(f"[ExtractorService] LLM requests via proxy {proxy_url}", file=sys.stderr) self._model = settings.llmaas_model self._max_tokens = settings.llmaas_max_tokens self._temperature = settings.llmaas_temperature diff --git a/src/mcp_memory/core/storage.py b/src/mcp_memory/core/storage.py index 40120be..d20f36a 100644 --- a/src/mcp_memory/core/storage.py +++ b/src/mcp_memory/core/storage.py @@ -40,15 +40,23 @@ def __init__(self): # Région Dell ECS Cloud Temple region = settings.s3_region_name if settings.s3_region_name else "fr1" + # Proxy HTTP sortant — utilise PROXY_URL (variable custom) plutôt que + # HTTP_PROXY/HTTPS_PROXY pour ne pas affecter les autres libs Python. + proxy_url = settings.proxy_url.strip() or None + _proxies: dict | None = ( + {"http": proxy_url, "https": proxy_url} if proxy_url else None + ) + # Client SigV2 pour PUT/GET/DELETE (opérations sur objets) # Tests validés: PUT ✅, GET ✅, DELETE ✅ config_v2 = Config( region_name=region, signature_version='s3', # SigV2 legacy s3={'addressing_style': 'path'}, - retries={'max_attempts': 3, 'mode': 'adaptive'} + retries={'max_attempts': 3, 'mode': 'adaptive'}, + **({"proxies": _proxies} if _proxies else {}), ) - + self._client_v2 = boto3.client( 's3', endpoint_url=settings.s3_endpoint_url, @@ -57,16 +65,17 @@ def __init__(self): region_name=region, config=config_v2 ) - + # Client SigV4 pour HEAD/LIST (opérations métadonnées) # Utilisé en fallback si SigV2 échoue sur ces opérations config_v4 = Config( region_name=region, signature_version='s3v4', s3={'addressing_style': 'path', 'payload_signing_enabled': False}, - retries={'max_attempts': 3, 'mode': 'adaptive'} + retries={'max_attempts': 3, 'mode': 'adaptive'}, + **({"proxies": _proxies} if _proxies else {}), ) - + self._client_v4 = boto3.client( 's3', endpoint_url=settings.s3_endpoint_url, @@ -75,6 +84,12 @@ def __init__(self): region_name=region, config=config_v4 ) + + if proxy_url: + print( + f"[StorageService] S3 requests via proxy {proxy_url}", + file=__import__("sys").stderr, + ) # Client par défaut (SigV2 pour compatibilité maximale) self._client = self._client_v2