Compare commits

..

15 commits

Author SHA1 Message Date
6abf93005b
web: start: add icons gestion
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:40 +02:00
1d0e046ff4
web: start: add vnc-viewer feature
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:40 +02:00
c805169747
web: start: add dynamique menu
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:39 +02:00
d4282353e7
web: start: add sliding menu
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:39 +02:00
38939a0579
web: start: clean some code
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:38 +02:00
afa189d438
web: start: menu css
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:38 +02:00
19c656e69f
web: start: serparator for menu
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:38 +02:00
bec5e65728
web: start: simple form component
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:37 +02:00
2e06cd85bc
web: start: multiple pages
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:37 +02:00
6e976ac6e3
web: start: move css to dedicated file
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:37 +02:00
158dd0451a
web: start: responsive work
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:36 +02:00
aa171000dd
web: start: add side panel
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:36 +02:00
1d79f06f60
web: start: build
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:35 +02:00
da740aa322
web: start: login et logout
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:35 +02:00
14ef0ecc83
web: start: premier j'ai d'un dashboard modulaire
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-06-14 23:08:35 +02:00
261 changed files with 3486 additions and 23337 deletions

View file

@ -27,7 +27,7 @@ jobs:
- uses: actions/checkout@v3
- uses: actions/setup-go@v5
with:
go-version: "1.25.14"
go-version: "1.21"
- name: Build du projet
run: |
echo "Building for ${BINARI}/${GOOS}/${GOARCH} (release: ${RELEASE_CIBLE})"

View file

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

View file

@ -0,0 +1,69 @@
name: Pre Release Workflow
on:
push:
tags:
- '*rc*'
jobs:
set-release-target:
runs-on: docker
outputs:
release_cible: ${{ steps.setvar.outputs.release_cible }}
steps:
- name: Déterminer la release cible
id: setvar
run: |
if [[ "${GITHUB_REF}" == refs/tags/* ]]; then
TAG="${GITHUB_REF#refs/tags/}"
echo "release_cible=$TAG" >> $GITHUB_OUTPUT
elif [[ "${GITHUB_REF}" == "refs/heads/main" ]]; then
echo "release_cible=latest" >> $GITHUB_OUTPUT
else
echo "release_cible=unknown" >> $GITHUB_OUTPUT
fi
- name: Afficher la variable
run: echo "Release cible = ${{ steps.setvar.outputs.release_cible }}"
build:
runs-on: docker
needs: [set-release-target]
strategy:
matrix:
goos: [linux]
goarch: [amd64]
binaries:
- metadata
- agent
uses: ./.forgejo/workflows/build.yml
with:
tag: ${{ needs.set-release-target.outputs.release_cible }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
binari: ${{ matrix.binaries }}
secrets: inherit
upload-scripts:
runs-on: docker
needs: [set-release-target]
strategy:
matrix:
script:
- run-dnsmasq-in-netns.sh
steps:
- uses: actions/checkout@v3
- name: Move asset
run: |
mkdir -p "dist"
cp scripts/${{ matrix.script }} dist/
- name: Upload script
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.script }}-${{ needs.set-release-target.outputs.release_cible }}
path: dist/${{ matrix.script }}
prerelease:
runs-on: docker
needs: [set-release-target, build]
uses: ./.forgejo/workflows/release.yml
with:
tag: ${{ needs.set-release-target.outputs.release_cible }}
secrets: inherit

View file

@ -1,132 +0,0 @@
name: Release Pipeline
on:
push:
tags:
- '[0-9]*.[0-9]*.[0-9]*'
jobs:
set-release-target:
runs-on: docker
outputs:
release_cible: ${{ steps.setvar.outputs.release_cible }}
prerelease: ${{ steps.setvar.outputs.prerelease }}
steps:
- name: Déterminer la release cible
id: setvar
run: |
TAG="${GITHUB_REF#refs/tags/}"
# Formes acceptées, et rien d'autre :
# X.Y.Z release finale → prerelease=false, notes obligatoires
# X.Y.ZrcNNN préversion → prerelease=true, notes optionnelles
#
# Le nombre de chiffres de rc est fixé à 3 (rc016, pas rc16) pour que
# le tri lexicographique des tags suive l'ordre chronologique.
if [[ "${TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
then
PRERELEASE=false
elif [[ "${TAG}" =~ ^[0-9]+\.[0-9]+\.[0-9]+rc[0-9]{3}$ ]]
then
PRERELEASE=true
else
echo "tag hors convention : ${TAG}" >&2
echo "formes acceptées : X.Y.Z (release) ou X.Y.ZrcNNN (préversion, rc sur 3 chiffres)" >&2
exit 1
fi
echo "release_cible=${TAG}" >> $GITHUB_OUTPUT
echo "prerelease=${PRERELEASE}" >> $GITHUB_OUTPUT
- name: Afficher la variable
run: |
echo "Release cible = ${{ steps.setvar.outputs.release_cible }}"
echo "Prerelease = ${{ steps.setvar.outputs.prerelease }}"
build:
needs: [set-release-target]
strategy:
matrix:
goos: [linux]
goarch: [amd64]
binaries:
- metadata
- agent
- dhcp
uses: ./.forgejo/workflows/build.yml
with:
tag: ${{ needs.set-release-target.outputs.release_cible }}
goos: ${{ matrix.goos }}
goarch: ${{ matrix.goarch }}
binari: ${{ matrix.binaries }}
secrets: inherit
# Scripts et units systemd publiés comme assets de release : deploy.sh les
# installe depuis la release, plus depuis la branche. Ajouter une entrée ici
# suffit à livrer un nouveau fichier.
upload-assets:
runs-on: docker
needs: [set-release-target]
strategy:
matrix:
include:
- path: scripts/run-dnsmasq-in-netns.sh
name: run-dnsmasq-in-netns.sh
- path: scripts/run-dhcp-in-netns.sh
name: run-dhcp-in-netns.sh
- path: systemd/agent.service
name: agent.service
- path: systemd/dnsmasq@.service
name: dnsmasq@.service
- path: systemd/dhcp@.service
name: dhcp@.service
- path: systemd/metadata@.service
name: metadata@.service
steps:
- uses: actions/checkout@v3
- name: Move asset
run: |
mkdir -p "dist"
cp "${{ matrix.path }}" dist/
- name: Upload asset
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.name }}-${{ needs.set-release-target.outputs.release_cible }}
path: dist/${{ matrix.name }}
# Manifeste des sommes de contrôle de tous les artefacts, au format sha256sum.
# Dépend de tous les jobs qui produisent des artefacts : ajouter un producteur
# sans l'ajouter ici donnerait un manifeste incomplet, donc un déploiement qui
# refuse des assets légitimes.
checksums:
runs-on: docker
needs: [set-release-target, build, upload-assets]
steps:
- name: Download all artifacts
uses: actions/download-artifact@v3
with:
path: artifacts/
- name: Générer SHA256SUMS
run: |
mkdir -p dist
# download-artifact place chaque artefact dans son propre
# sous-répertoire : on aplatit pour que le manifeste porte les noms
# d'assets, tels que deploy.sh les demandera.
find artifacts/ -type f -exec cp {} dist/ \;
# Généré depuis dist/ pour que les noms soient nus, et hors de dist/
# pour que le manifeste ne se liste pas lui-même.
( cd dist && sha256sum * ) > SHA256SUMS
cat SHA256SUMS
- name: Upload SHA256SUMS
uses: actions/upload-artifact@v3
with:
name: SHA256SUMS-${{ needs.set-release-target.outputs.release_cible }}
path: SHA256SUMS
publish:
# Tous les producteurs d'artefacts sont dans les needs : sans ça, le job
# release peut appeler download-artifact avant la fin des uploads et publier
# une release incomplète (assets ou manifeste manquants de façon non
# déterministe).
needs: [set-release-target, build, upload-assets, checksums]
uses: ./.forgejo/workflows/release.yml
with:
tag: ${{ needs.set-release-target.outputs.release_cible }}
prerelease: ${{ needs.set-release-target.outputs.prerelease }}
secrets: inherit

View file

@ -4,9 +4,6 @@ on:
tag:
required: true
type: string
prerelease:
required: true
type: string
jobs:
release:
@ -14,9 +11,7 @@ jobs:
env:
TOKEN: ${{ secrets.RELEASE }}
TAG: ${{ inputs.tag }}
PRERELEASE: ${{ inputs.prerelease }}
steps:
- uses: actions/checkout@v3
- name: Download all build artifacts
uses: actions/download-artifact@v3
with:
@ -27,68 +22,21 @@ jobs:
run: |
apt-get update
apt-get install -y jq
- name: Préparer les notes de version
- name: Create prerelease
run: |
NOTES="release_notes/${TAG}.md"
# Titre par défaut : le tag seul. Un H1 en première ligne des notes le
# remplace par « <tag> — <nom de version> ».
RELEASE_NAME="${TAG}"
if [[ -f "${NOTES}" ]]
then
echo "notes trouvées : ${NOTES}"
FIRST_LINE=$(head -1 "${NOTES}")
if [[ "${FIRST_LINE}" =~ ^\#[[:space:]]+(.+)$ ]]
then
RELEASE_NAME="${TAG} — ${BASH_REMATCH[1]}"
# H1 retiré du corps : Forgejo affiche déjà le titre au-dessus, le
# garder l'afficherait deux fois. `/./,$!d` mange les lignes vides
# laissées en tête.
tail -n +2 "${NOTES}" | sed '/./,$!d' > body.md
else
cp "${NOTES}" body.md
fi
elif [[ "${PRERELEASE}" == "true" ]]
then
echo "aucune note pour ${TAG}, texte générique (préversion)"
echo "Prerelease automatique générée par la CI" > body.md
else
echo "release finale ${TAG} sans notes de version" >&2
echo "créer ${NOTES} avant de pousser le tag" >&2
exit 1
fi
echo "RELEASE_NAME=${RELEASE_NAME}" >> "${GITHUB_ENV}"
echo "--- titre --- ${RELEASE_NAME}"
echo "--- body ---"; cat body.md
- name: Create release
run: |
jq -n \
--arg tag "${TAG}" \
--arg name "${RELEASE_NAME}" \
--rawfile body body.md \
--argjson prerelease "${PRERELEASE}" \
'{
tag_name: $tag,
name: $name,
body: $body,
draft: false,
prerelease: $prerelease,
hide_archive_links: true
}' > payload.json
cat payload.json | jq -c '.body |= (.[0:60] + "…")'
# Code HTTP vérifié explicitement plutôt qu'avec --fail-with-body, qui
# exige curl >= 7.76 : sans ça un échec (409 tag déjà publié, 401 token
# invalide) passerait inaperçu et la release resterait sans assets.
HTTP=$(curl -sS -o resp.json -w '%{http_code}' -X POST \
-H "Authorization: token ${TOKEN}" \
curl -X POST \
-H "Authorization: token $TOKEN" \
-H "Content-Type: application/json" \
"https://git.g3e.fr/api/v1/repos/${{ github.repository }}/releases" \
-d @payload.json)
echo "HTTP ${HTTP}"
cat resp.json; echo
[[ "${HTTP}" == 2* ]] || { echo "création de la release échouée" >&2; exit 1; }
-d @- <<EOF
{
"tag_name": "$TAG",
"name": "$TAG",
"body": "Prerelease automatique générée par la CI",
"draft": false,
"prerelease": true
}
EOF
- name: Upload asset
run: |
RELEASE_ID=$(curl -s \
@ -96,10 +44,6 @@ jobs:
https://git.g3e.fr/api/v1/repos/${{ github.repository }}/releases/tags/${TAG} \
| jq -r .id)
echo ${RELEASE_ID}
# Sans ce garde-fou, un id absent (null) enverrait chaque asset sur
# .../releases/null/assets : une cascade de 404 silencieux et une
# release publiée sans aucun binaire.
[[ "${RELEASE_ID}" =~ ^[0-9]+$ ]] || { echo "id de release introuvable pour ${TAG}" >&2; exit 1; }
ls dist | while read tmp
do
FILE=$(ls "./dist/${tmp}")
@ -109,4 +53,4 @@ jobs:
-H "Content-Type: application/octet-stream" \
--data-binary @dist/${tmp}/${FILE} \
"https://git.g3e.fr/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets?name=${FILE}"
done
done

7
.gitignore vendored
View file

@ -31,5 +31,8 @@ go.work.sum
# ignore local info
data/
# Sphinx build output
docs/_build/
web/components.json
web/navigation.json
web/pages.json
web/vendor/

View file

@ -1,86 +1,3 @@
# syonad/two
# syonad
Orchestrateur réseau et machines virtuelles mono-nœud, pensé pour être piloté par un logiciel
plutôt que par un humain.
Il expose une API HTTP qui crée des **VPC** — isolés par network namespace —, des **subnets** —
en VXLAN ou attachés à un bridge existant — et des **VM** QEMU/KVM raccordées à ces subnets, avec
DHCP, routage et metadata cloud-init fournis automatiquement.
## Installation
```bash
curl -O https://git.g3e.fr/syonad/two/raw/branch/main/scripts/deploy.sh
bash ./deploy.sh -t 0.1.0 -i
```
`deploy.sh` se met à jour lui-même depuis la branche, télécharge binaires, units systemd et
scripts depuis la release, et les vérifie contre le manifeste `SHA256SUMS`. Le drapeau `-i`
prépare l'host : paquets, module `br_netfilter`, `sysctl`, et bridges.
Options utiles :
| Option | Effet |
|---|---|
| `-t <tag>` | déployer une release donnée |
| `-b <branche>` | déployer depuis une branche au lieu d'une release |
| `-i` | préparer l'host (paquets, noyau, réseau) |
| `-u <iface>` | interface physique d'uplink, `eno1` par défaut |
| `-B <bridge>` | bridge principal auquel l'uplink est rattaché |
| `-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
services, et les redémarre ensuite — c'est la seule façon de savoir lesquelles relancer.
## Configuration
Un seul fichier, `/etc/two/agent.yml`, partagé par les trois binaires. Voir
[`conf/agent/config.exemple.yml`](conf/agent/config.exemple.yml) pour l'ensemble des options :
chemins de la base et des sockets QEMU, pool de workers, correspondance des types d'interface vers
les bridges physiques, watchdog, API d'administration, journalisation.
## Prise en main
```bash
# Un VPC, avec son CIDR interne
curl -X POST http://127.0.0.1:8080/vpcs \
-d '{"name": "vp-admin", "cidr": "192.168.0.0/16"}'
# Un subnet en VXLAN dans ce VPC
curl -X POST http://127.0.0.1:8080/subnets \
-d '{"name": "sn-000001", "vpc": "vp-admin", "mode": "vxlan", "vxlan_id": 1,
"iface_type": "vms", "interface_ip": "10.1.1.1", "cidr": "10.1.0.0/23"}'
# Une VM, avec une clé SSH et un user-data cloud-init en base64
curl -X POST http://127.0.0.1:8080/vms \
-d '{"name": "i-web", "memory": 2048, "cpus": 2,
"metadata": {"sshkey": "ssh-ed25519 AAAA…",
"user_data": "'"$(base64 -w0 < user-data.yml)"'"},
"interfaces": [{"subnet": "sn-000001", "ip": "10.1.1.2", "primary": true}],
"storage": [{"path": "/data/disks/vms/i-web.qcow2", "dev": "vda"}]}'
```
Les créations sont **asynchrones** : l'API répond `202` et l'état de la ressource passe de
`creating` à `running` en base. `GET /vms/i-web` renvoie l'état courant.
Une VM peut porter plusieurs interfaces, dans un même VPC ; exactement une doit être marquée
`primary` — elle porte la route par défaut et le serveur de metadata.
La spécification complète est dans [`api/agent.yaml`](api/agent.yaml).
## Composants
| Binaire | Rôle |
|---|---|
| `agent` | processus principal : API, dispatcher, exécution, watchdog |
| `metadata` | serveur de metadata cloud-init, une instance par VM dans le netns du VPC |
| `db` | inspection de la base clé-valeur en ligne de commande |
L'agent prend `-config`, les deux autres `-conf`.
## Versions
Les notes de version sont dans [`release_notes/`](release_notes/). Chaque version porte un nom de
code, dérivé du rang de sa publication : anges et démons alternés, listés dans
[`release_notes/codenames.md`](release_notes/codenames.md).
A simple but powerful orchestrator, designed to be easy to use and API-first.

View file

@ -91,7 +91,7 @@ paths:
"404":
$ref: "#/components/responses/NotFound"
"409":
description: VPC not deletable — only running or error states can be deleted, and all its subnets must be deleted first
description: VPC not in a deletable state
content:
application/json:
schema:
@ -146,7 +146,7 @@ paths:
schema:
$ref: "#/components/schemas/Error"
"422":
description: Subnet not found, or not in creating/running state
description: Subnet not found or not in created state
content:
application/json:
schema:
@ -185,12 +185,6 @@ paths:
$ref: "#/components/schemas/VM"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: VM not stoppable — only running or error states can be stopped
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
@ -280,12 +274,6 @@ paths:
$ref: "#/components/schemas/Subnet"
"404":
$ref: "#/components/responses/NotFound"
"409":
description: Subnet not deletable — only running or error states can be deleted
content:
application/json:
schema:
$ref: "#/components/schemas/Error"
"500":
$ref: "#/components/responses/InternalError"
@ -326,8 +314,8 @@ components:
example: vp-00001
state:
type: string
enum: [creating, running, error, deleting, deleted]
example: running
enum: [creating, created, deleting, deleted]
example: created
cidr:
type: string
example: "10.0.0.0/16"
@ -349,10 +337,8 @@ components:
description: >
Subnet mode. "vxlan" (default): creates a VXLAN tunnel and a host bridge.
"bridge": attaches directly to an existing bridge resolved from iface_type in the agent config.
"public_ip": accepted and routed like vxlan for DHCP purposes, but its host network
setup is not implemented yet — creating such a subnet currently fails in Execute.
"vlan" is reserved for future use.
enum: [vxlan, bridge, public_ip]
enum: [vxlan, bridge]
default: vxlan
example: vxlan
vxlan_id:
@ -375,20 +361,9 @@ components:
default_route:
type: boolean
description: >
A default route is always advertised via DHCP; this flag only chooses its next-hop.
When false, the next-hop is the subnet interface_ip. When true, it is the `gateway`
field if supplied, otherwise the gateway read from the host routing table.
The route to the VPC CIDR always keeps interface_ip as its next-hop (except in bridge
mode, which has no VPC route), so VPC traffic never leaves through a public gateway.
If true, advertise a default route via DHCP. For vxlan mode the gateway is the interface IP.
For bridge mode the gateway is read from the host routing table.
default: false
gateway:
type: string
format: ipv4
description: >
Optional next-hop for the default route. Only used when default_route is true;
supplied with default_route false, it is ignored. Not validated by the agent:
reachability and coherence with the subnet CIDR are the caller's responsibility.
example: "10.10.10.254"
Subnet:
type: object
@ -398,14 +373,14 @@ components:
example: sn-00001
state:
type: string
enum: [creating, running, error, deleting, deleted]
example: running
enum: [creating, created, deleting, deleted]
example: created
vpc:
type: string
example: vpc1
mode:
type: string
enum: [vxlan, bridge, public_ip]
enum: [vxlan, bridge]
example: vxlan
vxlan_id:
type: integer
@ -424,9 +399,6 @@ components:
default_route:
type: boolean
example: false
gateway:
type: string
example: "10.10.10.254"
VMCreateRequest:
type: object
@ -443,17 +415,14 @@ components:
type: integer
description: Number of vCPUs (default 1)
example: 2
metadata:
$ref: "#/components/schemas/VMMetadata"
password:
type: string
sshkey:
type: string
example: "ssh-ed25519 AAAA..."
interfaces:
type: array
minItems: 1
description: >
Network interfaces, in order. The position determines the PCI slot
(0x03 + index) and therefore the interface name inside the guest.
**Exactly one** interface must be marked primary: it carries the
default route and the metadata server. All subnets must belong to
the same VPC.
items:
$ref: "#/components/schemas/VMInterface"
storage:
@ -466,37 +435,6 @@ components:
description: Boot with UEFI firmware (OVMF). Defaults to false (SeaBIOS).
example: false
VMMetadata:
type: object
description: >
cloud-init inputs for this VM. Every field is optional. Documents not
supplied here fall back to the agent's built-in templates; a document
supplied as an empty string is served empty, which is not the same
thing.
properties:
password:
type: string
description: >
Password *hash* for the syonad account, as expected by the
cloud-config `passwd` key (e.g. `$6$...`) — not a plaintext
password. When omitted, the account is created locked
(`lock_passwd: true`); when neither password nor sshkey is given,
no account is created at all.
example: "$6$rounds=4096$saltsalt$hash..."
sshkey:
type: string
description: Public SSH key added to the syonad account. Sent as-is, not encoded.
example: "ssh-ed25519 AAAA..."
user_data:
type: string
format: byte
description: >
cloud-init user-data, **base64-encoded**. Encoding keeps multi-line
documents out of JSON escaping and allows gzip+base64 payloads.
Invalid base64 is rejected with 400 rather than silently serving an
empty document. The agent never interprets this content.
example: "I2Nsb3VkLWNvbmZpZwpwYWNrYWdlczoKICAtIG5naW54Cg=="
VMInterface:
type: object
required: [subnet, ip, primary]
@ -534,8 +472,8 @@ components:
example: vm-00001
state:
type: string
enum: [creating, running, error, deleting, deleted]
example: running
enum: [starting, started, stopping, stopped]
example: started
metadata_port:
type: string
example: "80"

View file

@ -1,22 +1,14 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
"syscall"
"time"
agentapi "git.g3e.fr/syonad/two/internal/api/agent"
configuration "git.g3e.fr/syonad/two/internal/config/agent"
dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent"
"git.g3e.fr/syonad/two/internal/migration"
agentmetrics "git.g3e.fr/syonad/two/internal/prometheus/agent"
"git.g3e.fr/syonad/two/internal/watchdog"
"git.g3e.fr/syonad/two/internal/watchdog/notify"
"git.g3e.fr/syonad/two/pkg/db/kv"
"git.g3e.fr/syonad/two/pkg/logger"
promserver "git.g3e.fr/syonad/two/pkg/prometheus"
@ -24,8 +16,6 @@ import (
"github.com/prometheus/client_golang/prometheus"
)
const shutdownTimeout = 20 * time.Second
func main() {
confFile := flag.String("config", "/etc/two/agent.yml", "config file path")
flag.Parse()
@ -39,24 +29,7 @@ func main() {
log := logger.New(cfg.Logger.Level, cfg.Logger.Debug)
db := kv.InitDB(kv.Config{Path: cfg.Database.Path}, false)
closeDB := true
defer func() {
if closeDB {
db.Close()
}
}()
// Avant tout démarrage de service : la DB peut porter l'ancien vocabulaire
// d'états, et des ressources transitoires orphelines d'un arrêt précédent.
if err := migration.MigrateStates(db, log.With(slog.String("component", "migration"))); err != nil {
log.Error("failed to migrate states", "error", err)
return
}
if err := migration.MigrateVMNICs(db, log.With(slog.String("component", "migration"))); err != nil {
log.Error("vm nic migration failed", "error", err)
return
}
defer db.Close()
q := worker.New(cfg.Worker.BufferSize)
q.Start(cfg.Worker.Count)
@ -75,69 +48,13 @@ func main() {
"debug", cfg.Logger.Debug,
)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
d := dispatcher.New(q, db, cfg, log.With(slog.String("component", "dispatcher")))
apiSrv := agentapi.New(d, db, log.With(slog.String("component", "api")), apiAddr)
go apiSrv.Start()
promSrv := promserver.New(promAddr, registry)
go promSrv.Start()
var adminSrv *kv.AdminServer
go agentapi.New(d, db, log.With(slog.String("component", "api"))).Start(apiAddr)
go promserver.Start(promAddr, registry)
if cfg.Admin.Enabled {
adminAddr := fmt.Sprintf("%s:%d", cfg.Admin.Address, cfg.Admin.Port)
adminSrv = kv.NewAdminServer(db, log.With(slog.String("component", "admin")), adminAddr)
go adminSrv.Start()
go kv.NewAdminServer(db, log.With(slog.String("component", "admin"))).Start(adminAddr)
}
if cfg.Watchdog.Enabled {
wlog := log.With(slog.String("component", "watchdog"))
go watchdog.New(db, cfg, notify.NewStderr(wlog), wlog,
time.Duration(cfg.Watchdog.IntervalSeconds)*time.Second,
).Run(ctx)
}
<-ctx.Done()
stop()
servers := map[string]httpShutdowner{"api": apiSrv, "prometheus": promSrv}
if adminSrv != nil {
servers["admin"] = adminSrv
}
closeDB = shutdown(log, q, servers, shutdownTimeout)
}
type httpShutdowner interface {
Shutdown(context.Context) error
}
func shutdown(log *slog.Logger, q *worker.Queue, servers map[string]httpShutdowner, timeout time.Duration) bool {
log.Info("shutting down", "timeout", timeout)
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
for name, srv := range servers {
if err := srv.Shutdown(ctx); err != nil {
log.Error("http server shutdown", "server", name, "error", err)
}
}
drained := make(chan struct{})
go func() {
q.Stop()
close(drained)
}()
select {
case <-drained:
log.Info("workers drained")
return true
case <-ctx.Done():
log.Error("workers still running after timeout, leaving database untouched")
return false
}
select {}
}

View file

@ -1,98 +0,0 @@
package main
import (
"context"
"errors"
"io"
"log/slog"
"strings"
"sync/atomic"
"testing"
"time"
"git.g3e.fr/syonad/two/pkg/worker"
)
type fakeServer struct {
called atomic.Bool
err error
}
func (f *fakeServer) Shutdown(context.Context) error {
f.called.Store(true)
return f.err
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func TestShutdown_DrainReussi(t *testing.T) {
q := worker.New(10)
q.Start(2)
var done atomic.Int32
for range 3 {
q.Submit(func() {
time.Sleep(20 * time.Millisecond)
done.Add(1)
})
}
api, prom := &fakeServer{}, &fakeServer{}
servers := map[string]httpShutdowner{"api": api, "prometheus": prom}
if !shutdown(discardLogger(), q, servers, 5*time.Second) {
t.Fatal("un drainage réussi doit autoriser la fermeture de la base")
}
if !api.called.Load() || !prom.called.Load() {
t.Error("tous les serveurs HTTP doivent être arrêtés")
}
if got := done.Load(); got != 3 {
t.Errorf("les 3 tâches devaient se terminer, %d terminées", got)
}
}
func TestShutdown_TimeoutLaisseLaBaseIntacte(t *testing.T) {
q := worker.New(10)
q.Start(1)
q.Submit(func() { time.Sleep(2 * time.Second) })
var buf strings.Builder
log := slog.New(slog.NewTextHandler(&buf, nil))
if shutdown(log, q, map[string]httpShutdowner{}, 50*time.Millisecond) {
t.Fatal("un drainage incomplet ne doit pas autoriser la fermeture de la base")
}
if !strings.Contains(buf.String(), "leaving database untouched") {
t.Errorf("le dépassement devrait être logué, obtenu %q", buf.String())
}
}
func TestShutdown_ErreurServeurNEmpechePasLeDrainage(t *testing.T) {
q := worker.New(10)
q.Start(1)
var buf strings.Builder
log := slog.New(slog.NewTextHandler(&buf, nil))
servers := map[string]httpShutdowner{
"api": &fakeServer{err: errors.New("boom")},
"prometheus": &fakeServer{},
}
if !shutdown(log, q, servers, 5*time.Second) {
t.Fatal("une erreur d'arrêt HTTP ne doit pas empêcher le drainage")
}
if !strings.Contains(buf.String(), "http server shutdown") {
t.Errorf("l'erreur devrait être loguée, obtenu %q", buf.String())
}
}
func TestShutdown_SansServeur(t *testing.T) {
q := worker.New(10)
q.Start(1)
if !shutdown(discardLogger(), q, map[string]httpShutdowner{}, 5*time.Second) {
t.Fatal("l'absence de serveur ne doit pas empêcher un arrêt propre")
}
}

View file

@ -1,78 +0,0 @@
package main
import (
"flag"
"fmt"
"net"
"os"
dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
configuration "git.g3e.fr/syonad/two/internal/config/agent"
"git.g3e.fr/syonad/two/internal/dhcpd"
"git.g3e.fr/syonad/two/pkg/logger"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/server4"
)
var (
confFile = flag.String("conf", "/etc/two/agent.yml", "configuration file")
iface = flag.String("interface", "", "bridge to serve, already present in the current network namespace")
statePath = flag.String("state", "", "state file owned by this process")
socketPath = flag.String("socket", "", "control socket the agent talks to")
)
func main() {
flag.Parse()
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "dhcp: %v\n", err)
os.Exit(1)
}
}
func run() error {
for name, value := range map[string]string{
"-interface": *iface,
"-state": *statePath,
"-socket": *socketPath,
} {
if value == "" {
return fmt.Errorf("%s is required", name)
}
}
cfg, err := configuration.LoadConfig(*confFile)
if err != nil {
return fmt.Errorf("load config: %w", err)
}
log := logger.New(cfg.Logger.Level, cfg.Logger.Debug).With("bridge", *iface)
store := dhcpd.NewStore(*statePath)
if err := store.Load(); err != nil {
return fmt.Errorf("load state: %w", err)
}
control, err := dhcpapi.Listen(store, *socketPath, log)
if err != nil {
return fmt.Errorf("listen on the control socket: %w", err)
}
defer control.Close()
go func() {
if err := control.Serve(); err != nil {
log.Error("control socket stopped", "error", err)
}
}()
conn, err := server4.NewIPv4UDPConn(*iface, &net.UDPAddr{Port: dhcpv4.ServerPort})
if err != nil {
return fmt.Errorf("bind udp/%d on %s: %w", dhcpv4.ServerPort, *iface, err)
}
defer conn.Close()
log.Info("dhcp server started", "state", store.Path(), "socket", control.Addr())
return store.Serve(conn, log)
}

View file

@ -1,193 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"syscall"
"time"
"git.g3e.fr/syonad/two/internal/lab/machine"
"git.g3e.fr/syonad/two/internal/lab/provision"
)
const (
topologyFile = "topology.yml"
pollInterval = 5 * time.Second
stopTimeout = 30 * time.Second
)
var (
execve = syscall.Exec
procDir = "/proc"
)
func defaultDir(parts ...string) string {
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(append([]string{home}, parts...)...)
}
func flags(name string, stderr io.Writer) (*flag.FlagSet, *string) {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() { fmt.Fprint(stderr, usage) }
return fs, fs.String("run", defaultDir("lab-run"), "run directory of the lab")
}
func lab(runDir string, plan string, stdout, stderr io.Writer) (machine.Lab, bool) {
dir, err := filepath.Abs(runDir)
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return machine.Lab{}, false
}
p, ok := load(plan, stderr)
if !ok {
return machine.Lab{}, false
}
return machine.Lab{
Plan: p,
RunDir: dir,
ProcDir: procDir,
Runner: provision.ExecRunner{},
Poll: pollInterval,
Stop: stopTimeout,
Out: stdout,
}, true
}
func current(runDir string, stdout, stderr io.Writer) (machine.Lab, bool) {
saved := filepath.Join(runDir, topologyFile)
if _, err := os.Stat(saved); err != nil {
fmt.Fprintf(stderr, "lab: no lab in %s: %v\n", runDir, err)
return machine.Lab{}, false
}
return lab(runDir, saved, stdout, stderr)
}
func upCmd(args []string, stdout, stderr io.Writer) int {
fs, runDir := flags("up", stderr)
cacheDir := fs.String("cache", defaultDir(".cache", "two-lab"), "image cache directory")
timeout := fs.Duration("timeout", 20*time.Minute, "how long to wait for the nodes to be ready")
if err := fs.Parse(args); err != nil || fs.NArg() != 1 {
fs.Usage()
return 2
}
cache, err := filepath.Abs(*cacheDir)
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
if _, err := os.Stat(filepath.Join(*runDir, topologyFile)); err == nil {
previous, ok := current(*runDir, stdout, stderr)
if !ok {
return 1
}
running, err := previous.Running()
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
if len(running) > 0 {
fmt.Fprintf(stderr, "lab: lab %s is still running in %s: 'lab down' first\n", previous.Plan.Name, previous.RunDir)
return 1
}
}
l, ok := lab(*runDir, fs.Arg(0), stdout, stderr)
if !ok {
return 1
}
source, err := os.ReadFile(fs.Arg(0))
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
if err := os.MkdirAll(l.RunDir, 0o700); err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
if err := os.WriteFile(filepath.Join(l.RunDir, topologyFile), source, 0o600); err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
fetcher := provision.Fetcher{Client: &http.Client{}, CacheDir: cache}
if err := l.Up(ctx, fetcher, *timeout); err != nil {
fmt.Fprintf(stderr, "lab: %v\nlab: started nodes keep running: 'lab status', 'lab down'\n", err)
return 1
}
return 0
}
func statusCmd(args []string, stdout, stderr io.Writer) int {
fs, runDir := flags("status", stderr)
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
fs.Usage()
return 2
}
l, ok := current(*runDir, stdout, stderr)
if !ok {
return 1
}
if err := l.Status(stdout); err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
return 0
}
func downCmd(args []string, stdout, stderr io.Writer) int {
fs, runDir := flags("down", stderr)
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
fs.Usage()
return 2
}
l, ok := current(*runDir, stdout, stderr)
if !ok {
return 1
}
if err := l.Down(context.Background()); err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
return 0
}
func sshCmd(args []string, stdout, stderr io.Writer) int {
fs, runDir := flags("ssh", stderr)
if err := fs.Parse(args); err != nil || fs.NArg() < 1 {
fs.Usage()
return 2
}
l, ok := current(*runDir, stdout, stderr)
if !ok {
return 1
}
argv, err := l.SSH(fs.Arg(0), isTerminal(os.Stdin), fs.Args()[1:])
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
path, err := exec.LookPath(argv[0])
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
if err := execve(path, argv, os.Environ()); err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
return 0
}

View file

@ -1,174 +0,0 @@
package main
import (
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"git.g3e.fr/syonad/two/internal/lab/provision"
"git.g3e.fr/syonad/two/internal/lab/render"
"git.g3e.fr/syonad/two/internal/lab/topology"
)
const usage = `usage: lab <command> [options] <topology.yml> [dir]
plan <topology.yml>
validate the topology and print the deterministic plan: addresses, cables, ports
render -key <public key file> <topology.yml> <dir>
write, for each node, <dir>/<node>/qemu.args (one argument per line) and the
cloud-init seed files meta-data, user-data and network-config
up [-run dir] [-cache dir] [-timeout duration] <topology.yml>
download and verify the images, create fresh disks and seeds, start every node
(switches first) and wait until cloud-init has finished on each of them;
the topology is kept in <run>/topology.yml for the commands below
status [-run dir]
list the nodes of the lab and whether their qemu process is running
down [-run dir]
stop every qemu process of the lab (SIGTERM, then SIGKILL)
ssh [-run dir] <node> [command...]
open a shell on a node, or run a command on it, with the key generated by up
defaults: -run $HOME/lab-run, -cache $HOME/.cache/two-lab, -timeout 20m
`
type keyFiles []string
func (k *keyFiles) String() string { return strings.Join(*k, ",") }
func (k *keyFiles) Set(v string) error { *k = append(*k, v); return nil }
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) == 0 {
fmt.Fprint(stderr, usage)
return 2
}
switch args[0] {
case "plan":
if len(args) != 2 {
fmt.Fprint(stderr, usage)
return 2
}
return plan(args[1], stdout, stderr)
case "render":
return renderCmd(args[1:], stdout, stderr)
case "up":
return upCmd(args[1:], stdout, stderr)
case "status":
return statusCmd(args[1:], stdout, stderr)
case "down":
return downCmd(args[1:], stdout, stderr)
case "ssh":
return sshCmd(args[1:], stdout, stderr)
default:
fmt.Fprint(stderr, usage)
return 2
}
}
func load(path string, stderr io.Writer) (*topology.Plan, bool) {
t, err := topology.Load(path)
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return nil, false
}
p, err := topology.Compute(t)
if err != nil {
fmt.Fprintf(stderr, "lab: %s:\n%v\n", path, err)
return nil, false
}
return p, true
}
func plan(path string, stdout, stderr io.Writer) int {
p, ok := load(path, stderr)
if !ok {
return 1
}
if err := p.Write(stdout); err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
return 0
}
func renderCmd(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("render", flag.ContinueOnError)
fs.SetOutput(stderr)
fs.Usage = func() { fmt.Fprint(stderr, usage) }
var keys keyFiles
fs.Var(&keys, "key", "public key file allowed to log in, repeatable")
if err := fs.Parse(args); err != nil {
return 2
}
if fs.NArg() != 2 || len(keys) == 0 {
fmt.Fprint(stderr, usage)
return 2
}
p, ok := load(fs.Arg(0), stderr)
if !ok {
return 1
}
dir, err := filepath.Abs(fs.Arg(1))
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
authorized, err := readKeys(keys)
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
frr, err := provision.ReadFRR(p)
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
agent, err := provision.ReadAgent(p)
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized, FRR: frr, Agent: agent})
if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
for _, n := range nodes {
if err := provision.WriteFiles(n); err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err)
return 1
}
fmt.Fprintf(stdout, "%s\n", n.Dir)
}
return 0
}
func readKeys(files []string) ([]string, error) {
var keys []string
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
return nil, err
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
keys = append(keys, line)
}
}
return keys, nil
}

View file

@ -1,354 +0,0 @@
package main
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"syscall"
"testing"
)
func runLab(args ...string) (int, string, string) {
var stdout, stderr bytes.Buffer
code := run(args, &stdout, &stderr)
return code, stdout.String(), stderr.String()
}
func TestRun_UsageOnMissingArguments(t *testing.T) {
for _, args := range [][]string{nil, {"plan"}, {"deploy", "x.yml"}, {"plan", "a.yml", "b.yml"}, {"render", "a.yml", "dir"}, {"render", "-key", "k.pub", "a.yml"}, {"render", "-bogus"}} {
code, _, stderr := runLab(args...)
if code != 2 || !strings.Contains(stderr, "usage: lab") {
t.Errorf("args %v: code %d, stderr %q", args, code, stderr)
}
}
}
func TestRun_PlanOfTheShippedExampleTopology(t *testing.T) {
code, stdout, stderr := runLab("plan", filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"))
if code != 0 {
t.Fatalf("code %d, stderr %s", code, stderr)
}
for _, want := range []string{
"lab evpn-2hv: nodes 4, segments 1, cables 3",
"gateway 192.168.14.1",
"hv2 underlay 192.168.14.12/24 02:4c:00:03:00:00 20004 <-> sw1 p2",
"rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf",
} {
if !strings.Contains(stdout, want) {
t.Errorf("output does not contain %q:\n%s", want, stdout)
}
}
}
func TestRun_InvalidTopologyExitsWithErrors(t *testing.T) {
path := filepath.Join(t.TempDir(), "bad.yml")
doc := `name: bad
images:
deb: { url: https://example.invalid/a, sums: https://example.invalid/b }
segments:
under: { switch: sw, cidr: 10.0.0.0/31 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
rr: { role: router, image: deb, cpus: 1, memory: 512, segments: [under] }
`
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
t.Fatal(err)
}
code, stdout, stderr := runLab("plan", path)
if code != 1 || stdout != "" {
t.Fatalf("code %d, stdout %q", code, stdout)
}
for _, want := range []string{path, `role "router"`, "prefix length out of range"} {
if !strings.Contains(stderr, want) {
t.Errorf("stderr does not mention %q:\n%s", want, stderr)
}
}
}
func TestRun_MissingFile(t *testing.T) {
code, _, stderr := runLab("plan", filepath.Join(t.TempDir(), "absent.yml"))
if code != 1 || !strings.Contains(stderr, "absent.yml") {
t.Errorf("code %d, stderr %q", code, stderr)
}
}
func TestRun_RenderWritesEveryNodeFiles(t *testing.T) {
dir := t.TempDir()
key := filepath.Join(dir, "lab.pub")
if err := os.WriteFile(key, []byte("# lab key\n\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation\n"), 0o600); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "run")
code, stdout, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"), out)
if code != 0 {
t.Fatalf("code %d, stderr %s", code, stderr)
}
for _, node := range []string{"sw1", "rr1", "hv1", "hv2"} {
if !strings.Contains(stdout, filepath.Join(out, node)) {
t.Errorf("stdout does not list %s:\n%s", node, stdout)
}
for _, f := range []string{"qemu.args", "meta-data", "user-data", "network-config"} {
info, err := os.Stat(filepath.Join(out, node, f))
if err != nil {
t.Errorf("%s/%s: %v", node, f, err)
continue
}
if info.Mode().Perm() != 0o600 {
t.Errorf("%s/%s mode %o, want 600", node, f, info.Mode().Perm())
}
}
}
args, err := os.ReadFile(filepath.Join(out, "hv1", "qemu.args"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(args), "\n-netdev\nuser,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22\n") {
t.Errorf("qemu.args is not one argument per line:\n%s", args)
}
userData, err := os.ReadFile(filepath.Join(out, "hv1", "user-data"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(userData), "two-lab-automation") || strings.Contains(string(userData), "# lab key") {
t.Errorf("keys not read as an authorized_keys file:\n%s", userData)
}
}
func TestRun_RenderRefusesMissingKeyFile(t *testing.T) {
code, _, stderr := runLab("render", "-key", filepath.Join(t.TempDir(), "absent.pub"), filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"), t.TempDir())
if code != 1 || !strings.Contains(stderr, "absent.pub") {
t.Errorf("code %d, stderr %q", code, stderr)
}
}
func savedLab(t *testing.T) string {
t.Helper()
run := filepath.Join(t.TempDir(), "run")
if err := os.MkdirAll(run, 0o700); err != nil {
t.Fatal(err)
}
example, err := os.ReadFile(filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(run, "topology.yml"), example, 0o600); err != nil {
t.Fatal(err)
}
return run
}
func TestRun_LifecycleUsage(t *testing.T) {
for _, args := range [][]string{{"up"}, {"up", "a.yml", "b.yml"}, {"status", "x"}, {"down", "x"}, {"ssh"}, {"up", "-bogus", "a.yml"}} {
code, _, stderr := runLab(args...)
if code != 2 || !strings.Contains(stderr, "usage: lab") {
t.Errorf("args %v: code %d, stderr %q", args, code, stderr)
}
}
}
func TestRun_CommandsWithoutALabInTheRunDir(t *testing.T) {
run := t.TempDir()
for _, cmd := range []string{"status", "down", "ssh"} {
args := []string{cmd, "-run", run}
if cmd == "ssh" {
args = append(args, "hv1")
}
code, _, stderr := runLab(args...)
if code != 1 || !strings.Contains(stderr, "lab: no lab in "+run) {
t.Errorf("%s: code %d, stderr %q", cmd, code, stderr)
}
}
}
func TestRun_StatusOfAStoppedLab(t *testing.T) {
code, stdout, stderr := runLab("status", "-run", savedLab(t))
want := `node role state pid ssh
sw1 switch stopped - 127.0.0.1:2200
rr1 rr stopped - 127.0.0.1:2201
hv1 hypervisor stopped - 127.0.0.1:2202
hv2 hypervisor stopped - 127.0.0.1:2203
`
if code != 0 || stdout != want {
t.Errorf("code %d, stderr %q, stdout:\n%s", code, stderr, stdout)
}
}
func TestRun_DownOfAStoppedLab(t *testing.T) {
code, stdout, stderr := runLab("down", "-run", savedLab(t))
if code != 0 || stdout != "" || stderr != "" {
t.Errorf("code %d, stdout %q, stderr %q", code, stdout, stderr)
}
}
func TestRun_SSHExecsSSHWithTheNodePort(t *testing.T) {
run := savedLab(t)
var gotPath string
var gotArgv []string
execve = func(path string, argv []string, env []string) error {
gotPath, gotArgv = path, argv
return nil
}
t.Cleanup(func() { execve = syscall.Exec })
code, _, stderr := runLab("ssh", "-run", run, "hv2", "ip", "-br", "a")
if code != 0 {
t.Fatalf("code %d, stderr %q", code, stderr)
}
if filepath.Base(gotPath) != "ssh" {
t.Errorf("path = %q", gotPath)
}
want := []string{"ssh",
"-i", filepath.Join(run, "lab_ed25519"),
"-o", "IdentitiesOnly=yes",
"-o", "IdentityAgent=none",
"-o", "StrictHostKeyChecking=no",
"-o", "UserKnownHostsFile=/dev/null",
"-o", "LogLevel=ERROR",
"-p", "2203",
"debian@127.0.0.1",
"ip", "-br", "a",
}
if strings.Join(gotArgv, " ") != strings.Join(want, " ") {
t.Errorf("\n got %q\nwant %q", gotArgv, want)
}
}
func TestRun_SSHUnknownNode(t *testing.T) {
code, _, stderr := runLab("ssh", "-run", savedLab(t), "hv9")
if code != 1 || !strings.Contains(stderr, `node "hv9" is not in lab evpn-2hv`) {
t.Errorf("code %d, stderr %q", code, stderr)
}
}
func TestRun_UpRefusesAnInvalidTopologyAndKeepsTheSavedOne(t *testing.T) {
run := savedLab(t)
before, _ := os.ReadFile(filepath.Join(run, "topology.yml"))
bad := filepath.Join(t.TempDir(), "bad.yml")
if err := os.WriteFile(bad, []byte("name: x\n"), 0o600); err != nil {
t.Fatal(err)
}
code, _, stderr := runLab("up", "-run", run, bad)
if code != 1 || !strings.Contains(stderr, "at least one node is required") {
t.Errorf("code %d, stderr %q", code, stderr)
}
if after, _ := os.ReadFile(filepath.Join(run, "topology.yml")); string(after) != string(before) {
t.Error("the saved topology was replaced by an invalid one")
}
}
func TestIsTerminal_DevNullAndPipesAreNotTerminals(t *testing.T) {
null, err := os.Open(os.DevNull)
if err != nil {
t.Fatal(err)
}
defer null.Close()
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
for name, f := range map[string]*os.File{"/dev/null": null, "pipe": r} {
if isTerminal(f) {
t.Errorf("%s is detected as a terminal", name)
}
}
}
func TestRun_UpRefusesToReplaceARunningLab(t *testing.T) {
run := savedLab(t)
before, _ := os.ReadFile(filepath.Join(run, "topology.yml"))
proc := t.TempDir()
procDir = proc
t.Cleanup(func() { procDir = "/proc" })
cmd := exec.Command("sleep", "30")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() })
pid := strconv.Itoa(cmd.Process.Pid)
if err := os.MkdirAll(filepath.Join(proc, pid), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(proc, pid, "cmdline"), []byte("qemu-system-x86_64\x00-name\x00hv1\x00"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(run, "hv1"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(run, "hv1", "qemu.pid"), []byte(pid+"\n"), 0o600); err != nil {
t.Fatal(err)
}
other := filepath.Join(t.TempDir(), "other.yml")
if err := os.WriteFile(other, bytes.ReplaceAll(before, []byte("evpn-2hv"), []byte("other")), 0o600); err != nil {
t.Fatal(err)
}
code, _, stderr := runLab("up", "-run", run, other)
if code != 1 || !strings.Contains(stderr, "lab evpn-2hv is still running in "+run) {
t.Errorf("code %d, stderr %q", code, stderr)
}
if after, _ := os.ReadFile(filepath.Join(run, "topology.yml")); string(after) != string(before) {
t.Error("the topology of a running lab was replaced")
}
}
func TestRun_RenderShipsTheExampleFRRConfigs(t *testing.T) {
dir := t.TempDir()
key := filepath.Join(dir, "lab.pub")
if err := os.WriteFile(key, []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation\n"), 0o600); err != nil {
t.Fatal(err)
}
out := filepath.Join(dir, "run")
code, _, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"), out)
if code != 0 {
t.Fatalf("code %d, stderr %s", code, stderr)
}
for node, want := range map[string]string{
"sw1": "router bgp 65100",
"rr1": "bgp listen range 192.168.14.0/24 peer-group fabric",
"hv1": "bgp router-id 192.168.14.11",
"hv2": "bgp router-id 192.168.14.12",
} {
data, err := os.ReadFile(filepath.Join(out, node, "user-data"))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(data), want) {
t.Errorf("%s user-data does not carry %q", node, want)
}
}
}
func TestRun_RenderReportsAMissingFRRConfig(t *testing.T) {
dir := t.TempDir()
key := filepath.Join(dir, "lab.pub")
if err := os.WriteFile(key, []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O x\n"), 0o600); err != nil {
t.Fatal(err)
}
topo := filepath.Join(dir, "lab.yml")
doc := `name: x
images:
deb: { url: https://example.invalid/deb.qcow2, sums: https://example.invalid/SHA512SUMS }
segments:
underlay: { switch: sw1, cidr: 10.1.0.0/24 }
nodes:
sw1: { role: switch, image: deb, cpus: 1, memory: 512, frr: frr/absent.conf }
hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [underlay], release: 0.2.0rc002 }
`
if err := os.WriteFile(topo, []byte(doc), 0o600); err != nil {
t.Fatal(err)
}
code, _, stderr := runLab("render", "-key", key, topo, filepath.Join(dir, "run"))
if code != 1 || !strings.Contains(stderr, "node sw1: ") || !strings.Contains(stderr, filepath.Join(dir, "frr", "absent.conf")) {
t.Errorf("code %d, stderr %q", code, stderr)
}
}

View file

@ -1,14 +0,0 @@
//go:build linux || darwin
package main
import (
"os"
"golang.org/x/sys/unix"
)
func isTerminal(f *os.File) bool {
_, err := unix.IoctlGetTermios(int(f.Fd()), ioctlReadTermios)
return err == nil
}

View file

@ -1,5 +0,0 @@
package main
import "golang.org/x/sys/unix"
const ioctlReadTermios = unix.TIOCGETA

View file

@ -1,5 +0,0 @@
package main
import "golang.org/x/sys/unix"
const ioctlReadTermios = unix.TCGETS

View file

@ -1,7 +0,0 @@
//go:build !linux && !darwin
package main
import "os"
func isTerminal(*os.File) bool { return false }

View file

@ -39,16 +39,6 @@ interfaces:
metadata:
run_dir: "/run/two/metadata"
# DHCP backend used for the subnets created by this agent.
# dnsmasq : dnsmasq@ instances driven by generated config files
# two : the built-in dhcp binary, driven over a unix socket
# Switching backends is a manual operation: drain the hypervisor, change this
# value, restart the agent. There is no hot migration.
# The per-subnet control socket and state file live in /run/two/dhcp, which is
# not configurable: the wrapper script hardcodes it too.
dhcp:
backend: dnsmasq
# QEMU runtime paths
qemu:
# UEFI firmware (requires apt install ovmf on Debian/Ubuntu)
@ -61,13 +51,6 @@ qemu:
monitor_dir: "/run/two/vms/monitor"
qmp_dir: "/run/two/vms/qmp"
# Consistency watchdog: periodically checks that resources marked running in the
# database still exist on the system, and reports the gaps. Read-only, never repairs.
# A persisting gap is reported at every tick (no deduplication).
watchdog:
enabled: true
interval_seconds: 60
# Admin API (read-only DB inspection, loopback only)
admin:
enabled: false

View file

@ -1,37 +0,0 @@
# Documentation `two`
Construction locale, avec rechargement automatique :
```bash
TMPDIR=$(mktemp -d)
python3 -m venv "${TMPDIR}/venv"
source "${TMPDIR}/venv/bin/activate"
pip install --upgrade pip
pip install -r requirements.txt
sphinx-autobuild . "${TMPDIR}/build"
```
Construction simple :
```bash
sphinx-build -b html . _build/html
```
La référence de l'API est générée depuis `../api/agent.yaml` : c'est la source unique du
contrat, la documentation ne la recopie pas.
## Schémas
Les schémas simples sont en mermaid, directement dans les pages. Les schémas travaillés sont des
SVG dans `schemas/`, en deux variantes :
```
schemas/<nom>.svg thème clair → .. figure:: /schemas/<nom>.svg :class: only-light
schemas/<nom>-dark.svg thème sombre → .. figure:: /schemas/<nom>-dark.svg :class: only-dark
```
`schemas/` est dans `exclude_patterns` : Sphinx n'y cherche pas de pages, mais copie les fichiers
référencés par une directive `figure` ou `image`.
Un export draw.io se dépose tel quel sous ce nom. Exporter en **SVG éditable** (« Include a copy
of my diagram ») pour pouvoir rouvrir le fichier dans draw.io ensuite.

View file

@ -1,86 +0,0 @@
Invariants et pièges
====================
Contraintes découvertes en production ou en corrigeant des bugs. Les enfreindre casse quelque
chose qui fonctionne, souvent en silence.
.. note::
Cette page reprend la section « Invariants et pièges » de ``CLAUDE.md``, qui reste la
référence de développement et fait foi en cas d'écart.
Réseau
------
* **Ne pas retirer la route ``/32`` vers ``169.254.169.254``** de l'option 121, même quand elle
paraît redondante avec la route par défaut. La DNAT vers le serveur de metadata est posée dans
le netns du VPC en ``PREROUTING`` : le paquet n'y est traité en L3 que si son next-hop est
``interface_ip``. Avec un autre next-hop, la trame est commutée en L2 sans traverser
``PREROUTING``, et le provisionnement cloud-init échoue.
* **RFC 3442** : un client qui lit l'option 121 **ignore l'option 3**. Toute route par défaut
doit donc figurer dans la 121 ; l'option 3 ne sert que les clients qui n'implémentent pas la
121.
* **La route vers le CIDR du VPC garde ``interface_ip`` comme next-hop** dans tous les modes sauf
``bridge`` : sur un subnet à IP publique, le trafic interne ne doit pas sortir par la gateway
publique.
* ``169.254.169.254`` est centralisé dans ``metadata.ServiceIP`` — ne pas le réécrire en dur.
QEMU et VM
----------
* **Un seul disque ``vdX`` par VM** ; les disques additionnels passent par le SCSI (``sdX``). La
carte PCI en dépend : NIC en ``0x03``, contrôleur SCSI en ``0x1e``, virtio-blk en ``0x1f``.
* ``bus=pci.0`` est explicite sur les trois ``-device`` : un passage de la machine en **q35**
casserait le démarrage (``Bus 'pci.0' not found``).
* QEMU est lancé par ``systemd-run --scope``, **jamais** en unit transitoire : le scope est
exécuté par le processus appelant et hérite du netns posé par ``netns.Call``. Une unit
transitoire, forkée par PID 1, démarrerait dans le netns racine et ne verrait pas le tap.
* L'arrêt d'une VM ne touche **jamais** aux fichiers disque. En revanche, un ``quit`` brutal est
envoyé à l'expiration de ``dispatcher.timeout_seconds``.
Arrêt de l'agent
----------------
* Ordre imposé : serveurs HTTP → drainage des workers → fermeture de la base. L'inverser crée une
course.
* Budget d'arrêt dépassé ⇒ **la base n'est pas fermée** : fermer Badger sous un écrivain
concurrent est pire qu'un rejeu du journal au démarrage suivant.
* Jamais de ``log.Fatal`` dans une goroutine : ``os.Exit`` n'exécute aucun ``defer``.
cloud-init
----------
* ``network-config.tmpl`` cible ``eth0`` alors que les guests sont en ``ens3`` : il ne s'applique
donc à rien, et le réseau vient du DHCP. **Ne pas le « corriger » ni le supprimer** — le rendre
opérant ferait remplacer par cloud-init la configuration réseau de l'image sur toutes les VM.
* Un document fourni par l'appelant est servi **verbatim** ; un document absent retombe sur le
template ; un document explicitement vide est servi vide. Les trois cas sont distincts.
* ``metadata.password`` est un **hash**, pas un mot de passe en clair.
* ``instance-id`` vaut le nom de la VM : recréer une VM du même nom sur le même disque fait que
cloud-init la reconnaît et **n'applique pas** le user-data.
Configuration
-------------
* Le chargement se fait par **viper** : tags ``mapstructure``, jamais ``yaml``.
* Un chemin configurable se propage par les signatures de fonction, jamais par une variable ou un
setter de paquet.
Sécurité connue et acceptée
---------------------------
Ces points sont documentés parce qu'ils sont **assumés en l'état**, pas parce qu'ils sont sans
conséquence. Ils doivent être réévalués avant toute exposition élargie de l'API.
* **L'API n'a aucune authentification** et l'exemple de configuration l'expose sur
``0.0.0.0:8080``. Quiconque atteint ce port pilote le host KVM.
* ``vm/<name>/password`` est stocké tel quel (c'est un hash) et restitué par ``/db?prefix=vm/``
du serveur d'administration — contenu par ``admin.enabled: false`` et l'écoute en boucle
locale.
* ``/run/two/metadata/<vm>/vendor-data`` est en ``0644`` et contient ce hash : tout compte local
du host peut le lire.
* ``pkg/systemd.New()`` n'a **pas de timeout** : si le socket D-Bus accepte sans répondre,
l'appelant se fige. Concerne le watchdog, la création et la suppression de subnets, et le
serveur de metadata.
* Il n'y a **pas de rollback** : un échec partiel de création laisse des objets réseau orphelins
jusqu'à un ``DELETE`` explicite.

View file

@ -1,13 +0,0 @@
Architecture
============
L'organisation interne de l'agent : découpage en paquets, stockage, et contraintes à ne pas
enfreindre. Ces pages s'adressent à qui modifie le code ; ``CLAUDE.md``, à la racine du dépôt,
reste la référence de développement et fait foi en cas d'écart.
.. toctree::
:maxdepth: 1
vue-densemble
stockage
contraintes

View file

@ -1,51 +0,0 @@
Schéma des clés
===============
Toutes les valeurs stockées dans Badger sont des **chaînes plates** : une clé, une valeur, pas
de sérialisation structurée.
.. code-block:: text
vpc/<name>/state → creating | running | error | deleting | deleted
vpc/<name>/cidr → <cidr>
subnet/<name>/state → creating | running | error | deleting | deleted
subnet/<name>/vpc → <vpc-name>
subnet/<name>/mode → vxlan | bridge | public_ip
subnet/<name>/vxlan_id → <id> (mode vxlan uniquement)
subnet/<name>/cidr → <cidr>
subnet/<name>/interface_ip → <ip> (gateway, portée par br-<subnetID>)
subnet/<name>/local_iface → <bridge-name>
subnet/<name>/default_route → "true" | "false"
subnet/<name>/gateway → <ip> (optionnel)
subnet/<name>/dhcp/<ip> → <mac>
vm/<name>/state → creating | running | error | deleting | deleted
vm/<name>/subnet → <subnet-name>
vm/<name>/tap_id → <int>
vm/<name>/ip → <ip>
vm/<name>/metadata_port → <port>
vm/<name>/disk/<dev> → <path> (une clé par disque : sda, vda, …)
vm/<name>/memory → <int> (Mo)
vm/<name>/cpus → <int>
vm/<name>/uefi → "true" (absent si SeaBIOS)
vm/<name>/password → <hash> (optionnel — un hash, pas un mot de passe)
vm/<name>/sshkey → <pubkey> (optionnel)
vm/<name>/metadata/<document> → <contenu brut> (optionnel : user-data, vendor-data, …)
Règles
------
**Pas de duplication.** Une ressource ne stocke que ce qui lui est propre. Une VM garde le lien
``vm/<name>/subnet`` ; le VPC, le bridge et l'``interface_ip`` sont lus depuis le subnet, leur
source canonique.
**Les états passent par ``state``.** Toujours ``state.Set`` / ``state.Get`` : ``Set`` refuse une
valeur hors énumération, ``Get`` refuse de retourner une valeur non reconnue. ``error`` n'est
écrit que par ``Dispatcher.Dispatch``, via ``cmd.Key()``.
**Tout entier lu depuis la base peut être corrompu.** Les erreurs de conversion sont retournées,
jamais ignorées : une valeur absente ou illisible est un état d'erreur réel.
**Un seul ouvreur.** L'agent est le seul processus à ouvrir la base. Le serveur de metadata lit
des fichiers écrits par l'agent sous ``metadata.run_dir``, jamais Badger.

View file

@ -1,90 +0,0 @@
Vue d'ensemble
==============
Cycle d'une requête
-------------------
.. code-block:: text
HTTP → internal/api/agent → Dispatcher.Prepare() → Dispatcher.Dispatch() → worker.Queue → Command.Execute()
**Prepare** (synchrone, dans le handler HTTP)
valide l'état, écrit l'état initial (``creating`` / ``deleting``) en base, et retourne 202 ou
une erreur.
**Dispatch** (asynchrone)
place la commande sur un canal bufferisé ; une goroutine worker appelle ``Execute``. C'est
``Dispatch``, et lui seul, qui marque la ressource en ``error`` si ``Execute`` échoue.
**Execute**
effectue le travail réseau (netns, netif, VXLAN, veth, bridge, DHCP), puis met l'état à
``running`` / ``deleted``.
Paquets
-------
.. list-table::
:header-rows: 1
:widths: 32 68
* - Chemin
- Rôle
* - ``internal/api/agent``
- handlers HTTP de ``/vpcs``, ``/subnets`` et ``/vms``
* - ``internal/dispatcher/agent``
- interface ``Command`` (``Prepare``/``Execute``/``Key``) et commandes concrètes
* - ``internal/state``
- énumération des états, ``CanDelete``/``IsTransient``, seul point d'écriture des états
* - ``internal/migration``
- migrations idempotentes jouées au démarrage de l'agent
* - ``internal/vpc``, ``internal/subnet``
- création et suppression bas niveau (netns + netif)
* - ``internal/netns``
- network namespaces : create/enter/delete/call
* - ``internal/netif``
- netlink : bridge, veth, vxlan, tap, routes, adresses
* - ``internal/ebtables``, ``internal/iptables``
- wrappers dédiés ; ne pas appeler ces binaires ailleurs
* - ``internal/qemu``, ``internal/qmp``
- lancement de QEMU et client QMP sur socket Unix
* - ``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
* - ``internal/metadata``
- serveur de metadata cloud-init et ses templates
* - ``internal/watchdog``
- vérification périodique en lecture seule
* - ``internal/config/agent``
- chargement par viper — tags ``mapstructure``, jamais ``yaml``
* - ``internal/prometheus/agent``
- collector des métriques ``syonad_*``
* - ``pkg/db/kv``
- wrapper Badger ; toutes les valeurs sont des chaînes plates
* - ``pkg/worker``
- pool de goroutines sur canal
* - ``pkg/systemd``
- client D-Bus systemd
* - ``pkg/logger``, ``pkg/prometheus``
- journalisation ``slog`` et serveur de métriques
Ajouter un type de ressource
----------------------------
#. ajouter les helpers KV dans ``pkg/db/kv`` si nécessaire ;
#. définir ``Create<X>`` / ``Delete<X>`` dans un nouveau paquet ``internal/<x>/`` ;
#. ajouter ``Create<X>Command`` / ``Delete<X>Command`` dans ``internal/dispatcher/agent/``, dont
``Key()`` qui retourne ``<x>/<name>`` et le contrôle ``state.CanDelete`` dans
``Delete<X>Command.Prepare`` ;
#. ajouter les handlers HTTP dans ``internal/api/agent/`` et les routes dans ``server.go``.
Stubs de plateforme
-------------------
Les fichiers ``_linux.go`` portent l'implémentation netlink/netns réelle ; les ``_other.go``
correspondants retournent une erreur « not supported on this platform ». Tous les paquets
**compilent** sur macOS, ce qui permet d'y tester la logique qui ne touche ni netlink ni netns.
Deux exceptions à connaître : les stubs de ``netns`` exécutent ``fn`` **sans changer de
namespace** — ``netns.Call`` réussit donc hors Linux — et ``netif`` compile partout parce que
netlink fournit une implémentation « unspecified ».

View file

@ -1,63 +0,0 @@
Cycle de vie des ressources
===========================
VPC, subnets et VM partagent le même jeu d'états.
.. mermaid::
stateDiagram-v2
[*] --> creating
creating --> running
creating --> error
running --> deleting
running --> error
deleting --> deleted
deleting --> error
error --> deleting
deleted --> [*]
.. list-table::
:header-rows: 1
:widths: 15 85
* - État
- Signification
* - ``creating``
- la demande est acceptée et enregistrée ; ``Execute`` n'a pas encore abouti
* - ``running``
- la ressource existe sur le système
* - ``error``
- ``Execute`` a échoué ; état **terminal**, il n'y a pas de reprise automatique
* - ``deleting``
- suppression en cours
* - ``deleted``
- suppression terminée
Suppression
-----------
Elle n'est autorisée que depuis ``running`` ou ``error`` — sinon **409**. Depuis ``error``, elle
est **best-effort** : les ressources système peuvent n'avoir été créées que partiellement.
Un VPC ne peut être supprimé qu'une fois tous ses subnets supprimés.
Pas de rollback
---------------
En cas d'échec partiel pendant une création, les ressources réseau déjà créées **ne sont pas
nettoyées**. C'est un choix délibéré : le nettoyage est déclenché explicitement par une
suppression, qui est justement autorisée depuis ``error``.
Conséquence pour l'appelant : après un passage en ``error``, émettre un ``DELETE`` avant toute
tentative de recréation, faute de quoi la recréation butera sur des objets système résiduels.
États transitoires au redémarrage de l'agent
--------------------------------------------
La file d'attente des workers est **en mémoire**. Une ressource restée en ``creating`` ou
``deleting`` au moment d'un arrêt de l'agent est donc nécessairement orpheline : plus personne
ne la traite.
Au démarrage, une migration idempotente bascule ces ressources en ``error``, et traduit
l'ancien vocabulaire d'états. Une ressource retrouvée en ``error`` après un redémarrage n'a donc
pas forcément échoué techniquement — elle peut simplement avoir été interrompue.

View file

@ -1,14 +0,0 @@
Concepts
========
Le modèle de données, les modes réseau et le cycle de vie des ressources : comment les éléments
fonctionnent entre eux. Le contrat HTTP correspondant est dans
:doc:`/exploitation/api-agent/index`.
.. toctree::
:maxdepth: 1
vpc-subnet-vm
modes-reseau
cycle-de-vie
metadata-cloud-init

View file

@ -1,71 +0,0 @@
Metadata et cloud-init
======================
Chaque VM dispose d'un serveur de metadata NoCloud, servi sur ``169.254.169.254`` dans le netns
de son VPC, sous la forme d'une instance systemd ``metadata@<vm>``.
Chaîne de production
--------------------
.. mermaid::
graph LR
A["agent<br/>WriteNoCloudFiles"] -->|"/run/two/metadata/&lt;vm&gt;/"| M["binaire metadata"]
M -->|HTTP 169.254.169.254| G["guest<br/>cloud-init"]
L'agent écrit les fichiers cloud-init sur disque **avant** de démarrer le service ; le binaire
``metadata`` les lit et les sert. Le processus ``metadata`` n'ouvre **jamais** la base Badger :
deux processus ne doivent pas partager une même instance.
Documents servis
----------------
Chaque document suit la même règle :
* fourni par l'appelant → servi **verbatim**, l'agent n'interprète rien ;
* absent → le template par défaut est rendu ;
* fourni **vide** → servi vide.
Les deux derniers cas sont distincts, et c'est délibéré : fournir une chaîne vide est une façon
explicite de neutraliser un document.
Champs de ``metadata``
----------------------
``sshkey``
Clé publique ajoutée au compte ``syonad``. Transmise telle quelle, non encodée.
``password``
Un **hash**, tel qu'attendu par la clé ``passwd`` de cloud-config (``$6$…``) — jamais un mot
de passe en clair. Omis, le compte est créé verrouillé ; sans ``password`` ni ``sshkey``,
aucun compte n'est créé.
``user_data``
Le user-data cloud-init, **encodé en base64**. L'encodage évite l'échappement JSON des
documents multi-lignes et autorise les charges ``gzip+base64``. Un base64 invalide est rejeté
en 400 plutôt que servi vide.
``instance-id``
------------------
``instance-id`` vaut le **nom de la VM**. Recréer une VM du même nom sur le même disque fait que
cloud-init la reconnaît comme déjà provisionnée et **n'applique pas** le user-data. Pour rejouer
un provisionnement : changer de nom, repartir d'un disque neuf, ou exécuter
``cloud-init clean --logs`` dans le guest avant l'extinction.
Configuration réseau
--------------------
.. warning::
``network-config.tmpl`` cible ``eth0`` alors que les guests utilisent ``ens3`` : il ne
s'applique donc à rien, et le réseau des VM vient du DHCP. **Ne pas le « corriger » ni le
supprimer.** Le rendre opérant ferait remplacer par cloud-init la configuration réseau de
l'image, sur toutes les VM.
Sécurité
--------
``/run/two/metadata/<vm>/vendor-data`` est en ``0644`` et contient le hash de mot de passe. Tout
compte local du host peut le lire. C'est une exposition connue et acceptée en l'état ; elle
disqualifie l'usage de hashs faibles ou réutilisés.

View file

@ -1,99 +0,0 @@
Modes réseau
============
Le champ ``mode`` d'un subnet détermine la façon dont il est raccordé à l'host, et les routes
annoncées aux VM.
.. list-table::
:header-rows: 1
:widths: 15 45 40
* - Mode
- Raccordement
- État
* - ``vxlan``
- tunnel VXLAN (``vxlan_id``) + bridge dans le netns du VPC
- défaut
* - ``bridge``
- rattachement direct à un bridge existant de l'host, résolu depuis ``iface_type``
- disponible
* - ``public_ip``
- routé comme ``vxlan`` côté DHCP
- **mise en place host non implémentée** — la création échoue à l'exécution
* - ``vlan``
- —
- réservé, non implémenté
.. warning::
``public_ip`` est accepté par l'API et traité comme ``vxlan`` pour le DHCP, mais sa
configuration réseau côté host n'existe pas encore : la création part en ``error`` dans
``Execute``. Ne pas s'appuyer dessus en production.
vxlan
-----
.. mermaid::
graph LR
VM --- TAP[tap] --- BR["br-&lt;subnet&gt;<br/>interface_ip"]
BR --- VX["vxlan&lt;vni&gt;"] --- HBR["bridge host<br/>(iface_type)"] --- UP[uplink]
Le subnet vit dans le netns du VPC. La VM n'est donc **pas joignable depuis l'host** sans route
explicite — point à connaître avant de câbler un outil externe dessus.
bridge
------
Le subnet est rattaché directement à un bridge existant de l'host. Pas de tunnel, pas de route
VPC : le trafic sort par le bridge, et la VM est joignable depuis l'host.
Routes annoncées aux VM
-----------------------
Les routes sont poussées par DHCP, dans l'**option 121** (routes statiques sans classe,
RFC 3442). Trois entrées y figurent :
#. la route ``/32`` vers ``169.254.169.254``, le serveur de metadata ;
#. la route vers le CIDR du VPC ;
#. la route par défaut ``0.0.0.0/0``.
.. important::
**Un client qui lit l'option 121 ignore l'option 3.** Toute route par défaut doit donc figurer
dans l'option 121 ; l'option 3 ne sert que les clients qui n'implémentent pas la 121.
Route par défaut : ``default_route`` et ``gateway``
----------------------------------------------------------
Une route par défaut est **toujours** annoncée. Le champ ``default_route`` ne choisit que son
next-hop :
``default_route: false`` (défaut)
next-hop = ``interface_ip`` du subnet.
``default_route: true``
next-hop = le champ ``gateway`` s'il est fourni, sinon la gateway lue dans la table de routage
de l'host.
``gateway`` n'est **pas validé** par l'agent : sa joignabilité et sa cohérence avec le CIDR du
subnet relèvent de l'appelant. Fourni avec ``default_route: false``, il est ignoré.
Dans tous les modes sauf ``bridge``, la route vers le CIDR du VPC garde ``interface_ip`` comme
next-hop : sur un subnet à IP publique, le trafic interne ne doit pas sortir par la gateway
publique.
Pourquoi la route ``/32`` vers le serveur de metadata est indispensable
----------------------------------------------------------------------------
Elle paraît redondante avec la route par défaut. Elle ne l'est pas.
La DNAT vers le serveur de metadata est posée dans le netns du VPC, en ``PREROUTING``. Le paquet
n'y est traité en L3 que si son next-hop est ``interface_ip``, portée par le bridge du netns.
Avec un autre next-hop, la trame est commutée en **L2** sans traverser ``PREROUTING`` : le
serveur de metadata devient injoignable et tout le provisionnement cloud-init échoue,
silencieusement.
.. danger::
Ne jamais retirer cette route de l'option 121, quelle que soit l'apparence de redondance.

View file

@ -1,64 +0,0 @@
VPC, subnet et VM
=================
Trois types de ressources, une hiérarchie stricte.
.. mermaid::
graph TD
VPC["VPC<br/><i>network namespace</i><br/>cidr"] --> SN1["Subnet<br/><i>bridge + VXLAN</i><br/>interface_ip, cidr"]
VPC --> SN2["Subnet"]
SN1 --> VM1["VM<br/><i>QEMU/KVM</i>"]
SN1 --> VM2["VM"]
SN2 --> VM2
VPC
---
Un VPC est un **network namespace** portant un espace d'adressage (``cidr``). C'est l'unité
d'isolation : deux VPC ne se voient pas, et peuvent réutiliser les mêmes plages d'adresses.
Un VPC ne peut être supprimé que si tous ses subnets le sont déjà — sinon 409.
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.
``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
``default_interface``. Ce niveau d'indirection permet au même appel d'API de fonctionner sur des
hosts dont le nommage réseau diffère.
Le comportement réseau dépend du :doc:`mode </concepts/modes-reseau>`.
VM
--
Une VM est un processus QEMU/KVM raccordé à un ou plusieurs subnets par des taps.
**Interfaces.** L'ordre du tableau ``interfaces`` détermine le slot PCI (``0x03 + index``), donc
le nom de l'interface dans le guest. Exactement une interface doit être ``primary`` : elle porte
la route par défaut et le serveur de metadata. Tous les subnets d'une VM doivent appartenir au
**même VPC**.
**Stockage.** Un seul disque ``vdX`` (virtio-blk) par VM ; les disques supplémentaires passent
par le contrôleur SCSI (``sdX``). Cette contrainte vient de la carte PCI figée — voir
:doc:`/architecture/contraintes`.
Ce qui est stocké, et où
------------------------
Une ressource ne porte en base que ce qui lui est propre. Une VM stocke le **lien** vers son
subnet (``vm/<name>/subnet``), pas le VPC ni le bridge ni la gateway : ces valeurs sont lues
depuis le subnet, leur source canonique. Le schéma complet des clés est dans
:doc:`/architecture/stockage`.
Nommage
-------
L'API est machine-to-machine : elle **ne valide pas** les conventions de nommage, à l'exception
du motif documenté pour les VPC (``vp-…``). Les exemples de cette documentation suivent la
convention ``vp-`` / ``sn-`` / ``i-``, mais c'est à l'appelant de la faire respecter.

View file

@ -1,92 +0,0 @@
# Configuration file for the Sphinx documentation builder.
#
# For the full list of built-in configuration values, see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html
import os
# -- Project information -----------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
project = 'two'
copyright = '2026, Nicolas Boufidjeline'
author = 'Nicolas Boufidjeline'
version = '0.1'
release = '0.1.0'
# -- General configuration ---------------------------------------------------
templates_path = ['_templates']
exclude_patterns = ['_build', 'README.md', 'requirements.txt', 'schemas']
language = 'fr'
extensions = [
'myst_parser',
'sphinxcontrib.mermaid',
'sphinxcontrib.openapi',
]
myst_enable_extensions = [
'colon_fence',
'deflist',
]
source_suffix = {
'.rst': 'restructuredtext',
'.md': 'markdown',
}
# -- Options for HTML output -------------------------------------------------
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
html_theme = 'sphinx_book_theme'
html_static_path = []
html_show_sphinx = False
# Le thème publie le source de chaque page dans _sources/ et l'expose derrière
# un bouton de téléchargement. Les deux vont ensemble : couper la copie sans
# couper le bouton laisserait un lien mort vers un répertoire vide.
html_copy_source = False
html_show_sourcelink = False
# Le sélecteur de version est piloté par le workflow de publication : hors CI
# la variable est absente, le sélecteur n'apparaît pas, et le build ne dépend
# d'aucun réseau.
_docs_version = os.environ.get('DOCS_VERSION')
html_theme_options = {
'home_page_in_toc': True,
'use_download_button': False,
'icon_links': [
{
'name': 'Dépôt',
'url': 'https://git.g3e.fr/syonad/two',
'icon': 'fa-solid fa-code-branch',
'type': 'fontawesome',
},
],
}
if _docs_version:
html_theme_options['switcher'] = {
# Chemin relatif volontairement : le thème le résout contre la racine
# de la version courante, donc toujours dans la même origine que la
# page. Une URL absolue ferait échouer la requête en CORS dès que le
# site est consulté depuis un autre hôte — un serveur de test local,
# par exemple.
'json_url': '../switcher.json',
'version_match': _docs_version,
}
# Le thème book vide navbar_start et place tout dans la barre latérale : le
# sélecteur doit donc y être inséré explicitement, à côté du logo.
html_sidebars = {
'**': [
'navbar-logo.html',
'icon-links.html',
'version-switcher.html',
'search-button-field.html',
'sbt-sidebar-nav.html',
]
}

View file

@ -1,14 +0,0 @@
Démarrage
=========
Le parcours court : un hyperviseur, un VPC, un subnet, une VM qui démarre. Tout reste sur le
même nœud — c'est suffisant pour valider une installation et pour découvrir le modèle, pas pour
faire fonctionner un parc.
Pour un cluster, poursuivre avec :doc:`/deploiement/index`.
.. toctree::
:maxdepth: 1
installation
premier-vpc

View file

@ -1,156 +0,0 @@
Installation d'un hyperviseur
=============================
Cette page installe l'agent sur **un** hyperviseur. Le réseau du cluster — routage entre nœuds,
plan de contrôle — est traité à part : voir :doc:`/deploiement/index`.
Prérequis
---------
Un host Linux avec KVM, sur lequel vous avez ``root``. Les opérations réseau (network
namespaces, netlink, VXLAN, ebtables, iptables) et QEMU ne fonctionnent que sous Linux.
Déploiement
-----------
.. code-block:: bash
curl -O https://git.g3e.fr/syonad/two/raw/branch/main/scripts/deploy.sh
bash ./deploy.sh -t 0.1.0 -i
``deploy.sh`` se met à jour lui-même depuis la branche avant toute action — s'il diffère, il se
réécrit et demande d'être relancé. Il télécharge ensuite binaires, units systemd et scripts
depuis la release, et les vérifie contre le manifeste ``SHA256SUMS``.
.. list-table::
:header-rows: 1
:widths: 26 54 20
* - Option
- Effet
- Défaut
* - ``-t <tag>``
- déployer une release donnée
- dernière
* - ``-b <branche>``
- branche utilisée pour l'auto-mise à jour du script
- ``main``
* - ``-p <profil>``
- profil d'host ; seul ``kvm`` installe les units de l'agent
- ``kvm``
* - ``-i``
- préparer l'host : paquets, noyau, réseau
- désactivé
* - ``-u <iface>``
- interface physique d'uplink
- ``eno1``
* - ``-B <bridge>``
- bridge principal, auquel l'uplink est rattaché
- ``br-000000``
* - ``-P <bridge>``
- bridge supplémentaire, créé vide et réservé
- ``br-public``
* - ``-R <secondes>``
- délai avant le redémarrage de secours pendant la migration réseau
- ``120``
* - ``-d``
- dry-run : affiche les commandes sans les exécuter
- désactivé
Les options booléennes actives par défaut se **désactivent** par leur forme longue négative :
``--nopackages``, ``--nonetwork``, ``--noverify``, ``--noup_script``.
.. warning::
``--noverify`` désactive la seule vérification d'intégrité des artefacts téléchargés. Ne
l'utiliser que pour diagnostiquer un manifeste cassé, jamais en déploiement courant.
Ce que fait ``-i``
------------------
**Paquets** — ``qemu-system-x86``, ``ovmf``, ``dnsmasq``, ``ebtables``, ``iptables``,
``nfs-common``, ``jq``, ``curl``. Le service ``dnsmasq`` du système est ensuite désactivé et
**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
ne se provisionne pas. Contrepartie assumée : tout le trafic inter-VM traverse les tables NAT.
**Réseau** — création du bridge réservé, puis rattachement de l'uplink au bridge principal,
l'adresse et la route par défaut étant déplacées de l'interface physique vers le bridge.
.. danger::
La migration réseau **coupe le réseau de l'host si elle échoue à mi-parcours**, sans console
de secours. Deux garde-fous sont en place : un redémarrage de secours armé avant l'opération
(``-R``, 120 s par défaut) qui ramène la configuration d'origine puisque rien n'est écrit sur
disque, et l'exécution de la séquence sous systemd plutôt que dans la session SSH, pour
qu'une coupure de SSH ne l'interrompe pas.
Le désarmement n'a lieu **qu'après** un ping réussi vers la passerelle. Prévoir un accès
physique ou console avant de lancer un ``-i`` à distance sur un host de production.
Host sans état
--------------
L'hyperviseur est **stateless** : sa racine est en tmpfs, rien de ce que pose ``-i`` ne survit à
un redémarrage. ``deploy.sh --bootstrap`` est donc rejoué à chaque démarrage — c'est le
mécanisme normal, pas une réparation.
Binaires installés
------------------
.. list-table::
:header-rows: 1
:widths: 20 60 20
* - Binaire
- Rôle
- Drapeau de config
* - ``agent``
- processus principal : API, dispatcher, exécution, watchdog
- ``-config``
* - ``metadata``
- serveur de metadata cloud-init, une instance par VM dans le netns du VPC
- ``-conf``
* - ``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.
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.
Vérifier l'installation
-----------------------
.. code-block:: bash
systemctl status agent
curl -s http://127.0.0.1:8080/vpcs
Une liste JSON — vide au premier démarrage — signifie que l'API répond. Passez à
:doc:`/demarrage/premier-vpc`.
.. important::
L'API de l'agent **n'a aucune authentification**. Avant d'ouvrir le port au-delà de la boucle
locale, lisez l'avertissement de :doc:`/exploitation/configuration` : quiconque atteint ce
port pilote la totalité de l'hyperviseur.

View file

@ -1,155 +0,0 @@
Premier VPC, premier subnet, première VM
========================================
Ce tutoriel crée de bout en bout une VM joignable, sur un hyperviseur où l'agent est installé et
répond. Il suppose l'API sur ``127.0.0.1:8080`` et une image disque déjà présente sur l'host.
Tout se passe sur un **seul nœud** : un subnet ne s'étend à d'autres hyperviseurs qu'une fois le
plan de contrôle du cluster en place, cf. :doc:`/deploiement/architecture-cluster`.
Ce que l'on construit
---------------------
.. mermaid::
graph LR
subgraph netns vp-admin
BR["br-sn000001<br/>10.1.1.1"]
MD["metadata@i-web<br/>169.254.169.254"]
end
VM["VM i-web<br/>10.1.1.2"] --- BR
BR --- MD
BR --- VXLAN["VXLAN vni 1<br/>br-000000"]
Le VPC est un network namespace ; le subnet y pose un bridge porteur de la gateway ; la VM s'y
raccroche par un tap, reçoit son adresse en DHCP et son cloud-init depuis le serveur de metadata
du netns.
1. Le VPC
---------
.. code-block:: bash
curl -X POST http://127.0.0.1:8080/vpcs \
-H 'Content-Type: application/json' \
-d '{"name": "vp-admin", "cidr": "192.168.0.0/16"}'
Le ``cidr`` est l'espace d'adressage global du VPC : c'est lui qui sera annoncé aux VM comme
route interne, quel que soit le mode du subnet.
La réponse est un **202** : la création est acceptée, pas terminée.
.. code-block:: bash
curl -s http://127.0.0.1:8080/vpcs/vp-admin
Attendez ``"state": "running"`` avant l'étape suivante — un subnet dont le VPC parent n'est pas
prêt est refusé en **422**. Le modèle d'attente est décrit dans :doc:`/exploitation/api-agent/asynchronisme`.
2. Le subnet
------------
.. code-block:: bash
curl -X POST http://127.0.0.1:8080/subnets \
-H 'Content-Type: application/json' \
-d '{"name": "sn-000001",
"vpc": "vp-admin",
"mode": "vxlan",
"vxlan_id": 1,
"iface_type": "vms",
"interface_ip": "10.1.1.1",
"cidr": "10.1.0.0/23"}'
``iface_type`` est une clé **logique** résolue dans la configuration de l'agent (section
``interfaces``) vers un bridge physique de l'host ; une clé inconnue retombe sur
``default_interface``. ``interface_ip`` est la gateway du subnet, portée par le bridge créé dans
le netns.
Les modes disponibles et leurs conséquences sur le routage sont détaillés dans
:doc:`/concepts/modes-reseau`.
Là encore, attendez ``running`` :
.. code-block:: bash
curl -s http://127.0.0.1:8080/subnets/sn-000001
3. La VM
--------
.. code-block:: bash
curl -X POST http://127.0.0.1:8080/vms \
-H 'Content-Type: application/json' \
-d '{"name": "i-web",
"memory": 2048,
"cpus": 2,
"uefi": true,
"metadata": {"sshkey": "ssh-ed25519 AAAA…",
"user_data": "'"$(base64 < user-data.yml | tr -d '\n')"'"},
"interfaces": [{"subnet": "sn-000001", "ip": "10.1.1.2", "primary": true}],
"storage": [{"path": "/var/lib/two/volumes/i-web.qcow2", "dev": "vda"}]}'
Quatre points qui coûtent du temps quand on les découvre en production :
``user_data`` est **encodé en base64**
Un base64 invalide est rejeté en 400 plutôt que servi vide. L'agent n'interprète jamais ce
contenu.
``password`` est un **hash**, pas un mot de passe
Le champ attend la valeur de la clé ``passwd`` de cloud-config (``$6$…``). Sans ``password``
ni ``sshkey``, aucun compte n'est créé.
Exactement une interface est ``primary``
Elle porte la route par défaut et le serveur de metadata. L'ordre du tableau détermine le
slot PCI (``0x03 + index``), donc le nom de l'interface dans le guest. Tous les subnets d'une
VM doivent appartenir au même VPC.
Un seul disque ``vdX``
Les disques supplémentaires passent par ``sdX``. La carte PCI en dépend — voir
:doc:`/architecture/contraintes`.
4. Vérifier
-----------
.. code-block:: bash
curl -s http://127.0.0.1:8080/vms/i-web
En ``running``, la VM est démarrée et le serveur de metadata est en place. Le provisionnement
cloud-init, lui, se déroule dans le guest ; on l'observe par la console série :
.. code-block:: bash
socat -,raw,echo=0 UNIX-CONNECT:/run/two/vms/serial/i-web.sock
Puis, depuis l'host :
.. code-block:: bash
ssh syonad@10.1.1.2
.. note::
En mode ``vxlan``, la VM vit dans le netns du VPC : elle n'est pas joignable depuis l'host
sans route explicite. En mode ``bridge``, elle l'est directement.
5. Supprimer
------------
Dans l'ordre inverse — un VPC dont il reste des subnets est refusé en **409** :
.. code-block:: bash
curl -X DELETE http://127.0.0.1:8080/vms/i-web
curl -X DELETE http://127.0.0.1:8080/subnets/sn-000001
curl -X DELETE http://127.0.0.1:8080/vpcs/vp-admin
La suppression d'une VM ne touche **jamais** aux fichiers disque.
.. warning::
``instance-id`` vaut le nom de la VM. Recréer une VM du même nom sur le même disque fait que
cloud-init la reconnaît et **n'applique pas** le user-data. Pour rejouer un provisionnement,
changez de nom ou repartez d'un disque neuf.

View file

@ -1,100 +0,0 @@
Architecture du cluster
=======================
Topologie
---------
.. figure:: /schemas/architecture-cluster.svg
:alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle
:align: center
:width: 100%
:class: only-light
Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle
(sessions BGP). Pointillé gris : interfaces créées par l'agent.
.. figure:: /schemas/architecture-cluster-dark.svg
:alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle
:align: center
:width: 100%
:class: only-dark
Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle
(sessions BGP). Pointillé gris : interfaces créées par l'agent.
Deux plans distincts, à ne pas confondre au moment du diagnostic :
Plan de données
les tunnels VXLAN entre hyperviseurs, encapsulés sur le réseau qui les relie.
Plan de contrôle
ce qui dit à chaque hyperviseur où se trouvent les adresses MAC des autres. C'est le rôle de
FRR et du route reflector.
Ce que l'agent suppose déjà en place
------------------------------------
L'agent ne configure **que** son propre hyperviseur, et seulement à partir du bridge d'uplink.
Tout ce qui est en amont — adressage des hyperviseurs, routage entre eux, plan de contrôle — lui
préexiste et n'est jamais créé ni vérifié par lui.
Concrètement, il attend :
* le bridge d'uplink de la configuration (``br-000000`` par défaut), avec l'interface physique
esclave et l'adresse de l'hyperviseur portée par le bridge — c'est ce que fait
``deploy.sh --bootstrap``, voir :doc:`/demarrage/installation` ;
* une connectivité IP entre hyperviseurs sur cette adresse, port UDP **4789** ouvert dans les
deux sens ;
* un plan de contrôle qui peuple la table de transfert VXLAN — voir ci-dessous.
Pourquoi un plan de contrôle est nécessaire
-------------------------------------------
L'agent crée les interfaces VXLAN sur le port 4789 **sans groupe multicast et avec
l'apprentissage désactivé** (``Learning: false``). Il n'y a donc ni inondation multicast, ni
apprentissage des adresses MAC depuis le trafic, ni voisin statique configuré.
.. important::
Conséquence directe : sur un même VNI, **rien ne traverse d'un hyperviseur à l'autre** tant
qu'un composant externe n'a pas peuplé la table de transfert (FDB) du VXLAN. Sur un nœud
isolé le trafic reste sur le bridge local et cette absence ne se voit pas ; elle apparaît dès
le deuxième nœud.
C'est exactement le rôle que remplissent FRR sur chaque hyperviseur et le route reflector qui
les fait converger.
MTU
---
.. warning::
L'agent crée bridges, veth et interfaces VXLAN avec un **MTU figé à 1500**. VXLAN ajoute 50
octets d'encapsulation : le réseau qui relie les hyperviseurs doit donc accepter au moins
**1550 octets** de MTU, sinon les paquets pleine taille des VM sont perdus.
Le symptôme est trompeur : le ping passe, les petites requêtes passent, les transferts
volumineux et les poignées de main TLS échouent.
Hyperviseurs sans état
----------------------
L'hyperviseur est **stateless** — sa racine est en tmpfs, rien de ce que pose
``deploy.sh --bootstrap`` ne survit à un redémarrage, et le script est rejoué à chaque démarrage.
Toute configuration ajoutée à un hyperviseur — FRR compris — doit donc être posée par un
mécanisme rejouable au démarrage, jamais par une modification manuelle d'un fichier sous
``/etc``.
Adressage
---------
.. note::
**À rédiger** — cette page ne décrit pas encore le plan d'adressage du cluster. À documenter :
* la plage utilisée pour les adresses d'hyperviseurs, et son rapport avec ``br-000000`` ;
* l'allocation des VNI VXLAN : qui la tient, et comment on évite les collisions, puisque
l'agent ne valide pas ``vxlan_id`` ;
* l'usage prévu de ``br-public``, créé vide et réservé par le bootstrap ;
* le plan d'adressage des VPC, et ce qui garantit qu'ils ne se recouvrent pas entre clients.

View file

@ -1,341 +0,0 @@
Construction de l'image qcow2
=============================
Toutes les VM du cluster — ``intel``, PostgreSQL, route reflector et les suivantes — partent
d'une même image qcow2 « golden », construite une fois puis réutilisée. Cette page décrit la
procédure en service.
.. important::
Cette image est un **artefact redistribuable** : tout ce qui s'y trouve se retrouve dans
chaque VM qui en dérive. Les étapes de nettoyage de la fin ne sont pas une commodité, ce sont
des exigences.
Principe
--------
La construction se fait dans une **VM jetable**, et non par montage de l'image sur l'host : le
chroot a besoin d'un noyau et d'un espace utilisateur cohérents avec la distribution cible, ce
que l'host ne fournit pas nécessairement.
Cette VM de construction démarre sur un overlay de l'image du fournisseur et voit deux disques
supplémentaires : le futur disque « golden », et un espace de travail.
.. mermaid::
graph LR
ISO["seed.iso<br/>cloud-init NoCloud"] --> BVM
OVL["&lt;os&gt;-tmp.qcow2<br/><i>overlay, jetable</i>"] --> BVM["VM de construction"]
BVM --> ROOT["&lt;os&gt;-root.qcow2<br/><b>image golden</b>"]
BVM --> WORK["tmp.qcow2<br/><i>espace de travail</i>"]
BASE["image du fournisseur<br/>(qcow2)"] -.backing file.-> OVL
.. list-table::
:header-rows: 1
:widths: 26 20 54
* - Disque
- Vu dans la VM
- Rôle
* - ``<os>-tmp.qcow2``
- ``vda`` (virtio-blk)
- système de la VM de construction ; overlay de l'image du fournisseur, jeté à la fin
* - ``<os>-root.qcow2``
- ``sda`` (SCSI)
- **le résultat** : l'image golden, écrite en brut depuis la VM
* - ``tmp.qcow2``
- ``sdb`` (SCSI)
- espace de travail : téléchargement et conversion
Variables
---------
.. code-block:: bash
export os=<nom_os>
export os_link=<url_du_qcow2_fournisseur>
export os_file=<nom_du_fichier_qcow2>
export os_dir=<repertoire_de_telechargement>
export disk_dir=<repertoire_des_disques>
Étape 1 — Le seed cloud-init de la VM de construction
------------------------------------------------------
Ce seed ne concerne **que la VM de construction**. Il n'a aucun rapport avec la configuration
cloud-init de l'image produite, qui est posée plus loin en chroot. Son seul rôle est de donner
un accès à la VM le temps du build.
.. code-block:: bash
mkdir -p "${os_dir}" && cd "${os_dir}"
mkdir -p /opt/seed/${os}
cat << 'ENDFILE' > /opt/seed/${os}/meta-data
instance-id: iid-local01
local-hostname: my-vm-01
ENDFILE
cat << 'ENDFILE' > /opt/seed/${os}/network-config
version: 2
renderer: networkd
ethernets:
eth0:
dhcp4: true
ENDFILE
cat << 'ENDFILE' > /opt/seed/${os}/user-data
#cloud-config
users:
- name: <utilisateur>
lock_passwd: false
passwd: "<hash du mot de passe>"
sudo: ALL=(ALL) NOPASSWD:ALL
ssh_authorized_keys:
- <clé publique ssh>
ENDFILE
mkisofs -o /opt/seed/${os}_seed.iso -V cidata -J -r /opt/seed/${os}/
Le label de volume ``cidata`` n'est pas décoratif : c'est ce qui fait reconnaître l'ISO comme une
source NoCloud par cloud-init.
.. warning::
``passwd`` attend un **hash**, et ``ssh_authorized_keys`` une clé publique personnelle : ces
deux valeurs sont des données à ne pas recopier hors de l'host de construction. Elles ne
figurent volontairement pas dans cette documentation.
``openssl passwd -5`` pour generer un hash
Étape 2 — Les disques
---------------------
.. code-block:: bash
curl "${os_link}" -O
qemu-img create -f qcow2 "${disk_dir}/${os}-root.qcow2" 10G
qemu-img create -f qcow2 "${disk_dir}/tmp.qcow2" 50G
qemu-img create -f qcow2 -b "${os_dir}/${os_file}" -F qcow2 "${disk_dir}/${os}-tmp.qcow2" 10G
.. important::
``-F qcow2`` est **obligatoire** sur qemu récent : sans lui, le format du backing file n'est
pas figé dans l'en-tête de l'overlay.
La taille de ``<os>-root.qcow2`` (10 Gio ici) borne l'image produite : elle doit être au moins
égale à la taille **virtuelle** de l'image du fournisseur, pas à la taille de son fichier.
Étape 3 — Lancer la VM de construction
--------------------------------------
.. code-block:: bash
qemu-system-x86_64 \
-enable-kvm \
-cpu host \
-m 2048 \
-smp 2 \
-nographic \
-serial mon:stdio \
-monitor unix:/tmp/vm-build.mon-sock,server,nowait \
-drive file=/opt/seed/${os}_seed.iso,media=cdrom,if=ide \
\
-drive file=${disk_dir}/${os}-tmp.qcow2,format=qcow2,if=none,id=vda \
-device virtio-blk-pci,drive=vda,bootindex=0 \
\
-device virtio-scsi-pci,id=scsi0 \
\
-drive file=${disk_dir}/${os}-root.qcow2,if=none,id=hd0 \
-device scsi-hd,drive=hd0,bus=scsi0.0 \
\
-drive file=${disk_dir}/tmp.qcow2,if=none,id=hd1 \
-device scsi-hd,drive=hd1,bus=scsi0.0 \
\
-netdev tap,id=net0,ifname=tap0,script=no,downscript=no \
-device virtio-net-pci,netdev=net0,mac=00:22:33:00:00:01
La répartition virtio-blk pour le système / SCSI pour les disques supplémentaires est la même que
celle qu'impose l'agent — voir :doc:`/architecture/contraintes`. Le tap ``tap0`` doit exister et
être raccordé à un réseau qui donne un accès sortant : la suite télécharge l'image du
fournisseur depuis la VM.
Étape 4 — Écrire l'image du fournisseur sur le disque cible
------------------------------------------------------------
Les commandes suivantes s'exécutent **dans la VM de construction**. Identifier d'abord les
disques : le disque de travail et le disque cible ne doivent pas être confondus.
.. danger::
``qemu-img convert`` écrase intégralement le disque cible. Vérifier les noms avant, avec
``lsblk``, plutôt que de supposer l'ordre d'énumération.
.. code-block:: bash
work_disk=/dev/sdb
os_disk=/dev/sda
mkdir /work
mkfs.xfs ${work_disk}
mount ${work_disk} /work
cd /work
curl "${os_link}" -O
qemu-img convert ./*.qcow2 -O raw "${os_disk}"
L'image du fournisseur est écrite **en brut** directement sur le disque cible : le qcow2 obtenu
côté host contient donc une image disque complète et amorçable, sans backing file.
.. code-block:: bash
partprobe
echo 1 > /sys/block/sda/device/rescan
sleep 2
# La partition racine est la plus grande du disque
root_partition=$(fdisk -lo device,size "${os_disk}" | grep -E '^/dev/' | tr -s ' ' \
| sort -rhk2 | head -n1 | cut -d ' ' -f1)
mount -o nouuid $root_partition /mnt
mount -o bind /dev /mnt/dev
mount -o bind /proc /mnt/proc
mount -o bind /sys /mnt/sys
cp /etc/resolv.conf /mnt/etc/resolv.conf
``-o nouuid`` est nécessaire parce que le système de fichiers qui vient d'être écrit porte le
même UUID que celui déjà monté par la VM de construction. Le ``resolv.conf`` est copié pour que
les commandes en chroot aient la résolution DNS ; il est supprimé au nettoyage.
Étape 5 — Personnaliser l'image
-------------------------------
**Accès SSH**
.. code-block:: bash
yum install -y augeas
echo "The default user for Syonad VMs is 'syonad'." > /mnt/etc/banner
augtool -r /mnt -s <<'EOF'
set /files/etc/ssh/sshd_config/X11Forwarding no
set /files/etc/ssh/sshd_config/PermitTunnel no
set /files/etc/ssh/sshd_config/PermitRootLogin no
set /files/etc/ssh/sshd_config/RSAAuthentication yes
set /files/etc/ssh/sshd_config/PubkeyAuthentication yes
set /files/etc/ssh/sshd_config/PasswordAuthentication no
set /files/etc/ssh/sshd_config/UseDNS no
set /files/etc/ssh/sshd_config/ChallengeResponseAuthentication no
set /files/etc/ssh/sshd_config/GSSAPIAuthentication no
set /files/etc/ssh/sshd_config/Match[1]/Condition/User "root,centos,ubuntu,debian,ec2-user"
set /files/etc/ssh/sshd_config/Match[1]/Settings/Banner "/etc/banner"
EOF
``PasswordAuthentication no`` vaut pour toutes les VM dérivées : l'accès se fait par clé, et le
champ ``password`` de l'API de l'agent ne sert donc **pas** à ouvrir une session SSH.
**Utilisateur par défaut et source de metadata**
.. code-block:: bash
cat << 'ENDFILE' > /mnt/etc/cloud/cloud.cfg.d/20_user.cfg
system_info:
default_user:
name: syonad
ENDFILE
cat << 'ENDFILE' > /mnt/etc/cloud/cloud.cfg.d/99_metadata.cfg
datasource_list: [ NoCloud ]
datasource:
NoCloud:
seedfrom: 'http://169.254.169.254:80/'
timeout: 5
max_wait: 10
ENDFILE
C'est ce second fichier qui raccorde l'image au serveur de metadata de l'agent : ``NoCloud`` est
la seule source retenue, et elle pointe sur ``169.254.169.254``. La route ``/32`` vers cette
adresse est indispensable côté agent — voir :doc:`/concepts/modes-reseau`.
**Services et durcissement**
.. code-block:: bash
chroot /mnt/ systemctl enable fstrim.timer
chroot /mnt/ systemctl disable rpcbind.service
chroot /mnt/ systemctl disable rpcbind.socket
augtool -r /mnt -s set /files/etc/selinux/config/SELINUX disabled
chroot /mnt/ dnf remove -y 'cockpit*'
chroot /mnt/ rm -rf /run/cockpit
Étape 6 — Nettoyer, puis éteindre
---------------------------------
.. code-block:: bash
rm -f /mnt/etc/resolv.conf
rm -rf /mnt/var/cache/yum
rm -rf /mnt/root/.ssh
rm -rf /mnt/root/.bash_history
rm -rf /mnt/tmp/*
rm -rf /mnt/var/lib/dhcp/*
rm -rf /mnt/var/tmp/*
find /mnt/var/log ! -type d -exec rm '{}' \;
rm -rf /mnt/var/lib/cloud/*
poweroff
``/mnt/var/lib/cloud/*`` est le nettoyage le plus important : c'est lui qui fait que cloud-init
considère chaque VM dérivée comme une instance neuve. Sans lui, l'image embarque l'identité de
l'instance de construction et le user-data n'est pas appliqué — même mécanisme que la
recréation d'une VM sous un nom déjà utilisé, cf. :doc:`/concepts/metadata-cloud-init`.
Une fois la VM éteinte, ``${disk_dir}/${os}-root.qcow2`` est l'image golden.
``${os}-tmp.qcow2`` et ``tmp.qcow2`` sont jetables.
Points de vigilance
-------------------
.. warning::
**SELinux est désactivé** dans l'image. C'est une couche de protection en moins sur toutes les
VM qui en dérivent, y compris celles qui portent des fonctions sensibles comme le route
reflector ou la base de données. Décision à assumer explicitement, et à réévaluer : le mode
``permissive`` permettrait au minimum de savoir ce qui serait bloqué.
.. note::
``fstrim.timer`` est activé dans l'image, mais l'agent lance QEMU **sans** ``discard=unmap``
ni ``detect-zeroes=unmap`` sur les disques. Le ``fstrim`` du guest ne rend donc aujourd'hui
aucun espace à l'host : les qcow2 ne se rétractent pas. L'activation reste utile pour le jour
où l'option sera ajoutée côté agent, mais ne pas compter dessus pour la place disque.
.. warning::
**La barre oblique finale de** ``seedfrom`` **est indispensable avant cloud-init 23.1.**
cloud-init construit l'URL des documents en concaténant ``seedfrom`` avec ``meta-data``,
``user-data`` et ``vendor-data`` (``util.read_seeded``). Jusqu'à la 22.4 — celle de Debian 12,
22.4.2 —, sans barre oblique finale il demande ``http://169.254.169.254:80meta-data`` : la
source échoue, la VM démarre en ``DataSourceNone``, sans nom d'hôte ni user-data. À partir de
23.1, un drapeau actif par défaut (``NOCLOUD_SEED_URL_APPEND_FORWARD_SLASH``) ajoute la barre
oblique manquante, ce qui explique qu'une image récente fonctionne sans elle. Avec la barre
oblique, la configuration fonctionne quelle que soit la version de cloud-init.
.. note::
**Provenance de l'image du fournisseur** — tout ce qui est construit en hérite. Vérifier la
somme de contrôle, et la signature quand elle existe, avant de construire dessus. La
procédure actuelle télécharge l'image deux fois, une fois sur l'host et une fois dans la VM,
sans vérification.
.. note::
**À rédiger** — la procédure ne dit pas encore comment l'image produite est nommée, versionnée
et distribuée aux hyperviseurs, ni quelles variantes existent par rôle (``intel``, PostgreSQL,
route reflector) : image unique personnalisée au démarrage par cloud-init, ou images
dérivées ?

View file

@ -1,32 +0,0 @@
Déploiement d'un cluster
========================
Le :doc:`/demarrage/index` couvre un hyperviseur isolé : un VPC, un subnet, une VM, tout sur le
même nœud. Cette section couvre la mise en place d'un **cluster** complet, dans l'ordre où les
étapes se font.
Cet ordre n'est pas indifférent : chaque étape a besoin de la précédente. L'image doit exister
avant qu'on puisse démarrer quoi que ce soit ; le réseau doit être en place avant le premier
hyperviseur ; le route reflector est lui-même une VM, il lui faut donc un hyperviseur qui
fonctionne.
Étapes
------
#. :doc:`architecture-cluster` — la topologie cible, à lire avant tout le reste
#. :doc:`image-qcow2` — l'image golden dont dérivent toutes les VM du cluster
#. :doc:`routeurs` — le matériel : routeurs de cluster, de datacentre et de bordure
#. :doc:`premier-hyperviseur` — le premier nœud, agent et plan de contrôle
#. :doc:`route-reflector` — les VM route reflector
D'autres étapes viendront à mesure que les composants d'orchestration seront livrés.
.. toctree::
:hidden:
:maxdepth: 1
architecture-cluster
image-qcow2
routeurs
premier-hyperviseur
route-reflector

View file

@ -1,102 +0,0 @@
Premier hyperviseur
===================
Le premier nœud du cluster se déploie comme les suivants, mais il est le seul à devoir
fonctionner **avant** que le plan de contrôle existe : c'est lui qui hébergera la première VM
route reflector.
Installation
------------
L'installation de l'agent est identique à celle d'un nœud isolé et n'est pas reprise ici :
voir :doc:`/demarrage/installation` pour ``deploy.sh``, ses options, la préparation de l'host et
la migration réseau vers le bridge d'uplink.
Deux points à relire avant de lancer un ``-i`` sur un nœud de production : la migration réseau
coupe le réseau de l'host si elle échoue à mi-parcours, et l'hyperviseur est **sans état** —
tout ce qui est posé doit l'être par un mécanisme rejoué à chaque démarrage.
Plan de contrôle — FRR
----------------------
FRR tourne sur chaque hyperviseur et peuple la table de transfert (FDB) des interfaces VXLAN
créées par l'agent. C'est ce qui rend un subnet utilisable au-delà d'un seul nœud, puisque
l'agent désactive l'apprentissage et ne configure aucun voisin — cf.
:doc:`architecture-cluster`.
Configuration de FRR
~~~~~~~~~~~~~~~~~~~~
Seul ``bgpd`` est activé dans ``/etc/frr/daemons``. D'un hyperviseur à l'autre ne changent que
``hostname`` et ``router-id``, l'adresse de l'hyperviseur sur son segment.
.. literalinclude:: ../../test/e2e/topologies/frr/hv1.conf
:language: text
Ce qu'elle établit :
* **Une seule session**, en iBGP dans l'AS 64600, vers la loopback du route reflector
(``10.255.255.1``), jointe par la passerelle par défaut — le routeur de cluster l'a apprise du
route reflector (:doc:`routeurs`). Aucune adresse d'un autre hyperviseur n'est écrite : ajouter
un nœud ne touche pas la configuration des autres.
* **EVPN seulement** : l'IPv4 unicast n'est pas activé (``no bgp default ipv4-unicast``).
``advertise-all-vni`` annonce les VNI de toutes les interfaces VXLAN que FRR voit, et donc
celles que l'agent crée.
* **Pas de BFD** sur cette session, contrairement à la session entre route reflector et routeur.
Articulation avec l'agent
~~~~~~~~~~~~~~~~~~~~~~~~~
Rien à configurer à la création d'un subnet : la VXLAN apparaît, FRR la voit et l'annonce, sans
action ni redémarrage. Il faut en revanche que la VXLAN porte une **adresse VTEP locale** : sans
elle, FRR voit la VNI mais n'annonce rien, et deux hyperviseurs gérés par two ne se joignent pas.
L'agent la pose à partir de la release ``0.2.0rc003``
(`#51 <https://git.g3e.fr/syonad/two/issues/51>`_) : l'adresse IPv4 primaire du ``local_iface``
du subnet.
.. note::
**À rédiger.** Reste à documenter :
* la version de FRR de référence et son installation sur l'hyperviseur **sans état** : paquet
et configuration doivent être posés à chaque démarrage, par le bootstrap ou un mécanisme
équivalent (`#52 <https://git.g3e.fr/syonad/two/issues/52>`_) ;
* le démarrage à froid, selon que FRR démarre avant ou après l'agent ;
* les subnets ``vxlan`` créés avant ``0.2.0rc003``, dont la VXLAN n'a pas d'adresse VTEP
locale : les recréer ou poser l'adresse à chaud, à trancher dans
`#51 <https://git.g3e.fr/syonad/two/issues/51>`_ ;
* le cas particulier du **premier** hyperviseur, dont la session ne peut pas s'établir tant
que le route reflector n'existe pas.
Vérifier le plan de données
---------------------------
Ces deux vérifications restent valables quelle que soit la configuration retenue, et méritent
d'être dans toute procédure de diagnostic :
.. code-block:: bash
# La FDB du VXLAN doit contenir des entrées vers les autres hyperviseurs.
# Vide, c'est le plan de contrôle qui ne fonctionne pas, pas l'agent.
ip netns exec <vpc> bridge fdb show dev <interface-vxlan>
# L'interface VXLAN telle que l'agent l'a créée : port 4789, learning off,
# aucun groupe multicast, aucun remote.
ip netns exec <vpc> ip -d link show <interface-vxlan>
.. important::
Une FDB vide alors que le subnet est en ``running`` n'est **pas** un défaut de l'agent : il
crée délibérément l'interface sans apprentissage ni voisin, et laisse le peuplement au plan
de contrôle.
Valider le nœud
---------------
Avant de passer à la suite, le nœud doit savoir créer une VM de bout en bout à partir de l'image
golden — c'est exactement le parcours de :doc:`/demarrage/premier-vpc`, avec
``storage[0].path`` pointant sur une copie de l'image produite par :doc:`image-qcow2`.
Une VM qui démarre, obtient son adresse en DHCP et applique son user-data valide d'un coup
l'agent, le DHCP, la route vers le serveur de metadata et l'image. C'est le prérequis de
:doc:`route-reflector`.

View file

@ -1,113 +0,0 @@
VM route reflector
==================
Le route reflector est le point de rendez-vous du plan de contrôle : plutôt que de maintenir une
session entre chaque paire d'hyperviseurs, chaque hyperviseur ouvre une session vers le route
reflector, qui redistribue.
Il tourne lui-même en machine virtuelle, ce qui crée une dépendance circulaire à traiter
explicitement : la VM qui porte le plan de contrôle du cluster est hébergée par le cluster.
Adressage
---------
Le route reflector est **sur le même segment L2 que les hyperviseurs**, avec trois adresses :
.. list-table::
:header-rows: 1
:widths: 30 25 45
* - Adresse
- Interface
- Rôle
* - une adresse de ``192.168.14.0/24``
- interface principale
- celle de n'importe quelle machine du segment ; les réponses aux hyperviseurs en partent
* - ``169.254.0.3/28``
- interface principale, **adresse secondaire**
- le lien avec les routeurs de cluster (``169.254.0.1`` et ``.2``), sur le même L2
* - ``10.255.255.1/32``
- ``lo1``, interface ``dummy``
- ``router-id``, ``cluster-id`` et source des sessions EVPN ; seule route annoncée aux routeurs
Les hyperviseurs ne connaissent que la loopback : ils ouvrent leur session vers ``10.255.255.1``
par leur passerelle (``192.168.14.1``), les routeurs l'ayant apprise du route reflector en eBGP.
Aucune adresse d'hyperviseur n'est déclarée côté route reflector : il accepte toute session venant
du segment (``bgp listen range``).
L'adresse du lien est une **adresse secondaire** de l'interface principale, pas une interface à
part : même L2, même MAC sur le fil, une interface de moins qu'avec un ``ipvlan``. Elle doit
survivre aux renouvellements DHCP de l'adresse principale : la déclarer dans la configuration
réseau du système, pas la poser à la main.
.. note::
**Non vérifié sur l'image de production.** Avec NetworkManager, la forme attendue est :
.. code-block:: bash
nmcli connection modify <connexion> +ipv4.addresses 169.254.0.3/28
nmcli connection add type dummy ifname lo1 con-name lo1 \
ipv4.method manual ipv4.addresses 10.255.255.1/32 ipv6.method disabled
Ces commandes restent à valider sur l'image golden.
Configuration de FRR
--------------------
``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``.
.. literalinclude:: ../../test/e2e/topologies/frr/rr1.conf
:language: text
Ce qu'elle établit :
* **Routeurs de cluster** (groupe ``CLUSTER``) : eBGP vers l'AS 65100, avec BFD, en IPv4
unicast. Le route reflector n'annonce que sa loopback (``RR-LOOPBACK-OUT``) et **n'accepte
rien** (``NO-IN``) : il ne reçoit aucune route des routeurs.
* **Hyperviseurs** (groupe ``fabric``) : voisins dynamiques, toute session venant de
``192.168.14.0/24`` étant acceptée, jusqu'à 200. ``local-as 64600 no-prepend replace-as`` fait
que, du point de vue des hyperviseurs, la session est en iBGP dans l'AS 64600 — celui de leur
configuration — alors que le route reflector est en AS 65000 face aux routeurs.
* **EVPN** : seule famille activée vers les hyperviseurs, qui sont ses clients
(``route-reflector-client``) : il réfléchit les routes EVPN de chacun vers tous les autres.
.. warning::
**Les tunnels ne survivent pas à la perte du route reflector.** Avec un seul route reflector
et sans ``graceful-restart``, à l'arrêt de FRR sur le route reflector, la session EVPN des
hyperviseurs tombe aussitôt, FRR retire les routes apprises et, avec elles, le VTEP distant et l'entrée d'inondation du VXLAN — **plus
aucun paquet ne passe** entre hyperviseurs, à 30 s comme à 90 s. Au redémarrage de FRR sur le
route reflector, VTEP distant et trafic reviennent **31 s** plus tard. Le trafic entre VM d'un
même hyperviseur n'est pas concerné. La redondance (deux route reflectors) ou
``graceful-restart`` sont les deux leviers ; ni l'un ni l'autre n'est encore qualifié.
.. note::
**À rédiger.** Reste à documenter :
* la création de la VM : ressources, subnet et mode utilisés (``bridge``, pour pouvoir la
lancer sur n'importe quel hyperviseur), et s'il s'agit d'une VM créée par l'agent comme les
autres ou d'un cas particulier — l'image, elle, est l'image golden de :doc:`image-qcow2` ;
* la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ;
* la procédure de reconstruction — l'état du cluster pendant l'absence du route reflector est
décrit ci-dessus : plus de trafic entre hyperviseurs ;
* la procédure d'amorçage : ce qui fonctionne, et dans quel ordre, quand on démarre un cluster
entier depuis zéro — le premier hyperviseur n'a pas de session FRR établie tant que cette VM
n'existe pas, cf. :doc:`premier-hyperviseur`.
Points de vigilance
-------------------
.. warning::
L'agent **ne réattache pas** les VM existantes à son démarrage, et les processus QEMU ne
survivent pas à un redémarrage de l'hyperviseur. Le redémarrage de l'hyperviseur qui héberge
le route reflector est donc un événement à part entière : la procédure de remise en service
doit être écrite, et testée.
.. warning::
``instance-id`` valant le nom de la VM, recréer la VM route reflector sous le même nom sur le
même disque fait que cloud-init **ne rejoue pas** le user-data. Cf.
:doc:`/concepts/metadata-cloud-init`.

View file

@ -1,101 +0,0 @@
Routeurs
========
Trois niveaux de routage entourent le cluster, du plus proche des hyperviseurs au plus proche de
l'extérieur. Tous préexistent à l'agent : celui-ci ne les configure pas et n'en a aucune
connaissance.
Routeurs de cluster
raccordent les hyperviseurs entre eux. C'est le niveau dont dépend directement le plan de
données VXLAN.
Routeurs de datacentre
agrègent les clusters d'un même site.
Routeurs de bordure
terminent le routage vers l'extérieur.
Routeur de cluster
------------------
Le routeur de cluster est la passerelle des hyperviseurs et le voisin eBGP du route reflector, dont
il apprend la loopback : c'est par lui que chaque hyperviseur joint ``10.255.255.1`` pour ouvrir sa
session EVPN (:doc:`route-reflector`).
Adressage
~~~~~~~~~
.. list-table::
:header-rows: 1
:widths: 30 25 45
* - Adresse
- Interface
- Rôle
* - ``192.168.14.1/24``
- interface du segment des hyperviseurs
- passerelle par défaut des hyperviseurs et du route reflector
* - ``169.254.0.1/28``
- même interface, **adresse secondaire**
- lien avec le route reflector (``169.254.0.3``) ; ``router-id`` ; le second routeur prend
``169.254.0.2``
Le MTU minimal du segment, imposé par VXLAN, est donné plus bas.
Configuration de FRR
~~~~~~~~~~~~~~~~~~~~
``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``.
.. literalinclude:: ../../test/e2e/topologies/frr/sw1.conf
:language: text
Ce qu'elle établit :
* **Route reflector** : eBGP de l'AS 65100 vers l'AS 65000, avec BFD, en IPv4 unicast.
* **En entrée**, seule la loopback du route reflector est acceptée (``RR-IN``) ; **en sortie**,
rien n'est annoncé (``NO-OUT``). Symétrique de ``RR-LOOPBACK-OUT`` et ``NO-IN`` côté route
reflector : chaque côté filtre, aucun ne dépend du filtre de l'autre.
* **Aucune session avec les hyperviseurs** : le routeur leur sert de passerelle, pas de voisin BGP.
Le route reflector déclare aussi le second routeur (``169.254.0.2``). La redondance est l'objet de
`#54 <https://git.g3e.fr/syonad/two/issues/54>`_.
.. note::
Ce qui précède est le contrat du routeur de cluster : adresses, ASN, session, filtres. Sa
traduction dans la configuration d'un constructeur n'a pas sa place ici.
.. note::
**À rédiger.** Reste à documenter, faute d'éléments de terrain :
* pour le routeur de cluster : le matériel employé et sa version de référence ; la redondance
— deux routeurs, mécanisme de bascule, comportement attendu pendant une bascule
(`#54 <https://git.g3e.fr/syonad/two/issues/54>`_) ;
* pour les routeurs de datacentre et de bordure : tout — équipement, configuration de
référence, ce qui est annoncé et filtré à chaque niveau, redondance ;
* l'ordre de mise en service, et ce qui doit être opérationnel avant de préparer le premier
hyperviseur.
Contraintes imposées par le reste du cluster
---------------------------------------------
Indépendamment des choix d'équipement, deux contraintes viennent de ce que fait l'agent.
.. warning::
**MTU** — l'agent crée bridges, veth et interfaces VXLAN avec un MTU figé à 1500, et VXLAN
ajoute 50 octets d'encapsulation. Les liens entre hyperviseurs doivent donc accepter au moins
**1550 octets**. Cf. :doc:`architecture-cluster`.
.. warning::
**UDP 4789** doit passer entre hyperviseurs, dans les deux sens : c'est le port des tunnels
VXLAN.
.. warning::
**L'API de l'agent n'a aucune authentification.** Le filtrage réalisé ici est aujourd'hui
l'une des rares barrières entre cette API et le reste du réseau : son port ne doit être
joignable que depuis le réseau d'administration. Cf. :doc:`/exploitation/configuration`.

View file

@ -1,10 +0,0 @@
Développement
=============
Outillage réservé au développement de two : il ne s'installe sur aucun hyperviseur et ne sert
pas à exploiter un cluster.
.. toctree::
:maxdepth: 1
lab

View file

@ -1,899 +0,0 @@
Lab de test multi-nœud
======================
Ce qui fait l'intérêt de two ne se voit qu'à partir de **deux hyperviseurs** : sur un nœud isolé,
le trafic reste sur le bridge local et l'absence de plan de contrôle passe inaperçue (voir
:doc:`/deploiement/architecture-cluster`). Le lab reproduit la topologie du cluster —
hyperviseurs, route reflector, switch L3 — sous forme de VM, sur un serveur physique loué à
l'heure.
Le pourquoi des choix (serveur physique plutôt que VM cloud, câbles QEMU, MTU 9000, versions) est
consigné sur le ticket `#50 <https://git.g3e.fr/syonad/two/issues/50>`_. Cette page décrit
comment s'en servir.
.. note::
État actuel : le lab de #50 est livré — serveur loué à l'heure (``scripts/lab-host.sh``),
topologie déclarative et plan déterministe (``lab plan``), VM rendues et lancées sur le
serveur (``lab render``, ``lab up`` / ``status`` / ``down`` / ``ssh``), rôles installés au
démarrage (FRR sur le switch et le route reflector, two par ``deploy.sh`` sur les
hyperviseurs) et scénarios versionnés (``test/e2e/run.sh``). La redondance du plan de
contrôle — deux route reflectors, deux switchs — est l'objet de
`#54 <https://git.g3e.fr/syonad/two/issues/54>`_.
Le serveur de lab
-----------------
Un serveur **Scaleway Elastic Metal**, créé pour une campagne de tests puis supprimé.
.. list-table::
:widths: 25 75
* - Offre
- ``EM-B212X-SSD``, zone ``fr-par-1``, **facturation horaire** : 0,321 € HT de l'heure, sans
frais de mise en service
* - Matériel
- 2 × Xeon E5-2620 v4 *or equivalent*, 256 Go, 2 × 1 To SSD
* - Système
- Debian 12, installé par Scaleway à la création
* - Pourquoi Intel
- lab3 est en Intel : two lance ses VM en ``-cpu host``, et KVM a deux implémentations
distinctes (``kvm_intel``, ``kvm_amd``)
*Or equivalent* n'est pas une clause de style : le premier serveur livré était un
**Xeon E5-2640 v3** (Haswell, la génération de lab3), pas le E5-2620 v4 annoncé. Relever
``lscpu`` au début de chaque campagne.
Prérequis côté Scaleway
-----------------------
#. **Un projet dédié au lab**, séparé de toute autre ressource. ``lab-host.sh down`` supprime
tout serveur de lab du projet : il ne doit rien y avoir d'autre.
#. **Une clé d'API limitée à ce projet**, avec les droits Elastic Metal et la lecture des clés SSH
du projet. Rien d'autre.
#. **Les clés SSH publiques enregistrées dans le projet**, injectées à l'installation : sans elle,
le serveur serait facturé sans que personne puisse s'y connecter, et ``plan`` refuse de
continuer.
#. **Le quota Elastic Metal.** L'``EM-B212X-SSD`` exige un compte dont le moyen de paiement *et*
l'identité sont validés ; le quota est alors de 2. Vérifier dans la console : Organisation →
Quotas → Elastic Metal. Voir `les quotas Scaleway
<https://www.scaleway.com/en/docs/organizations-and-projects/organization/organization-quotas/>`_.
Fichiers locaux
---------------
Tout ce dont le script a besoin vit sous ``~/.config/two-lab/``, hors du dépôt.
``~/.config/two-lab/scaleway.env``
Identifiants Scaleway, une ligne ``CLÉ=valeur`` chacun :
.. code-block:: bash
SCW_SECRET_KEY=<clé secrète>
SCW_DEFAULT_PROJECT_ID=<identifiant du projet de lab>
SCW_DEFAULT_ZONE=fr-par-1
* le fichier doit être en ``0600`` : le script refuse de s'en servir s'il est lisible par
d'autres que son propriétaire ;
* il est **lu, jamais exécuté** — pas de ``source`` ; seules ces trois clés sont reconnues ;
* une variable d'environnement du même nom l'emporte sur le fichier ;
* la clé d'accès (``SCW…``) n'est pas nécessaire : l'API REST n'authentifie que par la clé
secrète, dans l'en-tête ``X-Auth-Token``.
Pour changer de clé, remplacer la ligne ``SCW_SECRET_KEY=`` ; rien d'autre à modifier.
``~/.config/two-lab/ssh/lab_ed25519``
Clé SSH dédiée au lab, **sans phrase de passe**, pour que les sessions tournent sans
intervention. Sa partie publique doit être enregistrée dans le projet. Quand elle existe, le
script l'utilise **seule** (``IdentitiesOnly``, agent désactivé) ; sinon il retombe sur
l'agent SSH.
Elle ne doit ouvrir que les serveurs éphémères du projet de lab : **ne jamais l'installer sur
lab3 ni sur une machine durable**. En cas de doute, la retirer du projet et en générer une
autre :
.. code-block:: bash
mkdir -p ~/.config/two-lab/ssh && chmod 700 ~/.config/two-lab ~/.config/two-lab/ssh
ssh-keygen -t ed25519 -N '' -C two-lab-automation -f ~/.config/two-lab/ssh/lab_ed25519
``~/.cache/two-lab/``
État de la session en cours : adresse et utilisateur du serveur, ``known_hosts`` dédié. Vidé
par ``down``.
Commandes
---------
.. code-block:: text
usage: lab-host.sh <commande> [arguments]
plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création
et le prix ; ne crée rien
up crée le serveur de lab, attend la fin de son installation et son SSH,
puis le prépare (voir prepare)
status liste les serveurs de lab du projet
ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé
que si l'entrée standard en est un
prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie
/dev/kvm et la virtualisation imbriquée ; lancé aussi par up
push <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le
répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle
référence) ; ensuite : ssh './lab up topology/<topologie>'
down supprime tous les serveurs de lab du projet et attend leur disparition
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
``plan`` et ``status`` sont **gratuits** ; ``up`` et ``session`` **créent un serveur facturé**.
Toujours commencer par ``plan``. Il valide la clé d'API, le quota d'offre, l'OS et les clés SSH,
et montre exactement ce qui serait commandé :
.. code-block:: text
$ scripts/lab-host.sh plan
== offre : EM-B212X-SSD (ddaf8ba6-b2b2-4279-8af3-51930fb602f8), facturation hourly, stock available
== prix : 0.321 EUR HT par heure, frais de mise en service 0 EUR
== os : Debian 12 (Bookworm) (83640d93-a0b8-45ad-9c9f-30cae48380a4), utilisateur root
== clés : 2 clé(s) SSH du projet
== requête : POST /baremetal/v1/zones/fr-par-1/servers
``session`` est la forme normale d'usage : le serveur est supprimé à la fin, que la commande
réussisse, échoue, ou que la session soit interrompue (Ctrl-C, ``TERM``, fermeture du terminal).
.. code-block:: text
$ scripts/lab-host.sh session 'uname -a; lscpu | grep -E "Model name|^CPU\(s\)|Virtualization"; free -g | head -2; echo "nested=$(cat /sys/module/kvm_intel/parameters/nested)"; ls -l /dev/kvm'
== création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT)
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 créé, facturé jusqu'à 'lab-host.sh down'
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ordered, installation to_install
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation installing
…
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation completed
== SSH pas encore joignable, nouvel essai dans 20s
…
== prêt : root@<adresse>
Linux two-lab 6.1.0-53-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.187-1 (2026-09-07) x86_64 GNU/Linux
CPU(s): 32
Model name: Intel(R) Xeon(R) CPU E5-2640 v3 @ 2.60GHz
…
Virtualization: VT-x
total used free shared buff/cache available
Mem: 251 1 250 0 0 249
nested=Y
crw-rw---- 1 root kvm 10, 232 Oct 3 17:23 /dev/kvm
== session terminée (code 0), suppression du serveur
== suppression de 2ecc1e6a-0de8-48c0-a198-93857eee5957
== aucun serveur de lab ne reste dans le projet
Extrait de la première campagne (lignes répétées remplacées par ``…``). Compter **environ 15 minutes** entre la création et le SSH disponible : 13 min 30 à la première
campagne, suppression comprise. SSH ne répond pas tout de suite après la fin de l'installation —
environ 100 secondes la première fois — d'où l'attente intégrée à ``up``. Le code de sortie de
``session`` est celui de la commande distante.
``up``, ``ssh`` et ``down`` séparément servent au debug interactif — et laissent la suppression
à la charge de l'utilisateur.
Une campagne sur le lab enchaîne ces commandes depuis le Mac ; ``lab`` s'exécute sur le serveur
(voir `Lancement des VM`_) :
.. code-block:: text
scripts/lab-host.sh up
scripts/lab-host.sh push test/e2e/topologies/evpn-2hv.yml
scripts/lab-host.sh ssh './lab up topology/evpn-2hv.yml'
scripts/lab-host.sh ssh './lab ssh hv1' # shell interactif sur hv1
scripts/lab-host.sh ssh './lab ssh hv1 ip -br a' # commande, code de retour propagé
scripts/lab-host.sh down
``push`` transfère par la connexion SSH du script — mêmes options, même clé, même
``known_hosts`` que ``ssh`` : le binaire par ``cat``, le répertoire de la topologie par ``tar``
(sans les métadonnées macOS), chacun renommé une fois complet. Tout le répertoire part, pour que
les fichiers que la topologie référence (``frr/*.conf``) arrivent avec elle.
Topologie
---------
Un lab est décrit par un fichier YAML : des **nœuds** (les VM) et des **segments** (des réseaux L2
portés par un switch). Exemple livré, ``test/e2e/topologies/evpn-2hv.yml`` :
.. literalinclude:: ../../test/e2e/topologies/evpn-2hv.yml
:language: yaml
Chaque nœud non-switch est relié au switch de chacun de ses segments par un câble virtuel QEMU ;
le switch met ces câbles dans un bridge et porte la passerelle du segment.
Ce que le fichier déclare :
``images``
``url`` de l'image qcow2 et ``sums`` du fichier de sommes à vérifier, tous deux en ``https://``.
``segments``
``switch`` (un nœud de rôle ``switch``), ``cidr`` IPv4 entre ``/8`` et ``/30``, ``mtu``
facultatif — 9000 par défaut, entre 1280 et 9000. Nom : 12 caractères au plus, minuscules et
chiffres, parce qu'il devient le nom d'interface dans les VM et, préfixé de ``br-``, celui du
bridge (15 caractères au plus sous Linux).
``nodes``
``role`` (``switch``, ``rr`` ou ``hypervisor``), ``image``, ``cpus``, ``memory`` en Mio
(256 au moins), ``segments`` auxquels le nœud est relié, et ``addresses`` pour fixer
l'adresse d'un nœud sur un segment (``addresses: {underlay: 192.168.14.50}``). Un switch ne
déclare ni ``segments`` ni ``addresses`` : il porte ceux dont il est le ``switch``.
Champs de rôle, facultatifs :
* ``secondary`` — des adresses supplémentaires par segment, avec leur longueur de préfixe
(``secondary: {underlay: [169.254.0.3/28]}``), posées sur la même interface que l'adresse
principale : même L2, même MAC. Elles doivent être **hors** du CIDR du segment, pour ne
jamais croiser l'attribution automatique. Sur un switch, elles vont sur le bridge du
segment ;
* ``loopback`` — une adresse sur une interface ``dummy`` nommée ``lo1``
(``loopback: 10.255.255.1/32``) ;
* ``frr`` — le chemin d'un ``frr.conf``, relatif au fichier de topologie : FRR est installé
au démarrage et la configuration déposée **telle quelle** (voir `Rôles`_).
* ``release`` — **obligatoire pour un hyperviseur**, refusé ailleurs : le tag de la release de
two que ``deploy.sh`` installe (``release: 0.2.0rc003``). Sans lui, ``deploy.sh`` prendrait la
dernière release, et le lab ne serait plus reproductible.
* ``agent`` — pour un hyperviseur seulement : le chemin d'un ``agent.yml``, relatif au fichier
de topologie, déposé dans ``/etc/two/agent.yml`` avant ``deploy.sh``. Sans lui, l'agent tourne
avec sa configuration par défaut. L'exemple met hv1 sur le serveur DHCP intégré
(``test/e2e/topologies/agent/two.yml`` : ``dhcp.backend: two``) et laisse hv2 sur dnsmasq.
``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms.
Ce que l'outil en déduit, de façon déterministe — même fichier, même plan :
.. list-table::
:widths: 30 70
* - Passerelle d'un segment
- la première adresse du CIDR, portée par le switch sur ``br-<segment>``
* - Adresse d'un nœud
- les suivantes, **dans l'ordre de déclaration des nœuds** ; une adresse fixée par
``addresses`` est réservée d'abord et sautée par l'attribution automatique
* - Câbles
- un par couple (segment, nœud), segments puis nœuds dans l'ordre de déclaration ; le
câble *i* utilise les ports UDP ``20000 + 2i`` (côté nœud) et ``20001 + 2i`` (côté switch)
* - MAC
- ``02:4c:<nœud>:<nœud>:<segment>:<côté>`` — préfixe localement administré, rang du nœud
sur deux octets, rang du segment, ``00`` côté nœud et ``01`` côté switch
* - Interfaces
- côté nœud, le nom du segment ; côté switch, ``p<i>``, du rang du câble
* - SSH d'administration
- ``127.0.0.1:<2200 + rang du nœud>`` sur l'hôte du lab
.. warning::
Réordonner les nœuds ou les segments dans le fichier **change les adresses, les MAC et les
ports**. C'est assumé pour un lab ; ``lab plan`` montre le résultat avant tout lancement.
Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le permet (22 768).
``lab plan`` valide le fichier et affiche le plan, sans rien lancer :
.. code-block:: text
$ go run ./cmd/lab plan test/e2e/topologies/evpn-2hv.yml
lab evpn-2hv: nodes 4, segments 1, cables 3
nodes
name role image cpus memory ssh
sw1 switch debian12 2 1024 MiB 127.0.0.1:2200
rr1 rr debian12 1 1024 MiB 127.0.0.1:2201
hv1 hypervisor debian12 4 16384 MiB 127.0.0.1:2202
hv2 hypervisor debian12 4 16384 MiB 127.0.0.1:2203
roles
name loopback secondary frr release agent
sw1 - underlay 169.254.0.1/28 sw1.conf - -
rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf - -
hv1 - - hv1.conf 0.2.0rc003 two.yml
hv2 - - hv2.conf 0.2.0rc003 -
segment underlay: 192.168.14.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 192.168.14.1
node interface address mac udp switch port mac udp
rr1 underlay 192.168.14.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001
hv1 underlay 192.168.14.11/24 02:4c:00:02:00:00 20002 <-> sw1 p1 02:4c:00:02:00:01 20003
hv2 underlay 192.168.14.12/24 02:4c:00:03:00:00 20004 <-> sw1 p2 02:4c:00:03:00:01 20005
Un fichier invalide est refusé avec **toutes** ses erreurs à la fois, et un code de sortie 1. Les
champs inconnus et les clés en double sont refusés aussi :
.. code-block:: text
$ lab plan cassee.yml
lab: cassee.yml:
segment underlay: rr1 is a rr, not a switch
segment underlay: cidr 10.250.0.0/31 prefix length out of range [/8, /30]
node sw1: switch carries no segment
Les ASN, la loopback du route reflector, le lien ``169.254.0.0/28`` et le subnet des hyperviseurs
de l'exemple sont **ceux de la production** (décision du 2026-10-04, #50) : les fichiers de
``test/e2e/topologies/`` restent ainsi au plus près de ce qui tourne réellement. Toutes les adresses y sont
**fixées** par ``addresses`` — le route reflector en ``.2``, les hyperviseurs à partir de ``.11`` —
pour que le modèle se lise sans le plan et ne dépende pas de l'ordre de déclaration : le
``frr.conf`` d'un hyperviseur, écrit à la main, porte son adresse en ``router-id``. Seul le switch
n'en déclare pas : il porte toujours la passerelle, la première adresse du segment.
Rôles
~~~~~
Les configurations FRR du lab vivent dans ``test/e2e/topologies/frr/``, une par nœud, **écrites à la main** :
ce sont les mêmes fichiers que la documentation de déploiement inclut, pour que le lab qualifie
exactement ce qu'elle prescrit. Celle du route reflector :
.. literalinclude:: ../../test/e2e/topologies/frr/rr1.conf
:language: text
Au premier démarrage, cloud-init installe FRR (``frr-stable`` de ``deb.frrouting.org``, sans les
paquets recommandés), active ``bgpd`` — et ``bfdd`` sur le switch et le route reflector —, puis
dépose le ``frr.conf`` du nœud et redémarre FRR. La mise à jour des index de paquets est réessayée
pendant cinq minutes : un nœud peut démarrer avant que le switch, par lequel il sort, n'ait posé
son NAT.
La **clé du dépôt FRR** n'est pas téléchargée au démarrage : elle est enregistrée dans ``lab``
(``internal/lab/render/frrouting.gpg``) et déposée par cloud-init. Elle a été récupérée le
2026-10-04 sur ``deb.frrouting.org`` ; les empreintes de ses clés primaires sont publiées sous la
même valeur sur ``keys.openpgp.org`` et ``keyserver.ubuntu.com`` :
.. code-block:: text
3D99 68AC 9AE7 BE11 6928 8DDB 1FD5 8398 95F5 7FDA David Lamparter
4A56 C773 8BB3 F815 95A8 05D2 A832 7699 08F1 3ED1 FRRouting Debian Repository
A90F C36D 9429 4097 98E9 C2D8 74DE ED43 AB19 4DBF Jafar Al-Gharaibeh
Une clé renouvelée par FRR fera échouer l'installation (signature inconnue) : remplacer le fichier
après avoir vérifié les nouvelles empreintes.
**Hyperviseurs.** Ils se déploient comme en production, par ``deploy.sh`` — celui **du dépôt**,
embarqué dans ``lab`` avec ``bootstrap_kvm.sh`` (paquet ``scripts``) et déposé dans
``/opt/two/scripts/``, où ``deploy.sh`` cherche d'abord ``bootstrap_kvm.sh`` :
.. code-block:: text
deploy.sh --noup_script -i -u <segment> -t <release>
``--noup_script`` empêche l'auto-mise à jour de remplacer le script par celui de ``main`` : le lab
teste les scripts de sa branche. L'uplink ``-u`` est l'interface qui porte la route par défaut —
celle du premier segment de l'hyperviseur dans l'ordre de déclaration des segments — parce que
``deploy.sh`` y lit l'adresse et la passerelle qu'il déplace sur ``br-000000``. ``deploy.sh``
télécharge la release sur ``git.g3e.fr`` sans réessayer : le lancement attend d'abord que le serveur
réponde, à travers le switch. FRR est installé **après** : il démarre sur le réseau final.
**Un seul script de provisionnement par nœud.** cloud-init exécute ``runcmd`` comme un script
``sh`` sans ``set -e`` : seule la dernière commande compte, et un ``deploy.sh`` en échec suivi d'un
FRR installé avec succès passerait pour un démarrage réussi. Chaque nœud reçoit donc
``/usr/local/sbin/lab-provision``, en ``set -eu``, qui enchaîne ses étapes ; ``runcmd`` n'appelle
que lui, et la première étape en échec met cloud-init en erreur.
**Ce que vérifie** ``lab up``, une fois cloud-init terminé sans erreur : ``agent.service`` actif
sur chaque hyperviseur, ``frr`` actif sur chaque nœud qui en a un. Ce contrôle couvre ce que
cloud-init ne voit pas — si la migration réseau échoue, ``deploy.sh`` arme un redémarrage de
secours, et la VM redémarrée ne rejoue pas ``runcmd``.
.. warning::
**Un hyperviseur du lab ne survit pas à un redémarrage.** En production, la racine est en
tmpfs et ``deploy.sh --bootstrap`` est rejoué à chaque démarrage ; dans le lab, ``-i`` n'est
exécuté qu'au premier, et la migration réseau, qui n'est pas persistée, est perdue. Recréer le
lab : ``lab down`` puis ``lab up``.
Vérifié le 2026-10-04 sur le serveur de lab, topologie ``evpn-2hv``, release ``0.2.0rc002`` :
.. code-block:: text
$ scripts/lab-host.sh ssh './lab up -timeout 25m topology/evpn-2hv.yml'
sw1: started
rr1: started
hv1: started
hv2: started
sw1: ready
rr1: ready
hv1: ready
hv2: ready
.. list-table::
:header-rows: 1
:widths: 55 45
* - Vérification
- Résultat
* - ``lab up`` complet : FRR, ``deploy.sh`` et vérification des services de chaque rôle
- 6 min 15
* - session switch ↔ route reflector, IPv4 unicast, BFD
- Established, BFD up ; le switch reçoit la seule loopback du route reflector
* - sessions EVPN des deux hyperviseurs vers la loopback du route reflector
- Established, voisins dynamiques, stables
* - réseau d'un hyperviseur après ``deploy.sh``
- adresse sur ``br-000000``, MTU 9000, API de l'agent qui répond
* - VPC, subnet ``vxlan`` et VM Debian ``genericcloud`` créés par l'API de hv1
- ``login:`` en 20 s, en KVM imbriqué
* - DHCP et routes (option 121) servis par two à la VM
- conformes, route ``/32`` vers ``169.254.169.254`` comprise
* - métadonnées, image configurée selon :doc:`/deploiement/image-qcow2`
- ``DataSourceNoCloudNet``, nom d'hôte appliqué — avec la barre oblique finale de
``seedfrom`` (voir cette page)
* - VM ↔ VM entre les deux hyperviseurs, même subnet ``vxlan``
- **échec** : les VXLAN de two n'ont pas d'adresse VTEP locale, rien n'est annoncé en EVPN —
`#51 <https://git.g3e.fr/syonad/two/issues/51>`_ ; avec l'adresse posée, ping et MTU 1500
passent
.. note::
**Le switch du lab est un routeur Linux avec FRR, par choix.** Il joue le rôle générique de
routeur de cluster : passerelle des hyperviseurs, session eBGP avec BFD vers le route reflector,
dont il n'accepte que la loopback (``test/e2e/topologies/frr/sw1.conf``). L'équipement réel dépend de qui
déploie l'infrastructure (MikroTik aujourd'hui ; Cisco, Juniper, Arista… demain) : il n'a besoin
que de BGP et d'EVPN, et sa configuration propre au constructeur n'a pas sa place dans le lab.
Rendu des VM
------------
``lab render`` produit, pour chaque nœud, ce qu'il faut pour démarrer sa VM — sans rien lancer :
.. code-block:: text
$ go run ./cmd/lab render -key ~/.config/two-lab/ssh/lab_ed25519.pub test/e2e/topologies/evpn-2hv.yml <répertoire>
``<répertoire>/<nœud>/`` reçoit :
``qemu.args``
Les arguments de ``qemu-system-x86_64``, **un par ligne** : rien à échapper, rien à
interpréter par un shell.
``meta-data``, ``user-data``, ``network-config``
Les trois fichiers NoCloud de cloud-init, à mettre dans une image de volume ``cidata``.
Les chemins de la VM (``disk.qcow2``, ``seed.iso``, ``console.log``, ``qmp.sock``, ``qemu.pid``)
sont ceux du répertoire du nœud ; ``-key`` peut être répété, et accepte un fichier
``authorized_keys`` (lignes vides et commentaires ignorés). Les fichiers sont créés en ``0600``.
Ce que contiennent les arguments QEMU d'un hyperviseur — extrait réel, côté réseau :
.. code-block:: text
-netdev
user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22
-device
virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile=
-netdev
dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003
-device
virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile=
Les choix qui s'y lisent :
* **machine** ``q35``, ``-accel kvm -cpu host`` — le KVM imbriqué des hyperviseurs du lab en
dépend ; ``-nodefaults`` pour qu'aucun périphérique implicite ne s'ajoute ;
* **administration** (``mgmt0``) : le NAT de QEMU, MAC ``02:4d:<nœud>:<nœud>:00:00``, SSH redirigé
sur la boucle locale de l'hôte. ``restrict=on`` pour tous les nœuds **sauf le switch** : un
nœud isolé ne joint ni l'hôte ni l'extérieur par là, seule la redirection SSH passe.
``ipv6=off`` partout (voir plus bas) ;
* **câbles** : ``dgram`` sur ``127.0.0.1``, les deux extrémités d'un câble se répondent
(port local de l'une = port distant de l'autre), ``host_mtu`` annonce le MTU du segment au
guest ;
* ``romfile=`` vide sur toutes les cartes : pas de ROM de démarrage réseau, donc pas de repli
sur un démarrage PXE si le firmware ne trouve pas le disque. Pendant les essais de #50, une VM
restée bloquée sans rien écrire sur sa console, CPU au repos, avait toutes les apparences de
ce repli ; la cause n'a pas été isolée, l'option est une précaution.
Ce que fait cloud-init :
* **toutes les VM** : interfaces nommées d'après leur MAC (``mgmt0``, nom du segment, ``p<i>``),
``dhcp4: false`` partout, ``mgmt0`` en ``10.0.2.15/24`` **sans passerelle** ; connexion SSH par
clé seulement, utilisateur ``debian``, ``root`` désactivé, mot de passe refusé ;
* **un nœud** : adresse sur chaque segment, MTU du segment, route par défaut et DNS
(``1.1.1.1``, ``8.8.8.8``) sur son **premier** segment — la sortie Internet passe par le
switch ;
* **le switch** : route par défaut par ``mgmt0`` ; un service ``lab-switch`` crée un bridge
``br-<segment>`` par segment (STP désactivé, MTU du segment), y branche ses ports, porte la
passerelle, active le routage et masque (NAT nftables) les segments vers ``mgmt0``. Le
service est rejoué à chaque démarrage.
Vérifié sur de vraies VM
~~~~~~~~~~~~~~~~~~~~~~~~
Le switch et le route reflector n'ont pas besoin de KVM imbriqué : ``sw1`` et ``rr1`` de
l'exemple ont été démarrés **sur un Mac**, en émulation (TCG), avec Debian 12 ``generic`` et
les fichiers produits par ``lab render``.
.. list-table::
:header-rows: 1
:widths: 60 40
* - Vérification
- Résultat
* - interfaces nommées et adressées, bridge ``br-underlay`` en ``10.250.0.1/24``
- conforme
* - service ``lab-switch`` actif, y compris après redémarrage
- conforme
* - ``ping -M do -s 8972`` de ``rr1`` vers le switch (MTU 9000, sans fragmentation)
- passe
* - ``ping -M do -s 8973`` (MTU 9001)
- refusé : ``message too long, mtu=9000``
* - Internet depuis ``rr1`` en IPv4
- passe, par ``10.250.0.1``
* - ``rr1`` vers un service TCP de l'hôte par ``mgmt0`` — le switch, témoin, y parvient
- bloqué
* - ``rr1`` vers Internet par ``mgmt0``
- bloqué
Un défaut trouvé par cet essai, et corrigé : sans ``ipv6=off``, le NAT de QEMU annonce un
préfixe IPv6 et ``mgmt0`` reçoit une **route IPv6 par défaut** — vers une impasse, puisque
``restrict=on`` bloque tout. Pas de fuite, mais chaque programme qui tente l'IPv6 d'abord (le DNS
renvoie d'abord des adresses IPv6) attend un délai avant de se rabattre sur l'IPv4.
.. note::
Un ``ping`` vers ``10.0.2.2`` n'est pas un test d'isolation : c'est la passerelle virtuelle de
QEMU qui répond elle-même, ``restrict=on`` ou non. Seule une connexion vers un vrai service de
l'hôte, avec un témoin qui y parvient, le prouve.
Reste à vérifier sur le serveur de lab : les hyperviseurs, qui exigent KVM imbriqué.
Lancement des VM
----------------
``lab`` s'exécute **sur le serveur de lab**. Il garde l'état du lab dans un répertoire
(``-run``, par défaut ``~/lab-run``) : ``status``, ``down`` et ``ssh`` n'ont donc pas besoin de la
topologie.
.. code-block:: text
lab up [-run dir] [-cache dir] [-timeout 20m] <topologie.yml>
lab status [-run dir]
lab down [-run dir]
lab ssh [-run dir] <nœud> [commande…]
``lab up``
1. refuse de continuer si un lab tourne déjà dans le répertoire ;
2. télécharge chaque image dans le cache (``-cache``, par défaut ``~/.cache/two-lab``) et la
vérifie contre ``SHA512SUMS`` ; une image déjà présente et toujours conforme n'est pas
retéléchargée, la liste des sommes est relue à chaque fois ;
3. génère une paire de clés SSH dans le répertoire du lab, si elle n'existe pas encore ;
4. pour chaque nœud : fichiers de ``lab render``, disque **neuf** en overlay qcow2 sur
l'image (``qemu-img create -b``, 20 Gio annoncés), image ``cidata`` (``genisoimage``) ;
5. démarre les QEMU, **switchs d'abord**, détachés (``-daemonize``) : ils survivent à la
session SSH qui les a lancés ;
6. attend sur chaque nœud la fin de cloud-init (``cloud-init status --wait`` par SSH),
jusqu'au délai ``-timeout``.
La topologie est copiée dans ``<run>/topology.yml``. Un échec laisse les nœuds démarrés en
place : ``lab status``, puis ``lab down``.
``lab status``
Pour chaque nœud : rôle, état du processus QEMU, PID, port SSH sur la boucle locale.
``lab down``
Arrête chaque QEMU par ``SIGTERM``, puis ``SIGKILL`` au bout de 30 s. Les disques sont
conservés jusqu'au prochain ``up``, qui les recrée.
``lab ssh``
Ouvre un shell sur un nœud, ou y exécute une commande, avec la clé générée par ``up``. ``lab``
cède la place à ``ssh``, dont le code de retour est donc celui de la commande. Un terminal
n'est demandé (``-t``) que si l'entrée de ``lab`` en est un : depuis un script, ni
pseudo-terminal ni ``\r\n`` dans la sortie.
Comme ``ssh``, ``lab ssh`` recolle ses arguments par des espaces et les confie à un shell
distant — et depuis le Mac, il y en a **deux** : celui du serveur, puis celui de la VM.
Une commande qui contient elle-même des guillemets se passe en une seule chaîne :
.. code-block:: text
$ echo | scripts/lab-host.sh ssh './lab ssh hv1 sh -c "exit 42"'; echo "rc 42=$?"
rc 42=0
$ echo | scripts/lab-host.sh ssh "./lab ssh hv1 'sh -c \"exit 42\"'"; echo "rc 42=$?"
rc 42=42
Dans le premier cas, la VM reçoit ``sh -c exit 42`` : ``exit`` sans argument, ``42`` en
``$0``. Pour plus d'une commande, passer un script sur l'entrée standard :
``scripts/lab-host.sh ssh "./lab ssh hv1 'sudo bash -s'" < script.sh``.
Un processus n'est tenu pour celui d'un nœud que si son PID, lu dans ``qemu.pid``, désigne un
processus vivant dont la ligne de commande (``/proc/<pid>/cmdline``) contient ``-name <nœud>``.
Un PID réutilisé par un autre programme n'est donc jamais signalé.
.. warning::
Le cache range une image sous son nom de fichier, et l'URL de Debian est ``latest`` : une
nouvelle publication remplace le fichier, et les overlays existants pointeraient sur une
base différente. ``up`` recrée toujours les disques, ce qui suffit avec un lab par serveur ;
**ne pas relancer un QEMU à la main** à partir d'un ``qemu.args`` après un ``up`` ultérieur.
Une campagne réelle, de la création du serveur à la première commande sur une VM — sorties du
2026-10-04 :
.. code-block:: text
$ scripts/lab-host.sh up
== création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT)
…
== préparation du serveur : qemu, genisoimage, KVM imbriqué
…
qemu QEMU emulator version 7.2.22 (Debian 1:7.2+dfsg-7+deb12u18+b3), nested=Y
== prêt : root@<adresse>
$ scripts/lab-host.sh push test/e2e/topologies/evpn-2hv.yml
== compilation de lab (linux/amd64)
== déposés sur le serveur : ~/lab, ~/evpn-2hv.yml — ensuite : lab-host.sh ssh './lab up evpn-2hv.yml'
$ scripts/lab-host.sh ssh './lab up evpn-2hv.yml'
sw1: started
rr1: started
hv1: started
hv2: started
sw1: ready
rr1: ready
hv1: ready
hv2: ready
$ scripts/lab-host.sh ssh './lab status'
node role state pid ssh
sw1 switch running 5158 127.0.0.1:2200
rr1 rr running 5171 127.0.0.1:2201
hv1 hypervisor running 5182 127.0.0.1:2202
hv2 hypervisor running 5196 127.0.0.1:2203
``lab up`` a pris **49 secondes**, téléchargement et vérification de l'image (427 Mio) compris ;
l'essentiel du temps d'une campagne est la livraison du serveur (environ 25 minutes avec
``prepare``).
Vérifié sur le serveur de lab
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Le 2026-10-04, sur un Xeon E5-2640 v3, Debian 12 et QEMU 7.2 sur le serveur, topologie
``evpn-2hv`` :
.. list-table::
:header-rows: 1
:widths: 60 40
* - Vérification
- Résultat
* - ``ping -M do -s 8972`` de hv1 à hv2 à travers le switch
- passe
* - ``ping -M do -s 8973``
- refusé : ``message too long, mtu=9000``
* - sortie Internet de hv1
- par ``10.250.0.1`` (le switch), HTTPS 200 ; aucune route IPv6 globale
* - hv1 vers un service TCP du serveur par ``mgmt0`` — sw1, témoin, y parvient (200)
- refusé
* - hv1 vers Internet par ``mgmt0``, route forcée via ``10.0.2.2`` — sw1 y parvient
- refusé
* - ``/dev/kvm`` et ``nested`` dans hv1
- présent, ``Y``
* - racine de hv1 (overlay de 20 Gio)
- 20 Go : ``growpart`` agrandit la partition au premier démarrage
* - code de retour à travers ``lab-host.sh ssh`` et ``lab ssh``, sans terminal
- propagé jusqu'au Mac
* - ``scripts/lab-host.sh ssh './lab ssh hv1'`` depuis un terminal
- shell interactif
* - ``lab down`` puis ``lab up`` d'une autre topologie
- conforme
Scénarios
---------
Les scénarios se lancent **depuis le Mac**, sur un lab démarré (``up``, ``push``, ``./lab up``) :
.. code-block:: text
test/e2e/run.sh s1 # un scénario
test/e2e/run.sh s1 s3 # plusieurs
test/e2e/run.sh all # tous, dans l'ordre
Chacun affiche une ligne ``RÉUSSI`` ou ``ÉCHOUÉ`` par vérification — un échec porte la dernière
ligne de la commande en cause —, des lignes ``INFO`` pour les mesures, puis son bilan. Le code de
sortie vaut 1 si une vérification échoue **ou si aucune n'a été faite**.
.. list-table::
:header-rows: 1
:widths: 22 58 20
* - Scénario
- Ce qui doit être vrai
- Hyperviseurs
* - ``s1-dhcp-two``
- backend DHCP ``two``, une VPC et deux subnets : chaque VM reçoit l'adresse de **son**
subnet, démarrée seule ou en même temps qu'une autre ; le bail est tenu par
systemd-networkd ; route par défaut, route vers la VPC et ``/32`` vers les métadonnées
via ``interface_ip`` ; l'état de chaque serveur DHCP ne connaît que les MAC de son subnet
(#46)
- hv1
* - ``s2-gateway``
- route par défaut via ``interface_ip``, ou via ``gateway`` avec ``default_route`` ; route
vers la VPC toujours via ``interface_ip`` ; même résultat après recréation du subnet (#31)
- hv1
* - ``s3-isolation-local``
- deux VPC sur le même hyperviseur ne se joignent pas, en ICMP comme en TCP ; chaque VM
joint sa passerelle (témoin)
- hv1
* - ``s4-evpn``
- adresse VTEP locale sur les VXLAN (#51), VTEP distant appris par EVPN, ping VM ↔ VM
entre hyperviseurs, trame de 1472 octets en ``-M do``, 1473 refusés
- hv1, hv2
* - ``s5-isolation-evpn``
- deux VPC de même plage, VNI différentes, sur deux hyperviseurs : la VM joint celle de sa
VPC sur l'autre hyperviseur (témoin) et pas celle de l'autre VPC — aucune résolution ARP
- hv1, hv2
* - ``s6-rr-loss``
- FRR arrêté sur le route reflector, ping continu entre les VM de ``s4`` : la session tombe,
le trafic restant est **mesuré** (``INFO``) à 30 et 90 s ; au retour du route reflector, le
VTEP distant est réappris et le trafic repasse — délai mesuré
- hv1, hv2, rr1
``s6`` réutilise les VM de ``s4`` : le lancer après.
**Comment c'est fait.** ``test/e2e/run.sh`` exécute chaque scénario sur le Mac ; un
scénario envoie des blocs de shell aux nœuds par ``on <nœud> [VAR=valeur…] <<'NODE'``, précédés de
``test/e2e/lib/node.sh`` — appels à l'API de l'agent, attente des états, image Debian compatible two
(préparée une fois par hyperviseur, ``seedfrom`` avec barre oblique finale), clé SSH des VM,
``check`` et ``vm_fails``. Une vérification négative (« ne joint pas ») passe par ``vm_fails`` :
elle n'est réussie que si le SSH vers la VM a fonctionné **et** que la commande y a échoué — un
SSH en panne ne passe jamais pour une isolation.
Résultats du 2026-10-04 sur le serveur de lab, release ``0.2.0rc003``, hv1 sur le DHCP intégré et
hv2 sur dnsmasq — toute la série en 8 minutes :
.. code-block:: text
$ test/e2e/run.sh all | grep -E '^(=== s[0-9].* : |INFO)'
INFO: MAC de sn-s1a : 00:22:33:00:00:0a 00:22:33:00:00:0b ; de sn-s1b : 00:22:33:00:00:0a 00:22:33:00:00:0b
=== s1-dhcp-two : 37 réussi(s), 0 échoué(s)
=== s2-gateway : 22 réussi(s), 0 échoué(s)
=== s3-isolation-local : 12 réussi(s), 0 échoué(s)
=== s4-evpn : 14 réussi(s), 0 échoué(s)
=== s5-isolation-evpn : 13 réussi(s), 0 échoué(s)
INFO: 30 s après l'arrêt du route reflector : 0 réponses dans les 10 dernières secondes (50 si le trafic passe intégralement)
INFO: VTEP distant encore connu : 0 ; entrée d'inondation : 0
INFO: 90 s après l'arrêt : 0 réponses dans les 10 dernières secondes
INFO: retour du VTEP distant et du trafic 31 s après le redémarrage de FRR
=== s6-rr-loss : 8 réussi(s), 0 échoué(s)
Dans ce run, le contrôle « uniquement les MAC de son subnet » de ``s1`` ne prouvait rien : two
dérive la MAC du rang de l'IP, et les VM ``.10``/``.11`` des deux subnets avaient les mêmes MAC.
Vérifié à la main sur le lab, chaque serveur DHCP ne connaissait que les couples MAC/IP de son
subnet ; le scénario compare désormais ces couples.
Les VM sont accessibles depuis le netns de leur VPC, sur l'hyperviseur, avec l'utilisateur
``syonad`` créé par les métadonnées de two :
.. code-block:: text
scripts/lab-host.sh ssh "./lab ssh hv1 'sudo ip netns exec vp-s4 ssh -i /root/.ssh/lab-vm syonad@10.240.1.10'"
Écrire un scénario
~~~~~~~~~~~~~~~~~~
Le lab sert à qualifier des comportements qui ne se voient qu'à plusieurs hyperviseurs — la
campagne L3VNI de `#41 <https://git.g3e.fr/syonad/two/issues/41>`_ en est le prochain exemple. Un
scénario est un fichier ``test/e2e/scenarios/<n>-<nom>.sh``, exécuté par ``scenario.sh`` sur le
Mac ; il envoie des blocs aux nœuds :
.. code-block:: bash
on hv1 <<'NODE'
two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; }
KEY=$(vm_key)
check "VPC vp-x" vpc_create vp-x 10.250.0.0/16
check "subnet sn-x" subnet_create sn-x vp-x 2601 10.250.1.1 10.250.1.0/24
check "VM x1" vm_create x1 sn-x 10.250.1.10 "${KEY}"
check "x1 joignable" vm_wait vp-x 10.250.1.10
check "x1 joint sa passerelle" vm_ssh vp-x 10.250.1.10 'ping -c 2 -W 2 10.250.1.1'
check "x1 ne joint pas 10.251.1.10" vm_fails vp-x 10.250.1.10 'ping -c 2 -W 2 10.251.1.10'
info "mesure : $(vtysh -c 'show evpn vni 2601' | grep -c 'flood')"
NODE
Les règles qui ont fait leurs preuves en E5 :
* **une vérification par ligne**, avec ``check`` — jamais un ``echo RÉUSSI`` écrit à la main ;
* **toute vérification négative a son témoin** : avant « ne joint pas », une ligne qui prouve que
la cible est vivante et que le chemin du test fonctionne ;
* **``vm_fails`` pour le négatif**, jamais ``!`` devant un ``vm_ssh`` : un SSH en panne doit
échouer, pas passer pour une isolation ;
* **une donnée qui distingue réellement les cas** : two dérivant la MAC du rang de l'IP, deux
subnets ont les mêmes MAC — comparer des couples MAC/IP, pas des MAC ;
* **les mesures en ``INFO``**, les attentes en ``check`` : un temps de reconvergence se mesure,
il ne se décrète pas ;
* chaque scénario crée ses propres VPC, plages et VNI, distinctes de celles des autres, pour que
``all`` les enchaîne sur le même lab ; un scénario qui dépend d'un autre le vérifie en tête
(``check "prérequis : …"``) ;
* variables vers un nœud : ``on hv1 NOM=valeur <<'NODE'`` (valeurs échappées par
``scenario.sh``) ;
* ``bash test/e2e/run_test.sh`` vérifie la syntaxe de chaque bloc réellement envoyé — à
lancer avant toute session.
Facturation
-----------
.. warning::
Un serveur Elastic Metal est facturé **de sa création à sa suppression, éteint compris**.
Éteindre ne suffit pas : il faut supprimer. La granularité n'est pas documentée par Scaleway —
compter chaque heure entamée comme une heure pleine.
Ce que le script garantit :
* il ne commande **jamais** d'offre mensuelle : il exige une seule offre au nom demandé, en
facturation horaire, en stock et sans frais de mise en service, sinon il refuse avant toute
création. La CLI ``scw`` n'est pas utilisée pour cette raison : son ``server create type=…``
choisit l'offre par son seul nom et peut tomber sur la mensuelle, qui engage un mois ;
* il refuse de créer un second serveur si un serveur de lab existe déjà ;
* ``down`` agit sur **tous** les serveurs portant le tag ``two-lab`` dans le projet, et
``session`` y ajoute l'identifiant reçu à la création : un serveur créé juste avant une
interruption est rattrapé ;
* une suppression refusée pendant la livraison ou l'installation est réessayée tant qu'elle dure,
dans la limite du délai d'installation augmenté du délai de suppression ;
* le serveur n'est déclaré supprimé qu'au 404 de l'API, jamais sur une erreur passagère ;
* un échec de suppression se termine par ``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)`` et un code
d'erreur.
Ce qu'il ne peut pas garantir : un ``SIGKILL``, une coupure de courant ou une mise en veille du
poste qui lance la session. En cas de doute, toujours :
.. code-block:: bash
scripts/lab-host.sh status
scripts/lab-host.sh down
Diagnostic
----------
``aucune clé SSH active dans le projet``
Aucune clé SSH n'est enregistrée dans le projet de lab. En ajouter une dans la console (projet
→ Clés SSH).
``offre horaire EM-B212X-SSD : 0 correspondance(s)``
L'offre n'existe pas dans la zone en facturation horaire. Vérifier ``SCW_DEFAULT_ZONE``.
``création incertaine``
La création a échoué ou n'a pas rendu d'identifiant. Le serveur a pu être créé malgré tout —
cas typique : le quota (identité non validée), ou une réponse perdue. Le script indique s'il
voit un serveur de lab ; dans tous les cas, lancer ``status`` puis ``down``.
``SSH injoignable``
L'installation est terminée mais SSH ne répond pas après 10 minutes. Avec une clé matérielle
(Yubikey), chaque connexion demande un PIN ou un toucher : utiliser la clé dédiée du lab. Le
serveur est toujours facturé : ``down``.
``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)``
La suppression n'a pas abouti dans les délais. Relancer ``down`` ; si l'erreur persiste,
supprimer depuis la console Scaleway.
``HTTP 403 insufficient permissions``
La clé d'API est authentifiée mais n'a pas le droit demandé — en général la lecture des clés
SSH du projet. Compléter la politique de la clé, limitée au projet.
Sécurité
--------
* La clé secrète n'apparaît ni dans les arguments des processus (elle est passée à ``curl`` par
un descripteur de fichier), ni dans les journaux, ni dans l'environnement de ``ssh``.
* **Ne jamais lancer le script sous** ``bash -x`` : la trace afficherait la clé.
* Le serveur n'expose que SSH, par clé. Le lab n'a aucune donnée personnelle ni secret de
production.
* Une clé secrète qui a circulé ailleurs que dans ``scaleway.env`` (conversation, terminal
partagé, capture d'écran) se régénère.
* **Clé SSH des VM** : générée par ``lab up`` sur le serveur, c'est la seule clé autorisée dans
les VM. Elle ne quitte jamais le serveur, n'ouvre que les VM du lab — qui n'écoutent qu'en
boucle locale — et disparaît avec lui. La clé publique du Mac n'est jamais envoyée aux VM.
* **Clés d'hôte des VM non vérifiées** par ``lab ssh`` (``known_hosts`` jetable) : elles changent
à chaque ``up``. Acceptable uniquement parce que la connexion reste sur la boucle locale d'un
serveur auquel on s'est authentifié.
* **Code exécuté sans épinglage par** ``deploy.sh`` : la bibliothèque ``shflags`` est récupérée
par ``curl`` sur la branche ``main`` d'un autre dépôt (``H6N/tools``) et exécutée par ``eval``,
sans vérification d'intégrité — dans le lab comme en production. Les artefacts de la release
sont, eux, vérifiés contre ``SHA256SUMS``.
* **Image** : ``SHA512SUMS`` vient de la même origine que l'image, en HTTPS. La vérification
protège contre la corruption, pas contre une origine compromise ; la signature GPG de Debian
(``SHA512SUMS.sign``) n'est pas encore vérifiée.
Tests
-----
.. code-block:: bash
bash scripts/lab-host_test.sh
bash test/e2e/run_test.sh
go test ./internal/lab/... ./cmd/lab/
Environ une minute et demie, sans réseau : la suite remplace ``curl`` par une fausse API Scaleway
qui se place dans le pire cas (offre mensuelle listée avant l'horaire, serveurs d'autres projets,
suppressions refusées, erreurs 503, serveur qui tarde à disparaître) et ``ssh`` par un faux client.
Elle tourne sous bash 5 comme sous le bash 3.2 de macOS.

View file

@ -1,91 +0,0 @@
Modèle asynchrone et codes de retour
====================================
Toute création et toute suppression sont **asynchrones**. C'est le point qui surprend le plus
souvent à l'intégration : un ``202`` ne dit pas que la ressource existe, il dit que la demande
a été acceptée et enregistrée.
Les deux temps d'une requête
----------------------------
.. mermaid::
sequenceDiagram
participant C as Appelant
participant A as API
participant W as Worker
C->>A: POST /subnets
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: état → running (ou error)
C->>A: GET /subnets/<name>
A-->>C: 200 + state
**Prepare** est synchrone, dans le handler HTTP : il valide l'état, écrit l'état initial en base
et répond. **Execute** est asynchrone : il fait le travail réseau réel, puis positionne l'état
final.
Conséquence directe : un échec d'``Execute`` ne peut pas être remonté dans la réponse HTTP. Il
se lit dans l'état de la ressource, qui passe à ``error``.
Attendre correctement
---------------------
Il n'y a pas de webhook ni de long-polling : l'appelant interroge ``GET /<type>/<name>`` jusqu'à
un état stable.
.. code-block:: bash
until [ "$(curl -sf http://127.0.0.1:8080/subnets/sn-000001 | jq -r .state)" = running ]; do
sleep 2
done
Trois règles pour un appelant robuste :
* **Toujours borner l'attente.** Côté agent, ``dispatcher.timeout_seconds`` (300 s par défaut)
borne les opérations qui attendent une transition ; l'appelant doit avoir sa propre borne.
* **Traiter ``error`` comme terminal, pas comme un échec transitoire.** Un ``Execute`` en échec
ne se rejoue pas tout seul.
* **Ne pas enchaîner sans vérifier.** Créer un subnet dont le VPC est encore en ``creating``
échoue en 422 ; démarrer une VM sur un subnet en ``creating`` ou ``running`` est en revanche
accepté.
Codes de retour
---------------
.. list-table::
:header-rows: 1
:widths: 12 88
* - Code
- Signification
* - ``202``
- demande acceptée et enregistrée ; l'état passera à ``running`` ou ``error``
* - ``200``
- lecture réussie (``GET``)
* - ``400``
- champ obligatoire manquant, corps invalide, ``iface_type`` inconnu, base64 invalide
* - ``404``
- ressource inexistante
* - ``409``
- conflit d'existence ou d'état : ressource déjà créée, ou suppression depuis un état qui
ne l'autorise pas ; pour un VPC, subnets encore présents
* - ``422``
- dépendance absente ou pas prête : VPC parent d'un subnet, subnet d'une VM
* - ``500``
- erreur interne
Le corps d'erreur est uniforme : ``{"error": "…"}``.
Idempotence
-----------
Les créations ne sont **pas** idempotentes : recréer une ressource existante donne 409, pas 202.
Un appelant qui rejoue une requête après un timeout réseau doit donc traiter 409 comme
« déjà fait », après avoir vérifié l'état par un ``GET``.
Les suppressions depuis l'état ``error`` sont acceptées mais **best-effort** : les ressources
système peuvent n'avoir été créées que partiellement, et il n'y a pas de rollback — voir
:doc:`/concepts/cycle-de-vie`.

View file

@ -1,21 +0,0 @@
API de l'agent
==============
L'agent expose une API HTTP par hyperviseur. C'est aujourd'hui la seule API de ``two`` ; les
API de niveau supérieur viendront avec les composants d'orchestration, et seront documentées
à part.
Elle est **machine-to-machine** : elle est consommée par un autre logiciel, pas par un humain.
La validation de cohérence des entrées (format des CIDR, plage des VXLAN ID, convention de
nommage) est à la charge de l'appelant — l'agent ne la refait pas.
.. warning::
Cette API **n'a aucune authentification**. Voir :doc:`/exploitation/configuration` avant de
l'exposer au-delà de la boucle locale.
.. toctree::
:maxdepth: 1
asynchronisme
reference

View file

@ -1,8 +0,0 @@
Référence
=========
Cette page est générée depuis ``api/agent.yaml``, à la racine du dépôt. C'est la source unique
du contrat : en cas d'écart avec le reste de la documentation, c'est elle qui fait foi.
.. openapi:: ../../../api/agent.yaml
:examples:

View file

@ -1,210 +0,0 @@
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
``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
``0.0.0.0:8080`` : quiconque atteint ce port peut créer et détruire des VM et des réseaux sur
l'host KVM, c'est-à-dire en prendre le contrôle.
Sur tout déploiement réel : restreindre ``api.address`` à une adresse d'administration, ou
filtrer le port en amont (pare-feu, réseau dédié). Traiter l'ouverture de ce port comme une
décision d'architecture, pas comme un réglage.
Base de données
---------------
.. code-block:: yaml
database:
path: "/var/lib/two/data/"
Répertoire de la base clé-valeur Badger. **Un seul processus l'ouvre** : l'agent. Ni le serveur
de metadata ni aucun autre outil ne doit être configuré pour ouvrir le même répertoire pendant
que l'agent tourne.
Serveurs
--------
.. code-block:: yaml
api:
address: "0.0.0.0"
port: 8080
prometheus:
address: "0.0.0.0"
port: 9090
admin:
enabled: false
address: "127.0.0.1"
port: 9091
``admin`` expose une inspection en lecture seule de la base (``/db?prefix=…``). Elle est
désactivée par défaut et prévue pour la boucle locale uniquement.
.. warning::
Le contenu de la base inclut ``vm/<name>/password``, qui est un hash de mot de passe.
L'activation de l'API d'administration rend ces valeurs lisibles par tout ce qui atteint le
port. Ne pas l'exposer hors de la boucle locale.
Exécution des commandes
-----------------------
.. code-block:: yaml
worker:
count: 4
buffer_size: 100
dispatcher:
timeout_seconds: 300
poll_seconds: 2
``worker.count`` est le nombre de goroutines qui exécutent les commandes ; ``buffer_size`` le
nombre de commandes en attente au-delà duquel ``Dispatch`` bloque.
``dispatcher.timeout_seconds`` borne les opérations qui attendent une transition d'état, dont
l'extinction d'une VM.
.. warning::
À l'expiration de ce délai, une VM qui ne s'est pas éteinte reçoit un ``quit`` QMP — un arrêt
**brutal**. Pour des charges dont l'extinction est lente (bases de données, construction
d'images), une valeur confortable évite un système de fichiers invité incohérent.
Correspondance des interfaces
-----------------------------
.. code-block:: yaml
default_interface: br-000000
interfaces:
vms: br-000000
internet: br-000000
admin: br-000000
Traduit les clés logiques ``iface_type`` de l'API vers les bridges physiques de l'host. Une clé
inconnue ou omise retombe silencieusement sur ``default_interface`` — ce n'est pas une erreur,
mais c'est une source de subnets branchés au mauvais endroit sans le dire.
Metadata et QEMU
----------------
.. code-block:: yaml
metadata:
run_dir: "/run/two/metadata"
qemu:
ovmf_code_path: "/usr/share/OVMF/OVMF_CODE.fd"
ovmf_vars_template: "/usr/share/OVMF/OVMF_VARS.fd"
uefi_vars_dir: "/run/two/vms/efi"
serial_dir: "/run/two/vms/serial"
monitor_dir: "/run/two/vms/monitor"
qmp_dir: "/run/two/vms/qmp"
Les chemins OVMF sont nécessaires aux VM démarrées avec ``uefi: true`` (paquet ``ovmf`` sur
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@<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
--------
.. code-block:: yaml
watchdog:
enabled: true
interval_seconds: 60
Vérification périodique **en lecture seule** — voir :doc:`/exploitation/observabilite`.
Journalisation
--------------
.. code-block:: yaml
logger:
level: info # debug, info, warn, error
debug: false # force le niveau debug quel que soit level

View file

@ -1,148 +0,0 @@
Diagnostic
==========
Symptôme, cause probable, vérification. Les causes listées sont celles réellement rencontrées.
La ressource part en ``error`` juste après le 202
-------------------------------------------------
``Execute`` a échoué : la cause est dans le journal de l'agent, pas dans la réponse HTTP.
.. code-block:: bash
journalctl -u agent -n 100
Cas fréquents :
* subnet en mode ``public_ip`` — la mise en place host n'est pas implémentée, l'échec est attendu ;
* ``vxlan_id`` déjà utilisé sur l'host ;
* bridge cible absent : ``iface_type`` inconnu retombé sur ``default_interface``, lui-même
inexistant.
Avant toute recréation, émettre un ``DELETE`` : il n'y a pas de rollback, les objets système
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``**
.. code-block:: bash
systemctl status 'dnsmasq@<netns>_<bridge>'
tail -50 /var/log/dnsmasq-<netns>_<bridge>.log
cat /run/dnsmasq-<netns>_<bridge>.leases
cat /etc/dnsmasq.d/<netns>_<bridge>.conf
**Backend ``two``**
.. 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.
La VM a une adresse mais cloud-init n'applique rien
---------------------------------------------------
Deux causes distinctes, à écarter dans cet ordre.
**1. La VM porte déjà cet ``instance-id``.** ``instance-id`` vaut le nom de la VM : sur un disque
déjà provisionné sous le même nom, cloud-init considère l'instance connue et ne rejoue pas le
user-data. Vérification dans le guest :
.. code-block:: bash
cloud-init query instance-id
ls /var/lib/cloud/instances/
**2. Le serveur de metadata est injoignable.** Depuis le guest :
.. code-block:: bash
ip route
curl -s http://169.254.169.254/latest/meta-data/
La route ``169.254.169.254/32`` doit être présente, avec l'``interface_ip`` du subnet comme
next-hop. Si elle est absente ou pointe ailleurs, la DNAT posée en ``PREROUTING`` dans le netns
n'est jamais traversée : la trame est commutée en L2 et le serveur reste injoignable. Voir
:doc:`/concepts/modes-reseau`.
Depuis l'host, l'instance correspondante :
.. code-block:: bash
systemctl status 'metadata@<vm>'
ls -l /run/two/metadata/<vm>/
Le user-data est servi vide
---------------------------
Un document fourni explicitement vide est servi vide — ce n'est pas la même chose qu'un document
absent, qui retombe sur le template. Vérifier le contenu réellement écrit :
.. code-block:: bash
cat /run/two/metadata/<vm>/user-data
Un base64 invalide, lui, aurait été rejeté en 400 à la création.
La VM ne démarre pas (UEFI)
---------------------------
``uefi: true`` exige les fichiers OVMF déclarés dans la configuration :
.. code-block:: bash
ls -l /usr/share/OVMF/OVMF_CODE.fd /usr/share/OVMF/OVMF_VARS.fd
ls -l /run/two/vms/efi/
Sur Debian et Ubuntu, le paquet est ``ovmf``.
Le ``DELETE`` renvoie 409
-------------------------
La suppression n'est autorisée que depuis ``running`` ou ``error``. Depuis ``creating`` ou
``deleting``, attendre l'état stable. Pour un VPC, tous les subnets doivent être supprimés
d'abord.
La base et le système ont divergé
---------------------------------
Le watchdog signale une ressource ``running`` absente du système. Il ne répare rien : la
correction est un ``DELETE`` explicite suivi d'une recréation. Après un redémarrage de l'agent,
les ressources restées transitoires sont basculées en ``error`` par la migration de démarrage —
elles n'ont pas forcément échoué, elles ont été interrompues.
L'agent ne redémarre pas après un arrêt brutal
----------------------------------------------
Badger rejoue son journal au démarrage : c'est normal et attendu, notamment si le budget d'arrêt
précédent a été dépassé et que la base n'a pas été fermée. Si le démarrage échoue vraiment, le
message se trouve dans ``journalctl -u agent``.
.. note::
Les VM ne sont pas réattachées au redémarrage de l'agent : un processus QEMU survivant à
l'agent n'est plus piloté par lui.

View file

@ -1,14 +0,0 @@
Exploitation
============
Faire tourner un hyperviseur en service : configuration, services systemd, API de l'agent,
observabilité et diagnostic.
.. toctree::
:maxdepth: 1
configuration
services
api-agent/index
observabilite
diagnostic

View file

@ -1,84 +0,0 @@
Observabilité
=============
Métriques Prometheus
--------------------
Exposées sur le port ``prometheus.port`` (9090 par défaut), alimentées par l'état lu en base :
.. list-table::
:header-rows: 1
:widths: 40 60
* - Métrique
- Description
* - ``syonad_vpcs_total``
- nombre de VPC, par état
* - ``syonad_subnets_total``
- nombre de subnets, par état
* - ``syonad_vms_total``
- nombre de VM, par état
Les états sont ceux du :doc:`cycle de vie </concepts/cycle-de-vie>`. Une valeur non nulle et
durable sur ``error`` est l'alerte la plus utile à poser ; une valeur durable sur ``creating``
ou ``deleting`` signale une opération qui n'aboutit pas.
Watchdog
--------
Une goroutine périodique vérifie que les ressources marquées ``running`` en base existent
toujours sur le système, et **notifie les écarts sans jamais réparer**.
.. code-block:: yaml
watchdog:
enabled: true
interval_seconds: 60
Il contrôle notamment l'existence des network namespaces, des liens réseau des subnets, des taps
de VM et la réponse des units systemd associées.
.. note::
Un écart persistant est signalé **à chaque tick**, sans déduplication. Le volume de
notifications est donc proportionnel à la durée de l'anomalie : c'est voulu, mais cela veut
dire qu'une alerte doit agréger, pas compter.
Le watchdog étant strictement en lecture seule, une divergence entre la base et le système
subsiste jusqu'à une action explicite (``DELETE`` puis recréation).
Journaux
--------
``slog`` structuré, niveau réglé par ``logger.level`` (``debug``, ``info``, ``warn``, ``error``),
``logger.debug: true`` forçant ``debug``.
.. code-block:: bash
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
Inspection de la base
---------------------
En ligne de commande, sur l'host :
.. code-block:: bash
/opt/two/bin/db -conf /etc/two/agent.yml
.. warning::
``db`` ouvre directement la base Badger. **Ne pas l'utiliser pendant que l'agent tourne** :
deux processus ne doivent pas ouvrir la même instance.
L'API d'administration donne la même lecture sans ce risque, quand elle est activée :
.. code-block:: bash
curl -s 'http://127.0.0.1:9091/db?prefix=vm/'
Elle expose l'intégralité des valeurs, **y compris les hashs de mot de passe** — cf.
:doc:`/exploitation/configuration`.

View file

@ -1,132 +0,0 @@
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`).
.. list-table::
:header-rows: 1
:widths: 28 32 40
* - Unit
- Instance ``%i``
- Rôle
* - ``agent.service``
- —
- processus principal : API, dispatcher, exécution, watchdog
* - ``dnsmasq@.service``
- ``<netns>_<bridge>``
- dnsmasq lancé dans le netns du VPC, un par subnet — backend ``dnsmasq``
* - ``dhcp@.service``
- ``<netns>_<bridge>``
- serveur DHCP intégré, un par subnet — backend ``two``
* - ``metadata@.service``
- ``<nom de la VM>``
- serveur de metadata cloud-init, un par VM
Les instances sont créées et pilotées par l'agent au fil des créations de subnets et de VM : il
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 '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 :
.. list-table::
:widths: 40 60
* - Configuration
- ``/etc/dnsmasq.d/<netns>_<bridge>.conf``
* - Baux
- ``/run/dnsmasq-<netns>_<bridge>.leases``
* - Journal
- ``/var/log/dnsmasq-<netns>_<bridge>.log``
* - PID
- ``/run/dnsmasq-<netns>_<bridge>.pid``
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/<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
-----------------------
Les processus QEMU sont lancés par ``systemd-run --scope``, **jamais** en unit transitoire. Un
scope est exécuté par le processus appelant et hérite donc du network namespace posé par
l'agent ; une unit transitoire, forkée par PID 1, démarrerait dans le netns racine et ne verrait
pas le tap de la VM.
Conséquence pratique : les VM n'apparaissent pas dans ``systemctl list-units`` mais dans
``systemd-cgls``, et elles ne survivent pas à un ``systemctl stop agent`` suivi d'un
redémarrage — l'agent ne réattache pas les VM existantes.
Sockets par VM
--------------
.. code-block:: text
/run/two/vms/serial/<vm>.sock console série
/run/two/vms/monitor/<vm>.sock monitor QEMU
/run/two/vms/qmp/<vm>.sock QMP (utilisé par l'agent)
.. code-block:: bash
socat -,raw,echo=0 UNIX-CONNECT:/run/two/vms/serial/i-web.sock
Arrêt de l'agent
----------------
L'ordre d'arrêt est imposé : serveurs HTTP, puis drainage des workers, puis fermeture de la base.
Si le budget d'arrêt est dépassé, la base **n'est pas fermée** — fermer Badger sous un écrivain
concurrent est pire qu'un rejeu du journal au démarrage suivant. Un message à ce sujet dans le
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
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.

View file

@ -1,75 +0,0 @@
two
===
**two** est un orchestrateur de virtualisation et de réseau : il pilote un parc d'hyperviseurs,
le réseau qui les relie, et les machines virtuelles qui y tournent.
Il se compose de plusieurs éléments, déployés et versionnés séparément.
.. list-table::
:header-rows: 1
:widths: 22 58 20
* - Composant
- Rôle
- État
* - **agent**
- un par hyperviseur : expose une API HTTP qui crée des VPC — isolés par network
namespace —, des subnets — VXLAN ou bridge — et des VM QEMU/KVM raccordées à ces
subnets, avec DHCP, routage et metadata cloud-init
- livré (0.1.0)
* - *à venir*
- les composants de niveau supérieur — ordonnancement sur le parc, API d'orchestration,
interface d'administration — sont à documenter au fur et à mesure de leur livraison
- à venir
À ce stade, la totalité de cette documentation porte donc sur l'**agent** et sur le réseau du
cluster qui l'entoure.
Par où commencer
----------------
:doc:`/demarrage/index`
Installer l'agent sur un hyperviseur et créer un premier VPC, un subnet et une VM. C'est le
parcours court, sur un nœud isolé.
:doc:`/deploiement/index`
L'architecture complète : réseau du cluster, routage, et ce qu'il faut mettre en place avant
qu'un parc d'hyperviseurs fonctionne ensemble.
:doc:`/exploitation/index`
Configuration, services, API de l'agent, métriques et diagnostic sur un nœud en service.
:doc:`/developpement/index`
Outillage de développement de two, dont le lab de test multi-nœud sur serveur loué à l'heure.
:doc:`/concepts/index`
Comment les éléments fonctionnent entre eux : modèle de données, modes réseau, cycle de vie,
metadata. À lire avant de diagnostiquer un comportement inattendu.
.. toctree::
:hidden:
:caption: Mise en œuvre
demarrage/index
deploiement/index
.. toctree::
:hidden:
:caption: Exploitation
exploitation/index
.. toctree::
:hidden:
:caption: Développement
developpement/index
.. toctree::
:hidden:
:caption: Interne
concepts/index
architecture/index
versions/index

View file

@ -1,6 +0,0 @@
sphinx==9.1.0
sphinx-autobuild==2025.8.25
sphinx-book-theme==1.2.0
myst-parser==5.1.0
sphinxcontrib.mermaid==2.0.2
sphinxcontrib-openapi==0.9.0

View file

@ -1,155 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 736" width="1000" height="736" role="img" aria-label="Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle">
<style>
.lbl { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; fill: #e6edf3; }
.sub { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #9198a1; }
.grp { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; font-weight: 600; fill: #9198a1; letter-spacing: .04em; }
.ctrlt { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #c297ff; }
.datat { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; font-weight: 600; fill: #4493f8; }
.box { fill: #22272e; stroke: #6e7781; stroke-width: 1.5; }
.group { fill: #1c2128; stroke: #444c56; stroke-width: 1.5; }
.pill { fill: #22272e; stroke: #9198a1; stroke-width: 1.5; }
.bus { stroke: #9198a1; stroke-width: 4; fill: none; stroke-linecap: round; }
.link { stroke: #9198a1; stroke-width: 1.8; fill: none; }
.ctrl { stroke: #c297ff; stroke-width: 1.6; fill: none; stroke-dasharray: 5 4; }
.data { stroke: #4493f8; stroke-width: 3; fill: none; }
.made { stroke: #6e7781; stroke-width: 1.5; fill: none; stroke-dasharray: 2 3; }
.head-ctrl { fill: #c297ff; }
.head-data { fill: #4493f8; }
.head-made { fill: #6e7781; }
</style>
<defs>
<marker id="a-ctrl" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="head-ctrl" d="M0 0 L10 5 L0 10 z"/></marker>
<marker id="a-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path class="head-data" d="M0 0 L10 5 L0 10 z"/></marker>
<marker id="a-made" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="head-made" d="M0 0 L10 5 L0 10 z"/></marker>
</defs>
<rect class="group" x="40" y="20" width="400" height="144" rx="8"/>
<text class="grp" x="60" y="44">ROUTEURS DE CLUSTER · AS 65100</text>
<rect class="box" x="60" y="66" width="170" height="60" rx="6"/>
<text class="lbl" x="145" y="92" text-anchor="middle">routeur 1</text>
<text class="sub" x="145" y="111" text-anchor="middle">169.254.0.1/28</text>
<rect class="box" x="250" y="66" width="170" height="60" rx="6"/>
<text class="lbl" x="335" y="92" text-anchor="middle">routeur 2</text>
<text class="sub" x="335" y="111" text-anchor="middle">169.254.0.2/28</text>
<text class="sub" x="240" y="150" text-anchor="middle">passerelle 192.168.14.1/24</text>
<rect class="group" x="560" y="20" width="400" height="144" rx="8"/>
<text class="grp" x="580" y="44">VM ROUTE REFLECTOR · AS 65000</text>
<text class="lbl" x="580" y="70">lo1 · 10.255.255.1/32</text>
<text class="sub" x="580" y="87">router-id, cluster-id, source des sessions EVPN</text>
<text class="lbl" x="580" y="111">192.168.14.2/24 · 169.254.0.3/28</text>
<text class="sub" x="580" y="128">principale, et secondaire pour le lien avec les routeurs</text>
<text class="sub" x="580" y="150">hébergée par le cluster lui-même</text>
<path class="ctrl" d="M440 92 L560 92"/>
<text class="ctrlt" x="500" y="84" text-anchor="middle">eBGP · BFD (×2)</text>
<text class="ctrlt" x="500" y="108" text-anchor="middle">annonce lo1 seule</text>
<text class="ctrlt" x="500" y="122" text-anchor="middle">n'accepte rien</text>
<path class="link" d="M145 126 L145 196"/>
<path class="link" d="M335 126 L335 196"/>
<path class="link" d="M760 164 L760 196"/>
<path class="bus" d="M40 196 L960 196"/>
<rect class="pill" x="376" y="177" width="216" height="38" rx="19"/>
<text class="lbl" x="484" y="194" text-anchor="middle">segment underlay · L2</text>
<text class="sub" x="484" y="209" text-anchor="middle">192.168.14.0/24 · 169.254.0.0/28</text>
<rect class="group" x="40" y="262" width="430" height="306" rx="8"/>
<text class="grp" x="60" y="284">HYPERVISEUR 1</text>
<rect class="box" x="274" y="298" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="317" text-anchor="middle">br-000000</text>
<text class="sub" x="364" y="334" text-anchor="middle">uplink · 192.168.14.11</text>
<rect class="box" x="274" y="366" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="385" text-anchor="middle">vxlan-&lt;vni&gt;</text>
<text class="sub" x="364" y="402" text-anchor="middle">dans br-&lt;subnet&gt;, sans learning</text>
<rect class="box" x="274" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="453" text-anchor="middle">netns du VPC</text>
<text class="sub" x="364" y="470" text-anchor="middle">passerelle, DHCP</text>
<rect class="box" x="274" y="502" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="521" text-anchor="middle">VM</text>
<text class="sub" x="364" y="538" text-anchor="middle">QEMU / KVM</text>
<path class="link" d="M364 344 L364 366"/>
<text class="sub" x="372" y="359">VTEP</text>
<path class="link" d="M364 412 L364 434"/>
<text class="sub" x="372" y="427">veth</text>
<path class="link" d="M364 480 L364 502"/>
<text class="sub" x="372" y="495">tap</text>
<rect class="box" x="56" y="298" width="180" height="70" rx="6"/>
<text class="lbl" x="146" y="321" text-anchor="middle">FRR</text>
<text class="sub" x="146" y="340" text-anchor="middle">bgpd · AS 64600</text>
<text class="sub" x="146" y="356" text-anchor="middle">advertise-all-vni</text>
<rect class="box" x="56" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="146" y="453" text-anchor="middle">agent</text>
<text class="sub" x="146" y="470" text-anchor="middle">API REST · Badger</text>
<path class="ctrl" d="M236 352 L255 352 L255 389 L272 389" marker-end="url(#a-ctrl)"/>
<text class="ctrlt" x="255" y="345" text-anchor="middle">FDB</text>
<path class="made" d="M236 457 L272 457" marker-end="url(#a-made)"/>
<text class="sub" x="255" y="450" text-anchor="middle">crée</text>
<rect class="group" x="530" y="262" width="430" height="306" rx="8"/>
<text class="grp" x="656" y="284">HYPERVISEUR 2</text>
<rect class="box" x="546" y="298" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="317" text-anchor="middle">br-000000</text>
<text class="sub" x="636" y="334" text-anchor="middle">uplink · 192.168.14.12</text>
<rect class="box" x="546" y="366" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="385" text-anchor="middle">vxlan-&lt;vni&gt;</text>
<text class="sub" x="636" y="402" text-anchor="middle">dans br-&lt;subnet&gt;, sans learning</text>
<rect class="box" x="546" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="453" text-anchor="middle">netns du VPC</text>
<text class="sub" x="636" y="470" text-anchor="middle">passerelle, DHCP</text>
<rect class="box" x="546" y="502" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="521" text-anchor="middle">VM</text>
<text class="sub" x="636" y="538" text-anchor="middle">QEMU / KVM</text>
<path class="link" d="M636 344 L636 366"/>
<text class="sub" x="628" y="359" text-anchor="end">VTEP</text>
<path class="link" d="M636 412 L636 434"/>
<text class="sub" x="628" y="427" text-anchor="end">veth</text>
<path class="link" d="M636 480 L636 502"/>
<text class="sub" x="628" y="495" text-anchor="end">tap</text>
<rect class="box" x="764" y="298" width="180" height="70" rx="6"/>
<text class="lbl" x="854" y="321" text-anchor="middle">FRR</text>
<text class="sub" x="854" y="340" text-anchor="middle">bgpd · AS 64600</text>
<text class="sub" x="854" y="356" text-anchor="middle">advertise-all-vni</text>
<rect class="box" x="764" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="854" y="453" text-anchor="middle">agent</text>
<text class="sub" x="854" y="470" text-anchor="middle">API REST · Badger</text>
<path class="ctrl" d="M764 352 L745 352 L745 389 L728 389" marker-end="url(#a-ctrl)"/>
<text class="ctrlt" x="745" y="345" text-anchor="middle">FDB</text>
<path class="made" d="M764 457 L728 457" marker-end="url(#a-made)"/>
<text class="sub" x="745" y="450" text-anchor="middle">crée</text>
<path class="link" d="M364 298 L364 196"/>
<path class="link" d="M636 298 L636 196"/>
<path class="ctrl" d="M210 298 L210 246 L357 246"/>
<path class="ctrl" d="M371 246 L612 246 L612 164"/>
<path class="ctrl" d="M854 298 L854 164"/>
<text class="ctrlt" x="384" y="240">iBGP l2vpn evpn · AS 64600</text>
<text class="ctrlt" x="862" y="240">iBGP</text>
<path class="data" d="M458 389 L542 389" marker-start="url(#a-data)" marker-end="url(#a-data)"/>
<text class="datat" x="500" y="378" text-anchor="middle">VXLAN</text>
<text class="datat" x="500" y="406" text-anchor="middle">UDP 4789</text>
<path class="link" d="M40 604 L76 604"/>
<text class="sub" x="84" y="608">lien L2 / interface</text>
<path class="data" d="M220 604 L256 604"/>
<text class="sub" x="264" y="608">plan de données : tunnels VXLAN</text>
<path class="ctrl" d="M470 604 L506 604"/>
<text class="sub" x="514" y="608">plan de contrôle : sessions BGP</text>
<path class="made" d="M716 604 L752 604"/>
<text class="sub" x="760" y="608">créé par l'agent</text>
<text class="sub" x="40" y="640">Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer.</text>
<text class="sub" x="40" y="658">Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs.</text>
<text class="sub" x="40" y="676">Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5).</text>
<text class="sub" x="40" y="694">MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab.</text>
<text class="sub" x="40" y="712">ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger.</text>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,155 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 736" width="1000" height="736" role="img" aria-label="Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle">
<style>
.lbl { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; fill: #1f2328; }
.sub { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #57606a; }
.grp { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; font-weight: 600; fill: #57606a; letter-spacing: .04em; }
.ctrlt { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #8250df; }
.datat { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; font-weight: 600; fill: #0969da; }
.box { fill: #ffffff; stroke: #8c959f; stroke-width: 1.5; }
.group { fill: #f6f8fa; stroke: #d0d7de; stroke-width: 1.5; }
.pill { fill: #ffffff; stroke: #57606a; stroke-width: 1.5; }
.bus { stroke: #57606a; stroke-width: 4; fill: none; stroke-linecap: round; }
.link { stroke: #57606a; stroke-width: 1.8; fill: none; }
.ctrl { stroke: #8250df; stroke-width: 1.6; fill: none; stroke-dasharray: 5 4; }
.data { stroke: #0969da; stroke-width: 3; fill: none; }
.made { stroke: #8c959f; stroke-width: 1.5; fill: none; stroke-dasharray: 2 3; }
.head-ctrl { fill: #8250df; }
.head-data { fill: #0969da; }
.head-made { fill: #8c959f; }
</style>
<defs>
<marker id="a-ctrl" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="head-ctrl" d="M0 0 L10 5 L0 10 z"/></marker>
<marker id="a-data" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="5" markerHeight="5" orient="auto-start-reverse"><path class="head-data" d="M0 0 L10 5 L0 10 z"/></marker>
<marker id="a-made" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path class="head-made" d="M0 0 L10 5 L0 10 z"/></marker>
</defs>
<rect class="group" x="40" y="20" width="400" height="144" rx="8"/>
<text class="grp" x="60" y="44">ROUTEURS DE CLUSTER · AS 65100</text>
<rect class="box" x="60" y="66" width="170" height="60" rx="6"/>
<text class="lbl" x="145" y="92" text-anchor="middle">routeur 1</text>
<text class="sub" x="145" y="111" text-anchor="middle">169.254.0.1/28</text>
<rect class="box" x="250" y="66" width="170" height="60" rx="6"/>
<text class="lbl" x="335" y="92" text-anchor="middle">routeur 2</text>
<text class="sub" x="335" y="111" text-anchor="middle">169.254.0.2/28</text>
<text class="sub" x="240" y="150" text-anchor="middle">passerelle 192.168.14.1/24</text>
<rect class="group" x="560" y="20" width="400" height="144" rx="8"/>
<text class="grp" x="580" y="44">VM ROUTE REFLECTOR · AS 65000</text>
<text class="lbl" x="580" y="70">lo1 · 10.255.255.1/32</text>
<text class="sub" x="580" y="87">router-id, cluster-id, source des sessions EVPN</text>
<text class="lbl" x="580" y="111">192.168.14.2/24 · 169.254.0.3/28</text>
<text class="sub" x="580" y="128">principale, et secondaire pour le lien avec les routeurs</text>
<text class="sub" x="580" y="150">hébergée par le cluster lui-même</text>
<path class="ctrl" d="M440 92 L560 92"/>
<text class="ctrlt" x="500" y="84" text-anchor="middle">eBGP · BFD (×2)</text>
<text class="ctrlt" x="500" y="108" text-anchor="middle">annonce lo1 seule</text>
<text class="ctrlt" x="500" y="122" text-anchor="middle">n'accepte rien</text>
<path class="link" d="M145 126 L145 196"/>
<path class="link" d="M335 126 L335 196"/>
<path class="link" d="M760 164 L760 196"/>
<path class="bus" d="M40 196 L960 196"/>
<rect class="pill" x="376" y="177" width="216" height="38" rx="19"/>
<text class="lbl" x="484" y="194" text-anchor="middle">segment underlay · L2</text>
<text class="sub" x="484" y="209" text-anchor="middle">192.168.14.0/24 · 169.254.0.0/28</text>
<rect class="group" x="40" y="262" width="430" height="306" rx="8"/>
<text class="grp" x="60" y="284">HYPERVISEUR 1</text>
<rect class="box" x="274" y="298" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="317" text-anchor="middle">br-000000</text>
<text class="sub" x="364" y="334" text-anchor="middle">uplink · 192.168.14.11</text>
<rect class="box" x="274" y="366" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="385" text-anchor="middle">vxlan-&lt;vni&gt;</text>
<text class="sub" x="364" y="402" text-anchor="middle">dans br-&lt;subnet&gt;, sans learning</text>
<rect class="box" x="274" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="453" text-anchor="middle">netns du VPC</text>
<text class="sub" x="364" y="470" text-anchor="middle">passerelle, DHCP</text>
<rect class="box" x="274" y="502" width="180" height="46" rx="6"/>
<text class="lbl" x="364" y="521" text-anchor="middle">VM</text>
<text class="sub" x="364" y="538" text-anchor="middle">QEMU / KVM</text>
<path class="link" d="M364 344 L364 366"/>
<text class="sub" x="372" y="359">VTEP</text>
<path class="link" d="M364 412 L364 434"/>
<text class="sub" x="372" y="427">veth</text>
<path class="link" d="M364 480 L364 502"/>
<text class="sub" x="372" y="495">tap</text>
<rect class="box" x="56" y="298" width="180" height="70" rx="6"/>
<text class="lbl" x="146" y="321" text-anchor="middle">FRR</text>
<text class="sub" x="146" y="340" text-anchor="middle">bgpd · AS 64600</text>
<text class="sub" x="146" y="356" text-anchor="middle">advertise-all-vni</text>
<rect class="box" x="56" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="146" y="453" text-anchor="middle">agent</text>
<text class="sub" x="146" y="470" text-anchor="middle">API REST · Badger</text>
<path class="ctrl" d="M236 352 L255 352 L255 389 L272 389" marker-end="url(#a-ctrl)"/>
<text class="ctrlt" x="255" y="345" text-anchor="middle">FDB</text>
<path class="made" d="M236 457 L272 457" marker-end="url(#a-made)"/>
<text class="sub" x="255" y="450" text-anchor="middle">crée</text>
<rect class="group" x="530" y="262" width="430" height="306" rx="8"/>
<text class="grp" x="656" y="284">HYPERVISEUR 2</text>
<rect class="box" x="546" y="298" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="317" text-anchor="middle">br-000000</text>
<text class="sub" x="636" y="334" text-anchor="middle">uplink · 192.168.14.12</text>
<rect class="box" x="546" y="366" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="385" text-anchor="middle">vxlan-&lt;vni&gt;</text>
<text class="sub" x="636" y="402" text-anchor="middle">dans br-&lt;subnet&gt;, sans learning</text>
<rect class="box" x="546" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="453" text-anchor="middle">netns du VPC</text>
<text class="sub" x="636" y="470" text-anchor="middle">passerelle, DHCP</text>
<rect class="box" x="546" y="502" width="180" height="46" rx="6"/>
<text class="lbl" x="636" y="521" text-anchor="middle">VM</text>
<text class="sub" x="636" y="538" text-anchor="middle">QEMU / KVM</text>
<path class="link" d="M636 344 L636 366"/>
<text class="sub" x="628" y="359" text-anchor="end">VTEP</text>
<path class="link" d="M636 412 L636 434"/>
<text class="sub" x="628" y="427" text-anchor="end">veth</text>
<path class="link" d="M636 480 L636 502"/>
<text class="sub" x="628" y="495" text-anchor="end">tap</text>
<rect class="box" x="764" y="298" width="180" height="70" rx="6"/>
<text class="lbl" x="854" y="321" text-anchor="middle">FRR</text>
<text class="sub" x="854" y="340" text-anchor="middle">bgpd · AS 64600</text>
<text class="sub" x="854" y="356" text-anchor="middle">advertise-all-vni</text>
<rect class="box" x="764" y="434" width="180" height="46" rx="6"/>
<text class="lbl" x="854" y="453" text-anchor="middle">agent</text>
<text class="sub" x="854" y="470" text-anchor="middle">API REST · Badger</text>
<path class="ctrl" d="M764 352 L745 352 L745 389 L728 389" marker-end="url(#a-ctrl)"/>
<text class="ctrlt" x="745" y="345" text-anchor="middle">FDB</text>
<path class="made" d="M764 457 L728 457" marker-end="url(#a-made)"/>
<text class="sub" x="745" y="450" text-anchor="middle">crée</text>
<path class="link" d="M364 298 L364 196"/>
<path class="link" d="M636 298 L636 196"/>
<path class="ctrl" d="M210 298 L210 246 L357 246"/>
<path class="ctrl" d="M371 246 L612 246 L612 164"/>
<path class="ctrl" d="M854 298 L854 164"/>
<text class="ctrlt" x="384" y="240">iBGP l2vpn evpn · AS 64600</text>
<text class="ctrlt" x="862" y="240">iBGP</text>
<path class="data" d="M458 389 L542 389" marker-start="url(#a-data)" marker-end="url(#a-data)"/>
<text class="datat" x="500" y="378" text-anchor="middle">VXLAN</text>
<text class="datat" x="500" y="406" text-anchor="middle">UDP 4789</text>
<path class="link" d="M40 604 L76 604"/>
<text class="sub" x="84" y="608">lien L2 / interface</text>
<path class="data" d="M220 604 L256 604"/>
<text class="sub" x="264" y="608">plan de données : tunnels VXLAN</text>
<path class="ctrl" d="M470 604 L506 604"/>
<text class="sub" x="514" y="608">plan de contrôle : sessions BGP</text>
<path class="made" d="M716 604 L752 604"/>
<text class="sub" x="760" y="608">créé par l'agent</text>
<text class="sub" x="40" y="640">Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer.</text>
<text class="sub" x="40" y="658">Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs.</text>
<text class="sub" x="40" y="676">Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5).</text>
<text class="sub" x="40" y="694">MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab.</text>
<text class="sub" x="40" y="712">ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger.</text>
</svg>

Before

Width:  |  Height:  |  Size: 10 KiB

View file

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

View file

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

View file

@ -1,13 +0,0 @@
Versions
========
Chaque version porte un nom de code dérivé du rang de sa publication : anges et démons alternés.
.. toctree::
:maxdepth: 1
0.2.0
0.1.0
.. include:: ../../release_notes/codenames.md
:parser: myst_parser.sphinx_

33
go.mod
View file

@ -1,25 +1,14 @@
module git.g3e.fr/syonad/two
go 1.25.0
go 1.24.0
toolchain go1.25.14
require (
github.com/coreos/go-systemd/v22 v22.6.0
github.com/dgraph-io/badger/v4 v4.8.0
github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2
github.com/prometheus/client_golang v1.23.2
github.com/prometheus/client_model v0.6.2
github.com/spf13/viper v1.21.0
github.com/vishvananda/netlink v1.3.1
github.com/vishvananda/netns v0.0.5
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/sys v0.45.0
)
toolchain go1.24.11
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/coreos/go-systemd/v22 v22.6.0 // indirect
github.com/dgraph-io/badger/v4 v4.8.0 // indirect
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
@ -28,11 +17,11 @@ require (
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/google/flatbuffers v25.2.10+incompatible // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.66.1 // indirect
github.com/prometheus/procfs v0.16.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
@ -40,14 +29,18 @@ require (
github.com/spf13/afero v1.15.0 // indirect
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/spf13/viper v1.21.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/otel v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.0 // indirect
go.yaml.in/yaml/v2 v2.4.2 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/text v0.37.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.28.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
)

54
go.sum
View file

@ -4,18 +4,12 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/coreos/go-systemd/v22 v22.6.0 h1:aGVa/v8B7hpb0TKl0MWoAavPDmHvobFe5R5zn0bCJWo=
github.com/coreos/go-systemd/v22 v22.6.0/go.mod h1:iG+pp635Fo7ZmV/j14KUcmEyWF+0X7Lua8rrTWzYgWU=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.8.0 h1:JYph1ChBijCw8SLeybvPINizbDKWZ5n/GYbz2yhN/bs=
github.com/dgraph-io/badger/v4 v4.8.0/go.mod h1:U6on6e8k/RTbUWxqKR0MvugJuVmkxSNc79ap4917h4w=
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@ -29,29 +23,12 @@ github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2 h1:rhbGNh5bIA6JYahHdphjFiIbavqVspsvXFvTpBR7dQE=
github.com/insomniacslk/dhcp v0.0.0-20260831074340-8416b400a2b2/go.mod h1:tGfUTcnFYGYvVNCaZZhwlJySU/fQQxh9TmpsFzWXnnY=
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
@ -60,8 +37,6 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
@ -74,12 +49,8 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
@ -92,25 +63,24 @@ go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/Wgbsd
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/sys v0.0.0-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -23,5 +23,5 @@ func newTestServer(t *testing.T) (*Server, *badger.DB) {
cfg := &configuration.Config{DefaultInterface: "br-test"}
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
d := dispatcher.New(q, db, cfg, logger)
return New(d, db, logger, "127.0.0.1:0"), db
return New(d, db, logger), db
}

View file

@ -20,7 +20,6 @@ type SubnetCreateRequest struct {
InterfaceIP string `json:"interface_ip"`
CIDR string `json:"cidr"`
DefaultRoute bool `json:"default_route"`
Gateway string `json:"gateway"`
}
type Subnet struct {
@ -33,7 +32,6 @@ type Subnet struct {
InterfaceIP string `json:"interface_ip"`
CIDR string `json:"cidr"`
DefaultRoute bool `json:"default_route"`
Gateway string `json:"gateway"`
}
type VMInterface struct {
@ -47,18 +45,13 @@ type VMStorage struct {
Dev string `json:"dev"`
}
type VMMetadata struct {
Password string `json:"password"`
SSHKey string `json:"sshkey"`
UserData string `json:"user_data"`
}
type VMCreateRequest struct {
Name string `json:"name"`
Memory int `json:"memory"`
CPUs int `json:"cpus"`
UEFI bool `json:"uefi"`
Metadata VMMetadata `json:"metadata"`
Password string `json:"password"`
SSHKey string `json:"sshkey"`
Interfaces []VMInterface `json:"interfaces"`
Storage []VMStorage `json:"storage"`
}

View file

@ -1,10 +1,8 @@
package agentapi
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"log/slog"
"net/http"
"time"
@ -17,11 +15,13 @@ type Server struct {
dispatcher *dispatcher.Dispatcher
db *badger.DB
logger *slog.Logger
srv *http.Server
}
func New(d *dispatcher.Dispatcher, db *badger.DB, logger *slog.Logger, address string) *Server {
s := &Server{dispatcher: d, db: db, logger: logger}
func New(d *dispatcher.Dispatcher, db *badger.DB, logger *slog.Logger) *Server {
return &Server{dispatcher: d, db: db, logger: logger}
}
func (s *Server) Start(address string) {
mux := http.NewServeMux()
mux.HandleFunc("/vpcs", s.VpcsHandler)
mux.HandleFunc("/vpcs/", s.VpcByNameHandler)
@ -29,21 +29,12 @@ func New(d *dispatcher.Dispatcher, db *badger.DB, logger *slog.Logger, address s
mux.HandleFunc("/subnets/", s.SubnetByNameHandler)
mux.HandleFunc("/vms", s.VmsHandler)
mux.HandleFunc("/vms/", s.VmByNameHandler)
s.srv = &http.Server{Addr: address, Handler: s.logMiddleware(mux)}
return s
}
func (s *Server) Start() {
s.logger.Info("API server listening", "address", s.srv.Addr)
if err := s.srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
s.logger.Info("API server listening", "address", address)
if err := http.ListenAndServe(address, s.logMiddleware(mux)); err != nil {
s.logger.Error("API server stopped", "error", err)
}
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.srv.Shutdown(ctx)
}
type statusWriter struct {
http.ResponseWriter
status int

View file

@ -59,8 +59,6 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string)
sub.CIDR = value
case "default_route":
sub.DefaultRoute = value == "true"
case "gateway":
sub.Gateway = value
}
}
w.WriteHeader(http.StatusOK)
@ -70,13 +68,7 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string)
func (s *Server) deleteSubnet(w http.ResponseWriter, _ *http.Request, name string) {
cmd := dispatcher.DeleteSubnetCommand{Name: name}
if err := s.dispatcher.Prepare(cmd); err != nil {
// 404 si la ressource n'existe pas, 409 si elle existe mais n'est pas
// dans un état supprimable — même convention que /vpcs et /vms.
if _, dbErr := kv.GetFromDB(s.db, "subnet/"+name+"/state"); dbErr != nil {
w.WriteHeader(http.StatusNotFound)
} else {
w.WriteHeader(http.StatusConflict)
}
w.WriteHeader(http.StatusNotFound)
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
return
}

View file

@ -28,7 +28,7 @@ func TestListSubnets_Empty(t *testing.T) {
func TestListSubnets_WithData(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/state", "created")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
kv.AddInDB(db, "subnet/sn-2/state", "creating")
kv.AddInDB(db, "subnet/sn-2/vpc", "vpc-1")
@ -55,13 +55,13 @@ func TestListSubnets_InvalidMethod(t *testing.T) {
func TestPostSubnet_Created(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-1/state", "running")
kv.AddInDB(db, "vpc/vpc-1/state", "created")
req := SubnetCreateRequest{
Name: "sn-new",
VPC: "vpc-1",
IfaceType: "vms",
Name: "sn-new",
VPC: "vpc-1",
IfaceType: "vms",
InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24",
CIDR: "10.0.0.0/24",
}
body, _ := json.Marshal(req)
w := httptest.NewRecorder()
@ -91,12 +91,12 @@ func TestPostSubnet_MissingFields(t *testing.T) {
func TestPostSubnet_IfaceTypeOptional(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-1/state", "running")
kv.AddInDB(db, "vpc/vpc-1/state", "created")
req := SubnetCreateRequest{
Name: "sn-opt",
VPC: "vpc-1",
Name: "sn-opt",
VPC: "vpc-1",
InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24",
CIDR: "10.0.0.0/24",
// IfaceType omis — doit utiliser default_interface
}
body, _ := json.Marshal(req)
@ -110,11 +110,11 @@ func TestPostSubnet_IfaceTypeOptional(t *testing.T) {
func TestPostSubnet_VPCNotFound(t *testing.T) {
s, _ := newTestServer(t)
req := SubnetCreateRequest{
Name: "sn-1",
VPC: "vpc-inexistant",
IfaceType: "vms",
Name: "sn-1",
VPC: "vpc-inexistant",
IfaceType: "vms",
InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24",
CIDR: "10.0.0.0/24",
}
body, _ := json.Marshal(req)
w := httptest.NewRecorder()
@ -126,14 +126,14 @@ func TestPostSubnet_VPCNotFound(t *testing.T) {
func TestPostSubnet_Duplicate(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-1/state", "running")
kv.AddInDB(db, "subnet/sn-exist/state", "running")
kv.AddInDB(db, "vpc/vpc-1/state", "created")
kv.AddInDB(db, "subnet/sn-exist/state", "created")
req := SubnetCreateRequest{
Name: "sn-exist",
VPC: "vpc-1",
IfaceType: "vms",
Name: "sn-exist",
VPC: "vpc-1",
IfaceType: "vms",
InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24",
CIDR: "10.0.0.0/24",
}
body, _ := json.Marshal(req)
w := httptest.NewRecorder()
@ -147,11 +147,11 @@ func TestPostSubnet_VPCDeleting(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-dying/state", "deleting")
req := SubnetCreateRequest{
Name: "sn-1",
VPC: "vpc-dying",
IfaceType: "vms",
Name: "sn-1",
VPC: "vpc-dying",
IfaceType: "vms",
InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24",
CIDR: "10.0.0.0/24",
}
body, _ := json.Marshal(req)
w := httptest.NewRecorder()
@ -163,14 +163,14 @@ func TestPostSubnet_VPCDeleting(t *testing.T) {
func TestPostSubnet_BridgeMode_Success(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-1/state", "running")
kv.AddInDB(db, "vpc/vpc-1/state", "created")
req := SubnetCreateRequest{
Name: "sn-br",
VPC: "vpc-1",
Mode: "bridge",
IfaceType: "vms",
Name: "sn-br",
VPC: "vpc-1",
Mode: "bridge",
IfaceType: "vms",
InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24",
CIDR: "10.0.0.0/24",
}
body, _ := json.Marshal(req)
w := httptest.NewRecorder()
@ -190,13 +190,13 @@ func TestPostSubnet_BridgeMode_Success(t *testing.T) {
func TestPostSubnet_UnknownMode(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-1/state", "running")
kv.AddInDB(db, "vpc/vpc-1/state", "created")
req := SubnetCreateRequest{
Name: "sn-1",
VPC: "vpc-1",
Mode: "vlan",
Name: "sn-1",
VPC: "vpc-1",
Mode: "vlan",
InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24",
CIDR: "10.0.0.0/24",
}
body, _ := json.Marshal(req)
w := httptest.NewRecorder()
@ -219,7 +219,7 @@ func TestPostSubnet_InvalidBody(t *testing.T) {
func TestGetSubnet_Found(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/state", "created")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
kv.AddInDB(db, "subnet/sn-1/cidr", "10.0.0.0/24")
kv.AddInDB(db, "subnet/sn-1/interface_ip", "10.0.0.1")
@ -231,7 +231,7 @@ func TestGetSubnet_Found(t *testing.T) {
}
var result Subnet
json.NewDecoder(w.Body).Decode(&result)
if result.Name != "sn-1" || result.State != "running" {
if result.Name != "sn-1" || result.State != "created" {
t.Errorf("résultat inattendu : %+v", result)
}
if result.VPC != "vpc-1" {
@ -261,7 +261,7 @@ func TestGetSubnet_EmptyName(t *testing.T) {
func TestDeleteSubnet_Success(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-del/state", "running")
kv.AddInDB(db, "subnet/sn-del/state", "created")
req := httptest.NewRequest(http.MethodDelete, "/subnets/sn-del", nil)
w := httptest.NewRecorder()
s.SubnetByNameHandler(w, req)
@ -275,17 +275,6 @@ func TestDeleteSubnet_Success(t *testing.T) {
}
}
func TestDeleteSubnet_ConflictWhileCreating(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-wip/state", "creating")
req := httptest.NewRequest(http.MethodDelete, "/subnets/sn-wip", nil)
w := httptest.NewRecorder()
s.SubnetByNameHandler(w, req)
if w.Code != http.StatusConflict {
t.Errorf("attendu 409, obtenu %d: %s", w.Code, w.Body.String())
}
}
func TestDeleteSubnet_NotFound(t *testing.T) {
s, _ := newTestServer(t)
req := httptest.NewRequest(http.MethodDelete, "/subnets/inexistant", nil)
@ -298,7 +287,7 @@ func TestDeleteSubnet_NotFound(t *testing.T) {
func TestSubnetByName_InvalidMethod(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/state", "created")
req := httptest.NewRequest(http.MethodPut, "/subnets/sn-1", nil)
w := httptest.NewRecorder()
s.SubnetByNameHandler(w, req)
@ -306,34 +295,3 @@ func TestSubnetByName_InvalidMethod(t *testing.T) {
t.Errorf("attendu 405, obtenu %d", w.Code)
}
}
func TestCreateSubnet_GatewayRoundTrip(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-1/state", "running")
body, _ := json.Marshal(SubnetCreateRequest{
Name: "sn-gw", VPC: "vpc-1", Mode: "public_ip",
IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24",
DefaultRoute: true, Gateway: "203.0.113.1",
})
w := httptest.NewRecorder()
s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body)))
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
if gw, _ := kv.GetFromDB(db, "subnet/sn-gw/gateway"); gw != "203.0.113.1" {
t.Errorf("gateway attendue en DB, obtenu %q", gw)
}
w = httptest.NewRecorder()
s.SubnetByNameHandler(w, httptest.NewRequest(http.MethodGet, "/subnets/sn-gw", nil))
var got Subnet
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("réponse illisible : %v", err)
}
if got.Gateway != "203.0.113.1" {
t.Errorf("gateway absente de la réponse GET : %+v", got)
}
}

View file

@ -87,7 +87,6 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) {
InterfaceIP: req.InterfaceIP,
CIDR: req.CIDR,
DefaultRoute: req.DefaultRoute,
Gateway: req.Gateway,
}
if err := s.dispatcher.Prepare(cmd); err != nil {
if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil {

View file

@ -3,7 +3,6 @@ package agentapi
import (
"encoding/json"
"net/http"
"sort"
"strconv"
"strings"
@ -72,52 +71,6 @@ func (s *Server) stopVM(w http.ResponseWriter, _ *http.Request, name string) {
json.NewEncoder(w).Encode(vm)
}
// interfacesFromDB reconstruit les interfaces depuis vm/<name>/nic/<index>/…,
// triées par index — celui-ci détermine le slot PCI, donc le nom de l'interface
// dans le guest.
func interfacesFromDB(prefix string, entries map[string]string) []VMInterface {
nicPrefix := prefix + "nic/"
byIndex := make(map[int]*VMInterface)
for key, value := range entries {
rest := strings.TrimPrefix(key, nicPrefix)
if rest == key {
continue
}
parts := strings.Split(rest, "/")
if len(parts) != 2 {
continue
}
idx, err := strconv.Atoi(parts[0])
if err != nil {
continue
}
if byIndex[idx] == nil {
byIndex[idx] = &VMInterface{}
}
switch parts[1] {
case "subnet":
byIndex[idx].Subnet = value
case "ip":
byIndex[idx].IP = value
case "primary":
byIndex[idx].Primary = value == "true"
}
}
indexes := make([]int, 0, len(byIndex))
for idx := range byIndex {
indexes = append(indexes, idx)
}
sort.Ints(indexes)
ifaces := make([]VMInterface, 0, len(indexes))
for _, idx := range indexes {
ifaces = append(ifaces, *byIndex[idx])
}
return ifaces
}
func vmFromDB(name string, entries map[string]string) (VM, error) {
prefix := "vm/" + name + "/"
vm := VM{Name: name}
@ -128,7 +81,11 @@ func vmFromDB(name string, entries map[string]string) (VM, error) {
vm.CPUs, _ = strconv.Atoi(entries[prefix+"cpus"])
vm.UEFI = entries[prefix+"uefi"] == "true"
vm.Interfaces = interfacesFromDB(prefix, entries)
subnet := entries[prefix+"subnet"]
ip := entries[prefix+"ip"]
if subnet != "" || ip != "" {
vm.Interfaces = []VMInterface{{Subnet: subnet, IP: ip, Primary: true}}
}
diskPrefix := prefix + "disk/"
for key, path := range entries {

View file

@ -2,23 +2,20 @@ package agentapi
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"git.g3e.fr/syonad/two/pkg/db/kv"
"github.com/dgraph-io/badger/v4"
)
// --- vmFromDB ---
func TestVmFromDB_SingleDisk(t *testing.T) {
entries := map[string]string{
"vm/vm-1/state": "running",
"vm/vm-1/state": "started",
"vm/vm-1/subnet": "sn-1",
"vm/vm-1/ip": "10.0.0.5",
"vm/vm-1/metadata_port": "1234",
@ -40,7 +37,7 @@ func TestVmFromDB_SingleDisk(t *testing.T) {
func TestVmFromDB_MultiDisk(t *testing.T) {
entries := map[string]string{
"vm/vm-2/state": "running",
"vm/vm-2/state": "started",
"vm/vm-2/subnet": "sn-1",
"vm/vm-2/ip": "10.0.0.6",
"vm/vm-2/metadata_port": "1235",
@ -65,7 +62,7 @@ func TestVmFromDB_MultiDisk(t *testing.T) {
func TestVmFromDB_SlotGap(t *testing.T) {
// sdb absent — sda et sdc seulement
entries := map[string]string{
"vm/vm-3/state": "running",
"vm/vm-3/state": "started",
"vm/vm-3/subnet": "sn-1",
"vm/vm-3/ip": "10.0.0.7",
"vm/vm-3/metadata_port": "1236",
@ -97,7 +94,7 @@ func TestVmFromDB_SlotGap(t *testing.T) {
func TestStartVM_MultiDisk(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/state", "created")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
body, _ := json.Marshal(VMCreateRequest{
@ -131,7 +128,7 @@ func TestStartVM_MultiDisk(t *testing.T) {
func TestStartVM_StorageReturnedInResponse(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/state", "created")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
body, _ := json.Marshal(VMCreateRequest{
@ -159,192 +156,3 @@ func TestStartVM_StorageReturnedInResponse(t *testing.T) {
t.Errorf("disque inattendu dans la réponse : %+v", vm.Storage[0])
}
}
// --- POST /vms : objet metadata ---
func postVM(t *testing.T, name string, meta VMMetadata) (*httptest.ResponseRecorder, *badger.DB) {
t.Helper()
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
body, _ := json.Marshal(VMCreateRequest{
Name: name,
Interfaces: []VMInterface{{Subnet: "sn-1", IP: "10.0.0.20", Primary: true}},
Storage: []VMStorage{{Path: "/data/root.qcow2", Dev: "vda"}},
Memory: 1024,
CPUs: 2,
Metadata: meta,
})
w := httptest.NewRecorder()
s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body)))
return w, db
}
func TestStartVM_UserDataIsDecodedFromBase64(t *testing.T) {
plain := "#cloud-config\npackages:\n - nginx\n"
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
w, db := postVM(t, "vm-md1", VMMetadata{UserData: encoded})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
got, err := kv.GetFromDB(db, "vm/vm-md1/metadata/user-data")
if err != nil {
t.Fatalf("user-data absent en DB : %v", err)
}
if got != plain {
t.Errorf("user-data décodé attendu %q, obtenu %q", plain, got)
}
}
func TestStartVM_InvalidBase64IsRejected(t *testing.T) {
w, db := postVM(t, "vm-md2", VMMetadata{UserData: "ceci n'est pas du base64 !!"})
if w.Code != http.StatusBadRequest {
t.Fatalf("un base64 invalide doit être refusé en 400, obtenu %d : %s", w.Code, w.Body.String())
}
if _, err := kv.GetFromDB(db, "vm/vm-md2/state"); err == nil {
t.Error("aucune VM ne doit être créée quand la requête est refusée")
}
}
func TestStartVM_InvalidBase64ErrorNamesTheField(t *testing.T) {
w, _ := postVM(t, "vm-md3", VMMetadata{UserData: "@@@"})
if !strings.Contains(w.Body.String(), "user_data") {
t.Errorf("le message doit nommer le champ fautif : %s", w.Body.String())
}
}
func TestStartVM_NoUserDataWritesNoDocument(t *testing.T) {
w, db := postVM(t, "vm-md4", VMMetadata{SSHKey: "ssh-ed25519 AAAA user@host"})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
entries, err := kv.ListByPrefix(db, "vm/vm-md4/metadata/")
if err != nil {
t.Fatalf("ListByPrefix : %v", err)
}
if len(entries) != 0 {
t.Errorf("sans user_data, aucun document ne doit être stocké : %v", entries)
}
}
func TestStartVM_PasswordAndSSHKeyComeFromMetadata(t *testing.T) {
w, db := postVM(t, "vm-md5", VMMetadata{
Password: "$6$rounds$hash",
SSHKey: "ssh-ed25519 AAAA user@host",
})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
if got, _ := kv.GetFromDB(db, "vm/vm-md5/password"); got != "$6$rounds$hash" {
t.Errorf("password attendu depuis metadata, obtenu %q", got)
}
if got, _ := kv.GetFromDB(db, "vm/vm-md5/sshkey"); got != "ssh-ed25519 AAAA user@host" {
t.Errorf("sshkey attendu depuis metadata, obtenu %q", got)
}
}
func TestStartVM_EmptyBase64MeansNoDocument(t *testing.T) {
w, db := postVM(t, "vm-md6", VMMetadata{UserData: base64.StdEncoding.EncodeToString([]byte(""))})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
entries, _ := kv.ListByPrefix(db, "vm/vm-md6/metadata/")
if len(entries) != 0 {
t.Errorf("un base64 vide est indiscernable d'un champ absent : %v", entries)
}
}
// --- interfaces multiples ---
func TestVmFromDB_MultipleInterfacesSortedByIndex(t *testing.T) {
vm, err := vmFromDB("vm-multi", map[string]string{
"vm/vm-multi/state": "running",
"vm/vm-multi/nic/1/subnet": "sn-2",
"vm/vm-multi/nic/1/ip": "10.2.0.5",
"vm/vm-multi/nic/0/subnet": "sn-1",
"vm/vm-multi/nic/0/ip": "10.1.0.5",
"vm/vm-multi/nic/0/primary": "true",
"vm/vm-multi/disk/vda": "/data/root.qcow2",
})
if err != nil {
t.Fatalf("vmFromDB : %v", err)
}
if len(vm.Interfaces) != 2 {
t.Fatalf("2 interfaces attendues, obtenu %d : %+v", len(vm.Interfaces), vm.Interfaces)
}
if vm.Interfaces[0].Subnet != "sn-1" || !vm.Interfaces[0].Primary {
t.Errorf("la première doit être l'index 0, primaire : %+v", vm.Interfaces[0])
}
if vm.Interfaces[1].Subnet != "sn-2" || vm.Interfaces[1].Primary {
t.Errorf("la seconde doit être l'index 1, non primaire : %+v", vm.Interfaces[1])
}
}
func TestStartVM_StoresAllInterfaces(t *testing.T) {
s, db := newTestServer(t)
for _, sn := range []string{"sn-1", "sn-2"} {
kv.AddInDB(db, "subnet/"+sn+"/state", "running")
kv.AddInDB(db, "subnet/"+sn+"/vpc", "vpc-1")
}
body, _ := json.Marshal(VMCreateRequest{
Name: "vm-multi",
Interfaces: []VMInterface{
{Subnet: "sn-1", IP: "10.1.0.5", Primary: true},
{Subnet: "sn-2", IP: "10.2.0.5"},
},
Storage: []VMStorage{{Path: "/data/root.qcow2", Dev: "vda"}},
})
w := httptest.NewRecorder()
s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body)))
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
if got, _ := kv.GetFromDB(db, "vm/vm-multi/nic/1/subnet"); got != "sn-2" {
t.Errorf("seconde interface non stockée : %q", got)
}
if got, _ := kv.GetFromDB(db, "vm/vm-multi/nic/0/primary"); got != "true" {
t.Errorf("primaire non marquée : %q", got)
}
if _, err := kv.GetFromDB(db, "vm/vm-multi/nic/1/primary"); err == nil {
t.Error("une interface non primaire ne doit pas porter la clé primary")
}
}
func TestStartVM_RejectsZeroOrTwoPrimaries(t *testing.T) {
cases := map[string][]VMInterface{
"aucune primaire": {{Subnet: "sn-1", IP: "10.1.0.5"}},
"deux primaires": {
{Subnet: "sn-1", IP: "10.1.0.5", Primary: true},
{Subnet: "sn-2", IP: "10.2.0.5", Primary: true},
},
}
for label, ifaces := range cases {
s, db := newTestServer(t)
for _, sn := range []string{"sn-1", "sn-2"} {
kv.AddInDB(db, "subnet/"+sn+"/state", "running")
kv.AddInDB(db, "subnet/"+sn+"/vpc", "vpc-1")
}
body, _ := json.Marshal(VMCreateRequest{
Name: "vm-bad",
Interfaces: ifaces,
Storage: []VMStorage{{Path: "/data/root.qcow2", Dev: "vda"}},
})
w := httptest.NewRecorder()
s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body)))
if w.Code != http.StatusBadRequest {
t.Errorf("%s : attendu 400, obtenu %d — %s", label, w.Code, w.Body.String())
}
}
}

View file

@ -1,14 +1,11 @@
package agentapi
import (
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"strings"
dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent"
"git.g3e.fr/syonad/two/internal/metadata"
"git.g3e.fr/syonad/two/pkg/db/kv"
)
@ -67,17 +64,16 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
return
}
nics := make([]dispatcher.VMNIC, len(req.Interfaces))
primaries := 0
for i, iface := range req.Interfaces {
nics[i] = dispatcher.VMNIC{Subnet: iface.Subnet, IP: iface.IP, Primary: iface.Primary}
if iface.Primary {
primaries++
var primary *VMInterface
for i := range req.Interfaces {
if req.Interfaces[i].Primary {
primary = &req.Interfaces[i]
break
}
}
if primaries != 1 {
if primary == nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(ErrorResponse{Error: "exactly one interface must be primary"})
json.NewEncoder(w).Encode(ErrorResponse{Error: "one interface must be primary"})
return
}
@ -86,23 +82,16 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
disks[i] = dispatcher.VMDisk{Path: s.Path, Dev: s.Dev}
}
documents, err := decodeDocuments(req.Metadata)
if err != nil {
w.WriteHeader(http.StatusBadRequest)
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
return
}
cmd := dispatcher.StartVMCommand{
Name: req.Name,
NICs: nics,
Disks: disks,
Memory: req.Memory,
CPUs: req.CPUs,
UEFI: req.UEFI,
Password: req.Metadata.Password,
SSHKey: req.Metadata.SSHKey,
Documents: documents,
Name: req.Name,
Subnet: primary.Subnet,
IP: primary.IP,
Disks: disks,
Memory: req.Memory,
CPUs: req.CPUs,
UEFI: req.UEFI,
Password: req.Password,
SSHKey: req.SSHKey,
}
if err := s.dispatcher.Prepare(cmd); err != nil {
@ -126,14 +115,3 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusAccepted)
json.NewEncoder(w).Encode(vm)
}
func decodeDocuments(m VMMetadata) (map[string]string, error) {
if m.UserData == "" {
return nil, nil
}
content, err := base64.StdEncoding.DecodeString(m.UserData)
if err != nil {
return nil, fmt.Errorf("metadata.user_data is not valid base64: %w", err)
}
return map[string]string{metadata.DocUserData: string(content)}, nil
}

View file

@ -28,7 +28,7 @@ func TestListVpcs_Empty(t *testing.T) {
func TestListVpcs_WithData(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/v1/state", "running")
kv.AddInDB(db, "vpc/v1/state", "created")
kv.AddInDB(db, "vpc/v2/state", "creating")
w := httptest.NewRecorder()
s.VpcsHandler(w, httptest.NewRequest(http.MethodGet, "/vpcs", nil))
@ -104,7 +104,7 @@ func TestPostVpc_InvalidCIDR(t *testing.T) {
func TestPostVpc_Duplicate(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-exist/state", "running")
kv.AddInDB(db, "vpc/vpc-exist/state", "created")
body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-exist", CIDR: "10.0.0.0/16"})
w := httptest.NewRecorder()
s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body)))
@ -126,7 +126,7 @@ func TestPostVpc_InvalidBody(t *testing.T) {
func TestGetVpc_Found(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-1/state", "running")
kv.AddInDB(db, "vpc/vpc-1/state", "created")
req := httptest.NewRequest(http.MethodGet, "/vpcs/vpc-1", nil)
w := httptest.NewRecorder()
s.VpcByNameHandler(w, req)
@ -135,7 +135,7 @@ func TestGetVpc_Found(t *testing.T) {
}
var result VPC
json.NewDecoder(w.Body).Decode(&result)
if result.Name != "vpc-1" || result.State != "running" {
if result.Name != "vpc-1" || result.State != "created" {
t.Errorf("résultat inattendu : %+v", result)
}
}
@ -162,7 +162,7 @@ func TestGetVpc_EmptyName(t *testing.T) {
func TestDeleteVpc_Success(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-del/state", "running")
kv.AddInDB(db, "vpc/vpc-del/state", "created")
req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-del", nil)
w := httptest.NewRecorder()
s.VpcByNameHandler(w, req)
@ -186,37 +186,10 @@ func TestDeleteVpc_NotFound(t *testing.T) {
}
}
func TestDeleteVpc_ConflictWhileCreating(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-wip/state", "creating")
req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-wip", nil)
w := httptest.NewRecorder()
s.VpcByNameHandler(w, req)
if w.Code != http.StatusConflict {
t.Errorf("attendu 409, obtenu %d: %s", w.Code, w.Body.String())
}
}
func TestDeleteVpc_AllowedFromError(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-ko/state", "error")
req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-ko", nil)
w := httptest.NewRecorder()
s.VpcByNameHandler(w, req)
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String())
}
var result VPC
json.NewDecoder(w.Body).Decode(&result)
if result.State != "deleting" {
t.Errorf("state attendu deleting, obtenu %q", result.State)
}
}
func TestDeleteVpc_BlockedByActiveSubnet(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-busy/state", "running")
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "vpc/vpc-busy/state", "created")
kv.AddInDB(db, "subnet/sn-1/state", "created")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-busy")
req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-busy", nil)
w := httptest.NewRecorder()
@ -228,7 +201,7 @@ func TestDeleteVpc_BlockedByActiveSubnet(t *testing.T) {
func TestVpcByName_InvalidMethod(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-1/state", "running")
kv.AddInDB(db, "vpc/vpc-1/state", "created")
req := httptest.NewRequest(http.MethodPut, "/vpcs/vpc-1", nil)
w := httptest.NewRecorder()
s.VpcByNameHandler(w, req)

View file

@ -1,83 +0,0 @@
package dhcpapi
import (
"fmt"
"net"
"git.g3e.fr/syonad/two/internal/dhcpd"
)
func (s Subnet) toConfig() (dhcpd.SubnetConfig, error) {
_, network, err := net.ParseCIDR(s.Network)
if err != nil {
return dhcpd.SubnetConfig{}, fmt.Errorf("invalid network %q: %w", s.Network, err)
}
interfaceIP := net.ParseIP(s.InterfaceIP)
if interfaceIP == nil {
return dhcpd.SubnetConfig{}, fmt.Errorf("invalid interface ip %q", s.InterfaceIP)
}
c := dhcpd.SubnetConfig{Network: network, InterfaceIP: interfaceIP}
if s.VPCRoute != "" {
if _, c.VPCRoute, err = net.ParseCIDR(s.VPCRoute); err != nil {
return dhcpd.SubnetConfig{}, fmt.Errorf("invalid vpc route %q: %w", s.VPCRoute, err)
}
}
if s.DefaultGateway != "" {
if c.DefaultGateway = net.ParseIP(s.DefaultGateway); c.DefaultGateway == nil {
return dhcpd.SubnetConfig{}, fmt.Errorf("invalid default gateway %q", s.DefaultGateway)
}
}
return c, nil
}
func (h Host) toHost() (dhcpd.Host, error) {
mac, err := net.ParseMAC(h.MAC)
if err != nil {
return dhcpd.Host{}, fmt.Errorf("invalid mac %q: %w", h.MAC, err)
}
ip := net.ParseIP(h.IP)
if ip == nil {
return dhcpd.Host{}, fmt.Errorf("invalid host ip %q", h.IP)
}
return dhcpd.Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil
}
func subnetFromConfig(c dhcpd.SubnetConfig) Subnet {
s := Subnet{
Network: c.Network.String(),
InterfaceIP: c.InterfaceIP.String(),
}
if c.VPCRoute != nil {
s.VPCRoute = c.VPCRoute.String()
}
if c.DefaultGateway != nil {
s.DefaultGateway = c.DefaultGateway.String()
}
return s
}
func hostFromHost(h dhcpd.Host) Host {
return Host{
MAC: h.MAC.String(),
IP: h.IP.String(),
VM: h.VM,
DefaultRoute: h.DefaultRoute,
}
}
func stateFromStore(store *dhcpd.Store) State {
state := State{Hosts: make([]Host, 0)}
if config, configured := store.Subnet(); configured {
subnet := subnetFromConfig(config)
state.Subnet = &subnet
}
for _, h := range store.Hosts() {
state.Hosts = append(state.Hosts, hostFromHost(h))
}
SortHosts(state.Hosts)
return state
}

View file

@ -1,116 +0,0 @@
package dhcpapi
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"sort"
)
func canonicalMAC(s string) (string, error) {
mac, err := net.ParseMAC(s)
if err != nil {
return "", fmt.Errorf("invalid mac %q: %w", s, err)
}
return mac.String(), nil
}
func canonicalIP(s string) (string, error) {
ip := net.ParseIP(s)
if ip == nil {
return "", fmt.Errorf("invalid ip %q", s)
}
return ip.String(), nil
}
func canonicalCIDR(s string) (string, error) {
_, network, err := net.ParseCIDR(s)
if err != nil {
return "", fmt.Errorf("invalid cidr %q: %w", s, err)
}
return network.String(), nil
}
func SortHosts(hosts []Host) {
sort.Slice(hosts, func(i, j int) bool { return hosts[i].MAC < hosts[j].MAC })
}
func CanonicalSubnet(s Subnet) (Subnet, error) {
network, err := canonicalCIDR(s.Network)
if err != nil {
return Subnet{}, err
}
interfaceIP, err := canonicalIP(s.InterfaceIP)
if err != nil {
return Subnet{}, err
}
out := Subnet{Network: network, InterfaceIP: interfaceIP}
if s.VPCRoute != "" {
if out.VPCRoute, err = canonicalCIDR(s.VPCRoute); err != nil {
return Subnet{}, err
}
}
if s.DefaultGateway != "" {
if out.DefaultGateway, err = canonicalIP(s.DefaultGateway); err != nil {
return Subnet{}, err
}
}
return out, nil
}
func CanonicalHost(h Host) (Host, error) {
mac, err := canonicalMAC(h.MAC)
if err != nil {
return Host{}, err
}
ip, err := canonicalIP(h.IP)
if err != nil {
return Host{}, err
}
return Host{MAC: mac, IP: ip, VM: h.VM, DefaultRoute: h.DefaultRoute}, nil
}
func Canonical(s State) (State, error) {
out := State{Hosts: make([]Host, 0, len(s.Hosts))}
if s.Subnet != nil {
subnet, err := CanonicalSubnet(*s.Subnet)
if err != nil {
return State{}, err
}
out.Subnet = &subnet
}
seen := make(map[string]struct{}, len(s.Hosts))
for _, h := range s.Hosts {
host, err := CanonicalHost(h)
if err != nil {
return State{}, err
}
if _, dup := seen[host.MAC]; dup {
return State{}, fmt.Errorf("duplicate mac %s", host.MAC)
}
seen[host.MAC] = struct{}{}
out.Hosts = append(out.Hosts, host)
}
SortHosts(out.Hosts)
return out, nil
}
func Digest(s State) (string, error) {
canonical, err := Canonical(s)
if err != nil {
return "", err
}
raw, err := json.Marshal(canonical)
if err != nil {
return "", fmt.Errorf("encode state: %w", err)
}
sum := sha256.Sum256(raw)
return hex.EncodeToString(sum[:]), nil
}

View file

@ -1,185 +0,0 @@
package dhcpapi
import (
"encoding/json"
"strings"
"testing"
)
func subnet() Subnet {
return Subnet{
Network: "10.0.5.0/24",
InterfaceIP: "10.0.5.1",
VPCRoute: "10.0.0.0/16",
DefaultGateway: "10.0.5.254",
}
}
func hosts() []Host {
return []Host{
{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-a", DefaultRoute: true},
{MAC: "00:22:33:00:00:0b", IP: "10.0.5.11", VM: "vm-b"},
}
}
func digestOf(t *testing.T, s State) string {
t.Helper()
d, err := Digest(s)
if err != nil {
t.Fatalf("Digest: %v", err)
}
return d
}
func TestDigest_IsStableAcrossHostOrder(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
reversed := hosts()
reversed[0], reversed[1] = reversed[1], reversed[0]
b := State{Subnet: &sub, Hosts: reversed}
if digestOf(t, a) != digestOf(t, b) {
t.Error("host order must not change the digest: the watchdog would report a phantom drift")
}
}
func TestDigest_IsStableAcrossMACCase(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
upper := hosts()
upper[0].MAC = "00:22:33:00:00:0A"
b := State{Subnet: &sub, Hosts: upper}
if digestOf(t, a) != digestOf(t, b) {
t.Error("mac case must not change the digest")
}
}
func TestDigest_IsStableAcrossIPv4InIPv6Notation(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
mapped := subnet()
mapped.InterfaceIP = "::ffff:10.0.5.1"
b := State{Subnet: &mapped, Hosts: hosts()}
if digestOf(t, a) != digestOf(t, b) {
t.Error("the same address written in ipv4-mapped form must hash alike")
}
}
func TestDigest_ChangesWhenAHostIPChanges(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
moved := hosts()
moved[0].IP = "10.0.5.99"
b := State{Subnet: &sub, Hosts: moved}
if digestOf(t, a) == digestOf(t, b) {
t.Error("a changed reservation must change the digest")
}
}
func TestDigest_ChangesWhenTheDefaultRouteFlagChanges(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
flipped := hosts()
flipped[0].DefaultRoute = false
b := State{Subnet: &sub, Hosts: flipped}
if digestOf(t, a) == digestOf(t, b) {
t.Error("the default route flag is part of the served state")
}
}
func TestDigest_ChangesWhenTheSubnetChanges(t *testing.T) {
sub := subnet()
a := State{Subnet: &sub, Hosts: hosts()}
other := subnet()
other.DefaultGateway = "10.0.5.253"
b := State{Subnet: &other, Hosts: hosts()}
if digestOf(t, a) == digestOf(t, b) {
t.Error("the subnet configuration is part of the served state")
}
}
func TestDigest_DistinguishesNoSubnetFromAConfiguredOne(t *testing.T) {
sub := subnet()
configured := State{Subnet: &sub}
bare := State{}
if digestOf(t, configured) == digestOf(t, bare) {
t.Error("an unconfigured subnet must not hash like a configured one")
}
}
func TestDigest_EmptyAndNilHostsHashAlike(t *testing.T) {
if digestOf(t, State{Hosts: nil}) != digestOf(t, State{Hosts: []Host{}}) {
t.Error("nil and empty host lists describe the same state")
}
}
func TestDigest_RejectsAnInvalidMAC(t *testing.T) {
if _, err := Digest(State{Hosts: []Host{{MAC: "nope", IP: "10.0.5.10"}}}); err == nil {
t.Fatal("an invalid mac must be reported, not hashed")
}
}
func TestDigest_RejectsAnInvalidIP(t *testing.T) {
if _, err := Digest(State{Hosts: []Host{{MAC: "00:22:33:00:00:0a", IP: "10.0.5.300"}}}); err == nil {
t.Fatal("an invalid ip must be reported, not hashed")
}
}
func TestCanonical_RejectsADuplicateMAC(t *testing.T) {
dup := []Host{
{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10"},
{MAC: "00:22:33:00:00:0A", IP: "10.0.5.11"},
}
if _, err := Canonical(State{Hosts: dup}); err == nil {
t.Fatal("the same mac twice is an inconsistent state, not something to hash")
}
}
func TestCanonical_NormalizesTheNetworkToItsBaseAddress(t *testing.T) {
sub := subnet()
sub.Network = "10.0.5.42/24"
got, err := CanonicalSubnet(sub)
if err != nil {
t.Fatalf("CanonicalSubnet: %v", err)
}
if got.Network != "10.0.5.0/24" {
t.Errorf("network = %s, want 10.0.5.0/24", got.Network)
}
}
func TestCanonical_SortsHostsByMAC(t *testing.T) {
unsorted := []Host{
{MAC: "00:22:33:00:00:0c", IP: "10.0.5.12"},
{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10"},
}
got, err := Canonical(State{Hosts: unsorted})
if err != nil {
t.Fatalf("Canonical: %v", err)
}
if got.Hosts[0].MAC != "00:22:33:00:00:0a" {
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

@ -1,58 +0,0 @@
package dhcpapi
type Verb string
const (
VerbSetSubnet Verb = "set-subnet"
VerbSetHost Verb = "set-host"
VerbDelHost Verb = "del-host"
VerbGetState Verb = "get-state"
VerbProbe Verb = "probe"
)
const MaxMessageBytes = 64 * 1024
type Subnet struct {
Network string `json:"network"`
InterfaceIP string `json:"interface_ip"`
VPCRoute string `json:"vpc_route,omitempty"`
DefaultGateway string `json:"default_gateway,omitempty"`
}
type Host struct {
MAC string `json:"mac"`
IP string `json:"ip"`
VM string `json:"vm,omitempty"`
DefaultRoute bool `json:"default_route"`
}
type State struct {
Subnet *Subnet `json:"subnet,omitempty"`
Hosts []Host `json:"hosts"`
}
type Lease struct {
MAC string `json:"mac"`
IP string `json:"ip"`
Netmask string `json:"netmask"`
Router string `json:"router,omitempty"`
DNS []string `json:"dns"`
Routes []string `json:"routes"`
LeaseSeconds uint32 `json:"lease_seconds"`
}
type Request struct {
Verb Verb `json:"verb"`
Subnet *Subnet `json:"subnet,omitempty"`
Host *Host `json:"host,omitempty"`
MAC string `json:"mac,omitempty"`
}
type Response struct {
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
State *State `json:"state,omitempty"`
Digest string `json:"digest,omitempty"`
Lease *Lease `json:"lease,omitempty"`
Served bool `json:"served"`
}

View file

@ -1,29 +0,0 @@
package dhcpapi
import (
"path/filepath"
)
const (
DefaultRunDir = "/run/two/dhcp"
SocketExt = ".sock"
StateExt = ".state"
UnitExt = ".service"
UnitName = "dhcp@"
)
func Instance(vpc, bridge string) string {
return vpc + "_" + bridge
}
func Unit(instance string) string {
return UnitName + instance + UnitExt
}
func SocketPath(runDir, instance string) string {
return filepath.Join(runDir, instance+SocketExt)
}
func StatePath(runDir, instance string) string {
return filepath.Join(runDir, instance+StateExt)
}

View file

@ -1,69 +0,0 @@
package dhcpapi
import (
"os"
"strings"
"testing"
)
func TestInstance_JoinsVPCAndBridge(t *testing.T) {
if got := Instance("vp-admin", "br-000001"); got != "vp-admin_br-000001" {
t.Errorf("Instance = %s, want vp-admin_br-000001", got)
}
}
func TestUnit_NamesTheTemplatedService(t *testing.T) {
if got := Unit(Instance("vp-admin", "br-000001")); got != "dhcp@vp-admin_br-000001.service" {
t.Errorf("Unit = %s", got)
}
}
func TestSocketPath_SitsUnderTheRunDir(t *testing.T) {
got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001"))
if got != "/run/two/dhcp/vp-admin_br-000001.sock" {
t.Errorf("SocketPath = %s", got)
}
}
func TestStatePath_SitsUnderTheRunDir(t *testing.T) {
got := StatePath(DefaultRunDir, Instance("vp-admin", "br-000001"))
if got != "/run/two/dhcp/vp-admin_br-000001.state" {
t.Errorf("StatePath = %s", got)
}
}
func TestPaths_NameTheVPCSoAListingIsReadable(t *testing.T) {
got := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001"))
if !strings.Contains(got, "vp-admin") {
t.Errorf("path = %s, want the vpc visible when listing the run dir", got)
}
}
func TestPaths_DistinguishTwoSubnetsOfTheSameVPC(t *testing.T) {
a := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000001"))
b := SocketPath(DefaultRunDir, Instance("vp-admin", "br-000002"))
if a == b {
t.Error("two subnets must not share a control socket")
}
}
func TestSocketPath_StaysUnderTheUnixPathLimit(t *testing.T) {
got := SocketPath(DefaultRunDir, Instance("vp-000000", "br-000000"))
if len(got) > 100 {
t.Errorf("socket path is %d bytes (%s): sun_path caps at 104 on darwin and 108 on linux", len(got), got)
}
}
func TestDefaultRunDir_MatchesTheWrapperScript(t *testing.T) {
const script = "../../../scripts/run-dhcp-in-netns.sh"
raw, err := os.ReadFile(script)
if err != nil {
t.Fatalf("read %s: %v", script, err)
}
want := `RUN_DIR="` + DefaultRunDir + `"`
if !strings.Contains(string(raw), want) {
t.Errorf("%s does not set %s: the agent would talk to a socket the server never creates", script, want)
}
}

View file

@ -1,190 +0,0 @@
package dhcpapi
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"os"
"path/filepath"
"git.g3e.fr/syonad/two/internal/dhcpd"
"git.g3e.fr/syonad/two/pkg/db/statefile"
"github.com/insomniacslk/dhcp/dhcpv4"
)
const SocketMode = 0o600
type Server struct {
store *dhcpd.Store
listener net.Listener
logger *slog.Logger
}
func Listen(store *dhcpd.Store, path string, logger *slog.Logger) (*Server, error) {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, statefile.DirMode); err != nil {
return nil, fmt.Errorf("create %s: %w", dir, err)
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("remove stale socket %s: %w", path, err)
}
listener, err := net.Listen("unix", path)
if err != nil {
return nil, fmt.Errorf("listen on %s: %w", path, err)
}
if err := os.Chmod(path, SocketMode); err != nil {
listener.Close()
return nil, fmt.Errorf("chmod %s: %w", path, err)
}
return &Server{store: store, listener: listener, logger: logger}, nil
}
func (s *Server) Addr() string {
return s.listener.Addr().String()
}
func (s *Server) Close() error {
return s.listener.Close()
}
func (s *Server) Serve() error {
for {
conn, err := s.listener.Accept()
if err != nil {
return err
}
go s.handleConn(conn)
}
}
func (s *Server) handleConn(conn net.Conn) {
defer conn.Close()
defer func() {
if r := recover(); r != nil {
s.logger.Error("control connection panicked", "panic", r)
}
}()
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 0, 4096), MaxMessageBytes)
encoder := json.NewEncoder(conn)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var req Request
if err := json.Unmarshal(line, &req); err != nil {
if err := encoder.Encode(failure(fmt.Errorf("malformed request: %w", err))); err != nil {
return
}
continue
}
if err := encoder.Encode(s.dispatch(req)); err != nil {
return
}
}
if err := scanner.Err(); err != nil {
s.logger.Error("control connection read failed", "error", err)
}
}
func failure(err error) Response {
return Response{OK: false, Error: err.Error()}
}
func (s *Server) dispatch(req Request) Response {
switch req.Verb {
case VerbSetSubnet:
if req.Subnet == nil {
return failure(errors.New("set-subnet requires a subnet"))
}
config, err := req.Subnet.toConfig()
if err != nil {
return failure(err)
}
if err := s.store.SetSubnet(config); err != nil {
return failure(err)
}
return Response{OK: true}
case VerbSetHost:
if req.Host == nil {
return failure(errors.New("set-host requires a host"))
}
host, err := req.Host.toHost()
if err != nil {
return failure(err)
}
if err := s.store.SetHost(host); err != nil {
return failure(err)
}
return Response{OK: true}
case VerbDelHost:
mac, err := net.ParseMAC(req.MAC)
if err != nil {
return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err))
}
if err := s.store.DelHost(mac); err != nil {
return failure(err)
}
return Response{OK: true}
case VerbGetState:
state := stateFromStore(s.store)
digest, err := Digest(state)
if err != nil {
return failure(err)
}
return Response{OK: true, State: &state, Digest: digest}
case VerbProbe:
mac, err := net.ParseMAC(req.MAC)
if err != nil {
return failure(fmt.Errorf("invalid mac %q: %w", req.MAC, err))
}
reply, err := s.store.Probe(mac)
if err != nil {
return failure(err)
}
if reply == nil {
return Response{OK: true, Served: false}
}
return Response{OK: true, Served: true, Lease: leaseFromReply(mac, reply)}
default:
return failure(fmt.Errorf("unknown verb %q", req.Verb))
}
}
func leaseFromReply(mac net.HardwareAddr, reply *dhcpv4.DHCPv4) *Lease {
lease := &Lease{
MAC: mac.String(),
IP: reply.YourIPAddr.String(),
Netmask: net.IP(reply.SubnetMask()).String(),
DNS: make([]string, 0, 2),
Routes: make([]string, 0, 3),
LeaseSeconds: uint32(reply.IPAddressLeaseTime(0).Seconds()),
}
if routers := reply.Router(); len(routers) > 0 {
lease.Router = routers[0].String()
}
for _, dns := range reply.DNS() {
lease.DNS = append(lease.DNS, dns.String())
}
for _, route := range reply.ClasslessStaticRoute() {
lease.Routes = append(lease.Routes, route.Dest.String()+" via "+route.Router.String())
}
return lease
}

View file

@ -1,104 +0,0 @@
package dhcpclient
import (
"bufio"
"encoding/json"
"errors"
"fmt"
"net"
"time"
dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
)
const DefaultTimeout = 5 * time.Second
var ErrNotServed = errors.New("mac is not served by this subnet")
type Client struct {
path string
timeout time.Duration
}
func New(path string) *Client {
return &Client{path: path, timeout: DefaultTimeout}
}
func (c *Client) WithTimeout(d time.Duration) *Client {
return &Client{path: c.path, timeout: d}
}
func (c *Client) call(req dhcpapi.Request) (dhcpapi.Response, error) {
conn, err := net.DialTimeout("unix", c.path, c.timeout)
if err != nil {
return dhcpapi.Response{}, fmt.Errorf("dial %s: %w", c.path, err)
}
defer conn.Close()
if err := conn.SetDeadline(time.Now().Add(c.timeout)); err != nil {
return dhcpapi.Response{}, fmt.Errorf("set deadline on %s: %w", c.path, err)
}
raw, err := json.Marshal(req)
if err != nil {
return dhcpapi.Response{}, fmt.Errorf("encode %s: %w", req.Verb, err)
}
if _, err := conn.Write(append(raw, '\n')); err != nil {
return dhcpapi.Response{}, fmt.Errorf("send %s: %w", req.Verb, err)
}
scanner := bufio.NewScanner(conn)
scanner.Buffer(make([]byte, 0, 4096), dhcpapi.MaxMessageBytes)
if !scanner.Scan() {
if err := scanner.Err(); err != nil {
return dhcpapi.Response{}, fmt.Errorf("read reply to %s: %w", req.Verb, err)
}
return dhcpapi.Response{}, fmt.Errorf("no reply to %s", req.Verb)
}
var resp dhcpapi.Response
if err := json.Unmarshal(scanner.Bytes(), &resp); err != nil {
return dhcpapi.Response{}, fmt.Errorf("parse reply to %s: %w", req.Verb, err)
}
if !resp.OK {
return resp, fmt.Errorf("%s refused: %s", req.Verb, resp.Error)
}
return resp, nil
}
func (c *Client) SetSubnet(subnet dhcpapi.Subnet) error {
_, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbSetSubnet, Subnet: &subnet})
return err
}
func (c *Client) SetHost(host dhcpapi.Host) error {
_, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbSetHost, Host: &host})
return err
}
func (c *Client) DelHost(mac string) error {
_, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbDelHost, MAC: mac})
return err
}
func (c *Client) GetState() (dhcpapi.State, string, error) {
resp, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbGetState})
if err != nil {
return dhcpapi.State{}, "", err
}
if resp.State == nil {
return dhcpapi.State{}, "", errors.New("get-state returned no state")
}
return *resp.State, resp.Digest, nil
}
func (c *Client) Probe(mac string) (dhcpapi.Lease, error) {
resp, err := c.call(dhcpapi.Request{Verb: dhcpapi.VerbProbe, MAC: mac})
if err != nil {
return dhcpapi.Lease{}, err
}
if !resp.Served || resp.Lease == nil {
return dhcpapi.Lease{}, ErrNotServed
}
return *resp.Lease, nil
}

View file

@ -1,364 +0,0 @@
package dhcpclient
import (
"errors"
"io"
"log/slog"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
"git.g3e.fr/syonad/two/internal/dhcpd"
)
func shortTempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "dhcpd")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
return dir
}
func testSubnet() dhcpapi.Subnet {
return dhcpapi.Subnet{
Network: "10.0.5.0/24",
InterfaceIP: "10.0.5.1",
VPCRoute: "10.0.0.0/16",
DefaultGateway: "10.0.5.254",
}
}
func testHost() dhcpapi.Host {
return dhcpapi.Host{MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", VM: "vm-test", DefaultRoute: true}
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func serve(t *testing.T) (*Client, *dhcpd.Store, string) {
t.Helper()
dir := shortTempDir(t)
store := dhcpd.NewStore(filepath.Join(dir, "s.state"))
if err := store.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
socketPath := filepath.Join(dir, "s.sock")
server, err := dhcpapi.Listen(store, socketPath, discardLogger())
if err != nil {
t.Fatalf("Listen: %v", err)
}
go server.Serve()
t.Cleanup(func() { server.Close() })
return New(socketPath), store, socketPath
}
func TestListen_SocketIsOwnerOnly(t *testing.T) {
_, _, socketPath := serve(t)
info, err := os.Stat(socketPath)
if err != nil {
t.Fatalf("Stat: %v", err)
}
if got := info.Mode().Perm(); got != 0o600 {
t.Errorf("mode = %o, want 600: whoever reaches it rewrites the subnet addressing", got)
}
}
func TestListen_ReplacesAStaleSocketFile(t *testing.T) {
dir := shortTempDir(t)
socketPath := filepath.Join(dir, "stale.sock")
if err := os.WriteFile(socketPath, []byte("leftover"), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
store := dhcpd.NewStore(filepath.Join(dir, "s.state"))
if err := store.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
server, err := dhcpapi.Listen(store, socketPath, discardLogger())
if err != nil {
t.Fatalf("a socket left by an unclean stop must not block startup: %v", err)
}
server.Close()
}
func TestSetSubnet_ReachesTheStore(t *testing.T) {
client, store, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if _, configured := store.Subnet(); !configured {
t.Error("the subnet configuration did not reach the store")
}
}
func TestSetSubnet_InvalidNetworkIsRefused(t *testing.T) {
client, _, _ := serve(t)
subnet := testSubnet()
subnet.Network = "10.0.5.0"
err := client.SetSubnet(subnet)
if err == nil {
t.Fatal("an invalid network must be refused")
}
if !strings.Contains(err.Error(), "refused") {
t.Errorf("error = %v, want the server refusal to surface", err)
}
}
func TestSetHost_IsIdempotent(t *testing.T) {
client, store, _ := serve(t)
for range 3 {
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
}
if got := len(store.Hosts()); got != 1 {
t.Errorf("hosts = %d, want 1: set-host replaces the entry for that mac", got)
}
}
func TestSetHost_InvalidMACIsRefused(t *testing.T) {
client, _, _ := serve(t)
host := testHost()
host.MAC = "nope"
if err := client.SetHost(host); err == nil {
t.Fatal("an invalid mac must be refused")
}
}
func TestDelHost_RemovesTheEntry(t *testing.T) {
client, store, _ := serve(t)
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
if err := client.DelHost(testHost().MAC); err != nil {
t.Fatalf("DelHost: %v", err)
}
if got := len(store.Hosts()); got != 0 {
t.Errorf("hosts = %d, want 0", got)
}
}
func TestDelHost_UnknownMACIsNotAnError(t *testing.T) {
client, _, _ := serve(t)
if err := client.DelHost("00:22:33:ff:ff:ff"); err != nil {
t.Errorf("deleting an absent entry must be idempotent, got %v", err)
}
}
func TestDelHost_InvalidMACIsRefused(t *testing.T) {
client, _, _ := serve(t)
if err := client.DelHost("not-a-mac"); err == nil {
t.Fatal("an invalid mac must be refused")
}
}
func TestGetState_ReturnsStateAndDigest(t *testing.T) {
client, _, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
state, digest, err := client.GetState()
if err != nil {
t.Fatalf("GetState: %v", err)
}
if state.Subnet == nil || len(state.Hosts) != 1 {
t.Fatalf("state = %+v, want one subnet and one host", state)
}
if digest == "" {
t.Fatal("the digest is what the watchdog compares")
}
local, err := dhcpapi.Digest(state)
if err != nil {
t.Fatalf("Digest: %v", err)
}
if local != digest {
t.Errorf("digest recomputed locally = %s, server said %s: the canonical form diverges", local, digest)
}
}
func TestGetState_DigestFollowsTheState(t *testing.T) {
client, _, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
_, before, err := client.GetState()
if err != nil {
t.Fatalf("GetState: %v", err)
}
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
_, after, err := client.GetState()
if err != nil {
t.Fatalf("GetState: %v", err)
}
if before == after {
t.Error("adding a reservation must change the digest")
}
}
func TestProbe_DescribesWhatWouldBeSent(t *testing.T) {
client, _, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if err := client.SetHost(testHost()); err != nil {
t.Fatalf("SetHost: %v", err)
}
lease, err := client.Probe("00:22:33:00:00:0A")
if err != nil {
t.Fatalf("Probe: %v", err)
}
if lease.IP != "10.0.5.10" {
t.Errorf("ip = %s, want 10.0.5.10", lease.IP)
}
if lease.Netmask != "255.255.255.0" {
t.Errorf("netmask = %s, want 255.255.255.0", lease.Netmask)
}
if lease.Router != "10.0.5.254" {
t.Errorf("router = %s, want 10.0.5.254", lease.Router)
}
if len(lease.DNS) != 2 {
t.Errorf("dns = %v, want two servers", lease.DNS)
}
if len(lease.Routes) != 3 {
t.Errorf("routes = %v, want metadata, vpc and default", lease.Routes)
}
if lease.LeaseSeconds != 43200 {
t.Errorf("lease = %ds, want 43200", lease.LeaseSeconds)
}
if lease.MAC != "00:22:33:00:00:0a" {
t.Errorf("mac = %s, want the normalized form", lease.MAC)
}
}
func TestProbe_UnservedMACIsReported(t *testing.T) {
client, _, _ := serve(t)
if err := client.SetSubnet(testSubnet()); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if _, err := client.Probe("00:22:33:ff:ff:ff"); !errors.Is(err, ErrNotServed) {
t.Fatalf("error = %v, want ErrNotServed", err)
}
}
func TestProbe_WithoutSubnetConfigurationIsRefused(t *testing.T) {
client, _, _ := serve(t)
if _, err := client.Probe("00:22:33:00:00:0a"); err == nil {
t.Fatal("probing an unconfigured subnet must be refused")
}
}
func TestCall_UnknownVerbIsRefused(t *testing.T) {
_, _, socketPath := serve(t)
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte(`{"verb":"drop-everything"}` + "\n")); err != nil {
t.Fatalf("Write: %v", err)
}
buf := make([]byte, 512)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read: %v", err)
}
if !strings.Contains(string(buf[:n]), "unknown verb") {
t.Errorf("reply = %s, want an unknown verb refusal", buf[:n])
}
}
func TestCall_MalformedLineIsRefusedWithoutClosingTheConnection(t *testing.T) {
_, _, socketPath := serve(t)
conn, err := net.Dial("unix", socketPath)
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer conn.Close()
if _, err := conn.Write([]byte("{not json\n" + `{"verb":"get-state"}` + "\n")); err != nil {
t.Fatalf("Write: %v", err)
}
buf := make([]byte, 4096)
n, err := conn.Read(buf)
if err != nil {
t.Fatalf("Read: %v", err)
}
if !strings.Contains(string(buf[:n]), "malformed request") {
t.Errorf("first reply = %s, want a malformed request refusal", buf[:n])
}
}
func TestCall_OnAnAbsentSocketFails(t *testing.T) {
client := New(filepath.Join(shortTempDir(t), "nothing.sock"))
if err := client.DelHost("00:22:33:00:00:0a"); err == nil {
t.Fatal("an absent socket must be reported")
}
}
func TestCall_HonoursItsTimeout(t *testing.T) {
socketPath := filepath.Join(shortTempDir(t), "mute.sock")
listener, err := net.Listen("unix", socketPath)
if err != nil {
t.Fatalf("Listen: %v", err)
}
defer listener.Close()
go func() {
conn, err := listener.Accept()
if err != nil {
return
}
defer conn.Close()
time.Sleep(3 * time.Second)
}()
client := New(socketPath).WithTimeout(150 * time.Millisecond)
start := time.Now()
if err := client.DelHost("00:22:33:00:00:0a"); err == nil {
t.Fatal("a mute server must not hang the caller")
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("returned after %s, want the 150ms deadline to apply", elapsed)
}
}

View file

@ -81,68 +81,3 @@ database:
t.Errorf("attendu %q, obtenu %q", "/opt/two/data", cfg.Database.Path)
}
}
func TestLoadConfig_WatchdogDefauts(t *testing.T) {
path := writeYAML(t, "")
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig a échoué : %v", err)
}
if cfg.Watchdog.Enabled {
t.Error("watchdog.enabled devrait être false par défaut")
}
if cfg.Watchdog.IntervalSeconds != 60 {
t.Errorf("watchdog.interval_seconds attendu 60, obtenu %d", cfg.Watchdog.IntervalSeconds)
}
}
func TestLoadConfig_WatchdogValeursExplicites(t *testing.T) {
path := writeYAML(t, `
watchdog:
enabled: true
interval_seconds: 30
`)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig a échoué : %v", err)
}
if !cfg.Watchdog.Enabled {
t.Error("watchdog.enabled attendu true")
}
if cfg.Watchdog.IntervalSeconds != 30 {
t.Errorf("watchdog.interval_seconds attendu 30, obtenu %d", cfg.Watchdog.IntervalSeconds)
}
}
func TestLoadConfig_WatchdogActiveSansIntervalle(t *testing.T) {
path := writeYAML(t, `
watchdog:
enabled: true
`)
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig a échoué : %v", err)
}
if !cfg.Watchdog.Enabled {
t.Error("watchdog.enabled attendu true")
}
if cfg.Watchdog.IntervalSeconds != 60 {
t.Errorf("watchdog.interval_seconds attendu 60 (défaut viper), obtenu %d", cfg.Watchdog.IntervalSeconds)
}
}
func TestLoadConfig_ExempleFourniEstValide(t *testing.T) {
cfg, err := LoadConfig("../../../conf/agent/config.exemple.yml")
if err != nil {
t.Fatalf("config.exemple.yml illisible : %v", err)
}
if !cfg.Watchdog.Enabled {
t.Error("config.exemple.yml devrait activer le watchdog")
}
if cfg.Watchdog.IntervalSeconds != 60 {
t.Errorf("config.exemple.yml : interval_seconds attendu 60, obtenu %d", cfg.Watchdog.IntervalSeconds)
}
if cfg.QEMU.QMPDir == "" {
t.Error("config.exemple.yml devrait définir qemu.qmp_dir")
}
}

View file

@ -1,17 +0,0 @@
package configuration
import "fmt"
const (
BackendDnsmasq = "dnsmasq"
BackendTwo = "two"
)
func ValidBackend(backend string) error {
switch backend {
case BackendDnsmasq, BackendTwo:
return nil
default:
return fmt.Errorf("unknown dhcp backend %q: expected %q or %q", backend, BackendDnsmasq, BackendTwo)
}
}

View file

@ -1,82 +0,0 @@
package configuration
import (
"os"
"path/filepath"
"testing"
)
func writeConfig(t *testing.T, body string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "agent.yml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("WriteFile: %v", err)
}
return path
}
func TestValidBackend_AcceptsTheTwoKnownBackends(t *testing.T) {
for _, backend := range []string{BackendDnsmasq, BackendTwo} {
if err := ValidBackend(backend); err != nil {
t.Errorf("ValidBackend(%q) = %v, want nil", backend, err)
}
}
}
func TestValidBackend_RejectsAnythingElse(t *testing.T) {
for _, backend := range []string{"", "dhcpd", "DNSMASQ", "two "} {
if err := ValidBackend(backend); err == nil {
t.Errorf("ValidBackend(%q) = nil, want an error", backend)
}
}
}
func TestLoadConfig_DefaultsToDnsmasq(t *testing.T) {
path := writeConfig(t, "database:\n path: /tmp/two\n")
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.DHCP.Backend != BackendDnsmasq {
t.Errorf("backend = %q, want %q: a 0.1.0 config must keep behaving as before", cfg.DHCP.Backend, BackendDnsmasq)
}
}
func TestLoadConfig_ReadsTheTwoBackend(t *testing.T) {
path := writeConfig(t, "dhcp:\n backend: two\n")
cfg, err := LoadConfig(path)
if err != nil {
t.Fatalf("LoadConfig: %v", err)
}
if cfg.DHCP.Backend != BackendTwo {
t.Errorf("backend = %q, want two", cfg.DHCP.Backend)
}
}
func TestLoadConfig_MalformedFileIsReported(t *testing.T) {
path := writeConfig(t, "--\n\ndhcp:\n backend: two\n")
if _, err := LoadConfig(path); err == nil {
t.Fatal("an unparseable config must be reported: silently falling back to defaults would run the wrong dhcp backend")
}
}
func TestLoadConfig_TabIndentedFileIsReported(t *testing.T) {
path := writeConfig(t, "dhcp:\n\tbackend: two\n")
if _, err := LoadConfig(path); err == nil {
t.Fatal("yaml forbids tabs for indentation: that must surface, not be swallowed")
}
}
func TestLoadConfig_MissingFileStillFallsBackToDefaults(t *testing.T) {
cfg, err := LoadConfig(filepath.Join(t.TempDir(), "absent.yml"))
if err != nil {
t.Fatalf("an absent file remains valid, only an unreadable one is an error: %v", err)
}
if cfg.DHCP.Backend != BackendDnsmasq {
t.Errorf("backend = %q, want %q", cfg.DHCP.Backend, BackendDnsmasq)
}
}

View file

@ -1,10 +1,6 @@
package configuration
import (
"errors"
"fmt"
"os"
"github.com/spf13/viper"
)
@ -35,9 +31,6 @@ type Config struct {
Metadata struct {
RunDir string `mapstructure:"run_dir"`
} `mapstructure:"metadata"`
DHCP struct {
Backend string `mapstructure:"backend"`
} `mapstructure:"dhcp"`
Admin struct {
Enabled bool `mapstructure:"enabled"`
Address string `mapstructure:"address"`
@ -51,10 +44,6 @@ type Config struct {
MonitorDir string `mapstructure:"monitor_dir"`
QMPDir string `mapstructure:"qmp_dir"`
} `mapstructure:"qemu"`
Watchdog struct {
Enabled bool `mapstructure:"enabled"`
IntervalSeconds int `mapstructure:"interval_seconds"`
} `mapstructure:"watchdog"`
DefaultInterface string `mapstructure:"default_interface"`
Interfaces map[string]string `mapstructure:"interfaces"`
}
@ -74,15 +63,12 @@ func LoadConfig(path string) (*Config, error) {
v.SetDefault("dispatcher.timeout_seconds", 300)
v.SetDefault("dispatcher.poll_seconds", 2)
v.SetDefault("metadata.run_dir", "/run/two/metadata")
v.SetDefault("dhcp.backend", BackendDnsmasq)
v.SetDefault("qemu.ovmf_code_path", "/usr/share/OVMF/OVMF_CODE.fd")
v.SetDefault("qemu.ovmf_vars_template", "/usr/share/OVMF/OVMF_VARS.fd")
v.SetDefault("qemu.uefi_vars_dir", "/run/two/vms/uefi")
v.SetDefault("qemu.serial_dir", "/run/two/vms/serial")
v.SetDefault("qemu.monitor_dir", "/run/two/vms/monitor")
v.SetDefault("qemu.qmp_dir", "/run/two/vms/qmp")
v.SetDefault("watchdog.enabled", false)
v.SetDefault("watchdog.interval_seconds", 60)
v.SetDefault("admin.enabled", false)
v.SetDefault("admin.address", "127.0.0.1")
v.SetDefault("admin.port", 9091)
@ -90,9 +76,7 @@ func LoadConfig(path string) (*Config, error) {
v.SetDefault("logger.level", "info")
v.SetDefault("logger.debug", false)
if err := v.ReadInConfig(); err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read %s: %w", path, err)
}
v.ReadInConfig()
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {

View file

@ -55,7 +55,7 @@ func newConf(t *testing.T, cidr string) Config {
gw := net.ParseIP("192.168.1.1").To4()
return Config{
Network: network,
InterfaceIP: net.ParseIP("192.168.1.254").To4(),
VPCGateway: gw,
VPCRoute: vpcNet,
DefaultGateway: gw,
Name: "test",
@ -109,59 +109,33 @@ func TestGenerateConfig_NoDefaultGateway(t *testing.T) {
}
}
func TestGenerateConfig_NoDefaultGatewaySuppressesRouterOption(t *testing.T) {
conf := newConf(t, "192.168.1.0/29")
conf.DefaultGateway = nil
path, _, _ := GenerateConfig(conf)
content, _ := os.ReadFile(path)
if !strings.Contains(string(content), "\ndhcp-option=3\n") {
t.Errorf("dhcp-option=3 nue absente : sans elle dnsmasq annonce sa propre adresse comme routeur\n%s", content)
}
}
func TestGenerateConfig_NilDefaultGatewayEmitsNoRoute(t *testing.T) {
// Contrat du paquet, pas politique de subnet : depuis 2026-08-24 `startDHCP`
// renseigne toujours DefaultGateway, donc ce chemin n'est plus emprunté en
// production. Il reste valide — le générateur ne doit rien inventer.
conf := newConf(t, "192.168.1.0/29")
conf.DefaultGateway = nil
path, _, _ := GenerateConfig(conf)
content, _ := os.ReadFile(path)
if !strings.Contains(string(content), "dhcp-option=121,") {
t.Fatalf("dhcp-option=121 toujours attendue, ne serait-ce que pour la route metadata :\n%s", content)
}
if strings.Contains(string(content), "dhcp-option=3,") {
t.Errorf("DefaultGateway nulle : aucune route par défaut ne doit être émise\n%s", content)
}
}
func TestGenerateConfig_ContainsVPCRoute(t *testing.T) {
routes := route121(t, confLines(t, newConf(t, "192.168.1.0/29")))
if !strings.Contains(routes, "10.0.0.0/16,192.168.1.254") {
t.Errorf("route VPC absente, ou next-hop autre que l'interface_ip du subnet :\n%s", routes)
conf := newConf(t, "192.168.1.0/29")
path, _, _ := GenerateConfig(conf)
content, _ := os.ReadFile(path)
if !strings.Contains(string(content), "dhcp-option=121,10.0.0.0/16,192.168.1.1") {
t.Errorf("dhcp-option=121 absente ou incorrecte :\n%s", content)
}
}
func TestGenerateConfig_NoVPCRoute(t *testing.T) {
conf := newConf(t, "192.168.1.0/29")
conf.VPCRoute = nil
path, _, _ := GenerateConfig(conf)
content, _ := os.ReadFile(path)
routes := route121(t, confLines(t, conf))
if strings.Contains(routes, "10.0.0.0/16") {
t.Errorf("route VPC présente alors que VPCRoute=nil :\n%s", routes)
if strings.Contains(string(content), "dhcp-option=121,") {
t.Errorf("dhcp-option=121 présente alors que VPCRoute=nil :\n%s", content)
}
}
func TestGenerateConfig_ContainsDhcpRange(t *testing.T) {
_, network, _ := net.ParseCIDR("10.10.0.0/24")
conf := Config{
Network: network,
InterfaceIP: net.ParseIP("10.10.0.1").To4(),
Name: "vpc1",
ConfDir: t.TempDir(),
Network: network,
Name: "vpc1",
ConfDir: t.TempDir(),
}
path, _, _ := GenerateConfig(conf)
content, _ := os.ReadFile(path)
@ -171,56 +145,32 @@ func TestGenerateConfig_ContainsDhcpRange(t *testing.T) {
}
}
func TestGenerateConfig_OneEntryPerIP(t *testing.T) {
// /29 = 8 adresses. Les entrées ne vont plus dans le fichier dnsmasq mais
// dans la map retournée, que StoreDHCPEntries écrit en base pour GetMACForIP.
_, entries, err := GenerateConfig(newConf(t, "10.0.0.0/29"))
if err != nil {
t.Fatalf("GenerateConfig : %v", err)
}
if len(entries) != 8 {
t.Errorf("attendu 8 entrées ip->mac, obtenu %d", len(entries))
}
}
func TestGenerateConfig_NoPreGeneratedHosts(t *testing.T) {
// Une entrée dhcp-host pré-générée fait rejeter celle du dhcp-hostsdir
// (« duplicate dhcp-host IP address »), sans erreur : la VM reçoit alors
// les options non taggées. Vérifié sur dnsmasq 2.90.
content := confLines(t, newConf(t, "10.0.0.0/29"))
if strings.Contains(content, "dhcp-host=") {
t.Errorf("aucune entrée dhcp-host ne doit être pré-générée :\n%s", content)
}
}
func TestGenerateConfig_PointsToDirs(t *testing.T) {
func TestGenerateConfig_OneHostEntryPerIP(t *testing.T) {
// /29 = réseau + broadcast + 6 hôtes → 8 adresses
conf := newConf(t, "10.0.0.0/29")
content := confLines(t, conf)
path, _, _ := GenerateConfig(conf)
content, _ := os.ReadFile(path)
for _, want := range []string{
"dhcp-hostsdir=" + HostsDir(conf.ConfDir, conf.Name),
"dhcp-optsdir=" + OptsDir(conf.ConfDir, conf.Name),
} {
if !strings.Contains(content, want) {
t.Errorf("%q absent :\n%s", want, content)
lines := strings.Split(string(content), "\n")
count := 0
for _, l := range lines {
if strings.HasPrefix(l, "dhcp-host=") {
count++
}
}
for _, dir := range []string{HostsDir(conf.ConfDir, conf.Name), OptsDir(conf.ConfDir, conf.Name)} {
if fi, err := os.Stat(dir); err != nil || !fi.IsDir() {
t.Errorf("répertoire %q non créé : %v", dir, err)
}
// /29 contient 8 adresses (0 à 7)
if count != 8 {
t.Errorf("attendu 8 entrées dhcp-host, obtenu %d", count)
}
}
func TestGenerateConfig_MACPrefix(t *testing.T) {
_, entries, err := GenerateConfig(newConf(t, "10.0.0.0/30"))
if err != nil {
t.Fatalf("GenerateConfig : %v", err)
}
for ip, mac := range entries {
if !strings.HasPrefix(mac, "00:22:33:") {
t.Errorf("mac de %s sans le préfixe 00:22:33: : %s", ip, mac)
}
conf := newConf(t, "10.0.0.0/30") // 4 adresses
path, _, _ := GenerateConfig(conf)
content, _ := os.ReadFile(path)
if !strings.Contains(string(content), "00:22:33:") {
t.Errorf("préfixe MAC 00:22:33: absent :\n%s", content)
}
}
@ -228,10 +178,9 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) {
dir := filepath.Join(t.TempDir(), "sous", "dossier")
_, network, _ := net.ParseCIDR("10.0.0.0/30")
conf := Config{
Network: network,
InterfaceIP: net.ParseIP("10.0.0.1").To4(),
Name: "net",
ConfDir: dir,
Network: network,
Name: "net",
ConfDir: dir,
}
if _, _, err := GenerateConfig(conf); err != nil {
t.Fatalf("GenerateConfig devrait créer les répertoires manquants : %v", err)
@ -240,79 +189,3 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) {
t.Errorf("répertoire %q non créé", dir)
}
}
// --- option 121 : routes classless ---
func confLines(t *testing.T, c Config) string {
t.Helper()
path, _, err := GenerateConfig(c)
if err != nil {
t.Fatalf("GenerateConfig : %v", err)
}
content, err := os.ReadFile(path)
if err != nil {
t.Fatalf("lecture : %v", err)
}
return string(content)
}
func route121(t *testing.T, content string) string {
t.Helper()
for _, line := range strings.Split(content, "\n") {
if strings.HasPrefix(line, "dhcp-option=121,") {
return strings.TrimPrefix(line, "dhcp-option=121,")
}
}
t.Fatalf("aucune dhcp-option=121 dans :\n%s", content)
return ""
}
func TestGenerateConfig_AlwaysRoutesToMetadata(t *testing.T) {
conf := newConf(t, "192.168.1.0/29")
conf.VPCRoute = nil
conf.DefaultGateway = nil
routes := route121(t, confLines(t, conf))
if !strings.Contains(routes, "169.254.169.254/32,192.168.1.254") {
t.Errorf("sans route vers le serveur de métadonnées, cloud-init échoue et la VM n'est pas provisionnée :\n%s", routes)
}
}
func TestGenerateConfig_MetadataRouteEvenInBridgeMode(t *testing.T) {
conf := newConf(t, "192.168.1.0/29")
conf.VPCRoute = nil
routes := route121(t, confLines(t, conf))
if !strings.Contains(routes, "169.254.169.254/32") {
t.Errorf("le mode bridge a besoin de la même route :\n%s", routes)
}
}
func TestGenerateConfig_DefaultRouteAlsoInOption121(t *testing.T) {
conf := newConf(t, "192.168.1.0/29")
conf.VPCRoute = nil
routes := route121(t, confLines(t, conf))
if !strings.Contains(routes, "0.0.0.0/0,192.168.1.1") {
t.Errorf("RFC 3442 : un client qui lit l'option 121 ignore l'option 3, la route par défaut doit donc figurer dans la 121 :\n%s", routes)
}
}
func TestGenerateConfig_NoDefaultRouteMeansNoCatchAllInOption121(t *testing.T) {
conf := newConf(t, "192.168.1.0/29")
conf.DefaultGateway = nil
routes := route121(t, confLines(t, conf))
if strings.Contains(routes, "0.0.0.0/0") {
t.Errorf("aucune route par défaut demandée, la 121 ne doit pas en contenir :\n%s", routes)
}
}
func TestGenerateConfig_MissingInterfaceIPIsAnError(t *testing.T) {
conf := newConf(t, "192.168.1.0/29")
conf.InterfaceIP = nil
if _, _, err := GenerateConfig(conf); err == nil {
t.Error("sans interface_ip aucune route metadata n'est possible : il faut échouer, pas écrire une conf muette")
}
}

View file

@ -6,64 +6,38 @@ import (
"os"
"path/filepath"
"strings"
"git.g3e.fr/syonad/two/internal/metadata"
)
func GenerateConfig(c Config) (string, map[string]string, error) {
if c.InterfaceIP == nil {
return "", nil, fmt.Errorf("interface ip is required: guests would have no route to the metadata server")
}
mask := fmt.Sprintf("%d.%d.%d.%d", c.Network.Mask[0], c.Network.Mask[1], c.Network.Mask[2], c.Network.Mask[3])
var sb strings.Builder
fmt.Fprintf(&sb, "no-resolv\n")
fmt.Fprintf(&sb, "dhcp-range=%s,static,%s,12h\n", c.Network.IP.String(), mask)
fmt.Fprintf(&sb, "dhcp-option=121,%s\n", strings.Join(classlessRoutes(c), ","))
if c.VPCRoute != nil {
fmt.Fprintf(&sb, "dhcp-option=121,%s,%s\n", c.VPCRoute.String(), c.VPCGateway.String())
}
if c.DefaultGateway != nil {
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.DefaultGateway.String())
} else {
fmt.Fprintf(&sb, "dhcp-option=3\n")
}
fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n")
fmt.Fprintf(&sb, "dhcp-hostsdir=%s\n", HostsDir(c.ConfDir, c.Name))
fmt.Fprintf(&sb, "dhcp-optsdir=%s\n", OptsDir(c.ConfDir, c.Name))
fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n\n")
entries := Entries(c.Network)
for _, dir := range []string{c.ConfDir, HostsDir(c.ConfDir, c.Name), OptsDir(c.ConfDir, c.Name)} {
if err := os.MkdirAll(dir, 0755); err != nil {
return "", nil, fmt.Errorf("create %s: %w", dir, err)
}
entries := make(map[string]string)
i := 0
for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) {
mac := fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF)
fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", mac, ip)
entries[ip.String()] = mac
i++
}
outPath := filepath.Join(c.ConfDir, c.Name+".conf")
if err := os.MkdirAll(c.ConfDir, 0755); err != nil {
return "", nil, err
}
return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644)
}
func Entries(network *net.IPNet) map[string]string {
entries := make(map[string]string)
i := 0
for ip := cloneIP(network.IP); network.Contains(ip); incrementIP(ip) {
entries[ip.String()] = fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF)
i++
}
return entries
}
func classlessRoutes(c Config) []string {
nextHop := c.InterfaceIP.String()
routes := []string{metadata.ServiceIP + "/32," + nextHop}
if c.VPCRoute != nil {
routes = append(routes, c.VPCRoute.String()+","+nextHop)
}
if c.DefaultGateway != nil {
routes = append(routes, "0.0.0.0/0,"+c.DefaultGateway.String())
}
return routes
}
func incrementIP(ip net.IP) {
for j := len(ip) - 1; j >= 0; j-- {
ip[j]++

View file

@ -1,127 +0,0 @@
package dhcp
import (
"fmt"
"os"
"path/filepath"
"strings"
)
type Reservation struct {
MAC string
IP string
Tag string // pose set:<Tag> sur l'entrée, pour cibler les options par interface
}
func HostsDir(confDir, name string) string {
return filepath.Join(confDir, name+".hosts.d")
}
func OptsDir(confDir, name string) string {
return filepath.Join(confDir, name+".opts.d")
}
func UnitName(name string) string {
return "dnsmasq@" + name + ".service"
}
func WriteReservations(confDir, name, vmName string, res []Reservation) error {
if len(res) == 0 {
return fmt.Errorf("no reservation for vm %q: it would get no address", vmName)
}
dir := HostsDir(confDir, name)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
var sb strings.Builder
for _, r := range res {
if r.MAC == "" || r.IP == "" {
return fmt.Errorf("incomplete reservation for vm %q: mac=%q ip=%q", vmName, r.MAC, r.IP)
}
if r.Tag == "" {
fmt.Fprintf(&sb, "%s,%s\n", r.MAC, r.IP)
continue
}
fmt.Fprintf(&sb, "%s,%s,set:%s\n", r.MAC, r.IP, r.Tag)
}
path := filepath.Join(dir, vmName)
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
// WriteVMOptions écrit les options DHCP propres à des interfaces de cette VM
// sur ce subnet : elles suppriment la route par défaut — option 3 nue — et
// réémettent les autres routes.
//
// Un override de l'option 121 remplace la précédente en entier, il ne s'y
// ajoute pas (vérifié sur dnsmasq 2.90) : omettre la route vers le serveur de
// métadonnées la ferait disparaître, et la VM ne se provisionnerait pas.
func WriteVMOptions(confDir, name, vmName string, tags []string, c Config) error {
if len(tags) == 0 {
return RemoveVMOptions(confDir, name, vmName)
}
if c.InterfaceIP == nil {
return fmt.Errorf("interface ip is required to build options for vm %q", vmName)
}
if c.DefaultGateway != nil {
return fmt.Errorf("vm options for %q must not carry a default route", vmName)
}
dir := OptsDir(confDir, name)
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("create %s: %w", dir, err)
}
routes := strings.Join(classlessRoutes(c), ",")
var sb strings.Builder
for _, tag := range tags {
fmt.Fprintf(&sb, "tag:%s,3\n", tag)
fmt.Fprintf(&sb, "tag:%s,121,%s\n", tag, routes)
}
path := filepath.Join(dir, vmName)
if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
func RemoveVMOptions(confDir, name, vmName string) error {
path := filepath.Join(OptsDir(confDir, name), vmName)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", path, err)
}
return nil
}
func RemoveReservations(confDir, name, vmName string) error {
for _, dir := range []string{HostsDir(confDir, name), OptsDir(confDir, name)} {
path := filepath.Join(dir, vmName)
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", path, err)
}
}
return nil
}
func RemoveSubnetDirs(confDir, name string) error {
for _, dir := range []string{HostsDir(confDir, name), OptsDir(confDir, name)} {
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("remove %s: %w", dir, err)
}
}
return nil
}
func RemoveConfig(confDir, name string) error {
path := filepath.Join(confDir, name+".conf")
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove %s: %w", path, err)
}
return nil
}

View file

@ -1,228 +0,0 @@
package dhcp
import (
"net"
"os"
"path/filepath"
"strings"
"testing"
)
const subName = "vp-admin_br-000001"
func TestWriteReservations_WritesOneLinePerInterface(t *testing.T) {
dir := t.TempDir()
res := []Reservation{
{MAC: "00:22:33:00:01:02", IP: "10.1.1.2"},
{MAC: "00:22:33:00:02:07", IP: "10.1.2.7"},
}
if err := WriteReservations(dir, subName, "i-web", res); err != nil {
t.Fatalf("WriteReservations : %v", err)
}
content, err := os.ReadFile(filepath.Join(HostsDir(dir, subName), "i-web"))
if err != nil {
t.Fatalf("fichier absent : %v", err)
}
want := "00:22:33:00:01:02,10.1.1.2\n00:22:33:00:02:07,10.1.2.7\n"
if string(content) != want {
t.Errorf("contenu attendu %q, obtenu %q", want, content)
}
}
func TestWriteReservations_CreatesDir(t *testing.T) {
dir := t.TempDir()
if err := WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}}); err != nil {
t.Fatalf("WriteReservations : %v", err)
}
if fi, err := os.Stat(HostsDir(dir, subName)); err != nil || !fi.IsDir() {
t.Errorf("hosts.d non créé : %v", err)
}
}
func TestWriteReservations_EmptyIsAnError(t *testing.T) {
if err := WriteReservations(t.TempDir(), subName, "i-web", nil); err == nil {
t.Error("sans réservation la VM n'obtiendrait aucune adresse : il faut échouer, pas écrire un fichier vide")
}
}
func TestWriteReservations_IncompleteIsAnError(t *testing.T) {
cases := []Reservation{{MAC: "", IP: "10.0.0.1"}, {MAC: "aa:bb", IP: ""}}
for _, r := range cases {
if err := WriteReservations(t.TempDir(), subName, "i-web", []Reservation{r}); err == nil {
t.Errorf("réservation incomplète acceptée : %+v", r)
}
}
}
func TestWriteReservations_Overwrites(t *testing.T) {
dir := t.TempDir()
_ = WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}})
if err := WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "bb", IP: "10.0.0.2"}}); err != nil {
t.Fatalf("WriteReservations : %v", err)
}
content, _ := os.ReadFile(filepath.Join(HostsDir(dir, subName), "i-web"))
if string(content) != "bb,10.0.0.2\n" {
t.Errorf("la réécriture doit remplacer, obtenu %q", content)
}
}
func TestRemoveReservations_RemovesBothFiles(t *testing.T) {
dir := t.TempDir()
_ = WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}})
if err := os.MkdirAll(OptsDir(dir, subName), 0755); err != nil {
t.Fatal(err)
}
optsFile := filepath.Join(OptsDir(dir, subName), "i-web")
if err := os.WriteFile(optsFile, []byte("tag:i-web,3,10.0.0.1\n"), 0644); err != nil {
t.Fatal(err)
}
if err := RemoveReservations(dir, subName, "i-web"); err != nil {
t.Fatalf("RemoveReservations : %v", err)
}
for _, p := range []string{filepath.Join(HostsDir(dir, subName), "i-web"), optsFile} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Errorf("%s aurait dû être supprimé", p)
}
}
}
func TestRemoveReservations_AbsentIsNotAnError(t *testing.T) {
if err := RemoveReservations(t.TempDir(), subName, "jamais-creee"); err != nil {
t.Errorf("supprimer une VM sans réservation ne doit pas échouer : %v", err)
}
}
func TestRemoveReservations_LeavesOtherVMs(t *testing.T) {
dir := t.TempDir()
_ = WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}})
_ = WriteReservations(dir, subName, "i-db", []Reservation{{MAC: "bb", IP: "10.0.0.2"}})
if err := RemoveReservations(dir, subName, "i-web"); err != nil {
t.Fatalf("RemoveReservations : %v", err)
}
if _, err := os.Stat(filepath.Join(HostsDir(dir, subName), "i-db")); err != nil {
t.Errorf("i-db ne devait pas être touchée : %v", err)
}
}
func TestRemoveSubnetDirs(t *testing.T) {
dir := t.TempDir()
_ = WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}})
if err := RemoveSubnetDirs(dir, subName); err != nil {
t.Fatalf("RemoveSubnetDirs : %v", err)
}
for _, d := range []string{HostsDir(dir, subName), OptsDir(dir, subName)} {
if _, err := os.Stat(d); !os.IsNotExist(err) {
t.Errorf("%s aurait dû être supprimé", d)
}
}
}
func TestUnitName(t *testing.T) {
if got := UnitName(subName); got != "dnsmasq@vp-admin_br-000001.service" {
t.Errorf("unit attendue dnsmasq@%s.service, obtenu %s", subName, got)
}
}
// --- options par interface ---
func TestWriteReservations_WithTags(t *testing.T) {
dir := t.TempDir()
res := []Reservation{
{MAC: "00:22:33:00:01:02", IP: "10.1.1.2", Tag: "i-web-0"},
{MAC: "00:22:33:00:02:07", IP: "10.1.2.7", Tag: "i-web-1"},
}
if err := WriteReservations(dir, subName, "i-web", res); err != nil {
t.Fatalf("WriteReservations : %v", err)
}
content, _ := os.ReadFile(filepath.Join(HostsDir(dir, subName), "i-web"))
want := "00:22:33:00:01:02,10.1.1.2,set:i-web-0\n00:22:33:00:02:07,10.1.2.7,set:i-web-1\n"
if string(content) != want {
t.Errorf("attendu %q, obtenu %q", want, content)
}
}
func vmOptions(t *testing.T, dir string, tags []string, vpcRoute *net.IPNet) string {
t.Helper()
if err := WriteVMOptions(dir, subName, "i-web", tags, Config{
InterfaceIP: net.ParseIP("10.1.1.1").To4(),
VPCRoute: vpcRoute,
}); err != nil {
t.Fatalf("WriteVMOptions : %v", err)
}
b, err := os.ReadFile(filepath.Join(OptsDir(dir, subName), "i-web"))
if err != nil {
return ""
}
return string(b)
}
func TestWriteVMOptions_SuppressesDefaultRoute(t *testing.T) {
_, vpcNet, _ := net.ParseCIDR("192.168.0.0/16")
got := vmOptions(t, t.TempDir(), []string{"i-web-1"}, vpcNet)
if !strings.Contains(got, "tag:i-web-1,3\n") {
t.Errorf("l'option 3 nue doit supprimer la route par défaut :\n%s", got)
}
if strings.Contains(got, "0.0.0.0/0") {
t.Errorf("la route par défaut ne doit pas figurer dans l'override :\n%s", got)
}
}
func TestWriteVMOptions_ReEmitsMetadataRoute(t *testing.T) {
_, vpcNet, _ := net.ParseCIDR("192.168.0.0/16")
got := vmOptions(t, t.TempDir(), []string{"i-web-1"}, vpcNet)
if !strings.Contains(got, "169.254.169.254/32,10.1.1.1") {
t.Errorf("un override de l'option 121 remplace la précédente en entier : sans la route metadata, la VM ne se provisionne pas\n%s", got)
}
if !strings.Contains(got, "192.168.0.0/16,10.1.1.1") {
t.Errorf("la route VPC doit être réémise elle aussi :\n%s", got)
}
}
func TestWriteVMOptions_BridgeHasNoVPCRoute(t *testing.T) {
got := vmOptions(t, t.TempDir(), []string{"i-web-1"}, nil)
if !strings.Contains(got, "169.254.169.254/32") {
t.Errorf("route metadata absente :\n%s", got)
}
if strings.Contains(got, "192.168") {
t.Errorf("aucune route VPC attendue en mode bridge :\n%s", got)
}
}
func TestWriteVMOptions_OneBlockPerTag(t *testing.T) {
got := vmOptions(t, t.TempDir(), []string{"i-web-1", "i-web-2"}, nil)
for _, tag := range []string{"tag:i-web-1,3", "tag:i-web-2,3"} {
if !strings.Contains(got, tag) {
t.Errorf("%q absent :\n%s", tag, got)
}
}
}
func TestWriteVMOptions_NoTagRemovesFile(t *testing.T) {
dir := t.TempDir()
_ = vmOptions(t, dir, []string{"i-web-1"}, nil)
if err := WriteVMOptions(dir, subName, "i-web", nil, Config{}); err != nil {
t.Fatalf("WriteVMOptions : %v", err)
}
if _, err := os.Stat(filepath.Join(OptsDir(dir, subName), "i-web")); !os.IsNotExist(err) {
t.Error("sans interface non primaire, aucun fichier d'options ne doit subsister")
}
}
func TestWriteVMOptions_RefusesDefaultGateway(t *testing.T) {
err := WriteVMOptions(t.TempDir(), subName, "i-web", []string{"i-web-1"}, Config{
InterfaceIP: net.ParseIP("10.1.1.1").To4(),
DefaultGateway: net.ParseIP("10.1.1.254").To4(),
})
if err == nil {
t.Error("ces options servent à retirer la route par défaut : en porter une est une incohérence")
}
}

View file

@ -4,13 +4,11 @@ import (
"net"
)
const DefaultConfDir = "/etc/dnsmasq.d"
type Config struct {
Network *net.IPNet
InterfaceIP net.IP // subnet gateway; next-hop for the metadata and VPC routes
VPCRoute *net.IPNet // if non-nil, routed via InterfaceIP in option 121
DefaultGateway net.IP // if non-nil, default route via option 3 and 0.0.0.0/0 in option 121
VPCGateway net.IP // next-hop for VPCRoute (option 121)
VPCRoute *net.IPNet // if non-nil, emit dhcp-option=121,VPCRoute,VPCGateway
DefaultGateway net.IP // if non-nil, emit dhcp-option=3,DefaultGateway
Name string
ConfDir string
}

View file

@ -1,53 +0,0 @@
package dhcpbackend
import (
"fmt"
"net"
configuration "git.g3e.fr/syonad/two/internal/config/agent"
)
type Subnet struct {
Name string
VPC string
Bridge string
Network *net.IPNet
InterfaceIP net.IP
VPCRoute *net.IPNet
DefaultGateway net.IP
}
func (s Subnet) Instance() string {
return s.VPC + "_" + s.Bridge
}
type Reservation struct {
Index int
MAC string
IP string
DefaultRoute bool
}
type Backend interface {
Unit(s Subnet) string
ConfigureSubnet(s Subnet) error
TeardownSubnet(s Subnet) error
SetVM(s Subnet, vmName string, res []Reservation) error
DelVM(s Subnet, vmName string, res []Reservation) error
}
func New(cfg *configuration.Config) (Backend, error) {
if cfg == nil {
return nil, fmt.Errorf("configuration is required to pick a dhcp backend")
}
if err := configuration.ValidBackend(cfg.DHCP.Backend); err != nil {
return nil, err
}
switch cfg.DHCP.Backend {
case configuration.BackendTwo:
return Two{}, nil
default:
return Dnsmasq{}, nil
}
}

View file

@ -1,99 +0,0 @@
package dhcpbackend
import (
"net"
"testing"
configuration "git.g3e.fr/syonad/two/internal/config/agent"
)
func testSubnet(t *testing.T) Subnet {
t.Helper()
_, network, err := net.ParseCIDR("10.0.5.0/24")
if err != nil {
t.Fatalf("ParseCIDR: %v", err)
}
_, vpcRoute, err := net.ParseCIDR("10.0.0.0/16")
if err != nil {
t.Fatalf("ParseCIDR: %v", err)
}
return Subnet{
Name: "sn-000001",
VPC: "vp-admin",
Bridge: "br-000001",
Network: network,
InterfaceIP: net.ParseIP("10.0.5.1"),
VPCRoute: vpcRoute,
DefaultGateway: net.ParseIP("10.0.5.254"),
}
}
func configFor(backend string) *configuration.Config {
cfg := &configuration.Config{}
cfg.DHCP.Backend = backend
return cfg
}
func TestInstance_JoinsVPCAndBridge(t *testing.T) {
if got := testSubnet(t).Instance(); got != "vp-admin_br-000001" {
t.Errorf("Instance = %s, want vp-admin_br-000001", got)
}
}
func TestNew_DnsmasqIsTheDefault(t *testing.T) {
backend, err := New(configFor(configuration.BackendDnsmasq))
if err != nil {
t.Fatalf("New: %v", err)
}
if _, ok := backend.(Dnsmasq); !ok {
t.Errorf("backend = %T, want Dnsmasq", backend)
}
}
func TestNew_ReturnsTheTwoBackendWhenAsked(t *testing.T) {
backend, err := New(configFor(configuration.BackendTwo))
if err != nil {
t.Fatalf("New: %v", err)
}
if _, ok := backend.(Two); !ok {
t.Errorf("backend = %T, want Two", backend)
}
}
func TestNew_RejectsAnUnknownBackend(t *testing.T) {
if _, err := New(configFor("dhcpd")); err == nil {
t.Fatal("an unknown backend must be reported rather than silently defaulted")
}
}
func TestNew_RejectsAnEmptyBackend(t *testing.T) {
if _, err := New(configFor("")); err == nil {
t.Fatal("an empty backend must be reported: the config default is what fills it")
}
}
func TestNew_RejectsANilConfig(t *testing.T) {
if _, err := New(nil); err == nil {
t.Fatal("a nil config must be reported")
}
}
func TestUnit_NamesADistinctUnitPerBackend(t *testing.T) {
s := testSubnet(t)
if got := (Dnsmasq{}).Unit(s); got != "dnsmasq@vp-admin_br-000001.service" {
t.Errorf("dnsmasq unit = %s", got)
}
if got := (Two{}).Unit(s); got != "dhcp@vp-admin_br-000001.service" {
t.Errorf("two unit = %s", got)
}
}
func TestTag_IsPerInterfaceNotPerVM(t *testing.T) {
if tag("vm-web", 0) == tag("vm-web", 1) {
t.Error("two interfaces of the same vm must get distinct tags")
}
if got := tag("vm-web", 1); got != "vm-web-1" {
t.Errorf("tag = %s, want vm-web-1", got)
}
}

View file

@ -1,132 +0,0 @@
package dhcpbackend
import (
"fmt"
"path/filepath"
"git.g3e.fr/syonad/two/internal/dhcp"
"git.g3e.fr/syonad/two/pkg/systemd"
)
type Dnsmasq struct {
ConfDir string
}
func (b Dnsmasq) confDir() string {
if b.ConfDir == "" {
return dhcp.DefaultConfDir
}
return b.ConfDir
}
func tag(vmName string, index int) string {
return fmt.Sprintf("%s-%d", vmName, index)
}
func (Dnsmasq) Unit(s Subnet) string {
return dhcp.UnitName(s.Instance())
}
func (b Dnsmasq) config(s Subnet) dhcp.Config {
return dhcp.Config{
Network: s.Network,
Name: s.Instance(),
ConfDir: b.confDir(),
InterfaceIP: s.InterfaceIP,
VPCRoute: s.VPCRoute,
DefaultGateway: s.DefaultGateway,
}
}
func (b Dnsmasq) ConfigureSubnet(s Subnet) error {
if _, _, err := dhcp.GenerateConfig(b.config(s)); err != nil {
return fmt.Errorf("generate dhcp config: %w", err)
}
svc, err := systemd.New()
if err != nil {
return fmt.Errorf("connect to systemd: %w", err)
}
defer svc.Close()
if err := svc.Start(b.Unit(s)); err != nil {
return fmt.Errorf("start dnsmasq: %w", err)
}
return nil
}
func (b Dnsmasq) TeardownSubnet(s Subnet) error {
svc, err := systemd.New()
if err != nil {
return fmt.Errorf("connect to systemd: %w", err)
}
defer svc.Close()
unit := b.Unit(s)
if status, err := svc.Status(unit); err == nil && status.ActiveState == "active" {
if err := svc.Stop(unit); err != nil {
return fmt.Errorf("stop dnsmasq: %w", err)
}
}
if err := dhcp.RemoveConfig(b.confDir(), s.Instance()); err != nil {
return err
}
return dhcp.RemoveSubnetDirs(b.confDir(), s.Instance())
}
func (b Dnsmasq) SetVM(s Subnet, vmName string, res []Reservation) error {
instance := s.Instance()
reservations := make([]dhcp.Reservation, 0, len(res))
var tags []string
for _, r := range res {
reservations = append(reservations, dhcp.Reservation{
MAC: r.MAC, IP: r.IP, Tag: tag(vmName, r.Index),
})
if !r.DefaultRoute {
tags = append(tags, tag(vmName, r.Index))
}
}
if err := dhcp.WriteReservations(b.confDir(), instance, vmName, reservations); err != nil {
return fmt.Errorf("write dhcp reservations on %s: %w", instance, err)
}
options := dhcp.Config{InterfaceIP: s.InterfaceIP, VPCRoute: s.VPCRoute}
if err := dhcp.WriteVMOptions(b.confDir(), instance, vmName, tags, options); err != nil {
return fmt.Errorf("write dhcp options on %s: %w", instance, err)
}
return nil
}
func (b Dnsmasq) DelVM(s Subnet, vmName string, _ []Reservation) error {
if err := dhcp.RemoveReservations(b.confDir(), s.Instance(), vmName); err != nil {
return err
}
svc, err := systemd.New()
if err != nil {
return fmt.Errorf("connect to systemd: %w", err)
}
defer svc.Close()
unit := b.Unit(s)
status, err := svc.Status(unit)
if err != nil || status.ActiveState != "active" {
return nil
}
if err := svc.Restart(unit); err != nil {
return fmt.Errorf("restart %s: %w", unit, err)
}
if status, err := svc.Status(unit); err != nil {
return fmt.Errorf("status %s after restart: %w", unit, err)
} else if status.ActiveState != "active" {
return fmt.Errorf("%s is %s after restart", unit, status.ActiveState)
}
return nil
}
func (b Dnsmasq) ConfigPath(s Subnet) string {
return filepath.Join(b.confDir(), s.Instance()+".conf")
}

View file

@ -1,113 +0,0 @@
package dhcpbackend
import (
"os"
"path/filepath"
"strings"
"testing"
)
func dnsmasqBackend(t *testing.T) Dnsmasq {
t.Helper()
return Dnsmasq{ConfDir: t.TempDir()}
}
func readFile(t *testing.T, path string) string {
t.Helper()
raw, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(raw)
}
func TestDnsmasq_ConfDirDefaultsToTheSystemOne(t *testing.T) {
if got := (Dnsmasq{}).confDir(); got != "/etc/dnsmasq.d" {
t.Errorf("confDir = %s, want /etc/dnsmasq.d", got)
}
}
func TestDnsmasqSetVM_WritesOneReservationPerInterface(t *testing.T) {
b := dnsmasqBackend(t)
s := testSubnet(t)
res := []Reservation{
{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
{Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
}
if err := b.SetVM(s, "vm-web", res); err != nil {
t.Fatalf("SetVM: %v", err)
}
hosts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".hosts.d", "vm-web"))
for _, want := range []string{"00:22:33:00:00:0a,10.0.5.10,set:vm-web-0", "00:22:33:00:00:0b,10.0.5.11,set:vm-web-1"} {
if !strings.Contains(hosts, want) {
t.Errorf("hosts file missing %q:\n%s", want, hosts)
}
}
}
func TestDnsmasqSetVM_TagsOnlyTheInterfacesWithoutADefaultRoute(t *testing.T) {
b := dnsmasqBackend(t)
s := testSubnet(t)
res := []Reservation{
{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
{Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
}
if err := b.SetVM(s, "vm-web", res); err != nil {
t.Fatalf("SetVM: %v", err)
}
opts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web"))
if strings.Contains(opts, "tag:vm-web-0") {
t.Errorf("the interface carrying the default route must get no override:\n%s", opts)
}
if !strings.Contains(opts, "tag:vm-web-1,3\n") {
t.Errorf("the secondary interface must get a bare option 3:\n%s", opts)
}
if !strings.Contains(opts, "tag:vm-web-1,121,") {
t.Errorf("the secondary interface must get its own option 121:\n%s", opts)
}
}
func TestDnsmasqSetVM_SecondaryOptionsKeepTheMetadataRoute(t *testing.T) {
b := dnsmasqBackend(t)
s := testSubnet(t)
res := []Reservation{{Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"}}
if err := b.SetVM(s, "vm-web", res); err != nil {
t.Fatalf("SetVM: %v", err)
}
opts := readFile(t, filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web"))
if !strings.Contains(opts, "169.254.169.254/32,10.0.5.1") {
t.Errorf("overriding option 121 without the metadata route breaks cloud-init:\n%s", opts)
}
if strings.Contains(opts, "0.0.0.0/0") {
t.Errorf("a secondary interface must not receive a default route:\n%s", opts)
}
}
func TestDnsmasqSetVM_AllInterfacesDefaultRoutedWritesNoOptions(t *testing.T) {
b := dnsmasqBackend(t)
s := testSubnet(t)
res := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}}
if err := b.SetVM(s, "vm-web", res); err != nil {
t.Fatalf("SetVM: %v", err)
}
path := filepath.Join(b.ConfDir, s.Instance()+".opts.d", "vm-web")
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("no options file expected, got %v: the subnet-wide options already carry the default route", err)
}
}
func TestDnsmasqSetVM_RejectsAVMWithoutReservation(t *testing.T) {
b := dnsmasqBackend(t)
if err := b.SetVM(testSubnet(t), "vm-web", nil); err == nil {
t.Fatal("a vm with no reservation would get no address: that must be reported")
}
}

View file

@ -1,147 +0,0 @@
package dhcpbackend
import (
"fmt"
"time"
dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
dhcpclient "git.g3e.fr/syonad/two/internal/client/dhcp"
"git.g3e.fr/syonad/two/pkg/db/statefile"
"git.g3e.fr/syonad/two/pkg/systemd"
)
const (
readyTimeout = 5 * time.Second
readyPoll = 50 * time.Millisecond
)
type Two struct {
RunDir string
}
func (b Two) runDir() string {
if b.RunDir == "" {
return dhcpapi.DefaultRunDir
}
return b.RunDir
}
func (b Two) Unit(s Subnet) string {
return dhcpapi.Unit(s.Instance())
}
func (b Two) client(s Subnet) *dhcpclient.Client {
return dhcpclient.New(dhcpapi.SocketPath(b.runDir(), s.Instance()))
}
func (b Two) statePath(s Subnet) string {
return dhcpapi.StatePath(b.runDir(), s.Instance())
}
func (b Two) waitReady(s Subnet, timeout, poll time.Duration) error {
client := b.client(s)
deadline := time.Now().Add(timeout)
var err error
for {
if _, _, err = client.GetState(); err == nil {
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("dhcp server for %s did not answer within %s: %w", s.Instance(), timeout, err)
}
time.Sleep(poll)
}
}
func (b Two) ConfigureSubnet(s Subnet) error {
if err := statefile.Remove(b.statePath(s)); err != nil {
return fmt.Errorf("remove residual state: %w", err)
}
svc, err := systemd.New()
if err != nil {
return fmt.Errorf("connect to systemd: %w", err)
}
defer svc.Close()
if err := svc.Start(b.Unit(s)); err != nil {
return fmt.Errorf("start dhcp: %w", err)
}
if err := b.waitReady(s, readyTimeout, readyPoll); err != nil {
return err
}
return b.pushSubnet(s)
}
func (b Two) pushSubnet(s Subnet) error {
subnet := dhcpapi.Subnet{
Network: s.Network.String(),
InterfaceIP: s.InterfaceIP.String(),
}
if s.VPCRoute != nil {
subnet.VPCRoute = s.VPCRoute.String()
}
if s.DefaultGateway != nil {
subnet.DefaultGateway = s.DefaultGateway.String()
}
if err := b.client(s).SetSubnet(subnet); err != nil {
return fmt.Errorf("configure dhcp for %s: %w", s.Instance(), err)
}
return nil
}
func (b Two) TeardownSubnet(s Subnet) error {
svc, err := systemd.New()
if err != nil {
return fmt.Errorf("connect to systemd: %w", err)
}
defer svc.Close()
unit := b.Unit(s)
if status, err := svc.Status(unit); err == nil && status.ActiveState == "active" {
if err := svc.Stop(unit); err != nil {
return fmt.Errorf("stop dhcp: %w", err)
}
}
return statefile.Remove(b.statePath(s))
}
func (b Two) SetVM(s Subnet, vmName string, res []Reservation) error {
client := b.client(s)
for _, r := range res {
host := dhcpapi.Host{
MAC: r.MAC,
IP: r.IP,
VM: vmName,
DefaultRoute: r.DefaultRoute,
}
if err := client.SetHost(host); err != nil {
return fmt.Errorf("reserve %s for vm %s on %s: %w", r.MAC, vmName, s.Instance(), err)
}
}
return nil
}
func (b Two) DelVM(s Subnet, vmName string, res []Reservation) error {
client := b.client(s)
for _, r := range res {
if err := client.DelHost(r.MAC); err != nil {
return fmt.Errorf("release %s of vm %s on %s: %w", r.MAC, vmName, s.Instance(), err)
}
}
return nil
}
func (b Two) StatePath(s Subnet) string {
return b.statePath(s)
}
func (b Two) State(s Subnet) (dhcpapi.State, string, error) {
return b.client(s).GetState()
}

View file

@ -1,254 +0,0 @@
package dhcpbackend
import (
"io"
"log/slog"
"net"
"os"
"testing"
"time"
dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
"git.g3e.fr/syonad/two/internal/dhcpd"
)
func shortTempDir(t *testing.T) string {
t.Helper()
dir, err := os.MkdirTemp("", "dhcpd")
if err != nil {
t.Fatalf("MkdirTemp: %v", err)
}
t.Cleanup(func() { os.RemoveAll(dir) })
return dir
}
func twoBackend(t *testing.T) (Two, Subnet, *dhcpd.Store) {
t.Helper()
b := Two{RunDir: shortTempDir(t)}
s := testSubnet(t)
store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance()))
if err := store.Load(); err != nil {
t.Fatalf("Load: %v", err)
}
server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil)))
if err != nil {
t.Fatalf("Listen: %v", err)
}
go server.Serve()
t.Cleanup(func() { server.Close() })
return b, s, store
}
func TestTwo_RunDirDefaultsToTheSharedOne(t *testing.T) {
if got := (Two{}).runDir(); got != dhcpapi.DefaultRunDir {
t.Errorf("runDir = %s, want %s", got, dhcpapi.DefaultRunDir)
}
}
func TestTwoWaitReady_ReturnsOnceTheServerAnswers(t *testing.T) {
b, s, _ := twoBackend(t)
if err := b.waitReady(s, time.Second, 10*time.Millisecond); err != nil {
t.Fatalf("waitReady: %v", err)
}
}
func TestTwoWaitReady_TimesOutWhenNothingListens(t *testing.T) {
b := Two{RunDir: shortTempDir(t)}
s := testSubnet(t)
start := time.Now()
err := b.waitReady(s, 200*time.Millisecond, 10*time.Millisecond)
if err == nil {
t.Fatal("waitReady must report a server that never came up")
}
if elapsed := time.Since(start); elapsed > 2*time.Second {
t.Errorf("waitReady took %s, want the 200ms budget to apply", elapsed)
}
}
func TestTwoPushSubnet_ReachesTheStore(t *testing.T) {
b, s, store := twoBackend(t)
if err := b.pushSubnet(s); err != nil {
t.Fatalf("pushSubnet: %v", err)
}
got, configured := store.Subnet()
if !configured {
t.Fatal("the subnet configuration did not reach the server")
}
if !got.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) {
t.Errorf("interface ip = %s, want 10.0.5.1", got.InterfaceIP)
}
if got.VPCRoute == nil || got.VPCRoute.String() != "10.0.0.0/16" {
t.Errorf("vpc route = %v, want 10.0.0.0/16", got.VPCRoute)
}
if !got.DefaultGateway.Equal(net.ParseIP("10.0.5.254")) {
t.Errorf("default gateway = %s, want 10.0.5.254", got.DefaultGateway)
}
}
func TestTwoPushSubnet_OmitsAnAbsentVPCRouteAndGateway(t *testing.T) {
b, s, store := twoBackend(t)
s.VPCRoute = nil
s.DefaultGateway = nil
if err := b.pushSubnet(s); err != nil {
t.Fatalf("pushSubnet: %v", err)
}
got, _ := store.Subnet()
if got.VPCRoute != nil {
t.Errorf("vpc route = %v, want none", got.VPCRoute)
}
if got.DefaultGateway != nil {
t.Errorf("default gateway = %v, want none: a bridge subnet has no gateway of ours", got.DefaultGateway)
}
}
func TestTwoSetVM_ReservesEveryInterface(t *testing.T) {
b, s, store := twoBackend(t)
if err := b.pushSubnet(s); err != nil {
t.Fatalf("pushSubnet: %v", err)
}
res := []Reservation{
{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
{Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
}
if err := b.SetVM(s, "vm-web", res); err != nil {
t.Fatalf("SetVM: %v", err)
}
hosts := store.Hosts()
if len(hosts) != 2 {
t.Fatalf("hosts = %d, want 2", len(hosts))
}
for _, h := range hosts {
if h.VM != "vm-web" {
t.Errorf("host %s carries vm %q, want vm-web", h.MAC, h.VM)
}
}
}
func TestTwoSetVM_CarriesTheDefaultRouteFlagPerInterface(t *testing.T) {
b, s, store := twoBackend(t)
if err := b.pushSubnet(s); err != nil {
t.Fatalf("pushSubnet: %v", err)
}
res := []Reservation{
{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
{Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
}
if err := b.SetVM(s, "vm-web", res); err != nil {
t.Fatalf("SetVM: %v", err)
}
primary, known := store.Lookup(mustMAC(t, "00:22:33:00:00:0a"))
if !known || !primary.DefaultRoute {
t.Errorf("primary interface = %+v, want the default route", primary)
}
secondary, known := store.Lookup(mustMAC(t, "00:22:33:00:00:0b"))
if !known || secondary.DefaultRoute {
t.Errorf("secondary interface = %+v, want no default route", secondary)
}
}
func TestTwoSetVM_IsIdempotent(t *testing.T) {
b, s, store := twoBackend(t)
if err := b.pushSubnet(s); err != nil {
t.Fatalf("pushSubnet: %v", err)
}
res := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}}
for range 3 {
if err := b.SetVM(s, "vm-web", res); err != nil {
t.Fatalf("SetVM: %v", err)
}
}
if got := len(store.Hosts()); got != 1 {
t.Errorf("hosts = %d, want 1", got)
}
}
func TestTwoSetVM_RejectsAnInvalidMAC(t *testing.T) {
b, s, _ := twoBackend(t)
if err := b.pushSubnet(s); err != nil {
t.Fatalf("pushSubnet: %v", err)
}
res := []Reservation{{Index: 0, MAC: "nope", IP: "10.0.5.10"}}
if err := b.SetVM(s, "vm-web", res); err == nil {
t.Fatal("an invalid mac must be reported")
}
}
func TestTwoDelVM_ReleasesEveryInterface(t *testing.T) {
b, s, store := twoBackend(t)
if err := b.pushSubnet(s); err != nil {
t.Fatalf("pushSubnet: %v", err)
}
res := []Reservation{
{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true},
{Index: 1, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11"},
}
if err := b.SetVM(s, "vm-web", res); err != nil {
t.Fatalf("SetVM: %v", err)
}
if err := b.DelVM(s, "vm-web", res); err != nil {
t.Fatalf("DelVM: %v", err)
}
if got := len(store.Hosts()); got != 0 {
t.Errorf("hosts = %d, want 0", got)
}
}
func TestTwoDelVM_LeavesOtherVMsAlone(t *testing.T) {
b, s, store := twoBackend(t)
if err := b.pushSubnet(s); err != nil {
t.Fatalf("pushSubnet: %v", err)
}
web := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0a", IP: "10.0.5.10", DefaultRoute: true}}
db := []Reservation{{Index: 0, MAC: "00:22:33:00:00:0b", IP: "10.0.5.11", DefaultRoute: true}}
for name, res := range map[string][]Reservation{"vm-web": web, "vm-db": db} {
if err := b.SetVM(s, name, res); err != nil {
t.Fatalf("SetVM %s: %v", name, err)
}
}
if err := b.DelVM(s, "vm-web", web); err != nil {
t.Fatalf("DelVM: %v", err)
}
hosts := store.Hosts()
if len(hosts) != 1 || hosts[0].VM != "vm-db" {
t.Errorf("remaining hosts = %+v, want only vm-db", hosts)
}
}
func TestTwoDelVM_OnAnUnknownMACIsNotAnError(t *testing.T) {
b, s, _ := twoBackend(t)
res := []Reservation{{Index: 0, MAC: "00:22:33:ff:ff:ff", IP: "10.0.5.99"}}
if err := b.DelVM(s, "vm-gone", res); err != nil {
t.Errorf("releasing an absent reservation must be idempotent, got %v", err)
}
}
func mustMAC(t *testing.T, s string) net.HardwareAddr {
t.Helper()
m, err := net.ParseMAC(s)
if err != nil {
t.Fatalf("ParseMAC(%q): %v", s, err)
}
return m
}

View file

@ -1,29 +0,0 @@
package dhcpd
import (
"net"
"time"
)
const LeaseTime = 12 * time.Hour
func DNSServers() []net.IP {
return []net.IP{
net.IPv4(1, 1, 1, 1),
net.IPv4(8, 8, 8, 8),
}
}
type SubnetConfig struct {
Network *net.IPNet
InterfaceIP net.IP
VPCRoute *net.IPNet
DefaultGateway net.IP
}
type Host struct {
MAC net.HardwareAddr
IP net.IP
VM string
DefaultRoute bool
}

View file

@ -1,60 +0,0 @@
package dhcpd
import (
"net"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func answerable(req *dhcpv4.DHCPv4) bool {
switch req.MessageType() {
case dhcpv4.MessageTypeDiscover, dhcpv4.MessageTypeRequest:
return true
default:
return false
}
}
func (s *Store) Handle(req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) {
if req == nil {
return nil, ErrNoRequest
}
if !answerable(req) {
return nil, nil
}
subnet, configured := s.Subnet()
if !configured {
return nil, nil
}
host, known := s.Lookup(req.ClientHWAddr)
if !known {
return nil, nil
}
return BuildReply(subnet, host, req)
}
func (s *Store) Probe(mac net.HardwareAddr) (*dhcpv4.DHCPv4, error) {
if len(mac) == 0 {
return nil, ErrNoMAC
}
subnet, configured := s.Subnet()
if !configured {
return nil, ErrNotConfigured
}
host, known := s.Lookup(mac)
if !known {
return nil, nil
}
req, err := dhcpv4.New(dhcpv4.WithMessageType(dhcpv4.MessageTypeRequest), dhcpv4.WithHwAddr(mac))
if err != nil {
return nil, err
}
return BuildReply(subnet, host, req)
}

View file

@ -1,195 +0,0 @@
package dhcpd
import (
"errors"
"net"
"testing"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func configuredStore(t *testing.T) *Store {
t.Helper()
s, _ := loadedStore(t)
if err := s.SetSubnet(fullConfig(t)); err != nil {
t.Fatalf("SetSubnet: %v", err)
}
if err := s.SetHost(testHost(t)); err != nil {
t.Fatalf("SetHost: %v", err)
}
return s
}
func TestHandle_KnownMACGetsAnOfferOnDiscover(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if reply == nil {
t.Fatal("a known mac must be answered")
}
if reply.MessageType() != dhcpv4.MessageTypeOffer {
t.Errorf("message type = %s, want OFFER", reply.MessageType())
}
if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("yiaddr = %s, want the reserved 10.0.5.10", reply.YourIPAddr)
}
}
func TestHandle_UnknownMACIsAnsweredWithSilence(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff")))
if err != nil {
t.Fatalf("an unknown mac is not an error: %v", err)
}
if reply != nil {
t.Error("an unknown mac must get no reply, not a NAK")
}
}
func TestHandle_UnconfiguredSubnetIsAnsweredWithSilence(t *testing.T) {
s, _ := loadedStore(t)
if err := s.SetHost(testHost(t)); err != nil {
t.Fatalf("SetHost: %v", err)
}
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if reply != nil {
t.Error("without a subnet configuration the server must stay silent")
}
}
func TestHandle_ReleaseIsANoOp(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("a RELEASE is not an error: %v", err)
}
if reply != nil {
t.Error("a RELEASE must get no reply: reservations are static")
}
}
func TestHandle_DeclineIsANoOp(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDecline, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("a DECLINE is not an error: %v", err)
}
if reply != nil {
t.Error("a DECLINE must get no reply: there is nothing to release")
}
}
func TestHandle_RequestIsAnsweredWithAnAck(t *testing.T) {
s := configuredStore(t)
reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if reply == nil || reply.MessageType() != dhcpv4.MessageTypeAck {
t.Fatalf("reply = %v, want an ACK", reply)
}
}
func TestHandle_NilRequestIsRejected(t *testing.T) {
s := configuredStore(t)
if _, err := s.Handle(nil); !errors.Is(err, ErrNoRequest) {
t.Fatalf("error = %v, want ErrNoRequest", err)
}
}
func TestHandle_DeletedHostStopsBeingAnswered(t *testing.T) {
s := configuredStore(t)
if err := s.DelHost(mac(t, "00:22:33:00:00:0a")); err != nil {
t.Fatalf("DelHost: %v", err)
}
reply, err := s.Handle(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if reply != nil {
t.Error("a deleted host must no longer be served")
}
}
func TestProbe_ReturnsWhatWouldBeSentToTheMAC(t *testing.T) {
s := configuredStore(t)
reply, err := s.Probe(mac(t, "00:22:33:00:00:0A"))
if err != nil {
t.Fatalf("Probe: %v", err)
}
if reply == nil {
t.Fatal("a known mac must be described")
}
if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr)
}
if got := reply.ClasslessStaticRoute(); len(got) != 3 {
t.Errorf("routes = %s, want metadata, vpc and default", got)
}
}
func TestProbe_UnknownMACReturnsNothing(t *testing.T) {
s := configuredStore(t)
reply, err := s.Probe(mac(t, "00:22:33:ff:ff:ff"))
if err != nil {
t.Fatalf("Probe: %v", err)
}
if reply != nil {
t.Error("an unknown mac must describe no reply")
}
}
func TestProbe_WithoutSubnetConfigurationIsRejected(t *testing.T) {
s, _ := loadedStore(t)
if _, err := s.Probe(mac(t, "00:22:33:00:00:0a")); !errors.Is(err, ErrNotConfigured) {
t.Fatalf("error = %v, want ErrNotConfigured", err)
}
}
func TestProbe_EmptyMACIsRejected(t *testing.T) {
s := configuredStore(t)
if _, err := s.Probe(nil); !errors.Is(err, ErrNoMAC) {
t.Fatalf("error = %v, want ErrNoMAC", err)
}
}
func TestHandle_SecondaryInterfaceGetsNoDefaultRoute(t *testing.T) {
s := configuredStore(t)
h := testHost(t)
h.MAC = mac(t, "00:22:33:00:00:0b")
h.IP = net.ParseIP("10.0.5.11")
h.DefaultRoute = false
if err := s.SetHost(h); err != nil {
t.Fatalf("SetHost: %v", err)
}
reply, err := s.Handle(request(t, dhcpv4.MessageTypeRequest, mac(t, "00:22:33:00:00:0b")))
if err != nil {
t.Fatalf("Handle: %v", err)
}
if got := reply.Router(); len(got) != 0 {
t.Errorf("router option = %v, want none on a secondary interface", got)
}
for _, r := range reply.ClasslessStaticRoute() {
if ones, _ := r.Dest.Mask.Size(); ones == 0 {
t.Errorf("unexpected default route for a secondary interface: %s", reply.ClasslessStaticRoute())
}
}
}

View file

@ -1,59 +0,0 @@
package dhcpd
import (
"net"
"testing"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func FuzzBuildReply(f *testing.F) {
mac, err := net.ParseMAC("00:22:33:00:00:0a")
if err != nil {
f.Fatalf("ParseMAC: %v", err)
}
for _, kind := range []dhcpv4.MessageType{
dhcpv4.MessageTypeDiscover,
dhcpv4.MessageTypeRequest,
dhcpv4.MessageTypeRelease,
dhcpv4.MessageTypeDecline,
} {
req, err := dhcpv4.New(dhcpv4.WithMessageType(kind), dhcpv4.WithHwAddr(mac))
if err != nil {
f.Fatalf("New request: %v", err)
}
f.Add(req.ToBytes())
}
_, network, err := net.ParseCIDR("10.0.5.0/24")
if err != nil {
f.Fatalf("ParseCIDR: %v", err)
}
_, vpcRoute, err := net.ParseCIDR("10.0.0.0/16")
if err != nil {
f.Fatalf("ParseCIDR: %v", err)
}
c := SubnetConfig{
Network: network,
InterfaceIP: net.ParseIP("10.0.5.1"),
VPCRoute: vpcRoute,
DefaultGateway: net.ParseIP("10.0.5.254"),
}
h := Host{MAC: mac, IP: net.ParseIP("10.0.5.10"), VM: "vm-fuzz", DefaultRoute: true}
f.Fuzz(func(t *testing.T, raw []byte) {
req, err := dhcpv4.FromBytes(raw)
if err != nil {
return
}
reply, err := BuildReply(c, h, req)
if err != nil {
return
}
if reply == nil {
t.Fatal("nil reply without an error")
}
reply.ToBytes()
})
}

View file

@ -1,68 +0,0 @@
package dhcpd
import (
"log/slog"
"net"
"github.com/insomniacslk/dhcp/dhcpv4"
)
const MaxDatagramBytes = 1500
var clientBroadcast = net.IPv4bcast
func replyTo(peer net.Addr) net.Addr {
udp, ok := peer.(*net.UDPAddr)
if !ok {
return peer
}
if udp.IP == nil || udp.IP.IsUnspecified() {
return &net.UDPAddr{IP: clientBroadcast, Port: udp.Port}
}
return udp
}
func (s *Store) serveDatagram(conn net.PacketConn, raw []byte, peer net.Addr, logger *slog.Logger) {
defer func() {
if r := recover(); r != nil {
logger.Error("dhcp datagram handling panicked", "peer", peer, "panic", r)
}
}()
req, err := dhcpv4.FromBytes(raw)
if err != nil {
logger.Debug("malformed dhcp datagram", "peer", peer, "error", err)
return
}
reply, err := s.Handle(req)
if err != nil {
logger.Error("building dhcp reply failed",
"peer", peer, "mac", req.ClientHWAddr, "type", req.MessageType(), "error", err)
return
}
if reply == nil {
logger.Debug("no reply for datagram", "mac", req.ClientHWAddr, "type", req.MessageType())
return
}
target := replyTo(peer)
if _, err := conn.WriteTo(reply.ToBytes(), target); err != nil {
logger.Error("sending dhcp reply failed", "target", target, "mac", req.ClientHWAddr, "error", err)
return
}
logger.Info("dhcp reply sent",
"mac", req.ClientHWAddr, "type", reply.MessageType(), "ip", reply.YourIPAddr, "target", target)
}
func (s *Store) Serve(conn net.PacketConn, logger *slog.Logger) error {
buf := make([]byte, MaxDatagramBytes)
for {
n, peer, err := conn.ReadFrom(buf)
if err != nil {
return err
}
s.serveDatagram(conn, buf[:n], peer, logger)
}
}

View file

@ -1,206 +0,0 @@
package dhcpd
import (
"io"
"log/slog"
"net"
"sync"
"testing"
"time"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func discard() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func loopbackPair(t *testing.T) (*net.UDPConn, *net.UDPConn) {
t.Helper()
server, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("ListenUDP server: %v", err)
}
t.Cleanup(func() { server.Close() })
client, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1)})
if err != nil {
t.Fatalf("ListenUDP client: %v", err)
}
t.Cleanup(func() { client.Close() })
return server, client
}
func exchange(t *testing.T, s *Store, raw []byte) *dhcpv4.DHCPv4 {
t.Helper()
server, client := loopbackPair(t)
go s.Serve(server, discard())
if _, err := client.WriteToUDP(raw, server.LocalAddr().(*net.UDPAddr)); err != nil {
t.Fatalf("WriteToUDP: %v", err)
}
if err := client.SetReadDeadline(time.Now().Add(500 * time.Millisecond)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
buf := make([]byte, MaxDatagramBytes)
n, _, err := client.ReadFromUDP(buf)
if err != nil {
return nil
}
reply, err := dhcpv4.FromBytes(buf[:n])
if err != nil {
t.Fatalf("the reply must be a valid dhcp packet: %v", err)
}
return reply
}
func TestServe_AnswersAKnownMAC(t *testing.T) {
s := configuredStore(t)
reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes())
if reply == nil {
t.Fatal("a known mac must be answered on the wire")
}
if reply.MessageType() != dhcpv4.MessageTypeOffer {
t.Errorf("message type = %s, want OFFER", reply.MessageType())
}
if !reply.YourIPAddr.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("yiaddr = %s, want 10.0.5.10", reply.YourIPAddr)
}
}
func TestServe_StaysSilentForAnUnknownMAC(t *testing.T) {
s := configuredStore(t)
if reply := exchange(t, s, request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:ff:ff:ff")).ToBytes()); reply != nil {
t.Errorf("an unknown mac must get nothing on the wire, got %s", reply.MessageType())
}
}
func TestServe_StaysSilentOnARelease(t *testing.T) {
s := configuredStore(t)
if reply := exchange(t, s, request(t, dhcpv4.MessageTypeRelease, mac(t, "00:22:33:00:00:0a")).ToBytes()); reply != nil {
t.Errorf("a RELEASE must get nothing on the wire, got %s", reply.MessageType())
}
}
func TestServe_SurvivesAMalformedDatagram(t *testing.T) {
s := configuredStore(t)
server, client := loopbackPair(t)
go s.Serve(server, discard())
target := server.LocalAddr().(*net.UDPAddr)
for _, garbage := range [][]byte{{}, {0x01}, make([]byte, 1200)} {
if _, err := client.WriteToUDP(garbage, target); err != nil {
t.Fatalf("WriteToUDP: %v", err)
}
}
if _, err := client.WriteToUDP(request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes(), target); err != nil {
t.Fatalf("WriteToUDP: %v", err)
}
if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
buf := make([]byte, MaxDatagramBytes)
if _, _, err := client.ReadFromUDP(buf); err != nil {
t.Fatalf("the loop must survive garbage and keep serving: %v", err)
}
}
func TestServe_ReturnsWhenTheConnectionCloses(t *testing.T) {
s := configuredStore(t)
server, _ := loopbackPair(t)
done := make(chan error, 1)
go func() { done <- s.Serve(server, discard()) }()
server.Close()
select {
case err := <-done:
if err == nil {
t.Error("Serve must report why it stopped")
}
case <-time.After(time.Second):
t.Fatal("Serve did not return after the connection closed")
}
}
type explodingConn struct {
net.PacketConn
mu sync.Mutex
writes int
}
func (c *explodingConn) WriteTo(b []byte, addr net.Addr) (int, error) {
c.mu.Lock()
first := c.writes == 0
c.writes++
c.mu.Unlock()
if first {
panic("write exploded")
}
return c.PacketConn.WriteTo(b, addr)
}
func TestServe_SurvivesAPanicWhileHandlingADatagram(t *testing.T) {
s := configuredStore(t)
server, client := loopbackPair(t)
go s.Serve(&explodingConn{PacketConn: server}, discard())
target := server.LocalAddr().(*net.UDPAddr)
raw := request(t, dhcpv4.MessageTypeDiscover, mac(t, "00:22:33:00:00:0a")).ToBytes()
for range 2 {
if _, err := client.WriteToUDP(raw, target); err != nil {
t.Fatalf("WriteToUDP: %v", err)
}
time.Sleep(50 * time.Millisecond)
}
if err := client.SetReadDeadline(time.Now().Add(time.Second)); err != nil {
t.Fatalf("SetReadDeadline: %v", err)
}
buf := make([]byte, MaxDatagramBytes)
if _, _, err := client.ReadFromUDP(buf); err != nil {
t.Fatalf("a panic on one datagram must not kill the serving loop: %v", err)
}
}
func TestReplyTo_BroadcastsWhenTheClientHasNoAddress(t *testing.T) {
got := replyTo(&net.UDPAddr{IP: net.IPv4zero, Port: 68})
udp, ok := got.(*net.UDPAddr)
if !ok {
t.Fatalf("target = %T, want *net.UDPAddr", got)
}
if !udp.IP.Equal(net.IPv4bcast) {
t.Errorf("target = %s, want 255.255.255.255: the client cannot be reached by unicast yet", udp.IP)
}
if udp.Port != 68 {
t.Errorf("port = %d, want the client port to be kept", udp.Port)
}
}
func TestReplyTo_KeepsTheUnicastPeerWhenItHasAnAddress(t *testing.T) {
got := replyTo(&net.UDPAddr{IP: net.ParseIP("10.0.5.10"), Port: 68})
udp := got.(*net.UDPAddr)
if !udp.IP.Equal(net.ParseIP("10.0.5.10")) {
t.Errorf("target = %s, want the renewing client itself", udp.IP)
}
}
func TestReplyTo_BroadcastsWhenThePeerIPIsNil(t *testing.T) {
udp := replyTo(&net.UDPAddr{Port: 68}).(*net.UDPAddr)
if !udp.IP.Equal(net.IPv4bcast) {
t.Errorf("target = %s, want 255.255.255.255", udp.IP)
}
}

View file

@ -1,97 +0,0 @@
package dhcpd
import (
"errors"
"fmt"
"net"
"git.g3e.fr/syonad/two/internal/metadata"
"github.com/insomniacslk/dhcp/dhcpv4"
)
var (
ErrNoInterfaceIP = errors.New("interface ip is required: guests would have no route to the metadata server")
ErrNoNetwork = errors.New("subnet network is required")
ErrNoHostIP = errors.New("host ip is required")
ErrNoRequest = errors.New("request is nil")
)
func metadataRoute() *net.IPNet {
return &net.IPNet{
IP: net.ParseIP(metadata.ServiceIP).To4(),
Mask: net.CIDRMask(32, 32),
}
}
func defaultRoute() *net.IPNet {
return &net.IPNet{
IP: net.IPv4zero.To4(),
Mask: net.CIDRMask(0, 32),
}
}
func Routes(c SubnetConfig, h Host) (dhcpv4.Routes, error) {
if c.InterfaceIP == nil {
return nil, ErrNoInterfaceIP
}
routes := dhcpv4.Routes{{Dest: metadataRoute(), Router: c.InterfaceIP}}
if c.VPCRoute != nil {
routes = append(routes, &dhcpv4.Route{Dest: c.VPCRoute, Router: c.InterfaceIP})
}
if h.DefaultRoute && c.DefaultGateway != nil {
routes = append(routes, &dhcpv4.Route{Dest: defaultRoute(), Router: c.DefaultGateway})
}
return routes, nil
}
func replyType(req *dhcpv4.DHCPv4) (dhcpv4.MessageType, error) {
switch req.MessageType() {
case dhcpv4.MessageTypeDiscover:
return dhcpv4.MessageTypeOffer, nil
case dhcpv4.MessageTypeRequest:
return dhcpv4.MessageTypeAck, nil
default:
return 0, fmt.Errorf("no reply built for message type %s", req.MessageType())
}
}
func BuildReply(c SubnetConfig, h Host, req *dhcpv4.DHCPv4) (*dhcpv4.DHCPv4, error) {
if req == nil {
return nil, ErrNoRequest
}
if c.Network == nil {
return nil, ErrNoNetwork
}
if h.IP == nil {
return nil, ErrNoHostIP
}
kind, err := replyType(req)
if err != nil {
return nil, err
}
routes, err := Routes(c, h)
if err != nil {
return nil, err
}
mods := []dhcpv4.Modifier{
dhcpv4.WithMessageType(kind),
dhcpv4.WithServerIP(c.InterfaceIP),
dhcpv4.WithYourIP(h.IP),
dhcpv4.WithNetmask(c.Network.Mask),
dhcpv4.WithLeaseTime(uint32(LeaseTime.Seconds())),
dhcpv4.WithOption(dhcpv4.OptServerIdentifier(c.InterfaceIP)),
dhcpv4.WithOption(dhcpv4.OptDNS(DNSServers()...)),
dhcpv4.WithOption(dhcpv4.OptClasslessStaticRoute(routes...)),
}
if h.DefaultRoute && c.DefaultGateway != nil {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptRouter(c.DefaultGateway)))
}
return dhcpv4.NewReplyFromRequest(req, mods...)
}

View file

@ -1,339 +0,0 @@
package dhcpd
import (
"bytes"
"errors"
"net"
"testing"
"git.g3e.fr/syonad/two/internal/metadata"
"github.com/insomniacslk/dhcp/dhcpv4"
)
func cidr(t *testing.T, s string) *net.IPNet {
t.Helper()
_, n, err := net.ParseCIDR(s)
if err != nil {
t.Fatalf("ParseCIDR(%q): %v", s, err)
}
return n
}
func testConfig(t *testing.T) SubnetConfig {
t.Helper()
return SubnetConfig{
Network: cidr(t, "10.0.5.0/24"),
InterfaceIP: net.ParseIP("10.0.5.1"),
}
}
func fullConfig(t *testing.T) SubnetConfig {
t.Helper()
c := testConfig(t)
c.VPCRoute = cidr(t, "10.0.0.0/16")
c.DefaultGateway = net.ParseIP("10.0.5.254")
return c
}
func mac(t *testing.T, s string) net.HardwareAddr {
t.Helper()
m, err := net.ParseMAC(s)
if err != nil {
t.Fatalf("ParseMAC(%q): %v", s, err)
}
return m
}
func testHost(t *testing.T) Host {
t.Helper()
return Host{MAC: mac(t, "00:22:33:00:00:0a"), IP: net.ParseIP("10.0.5.10"), VM: "vm-test", DefaultRoute: true}
}
func request(t *testing.T, kind dhcpv4.MessageType, mac net.HardwareAddr) *dhcpv4.DHCPv4 {
t.Helper()
req, err := dhcpv4.New(dhcpv4.WithMessageType(kind), dhcpv4.WithHwAddr(mac))
if err != nil {
t.Fatalf("New request: %v", err)
}
return req
}
func encodedRoute(t *testing.T, routes dhcpv4.Routes, dest string) []byte {
t.Helper()
for _, r := range routes {
if r.Dest.String() == dest {
return dhcpv4.Routes{r}.ToBytes()
}
}
t.Fatalf("no route to %s in %s", dest, routes)
return nil
}
func TestRoutes_AlwaysCarriesTheMetadataRoute(t *testing.T) {
c := testConfig(t)
routes, err := Routes(c, Host{IP: net.ParseIP("10.0.5.10")})
if err != nil {
t.Fatalf("Routes: %v", err)
}
if len(routes) != 1 {
t.Fatalf("expected the metadata route alone, got %s", routes)
}
if got := routes[0].Dest.String(); got != metadata.ServiceIP+"/32" {
t.Errorf("destination = %s, want %s/32", got, metadata.ServiceIP)
}
if !routes[0].Router.Equal(c.InterfaceIP) {
t.Errorf("next-hop = %s, want the subnet interface ip %s", routes[0].Router, c.InterfaceIP)
}
}
func TestRoutes_WithoutInterfaceIPIsRejected(t *testing.T) {
_, err := Routes(SubnetConfig{Network: cidr(t, "10.0.5.0/24")}, testHost(t))
if !errors.Is(err, ErrNoInterfaceIP) {
t.Fatalf("error = %v, want ErrNoInterfaceIP", err)
}
}
func TestRoutes_VPCRouteUsesTheInterfaceIPAsNextHop(t *testing.T) {
c := testConfig(t)
c.VPCRoute = cidr(t, "10.0.0.0/16")
c.DefaultGateway = net.ParseIP("192.0.2.1")
routes, err := Routes(c, testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
for _, r := range routes {
if r.Dest.String() != "10.0.0.0/16" {
continue
}
if !r.Router.Equal(c.InterfaceIP) {
t.Fatalf("vpc route next-hop = %s, want %s", r.Router, c.InterfaceIP)
}
return
}
t.Fatalf("no vpc route in %s", routes)
}
func TestRoutes_NoDefaultRouteWithoutDefaultGateway(t *testing.T) {
routes, err := Routes(testConfig(t), testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
for _, r := range routes {
if ones, _ := r.Dest.Mask.Size(); ones == 0 {
t.Fatalf("unexpected default route in %s", routes)
}
}
}
func TestRoutes_NoDefaultRouteWhenTheInterfaceDoesNotReceiveIt(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
h := testHost(t)
h.DefaultRoute = false
routes, err := Routes(c, h)
if err != nil {
t.Fatalf("Routes: %v", err)
}
for _, r := range routes {
if ones, _ := r.Dest.Mask.Size(); ones == 0 {
t.Fatalf("a secondary interface must not receive the default route, got %s", routes)
}
}
}
func TestRoutes_DefaultRouteEncodesZeroDestinationOctets(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
routes, err := Routes(c, testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
want := []byte{0x00, 10, 0, 5, 254}
if got := encodedRoute(t, routes, "0.0.0.0/0"); !bytes.Equal(got, want) {
t.Errorf("default route encoding = % x, want % x", got, want)
}
}
func TestRoutes_UnalignedPrefixEncodesOnlyItsSignificantOctets(t *testing.T) {
c := testConfig(t)
c.VPCRoute = cidr(t, "10.16.0.0/12")
routes, err := Routes(c, testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
want := []byte{0x0c, 10, 16, 10, 0, 5, 1}
if got := encodedRoute(t, routes, "10.16.0.0/12"); !bytes.Equal(got, want) {
t.Errorf("/12 encoding = % x, want % x", got, want)
}
}
func TestRoutes_MetadataRouteEncodesOnFourDestinationOctets(t *testing.T) {
routes, err := Routes(testConfig(t), testHost(t))
if err != nil {
t.Fatalf("Routes: %v", err)
}
want := []byte{0x20, 169, 254, 169, 254, 10, 0, 5, 1}
if got := encodedRoute(t, routes, metadata.ServiceIP+"/32"); !bytes.Equal(got, want) {
t.Errorf("metadata route encoding = % x, want % x", got, want)
}
}
func TestBuildReply_DiscoverIsAnsweredWithAnOffer(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDiscover, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if reply.MessageType() != dhcpv4.MessageTypeOffer {
t.Errorf("message type = %s, want OFFER", reply.MessageType())
}
}
func TestBuildReply_RequestIsAnsweredWithAnAck(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if reply.MessageType() != dhcpv4.MessageTypeAck {
t.Errorf("message type = %s, want ACK", reply.MessageType())
}
}
func TestBuildReply_ReleaseGetsNoReply(t *testing.T) {
h := testHost(t)
if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRelease, h.MAC)); err == nil {
t.Fatal("a RELEASE must not produce a reply")
}
}
func TestBuildReply_DeclineGetsNoReply(t *testing.T) {
h := testHost(t)
if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeDecline, h.MAC)); err == nil {
t.Fatal("a DECLINE must not produce a reply")
}
}
func TestBuildReply_CarriesAddressMaskLeaseAndServerIdentifier(t *testing.T) {
c := testConfig(t)
h := testHost(t)
reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if !reply.YourIPAddr.Equal(h.IP) {
t.Errorf("yiaddr = %s, want %s", reply.YourIPAddr, h.IP)
}
if got := net.IP(reply.SubnetMask()).String(); got != net.IP(c.Network.Mask).String() {
t.Errorf("netmask = %s, want %s", got, net.IP(c.Network.Mask))
}
if got := reply.IPAddressLeaseTime(0); got != LeaseTime {
t.Errorf("lease time = %s, want %s", got, LeaseTime)
}
if got := reply.ServerIdentifier(); !got.Equal(c.InterfaceIP) {
t.Errorf("server identifier = %s, want %s", got, c.InterfaceIP)
}
if got := reply.DNS(); len(got) != 2 || !got[0].Equal(net.IPv4(1, 1, 1, 1)) || !got[1].Equal(net.IPv4(8, 8, 8, 8)) {
t.Errorf("dns = %v, want 1.1.1.1 and 8.8.8.8", got)
}
}
func TestBuildReply_NoRouterOptionWithoutDefaultRoute(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if got := reply.Router(); len(got) != 0 {
t.Errorf("router option = %v, want none: the guest would use the server as its gateway", got)
}
}
func TestBuildReply_RouterOptionCarriesTheDefaultGateway(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
h := testHost(t)
reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
got := reply.Router()
if len(got) != 1 || !got[0].Equal(c.DefaultGateway) {
t.Errorf("router option = %v, want [%s]", got, c.DefaultGateway)
}
}
func TestBuildReply_SecondaryInterfaceGetsNoRouterOption(t *testing.T) {
c := testConfig(t)
c.DefaultGateway = net.ParseIP("10.0.5.254")
h := testHost(t)
h.DefaultRoute = false
reply, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if got := reply.Router(); len(got) != 0 {
t.Errorf("router option = %v, want none on a secondary interface", got)
}
}
func TestBuildReply_ClasslessStaticRouteIsPresent(t *testing.T) {
h := testHost(t)
reply, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, h.MAC))
if err != nil {
t.Fatalf("BuildReply: %v", err)
}
if got := reply.ClasslessStaticRoute(); len(got) == 0 {
t.Fatal("option 121 missing: cloud-init would have no route to the metadata server")
}
}
func TestBuildReply_WithoutInterfaceIPIsRejected(t *testing.T) {
c := testConfig(t)
c.InterfaceIP = nil
h := testHost(t)
if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoInterfaceIP) {
t.Fatalf("error = %v, want ErrNoInterfaceIP", err)
}
}
func TestBuildReply_WithoutNetworkIsRejected(t *testing.T) {
c := testConfig(t)
c.Network = nil
h := testHost(t)
if _, err := BuildReply(c, h, request(t, dhcpv4.MessageTypeRequest, h.MAC)); !errors.Is(err, ErrNoNetwork) {
t.Fatalf("error = %v, want ErrNoNetwork", err)
}
}
func TestBuildReply_WithoutHostIPIsRejected(t *testing.T) {
h := testHost(t)
h.IP = nil
if _, err := BuildReply(testConfig(t), h, request(t, dhcpv4.MessageTypeRequest, testHost(t).MAC)); !errors.Is(err, ErrNoHostIP) {
t.Fatalf("error = %v, want ErrNoHostIP", err)
}
}
func TestBuildReply_NilRequestIsRejected(t *testing.T) {
if _, err := BuildReply(testConfig(t), testHost(t), nil); !errors.Is(err, ErrNoRequest) {
t.Fatalf("error = %v, want ErrNoRequest", err)
}
}

Some files were not shown because too many files have changed in this diff Show more