feat: implémentation initiale du reverse proxy de maintenance

Reverse proxy Nginx (image stable-alpine) qui sert l'app upstream ou une
page de maintenance 503 selon l'IP du client.

- Modes whitelist/blacklist commutables via MAINTENANCE_MODE
- Liste IPv4 via MAINTENANCE_IP_LIST (séparée par virgules, validée au boot)
- Logique en directives Nginx natives (geo + map), zéro Lua/njs
- Page statique sobre HTML+CSS inline, zéro JS, zéro réseau sortant
- Log dédié /var/log/nginx/maintenance.log pour les requêtes bloquées
- Validation des env vars dans /docker-entrypoint.d/10-init.sh
- Stack Docker + docker-compose pour dev local et tests
- 6 cas de tests d'intégration (whitelist/blacklist x autorisée/bloquée + log + nginx -t)
- Lint shellcheck propre sur tous les scripts shell
This commit is contained in:
2026-05-05 13:52:13 +02:00
commit b968ec8aa5
24 changed files with 1147 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.gitignore
.dockerignore
.editorconfig
*.md
docker-compose*.yml
tests/
.env
.env.local
.idea/
.vscode/
+15
View File
@@ -0,0 +1,15 @@
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 4
[*.{yml,yaml,md}]
indent_size = 2
[Makefile]
indent_style = tab
+9
View File
@@ -0,0 +1,9 @@
*.log
.DS_Store
.idea/
.vscode/
*.swp
*.swo
nginx/snippets/_generated_*.conf
.env
.env.local
+17
View File
@@ -0,0 +1,17 @@
# Changelog
Tous les changements notables de ce projet sont documentés dans ce fichier.
Le format suit [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/) et le projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Reverse proxy Nginx avec page de maintenance conditionnelle par IP (mode whitelist/blacklist).
- Configuration via variables d'environnement : `MAINTENANCE_MODE`, `MAINTENANCE_IP_LIST`, `UPSTREAM_HOST`, `LISTEN_PORT`, `SERVER_NAME`.
- Page de maintenance HTML statique sobre, sans JS, sans dépendance réseau.
- Log dédié `maintenance.log` pour les requêtes bloquées.
- Validation des env vars et de la conf Nginx au démarrage du conteneur.
- Tests d'intégration shell (whitelist autorisée/bloquée, blacklist bloquée/normale, log dédié, syntaxe Nginx).
- Lint shellcheck + `nginx -t`.
- Packaging Docker + docker-compose pour le dev local et les tests.
+31
View File
@@ -0,0 +1,31 @@
FROM nginx:stable-alpine
# Limite envsubst aux seules variables explicitement utilisées dans le template,
# pour préserver les variables Nginx natives ($remote_addr, $host, ...).
ENV NGINX_ENVSUBST_FILTER='^(LISTEN_PORT|SERVER_NAME|UPSTREAM_HOST|MAINTENANCE_MODE)$'
# Le snippet de log est statique et copié une fois pour toutes.
COPY nginx/snippets/maintenance-log.conf /etc/nginx/snippets/maintenance-log.conf
# Le template est traité par /docker-entrypoint.d/20-envsubst-on-templates.sh
# fourni par l'image officielle, après notre hook 10-init.sh.
COPY nginx/templates/default.conf.template /etc/nginx/templates/default.conf.template
# Page de maintenance statique.
COPY public/maintenance.html /usr/share/nginx/html/maintenance.html
# Scripts utilitaires + hook d'initialisation.
COPY scripts/build-ip-list.sh /usr/local/bin/build-ip-list.sh
COPY scripts/entrypoint.sh /docker-entrypoint.d/10-init.sh
RUN chmod +x /usr/local/bin/build-ip-list.sh /docker-entrypoint.d/10-init.sh
# Crée le fichier maintenance.log à l'avance (sinon Nginx ne logge pas tant
# que le fichier n'existe pas) et délègue les logs standards à stdout/stderr.
RUN ln -sf /dev/stdout /var/log/nginx/access.log \
&& ln -sf /dev/stderr /var/log/nginx/error.log \
&& touch /var/log/nginx/maintenance.log
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- "http://127.0.0.1:${LISTEN_PORT:-8080}/" >/dev/null 2>&1 || exit 1
+322
View File
@@ -0,0 +1,322 @@
# PROMPT.md — Reverse Proxy Nginx avec Page de Maintenance Conditionnelle par IP
> Document de spécification autonome du projet. Toute personne (humaine ou agent IA) peut s'y référer pour comprendre, implémenter et maintenir le projet sans contexte externe.
---
## 1. Vision du projet
Construire un **reverse proxy Nginx** qui, selon l'adresse IP du client, **redirige les requêtes vers une application locale** (proxy_pass) **ou affiche une page de maintenance statique** avec un code HTTP `503 Service Unavailable`. Le projet est packagé en Docker, agnostique de l'application backend, et la logique (mode whitelist / blacklist + liste d'IP + upstream) est entièrement pilotée par variables d'environnement au démarrage du conteneur.
Cas d'usage typique : exposer une application en pré-production à une équipe interne uniquement, ou bloquer ponctuellement quelques IP indésirables pendant qu'on travaille sur un correctif.
---
## 2. Stack technique
| Composant | Choix | Confiance | Justification |
|---|---|---|---|
| Reverse proxy | **Nginx** (image `nginx:stable-alpine`) | 🟢 élevée | Standard de l'industrie, déterministe, configuration purement déclarative. |
| Logique conditionnelle | Directives natives `geo` + `map` + `if` (pas de Lua, pas de njs) | 🟢 élevée | Couvre 100 % du besoin sans dépendance tierce. Évite les modules dynamiques. |
| Templating de config | `envsubst` (depuis `gettext`) appelé par l'entrypoint Nginx officiel via `/etc/nginx/templates/` | 🟢 élevée | Mécanisme officiel de l'image Nginx, zéro magie, pas de runtime supplémentaire. |
| Page de maintenance | HTML + CSS inline, system font stack, icône SVG inline, **zéro JavaScript** | 🟢 élevée | Sert même si tout est cassé en aval. Pas de dépendance réseau (pas de Google Fonts, pas de CDN). |
| Packaging | **Docker** + **docker-compose** (pour le dev local et la démo) | 🟢 élevée | Reproductible, mêmes commandes en local et en prod. |
| Entrypoint | Script **bash** (validation des env vars + `nginx -t`) | 🟢 élevée | Échec rapide au boot si conf invalide, plutôt qu'un 502 silencieux. |
| Tests | Scripts **bash + curl** lancés contre un conteneur de test (avec un upstream simulé en `nginx:alpine` ou `python -m http.server`) | 🟢 élevée | Boîte noire = vérité terrain. Pas de framework de test à apprendre. |
| Lint shell | **shellcheck** sur tous les `.sh` | 🟢 élevée | Outil standard, signaux nets. |
| Validation conf | **`nginx -t`** systématique avant tout commit (pre-commit local) | 🟢 élevée | Coût zéro, attrape 90 % des bugs. |
**Aucune techno 🟡 ou 🔵 dans ce projet.** Toutes les briques sont dans la zone de fiabilité maximale d'un agent comme moi : Nginx natif, bash POSIX, Docker, curl. Aucune librairie de niche.
---
## 3. Architecture et structure des dossiers
```
appli_test/
├── PROMPT.md # Ce fichier — source de vérité du projet
├── README.md # Quickstart : comment lancer, comment configurer
├── CHANGELOG.md # Conventional Changelog
├── .editorconfig # Cohérence indentation entre éditeurs
├── .gitignore
├── .dockerignore
├── Dockerfile # Image basée sur nginx:stable-alpine
├── docker-compose.yml # Stack de dev : proxy + upstream factice
├── docker-compose.test.yml # Stack de tests d'intégration
├── nginx/
│ ├── templates/
│ │ └── default.conf.template # Template traité par envsubst au boot
│ └── snippets/
│ └── maintenance-log.conf # Format de log dédié maintenance.log
├── public/
│ └── maintenance.html # Page statique servie en 503 (HTML+CSS inline)
├── scripts/
│ ├── entrypoint.sh # Validation env + génération conf + nginx -t + exec nginx
│ ├── build-ip-list.sh # Transforme MAINTENANCE_IP_LIST="a,b,c" en directives geo
│ └── lint.sh # Lance shellcheck + nginx -t
└── tests/
├── run.sh # Orchestrateur : up compose.test, attend, lance les cas, tear down
├── cases/
│ ├── whitelist_authorized_ip.sh # IP listée → 200 (proxy OK)
│ ├── whitelist_unauthorized_ip.sh # IP non listée → 503 + page maintenance
│ ├── blacklist_blocked_ip.sh # IP listée → 503
│ ├── blacklist_normal_ip.sh # IP non listée → 200
│ ├── log_dedicated.sh # Une requête bloquée apparaît dans maintenance.log
│ └── nginx_syntax.sh # `nginx -t` retourne 0
└── fixtures/
└── upstream/ # Petit serveur factice pour simuler l'app cible
```
**Pourquoi cette structure ?**
- `nginx/templates/` est le chemin que l'entrypoint officiel de l'image Nginx scanne automatiquement pour `envsubst`. On suit la convention upstream.
- `public/` séparé du templating : la page statique n'a pas à passer par envsubst.
- `scripts/` regroupe tout le shell pour qu'il soit lintable et testable d'un coup.
- `tests/cases/` : un fichier `.sh` par scénario, lisible, débogable individuellement.
---
## 4. Spécification fonctionnelle détaillée
### 4.1 Variables d'environnement (contrat d'API)
| Variable | Obligatoire | Exemple | Description |
|---|---|---|---|
| `MAINTENANCE_MODE` | ✅ | `whitelist` ou `blacklist` | Sémantique de la liste. `whitelist` = seules les IP listées passent, le reste voit la maintenance. `blacklist` = inverse. |
| `MAINTENANCE_IP_LIST` | ✅ | `81.92.47.8,10.0.0.42,192.168.1.5` | Liste d'IP **IPv4 individuelles** séparées par virgules. Espaces tolérés autour des virgules. Pas de CIDR, pas d'IPv6 (out of scope MVP). |
| `UPSTREAM_HOST` | ✅ | `127.0.0.1:3000` | Hôte:port de l'application backend. Le proxy fait `proxy_pass http://$UPSTREAM_HOST`. |
| `LISTEN_PORT` | ❌ (défaut `8080`) | `80` | Port d'écoute du conteneur. |
| `SERVER_NAME` | ❌ (défaut `_`) | `app.example.com` | Valeur de `server_name` dans le bloc Nginx. |
### 4.2 Comportement
- **Topologie** : Nginx est **directement exposé** au client. L'IP source = `$remote_addr`. Aucune confiance accordée à `X-Forwarded-For` reçu de l'extérieur.
- **Code HTTP** : la page de maintenance est servie avec **`503 Service Unavailable`** (et un en-tête `Retry-After: 3600` indicatif).
- **Scope** : la maintenance s'applique à **tout le domaine** — chaque path, chaque méthode, y compris assets et appels API. Aucune exclusion (pas de healthcheck particulier, pas d'exception assets).
- **Logging** :
- `access.log` standard pour toutes les requêtes (format combined).
- `maintenance.log` **dédié** : ne contient **que** les requêtes ayant abouti sur la page de maintenance. Format : `$time_iso8601 $remote_addr "$request" "$http_user_agent"`.
- **Pas de bypass token** : le seul critère discriminant est l'IP. Pas de cookie magique, pas de header de contournement.
- **Validation au démarrage** : si `MAINTENANCE_MODE` n'est ni `whitelist` ni `blacklist`, ou si `MAINTENANCE_IP_LIST` est vide, ou si `UPSTREAM_HOST` est absent, l'entrypoint **échoue immédiatement** avec un message clair sur stderr et exit code 1. Pas de fallback silencieux.
### 4.3 Logique Nginx (cœur du projet)
Le mécanisme repose sur deux directives :
```nginx
# Construit dynamiquement par envsubst depuis MAINTENANCE_IP_LIST
geo $ip_in_list {
default 0;
81.92.47.8 1;
10.0.0.42 1;
# ... une ligne par IP, générée au boot
}
# MAINTENANCE_MODE = whitelist → on bloque si pas dans la liste (ip_in_list=0)
# MAINTENANCE_MODE = blacklist → on bloque si dans la liste (ip_in_list=1)
map "$maintenance_mode:$ip_in_list" $is_in_maintenance {
default 0;
"whitelist:0" 1;
"blacklist:1" 1;
}
```
L'astuce : `$maintenance_mode` est lui-même injecté par `envsubst`, et `MAINTENANCE_IP_LIST` est transformée en lignes `geo` par `scripts/build-ip-list.sh` avant que Nginx ne démarre.
---
## 5. Conventions de codage
### 5.1 Nginx
- **Indentation** : 4 espaces, jamais de tabulations.
- **Une directive par ligne**, accolades sur leur propre ligne pour les blocs longs.
- **Commentaires** uniquement quand le *pourquoi* n'est pas évident depuis la lecture. Jamais de commentaire qui paraphrase la directive.
- **Pas de `if` dans les blocs `location`** sauf cas spécifiquement supportés (cf. [If Is Evil](https://nginx.org/en/docs/faq/if_is_evil.html)). On préfère `map` + `try_files` + `return`.
- Toute conf doit passer `nginx -t` ; aucune exception.
### 5.2 Bash
- **Shebang** `#!/usr/bin/env bash` partout.
- `set -euo pipefail` en première ligne après le shebang.
- **Toutes les variables entre guillemets** : `"$VAR"`, `"${ARR[@]}"`.
- Vérification d'arguments en début de script avec message d'usage.
- Tous les scripts `.sh` doivent passer `shellcheck` sans warning.
### 5.3 HTML/CSS (page de maintenance)
- **Un seul fichier** `public/maintenance.html` autonome.
- CSS **inline dans `<style>`**, pas de fichier externe.
- **System font stack** : `system-ui, -apple-system, "Segoe UI", Roboto, sans-serif`.
- **Icône SVG inline** pour l'illustration (pas de `<img>` qui pourrait 404).
- Layout centré (flex), max-width raisonnable (~480px), respiration visuelle.
- Doit être **lisible sans JS et sans réseau**.
### 5.4 Conventional Commits
Format obligatoire :
```
<type>(<scope>): <description courte impérative>
[corps optionnel expliquant le pourquoi]
```
Types autorisés : `feat`, `fix`, `docs`, `refactor`, `test`, `chore`, `ci`.
**❌ Ne JAMAIS ajouter de ligne `Co-Authored-By: Claude ...` dans les messages de commit.** Aucune attribution d'agent IA dans l'historique Git.
### 5.5 Git workflow — **trunk-based**
- Tous les commits vont **directement sur `main`**.
- Pas de branches `feature/*`, pas de pull requests internes.
- Conséquence : chaque commit doit être **atomique, vert, et déployable**. `nginx -t` + `tests/run.sh` doivent passer avant chaque commit.
- Tags Git pour les versions notables : `v0.1.0`, `v0.2.0`, etc.
---
## 6. Outils et plugins Claude Code
Cette section décrit comment un agent Claude Code doit travailler sur ce projet.
### 6.1 Superpowers — skills à utiliser systématiquement
| Skill | Quand l'invoquer |
|---|---|
| `superpowers:brainstorming` | Avant **toute** modification non triviale du comportement (nouvelle env var, nouveau code HTTP, nouvelle exclusion de path). On clarifie l'intention avant le code. |
| `superpowers:test-driven-development` | Pour toute feature comportementale : écrire d'abord un cas de test dans `tests/cases/` qui échoue, puis modifier la conf Nginx pour qu'il passe. |
| `superpowers:systematic-debugging` | Dès qu'un test échoue ou qu'une requête ne se comporte pas comme attendu. Pas de "patch jusqu'à ce que ça marche". |
| `superpowers:verification-before-completion` | Avant de déclarer une tâche terminée : `nginx -t` ✅, `tests/run.sh` ✅, `shellcheck scripts/*.sh` ✅. **Évidence avant assertion**. |
| `superpowers:writing-plans` | Pour toute évolution dépassant 2-3 fichiers (ex : ajout du support CIDR ultérieurement). |
### 6.2 Superpowers — skills **non utilisés** pour ce projet
- `superpowers:using-git-worktrees` : **non applicable**. Le projet est en trunk-based, mono-développeur, scope minimal. Si le projet grossit et que des features parallèles deviennent nécessaires, ce skill pourra être réintroduit — mais pas avant.
### 6.3 Context7
- **À utiliser** quand on a un doute sur une directive Nginx récente, une option de l'image Docker `nginx:stable-alpine`, ou la sémantique de `envsubst`.
- **Inutile** pour le HTML/CSS/bash basique — c'est dans la zone 🟢.
- Préférer Context7 à la recherche web pour toute documentation officielle.
### 6.4 Frontend Design
- **Applicable uniquement** à `public/maintenance.html`. Si on veut faire évoluer le visuel (au-delà du sobre actuel), on peut invoquer ce skill.
- Pour le MVP, le design est intentionnellement simple : pas besoin du skill au démarrage.
### 6.5 Commit Commands
- Tout commit doit suivre les Conventional Commits (cf. § 5.4).
- **Interdiction absolue de `Co-Authored-By` dans les commits.**
- Commits atomiques : un commit = un changement cohérent et testé.
---
## 7. Phases de développement
**MVP en une seule phase** (choix utilisateur). Le projet est suffisamment petit pour qu'un découpage en phases produirait plus de cérémonie que de valeur.
### Phase 1 — MVP complet
- [ ] **Squelette projet** : `Dockerfile`, `docker-compose.yml`, `.gitignore`, `.dockerignore`, `.editorconfig`, `README.md` quickstart, `CHANGELOG.md`.
- [ ] **Page de maintenance** `public/maintenance.html` : HTML+CSS inline, system fonts, icône SVG, layout centré, message sobre en français.
- [ ] **Template Nginx** `nginx/templates/default.conf.template` :
- Bloc `server` écoutant sur `${LISTEN_PORT}`.
- Directives `geo` (générées dynamiquement) et `map` pour la décision.
- `location /` qui :
- sert `/maintenance.html` avec status 503 + `Retry-After` si `$is_in_maintenance = 1`,
- fait `proxy_pass http://${UPSTREAM_HOST}` sinon.
- Log dédié `maintenance.log` conditionné par `$is_in_maintenance`.
- [ ] **Script `scripts/build-ip-list.sh`** : transforme `MAINTENANCE_IP_LIST` en lignes Nginx dans un fichier inclus par le template.
- [ ] **Script `scripts/entrypoint.sh`** : valide les env vars (`MAINTENANCE_MODE` ∈ {whitelist, blacklist}, `MAINTENANCE_IP_LIST` non vide, `UPSTREAM_HOST` défini), appelle `build-ip-list.sh`, lance `nginx -t`, puis `exec nginx -g "daemon off;"`.
- [ ] **Tests d'intégration** `tests/run.sh` + 6 cas dans `tests/cases/` couvrant whitelist (autorisée + bloquée), blacklist (bloquée + normale), log dédié, syntaxe Nginx.
- [ ] **`docker-compose.test.yml`** : stack qui combine le proxy avec un upstream factice (image `python:3.12-alpine` lançant un `http.server` ou un autre `nginx:alpine` qui sert un texte fixe).
- [ ] **`scripts/lint.sh`** : `shellcheck scripts/*.sh tests/run.sh tests/cases/*.sh` + `nginx -t` (via conteneur jetable).
- [ ] **README.md** : usage (`docker run -e MAINTENANCE_MODE=... -e MAINTENANCE_IP_LIST=... -e UPSTREAM_HOST=... -p 8080:8080 image`), exemples docker-compose, comment lancer les tests, comment ajouter une IP.
- [ ] **Validation finale** : tous les tests verts, image Docker construit, README à jour.
---
## 8. Design & UX (page de maintenance)
**Direction esthétique** : sobre avec un peu de style. Pas minimaliste austère, pas branding. Niveau "page d'erreur soignée d'une bonne app indé".
| Élément | Choix |
|---|---|
| Palette | Fond `#fafaf9` (presque blanc cassé), texte `#1c1917` (presque noir), accent `#0891b2` (cyan calme) pour l'icône. |
| Typographie | System font stack, titre 28-32px, corps 16px, line-height 1.6. |
| Icône | SVG inline ~64px représentant un outil (clé, engrenage, ou cône de chantier). Couleur d'accent. |
| Layout | Flex centré horizontalement et verticalement, `min-height: 100vh`, max-width contenu ~480px, padding généreux. |
| Ton | Neutre, en français, rassurant : « Site en maintenance. Nous revenons très vite. » + sous-titre court. Aucune date estimée (pas demandé). |
| Responsive | Mobile-first par défaut (largeurs en pourcentage / max-width). |
| Accessibilité | Contraste AA minimum, `<title>` explicite, `lang="fr"` sur `<html>`. |
---
## 9. Règles impératives
Liste de règles **non négociables**. Tout PR/commit qui les viole doit être refusé.
1. **Aucune dépendance Lua, njs, ou module Nginx tiers.** On reste sur les directives natives.
2. **Aucun JavaScript dans la page de maintenance.** Elle doit fonctionner navigateur texte / curl / w3m.
3. **Aucune requête réseau sortante depuis la page de maintenance** (pas de Google Fonts, pas de CDN, pas d'analytics).
4. **`nginx -t` doit passer avant chaque commit.**
5. **Tous les scripts shell doivent passer `shellcheck` sans warning.**
6. **Tous les tests d'intégration doivent passer avant chaque commit** sur `main`.
7. **Aucun secret en dur** dans l'image, les templates ou les scripts. Tout passe par variables d'environnement.
8. **Aucune écriture sur le disque** par Nginx en dehors des logs (`access.log`, `error.log`, `maintenance.log`).
9. **La page de maintenance retourne strictement `503`**, jamais `200` ni `307`.
10. **La maintenance s'applique à tout le domaine.** Aucune exclusion de path tant que ce n'est pas une exigence explicite et discutée.
11. **Aucune ligne `Co-Authored-By` dans les messages de commit.**
12. **Toute IP doit être validée syntaxiquement** par `build-ip-list.sh` avant d'être injectée dans la conf Nginx (regex IPv4). Une IP malformée → erreur de boot, pas un silence.
13. **Validation explicite à l'entrypoint.** Si une variable obligatoire manque ou est invalide, le conteneur sort en erreur avec un message lisible. Pas de défaut implicite.
14. **Pas de bypass token, pas de cookie magique, pas de header de contournement.** Le seul critère est l'IP.
---
## 10. Stratégie de fiabilité
Cette section décrit comment un agent doit se comporter face à l'incertitude.
### 10.1 Toutes les technos sont 🟢 dans ce projet
Aucun composant 🟡 ni 🔵. Si une évolution future introduit une techno 🟡 (ex : OpenResty/Lua) ou 🔵 (ex : njs, module dynamique exotique), elle doit être :
- explicitement marquée avec son niveau de confiance dans une mise à jour de PROMPT.md,
- accompagnée d'une vérification Context7 pour récupérer la doc officielle à jour,
- isolée dans un commit dédié (pas mêlée à d'autres changements), pour pouvoir être revertée proprement.
### 10.2 Quand un pattern Nginx ne marche pas comme prévu
1. **Lire `error.log`** d'abord. 80 % du temps, il y a un message explicite.
2. **Reproduire avec un cas de test minimal** dans `tests/cases/`.
3. **Ne pas ajouter de `if` dans une `location`** comme rustine — c'est presque toujours le mauvais outil.
4. Si la directive native ne suffit pas, **proposer une alternative** dans un commit séparé et prévenir l'utilisateur avant de la merger.
### 10.3 Vérification systématique avant tout `Done`
Checklist à exécuter mentalement avant de déclarer quoi que ce soit terminé :
- [ ] `docker compose -f docker-compose.test.yml up --abort-on-container-exit` retourne 0 ?
- [ ] `tests/run.sh` affiche `OK` pour chaque cas ?
- [ ] `scripts/lint.sh` ne produit aucune sortie ?
- [ ] `nginx -t` passe sur le template instancié avec un jeu d'env vars représentatif ?
- [ ] `README.md` reflète bien les env vars actuelles ?
- [ ] `CHANGELOG.md` mentionne le changement ?
**Évidence avant assertion** : ne jamais dire « ça marche » sans avoir relancé les tests dans la session courante. Le skill `superpowers:verification-before-completion` formalise cette discipline.
### 10.4 Quand demander à l'utilisateur
- **Toute extension du contrat d'env vars** (nouvelle variable, changement de format).
- **Tout changement de comportement HTTP** (code de retour, en-têtes).
- **Toute exception au scope « maintenance s'applique à tout le domaine »**.
- **Toute introduction d'un composant 🟡 ou 🔵.**
Pour le reste (refactor interne, amélioration de la page de maintenance, ajout de tests), l'agent peut avancer en autonomie tant qu'il respecte les règles impératives du § 9.
---
*Fin du PROMPT.md. Toute évolution de ce document doit être commitée avec un message `docs(prompt): <changement>` et discutée avec l'utilisateur en amont si elle modifie le scope ou le contrat d'API.*
+103
View File
@@ -0,0 +1,103 @@
# Maintenance Proxy
Reverse proxy Nginx qui, selon l'adresse IP du client, **redirige les requêtes vers une application locale** (proxy_pass) **ou affiche une page de maintenance statique** avec un code HTTP `503 Service Unavailable`.
Cas d'usage typique : exposer une application en pré-production à une équipe interne uniquement, ou bloquer ponctuellement quelques IP indésirables pendant un correctif.
> Pour la spécification complète et les règles de contribution, voir [PROMPT.md](PROMPT.md).
---
## Quickstart
### Avec docker-compose (dev local)
```bash
docker compose up --build
# → http://localhost:8080
```
Par défaut, la stack démarre en mode `whitelist` avec `127.0.0.1` autorisée et un upstream factice qui sert une page « UPSTREAM_OK ». Édite `docker-compose.yml` pour brancher ton vraie app.
### Avec docker run
```bash
docker build -t maintenance-proxy .
docker run --rm -p 8080:8080 \
-e MAINTENANCE_MODE=whitelist \
-e MAINTENANCE_IP_LIST="81.92.47.8,10.0.0.42" \
-e UPSTREAM_HOST="host.docker.internal:3000" \
maintenance-proxy
```
---
## Variables d'environnement
| Variable | Obligatoire | Défaut | Description |
|---|---|---|---|
| `MAINTENANCE_MODE` | ✅ | — | `whitelist` (seules les IP listées passent) ou `blacklist` (les IP listées sont bloquées). |
| `MAINTENANCE_IP_LIST` | ✅ | — | Liste d'IPv4 individuelles séparées par des virgules. Espaces tolérés. Ex : `81.92.47.8, 10.0.0.42`. |
| `UPSTREAM_HOST` | ✅ | — | Cible du `proxy_pass` au format `host:port`. Ex : `127.0.0.1:3000`. |
| `LISTEN_PORT` | ❌ | `8080` | Port d'écoute du conteneur. |
| `SERVER_NAME` | ❌ | `_` | `server_name` Nginx. |
Le conteneur **refuse de démarrer** si une variable obligatoire est absente, si `MAINTENANCE_MODE` n'est pas `whitelist` ou `blacklist`, ou si une IP est syntaxiquement invalide.
---
## Comportement
- Réponse `503 Service Unavailable` + en-tête `Retry-After: 3600` quand l'IP doit voir la maintenance.
- Toute le domaine est concerné, sans exclusion (ni assets, ni healthcheck).
- Un log dédié `/var/log/nginx/maintenance.log` (à l'intérieur du conteneur) ne contient **que** les requêtes ayant abouti sur la page de maintenance.
- Pas de bypass token, pas de cookie magique : seul critère = IP source (`$remote_addr`).
- Nginx est censé être directement exposé au client. Aucune confiance accordée à `X-Forwarded-For` reçu de l'extérieur.
### Ajouter ou retirer une IP
1. Modifier `MAINTENANCE_IP_LIST` (env var ou `docker-compose.yml`).
2. Redémarrer le conteneur : `docker compose up -d --force-recreate proxy`.
Pas de hot-reload : les changements de liste exigent un restart (validation et regen du snippet `geo` au boot).
---
## Tests
```bash
./tests/run.sh
```
6 cas couverts :
- whitelist + IP autorisée → 200 (proxy)
- whitelist + IP non autorisée → 503 (maintenance)
- blacklist + IP listée → 503 (maintenance)
- blacklist + IP non listée → 200 (proxy)
- log dédié `maintenance.log` n'est rempli que sur les 503
- `nginx -t` valide la conf templatée
## Lint
```bash
./scripts/lint.sh
```
Lance `shellcheck` sur tous les scripts puis `nginx -t` dans un conteneur jetable.
---
## Architecture
Voir [PROMPT.md](PROMPT.md) §3 pour le détail. En bref :
```
nginx/templates/default.conf.template # template envsubst
nginx/snippets/maintenance-log.conf # log_format dédié
public/maintenance.html # page statique sobre, zéro JS
scripts/build-ip-list.sh # MAINTENANCE_IP_LIST → snippet `geo`
scripts/entrypoint.sh # validation env + génération snippet
Dockerfile # nginx:stable-alpine + assemblage
```
+43
View File
@@ -0,0 +1,43 @@
# Stack de tests d'intégration. Le réseau est fixé pour que l'IP du
# conteneur 'client' soit déterministe (172.28.5.50), ce qui permet
# de jouer scénarios whitelist/blacklist relatifs à cette IP connue.
services:
proxy:
build: .
image: maintenance-proxy:test
networks:
testnet:
ipv4_address: 172.28.5.10
environment:
MAINTENANCE_MODE: "${MAINTENANCE_MODE:-whitelist}"
MAINTENANCE_IP_LIST: "${MAINTENANCE_IP_LIST:-172.28.5.50}"
UPSTREAM_HOST: "upstream:80"
LISTEN_PORT: "8080"
SERVER_NAME: "_"
depends_on:
- upstream
upstream:
image: nginx:stable-alpine
networks:
testnet:
ipv4_address: 172.28.5.20
volumes:
- ./tests/fixtures/upstream:/usr/share/nginx/html:ro
client:
image: alpine:3
networks:
testnet:
ipv4_address: 172.28.5.50
entrypoint: ["/bin/sh", "-c", "apk add --no-cache curl >/dev/null && sleep infinity"]
depends_on:
- proxy
networks:
testnet:
driver: bridge
ipam:
config:
- subnet: 172.28.5.0/24
+22
View File
@@ -0,0 +1,22 @@
# Stack de développement local : reverse proxy + upstream factice servant
# une page identifiable. Pour usage hors-test, ajuster les variables d'env.
services:
proxy:
build: .
image: maintenance-proxy:dev
ports:
- "8080:8080"
environment:
MAINTENANCE_MODE: whitelist
MAINTENANCE_IP_LIST: "127.0.0.1,::1"
UPSTREAM_HOST: "upstream:80"
LISTEN_PORT: "8080"
SERVER_NAME: "_"
depends_on:
- upstream
upstream:
image: nginx:stable-alpine
volumes:
- ./tests/fixtures/upstream:/usr/share/nginx/html:ro
+3
View File
@@ -0,0 +1,3 @@
# Format de log dédié aux requêtes ayant abouti sur la page de maintenance.
# Inclus dans le bloc http via le template default.conf.template.
log_format maintenance_log '$time_iso8601 $remote_addr "$request" "$http_user_agent"';
+49
View File
@@ -0,0 +1,49 @@
include /etc/nginx/snippets/maintenance-log.conf;
# Liste des IP générée au boot par scripts/build-ip-list.sh à partir
# de MAINTENANCE_IP_LIST. Définit la variable $ip_in_list (0 ou 1).
include /etc/nginx/snippets/_generated_geo.conf;
# Décision finale en croisant le mode et l'appartenance à la liste.
# whitelist : on bloque si l'IP n'est PAS dans la liste (ip_in_list=0)
# blacklist : on bloque si l'IP EST dans la liste (ip_in_list=1)
map "${MAINTENANCE_MODE}:$ip_in_list" $is_in_maintenance {
default 0;
"whitelist:0" 1;
"blacklist:1" 1;
}
server {
listen ${LISTEN_PORT};
server_name ${SERVER_NAME};
access_log /var/log/nginx/access.log combined;
access_log /var/log/nginx/maintenance.log maintenance_log if=$is_in_maintenance;
error_log /var/log/nginx/error.log warn;
error_page 503 /__maintenance.html;
# Page statique servie uniquement via error_page (jamais directement accessible).
location = /__maintenance.html {
internal;
root /usr/share/nginx/html;
try_files /maintenance.html =500;
add_header Retry-After 3600 always;
add_header Cache-Control "no-store" always;
}
location / {
# if + return est l'un des seuls usages sûrs de `if` en location (cf. If is Evil).
if ($is_in_maintenance) {
return 503;
}
proxy_pass http://${UPSTREAM_HOST};
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
}
+78
View File
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="noindex">
<title>Site en maintenance</title>
<style>
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background-color: #fafaf9;
color: #1c1917;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
line-height: 1.6;
}
main {
max-width: 480px;
width: 100%;
text-align: center;
padding: 40px 24px;
}
.icon {
width: 64px;
height: 64px;
color: #0891b2;
margin: 0 auto 24px;
display: block;
}
h1 {
font-size: 30px;
font-weight: 600;
margin: 0 0 12px;
letter-spacing: -0.01em;
}
p {
font-size: 16px;
margin: 0 0 8px;
color: #44403c;
}
p.subtle {
font-size: 14px;
color: #78716c;
margin-top: 24px;
}
@media (max-width: 480px) {
h1 { font-size: 26px; }
}
</style>
</head>
<body>
<main>
<svg class="icon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"></path>
</svg>
<h1>Site en maintenance</h1>
<p>Nous effectuons actuellement une opération de maintenance sur le service.</p>
<p>Le site sera de nouveau accessible sous peu.</p>
<p class="subtle">Merci de votre patience.</p>
</main>
</body>
</html>
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env bash
#
# Transforme MAINTENANCE_IP_LIST="1.2.3.4, 5.6.7.8" en un snippet Nginx :
#
# geo $ip_in_list {
# default 0;
# 1.2.3.4 1;
# 5.6.7.8 1;
# }
#
# Toute IP malformée (non IPv4 valide) provoque une sortie en erreur.
# Usage : build-ip-list.sh <fichier_de_sortie>
set -euo pipefail
if [[ $# -ne 1 ]]; then
echo "usage: $(basename "$0") <output_file>" >&2
exit 2
fi
output_file="$1"
if [[ -z "${MAINTENANCE_IP_LIST:-}" ]]; then
echo "ERROR: MAINTENANCE_IP_LIST est vide ou non défini." >&2
exit 1
fi
# Regex IPv4 stricte : chaque octet entre 0 et 255.
ipv4_regex='^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$'
declare -a ips=()
# Découpe sur la virgule, trim les espaces, ignore les entrées vides.
IFS=',' read -ra raw_entries <<< "$MAINTENANCE_IP_LIST"
for raw in "${raw_entries[@]}"; do
ip="${raw// /}"
[[ -z "$ip" ]] && continue
if ! [[ "$ip" =~ $ipv4_regex ]]; then
echo "ERROR: IP invalide dans MAINTENANCE_IP_LIST : '$ip'" >&2
exit 1
fi
ips+=("$ip")
done
if [[ ${#ips[@]} -eq 0 ]]; then
echo "ERROR: MAINTENANCE_IP_LIST ne contient aucune IP exploitable." >&2
exit 1
fi
{
echo "# Généré automatiquement par scripts/build-ip-list.sh — ne pas éditer à la main."
echo "geo \$ip_in_list {"
echo " default 0;"
for ip in "${ips[@]}"; do
printf ' %-15s 1;\n' "$ip"
done
echo "}"
} > "$output_file"
echo "build-ip-list: ${#ips[@]} IP écrites dans $output_file" >&2
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Hook d'initialisation exécuté par l'entrypoint officiel de l'image
# nginx:stable-alpine, AVANT le script de templating envsubst (20-envsubst-on-templates.sh).
#
# Responsabilités :
# 1. Valider les variables d'environnement obligatoires (MAINTENANCE_MODE,
# MAINTENANCE_IP_LIST, UPSTREAM_HOST). Sortie 1 si invalide.
# 2. Appliquer les valeurs par défaut documentées (LISTEN_PORT=8080, SERVER_NAME=_).
# 3. Générer le snippet geo $ip_in_list via build-ip-list.sh.
#
# Le templating envsubst et le `nginx -t` final sont gérés par les scripts
# officiels qui s'exécutent ensuite.
set -euo pipefail
log() { echo "[init] $*" >&2; }
fail() { echo "[init] ERROR: $*" >&2; exit 1; }
# --- 1. Validation des variables obligatoires ---------------------------------
: "${MAINTENANCE_MODE:=}"
: "${MAINTENANCE_IP_LIST:=}"
: "${UPSTREAM_HOST:=}"
case "$MAINTENANCE_MODE" in
whitelist|blacklist) ;;
"") fail "MAINTENANCE_MODE est requis (valeurs : 'whitelist' ou 'blacklist')." ;;
*) fail "MAINTENANCE_MODE='$MAINTENANCE_MODE' invalide (attendu : 'whitelist' ou 'blacklist')." ;;
esac
if [[ -z "$MAINTENANCE_IP_LIST" ]]; then
fail "MAINTENANCE_IP_LIST est requis (liste d'IPv4 séparées par des virgules)."
fi
if [[ -z "$UPSTREAM_HOST" ]]; then
fail "UPSTREAM_HOST est requis (ex: '127.0.0.1:3000')."
fi
# Sanity check léger sur UPSTREAM_HOST : doit contenir un ':' (host:port).
if [[ "$UPSTREAM_HOST" != *:* ]]; then
fail "UPSTREAM_HOST='$UPSTREAM_HOST' doit être au format 'host:port'."
fi
# --- 2. Valeurs par défaut ---------------------------------------------------
export LISTEN_PORT="${LISTEN_PORT:-8080}"
export SERVER_NAME="${SERVER_NAME:-_}"
export MAINTENANCE_MODE
export UPSTREAM_HOST
# --- 3. Génération du snippet geo --------------------------------------------
snippets_dir="/etc/nginx/snippets"
mkdir -p "$snippets_dir"
# Chemin fixé par le Dockerfile (COPY ... /usr/local/bin/build-ip-list.sh).
# Surchargable via $BUILD_IP_LIST_SCRIPT pour le développement local hors conteneur.
build_script="${BUILD_IP_LIST_SCRIPT:-/usr/local/bin/build-ip-list.sh}"
if [[ ! -x "$build_script" ]]; then
fail "Script attendu introuvable ou non exécutable : $build_script"
fi
"$build_script" "$snippets_dir/_generated_geo.conf"
log "Initialisation OK (mode=$MAINTENANCE_MODE, listen=$LISTEN_PORT, upstream=$UPSTREAM_HOST)."
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
#
# Vérifie la qualité du projet :
# 1. shellcheck sur tous les scripts shell.
# 2. nginx -t sur la conf templatée (via un conteneur jetable).
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_ROOT"
shell_files=(
scripts/build-ip-list.sh
scripts/entrypoint.sh
scripts/lint.sh
tests/run.sh
tests/lib.sh
tests/cases/whitelist_authorized_ip.sh
tests/cases/whitelist_unauthorized_ip.sh
tests/cases/blacklist_blocked_ip.sh
tests/cases/blacklist_normal_ip.sh
tests/cases/log_dedicated.sh
tests/cases/nginx_syntax.sh
)
echo "==> shellcheck"
if command -v shellcheck >/dev/null 2>&1; then
shellcheck -x "${shell_files[@]}"
else
# Fallback : shellcheck via Docker si l'outil n'est pas installé localement.
docker run --rm -v "$PROJECT_ROOT:/mnt" -w /mnt koalaman/shellcheck:stable -x "${shell_files[@]}"
fi
echo "shellcheck OK"
echo "==> nginx -t (build d'image + check)"
docker build --quiet -t maintenance-proxy:lint . >/dev/null
docker run --rm \
-e MAINTENANCE_MODE=whitelist \
-e MAINTENANCE_IP_LIST="1.2.3.4,5.6.7.8" \
-e UPSTREAM_HOST="upstream:80" \
-e LISTEN_PORT=8080 \
-e SERVER_NAME=_ \
--entrypoint /bin/sh \
maintenance-proxy:lint \
-c '/docker-entrypoint.sh nginx -t'
echo "nginx -t OK"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Mode blacklist + IP du client présente → page de maintenance (503).
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=../lib.sh
source "$(dirname "$0")/../lib.sh"
restart_proxy blacklist "172.28.5.50"
status="$(curl_status)"
assert_eq "503" "$status" "code HTTP"
body="$(curl_body)"
assert_contains "Site en maintenance" "$body" "corps de la page de maintenance"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Mode blacklist + IP du client absente → l'app upstream est servie (200).
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=../lib.sh
source "$(dirname "$0")/../lib.sh"
restart_proxy blacklist "10.99.99.99"
status="$(curl_status)"
assert_eq "200" "$status" "code HTTP"
body="$(curl_body)"
assert_contains "UPSTREAM_OK" "$body" "corps de la réponse"
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
# Une requête bloquée doit apparaître dans /var/log/nginx/maintenance.log
# avec l'IP du client, et NE DOIT PAS y apparaître en mode passant.
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=../lib.sh
source "$(dirname "$0")/../lib.sh"
# Scénario bloqué (whitelist sans l'IP du client)
restart_proxy whitelist "10.99.99.99"
# Vide le log avant le test pour partir d'un état connu
proxy_exec sh -c ': > /var/log/nginx/maintenance.log'
# Génère une requête bloquée
status="$(curl_status)"
assert_eq "503" "$status" "code HTTP attendu"
# Petit délai pour laisser Nginx flusher le log
sleep 1
log_content="$(proxy_exec cat /var/log/nginx/maintenance.log)"
assert_contains "172.28.5.50" "$log_content" "IP du client dans maintenance.log"
# Vérifie qu'une requête passante (mode blacklist sans IP du client) ne logge PAS
restart_proxy blacklist "10.99.99.99"
proxy_exec sh -c ': > /var/log/nginx/maintenance.log'
status="$(curl_status)"
assert_eq "200" "$status" "code HTTP en mode passant"
sleep 1
log_after_pass="$(proxy_exec cat /var/log/nginx/maintenance.log)"
if [[ -n "$log_after_pass" ]]; then
t_fail "maintenance.log devrait être vide après une requête non bloquée, contient : $log_after_pass"
fi
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env bash
# Vérifie que la configuration Nginx complète passe `nginx -t` une fois
# templatée et le snippet geo généré. Ne dépend pas du proxy "live" :
# lance un conteneur jetable.
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=../lib.sh
source "$(dirname "$0")/../lib.sh"
docker run --rm \
-e MAINTENANCE_MODE=whitelist \
-e MAINTENANCE_IP_LIST="172.28.5.50,10.0.0.42" \
-e UPSTREAM_HOST="upstream:80" \
-e LISTEN_PORT=8080 \
-e SERVER_NAME=_ \
--entrypoint /bin/sh \
maintenance-proxy:test \
-c '/docker-entrypoint.sh nginx -t' >/dev/null 2>&1 \
|| t_fail "nginx -t a échoué sur la configuration générée"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Mode whitelist + IP du client présente → l'app upstream est servie (200).
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=../lib.sh
source "$(dirname "$0")/../lib.sh"
restart_proxy whitelist "172.28.5.50"
status="$(curl_status)"
assert_eq "200" "$status" "code HTTP"
body="$(curl_body)"
assert_contains "UPSTREAM_OK" "$body" "corps de la réponse"
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Mode whitelist + IP du client absente → page de maintenance (503).
set -euo pipefail
# shellcheck source-path=SCRIPTDIR
# shellcheck source=../lib.sh
source "$(dirname "$0")/../lib.sh"
restart_proxy whitelist "10.99.99.99"
status="$(curl_status)"
assert_eq "503" "$status" "code HTTP"
body="$(curl_body)"
assert_contains "Site en maintenance" "$body" "corps de la page de maintenance"
+5
View File
@@ -0,0 +1,5 @@
<!DOCTYPE html>
<html lang="fr">
<head><meta charset="UTF-8"><title>Upstream OK</title></head>
<body><h1 id="upstream-marker">UPSTREAM_OK</h1></body>
</html>
+70
View File
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# Helpers partagés par tests/run.sh et tests/cases/*.sh.
# Ce fichier est sourcé, pas exécuté directement.
# shellcheck shell=bash
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
COMPOSE_FILE="$PROJECT_ROOT/docker-compose.test.yml"
COMPOSE=(docker compose -f "$COMPOSE_FILE" -p maintenance_proxy_tests)
PROXY_URL="http://172.28.5.10:8080/"
t_log() { echo "[test] $*"; }
t_fail() { echo "[test] FAIL: $*" >&2; return 1; }
# Recrée le conteneur proxy avec les env vars passées, puis attend qu'il
# réponde sur PROXY_URL (n'importe quel code HTTP suffit, on veut juste
# qu'Nginx ait fini de booter).
restart_proxy() {
local mode="$1"
local ip_list="$2"
MAINTENANCE_MODE="$mode" \
MAINTENANCE_IP_LIST="$ip_list" \
"${COMPOSE[@]}" up -d --force-recreate --no-deps proxy >/dev/null
local _
for _ in $(seq 1 30); do
if "${COMPOSE[@]}" exec -T client \
curl -s -o /dev/null -w '%{http_code}' --max-time 2 "$PROXY_URL" \
| grep -qE '^(200|503)$'; then
return 0
fi
sleep 1
done
t_fail "le proxy n'a pas démarré dans les 30s (mode=$mode, list=$ip_list)"
}
curl_status() {
"${COMPOSE[@]}" exec -T client \
curl -s -o /dev/null -w '%{http_code}' --max-time 5 "$PROXY_URL"
}
curl_body() {
"${COMPOSE[@]}" exec -T client curl -s --max-time 5 "$PROXY_URL"
}
proxy_exec() {
"${COMPOSE[@]}" exec -T proxy "$@"
}
assert_eq() {
local expected="$1"
local actual="$2"
local label="${3:-valeur}"
if [[ "$expected" != "$actual" ]]; then
t_fail "$label : attendu '$expected', obtenu '$actual'"
fi
}
assert_contains() {
local needle="$1"
local haystack="$2"
local label="${3:-contenu}"
if [[ "$haystack" != *"$needle"* ]]; then
t_fail "$label : '$needle' introuvable dans la sortie"
fi
}
Executable
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
#
# Orchestrateur des tests d'intégration :
# 1. Build l'image proxy.
# 2. Démarre la stack docker-compose.test.yml (upstream + client + proxy initial).
# 3. Exécute chaque tests/cases/*.sh dans un sous-shell.
# 4. Tear down systématique en fin (succès ou échec).
set -euo pipefail
PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$PROJECT_ROOT"
# shellcheck source-path=SCRIPTDIR
# shellcheck source=lib.sh
source "$PROJECT_ROOT/tests/lib.sh"
cleanup() {
t_log "Tear down de la stack de tests..."
"${COMPOSE[@]}" down --remove-orphans --volumes >/dev/null 2>&1 || true
}
trap cleanup EXIT
t_log "Build de l'image maintenance-proxy:test..."
"${COMPOSE[@]}" build --quiet proxy
t_log "Démarrage initial de la stack..."
MAINTENANCE_MODE="whitelist" \
MAINTENANCE_IP_LIST="172.28.5.50" \
"${COMPOSE[@]}" up -d >/dev/null
# Attente que le client ait fini d'installer curl (entrypoint contient apk add).
for _ in $(seq 1 30); do
if "${COMPOSE[@]}" exec -T client sh -c 'command -v curl' >/dev/null 2>&1; then
break
fi
sleep 1
done
if ! "${COMPOSE[@]}" exec -T client sh -c 'command -v curl' >/dev/null 2>&1; then
echo "[test] FAIL: curl indisponible dans le conteneur client" >&2
exit 1
fi
shopt -s nullglob
cases=("$PROJECT_ROOT/tests/cases/"*.sh)
if [[ ${#cases[@]} -eq 0 ]]; then
echo "[test] aucun cas trouvé dans tests/cases/" >&2
exit 1
fi
passed=0
failed=0
failed_names=()
for case_path in "${cases[@]}"; do
case_name="$(basename "$case_path" .sh)"
t_log "$case_name"
if bash "$case_path"; then
t_log " ✓ OK"
passed=$((passed + 1))
else
t_log " ✗ FAIL"
failed=$((failed + 1))
failed_names+=("$case_name")
fi
done
echo
echo "==============================================="
echo " Résultats : $passed OK / $failed FAIL / $((passed + failed)) total"
echo "==============================================="
if [[ $failed -gt 0 ]]; then
echo "Cas échoués :" >&2
for n in "${failed_names[@]}"; do
echo " - $n" >&2
done
exit 1
fi