Claude commited on
Commit
d349d11
Β·
unverified Β·
1 Parent(s): 12acb53

ci(compose): validate base+prod merge in CI and freeze invariants

Browse files

Sans ce check, une incohΓ©rence entre docker-compose.yml et
docker-compose.prod.yml (variable non hΓ©ritΓ©e, override de port mal
formΓ©, conflit de services, valeur YAML ambiguΓ«) ne se voit qu'au
dΓ©ploiement. ``docker compose config`` rΓ©sout les merges et substitue
les variables β€” c'est le linter qui manquait.

Bug dΓ©tectΓ© immΓ©diatement par le nouveau check :
``docker-compose.prod.yml`` dΓ©clarait
``PICARONES_CSRF_SECRET=${PICARONES_CSRF_SECRET:?... gΓ©nΓ©rer avec: openssl rand -hex 32}``
sans guillemets. Le ``avec:`` (deux-points + espace) faisait
interprΓ©ter la string comme un map YAML dans le contexte d'une liste,
faisant Γ©chouer ``compose config`` avec un message obscur. Quotage
explicite et reformulation du message pour éviter le piège.

Ajouts :
- Cible ``make compose-check`` qui valide le compose seul et le merge
base+prod (avec secret CSRF factice pour permettre l'Γ©valuation YAML
de ``${VAR:?}``) ;
- Job CI ``compose-check`` qui rejoue les deux validations sur chaque
push/PR ;
- ``tests/architecture/test_compose_invariants.py`` qui fige les
propriΓ©tΓ©s *sΓ©mantiques* qu'un ``config`` ne capture pas :
* Ollama doit rester profile-gated dans le base (sinon ``up`` le
lance par dΓ©faut), et prod ne doit pas le rΓ©activer ;
* Prod force ``CSRF_REQUIRED=1`` et ``SECURE_COOKIES=1`` en dur
(pas de fallback ``:-0``) ;
* Prod exige ``${PICARONES_CSRF_SECRET:?...}`` (la syntaxe ``:?``
qui fait Γ©chouer Compose si la variable n'est pas posΓ©e) ;
* Port 7860 stable cΓ΄tΓ© base ET prod (alignement HuggingFace Space).

Sortie de Phase 0 : 39 tests Phase 0 verts en 0.78s, 232 tests
architecture+docs verts au total, ruff vert, compose merge validΓ©.

.github/workflows/ci.yml CHANGED
@@ -274,6 +274,41 @@ jobs:
274
  - name: Run ruff
275
  run: ruff check picarones/ tests/
276
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
277
  # ──────────────────────────────────────────────────────────────────
278
  # Job 4-bis : Sync compteurs README/CLAUDE.md β€” Phase 2.1 audit
279
  # code-quality (2026-05). Le script gen_readme_tables.py reflΓ¨te
 
274
  - name: Run ruff
275
  run: ruff check picarones/ tests/
276
 
277
+ # ──────────────────────────────────────────────────────────────────
278
+ # Job 4-ter : Validation Docker compose
279
+ #
280
+ # Sans ce check, une incohΓ©rence entre ``docker-compose.yml`` et
281
+ # ``docker-compose.prod.yml`` (variable non hΓ©ritΓ©e, override de
282
+ # port mal formΓ©, conflit de services) ne se voit qu'au
283
+ # dΓ©ploiement. ``docker compose config`` rΓ©sout les merges,
284
+ # substitue les variables d'env et Γ©choue si la composition est
285
+ # invalide β€” c'est exactement le linter manquant.
286
+ #
287
+ # Le secret CSRF est forcΓ© sur une valeur factice : ``prod.yml``
288
+ # exige ``${PICARONES_CSRF_SECRET:?...}`` qui ferait Γ©chouer
289
+ # ``config`` sans valeur. La valeur n'est jamais utilisΓ©e β€” on ne
290
+ # dΓ©marre aucun container, on valide uniquement le YAML.
291
+ # ──────────────────────────────────────────────────────────────────
292
+ compose-check:
293
+ name: Docker compose validation
294
+ runs-on: ubuntu-latest
295
+
296
+ steps:
297
+ - name: Checkout
298
+ uses: actions/checkout@v4
299
+
300
+ - name: Validate local compose
301
+ run: docker compose -f docker-compose.yml config > /dev/null
302
+
303
+ - name: Validate local + prod merge
304
+ env:
305
+ PICARONES_CSRF_SECRET: compose-check-not-a-real-secret
306
+ run: |
307
+ docker compose \
308
+ -f docker-compose.yml \
309
+ -f docker-compose.prod.yml \
310
+ config > /dev/null
311
+
312
  # ──────────────────────────────────────────────────────────────────
313
  # Job 4-bis : Sync compteurs README/CLAUDE.md β€” Phase 2.1 audit
314
  # code-quality (2026-05). Le script gen_readme_tables.py reflΓ¨te
Makefile CHANGED
@@ -233,6 +233,18 @@ docker-compose-down: ## ArrΓͺte les services Docker Compose
233
  docker-compose-logs: ## Affiche les logs Docker Compose
234
  docker compose logs -f picarones
235
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  # ──────────────────────────────────────────────────────────────────
237
  # Nettoyage
238
  # ──────────────────────────────────────────────────────────────────
 
233
  docker-compose-logs: ## Affiche les logs Docker Compose
234
  docker compose logs -f picarones
235
 
236
+ compose-check: ## Valide que docker-compose.yml seul + le merge avec prod.yml sont syntaxiquement corrects
237
+ @# Sans cette validation, une incohΓ©rence dans le merge (variable
238
+ @# non hΓ©ritΓ©e, port surchargΓ© mal, conflit de services) ne se
239
+ @# voit qu'au dΓ©ploiement. Le secret CSRF est forcΓ© sur une
240
+ @# valeur factice pour permettre l'Γ©valuation YAML (prod.yml
241
+ @# exige ``${PICARONES_CSRF_SECRET:?...}``) β€” la valeur n'est
242
+ @# jamais utilisΓ©e.
243
+ @docker compose -f docker-compose.yml config > /dev/null
244
+ @PICARONES_CSRF_SECRET=compose-check-not-a-real-secret \
245
+ docker compose -f docker-compose.yml -f docker-compose.prod.yml config > /dev/null
246
+ @echo "$(GREEN)βœ“ compose merge (local + prod) syntaxiquement valide$(RESET)"
247
+
248
  # ──────────────────────────────────────────────────────────────────
249
  # Nettoyage
250
  # ──────────────────────────────────────────────────────────────────
docker-compose.prod.yml CHANGED
@@ -22,4 +22,9 @@ services:
22
  - PICARONES_PUBLIC_MODE=${PICARONES_PUBLIC_MODE:-1}
23
  - PICARONES_CSRF_REQUIRED=1
24
  - PICARONES_SECURE_COOKIES=1
25
- - PICARONES_CSRF_SECRET=${PICARONES_CSRF_SECRET:?PICARONES_CSRF_SECRET requis en prod β€” gΓ©nΓ©rer avec: openssl rand -hex 32}
 
 
 
 
 
 
22
  - PICARONES_PUBLIC_MODE=${PICARONES_PUBLIC_MODE:-1}
23
  - PICARONES_CSRF_REQUIRED=1
24
  - PICARONES_SECURE_COOKIES=1
25
+ # ATTENTION : la valeur doit Γͺtre quotΓ©e β€” le message d'erreur
26
+ # contient ``avec:`` (deux-points + espace) que YAML interprète
27
+ # comme un map dans le contexte d'une liste, ce qui fait Γ©chouer
28
+ # ``docker compose config`` avec un message obscur. La quote
29
+ # force le scalaire.
30
+ - "PICARONES_CSRF_SECRET=${PICARONES_CSRF_SECRET:?PICARONES_CSRF_SECRET requis en prod β€” gΓ©nΓ©rer avec openssl rand -hex 32}"
tests/architecture/test_compose_invariants.py ADDED
@@ -0,0 +1,181 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Invariants statiques sur ``docker-compose.yml`` et
2
+ ``docker-compose.prod.yml``.
3
+
4
+ Ce test ne lance pas ``docker compose config`` (qui demande Docker
5
+ installΓ© et fait un merge dynamique). Il vΓ©rifie des propriΓ©tΓ©s
6
+ *structurelles* du YAML pour figer ce qu'on tient Γ  conserver :
7
+
8
+ 1. **Le service Ollama est gated par un profil Compose**. Sans
9
+ cela, ``docker compose up`` lancerait Ollama par dΓ©faut, alors
10
+ que le service est optionnel (un opΓ©rateur institutionnel n'en
11
+ veut gΓ©nΓ©ralement pas β€” l'override prod n'a pas Γ  le dΓ©sactiver
12
+ manuellement parce que le profil le neutralise dΓ©jΓ ).
13
+
14
+ 2. **L'override prod force CSRF_REQUIRED + SECURE_COOKIES et exige
15
+ le secret**. RΓ©gression possible si quelqu'un retire le ``:?``
16
+ pensant simplifier.
17
+
18
+ 3. **Le port publiΓ© par dΓ©faut est 7860 dans les deux fichiers**.
19
+ Le port a déjà migré de 8000 à 7860 — un retour en arrière
20
+ dΓ©saligne le compose, le Dockerfile et le HuggingFace Space.
21
+
22
+ La validation syntaxique du merge (``docker compose config``) est
23
+ faite par le job CI ``compose-check`` et la cible ``make
24
+ compose-check`` ; ce test couvre l'aspect *sΓ©mantique* qu'un
25
+ ``config`` ne capture pas.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from pathlib import Path
31
+
32
+ import yaml
33
+
34
+ REPO_ROOT = Path(__file__).resolve().parents[2]
35
+ BASE_COMPOSE = REPO_ROOT / "docker-compose.yml"
36
+ PROD_COMPOSE = REPO_ROOT / "docker-compose.prod.yml"
37
+
38
+
39
+ def _load(path: Path) -> dict:
40
+ with path.open("r", encoding="utf-8") as f:
41
+ doc = yaml.safe_load(f)
42
+ assert isinstance(doc, dict), f"{path.name} : YAML racine non-dict"
43
+ return doc
44
+
45
+
46
+ # ─────────────────────────────────────────────────────────────────────
47
+ # 1. Ollama est optionnel (profil Compose)
48
+ # ─────────────────────────────────────────────────────────────────────
49
+
50
+
51
+ def test_ollama_service_is_profile_gated_in_base() -> None:
52
+ """Le service ``ollama`` du compose de base doit dΓ©clarer
53
+ ``profiles: [ollama]`` (ou un nom Γ©quivalent) pour qu'il ne
54
+ dΓ©marre PAS par dΓ©faut.
55
+
56
+ Sans cette clause, ``docker compose up`` lance Ollama, ce qui
57
+ consomme un GPU / 4 GB RAM mΓͺme quand l'utilisateur n'en veut
58
+ pas, et expose le port 11434 sur le host.
59
+ """
60
+ base = _load(BASE_COMPOSE)
61
+ services = base.get("services") or {}
62
+ assert "ollama" in services, (
63
+ "Service ``ollama`` absent du compose de base. Si retirΓ© "
64
+ "volontairement, supprimer aussi ce test."
65
+ )
66
+ ollama_cfg = services["ollama"]
67
+ profiles = ollama_cfg.get("profiles")
68
+ assert profiles, (
69
+ "Le service ``ollama`` n'a pas de clause ``profiles:`` β€” "
70
+ "il dΓ©marre par dΓ©faut. Ajouter ``profiles: [ollama]`` "
71
+ "pour le rendre opt-in via ``docker compose --profile ollama up``."
72
+ )
73
+ assert isinstance(profiles, list) and len(profiles) >= 1, (
74
+ f"``profiles:`` doit Γͺtre une liste non vide, vu : {profiles!r}"
75
+ )
76
+
77
+
78
+ def test_prod_compose_does_not_re_enable_ollama() -> None:
79
+ """L'override prod ne doit pas activer Ollama (en retirant son
80
+ profil ou en redΓ©finissant le service sans profil).
81
+
82
+ Le dΓ©ploiement institutionnel n'utilise pas Ollama β€” c'est le
83
+ LLM provider qui change (cloud API). RΓ©activer Ollama en prod
84
+ serait une rΓ©gression silencieuse.
85
+ """
86
+ prod = _load(PROD_COMPOSE)
87
+ services = prod.get("services") or {}
88
+ if "ollama" not in services:
89
+ return # pas de redΓ©finition = profil hΓ©ritΓ© = ok
90
+ ollama_override = services["ollama"]
91
+ profiles = ollama_override.get("profiles")
92
+ assert profiles, (
93
+ "``docker-compose.prod.yml`` redΓ©finit le service ``ollama`` "
94
+ "sans ``profiles:`` β€” il serait activΓ© en prod alors que le "
95
+ "dΓ©ploiement institutionnel utilise un LLM cloud. Soit "
96
+ "retirer la redΓ©finition, soit garder un profil explicite."
97
+ )
98
+
99
+
100
+ # ─────────────────────────────────────────────────────────────────────
101
+ # 2. L'override prod durcit la sΓ©curitΓ©
102
+ # ─────────────────────────────────────────────────────────────────────
103
+
104
+
105
+ def _env_dict(env_block: list | dict) -> dict[str, str]:
106
+ """Compose accepte ``environment:`` au format liste
107
+ (``["KEY=value", ...]``) ou dict. Normaliser en dict."""
108
+ if isinstance(env_block, dict):
109
+ return {k: str(v) for k, v in env_block.items()}
110
+ out: dict[str, str] = {}
111
+ for item in env_block or []:
112
+ s = str(item)
113
+ if "=" in s:
114
+ k, _, v = s.partition("=")
115
+ out[k] = v
116
+ else:
117
+ out[s] = ""
118
+ return out
119
+
120
+
121
+ def test_prod_compose_forces_csrf_required() -> None:
122
+ """``PICARONES_CSRF_REQUIRED=1`` doit Γͺtre hardcoded en prod
123
+ (pas une valeur par dΓ©faut ``${... :-0}``)."""
124
+ prod = _load(PROD_COMPOSE)
125
+ env = _env_dict(prod["services"]["picarones"].get("environment", []))
126
+ val = env.get("PICARONES_CSRF_REQUIRED", "")
127
+ assert val == "1", (
128
+ f"``PICARONES_CSRF_REQUIRED`` en prod = {val!r}, attendu '1'. "
129
+ f"L'override prod doit forcer CSRF β€” pas le rendre opt-in."
130
+ )
131
+
132
+
133
+ def test_prod_compose_forces_secure_cookies() -> None:
134
+ """``PICARONES_SECURE_COOKIES=1`` doit Γͺtre hardcoded en prod."""
135
+ prod = _load(PROD_COMPOSE)
136
+ env = _env_dict(prod["services"]["picarones"].get("environment", []))
137
+ val = env.get("PICARONES_SECURE_COOKIES", "")
138
+ assert val == "1", (
139
+ f"``PICARONES_SECURE_COOKIES`` en prod = {val!r}, attendu '1'."
140
+ )
141
+
142
+
143
+ def test_prod_compose_requires_csrf_secret() -> None:
144
+ """``PICARONES_CSRF_SECRET`` en prod doit utiliser la syntaxe
145
+ ``${VAR:?msg}`` qui fait Γ©chouer Compose si la variable n'est pas
146
+ posΓ©e. Sans Γ§a, le conteneur dΓ©marrerait avec un secret vide ou
147
+ un fallback dangereux."""
148
+ prod_text = PROD_COMPOSE.read_text(encoding="utf-8")
149
+ assert "PICARONES_CSRF_SECRET=${PICARONES_CSRF_SECRET:?" in prod_text, (
150
+ "``docker-compose.prod.yml`` doit dΓ©clarer "
151
+ "``PICARONES_CSRF_SECRET=${PICARONES_CSRF_SECRET:?...}`` "
152
+ "(la syntaxe ``:?`` fait Γ©chouer Compose Γ  l'Γ©valuation si "
153
+ "la variable n'est pas posΓ©e). Sinon le secret peut Γͺtre "
154
+ "vide en prod."
155
+ )
156
+
157
+
158
+ # ─────────────────────────────────────────────────────────────────────
159
+ # 3. Port 7860 stable
160
+ # ─────────────────────────────────────────────────────────────────────
161
+
162
+
163
+ def test_base_compose_publishes_port_7860() -> None:
164
+ """Le compose de base publie le service Picarones sur 7860 (port
165
+ alignΓ© avec HuggingFace Space et le Dockerfile)."""
166
+ base = _load(BASE_COMPOSE)
167
+ ports = base["services"]["picarones"].get("ports") or []
168
+ assert any(":7860" in str(p) for p in ports), (
169
+ f"Aucun mapping de port vers 7860 trouvΓ© dans docker-compose.yml "
170
+ f"(ports vus : {ports}). Port HuggingFace Space = 7860."
171
+ )
172
+
173
+
174
+ def test_prod_compose_publishes_port_7860() -> None:
175
+ """L'override prod doit aussi pointer sur 7860."""
176
+ prod = _load(PROD_COMPOSE)
177
+ ports = prod["services"]["picarones"].get("ports") or []
178
+ assert any(":7860" in str(p) for p in ports), (
179
+ f"Aucun mapping vers 7860 dans docker-compose.prod.yml "
180
+ f"(ports vus : {ports})."
181
+ )