ADR 028 â 2e workload supply chain : frontend SPA conteneurisĂ©e (2026-06-30)
Statut
AcceptĂ© â vertical slice V1 validĂ©e live le 2026-07-02 (#110). Acte les dĂ©cisions d'architecture de l'issue #109 (build + CI + image scannĂ©e). La couche code/Dockerfile est livrĂ©e en MR2 (#109), le dĂ©ploiement GitOps + routing en #110.
Contexte
Le projet n'expose qu'un seul workload applicatif (l'API FastAPI). Ajouter un frontend sert deux objectifs : un 2e workload pour démontrer la supply chain de bout en bout (build hardened, scan, ECR dédié, GitOps) et une UI qui consomme l'API.
L'angle est 100 % DevOps : le contenu React est volontairement trivial. La valeur démontrée est la chaßne, pas l'applicatif. On veut donc une plateforme solide dÚs le départ (V1) et un contenu qui peut évoluer ensuite (V2) sans retoucher la chaßne DevSecOps.
Décision
Stratégie V1 / V2 (walking skeleton)
- V1 = #109 + #110 : la vertical slice n'est prouvée que live de bout en bout
(image scannĂ©e â dĂ©ployĂ©e â URL qui rĂ©pond). #109 seul livre la moitiĂ© supply-chain
(candidate dans ECR), #110 ferme la slice (deploy GitOps + routing).
Validé live le 2026-07-02 :
app.devopsyouss.comsert la SPA qui fait unfetchcross-origin versapi.devopsyouss.com/posts/public, CORS strict (origineapp.autorisée, toute autre refusée), le feed s'affiche. Détail :incidents/sprints/sprint5.md. - V2 = enrichissement du contenu React uniquement, sans toucher l'infra ni le back.
- Garantie que V2 ne touche pas la chaĂźne = figer trois invariants en V1 :
- Origine de la SPA : sous-domaine
app.devopsyouss.com(cohérent avecapi./grafana./argocd., couvert par le cert wildcard*.devopsyouss.com, CNAME auto ExternalDNS, GatewayallowedRoutes: All). Choix figé maintenant pour ne jamais avoir à changer d'origine aprÚs coup (un changement d'origine = ajout/retrait CORS cÎté FastAPI + refonte du routing = modification de la chaßne). Implémenté en #110. - CORS :
CORSMiddlewareFastAPI restreint Ăhttps://app.devopsyouss.com(least-privilege, pas*), lecture seule pour dĂ©marrer. PosĂ© en #110. - Contrat API : la SPA ne consomme que des endpoints dĂ©jĂ existants (
/posts, lecture seule, public en V1). Tout nouvel endpoint redeviendrait du back, hors V2.
Runtime = nginx-unprivileged
Image runtime nginxinc/nginx-unprivileged (pinnée par digest), uid 101 non-root, écoute
sur le port 8080 (non privilégié), fallback SPA try_files $uri $uri/ /index.html.
Standard industrie pour servir des assets statiques, compatible runAsNonRoot /
readOnlyRootFilesystem, défendable en entretien. Dockerfile multi-stage : node (build
Vite) â nginx-unprivileged (runtime), seuls les assets compilĂ©s passent dans l'image finale
(pas de toolchain Node en production).
Monorepo
Le frontend vit dans frontend/ du repo existant (pas de nouveau dépÎt). Le trivy-fs-scan
de la CI scanne dĂ©jĂ . Ă la racine â frontend/package-lock.json est ramassĂ©
automatiquement (pas de job de scan deps front à créer). Un seul dépÎt, une seule CI, deux
workloads.
ECR isolé (2e instance du module)
Un repository ECR dédié au front (fastapi-eks/frontend), séparé du back
(fastapi-eks/fastapi), pour isoler les cycles de vie d'images (lifecycle, scan, rétention).
Réalisé en instanciant le module ecr une 2e fois (paramÚtre repo_suffix), plutÎt qu'un
refactor for_each risqué sur une ressource ECR contenant les images deployed- servies
(tout destroy/replace = ImagePullBackOff). Le renommage interne de la ressource
(.fastapi â .this) est fait via des blocs moved {} = renommage d'Ă©tat, zĂ©ro
destroy/replace sur le repo back existant.
Conséquences
- Le module
ecrdevient rĂ©utilisable :module.ecr(back) +module.ecr_frontend(front), mĂȘme lifecycle policy Ă 3 rĂšgles (ADR 021), mĂȘme politique de pull EKS. - Au
terraform applypersistent : 1 ressource créée (module.ecr_frontend), 0 destroy/replace sur le back (lesmovedcouvrent le renommage ; seul le tagNamedu repo back est mis à jour in-place, sans recréation). - La chaßne CI (jobs
build-frontend-candidate+trivy-frontend-image-scan) et le déploiement (#110) consomment ce repo. Le promotedeployed-/ write-back GitOps front est traité en #110. - V2 (enrichissement React) ne touche ni Terraform, ni CI, ni back tant que les 3 invariants tiennent.
Alternatives écartées
- Path-based, mĂȘme origine (
app.devopsyouss.com/front +/postsback sur le mĂȘme host) : Ă©viterait CORS, mais forcerait Ă rĂ©organiser la rule HTTPRoute/du back (qui attrape tout, cf #81) â collision de routes, plus risquĂ©. Le sous-domaine rĂ©utilise un pattern dĂ©jĂ rodĂ© (grafana, argocd). - Refactor
for_eachdu module ECR : élégant mais à risque sur un repo persistant contenant les images servies (mauvaise clé d'index = destroy/recreate =ImagePullBackOff). Le 2e appel de module est plus sûr. - DépÎt Git séparé pour le front : ajouterait une 2e CI, un 2e cycle de release, pour un contenu trivial. Le monorepo garde une seule chaßne.
- Image runtime
nginxstandard (root) : tourne en root, écoute sur 80 privilégié, incompatible avec un durcissementrunAsNonRootpropre.
Validation
- Hors infra (MR1) :
terraform fmt/validate,tfsec(0 HIGH, image CI exacte sur copie propre, INC-049),mkdocs build. - Live (clĂŽture #109) :
terraform applypersistent â plan = 1 add (ecr_frontend), 0 destroy/replace surecr; puis push develop âbuild-frontend-candidate+trivy-frontend-image-scanverts, candidate front poussĂ©e dansfastapi-eks/frontend.
Addendum V2 â authentification frontend (#128, 2026-07-03)
Recadré le 2026-07-02 (décision Youssef) : la V2 initialement prévue Sprint 6 est avancée au
Sprint 5. Contenu : login, register, crĂ©ation de posts, votes depuis la SPA â sans toucher
la chaßne DevSecOps ni les 3 invariants ci-dessus. Vérifié avant code que tout tient dans
les endpoints existants : POST /users/ (public), POST /login (form-urlencoded), POST
/posts/ (Bearer), POST /votes/ (Bearer), GET /posts/public (pagination + recherche dĂ©jĂ
servies), CORS allow_methods:*/allow_headers:*. ZĂ©ro changement back/infra â promesse
V1/V2 tenue.
Stockage du JWT : mémoire + sessionStorage
- ĂcartĂ© : cookie httpOnly. Le plus sĂ»r contre XSS, mais exige un changement back
(réponse
Set-Cookie, protection CSRF) â sort de la promesse « V2 = React uniquement ». - ĂcartĂ© : localStorage. Persiste au-delĂ de l'onglet/fenĂȘtre sans bĂ©nĂ©fice pour une dĂ©mo portfolio ; surface XSS identique Ă sessionStorage mais durĂ©e de vie plus large.
- Retenu : état React (source de vérité pour les rendus) + copie sessionStorage (survit à un F5, pas à la fermeture de l'onglet). Compromis assumé pour un projet de démonstration, pas pour une prod avec des données sensibles.
Ăcriture publique assumĂ©e
POST /users/ (register) est dĂ©jĂ public cĂŽtĂ© API â la SPA ne fait qu'exposer un formulaire
sur un endpoint existant, elle n'affaiblit pas la posture. Risque de spam documenté et
accepté pour la démo (pas de CAPTCHA ni de vérification email) ; mitigation = #129 rate
limiting Envoy, créée en backlog, hors scope #128.
Vote UI inclus
POST /votes/ (Bearer) dĂ©jĂ servi par l'API â bouton vote ajoutĂ© Ă la carte post (MR2), aucun
nouvel endpoint requis. L'API ne renvoie aucun flag « j'ai voté » sur un post (ni dans
PostOut, ni ailleurs) : impossible de savoir au chargement du feed si l'utilisateur courant a
déjà voté. Retenu : l'état « voté » du client est déduit des réponses d'erreur de POST
/votes/ â un 409 (vote dĂ©jĂ existant) confirme un vote, un 404 (rien Ă retirer) confirme
l'absence de vote â plutĂŽt que d'ajouter un champ cĂŽtĂ© back (hors promesse V1/V2). ConsĂ©quence
assumée : cet état est local à l'onglet (pas persisté), un F5 réinitialise l'affichage
« non votĂ© » mĂȘme si le vote existe toujours cĂŽtĂ© serveur (le compteur, lui, reste exact).
Découpage 3 MR hors infra
MR1 (#128, auth : login/register/token/logout) â MR2 (Ă©criture : formulaire post + vote) â MR3
(lecture enrichie : pagination/recherche/détail client-side sur GET /posts/{id} authentifié).
Chaque MR se valide en local (docker-compose-dev, cross-origin localhost:3000 â
localhost:8080), la chaßne CI/scan/GitOps est inchangée.
MR1 (auth) : frontend/src/api.js (fetch purs login()/register(), contrat form-urlencoded
vs JSON respecté), frontend/src/auth.js (sessionStorage), frontend/src/AuthForms.jsx
(panneau connexion/inscription). Pas d'auto-login aprĂšs inscription (/users/ ne renvoie pas de
token) : retour à l'onglet connexion. Validé : build Vite OK, Trivy 0 CVE, flux réel
registerâloginâJWT testĂ© via docker-compose-dev (curl direct API + vĂ©rification du prĂ©flight
CORS depuis localhost:3000), bundle JS servi contient le code auth.
MR2 (écriture) : frontend/src/api.js étendu (authFetch() interne portant le header
Authorization: Bearer, createPost(), castVote() â les erreurs portent le status HTTP pour
que l'appelant distingue un 409/404 d'une vraie panne), frontend/src/PostForm.jsx (formulaire
titre/contenu, visible uniquement connecté), App.jsx (bouton vote interactif par post,
votedPostIds en état local). La réponse de POST /posts/ (schemas.Post, owner déjà résolu
par la relation SQLAlchemy) est ré-emballée cÎté client au format { Post, votes: 0 } de
/posts/public pour rĂ©utiliser le mĂȘme rendu, sans re-fetch. ValidĂ© : build Vite OK, Trivy 0
CVE, cycle complet testĂ© via docker-compose-dev (registerâloginâcreate postâvote dir=1â409 sur
rejeuâvote dir=0â404 sur rejeu, contrat API confirmĂ© exactement conforme au code), testĂ© en
direct par Youssef dans le navigateur (localhost:3000, logs confirmant un POST /votes/ 201
depuis Chrome).
MR3 (lecture enrichie) : frontend/src/api.js étendu avec fetchPublicPosts({ search, skip,
limit }) (réutilise les paramÚtres déjà servis par /posts/public, aucun nouveau paramÚtre cÎté
back) et getPostDetail(token, id) (GET /posts/{id}, Bearer). App.jsx ajoute une barre de
recherche (soumission = rechargement de la page 1 avec le terme), un bouton « Charger plus »
(skip avance de PAGE_SIZE=5 tant que la derniÚre page est pleine), et un panneau détail
(un post créé localement via MR2 décale skip d'1 pour éviter un doublon lors d'un « Charger
plus » ultĂ©rieur â mitigation best-effort, pas une garantie stricte sur un flux concurrent).
- Détail réservé aux connectés :
GET /posts/{id}exige dĂ©jĂDepends(oauth2.get_current_user)cĂŽtĂ© back (contrairement Ă/posts/public) â le bouton « DĂ©tail » n'est affichĂ© que siauthest posĂ©, cohĂ©rent avec le contrat existant plutĂŽt qu'une restriction ajoutĂ©e cĂŽtĂ© front. - PiĂšge dĂ©couvert en testant (pas en lisant le code) :
GET /posts/{id}renvoie le mĂȘme formatPostOutque/posts/public({ Post: {...}, votes }), pas unschemas.Postplat commePOST /posts/. Un premier essai accĂ©dait Ădetail.post.titledirectement âundefinedsilencieux (React n'aurait rien affichĂ©, pas d'erreur bruyante). CorrigĂ© aprĂšs un testcurldirect sur/posts/1qui a rĂ©vĂ©lĂ© la vraie forme de la rĂ©ponse. - Pas de nouvelle page dĂ©diĂ©e : le dĂ©tail s'affiche dans un panneau au-dessus du feed (pas de routeur ajoutĂ©, cohĂ©rent avec l'absence de dĂ©pendance de routing dans le projet).
Validé : build Vite OK, Trivy 0 CVE, pagination/recherche/détail testés via docker-compose-dev
(7 posts créés, limit=5&skip=0 â 5 rĂ©sultats, skip=5 â 2 restants, search=Post%203 â 1
rĂ©sultat, dĂ©tail avec token â 200, dĂ©tail sans token â 401 confirmant le gate back), testĂ© en
direct par Youssef dans le navigateur.
Addendum V3 â CRUD complet + habillage DevOps (#130, 2026-07-04)
Deux manques relevés pendant la validation live de la V2 (2026-07-04) : pas de
modification/suppression de ses posts depuis la SPA, et le panneau détail affiché dans un
bloc fixe en haut de page (peu lisible avec plusieurs posts). Comme pour la V2, tout tient
dans les endpoints existants : PUT /posts/{id} et DELETE /posts/{id} (Bearer,
owner-only : 403 si l'utilisateur n'est pas propriĂ©taire) sont dans l'API depuis l'origine â
zéro changement back/infra, promesse V1/V2 toujours tenue.
MR1 â edit/delete + dĂ©tail inline
frontend/src/api.js:updatePost()(PUT, body{ title, content }identique Ă la crĂ©ation,publishedgarde son dĂ©faut) etdeletePost()(DELETE, 204 sans corps). La rĂ©ponse du PUT est unPostplat sans garantie sur la relationowner: le client fusionnetitle/contentdans l'item dĂ©jĂ affichĂ© au lieu de remplacer l'objet (mĂȘme leçon que le wrapperPostOutde la V2 MR3 : ne pas se fier Ă la forme supposĂ©e d'une rĂ©ponse).- Ownership cĂŽtĂ© client = affichage seulement : les boutons Modifier/Supprimer ne sont
rendus que si
owner.email === auth.email, mais c'est l'API qui fait autorité (403 rejoué cÎté serveur sur PUT/DELETE). Le front ne peut pas affaiblir la posture. - Suppression confirmée (
window.confirmnatif : zĂ©ro dĂ©pendance, pattern suffisant pour une dĂ©mo) ; le retrait du post dĂ©cale le curseur de pagination (symĂ©trique de la crĂ©ation). - DĂ©tail inline : le panneau se rend sous le post cliquĂ© (re-clic = repli), plus de bloc fixe en haut de page. Ădition inline de mĂȘme : le formulaire prĂ©-rempli remplace le contenu de la carte le temps de l'Ă©dition.
MR2 â habillage DevOps (logos SVG inline + footer stack)
Logos de la stack (GitLab, ArgoCD, AWS/EKS, Kubernetes, Terraform, Grafana) embarquĂ©s en SVG inline dans le bundle â pas de CDN externe : zĂ©ro dĂ©pendance rĂ©seau au runtime, aucune requĂȘte tierce, surface supply chain inchangĂ©e (le scan Trivy couvre tout ce qui est servi). Footer « stack » cliquable vers les livrables du projet (repo GitLab, doc MkDocs, Grafana, API). La page Ă©tant la carte de visite du projet en entretien, cet habillage sert le portfolio autant que l'esthĂ©tique.