Aller au contenu

Annexes locales

Objectif: faire tourner les briques locales utilisées par le service Todo: MongoDB, lecture de la base, secrets locaux et OpenTelemetry.

Services locaux autour du Todo service

Pourquoi MongoDB ?

Le repository memory suffit pour les tests et pour un seul processus Python. MongoDB sert de stockage partagé quand l'API FastAPI, le serveur MCP et LangGraph tournent dans des processus séparés.

Lancer MongoDB avec Docker

Commande minimale:

Bash
docker run --name arclith-mongo   -p 27017:27017   -e MONGO_INITDB_ROOT_USERNAME=arclith   -e MONGO_INITDB_ROOT_PASSWORD=arclith   -v arclith-mongo-data:/data/db   -d mongo:8

Vérifier que le container tourne:

Bash
docker ps --filter name=arclith-mongo
docker logs -f arclith-mongo

Pour repartir d'une base vide:

Bash
docker rm -f arclith-mongo
docker volume rm arclith-mongo-data

La suppression du volume efface uniquement les données MongoDB de ce tutoriel.

Configurer le repository MongoDB

Installer l'extra:

Bash
uv add "arclith[mongodb]"

Ajouter l'adapter:

Bash
arclith-cli add-adapter --capability repository

Choisir mongodb, puis répondre:

Text Only
1
2
3
db_name (todo-list-service): todo-list-service
multitenant [y/n] (n): n
Activer mongodb [y/n] (y): y

Modifier config/adapters/adapters.yaml:

YAML
1
2
3
4
5
logger: console
repository: mongodb
observability:
  enabled:
  - langsmith

Modifier config/adapters/outbound/mongodb.yaml:

YAML
1
2
3
multitenant: false   # true = URI + db_name resolus par requete via JWT -> Vault
db_name: todo-list-service   # uri -> secrets.yaml ou Vault (fallback single-tenant)
collection_name: todo

Créer config/secrets.yaml:

YAML
1
2
3
resolver: yaml
mappings:
  adapters.mongodb.uri: adapters.mongodb.uri

Créer secrets.yaml à la racine du projet. Ce fichier reste local:

YAML
1
2
3
adapters:
  mongodb:
    uri: "mongodb://arclith:arclith@127.0.0.1:27017/todo_list_service?authSource=admin"

Adapter MongoDB Todo

Créer les packages:

Bash
1
2
3
mkdir -p src/todo_list_service/adapters/outbound/mongodb/repositories
touch src/todo_list_service/adapters/outbound/mongodb/__init__.py
touch src/todo_list_service/adapters/outbound/mongodb/repositories/__init__.py

Créer src/todo_list_service/adapters/outbound/mongodb/repositories/todo_repository.py:

Python
from datetime import date
from typing import Any

from arclith.adapters.outbound.mongodb.config import MongoDBConfig
from arclith.adapters.outbound.mongodb.repository import MongoDBRepository
from arclith.domain.ports.outbound.logger import Logger
from todo_list_service.domain.models.todo import Todo


class MongoDBTodoRepository(MongoDBRepository[Todo]):
    def __init__(self, config: MongoDBConfig, logger: Logger) -> None:
        super().__init__(config, Todo, logger)

    def _to_doc(self, entity: Todo) -> dict[str, Any]:
        doc = super()._to_doc(entity)
        due_date = doc.get("due_date")
        if isinstance(due_date, date):
            doc["due_date"] = due_date.isoformat()
        return doc

    # TODO: add custom query methods here
    # async def find_by_name(self, name: str) -> list[Todo]:
    #     async with self._collection() as col:
    #         return [
    #             self._from_doc(doc)
    #             async for doc in col.find({"name": name, "deleted_at": None})
    #         ]

Créer src/todo_list_service/adapters/outbound/mongodb/repository.py:

Python
1
2
3
from todo_list_service.adapters.outbound.mongodb.repositories.todo_repository import MongoDBTodoRepository

__all__ = ["MongoDBTodoRepository"]

Modifier src/todo_list_service/infrastructure/containers/todo_container.py:

Python
from __future__ import annotations

from weakref import WeakKeyDictionary

from arclith import Arclith
from arclith.domain.ports.outbound.repository import Repository

from todo_list_service.adapters.outbound.mongodb.repositories.todo_repository import MongoDBTodoRepository
from todo_list_service.application.use_cases.create_todo import CreateTodoUseCase
from todo_list_service.application.use_cases.list_todos import ListTodosUseCase
from todo_list_service.domain.models.todo import Todo
from todo_list_service.domain.ports.inbound.create_todo import CreateTodoPort
from todo_list_service.domain.ports.inbound.list_todos import ListTodosPort

_repositories: WeakKeyDictionary[Arclith, Repository[Todo]] = WeakKeyDictionary()


def build_todo_repository(app: Arclith) -> Repository[Todo]:
    repository = _repositories.get(app)
    if repository is None:
        repository = _create_todo_repository(app)
        _repositories[app] = repository
    return repository


def _create_todo_repository(app: Arclith) -> Repository[Todo]:
    if app.config.adapters.repository == "mongodb":
        return MongoDBTodoRepository(app.config.adapters.mongodb, app.logger)
    return app.repository(Todo)


def clear_todo_repository_cache() -> None:
    _repositories.clear()


def build_create_todo_use_case(app: Arclith) -> CreateTodoPort:
    return CreateTodoUseCase(build_todo_repository(app))


def build_list_todos_use_case(app: Arclith) -> ListTodosPort:
    return ListTodosUseCase(build_todo_repository(app))

Le container choisit MongoDBTodoRepository quand repository: mongodb est actif. Sinon, il utilise le repository standard Arclith, ce qui permet aux tests de rester en memory.

Pyproject complet

Les commandes uv add des étapes API, MCP, agent et MongoDB donnent ce pyproject.toml:

TOML
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "todo-list-service"
version = "0.1.0"
description = "Arclith service"
requires-python = ">=3.13"
dependencies = [
    "arclith[fastapi,langgraph,mcp,mongodb]>=0.15.0",
]

[tool.hatch.build.targets.wheel]
packages = ["src/todo_list_service"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

[dependency-groups]
dev = [
    "pytest>=9.0.0",
    "pytest-asyncio>=1.3.0",
    "httpx>=0.27.0",
]

uv.lock est généré par uv sync; il n'est pas recopié à la main dans le tutoriel.

Relancer API, MCP et LangGraph

Lancer chaque canal dans son terminal.

API:

Bash
MODE=api uv run python main.py

MCP:

Bash
MODE=mcp_http uv run python main.py

LangGraph:

Bash
1
2
3
export LANGSMITH_TRACING=false
export LANGGRAPH_CLI_NO_ANALYTICS=1
uv run langgraph dev --no-browser --allow-blocking --port 2024

À partir de là, une todo créée par Swagger, par LM Studio via MCP ou par LangGraph doit être visible par les autres canaux.

Valider LangGraph Hors Ligne

Studio est utile pour apprendre le graphe, mais l'UI hébergée n'est pas nécessaire. Hors ligne, tester l'Agent Server local par API:

Bash
curl -N -X POST "http://127.0.0.1:2024/runs/stream" \
  -H "Content-Type: application/json" \
  -d '{
    "assistant_id": "todo_agent",
    "input": {
      "messages": [
        {"role": "human", "content": "Quelles sont mes tâches en cours ?"}
      ]
    },
    "stream_mode": "values"
  }'

Pour prouver la persistance de conversation, créer un thread:

Bash
1
2
3
4
5
6
7
8
9
THREAD_ID=$(curl -fsS -X POST "http://127.0.0.1:2024/threads" \
  -H "Content-Type: application/json" \
  -d '{}' | python -c 'import json,sys; print(json.load(sys.stdin)["thread_id"])')

curl -N -X POST "http://127.0.0.1:2024/threads/$THREAD_ID/runs/stream" \
  -H "Content-Type: application/json" \
  -d '{"assistant_id":"todo_agent","input":{"messages":[{"role":"human","content":"Quelles sont mes tâches en cours ?"}]},"stream_mode":"values"}'

curl -fsS "http://127.0.0.1:2024/threads/$THREAD_ID/state" | python -m json.tool

Les données métier doivent venir de MongoDB via TodoRepositoryPort, pas de la mémoire interne du processus LangGraph.

Visualiser MongoDB avec Compass

Connexion:

Text Only
mongodb://arclith:arclith@127.0.0.1:27017/todo_list_service?authSource=admin

À vérifier:

  • base: todo-list-service;
  • collection: todo;
  • documents: un document par todo, avec _id égal à l'UUID public de l'entité.

Requêter MongoDB en CLI

Installer mongosh si nécessaire, puis:

Bash
mongosh "mongodb://arclith:arclith@127.0.0.1:27017/todo_list_service?authSource=admin"

Dans le shell:

JavaScript
1
2
3
show collections
db.todo.find().pretty()
db.todo.countDocuments({ deleted_at: null })

Ajouter Vault localement

Cette section remplace le fichier local secrets.yaml par un Vault de développement. Le mode dev conserve tout en mémoire, démarre déjà initialisé et non scellé, et utilise un token racine : il est adapté uniquement à ce POC local, jamais à la production.

Démarrer Vault en mode dev

Choisir un token jetable dans le terminal courant. La valeur ci-dessous n'est pas un credential réel et ne doit pas être réutilisée ailleurs :

Bash
1
2
3
4
5
6
7
8
export VAULT_ADDR=http://127.0.0.1:8200
export VAULT_TOKEN=arclith-dev-only

docker run --rm --name arclith-vault \
  -p 127.0.0.1:8200:8200 \
  -e VAULT_DEV_ROOT_TOKEN_ID="$VAULT_TOKEN" \
  -e VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200 \
  -d hashicorp/vault:2.1.0 server -dev

Attendre au maximum 30 secondes que le serveur soit disponible :

Bash
1
2
3
4
5
6
7
8
for attempt in $(seq 1 30); do
  curl -fsS "$VAULT_ADDR/v1/sys/health" >/dev/null && break
  if [ "$attempt" -eq 30 ]; then
    docker logs arclith-vault
    exit 1
  fi
  sleep 1
done

Le binding 127.0.0.1 évite d'exposer le port dev sur le réseau local. Le token est transmis au container uniquement pour ce lancement jetable ; ne pas le mettre dans Git, dans une image ou dans un environnement partagé.

Initialiser KV v2 et les données du POC

Télécharger le script de seed vérifié, le placer dans le projet Todo sous scripts/seed-vault.sh, puis l'exécuter :

Bash
1
2
3
4
5
6
mkdir -p scripts
export ARCLITH_REF="${ARCLITH_REF:-main}"
curl -fsSLo scripts/seed-vault.sh \
  "https://raw.githubusercontent.com/karned-rekipe/arclith/${ARCLITH_REF}/docs/tutorials/todo-list/scripts/seed-vault.sh"
chmod +x scripts/seed-vault.sh
./scripts/seed-vault.sh

ARCLITH_REF peut désigner le tag ou le SHA correspondant à la version de cette documentation afin de conserver un POC reproductible. Sa valeur par défaut, main, convient pour suivre la version de développement courante.

Le script active le mount kv en KV v2 s'il n'existe pas. S'il existe déjà dans une autre version, le script échoue explicitement au lieu de poursuivre avec des routes incompatibles. Il écrit ensuite deux entrées de démonstration :

Chemin Consommateur Forme attendue
kv/apps/todo-list/mongodb VaultSecretAdapter champ unique value
kv/rekipe/tenants/client-a VaultTenantResolver champs uri et db_name

Il peut être rejoué : les mêmes valeurs sont réécrites dans une nouvelle version KV. Pour un POC strictement hors ligne, copier le contenu affiché ci-dessous au lieu de le télécharger :

Bash
#!/usr/bin/env bash

set -euo pipefail

: "${VAULT_TOKEN:?Définir VAULT_TOKEN avec le token jetable du serveur Vault dev}"

VAULT_ADDR="${VAULT_ADDR:-http://127.0.0.1:8200}"
VAULT_MOUNT="${VAULT_MOUNT:-kv}"

vault_request() {
  curl --fail --silent --show-error \
    --connect-timeout 3 \
    --max-time 10 \
    --header "X-Vault-Token: ${VAULT_TOKEN}" \
    "$@"
}

if mount_details=$(
  vault_request "${VAULT_ADDR}/v1/sys/mounts/${VAULT_MOUNT}/tune" 2>/dev/null
); then
  if ! grep -Eq '"version"[[:space:]]*:[[:space:]]*"2"' <<<"${mount_details}"; then
    printf 'Erreur : le mount %s existe mais n\x27est pas un KV v2.\n' "${VAULT_MOUNT}" >&2
    exit 1
  fi
else
  vault_request \
    --request POST \
    --header "Content-Type: application/json" \
    --data '{"type":"kv","options":{"version":"2"}}' \
    "${VAULT_ADDR}/v1/sys/mounts/${VAULT_MOUNT}" \
    >/dev/null
fi

vault_request \
  --request POST \
  --header "Content-Type: application/json" \
  --data '{"data":{"value":"mongodb://arclith:arclith@127.0.0.1:27017/todo_list_service?authSource=admin"}}' \
  "${VAULT_ADDR}/v1/${VAULT_MOUNT}/data/apps/todo-list/mongodb" \
  >/dev/null

vault_request \
  --request POST \
  --header "Content-Type: application/json" \
  --data '{"data":{"uri":"mongodb://arclith:arclith@127.0.0.1:27017/todo_client_a?authSource=admin","db_name":"todo_client_a"}}' \
  "${VAULT_ADDR}/v1/${VAULT_MOUNT}/data/rekipe/tenants/client-a" \
  >/dev/null

vault_request \
  "${VAULT_ADDR}/v1/${VAULT_MOUNT}/data/apps/todo-list/mongodb" \
  >/dev/null
vault_request \
  "${VAULT_ADDR}/v1/${VAULT_MOUNT}/data/rekipe/tenants/client-a" \
  >/dev/null

printf '%s\n' \
  "Vault KV v2 prêt :" \
  "- ${VAULT_MOUNT}/apps/todo-list/mongodb" \
  "- ${VAULT_MOUNT}/rekipe/tenants/client-a"

Résoudre le secret applicatif

Installer l'extra Vault, puis remplacer le resolver YAML de la section MongoDB par la capability CLI secrets/vault :

Bash
1
2
3
4
5
6
7
8
9
uv add "arclith[vault]"
arclith-cli add-adapter \
  --capability secrets \
  --adapter vault \
  --param field_path=adapters.mongodb.uri \
  --param secret_key=apps/todo-list/mongodb \
  --param addr=http://127.0.0.1:8200 \
  --param mount=kv \
  --yes

Le fichier versionné config/secrets.yaml ne contient que le resolver et le mapping :

YAML
1
2
3
4
5
6
resolver: vault
vault:
  addr: http://127.0.0.1:8200
  mount: kv
mappings:
  adapters.mongodb.uri: apps/todo-list/mongodb

Prouver que le service Arclith charge le secret sans afficher sa valeur :

Bash
1
2
3
4
5
6
7
8
uv run python - <<'PY'
from arclith import Arclith

runtime = Arclith("config")
mongodb = runtime.config.adapters.mongodb
assert mongodb is not None and mongodb.uri
print("Secret applicatif MongoDB résolu par Vault")
PY

Lancer ensuite le service avec la même configuration ; son bootstrap effectue la même résolution :

Bash
MODE=api uv run python main.py

Dans un autre terminal, curl -fsS "http://127.0.0.1:8120/v1/todos/?page=1&per_page=20" doit répondre sans erreur de secret. MongoDB doit toujours être démarré comme indiqué au début de cette page.

VaultSecretAdapter intervient une fois au chargement de la configuration. Le chemin mappé doit exposer un champ value; il convient aux secrets partagés par l'instance du service.

Résoudre des coordonnées par tenant

Cette sous-partie requiert elle aussi l'extra arclith[vault]. L'installer si la sous-partie précédente n'a pas été suivie, puis configurer séparément tenant/vault avec le catalogue CLI courant :

Bash
uv add "arclith[vault]"
arclith-cli add-adapter \
  --capability tenant \
  --adapter vault \
  --param addr=http://127.0.0.1:8200 \
  --param mount=kv \
  --param path_prefix=rekipe/tenants \
  --param tenant_claim=tenant_id \
  --param tenant_uri_ttl=300 \
  --yes

Dans une API multitenant complète, le pipeline d'authentification extrait tenant_id d'un JWT signé et appelle le resolver. Pour vérifier ici le contrat Vault sans ajouter de logique métier ni simuler un JWT, appeler directement le port avec l'identifiant de démonstration :

Bash
uv run python - <<'PY'
import asyncio
import os

from arclith.adapters.outbound.memory.cache_adapter import MemoryCacheAdapter
from arclith.adapters.outbound.vault.tenant_adapter import VaultTenantResolver


async def main() -> None:
    resolver = VaultTenantResolver(
        "mongodb",
        addr=os.environ["VAULT_ADDR"],
        mount="kv",
        path_prefix="rekipe/tenants",
        cache=MemoryCacheAdapter(),
    )
    context = await resolver.resolve("client-a")
    coords = context.get("mongodb")
    assert coords is not None and coords.require("uri")
    print("Tenant résolu :", coords.require("db_name"))


asyncio.run(main())
PY

Le résultat attendu est Tenant résolu : todo_client_a. Contrairement au resolver de secrets applicatifs, VaultTenantResolver lit tous les champs du chemin tenant, les place dans une tranche AdapterTenantCoords et peut les mettre en cache. L'activation réelle du repository multitenant requiert aussi multitenant=true et le pipeline JWT décrit dans la capability tenant.

Réinitialiser et diagnostiquer

Bash
1
2
3
4
5
# Supprime le container et toutes les données en mémoire du POC.
docker stop arclith-vault

# Retire le token jetable du terminal courant.
unset VAULT_TOKEN VAULT_ADDR

Erreurs fréquentes :

  • permission denied : vérifier que VAULT_TOKEN correspond au lancement dev courant ;
  • no handler for route : relancer scripts/seed-vault.sh pour activer le mount KV v2 ;
  • secret applicatif non résolu : vérifier le champ value, le mapping et l'extra arclith[vault] ;
  • tenant introuvable : vérifier le préfixe rekipe/tenants et l'identifiant client-a ;
  • container redémarré : le stockage dev est en mémoire, il faut donc rejouer le seed.

Références : serveur Vault en mode dev et configuration d'un mount KV v2.

Ajouter Keycloak localement

Ce POC ajoute une identité locale au service Todo sans dépendre du fournisseur de production. Le realm fourni crée un client public Swagger avec PKCE S256, un rôle licence, deux utilisateurs de test et un claim tenant_id. Tout est jetable : arrêter le container supprime le realm, les comptes et les clés de signature.

Télécharger le realm de développement

Depuis la racine du projet Todo, télécharger la même révision que celle de la documentation. Un tag ou un SHA peut remplacer main pour figer le POC :

Bash
1
2
3
4
5
mkdir -p local/keycloak
export ARCLITH_REF="${ARCLITH_REF:-main}"
curl -fsSLo local/keycloak/arclith-local-realm.json \
  "https://raw.githubusercontent.com/karned-rekipe/arclith/${ARCLITH_REF}/docs/tutorials/todo-list/keycloak/arclith-local-realm.json"
jq empty local/keycloak/arclith-local-realm.json

Le fichier arclith-local-realm.json contient exclusivement des données de démonstration :

Élément Valeur locale But
realm arclith-local issuer isolé de la production
client public todo-swagger Authorization Code + PKCE S256
audience todo-api validation du token par JWTDecoder
rôle realm rekipe:licensed validation par RoleLicenseValidator
alice mot de passe arclith-dev-only rôle licence et tenant_id=client-a
bob mot de passe arclith-dev-only utilisateur valide sans licence

Ces mots de passe sont publics, limités au container local et ne doivent être réutilisés nulle part.

Démarrer Keycloak avec l'import

La commande lie Keycloak à l'interface loopback et monte le realm en lecture seule dans le répertoire d'import officiel :

Bash
1
2
3
4
5
6
7
8
9
realm_file="$(pwd)/local/keycloak/arclith-local-realm.json"

docker run --rm --name arclith-keycloak \
  -p 127.0.0.1:8080:8080 \
  -e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
  -e KC_BOOTSTRAP_ADMIN_PASSWORD=arclith-dev-only \
  -v "${realm_file}:/opt/keycloak/data/import/arclith-local-realm.json:ro" \
  -d quay.io/keycloak/keycloak:26.7.3 \
  start-dev --import-realm

Le compte admin sert uniquement à inspecter ce serveur jetable. Attendre que le document de découverte du realm soit disponible :

Bash
for attempt in $(seq 1 60); do
  curl -fsS \
    http://127.0.0.1:8080/realms/arclith-local/.well-known/openid-configuration \
    >/dev/null && break
  if [ "$attempt" -eq 60 ]; then
    docker logs arclith-keycloak
    exit 1
  fi
  sleep 1
done

L'endpoint JWKS annoncé par ce document, et utilisé par Arclith, doit être :

Text Only
http://127.0.0.1:8080/realms/arclith-local/protocol/openid-connect/certs

Configurer l'authentification et la licence

Installer l'extra d'authentification, puis utiliser les capabilities CLI courantes :

Bash
uv add "arclith[auth]"
arclith-cli add-adapter \
  --capability auth \
  --adapter keycloak \
  --param url=http://127.0.0.1:8080 \
  --param realm=arclith-local \
  --param audience=todo-api \
  --param client_id=todo-swagger \
  --yes

arclith-cli add-adapter \
  --capability license \
  --adapter role \
  --param role=rekipe:licensed \
  --yes

Le CLI génère les deux fichiers inbound attendus :

YAML
1
2
3
4
5
# config/adapters/inbound/keycloak.yaml
url: "http://127.0.0.1:8080"
realm: "arclith-local"
audience: todo-api
client_id: todo-swagger
YAML
# config/adapters/inbound/license.yaml
role: "rekipe:licensed"

Dans src/todo_list_service/adapters/inbound/fastapi/register.py, protéger toutes les routes du router Todo avec la dépendance construite par Arclith :

Python
1
2
3
4
5
6
7
8
9
from fastapi import Depends, FastAPI

# ... imports et construction des handlers inchangés ...

require_auth = arclith.auth_dependency()
app.include_router(
    build_todo_router(handlers),
    dependencies=[Depends(require_auth)],
)

Cette dépendance vérifie la signature RS256 via le JWKS local, l'audience todo-api, l'expiration et le rôle rekipe:licensed. Elle produit aussi le schéma OAuth2 utilisé par Swagger UI.

Relier le claim au POC Vault

Si la section Vault précédente a été suivie, son entrée kv/rekipe/tenants/client-a correspond déjà au claim signé de alice. Configurer le resolver avec le même nom de claim :

Bash
1
2
3
4
5
6
7
8
9
arclith-cli add-adapter \
  --capability tenant \
  --adapter vault \
  --param addr=http://127.0.0.1:8200 \
  --param mount=kv \
  --param path_prefix=rekipe/tenants \
  --param tenant_claim=tenant_id \
  --param tenant_uri_ttl=300 \
  --yes

Le smoke ci-dessous vérifie que le token porte bien tenant_id=client-a. Pour un repository multitenant: true, remplacer la dépendance auth seule par le pipeline complet make_inject_tenant_uri décrit dans la documentation multitenant : le resolver utilisera alors ce claim signé pour lire l'entrée Vault, sans accepter de tenant libre depuis un header ou une URL.

Tester Swagger et la route protégée

Démarrer l'API, puis ouvrir impérativement l'URL déclarée dans le client :

Text Only
http://127.0.0.1:8120/docs

Cliquer sur Authorize, conserver les scopes openid profile, puis se connecter avec alice et le mot de passe local. Swagger exécute le flux Authorization Code avec PKCE et injecte le Bearer token pour appeler GET /v1/todos/. bob peut s'authentifier mais reçoit 403, car son token ne porte pas le rôle licence.

Pour un smoke CLI déterministe, le realm active aussi le Direct Access Grant sur ce seul client de développement. Ce flux par mot de passe ne remplace pas PKCE et ne doit pas être repris en production :

Bash
TOKEN="$(curl -fsS \
  -X POST \
  http://127.0.0.1:8080/realms/arclith-local/protocol/openid-connect/token \
  -d grant_type=password \
  -d client_id=todo-swagger \
  -d username=alice \
  -d password=arclith-dev-only \
  -d 'scope=openid profile' \
  | jq -er .access_token)"

curl -fsS \
  -H "Authorization: Bearer ${TOKEN}" \
  "http://127.0.0.1:8120/v1/todos/?page=1&per_page=20"
unset TOKEN

Le script fourni vérifie sans afficher les tokens le document de découverte, le JWKS, le rejet d'un flux Authorization Code sans challenge PKCE, l'audience, le rôle et le claim tenant. Avec TODO_API_URL, il vérifie aussi les statuts de la route réelle :

Bash
1
2
3
4
5
6
7
8
9
mkdir -p scripts
export ARCLITH_REF="${ARCLITH_REF:-main}"
curl -fsSLo scripts/smoke-keycloak.sh \
  "https://raw.githubusercontent.com/karned-rekipe/arclith/${ARCLITH_REF}/docs/tutorials/todo-list/scripts/smoke-keycloak.sh"
chmod +x scripts/smoke-keycloak.sh

./scripts/smoke-keycloak.sh
TODO_API_URL="http://127.0.0.1:8120/v1/todos/?page=1&per_page=20" \
  ./scripts/smoke-keycloak.sh

Le second appel attend 401 sans token, 200 avec alice et 403 avec bob.

Tokens courts, rotation et nettoyage

Le realm fixe la durée de vie des access tokens à cinq minutes. Un client interactif renouvelle son token via le flux OIDC ; un traitement asynchrone ne doit jamais persister ni transporter le JWT brut. Keycloak renouvelle ses clés au redémarrage de ce container éphémère : redémarrer aussi l'API du POC pour vider son cache JWKS. En production, conserver les anciennes clés jusqu'à l'expiration des tokens déjà émis et coordonner la rotation avec le TTL du cache JWKS.

Bash
1
2
3
docker stop arclith-keycloak
unset TOKEN KEYCLOAK_URL KEYCLOAK_REALM KEYCLOAK_CLIENT_ID KEYCLOAK_PASSWORD
unset PYTHON_BIN SWAGGER_REDIRECT_URI

Erreurs fréquentes :

  • invalid redirect_uri : ouvrir Swagger via http://127.0.0.1:8120, pas via localhost ;
  • Invalid audience : conserver audience=todo-api et vérifier le mapper du client importé ;
  • Clé JWKS introuvable après relance : redémarrer l'API locale pour vider son cache en mémoire ;
  • 403 Licence invalide ou absente avec alice : vérifier license.role=rekipe:licensed et le realm effectivement importé ;
  • claim tenant absent : vérifier le mapper tenant-id et l'attribut de l'utilisateur dans le realm.

Références : container Keycloak et import au démarrage, endpoints OIDC Keycloak et authentification Arclith.

Ajouter OpenTelemetry localement

LangSmith observe surtout les runs LLM et LangGraph. OpenTelemetry sert à tracer le service comme un microservice classique: requêtes HTTP, spans, latences et erreurs techniques.

Démarrer Jaeger

Pour ce POC, l'image Jaeger all-in-one regroupe le Collector OTLP, le stockage temporaire et l'UI. Elle ne crée ni compte distant ni volume Docker : arrêter le container efface les traces du labo.

Bash
1
2
3
4
5
6
7
docker run --rm --name arclith-jaeger \
  -p 16686:16686 \
  -p 4317:4317 \
  -p 4318:4318 \
  -p 5778:5778 \
  -p 9411:9411 \
  -d cr.jaegertracing.io/jaegertracing/jaeger:2.20.0

Attendre que l'API de requête Jaeger réponde, puis ouvrir son UI :

Bash
1
2
3
4
5
6
7
8
for attempt in $(seq 1 30); do
  curl -fsS http://127.0.0.1:16686/api/services >/dev/null && break
  if [ "$attempt" -eq 30 ]; then
    docker logs arclith-jaeger
    exit 1
  fi
  sleep 1
done
Text Only
http://127.0.0.1:16686

Les ports utiles ici sont 4318 pour OTLP HTTP et 16686 pour l'UI. Le runtime Todo s'exécute sur l'hôte et envoie donc ses traces à http://127.0.0.1:4318. Arclith ajoute automatiquement le chemin OTLP /v1/traces pour le protocole http/protobuf.

Configurer le projet Todo

Depuis la racine du projet généré, ajouter l'adapter avec les paramètres du catalogue courant :

Bash
1
2
3
4
5
6
7
8
9
uv add "arclith[opentelemetry]"
arclith-cli add-adapter \
  --capability observability \
  --adapter opentelemetry \
  --profile development \
  --param service_name=todo-list-service \
  --param endpoint=http://127.0.0.1:4318 \
  --param metrics=false \
  --yes

Le profil development active les traces à 100 %. metrics=false évite d'envoyer des métriques à Jaeger, qui sert ici de backend de traces. Le CLI active opentelemetry dans config/adapters/adapters.yaml et génère notamment :

YAML
# config/adapters/outbound/opentelemetry.yaml
service:
  name: "todo-list-service"
export:
  protocol: "http/protobuf"
  endpoint: "http://127.0.0.1:4318"
signals:
  traces:
    enabled: true
  metrics:
    enabled: false

Pour identifier ce lancement dans les ressources OpenTelemetry, exporter la variable avant de démarrer l'API :

Bash
export OTEL_RESOURCE_ATTRIBUTES=deployment.environment.name=local
MODE=api uv run python main.py

Produire et retrouver une trace

Dans un second terminal, appeler une route métier. /health, /ready et /metrics sont exclus de l'instrumentation par défaut et ne conviennent donc pas à ce smoke :

Bash
curl -i -fsS "http://127.0.0.1:8120/v1/todos/?page=1&per_page=20"

Le runtime exporte par lot. Après quelques secondes, vérifier sans dépendre de l'UI que Jaeger a indexé le service et au moins une trace :

Bash
1
2
3
4
curl -fsS http://127.0.0.1:16686/api/services | uv run python -m json.tool
curl -fsS \
  "http://127.0.0.1:16686/api/traces?service=todo-list-service&limit=20" \
  | uv run python -c 'import json, sys; print(len(json.load(sys.stdin)["data"]))'

La première commande doit contenir todo-list-service; la seconde doit afficher un entier supérieur ou égal à 1. Dans l'UI Jaeger, sélectionner ce service, cliquer sur Find Traces, puis ouvrir la trace dont l'opération est GET /v1/todos/.

Arrêter, relancer ou réinitialiser

Bash
1
2
3
4
# Arrêt propre ; --rm supprime ensuite le container et ses traces en mémoire.
docker stop arclith-jaeger

# Relance à neuf : réexécuter la commande docker run de cette section.

Erreurs fréquentes :

  • port is already allocated : arrêter le processus ou le container qui utilise déjà 16686, 4317 ou 4318, puis relancer Jaeger ;
  • connection refused sur 4318 : vérifier docker ps --filter name=arclith-jaeger et docker logs arclith-jaeger ;
  • service absent dans Jaeger : appeler une route métier non exclue, attendre le prochain export par lot et vérifier signals.traces.enabled, le sampling et l'endpoint ;
  • export 404 sur /v1/metrics : conserver metrics=false pour ce POC Jaeger, ou utiliser un OpenTelemetry Collector configuré avec un backend de métriques ;
  • projet lui-même dans Docker : 127.0.0.1 désigne alors son propre container ; placer le service et Jaeger sur le même réseau Docker et utiliser le nom du service Jaeger à la place.

Le smoke Docker est volontairement manuel : la construction de la documentation ne suppose pas un daemon Docker disponible en CI. La commande a été vérifiée avec l'image épinglée, l'export OTLP HTTP, l'API Jaeger et une trace FastAPI portant le service todo-list-service.

LangSmith ou OpenTelemetry ?

Les deux sont complémentaires:

Besoin Outil
comprendre les messages envoyés au modèle LangSmith
voir pourquoi l'agent repose une question LangSmith Studio
mesurer les appels HTTP et les erreurs techniques OpenTelemetry
corréler plusieurs microservices OpenTelemetry

Pour ce tutoriel, LangSmith aide à apprendre l'agent. OpenTelemetry prépare la suite production. Pour travailler sans internet, garder LangSmith désactivé et suivre Validation IA locale et hors ligne.

Tout valider

Bash
uv sync
uv run python -m pytest

Résultat attendu:

Text Only
26 passed

Étape précédente: ajouter un agent.