Skip to content

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.com sert la SPA qui fait un fetch cross-origin vers api.devopsyouss.com/posts/public, CORS strict (origine app. 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 avec api./grafana./argocd., couvert par le cert wildcard *.devopsyouss.com, CNAME auto ExternalDNS, Gateway allowedRoutes: 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 : CORSMiddleware FastAPI 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 ecr devient 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 apply persistent : 1 ressource créée (module.ecr_frontend), 0 destroy/replace sur le back (les moved couvrent le renommage ; seul le tag Name du 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 promote deployed- / 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 + /posts back 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_each du 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 nginx standard (root) : tourne en root, Ă©coute sur 80 privilĂ©giĂ©, incompatible avec un durcissement runAsNonRoot propre.

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 apply persistent → plan = 1 add (ecr_frontend), 0 destroy/replace sur ecr ; puis push develop → build-frontend-candidate + trivy-frontend-image-scan verts, candidate front poussĂ©e dans fastapi-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 si auth est 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 format PostOut que /posts/public ({ Post: {...}, votes }), pas un schemas.Post plat comme POST /posts/. Un premier essai accĂ©dait Ă  detail.post.title directement → undefined silencieux (React n'aurait rien affichĂ©, pas d'erreur bruyante). CorrigĂ© aprĂšs un test curl direct sur /posts/1 qui 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, published garde son dĂ©faut) et deletePost() (DELETE, 204 sans corps). La rĂ©ponse du PUT est un Post plat sans garantie sur la relation owner : le client fusionne title/content dans l'item dĂ©jĂ  affichĂ© au lieu de remplacer l'objet (mĂȘme leçon que le wrapper PostOut de 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.confirm natif : 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.

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.