Imago/doc/PROMPT_PHASE2_claude-opus.md
Bruno Charest cc99fea20a
Some checks failed
CI / Lint & Format (push) Has been cancelled
CI / Tests (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / Docker Build (push) Has been cancelled
Add comprehensive test suite for image processing and related services
- Implement tests for database generator to ensure proper session handling.
- Create tests for EXIF extraction and conversion functions.
- Add tests for image-related endpoints, ensuring proper data retrieval and isolation between clients.
- Develop tests for OCR functionality, including language detection and text extraction.
- Introduce tests for the image processing pipeline, covering success and failure scenarios.
- Validate rate limiting functionality and ensure independent counters for different clients.
- Implement scraper tests to verify HTML content fetching and error handling.
- Add unit tests for various services, including storage and filename generation.
- Establish worker entry point for ARQ to handle background image processing tasks.
2026-02-24 11:22:10 -05:00

766 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Prompt Claude Code — Phase 2 : Robustesse et scalabilité
# Modèle : claude-opus-4-6
# Usage : claude --model claude-opus-4-6 -p "$(cat PROMPT_PHASE2_claude-opus.md)"
# ou coller directement dans une session Claude Code interactive
---
<role>
Tu es un ingénieur backend senior Python spécialisé en systèmes distribués, FastAPI async et infrastructure de production. Tu travailles sur le projet Imago, un hub centralisé de gestion d'images servant plusieurs applications clientes simultanément.
La Phase 1 (authentification, isolation multi-tenants, rate limiting, tests d'intégration) est entièrement terminée et validée. Tu prends le relais pour rendre ce hub robuste, scalable et observable en production.
</role>
<project_context>
## État du projet après Phase 1
### Stack en place (Phase 1 complète)
- **FastAPI + Uvicorn** — framework web async
- **SQLAlchemy async + Alembic** — ORM + migrations
- **Pydantic v2 + pydantic-settings** — validation + config
- **python-jose + passlib** — JWT + hachage API Keys
- **slowapi** — rate limiting par client
- **Pillow + piexif** — traitement images + EXIF
- **pytesseract** — OCR Tesseract
- **Anthropic Claude via httpx** — Vision AI
- **aiofiles** — I/O async
- **pytest-asyncio + httpx** — tests d'intégration
### Structure complète du projet (après Phase 1)
```
imago/
├── app/
│ ├── main.py # Application FastAPI, lifespan, middlewares
│ ├── config.py # Settings depuis .env (inclut ADMIN_API_KEY, JWT_SECRET)
│ ├── database.py # Engine SQLAlchemy async + session factory
│ ├── models/
│ │ ├── __init__.py
│ │ ├── image.py # Image (avec client_id FK, EXIF, OCR, AI, statut)
│ │ └── client.py # APIClient (id, name, api_key_hash, scopes, plan, quotas)
│ ├── schemas/
│ │ └── __init__.py # Schémas Pydantic complets
│ ├── dependencies/
│ │ ├── __init__.py
│ │ └── auth.py # verify_api_key, require_scope, get_current_client
│ ├── middleware/
│ │ └── rate_limit.py # Rate limiting par plan client (slowapi)
│ ├── routers/
│ │ ├── __init__.py
│ │ ├── auth.py # CRUD clients, rotation de clé
│ │ ├── images.py # Endpoints images (filtrés par client_id)
│ │ └── ai.py # Endpoints AI (résumé URL, tâches)
│ └── services/
│ ├── __init__.py
│ ├── storage.py # Stockage fichiers (uploads/{client_id}/{filename})
│ ├── exif_service.py # Extraction EXIF
│ ├── ocr_service.py # OCR Tesseract
│ ├── ai_vision.py # Vision AI Claude + summarize_url + draft_task
│ ├── scraper.py # Scraping web BeautifulSoup
│ └── pipeline.py # Pipeline EXIF → OCR → AI (via BackgroundTasks)
├── tests/
│ ├── conftest.py # Fixtures : test_db, client_a/b, auth_headers
│ ├── test_services.py # Tests unitaires services
│ ├── test_auth.py # Tests authentification
│ ├── test_isolation.py # Tests isolation multi-tenants
│ └── test_rate_limit.py # Tests rate limiting
├── alembic/
│ └── versions/ # Migrations : table images + table api_clients
├── requirements.txt
├── .env
├── Dockerfile
└── docker-compose.yml
```
### Ce qui reste problématique (cible de la Phase 2)
**Pipeline non persistant** : les `BackgroundTasks` FastAPI perdent toutes les tâches en file si le serveur redémarre. Aucun retry en cas d'échec de l'API Anthropic. Aucune priorité entre clients `free` et `premium`. Concurrence non contrôlée.
**Stockage non abstrait** : fichiers sur disque local uniquement, couplé aux chemins absolus. Impossible de scaler, aucune réplication, URLs statiques exposent les chemins réels.
**Observabilité absente** : tous les logs sont des `print()`. Aucune métrique applicative. Health check basique qui retourne toujours "ok". Impossible de monitorer en production.
**CI/CD inexistant** : pas d'automatisation des tests, pas de pipeline de déploiement, pas de contrôles qualité automatisés.
</project_context>
<mission>
## Mission : implémenter la Phase 2 — Robustesse et scalabilité
Tu dois réaliser les 5 livrables suivants dans l'ordre indiqué. Chaque livrable doit être **complet, testé et fonctionnel** avant de passer au suivant.
---
### Livrable 2.1 — Migration BackgroundTasks → ARQ + Redis (priorité HAUTE)
**Objectif** : remplacer le pipeline fragile par un système de file de tâches persistant avec retry automatique, priorités par plan client et concurrence contrôlée.
**Nouvelles dépendances à ajouter dans `requirements.txt`** :
```
arq==0.25.0
redis==5.0.8
```
**Ce qui doit être créé ou modifié** :
1. `app/config.py` — Ajouter les variables Redis et worker :
```python
REDIS_URL: str = "redis://localhost:6379"
WORKER_MAX_JOBS: int = 10 # concurrence max globale
WORKER_JOB_TIMEOUT: int = 180 # secondes avant timeout forcé
WORKER_MAX_TRIES: int = 3 # tentatives avant dead-letter
AI_STEP_TIMEOUT: int = 120 # timeout spécifique appel Vision AI
OCR_STEP_TIMEOUT: int = 30 # timeout spécifique OCR
```
2. `app/workers/__init__.py` + `app/workers/image_worker.py` — Worker ARQ :
- Fonction `process_image_task(ctx, image_id: int, client_id: str)` qui appelle `pipeline.py`
- `WorkerSettings` avec :
- `functions = [process_image_task]`
- `redis_settings` depuis `settings.REDIS_URL`
- `max_jobs = settings.WORKER_MAX_JOBS`
- `job_timeout = settings.WORKER_JOB_TIMEOUT`
- `retry_jobs = True`
- `max_tries = settings.WORKER_MAX_TRIES`
- `on_job_start`, `on_job_end`, `on_job_abort` — hooks de logging
- Backoff exponentiel : délais `[1, 4, 16]` secondes entre les tentatives
- Dead-letter : après `max_tries` échecs, marquer l'image `status=error` + log `ERROR`
3. `app/workers/redis_client.py` — Client Redis partagé :
- Pool de connexions async (`aioredis` ou `redis.asyncio`)
- Fonction `get_redis_pool()` injectable comme dépendance FastAPI
- Gestion propre de la connexion dans le lifespan de `main.py`
4. `app/services/pipeline.py` — Ajouter publication d'événements Redis :
- À chaque étape complétée, publier sur le channel `pipeline:{image_id}` :
```python
await redis.publish(f"pipeline:{image_id}", json.dumps({
"event": "step.completed",
"step": "exif",
"duration_ms": elapsed,
"data": { ... } # résumé des données extraites
}))
```
- Événements à publier : `pipeline.started`, `step.completed` (×3), `pipeline.done`, `pipeline.error`
5. `app/routers/images.py` — Modifier `POST /images/upload` :
- Remplacer `background_tasks.add_task(process_image_pipeline, ...)` par :
```python
queue_name = "premium" if client.plan == "premium" else "standard"
await arq_pool.enqueue_job(
"process_image_task",
image.id,
str(client.id),
_queue_name=queue_name
)
```
6. `app/main.py` — Modifier le `lifespan` :
- Créer et stocker le pool ARQ au démarrage : `app.state.arq_pool`
- Créer et stocker le pool Redis au démarrage : `app.state.redis`
- Fermer proprement les deux à l'arrêt
7. `worker.py` — Script de démarrage du worker (à la racine du projet) :
```python
# Lancer avec : python worker.py
import asyncio
from arq import run_worker
from app.workers.image_worker import WorkerSettings
if __name__ == "__main__":
asyncio.run(run_worker(WorkerSettings))
```
8. `docker-compose.yml` — Ajouter le service Redis et le worker :
```yaml
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: ["redis_data:/data"]
command: redis-server --appendonly yes # persistance AOF
worker:
build: .
command: python worker.py
depends_on: [backend, redis]
env_file: .env
```
**Contraintes** :
- Le pool ARQ doit être créé une seule fois au démarrage, pas à chaque requête
- Les tâches doivent survivre à un redémarrage du serveur FastAPI (elles restent dans Redis)
- Un job qui dépasse `job_timeout` doit être marqué `error` en base, pas silencieusement ignoré
- Les files `premium` et `standard` doivent être des queues ARQ distinctes (pas juste un label)
---
### Livrable 2.2 — Abstraction StorageBackend + support MinIO/S3 (priorité HAUTE)
**Objectif** : découpler le code du stockage physique pour permettre une migration transparente vers S3/MinIO sans modifier les routers ni les services métier.
**Nouvelle dépendance** :
```
aioboto3==13.0.0
```
**Ce qui doit être créé ou modifié** :
1. `app/services/storage_backend.py` — Interface abstraite + deux implémentations :
**Classe de base** :
```python
class StorageBackend(ABC):
@abstractmethod
async def save(self, content: bytes, path: str, content_type: str) -> str:
"""Sauvegarde un fichier. Retourne le chemin stocké."""
@abstractmethod
async def delete(self, path: str) -> None:
"""Supprime un fichier."""
@abstractmethod
async def get_signed_url(self, path: str, expires_in: int = 900) -> str:
"""Retourne une URL d'accès temporaire signée."""
@abstractmethod
async def exists(self, path: str) -> bool:
"""Vérifie qu'un fichier existe."""
@abstractmethod
async def get_size(self, path: str) -> int:
"""Retourne la taille en bytes."""
```
**`LocalStorage(StorageBackend)`** :
- `save()` : écrit dans `{UPLOAD_DIR}/{path}` avec `aiofiles`, crée les dossiers parents si besoin
- `delete()` : supprime le fichier, ignore si absent
- `get_signed_url()` : génère un token HMAC-SHA256 signé avec `itsdangerous.URLSafeTimedSerializer` incluant `path` + expiration. Retourne `/files/signed/{token}`
- `exists()` : `Path(full_path).exists()`
- `get_size()` : `Path(full_path).stat().st_size`
**`S3Storage(StorageBackend)`** :
- Constructeur : `bucket`, `prefix`, `session` (`aioboto3.Session`)
- `save()` : `put_object` avec le `content_type` correct
- `delete()` : `delete_object`
- `get_signed_url()` : `generate_presigned_url("get_object", ExpiresIn=expires_in)`
- `exists()` : `head_object` → True/False
- `get_size()` : `head_object` → `ContentLength`
- Compatible S3, MinIO et Cloudflare R2 (même API boto3)
2. `app/services/storage.py` — Refactoring complet :
- Supprimer le code de stockage direct
- La fonction `save_upload(file, client_id)` utilise maintenant le backend injecté
- La fonction `delete_files(paths)` utilise le backend
- Exposer `get_storage_backend() -> StorageBackend` : factory qui lit `settings.STORAGE_BACKEND` et instancie le bon backend
3. `app/config.py` — Ajouter :
```python
STORAGE_BACKEND: str = "local" # "local" | "s3"
S3_BUCKET: str = ""
S3_REGION: str = "us-east-1"
S3_ENDPOINT_URL: str = "" # vide = AWS, sinon MinIO/R2
S3_ACCESS_KEY: str = ""
S3_SECRET_KEY: str = ""
S3_PREFIX: str = "imago" # préfixe dans le bucket
SIGNED_URL_SECRET: str = "changez-moi" # secret HMAC pour LocalStorage
```
4. `app/routers/images.py` — Ajouter les endpoints d'URLs signées :
- `GET /images/{id}/download-url?expires_in=900` → `{ "url": "...", "expires_in": 900 }`
- `GET /images/{id}/thumbnail-url?expires_in=900` → `{ "url": "...", "expires_in": 900 }`
- Vérifier que l'image appartient au client avant de générer l'URL
- Appliquer `require_scope("images:read")`
5. `app/routers/files.py` — Nouveau router pour le stockage local signé :
- `GET /files/signed/{token}` — valide le token HMAC, retourne le fichier via `FileResponse`
- Lever `HTTP 403` si token invalide ou expiré
- Lever `HTTP 410 Gone` si le fichier n'existe plus sur disque
- Ce router n'est monté que si `STORAGE_BACKEND == "local"`
6. `app/main.py` — Supprimer les `StaticFiles` mounts `/static/*` et les remplacer par le router `files.py`
**Contraintes** :
- Le reste du code (routers, pipeline) ne doit **jamais** importer `LocalStorage` ou `S3Storage` directement — uniquement `StorageBackend` et la factory
- Les tokens HMAC doivent avoir une durée de vie stricte (vérification côté serveur à la lecture)
- Le backend S3 doit fonctionner avec MinIO en local (via `S3_ENDPOINT_URL`)
---
### Livrable 2.3 — URLs signées : quota et tracking de l'espace disque (priorité HAUTE)
**Objectif** : enrichir le système d'URLs signées avec le suivi de la consommation de stockage par client pour permettre l'application des quotas.
**Ce qui doit être créé ou modifié** :
1. `app/models/client.py` — Ajouter la méthode de calcul de quota :
```python
async def get_storage_used_bytes(self, db: AsyncSession) -> int:
"""Somme de file_size pour toutes les images actives du client."""
result = await db.execute(
select(func.sum(Image.file_size))
.where(Image.client_id == self.id)
)
return result.scalar_one() or 0
```
2. `app/services/storage.py` — Dans `save_upload()` :
- Avant de sauvegarder, vérifier que `storage_used + file_size <= quota_storage_mb * 1024 * 1024`
- Lever `HTTP 413` avec message clair si quota dépassé : `"Quota de stockage atteint (X MB / Y MB)"`
- Vérifier aussi `count(images) < quota_images` avant l'upload
3. `app/routers/images.py` — Ajouter dans `GET /images` la consommation actuelle dans la réponse :
```json
{
"total": 42,
"storage_used_mb": 128.5,
"storage_quota_mb": 500,
"quota_pct": 25.7
}
```
4. `app/schemas/__init__.py` — Mettre à jour `PaginatedImages` avec les champs de quota
**Contraintes** :
- La vérification de quota doit être atomique (SELECT + INSERT dans la même transaction si possible)
- Ne pas recalculer la somme à chaque requête GET — utiliser une colonne `storage_used_bytes` dénormalisée sur `APIClient`, mise à jour lors des uploads et suppressions
---
### Livrable 2.4 — Logs structurés + métriques Prometheus (priorité MOYENNE)
**Objectif** : remplacer tous les `print()` par des logs JSON structurés et exposer des métriques Prometheus exploitables pour le monitoring de production.
**Nouvelles dépendances** :
```
structlog==24.4.0
prometheus-fastapi-instrumentator==0.14.0
```
**Ce qui doit être créé ou modifié** :
1. `app/logging_config.py` — Configuration structlog centralisée :
```python
import structlog
def configure_logging(debug: bool = False):
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.dev.ConsoleRenderer() if debug
else structlog.processors.JSONRenderer(),
],
wrapper_class=structlog.make_filtering_bound_logger(
logging.DEBUG if debug else logging.INFO
),
)
```
2. Remplacer **chaque `print()`** dans le projet par le logger structlog approprié :
Dans `app/services/pipeline.py` :
```python
log = structlog.get_logger()
# Remplacer print(f"[Pipeline:{image_id}] Étape 1/3 — EXIF") par :
log.info("pipeline.step.started", image_id=image_id, step="exif")
# Remplacer print(f"[Pipeline:{image_id}] EXIF OK") par :
log.info("pipeline.step.completed", image_id=image_id, step="exif",
duration_ms=elapsed, camera=image.exif_make)
# En cas d'erreur :
log.error("pipeline.step.failed", image_id=image_id, step="exif",
error=str(e), exc_info=True)
```
Dans `app/services/storage.py`, `exif_service.py`, `ocr_service.py`, `ai_vision.py` : même principe.
Dans `app/workers/image_worker.py` : logs sur `on_job_start`, `on_job_end`, `on_job_abort`.
3. `app/middleware/logging_middleware.py` — Middleware HTTP de logs :
- Logger chaque requête : `method`, `path`, `status_code`, `duration_ms`, `client_id` (si authentifié)
- Exclure `/health` et `/metrics` pour éviter le bruit
- Format : `log.info("http.request", method="POST", path="/api/v1/images/upload", status=201, duration_ms=45, client_id="abc")`
4. `app/main.py` — Intégration Prometheus :
```python
from prometheus_fastapi_instrumentator import Instrumentator
Instrumentator(
should_group_status_codes=True,
should_ignore_untemplated=True,
excluded_handlers=["/health", "/metrics"],
).instrument(app).expose(app, endpoint="/metrics", include_in_schema=False)
```
5. `app/metrics.py` — Métriques custom applicatives :
```python
from prometheus_client import Counter, Histogram, Gauge
images_uploaded = Counter(
"hub_images_uploaded_total",
"Total uploads par client et par plan",
["client_id", "plan"]
)
pipeline_duration = Histogram(
"hub_pipeline_duration_seconds",
"Durée du pipeline par étape",
["step"],
buckets=[0.1, 0.5, 1, 2, 5, 10, 30, 60, 120]
)
ai_tokens_consumed = Counter(
"hub_ai_tokens_consumed_total",
"Tokens AI consommés par client",
["client_id", "token_type"] # prompt / output
)
pipeline_errors = Counter(
"hub_pipeline_errors_total",
"Erreurs pipeline par étape",
["step"]
)
storage_used_bytes = Gauge(
"hub_storage_used_bytes",
"Espace disque utilisé par client",
["client_id"]
)
arq_queue_size = Gauge(
"hub_arq_queue_size",
"Tâches en attente dans ARQ",
["queue"]
)
```
- Incrémenter ces métriques aux bons endroits (upload, pipeline, suppression)
6. `app/main.py` — Améliorer `/health/detailed` :
```python
@app.get("/health/detailed")
async def health_detailed(db: AsyncSession = Depends(get_db)):
checks = {}
t0 = time()
# Base de données
try:
await db.execute(text("SELECT 1"))
checks["database"] = {"status": "ok", "latency_ms": round((time()-t0)*1000)}
except Exception as e:
checks["database"] = {"status": "error", "detail": str(e)}
# Redis
try:
await app.state.redis.ping()
checks["redis"] = {"status": "ok"}
except Exception as e:
checks["redis"] = {"status": "error", "detail": str(e)}
# ARQ queue
try:
queue_info = await app.state.arq_pool.queued_jobs()
checks["queue"] = {"status": "ok", "pending_jobs": len(queue_info)}
except Exception as e:
checks["queue"] = {"status": "error", "detail": str(e)}
# Tesseract
try:
pytesseract.get_tesseract_version()
checks["tesseract"] = {"status": "ok"}
except Exception as e:
checks["tesseract"] = {"status": "error", "detail": str(e)}
# API Anthropic (vérification clé uniquement, sans appel facturable)
checks["anthropic"] = {
"status": "ok" if settings.ANTHROPIC_API_KEY else "error",
"configured": bool(settings.ANTHROPIC_API_KEY)
}
# Espace disque
try:
usage = shutil.disk_usage(settings.UPLOAD_DIR)
pct = round(usage.used / usage.total * 100, 1)
checks["disk"] = {
"status": "warning" if pct > 85 else "ok",
"used_pct": pct,
"free_gb": round(usage.free / 1e9, 2)
}
except Exception as e:
checks["disk"] = {"status": "error", "detail": str(e)}
overall = "healthy" if all(
c.get("status") in ("ok", "warning") for c in checks.values()
) else "degraded"
return {
"status": overall,
"version": settings.APP_VERSION,
"checks": checks,
"checked_at": datetime.utcnow().isoformat()
}
```
**Contraintes** :
- Zéro `print()` restant dans tout le projet après ce livrable
- Les logs de requêtes HTTP ne doivent pas logger les valeurs des headers `Authorization`
- Les métriques Prometheus ne doivent pas exposer de données sensibles (pas de `client_id` en clair dans les labels si trop nombreux — utiliser le `plan` à la place)
---
### Livrable 2.5 — CI/CD complet avec GitHub Actions (priorité HAUTE)
**Objectif** : automatiser entièrement la qualité du code, les tests, le build Docker et le déploiement.
**Nouvelles dépendances dev** — créer `requirements-dev.txt` :
```
pytest==8.3.0
pytest-asyncio==0.24.0
pytest-cov==5.0.0
httpx==0.27.2
ruff==0.6.0
black==24.8.0
mypy==1.11.0
bandit==1.7.10
pre-commit==3.8.0
faker==27.0.0 # génération de données de test
```
**Ce qui doit être créé** :
1. `.github/workflows/ci.yml` — Pipeline CI complet :
**Job `quality`** — sur chaque push et PR :
```yaml
- name: Lint (ruff)
run: ruff check . --output-format=github
- name: Format check (black)
run: black --check . --line-length 100
- name: Type check (mypy)
run: mypy app/ --strict --ignore-missing-imports
- name: Security scan (bandit)
run: bandit -r app/ -ll -x tests/
```
**Job `tests`** — sur chaque push et PR :
```yaml
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
steps:
- run: pytest tests/ -v --cov=app --cov-report=xml --cov-fail-under=80
- uses: codecov/codecov-action@v4
```
**Job `docker`** — sur push vers `main` uniquement :
```yaml
needs: [quality, tests]
steps:
- uses: docker/build-push-action@v5
with:
push: true
tags: ghcr.io/${{ github.repository }}:latest
```
**Job `deploy-staging`** — sur push vers `main`, après `docker` :
- Déploiement vers un environnement de staging via SSH ou webhook
- Commenter dans le workflow s'il n'y a pas encore d'environnement de staging
2. `.pre-commit-config.yaml` — Hooks locaux :
```yaml
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.6.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.0
hooks:
- id: mypy
args: [--strict, --ignore-missing-imports]
additional_dependencies: [types-all]
- repo: https://github.com/PyCQA/bandit
rev: 1.7.10
hooks:
- id: bandit
args: [-ll, -x, tests/]
```
3. `pyproject.toml` — Configuration centralisée des outils :
```toml
[tool.ruff]
line-length = 100
target-version = "py312"
select = ["E", "F", "I", "N", "W", "UP", "S", "B", "A"]
ignore = ["S101"] # autorise les assert dans les tests
[tool.black]
line-length = 100
target-version = ["py312"]
[tool.mypy]
python_version = "3.12"
strict = true
ignore_missing_imports = true
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "--tb=short -q"
[tool.coverage.run]
source = ["app"]
omit = ["app/main.py", "*/migrations/*"]
[tool.coverage.report]
fail_under = 80
show_missing = true
```
4. `Makefile` — Raccourcis de développement :
```makefile
.PHONY: dev test lint format typecheck security ci
dev:
uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
worker:
python worker.py
test:
pytest tests/ -v --cov=app --cov-report=term-missing
lint:
ruff check . --fix
format:
black . --line-length 100
typecheck:
mypy app/ --strict --ignore-missing-imports
security:
bandit -r app/ -ll -x tests/
ci: lint format typecheck security test
@echo "✅ Tous les checks CI passent"
migrate:
alembic upgrade head
docker-up:
docker-compose up -d
docker-logs:
docker-compose logs -f backend worker
```
5. `tests/test_pipeline_arq.py` — Tests du pipeline ARQ :
- Mocker `arq_pool.enqueue_job` pour vérifier que la bonne queue est utilisée selon le plan
- Tester que `process_image_task` est appelé avec les bons paramètres
- Tester le comportement en cas d'échec (image marquée `error` après `max_tries`)
- Tester que les événements Redis sont publiés aux bonnes étapes
6. `tests/test_storage.py` — Tests du StorageBackend :
- Tester `LocalStorage` avec un répertoire temporaire (`tmp_path` de pytest)
- Tester que les tokens HMAC expirent correctement
- Tester que `S3Storage` appelle les bonnes méthodes boto3 (avec `moto` pour mocker AWS)
- Tester la vérification de quota dans `save_upload()`
7. `tests/test_observability.py` — Tests des métriques et logs :
- Vérifier que `GET /metrics` retourne `HTTP 200` avec du texte Prometheus
- Vérifier que `GET /health/detailed` retourne un statut structuré
- Vérifier que les métriques `hub_images_uploaded_total` s'incrémentent à chaque upload
**Contraintes** :
- Le pipeline CI doit échouer si la couverture de tests descend sous 80%
- `mypy --strict` doit passer sans erreur sur tout le répertoire `app/`
- `bandit` ne doit rapporter aucun problème de sévérité HIGH ou MEDIUM
- Aucun secret ne doit apparaître dans les logs du CI (utiliser `${{ secrets.* }}` pour tout)
</mission>
<execution_rules>
## Règles d'exécution
### Prérequis avant de commencer
1. Vérifier que la Phase 1 est bien en place : `pytest tests/ -v` doit passer sans erreur
2. Lire les fichiers existants concernés **avant** de les modifier
3. S'assurer que Redis est disponible localement pour les tests ARQ
### Ordre de réalisation
Implémenter dans l'ordre strict : **2.1 → 2.2 → 2.3 → 2.4 → 2.5**. Valider chaque livrable avec `pytest` avant de continuer.
### À chaque livrable
1. Lire les fichiers concernés avec l'outil `Read` ou `Glob`
2. Implémenter le code complet (zéro `# TODO`, zéro `...` comme corps de fonction)
3. Mettre à jour `requirements.txt` et `requirements-dev.txt` si nécessaire
4. Créer la migration Alembic si le schéma change
5. Lancer `pytest tests/ -v` et corriger avant de passer au suivant
6. Pour le livrable 2.5, lancer `make ci` pour valider l'ensemble
### Qualité du code
- **Typage complet** : toutes les fonctions annotées, compatible `mypy --strict`
- **Docstrings** sur toutes les classes et méthodes publiques
- **Zéro secret hardcodé** : tout passe par `app/config.py` + `.env`
- **Zéro `print()`** : remplacé par `structlog` dans tout le projet
- **Gestion explicite des exceptions** : chaque `except` doit logger + réagir, jamais `pass`
- **Fermeture propre des ressources** : pools Redis, connexions S3 fermées dans le lifespan
### Infrastructure locale pour le développement
Ajouter dans `docker-compose.yml` (si pas déjà présent) :
```yaml
services:
redis:
image: redis:7-alpine
ports: ["6379:6379"]
volumes: ["redis_data:/data"]
command: redis-server --appendonly yes
minio:
image: minio/minio
ports: ["9000:9000", "9001:9001"]
environment:
MINIO_ROOT_USER: minioadmin
MINIO_ROOT_PASSWORD: minioadmin
command: server /data --console-address ":9001"
volumes: ["minio_data:/data"]
volumes:
redis_data:
minio_data:
```
### Critère de succès final
Les commandes suivantes doivent toutes réussir sans erreur :
```bash
# Tests complets avec couverture
pytest tests/ -v --cov=app --cov-report=term-missing
# Qualité de code
make ci
# Démarrage du serveur
python run.py
# Démarrage du worker ARQ (dans un autre terminal)
python worker.py
# Vérification de santé
curl http://localhost:8000/health/detailed | python -m json.tool
```
La mise a jour des documentations API_GUIDE.md et README.md est aussi requise.
</execution_rules>
<deliverable_summary>
## Résumé des livrables attendus
| # | Fichiers créés ou modifiés | Validation |
|---|---|---|
| **2.1** | `app/workers/image_worker.py`, `app/workers/redis_client.py`, `app/services/pipeline.py`, `app/routers/images.py`, `app/main.py`, `app/config.py`, `worker.py`, `docker-compose.yml` | `pytest tests/test_pipeline_arq.py` |
| **2.2** | `app/services/storage_backend.py`, `app/services/storage.py`, `app/routers/files.py`, `app/routers/images.py`, `app/main.py`, `app/config.py` | `pytest tests/test_storage.py` |
| **2.3** | `app/models/client.py`, `app/services/storage.py`, `app/routers/images.py`, `app/schemas/__init__.py` | `pytest tests/test_storage.py -k quota` |
| **2.4** | `app/logging_config.py`, `app/metrics.py`, `app/middleware/logging_middleware.py`, `app/main.py`, + remplacement de tous les `print()` dans `services/` et `workers/` | `pytest tests/test_observability.py` |
| **2.5** | `.github/workflows/ci.yml`, `.pre-commit-config.yaml`, `pyproject.toml`, `Makefile`, `requirements-dev.txt`, `tests/test_pipeline_arq.py`, `tests/test_storage.py`, `tests/test_observability.py` | `make ci` |
**Résultat final de la Phase 2 :** hub production-ready avec pipeline persistant et retry automatique, stockage abstrait compatible S3/MinIO, logs JSON structurés, métriques Prometheus et pipeline CI/CD complet.
</deliverable_summary>