Contenu

Plugin Cloudflare hugo-mcp : purge ciblée plutôt que totale

TL;DR

Le plugin Cloudflare de hugo-mcp v2.0 implémente 3 modes de purge cache (full, partial, smart). Le mode partial calcule les URLs liées à invalider (canonique + sitemap + RSS + listing + home) pour préserver 95% du cache CDN à chaque modification. Concrètement : 6 URLs purgées au lieu de tout vider. Ce post détaille le calcul, les pièges, et pourquoi smart est devenu le défaut.

Ce post complète Plugin-system architecture en zoomant sur le plus sophistiqué des 3 plugins fournis dans v2.0.

Le problème de la purge totale

Cloudflare cache agressivement le HTML. Sans purge, après chaque modification d’article les visiteurs voient l’ancienne version pendant des heures. Le réflexe naïf : purge_everything à chaque save.

# Approche naïve — fonctionne mais gâcheuse
async def on_page_event(self, event_type, urls, context):
    await client.post(
        f"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache",
        json={"purge_everything": True},
    )

Le problème : purge_everything invalide toutes les pages du domaine. Si je modifie une typo dans un article, je perds le cache pour :

  • La home (récupération immédiate au prochain visiteur, coût CPU NUC)
  • Les 130+ autres pages d’articles (idem)
  • Le sitemap, RSS, robots.txt (re-générés à la première requête)
  • Les assets CSS/JS qui transitent par Cloudflare

Sur un blog à 100+ visiteurs/jour, ça veut dire que le NUC re-fait travailler nginx pendant 5-10 minutes après chaque save, le temps que Cloudflare repeuple ses edges. CPU spike, latence en hausse, dette de cache.

La purge ciblée

Cloudflare propose une API files: [...] qui invalide seulement les URLs listées (max 30 par appel). C’est ce qu’il faut.

Mais quand je modifie /posts/foo/, quelles URLs dois-je purger exactement ?

Pas que /posts/foo/. Si je purge uniquement ça, je laisse en cache :

  • La home qui liste l’article modifié (avec son ancien titre/résumé)
  • Le listing /posts/ (pareil)
  • Le sitemap.xml qui mentionne la date lastmod
  • Le RSS /index.xml qui montre l’ancien résumé
  • Les pages de taxonomies (/tags/python/, /categories/infrastructure/) qui agrègent

Donc une modification = un graphe d’invalidation à calculer.

Le calcul des URLs liées

Voici la logique du plugin cloudflare en mode partial :

def _compute_related_urls(self, urls: list[str], context: dict) -> list[str]:
    """Calcule les URLs liées à invalider pour une modification donnée."""
    base = self.base_url.rstrip("/")
    related = set()
    
    # Toujours invalider la home et le sitemap
    related.add(f"{base}/")
    related.add(f"{base}/sitemap.xml")
    related.add(f"{base}/index.xml")  # RSS root
    
    # Si l'URL est dans /posts/, invalider le listing et le RSS de section
    for url in urls:
        if "/posts/" in url:
            related.add(f"{base}/posts/")
            related.add(f"{base}/posts/index.xml")
        if "/en/posts/" in url:
            related.add(f"{base}/en/posts/")
            related.add(f"{base}/en/posts/index.xml")
            related.add(f"{base}/en/")
    
    return sorted(related)

Pour un update_page sur /posts/migration-grav-hugo/, ça donne 6 URLs :

https://www.arleo.eu/
https://www.arleo.eu/posts/
https://www.arleo.eu/posts/migration-grav-hugo/
https://www.arleo.eu/sitemap.xml
https://www.arleo.eu/index.xml
https://www.arleo.eu/posts/index.xml

6 URLs invalidées au lieu de 130+. Le cache pour tous les autres articles est préservé.

Les 3 modes

# config/plugins.yaml
cloudflare:
  enabled: true
  mode: smart                      # full | partial | smart
  api_token_env: CF_API_TOKEN
  zone_id: d2f7807c2c5b7c9737da45f538072423
  base_url: "https://www.arleo.eu"

Mode full

Comportement legacy. purge_everything à chaque événement. Sûr mais coûteux. Utilité : compat avec les workflows existants, ou debug quand on suspecte un problème de cache plus large.

Mode partial

purge_everything jamais déclenché. À chaque événement, calcul des URLs liées et appel API avec files: [...]. Économique, ciblé.

Risque assumé : si une modification a un impact qu’on n’a pas anticipé (changement de footer global via shortcode, modification d’un tag affectant toutes les pages avec ce tag), le mode partial peut laisser des pages obsolètes en cache. Pour ces cas exceptionnels, basculer manuellement en full pour la modif, puis revenir en partial.

Mode smart (défaut)

Le compromis qui m’a paru le bon après quelques jours de prod :

if mode == "smart":
    effective_mode = "full" if event_type == "deleted" else "partial"

partial sur created et updated, full sur deleted.

Pourquoi full sur deleted ? Parce qu’une suppression peut affecter beaucoup d’agrégations (la page de tag perd un article, la home aussi, les “articles liés” en bas de chaque article peuvent référencer le supprimé). C’est rare en pratique (je supprime peu) mais quand ça arrive, autant purger large.

Mesures en prod

Sur le dernier update_page (publication de ce post) :

{
  "plugin": "cloudflare",
  "success": true,
  "mode": "partial",
  "urls_purged": 6,
  "duration_ms": 337,
  "cf_response": {"success": true, "errors": []}
}

337 ms pour purger 6 URLs. Le full purge prend en moyenne 200-300 ms aussi côté API Cloudflare (le travail réel se passe sur les edges après). La latence n’est donc pas le critère discriminant — c’est l’économie de cache qui compte.

Quand update_page Cloudflare retourne, le cache des 130+ pages non modifiées est intact, et les visiteurs continuent de toucher l’edge sans re-frapper le NUC.

Pièges rencontrés

Cloudflare ne purge pas les URLs avec trailing slash si on les soumet sans

L’API Cloudflare est sensible au format exact. https://www.arleo.eu/posts/foo et https://www.arleo.eu/posts/foo/ sont deux entrées de cache différentes. Hugo génère des URLs avec trailing slash. Donc le plugin doit toujours soumettre avec trailing slash.

Limite 30 URLs par appel

L’API files: [...] accepte max 30 URLs par appel. Pour un blog perso avec 6 URLs par modification c’est OK, mais si tu construis un système qui invalide des centaines d’URLs (refonte taxonomie, etc.), il faut chunker.

async def _purge_partial(self, urls: list[str]) -> dict:
    CHUNK_SIZE = 30
    results = []
    for i in range(0, len(urls), CHUNK_SIZE):
        chunk = urls[i:i+CHUNK_SIZE]
        response = await client.post(api_url, json={"files": chunk})
        results.append(response.json())
    return {"success": all(r.get("success") for r in results), "chunks": len(results)}

CF_API_TOKEN doit avoir le scope Cache Purge minimal

Pas Zone:DNS:Edit, pas Zone:Workers, juste Cache Purge. Si le token a trop de scopes, tu agrandis inutilement la surface en cas de fuite. Bonne pratique : génère un token dédié à ce plugin avec ce seul scope.

Whitelist systemd des IPs Cloudflare

L’API Cloudflare est joignable sur api.cloudflare.com qui tourne sur les IPs habituelles Cloudflare (104.16.0.0/12, 172.64.0.0/13). Si ton hugo-mcp tourne avec IPAddressDeny=any + IPAddressAllow (cas du sprint sécu C1-C10), ajoute :

IPAddressAllow=104.16.0.0/12
IPAddressAllow=172.64.0.0/13

Sinon le plugin timeout silencieusement.

Audit log structuré

Chaque purge émet un événement structlog qui remonte dans BetterStack :

{
  "ts": "2026-05-09T23:50:40Z",
  "event": "plugin.cloudflare.purge",
  "level": "info",
  "mode": "partial",
  "urls_purged": 6,
  "duration_ms": 337,
  "trigger": "mcp.update_page",
  "route": "/posts/hugo-mcp-plugins-architecture/",
  "success": true
}

Ça permet de mesurer la latence de l’API CF dans le temps, détecter les modes full inattendus, corréler les ratés de purge avec les ratés visibles côté visiteurs.

Avantages du mode smart

Trois mois de recul ne sont pas encore là (je viens de l’implémenter), mais voici l’analyse prospective :

  1. Cache hit rate maintenu : les 130+ pages non modifiées restent fresh dans le cache Cloudflare. Les visiteurs touchent l’edge, pas le NUC.
  2. CPU NUC stable : pas de spike de re-rendu après chaque save.
  3. Latence stable : pas de période “rebuild cache” perceptible côté visiteur.
  4. Coût Cloudflare maîtrisé : moins d’appels API.

L’inconvénient : si on manque une URL dans le calcul des “liées”, elle reste en cache obsolète. Pour mitiger, je peux toujours faire un build_site MCP qui passe en mode full manuellement quand je sens que quelque chose ne refresh pas.

Backlog

Ce qui manque encore :

  • Détection auto des taxonomies impactées : si un post a le tag python, la modification devrait aussi invalider /tags/python/. Pas encore fait, je purge à la main si je touche aux tags.
  • Webhook Cloudflare vers BetterStack : pour confirmer côté CDN que la purge a bien propagé (actuellement on fait confiance à la réponse API).
  • Mode “explain” : un dry-run qui montre quelles URLs seraient purgées, sans appeler l’API. Utile pour debug.

Conclusion

Le plugin Cloudflare en mode smart économise ~95% du cache à chaque modification, pour un coût d’implémentation de ~150 lignes Python. Le calcul du graphe d’invalidation est simple : home + sitemap + RSS + listings + canonique. Et full purge reste là en mode dégradé pour les cas exceptionnels.

Code complet dans plugins/cloudflare/plugin.py sur GitHub. Pour l’architecture plugin-system qui héberge ce plugin, Plugin-system architecture. Pour le contexte global de la migration qui a justifié tout ça, Migration Grav vers Hugo.