Configuration runtime du frontend (une image, trois environnements)
Comment une SPA compilée peut servir trois environnements sans être rebuildée pour chacun, et pourquoi c'était le dernier verrou avant d'exposer les fronts de dev et de staging. Issue #156.
1. Le problème : Vite compile la config dans le bundle
Vite remplace les import.meta.env.VITE_* par leur valeur au moment du build. Ce
n'est pas une lecture de variable d'environnement au démarrage : c'est une substitution
de texte dans le JavaScript produit.
// avant #156, dans frontend/src/api.js
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
devenait, dans le bundle livré :
const API_BASE_URL = "https://api.devopsyouss.com";
Le frontend/Dockerfile fixait cette valeur par un ARG dont le défaut était la prod,
et la CI (build-frontend-candidate) ne passait aucun --build-arg. Une seule image
frontend est buildée puis promue par digest vers les trois environnements (ADR 028,
ADR 029 D2) : les trois servaient donc une SPA qui parlait à l'API de prod.
Pourquoi c'était bloquant, et pas juste inélégant
Le cloisonnement construit en #137 et #138 (une database, un secret et un rôle IRSA par env) protège ce que fait le serveur. Ici c'est le navigateur qui choisit sa cible : un front de dev exposé aurait écrit dans les données de prod depuis le poste de l'utilisateur, en contournant toute l'isolation côté cluster. C'est SEC-006 à l'identique, déplacé du backend vers le client.
Les HTTPRoute des overlays frontend/dev et frontend/staging étaient donc
supprimées ($patch: delete) : la protection était une absence, pas une
restriction. Voir Exposition involontaire.
2. La solution : lire la config au démarrage, pas au build
L'URL sort du bundle et devient un fichier servi à côté de lui.
flowchart LR
subgraph img["Image unique (promue par digest)"]
bundle["bundle JS<br/>aucune URL d'API"]
end
subgraph pod["Pod frontend-dev"]
cm["ConfigMap frontend-config<br/>(overlay dev)"] -->|monté RO| cfg["/config/config.js<br/>window.__ENV"]
end
bundle -->|"window.__ENV?.API_BASE_URL"| cfg
cfg -->|fetch navigateur| api["api-dev.devopsyouss.com"]
Trois pièces, aucun script de démarrage :
Le fichier, généré par overlay et donc différent par env :
// k8s/overlays/frontend/dev/config.js
window.__ENV = { API_BASE_URL: "https://api-dev.devopsyouss.com" };
Son chargement, avant le bundle dans frontend/index.html :
<script src="/config/config.js"></script>
<script type="module" src="/src/main.jsx"></script>
Un <script> classique est exécuté immédiatement, alors qu'un type="module" est
différé par le navigateur : window.__ENV est donc toujours prêt quand le bundle le lit.
Sa lecture, dans frontend/src/api.js :
export const API_BASE_URL = window.__ENV?.API_BASE_URL ?? window.location.origin;
3. Pourquoi un ConfigMap monté plutôt qu'un entrypoint
L'approche classique est un entrypoint.sh qui génère config.js au démarrage depuis
une variable d'environnement. Elle a été écartée :
| ConfigMap monté (retenu) | entrypoint.sh | |
|---|---|---|
| Écriture au démarrage | aucune | il faut écrire dans html/ |
readOnlyRootFilesystem: true |
intact | à contourner par un emptyDir |
| Image | nginx-unprivileged inchangée |
ENTRYPOINT custom à maintenir |
| Rollout au changement d'URL | automatique (hash kustomize) | à déclencher soi-même |
Le configMapGenerator de Kustomize suffixe le nom du ConfigMap par un hash de son
contenu et réécrit la référence dans le Deployment. Changer l'URL d'un env change le
hash, donc le nom, donc le podspec : les pods roulent tout seuls. Un ConfigMap monté à
nom fixe, lui, se serait mis à jour dans le volume sans que nginx ni le navigateur ne le
sachent.
# k8s/frontend/deployment.yaml — le nom est réécrit par l'overlay
volumes:
- name: runtime-config
configMap:
name: frontend-config
4. Le repli, et pourquoi il ne pointe pas sur la prod
Si config.js est absent (ConfigMap non monté, npm run dev), window.__ENV est
undefined et le front se rabat sur window.location.origin : il tape sur lui-même et
échoue en 404, ce qui se voit tout de suite.
Un défaut sûr n'est pas un défaut qui marche
Le défaut précédent — l'URL de prod — était pratique : l'image fonctionnait toujours, partout. C'est précisément ce qui rendait la faute invisible, puisqu'un front mal configuré se connectait quand même, à la mauvaise base. Un repli qui échoue bruyamment en local vaut mieux qu'un repli silencieux vers la production.
Le location = /config/config.js de nginx.conf répond no-store : le bundle est
immuable et cachable, mais un config.js en cache navigateur ferait survivre l'URL d'API
précédente après un changement d'env.
5. En local, le même mécanisme
Le docker-compose-dev.yaml ne passe plus de build-arg : il monte le répertoire
frontend/local-config/, qui joue le rôle du ConfigMap.
volumes:
- ./frontend/local-config:/usr/share/nginx/html/config:ro
Le mount porte sur le répertoire, pas sur le fichier : un bind-mount de fichier unique
ne suit pas les réécritures d'un éditeur (qui remplace l'inode au lieu de l'éditer). Pour
viser une autre API, éditer frontend/local-config/config.js et redémarrer le conteneur,
sans rebuild.
6. Ce que ça débloque
- Les overlays
frontend/devetfrontend/stagingont retrouvé leur HTTPRoute :app-dev.devopsyouss.cometapp-staging.devopsyouss.comsont exposés. - Le CORS côté API était déjà prêt :
ALLOWED_ORIGINSsuit le front du même env dans les overlaysfastapi/{dev,staging}depuis #138, en prévision de ce jour. - L'image reste unique et promue par digest : la propriété d'ADR 029 D2 (ce qui est déployé en prod est exactement ce qui a été scanné) est préservée, pas contournée.
Ce que ça ne fait pas
Rien n'empêche techniquement d'écrire l'URL de prod dans le config.js de dev : c'est
un fichier du dépôt, la garantie est celle de la revue de MR. La barrière dure reste
celle de #137/#138 côté serveur (database, secret et rôle IAM distincts), qui elle ne
dépend d'aucun fichier de configuration du front.