(english version)

Genèse du projet

Le projet Vauban a été conçu dans le cadre de mon travail à Diabolocom. Le but du projet était de moderniser un composant déjà existant à Diabolocom, à savoir la gestion des images pour les serveurs. En effet, les serveurs et machines virtuelles bootent en PXE sur une image. Cette image, générée à partir d’une machine dédiée appelée master, contient tous les programmes et la configuration pour les serveurs ou les machines virtuelles qui booteront dessus. Il y a autant de masters qu’il y a de “types” de serveurs. Par exemple, nous aurons un master “postgresql” qui contiendra l’installation et la configuration pour tous les serveurs de base de données, toutes ces VM booterons donc sur une même image tirée du master. On a donc un master pour les gateways, pour les logs, les noeuds backends, etc.

Les serveurs bootent ensuite en PXE sur cette image (voir plus bas pour plus d’explications), l’administration est en réalité assez analogue à la gestion de conteneurs, puisqu’il est possible de faire une analogie entre master et image docker, et VM et conteneur (dans certaines mesures).

Cette technique présente plusieurs avantages :

  • possibilité de revenir en arrière de manière très nette : il suffit de boot sur une ancienne version
  • visualisation des modifications apportées en live simplifiées (voir plus bas)
  • provisionnement de VM complet

La manière d’administrer ces masters, et la génération des images à partir du master, présentaient plusieurs problèmes que le projet Vauban cherche à résoudre.

Problématiques et résolutions

Le problème du master stateful

L’un des principaux problèmes avec ce système de génération était la nécessité d’avoir une VM “master” par type de master que nous souhaitions avoir. Créer un nouveau type d’image ? Il faut d’abord créer une nouvelle VM, et ce manuellement. L’image master était construite à partir de la VM master.

On a donc des enregistrements DNS, DHCP et netbox pour ces VM master, des VM qui tournent et ne font fondamentalement rien (tout en prenant néanmoins de la RAM), pour pouvoir en tirer occasionellement une image.

Le provisionnement était manuel (c’est à dire que toutes les ressources de la VM doivent être créées manuellement, la VM doit être installée manuellement via l’installateur de debian), ce qui était déjà un problème en soi pour la flexibilité.

Cependant, le vrai problème était celui de la gestion de l’IaC (Infra as Code).

Le problème de l’IaC avec des VM masters

Diabolocom utilise Ansible intensément pour toute la gestion de l’infra. Un “problème” connu et inhérent au fonctionnement d’Ansible est la gestion de la suppression.

Ansible est stateless, contrairement à Terraform par exemple. Dans l’idée, on déclare une ressource, que l’on souhaite avoir dans un état défini. Les ressources qui ne sont pas définies par Ansible sont ignorées. C’est un avantage, mais aussi un inconvénient.

Prenons par exemple la gestion des utilisateurs. En partant de Debian 11, vous disposez déjà d’un certain nombre d’utilisateurs pré-existants. Si vous souhaitez ajouter avec Ansible le vôtre, vous pouvez utiliser le module user ainsi :

- name: Add user zarak
  user:
    name: zarak
    state: present

Cette tâche Ansible va créer l’utilisateur zarak s’il n’existe pas, ou ne rien faire s’il existe déjà (idempotence).

Maintenant pour supprimer cet utilisateur, je ne peux pas me contenter de juste supprimer cette tâche, je dois explicitement demander la suppression de l’utilisateur ainsi :

- name: Ensure user zarak doesn't exist
  user:
    name: zarak
    state: absent

Maintenant imaginons que cette étape de suppression explicite n’ait pas été écrite lors d’un gros changement sur notre Ansible : l’utilisateur existera toujours sur la machine car jamais explicitement supprimé.

On peut étendre le raisonnement avec d’autres choses que les utilisateurs. La machine master étant stateful, on pourrait imaginer une backdoor installée manuellement sur la machine qui ne sera jamais retirée, un paquet installé via apt (via Ansible ou non, peu importe) qui ne serait jamais désinstallé, etc.

D’autres problèmes

Ce système présentait aussi d’autres problèmes qui ne seront pas détaillés ici, mais en bref :

  • La gestion de la configuration problématique
  • Le fonctionnement de l’initramfs
  • Les scripts pour booter ou générer les images
  • Les mises à jour

Solutions apportées par Vauban

L’idée de Vauban est de repartir systématiquement de zéro (depuis une image officielle “raw” de debian), et d’y appliquer les playbooks Ansible nécéssaires pour arriver à l’image désirée. Il n’y a pas d’éléments stateful, de restes d’une désinstallation partielle, … Pas besoin non plus d’avoir une VM qui tourne pour ça. Grâce à chroot(1) par exemple – pour n’en citer qu’un – nous pouvons créer des images n’importe-où : en local sur nos machines, ou bien dans gitlab-CI.

Ce système, en plus de résoudre les problématique présentées ci-dessus, propose également d’autres éléments pertinents dans le cadre de la gestion des images, et seront présentés plus en détails dans la suite de cet article.

Les prérequis

Avant de comprendre le projet et son fonctionnement, il s’agit de comprendre les technos employées et utilisées conjointement pour arriver au produit fini.

PXE

L’élément central du système de boot ici est l’utilisation de PXE.

Bien que fondamentalement non-nécessaire pour Vauban, c’est la méthode qui avait été retenue par les équipes précédentes pour gérer les VM et serveurs. Vauban doit donc pouvoir génerer des images qui fonctionnent en PXE, et en pratique c’est l’approche qui a été retenue.

Actuellement Vauban ne gère que des images ayant pour vocation d’être boot en PXE. Elles doivent surement pouvoir fonctionner aussi avec un boot local, mais ça n’a jamais été testé, et de la configuration précise pour le boot PXE est apportée dans le projet.

Sans rentrer dans tous les détails de PXE, l’idée est de pouvoir boot depuis le réseau.

L’idée classique du boot d’un serveur/VM/laptop/whatever est d’avoir en local un disque dit “bootable”, c’est à dire contenant à minima une partition de boot et une partition “root”, et d’avoir le BIOS/UEFI qui s’occupe de démarrer dessus.

En PXE, vous ne démarrez non pas sur un disque local, mais sur des ressources en réseau. Pour cela, vous devez avoir une carte réseau compatible, le BIOS/UEFI configuré pour boot en PXE, et un serveur DHCP.

Vous récupérez une adresse IP par le serveur DHCP, ainsi que certaines instructions spécifiques à PXE (toujours via DHCP) concernant l’adresse d’un serveur à contacter et d’un fichier à télécharger, en TFTP par exemple.

L’ordinateur s’occuper de télécharger ce fichier, ici un bootloader réseau (grubnet). Ce bootloader va ensuite s’occuper de télécharger notre kernel (vmlinuz) et un initramfs (voir plus bas).

L’étape d’après consiste ensuite à lancer le kernel, et lui donner notre initramfs, et ensuite le processus de boot peut continuer en fonction de la configuration de notre initramfs.

L’idée derrière est par conséquent la possibilité de ne pas avoir de disque dur dans la machine par exemple, mais aussi de pouvoir centraliser en un point unique ce sur quoi les machines vont boot.

Le fait de ne pas avoir l’OS dans un disque dur signifie que l’OS sera en RAM. Tous les changements qui ne seront pas sauvegardés sur un stockage externe seront perdus au reboot.

Le PXE est utilisé notamment à EPITA pour les workstations des étudiants. Le CRI s’occupe de créer l’image sur laquelle les étudiants pourront travailler (ou passer un examen), et la même image est disponible sur tous les postes grâce à PXE. Si un étudiant casse l’OS, un reboot et c’est reglé. L’identicité des OS est également assurée afin d’éviter les “ça marche sur telle machine mais pas sur celle-là”, ou pour éviter la triche en examen par exemple.

Comment Linux boot

Les composants

Savoir brièvement comment Linux boot est fondamental pour comprendre le projet.

Essentiellement, 3 ressources sont nécessaires :

  • Un kernel (vmlinuz)
  • Un rootfs
  • Un initramfs

Le kernel est tout simplement linux dans notre cas présent.

Le rootfs est notre OS à proprement parler, c’est ce qui sera mount sur /. Si l’on peut avoir /home ou /var mounté sur d’autres disques, / (votre rootfs) doit contenir le minimum d’un OS classique (Debian par exemple), donc /etc/, /usr/, etc …

La taille du rootfs peut ainsi être relativement conséquente, puisqu’elle varie de quelques centaines de MiB à quelques GiB

L’initramfs est un élément un peu moins connu, et est par conséquent détaillé dans (une section entière de cet article.

Un initramfs n’est en réalité pas nécessaire pour boot, mais il le devient dans beaucoup de setup, comme par exemple si le rootfs est chiffré, ou si le rootfs s’obtient en réseau par torrent par exemple. Il est donc pratique et commun de dire qu’il est en réalité nécessaire.

Bien entendu il existe des exceptions, pour les systèmes embarqués par exemple, mais les descriptions faites ici tendent à rester assez généralistes.

Le boot

Lors du processus de boot, le kernel est chargé en mémoire, tout comme l’initramfs (d’où le RAM dans initramfs). Le kernel s’occupe de setup tout un tas de choses qui ne seront pas détaillées ici, puis lance le processus init de l’initramfs.

L’initramfs et son processus init “fait des choses”, et passe le flambeau au rootfs une fois qu’il a fini. Une fois que le processus init du rootfs a fini de préparer les dernières choses et de lancer les services à lancer, on peut considérer le processus de boot comme terminé.

Le processus init par défaut sur les distributions les plus répandues – dont Debian – est le plus souvent Systemd.

Systemd au lancement s’occupe – entre autres – de lancer les services qui ont été enabled, de lancer les unités (.mount, certaines .target, etc), … Il est possible d’obtenir quelques infos avec systemctl list-units --all, systemctl list-unit-files et systemctl status.

L’initramfs

L’initramfs est une machinerie qui peut sembler au premier abord assez complexe et floue. Rassurez vous, c’est également le cas au second et troisième abords. Son rôle est de préparer tout ce qui est nécessaire pour donner la main au rootfs.

Pour bien comprendre le but de l’initramfs, le mieux reste encore de regarder quelques exemples concrets :

  • Comment boot sur une partition/rootfs chiffrée ?

    Linux n’est pas capable de déchiffrer “tout seul” une partition chiffrée, il semble donc difficile de trouver sur une partion chiffrée comment boot dessus. L’initramfs intervient ici en étant non-chiffré (linux peut ainsi le lancer), et en ayant un init process capable de demander un mot de passe à l’utilisateur, et de l’utiliser pour déchiffrer le rootfs. Une fois le rootfs déchiffré, l’initramfs laisse place au rootfs.

  • Comment boot sur un filesystem exotique ?

    Si pour une raison ou pour une autre on décide d’utiliser autre chose comme filesystem que ceux supportés nativement par Linux (ext4, xfs, …), on peut avoir besoin de charger un module kernel spécifique pour notre rootfs. L’initramfs va donc charger le module kernel, pour nous permettre ensuite de mount le rootfs, et enfin basculer dessus.

  • Comment boot sur un rootfs situé sur le réseau ?

    Par exemple, si par le plus grand des hasards, on cherche à booter sur un rootfs qui se trouve en dehors de notre machine, quelque part sur le réseau, nous aurions besoin de monter une stack réseau (choper une adresse IP par exemple), avoir de quoi télécharger le rootfs via un protocole réseau (HTTP, BitTorrent, …), puis savoir quoi faire avec le rootfs téléchargé avant de lui donner la main. Linux ne pouvant faire tout ça tout seul, on retrouve une fois de plus dans une situation où l’initramfs est nécessaire.

L’initramfs est une archive contenant normalement le strict minimum pour pouvoir effectuer l’une ou les étapes mentionnées, nécessaires au processus de boot. L’archive est donc de taille réduite : quelques MiB voire dizaines de MiB.

Assez souvent, l’initramfs est généré par un outil (initramfs-tools, dracut, mkinitcpio, …) qui détecte ce dont la machine actuelle a besoin pour boot, et créer une archive spécifique pour notre installation.

Il est possible d’influer sur la création de l’initramfs via la config de l’outil le générant, ou grâce à un système de hooks, très commun dans les initramfs.

Le code exécuté dans un initramfs est le plus souvent des scripts shell (bash, ash, …), par la nature des opérations réalisées (beaucoup d’appels à des binaires “système”)

L’initramfs permet de conserver la généricité du Kernel, tout en permettant d’avoir des systèmes fonctionnant assez différements les uns des autres, avec leurs besoins particuliers.

On peut également trouver la dénomination initrd. Bien qu’il existe quelques différences entre initramfs et initrd, leur rôle reste sensiblement le même, et il est possible d’utiliser l’un ou l’autre. L’initramfs reste en revanche à privilégier puisque plus simple à utiliser. La différence principale entre les deux reste la manière de stocker les données :

  • l’initrd est un block device en RAM, nécessite donc d’être mount et de posséder un filesystem (ext4 par exemple).
  • l’initramfs est une archive, au format cpio(1). Il suffit donc d’extraire l’archive pour accéder au contenu. Le kernel s’occupe de mount un tmpfs dans lequel il va extraire l’initramfs. Il n’y a donc pas besoin de filesystem, tmpfs étant utilisé ici et étant un filesystem virtuel purement en RAM, dans le kernel

Les étapes de l’initramfs

Les initramfs suivent généralement une logique assez similaire, une suite d’étapes identiques. Regardons en détails les étapes d’un initramfs en prenant par exemple un initramfs généré par mkinitcpio :

$ file initramfs.img
initramfs.img: gzip compressed data, max compression, from Unix, original size modulo 2^32 155400192
$ mv initramfs.img initramfs.img.gz && gunzip initramfs.img.gz
$ file initramfs.img
initramfs.img: ASCII cpio archive (SVR4 with no CRC)
$ cpio -i < initramfs.img
cpio: dev/console: Cannot mknod: Operation not permitted
cpio: dev/kmsg: Cannot mknod: Operation not permitted
cpio: dev/null: Cannot mknod: Operation not permitted
cpio: dev/random: Cannot mknod: Operation not permitted
cpio: dev/urandom: Cannot mknod: Operation not permitted
303516 blocks
Les erreurs lors de l'extraction sont normales. L'opération a été faite en utilisateur normal, non privilégié, et la création de char device avec mkdnodat(2) n'est pas possible sans les permissions appropriées. On peut regarder avec :
$ strace -Z -e trace='!lstat' cpio -i < initramfs.img
...
mknodat(AT_FDCWD, "dev/urandom", S_IFCHR|0644, makedev(0x1, 0x9)) = -1 EPERM (Operation not permitted)
cpio: dev/urandom: Cannot mknod: Operation not permitted
...
Cette page contient également des informations sur les char devices et major/minor dans linux
$ ls
bin          etc             initramfs.img      lib       run   usr
buildconfig  hooks           initramfs.img.zst  lib64     sbin  var
config       init            keymap.bin         new_root  sys   VERSION
dev          init_functions  keymap.utf8        proc      tmp
$ # we're in the extracted initramfs, not our rootfs
$ pwd
/tmp/initramfs-working-dir
$ file init
init: Neil Browns ash script, ASCII text executable
$ cat init  # The file here isn't accurate. It has been curated for demonstration purposes.
#!/usr/bin/ash

. /init_functions

mount_setup

# parse the kernel command line
parse_cmdline </proc/cmdline  # [Redactor's note: n°1]

# setup logging as early as possible>
rdlogger_start  # [Redactor's note: n°2]

. /config

run_hookfunctions 'run_earlyhook' 'early hook' $EARLYHOOKS  # [Redactor's note: n°3]

if [ -n "$earlymodules$MODULES" ]; then
    modprobe -qab ${earlymodules//,/ } $MODULES  # [Redactor's note: n°4]
fi

run_hookfunctions 'run_hook' 'hook' $HOOKS  # [Redactor's note: n°5,6,7]

if [ "${break}" = "y" ] || [ "${break}" = "premount" ]; then
    echo ":: Pre-mount break requested, type 'exit' to resume operation"
    launch_interactive_shell
fi

rootdev=$(resolve_device "$root") && root=$rootdev  # [Redactor's note: n°8]
unset rootdev

fsck_root

# Mount root at /new_root
"$mount_handler" /new_root  # [Redactor's note: n°10]

run_hookfunctions 'run_latehook' 'late hook' $LATEHOOKS  # [Redactor's note: n°11]
run_hookfunctions 'run_cleanuphook' 'cleanup hook' $CLEANUPHOOKS

if [ "${break}" = "postmount" ]; then
    echo ":: Post-mount break requested, type 'exit' to resume operation"
    launch_interactive_shell
fi

# [Redactor's note: n°12]
exec env -i \
    "TERM=$TERM" \
    /usr/bin/switch_root /new_root $init "$@"

Reformulons les étapes :

  1. Regarder /proc/cmdline (proc(5)) – notre source de configuration runtime
  2. Setup du log
  3. Lancer des hooks (early/pre-module)
  4. Charger des modules kernel (modprobe(8))
  5. Lancer des hooks (“hooks”)
  6. Lancer udev(7) (ici via le hook n°5)
  7. Lancer des hooks (ici les mêmes hooks que n°5. L’ordre des hooks a de l’importance)
  8. Preparer le(s) disque(s)
  9. Lancer des hooks (pre-mount)
  10. mount(8) le(s) disque(s)
  11. Lancer des hooks (late/cleanup)
  12. switch_root(8) sur le nouveau init

Dracut fourni des étapes similaires, avec quelques subtilités, comme un hook supplémentaire avant de trigger udev (l’étape 6 n’est pas un hook comme avec mkinitcpio, et est divisée en 3 : lancement de udev, des hooks, et trigger de udev via udevadm), ou d’autres noms pour les hook (comme pre-pivot au lieu de late). Le fond reste identique.

Il est également possible d’utiliser systemd comme init process, même dans l’initramfs. Les étapes sont ainsi divisées en target, mount et service.

Création avec Vauban

Le but de Vauban va donc être de pouvoir générer ces différents éléments, et d’une manière confortable, et reproductible.

Intéressons nous pour commencer à la génération de l’initramfs :

Génération de l’initramfs via dracut avec Vauban

Pour la création de l’initramfs, dracut représente un bon outil, de par sa maturité et sa complétude. Je vous passerai les détails de comment je suis arrivé au résultat final, je vais plutôt vous présenter directement le fonctionnement final de Vauban pour la génération de l’initramfs.

Pour commencer, il est nécessaire de partir d’une base, un ISO initial à partir duquel on va installer nos services et créer nos composants. A Diabolocom, nous utilisons Debian, donc prenons l’image RAW de Debian 11.

Actuellement Vauban ne gère que Debian.

Regardons les étapes de la fonction build_initramfs de Vauban :

build_initramfs

mount_iso

Pour commencer, il faut pouvoir “attaquer” notre image raw téléchargée, savoir comment intéragir avec. Cette image est un block device contenant plusieurs partitions. Pour y accéder, il est nécessaire de créer un loop device avec losetup(8), puis de mounter la partition principale (la n°1), qui est un filesystem ext4, quelque part.

Un loop device est un mapping d’un fichier classique en block device. Cela permet d’utiliser les fonctions et utilitaires qui s’attendent à intéragir avec un block device sur notre fichier qui représente un disque.

Les modifications que nous faisons sont reportées dans le fichier .raw, il est donc nécessaire d’effectuer une sauvegarde du fichier intact au préalable sous réserve de devoir le retélécharger à chaque lancement de Vauban.

bootstrap_fs

Notre OS est mount et accessible facilement, il faut tout d’abord effectuer quelques opérations dedans pour le préparer pour la suite.

Pour cela, la fonction bootstrap_fs s’occupe de chroot(1) dans notre fs pour :

  • Mettre en place des DNS
  • Supprimer le fstab qui ne nous concerne pas
  • Retirer le bootloader et l’outil de base de génération de l’initramfs (initramfs-tools)
  • Mettre à jour le kernel et supprimer les vieilles versions

build_initramfs – le kernel

Le kernel étant mis à jour, il faut désormais le “trouver”, regarder sa version et le mettre de côté. Pour cela, un simple find(1) suffit, ainsi que file(1) (pour en extraire facilement la version).

Le kernel est mis de côté, nous disposons désormais d’un des trois éléments nécessaires au boot (certes, le plus simple à obtenir), yay !

Créons donc notre initramfs à proprement parler, en utilisant dracut.

chroot_dracut

Pour utiliser dracut dans notre image, le plus simple reste encore de chroot dedans, de l’installer, le configurer et de le lancer. Ainsi, on maximise les chances que les interactions entre dracut et notre OS se passent correctement.

Utilisons chroot(1) pour installer les paquets Debian pour dracut.

Il faut faire attention aux noms des paquets, qui changent de la version 10 à 11 de Debian. Si pour Debian 10, il suffit d’installer dracut, dracut-core et dracut-network, pour la version 11 il faudra aussi dracut-live et dracut-squash.

Nous pouvons ensuite positionner le dracut.conf suivant dans notre FS :

add_dracutmodules+=" ifcfg "
add_dracutmodules+=" bash "
add_dracutmodules+=" livenet "
add_dracutmodules+=" dmsquash-live "
add_dracutmodules+=" virtfs "

omit_dracutmodules+=" iscsi "
omit_dracutmodules+=" nfs "
omit_dracutmodules+=" lvm "
omit_dracutmodules+=" fcoe "
omit_dracutmodules+=" fcoe-uefi "
omit_dracutmodules+=" crypt "
omit_dracutmodules+=" btrfs "
omit_dracutmodules+=" bootchart "

add_drivers+=" overlay "
add_drivers+=" loop "
add_drivers+=" squashfs "
add_drivers+=" e1000e "
add_drivers+=" bnx2 bnx2x bnx2fc bnx2i cnic "

early_microcode="no"
hostonly_cmdline="no"
use_fstab="no"
mdadmconf="no"
hostonly="no"

Les espaces dans les quotes sont nécessaires et ne peuvent être supprimés !

La configuration ici est dédiée au boot en PXE. Dans les modules, on demande explicitement bash, mais aussi les modules nécessaires avoir un rootfs en réseau, en live.

Si ces modules ne sont pas disponibles dans notre debian chrooté, il est peut être nécessaire de les y télécharger/copier depuis le repo officiel de dracut pour les mettre dans /usr/lib/dracut/modules.d/ (dans notre fs de Debian, évidemment)

On demande aussi explicitement de ne pas installer des modules qui ne nous concernent pas, puisque notre setup n’utilisent ni NFS, ni BTRFS, ni LVM, ni chiffrement, …

Cela ne signifie pas qu’on ne pourra pas utiliser NFS & co une fois l’OS booté, cela permet juste de ne pas avoir le support pour ces techno dans notre initramfs. Puisque nous ne les utilisons pas pour boot, mount notre rootfs, etc, autant gagner de l’espace disque et ne pas les inclure.

On demande les drivers utiles pour le réseau (e1000e) et ceux qui nous serviront pour notre rootfs (voir plus bas).

Pour finir, on demande à dracut de générer un initramfs qui sera générique, et non pas dédié à notre machine actuelle (comportement par défaut).

On peut ensuite lancer dracut pour qu’il génère l’initramfs (c’est une opération qui prend du temps) :

dracut -N --conf dracut.conf -f -k "$modules" initramfs.img "$kernel_version" 2>&1 > /dev/null;

Nous avons besoin de préciser la version du kernel et l’emplacement de ses modules associé puisque nous sommes actuellement en chroot : le kernel actuel avec lequel on tourne n’est pas nécessairement celui contre lequel on cherche à construire l’initramfs (en l’occurence j’utilise ArchLinux, j’ai donc le kernel 5.15.7 contre 5.10 pour debian 11). Sans préciser ces informations, dracut va chercher à génerer un initramfs pour le kernel 5.15.7, ce qui va poser problème au moment de boot avec notre kernel 5.10.

Après un certain temps, nous pouvons trouver le fichier initramfs.img qui nous intéresse !

Modularité de dracut

Notre initramfs peut être configuré avec le fichier dracut.conf, mais également plus en profondeur via les modules. La force de dracut est sa modularité.

Dracut consiste en la génération d’un squelette robuste et générique, qui est configurable par des modules. Les modules possèdent un module-setup.sh, script exécuté à la génération de l’initramfs qui permet de déterminer si le module est pertinent dans la création actuelle.

Par exemple, si l’on souhaite créer un initramfs pour sa machine actuelle, et que celle-ci utilise un rootfs chiffré, dracut propose un module 90crypt pour déchiffrer le rootfs en demandant à l’utilisateur son mot de passe.

Le module n’est installé que si le rootfs est effectivement chiffré, et que le binaire cryptsetup – nécessaire pour déchiffrer le disque – est présent sur la machine. Si ce n’est pas le cas, le module n’est pas installé dans l’initramfs.

Si le module est installé (la fonction check() du module-setup.sh a renvoyé vrai), le module peut préciser des dépendances parmi d’autres modules pour son propre fonctionnement via la fonction depends. Ainsi, 90crypt dépend de dm et rootfs-block.

Le module expose plusieurs fonctions, qui seront appelées par dracut lors de la génération, toujours dans module-setup.sh, pour l’installation du module dans l’initramfs. L’installation peut être “complexe”, c’est à dire qu’elle peut consister en plusieurs scripts bash qui seront exécutés à plusieurs moments différents : les fameux hooks dont nous parlions plus haut.

La configuration “runtime” du module se fait via la cmdline kernel.

Notre rootfs

Maintenant que nous disposons du kernel et de l’initramfs, il s’agit de générer le dernier élément pour pouvoir avoir une image complète : le rootfs.

Réflexions sur le rootfs et notre cas d’usage

En réalité nous disposons déjà du rootfs : l’image raw de Debian que nous utilisons depuis tout à l’heure est notre rootfs. Elle contient en effet notre OS, et se mount sur /.

C’est donc de cette image qu’il faut partir. Cependant, l’image seule bien évidemment serait un peu légère. Il faut y ajouter toute notre configuration, nos paquets, etc.

A la manière des anciens masters qui étaient des VM, nous allons chercher à générer un “master”, c’est à dire un type de rootfs, par rôle.

Notre ancien master-postgresql deviendra donc une représentation virtuelle (par opposition à une machine … virtuelle) de ce master par un couple (rootfs initial, quoi installer et comment), en plus de désigner le rootfs final produit par la somme de ce couple.

En d’autre termes, un master avec Vauban est :

  • un rootfs qui contient notre configuration et nos programmes pour un usage précis, pour un type de VM précis (un rootfs pour postgresql par exemple)
  • également la description de comment arriver à ce rootfs, comment le génerer. Le couple (rootfs inital, quoi+comment installer).

Approche hiérarchique

En réfléchissant un peu plus, on se rend compte que toutes nos VM possèdent une base communes :

  • des paquets de bases installés partout (vim, openssh-server, …)
  • de la configuration de sécurité
  • de la configuration pour laisser les ops accéder à la machine
  • de la configuration pour centraliser les logs
  • du monitoring
  • ….

Il serait donc pertinent de centraliser, de mutualiser, cette image “de base”, construite à partir de notre Debian raw, pour utiliser cette nouvelle image “de base” comme nouvelle base de toutes les autres images.

Puisque nous étions parti du principe qu’un master était défini par un rootfs initial, il nous suffit de considérer qu’au lieu de partir du rootfs fourni par debian pour installer notre postgresql, on partira de notre nouvelle image “de base” pour ensuite ajouter postgresql.

En poussant encore plus loin, on se rend compte que certains paquets sont présent dans beaucoup de machines. Par exemple docker est fréquement utilisé. Par un raisonnement analogue, on peut considérer pertinent de créer une image contenant docker, créée à partir de notre image “de base”, qui pourra ensuite être réutilisée pour en dériver d’autres images.

Nous sommes donc en train de créer un système hiérarchique des images.

Comme nous faisons de l’IaC, il est important de poser la configuration à plat. C’est pourquoi Vauban propose un fichier de config, le bien-nommé config.yml (on aime le YAML en tant que YAMl-engineer), qui s’occupe de déclarer les masters avec l’approche hiérarchique évoquée ci-dessus.

Regardons un extrait de ce fichier de configuration :

"debian-11-generic-amd64.raw":
  name: debian-11/iso
  master-11-base:
    stages:
      - playbook_base.yml
    master-11-postgresql:
      stages:
        - playbook_postgresql.yml
    master-11-docker:
      stages:
        - playbook_docker.yml
      master-11-harbor:
        stages:
          - playbook_harbor.yml
      master-11-elastic:
        stages:
          - playbook_elastic.yml

Ce fichier de configuration est incomplet pour Vauban, et ne représente que l’état actuel de la réflexion à des fins de démonstration sur comment gérer les masters et la configuration. Voir plus bas pour le fichier dans sa complétude.

Comme démontré dans ce fichier de configuration, on peut voir l’aspect hiérarchique de la création des masters. Les masters master-11-harbor et master-11-elastic dépendent tous les deux du master master-11-docker, dépendant lui-même du master master-11-base, etc. A à la racine, on retrouve bien notre image Debian raw de laquelle nous sommes partis.

Les stages sont les étapes à jouer pour la création du master. Ainsi, pour résumé, la création du master master-11-docker nécessite de partir du master master-11-base et d’y appliquer le playbook Ansible playbook_docker.yml.

Un tel système nécessite de la méthodologie pour la maintenance et la mise à jour, ou du tooling approprié. Mettre à jour master-11-docker uniquement ne mettra à jour que le contenu du playbook playbook_docker.yml, il est donc nécessaire de reconstruire l’intrégralité de l’arbre régulièrement, ou si un changement est introduit en amont de plusieurs masters. Analoguement, une mise à jour dans le playbook_base.yml entraine la nécessité de reconstruire master-11-base, mais également tous les fils qui en dépendent, pour propager les mises à jour.

Et la configuration ?

S’il est bien beau de parler de master commun à un regroupement de machines, il arrive forcément le moment où 2 VM, disons 2 serveurs postgresql, auront besoin de se distinguer l’une de l’autre.

Bien qu’elles partageront 99% de leur rootfs en commun, auront le même OS et les mêmes paquets installés, elles auront besoin d’avoir leur propre hostname par exemple, ou une configuration de postgresql légèrement différentes.

Si pour le cas du hostname c’est un non-problème puisqu’il peut être déterminé automatiquement lorsque la machine est bootée (en regardant par exemple l’IP fournie par le DHCP et en faisant une requête PTR), la configuration propre à la machine elle est une autre paire de manche.

Nous pourrions générer un rootfs par machine, mais dupliquer les 99% (voire plus) du rootfs en commun par autant de machines semble nous indiquer que cette solution est sub-optimale. Elle l’est d’autant plus lorsque l’on considère la taille d’un rootfs : environ 800 MiB pour un rootfs avec postgresql, pour seulement quelques KiB de différence dans le pg_hba.conf.

Il est donc utile d’avoir un mécanisme pour mutualiser le gros des machines (le rootfs), et d’y apposer la configuration par machine.

C’est l’approche que développe Vauban, avec la création d’un conffs.

Si le rootfs contient l’OS et tous les programmes, le conffs lui ne contient que la configuration, ou les quelques petites différences d’un host à un autre.

La machine finale est obtenue en sommant rootfs et conffs.

Si l’on reprend notre système hiérarchique de création de rootfs, avec la notion de master composé du couple (rootfs/master initial, configuration à appliquer), on se rend compte que notre conffs peut être construit de manière analogue. Il suffirait “juste” de faire la différences entre le résultat final et l’état initial pour obtenir le conffs.

C’est cette approche qui est mise en oeuvre dans Vauban et expliquée dans la suite de l’article.

Si, comme son nom le suggère, le conffs est dédié à contenir la configuration d’une machine, en réalité il n’y a rien qui impose que le conffs ne puisse pas contenir d’autres choses (une version plus à jour d’un programme par exemple).

Et comment la configuration était gérée avant ?
Pourquoi ne pas simplement recopier le système de gestion de configuration avec la méthode précédente de génération des images ?
Tout simplement car la technique utilisée consistait à poser toutes les configurations de toutes les machines tirées d’un master sur ledit master.
Ainsi, pour pg_hba.conf, nous avions pg_hba.conf.<hostname> avec autant de fichiers .<hostname> que nous avions de hosts tirés de ce master. Il y avait donc un service au démarrage de la machine qui s’occupait de faire un softlink du fichier de config pg_hab.conf.<hostname> à pg_hba.conf pour mettre à disposition le fichier de configuration à utiliser dans le cas présent.
L’écriture dans Ansible était pénible, les rôles étaient rallongés (pour poser un fichier de conf il fallait en poser en réalité 50 si nous avions 50 machines tirées du master, donc 50x plus de temps pour la tâche), toutes les machines avaient les fichiers de tout le monde, l’ajout d’une machine entrainait des diffs sur toutes les autres machines, il fallait systématiquement penser à ne pas configurer directement dans le fichier de conf mais dans son .hostname, penser à mettre à jour la configuration du service au démarrage pour qu’il nous link bien notre fichier, etc, etc …
Finalement le conffs est plus simple, non ? :smile:

Génération du rootfs avec Vauban

Si, comme moi, quand vous voyez ce système hiérarchique de rootfs et conffs vous pensez à overlayfs, félicitations, c’est effectivement comme ça que Vauban gère ses rootfs et conffs !

Plus exactement, Vauban utilise docker. Pourquoi utiliser docker ?

  • Docker offre naturellement un système de build hiérarchique (FROM .... dans un Dockerfile)
  • Docker en utilisant overlayfs, nous permet d’extraire notre conffs facilement
  • Docker nous permet de stocker et déplacer nos rootfs et étapes intermédiaires facilement grâce au registry
  • Docker nous offre du tooling autour du chroot et de l’exécution chrooté et isolé
  • Docker a du tooling également sur l’importation et l’exportation d’archives

Importation du rootfs dans docker (import_iso)

Notre rootfs initial, tiré de l’image raw de Debian, doit être importé dans Docker pour nous permettre de commencer à travailler avec.

Pour ça, on regarde dans notre image initiale la version de Debian (via lsb_release), puis on crée une image docker avec docker import que l’on nommera avec la version de Debian + raw-iso (exemple : debian-11.0/raw-iso)

Le “raw-iso” ne correspond pas à l’idée que l’on pourrait se faire d’une image totalement minimaliste, puisqu’elle contient des paquets inutiles pour notre utilisation.

La première étape consistera donc à “assainir” cette image en retirant les paquets inutiles (dont par exemple dracut que nous avions installé précédemment).

Pour cela, Vauban utilise le premier Dockerfile, Dockerfile.base, qui retire beaucoup de paquets des images live, et quelques paquets des images raw.

Le dockerfile s’occupe aussi de préparer l’image pour la suite, en installant python et Ansible, en positionnant la clé SSH de déploiement d’Ansible, celle utilisée pour clone le repo, et en clonant effectivement le repo dans /root.

Après un docker build, nous disposons donc d’une image docker correspondant quasiment à notre image source, à quelques détails près. Cette image s’appelle par exemple debian-11.0/iso.

Application d’Ansible (apply_stages)

Maintenant que nous avons réussi à importer correctement notre rootfs initial dans Docker, il s’agit d’appliquer Ansible à ce rootfs. Si l’on reprend le fichier de configuration de Vauban présenté plus haut, cette configuration à appliquer pour Ansible se traduit par une liste de “stages”, c’est à dire un liste de playbooks Ansible à appliquer successivement.

C’est le rôle de la fonction apply_stages. Regardons son fonctionnement plus en détails.

La fonction prend en argument :

  • source_name : Le nom de l’image source sur laquelle on veut appliquer les modifications
  • prefix_name : Le prefix commun à tous les produits intermédiaires de l’application des stages
  • final_name : Le nom de l’image à obtenir
  • hostname : Le hostname à définir dans notre docker build, pour simuler l’application de notre playbook Ansible sur tel hostname (et ainsi avoir le bon groupe Ansible, les bonnes variables, etc)
  • stages : La liste des playbooks à appliquer

Les stages ne sont pas qu’une liste de playbooks à appliquer, ils permettent également de sélectionner une branche git de l’Ansible sur laquelle aller pour appliquer tel playbook, au lieu de la branche choisie globalement.

Cette option peut être utile si la création d’un rootfs nécessite l’application de plusieurs playbooks qui se trouveraient – pour le moment – sur plusieurs branches différentes de l’Ansible.

La syntaxe d’un stage est donc [<branch>@]<playbook>.

Le prefix commun quant à lui est la manière de dénommer chacune des étapes de création de l’image finale. Chaque stage à appliquer créé en réalité une image docker intermédiaire qui est ensuite reprise pour appliquer le stage suivant. Chacune de ces images intermédiaires s’appellent $prefix_name/$stage[$i]

Par exemple si l’on cherche à build le master-11-elastic de l’exemple ci-dessus :

- master-11-docker
- master-11-docker/playbook_elastic.yml
- master-11-elastic

Les images master-11-docker/playbook_elastic.yml et master-11-elastic sont en réalité deux labels différents sur la même image docker. En effet, master-11-elastic n’est rien de plus que l’image master-11-docker sur laquelle on exécute playbook_elastic.yml.

Regardons l’étape du docker build plus en détails :

docker build -f Dockerfile.stages

Pour chaque application d’un layer, on appelle Docker de la manière suivante :

docker build \
            --build-arg SOURCE="${iter_source_name}" \
            --build-arg PLAYBOOK="$local_pb" \
            --build-arg BRANCH="$local_branch" \
            --build-arg HOOK_PRE_ANSIBLE="${HOOK_PRE_ANSIBLE:-}" \
            --build-arg HOOK_POST_ANSIBLE="${HOOK_POST_ANSIBLE:-}" \
            --build-arg ANSIBLE_ROOT_DIR="${ANSIBLE_ROOT_DIR:-}" \
            --build-arg HOSTNAME="$hostname" \
            --no-cache \
            -t "${prefix_name}/${local_pb}" \
            -f Dockerfile.stages .

On fourni plusieurs arguments à notre docker build, qui sont les paramètres de notre stage : de qui, comment et avec quoi build.

On dispose également de hooks pré et post Ansible-playbook. Dans les variables bash on peut mettre du bash qui sera exécuté avant et après l’exécution de notre ansible-playbook. C’est un bon moyen de conserver la généricité de Vauban tout en ayant un moyen d’effectuer des actions spécifiques. Par exemple, on peut faire un ansible-galaxy install dans le $HOOK_PRE_ANSIBLE.

On retire le cache, pour forcer la reconstruction de notre image.

L’image créée est bien celle expliquée plus haut : $prefix_name/$playbook.

Regardons le Dockerfile :

ARG SOURCE

FROM ${SOURCE}

ARG SOURCE
ARG PLAYBOOK
ARG HOSTNAME
ARG BRANCH
ARG HOOK_PRE_ANSIBLE
ARG HOOK_POST_ANSIBLE
ARG ANSIBLE_ROOT_DIR

RUN cd /root/ansible/$ANSIBLE_ROOT_DIR && \
    git fetch && [ "$(git rev-parse HEAD)" = "$(git rev-parse origin/$BRANCH)" ] || git reset origin/$BRANCH --hard && \
    echo $HOSTNAME > /etc/hostname && \
    echo "\n[all]\n$HOSTNAME\n" >> inventory && \
    hook_pre_ansible() { eval "$HOOK_PRE_ANSIBLE" ; } && hook_pre_ansible && \
    ansible-playbook "${PLAYBOOK}" --diff -l "$HOSTNAME" -c local -v && \
    hook_post_ansible() { eval "$HOOK_POST_ANSIBLE" ; } && hook_post_ansible

Le Dockerfile ici nous permet d’être générique puisqu’applicable depuis n’importe quel rootfs pour obtenir n’importe quel autre rootfs (grâce à ARG SOURCE).

Les étapes ne sont pas très complexes :

  1. On s’assure de posséder la dernière version du repo Ansible, et on se positionne sur la branche qui a été demandée
  2. On défini notre hostname sur celui demandé
  3. On s’assure que l’hostname existe dans l’inventory Ansible. Cette étape est utile par exemple lors de la création d’un master, qui n’existerai pas forcément dans l’inventory (puisqu’il n’est pas réellement une machine)
  4. On exécute les hooks
  5. On lance Ansible en local, sur soi même
  6. On exécute les hooks

L’étape 3 n’est probablement pas générique et est peut-être à adapter/changer.

Cette manière d’appliquer Ansible présente quelques désagréments. Voir la section limitation avec Ansible.

From docker image to rootfs: build_rootfs

Une fois notre image Docker générée, il est temps de l’exporter sous la forme d’un rootfs sur lequel nous pourrons boot. C’est le rôle de la fonction build_rootfs.

Heureusement, Docker est gentil avec nous et nous fourni docker export, qui nous permet d’exporter sous le format tar(1) l’intégralité de notre rootfs.

Exportons donc notre image dans un dossier temporaire :

rm -rf tmp && mkdir tmp
echo "Creating rootfs from $image_name"
docker create --name $$ "$image_name" --entrypoint bash
cd tmp && docker export $$ | tar x
docker rm $$

Ainsi notre dossier tmp contient l’intégralité de notre rootfs. Il ne reste plus qu’à le préparer et le compresser.

On va notamment :

  • Link /etc/resolvconf pour pallier le fait que Docker le mount automatiquement dans un conteneur (et donc le casse une fois exporté puisqu’est exporté le resolvconf du host qui build)
  • apt-get clean -y, pour éviter de prendre trop de place avec les .deb en cache
  • Retirer notre repo Ansible, la clé SSH qui a servi à git clone et l’ancien initramfs (on a déjà le nôtre).

La gestion des clés SSH serveur : put_sshd_keys

Une étape importante à cet instant est la gestion des clés sshd serveur. Si l’on a d’autres solutions pour le gérer (comme les imposer dans Ansible, mettre en place une authentification par certificats, …), la solution retenue ici est d’avoir Vauban comme source de configuration pour les clés serveurs ssh.

Et oui, si on laisse openssh-server générer ses clés dans le processus d’installation, nous aurions deux problèmes :

  • Toutes les machines construites à partir du master dans lequel on installe sshd auraient les mêmes clés ssh serveur, donc la même identité.
  • Reconstruire ledit master changerait les clés, changeant l’identité du serveur.

Pour pallier ces deux problèmes, il est plus simple de gérer les clés sshd au moment de construire l’archive du rootfs, au moment de nettoyer l’image des dépendances de build.

Vauban gère donc dans son repo un ~vault dans lequel se trouvent les clés de toutes les machines. Lors de la création d’un master, si sa clé n’a pas encore été générée, elle est générée, posée dans le rootfs, mais également chiffré symétriquement avec un mot de passe configurable dans Vauban, et stockée dans git. Lors du prochain build, la clé existant déjà, elle est alors déchiffrée puis utilisée. C’est le dossier vault/, avec les clés sshd tar-ées et gpg-ées dedans.

Si l’on build une image dans la CI, Vauban s’occupe également de commit les clés générées pour qu’elles ne soient pas perdues. Ce sont les commits automatiques [CI] Update vault/

La notion de vault est relative, on pourrait utiliser une solution comme hashicorp vault, mais la solution d’avoir un “vault” géré dans le repo est un bon compromis sécurité/simplicité.

mksquashfs

Pour finaliser la création de notre rootfs, il faut désormais convertir le dossier temporaire avec lequel nous travaillions en archive. Pour le rootfs, le format retenu est le squashfs, qui est parfaitement adapté à ce genre d’opérations. Pour cela on utilise mksquashfs et la compression via xz, plus lente mais plus efficace, ainsi que les options -noappend -always-use-fragments -no-exports pour gagner en espace disque.

Le rootfs est prêt !

La gestion de la configuration, et les conffs

Comme évoqué précédemment, la configuration et la création du conffs se fait en appliquant de nouveau nos étapes, mais cette fois en ne s’intéressant qu’à la configuration, et non pas au gros de l’image.

Pour générer le conffs, nous allons tout simplement reprendre la fonction apply_stages pour re-appliquer les stages, mais cette fois-ci en simulant une machine cible correspondant à la machine pour laquelle on souhaite générer la configuration. Si nous avons 10 images tirées d’un master, pour générer les 10 conffs nous allons re-appliquer nos stages sur 10 docker.

Cette étape est en réalité plus rapide que le rootfs, malgré le nombre de hosts, puisqu’Ansible a déjà efectué les tâches les plus longues, comme installer les paquets.

Regardons comment nous allons procéder :

Construisons notre conffs avec build_conffs

La première étape pour construire le(s) conffs est de savoir quel(s) conffs construire (:thinking:).

Pour cela, nous disposons d’une option dans la configuration de Vauban, qui nous indique justement quels conffs construire : conffs (original).

Rerenons notre fichier de config présenté plus haut, mais cette fois-ci un poil plus complet :

"debian-11-generic-amd64.raw":
  name: debian-11/iso
  master-11-base:
    stages:
      - playbook_base.yml
    master-11-postgresql:
      stages:
        - playbook_postgresql.yml
      conffs: "postgresql*"

On notera l’ajout de conffs pour master-11-postgresql. Cette configuration signifie que pour le master master-11-postgresql nous allons avoir des hosts, et que ceux-ci sont identifiables par l’expression postgresql*. En réalité, cette expression correspond à celle qu’on utiliserai avec l’option --limit d’ansible-playbook. La syntaxe est exactement la même – assez logiquement – puisque la chaîne est envoyée à Ansible pour qu’il l’interprete.

Ainsi on pourrait écrire postgresql*,*-postgresql*,!postgresql9* par exemple pour matcher de manière plus sélective les hostnames qui nous plaisent.

Il est à noter que Vauban va automatiquement exclure les hosts qui commencent par master-, pour éviter de génerer un conffs pour des machines “virtuelles”.

La fonction get_conffs_hosts s’occupe de demander à ansible-playbook d’expand la chaîne de conffs en une liste de hostname

La fonction build_conffs va donc itérer sur ces conffs 20 par 20 pour construire en parallèle les conffs propre à ces hosts, via la fonction build_conffs_given_host.

Il est à noté que pour s’y retrouver dans l’output de Vauban, chacun des conffs construits en parallèle output ses logs dans son fichier dans /tmp/vauban-logs/. Le fichier de logs est plus lisible puisqu’il ne concerne qu’un seul host, et permet de mieux comprendre le déroulement de la création du conffs d’un host donné – et ce qui a pu potentiellement échouer.

Le conffs d’un host avec build_conffs_given_host

Comme expliqué précédemment, build_conffs_given_host va réutiliser apply_stages pour générer la configuration.

La partie intéressante n’est pas dans le apply_stages, puisqu’il a déjà été présenté, mais sur comment transformer l’image docker finale en conffs.

On l’a dit, il “suffit” de récupérer et isoler les dernières couches de l’image finale, celles qui partent du rootfs jusqu’à la fin. Pour cela, on itère sur les stages qui ont été appliqués pour le conffs, et pour chaque stage on récupère le layer docker associé via docker inspect. En effet, grâce à la magie d’overlayfs et de la CLI de docker, il est facile de récupérer le path sur la machine du résultat d’application d’un stage, sous la forme d’un diff par rapport à la version d’avant. Cela signifie donc qu’on a effectivement accès aux différences, et uniquements les différences, constituant donc le conffs.

Si les notions d’overlayfs, de layer et de diff ne sont pas claires, la documentation de docker fourni des explications assez détaillées et compréhensibles sur le fonctionnement, et l’utilisation dans docker.

Regardons concrètement une image générée :

$ docker inspect master-11-elastic/elastic01.stg.example.com | jq '.[0].GraphDriver'
{
  "Data": {
    "LowerDir": "/var/lib/docker/overlay2/4e24493e3903fd78c9609213951cfb814eb411c4952f779a790fa21fdf775950/diff:/var/lib/docker/overlay2/2de759e2e1cdfc162a98fc9cde8590d1620e84ec2ebdcb212400605edc7696bb/diff:/var/lib/docker/overlay2/3144822ed40ee30f1d14dcb9de5e0fcd392ddf4bbb84d9eb69ed3f74a8cb66a1/diff:/var/lib/docker/overlay2/a9c8a8046c5b6f5803b652ea8e6bf0f9c167b46f44824a33729369a7c73a8c55/diff:/var/lib/docker/overlay2/c6c1702d066203873cb5a436ccbbb14bd58a2ef121748ec266b9c6165e5dc957/diff:/var/lib/docker/overlay2/364d2cc01897285ef6a3fb10f23f20c469036eb2804a9e0ede8598fa686ad9f7/diff:/var/lib/docker/overlay2/62b40ebdd2ed2a952b482c560c56759e4f8e6ea3664e5d08a10e4cfd3330861a/diff:/var/lib/docker/overlay2/7a0086d242243b432f12ab95ebbe10415c1c6b4604e52bd8a1239cb89584febc/diff",
    "MergedDir": "/var/lib/docker/overlay2/6f45a6128d61a0e58df704548a7a3848c1bf1c6dca445f9d3f6b03cf63a5eee8/merged",
    "UpperDir": "/var/lib/docker/overlay2/6f45a6128d61a0e58df704548a7a3848c1bf1c6dca445f9d3f6b03cf63a5eee8/diff",
    "WorkDir": "/var/lib/docker/overlay2/6f45a6128d61a0e58df704548a7a3848c1bf1c6dca445f9d3f6b03cf63a5eee8/work"
  },
  "Name": "overlay2"
}
$ sudo tree /var/lib/docker/overlay2/6f45a6128d61a0e58df704548a7a3848c1bf1c6dca445f9d3f6b03cf63a5eee8/diff
/var/lib/docker/overlay2/6f45a6128d61a0e58df704548a7a3848c1bf1c6dca445f9d3f6b03cf63a5eee8/diff
├── imginfo
├── packages
├── root
│   └── ansible
│       ├── diabolocom
│       │   └── inventory
...
├── tmp
│   ├── apt-after
│   └── apt-before
└── usr
    └── share
        └── elasticsearch
            └── config
                └── elasticsearch.yml

12 directories, 6 files

Si on regarde donc la dernière couche de notre image générée pour un host précis, qui correspond ici à l’application d’un playbook pour configurer Elasticsearch, on peut voir que docker nous donne le dossier du layer, et ce dernier ne contient que les diffs (12 directories, 6 files).

Ignorons pour le moment les fichiers imginfo, packages, et tmp, ils seront présentés plus tard

Sommons tous les dossiers de toutes les étapes de conffs, et nous obtenons donc notre conffs !

Pour sommer des étapes construites avec overlayfs, quoi de mieux que d’utiliser de nouveau overlayfs ? Après tout, c’est le but …

build_conffs_given_host va donc récupérer tous les paths des layers de config, et monter un overlayfs avec des paths :

# overlayfs_args est contruit à base de `docker inspect`

mkdir -p "overlayfs-${host}/merged" "overlayfs-${host}/lower" && cd "overlayfs-${host}"

if [[ -n "$overlayfs_args" ]]; then
    mount -t overlay overlay -o "lowerdir=lower$overlayfs_args,metacopy=off" merged
else
    echo "WARNING: Creating some empty conffs !"
fi

De part la nature d’overlayfs et de l’archive que nous cherchons à créer, il n’est pas possible d’exporter une suppression de fichier qui aurait eu lieu dans le conffs par rapport au rootfs.
Si un fichier est créé dans le rootfs, et supprimé dans le conffs, il n’est pas possible d’exporter cette notion de suppression de fichier comme le fait overlayfs.
Pour plus de détails à ce sujet, vous pouvez regarder l’implémentation de la suppression dans overlayfs avec un marqueur char device

Le résultat mounté est donc la configuration propre à l’host, et est accessible dans le dossier dans lequel on l’a mount. Il ne nous reste plus qu’à compresser ce dossier pour générer notre conffs.

On utilise pour cela tar, et on fait bien attention d’exclure /var/log, /var/cache et /root/ansible.

Notre conffs est prêt !

Upload toutes ces ressources avec upload

Une fois nos 4 ressources créées, il faut les mettre à disposition de nos serveurs PXE. Pour cela, l’étape finale de Vauban est la fonction upload, dont le nom est très explicite.

S’il n’y a pas grand chose à dire sur le fonctionnement de la fonction en elle même, il y a cependant un mécanisme pour gérer le matching entre version du kernel (et l’initramfs associé) et la version des kernels modules + sources dans le rootfs.

En effet, si l’on build le kernel+initramfs pour avoir la dernière version, mais que le rootfs lui n’a pas été rebuild depuis un moment et utilise un vieux kernel, on aura un mismatch dans /usr/lib/modules, et donc une impossibilité d’utiliser des modules, etc.

De plus, les initramfs et kernel étant identiques d’un master à un autre, il est pertinent de les mutualiser pour éviter d’avoir trop de duplication.

Pour cela, la fonction d’upload s’occupe de positionner les ressources selon la disposition suivante :

/root/of/PXE-configurable_in_vauban_options
├── linux
│   ├── initramfs.img.5.10.10
│   ├── initramfs.img.5.10.11
│   ├── vmlinuz.5.10.10
│   └── vmlinuz.5.10.11
├── master-11-elasticsearch
│   ├── conffs-elasticsearch01.prd.example.com.tgz
│   ├── conffs-elasticsearch01.stg.example.com.tgz
│   ├── conffs-elasticsearch02.prd.example.com.tgz
│   ├── conffs-elasticsearch03.prd.example.com.tgz
│   ├── initramfs.img -> ../linux/initramfs.img.5.10.11
│   ├── vmlinuz -> ../linux/vmlinuz.5.10.11
│   └── rootfs.tgz
└── master-11-postgresql
    ├── conffs-postgresql01.prd.example.com.tgz
    ├── conffs-postgresql01.stg.example.com.tgz
    ├── conffs-postgresql02.prd.example.com.tgz
    ├── conffs-postgresql03.prd.example.com.tgz
    ├── initramfs.img -> ../linux/initramfs.img.5.10.10
    ├── vmlinuz -> ../linux/vmlinuz.5.10.10
    └── rootfs.tgz

Le dossier linux contient les kernels et initramfs, suffixés par leur version, et les dossiers des masters font un symlink sur la version qui les concernent.

Les conffs ne devraient pas contenir de modules kernels ou de mise à jour des modules du rootfs pour éviter de casser ce mécanisme qui ne permet pas d’avoir une granularité de l’ordre de l’host.

Intégration avec le Docker Registry

Du fait que tous nos rootfs et conffs soient constructibles hiérarchiquement et via Docker, il est également intéressant d’utiliser un Docker Registry pour stocker les images finales prêtes à être exportées, et les images intermédiaires.

Pour cela, toutes les commandes de docker sont en réalité wrappées dans une fonction de utils.sh, qui s’occupe de :

  • Se connecter (docker login) automatiquement au registry avec les valeurs définies dans la configuration de Vauban si nécessaire. Peu utile sur un laptop perso, mais beaucoup plus dans la CI par exemple
  • Retagger l’image lors d’un docker build pour avoir en plus du tag latest, un tag avec le datetime actuel (ISO 8601), et avec le nom du registry
  • Push l’image et les tags sur le registry

La fonction va de paire avec la fonction pull_image qui s’occupe de pull l’image si elle n’existe pas, en essayant d’être intelligent sur le nom de l’image (avec ou sans le registry).

Cela nous permet notamment d’avoir un outil qui fonctionne entre plusieurs hosts et runner de CI, et de ne pas craindre la suppression locale d’une image docker.

Comment tout assembler ?

La question qui se pose désormais, c’est comment booter sur ces ressources, et comment elles font pour s’assembler entre elles.

Cet article n’a pas pour but de présenter en détail comment monter une stack PXE et DHCP, donc on va partir du principe que cet aspect est déjà couvert.

PXE boot options

Regardons un exemple de configuration de grubnet pour boot en PXE sur un master avec son conffs :

set root="/srv/PXE/"
set master="master-11-elasticsearch"
set master_file="rootfs.tgz"
set initramfs="initramfs.img"
set conffs="conffs-elasticsearch01.prd.example.com.tgz"

set boot_opts="console=tty0 console=ttyS0,115200 net.ifnames=0 verbose \
               rd.debug rd.shell rd.writable.fsimg=1 rd.luks=0 rd.lvm=0 \
               rd.md=0 rd.dm=0 rd.neednet=1 rd.live.debug=1 rd.live.image \
               rootflags=rw rootovl systemd.debug_shell"

menuentry "PXE ${master}/${conffs}" {
    insmod gzio
    echo    "Loading Linux ${master} from ${root} with ${conffs} ..."
    linux   ${root}/${master}/${vmlinuz} boot=tmpfs \
            root=live:http://<this server IP address>/${master}/${master_file} \
            live.updates=http://<this server IP address>/${master}/${conffs} \
            pxemac=${net_default_mac} ip=eth0:dhcp:${net_default_mac} \
            ${boot_opts}
    echo    "Loading initramfs from ${root} ..."
    initrd   ${root}/${master}/${initramfs}
}

La génération et gestion de cette configuration – propre à chaque host – est laissée à la discrétion du lecteur.

Bon la configuration semble un peu velue lors de la première approche, certes.

L’idée de base est d’indiquer via les directives linux et initrd où trouver le kernel et l’initramfs, mais aussi de configurer le kernel via la command line.

Les premières variables sont normalement relativement simples à comprendre, en revanche boot_opts mérite quelques explications supplémentaires.

Avant de s’y intéresser, regardons les autres directives données à linux.

L’option root sera utilisée par l’initramfs pour savoir où trouver le rootfs. On consultera dracut.cmdline(7) pour comprendre toutes les options dont on dispose, mais essentiellement ici on préfixe par live: pour indiquer que l’on va boot sur une image live, trouvée sur le réseau et posée en RAM. L’adresse est celle de notre rootfs, via HTTP (on s’assurera que le rootfs soit téléchargeable à l’adresse indiquée).

L’option live.updates nous permet de préciser notre conffs de la même manière.

Les options pxemac et ip nous permettent de forcer une configuration réseau dans l’initramfs, ici le fait d’utiliser l’interface eth0 pour trouver une IP avec la MAC indiquée. Ce paramètre sera peut-être à adapter en fonction de l’infra. La variable net_default_mac doit en revanche être obligatoirement changée d’une machine à une autre, assez logiquement.

Ensuite, on fourni les boot_opts définies plus haut.

Ces options nous permettent de configurer linux (net.ifnames=0), mais surtout dracut. Les options en rd... sont des options de dracut (voir la page de documentation dracut.cmdline(7) pour plus de détails).

Nous désactivons ici explicitement les options dont nous n’avons pas besoin, pour gagner du temps au boot en évitant de checker des choses qui n’existent pas dans notre setup, comme luks, md ou lvm.

On indique en revanche la nécessité d’avoir du réseau (rd.neednet=1), et le fait que l’on veuille avoir un rootfs qui soit accessible en écriture également.

Avoir un système en lecture-écriture

Une des limitations de boot en live sur un rootfs en squashfs, c’est l’impossibilité d’écriture. Un système avec une partition / en lecture seule est beaucoup trop contraignant, il va falloir ruser.

Pour pouvoir écrire sur notre partition en lecture seule, il serait intéressant d’avoir un système où on pourrait lire sur le squashfs, mais où l’écriture se ferait ailleurs, à un endroit où l’on peut écrire, mais que la représentation finale de / soit la somme de la lecture seule + les modifications apportées.

Si ça vous semble familier, c’est normal, je décris une fois de plus overlayfs.

Il nous suffit de faire un overlayfs avec en couche basse notre rootfs, en couche haute un tmpfs et mount le merged sur / pour avoir un système en lecture/écriture.

Tant qu’on y est, autant ajouter par dessus notre conffs, puisque c’est églament une méthode simple d’incorporer ses changements dans la stack.

On se retrouverai donc avec quelque chose similaire à :

tmpfs on /live/cow type tmpfs (rw,relatime,mode=755)
/dev/mapper/live on /live/image type squashfs (ro,noatime)
overlay on / type overlay (rw,noatime,lowerdir=/live/image:/live/cow/conffs,upperdir=/live/cow/rw,workdir=/live/cow/work,default_permissions)

Quelque chose d’intéressant avec ce système est aussi la possibilité de savoir quelles ont été les modifications apportées au filesystem depuis que la machine a boot, grâce à overlayfs. Il suffit de regarder le contenu de l’upperdir.

Les modules responsables de ce fonctionnement sont dans modules.d dans le projet Vauban. Ce sont des versions légèrement modifiées de dracut modules existant déjà et trouvables sur leur repo git.

Comment utiliser Vauban au quotidien ?

config.yml

Précédemment j’évoquais le fichier de configuration config.yml de Vauban pour indiquer quoi build et comment. En réalité le projet Vauban même n’est pas capable de lire ce fichier, c’est le rôle de ci.py, le script exécuté dans la CI – qui peut aussi être exécuté manuellement – qui s’occupe de lire la configuration et d’invoquer Vauban avec les options correctes.

branches

Le fichier config.yml contient la déclaration des masters et conffs. Si le fonctionnement général a déjà été présenté plus haut, il y a également la possibilité de préciser la branche à utiliser pour le master en général en plus de le préciser pour un stage. Considérons l’exemple suivant :

  master-11-docker-compose:
    stages:
      - playbook_docker-compose.yml
      - bugfix/fix-sshd@playbook_authentication.yml
    conffs: "compose*,docker-compose*,!docker-compose-foo*"
    branch: bugfix/fix-docker-permissions

Dans cet exemple, on défini la branche par défaut pour master-11-docker-compose à bugfix/fix-docker-permissions, tout en précisant une branche plus spécifique pour le playbook playbook_authentication.yml.

Assez logiquement, au vu de la définition de la configuration d’un master, il n’est pas possible d’appeler un master stages, conffs ou branch. Une clé du dictionnaire qui ne fait pas parti de cette liste est la définition d’un master fils, héritant de notre master actuel.

CLI de Vauban

La CLI de Vauban devrait être assez complexe à utiliser au quotidien de part sa complétude et son nombre d’options :

# ./vauban.sh -h
Build master images and makes coffee
Usage: ./vauban.sh [-r|--rootfs <arg>] [-i|--initramfs <arg>] [-l|--conffs <arg>] [-u|--upload <arg>] [-f|--iso <arg>] [-s|--source-image <arg>] [-k|--ssh-priv-key <arg>] [-n|--name <arg>] [-b|--branch <arg>] [-a|--ansible-host <arg>] [-h|--help] [<stages-1>] ... [<stages-n>] ...
	<stages>: The stages to add to this image, i.e. the ansible playbooks to apply. For example pb_base.yml
	-r, --rootfs: Build the rootfs ? (default: 'yes')
	-i, --initramfs: Build the initramfs ? (default: 'yes')
	-l, --conffs: Build the conffs ? (default: 'yes')
	-u, --upload: Upload the generated master to DHCP servers ? (default: 'yes')
	-f, --iso: The ISO file to use as a base (no default)
	-s, --source-image: The source image to use as a base (no default)
	-k, --ssh-priv-key: The SSH private key used to access Ansible repository ro (default: './ansible-ro')
	-n, --name: The name of the image to be built (default: 'master-test')
	-b, --branch: The name of the Ansible branch (default: 'master')
	-a, --ansible-host: The Ansible hosts to generate the config rootfs on. Equivalent to Ansible's --limit, but is empty by default (no default)
	-h, --help: Prints help

ci.py pour simplifier

Pour pallier cela, il est préférable d’utiliser le script ci.py, pour ne pas avoir à définir sur la CLI les options d’invocation de Vauban, mais utiliser celles de config.yml

Le script à été conçu pour fonctionner via Gitlab-CI, mais est également utilisable localement. Le script prend ses arguments par variable d’environement (car prévu pour fonctionner en CI), et lit automatiquement config.yml pour déduire le reste.

Les arguments sont les suivants :

  • name : Le nom du master à build
  • stage : Quoi construire (rootfs, initramfs, conffs, all)
  • build_parent : Si mis à une valeur supérieure à 0, construit autant de parents que demandé
  • debug : Si mis à true ou yes, ne lance pas vauban.sh mais affiche les commandes qui seront exécutées

Pour build_parent, le parent d’un master (rootfs) est le master (rootfs) dont il hérite.

Pour build_parent, le parent d’un conffs et d’un initramfs est le rootfs associé.

Pour build_parent, si stage est défini à all, alors stage=all sera également appliqué au(x) master(s) dont il hérite

Exemples d’utilisation

$ # Je veux construire tous les composants d'un master
$ name=master-11-docker-compose stage=all ./ci.py

$ # Je veux reconstruire uniquement le rootfs et conffs
$ name=master-11-docker-compose stage=rootfs ./ci.py && name=master-11-docker-compose stage=conffs ./ci.py
$ # ou
$ name=master-11-docker-compose stage=conffs build_parents=1 ./ci.py

$ # Je veux reconstruire tous les rootfs dont mon master hérite
$ name=master-11-docker-compose stage=rootfs build_parents=5 ./ci.py  # Si il y en a 5
$ # ou
$ name=master-11-docker-compose stage=rootfs build_parents=-1 ./ci.py
$ # ou
$ name=master-11-docker-compose stage=rootfs build_parents=yes ./ci.py

Configuration de Vauban

Vauban nécessite pour fonctionner certains secrets et paramètres de configuration. Ces paramètres ne sont pas propres à un master donné comme peuvent l’être les options de config.yml, mais sont en revanche génériques.

C’est pour cette raison que ces valeurs sont configurables via deux options :

  • des variables d’environnement
  • un fichier .sh

Dans tous les cas, le fichier à regarder pour la configuration est vauban-config.sh.

Pour définir les valeurs via le fichier, il est possible de modifier vauban-config.sh directement, ou bien de créer un fichier .secrets.env qui sera sourcé par vauban-config.sh automatiquement.

Le format de .secrets.env doit ressembler à :

export REGISTRY_HOSTNAME='registry.example.com'
export REGISTRY_USERNAME='robot$example'
export REGISTRY_PASSWORD='mzoihgmdnvmiezgdvzeogDdozegp'

Les clés sont bien évidemment celles de vauban-config.sh.

Pour la configuration via variable d’environnement, le principe reste le même :

$ export REGISTRY_HOSTNAME='registry.example.com'
$ export REGISTRY_USERNAME='robot$example'
$  REGISTRY_PASSWORD='mzoihgmdnvmiezgdvzeogDdozegp' ./vauban.sh

Les variables sont normalement présentées avec suffisament de détails dans vauban-config.sh.

Il n’est pas possible d’utiliser une variable d’environnement dans les scripts de hook pre/post Ansible

Une variable définie dans .secrets.env ne peut pas être overwrite avec une variable d’environnement.

Limitations de Vauban et choses à savoir

Tracabilité

Un des avantages de Vauban comme nous l’avions évoqué précédemment est son approche stateless et IaC. Grâce à la manière avec laquelle on build notre image, il est également possible d’ajouter de la tracabilité sur les opérations qui ont été effectuées pour arriver au produit fini.

De la même manière qu’un docker inspect sur une image docker vous donnera la liste des commandes qui ont été exécutés pour générer l’image (attention à ne pas y mettre de secrets donc !), Vauban se dote de deux fichiers :

  • /imginfo, qui contient, au format YAML, la liste d’application de playbook Ansible, avec la date, la branche git, le hash du commit et le hostname de la machine qui a été utilisé.
  • /packages, également au format YAML, qui contient les différences sur la liste des paquets installés, étape par étape.

Il est possible donc à partir de n’importe quelle image docker, ou directement dans la VM bootée de consulter ces fichiers pour avoir des infos sur le contenu de l’image.

Regardons ces fichiers en exemple :

---
iso: debian-11-generic-amd64.raw
date: 2022-01-31T10:54:34+00:00
stages:

    - date: 2022-01-31T16:28:38+00:00
      playbook: pb_base.yml
      hostname: master-11-base
      source: debian-11/iso
      git-sha1: 241d000c558f572eb7c713030a9877333514adbc
      git-branch: master

    - date: 2022-02-01T12:51:06+01:00
      playbook: pb_netdata.yml
      hostname: master-11-netdata
      source: master-11-base
      git-sha1: fd1876af78c1b75443846621fc1dfbc7415ae2f3
      git-branch: add-netdata

    - date: 2022-02-01T14:20:42+01:00
      playbook: pb_netdata.yml
      hostname: netdata01.stg.example.com
      source: master-11-netdata
      git-sha1: fd1876af78c1b75443846621fc1dfbc7415ae2f3
      git-branch: add-netdata

On peut voir les paquets nouvellement installés, et les mises à jour de paquets. On remarquera également la construction d’un conffs pour le host netdata01.stg.example.com

---
packages:

    - playbook: pb_base.yml
      hostname: master-11-base
      packages: |
          + apt-transport-https/stable,now 2.2.4 all [installed]
          ...
          - util-linux/stable,now 2.36.1-8 amd64 [installed,upgradable to: 2.36.1-8+deb11u1]
          - uuid-runtime/stable,now 2.36.1-8 amd64 [installed,upgradable to: 2.36.1-8+deb11u1]
          - vim-common/now 2:8.2.2434-3 all [installed,upgradable to: 2:8.2.2434-3+deb11u1]
          - vim-runtime/now 2:8.2.2434-3 all [installed,upgradable to: 2:8.2.2434-3+deb11u1]
          - vim-tiny/now 2:8.2.2434-3 amd64 [installed,upgradable to: 2:8.2.2434-3+deb11u1]
          - vim/now 2:8.2.2434-3 amd64 [installed,upgradable to: 2:8.2.2434-3+deb11u1]
          - wget/now 1.21-1+b1 amd64 [installed,upgradable to: 1.21-1+deb11u1]
          + util-linux/stable-security,now 2.36.1-8+deb11u1 amd64 [installed]
          + uuid-runtime/stable-security,now 2.36.1-8+deb11u1 amd64 [installed]
          + vim-common/stable,now 2:8.2.2434-3+deb11u1 all [installed,automatic]
          + vim-runtime/stable,now 2:8.2.2434-3+deb11u1 all [installed,automatic]
          + vim-tiny/stable,now 2:8.2.2434-3+deb11u1 amd64 [installed]
          + vim/stable,now 2:8.2.2434-3+deb11u1 amd64 [installed]


    - playbook: pb_netdata.yml
      hostname: master-11-netdata
      packages: |
          + libjudydebian1/stable,now 1.0.5-5+b2 amd64 [installed,automatic]
          + libnetfilter-acct1/stable,now 1.0.3-3 amd64 [installed,automatic]
          + libprotobuf23/stable,now 3.12.4-1 amd64 [installed,automatic]
          + libsnappy1v5/stable,now 1.1.8-1 amd64 [installed,automatic]
          + netdata/bullseye,now 1.33.0-11-nightly amd64 [installed]

    - playbook: pb_netdata.yml
      hostname: netdata01.stg.example.com
      packages: |

On peut voir les paquets nouvellement installés, et les mises à jour de paquets.
La dernière étape n’a pas modifié de paquet.

Limitations avec Ansible

Pas de systemd ni de kernel

Si l’intérêt du projet est de pouvoir construire des images “offlines”, reproductibles et hiérarchiques, un des inconvénients majeur est l’application d’Ansible dans un container Docker.

Le container Docker ne tourne pas avec son propre kernel, mais celui de l’host. Si des tâches d’Ansible nécessite d’avoir des interactions avec le kernel, des erreurs vont se produire. Par exemple :

  • L’utilisation du module sysctl avec l’option d’application immédiate
  • Le build de modules kernel DKMS (comme ZFS)

Par ailleurs, systemd n’est pas l’init process du container (et ne peut pas l’être). Toutes les opérations qui nécessitent d’intéragir avec un systemd qui tourne vont échouer, comme par exemple : service state=restarted.

Enable/disable un service systemd est une opération offline qui peut donc être faite dans Vauban.

Les opérations comme delegate_to ont également de fortes chances d’échouer si rien n’est fait dans les hooks pour permettre la connexion.

Il existe encore surement d’autres interactions précise de la même nature qui risquent de ne pas fonctionner. Ces opérations restent rares, et il est normalement possible de faire fonctionner la plupart des rôles et playbooks Ansible assez facilement avec Vauban.

Pour conserver certains comportement runtime dans Ansible, il est peut-être necéssaire de fournir une condition du style : when: ansible_connection|default('dummy') != 'local'. Une autre possibilité serait de définir dans group_vars/all.yml une variable vauban: true dans les hooks vauban pré-ansible, et de filtrer avec when: not vauban|default('false').

Volumes persistants

Une des faiblesses de Vauban actuellement est la gestion des volumes persistants.

De la même manière que les containers, auxquels on peut assimiler nos images bootées en PXE, il est possible d’avoir du stockage persistant en mount-ant un disque dans la machine.

Si ce système fonctionne très bien pour stocker les données persistantes, le problème réside dans le bootstrapping d’une nouvelle machine. Si l’on souhaite avoir des données définies par Ansible dans un point de montage persistant, ces données ne seront par défaut pas accessibles lors du premier lancement de notre VM.

Par exemple :

Partons du principe que l’on souhaite avoir des données persistantes stockées dans /srv/my-custom-app.

Il se trouve que l’on souhaite également modifier un ou plusieurs fichiers de configuration dans /srv/my-custom-app. On va donc le faire avec Ansible, construire l’image avec Vauban, et ainsi avoir /srv/my-custom-app avec notre configuration.

Maintenant, lors du boot de notre première VM avec cette image, on fourni également un disque à mount sur /srv/my-custom-app.

Ce disque devra potentiellement être partitionnié et le(s) filesystem(s) créé(s). Cette opération ne peut être faite directement depuis Vauban, puisqu’elle est propre à la VM.

Si maintenant le disque est prêt, et mounté dans /srv/my-custom-app. Par défaut, les fichiers de configuration que l’on avait mis dans ce dossier n’existeront pas, puisque situé “sous” le point de montage, dans l’image et non pas sur le disque.

La solution la plus simple reste encore d’appliquer notre Ansible une seconde fois, sur la VM nouvellement créée pour bootstrap la VM.

Une fois cette opération faite, la VM se comporte comme prévue, et peut-être redémarrée sans problème.

Pour cette raison, il est préférable de ne mount que les données persistantes, et non pas la configuration, sur les volumes. La configuration peut-être mise à jour en live via Ansible, et de manière définitive dans l’image au besoin.

Pour la gestion des partitions et filesystem sur les disques persistants, soit l’utilisant d’un fs réseau (déjà partitionné et formaté) peut être suggérée, soit un service se lançant tôt dans le processus de boot pour créer les partions peut résoudre le problème

Conclusion

J’espère que cet article a pu être éclairant sur certains points, et que le projet vous a intrigué.

Le projet est disponible sur GitHub.

J’ai pu également donner une conférence disponible sur YouTube présentant le projet lors de la nuit de l’info 2021.

Pour toute autre question ou remarque, je serai ravi de les adresser via GitHub, ou par mail.

Merci d’avoir lu ce pavé jusqu’au bout :sweat_smile:

Updated:

Leave a Comment

Your email address will not be published. Required fields are marked *

Loading...