diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml new file mode 100644 index 0000000..e8b7b07 --- /dev/null +++ b/.forgejo/workflows/docs.yml @@ -0,0 +1,168 @@ +name: Documentation + +# Un tag de release ajoute une version au site : la publication est donc +# déclenchée par les deux, sans filtre de chemin sur les tags — c'est le tag +# lui-même qui est la nouveauté, pas un fichier modifié. +on: + push: + branches: + - main + tags: + - '[0-9]*.[0-9]*.[0-9]*' + workflow_dispatch: + +# Deux publications simultanées se pousseraient l'une sur l'autre. +concurrency: + group: pages + cancel-in-progress: false + +jobs: + publish: + runs-on: docker + env: + TOKEN: ${{ secrets.RELEASE }} + SITE_DIR: /tmp/site + # Préfixe de chemin sous lequel le site est servi. Vide = racine du + # domaine, ce qui couvre le cas courant et un serveur de test local. + # À renseigner (par exemple /two) seulement si les pages sont publiées + # sous un sous-chemin. Aucun nom d'hôte ici : les liens du menu de + # version sont relatifs à l'origine, donc le site fonctionne à + # l'identique en local et en production. + DOCS_BASE_PATH: '' + steps: + # fetch-depth: 0 — les tags et leur contenu sont nécessaires : chaque + # version est construite depuis son propre ref. + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - name: Installer Sphinx + run: | + apt-get update + apt-get install -y python3 python3-venv git + python3 -m venv /tmp/venv + /tmp/venv/bin/pip install --quiet --upgrade pip + /tmp/venv/bin/pip install --quiet -r docs/requirements.txt + + # Les versions publiables : main, plus les tags finaux qui contiennent + # déjà un répertoire docs/. 0.1.0 est antérieure à la documentation et + # n'est donc pas constructible — le filtre l'écarte de lui-même, sans + # liste à maintenir. + - name: Choisir les versions à publier + run: | + versions="" + for tag in $(git tag --sort=-v:refname) + do + case "${tag}" in *rc*) continue ;; esac + if git ls-tree --name-only "${tag}" | grep -qx docs + then + versions="${versions} ${tag}" + else + echo "ignoré : ${tag} n'a pas de docs/" + fi + done + echo "VERSIONS=${versions# }" >> "${GITHUB_ENV}" + echo "versions retenues : main${versions}" + + # main est construite en premier et son échec est fatal : la doc courante + # doit toujours partir. L'échec d'une version figée est signalé mais ne + # bloque pas la publication — une vieille version qui ne se reconstruit + # plus ne doit pas empêcher de publier la doc du jour. + - name: Construire chaque version + run: | + build () { + local ref="$1" src="$2" + DOCS_VERSION="${ref}" \ + /tmp/venv/bin/sphinx-build -b html -W --keep-going \ + -d "/tmp/doctrees-${ref}" "${src}/docs" "${SITE_DIR}/${ref}" + } + + build main . + + for version in ${VERSIONS} + do + rm -rf "/tmp/src-${version}" + git worktree add --quiet --detach "/tmp/src-${version}" "${version}" + if build "${version}" "/tmp/src-${version}" + then + echo "construit : ${version}" + else + echo "::warning::la version ${version} ne se construit plus, elle est absente du site" + rm -rf "${SITE_DIR}/${version}" + fi + git worktree remove --force "/tmp/src-${version}" + done + + # Les liens du menu de version sont relatifs à l'origine : le thème les + # concatène au chemin de la page courante avant de les poser en href, si + # bien qu'un chemin relatif y serait résolu depuis la page et casserait + # selon sa profondeur. Une barre initiale les ancre à la racine du site, + # sans jamais nommer d'hôte. + - name: Assembler la racine du site + run: | + preferred="$(echo ${VERSIONS} | tr ' ' '\n' | head -1)" + [ -n "${preferred}" ] || preferred="main" + + { + echo '[' + echo ' {"name": "dev (main)", "version": "main", "url": "'"${DOCS_BASE_PATH}"'/main/"},' + first=1 + for version in ${VERSIONS} + do + [ -d "${SITE_DIR}/${version}" ] || continue + [ ${first} -eq 1 ] && suffix=', "preferred": true' || suffix='' + first=0 + echo ' {"name": "'"${version}"'", "version": "'"${version}"'", "url": "'"${DOCS_BASE_PATH}"'/'"${version}"'/"'"${suffix}"'},' + done + } | sed '$ s/,$//' > "${SITE_DIR}/switcher.json" + echo ']' >> "${SITE_DIR}/switcher.json" + + python3 -c "import json,sys; json.load(open('${SITE_DIR}/switcher.json'))" + cat "${SITE_DIR}/switcher.json" + + # La racine ne sert qu'à rediriger : le contenu vit dans les + # sous-répertoires de version. + cat > "${SITE_DIR}/index.html" < + + + + two — documentation + + +

Documentation de two

+ + HTML + + - name: Alléger le site + run: | + find "${SITE_DIR}" -name '*.map' -delete + find "${SITE_DIR}" -name '.buildinfo' -delete + touch "${SITE_DIR}/.nojekyll" + du -sh "${SITE_DIR}" + + - name: Publier sur la branche pages + run: | + cd "${SITE_DIR}" + git init --quiet --initial-branch=pages + git config user.name "forgejo-actions" + git config user.email "forgejo-actions@git.g3e.fr" + git remote add origin "https://${TOKEN}@git.g3e.fr/${{ github.repository }}.git" + + if git fetch --quiet --depth=1 origin pages 2>/dev/null + then + git reset --soft FETCH_HEAD + else + echo "branche pages absente : premier build" + fi + + git add -A + if git diff --cached --quiet + then + echo "site identique au précédent, rien à publier" + exit 0 + fi + + git commit --quiet -m "docs: build de ${GITHUB_SHA}" + git push --quiet origin pages + echo "publié : $(git rev-parse --short HEAD) — $(git ls-files | wc -l) fichiers" diff --git a/.gitignore b/.gitignore index 68ae417..fd7d60e 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ go.work.sum # ignore local info data/ + +# Sphinx build output +docs/_build/ diff --git a/README.md b/README.md index 9439539..20627f4 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Options utiles : | `-d` | dry-run : affiche les commandes sans les exécuter | | `-V` | désactiver la vérification des sommes de contrôle | -Un déploiement relève les instances `dnsmasq@` et `metadata@` actives **avant** l'arrêt des +Un déploiement relève les instances `dnsmasq@`, `dhcp@` et `metadata@` actives **avant** l'arrêt des services, et les redémarre ensuite — c'est la seule façon de savoir lesquelles relancer. ## Configuration diff --git a/docs/architecture/vue-densemble.rst b/docs/architecture/vue-densemble.rst index b8fb8e8..a0f5c3f 100644 --- a/docs/architecture/vue-densemble.rst +++ b/docs/architecture/vue-densemble.rst @@ -50,7 +50,7 @@ Paquets * - ``internal/vm`` - cycle de vie d'une VM : tap, iptables, metadata, qemu * - ``internal/dhcp`` - - génération des configurations dnsmasq et entrées ip → mac + - plan d'adressage ip → mac, et configurations dnsmasq du backend historique * - ``internal/metadata`` - serveur de metadata cloud-init et ses templates * - ``internal/watchdog`` diff --git a/docs/concepts/vpc-subnet-vm.rst b/docs/concepts/vpc-subnet-vm.rst index ac1e673..6337ea9 100644 --- a/docs/concepts/vpc-subnet-vm.rst +++ b/docs/concepts/vpc-subnet-vm.rst @@ -24,7 +24,8 @@ Subnet ------ Un subnet appartient à un VPC et pose, dans son netns, un bridge qui porte ``interface_ip`` — la -gateway vue par les VM. Il fournit aussi le DHCP (dnsmasq) et les routes annoncées aux guests. +gateway vue par les VM. Il fournit aussi le DHCP — dnsmasq ou le serveur intégré selon +``dhcp.backend`` — et les routes annoncées aux guests. ``iface_type`` est une clé **logique** (``vms``, ``internet``, ``admin``…), traduite en nom de bridge physique par la configuration de l'agent. Une clé absente ou inconnue retombe sur diff --git a/docs/conf.py b/docs/conf.py index 19d4d9e..e6cdf00 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -3,6 +3,8 @@ # For the full list of built-in configuration values, see the documentation: # https://www.sphinx-doc.org/en/master/usage/configuration.html +import os + # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -43,6 +45,48 @@ html_theme = 'sphinx_book_theme' html_static_path = [] html_show_sphinx = False +# Le thème publie le source de chaque page dans _sources/ et l'expose derrière +# un bouton de téléchargement. Les deux vont ensemble : couper la copie sans +# couper le bouton laisserait un lien mort vers un répertoire vide. +html_copy_source = False +html_show_sourcelink = False + +# Le sélecteur de version est piloté par le workflow de publication : hors CI +# la variable est absente, le sélecteur n'apparaît pas, et le build ne dépend +# d'aucun réseau. +_docs_version = os.environ.get('DOCS_VERSION') + html_theme_options = { 'home_page_in_toc': True, + 'use_download_button': False, + 'icon_links': [ + { + 'name': 'Dépôt', + 'url': 'https://git.g3e.fr/syonad/two', + 'icon': 'fa-solid fa-code-branch', + 'type': 'fontawesome', + }, + ], } + +if _docs_version: + html_theme_options['switcher'] = { + # Chemin relatif volontairement : le thème le résout contre la racine + # de la version courante, donc toujours dans la même origine que la + # page. Une URL absolue ferait échouer la requête en CORS dès que le + # site est consulté depuis un autre hôte — un serveur de test local, + # par exemple. + 'json_url': '../switcher.json', + 'version_match': _docs_version, + } + # Le thème book vide navbar_start et place tout dans la barre latérale : le + # sélecteur doit donc y être inséré explicitement, à côté du logo. + html_sidebars = { + '**': [ + 'navbar-logo.html', + 'icon-links.html', + 'version-switcher.html', + 'search-button-field.html', + 'sbt-sidebar-nav.html', + ] + } diff --git a/docs/demarrage/installation.rst b/docs/demarrage/installation.rst index 56c4cda..ed4217a 100644 --- a/docs/demarrage/installation.rst +++ b/docs/demarrage/installation.rst @@ -73,6 +73,10 @@ Ce que fait ``-i`` **masqué** : il prendrait le port 53 en concurrence des instances ``dnsmasq@`` que l'agent lance dans les netns. +``dnsmasq`` reste installé même avec ``dhcp.backend: two`` : le backend intégré ne le remplace que +pour les subnets créés après la bascule, et le paquet est nécessaire tant qu'un hyperviseur peut +revenir en arrière. Voir :doc:`/exploitation/configuration`. + **Noyau** — chargement de ``br_netfilter``, puis ``net.ipv4.ip_forward = 1`` et ``net.bridge.bridge-nf-call-iptables = 1``. Cette dernière clé est **requise** par la DNAT vers le serveur de metadata : sans elle, iptables ne voit pas le trafic bridgé des VM et cloud-init @@ -118,16 +122,21 @@ Binaires installés * - ``db`` - inspection de la base clé-valeur en ligne de commande - ``-conf`` + * - ``dhcp`` + - serveur DHCP intégré, une instance par subnet dans le netns du VPC ; démarré uniquement + avec ``dhcp.backend: two`` + - ``-conf`` -Les trois partagent le même fichier, ``/etc/two/agent.yml`` — voir -:doc:`/exploitation/configuration`. +Les quatre partagent le même fichier, ``/etc/two/agent.yml`` — voir +:doc:`/exploitation/configuration`. ``dhcp`` reçoit en plus son bridge et ses deux chemins de +fichiers en paramètres, posés par son script d'enrobage. Mise à jour ----------- -``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les -services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter -les services à la main avant de lancer le script fait perdre cette liste. +``deploy.sh`` relève les instances ``dnsmasq@``, ``dhcp@`` et ``metadata@`` actives **avant** +d'arrêter les services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles +relancer. Arrêter les services à la main avant de lancer le script fait perdre cette liste. Vérifier l'installation ----------------------- diff --git a/docs/deploiement/image-qcow2.rst b/docs/deploiement/image-qcow2.rst index 63ddc28..1283f52 100644 --- a/docs/deploiement/image-qcow2.rst +++ b/docs/deploiement/image-qcow2.rst @@ -182,7 +182,7 @@ disques : le disque de travail et le disque cible ne doivent pas être confondus cd /work curl "${os_link}" -O - qemu-img convert ./*.qcow2 -O raw ${os_disk} + qemu-img convert ./*.qcow2 -O raw "${os_disk}" L'image du fournisseur est écrite **en brut** directement sur le disque cible : le qcow2 obtenu côté host contient donc une image disque complète et amorçable, sans backing file. @@ -194,7 +194,7 @@ côté host contient donc une image disque complète et amorçable, sans backing sleep 2 # La partition racine est la plus grande du disque - root_partition=$(fdisk -lo device,size /dev/sda | grep -E '^\/dev\/' | tr -s ' ' \ + root_partition=$(fdisk -lo device,size "${os_disk}" | grep -E '^/dev/' | tr -s ' ' \ | sort -rhk2 | head -n1 | cut -d ' ' -f1) mount -o nouuid $root_partition /mnt diff --git a/docs/exploitation/api-agent/asynchronisme.rst b/docs/exploitation/api-agent/asynchronisme.rst index 96b5172..a506d55 100644 --- a/docs/exploitation/api-agent/asynchronisme.rst +++ b/docs/exploitation/api-agent/asynchronisme.rst @@ -18,7 +18,7 @@ Les deux temps d'une requête A->>A: Prepare — valide, écrit "creating" A-->>C: 202 + ressource en creating A->>W: Dispatch (file d'attente) - W->>W: Execute — netns, netif, dnsmasq + W->>W: Execute — netns, netif, dhcp W->>W: état → running (ou error) C->>A: GET /subnets/ A-->>C: 200 + state diff --git a/docs/exploitation/configuration.rst b/docs/exploitation/configuration.rst index 63b29a9..7024527 100644 --- a/docs/exploitation/configuration.rst +++ b/docs/exploitation/configuration.rst @@ -1,12 +1,19 @@ Configuration ============= -Un seul fichier, ``/etc/two/agent.yml``, partagé par les trois binaires : ``agent -config``, -``metadata -conf`` et ``db -conf``. Le fichier de référence commenté est +Un seul fichier, ``/etc/two/agent.yml``, partagé par les quatre binaires : ``agent -config``, +``metadata -conf``, ``db -conf`` et ``dhcp -conf``. Le fichier de référence commenté est ``conf/agent/config.exemple.yml`` dans le dépôt. Le chargement se fait par **viper** : les clés sont celles ci-dessous, en YAML. +.. warning:: + + Un fichier **absent** est toléré : toutes les valeurs par défaut s'appliquent. Un fichier + **présent mais invalide** fait en revanche échouer le démarrage, volontairement — jusqu'à + la version 0.1.0 il était ignoré en silence, et l'agent tournait alors entièrement sur les + défauts sans le dire. Une tabulation d'indentation ou un ``--`` égaré suffisent. + .. danger:: **L'API de l'agent n'a aucune authentification.** L'exemple livré écoute sur @@ -117,6 +124,71 @@ Les chemins OVMF sont nécessaires aux VM démarrées avec ``uefi: true`` (paque Debian et Ubuntu). ``uefi_vars_dir`` reçoit une copie inscriptible des variables UEFI par VM, créée au démarrage et supprimée à l'arrêt. +Backend DHCP +------------ + +.. code-block:: yaml + + dhcp: + backend: dnsmasq # ou two + +Choisit qui sert le DHCP des subnets **créés par cet agent** : + +.. list-table:: + :header-rows: 1 + :widths: 14 44 42 + + * - Valeur + - Serveur + - Unit + * - ``dnsmasq`` + - dnsmasq, configuré par fichiers dans ``/etc/dnsmasq.d`` + - ``dnsmasq@_`` + * - ``two`` + - le binaire ``dhcp``, piloté par socket Unix + - ``dhcp@_`` + +Le défaut est ``dnsmasq`` : un fichier de configuration de la 0.1.0, non modifié, se comporte +exactement comme avant. Toute autre valeur que ``dnsmasq`` ou ``two`` fait échouer le démarrage. + +Le répertoire d'exécution du backend ``two`` — ``/run/two/dhcp`` — **n'est pas configurable** : +le script d'enrobage le code en dur, une clé que lui ignorerait serait un mensonge. + +Ce que le backend ``two`` apporte : la configuration DHCP devient modifiable par VM et non plus +seulement par subnet, ce qui permet de n'annoncer la route par défaut que sur **une** interface +d'une VM multi-réseaux. Le watchdog peut en outre interroger le serveur et comparer ce qu'il sert +à ce que la base dit — voir :doc:`diagnostic`. + +Bascule d'un backend à l'autre +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. warning:: + + L'option ne décide que du backend des **nouveaux** subnets. Elle ne migre rien : un subnet + déjà créé continue d'être servi par le serveur qui l'a été. Changer la valeur sans vider + l'hyperviseur laisse l'agent parler à un serveur qui ne tourne pas — les VM existantes + continuent, les nouvelles n'obtiennent pas d'adresse. + +La bascule est **manuelle** et suppose un hyperviseur vide : + +1. Supprimer toutes les VM, puis tous les subnets, puis les VPC. +2. Vérifier qu'il ne reste aucune unit DHCP active et aucun résidu : + + .. code-block:: bash + + systemctl list-units 'dnsmasq@*' 'dhcp@*' + ls /etc/dnsmasq.d/ /run/two/dhcp/ + +3. Modifier ``dhcp.backend`` dans ``/etc/two/agent.yml``. +4. ``systemctl restart agent`` — la valeur est lue au démarrage, pas à chaque commande. +5. Recréer VPC, subnets et VM. +6. Sur la première VM, vérifier l'adresse **et les trois routes** : la route par défaut, la + route vers le CIDR du VPC, et la route ``/32`` vers ``169.254.169.254``. C'est cette + dernière qui conditionne le provisionnement cloud-init. + +Le retour arrière suit la même procédure. Il n'y a pas de bascule à chaud, dans un sens ni dans +l'autre. + Watchdog -------- diff --git a/docs/exploitation/diagnostic.rst b/docs/exploitation/diagnostic.rst index 89f7b81..b60200c 100644 --- a/docs/exploitation/diagnostic.rst +++ b/docs/exploitation/diagnostic.rst @@ -25,7 +25,9 @@ partiellement créés subsistent. La VM démarre mais n'a pas d'adresse ------------------------------------ -Le DHCP est servi par l'instance ``dnsmasq@`` du subnet. +Le DHCP est servi par une instance dédiée au subnet. Quelle unit selon ``dhcp.backend`` : + +**Backend ``dnsmasq``** .. code-block:: bash @@ -34,7 +36,31 @@ Le DHCP est servi par l'instance ``dnsmasq@`` du subnet. cat /run/dnsmasq-_.leases cat /etc/dnsmasq.d/_.conf -Si dnsmasq ne voit passer aucune requête, le problème est en amont : tap absent, bridge non +**Backend ``two``** + +.. code-block:: bash + + systemctl status 'dhcp@_' + journalctl -u 'dhcp@_' -n 50 + + # Ce que le serveur a réellement en mémoire + echo '{"verb":"get-state"}' \ + | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq . + + # Ce qu'il enverrait à une MAC donnée, sans effet de bord + echo '{"verb":"probe","mac":"00:22:33:00:00:0A"}' \ + | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq .lease + +``probe`` est le point de départ le plus rapide : il montre l'adresse, le masque, le routeur, les +DNS et les routes classless tels qu'ils partiraient. Une réponse ``"served": false`` signifie que +la MAC n'est pas réservée — l'ordre ``set-host`` n'a jamais atteint le serveur, ou la VM n'a pas +été créée par cet agent. + +Le watchdog signale ces écarts de lui-même, à chaque tick, en comparant l'état servi à la base : +``dhcp reservation missing on the server``, ``stale dhcp reservation``, ``dhcp reservation +diverges``. Regarder ses notifications avant de sonder à la main. + +Si le serveur ne voit passer aucune requête, le problème est en amont : tap absent, bridge non raccordé, VM dans le mauvais netns. La VM a une adresse mais cloud-init n'applique rien diff --git a/docs/exploitation/observabilite.rst b/docs/exploitation/observabilite.rst index 05392d1..b8f7a4c 100644 --- a/docs/exploitation/observabilite.rst +++ b/docs/exploitation/observabilite.rst @@ -57,7 +57,8 @@ Journaux journalctl -u agent -f journalctl -u 'metadata@i-web' -n 50 - tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log + tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log # backend dnsmasq + journalctl -fu 'dhcp@vp-admin_br-sn000001' # backend two Inspection de la base --------------------- diff --git a/docs/exploitation/services.rst b/docs/exploitation/services.rst index 4e096cc..146f98c 100644 --- a/docs/exploitation/services.rst +++ b/docs/exploitation/services.rst @@ -1,7 +1,8 @@ Services systemd ================ -Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``. +Quatre units, installées sous ``/opt/two/bin`` par ``deploy.sh``. Les deux units DHCP +s'excluent : celle qui tourne dépend de ``dhcp.backend`` (voir :doc:`configuration`). .. list-table:: :header-rows: 1 @@ -15,7 +16,10 @@ Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``. - processus principal : API, dispatcher, exécution, watchdog * - ``dnsmasq@.service`` - ``_`` - - dnsmasq lancé dans le netns du VPC, un par subnet + - dnsmasq lancé dans le netns du VPC, un par subnet — backend ``dnsmasq`` + * - ``dhcp@.service`` + - ``_`` + - serveur DHCP intégré, un par subnet — backend ``two`` * - ``metadata@.service`` - ```` - serveur de metadata cloud-init, un par VM @@ -26,14 +30,15 @@ n'y a pas à les démarrer à la main en fonctionnement normal. .. code-block:: bash systemctl status agent - systemctl status 'dnsmasq@vp-admin_br-sn000001' + systemctl status 'dnsmasq@vp-admin_br-sn000001' # backend dnsmasq + systemctl status 'dhcp@vp-admin_br-sn000001' # backend two systemctl status 'metadata@i-web' dnsmasq ------- -Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq avec un fichier -de configuration par subnet, généré par l'agent : +Backend historique. Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq +avec un fichier de configuration par subnet, généré par l'agent : .. list-table:: :widths: 40 60 @@ -50,6 +55,42 @@ de configuration par subnet, généré par l'agent : Le fichier de baux et le journal sont les deux premiers endroits à regarder quand une VM n'obtient pas d'adresse. +Serveur DHCP intégré +-------------------- + +Backend ``two``. Le script ``run-dhcp-in-netns.sh`` entre dans le netns puis exécute le binaire +``dhcp``, à qui il passe le bridge à servir et ses deux chemins de fichiers — il ne déduit rien et +ignore le netns dans lequel il tourne : + +.. code-block:: bash + + /opt/two/bin/dhcp -conf /etc/two/agent.yml \ + -interface br-sn000001 \ + -state /run/two/dhcp/vp-admin_br-sn000001.state \ + -socket /run/two/dhcp/vp-admin_br-sn000001.sock + +.. list-table:: + :widths: 40 60 + + * - Socket de contrôle + - ``/run/two/dhcp/_.sock`` + * - État + - ``/run/two/dhcp/_.state`` + * - Journal + - ``journalctl -u 'dhcp@_'`` + +Il n'y a **ni fichier de configuration ni fichier de baux**. L'agent pousse l'état désiré sur la +socket de contrôle : la configuration du subnet à sa création, une réservation par interface à +chaque création ou suppression de VM. Les réservations sont statiques — une MAC inconnue n'obtient +rien, et le serveur reste silencieux plutôt que de répondre par un refus. + +Le fichier d'état **appartient au processus**, qui l'écrit et le relit à son démarrage. L'agent ne +l'écrit jamais ; il le supprime seulement, à la création du subnet pour écarter un résidu et à sa +suppression après avoir arrêté l'unit. Il vit dans ``/run`` parce qu'il n'a aucun sens sans le +netns, qui ne survit pas au redémarrage de l'host. + +Diagnostic : voir :doc:`diagnostic`, qui montre comment interroger la socket. + QEMU n'est pas une unit ----------------------- @@ -86,6 +127,6 @@ journal au moment d'un ``stop`` n'est donc pas une anomalie. Mise à jour ----------- -``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les +``deploy.sh`` relève les instances ``dnsmasq@``, ``dhcp@`` et ``metadata@`` actives **avant** d'arrêter les services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter les services à la main avant de lancer le script fait perdre cette liste. diff --git a/docs/versions/0.2.0.md b/docs/versions/0.2.0.md new file mode 100644 index 0000000..a8f52a5 --- /dev/null +++ b/docs/versions/0.2.0.md @@ -0,0 +1,2 @@ +```{include} ../../release_notes/0.2.0.md +``` diff --git a/docs/versions/index.rst b/docs/versions/index.rst index 4333ae4..09f40fa 100644 --- a/docs/versions/index.rst +++ b/docs/versions/index.rst @@ -6,6 +6,7 @@ Chaque version porte un nom de code dérivé du rang de sa publication : anges e .. toctree:: :maxdepth: 1 + 0.2.0 0.1.0 .. include:: ../../release_notes/codenames.md diff --git a/internal/api/dhcp/digest_test.go b/internal/api/dhcp/digest_test.go index fe5dd29..f8a0510 100644 --- a/internal/api/dhcp/digest_test.go +++ b/internal/api/dhcp/digest_test.go @@ -1,6 +1,8 @@ package dhcpapi import ( + "encoding/json" + "strings" "testing" ) @@ -171,3 +173,13 @@ func TestCanonical_SortsHostsByMAC(t *testing.T) { t.Errorf("hosts = %v, want sorted by mac", got.Hosts) } } + +func TestResponse_ServedIsAlwaysOnTheWire(t *testing.T) { + raw, err := json.Marshal(Response{OK: true, Served: false}) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + if !strings.Contains(string(raw), `"served":false`) { + t.Errorf("response = %s, want an explicit served:false — omitting it makes \"not served\" indistinguishable from a missing field when probing by hand", raw) + } +} diff --git a/internal/api/dhcp/models.go b/internal/api/dhcp/models.go index b0268cf..d454194 100644 --- a/internal/api/dhcp/models.go +++ b/internal/api/dhcp/models.go @@ -54,5 +54,5 @@ type Response struct { State *State `json:"state,omitempty"` Digest string `json:"digest,omitempty"` Lease *Lease `json:"lease,omitempty"` - Served bool `json:"served,omitempty"` + Served bool `json:"served"` } diff --git a/internal/config/agent/dhcp_test.go b/internal/config/agent/dhcp_test.go index 6796e56..50fed1c 100644 --- a/internal/config/agent/dhcp_test.go +++ b/internal/config/agent/dhcp_test.go @@ -54,3 +54,29 @@ func TestLoadConfig_ReadsTheTwoBackend(t *testing.T) { t.Errorf("backend = %q, want two", cfg.DHCP.Backend) } } + +func TestLoadConfig_MalformedFileIsReported(t *testing.T) { + path := writeConfig(t, "--\n\ndhcp:\n backend: two\n") + + if _, err := LoadConfig(path); err == nil { + t.Fatal("an unparseable config must be reported: silently falling back to defaults would run the wrong dhcp backend") + } +} + +func TestLoadConfig_TabIndentedFileIsReported(t *testing.T) { + path := writeConfig(t, "dhcp:\n\tbackend: two\n") + + if _, err := LoadConfig(path); err == nil { + t.Fatal("yaml forbids tabs for indentation: that must surface, not be swallowed") + } +} + +func TestLoadConfig_MissingFileStillFallsBackToDefaults(t *testing.T) { + cfg, err := LoadConfig(filepath.Join(t.TempDir(), "absent.yml")) + if err != nil { + t.Fatalf("an absent file remains valid, only an unreadable one is an error: %v", err) + } + if cfg.DHCP.Backend != BackendDnsmasq { + t.Errorf("backend = %q, want %q", cfg.DHCP.Backend, BackendDnsmasq) + } +} diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index d4afea1..0f84a09 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -1,6 +1,10 @@ package configuration import ( + "errors" + "fmt" + "os" + "github.com/spf13/viper" ) @@ -86,7 +90,9 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("logger.level", "info") v.SetDefault("logger.debug", false) - v.ReadInConfig() + if err := v.ReadInConfig(); err != nil && !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("read %s: %w", path, err) + } var cfg Config if err := v.Unmarshal(&cfg); err != nil { diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go index dc125a5..0e5833e 100644 --- a/internal/dhcp/generate.go +++ b/internal/dhcp/generate.go @@ -29,12 +29,7 @@ func GenerateConfig(c Config) (string, map[string]string, error) { fmt.Fprintf(&sb, "dhcp-hostsdir=%s\n", HostsDir(c.ConfDir, c.Name)) fmt.Fprintf(&sb, "dhcp-optsdir=%s\n", OptsDir(c.ConfDir, c.Name)) - entries := make(map[string]string) - i := 0 - for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) { - entries[ip.String()] = fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF) - i++ - } + entries := Entries(c.Network) for _, dir := range []string{c.ConfDir, HostsDir(c.ConfDir, c.Name), OptsDir(c.ConfDir, c.Name)} { if err := os.MkdirAll(dir, 0755); err != nil { @@ -46,6 +41,16 @@ func GenerateConfig(c Config) (string, map[string]string, error) { return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644) } +func Entries(network *net.IPNet) map[string]string { + entries := make(map[string]string) + i := 0 + for ip := cloneIP(network.IP); network.Contains(ip); incrementIP(ip) { + entries[ip.String()] = fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF) + i++ + } + return entries +} + func classlessRoutes(c Config) []string { nextHop := c.InterfaceIP.String() diff --git a/internal/dhcp/reservation.go b/internal/dhcp/reservation.go index 0cd875c..ab915f5 100644 --- a/internal/dhcp/reservation.go +++ b/internal/dhcp/reservation.go @@ -117,3 +117,11 @@ func RemoveSubnetDirs(confDir, name string) error { } return nil } + +func RemoveConfig(confDir, name string) error { + path := filepath.Join(confDir, name+".conf") + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", path, err) + } + return nil +} diff --git a/internal/dhcpbackend/backend.go b/internal/dhcpbackend/backend.go new file mode 100644 index 0000000..3c9c909 --- /dev/null +++ b/internal/dhcpbackend/backend.go @@ -0,0 +1,53 @@ +package dhcpbackend + +import ( + "fmt" + "net" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" +) + +type Subnet struct { + Name string + VPC string + Bridge string + Network *net.IPNet + InterfaceIP net.IP + VPCRoute *net.IPNet + DefaultGateway net.IP +} + +func (s Subnet) Instance() string { + return s.VPC + "_" + s.Bridge +} + +type Reservation struct { + Index int + MAC string + IP string + DefaultRoute bool +} + +type Backend interface { + Unit(s Subnet) string + ConfigureSubnet(s Subnet) error + TeardownSubnet(s Subnet) error + SetVM(s Subnet, vmName string, res []Reservation) error + DelVM(s Subnet, vmName string, res []Reservation) error +} + +func New(cfg *configuration.Config) (Backend, error) { + if cfg == nil { + return nil, fmt.Errorf("configuration is required to pick a dhcp backend") + } + if err := configuration.ValidBackend(cfg.DHCP.Backend); err != nil { + return nil, err + } + + switch cfg.DHCP.Backend { + case configuration.BackendTwo: + return Two{}, nil + default: + return Dnsmasq{}, nil + } +} diff --git a/internal/dhcpbackend/backend_test.go b/internal/dhcpbackend/backend_test.go new file mode 100644 index 0000000..bf63b2f --- /dev/null +++ b/internal/dhcpbackend/backend_test.go @@ -0,0 +1,99 @@ +package dhcpbackend + +import ( + "net" + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" +) + +func testSubnet(t *testing.T) Subnet { + t.Helper() + _, network, err := net.ParseCIDR("10.0.5.0/24") + if err != nil { + t.Fatalf("ParseCIDR: %v", err) + } + _, vpcRoute, err := net.ParseCIDR("10.0.0.0/16") + if err != nil { + t.Fatalf("ParseCIDR: %v", err) + } + return Subnet{ + Name: "sn-000001", + VPC: "vp-admin", + Bridge: "br-000001", + Network: network, + InterfaceIP: net.ParseIP("10.0.5.1"), + VPCRoute: vpcRoute, + DefaultGateway: net.ParseIP("10.0.5.254"), + } +} + +func configFor(backend string) *configuration.Config { + cfg := &configuration.Config{} + cfg.DHCP.Backend = backend + return cfg +} + +func TestInstance_JoinsVPCAndBridge(t *testing.T) { + if got := testSubnet(t).Instance(); got != "vp-admin_br-000001" { + t.Errorf("Instance = %s, want vp-admin_br-000001", got) + } +} + +func TestNew_DnsmasqIsTheDefault(t *testing.T) { + backend, err := New(configFor(configuration.BackendDnsmasq)) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, ok := backend.(Dnsmasq); !ok { + t.Errorf("backend = %T, want Dnsmasq", backend) + } +} + +func TestNew_ReturnsTheTwoBackendWhenAsked(t *testing.T) { + backend, err := New(configFor(configuration.BackendTwo)) + if err != nil { + t.Fatalf("New: %v", err) + } + if _, ok := backend.(Two); !ok { + t.Errorf("backend = %T, want Two", backend) + } +} + +func TestNew_RejectsAnUnknownBackend(t *testing.T) { + if _, err := New(configFor("dhcpd")); err == nil { + t.Fatal("an unknown backend must be reported rather than silently defaulted") + } +} + +func TestNew_RejectsAnEmptyBackend(t *testing.T) { + if _, err := New(configFor("")); err == nil { + t.Fatal("an empty backend must be reported: the config default is what fills it") + } +} + +func TestNew_RejectsANilConfig(t *testing.T) { + if _, err := New(nil); err == nil { + t.Fatal("a nil config must be reported") + } +} + +func TestUnit_NamesADistinctUnitPerBackend(t *testing.T) { + s := testSubnet(t) + + if got := (Dnsmasq{}).Unit(s); got != "dnsmasq@vp-admin_br-000001.service" { + t.Errorf("dnsmasq unit = %s", got) + } + if got := (Two{}).Unit(s); got != "dhcp@vp-admin_br-000001.service" { + t.Errorf("two unit = %s", got) + } +} + +func TestTag_IsPerInterfaceNotPerVM(t *testing.T) { + if tag("vm-web", 0) == tag("vm-web", 1) { + t.Error("two interfaces of the same vm must get distinct tags") + } + if got := tag("vm-web", 1); got != "vm-web-1" { + t.Errorf("tag = %s, want vm-web-1", got) + } +} diff --git a/internal/dhcpbackend/dnsmasq.go b/internal/dhcpbackend/dnsmasq.go new file mode 100644 index 0000000..207ecee --- /dev/null +++ b/internal/dhcpbackend/dnsmasq.go @@ -0,0 +1,132 @@ +package dhcpbackend + +import ( + "fmt" + "path/filepath" + + "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/pkg/systemd" +) + +type Dnsmasq struct { + ConfDir string +} + +func (b Dnsmasq) confDir() string { + if b.ConfDir == "" { + return dhcp.DefaultConfDir + } + return b.ConfDir +} + +func tag(vmName string, index int) string { + return fmt.Sprintf("%s-%d", vmName, index) +} + +func (Dnsmasq) Unit(s Subnet) string { + return dhcp.UnitName(s.Instance()) +} + +func (b Dnsmasq) config(s Subnet) dhcp.Config { + return dhcp.Config{ + Network: s.Network, + Name: s.Instance(), + ConfDir: b.confDir(), + InterfaceIP: s.InterfaceIP, + VPCRoute: s.VPCRoute, + DefaultGateway: s.DefaultGateway, + } +} + +func (b Dnsmasq) ConfigureSubnet(s Subnet) error { + if _, _, err := dhcp.GenerateConfig(b.config(s)); err != nil { + return fmt.Errorf("generate dhcp config: %w", err) + } + + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + if err := svc.Start(b.Unit(s)); err != nil { + return fmt.Errorf("start dnsmasq: %w", err) + } + return nil +} + +func (b Dnsmasq) TeardownSubnet(s Subnet) error { + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + unit := b.Unit(s) + if status, err := svc.Status(unit); err == nil && status.ActiveState == "active" { + if err := svc.Stop(unit); err != nil { + return fmt.Errorf("stop dnsmasq: %w", err) + } + } + + if err := dhcp.RemoveConfig(b.confDir(), s.Instance()); err != nil { + return err + } + return dhcp.RemoveSubnetDirs(b.confDir(), s.Instance()) +} + +func (b Dnsmasq) SetVM(s Subnet, vmName string, res []Reservation) error { + instance := s.Instance() + + reservations := make([]dhcp.Reservation, 0, len(res)) + var tags []string + for _, r := range res { + reservations = append(reservations, dhcp.Reservation{ + MAC: r.MAC, IP: r.IP, Tag: tag(vmName, r.Index), + }) + if !r.DefaultRoute { + tags = append(tags, tag(vmName, r.Index)) + } + } + + if err := dhcp.WriteReservations(b.confDir(), instance, vmName, reservations); err != nil { + return fmt.Errorf("write dhcp reservations on %s: %w", instance, err) + } + + options := dhcp.Config{InterfaceIP: s.InterfaceIP, VPCRoute: s.VPCRoute} + if err := dhcp.WriteVMOptions(b.confDir(), instance, vmName, tags, options); err != nil { + return fmt.Errorf("write dhcp options on %s: %w", instance, err) + } + return nil +} + +func (b Dnsmasq) DelVM(s Subnet, vmName string, _ []Reservation) error { + if err := dhcp.RemoveReservations(b.confDir(), s.Instance(), vmName); err != nil { + return err + } + + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + unit := b.Unit(s) + status, err := svc.Status(unit) + if err != nil || status.ActiveState != "active" { + return nil + } + if err := svc.Restart(unit); err != nil { + return fmt.Errorf("restart %s: %w", unit, err) + } + if status, err := svc.Status(unit); err != nil { + return fmt.Errorf("status %s after restart: %w", unit, err) + } else if status.ActiveState != "active" { + return fmt.Errorf("%s is %s after restart", unit, status.ActiveState) + } + return nil +} + +func (b Dnsmasq) ConfigPath(s Subnet) string { + return filepath.Join(b.confDir(), s.Instance()+".conf") +} diff --git a/internal/dhcpbackend/dnsmasq_test.go b/internal/dhcpbackend/dnsmasq_test.go new file mode 100644 index 0000000..1bb2d7c --- /dev/null +++ b/internal/dhcpbackend/dnsmasq_test.go @@ -0,0 +1,113 @@ +package dhcpbackend + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func dnsmasqBackend(t *testing.T) Dnsmasq { + t.Helper() + return Dnsmasq{ConfDir: t.TempDir()} +} + +func readFile(t *testing.T, path string) string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + return string(raw) +} + +func TestDnsmasq_ConfDirDefaultsToTheSystemOne(t *testing.T) { + if got := (Dnsmasq{}).confDir(); got != "/etc/dnsmasq.d" { + t.Errorf("confDir = %s, want /etc/dnsmasq.d", got) + } +} + +func TestDnsmasqSetVM_WritesOneReservationPerInterface(t *testing.T) { + b := dnsmasqBackend(t) + s := testSubnet(t) + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + hosts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".hosts.d", "vm-web")) + for _, want := range []string{"00:22:33:00:00:0a,10.0.5.10,set:vm-web-0", "00:22:33:00:00:0b,10.0.5.11,set:vm-web-1"} { + if !strings.Contains(hosts, want) { + t.Errorf("hosts file missing %q:\n%s", want, hosts) + } + } +} + +func TestDnsmasqSetVM_TagsOnlyTheInterfacesWithoutADefaultRoute(t *testing.T) { + b := dnsmasqBackend(t) + s := testSubnet(t) + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + opts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web")) + if strings.Contains(opts, "tag:vm-web-0") { + t.Errorf("the interface carrying the default route must get no override:\n%s", opts) + } + if !strings.Contains(opts, "tag:vm-web-1,3\n") { + t.Errorf("the secondary interface must get a bare option 3:\n%s", opts) + } + if !strings.Contains(opts, "tag:vm-web-1,121,") { + t.Errorf("the secondary interface must get its own option 121:\n%s", opts) + } +} + +func TestDnsmasqSetVM_SecondaryOptionsKeepTheMetadataRoute(t *testing.T) { + b := dnsmasqBackend(t) + s := testSubnet(t) + + res := []Reservation{{Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}} + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + opts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web")) + if !strings.Contains(opts, "169.254.169.254/32,10.0.5.1") { + t.Errorf("overriding option 121 without the metadata route breaks cloud-init:\n%s", opts) + } + if strings.Contains(opts, "0.0.0.0/0") { + t.Errorf("a secondary interface must not receive a default route:\n%s", opts) + } +} + +func TestDnsmasqSetVM_AllInterfacesDefaultRoutedWritesNoOptions(t *testing.T) { + b := dnsmasqBackend(t) + s := testSubnet(t) + + res := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}} + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + path := filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web") + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Errorf("no options file expected, got %v: the subnet-wide options already carry the default route", err) + } +} + +func TestDnsmasqSetVM_RejectsAVMWithoutReservation(t *testing.T) { + b := dnsmasqBackend(t) + + if err := b.SetVM(testSubnet(t), "vm-web", nil); err == nil { + t.Fatal("a vm with no reservation would get no address: that must be reported") + } +} diff --git a/internal/dhcpbackend/two.go b/internal/dhcpbackend/two.go new file mode 100644 index 0000000..71a4d37 --- /dev/null +++ b/internal/dhcpbackend/two.go @@ -0,0 +1,147 @@ +package dhcpbackend + +import ( + "fmt" + "time" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + dhcpclient "git.g3e.fr/syonad/two/internal/client/dhcp" + "git.g3e.fr/syonad/two/pkg/db/statefile" + "git.g3e.fr/syonad/two/pkg/systemd" +) + +const ( + readyTimeout = 5 * time.Second + readyPoll = 50 * time.Millisecond +) + +type Two struct { + RunDir string +} + +func (b Two) runDir() string { + if b.RunDir == "" { + return dhcpapi.DefaultRunDir + } + return b.RunDir +} + +func (b Two) Unit(s Subnet) string { + return dhcpapi.Unit(s.Instance()) +} + +func (b Two) client(s Subnet) *dhcpclient.Client { + return dhcpclient.New(dhcpapi.SocketPath(b.runDir(), s.Instance())) +} + +func (b Two) statePath(s Subnet) string { + return dhcpapi.StatePath(b.runDir(), s.Instance()) +} + +func (b Two) waitReady(s Subnet, timeout, poll time.Duration) error { + client := b.client(s) + deadline := time.Now().Add(timeout) + + var err error + for { + if _, _, err = client.GetState(); err == nil { + return nil + } + if time.Now().After(deadline) { + return fmt.Errorf("dhcp server for %s did not answer within %s: %w", s.Instance(), timeout, err) + } + time.Sleep(poll) + } +} + +func (b Two) ConfigureSubnet(s Subnet) error { + if err := statefile.Remove(b.statePath(s)); err != nil { + return fmt.Errorf("remove residual state: %w", err) + } + + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + if err := svc.Start(b.Unit(s)); err != nil { + return fmt.Errorf("start dhcp: %w", err) + } + if err := b.waitReady(s, readyTimeout, readyPoll); err != nil { + return err + } + + return b.pushSubnet(s) +} + +func (b Two) pushSubnet(s Subnet) error { + subnet := dhcpapi.Subnet{ + Network: s.Network.String(), + InterfaceIP: s.InterfaceIP.String(), + } + if s.VPCRoute != nil { + subnet.VPCRoute = s.VPCRoute.String() + } + if s.DefaultGateway != nil { + subnet.DefaultGateway = s.DefaultGateway.String() + } + + if err := b.client(s).SetSubnet(subnet); err != nil { + return fmt.Errorf("configure dhcp for %s: %w", s.Instance(), err) + } + return nil +} + +func (b Two) TeardownSubnet(s Subnet) error { + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + unit := b.Unit(s) + if status, err := svc.Status(unit); err == nil && status.ActiveState == "active" { + if err := svc.Stop(unit); err != nil { + return fmt.Errorf("stop dhcp: %w", err) + } + } + + return statefile.Remove(b.statePath(s)) +} + +func (b Two) SetVM(s Subnet, vmName string, res []Reservation) error { + client := b.client(s) + + for _, r := range res { + host := dhcpapi.Host{ + MAC: r.MAC, + IP: r.IP, + VM: vmName, + DefaultRoute: r.DefaultRoute, + } + if err := client.SetHost(host); err != nil { + return fmt.Errorf("reserve %s for vm %s on %s: %w", r.MAC, vmName, s.Instance(), err) + } + } + return nil +} + +func (b Two) DelVM(s Subnet, vmName string, res []Reservation) error { + client := b.client(s) + + for _, r := range res { + if err := client.DelHost(r.MAC); err != nil { + return fmt.Errorf("release %s of vm %s on %s: %w", r.MAC, vmName, s.Instance(), err) + } + } + return nil +} + +func (b Two) StatePath(s Subnet) string { + return b.statePath(s) +} + +func (b Two) State(s Subnet) (dhcpapi.State, string, error) { + return b.client(s).GetState() +} diff --git a/internal/dhcpbackend/two_test.go b/internal/dhcpbackend/two_test.go new file mode 100644 index 0000000..cedbb16 --- /dev/null +++ b/internal/dhcpbackend/two_test.go @@ -0,0 +1,254 @@ +package dhcpbackend + +import ( + "io" + "log/slog" + "net" + "os" + "testing" + "time" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpd" +) + +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "dhcpd") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func twoBackend(t *testing.T) (Two, Subnet, *dhcpd.Store) { + t.Helper() + + b := Two{RunDir: shortTempDir(t)} + s := testSubnet(t) + + store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance())) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("Listen: %v", err) + } + go server.Serve() + t.Cleanup(func() { server.Close() }) + + return b, s, store +} + +func TestTwo_RunDirDefaultsToTheSharedOne(t *testing.T) { + if got := (Two{}).runDir(); got != dhcpapi.DefaultRunDir { + t.Errorf("runDir = %s, want %s", got, dhcpapi.DefaultRunDir) + } +} + +func TestTwoWaitReady_ReturnsOnceTheServerAnswers(t *testing.T) { + b, s, _ := twoBackend(t) + + if err := b.waitReady(s, time.Second, 10*time.Millisecond); err != nil { + t.Fatalf("waitReady: %v", err) + } +} + +func TestTwoWaitReady_TimesOutWhenNothingListens(t *testing.T) { + b := Two{RunDir: shortTempDir(t)} + s := testSubnet(t) + + start := time.Now() + err := b.waitReady(s, 200*time.Millisecond, 10*time.Millisecond) + if err == nil { + t.Fatal("waitReady must report a server that never came up") + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("waitReady took %s, want the 200ms budget to apply", elapsed) + } +} + +func TestTwoPushSubnet_ReachesTheStore(t *testing.T) { + b, s, store := twoBackend(t) + + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + got, configured := store.Subnet() + if !configured { + t.Fatal("the subnet configuration did not reach the server") + } + if !got.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) { + t.Errorf("interface ip = %s, want 10.0.5.1", got.InterfaceIP) + } + if got.VPCRoute == nil || got.VPCRoute.String() != "10.0.0.0/16" { + t.Errorf("vpc route = %v, want 10.0.0.0/16", got.VPCRoute) + } + if !got.DefaultGateway.Equal(net.ParseIP("10.0.5.254")) { + t.Errorf("default gateway = %s, want 10.0.5.254", got.DefaultGateway) + } +} + +func TestTwoPushSubnet_OmitsAnAbsentVPCRouteAndGateway(t *testing.T) { + b, s, store := twoBackend(t) + s.VPCRoute = nil + s.DefaultGateway = nil + + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + got, _ := store.Subnet() + if got.VPCRoute != nil { + t.Errorf("vpc route = %v, want none", got.VPCRoute) + } + if got.DefaultGateway != nil { + t.Errorf("default gateway = %v, want none: a bridge subnet has no gateway of ours", got.DefaultGateway) + } +} + +func TestTwoSetVM_ReservesEveryInterface(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + hosts := store.Hosts() + if len(hosts) != 2 { + t.Fatalf("hosts = %d, want 2", len(hosts)) + } + for _, h := range hosts { + if h.VM != "vm-web" { + t.Errorf("host %s carries vm %q, want vm-web", h.MAC, h.VM) + } + } +} + +func TestTwoSetVM_CarriesTheDefaultRouteFlagPerInterface(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + + primary, known := store.Lookup(mustMAC(t, "00:22:33:00:00:0a")) + if !known || !primary.DefaultRoute { + t.Errorf("primary interface = %+v, want the default route", primary) + } + secondary, known := store.Lookup(mustMAC(t, "00:22:33:00:00:0b")) + if !known || secondary.DefaultRoute { + t.Errorf("secondary interface = %+v, want no default route", secondary) + } +} + +func TestTwoSetVM_IsIdempotent(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}} + for range 3 { + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + } + if got := len(store.Hosts()); got != 1 { + t.Errorf("hosts = %d, want 1", got) + } +} + +func TestTwoSetVM_RejectsAnInvalidMAC(t *testing.T) { + b, s, _ := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{{Index: 0, MAC: "nope", IP: "10.0.5.10"}} + if err := b.SetVM(s, "vm-web", res); err == nil { + t.Fatal("an invalid mac must be reported") + } +} + +func TestTwoDelVM_ReleasesEveryInterface(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + res := []Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}, + {Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}, + } + if err := b.SetVM(s, "vm-web", res); err != nil { + t.Fatalf("SetVM: %v", err) + } + if err := b.DelVM(s, "vm-web", res); err != nil { + t.Fatalf("DelVM: %v", err) + } + + if got := len(store.Hosts()); got != 0 { + t.Errorf("hosts = %d, want 0", got) + } +} + +func TestTwoDelVM_LeavesOtherVMsAlone(t *testing.T) { + b, s, store := twoBackend(t) + if err := b.pushSubnet(s); err != nil { + t.Fatalf("pushSubnet: %v", err) + } + + web := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}} + db := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11", DefaultRoute: true}} + for name, res := range map[string][]Reservation{"vm-web": web, "vm-db": db} { + if err := b.SetVM(s, name, res); err != nil { + t.Fatalf("SetVM %s: %v", name, err) + } + } + + if err := b.DelVM(s, "vm-web", web); err != nil { + t.Fatalf("DelVM: %v", err) + } + + hosts := store.Hosts() + if len(hosts) != 1 || hosts[0].VM != "vm-db" { + t.Errorf("remaining hosts = %+v, want only vm-db", hosts) + } +} + +func TestTwoDelVM_OnAnUnknownMACIsNotAnError(t *testing.T) { + b, s, _ := twoBackend(t) + + res := []Reservation{{Index: 0, MAC: "00:22:33:ff:ff:ff", IP: "10.0.5.99"}} + if err := b.DelVM(s, "vm-gone", res); err != nil { + t.Errorf("releasing an absent reservation must be idempotent, got %v", err) + } +} + +func mustMAC(t *testing.T, s string) net.HardwareAddr { + t.Helper() + m, err := net.ParseMAC(s) + if err != nil { + t.Fatalf("ParseMAC(%q): %v", s, err) + } + return m +} diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go index 8d71f0c..b2fb5f6 100644 --- a/internal/dispatcher/agent/subnet_commands.go +++ b/internal/dispatcher/agent/subnet_commands.go @@ -84,7 +84,7 @@ func (c CreateSubnetCommand) Execute(db *badger.DB, cfg *configuration.Config) e case <-time.After(time.Duration(cfg.Dispatcher.PollSeconds) * time.Second): } } - return subnet.CreateSubnet(db, c.Name) + return subnet.CreateSubnet(db, c.Name, cfg) } type DeleteSubnetCommand struct { @@ -104,8 +104,8 @@ func (c DeleteSubnetCommand) Prepare(db *badger.DB, _ *configuration.Config) err return state.Set(db, c.Key(), state.Deleting) } -func (c DeleteSubnetCommand) Execute(db *badger.DB, _ *configuration.Config) error { - if err := subnet.DeleteSubnet(db, c.Name); err != nil { +func (c DeleteSubnetCommand) Execute(db *badger.DB, cfg *configuration.Config) error { + if err := subnet.DeleteSubnet(db, c.Name, cfg); err != nil { return err } current, err := state.Get(db, c.Key()) diff --git a/internal/netif/vxlan.go b/internal/netif/vxlan.go index 70740bc..0930cb3 100644 --- a/internal/netif/vxlan.go +++ b/internal/netif/vxlan.go @@ -1,14 +1,31 @@ package netif import ( + "fmt" + "net" + "syscall" + "github.com/vishvananda/netlink" ) +const ( + scopeUniverse = 0 + addressSecondary = 0x01 +) + func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { link, err := netlink.LinkByName(localIface) if err != nil { return err } + addrs, err := netlink.AddrList(link, syscall.AF_INET) + if err != nil { + return err + } + local, err := vtepAddress(localIface, addrs) + if err != nil { + return err + } vxlan := &netlink.Vxlan{ LinkAttrs: netlink.LinkAttrs{ Name: name, @@ -17,7 +34,18 @@ func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { VxlanId: vxlanID, Port: 4789, VtepDevIndex: link.Attrs().Index, + SrcAddr: local, Learning: false, } return netlink.LinkAdd(vxlan) } + +func vtepAddress(iface string, addrs []netlink.Addr) (net.IP, error) { + for _, a := range addrs { + if a.IPNet == nil || a.IP.To4() == nil || a.Scope != scopeUniverse || a.Flags&addressSecondary != 0 { + continue + } + return a.IP.To4(), nil + } + return nil, fmt.Errorf("no primary global IPv4 address on %s for the VXLAN local endpoint", iface) +} diff --git a/internal/netif/vxlan_test.go b/internal/netif/vxlan_test.go new file mode 100644 index 0000000..6ea7ca4 --- /dev/null +++ b/internal/netif/vxlan_test.go @@ -0,0 +1,64 @@ +package netif + +import ( + "net" + "strings" + "testing" + + "github.com/vishvananda/netlink" +) + +func addr(cidr string, scope, flags int) netlink.Addr { + ip, network, err := net.ParseCIDR(cidr) + if err != nil { + panic(err) + } + network.IP = ip + return netlink.Addr{IPNet: network, Scope: scope, Flags: flags} +} + +func TestVtepAddress_PicksThePrimaryGlobalIPv4(t *testing.T) { + addrs := []netlink.Addr{ + addr("127.0.0.1/8", 254, 0), + addr("169.254.0.3/28", 0, 0x01), + addr("192.168.14.11/24", 0, 0x80), + addr("192.168.14.99/24", 0, 0x01), + } + got, err := vtepAddress("br-000000", addrs) + if err != nil || got.String() != "192.168.14.11" { + t.Errorf("vtepAddress = %v, %v, want 192.168.14.11", got, err) + } +} + +func TestVtepAddress_SkipsLinkScopeAndSecondaryAddresses(t *testing.T) { + addrs := []netlink.Addr{ + addr("169.254.0.3/28", 253, 0), + addr("192.168.14.99/24", 0, 0x01), + addr("192.168.14.12/24", 0, 0), + } + got, err := vtepAddress("br-000000", addrs) + if err != nil || got.String() != "192.168.14.12" { + t.Errorf("vtepAddress = %v, %v, want 192.168.14.12", got, err) + } +} + +func TestVtepAddress_RefusesAnInterfaceWithoutUsableAddress(t *testing.T) { + for name, addrs := range map[string][]netlink.Addr{ + "no address": nil, + "only secondary": {addr("192.168.14.99/24", 0, 0x01)}, + "only link": {addr("169.254.0.3/28", 253, 0)}, + "only IPv6": {addr("fd00::11/64", 0, 0)}, + } { + _, err := vtepAddress("br-000000", addrs) + if err == nil || !strings.Contains(err.Error(), "no primary global IPv4 address on br-000000") { + t.Errorf("%s: error = %v", name, err) + } + } +} + +func TestVtepAddress_ReturnsAFourByteAddress(t *testing.T) { + got, err := vtepAddress("br-000000", []netlink.Addr{addr("192.168.14.11/24", 0, 0)}) + if err != nil || len(got) != 4 { + t.Errorf("vtepAddress = %v (%d bytes), %v", got, len(got), err) + } +} diff --git a/internal/subnet/create.go b/internal/subnet/create.go index e37b865..1164219 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -3,17 +3,18 @@ package subnet import ( "fmt" + configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/state" - "git.g3e.fr/syonad/two/pkg/systemd" "github.com/dgraph-io/badger/v4" ) -func CreateSubnet(db *badger.DB, subnetName string) error { +func CreateSubnet(db *badger.DB, subnetName string, cfg *configuration.Config) error { current, err := state.Get(db, "subnet/"+subnetName) if err != nil { return err @@ -27,14 +28,19 @@ func CreateSubnet(db *badger.DB, subnetName string) error { return err } - if err := createSubnet(db, subnetName, d); err != nil { + backend, err := dhcpbackend.New(cfg) + if err != nil { + return err + } + + if err := createSubnet(db, subnetName, d, backend); err != nil { return err } return state.Set(db, "subnet/"+subnetName, state.Running) } -func createSubnet(db *badger.DB, subnetName string, d subnetData) error { +func createSubnet(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error { vethE := "v-" + d.subnetID + "-e" vethI := "v-" + d.subnetID + "-i" @@ -110,7 +116,7 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error { } } - return startDHCP(db, subnetName, d) + return startDHCP(db, subnetName, d, backend) } func setupVxlanHost(d subnetData, vethE string) error { @@ -136,35 +142,23 @@ func setupVxlanHost(d subnetData, vethE string) error { return nil } -func startDHCP(db *badger.DB, subnetName string, d subnetData) error { - conf := dhcp.Config{ - Network: d.cidr, - Name: d.vpc + "_" + d.bridge, - ConfDir: dhcp.DefaultConfDir, - InterfaceIP: d.interfaceIP, - } +func startDHCP(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error { defaultGateway, vpcRoute, err := dhcpRouting(d, netif.GetDefaultGateway) if err != nil { return err } - conf.DefaultGateway = defaultGateway - conf.VPCRoute = vpcRoute - _, entries, err := dhcp.GenerateConfig(conf) - if err != nil { - return fmt.Errorf("generate dhcp config: %w", err) - } - if err := dhcp.StoreDHCPEntries(db, subnetName, entries); err != nil { + + if err := dhcp.StoreDHCPEntries(db, subnetName, dhcp.Entries(d.cidr)); err != nil { return fmt.Errorf("store dhcp entries: %w", err) } - svc, err := systemd.New() - if err != nil { - return fmt.Errorf("connect to systemd: %w", err) - } - defer svc.Close() - - if err := svc.Start("dnsmasq@" + conf.Name + ".service"); err != nil { - return fmt.Errorf("start dnsmasq: %w", err) - } - return nil + return backend.ConfigureSubnet(dhcpbackend.Subnet{ + Name: subnetName, + VPC: d.vpc, + Bridge: d.bridge, + Network: d.cidr, + InterfaceIP: d.interfaceIP, + VPCRoute: vpcRoute, + DefaultGateway: defaultGateway, + }) } diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index 7fdf67a..a8f12d0 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -2,21 +2,19 @@ package subnet import ( "fmt" - "os" - "path/filepath" - "git.g3e.fr/syonad/two/internal/dhcp" + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/state" "git.g3e.fr/syonad/two/pkg/db/kv" - "git.g3e.fr/syonad/two/pkg/systemd" "github.com/dgraph-io/badger/v4" ) -func DeleteSubnet(db *badger.DB, subnetName string) error { +func DeleteSubnet(db *badger.DB, subnetName string, cfg *configuration.Config) error { current, err := state.Get(db, "subnet/"+subnetName) if err != nil { return err @@ -30,7 +28,12 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { return err } - if err := stopDHCP(db, subnetName, d); err != nil { + backend, err := dhcpbackend.New(cfg) + if err != nil { + return err + } + + if err := stopDHCP(db, subnetName, d, backend); err != nil { return err } @@ -50,26 +53,13 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { return state.Set(db, "subnet/"+subnetName, state.Deleted) } -func stopDHCP(db *badger.DB, subnetName string, d subnetData) error { - svc, err := systemd.New() - if err != nil { - return fmt.Errorf("connect to systemd: %w", err) - } - defer svc.Close() - - svcName := "dnsmasq@" + d.vpc + "_" + d.bridge + ".service" - if status, err := svc.Status(svcName); err == nil && status.ActiveState == "active" { - if err := svc.Stop(svcName); err != nil { - return fmt.Errorf("stop dnsmasq: %w", err) - } - } - - if err := os.Remove(filepath.Join(dhcp.DefaultConfDir, d.vpc+"_"+d.bridge+".conf")); err != nil && !os.IsNotExist(err) { - return fmt.Errorf("remove dnsmasq config: %w", err) - } - - if err := dhcp.RemoveSubnetDirs(dhcp.DefaultConfDir, d.vpc+"_"+d.bridge); err != nil { - return fmt.Errorf("remove dnsmasq dirs: %w", err) +func stopDHCP(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error { + if err := backend.TeardownSubnet(dhcpbackend.Subnet{ + Name: subnetName, + VPC: d.vpc, + Bridge: d.bridge, + }); err != nil { + return err } if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil { diff --git a/internal/vm/create.go b/internal/vm/create.go index c976e9c..692cc25 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -8,7 +8,7 @@ import ( "path/filepath" configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/iptables" "git.g3e.fr/syonad/two/internal/metadata" "git.g3e.fr/syonad/two/internal/netif" @@ -34,6 +34,11 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { } nic := d.primary() + backend, err := dhcpbackend.New(cfg) + if err != nil { + return err + } + for _, n := range d.nics { if err := netif.CreateTap(n.tapID, n.bridge, n.vpcName); err != nil { return fmt.Errorf("create tap of interface %d: %w", n.index, err) @@ -54,7 +59,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("add metadata redirect: %w", err) } - if err := writeDHCPFiles(d, name); err != nil { + if err := writeDHCPFiles(d, name, backend); err != nil { return err } @@ -109,53 +114,37 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { return state.Set(db, "vm/"+name, state.Running) } -// writeDHCPFiles écrit, pour chaque subnet touché par la VM, les réservations -// de ses interfaces et les options qui suppriment la route par défaut sur les -// interfaces non primaires. Le subnet de l'interface primaire ne reçoit aucune -// option : les options non taggées du subnet portent déjà la route par défaut. -func writeDHCPFiles(d vmData, name string) error { - type subnetFiles struct { - nic nicData - reservations []dhcp.Reservation - tags []string - } - bySubnet := make(map[string]*subnetFiles) +func dhcpReservations(d vmData) map[string]*subnetReservations { + bySubnet := make(map[string]*subnetReservations) for _, n := range d.nics { - confName := n.vpcName + "_" + n.bridge - if bySubnet[confName] == nil { - bySubnet[confName] = &subnetFiles{nic: n} + key := n.vpcName + "_" + n.bridge + if bySubnet[key] == nil { + bySubnet[key] = &subnetReservations{subnet: dhcpbackend.Subnet{ + Name: n.subnetName, + VPC: n.vpcName, + Bridge: n.bridge, + InterfaceIP: net.ParseIP(n.interfaceIP), + VPCRoute: n.vpcCIDR, + }} } - f := bySubnet[confName] - f.reservations = append(f.reservations, dhcp.Reservation{ - MAC: n.mac, IP: n.ip, Tag: nicTag(name, n.index), + f := bySubnet[key] + f.reservations = append(f.reservations, dhcpbackend.Reservation{ + Index: n.index, MAC: n.mac, IP: n.ip, DefaultRoute: n.primary, }) - if !n.primary { - f.tags = append(f.tags, nicTag(name, n.index)) - } } + return bySubnet +} - for confName, f := range bySubnet { - if err := dhcp.WriteReservations(dhcp.DefaultConfDir, confName, name, f.reservations); err != nil { - return fmt.Errorf("write dhcp reservations on %s: %w", confName, err) - } - if err := dhcp.WriteVMOptions(dhcp.DefaultConfDir, confName, name, f.tags, dhcp.Config{ - InterfaceIP: net.ParseIP(f.nic.interfaceIP), - VPCRoute: f.nic.vpcCIDR, - }); err != nil { - return fmt.Errorf("write dhcp options on %s: %w", confName, err) +func writeDHCPFiles(d vmData, name string, backend dhcpbackend.Backend) error { + for _, f := range dhcpReservations(d) { + if err := backend.SetVM(f.subnet, name, f.reservations); err != nil { + return err } } return nil } -// nicTag identifie une interface auprès de dnsmasq. Il est par interface et non -// par VM : deux interfaces d'une même VM peuvent partager un subnet, et n'y -// avoir pas le même rôle. -func nicTag(vmName string, index int) string { - return fmt.Sprintf("%s-%d", vmName, index) -} - func copyFile(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err diff --git a/internal/vm/delete.go b/internal/vm/delete.go index 96f921c..9b2a50c 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -7,14 +7,13 @@ import ( "time" configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/iptables" "git.g3e.fr/syonad/two/internal/metadata" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/qmp" "git.g3e.fr/syonad/two/internal/state" - "git.g3e.fr/syonad/two/pkg/systemd" "github.com/dgraph-io/badger/v4" ) @@ -34,6 +33,11 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { } nic := d.primary() + backend, err := dhcpbackend.New(cfg) + if err != nil { + return err + } + socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock") if _, err := os.Stat(socketPath); err == nil { @@ -69,7 +73,7 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { } } - if err := removeDHCPFiles(d, name); err != nil { + if err := removeDHCPFiles(d, name, backend); err != nil { return err } @@ -81,52 +85,15 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return state.Set(db, "vm/"+name, state.Deleted) } -// removeDHCPFiles retire les fichiers de la VM dans chaque subnet qu'elle -// touche, puis redémarre les dnsmasq concernés : un fichier ajouté dans un -// dhcp-hostsdir est relu à chaud, un fichier retiré ne l'est pas (vérifié sur -// dnsmasq 2.90). -func removeDHCPFiles(d vmData, name string) error { - seen := make(map[string]bool) - for _, n := range d.nics { - confName := n.vpcName + "_" + n.bridge - if seen[confName] { - continue - } - seen[confName] = true - if err := removeDHCPReservation(confName, name); err != nil { +func removeDHCPFiles(d vmData, name string, backend dhcpbackend.Backend) error { + for _, f := range dhcpReservations(d) { + if err := backend.DelVM(f.subnet, name, f.reservations); err != nil { return err } } return nil } -func removeDHCPReservation(confName, name string) error { - if err := dhcp.RemoveReservations(dhcp.DefaultConfDir, confName, name); err != nil { - return err - } - - svc, err := systemd.New() - if err != nil { - return fmt.Errorf("connect to systemd: %w", err) - } - defer svc.Close() - - unit := dhcp.UnitName(confName) - status, err := svc.Status(unit) - if err != nil || status.ActiveState != "active" { - return nil - } - if err := svc.Restart(unit); err != nil { - return fmt.Errorf("restart %s: %w", unit, err) - } - if status, err := svc.Status(unit); err != nil { - return fmt.Errorf("status %s after restart: %w", unit, err) - } else if status.ActiveState != "active" { - return fmt.Errorf("%s is %s after restart", unit, status.ActiveState) - } - return nil -} - func waitQMPDead(socketPath string, timeout, poll time.Duration) { timer := time.After(timeout) for { diff --git a/internal/vm/dhcp.go b/internal/vm/dhcp.go new file mode 100644 index 0000000..6acc2ae --- /dev/null +++ b/internal/vm/dhcp.go @@ -0,0 +1,10 @@ +package vm + +import ( + "git.g3e.fr/syonad/two/internal/dhcpbackend" +) + +type subnetReservations struct { + subnet dhcpbackend.Subnet + reservations []dhcpbackend.Reservation +} diff --git a/internal/vm/dhcp_test.go b/internal/vm/dhcp_test.go new file mode 100644 index 0000000..6f6e487 --- /dev/null +++ b/internal/vm/dhcp_test.go @@ -0,0 +1,122 @@ +package vm + +import ( + "net" + "testing" +) + +func nic(idx int, subnet, vpc, bridge, ip, mac string, primary bool) nicData { + return nicData{ + index: idx, + subnetName: subnet, + vpcName: vpc, + bridge: bridge, + interfaceIP: "10.0.5.1", + ip: ip, + mac: mac, + primary: primary, + } +} + +func group(t *testing.T, groups map[string]*subnetReservations, key string) *subnetReservations { + t.Helper() + g, ok := groups[key] + if !ok || g == nil { + t.Fatalf("no group %q, got %v", key, keysOf(groups)) + } + return g +} + +func keysOf(groups map[string]*subnetReservations) []string { + keys := make([]string, 0, len(groups)) + for k := range groups { + keys = append(keys, k) + } + return keys +} + +func TestDHCPReservations_GroupsInterfacesBySubnet(t *testing.T) { + d := vmData{nics: []nicData{ + nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true), + nic(1, "sn-000001", "vp-admin", "br-000001", "10.0.5.11", "00:22:33:00:00:0b", false), + nic(2, "sn-000002", "vp-admin", "br-000002", "10.0.6.10", "00:22:33:00:00:0c", false), + }} + + got := dhcpReservations(d) + if len(got) != 2 { + t.Fatalf("groups = %d, want one per subnet", len(got)) + } + if n := len(group(t, got, "vp-admin_br-000001").reservations); n != 2 { + t.Errorf("br-000001 carries %d reservations, want 2", n) + } + if n := len(group(t, got, "vp-admin_br-000002").reservations); n != 1 { + t.Errorf("br-000002 carries %d reservations, want 1", n) + } +} + +func TestDHCPReservations_OnlyThePrimaryCarriesTheDefaultRoute(t *testing.T) { + d := vmData{nics: []nicData{ + nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true), + nic(1, "sn-000001", "vp-admin", "br-000001", "10.0.5.11", "00:22:33:00:00:0b", false), + }} + + res := group(t, dhcpReservations(d), "vp-admin_br-000001").reservations + byMAC := map[string]bool{} + for _, r := range res { + byMAC[r.MAC] = r.DefaultRoute + } + if !byMAC["00:22:33:00:00:0a"] { + t.Error("the primary interface must carry the default route") + } + if byMAC["00:22:33:00:00:0b"] { + t.Error("a secondary interface must not carry the default route") + } +} + +func TestDHCPReservations_KeepsTheInterfaceIndex(t *testing.T) { + d := vmData{nics: []nicData{ + nic(3, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true), + }} + + res := group(t, dhcpReservations(d), "vp-admin_br-000001").reservations + if res[0].Index != 3 { + t.Errorf("index = %d, want 3: the dnsmasq tag is derived from it", res[0].Index) + } +} + +func TestDHCPReservations_CarriesTheSubnetIdentity(t *testing.T) { + _, vpcCIDR, err := net.ParseCIDR("10.0.0.0/16") + if err != nil { + t.Fatalf("ParseCIDR: %v", err) + } + n := nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true) + n.vpcCIDR = vpcCIDR + + g := group(t, dhcpReservations(vmData{nics: []nicData{n}}), "vp-admin_br-000001") + if g.subnet.Name != "sn-000001" { + t.Errorf("subnet name = %s, want sn-000001", g.subnet.Name) + } + if !g.subnet.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) { + t.Errorf("interface ip = %s, want 10.0.5.1", g.subnet.InterfaceIP) + } + if g.subnet.VPCRoute == nil || g.subnet.VPCRoute.String() != "10.0.0.0/16" { + t.Errorf("vpc route = %v, want 10.0.0.0/16", g.subnet.VPCRoute) + } +} + +func TestDHCPReservations_SameBridgeInTwoVPCsStaysSeparate(t *testing.T) { + d := vmData{nics: []nicData{ + nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true), + nic(1, "sn-000009", "vp-other", "br-000001", "10.9.5.10", "00:22:33:00:00:0d", false), + }} + + if got := len(dhcpReservations(d)); got != 2 { + t.Errorf("groups = %d, want 2: the vpc is part of the instance identity", got) + } +} + +func TestDHCPReservations_NoNICYieldsNoGroup(t *testing.T) { + if got := len(dhcpReservations(vmData{})); got != 0 { + t.Errorf("groups = %d, want none", got) + } +} diff --git a/internal/watchdog/check_dhcp.go b/internal/watchdog/check_dhcp.go new file mode 100644 index 0000000..5e03e57 --- /dev/null +++ b/internal/watchdog/check_dhcp.go @@ -0,0 +1,189 @@ +package watchdog + +import ( + "fmt" + "os" + "sort" + "strconv" + "strings" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" + "git.g3e.fr/syonad/two/internal/state" + "git.g3e.fr/syonad/two/internal/watchdog/notify" + "git.g3e.fr/syonad/two/pkg/db/kv" + + "github.com/dgraph-io/badger/v4" +) + +type configFileReporter interface { + ConfigPath(s dhcpbackend.Subnet) string +} + +type stateReporter interface { + State(s dhcpbackend.Subnet) (dhcpapi.State, string, error) +} + +func expectedHosts(db *badger.DB, subnetName string) ([]dhcpapi.Host, error) { + pairs, err := kv.ListByPrefix(db, prefixVM) + if err != nil { + return nil, fmt.Errorf("listing vms: %w", err) + } + + hosts := make([]dhcpapi.Host, 0) + for _, vmName := range resourceNames(pairs, prefixVM) { + st, err := state.Get(db, prefixVM+vmName) + if err != nil || st != state.Running { + continue + } + + vmHosts, err := expectedVMHosts(db, vmName, subnetName) + if err != nil { + return nil, err + } + hosts = append(hosts, vmHosts...) + } + + dhcpapi.SortHosts(hosts) + return hosts, nil +} + +func expectedVMHosts(db *badger.DB, vmName, subnetName string) ([]dhcpapi.Host, error) { + prefix := prefixVM + vmName + "/nic/" + entries, err := kv.ListByPrefix(db, prefix) + if err != nil { + return nil, fmt.Errorf("listing nics of vm %s: %w", vmName, err) + } + + indexes := make([]int, 0) + for key := range entries { + parts := strings.Split(strings.TrimPrefix(key, prefix), "/") + if len(parts) != 2 || parts[1] != "subnet" { + continue + } + idx, err := strconv.Atoi(parts[0]) + if err != nil { + return nil, fmt.Errorf("invalid nic index %q for vm %s", parts[0], vmName) + } + indexes = append(indexes, idx) + } + sort.Ints(indexes) + + hosts := make([]dhcpapi.Host, 0, len(indexes)) + for _, idx := range indexes { + nic := fmt.Sprintf("%s%d/", prefix, idx) + if entries[nic+"subnet"] != subnetName { + continue + } + + ip := entries[nic+"ip"] + if ip == "" { + return nil, fmt.Errorf("nic %d of vm %s has no ip", idx, vmName) + } + mac, err := dhcp.GetMACForIP(db, subnetName, ip) + if err != nil { + return nil, fmt.Errorf("get mac for ip %s: %w", ip, err) + } + + hosts = append(hosts, dhcpapi.Host{ + MAC: mac, + IP: ip, + VM: vmName, + DefaultRoute: entries[nic+"primary"] == "true", + }) + } + return hosts, nil +} + +func checkDHCP(db *badger.DB, name string, s dhcpbackend.Subnet, backend dhcpbackend.Backend, u unitChecker, n notify.Notifier) { + if backend == nil { + return + } + if r, ok := backend.(configFileReporter); ok { + checkDHCPConfigFile(name, r.ConfigPath(s), n) + } + if r, ok := backend.(stateReporter); ok { + checkDHCPState(db, name, s, r, n) + } + checkUnit(kindSubnet, name, backend.Unit(s), u, n) +} + +func checkDHCPConfigFile(name, path string, n notify.Notifier) { + if _, err := os.Stat(path); err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("dnsmasq config missing (%s): %v", path, err)) + } +} + +func checkDHCPState(db *badger.DB, name string, s dhcpbackend.Subnet, r stateReporter, n notify.Notifier) { + served, _, err := r.State(s) + if err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("dhcp server unreachable: %v", err)) + return + } + if served.Subnet == nil { + n.Notify(kindSubnet, name, "dhcp server has no subnet configuration: it serves nothing") + } + + expected, err := expectedHosts(db, name) + if err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("expected dhcp reservations unreadable in database: %v", err)) + return + } + + expectedDigest, err := dhcpapi.Digest(dhcpapi.State{Hosts: expected}) + if err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("expected dhcp reservations inconsistent in database: %v", err)) + return + } + servedDigest, err := dhcpapi.Digest(dhcpapi.State{Hosts: served.Hosts}) + if err != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("dhcp reservations reported by the server are inconsistent: %v", err)) + return + } + if expectedDigest == servedDigest { + return + } + + for _, gap := range reservationGaps(expected, served.Hosts) { + n.Notify(kindSubnet, name, gap) + } +} + +func reservationGaps(expected, served []dhcpapi.Host) []string { + index := func(hosts []dhcpapi.Host) map[string]dhcpapi.Host { + byMAC := make(map[string]dhcpapi.Host, len(hosts)) + for _, h := range hosts { + if c, err := dhcpapi.CanonicalHost(h); err == nil { + byMAC[c.MAC] = c + } else { + byMAC[h.MAC] = h + } + } + return byMAC + } + + want, got := index(expected), index(served) + + gaps := make([]string, 0) + for mac, h := range want { + s, ok := got[mac] + if !ok { + gaps = append(gaps, fmt.Sprintf("dhcp reservation missing on the server: %s → %s (vm %s)", mac, h.IP, h.VM)) + continue + } + if s.IP != h.IP { + gaps = append(gaps, fmt.Sprintf("dhcp reservation diverges for %s: server serves %s, database says %s (vm %s)", mac, s.IP, h.IP, h.VM)) + } + if s.DefaultRoute != h.DefaultRoute { + gaps = append(gaps, fmt.Sprintf("dhcp default route diverges for %s: server says %t, database says %t (vm %s)", mac, s.DefaultRoute, h.DefaultRoute, h.VM)) + } + } + for mac, h := range got { + if _, ok := want[mac]; !ok { + gaps = append(gaps, fmt.Sprintf("stale dhcp reservation on the server: %s → %s (vm %s)", mac, h.IP, h.VM)) + } + } + sort.Strings(gaps) + return gaps +} diff --git a/internal/watchdog/check_dhcp_test.go b/internal/watchdog/check_dhcp_test.go new file mode 100644 index 0000000..a9c4801 --- /dev/null +++ b/internal/watchdog/check_dhcp_test.go @@ -0,0 +1,348 @@ +package watchdog + +import ( + "fmt" + "io" + "log/slog" + "net" + "os" + "path/filepath" + "testing" + + dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp" + "git.g3e.fr/syonad/two/internal/dhcpbackend" + "git.g3e.fr/syonad/two/internal/dhcpd" + "git.g3e.fr/syonad/two/internal/state" + + "github.com/dgraph-io/badger/v4" +) + +const ( + testSubnetName = "sn-000001" + testVPC = "vp-admin" + testBridge = "br-000001" +) + +func twoSubnet() dhcpbackend.Subnet { + return dhcpbackend.Subnet{Name: testSubnetName, VPC: testVPC, Bridge: testBridge} +} + +func shortTempDir(t *testing.T) string { + t.Helper() + dir, err := os.MkdirTemp("", "dhcpd") + if err != nil { + t.Fatalf("MkdirTemp: %v", err) + } + t.Cleanup(func() { os.RemoveAll(dir) }) + return dir +} + +func servedBy(t *testing.T) (dhcpbackend.Two, *dhcpd.Store) { + t.Helper() + + b := dhcpbackend.Two{RunDir: shortTempDir(t)} + s := twoSubnet() + + store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance())) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + + server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("Listen: %v", err) + } + go server.Serve() + t.Cleanup(func() { server.Close() }) + + _, network, err := net.ParseCIDR("10.0.5.0/24") + if err != nil { + t.Fatalf("ParseCIDR: %v", err) + } + if err := store.SetSubnet(dhcpd.SubnetConfig{Network: network, InterfaceIP: net.ParseIP("10.0.5.1")}); err != nil { + t.Fatalf("SetSubnet: %v", err) + } + return b, store +} + +func seedSubnetWithVM(t *testing.T, db *badger.DB, vmName, ip, mac string, primary bool) { + t.Helper() + + seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC) + seedKV(t, db, "subnet/"+testSubnetName+"/dhcp/"+ip, mac) + + if err := state.Set(db, "vm/"+vmName, state.Running); err != nil { + t.Fatalf("state.Set: %v", err) + } + seedKV(t, db, "vm/"+vmName+"/nic/0/subnet", testSubnetName) + seedKV(t, db, "vm/"+vmName+"/nic/0/ip", ip) + seedKV(t, db, "vm/"+vmName+"/nic/0/primary", fmt.Sprintf("%t", primary)) +} + +func TestExpectedHosts_ReadsRunningVMsOnThatSubnet(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + hosts, err := expectedHosts(db, testSubnetName) + if err != nil { + t.Fatalf("expectedHosts: %v", err) + } + if len(hosts) != 1 { + t.Fatalf("hosts = %+v, want one", hosts) + } + if hosts[0].MAC != "00:22:33:00:00:0A" || hosts[0].IP != "10.0.5.10" { + t.Errorf("host = %+v, want the mac derived from the address plan", hosts[0]) + } + if hosts[0].VM != "vm-web" || !hosts[0].DefaultRoute { + t.Errorf("host = %+v, want vm-web carrying the default route", hosts[0]) + } +} + +func TestExpectedHosts_IgnoresVMsThatAreNotRunning(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + if err := state.Set(db, "vm/vm-web", state.Deleting); err != nil { + t.Fatalf("state.Set: %v", err) + } + + hosts, err := expectedHosts(db, testSubnetName) + if err != nil { + t.Fatalf("expectedHosts: %v", err) + } + if len(hosts) != 0 { + t.Errorf("hosts = %+v, want none: a vm being deleted is not expected to be served", hosts) + } +} + +func TestExpectedHosts_IgnoresInterfacesOnOtherSubnets(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + seedKV(t, db, "vm/vm-web/nic/1/subnet", "sn-000002") + seedKV(t, db, "vm/vm-web/nic/1/ip", "10.0.6.10") + seedKV(t, db, "vm/vm-web/nic/1/primary", "false") + + hosts, err := expectedHosts(db, testSubnetName) + if err != nil { + t.Fatalf("expectedHosts: %v", err) + } + if len(hosts) != 1 { + t.Errorf("hosts = %+v, want only the interface on this subnet", hosts) + } +} + +func TestExpectedHosts_SortsByMAC(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-b", "10.0.5.11", "00:22:33:00:00:0B", true) + seedSubnetWithVM(t, db, "vm-a", "10.0.5.10", "00:22:33:00:00:0A", true) + + hosts, err := expectedHosts(db, testSubnetName) + if err != nil { + t.Fatalf("expectedHosts: %v", err) + } + if len(hosts) != 2 || hosts[0].MAC != "00:22:33:00:00:0A" { + t.Errorf("hosts = %+v, want sorted by mac", hosts) + } +} + +func TestExpectedHosts_ReportsAnIPWithoutAMAC(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + seedKV(t, db, "vm/vm-web/nic/0/ip", "10.0.5.99") + + if _, err := expectedHosts(db, testSubnetName); err == nil { + t.Fatal("an ip absent from the address plan must be reported, not skipped") + } +} + +func TestCheckDHCPState_MACCaseAloneIsNotADivergence(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if len(r.calls) != 0 { + t.Errorf("dhcp.Entries stores uppercase macs and the server normalizes to lowercase: that alone must not read as drift, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ADivergenceIsReportedOnceNotAsBothMissingAndStale(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.99", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if len(r.calls) != 1 { + t.Errorf("notifications = %+v, want a single diverging-reservation report", r.calls) + } + if r.hasProblemContaining("missing on the server") || r.hasProblemContaining("stale dhcp") { + t.Errorf("without mac normalization the same host reads as both missing and stale: %+v", r.calls) + } +} + +func TestCheckDHCPState_SilentWhenServerMatchesDatabase(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if len(r.calls) != 0 { + t.Errorf("notifications = %+v, want none when the server agrees with the database", r.calls) + } +} + +func TestCheckDHCPState_ReportsAReservationTheServerNeverGot(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("dhcp reservation missing on the server") { + t.Errorf("a lost set-host order must be reported, got %+v", r.calls) + } + if !r.hasProblemContaining("vm vm-web") { + t.Errorf("the report must name the vm, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsAStaleReservation(t *testing.T) { + db := newTestDB(t) + seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-gone", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("stale dhcp reservation on the server") { + t.Errorf("a lost del-host order must be reported, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsADivergingIP(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.99", DefaultRoute: true}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("dhcp reservation diverges") { + t.Errorf("a mac served with the wrong address must be reported, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsADivergingDefaultRoute(t *testing.T) { + db := newTestDB(t) + seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true) + + b, _ := servedBy(t) + if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{ + {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: false}, + }); err != nil { + t.Fatalf("SetVM: %v", err) + } + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("dhcp default route diverges") { + t.Errorf("a wrong default route would break multi-subnet routing, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsAnUnreachableServer(t *testing.T) { + db := newTestDB(t) + seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC) + + b := dhcpbackend.Two{RunDir: shortTempDir(t)} + + r := &recorder{} + checkDHCPState(db, testSubnetName, twoSubnet(), b, r) + + if !r.hasProblemContaining("dhcp server unreachable") { + t.Errorf("a dead server must be reported, got %+v", r.calls) + } +} + +func TestCheckDHCPState_ReportsAServerWithNoSubnetConfiguration(t *testing.T) { + db := newTestDB(t) + seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC) + + b := dhcpbackend.Two{RunDir: shortTempDir(t)} + s := twoSubnet() + store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance())) + if err := store.Load(); err != nil { + t.Fatalf("Load: %v", err) + } + server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatalf("Listen: %v", err) + } + go server.Serve() + t.Cleanup(func() { server.Close() }) + + r := &recorder{} + checkDHCPState(db, testSubnetName, s, b, r) + + if !r.hasProblemContaining("no subnet configuration") { + t.Errorf("a server that was never configured serves nothing, got %+v", r.calls) + } +} + +func TestCheckDHCPConfigFile_ReportsAMissingDnsmasqConfig(t *testing.T) { + r := &recorder{} + checkDHCPConfigFile(testSubnetName, filepath.Join(t.TempDir(), "absent.conf"), r) + + if !r.hasProblemContaining("dnsmasq config missing") { + t.Errorf("notifications = %+v, want the missing config reported", r.calls) + } +} + +func TestCheckDHCP_WithoutABackendChecksNothing(t *testing.T) { + db := newTestDB(t) + r := &recorder{} + + checkDHCP(db, testSubnetName, twoSubnet(), nil, nil, r) + + if len(r.calls) != 0 { + t.Errorf("notifications = %+v, want none: the caller already reported the unusable backend", r.calls) + } +} diff --git a/internal/watchdog/check_subnet.go b/internal/watchdog/check_subnet.go index fafb2ff..99dac17 100644 --- a/internal/watchdog/check_subnet.go +++ b/internal/watchdog/check_subnet.go @@ -2,12 +2,11 @@ package watchdog import ( "fmt" - "os" - "path/filepath" "strconv" "strings" - "git.g3e.fr/syonad/two/internal/dhcp" + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/dhcpbackend" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/state" "git.g3e.fr/syonad/two/internal/watchdog/notify" @@ -30,16 +29,14 @@ func subnetIfaceNames(subnetName string) (hostVeth, nsVeth, bridge string, err e return "v-" + id + "-e", "v-" + id + "-i", "br-" + id, nil } -func dnsmasqName(vpc, bridge string) string { - return vpc + "_" + bridge -} - -func CheckSubnets(db *badger.DB, u unitChecker, n notify.Notifier) error { +func CheckSubnets(db *badger.DB, cfg *configuration.Config, u unitChecker, n notify.Notifier) error { pairs, err := kv.ListByPrefix(db, prefixSubnet) if err != nil { return fmt.Errorf("watchdog: listing subnets: %w", err) } + backend, backendErr := dhcpbackend.New(cfg) + for _, name := range resourceNames(pairs, prefixSubnet) { st, err := state.Get(db, prefixSubnet+name) if err != nil { @@ -49,12 +46,15 @@ func CheckSubnets(db *badger.DB, u unitChecker, n notify.Notifier) error { if st != state.Running { continue } - checkSubnet(db, name, u, n) + checkSubnet(db, name, backend, u, n) + if backendErr != nil { + n.Notify(kindSubnet, name, fmt.Sprintf("dhcp checks skipped, backend unusable: %v", backendErr)) + } } return nil } -func checkSubnet(db *badger.DB, name string, u unitChecker, n notify.Notifier) { +func checkSubnet(db *badger.DB, name string, backend dhcpbackend.Backend, u unitChecker, n notify.Notifier) { hostVeth, nsVeth, bridge, err := subnetIfaceNames(name) if err != nil { n.Notify(kindSubnet, name, err.Error()) @@ -90,13 +90,7 @@ func checkSubnet(db *badger.DB, name string, u unitChecker, n notify.Notifier) { checkSubnetNetns(name, vpc, nsVeth, bridge, n) - dnsName := dnsmasqName(vpc, bridge) - conf := filepath.Join(dhcp.DefaultConfDir, dnsName+".conf") - if _, err := os.Stat(conf); err != nil { - n.Notify(kindSubnet, name, fmt.Sprintf("dnsmasq config missing (%s): %v", conf, err)) - } - - checkUnit(kindSubnet, name, "dnsmasq@"+dnsName+".service", u, n) + checkDHCP(db, name, dhcpbackend.Subnet{Name: name, VPC: vpc, Bridge: bridge}, backend, u, n) } func checkVxlanIface(db *badger.DB, name string, n notify.Notifier) { diff --git a/internal/watchdog/check_subnet_test.go b/internal/watchdog/check_subnet_test.go index 6928e6f..ec4acd4 100644 --- a/internal/watchdog/check_subnet_test.go +++ b/internal/watchdog/check_subnet_test.go @@ -39,17 +39,11 @@ func TestSubnetIfaceNames_TiretFinal(t *testing.T) { } } -func TestDnsmasqName(t *testing.T) { - if got := dnsmasqName("vp-admin", "br-000000"); got != "vp-admin_br-000000" { - t.Errorf("dnsmasqName = %q, attendu vp-admin_br-000000", got) - } -} - func TestCheckSubnets_BaseVide(t *testing.T) { db := newTestDB(t) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } if len(r.calls) != 0 { @@ -64,7 +58,7 @@ func TestCheckSubnets_IgnoreLesEtatsNonRunning(t *testing.T) { } r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } if len(r.calls) != 0 { @@ -77,7 +71,7 @@ func TestCheckSubnets_VPCManquantEnBase(t *testing.T) { seedResource(t, db, prefixSubnet, "br-000042", state.Running) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -96,7 +90,7 @@ func TestCheckSubnets_ModeManquantEnBase(t *testing.T) { seedKV(t, db, prefixSubnet+"br-000042/vpc", "vp-admin") r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -110,7 +104,7 @@ func TestCheckSubnets_ModeInconnu(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", "macvlan") r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -124,7 +118,7 @@ func TestCheckSubnets_ModeBridgeNeVerifiePasDeVxlan(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -143,7 +137,7 @@ func TestCheckSubnets_ModeVxlanSansVxlanID(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeVxlan) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -158,7 +152,7 @@ func TestCheckSubnets_ModeVxlanVxlanIDInvalide(t *testing.T) { seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "pas-un-nombre") r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -173,7 +167,7 @@ func TestCheckSubnets_ModeVxlanVerifieLInterfaceVxlan(t *testing.T) { seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "42") r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -187,7 +181,7 @@ func TestCheckSubnets_ConfigDnsmasqAbsente(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -205,7 +199,7 @@ func TestCheckSubnets_UnitDnsmasqInterrogee(t *testing.T) { u := newFakeUnits().active("dnsmasq@vp-admin_br-000042.service") r := &recorder{} - if err := CheckSubnets(db, u, r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -223,7 +217,7 @@ func TestCheckSubnets_UnitDnsmasqInactive(t *testing.T) { u := newFakeUnits().inactive("dnsmasq@vp-admin_br-000042.service", "failed") r := &recorder{} - if err := CheckSubnets(db, u, r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -238,7 +232,7 @@ func TestCheckSubnets_UnitIllisible(t *testing.T) { u := newFakeUnits().failing("dnsmasq@vp-admin_br-000042.service", errors.New("dbus indisponible")) r := &recorder{} - if err := CheckSubnets(db, u, r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -252,7 +246,7 @@ func TestCheckSubnets_SansUnitCheckerPasDeVerificationDUnit(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) r := &recorder{} - if err := CheckSubnets(db, nil, r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), nil, r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -267,7 +261,7 @@ func TestCheckSubnets_EtatCorrompuNInterrompPasLaBoucle(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) r := &recorder{} - if err := CheckSubnets(db, newFakeUnits(), r); err != nil { + if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil { t.Fatalf("un état corrompu ne doit pas faire échouer CheckSubnets: %v", err) } diff --git a/internal/watchdog/helpers_test.go b/internal/watchdog/helpers_test.go index 891eeba..84e9c79 100644 --- a/internal/watchdog/helpers_test.go +++ b/internal/watchdog/helpers_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/state" "git.g3e.fr/syonad/two/internal/watchdog/notify" "git.g3e.fr/syonad/two/pkg/db/kv" @@ -108,3 +109,9 @@ func seedResource(t *testing.T, db *badger.DB, prefix, name string, s state.Stat t.Fatalf("seedResource %s%s: %v", prefix, name, err) } } + +func dnsmasqConfig() *configuration.Config { + cfg := &configuration.Config{} + cfg.DHCP.Backend = configuration.BackendDnsmasq + return cfg +} diff --git a/internal/watchdog/watchdog.go b/internal/watchdog/watchdog.go index 3ab1d52..6da33b8 100644 --- a/internal/watchdog/watchdog.go +++ b/internal/watchdog/watchdog.go @@ -65,7 +65,7 @@ func (w *Watchdog) tick() { if err := CheckVPCs(w.db, w.notifier); err != nil { w.logger.Error("watchdog: vpc check failed", "err", err) } - if err := CheckSubnets(w.db, u, w.notifier); err != nil { + if err := CheckSubnets(w.db, w.cfg, u, w.notifier); err != nil { w.logger.Error("watchdog: subnet check failed", "err", err) } if err := CheckVMs(w.db, w.cfg, u, w.notifier); err != nil { diff --git a/pkg/db/statefile/statefile.go b/pkg/db/statefile/statefile.go index fc62524..0d8ba51 100644 --- a/pkg/db/statefile/statefile.go +++ b/pkg/db/statefile/statefile.go @@ -85,8 +85,12 @@ func (f *File[T]) Save(value T) error { } func (f *File[T]) Remove() error { - if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) { - return fmt.Errorf("remove %s: %w", f.path, err) + return Remove(f.path) +} + +func Remove(path string) error { + if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("remove %s: %w", path, err) } return nil } diff --git a/release_notes/0.2.0.md b/release_notes/0.2.0.md new file mode 100644 index 0000000..5f42489 --- /dev/null +++ b/release_notes/0.2.0.md @@ -0,0 +1,87 @@ +# Bael + +Serveur DHCP intégré, en coexistence avec dnsmasq, et documentation du projet. + +## Fonctionnalités + +**Serveur DHCP intégré** + +- Nouveau binaire `dhcp`, une instance par subnet lancée dans le netns du VPC par l'unit + `dhcp@_`. Il reçoit son bridge et ses deux chemins de fichiers en paramètres : + il ne compose aucun chemin et ignore le netns dans lequel il tourne +- Piloté par l'agent sur une **socket Unix** `/run/two/dhcp/_.sock`, en JSON par + ligne. Ordres idempotents en remplacement intégral : configuration du subnet à sa création, + une réservation par interface à chaque création ou suppression de VM +- **Réservations statiques uniquement**, pas de baux : une MAC inconnue n'obtient rien, et le + serveur reste silencieux plutôt que de répondre par un refus. Aucun `DHCPNAK` n'est émis +- État auto-persisté dans `/run/two/dhcp/_.state`, en écriture atomique et lisible + par le seul `root`. Le fichier appartient au processus, qui le relit à son démarrage ; l'agent + ne l'écrit jamais et se borne à le supprimer — à la création du subnet pour écarter un résidu, + à sa suppression après avoir arrêté l'unit +- La route par défaut est décidée **par interface** et non plus par subnet, ce qui permet de ne + l'annoncer que sur une interface d'une VM multi-réseaux +- L'encodage RFC 3442 de l'option 121 est délégué à `github.com/insomniacslk/dhcp` + +**Coexistence avec dnsmasq** + +- Nouvelle clé `dhcp.backend`, `dnsmasq` ou `two`, qui choisit le serveur des subnets **créés par + cet agent**. Le défaut est `dnsmasq` : un fichier de configuration de la 0.1.0, non modifié, se + comporte exactement comme avant +- La bascule est une **opération manuelle** sur un hyperviseur vide — l'option ne migre rien, un + subnet déjà créé reste servi par le serveur qui l'a été. La procédure est documentée +- Toute valeur autre que `dnsmasq` ou `two` fait échouer le démarrage de l'agent + +**Exploitation** + +- Le watchdog interroge le serveur DHCP intégré et compare les réservations servies à celles que + la base implique. Il nomme ce qui diverge — ordre perdu à la création, ordre perdu à la + suppression, adresse ou route par défaut divergente — et reste en lecture seule +- La socket de contrôle répond à `get-state` et à `probe`, ce dernier montrant sans effet de bord + ce qui serait envoyé à une MAC donnée : adresse, masque, routeur, DNS et routes +- Documentation Sphinx du projet : concepts, architecture, déploiement et exploitation + +## Correctifs + +- **Un fichier de configuration présent mais invalide fait désormais échouer le démarrage.** + Jusqu'en 0.1.0 l'erreur de lecture était ignorée : l'agent tournait alors entièrement sur ses + valeurs par défaut sans le dire, ce qui rendait indétectable une simple tabulation d'indentation +- `probe` annonce explicitement `"served": false` pour une MAC non réservée, au lieu d'omettre le + champ et de le rendre indistinguable d'une réponse tronquée + +## Changements internes + +- Le module passe à **Go 1.25**, exigé par la bibliothèque DHCP retenue. La version de Go du + workflow de build, restée à 1.21 alors que le module en demandait davantage, est alignée +- Nouveau paquet `pkg/db/statefile` : persistance générique d'un état de composant dans un + fichier, en écriture atomique. Badger a été écarté pour cet usage — une instance par subnet + coûterait une memtable de 64 Mio et quatre goroutines de compaction pour environ un kilo-octet + d'état, dans un `tmpfs`, et laisserait un verrou résiduel après un arrêt brutal +- `internal/subnet` et `internal/vm` ne parlent plus à dnsmasq en direct mais à une interface + `Backend` à deux implémentations + +## Périmètre et limites connues + +Celles de la 0.1.0 restent valables, sauf mention contraire ci-dessus. S'y ajoutent : + +- **dnsmasq n'est pas retiré** et reste un paquet requis : le backend intégré ne sert que les + subnets créés après la bascule, et le retour arrière suppose dnsmasq installé +- Pas de DNS, pas de pool dynamique, pas de PXE, pas de DHCPv6 dans le serveur intégré +- La comparaison faite par le watchdog porte sur les **réservations** et non sur la configuration + du subnet : celle-ci dépend de la route par défaut de l'host, dont la lecture au moment du + contrôle produirait de faux écarts. Un serveur dépourvu de configuration est en revanche signalé +- L'option 249 (routes classless de Microsoft) n'est pas émise, comme dnsmasq ne l'émet pas +- Le serveur intégré écoute UDP/67 sans authentification, comme tout serveur DHCP : l'isolation + entre locataires d'un même subnet repose sur les règles ebtables anti-usurpation, inchangées +- `internal/dhcpbackend` n'est testé sous Linux que pour ses appels systemd ; le reste, y compris + le dialogue avec le serveur intégré, est couvert sur toute plateforme + +## Mise à jour depuis la 0.1.0 + +```bash +curl -O https://git.g3e.fr/syonad/two/raw/branch/main/scripts/deploy.sh +bash ./deploy.sh -t 0.2.0 +``` + +Aucune action n'est requise : sans `dhcp.backend` dans `/etc/two/agent.yml`, le comportement est +celui de la 0.1.0. Pour passer au serveur intégré, suivre la procédure de bascule dans la +documentation d'exploitation — elle suppose un hyperviseur vidé. diff --git a/release_notes/codenames.md b/release_notes/codenames.md index 17161c2..fe77557 100644 --- a/release_notes/codenames.md +++ b/release_notes/codenames.md @@ -21,7 +21,7 @@ prochain nom disponible sans tenir de compteur ailleurs : c'est la première lig | # | Nom | Nature | Version | Date | |---|---|---|---|---| | 1 | Michael | ange | [0.1.0](0.1.0.md) | 2026-08-26 | -| 2 | Bael | démon | 0.2.0 | | +| 2 | Bael | démon | [0.2.0](0.2.0.md) | | | 3 | Gabriel | ange | | | | 4 | Agares | démon | | | | 5 | Raphael | ange | | |