fix(draco): résolution dev/prod par l'hôte de la requête (7.82.4)
FlowDeck CI / test (push) Failing after 3h13m27s
FlowDeck CI / lint (push) Successful in 1m47s
FlowDeck CI / docker (push) Skipped

Le mode console du panneau restait une page morte sur flowdeck-dev :
CSP frame-src/connect-src et DRACO_PANNEAU_CONFIG.agent_url pointaient
l'agent prod (pas encore installée). Deux trous de la 7.82.3 :
agent_url() sans aucune dérivation, et aucune résolution lisant
l'hôte de la requête.

Pour tout ce qui est rendu à une requête (barre/DRACO_URLS, iframe
du panneau, CSP, boutons et redirections SSO) : explicite >
hôte public de la requête (motif fermé
^(og|flowdeck|tiktak|kanbante|agent)(SUFFIXE).dracodev.net$,
suffixe appliqué aux sœurs et à l'agent) > dérivation par les
adresses de l'agent connues en config (élargie : agent_url() se
dérive de l'URL d'événements explicite) > défaut officiel.
L'hôte est posé par le middleware CSP dans un ContextVar (patron
du nonce CSP) : iframe et CSP de la même réponse consomment la
même origine résolue. Contextes sans requête inchangés sinon.

Tests : +12 dans test_draco_urls_devprod.py (25/25) — Host dev
sans aucun env → tout en -dev ; Host prod → officiels ; Host LAN
→ repli config/défauts ; explicite gagne sur l'hôte ; motif fermé.
Suite complète : 4 failed / 26 errors identiques sur arbre propre
(familles préexistantes MCP/realtime/my_tasks/gallery/sync).
This commit is contained in:
Spark
2026-10-26 03:04:50 +00:00
parent 283e226adf
commit fd79a49d63
7 changed files with 403 additions and 18 deletions
+149 -7
View File
@@ -32,16 +32,63 @@ qui ne déclare que l'agent pointe ses liens inter-apps en ``-dev``
sans quatre variables de plus. Si l'hôte de l'agent ne correspond
pas au motif (autre domaine, LAN, IP), **aucune dérivation** : on
ne fabrique jamais un lien deviné. L'URL Gitea ne change pas.
**Résolution par l'hôte de la requête (v7.82.4)** — la v7.82.3
laissait deux trous, prouvés en direct sur flowdeck-dev (CSP
``frame-src 'self' https://agent.dracodev.net`` alors que la prod
n'existe pas encore → console du panneau = page morte) :
1. ``agent_url()`` n'avait **aucune** dérivation : même avec
``AGENT_EVENEMENTS_URL`` en ``-dev``, l'agent lui-même retombait
sur le nom officiel ;
2. **aucune** résolution ne lisait l'hôte de la requête : un
``.env`` dev sans ``DRACO_AGENT_URL`` ni ``AGENT_EVENEMENTS_URL``
retombait entièrement sur la prod.
Pour tout ce qui est **rendu à une requête** (liens de la barre /
``DRACO_URLS`` servi, source de l'iframe du panneau, origine agent
des CSP ``frame-src``/``connect-src``, boutons et redirections
SSO), l'ordre de résolution devient :
1. variable explicite de l'installation — prioritaire, inchangée ;
2. **hôte public de la requête** (posé par le middleware CSP dans
``HOTE_REQUETE``, même patron que le nonce CSP) : motif fermé
``^(og|flowdeck|tiktak|kanbante|agent)(<SUFFIXE>)\\.dracodev\\.net$``
(suffixe optionnel, p. ex. ``-dev``, vide en prod) →
l'environnement = ce suffixe, appliqué à toutes les sœurs **et
à l'agent**. Jamais de suffixe inventé depuis un hôte arbitraire ;
hors motif (LAN, IP) → étape suivante ;
3. dérivation par les adresses de l'agent connues en configuration
(URL d'événements **ou** base du service) — aussi la résolution
des contextes **sans requête**, inchangée sinon ;
4. défaut officiel.
L'iframe du panneau et la CSP de la même réponse consomment la
même origine résolue : les deux lisent ``agent_url()`` pendant la
même requête, avec le même ``HOTE_REQUETE``.
"""
from __future__ import annotations
import os
import re
from contextvars import ContextVar
from urllib.parse import urlparse
from app.config import settings
# Hôte public de la requête en cours (``X-Forwarded-Host`` sinon
# ``Host``), posé par le middleware CSP avant le traitement — même
# patron que ``CSP_NONCE`` : le contexte est copié dans la tâche qui
# rend les templates. Vide hors requête (tâches de fond, CLI, tests
# unitaires) → la résolution retombe sur la configuration.
HOTE_REQUETE: ContextVar[str] = ContextVar("draco_hote_requete", default="")
def definir_hote_requete(hote: str) -> None:
"""Mémorise l'hôte public de la requête (premier de la liste)."""
HOTE_REQUETE.set((hote or "").split(",")[0].strip().lower())
OFFICIELLES = {
"obsigate": "https://og.dracodev.net",
"agent": "https://agent.dracodev.net",
@@ -77,6 +124,16 @@ _SLUGS_SOEURS = {
# Hôte de l'agent : agent<SUFFIXE>.dracodev.net (SUFFIXE = ``-dev``…).
_MOTIF_HOTE_AGENT = re.compile(r"^agent(-[a-z0-9-]+)?\.dracodev\.net$")
# Hôte public d'une requête : motif FERMÉ des cinq applications.
# Le suffixe capturé (groupe 2, p. ex. ``-dev``) est l'environnement
# de TOUTE l'installation — jamais un suffixe inventé depuis un
# hôte arbitraire : hors motif (autre domaine, LAN, IP), None.
_MOTIF_HOTE_PUBLIC = re.compile(
r"^(og|flowdeck|tiktak|kanbante|agent)(-[a-z0-9-]+)?\.dracodev\.net$")
# Slugs pour la résolution par l'hôte de la requête (l'agent inclus).
_SLUGS_TOUTES = {**_SLUGS_SOEURS, "agent": "agent"}
def _suffixe_environnement() -> str | None:
"""Suffixe d'environnement porté par l'hôte de l'agent, ou None.
@@ -105,13 +162,60 @@ def _url_derivee(app: str) -> str:
return f"https://{slug}{suffixe}.dracodev.net"
def _suffixe_requete() -> str | None:
"""Suffixe d'environnement porté par l'hôte de la requête, ou None.
Motif fermé ``_MOTIF_HOTE_PUBLIC`` sur l'hôte public mémorisé
(port retiré) : ``""`` pour un nom officiel, ``"-dev"`` pour un
nom en ``-dev``, None hors motif ou hors requête.
"""
hote_req = HOTE_REQUETE.get().split(":")[0]
if not hote_req:
return None
m = _MOTIF_HOTE_PUBLIC.match(hote_req)
if not m:
return None
return m.group(2) or ""
def _url_depuis_requete(app: str) -> str:
"""URL d'``app`` dérivée de l'hôte de la requête ("" si aucune)."""
slug = _SLUGS_TOUTES.get(app)
if not slug:
return ""
suffixe = _suffixe_requete()
if suffixe is None:
return ""
return f"https://{slug}{suffixe}.dracodev.net"
def _suffixe_evenements_explicite() -> str | None:
"""Suffixe porté par l'URL d'événements **explicite**, ou None.
Lit ``AGENT_EVENEMENTS_URL`` (environnement puis settings) sans
passer par ``agent_evenements_url()`` : pas de récursion avec
``agent_url()``, dont c'est une source de dérivation.
"""
for candidate in (os.environ.get("AGENT_EVENEMENTS_URL", ""),
getattr(settings, "agent_evenements_url", "") or ""):
candidate = _nettoyer(candidate)
if candidate:
hote_agent = (urlparse(candidate).hostname or "").lower()
m = _MOTIF_HOTE_AGENT.match(hote_agent)
return (m.group(1) or "") if m else None
return None
def _resoudre_soeur(env_name: str, settings_attr: str, app: str) -> str:
"""Résolution d'une URL sœur : explicite > dérivation > officiel.
"""Résolution d'une URL sœur, dans l'ordre du chantier :
explicite > **hôte de la requête** > dérivation par l'agent
connu en configuration > officiel.
La valeur ``settings`` égale au nom officiel est le *défaut* de
``app/config.py``, pas un choix explicite : elle ne doit pas
bloquer la dérivation d'environnement. Une valeur settings
différente (dev, LAN, domaine propre) reste prioritaire.
bloquer les dérivations. Une valeur settings différente (dev,
LAN, domaine propre) reste prioritaire.
"""
env = _nettoyer(os.environ.get(env_name, ""))
if env:
@@ -119,6 +223,9 @@ def _resoudre_soeur(env_name: str, settings_attr: str, app: str) -> str:
cfg = _nettoyer(getattr(settings, settings_attr, "") or "")
if cfg and cfg != OFFICIELLES[app]:
return cfg
depuis_requete = _url_depuis_requete(app)
if depuis_requete:
return depuis_requete
derivee = _url_derivee(app)
if derivee:
return derivee
@@ -133,8 +240,30 @@ def obsigate_url() -> str:
def agent_url() -> str:
"""Service d'agents (Spark) — base sans chemin."""
return _resoudre("DRACO_AGENT_URL", "draco_agent_url", "agent")
"""Service d'agents (Spark) — base sans chemin.
Même ordre que les sœurs (la v7.82.3 l'avait laissé sans aucune
dérivation — trou n° 1 du mode console mort en dev) : explicite
(``DRACO_AGENT_URL`` / settings non officiel) > hôte de la
requête > dérivation par l'URL d'événements explicite
(``AGENT_EVENEMENTS_URL`` : ``agent-dev…`` → base ``agent-dev…``)
> défaut officiel.
"""
env = _nettoyer(os.environ.get("DRACO_AGENT_URL", ""))
if env:
return env
cfg = _nettoyer(getattr(settings, "draco_agent_url", "") or "")
if cfg and cfg != OFFICIELLES["agent"]:
return cfg
depuis_requete = _url_depuis_requete("agent")
if depuis_requete:
return depuis_requete
suffixe = _suffixe_evenements_explicite()
if suffixe is not None:
return f"https://agent{suffixe}.dracodev.net"
if cfg:
return cfg
return OFFICIELLES["agent"]
def tiktak_url() -> str:
@@ -154,7 +283,8 @@ def flowdeck_url() -> str:
FLOWDECK_URL > origine de OAUTH_REDIRECT_URI (déjà déclarée en
dev : ``https://flowdeck-dev.dracodev.net/auth/callback``) >
APP_BASE_URL si elle n'est pas locale > nom officiel.
hôte de la requête (motif fermé) > APP_BASE_URL si elle n'est
pas locale > dérivation par l'agent > nom officiel.
"""
env = _nettoyer(os.environ.get("FLOWDECK_URL", ""))
if env:
@@ -171,6 +301,13 @@ def flowdeck_url() -> str:
p = urlparse(candidate)
if p.scheme and p.netloc:
return f"{p.scheme}://{p.netloc}"
# Étape 2 du chantier : l'hôte de la requête — c'est l'origine
# que le navigateur utilise déjà pour cette instance, donc
# aussi celle du callback SSO (``/auth/draco``) quand rien
# d'explicite n'est déclaré.
depuis_requete = _url_depuis_requete("flowdeck")
if depuis_requete:
return depuis_requete
base = _nettoyer(getattr(settings, "app_base_url", "") or "")
if base and "localhost" not in base and "127.0.0.1" not in base:
return base
@@ -208,7 +345,12 @@ def hote(url: str) -> str:
def toutes() -> dict[str, str]:
"""Toutes les URL résolues — injectées aux templates (hôte = config)."""
"""Toutes les URL résolues — injectées aux templates.
Résolution à l'appel : dans une requête, l'hôte public de la
requête fait foi après les variables explicites ; hors requête,
la configuration seule (voir le docstring du module).
"""
return {
"obsigate": obsigate_url(),
"agent": agent_url(),