Compare commits

..

No commits in common. "df6c90d21629153f0cd16ea62f07d0d3ba5c2b8c" and "a220cce77eb81ce57f34e4ed83e723575953c272" have entirely different histories.

25 changed files with 61 additions and 931 deletions

View file

@ -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
View file

@ -30,6 +30,3 @@ go.work.sum
# ignore local info # ignore local info
data/ data/
# Sphinx build output
docs/_build/

View file

@ -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

View file

@ -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``

View file

@ -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

View file

@ -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
----------------------- -----------------------

View file

@ -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

View file

@ -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
-------- --------

View file

@ -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

View file

@ -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
--------------------- ---------------------

View file

@ -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.

View file

@ -1,2 +0,0 @@
```{include} ../../release_notes/0.2.0.md
```

View file

@ -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

View file

@ -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)
}
}

View file

@ -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"`
} }

View file

@ -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")
}

View file

@ -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()
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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) {

View file

@ -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)
} }

View file

@ -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
}

View file

@ -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 {

View file

@ -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é.

View file

@ -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 | | |