Compare commits
No commits in common. "df6c90d21629153f0cd16ea62f07d0d3ba5c2b8c" and "a220cce77eb81ce57f34e4ed83e723575953c272" have entirely different histories.
df6c90d216
...
a220cce77e
25 changed files with 61 additions and 931 deletions
|
|
@ -1,70 +0,0 @@
|
||||||
name: Documentation
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- feature-46
|
|
||||||
paths:
|
|
||||||
- 'docs/**'
|
|
||||||
- 'release_notes/**'
|
|
||||||
- '.forgejo/workflows/docs.yml'
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Deux publications simultanées se pousseraient l'une sur l'autre : la branche
|
|
||||||
# pages est écrasée à chaque fois, le dernier arrivé gagnerait au hasard.
|
|
||||||
concurrency:
|
|
||||||
group: pages
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
publish:
|
|
||||||
runs-on: docker
|
|
||||||
env:
|
|
||||||
TOKEN: ${{ secrets.RELEASE }}
|
|
||||||
SITE_DIR: /tmp/site
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- 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
|
|
||||||
|
|
||||||
# -W --keep-going : une référence croisée cassée doit arrêter la
|
|
||||||
# publication, pas produire un site avec des liens morts. --keep-going
|
|
||||||
# affiche tous les avertissements avant d'échouer, plutôt que le premier.
|
|
||||||
# -d place le cache de Sphinx hors du site : sans lui, .doctrees — près
|
|
||||||
# d'un mégaoctet d'état interne — se retrouve publié à la racine.
|
|
||||||
- name: Construire la documentation
|
|
||||||
run: |
|
|
||||||
/tmp/venv/bin/sphinx-build -b html -W --keep-going \
|
|
||||||
-d /tmp/doctrees docs "${SITE_DIR}"
|
|
||||||
|
|
||||||
- name: Alléger le site
|
|
||||||
run: |
|
|
||||||
# Source maps du thème : ~3 Mo de fichiers que seuls les outils de
|
|
||||||
# développement du navigateur vont chercher, jamais une page servie.
|
|
||||||
find "${SITE_DIR}" -name '*.map' -delete
|
|
||||||
rm -f "${SITE_DIR}/.buildinfo"
|
|
||||||
# Neutralise Jekyll si le serveur de pages l'applique : Sphinx écrit
|
|
||||||
# _static/ et _sources/, que Jekyll ignore silencieusement.
|
|
||||||
touch "${SITE_DIR}/.nojekyll"
|
|
||||||
du -sh "${SITE_DIR}"
|
|
||||||
|
|
||||||
# La branche pages ne contient que le site, à la racine, en un seul
|
|
||||||
# commit sans histoire : ce sont des artefacts reconstructibles, et
|
|
||||||
# l'historique de main suffit à savoir d'où ils viennent.
|
|
||||||
- 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 add -A
|
|
||||||
git commit --quiet -m "docs: build de ${GITHUB_SHA}"
|
|
||||||
git push --force --quiet \
|
|
||||||
"https://${TOKEN}@git.g3e.fr/${{ github.repository }}.git" pages
|
|
||||||
echo "publié : $(git rev-parse --short HEAD) — $(git ls-files | wc -l) fichiers"
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -30,6 +30,3 @@ go.work.sum
|
||||||
|
|
||||||
# ignore local info
|
# ignore local info
|
||||||
data/
|
data/
|
||||||
|
|
||||||
# Sphinx build output
|
|
||||||
docs/_build/
|
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ Options utiles :
|
||||||
| `-d` | dry-run : affiche les commandes sans les exécuter |
|
| `-d` | dry-run : affiche les commandes sans les exécuter |
|
||||||
| `-V` | désactiver la vérification des sommes de contrôle |
|
| `-V` | désactiver la vérification des sommes de contrôle |
|
||||||
|
|
||||||
Un déploiement relève les instances `dnsmasq@`, `dhcp@` et `metadata@` actives **avant** l'arrêt des
|
Un déploiement relève les instances `dnsmasq@` et `metadata@` actives **avant** l'arrêt des
|
||||||
services, et les redémarre ensuite — c'est la seule façon de savoir lesquelles relancer.
|
services, et les redémarre ensuite — c'est la seule façon de savoir lesquelles relancer.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ Paquets
|
||||||
* - ``internal/vm``
|
* - ``internal/vm``
|
||||||
- cycle de vie d'une VM : tap, iptables, metadata, qemu
|
- cycle de vie d'une VM : tap, iptables, metadata, qemu
|
||||||
* - ``internal/dhcp``
|
* - ``internal/dhcp``
|
||||||
- plan d'adressage ip → mac, et configurations dnsmasq du backend historique
|
- génération des configurations dnsmasq et entrées ip → mac
|
||||||
* - ``internal/metadata``
|
* - ``internal/metadata``
|
||||||
- serveur de metadata cloud-init et ses templates
|
- serveur de metadata cloud-init et ses templates
|
||||||
* - ``internal/watchdog``
|
* - ``internal/watchdog``
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,7 @@ Subnet
|
||||||
------
|
------
|
||||||
|
|
||||||
Un subnet appartient à un VPC et pose, dans son netns, un bridge qui porte ``interface_ip`` — la
|
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 ou le serveur intégré selon
|
gateway vue par les VM. Il fournit aussi le DHCP (dnsmasq) et les routes annoncées aux guests.
|
||||||
``dhcp.backend`` — et les routes annoncées aux guests.
|
|
||||||
|
|
||||||
``iface_type`` est une clé **logique** (``vms``, ``internet``, ``admin``…), traduite en nom de
|
``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
|
bridge physique par la configuration de l'agent. Une clé absente ou inconnue retombe sur
|
||||||
|
|
|
||||||
|
|
@ -73,10 +73,6 @@ Ce que fait ``-i``
|
||||||
**masqué** : il prendrait le port 53 en concurrence des instances ``dnsmasq@`` que l'agent lance
|
**masqué** : il prendrait le port 53 en concurrence des instances ``dnsmasq@`` que l'agent lance
|
||||||
dans les netns.
|
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
|
**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
|
``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
|
le serveur de metadata : sans elle, iptables ne voit pas le trafic bridgé des VM et cloud-init
|
||||||
|
|
@ -122,21 +118,16 @@ Binaires installés
|
||||||
* - ``db``
|
* - ``db``
|
||||||
- inspection de la base clé-valeur en ligne de commande
|
- inspection de la base clé-valeur en ligne de commande
|
||||||
- ``-conf``
|
- ``-conf``
|
||||||
* - ``dhcp``
|
|
||||||
- serveur DHCP intégré, une instance par subnet dans le netns du VPC ; démarré uniquement
|
|
||||||
avec ``dhcp.backend: two``
|
|
||||||
- ``-conf``
|
|
||||||
|
|
||||||
Les quatre partagent le même fichier, ``/etc/two/agent.yml`` — voir
|
Les trois 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
|
:doc:`/exploitation/configuration`.
|
||||||
fichiers en paramètres, posés par son script d'enrobage.
|
|
||||||
|
|
||||||
Mise à jour
|
Mise à jour
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
``deploy.sh`` relève les instances ``dnsmasq@``, ``dhcp@`` et ``metadata@`` actives **avant**
|
``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les
|
||||||
d'arrêter les services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles
|
services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter
|
||||||
relancer. Arrêter les services à la main avant de lancer le script fait perdre cette liste.
|
les services à la main avant de lancer le script fait perdre cette liste.
|
||||||
|
|
||||||
Vérifier l'installation
|
Vérifier l'installation
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ Les deux temps d'une requête
|
||||||
A->>A: Prepare — valide, écrit "creating"
|
A->>A: Prepare — valide, écrit "creating"
|
||||||
A-->>C: 202 + ressource en creating
|
A-->>C: 202 + ressource en creating
|
||||||
A->>W: Dispatch (file d'attente)
|
A->>W: Dispatch (file d'attente)
|
||||||
W->>W: Execute — netns, netif, dhcp
|
W->>W: Execute — netns, netif, dnsmasq
|
||||||
W->>W: état → running (ou error)
|
W->>W: état → running (ou error)
|
||||||
C->>A: GET /subnets/<name>
|
C->>A: GET /subnets/<name>
|
||||||
A-->>C: 200 + state
|
A-->>C: 200 + state
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,12 @@
|
||||||
Configuration
|
Configuration
|
||||||
=============
|
=============
|
||||||
|
|
||||||
Un seul fichier, ``/etc/two/agent.yml``, partagé par les quatre binaires : ``agent -config``,
|
Un seul fichier, ``/etc/two/agent.yml``, partagé par les trois binaires : ``agent -config``,
|
||||||
``metadata -conf``, ``db -conf`` et ``dhcp -conf``. Le fichier de référence commenté est
|
``metadata -conf`` et ``db -conf``. Le fichier de référence commenté est
|
||||||
``conf/agent/config.exemple.yml`` dans le dépôt.
|
``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.
|
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::
|
.. danger::
|
||||||
|
|
||||||
**L'API de l'agent n'a aucune authentification.** L'exemple livré écoute sur
|
**L'API de l'agent n'a aucune authentification.** L'exemple livré écoute sur
|
||||||
|
|
@ -124,71 +117,6 @@ 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,
|
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.
|
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@<netns>_<bridge>``
|
|
||||||
* - ``two``
|
|
||||||
- le binaire ``dhcp``, piloté par socket Unix
|
|
||||||
- ``dhcp@<netns>_<bridge>``
|
|
||||||
|
|
||||||
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
|
Watchdog
|
||||||
--------
|
--------
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,7 @@ partiellement créés subsistent.
|
||||||
La VM démarre mais n'a pas d'adresse
|
La VM démarre mais n'a pas d'adresse
|
||||||
------------------------------------
|
------------------------------------
|
||||||
|
|
||||||
Le DHCP est servi par une instance dédiée au subnet. Quelle unit selon ``dhcp.backend`` :
|
Le DHCP est servi par l'instance ``dnsmasq@`` du subnet.
|
||||||
|
|
||||||
**Backend ``dnsmasq``**
|
|
||||||
|
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
|
|
@ -36,31 +34,7 @@ Le DHCP est servi par une instance dédiée au subnet. Quelle unit selon ``dhcp.
|
||||||
cat /run/dnsmasq-<netns>_<bridge>.leases
|
cat /run/dnsmasq-<netns>_<bridge>.leases
|
||||||
cat /etc/dnsmasq.d/<netns>_<bridge>.conf
|
cat /etc/dnsmasq.d/<netns>_<bridge>.conf
|
||||||
|
|
||||||
**Backend ``two``**
|
Si dnsmasq ne voit passer aucune requête, le problème est en amont : tap absent, bridge non
|
||||||
|
|
||||||
.. code-block:: bash
|
|
||||||
|
|
||||||
systemctl status 'dhcp@<netns>_<bridge>'
|
|
||||||
journalctl -u 'dhcp@<netns>_<bridge>' -n 50
|
|
||||||
|
|
||||||
# Ce que le serveur a réellement en mémoire
|
|
||||||
echo '{"verb":"get-state"}' \
|
|
||||||
| socat - UNIX-CONNECT:/run/two/dhcp/<netns>_<bridge>.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/<netns>_<bridge>.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.
|
raccordé, VM dans le mauvais netns.
|
||||||
|
|
||||||
La VM a une adresse mais cloud-init n'applique rien
|
La VM a une adresse mais cloud-init n'applique rien
|
||||||
|
|
|
||||||
|
|
@ -57,8 +57,7 @@ Journaux
|
||||||
|
|
||||||
journalctl -u agent -f
|
journalctl -u agent -f
|
||||||
journalctl -u 'metadata@i-web' -n 50
|
journalctl -u 'metadata@i-web' -n 50
|
||||||
tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log # backend dnsmasq
|
tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log
|
||||||
journalctl -fu 'dhcp@vp-admin_br-sn000001' # backend two
|
|
||||||
|
|
||||||
Inspection de la base
|
Inspection de la base
|
||||||
---------------------
|
---------------------
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
Services systemd
|
Services systemd
|
||||||
================
|
================
|
||||||
|
|
||||||
Quatre units, installées sous ``/opt/two/bin`` par ``deploy.sh``. Les deux units DHCP
|
Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``.
|
||||||
s'excluent : celle qui tourne dépend de ``dhcp.backend`` (voir :doc:`configuration`).
|
|
||||||
|
|
||||||
.. list-table::
|
.. list-table::
|
||||||
:header-rows: 1
|
:header-rows: 1
|
||||||
|
|
@ -16,10 +15,7 @@ s'excluent : celle qui tourne dépend de ``dhcp.backend`` (voir :doc:`configurat
|
||||||
- processus principal : API, dispatcher, exécution, watchdog
|
- processus principal : API, dispatcher, exécution, watchdog
|
||||||
* - ``dnsmasq@.service``
|
* - ``dnsmasq@.service``
|
||||||
- ``<netns>_<bridge>``
|
- ``<netns>_<bridge>``
|
||||||
- dnsmasq lancé dans le netns du VPC, un par subnet — backend ``dnsmasq``
|
- dnsmasq lancé dans le netns du VPC, un par subnet
|
||||||
* - ``dhcp@.service``
|
|
||||||
- ``<netns>_<bridge>``
|
|
||||||
- serveur DHCP intégré, un par subnet — backend ``two``
|
|
||||||
* - ``metadata@.service``
|
* - ``metadata@.service``
|
||||||
- ``<nom de la VM>``
|
- ``<nom de la VM>``
|
||||||
- serveur de metadata cloud-init, un par VM
|
- serveur de metadata cloud-init, un par VM
|
||||||
|
|
@ -30,15 +26,14 @@ n'y a pas à les démarrer à la main en fonctionnement normal.
|
||||||
.. code-block:: bash
|
.. code-block:: bash
|
||||||
|
|
||||||
systemctl status agent
|
systemctl status agent
|
||||||
systemctl status 'dnsmasq@vp-admin_br-sn000001' # backend dnsmasq
|
systemctl status 'dnsmasq@vp-admin_br-sn000001'
|
||||||
systemctl status 'dhcp@vp-admin_br-sn000001' # backend two
|
|
||||||
systemctl status 'metadata@i-web'
|
systemctl status 'metadata@i-web'
|
||||||
|
|
||||||
dnsmasq
|
dnsmasq
|
||||||
-------
|
-------
|
||||||
|
|
||||||
Backend historique. Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq
|
Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq avec un fichier
|
||||||
avec un fichier de configuration par subnet, généré par l'agent :
|
de configuration par subnet, généré par l'agent :
|
||||||
|
|
||||||
.. list-table::
|
.. list-table::
|
||||||
:widths: 40 60
|
:widths: 40 60
|
||||||
|
|
@ -55,42 +50,6 @@ avec un fichier 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
|
Le fichier de baux et le journal sont les deux premiers endroits à regarder quand une VM n'obtient
|
||||||
pas d'adresse.
|
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/<netns>_<bridge>.sock``
|
|
||||||
* - État
|
|
||||||
- ``/run/two/dhcp/<netns>_<bridge>.state``
|
|
||||||
* - Journal
|
|
||||||
- ``journalctl -u 'dhcp@<netns>_<bridge>'``
|
|
||||||
|
|
||||||
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
|
QEMU n'est pas une unit
|
||||||
-----------------------
|
-----------------------
|
||||||
|
|
||||||
|
|
@ -127,6 +86,6 @@ journal au moment d'un ``stop`` n'est donc pas une anomalie.
|
||||||
Mise à jour
|
Mise à jour
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
``deploy.sh`` relève les instances ``dnsmasq@``, ``dhcp@`` et ``metadata@`` actives **avant** d'arrêter les
|
``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
|
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.
|
les services à la main avant de lancer le script fait perdre cette liste.
|
||||||
|
|
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
```{include} ../../release_notes/0.2.0.md
|
|
||||||
```
|
|
||||||
|
|
@ -6,7 +6,6 @@ Chaque version porte un nom de code dérivé du rang de sa publication : anges e
|
||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
|
|
||||||
0.2.0
|
|
||||||
0.1.0
|
0.1.0
|
||||||
|
|
||||||
.. include:: ../../release_notes/codenames.md
|
.. include:: ../../release_notes/codenames.md
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
package dhcpapi
|
package dhcpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -173,13 +171,3 @@ func TestCanonical_SortsHostsByMAC(t *testing.T) {
|
||||||
t.Errorf("hosts = %v, want sorted by mac", got.Hosts)
|
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -54,5 +54,5 @@ type Response struct {
|
||||||
State *State `json:"state,omitempty"`
|
State *State `json:"state,omitempty"`
|
||||||
Digest string `json:"digest,omitempty"`
|
Digest string `json:"digest,omitempty"`
|
||||||
Lease *Lease `json:"lease,omitempty"`
|
Lease *Lease `json:"lease,omitempty"`
|
||||||
Served bool `json:"served"`
|
Served bool `json:"served,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package dhcpbackend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
|
||||||
|
|
||||||
"git.g3e.fr/syonad/two/internal/dhcp"
|
"git.g3e.fr/syonad/two/internal/dhcp"
|
||||||
"git.g3e.fr/syonad/two/pkg/systemd"
|
"git.g3e.fr/syonad/two/pkg/systemd"
|
||||||
|
|
@ -126,7 +125,3 @@ func (b Dnsmasq) DelVM(s Subnet, vmName string, _ []Reservation) error {
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b Dnsmasq) ConfigPath(s Subnet) string {
|
|
||||||
return filepath.Join(b.confDir(), s.Instance()+".conf")
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -137,11 +137,3 @@ func (b Two) DelVM(s Subnet, vmName string, res []Reservation) error {
|
||||||
}
|
}
|
||||||
return nil
|
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()
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,189 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
@ -1,348 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,11 +2,12 @@ package watchdog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
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/netns"
|
"git.g3e.fr/syonad/two/internal/netns"
|
||||||
"git.g3e.fr/syonad/two/internal/state"
|
"git.g3e.fr/syonad/two/internal/state"
|
||||||
"git.g3e.fr/syonad/two/internal/watchdog/notify"
|
"git.g3e.fr/syonad/two/internal/watchdog/notify"
|
||||||
|
|
@ -29,14 +30,16 @@ func subnetIfaceNames(subnetName string) (hostVeth, nsVeth, bridge string, err e
|
||||||
return "v-" + id + "-e", "v-" + id + "-i", "br-" + id, nil
|
return "v-" + id + "-e", "v-" + id + "-i", "br-" + id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func CheckSubnets(db *badger.DB, cfg *configuration.Config, u unitChecker, n notify.Notifier) error {
|
func dnsmasqName(vpc, bridge string) string {
|
||||||
|
return vpc + "_" + bridge
|
||||||
|
}
|
||||||
|
|
||||||
|
func CheckSubnets(db *badger.DB, u unitChecker, n notify.Notifier) error {
|
||||||
pairs, err := kv.ListByPrefix(db, prefixSubnet)
|
pairs, err := kv.ListByPrefix(db, prefixSubnet)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("watchdog: listing subnets: %w", err)
|
return fmt.Errorf("watchdog: listing subnets: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
backend, backendErr := dhcpbackend.New(cfg)
|
|
||||||
|
|
||||||
for _, name := range resourceNames(pairs, prefixSubnet) {
|
for _, name := range resourceNames(pairs, prefixSubnet) {
|
||||||
st, err := state.Get(db, prefixSubnet+name)
|
st, err := state.Get(db, prefixSubnet+name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -46,15 +49,12 @@ func CheckSubnets(db *badger.DB, cfg *configuration.Config, u unitChecker, n not
|
||||||
if st != state.Running {
|
if st != state.Running {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
checkSubnet(db, name, backend, u, n)
|
checkSubnet(db, name, u, n)
|
||||||
if backendErr != nil {
|
|
||||||
n.Notify(kindSubnet, name, fmt.Sprintf("dhcp checks skipped, backend unusable: %v", backendErr))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkSubnet(db *badger.DB, name string, backend dhcpbackend.Backend, u unitChecker, n notify.Notifier) {
|
func checkSubnet(db *badger.DB, name string, u unitChecker, n notify.Notifier) {
|
||||||
hostVeth, nsVeth, bridge, err := subnetIfaceNames(name)
|
hostVeth, nsVeth, bridge, err := subnetIfaceNames(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
n.Notify(kindSubnet, name, err.Error())
|
n.Notify(kindSubnet, name, err.Error())
|
||||||
|
|
@ -90,7 +90,13 @@ func checkSubnet(db *badger.DB, name string, backend dhcpbackend.Backend, u unit
|
||||||
|
|
||||||
checkSubnetNetns(name, vpc, nsVeth, bridge, n)
|
checkSubnetNetns(name, vpc, nsVeth, bridge, n)
|
||||||
|
|
||||||
checkDHCP(db, name, dhcpbackend.Subnet{Name: name, VPC: vpc, Bridge: bridge}, backend, u, 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
func checkVxlanIface(db *badger.DB, name string, n notify.Notifier) {
|
func checkVxlanIface(db *badger.DB, name string, n notify.Notifier) {
|
||||||
|
|
|
||||||
|
|
@ -39,11 +39,17 @@ 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) {
|
func TestCheckSubnets_BaseVide(t *testing.T) {
|
||||||
db := newTestDB(t)
|
db := newTestDB(t)
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
if len(r.calls) != 0 {
|
if len(r.calls) != 0 {
|
||||||
|
|
@ -58,7 +64,7 @@ func TestCheckSubnets_IgnoreLesEtatsNonRunning(t *testing.T) {
|
||||||
}
|
}
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
if len(r.calls) != 0 {
|
if len(r.calls) != 0 {
|
||||||
|
|
@ -71,7 +77,7 @@ func TestCheckSubnets_VPCManquantEnBase(t *testing.T) {
|
||||||
seedResource(t, db, prefixSubnet, "br-000042", state.Running)
|
seedResource(t, db, prefixSubnet, "br-000042", state.Running)
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,7 +96,7 @@ func TestCheckSubnets_ModeManquantEnBase(t *testing.T) {
|
||||||
seedKV(t, db, prefixSubnet+"br-000042/vpc", "vp-admin")
|
seedKV(t, db, prefixSubnet+"br-000042/vpc", "vp-admin")
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,7 +110,7 @@ func TestCheckSubnets_ModeInconnu(t *testing.T) {
|
||||||
seedSubnet(t, db, "br-000042", "vp-admin", "macvlan")
|
seedSubnet(t, db, "br-000042", "vp-admin", "macvlan")
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,7 +124,7 @@ func TestCheckSubnets_ModeBridgeNeVerifiePasDeVxlan(t *testing.T) {
|
||||||
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
|
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -137,7 +143,7 @@ func TestCheckSubnets_ModeVxlanSansVxlanID(t *testing.T) {
|
||||||
seedSubnet(t, db, "br-000042", "vp-admin", modeVxlan)
|
seedSubnet(t, db, "br-000042", "vp-admin", modeVxlan)
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -152,7 +158,7 @@ func TestCheckSubnets_ModeVxlanVxlanIDInvalide(t *testing.T) {
|
||||||
seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "pas-un-nombre")
|
seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "pas-un-nombre")
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -167,7 +173,7 @@ func TestCheckSubnets_ModeVxlanVerifieLInterfaceVxlan(t *testing.T) {
|
||||||
seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "42")
|
seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "42")
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,7 +187,7 @@ func TestCheckSubnets_ConfigDnsmasqAbsente(t *testing.T) {
|
||||||
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
|
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -199,7 +205,7 @@ func TestCheckSubnets_UnitDnsmasqInterrogee(t *testing.T) {
|
||||||
u := newFakeUnits().active("dnsmasq@vp-admin_br-000042.service")
|
u := newFakeUnits().active("dnsmasq@vp-admin_br-000042.service")
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil {
|
if err := CheckSubnets(db, u, r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -217,7 +223,7 @@ func TestCheckSubnets_UnitDnsmasqInactive(t *testing.T) {
|
||||||
u := newFakeUnits().inactive("dnsmasq@vp-admin_br-000042.service", "failed")
|
u := newFakeUnits().inactive("dnsmasq@vp-admin_br-000042.service", "failed")
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil {
|
if err := CheckSubnets(db, u, r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -232,7 +238,7 @@ func TestCheckSubnets_UnitIllisible(t *testing.T) {
|
||||||
u := newFakeUnits().failing("dnsmasq@vp-admin_br-000042.service", errors.New("dbus indisponible"))
|
u := newFakeUnits().failing("dnsmasq@vp-admin_br-000042.service", errors.New("dbus indisponible"))
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil {
|
if err := CheckSubnets(db, u, r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -246,7 +252,7 @@ func TestCheckSubnets_SansUnitCheckerPasDeVerificationDUnit(t *testing.T) {
|
||||||
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
|
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), nil, r); err != nil {
|
if err := CheckSubnets(db, nil, r); err != nil {
|
||||||
t.Fatalf("erreur inattendue: %v", err)
|
t.Fatalf("erreur inattendue: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -261,7 +267,7 @@ func TestCheckSubnets_EtatCorrompuNInterrompPasLaBoucle(t *testing.T) {
|
||||||
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
|
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
|
||||||
r := &recorder{}
|
r := &recorder{}
|
||||||
|
|
||||||
if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
|
if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
|
||||||
t.Fatalf("un état corrompu ne doit pas faire échouer CheckSubnets: %v", err)
|
t.Fatalf("un état corrompu ne doit pas faire échouer CheckSubnets: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configuration "git.g3e.fr/syonad/two/internal/config/agent"
|
|
||||||
"git.g3e.fr/syonad/two/internal/state"
|
"git.g3e.fr/syonad/two/internal/state"
|
||||||
"git.g3e.fr/syonad/two/internal/watchdog/notify"
|
"git.g3e.fr/syonad/two/internal/watchdog/notify"
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
"git.g3e.fr/syonad/two/pkg/db/kv"
|
||||||
|
|
@ -109,9 +108,3 @@ func seedResource(t *testing.T, db *badger.DB, prefix, name string, s state.Stat
|
||||||
t.Fatalf("seedResource %s%s: %v", prefix, name, err)
|
t.Fatalf("seedResource %s%s: %v", prefix, name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func dnsmasqConfig() *configuration.Config {
|
|
||||||
cfg := &configuration.Config{}
|
|
||||||
cfg.DHCP.Backend = configuration.BackendDnsmasq
|
|
||||||
return cfg
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ func (w *Watchdog) tick() {
|
||||||
if err := CheckVPCs(w.db, w.notifier); err != nil {
|
if err := CheckVPCs(w.db, w.notifier); err != nil {
|
||||||
w.logger.Error("watchdog: vpc check failed", "err", err)
|
w.logger.Error("watchdog: vpc check failed", "err", err)
|
||||||
}
|
}
|
||||||
if err := CheckSubnets(w.db, w.cfg, u, w.notifier); err != nil {
|
if err := CheckSubnets(w.db, u, w.notifier); err != nil {
|
||||||
w.logger.Error("watchdog: subnet check failed", "err", err)
|
w.logger.Error("watchdog: subnet check failed", "err", err)
|
||||||
}
|
}
|
||||||
if err := CheckVMs(w.db, w.cfg, u, w.notifier); err != nil {
|
if err := CheckVMs(w.db, w.cfg, u, w.notifier); err != nil {
|
||||||
|
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
# 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@<netns>_<bridge>`. 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/<netns>_<bridge>.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/<netns>_<bridge>.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é.
|
|
||||||
|
|
@ -21,7 +21,7 @@ prochain nom disponible sans tenir de compteur ailleurs : c'est la première lig
|
||||||
| # | Nom | Nature | Version | Date |
|
| # | Nom | Nature | Version | Date |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | Michael | ange | [0.1.0](0.1.0.md) | 2026-08-26 |
|
| 1 | Michael | ange | [0.1.0](0.1.0.md) | 2026-08-26 |
|
||||||
| 2 | Bael | démon | [0.2.0](0.2.0.md) | |
|
| 2 | Bael | démon | 0.2.0 | |
|
||||||
| 3 | Gabriel | ange | | |
|
| 3 | Gabriel | ange | | |
|
||||||
| 4 | Agares | démon | | |
|
| 4 | Agares | démon | | |
|
||||||
| 5 | Raphael | ange | | |
|
| 5 | Raphael | ange | | |
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue