Compare commits
15 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
6abf93005b |
|||
|
1d0e046ff4 |
|||
|
c805169747 |
|||
|
d4282353e7 |
|||
|
38939a0579 |
|||
|
afa189d438 |
|||
|
19c656e69f |
|||
|
bec5e65728 |
|||
|
2e06cd85bc |
|||
|
6e976ac6e3 |
|||
|
158dd0451a |
|||
|
aa171000dd |
|||
|
1d79f06f60 |
|||
|
da740aa322 |
|||
|
14ef0ecc83 |
261 changed files with 3486 additions and 23337 deletions
|
|
@ -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})"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
69
.forgejo/workflows/prerelease.yml
Normal file
69
.forgejo/workflows/prerelease.yml
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
7
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
87
README.md
87
README.md
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
174
cmd/lab/main.go
174
cmd/lab/main.go
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package main
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
const ioctlReadTermios = unix.TIOCGETA
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
package main
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
const ioctlReadTermios = unix.TCGETS
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
//go:build !linux && !darwin
|
||||
|
||||
package main
|
||||
|
||||
import "os"
|
||||
|
||||
func isTerminal(*os.File) bool { return false }
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 ».
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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/<vm>/"| 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.
|
||||
|
|
@ -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-<subnet><br/>interface_ip"]
|
||||
BR --- VX["vxlan<vni>"] --- 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.
|
||||
|
|
@ -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.
|
||||
92
docs/conf.py
92
docs/conf.py
|
|
@ -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',
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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["<os>-tmp.qcow2<br/><i>overlay, jetable</i>"] --> BVM["VM de construction"]
|
||||
BVM --> ROOT["<os>-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 ?
|
||||
|
|
@ -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
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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`.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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-<vni></text>
|
||||
<text class="sub" x="364" y="402" text-anchor="middle">dans br-<subnet>, 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-<vni></text>
|
||||
<text class="sub" x="636" y="402" text-anchor="middle">dans br-<subnet>, 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 |
|
|
@ -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-<vni></text>
|
||||
<text class="sub" x="364" y="402" text-anchor="middle">dans br-<subnet>, 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-<vni></text>
|
||||
<text class="sub" x="636" y="402" text-anchor="middle">dans br-<subnet>, 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 |
|
|
@ -1,2 +0,0 @@
|
|||
```{include} ../../release_notes/0.1.0.md
|
||||
```
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
```{include} ../../release_notes/0.2.0.md
|
||||
```
|
||||
|
|
@ -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
33
go.mod
|
|
@ -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
54
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]++
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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...)
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue