diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml deleted file mode 100644 index 8e7d667..0000000 --- a/.forgejo/workflows/docs.yml +++ /dev/null @@ -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" diff --git a/.gitignore b/.gitignore index fd7d60e..68ae417 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,3 @@ go.work.sum # ignore local info data/ - -# Sphinx build output -docs/_build/ diff --git a/README.md b/README.md index 20627f4..9439539 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ Options utiles : | `-d` | dry-run : affiche les commandes sans les exécuter | | `-V` | désactiver la vérification des sommes de contrôle | -Un déploiement relève les instances `dnsmasq@`, `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. ## Configuration diff --git a/docs/architecture/vue-densemble.rst b/docs/architecture/vue-densemble.rst index a0f5c3f..b8fb8e8 100644 --- a/docs/architecture/vue-densemble.rst +++ b/docs/architecture/vue-densemble.rst @@ -50,7 +50,7 @@ Paquets * - ``internal/vm`` - cycle de vie d'une VM : tap, iptables, metadata, qemu * - ``internal/dhcp`` - - plan d'adressage ip → mac, et configurations dnsmasq du backend historique + - génération des configurations dnsmasq et entrées ip → mac * - ``internal/metadata`` - serveur de metadata cloud-init et ses templates * - ``internal/watchdog`` diff --git a/docs/concepts/vpc-subnet-vm.rst b/docs/concepts/vpc-subnet-vm.rst index 6337ea9..ac1e673 100644 --- a/docs/concepts/vpc-subnet-vm.rst +++ b/docs/concepts/vpc-subnet-vm.rst @@ -24,8 +24,7 @@ Subnet ------ Un subnet appartient à un VPC et pose, dans son netns, un bridge qui porte ``interface_ip`` — la -gateway vue par les VM. Il fournit aussi le DHCP — dnsmasq ou le serveur intégré selon -``dhcp.backend`` — et les routes annoncées aux guests. +gateway vue par les VM. Il fournit aussi le DHCP (dnsmasq) et les routes annoncées aux guests. ``iface_type`` est une clé **logique** (``vms``, ``internet``, ``admin``…), traduite en nom de bridge physique par la configuration de l'agent. Une clé absente ou inconnue retombe sur diff --git a/docs/demarrage/installation.rst b/docs/demarrage/installation.rst index ed4217a..56c4cda 100644 --- a/docs/demarrage/installation.rst +++ b/docs/demarrage/installation.rst @@ -73,10 +73,6 @@ Ce que fait ``-i`` **masqué** : il prendrait le port 53 en concurrence des instances ``dnsmasq@`` que l'agent lance dans les netns. -``dnsmasq`` reste installé même avec ``dhcp.backend: two`` : le backend intégré ne le remplace que -pour les subnets créés après la bascule, et le paquet est nécessaire tant qu'un hyperviseur peut -revenir en arrière. Voir :doc:`/exploitation/configuration`. - **Noyau** — chargement de ``br_netfilter``, puis ``net.ipv4.ip_forward = 1`` et ``net.bridge.bridge-nf-call-iptables = 1``. Cette dernière clé est **requise** par la DNAT vers le serveur de metadata : sans elle, iptables ne voit pas le trafic bridgé des VM et cloud-init @@ -122,21 +118,16 @@ Binaires installés * - ``db`` - inspection de la base clé-valeur en ligne de commande - ``-conf`` - * - ``dhcp`` - - serveur DHCP intégré, une instance par subnet dans le netns du VPC ; démarré uniquement - avec ``dhcp.backend: two`` - - ``-conf`` -Les quatre partagent le même fichier, ``/etc/two/agent.yml`` — voir -:doc:`/exploitation/configuration`. ``dhcp`` reçoit en plus son bridge et ses deux chemins de -fichiers en paramètres, posés par son script d'enrobage. +Les trois partagent le même fichier, ``/etc/two/agent.yml`` — voir +:doc:`/exploitation/configuration`. Mise à jour ----------- -``deploy.sh`` relève les instances ``dnsmasq@``, ``dhcp@`` et ``metadata@`` actives **avant** -d'arrêter les services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles -relancer. Arrêter les services à la main avant de lancer le script fait perdre cette liste. +``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les +services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter +les services à la main avant de lancer le script fait perdre cette liste. Vérifier l'installation ----------------------- diff --git a/docs/exploitation/api-agent/asynchronisme.rst b/docs/exploitation/api-agent/asynchronisme.rst index a506d55..96b5172 100644 --- a/docs/exploitation/api-agent/asynchronisme.rst +++ b/docs/exploitation/api-agent/asynchronisme.rst @@ -18,7 +18,7 @@ Les deux temps d'une requête A->>A: Prepare — valide, écrit "creating" A-->>C: 202 + ressource en creating A->>W: Dispatch (file d'attente) - W->>W: Execute — netns, netif, dhcp + W->>W: Execute — netns, netif, dnsmasq W->>W: état → running (ou error) C->>A: GET /subnets/ A-->>C: 200 + state diff --git a/docs/exploitation/configuration.rst b/docs/exploitation/configuration.rst index 7024527..63b29a9 100644 --- a/docs/exploitation/configuration.rst +++ b/docs/exploitation/configuration.rst @@ -1,19 +1,12 @@ Configuration ============= -Un seul fichier, ``/etc/two/agent.yml``, partagé par les quatre binaires : ``agent -config``, -``metadata -conf``, ``db -conf`` et ``dhcp -conf``. Le fichier de référence commenté est +Un seul fichier, ``/etc/two/agent.yml``, partagé par les trois binaires : ``agent -config``, +``metadata -conf`` et ``db -conf``. Le fichier de référence commenté est ``conf/agent/config.exemple.yml`` dans le dépôt. Le chargement se fait par **viper** : les clés sont celles ci-dessous, en YAML. -.. warning:: - - Un fichier **absent** est toléré : toutes les valeurs par défaut s'appliquent. Un fichier - **présent mais invalide** fait en revanche échouer le démarrage, volontairement — jusqu'à - la version 0.1.0 il était ignoré en silence, et l'agent tournait alors entièrement sur les - défauts sans le dire. Une tabulation d'indentation ou un ``--`` égaré suffisent. - .. danger:: **L'API de l'agent n'a aucune authentification.** L'exemple livré écoute sur @@ -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, créée au démarrage et supprimée à l'arrêt. -Backend DHCP ------------- - -.. code-block:: yaml - - dhcp: - backend: dnsmasq # ou two - -Choisit qui sert le DHCP des subnets **créés par cet agent** : - -.. list-table:: - :header-rows: 1 - :widths: 14 44 42 - - * - Valeur - - Serveur - - Unit - * - ``dnsmasq`` - - dnsmasq, configuré par fichiers dans ``/etc/dnsmasq.d`` - - ``dnsmasq@_`` - * - ``two`` - - le binaire ``dhcp``, piloté par socket Unix - - ``dhcp@_`` - -Le défaut est ``dnsmasq`` : un fichier de configuration de la 0.1.0, non modifié, se comporte -exactement comme avant. Toute autre valeur que ``dnsmasq`` ou ``two`` fait échouer le démarrage. - -Le répertoire d'exécution du backend ``two`` — ``/run/two/dhcp`` — **n'est pas configurable** : -le script d'enrobage le code en dur, une clé que lui ignorerait serait un mensonge. - -Ce que le backend ``two`` apporte : la configuration DHCP devient modifiable par VM et non plus -seulement par subnet, ce qui permet de n'annoncer la route par défaut que sur **une** interface -d'une VM multi-réseaux. Le watchdog peut en outre interroger le serveur et comparer ce qu'il sert -à ce que la base dit — voir :doc:`diagnostic`. - -Bascule d'un backend à l'autre -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -.. warning:: - - L'option ne décide que du backend des **nouveaux** subnets. Elle ne migre rien : un subnet - déjà créé continue d'être servi par le serveur qui l'a été. Changer la valeur sans vider - l'hyperviseur laisse l'agent parler à un serveur qui ne tourne pas — les VM existantes - continuent, les nouvelles n'obtiennent pas d'adresse. - -La bascule est **manuelle** et suppose un hyperviseur vide : - -1. Supprimer toutes les VM, puis tous les subnets, puis les VPC. -2. Vérifier qu'il ne reste aucune unit DHCP active et aucun résidu : - - .. code-block:: bash - - systemctl list-units 'dnsmasq@*' 'dhcp@*' - ls /etc/dnsmasq.d/ /run/two/dhcp/ - -3. Modifier ``dhcp.backend`` dans ``/etc/two/agent.yml``. -4. ``systemctl restart agent`` — la valeur est lue au démarrage, pas à chaque commande. -5. Recréer VPC, subnets et VM. -6. Sur la première VM, vérifier l'adresse **et les trois routes** : la route par défaut, la - route vers le CIDR du VPC, et la route ``/32`` vers ``169.254.169.254``. C'est cette - dernière qui conditionne le provisionnement cloud-init. - -Le retour arrière suit la même procédure. Il n'y a pas de bascule à chaud, dans un sens ni dans -l'autre. - Watchdog -------- diff --git a/docs/exploitation/diagnostic.rst b/docs/exploitation/diagnostic.rst index b60200c..89f7b81 100644 --- a/docs/exploitation/diagnostic.rst +++ b/docs/exploitation/diagnostic.rst @@ -25,9 +25,7 @@ partiellement créés subsistent. 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`` : - -**Backend ``dnsmasq``** +Le DHCP est servi par l'instance ``dnsmasq@`` du subnet. .. 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-_.leases cat /etc/dnsmasq.d/_.conf -**Backend ``two``** - -.. code-block:: bash - - systemctl status 'dhcp@_' - journalctl -u 'dhcp@_' -n 50 - - # Ce que le serveur a réellement en mémoire - echo '{"verb":"get-state"}' \ - | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq . - - # Ce qu'il enverrait à une MAC donnée, sans effet de bord - echo '{"verb":"probe","mac":"00:22:33:00:00:0A"}' \ - | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq .lease - -``probe`` est le point de départ le plus rapide : il montre l'adresse, le masque, le routeur, les -DNS et les routes classless tels qu'ils partiraient. Une réponse ``"served": false`` signifie que -la MAC n'est pas réservée — l'ordre ``set-host`` n'a jamais atteint le serveur, ou la VM n'a pas -été créée par cet agent. - -Le watchdog signale ces écarts de lui-même, à chaque tick, en comparant l'état servi à la base : -``dhcp reservation missing on the server``, ``stale dhcp reservation``, ``dhcp reservation -diverges``. Regarder ses notifications avant de sonder à la main. - -Si le serveur ne voit passer aucune requête, le problème est en amont : tap absent, bridge non +Si dnsmasq ne voit passer aucune requête, le problème est en amont : tap absent, bridge non raccordé, VM dans le mauvais netns. La VM a une adresse mais cloud-init n'applique rien diff --git a/docs/exploitation/observabilite.rst b/docs/exploitation/observabilite.rst index b8f7a4c..05392d1 100644 --- a/docs/exploitation/observabilite.rst +++ b/docs/exploitation/observabilite.rst @@ -57,8 +57,7 @@ Journaux journalctl -u agent -f journalctl -u 'metadata@i-web' -n 50 - tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log # backend dnsmasq - journalctl -fu 'dhcp@vp-admin_br-sn000001' # backend two + tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log Inspection de la base --------------------- diff --git a/docs/exploitation/services.rst b/docs/exploitation/services.rst index 146f98c..4e096cc 100644 --- a/docs/exploitation/services.rst +++ b/docs/exploitation/services.rst @@ -1,8 +1,7 @@ Services systemd ================ -Quatre units, installées sous ``/opt/two/bin`` par ``deploy.sh``. Les deux units DHCP -s'excluent : celle qui tourne dépend de ``dhcp.backend`` (voir :doc:`configuration`). +Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``. .. list-table:: :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 * - ``dnsmasq@.service`` - ``_`` - - dnsmasq lancé dans le netns du VPC, un par subnet — backend ``dnsmasq`` - * - ``dhcp@.service`` - - ``_`` - - serveur DHCP intégré, un par subnet — backend ``two`` + - dnsmasq lancé dans le netns du VPC, un par subnet * - ``metadata@.service`` - ```` - 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 systemctl status agent - systemctl status 'dnsmasq@vp-admin_br-sn000001' # backend dnsmasq - systemctl status 'dhcp@vp-admin_br-sn000001' # backend two + systemctl status 'dnsmasq@vp-admin_br-sn000001' systemctl status 'metadata@i-web' dnsmasq ------- -Backend historique. Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq -avec un fichier de configuration par subnet, généré par l'agent : +Le script ``run-dnsmasq-in-netns.sh`` entre dans le netns puis exécute dnsmasq avec un fichier +de configuration par subnet, généré par l'agent : .. list-table:: :widths: 40 60 @@ -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 pas d'adresse. -Serveur DHCP intégré --------------------- - -Backend ``two``. Le script ``run-dhcp-in-netns.sh`` entre dans le netns puis exécute le binaire -``dhcp``, à qui il passe le bridge à servir et ses deux chemins de fichiers — il ne déduit rien et -ignore le netns dans lequel il tourne : - -.. code-block:: bash - - /opt/two/bin/dhcp -conf /etc/two/agent.yml \ - -interface br-sn000001 \ - -state /run/two/dhcp/vp-admin_br-sn000001.state \ - -socket /run/two/dhcp/vp-admin_br-sn000001.sock - -.. list-table:: - :widths: 40 60 - - * - Socket de contrôle - - ``/run/two/dhcp/_.sock`` - * - État - - ``/run/two/dhcp/_.state`` - * - Journal - - ``journalctl -u 'dhcp@_'`` - -Il n'y a **ni fichier de configuration ni fichier de baux**. L'agent pousse l'état désiré sur la -socket de contrôle : la configuration du subnet à sa création, une réservation par interface à -chaque création ou suppression de VM. Les réservations sont statiques — une MAC inconnue n'obtient -rien, et le serveur reste silencieux plutôt que de répondre par un refus. - -Le fichier d'état **appartient au processus**, qui l'écrit et le relit à son démarrage. L'agent ne -l'écrit jamais ; il le supprime seulement, à la création du subnet pour écarter un résidu et à sa -suppression après avoir arrêté l'unit. Il vit dans ``/run`` parce qu'il n'a aucun sens sans le -netns, qui ne survit pas au redémarrage de l'host. - -Diagnostic : voir :doc:`diagnostic`, qui montre comment interroger la socket. - QEMU n'est pas une unit ----------------------- @@ -127,6 +86,6 @@ journal au moment d'un ``stop`` n'est donc pas une anomalie. 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 les services à la main avant de lancer le script fait perdre cette liste. diff --git a/docs/versions/0.2.0.md b/docs/versions/0.2.0.md deleted file mode 100644 index a8f52a5..0000000 --- a/docs/versions/0.2.0.md +++ /dev/null @@ -1,2 +0,0 @@ -```{include} ../../release_notes/0.2.0.md -``` diff --git a/docs/versions/index.rst b/docs/versions/index.rst index 09f40fa..4333ae4 100644 --- a/docs/versions/index.rst +++ b/docs/versions/index.rst @@ -6,7 +6,6 @@ Chaque version porte un nom de code dérivé du rang de sa publication : anges e .. toctree:: :maxdepth: 1 - 0.2.0 0.1.0 .. include:: ../../release_notes/codenames.md diff --git a/internal/api/dhcp/digest_test.go b/internal/api/dhcp/digest_test.go index f8a0510..fe5dd29 100644 --- a/internal/api/dhcp/digest_test.go +++ b/internal/api/dhcp/digest_test.go @@ -1,8 +1,6 @@ package dhcpapi import ( - "encoding/json" - "strings" "testing" ) @@ -173,13 +171,3 @@ func TestCanonical_SortsHostsByMAC(t *testing.T) { t.Errorf("hosts = %v, want sorted by mac", got.Hosts) } } - -func TestResponse_ServedIsAlwaysOnTheWire(t *testing.T) { - raw, err := json.Marshal(Response{OK: true, Served: false}) - if err != nil { - t.Fatalf("Marshal: %v", err) - } - if !strings.Contains(string(raw), `"served":false`) { - t.Errorf("response = %s, want an explicit served:false — omitting it makes \"not served\" indistinguishable from a missing field when probing by hand", raw) - } -} diff --git a/internal/api/dhcp/models.go b/internal/api/dhcp/models.go index d454194..b0268cf 100644 --- a/internal/api/dhcp/models.go +++ b/internal/api/dhcp/models.go @@ -54,5 +54,5 @@ type Response struct { State *State `json:"state,omitempty"` Digest string `json:"digest,omitempty"` Lease *Lease `json:"lease,omitempty"` - Served bool `json:"served"` + Served bool `json:"served,omitempty"` } diff --git a/internal/dhcpbackend/dnsmasq.go b/internal/dhcpbackend/dnsmasq.go index 207ecee..c34c4d0 100644 --- a/internal/dhcpbackend/dnsmasq.go +++ b/internal/dhcpbackend/dnsmasq.go @@ -2,7 +2,6 @@ package dhcpbackend import ( "fmt" - "path/filepath" "git.g3e.fr/syonad/two/internal/dhcp" "git.g3e.fr/syonad/two/pkg/systemd" @@ -126,7 +125,3 @@ func (b Dnsmasq) DelVM(s Subnet, vmName string, _ []Reservation) error { } return nil } - -func (b Dnsmasq) ConfigPath(s Subnet) string { - return filepath.Join(b.confDir(), s.Instance()+".conf") -} diff --git a/internal/dhcpbackend/two.go b/internal/dhcpbackend/two.go index 71a4d37..9a2d5b0 100644 --- a/internal/dhcpbackend/two.go +++ b/internal/dhcpbackend/two.go @@ -137,11 +137,3 @@ func (b Two) DelVM(s Subnet, vmName string, res []Reservation) error { } return nil } - -func (b Two) StatePath(s Subnet) string { - return b.statePath(s) -} - -func (b Two) State(s Subnet) (dhcpapi.State, string, error) { - return b.client(s).GetState() -} diff --git a/internal/watchdog/check_dhcp.go b/internal/watchdog/check_dhcp.go deleted file mode 100644 index 5e03e57..0000000 --- a/internal/watchdog/check_dhcp.go +++ /dev/null @@ -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 -} diff --git a/internal/watchdog/check_dhcp_test.go b/internal/watchdog/check_dhcp_test.go deleted file mode 100644 index a9c4801..0000000 --- a/internal/watchdog/check_dhcp_test.go +++ /dev/null @@ -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) - } -} diff --git a/internal/watchdog/check_subnet.go b/internal/watchdog/check_subnet.go index 99dac17..fafb2ff 100644 --- a/internal/watchdog/check_subnet.go +++ b/internal/watchdog/check_subnet.go @@ -2,11 +2,12 @@ package watchdog import ( "fmt" + "os" + "path/filepath" "strconv" "strings" - configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/internal/dhcpbackend" + "git.g3e.fr/syonad/two/internal/dhcp" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/state" "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 } -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) if err != nil { return fmt.Errorf("watchdog: listing subnets: %w", err) } - backend, backendErr := dhcpbackend.New(cfg) - for _, name := range resourceNames(pairs, prefixSubnet) { st, err := state.Get(db, prefixSubnet+name) if err != nil { @@ -46,15 +49,12 @@ func CheckSubnets(db *badger.DB, cfg *configuration.Config, u unitChecker, n not if st != state.Running { continue } - checkSubnet(db, name, backend, u, n) - if backendErr != nil { - n.Notify(kindSubnet, name, fmt.Sprintf("dhcp checks skipped, backend unusable: %v", backendErr)) - } + checkSubnet(db, name, u, n) } 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) if err != nil { 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) - 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) { diff --git a/internal/watchdog/check_subnet_test.go b/internal/watchdog/check_subnet_test.go index ec4acd4..6928e6f 100644 --- a/internal/watchdog/check_subnet_test.go +++ b/internal/watchdog/check_subnet_test.go @@ -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) { db := newTestDB(t) 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) } if len(r.calls) != 0 { @@ -58,7 +64,7 @@ func TestCheckSubnets_IgnoreLesEtatsNonRunning(t *testing.T) { } 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) } if len(r.calls) != 0 { @@ -71,7 +77,7 @@ func TestCheckSubnets_VPCManquantEnBase(t *testing.T) { seedResource(t, db, prefixSubnet, "br-000042", state.Running) 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) } @@ -90,7 +96,7 @@ func TestCheckSubnets_ModeManquantEnBase(t *testing.T) { seedKV(t, db, prefixSubnet+"br-000042/vpc", "vp-admin") 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) } @@ -104,7 +110,7 @@ func TestCheckSubnets_ModeInconnu(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", "macvlan") 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) } @@ -118,7 +124,7 @@ func TestCheckSubnets_ModeBridgeNeVerifiePasDeVxlan(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) 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) } @@ -137,7 +143,7 @@ func TestCheckSubnets_ModeVxlanSansVxlanID(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeVxlan) 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) } @@ -152,7 +158,7 @@ func TestCheckSubnets_ModeVxlanVxlanIDInvalide(t *testing.T) { seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "pas-un-nombre") 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) } @@ -167,7 +173,7 @@ func TestCheckSubnets_ModeVxlanVerifieLInterfaceVxlan(t *testing.T) { seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "42") 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) } @@ -181,7 +187,7 @@ func TestCheckSubnets_ConfigDnsmasqAbsente(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) 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) } @@ -199,7 +205,7 @@ func TestCheckSubnets_UnitDnsmasqInterrogee(t *testing.T) { u := newFakeUnits().active("dnsmasq@vp-admin_br-000042.service") 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) } @@ -217,7 +223,7 @@ func TestCheckSubnets_UnitDnsmasqInactive(t *testing.T) { u := newFakeUnits().inactive("dnsmasq@vp-admin_br-000042.service", "failed") 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) } @@ -232,7 +238,7 @@ func TestCheckSubnets_UnitIllisible(t *testing.T) { u := newFakeUnits().failing("dnsmasq@vp-admin_br-000042.service", errors.New("dbus indisponible")) r := &recorder{} - if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil { + if err := CheckSubnets(db, u, r); err != nil { t.Fatalf("erreur inattendue: %v", err) } @@ -246,7 +252,7 @@ func TestCheckSubnets_SansUnitCheckerPasDeVerificationDUnit(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) 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) } @@ -261,7 +267,7 @@ func TestCheckSubnets_EtatCorrompuNInterrompPasLaBoucle(t *testing.T) { seedSubnet(t, db, "br-000042", "vp-admin", modeBridge) 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) } diff --git a/internal/watchdog/helpers_test.go b/internal/watchdog/helpers_test.go index 84e9c79..891eeba 100644 --- a/internal/watchdog/helpers_test.go +++ b/internal/watchdog/helpers_test.go @@ -4,7 +4,6 @@ import ( "strings" "testing" - configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/state" "git.g3e.fr/syonad/two/internal/watchdog/notify" "git.g3e.fr/syonad/two/pkg/db/kv" @@ -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) } } - -func dnsmasqConfig() *configuration.Config { - cfg := &configuration.Config{} - cfg.DHCP.Backend = configuration.BackendDnsmasq - return cfg -} diff --git a/internal/watchdog/watchdog.go b/internal/watchdog/watchdog.go index 6da33b8..3ab1d52 100644 --- a/internal/watchdog/watchdog.go +++ b/internal/watchdog/watchdog.go @@ -65,7 +65,7 @@ func (w *Watchdog) tick() { if err := CheckVPCs(w.db, w.notifier); err != nil { w.logger.Error("watchdog: vpc check failed", "err", err) } - if err := CheckSubnets(w.db, 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) } if err := CheckVMs(w.db, w.cfg, u, w.notifier); err != nil { diff --git a/release_notes/0.2.0.md b/release_notes/0.2.0.md deleted file mode 100644 index 5f42489..0000000 --- a/release_notes/0.2.0.md +++ /dev/null @@ -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@_`. Il reçoit son bridge et ses deux chemins de fichiers en paramètres : - il ne compose aucun chemin et ignore le netns dans lequel il tourne -- Piloté par l'agent sur une **socket Unix** `/run/two/dhcp/_.sock`, en JSON par - ligne. Ordres idempotents en remplacement intégral : configuration du subnet à sa création, - une réservation par interface à chaque création ou suppression de VM -- **Réservations statiques uniquement**, pas de baux : une MAC inconnue n'obtient rien, et le - serveur reste silencieux plutôt que de répondre par un refus. Aucun `DHCPNAK` n'est émis -- État auto-persisté dans `/run/two/dhcp/_.state`, en écriture atomique et lisible - par le seul `root`. Le fichier appartient au processus, qui le relit à son démarrage ; l'agent - ne l'écrit jamais et se borne à le supprimer — à la création du subnet pour écarter un résidu, - à sa suppression après avoir arrêté l'unit -- La route par défaut est décidée **par interface** et non plus par subnet, ce qui permet de ne - l'annoncer que sur une interface d'une VM multi-réseaux -- L'encodage RFC 3442 de l'option 121 est délégué à `github.com/insomniacslk/dhcp` - -**Coexistence avec dnsmasq** - -- Nouvelle clé `dhcp.backend`, `dnsmasq` ou `two`, qui choisit le serveur des subnets **créés par - cet agent**. Le défaut est `dnsmasq` : un fichier de configuration de la 0.1.0, non modifié, se - comporte exactement comme avant -- La bascule est une **opération manuelle** sur un hyperviseur vide — l'option ne migre rien, un - subnet déjà créé reste servi par le serveur qui l'a été. La procédure est documentée -- Toute valeur autre que `dnsmasq` ou `two` fait échouer le démarrage de l'agent - -**Exploitation** - -- Le watchdog interroge le serveur DHCP intégré et compare les réservations servies à celles que - la base implique. Il nomme ce qui diverge — ordre perdu à la création, ordre perdu à la - suppression, adresse ou route par défaut divergente — et reste en lecture seule -- La socket de contrôle répond à `get-state` et à `probe`, ce dernier montrant sans effet de bord - ce qui serait envoyé à une MAC donnée : adresse, masque, routeur, DNS et routes -- Documentation Sphinx du projet : concepts, architecture, déploiement et exploitation - -## Correctifs - -- **Un fichier de configuration présent mais invalide fait désormais échouer le démarrage.** - Jusqu'en 0.1.0 l'erreur de lecture était ignorée : l'agent tournait alors entièrement sur ses - valeurs par défaut sans le dire, ce qui rendait indétectable une simple tabulation d'indentation -- `probe` annonce explicitement `"served": false` pour une MAC non réservée, au lieu d'omettre le - champ et de le rendre indistinguable d'une réponse tronquée - -## Changements internes - -- Le module passe à **Go 1.25**, exigé par la bibliothèque DHCP retenue. La version de Go du - workflow de build, restée à 1.21 alors que le module en demandait davantage, est alignée -- Nouveau paquet `pkg/db/statefile` : persistance générique d'un état de composant dans un - fichier, en écriture atomique. Badger a été écarté pour cet usage — une instance par subnet - coûterait une memtable de 64 Mio et quatre goroutines de compaction pour environ un kilo-octet - d'état, dans un `tmpfs`, et laisserait un verrou résiduel après un arrêt brutal -- `internal/subnet` et `internal/vm` ne parlent plus à dnsmasq en direct mais à une interface - `Backend` à deux implémentations - -## Périmètre et limites connues - -Celles de la 0.1.0 restent valables, sauf mention contraire ci-dessus. S'y ajoutent : - -- **dnsmasq n'est pas retiré** et reste un paquet requis : le backend intégré ne sert que les - subnets créés après la bascule, et le retour arrière suppose dnsmasq installé -- Pas de DNS, pas de pool dynamique, pas de PXE, pas de DHCPv6 dans le serveur intégré -- La comparaison faite par le watchdog porte sur les **réservations** et non sur la configuration - du subnet : celle-ci dépend de la route par défaut de l'host, dont la lecture au moment du - contrôle produirait de faux écarts. Un serveur dépourvu de configuration est en revanche signalé -- L'option 249 (routes classless de Microsoft) n'est pas émise, comme dnsmasq ne l'émet pas -- Le serveur intégré écoute UDP/67 sans authentification, comme tout serveur DHCP : l'isolation - entre locataires d'un même subnet repose sur les règles ebtables anti-usurpation, inchangées -- `internal/dhcpbackend` n'est testé sous Linux que pour ses appels systemd ; le reste, y compris - le dialogue avec le serveur intégré, est couvert sur toute plateforme - -## Mise à jour depuis la 0.1.0 - -```bash -curl -O https://git.g3e.fr/syonad/two/raw/branch/main/scripts/deploy.sh -bash ./deploy.sh -t 0.2.0 -``` - -Aucune action n'est requise : sans `dhcp.backend` dans `/etc/two/agent.yml`, le comportement est -celui de la 0.1.0. Pour passer au serveur intégré, suivre la procédure de bascule dans la -documentation d'exploitation — elle suppose un hyperviseur vidé. diff --git a/release_notes/codenames.md b/release_notes/codenames.md index fe77557..17161c2 100644 --- a/release_notes/codenames.md +++ b/release_notes/codenames.md @@ -21,7 +21,7 @@ prochain nom disponible sans tenir de compteur ailleurs : c'est la première lig | # | Nom | Nature | Version | Date | |---|---|---|---|---| | 1 | Michael | ange | [0.1.0](0.1.0.md) | 2026-08-26 | -| 2 | Bael | démon | [0.2.0](0.2.0.md) | | +| 2 | Bael | démon | 0.2.0 | | | 3 | Gabriel | ange | | | | 4 | Agares | démon | | | | 5 | Raphael | ange | | |