FR: Interface utilisateur agnostique de son environnement
Au moment où j’écris ces lignes, je travaille sur une application Angular / Spring Boot. Homme à tout faire (a.k.a développeur fullstack), me voilà à mettre les mains dans un framework Front que je connaissais vaguement, VueJS ayant depuis longtemps gagné mon coeur pour mes projets personnels.
Quoi qu'il en soit, que ce soit de l’Angular, du Vue, du React, du Svelte, du Moon ou que sais-je, une problématique revient sans cesse : la gestion des environnements.
Venant du monde des backends, cette gestion se fait assez facilement. L’idée étant de construire une image Docker unique - figée presque - correspondant à notre application. L’injection de la configuration d’un environnement se fait à l’exécution de l’image, principalement par variables d’environnement. On retrouve ce principe dans “The Twelves-Factor App”.
Dans notre projet, la problématique des environnements s’est posée pendant la création de notre CI/CD. Pour le backend Springboot, pas de problème donc, on applique les principes des “12 factors” et voilà. Côté Angular en revanche...
Ce que l’Internet proposait pour la partie front m’a décontenancé. Il fallait prévoir 1 fichier par environnement pour définir les URL, les clés d’API, parfois les credentials (notez qu’on peut toucher une problématique de sécurité que je n’aborderai pas dans cet article). Il fallait aussi prévoir la construction d’une image docker par environnement. En soi, rien de plus logique dans le sens où la construction d’un front représente l’état tel qu’il sera délivré côté client. Mais cette manière de faire était tout bonnement incompatible avec la CI/CD que mon équipe et moi-même avions imaginée. Il fallait que ça reste simple. Il fallait que la construction du front et du back aient leur spécificité tout en restant agnostique des environnements pour ne pas se retrouver avec un pipeline qui exécute tel ou tel job supplémentaire parce que nous sommes en pré-production ou en production.
Pour un développeur Java comme moi, la frustration est trop grande : ce qui change d’un environnement à un autre devrait être variable.
Et si nous arrivions à résoudre notre problématique en reprenant le même procédé que le backend ? Parce que le front est exécuté côté client, cela empêche-t-il réellement de se rendre agnostique de l’environnement dans lequel il est déployé ?
Changer l’usage des fichiers d’environnement
On retrouve communément dans les frameworks front des fichiers par environnement pour variabiliser ce qui change d’un environnement à l’autre. Ces fichiers sont statiques pour le développement et servent à produire des sorties différentes : le build pour de la production sera alimenté par le fichier environment.prod.ts
alors que celui de la pré-production sera alimenté par le fichier environment.preprod.ts
.
Ceci est valable pour Angular mais aussi pour les autres frameworks. Certains d’ailleurs utilisent des fichiers .env
qui alimentent le build sous la forme de variable d’environnement. Malheureusement, les fichiers .env
sont également statiques et ne servent qu’à l’usage du développeur.
Ici, nous souhaitons rediriger l’utilisateur vers le portail d’une autre équipe selon des conditions qui nous importent peu pour l’exemple. Bien évidemment, l’URL de ce portail dépend de l’environnement dans lequel on se trouve : portal-prod.entreprise.com pour la production, portal-re7.entreprise.com pour la recette, et j’en passe.
Plutôt que de créer un fichier environment.ts
par environnement, je vous propose donc de n’utiliser qu’un et un seul fichier, celui de production, d’une part parce que la production est l’environnement essentiel (j’ai envie de dire, le seul qui compte vraiment), mais aussi parce que le sujet de cet article est de variabiliser des fichiers, alors variabilisons !
export const environment = {
production: true,
portal: {
url: '${PORTAL_URL}'
},
// ... some other variables ...
};
// environment.prod.ts
Désormais, au build de notre application, nous obtenons des fichiers javascript prêts à l’utilisation, à une exception près : portal.url vaut ${PORTAL_URL} et n’est donc pas exploitable en l’état par nos utilisateurs finaux.
... some minified js stuff ...
{portal:{url:"${PORTAL_URL}"}}
... some other minified js stuff
// main-es.some-haschode-for-prod.js
L’étape suivante est donc de valoriser cette variable.
Valoriser les variables au déploiement du conteneur
La construction de l’image de notre frontend se fait au travers d’un Dockerfile, somme toute équivalent à ce que l’on peut trouver sur le web. Une image nginx, un entrypoint, la copie du front préalablement construit, un peu de configuration et de partage de droits et voilà.
FROM nginx:1.16.0-alpine
# Get the wrapper to launch the application
COPY ./entrypoint.sh /entrypoint.sh
# Copy artifact build from the 'build environment'
COPY ./dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/nginx.conf
# Add permissions for nginx user
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /etc/nginx/conf.d && \
chown -R nginx:nginx /entrypoint.sh
RUN chmod +x /entrypoint.sh
RUN chmod a+w /usr/share/nginx/html
# Expose port of nginx server
EXPOSE 8080
# Run as nginx
USER nginx
# Run the wrapper
ENTRYPOINT ["/entrypoint.sh"]
// Dockerfile
Ce qui va plutôt nous intéresser, c’est le contenu de ce fameux wrapper entrypoint.sh
. Parce que c’est bel et bien dans ce fichier que tout va se jouer.
#!/bin/sh
# Replace env variables in static files
set -a
VARIABLES_TO_FILL=`env | awk -F = '{printf " ${%s}", $1}'`
for file in $(find /usr/share/nginx/html/main-*.js -type f)
do
envsubst "$VARIABLES_TO_FILL" < $file > $file.tmp && mv $file.tmp $file
done
set +a
# Run nginx
nginx -p /tmp
// entrypoint.sh
Notez que dans cet exemple, j’ai simplifié la provenance des variables d’environnement disponibles avec la commande env.
Que se passe-t-il donc dans cet entrypoint ? Les variables d’environnements sont récupérées et présentées sous la forme ${MA_VARIABLE}
. Ensuite, on utilise la commande shell envsubst pour sourcer les fichiers qui contiennent des variables d’environnement.
Par exemple, si un fichier contient la phrase Hello ${ADMIN_NAME} (le format $ADMIN_NAME est aussi pris en compte) et qu’une variable d’environnement ADMIN_NAME vaut Petit Lapin. Alors le fichier en sortie de la commande envsubst contiendra Hello Petit Lapin.
Dans notre cas d’usage, les fichiers que l’on vient manipuler sont les main-*.js
produits par le build Angular, et qui représentent le javascript complet de l’application front.
De fait, si on monte le conteneur avec comme variable d’environnement PORTAL_URL valorisée à https://portal-prod.entreprise.com, on obtient ceci :
... some js stuff ...
{portal:{url:"https://portal-prod.entreprise.com"}}
... some other js stuff
// main-es.some-haschode-for-prod.js
Nos utilisateurs finaux ont désormais accès à cette URL et le tour est joué !
Revaloriser au redémarrage
A ce stade, la solution est opérationnelle. Que se passerait-il en revanche si on éteignait le conteneur et qu’on le rallumait avec des nouvelles valeurs pour nos variables d’environnement ?
Quand on redémarre un conteneur, c'est-à-dire sans le détruire et le reconstruire, on récupère l’état qu’il avait avant qu’il n’ait été éteint. De fait, notre main.js
ne peut plus être revalorisé par nos variables d’environnement.
En soi, ce cas ne devrait pas se produire dans un contexte de CI/CD et de mise en production. D’autant que la philosophie du conteneur jetable nous pousserait à plutôt détruire le conteneur que tenter de changer son comportement.
Mais, si par le plus grand des hasards, nous souhaitions absolument résoudre ce comportement, nous pourrions passer par du templating. C'est-à-dire qu’au lieu de variabiliser directement le main.js
, nous pourrions créer une copie variabilisée d’un main.template.js
. Cette copie deviendrait le nouveau main.js
.
Conclusion
En définitive, variabiliser l’environnement du frontend à l’exécution nous permet d’uniformiser notre déploiement de stack et d’utiliser les paradigmes des “12 factors”.
En effet, au delà de rendre le front agnostique de l’environnement dans lequel il se déploie, nous avons supprimé les spécificités que nous aurions pu avoir : des étapes de build différentes, une charge technique lors de la mise en place d’un nouvel environnement, une charge supplémentaire à l’exécution de la CI (quel job faut-il exécuter et quand ?). On gagne donc en souplesse et en portabilité.
Un autre atout à cette solution est qu'elle fonctionne qu’importe le framework front utilisé, la logique restant la même : on met des noms de variable, le build produit du javascript, et c’est à l’exécution du conteneur que la magie opère.