diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml
new file mode 100644
index 0000000..e8b7b07
--- /dev/null
+++ b/.forgejo/workflows/docs.yml
@@ -0,0 +1,168 @@
+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" <
+
+
+
+ two — documentation
+
+
+ Documentation de two
+
+ 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"
diff --git a/.gitignore b/.gitignore
index 68ae417..fd7d60e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,6 @@ go.work.sum
# ignore local info
data/
+
+# Sphinx build output
+docs/_build/
diff --git a/README.md b/README.md
index 9439539..20627f4 100644
--- a/README.md
+++ b/README.md
@@ -30,7 +30,7 @@ Options utiles :
| `-d` | dry-run : affiche les commandes sans les exécuter |
| `-V` | désactiver la vérification des sommes de contrôle |
-Un déploiement relève les instances `dnsmasq@` et `metadata@` actives **avant** l'arrêt des
+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
diff --git a/cmd/lab/lifecycle.go b/cmd/lab/lifecycle.go
new file mode 100644
index 0000000..c91205e
--- /dev/null
+++ b/cmd/lab/lifecycle.go
@@ -0,0 +1,193 @@
+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
+}
diff --git a/cmd/lab/main.go b/cmd/lab/main.go
new file mode 100644
index 0000000..3ff3cf2
--- /dev/null
+++ b/cmd/lab/main.go
@@ -0,0 +1,174 @@
+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 [options] [dir]
+
+ plan
+ validate the topology and print the deterministic plan: addresses, cables, ports
+
+ render -key
+ write, for each 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]
+ 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 /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] [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
+}
diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go
new file mode 100644
index 0000000..733fd3c
--- /dev/null
+++ b/cmd/lab/main_test.go
@@ -0,0 +1,354 @@
+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)
+ }
+}
diff --git a/cmd/lab/terminal.go b/cmd/lab/terminal.go
new file mode 100644
index 0000000..fa2cb06
--- /dev/null
+++ b/cmd/lab/terminal.go
@@ -0,0 +1,14 @@
+//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
+}
diff --git a/cmd/lab/terminal_darwin.go b/cmd/lab/terminal_darwin.go
new file mode 100644
index 0000000..4c493c7
--- /dev/null
+++ b/cmd/lab/terminal_darwin.go
@@ -0,0 +1,5 @@
+package main
+
+import "golang.org/x/sys/unix"
+
+const ioctlReadTermios = unix.TIOCGETA
diff --git a/cmd/lab/terminal_linux.go b/cmd/lab/terminal_linux.go
new file mode 100644
index 0000000..ba4230d
--- /dev/null
+++ b/cmd/lab/terminal_linux.go
@@ -0,0 +1,5 @@
+package main
+
+import "golang.org/x/sys/unix"
+
+const ioctlReadTermios = unix.TCGETS
diff --git a/cmd/lab/terminal_other.go b/cmd/lab/terminal_other.go
new file mode 100644
index 0000000..a929525
--- /dev/null
+++ b/cmd/lab/terminal_other.go
@@ -0,0 +1,7 @@
+//go:build !linux && !darwin
+
+package main
+
+import "os"
+
+func isTerminal(*os.File) bool { return false }
diff --git a/docs/architecture/vue-densemble.rst b/docs/architecture/vue-densemble.rst
index b8fb8e8..a0f5c3f 100644
--- a/docs/architecture/vue-densemble.rst
+++ b/docs/architecture/vue-densemble.rst
@@ -50,7 +50,7 @@ Paquets
* - ``internal/vm``
- cycle de vie d'une VM : tap, iptables, metadata, qemu
* - ``internal/dhcp``
- - génération des configurations dnsmasq et entrées ip → mac
+ - plan d'adressage ip → mac, et configurations dnsmasq du backend historique
* - ``internal/metadata``
- serveur de metadata cloud-init et ses templates
* - ``internal/watchdog``
diff --git a/docs/concepts/vpc-subnet-vm.rst b/docs/concepts/vpc-subnet-vm.rst
index ac1e673..6337ea9 100644
--- a/docs/concepts/vpc-subnet-vm.rst
+++ b/docs/concepts/vpc-subnet-vm.rst
@@ -24,7 +24,8 @@ 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) et les routes annoncées aux guests.
+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
diff --git a/docs/conf.py b/docs/conf.py
index 19d4d9e..e6cdf00 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -3,6 +3,8 @@
# 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
@@ -43,6 +45,48 @@ 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',
+ ]
+ }
diff --git a/docs/demarrage/installation.rst b/docs/demarrage/installation.rst
index 56c4cda..ed4217a 100644
--- a/docs/demarrage/installation.rst
+++ b/docs/demarrage/installation.rst
@@ -73,6 +73,10 @@ Ce que fait ``-i``
**masqué** : il prendrait le port 53 en concurrence des instances ``dnsmasq@`` que l'agent lance
dans les netns.
+``dnsmasq`` reste installé même avec ``dhcp.backend: two`` : le backend intégré ne le remplace que
+pour les subnets créés après la bascule, et le paquet est nécessaire tant qu'un hyperviseur peut
+revenir en arrière. Voir :doc:`/exploitation/configuration`.
+
**Noyau** — chargement de ``br_netfilter``, puis ``net.ipv4.ip_forward = 1`` et
``net.bridge.bridge-nf-call-iptables = 1``. Cette dernière clé est **requise** par la DNAT vers
le serveur de metadata : sans elle, iptables ne voit pas le trafic bridgé des VM et cloud-init
@@ -118,16 +122,21 @@ Binaires installés
* - ``db``
- inspection de la base clé-valeur en ligne de commande
- ``-conf``
+ * - ``dhcp``
+ - serveur DHCP intégré, une instance par subnet dans le netns du VPC ; démarré uniquement
+ avec ``dhcp.backend: two``
+ - ``-conf``
-Les trois partagent le même fichier, ``/etc/two/agent.yml`` — voir
-:doc:`/exploitation/configuration`.
+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@`` et ``metadata@`` actives **avant** d'arrêter les
-services, et les redémarre ensuite : c'est la seule façon de savoir lesquelles relancer. Arrêter
-les services à la main avant de lancer le script fait perdre cette liste.
+``deploy.sh`` relève les instances ``dnsmasq@``, ``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
-----------------------
diff --git a/docs/deploiement/architecture-cluster.rst b/docs/deploiement/architecture-cluster.rst
index d1eb9bf..3956bab 100644
--- a/docs/deploiement/architecture-cluster.rst
+++ b/docs/deploiement/architecture-cluster.rst
@@ -4,21 +4,23 @@ Architecture du cluster
Topologie
---------
-.. figure:: /schemas/topologie-cluster.svg
- :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs
+.. 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
- Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle.
+ 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/topologie-cluster-dark.svg
- :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs
+.. 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
- Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle.
+ 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 :
diff --git a/docs/deploiement/image-qcow2.rst b/docs/deploiement/image-qcow2.rst
index 63ddc28..1bf8866 100644
--- a/docs/deploiement/image-qcow2.rst
+++ b/docs/deploiement/image-qcow2.rst
@@ -182,7 +182,7 @@ disques : le disque de travail et le disque cible ne doivent pas être confondus
cd /work
curl "${os_link}" -O
- qemu-img convert ./*.qcow2 -O raw ${os_disk}
+ 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.
@@ -194,7 +194,7 @@ côté host contient donc une image disque complète et amorçable, sans backing
sleep 2
# La partition racine est la plus grande du disque
- root_partition=$(fdisk -lo device,size /dev/sda | grep -E '^\/dev\/' | tr -s ' ' \
+ 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
@@ -250,7 +250,7 @@ champ ``password`` de l'API de l'agent ne sert donc **pas** à ouvrir une sessio
datasource_list: [ NoCloud ]
datasource:
NoCloud:
- seedfrom: 'http://169.254.169.254:80'
+ seedfrom: 'http://169.254.169.254:80/'
timeout: 5
max_wait: 10
ENDFILE
@@ -315,12 +315,16 @@ Points de vigilance
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.
-.. note::
+.. warning::
- **À vérifier** — ``seedfrom`` sans barre oblique finale. cloud-init construit l'URL des
- documents en concaténant ``seedfrom`` avec ``meta-data`` et ``user-data``. Confirmer sur une
- VM réelle que les deux documents sont bien récupérés, et corriger en
- ``http://169.254.169.254:80/`` si ce n'est pas le cas.
+ **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::
diff --git a/docs/deploiement/premier-hyperviseur.rst b/docs/deploiement/premier-hyperviseur.rst
index ae70bc1..dc480fe 100644
--- a/docs/deploiement/premier-hyperviseur.rst
+++ b/docs/deploiement/premier-hyperviseur.rst
@@ -24,19 +24,47 @@ créées par l'agent. C'est ce qui rend un subnet utilisable au-delà d'un seul
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 `_) : l'adresse IPv4 primaire du ``local_iface``
+du subnet.
+
.. note::
- **À rédiger.** À documenter :
+ **À rédiger.** Reste à documenter :
- * la version de FRR de référence et son mode d'installation, sachant que l'hyperviseur est
- sans état : le paquet et la configuration doivent être posés à chaque démarrage, par le
- bootstrap ou par un mécanisme équivalent ;
- * les démons activés dans ``/etc/frr/daemons`` ;
- * la configuration de référence : numéro d'AS, session vers le route reflector, famille
- d'adresses utilisée pour annoncer les MAC et les VNI ;
- * l'articulation avec les interfaces créées par l'agent : comment FRR découvre une interface
- VXLAN qui apparaît à la création d'un subnet, et si une action est nécessaire ensuite ;
- * ce qui se passe au démarrage à froid, quand FRR démarre avant ou après l'agent ;
+ * 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 `_) ;
+ * 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 `_ ;
* le cas particulier du **premier** hyperviseur, dont la session ne peut pas s'établir tant
que le route reflector n'existe pas.
diff --git a/docs/deploiement/route-reflector.rst b/docs/deploiement/route-reflector.rst
index e0095d2..e4143a4 100644
--- a/docs/deploiement/route-reflector.rst
+++ b/docs/deploiement/route-reflector.rst
@@ -8,19 +8,90 @@ 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::
- **À rédiger.** À documenter :
+ **Non vérifié sur l'image de production.** Avec NetworkManager, la forme attendue est :
- * la création de la VM : ressources, subnet et mode utilisés, 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` ;
- * son adressage, et comment les hyperviseurs le connaissent ;
- * la configuration du démon de routage qu'elle héberge ;
+ .. code-block:: bash
+
+ nmcli connection modify +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, et l'état du cluster pendant que le route reflector est
- absent — les tunnels déjà établis continuent-ils de fonctionner, et pendant combien de
- temps ;
+ * 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`.
diff --git a/docs/deploiement/routeurs.rst b/docs/deploiement/routeurs.rst
index 76ec2e7..f8e8dbf 100644
--- a/docs/deploiement/routeurs.rst
+++ b/docs/deploiement/routeurs.rst
@@ -15,16 +15,66 @@ Routeurs de datacentre
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 `_.
+
.. note::
- **À rédiger.** Cette page attend les éléments de terrain. Pour chacun des trois niveaux :
+ 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.
- * le matériel ou le logiciel employé, et la version de référence ;
- * la configuration de référence : interfaces, adressage, protocole de routage et numéros
- d'AS ;
- * la redondance : combien d'équipements, quel mécanisme de bascule, quel comportement attendu
- pendant une bascule ;
- * ce qui est annoncé et ce qui est filtré à chaque niveau ;
+.. 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 `_) ;
+ * 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.
diff --git a/docs/developpement/index.rst b/docs/developpement/index.rst
new file mode 100644
index 0000000..2d78704
--- /dev/null
+++ b/docs/developpement/index.rst
@@ -0,0 +1,10 @@
+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
diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst
new file mode 100644
index 0000000..6e01936
--- /dev/null
+++ b/docs/developpement/lab.rst
@@ -0,0 +1,899 @@
+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 `_. 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 `_.
+
+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
+ `_.
+
+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=
+ SCW_DEFAULT_PROJECT_ID=
+ 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 [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 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/'
+ 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@
+ 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-``
+ * - 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::::`` — 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``, 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 -t
+
+``--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 `_ ; 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
+
+``//`` 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:::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``),
+ ``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-`` 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]
+ lab status [-run dir]
+ lab down [-run dir]
+ lab ssh [-run dir] [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 ``/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//cmdline``) contient ``-name ``.
+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@
+ $ 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 [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 `_ en est le prochain exemple. Un
+scénario est un fichier ``test/e2e/scenarios/-.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.
diff --git a/docs/exploitation/api-agent/asynchronisme.rst b/docs/exploitation/api-agent/asynchronisme.rst
index 96b5172..a506d55 100644
--- a/docs/exploitation/api-agent/asynchronisme.rst
+++ b/docs/exploitation/api-agent/asynchronisme.rst
@@ -18,7 +18,7 @@ Les deux temps d'une requête
A->>A: Prepare — valide, écrit "creating"
A-->>C: 202 + ressource en creating
A->>W: Dispatch (file d'attente)
- W->>W: Execute — netns, netif, dnsmasq
+ W->>W: Execute — netns, netif, dhcp
W->>W: état → running (ou error)
C->>A: GET /subnets/
A-->>C: 200 + state
diff --git a/docs/exploitation/configuration.rst b/docs/exploitation/configuration.rst
index 63b29a9..7024527 100644
--- a/docs/exploitation/configuration.rst
+++ b/docs/exploitation/configuration.rst
@@ -1,12 +1,19 @@
Configuration
=============
-Un seul fichier, ``/etc/two/agent.yml``, partagé par les trois binaires : ``agent -config``,
-``metadata -conf`` et ``db -conf``. Le fichier de référence commenté est
+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
@@ -117,6 +124,71 @@ Les chemins OVMF sont nécessaires aux VM démarrées avec ``uefi: true`` (paque
Debian et Ubuntu). ``uefi_vars_dir`` reçoit une copie inscriptible des variables UEFI par VM,
créée au démarrage et supprimée à l'arrêt.
+Backend DHCP
+------------
+
+.. code-block:: yaml
+
+ dhcp:
+ backend: dnsmasq # ou two
+
+Choisit qui sert le DHCP des subnets **créés par cet agent** :
+
+.. list-table::
+ :header-rows: 1
+ :widths: 14 44 42
+
+ * - Valeur
+ - Serveur
+ - Unit
+ * - ``dnsmasq``
+ - dnsmasq, configuré par fichiers dans ``/etc/dnsmasq.d``
+ - ``dnsmasq@_``
+ * - ``two``
+ - le binaire ``dhcp``, piloté par socket Unix
+ - ``dhcp@_``
+
+Le défaut est ``dnsmasq`` : un fichier de configuration de la 0.1.0, non modifié, se comporte
+exactement comme avant. Toute autre valeur que ``dnsmasq`` ou ``two`` fait échouer le démarrage.
+
+Le répertoire d'exécution du backend ``two`` — ``/run/two/dhcp`` — **n'est pas configurable** :
+le script d'enrobage le code en dur, une clé que lui ignorerait serait un mensonge.
+
+Ce que le backend ``two`` apporte : la configuration DHCP devient modifiable par VM et non plus
+seulement par subnet, ce qui permet de n'annoncer la route par défaut que sur **une** interface
+d'une VM multi-réseaux. Le watchdog peut en outre interroger le serveur et comparer ce qu'il sert
+à ce que la base dit — voir :doc:`diagnostic`.
+
+Bascule d'un backend à l'autre
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+.. warning::
+
+ L'option ne décide que du backend des **nouveaux** subnets. Elle ne migre rien : un subnet
+ déjà créé continue d'être servi par le serveur qui l'a été. Changer la valeur sans vider
+ l'hyperviseur laisse l'agent parler à un serveur qui ne tourne pas — les VM existantes
+ continuent, les nouvelles n'obtiennent pas d'adresse.
+
+La bascule est **manuelle** et suppose un hyperviseur vide :
+
+1. Supprimer toutes les VM, puis tous les subnets, puis les VPC.
+2. Vérifier qu'il ne reste aucune unit DHCP active et aucun résidu :
+
+ .. code-block:: bash
+
+ systemctl list-units 'dnsmasq@*' 'dhcp@*'
+ ls /etc/dnsmasq.d/ /run/two/dhcp/
+
+3. Modifier ``dhcp.backend`` dans ``/etc/two/agent.yml``.
+4. ``systemctl restart agent`` — la valeur est lue au démarrage, pas à chaque commande.
+5. Recréer VPC, subnets et VM.
+6. Sur la première VM, vérifier l'adresse **et les trois routes** : la route par défaut, la
+ route vers le CIDR du VPC, et la route ``/32`` vers ``169.254.169.254``. C'est cette
+ dernière qui conditionne le provisionnement cloud-init.
+
+Le retour arrière suit la même procédure. Il n'y a pas de bascule à chaud, dans un sens ni dans
+l'autre.
+
Watchdog
--------
diff --git a/docs/exploitation/diagnostic.rst b/docs/exploitation/diagnostic.rst
index 89f7b81..b60200c 100644
--- a/docs/exploitation/diagnostic.rst
+++ b/docs/exploitation/diagnostic.rst
@@ -25,7 +25,9 @@ partiellement créés subsistent.
La VM démarre mais n'a pas d'adresse
------------------------------------
-Le DHCP est servi par l'instance ``dnsmasq@`` du subnet.
+Le DHCP est servi par une instance dédiée au subnet. Quelle unit selon ``dhcp.backend`` :
+
+**Backend ``dnsmasq``**
.. code-block:: bash
@@ -34,7 +36,31 @@ Le DHCP est servi par l'instance ``dnsmasq@`` du subnet.
cat /run/dnsmasq-_.leases
cat /etc/dnsmasq.d/_.conf
-Si dnsmasq ne voit passer aucune requête, le problème est en amont : tap absent, bridge non
+**Backend ``two``**
+
+.. code-block:: bash
+
+ systemctl status 'dhcp@_'
+ journalctl -u 'dhcp@_' -n 50
+
+ # Ce que le serveur a réellement en mémoire
+ echo '{"verb":"get-state"}' \
+ | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq .
+
+ # Ce qu'il enverrait à une MAC donnée, sans effet de bord
+ echo '{"verb":"probe","mac":"00:22:33:00:00:0A"}' \
+ | socat - UNIX-CONNECT:/run/two/dhcp/_.sock | jq .lease
+
+``probe`` est le point de départ le plus rapide : il montre l'adresse, le masque, le routeur, les
+DNS et les routes classless tels qu'ils partiraient. Une réponse ``"served": false`` signifie que
+la MAC n'est pas réservée — l'ordre ``set-host`` n'a jamais atteint le serveur, ou la VM n'a pas
+été créée par cet agent.
+
+Le watchdog signale ces écarts de lui-même, à chaque tick, en comparant l'état servi à la base :
+``dhcp reservation missing on the server``, ``stale dhcp reservation``, ``dhcp reservation
+diverges``. Regarder ses notifications avant de sonder à la main.
+
+Si le serveur ne voit passer aucune requête, le problème est en amont : tap absent, bridge non
raccordé, VM dans le mauvais netns.
La VM a une adresse mais cloud-init n'applique rien
diff --git a/docs/exploitation/observabilite.rst b/docs/exploitation/observabilite.rst
index 05392d1..b8f7a4c 100644
--- a/docs/exploitation/observabilite.rst
+++ b/docs/exploitation/observabilite.rst
@@ -57,7 +57,8 @@ Journaux
journalctl -u agent -f
journalctl -u 'metadata@i-web' -n 50
- tail -f /var/log/dnsmasq-vp-admin_br-sn000001.log
+ 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
---------------------
diff --git a/docs/exploitation/services.rst b/docs/exploitation/services.rst
index 4e096cc..146f98c 100644
--- a/docs/exploitation/services.rst
+++ b/docs/exploitation/services.rst
@@ -1,7 +1,8 @@
Services systemd
================
-Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``.
+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
@@ -15,7 +16,10 @@ Trois units, installées sous ``/opt/two/bin`` par ``deploy.sh``.
- processus principal : API, dispatcher, exécution, watchdog
* - ``dnsmasq@.service``
- ``_``
- - dnsmasq lancé dans le netns du VPC, un par subnet
+ - dnsmasq lancé dans le netns du VPC, un par subnet — backend ``dnsmasq``
+ * - ``dhcp@.service``
+ - ``_``
+ - serveur DHCP intégré, un par subnet — backend ``two``
* - ``metadata@.service``
- ````
- serveur de metadata cloud-init, un par VM
@@ -26,14 +30,15 @@ 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'
+ systemctl status 'dnsmasq@vp-admin_br-sn000001' # backend dnsmasq
+ systemctl status 'dhcp@vp-admin_br-sn000001' # backend two
systemctl status 'metadata@i-web'
dnsmasq
-------
-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 :
+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
@@ -50,6 +55,42 @@ de configuration par subnet, généré par l'agent :
Le fichier de baux et le journal sont les deux premiers endroits à regarder quand une VM n'obtient
pas d'adresse.
+Serveur DHCP intégré
+--------------------
+
+Backend ``two``. Le script ``run-dhcp-in-netns.sh`` entre dans le netns puis exécute le binaire
+``dhcp``, à qui il passe le bridge à servir et ses deux chemins de fichiers — il ne déduit rien et
+ignore le netns dans lequel il tourne :
+
+.. code-block:: bash
+
+ /opt/two/bin/dhcp -conf /etc/two/agent.yml \
+ -interface br-sn000001 \
+ -state /run/two/dhcp/vp-admin_br-sn000001.state \
+ -socket /run/two/dhcp/vp-admin_br-sn000001.sock
+
+.. list-table::
+ :widths: 40 60
+
+ * - Socket de contrôle
+ - ``/run/two/dhcp/_.sock``
+ * - État
+ - ``/run/two/dhcp/_.state``
+ * - Journal
+ - ``journalctl -u 'dhcp@_'``
+
+Il n'y a **ni fichier de configuration ni fichier de baux**. L'agent pousse l'état désiré sur la
+socket de contrôle : la configuration du subnet à sa création, une réservation par interface à
+chaque création ou suppression de VM. Les réservations sont statiques — une MAC inconnue n'obtient
+rien, et le serveur reste silencieux plutôt que de répondre par un refus.
+
+Le fichier d'état **appartient au processus**, qui l'écrit et le relit à son démarrage. L'agent ne
+l'écrit jamais ; il le supprime seulement, à la création du subnet pour écarter un résidu et à sa
+suppression après avoir arrêté l'unit. Il vit dans ``/run`` parce qu'il n'a aucun sens sans le
+netns, qui ne survit pas au redémarrage de l'host.
+
+Diagnostic : voir :doc:`diagnostic`, qui montre comment interroger la socket.
+
QEMU n'est pas une unit
-----------------------
@@ -86,6 +127,6 @@ journal au moment d'un ``stop`` n'est donc pas une anomalie.
Mise à jour
-----------
-``deploy.sh`` relève les instances ``dnsmasq@`` et ``metadata@`` actives **avant** d'arrêter les
+``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.
diff --git a/docs/index.rst b/docs/index.rst
index b72be04..8eba581 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -40,6 +40,9 @@ Par où commencer
: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.
@@ -57,6 +60,12 @@ Par où commencer
exploitation/index
+.. toctree::
+ :hidden:
+ :caption: Développement
+
+ developpement/index
+
.. toctree::
:hidden:
:caption: Interne
diff --git a/docs/schemas/architecture-cluster-dark.svg b/docs/schemas/architecture-cluster-dark.svg
new file mode 100644
index 0000000..483d0f0
--- /dev/null
+++ b/docs/schemas/architecture-cluster-dark.svg
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
+ ROUTEURS DE CLUSTER · AS 65100
+
+ routeur 1
+ 169.254.0.1/28
+
+ routeur 2
+ 169.254.0.2/28
+ passerelle 192.168.14.1/24
+
+
+ VM ROUTE REFLECTOR · AS 65000
+ lo1 · 10.255.255.1/32
+ router-id, cluster-id, source des sessions EVPN
+ 192.168.14.2/24 · 169.254.0.3/28
+ principale, et secondaire pour le lien avec les routeurs
+ hébergée par le cluster lui-même
+
+
+ eBGP · BFD (×2)
+ annonce lo1 seule
+ n'accepte rien
+
+
+
+
+
+
+
+ segment underlay · L2
+ 192.168.14.0/24 · 169.254.0.0/28
+
+
+ HYPERVISEUR 1
+
+
+ br-000000
+ uplink · 192.168.14.11
+
+ vxlan-<vni>
+ dans br-<subnet>, sans learning
+
+ netns du VPC
+ passerelle, DHCP
+
+ VM
+ QEMU / KVM
+
+ VTEP
+
+ veth
+
+ tap
+
+
+ FRR
+ bgpd · AS 64600
+ advertise-all-vni
+
+ agent
+ API REST · Badger
+
+
+ FDB
+
+ crée
+
+
+ HYPERVISEUR 2
+
+
+ br-000000
+ uplink · 192.168.14.12
+
+ vxlan-<vni>
+ dans br-<subnet>, sans learning
+
+ netns du VPC
+ passerelle, DHCP
+
+ VM
+ QEMU / KVM
+
+ VTEP
+
+ veth
+
+ tap
+
+
+ FRR
+ bgpd · AS 64600
+ advertise-all-vni
+
+ agent
+ API REST · Badger
+
+
+ FDB
+
+ crée
+
+
+
+
+
+
+
+ iBGP l2vpn evpn · AS 64600
+ iBGP
+
+
+ VXLAN
+ UDP 4789
+
+
+ lien L2 / interface
+
+ plan de données : tunnels VXLAN
+
+ plan de contrôle : sessions BGP
+
+ créé par l'agent
+
+ 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.
+ 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.
+ Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5).
+ MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab.
+ 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.
+
diff --git a/docs/schemas/architecture-cluster.svg b/docs/schemas/architecture-cluster.svg
new file mode 100644
index 0000000..25028d6
--- /dev/null
+++ b/docs/schemas/architecture-cluster.svg
@@ -0,0 +1,155 @@
+
+
+
+
+
+
+
+
+
+ ROUTEURS DE CLUSTER · AS 65100
+
+ routeur 1
+ 169.254.0.1/28
+
+ routeur 2
+ 169.254.0.2/28
+ passerelle 192.168.14.1/24
+
+
+ VM ROUTE REFLECTOR · AS 65000
+ lo1 · 10.255.255.1/32
+ router-id, cluster-id, source des sessions EVPN
+ 192.168.14.2/24 · 169.254.0.3/28
+ principale, et secondaire pour le lien avec les routeurs
+ hébergée par le cluster lui-même
+
+
+ eBGP · BFD (×2)
+ annonce lo1 seule
+ n'accepte rien
+
+
+
+
+
+
+
+ segment underlay · L2
+ 192.168.14.0/24 · 169.254.0.0/28
+
+
+ HYPERVISEUR 1
+
+
+ br-000000
+ uplink · 192.168.14.11
+
+ vxlan-<vni>
+ dans br-<subnet>, sans learning
+
+ netns du VPC
+ passerelle, DHCP
+
+ VM
+ QEMU / KVM
+
+ VTEP
+
+ veth
+
+ tap
+
+
+ FRR
+ bgpd · AS 64600
+ advertise-all-vni
+
+ agent
+ API REST · Badger
+
+
+ FDB
+
+ crée
+
+
+ HYPERVISEUR 2
+
+
+ br-000000
+ uplink · 192.168.14.12
+
+ vxlan-<vni>
+ dans br-<subnet>, sans learning
+
+ netns du VPC
+ passerelle, DHCP
+
+ VM
+ QEMU / KVM
+
+ VTEP
+
+ veth
+
+ tap
+
+
+ FRR
+ bgpd · AS 64600
+ advertise-all-vni
+
+ agent
+ API REST · Badger
+
+
+ FDB
+
+ crée
+
+
+
+
+
+
+
+ iBGP l2vpn evpn · AS 64600
+ iBGP
+
+
+ VXLAN
+ UDP 4789
+
+
+ lien L2 / interface
+
+ plan de données : tunnels VXLAN
+
+ plan de contrôle : sessions BGP
+
+ créé par l'agent
+
+ 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.
+ 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.
+ Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5).
+ MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab.
+ 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.
+
diff --git a/docs/schemas/topologie-cluster-dark.svg b/docs/schemas/topologie-cluster-dark.svg
deleted file mode 100644
index 48a3916..0000000
--- a/docs/schemas/topologie-cluster-dark.svg
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
- ROUTEURS DE CLUSTER
-
- routeur A
-
- routeur B
-
-
- VM route reflector
- plan de contrôle
-
-
- HYPERVISEUR 1
-
- br-000000
-
- FRR
-
- agent
-
-
-
-
- HYPERVISEUR 2
-
- br-000000
-
- FRR
-
- agent
-
-
-
-
-
- uplink
- uplink
-
-
-
-
diff --git a/docs/schemas/topologie-cluster.svg b/docs/schemas/topologie-cluster.svg
deleted file mode 100644
index 858d18a..0000000
--- a/docs/schemas/topologie-cluster.svg
+++ /dev/null
@@ -1,52 +0,0 @@
-
-
-
-
- ROUTEURS DE CLUSTER
-
- routeur A
-
- routeur B
-
-
- VM route reflector
- plan de contrôle
-
-
- HYPERVISEUR 1
-
- br-000000
-
- FRR
-
- agent
-
-
-
-
- HYPERVISEUR 2
-
- br-000000
-
- FRR
-
- agent
-
-
-
-
-
- uplink
- uplink
-
-
-
-
diff --git a/docs/versions/0.2.0.md b/docs/versions/0.2.0.md
new file mode 100644
index 0000000..a8f52a5
--- /dev/null
+++ b/docs/versions/0.2.0.md
@@ -0,0 +1,2 @@
+```{include} ../../release_notes/0.2.0.md
+```
diff --git a/docs/versions/index.rst b/docs/versions/index.rst
index 4333ae4..09f40fa 100644
--- a/docs/versions/index.rst
+++ b/docs/versions/index.rst
@@ -6,6 +6,7 @@ Chaque version porte un nom de code dérivé du rang de sa publication : anges e
.. toctree::
:maxdepth: 1
+ 0.2.0
0.1.0
.. include:: ../../release_notes/codenames.md
diff --git a/go.mod b/go.mod
index 0e59eb9..9bcc86b 100644
--- a/go.mod
+++ b/go.mod
@@ -13,6 +13,7 @@ require (
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
)
@@ -46,7 +47,6 @@ require (
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
- go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/text v0.37.0 // indirect
google.golang.org/protobuf v1.36.8 // indirect
diff --git a/internal/api/dhcp/digest_test.go b/internal/api/dhcp/digest_test.go
index fe5dd29..f8a0510 100644
--- a/internal/api/dhcp/digest_test.go
+++ b/internal/api/dhcp/digest_test.go
@@ -1,6 +1,8 @@
package dhcpapi
import (
+ "encoding/json"
+ "strings"
"testing"
)
@@ -171,3 +173,13 @@ func TestCanonical_SortsHostsByMAC(t *testing.T) {
t.Errorf("hosts = %v, want sorted by mac", got.Hosts)
}
}
+
+func TestResponse_ServedIsAlwaysOnTheWire(t *testing.T) {
+ raw, err := json.Marshal(Response{OK: true, Served: false})
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+ if !strings.Contains(string(raw), `"served":false`) {
+ t.Errorf("response = %s, want an explicit served:false — omitting it makes \"not served\" indistinguishable from a missing field when probing by hand", raw)
+ }
+}
diff --git a/internal/api/dhcp/models.go b/internal/api/dhcp/models.go
index b0268cf..d454194 100644
--- a/internal/api/dhcp/models.go
+++ b/internal/api/dhcp/models.go
@@ -54,5 +54,5 @@ type Response struct {
State *State `json:"state,omitempty"`
Digest string `json:"digest,omitempty"`
Lease *Lease `json:"lease,omitempty"`
- Served bool `json:"served,omitempty"`
+ Served bool `json:"served"`
}
diff --git a/internal/config/agent/dhcp_test.go b/internal/config/agent/dhcp_test.go
index 6796e56..50fed1c 100644
--- a/internal/config/agent/dhcp_test.go
+++ b/internal/config/agent/dhcp_test.go
@@ -54,3 +54,29 @@ func TestLoadConfig_ReadsTheTwoBackend(t *testing.T) {
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)
+ }
+}
diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go
index d4afea1..0f84a09 100644
--- a/internal/config/agent/struct.go
+++ b/internal/config/agent/struct.go
@@ -1,6 +1,10 @@
package configuration
import (
+ "errors"
+ "fmt"
+ "os"
+
"github.com/spf13/viper"
)
@@ -86,7 +90,9 @@ func LoadConfig(path string) (*Config, error) {
v.SetDefault("logger.level", "info")
v.SetDefault("logger.debug", false)
- v.ReadInConfig()
+ if err := v.ReadInConfig(); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return nil, fmt.Errorf("read %s: %w", path, err)
+ }
var cfg Config
if err := v.Unmarshal(&cfg); err != nil {
diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go
index dc125a5..0e5833e 100644
--- a/internal/dhcp/generate.go
+++ b/internal/dhcp/generate.go
@@ -29,12 +29,7 @@ func GenerateConfig(c Config) (string, map[string]string, error) {
fmt.Fprintf(&sb, "dhcp-hostsdir=%s\n", HostsDir(c.ConfDir, c.Name))
fmt.Fprintf(&sb, "dhcp-optsdir=%s\n", OptsDir(c.ConfDir, c.Name))
- entries := make(map[string]string)
- i := 0
- for ip := cloneIP(c.Network.IP); c.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++
- }
+ 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 {
@@ -46,6 +41,16 @@ func GenerateConfig(c Config) (string, map[string]string, error) {
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()
diff --git a/internal/dhcp/reservation.go b/internal/dhcp/reservation.go
index 0cd875c..ab915f5 100644
--- a/internal/dhcp/reservation.go
+++ b/internal/dhcp/reservation.go
@@ -117,3 +117,11 @@ func RemoveSubnetDirs(confDir, name string) error {
}
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
+}
diff --git a/internal/dhcpbackend/backend.go b/internal/dhcpbackend/backend.go
new file mode 100644
index 0000000..3c9c909
--- /dev/null
+++ b/internal/dhcpbackend/backend.go
@@ -0,0 +1,53 @@
+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
+ }
+}
diff --git a/internal/dhcpbackend/backend_test.go b/internal/dhcpbackend/backend_test.go
new file mode 100644
index 0000000..bf63b2f
--- /dev/null
+++ b/internal/dhcpbackend/backend_test.go
@@ -0,0 +1,99 @@
+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)
+ }
+}
diff --git a/internal/dhcpbackend/dnsmasq.go b/internal/dhcpbackend/dnsmasq.go
new file mode 100644
index 0000000..207ecee
--- /dev/null
+++ b/internal/dhcpbackend/dnsmasq.go
@@ -0,0 +1,132 @@
+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")
+}
diff --git a/internal/dhcpbackend/dnsmasq_test.go b/internal/dhcpbackend/dnsmasq_test.go
new file mode 100644
index 0000000..1bb2d7c
--- /dev/null
+++ b/internal/dhcpbackend/dnsmasq_test.go
@@ -0,0 +1,113 @@
+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")
+ }
+}
diff --git a/internal/dhcpbackend/two.go b/internal/dhcpbackend/two.go
new file mode 100644
index 0000000..71a4d37
--- /dev/null
+++ b/internal/dhcpbackend/two.go
@@ -0,0 +1,147 @@
+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()
+}
diff --git a/internal/dhcpbackend/two_test.go b/internal/dhcpbackend/two_test.go
new file mode 100644
index 0000000..cedbb16
--- /dev/null
+++ b/internal/dhcpbackend/two_test.go
@@ -0,0 +1,254 @@
+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
+}
diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go
index 8d71f0c..b2fb5f6 100644
--- a/internal/dispatcher/agent/subnet_commands.go
+++ b/internal/dispatcher/agent/subnet_commands.go
@@ -84,7 +84,7 @@ func (c CreateSubnetCommand) Execute(db *badger.DB, cfg *configuration.Config) e
case <-time.After(time.Duration(cfg.Dispatcher.PollSeconds) * time.Second):
}
}
- return subnet.CreateSubnet(db, c.Name)
+ return subnet.CreateSubnet(db, c.Name, cfg)
}
type DeleteSubnetCommand struct {
@@ -104,8 +104,8 @@ func (c DeleteSubnetCommand) Prepare(db *badger.DB, _ *configuration.Config) err
return state.Set(db, c.Key(), state.Deleting)
}
-func (c DeleteSubnetCommand) Execute(db *badger.DB, _ *configuration.Config) error {
- if err := subnet.DeleteSubnet(db, c.Name); err != nil {
+func (c DeleteSubnetCommand) Execute(db *badger.DB, cfg *configuration.Config) error {
+ if err := subnet.DeleteSubnet(db, c.Name, cfg); err != nil {
return err
}
current, err := state.Get(db, c.Key())
diff --git a/internal/lab/machine/machine.go b/internal/lab/machine/machine.go
new file mode 100644
index 0000000..29e1312
--- /dev/null
+++ b/internal/lab/machine/machine.go
@@ -0,0 +1,293 @@
+package machine
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "syscall"
+ "text/tabwriter"
+ "time"
+
+ "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 (
+ User = "debian"
+
+ sshUnreachable = 255
+ cloudInitRecoverable = 2
+)
+
+type Lab struct {
+ Plan *topology.Plan
+ RunDir string
+ ProcDir string
+ Runner provision.Runner
+ Poll time.Duration
+ Stop time.Duration
+ Out io.Writer
+
+ signal func(pid int, sig syscall.Signal) error
+}
+
+func (l Lab) node(name string) (topology.NodePlan, error) {
+ for _, n := range l.Plan.Nodes {
+ if n.Name == name {
+ return n, nil
+ }
+ }
+ return topology.NodePlan{}, fmt.Errorf("node %q is not in lab %s", name, l.Plan.Name)
+}
+
+func (l Lab) pidFile(name string) string {
+ return filepath.Join(l.RunDir, name, render.PIDFile)
+}
+
+func (l Lab) PID(name string) (int, error) {
+ data, err := os.ReadFile(l.pidFile(name))
+ if errors.Is(err, os.ErrNotExist) {
+ return 0, nil
+ }
+ if err != nil {
+ return 0, err
+ }
+ pid, err := strconv.Atoi(strings.TrimSpace(string(data)))
+ if err != nil || pid <= 0 {
+ return 0, fmt.Errorf("%s: not a pid: %q", l.pidFile(name), data)
+ }
+ if !l.runs(pid, name) {
+ return 0, nil
+ }
+ return pid, nil
+}
+
+func (l Lab) runs(pid int, name string) bool {
+ if err := syscall.Kill(pid, 0); err != nil && !errors.Is(err, syscall.EPERM) {
+ return false
+ }
+ cmdline, err := os.ReadFile(filepath.Join(l.ProcDir, strconv.Itoa(pid), "cmdline"))
+ if err != nil {
+ return false
+ }
+ return bytes.Contains(cmdline, []byte("\x00-name\x00"+name+"\x00"))
+}
+
+func (l Lab) Running() ([]string, error) {
+ var running []string
+ for _, n := range l.Plan.Nodes {
+ pid, err := l.PID(n.Name)
+ if err != nil {
+ return nil, err
+ }
+ if pid != 0 {
+ running = append(running, n.Name)
+ }
+ }
+ return running, nil
+}
+
+func (l Lab) Up(ctx context.Context, fetcher provision.Fetcher, timeout time.Duration) error {
+ running, err := l.Running()
+ if err != nil {
+ return err
+ }
+ if len(running) > 0 {
+ return fmt.Errorf("lab %s is already running (%s): 'lab down' first", l.Plan.Name, strings.Join(running, ", "))
+ }
+
+ nodes, err := provision.Prepare(ctx, l.Plan, provision.Options{RunDir: l.RunDir, Fetcher: fetcher, Runner: l.Runner})
+ if err != nil {
+ return err
+ }
+ for _, n := range switchesFirst(l.Plan, nodes) {
+ if err := os.Remove(l.pidFile(n.Name)); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return err
+ }
+ if err := l.Runner.Run(ctx, render.QEMUBinary, append(n.QEMU, "-daemonize")...); err != nil {
+ return fmt.Errorf("node %s: %w", n.Name, err)
+ }
+ fmt.Fprintf(l.Out, "%s: started\n", n.Name)
+ }
+
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+ var errs []error
+ for _, n := range l.Plan.Nodes {
+ if err := l.waitReady(ctx, n); err != nil {
+ errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err))
+ continue
+ }
+ if err := l.checkServices(ctx, n); err != nil {
+ errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err))
+ continue
+ }
+ fmt.Fprintf(l.Out, "%s: ready\n", n.Name)
+ }
+ return errors.Join(errs...)
+}
+
+func switchesFirst(p *topology.Plan, nodes []render.Node) []render.Node {
+ var first, rest []render.Node
+ for i, n := range nodes {
+ if p.Nodes[i].Role == topology.RoleSwitch {
+ first = append(first, n)
+ } else {
+ rest = append(rest, n)
+ }
+ }
+ return append(first, rest...)
+}
+
+func (l Lab) waitReady(ctx context.Context, n topology.NodePlan) error {
+ args := l.sshArgs(n, true, []string{"cloud-init", "status", "--wait"})
+ for {
+ err := l.Runner.Run(ctx, "ssh", args...)
+ switch code := exitCode(err); {
+ case err == nil:
+ return nil
+ case code == cloudInitRecoverable:
+ fmt.Fprintf(l.Out, "%s: cloud-init finished with recoverable errors: %v\n", n.Name, err)
+ return nil
+ case code != sshUnreachable:
+ return fmt.Errorf("cloud-init failed: %w", err)
+ }
+ select {
+ case <-ctx.Done():
+ return fmt.Errorf("not reachable over ssh: %w", ctx.Err())
+ case <-time.After(l.Poll):
+ }
+ }
+}
+
+func services(n topology.NodePlan) []string {
+ var units []string
+ if n.Role == topology.RoleHypervisor {
+ units = append(units, "agent.service")
+ }
+ if n.FRR != "" {
+ units = append(units, "frr.service")
+ }
+ return units
+}
+
+func (l Lab) checkServices(ctx context.Context, n topology.NodePlan) error {
+ for _, unit := range services(n) {
+ if err := l.Runner.Run(ctx, "ssh", l.sshArgs(n, true, []string{"systemctl", "is-active", "--quiet", unit})...); err != nil {
+ return fmt.Errorf("%s is not active: %w", unit, err)
+ }
+ }
+ return nil
+}
+
+func exitCode(err error) int {
+ var coded interface{ ExitCode() int }
+ if errors.As(err, &coded) {
+ return coded.ExitCode()
+ }
+ return -1
+}
+
+func (l Lab) sshArgs(n topology.NodePlan, batch bool, command []string) []string {
+ args := []string{
+ "-i", filepath.Join(l.RunDir, provision.KeyFile),
+ "-o", "IdentitiesOnly=yes",
+ "-o", "IdentityAgent=none",
+ "-o", "StrictHostKeyChecking=no",
+ "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "LogLevel=ERROR",
+ "-p", strconv.Itoa(n.SSHPort),
+ }
+ if batch {
+ args = append(args, "-o", "BatchMode=yes", "-o", "ConnectTimeout=5")
+ }
+ args = append(args, User+"@127.0.0.1")
+ return append(args, command...)
+}
+
+func (l Lab) SSH(name string, terminal bool, command []string) ([]string, error) {
+ n, err := l.node(name)
+ if err != nil {
+ return nil, err
+ }
+ args := []string{"ssh"}
+ if terminal && len(command) > 0 {
+ args = append(args, "-t")
+ }
+ return append(args, l.sshArgs(n, false, command)...), nil
+}
+
+func (l Lab) Status(w io.Writer) error {
+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
+ fmt.Fprintf(tw, "node\trole\tstate\tpid\tssh\n")
+ for _, n := range l.Plan.Nodes {
+ pid, err := l.PID(n.Name)
+ if err != nil {
+ return err
+ }
+ state, shown := "stopped", "-"
+ if pid != 0 {
+ state, shown = "running", strconv.Itoa(pid)
+ }
+ fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t127.0.0.1:%d\n", n.Name, n.Role, state, shown, n.SSHPort)
+ }
+ return tw.Flush()
+}
+
+func (l Lab) Down(ctx context.Context) error {
+ var errs []error
+ for _, n := range l.Plan.Nodes {
+ pid, err := l.PID(n.Name)
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+ if pid == 0 {
+ continue
+ }
+ if err := l.stop(ctx, n.Name, pid); err != nil {
+ errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err))
+ continue
+ }
+ if err := os.Remove(l.pidFile(n.Name)); err != nil && !errors.Is(err, os.ErrNotExist) {
+ errs = append(errs, err)
+ continue
+ }
+ fmt.Fprintf(l.Out, "%s: stopped\n", n.Name)
+ }
+ return errors.Join(errs...)
+}
+
+func (l Lab) stop(ctx context.Context, name string, pid int) error {
+ if pid <= 0 {
+ return fmt.Errorf("refusing to signal pid %d", pid)
+ }
+ send := l.signal
+ if send == nil {
+ send = syscall.Kill
+ }
+ for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL} {
+ if err := send(pid, sig); err != nil && !errors.Is(err, syscall.ESRCH) {
+ return err
+ }
+ deadline := time.Now().Add(l.Stop)
+ for l.runs(pid, name) && time.Now().Before(deadline) {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(l.Poll):
+ }
+ }
+ if !l.runs(pid, name) {
+ return nil
+ }
+ }
+ return fmt.Errorf("pid %d still running after SIGKILL", pid)
+}
diff --git a/internal/lab/machine/machine_test.go b/internal/lab/machine/machine_test.go
new file mode 100644
index 0000000..78e2ba4
--- /dev/null
+++ b/internal/lab/machine/machine_test.go
@@ -0,0 +1,588 @@
+package machine
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha512"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "reflect"
+ "strconv"
+ "strings"
+ "sync"
+ "syscall"
+ "testing"
+ "time"
+
+ "git.g3e.fr/syonad/two/internal/lab/provision"
+ "git.g3e.fr/syonad/two/internal/lab/topology"
+)
+
+const labKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab"
+
+type exitErr int
+
+func (e exitErr) Error() string { return "exit status " + strconv.Itoa(int(e)) }
+func (e exitErr) ExitCode() int { return int(e) }
+
+type fakeRunner struct {
+ mu sync.Mutex
+ calls [][]string
+ fail string
+ ssh map[string][]error
+ always map[string]error
+ inactive string
+}
+
+func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ f.calls = append(f.calls, append([]string{name}, args...))
+ if name == f.fail {
+ return errors.New(name + " failed")
+ }
+ switch name {
+ case "ssh-keygen":
+ private := args[len(args)-1]
+ if err := os.WriteFile(private, []byte("private"), 0o600); err != nil {
+ return err
+ }
+ return os.WriteFile(private+".pub", []byte(labKey+"\n"), 0o644)
+ case "ssh":
+ port := args[indexOf(args, "-p")+1]
+ if args[len(args)-1] == f.inactive {
+ return exitErr(3)
+ }
+ answers := f.ssh[port]
+ if len(answers) == 0 {
+ return f.always[port]
+ }
+ f.ssh[port] = answers[1:]
+ return answers[0]
+ }
+ return nil
+}
+
+func (f *fakeRunner) commands(name string) [][]string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ var out [][]string
+ for _, c := range f.calls {
+ if c[0] == name {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+func indexOf(list []string, value string) int {
+ for i, v := range list {
+ if v == value {
+ return i
+ }
+ }
+ return -1
+}
+
+func mirror(t *testing.T) (*httptest.Server, string) {
+ t.Helper()
+ content := []byte("qcow2 image")
+ h := sha512.Sum512(content)
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "SHA512SUMS") {
+ fmt.Fprintf(w, "%s deb.qcow2\n", hex.EncodeToString(h[:]))
+ return
+ }
+ w.Write(content)
+ }))
+ t.Cleanup(server.Close)
+ return server, server.URL
+}
+
+func labPlan(t *testing.T, base, nodes string) *topology.Plan {
+ t.Helper()
+ doc := `name: evpn-2hv
+images:
+ deb:
+ url: ` + base + `/deb.qcow2
+ sums: ` + base + `/SHA512SUMS
+segments:
+ underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 }
+nodes:
+` + nodes
+ topo, err := topology.Parse([]byte(doc))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ p, err := topology.Compute(topo)
+ if err != nil {
+ t.Fatalf("Compute: %v", err)
+ }
+ return p
+}
+
+const switchLast = ` rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] }
+ hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
+ sw1: { role: switch, image: deb, cpus: 2, memory: 1024 }
+`
+
+type fixture struct {
+ lab Lab
+ runner *fakeRunner
+ out *bytes.Buffer
+ fetcher provision.Fetcher
+}
+
+func newFixture(t *testing.T, nodes string) *fixture {
+ t.Helper()
+ server, base := mirror(t)
+ root := t.TempDir()
+ runner := &fakeRunner{ssh: map[string][]error{}, always: map[string]error{}}
+ out := &bytes.Buffer{}
+ return &fixture{
+ lab: Lab{
+ Plan: labPlan(t, base, nodes),
+ RunDir: filepath.Join(root, "run"),
+ ProcDir: filepath.Join(root, "proc"),
+ Runner: runner,
+ Poll: time.Millisecond,
+ Stop: 500 * time.Millisecond,
+ Out: out,
+ },
+ runner: runner,
+ out: out,
+ fetcher: provision.Fetcher{Client: server.Client(), CacheDir: filepath.Join(root, "cache")},
+ }
+}
+
+func (f *fixture) process(t *testing.T, node, script string, cmdline ...string) int {
+ t.Helper()
+ cmd := exec.Command("sh", "-c", script)
+ if err := cmd.Start(); err != nil {
+ t.Fatal(err)
+ }
+ done := make(chan struct{})
+ go func() { cmd.Wait(); close(done) }()
+ t.Cleanup(func() {
+ cmd.Process.Kill()
+ <-done
+ })
+ pid := cmd.Process.Pid
+ proc := filepath.Join(f.lab.ProcDir, strconv.Itoa(pid))
+ if err := os.MkdirAll(proc, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte(strings.Join(cmdline, "\x00")+"\x00"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ f.pidFile(t, node, strconv.Itoa(pid)+"\n")
+ return pid
+}
+
+func (f *fixture) pidFile(t *testing.T, node, content string) {
+ t.Helper()
+ dir := filepath.Join(f.lab.RunDir, node)
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "qemu.pid"), []byte(content), 0o600); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func qemu(node string) []string {
+ return []string{"qemu-system-x86_64", "-name", node, "-machine", "q35"}
+}
+
+func alive(pid int) bool {
+ err := syscall.Kill(pid, 0)
+ return err == nil || errors.Is(err, syscall.EPERM)
+}
+
+func waitDead(t *testing.T, pid int) {
+ t.Helper()
+ deadline := time.Now().Add(2 * time.Second)
+ for alive(pid) && time.Now().Before(deadline) {
+ time.Sleep(5 * time.Millisecond)
+ }
+ if alive(pid) {
+ t.Errorf("pid %d is still alive", pid)
+ }
+}
+
+func TestPID_NoPidFileMeansStopped(t *testing.T) {
+ f := newFixture(t, switchLast)
+ if pid, err := f.lab.PID("hv1"); pid != 0 || err != nil {
+ t.Errorf("PID = %d, %v", pid, err)
+ }
+}
+
+func TestPID_RefusesACorruptedPidFile(t *testing.T) {
+ for _, content := range []string{"", "abc", "0", "-12"} {
+ f := newFixture(t, switchLast)
+ f.pidFile(t, "hv1", content)
+ if _, err := f.lab.PID("hv1"); err == nil || !strings.Contains(err.Error(), "not a pid") {
+ t.Errorf("pid file %q: error = %v", content, err)
+ }
+ }
+}
+
+func TestPID_RecognisesTheNodeProcess(t *testing.T) {
+ f := newFixture(t, switchLast)
+ want := f.process(t, "hv1", "sleep 30", qemu("hv1")...)
+ if pid, err := f.lab.PID("hv1"); pid != want || err != nil {
+ t.Errorf("PID = %d, %v, want %d", pid, err, want)
+ }
+}
+
+func TestPID_IgnoresAPidReusedByAnotherProcess(t *testing.T) {
+ cases := map[string][]string{
+ "another program": {"/usr/sbin/sshd", "-D"},
+ "another node": qemu("hv10"),
+ "name as a value": {"qemu-system-x86_64", "-serial", "-name", "-name", "hv2"},
+ }
+ for name, cmdline := range cases {
+ t.Run(name, func(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.process(t, "hv1", "sleep 30", cmdline...)
+ if pid, err := f.lab.PID("hv1"); pid != 0 || err != nil {
+ t.Errorf("PID = %d, %v, want 0", pid, err)
+ }
+ })
+ }
+}
+
+func TestPID_IgnoresADeadProcess(t *testing.T) {
+ f := newFixture(t, switchLast)
+ pid := f.process(t, "hv1", "exit 0", qemu("hv1")...)
+ waitDead(t, pid)
+ if got, err := f.lab.PID("hv1"); got != 0 || err != nil {
+ t.Errorf("PID = %d, %v, want 0", got, err)
+ }
+}
+
+func TestStatus(t *testing.T) {
+ f := newFixture(t, switchLast)
+ pid := strconv.Itoa(f.process(t, "hv1", "sleep 30", qemu("hv1")...))
+ var out bytes.Buffer
+ if err := f.lab.Status(&out); err != nil {
+ t.Fatal(err)
+ }
+ w := len(pid)
+ if w < 3 {
+ w = 3
+ }
+ want := fmt.Sprintf("node role state %-*s ssh\n", w, "pid") +
+ fmt.Sprintf("rr1 rr stopped %-*s 127.0.0.1:2200\n", w, "-") +
+ fmt.Sprintf("hv1 hypervisor running %-*s 127.0.0.1:2201\n", w, pid) +
+ fmt.Sprintf("sw1 switch stopped %-*s 127.0.0.1:2202\n", w, "-")
+ if out.String() != want {
+ t.Errorf("status:\n%s\nwant:\n%s", out.String(), want)
+ }
+}
+
+func TestDown_StopsWithSIGTERM(t *testing.T) {
+ f := newFixture(t, switchLast)
+ pid := f.process(t, "hv1", "sleep 30", qemu("hv1")...)
+
+ if err := f.lab.Down(context.Background()); err != nil {
+ t.Fatalf("Down: %v", err)
+ }
+
+ waitDead(t, pid)
+ if _, err := os.Stat(filepath.Join(f.lab.RunDir, "hv1", "qemu.pid")); !os.IsNotExist(err) {
+ t.Errorf("pid file still there: %v", err)
+ }
+ if f.out.String() != "hv1: stopped\n" {
+ t.Errorf("output = %q", f.out.String())
+ }
+}
+
+func TestDown_FallsBackToSIGKILL(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.lab.Stop = 100 * time.Millisecond
+ pid := f.process(t, "hv1", `trap "" TERM; while :; do sleep 0.05; done`, qemu("hv1")...)
+ time.Sleep(50 * time.Millisecond)
+
+ start := time.Now()
+ if err := f.lab.Down(context.Background()); err != nil {
+ t.Fatalf("Down: %v", err)
+ }
+
+ waitDead(t, pid)
+ if elapsed := time.Since(start); elapsed < 100*time.Millisecond {
+ t.Errorf("SIGKILL after %v, want at least the 100ms grace period", elapsed)
+ }
+}
+
+func TestDown_NeverSignalsAProcessThatIsNotTheNode(t *testing.T) {
+ f := newFixture(t, switchLast)
+ pid := f.process(t, "hv1", "sleep 30", "/usr/sbin/sshd", "-D")
+
+ if err := f.lab.Down(context.Background()); err != nil {
+ t.Fatalf("Down: %v", err)
+ }
+
+ time.Sleep(50 * time.Millisecond)
+ if !alive(pid) {
+ t.Error("a process that is not the node was killed")
+ }
+ if f.out.Len() != 0 {
+ t.Errorf("output = %q", f.out.String())
+ }
+}
+
+func TestDown_ReportsACorruptedPidFileAndStopsTheOthers(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.pidFile(t, "rr1", "garbage")
+ pid := f.process(t, "hv1", "sleep 30", qemu("hv1")...)
+
+ err := f.lab.Down(context.Background())
+
+ if err == nil || !strings.Contains(err.Error(), "not a pid") {
+ t.Errorf("error = %v", err)
+ }
+ waitDead(t, pid)
+}
+
+func TestSSH_Arguments(t *testing.T) {
+ f := newFixture(t, switchLast)
+ key := filepath.Join(f.lab.RunDir, "lab_ed25519")
+ base := []string{
+ "-i", key,
+ "-o", "IdentitiesOnly=yes",
+ "-o", "IdentityAgent=none",
+ "-o", "StrictHostKeyChecking=no",
+ "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "LogLevel=ERROR",
+ "-p", "2201",
+ "debian@127.0.0.1",
+ }
+ cases := map[string]struct {
+ terminal bool
+ command []string
+ want []string
+ }{
+ "interactive shell": {true, nil, append([]string{"ssh"}, base...)},
+ "command from a terminal": {true, []string{"top"}, append(append([]string{"ssh", "-t"}, base...), "top")},
+ "command from a script": {false, []string{"ip", "-br", "a"}, append(append([]string{"ssh"}, base...), "ip", "-br", "a")},
+ "shell from a script": {false, nil, append([]string{"ssh"}, base...)},
+ }
+ for name, c := range cases {
+ t.Run(name, func(t *testing.T) {
+ got, err := f.lab.SSH("hv1", c.terminal, c.command)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !reflect.DeepEqual(got, c.want) {
+ t.Errorf("\n got %q\nwant %q", got, c.want)
+ }
+ })
+ }
+}
+
+func TestSSH_UnknownNode(t *testing.T) {
+ f := newFixture(t, switchLast)
+ if _, err := f.lab.SSH("hv9", true, nil); err == nil || !strings.Contains(err.Error(), `node "hv9" is not in lab evpn-2hv`) {
+ t.Errorf("error = %v", err)
+ }
+}
+
+func TestUp_StartsSwitchesFirstAndWaitsForEveryNode(t *testing.T) {
+ f := newFixture(t, switchLast)
+
+ if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil {
+ t.Fatalf("Up: %v", err)
+ }
+
+ var started []string
+ for _, c := range f.runner.commands("qemu-system-x86_64") {
+ if c[len(c)-1] != "-daemonize" {
+ t.Errorf("qemu not daemonized: %q", c)
+ }
+ started = append(started, c[indexOf(c, "-name")+1])
+ }
+ if !reflect.DeepEqual(started, []string{"sw1", "rr1", "hv1"}) {
+ t.Errorf("start order = %v, want the switch first", started)
+ }
+
+ ssh := f.runner.commands("ssh")
+ if len(ssh) != 4 {
+ t.Fatalf("%d ssh calls, want 4", len(ssh))
+ }
+ want := []string{"ssh",
+ "-i", filepath.Join(f.lab.RunDir, "lab_ed25519"),
+ "-o", "IdentitiesOnly=yes",
+ "-o", "IdentityAgent=none",
+ "-o", "StrictHostKeyChecking=no",
+ "-o", "UserKnownHostsFile=/dev/null",
+ "-o", "LogLevel=ERROR",
+ "-p", "2200",
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=5",
+ "debian@127.0.0.1",
+ "cloud-init", "status", "--wait",
+ }
+ if !reflect.DeepEqual(ssh[0], want) {
+ t.Errorf("readiness check:\n got %q\nwant %q", ssh[0], want)
+ }
+ if f.out.String() != "sw1: started\nrr1: started\nhv1: started\nrr1: ready\nhv1: ready\nsw1: ready\n" {
+ t.Errorf("output = %q", f.out.String())
+ }
+}
+
+func TestUp_RetriesWhileSSHIsUnreachable(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.runner.ssh["2201"] = []error{exitErr(255), exitErr(255), nil}
+
+ if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil {
+ t.Fatalf("Up: %v", err)
+ }
+ if n := len(f.runner.commands("ssh")); n != 6 {
+ t.Errorf("%d ssh calls, want 6", n)
+ }
+}
+
+func TestUp_ReadinessFailures(t *testing.T) {
+ cases := map[string]struct {
+ answers []error
+ want string
+ }{
+ "cloud-init error": {[]error{exitErr(1)}, "node hv1: cloud-init failed: exit status 1"},
+ "not an exit status": {[]error{errors.New("fork failed")}, "node hv1: cloud-init failed: fork failed"},
+ "ssh never reachable": {nil, "node hv1: not reachable over ssh: context deadline exceeded"},
+ }
+ for name, c := range cases {
+ t.Run(name, func(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.lab.Poll = 10 * time.Millisecond
+ f.runner.ssh["2201"] = c.answers
+ f.runner.always["2201"] = exitErr(255)
+
+ err := f.lab.Up(context.Background(), f.fetcher, 300*time.Millisecond)
+
+ if err == nil || err.Error() != c.want {
+ t.Errorf("error = %v, want %q", err, c.want)
+ }
+ if !strings.Contains(f.out.String(), "rr1: ready\n") || !strings.Contains(f.out.String(), "sw1: ready\n") {
+ t.Errorf("the other nodes were not waited for: %q", f.out.String())
+ }
+ })
+ }
+}
+
+func TestUp_RecoverableCloudInitErrorsAreReportedNotFatal(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.runner.ssh["2201"] = []error{exitErr(2)}
+
+ if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil {
+ t.Fatalf("Up: %v", err)
+ }
+ if !strings.Contains(f.out.String(), "hv1: cloud-init finished with recoverable errors: exit status 2\nhv1: ready\n") {
+ t.Errorf("output = %q", f.out.String())
+ }
+}
+
+func TestUp_RefusesARunningLab(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.process(t, "hv1", "sleep 30", qemu("hv1")...)
+
+ err := f.lab.Up(context.Background(), f.fetcher, time.Second)
+
+ if err == nil || err.Error() != "lab evpn-2hv is already running (hv1): 'lab down' first" {
+ t.Errorf("error = %v", err)
+ }
+ if len(f.runner.calls) != 0 {
+ t.Errorf("commands ran: %q", f.runner.calls)
+ }
+}
+
+func TestUp_RemovesAStalePidFileBeforeStarting(t *testing.T) {
+ f := newFixture(t, switchLast)
+ pid := f.process(t, "hv1", "exit 0", qemu("hv1")...)
+ waitDead(t, pid)
+
+ if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil {
+ t.Fatalf("Up: %v", err)
+ }
+ if _, err := os.Stat(filepath.Join(f.lab.RunDir, "hv1", "qemu.pid")); !os.IsNotExist(err) {
+ t.Errorf("stale pid file kept: %v", err)
+ }
+}
+
+func TestUp_StopsAtTheFirstQEMUFailure(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.runner.fail = "qemu-system-x86_64"
+
+ err := f.lab.Up(context.Background(), f.fetcher, time.Second)
+
+ if err == nil || err.Error() != "node sw1: qemu-system-x86_64 failed" {
+ t.Errorf("error = %v", err)
+ }
+ if n := len(f.runner.commands("qemu-system-x86_64")); n != 1 {
+ t.Errorf("%d qemu starts, want 1", n)
+ }
+ if n := len(f.runner.commands("ssh")); n != 0 {
+ t.Errorf("%d ssh calls after a failed start", n)
+ }
+}
+
+func TestStop_RefusesPidsThatTargetAGroup(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.lab.signal = func(pid int, sig syscall.Signal) error {
+ t.Fatalf("signal %v sent to pid %d", sig, pid)
+ return nil
+ }
+ for _, pid := range []int{0, -1} {
+ if err := f.lab.stop(context.Background(), "hv1", pid); err == nil || err.Error() != fmt.Sprintf("refusing to signal pid %d", pid) {
+ t.Errorf("pid %d: error = %v", pid, err)
+ }
+ }
+}
+
+func TestUp_ChecksTheServicesOfEachRole(t *testing.T) {
+ f := newFixture(t, switchLast)
+ conf := filepath.Join(t.TempDir(), "sw1.conf")
+ if err := os.WriteFile(conf, []byte("hostname sw1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ f.lab.Plan.Nodes[2].FRR = conf
+
+ if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil {
+ t.Fatalf("Up: %v", err)
+ }
+
+ var checks []string
+ for _, c := range f.runner.commands("ssh") {
+ if c[len(c)-4] == "systemctl" {
+ checks = append(checks, c[indexOf(c, "-p")+1]+" "+strings.Join(c[len(c)-4:], " "))
+ }
+ }
+ want := []string{
+ "2201 systemctl is-active --quiet agent.service",
+ "2202 systemctl is-active --quiet frr.service",
+ }
+ if !reflect.DeepEqual(checks, want) {
+ t.Errorf("checks = %q, want %q", checks, want)
+ }
+}
+
+func TestUp_ReportsAnInactiveServiceAndWaitsForTheOthers(t *testing.T) {
+ f := newFixture(t, switchLast)
+ f.runner.inactive = "agent.service"
+
+ err := f.lab.Up(context.Background(), f.fetcher, time.Second)
+
+ if err == nil || err.Error() != "node hv1: agent.service is not active: exit status 3" {
+ t.Errorf("error = %v", err)
+ }
+ if !strings.Contains(f.out.String(), "rr1: ready\n") || !strings.Contains(f.out.String(), "sw1: ready\n") || strings.Contains(f.out.String(), "hv1: ready") {
+ t.Errorf("output = %q", f.out.String())
+ }
+}
diff --git a/internal/lab/provision/image.go b/internal/lab/provision/image.go
new file mode 100644
index 0000000..c4e8347
--- /dev/null
+++ b/internal/lab/provision/image.go
@@ -0,0 +1,185 @@
+package provision
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "crypto/sha512"
+ "encoding/hex"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "regexp"
+ "strings"
+
+ "git.g3e.fr/syonad/two/internal/lab/topology"
+)
+
+const maxSumsSize = 1 << 20
+
+var sha512Pattern = regexp.MustCompile(`^[0-9a-f]{128}$`)
+
+type Fetcher struct {
+ Client *http.Client
+ CacheDir string
+}
+
+func (f Fetcher) Image(ctx context.Context, img topology.Image) (string, error) {
+ if !filepath.IsAbs(f.CacheDir) {
+ return "", fmt.Errorf("image cache dir %q must be an absolute path", f.CacheDir)
+ }
+ name, err := fileName(img.URL)
+ if err != nil {
+ return "", fmt.Errorf("image %s: %w", img.Name, err)
+ }
+
+ sums, err := f.get(ctx, img.Sums, maxSumsSize)
+ if err != nil {
+ return "", fmt.Errorf("image %s: sums: %w", img.Name, err)
+ }
+ want, err := expectedSum(sums, name)
+ if err != nil {
+ return "", fmt.Errorf("image %s: %s: %w", img.Name, img.Sums, err)
+ }
+
+ dir := filepath.Join(f.CacheDir, img.Name)
+ target := filepath.Join(dir, name)
+ if got, err := fileSum(target); err == nil && got == want {
+ return target, nil
+ } else if err != nil && !errors.Is(err, os.ErrNotExist) {
+ return "", fmt.Errorf("image %s: %w", img.Name, err)
+ }
+
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return "", err
+ }
+ if err := f.download(ctx, img.URL, dir, target, want); err != nil {
+ return "", fmt.Errorf("image %s: %w", img.Name, err)
+ }
+ return target, nil
+}
+
+func (f Fetcher) client() *http.Client {
+ if f.Client != nil {
+ return f.Client
+ }
+ return http.DefaultClient
+}
+
+func (f Fetcher) open(ctx context.Context, rawURL string) (*http.Response, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return nil, err
+ }
+ resp, err := f.client().Do(req)
+ if err != nil {
+ return nil, err
+ }
+ if resp.StatusCode != http.StatusOK {
+ resp.Body.Close()
+ return nil, fmt.Errorf("GET %s: %s", rawURL, resp.Status)
+ }
+ return resp, nil
+}
+
+func (f Fetcher) get(ctx context.Context, rawURL string, limit int64) ([]byte, error) {
+ resp, err := f.open(ctx, rawURL)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1))
+ if err != nil {
+ return nil, err
+ }
+ if int64(len(data)) > limit {
+ return nil, fmt.Errorf("GET %s: larger than %d bytes", rawURL, limit)
+ }
+ return data, nil
+}
+
+func (f Fetcher) download(ctx context.Context, rawURL, dir, target, want string) error {
+ resp, err := f.open(ctx, rawURL)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ tmp, err := os.CreateTemp(dir, filepath.Base(target)+".part-*")
+ if err != nil {
+ return err
+ }
+ defer os.Remove(tmp.Name())
+
+ h := sha512.New()
+ if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil {
+ tmp.Close()
+ return fmt.Errorf("GET %s: %w", rawURL, err)
+ }
+ if err := tmp.Close(); err != nil {
+ return err
+ }
+ if got := hex.EncodeToString(h.Sum(nil)); got != want {
+ return fmt.Errorf("GET %s: sha512 %s, want %s", rawURL, got, want)
+ }
+ if err := os.Chmod(tmp.Name(), 0o644); err != nil {
+ return err
+ }
+ return os.Rename(tmp.Name(), target)
+}
+
+func fileName(rawURL string) (string, error) {
+ u, err := url.Parse(rawURL)
+ if err != nil {
+ return "", err
+ }
+ name := path.Base(u.Path)
+ if name == "" || name == "." || name == "/" {
+ return "", fmt.Errorf("url %q does not name a file", rawURL)
+ }
+ return name, nil
+}
+
+func expectedSum(sums []byte, name string) (string, error) {
+ var found []string
+ sc := bufio.NewScanner(bytes.NewReader(sums))
+ for sc.Scan() {
+ fields := strings.Fields(sc.Text())
+ if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != name {
+ continue
+ }
+ sum := strings.ToLower(fields[0])
+ if !sha512Pattern.MatchString(sum) {
+ return "", fmt.Errorf("entry for %s is not a sha512 sum", name)
+ }
+ found = append(found, sum)
+ }
+ if err := sc.Err(); err != nil {
+ return "", err
+ }
+ switch {
+ case len(found) == 0:
+ return "", fmt.Errorf("no sum for %s", name)
+ case len(found) > 1:
+ return "", fmt.Errorf("%d sums for %s", len(found), name)
+ }
+ return found[0], nil
+}
+
+func fileSum(p string) (string, error) {
+ file, err := os.Open(p)
+ if err != nil {
+ return "", err
+ }
+ defer file.Close()
+ h := sha512.New()
+ if _, err := io.Copy(h, file); err != nil {
+ return "", err
+ }
+ return hex.EncodeToString(h.Sum(nil)), nil
+}
diff --git a/internal/lab/provision/image_test.go b/internal/lab/provision/image_test.go
new file mode 100644
index 0000000..cd1ab20
--- /dev/null
+++ b/internal/lab/provision/image_test.go
@@ -0,0 +1,334 @@
+package provision
+
+import (
+ "context"
+ "crypto/sha512"
+ "encoding/hex"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "git.g3e.fr/syonad/two/internal/lab/topology"
+)
+
+const imageName = "debian-12-generic-amd64.qcow2"
+
+type mirror struct {
+ mu sync.Mutex
+ files map[string][]byte
+ status map[string]int
+ hits map[string]int
+ server *httptest.Server
+}
+
+func newMirror(t *testing.T) *mirror {
+ t.Helper()
+ m := &mirror{files: map[string][]byte{}, status: map[string]int{}, hits: map[string]int{}}
+ m.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.hits[r.URL.Path]++
+ if code := m.status[r.URL.Path]; code != 0 {
+ w.WriteHeader(code)
+ return
+ }
+ data, ok := m.files[r.URL.Path]
+ if !ok {
+ http.NotFound(w, r)
+ return
+ }
+ w.Write(data)
+ }))
+ t.Cleanup(m.server.Close)
+ return m
+}
+
+func (m *mirror) put(path string, data []byte) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ m.files[path] = data
+}
+
+func (m *mirror) count(path string) int {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+ return m.hits[path]
+}
+
+func (m *mirror) image() topology.Image {
+ return topology.Image{
+ Name: "debian12",
+ URL: m.server.URL + "/bookworm/latest/" + imageName,
+ Sums: m.server.URL + "/bookworm/latest/SHA512SUMS",
+ }
+}
+
+func sum(data []byte) string {
+ h := sha512.Sum512(data)
+ return hex.EncodeToString(h[:])
+}
+
+func publish(m *mirror, content []byte) {
+ m.put("/bookworm/latest/"+imageName, content)
+ m.put("/bookworm/latest/SHA512SUMS", []byte(
+ sum([]byte("other"))+" debian-12-genericcloud-amd64.qcow2\n"+
+ sum(content)+" "+imageName+"\n"+
+ sum([]byte("raw"))+" debian-12-generic-amd64.raw\n"))
+}
+
+func fetch(t *testing.T, m *mirror, cache string) string {
+ t.Helper()
+ got, err := Fetcher{Client: m.server.Client(), CacheDir: cache}.Image(context.Background(), m.image())
+ if err != nil {
+ t.Fatalf("Image: %v", err)
+ }
+ return got
+}
+
+func fetchError(t *testing.T, m *mirror, cache string) string {
+ t.Helper()
+ _, err := Fetcher{Client: m.server.Client(), CacheDir: cache}.Image(context.Background(), m.image())
+ if err == nil {
+ t.Fatal("Image: no error")
+ }
+ return err.Error()
+}
+
+func entries(t *testing.T, dir string) []string {
+ t.Helper()
+ list, err := os.ReadDir(dir)
+ if err != nil && !os.IsNotExist(err) {
+ t.Fatal(err)
+ }
+ var names []string
+ for _, e := range list {
+ names = append(names, e.Name())
+ }
+ return names
+}
+
+func TestImage_DownloadsAndVerifies(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ cache := t.TempDir()
+
+ got := fetch(t, m, cache)
+
+ if want := filepath.Join(cache, "debian12", "debian-12-generic-amd64.qcow2"); got != want {
+ t.Errorf("path = %s, want %s", got, want)
+ }
+ data, err := os.ReadFile(got)
+ if err != nil || string(data) != "qcow2 image" {
+ t.Errorf("content = %q, %v", data, err)
+ }
+ info, err := os.Stat(got)
+ if err != nil || info.Mode().Perm() != 0o644 {
+ t.Errorf("mode = %v, %v", info.Mode().Perm(), err)
+ }
+ if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 1 {
+ t.Errorf("cache dir holds %v, want only the image", names)
+ }
+}
+
+func TestImage_ReusesAVerifiedCacheButAlwaysRereadsTheSums(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ cache := t.TempDir()
+
+ fetch(t, m, cache)
+ fetch(t, m, cache)
+
+ if n := m.count("/bookworm/latest/" + imageName); n != 1 {
+ t.Errorf("image downloaded %d times, want 1", n)
+ }
+ if n := m.count("/bookworm/latest/SHA512SUMS"); n != 2 {
+ t.Errorf("sums read %d times, want 2", n)
+ }
+}
+
+func TestImage_ReplacesACorruptedCache(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ cache := t.TempDir()
+ path := fetch(t, m, cache)
+ if err := os.WriteFile(path, []byte("bit rot"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ fetch(t, m, cache)
+
+ if data, _ := os.ReadFile(path); string(data) != "qcow2 image" {
+ t.Errorf("content = %q, want the published image", data)
+ }
+ if n := m.count("/bookworm/latest/" + imageName); n != 2 {
+ t.Errorf("image downloaded %d times, want 2", n)
+ }
+}
+
+func TestImage_FollowsANewReleaseOfTheSameFile(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("release 1"))
+ cache := t.TempDir()
+ path := fetch(t, m, cache)
+
+ publish(m, []byte("release 2"))
+ fetch(t, m, cache)
+
+ if data, _ := os.ReadFile(path); string(data) != "release 2" {
+ t.Errorf("content = %q, want release 2", data)
+ }
+}
+
+func TestImage_RejectsAMismatchAndLeavesNothing(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ m.put("/bookworm/latest/"+imageName, []byte("tampered"))
+ cache := t.TempDir()
+
+ msg := fetchError(t, m, cache)
+
+ if !strings.Contains(msg, "sha512") {
+ t.Errorf("error = %q, want a sha512 mismatch", msg)
+ }
+ if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 {
+ t.Errorf("cache dir holds %v after a mismatch, want nothing", names)
+ }
+}
+
+func TestImage_KeepsTheVerifiedCacheWhenANewDownloadFails(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("release 1"))
+ cache := t.TempDir()
+ path := fetch(t, m, cache)
+
+ publish(m, []byte("release 2"))
+ m.put("/bookworm/latest/"+imageName, []byte("truncated"))
+ fetchError(t, m, cache)
+
+ if data, _ := os.ReadFile(path); string(data) != "release 1" {
+ t.Errorf("content = %q, want release 1 kept", data)
+ }
+ if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 1 {
+ t.Errorf("cache dir holds %v, want only the image", names)
+ }
+}
+
+func TestImage_AcceptsBinaryModeSumLines(t *testing.T) {
+ m := newMirror(t)
+ content := []byte("qcow2 image")
+ m.put("/bookworm/latest/"+imageName, content)
+ m.put("/bookworm/latest/SHA512SUMS", []byte(strings.ToUpper(sum(content))+" *"+imageName+"\n"))
+
+ fetch(t, m, t.TempDir())
+}
+
+func TestImage_SumsRejections(t *testing.T) {
+ content := []byte("qcow2 image")
+ cases := map[string]struct {
+ sums string
+ want string
+ }{
+ "no entry": {sum(content) + " debian-12-generic-arm64.qcow2\n", "no sum for " + imageName},
+ "two entries": {sum(content) + " " + imageName + "\n" + sum(content) + " " + imageName + "\n", "2 sums for " + imageName},
+ "not sha512": {"d41d8cd98f00b204e9800998ecf8427e " + imageName + "\n", "is not a sha512 sum"},
+ "prefix of name": {sum(content) + " " + imageName + ".sig\n", "no sum for " + imageName},
+ "name in directory": {sum(content) + " nested/" + imageName + "\n", "no sum for " + imageName},
+ }
+ for name, c := range cases {
+ t.Run(name, func(t *testing.T) {
+ m := newMirror(t)
+ m.put("/bookworm/latest/"+imageName, content)
+ m.put("/bookworm/latest/SHA512SUMS", []byte(c.sums))
+ if msg := fetchError(t, m, t.TempDir()); !strings.Contains(msg, c.want) {
+ t.Errorf("error = %q, want %q", msg, c.want)
+ }
+ if n := m.count("/bookworm/latest/" + imageName); n != 0 {
+ t.Errorf("image downloaded %d times before the sums were trusted", n)
+ }
+ })
+ }
+}
+
+func TestImage_RefusesOversizedSums(t *testing.T) {
+ m := newMirror(t)
+ m.put("/bookworm/latest/SHA512SUMS", make([]byte, 1<<20+1))
+ if msg := fetchError(t, m, t.TempDir()); !strings.Contains(msg, "larger than 1048576 bytes") {
+ t.Errorf("error = %q", msg)
+ }
+}
+
+func TestImage_AcceptsSumsOfExactlyTheLimit(t *testing.T) {
+ m := newMirror(t)
+ content := []byte("qcow2 image")
+ line := sum(content) + " " + imageName + "\n"
+ m.put("/bookworm/latest/"+imageName, content)
+ m.put("/bookworm/latest/SHA512SUMS", []byte(strings.Repeat("\n", 1<<20-len(line))+line))
+
+ fetch(t, m, t.TempDir())
+}
+
+func TestImage_HTTPErrors(t *testing.T) {
+ for _, path := range []string{"/bookworm/latest/SHA512SUMS", "/bookworm/latest/" + imageName} {
+ t.Run(path, func(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ m.status[path] = http.StatusServiceUnavailable
+ cache := t.TempDir()
+ if msg := fetchError(t, m, cache); !strings.Contains(msg, "503") {
+ t.Errorf("error = %q, want the http status", msg)
+ }
+ if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 {
+ t.Errorf("cache dir holds %v, want nothing", names)
+ }
+ })
+ }
+}
+
+func TestImage_RefusesARelativeCacheDir(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ if msg := fetchError(t, m, "cache"); !strings.Contains(msg, "must be an absolute path") {
+ t.Errorf("error = %q", msg)
+ }
+ if n := m.count("/bookworm/latest/SHA512SUMS"); n != 0 {
+ t.Errorf("sums read %d times", n)
+ }
+}
+
+func TestImage_RefusesAURLWithoutFileName(t *testing.T) {
+ m := newMirror(t)
+ img := m.image()
+ img.URL = m.server.URL + "/"
+ _, err := Fetcher{Client: m.server.Client(), CacheDir: t.TempDir()}.Image(context.Background(), img)
+ if err == nil || !strings.Contains(err.Error(), "does not name a file") {
+ t.Errorf("error = %v", err)
+ }
+}
+
+func TestImage_RejectsAnInterruptedDownloadAndLeavesNothing(t *testing.T) {
+ content := []byte("qcow2 image")
+ server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasSuffix(r.URL.Path, "SHA512SUMS") {
+ w.Write([]byte(sum(content) + " " + imageName + "\n"))
+ return
+ }
+ w.Header().Set("Content-Length", "1000")
+ w.Write(content[:4])
+ }))
+ defer server.Close()
+ cache := t.TempDir()
+ img := topology.Image{Name: "debian12", URL: server.URL + "/" + imageName, Sums: server.URL + "/SHA512SUMS"}
+
+ _, err := Fetcher{Client: server.Client(), CacheDir: cache}.Image(context.Background(), img)
+
+ if err == nil || !strings.Contains(err.Error(), "unexpected EOF") {
+ t.Errorf("error = %v, want an interrupted transfer", err)
+ }
+ if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 {
+ t.Errorf("cache dir holds %v, want nothing", names)
+ }
+}
diff --git a/internal/lab/provision/provision.go b/internal/lab/provision/provision.go
new file mode 100644
index 0000000..b83751a
--- /dev/null
+++ b/internal/lab/provision/provision.go
@@ -0,0 +1,182 @@
+package provision
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+
+ "git.g3e.fr/syonad/two/internal/lab/render"
+ "git.g3e.fr/syonad/two/internal/lab/topology"
+)
+
+const (
+ KeyFile = "lab_ed25519"
+ KeyLabel = "two-lab"
+ DiskSize = "20G"
+ SeedLabel = "cidata"
+
+ ArgsFile = "qemu.args"
+)
+
+var SeedFiles = []string{"user-data", "meta-data", "network-config"}
+
+type Runner interface {
+ Run(ctx context.Context, name string, args ...string) error
+}
+
+type ExecRunner struct{}
+
+func (ExecRunner) Run(ctx context.Context, name string, args ...string) error {
+ out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
+ if err != nil {
+ return fmt.Errorf("%s: %w: %s", name, err, strings.TrimSpace(string(out)))
+ }
+ return nil
+}
+
+type Options struct {
+ RunDir string
+ Fetcher Fetcher
+ Runner Runner
+}
+
+func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, error) {
+ if !filepath.IsAbs(o.RunDir) {
+ return nil, fmt.Errorf("run dir %q must be an absolute path", o.RunDir)
+ }
+ if err := os.MkdirAll(o.RunDir, 0o700); err != nil {
+ return nil, err
+ }
+
+ images := map[string]string{}
+ for _, n := range p.Nodes {
+ if _, ok := images[n.Image]; ok {
+ continue
+ }
+ img, ok := imageNamed(p, n.Image)
+ if !ok {
+ return nil, fmt.Errorf("node %s: image %q is not declared", n.Name, n.Image)
+ }
+ base, err := o.Fetcher.Image(ctx, img)
+ if err != nil {
+ return nil, err
+ }
+ images[n.Image] = base
+ }
+
+ key, err := EnsureKey(ctx, o.Runner, o.RunDir)
+ if err != nil {
+ return nil, err
+ }
+ frr, err := ReadFRR(p)
+ if err != nil {
+ return nil, err
+ }
+ agent, err := ReadAgent(p)
+ if err != nil {
+ return nil, err
+ }
+ nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}, FRR: frr, Agent: agent})
+ if err != nil {
+ return nil, err
+ }
+ for i, n := range nodes {
+ if err := Stage(ctx, o.Runner, n, images[p.Nodes[i].Image]); err != nil {
+ return nil, fmt.Errorf("node %s: %w", n.Name, err)
+ }
+ }
+ return nodes, nil
+}
+
+func ReadFRR(p *topology.Plan) (map[string]string, error) {
+ return readNodeFiles(p, func(n topology.NodePlan) string { return n.FRR })
+}
+
+func ReadAgent(p *topology.Plan) (map[string]string, error) {
+ return readNodeFiles(p, func(n topology.NodePlan) string { return n.Agent })
+}
+
+func readNodeFiles(p *topology.Plan, path func(topology.NodePlan) string) (map[string]string, error) {
+ files := map[string]string{}
+ for _, n := range p.Nodes {
+ if path(n) == "" {
+ continue
+ }
+ data, err := os.ReadFile(path(n))
+ if err != nil {
+ return nil, fmt.Errorf("node %s: %w", n.Name, err)
+ }
+ files[n.Name] = string(data)
+ }
+ return files, nil
+}
+
+func EnsureKey(ctx context.Context, r Runner, dir string) (string, error) {
+ private := filepath.Join(dir, KeyFile)
+ if _, err := os.Stat(private); errors.Is(err, os.ErrNotExist) {
+ if err := r.Run(ctx, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", KeyLabel, "-f", private); err != nil {
+ return "", err
+ }
+ } else if err != nil {
+ return "", err
+ }
+ data, err := os.ReadFile(private + ".pub")
+ if err != nil {
+ return "", err
+ }
+ key := strings.TrimSpace(string(data))
+ if key == "" || strings.ContainsAny(key, "\r\n") {
+ return "", fmt.Errorf("%s.pub: not a single public key", private)
+ }
+ return key, nil
+}
+
+func WriteFiles(n render.Node) error {
+ if err := os.MkdirAll(n.Dir, 0o700); err != nil {
+ return err
+ }
+ files := map[string][]byte{
+ ArgsFile: []byte(strings.Join(n.QEMU, "\n") + "\n"),
+ "meta-data": n.MetaData,
+ "user-data": n.UserData,
+ "network-config": n.NetworkConfig,
+ }
+ for name, content := range files {
+ if err := os.WriteFile(filepath.Join(n.Dir, name), content, 0o600); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func Stage(ctx context.Context, r Runner, n render.Node, base string) error {
+ if !filepath.IsAbs(base) {
+ return fmt.Errorf("base image %q must be an absolute path", base)
+ }
+ if err := WriteFiles(n); err != nil {
+ return err
+ }
+ disk := filepath.Join(n.Dir, render.DiskFile)
+ seed := filepath.Join(n.Dir, render.SeedFile)
+ if err := r.Run(ctx, "qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", base, disk, DiskSize); err != nil {
+ return err
+ }
+ args := []string{"-quiet", "-output", seed, "-volid", SeedLabel, "-joliet", "-rock"}
+ for _, f := range SeedFiles {
+ args = append(args, filepath.Join(n.Dir, f))
+ }
+ return r.Run(ctx, "genisoimage", args...)
+}
+
+func imageNamed(p *topology.Plan, name string) (topology.Image, bool) {
+ for _, i := range p.Images {
+ if i.Name == name {
+ return i, true
+ }
+ }
+ return topology.Image{}, false
+}
diff --git a/internal/lab/provision/provision_test.go b/internal/lab/provision/provision_test.go
new file mode 100644
index 0000000..403d3cc
--- /dev/null
+++ b/internal/lab/provision/provision_test.go
@@ -0,0 +1,407 @@
+package provision
+
+import (
+ "context"
+ "errors"
+ "os"
+ "path/filepath"
+ "reflect"
+ "strings"
+ "testing"
+
+ "go.yaml.in/yaml/v3"
+
+ "git.g3e.fr/syonad/two/internal/lab/render"
+ "git.g3e.fr/syonad/two/internal/lab/topology"
+)
+
+const generatedKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab"
+
+type fakeRunner struct {
+ calls [][]string
+ fail string
+}
+
+func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error {
+ f.calls = append(f.calls, append([]string{name}, args...))
+ if name == f.fail {
+ return errors.New(name + " failed")
+ }
+ if name == "ssh-keygen" {
+ private := args[len(args)-1]
+ if err := os.WriteFile(private, []byte("private"), 0o600); err != nil {
+ return err
+ }
+ return os.WriteFile(private+".pub", []byte(generatedKey+"\n"), 0o644)
+ }
+ return nil
+}
+
+func (f *fakeRunner) commands(name string) [][]string {
+ var out [][]string
+ for _, c := range f.calls {
+ if c[0] == name {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+func labPlan(t *testing.T, m *mirror) *topology.Plan {
+ t.Helper()
+ img := m.image()
+ doc := `name: evpn-2hv
+images:
+ debian12:
+ url: ` + img.URL + `
+ sums: ` + img.Sums + `
+segments:
+ underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 }
+nodes:
+ sw1: { role: switch, image: debian12, cpus: 2, memory: 1024 }
+ rr1: { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay] }
+ hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
+ hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
+`
+ topo, err := topology.Parse([]byte(doc))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ p, err := topology.Compute(topo)
+ if err != nil {
+ t.Fatalf("Compute: %v", err)
+ }
+ return p
+}
+
+func prepare(t *testing.T, runner *fakeRunner) (string, string, *mirror, []render.Node, error) {
+ t.Helper()
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ root := t.TempDir()
+ run := filepath.Join(root, "run")
+ cache := filepath.Join(root, "cache")
+ nodes, err := Prepare(context.Background(), labPlan(t, m), Options{
+ RunDir: run,
+ Fetcher: Fetcher{Client: m.server.Client(), CacheDir: cache},
+ Runner: runner,
+ })
+ return run, cache, m, nodes, err
+}
+
+func TestPrepare_StagesEveryNode(t *testing.T) {
+ runner := &fakeRunner{}
+ run, cache, _, nodes, err := prepare(t, runner)
+ if err != nil {
+ t.Fatalf("Prepare: %v", err)
+ }
+
+ base := filepath.Join(cache, "debian12", "debian-12-generic-amd64.qcow2")
+ want := [][]string{
+ {"ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "two-lab", "-f", filepath.Join(run, "lab_ed25519")},
+ }
+ for _, n := range []string{"sw1", "rr1", "hv1", "hv2"} {
+ dir := filepath.Join(run, n)
+ want = append(want,
+ []string{"qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", base, filepath.Join(dir, "disk.qcow2"), "20G"},
+ []string{"genisoimage", "-quiet", "-output", filepath.Join(dir, "seed.iso"), "-volid", "cidata", "-joliet", "-rock",
+ filepath.Join(dir, "user-data"), filepath.Join(dir, "meta-data"), filepath.Join(dir, "network-config")},
+ )
+ }
+ if !reflect.DeepEqual(runner.calls, want) {
+ t.Errorf("commands:\n got %q\nwant %q", runner.calls, want)
+ }
+
+ var names []string
+ for _, n := range nodes {
+ names = append(names, n.Name)
+ }
+ if !reflect.DeepEqual(names, []string{"sw1", "rr1", "hv1", "hv2"}) {
+ t.Errorf("nodes = %v", names)
+ }
+ if info, err := os.Stat(run); err != nil || info.Mode().Perm() != 0o700 {
+ t.Errorf("run dir mode = %v, %v", info.Mode().Perm(), err)
+ }
+}
+
+func TestPrepare_DownloadsASharedImageOnce(t *testing.T) {
+ _, _, m, _, err := prepare(t, &fakeRunner{})
+ if err != nil {
+ t.Fatalf("Prepare: %v", err)
+ }
+ if n := m.count("/bookworm/latest/" + imageName); n != 1 {
+ t.Errorf("image downloaded %d times for four nodes, want 1", n)
+ }
+ if n := m.count("/bookworm/latest/SHA512SUMS"); n != 1 {
+ t.Errorf("sums read %d times for four nodes, want 1", n)
+ }
+}
+
+func TestPrepare_TheGeneratedKeyIsTheOnlyAuthorizedKey(t *testing.T) {
+ _, _, _, nodes, err := prepare(t, &fakeRunner{})
+ if err != nil {
+ t.Fatalf("Prepare: %v", err)
+ }
+ for _, n := range nodes {
+ data, err := os.ReadFile(filepath.Join(n.Dir, "user-data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var cfg struct {
+ Keys []string `yaml:"ssh_authorized_keys"`
+ }
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
+ t.Fatalf("%s user-data: %v", n.Name, err)
+ }
+ if !reflect.DeepEqual(cfg.Keys, []string{generatedKey}) {
+ t.Errorf("%s authorized keys = %q, want only the generated key", n.Name, cfg.Keys)
+ }
+ }
+}
+
+func TestPrepare_WritesTheRenderedFilesPrivately(t *testing.T) {
+ run, _, _, nodes, err := prepare(t, &fakeRunner{})
+ if err != nil {
+ t.Fatalf("Prepare: %v", err)
+ }
+ hv1 := filepath.Join(run, "hv1")
+ if nodes[2].Dir != hv1 {
+ t.Fatalf("hv1 dir = %s, want %s", nodes[2].Dir, hv1)
+ }
+ for _, f := range []string{"qemu.args", "user-data", "meta-data", "network-config"} {
+ info, err := os.Stat(filepath.Join(hv1, f))
+ if err != nil || info.Mode().Perm() != 0o600 {
+ t.Errorf("%s mode = %v, %v", f, info.Mode().Perm(), err)
+ }
+ }
+ args, err := os.ReadFile(filepath.Join(hv1, "qemu.args"))
+ if err != nil || !strings.HasPrefix(string(args), "-name\nhv1\n") || !strings.HasSuffix(string(args), "\n") {
+ t.Errorf("qemu.args = %q, %v", args, err)
+ }
+}
+
+func TestPrepare_StopsAtTheFirstFailedCommand(t *testing.T) {
+ runner := &fakeRunner{fail: "qemu-img"}
+ _, _, _, _, err := prepare(t, runner)
+ if err == nil || !strings.Contains(err.Error(), "node sw1: qemu-img failed") {
+ t.Errorf("error = %v", err)
+ }
+ if n := len(runner.commands("genisoimage")); n != 0 {
+ t.Errorf("genisoimage ran %d times after qemu-img failed", n)
+ }
+}
+
+func TestPrepare_StopsBeforeAnyCommandWhenTheImageIsWrong(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ m.put("/bookworm/latest/"+imageName, []byte("tampered"))
+ runner := &fakeRunner{}
+ root := t.TempDir()
+ _, err := Prepare(context.Background(), labPlan(t, m), Options{
+ RunDir: filepath.Join(root, "run"),
+ Fetcher: Fetcher{Client: m.server.Client(), CacheDir: filepath.Join(root, "cache")},
+ Runner: runner,
+ })
+ if err == nil || !strings.Contains(err.Error(), "sha512") {
+ t.Errorf("error = %v", err)
+ }
+ if len(runner.calls) != 0 {
+ t.Errorf("commands ran after a bad image: %q", runner.calls)
+ }
+}
+
+func TestPrepare_RefusesARelativeRunDir(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ runner := &fakeRunner{}
+ _, err := Prepare(context.Background(), labPlan(t, m), Options{
+ RunDir: "run",
+ Fetcher: Fetcher{Client: m.server.Client(), CacheDir: t.TempDir()},
+ Runner: runner,
+ })
+ if err == nil || !strings.Contains(err.Error(), `run dir "run" must be an absolute path`) {
+ t.Errorf("error = %v", err)
+ }
+ if n := m.count("/bookworm/latest/SHA512SUMS"); n != 0 {
+ t.Errorf("sums read %d times before the run dir was checked", n)
+ }
+}
+
+func TestPrepare_RefusesAnUndeclaredImage(t *testing.T) {
+ m := newMirror(t)
+ p := labPlan(t, m)
+ p.Images = nil
+ _, err := Prepare(context.Background(), p, Options{RunDir: t.TempDir(), Runner: &fakeRunner{}})
+ if err == nil || !strings.Contains(err.Error(), `node sw1: image "debian12" is not declared`) {
+ t.Errorf("error = %v", err)
+ }
+}
+
+func TestEnsureKey_GeneratesOnlyOnce(t *testing.T) {
+ dir := t.TempDir()
+ runner := &fakeRunner{}
+
+ for i := 0; i < 2; i++ {
+ key, err := EnsureKey(context.Background(), runner, dir)
+ if err != nil || key != generatedKey {
+ t.Fatalf("EnsureKey #%d = %q, %v", i+1, key, err)
+ }
+ }
+ if n := len(runner.commands("ssh-keygen")); n != 1 {
+ t.Errorf("ssh-keygen ran %d times, want 1", n)
+ }
+}
+
+func TestEnsureKey_Rejections(t *testing.T) {
+ cases := map[string]string{
+ "empty": "\n",
+ "two keys": generatedKey + "\n" + generatedKey + "\n",
+ "stray break": "ssh-ed25519\rAAAA",
+ }
+ for name, pub := range cases {
+ t.Run(name, func(t *testing.T) {
+ dir := t.TempDir()
+ if err := os.WriteFile(filepath.Join(dir, "lab_ed25519"), []byte("private"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(dir, "lab_ed25519.pub"), []byte(pub), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := EnsureKey(context.Background(), &fakeRunner{}, dir); err == nil || !strings.Contains(err.Error(), "not a single public key") {
+ t.Errorf("error = %v", err)
+ }
+ })
+ }
+}
+
+func TestEnsureKey_ReportsAFailedGeneration(t *testing.T) {
+ if _, err := EnsureKey(context.Background(), &fakeRunner{fail: "ssh-keygen"}, t.TempDir()); err == nil || !strings.Contains(err.Error(), "ssh-keygen failed") {
+ t.Errorf("error = %v", err)
+ }
+}
+
+func TestStage_RefusesARelativeBaseImage(t *testing.T) {
+ runner := &fakeRunner{}
+ err := Stage(context.Background(), runner, render.Node{Name: "hv1", Dir: t.TempDir()}, "debian.qcow2")
+ if err == nil || !strings.Contains(err.Error(), "must be an absolute path") {
+ t.Errorf("error = %v", err)
+ }
+ if len(runner.calls) != 0 {
+ t.Errorf("commands ran: %q", runner.calls)
+ }
+}
+
+func TestExecRunner_ReportsTheCommandOutput(t *testing.T) {
+ err := ExecRunner{}.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 3")
+ if err == nil || !strings.Contains(err.Error(), "sh: exit status 3: boom") {
+ t.Errorf("error = %v", err)
+ }
+ if err := (ExecRunner{}).Run(context.Background(), "true"); err != nil {
+ t.Errorf("true: %v", err)
+ }
+}
+
+func TestEnsureKey_WithTheRealSSHKeygen(t *testing.T) {
+ dir := t.TempDir()
+ key, err := EnsureKey(context.Background(), ExecRunner{}, dir)
+ if err != nil {
+ t.Fatalf("EnsureKey: %v", err)
+ }
+ if !strings.HasPrefix(key, "ssh-ed25519 ") || !strings.HasSuffix(key, " two-lab") {
+ t.Errorf("key = %q", key)
+ }
+ info, err := os.Stat(filepath.Join(dir, "lab_ed25519"))
+ if err != nil || info.Mode().Perm() != 0o600 {
+ t.Errorf("private key mode = %v, %v", info.Mode().Perm(), err)
+ }
+}
+
+func TestReadFRR_ReadsOnlyTheNodesThatDeclareOne(t *testing.T) {
+ dir := t.TempDir()
+ conf := filepath.Join(dir, "rr1.conf")
+ if err := os.WriteFile(conf, []byte("hostname rr1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ m := newMirror(t)
+ p := labPlan(t, m)
+ p.Nodes[1].FRR = conf
+
+ got, err := ReadFRR(p)
+
+ if err != nil || !reflect.DeepEqual(got, map[string]string{"rr1": "hostname rr1\n"}) {
+ t.Errorf("ReadFRR = %q, %v", got, err)
+ }
+}
+
+func TestReadFRR_NamesTheNodeOfAMissingFile(t *testing.T) {
+ m := newMirror(t)
+ p := labPlan(t, m)
+ p.Nodes[2].FRR = filepath.Join(t.TempDir(), "absent.conf")
+
+ if _, err := ReadFRR(p); err == nil || !strings.Contains(err.Error(), "node hv1: ") || !strings.Contains(err.Error(), "absent.conf") {
+ t.Errorf("error = %v", err)
+ }
+}
+
+func TestPrepare_PutsTheFRRConfigIntoTheSeed(t *testing.T) {
+ m := newMirror(t)
+ publish(m, []byte("qcow2 image"))
+ root := t.TempDir()
+ conf := filepath.Join(root, "hv1.conf")
+ if err := os.WriteFile(conf, []byte("hostname hv1\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ p := labPlan(t, m)
+ p.Nodes[2].FRR = conf
+
+ nodes, err := Prepare(context.Background(), p, Options{
+ RunDir: filepath.Join(root, "run"),
+ Fetcher: Fetcher{Client: m.server.Client(), CacheDir: filepath.Join(root, "cache")},
+ Runner: &fakeRunner{},
+ })
+ if err != nil {
+ t.Fatalf("Prepare: %v", err)
+ }
+ data, err := os.ReadFile(filepath.Join(nodes[2].Dir, "user-data"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ var cfg struct {
+ Files []struct {
+ Path string `yaml:"path"`
+ Content string `yaml:"content"`
+ } `yaml:"write_files"`
+ }
+ if err := yaml.Unmarshal(data, &cfg); err != nil {
+ t.Fatal(err)
+ }
+ for _, f := range cfg.Files {
+ if f.Path == "/etc/lab/frr.conf" {
+ if f.Content != "hostname hv1\n" {
+ t.Errorf("frr.conf = %q", f.Content)
+ }
+ return
+ }
+ }
+ t.Errorf("hv1 user-data has no /etc/lab/frr.conf:\n%s", data)
+}
+
+func TestReadAgent_ReadsOnlyTheNodesThatDeclareOne(t *testing.T) {
+ conf := filepath.Join(t.TempDir(), "two.yml")
+ if err := os.WriteFile(conf, []byte("dhcp:\n backend: two\n"), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ m := newMirror(t)
+ p := labPlan(t, m)
+ p.Nodes[2].Agent = conf
+
+ got, err := ReadAgent(p)
+
+ if err != nil || !reflect.DeepEqual(got, map[string]string{"hv1": "dhcp:\n backend: two\n"}) {
+ t.Errorf("ReadAgent = %q, %v", got, err)
+ }
+ p.Nodes[3].Agent = filepath.Join(t.TempDir(), "absent.yml")
+ if _, err := ReadAgent(p); err == nil || !strings.Contains(err.Error(), "node hv2: ") {
+ t.Errorf("error = %v", err)
+ }
+}
diff --git a/internal/lab/render/frrouting.gpg b/internal/lab/render/frrouting.gpg
new file mode 100644
index 0000000..c38f82b
Binary files /dev/null and b/internal/lab/render/frrouting.gpg differ
diff --git a/internal/lab/render/qemu.go b/internal/lab/render/qemu.go
new file mode 100644
index 0000000..9a870d2
--- /dev/null
+++ b/internal/lab/render/qemu.go
@@ -0,0 +1,54 @@
+package render
+
+import (
+ "fmt"
+ "path/filepath"
+ "strconv"
+
+ "git.g3e.fr/syonad/two/internal/lab/topology"
+)
+
+const loopback = "127.0.0.1"
+
+func qemuArgs(p *topology.Plan, n topology.NodePlan, index int, dir string) []string {
+ args := []string{
+ "-name", n.Name,
+ "-machine", "q35",
+ "-accel", "kvm",
+ "-cpu", "host",
+ "-smp", strconv.Itoa(n.CPUs),
+ "-m", strconv.Itoa(n.Memory),
+ "-nodefaults",
+ "-display", "none",
+ "-serial", "file:" + filepath.Join(dir, ConsoleFile),
+ "-qmp", "unix:" + filepath.Join(dir, QMPFile) + ",server=on,wait=off",
+ "-pidfile", filepath.Join(dir, PIDFile),
+ "-drive", "file=" + filepath.Join(dir, DiskFile) + ",if=virtio,format=qcow2",
+ "-drive", "file=" + filepath.Join(dir, SeedFile) + ",media=cdrom,readonly=on",
+ }
+
+ restrict := "on"
+ if n.Role == topology.RoleSwitch {
+ restrict = "off"
+ }
+ args = append(args,
+ "-netdev", fmt.Sprintf("user,id=%s,restrict=%s,ipv6=off,hostfwd=tcp:%s:%d-:22", AdminInterface, restrict, loopback, n.SSHPort),
+ "-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,romfile=", AdminInterface, adminMAC(index)),
+ )
+
+ for _, c := range nodeCables(p, n.Name) {
+ args = append(args, cable(c.NodeInterface, c.NodePort, c.SwitchPort, c.NodeMAC.String(), c.MTU)...)
+ }
+ for _, c := range switchCables(p, n.Name) {
+ args = append(args, cable(c.SwitchInterface, c.SwitchPort, c.NodePort, c.SwitchMAC.String(), c.MTU)...)
+ }
+ return args
+}
+
+func cable(id string, local, remote int, mac string, mtu int) []string {
+ return []string{
+ "-netdev", fmt.Sprintf("dgram,id=%s,local.type=inet,local.host=%s,local.port=%d,remote.type=inet,remote.host=%s,remote.port=%d",
+ id, loopback, local, loopback, remote),
+ "-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,host_mtu=%d,romfile=", id, mac, mtu),
+ }
+}
diff --git a/internal/lab/render/render.go b/internal/lab/render/render.go
new file mode 100644
index 0000000..be95453
--- /dev/null
+++ b/internal/lab/render/render.go
@@ -0,0 +1,142 @@
+package render
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "path/filepath"
+ "strings"
+
+ "git.g3e.fr/syonad/two/internal/lab/topology"
+)
+
+const (
+ QEMUBinary = "qemu-system-x86_64"
+
+ AdminInterface = topology.ReservedInterface
+ AdminAddress = "10.0.2.15/24"
+ AdminGateway = "10.0.2.2"
+ AdminDNS = "10.0.2.3"
+
+ DiskFile = "disk.qcow2"
+ SeedFile = "seed.iso"
+ ConsoleFile = "console.log"
+ QMPFile = "qmp.sock"
+ PIDFile = "qemu.pid"
+)
+
+var Nameservers = []string{"1.1.1.1", "8.8.8.8"}
+
+type Options struct {
+ RunDir string
+ AuthorizedKeys []string
+ FRR map[string]string
+ Agent map[string]string
+}
+
+type Node struct {
+ Name string
+ Dir string
+ QEMU []string
+ MetaData []byte
+ UserData []byte
+ NetworkConfig []byte
+}
+
+func Render(p *topology.Plan, o Options) ([]Node, error) {
+ if err := o.validate(); err != nil {
+ return nil, err
+ }
+ for _, n := range p.Nodes {
+ if _, ok := o.FRR[n.Name]; n.FRR != "" && !ok {
+ return nil, fmt.Errorf("node %s: frr configuration %s was not read", n.Name, n.FRR)
+ }
+ if _, ok := o.Agent[n.Name]; n.Agent != "" && !ok {
+ return nil, fmt.Errorf("node %s: agent configuration %s was not read", n.Name, n.Agent)
+ }
+ }
+ var nodes []Node
+ for index, n := range p.Nodes {
+ dir := filepath.Join(o.RunDir, n.Name)
+ meta, err := metaData(p, n)
+ if err != nil {
+ return nil, err
+ }
+ user, err := userData(p, n, o)
+ if err != nil {
+ return nil, err
+ }
+ network, err := networkConfig(p, n, index)
+ if err != nil {
+ return nil, err
+ }
+ nodes = append(nodes, Node{
+ Name: n.Name,
+ Dir: dir,
+ QEMU: qemuArgs(p, n, index, dir),
+ MetaData: meta,
+ UserData: user,
+ NetworkConfig: network,
+ })
+ }
+ return nodes, nil
+}
+
+func (o Options) validate() error {
+ var errs []error
+ if !filepath.IsAbs(o.RunDir) {
+ errs = append(errs, fmt.Errorf("run dir %q must be an absolute path", o.RunDir))
+ }
+ if len(o.AuthorizedKeys) == 0 {
+ errs = append(errs, errors.New("at least one authorized ssh key is required"))
+ }
+ for i, k := range o.AuthorizedKeys {
+ if strings.ContainsAny(k, "\r\n") {
+ errs = append(errs, fmt.Errorf("authorized key %d spans several lines", i+1))
+ continue
+ }
+ fields := strings.Fields(k)
+ if len(fields) < 2 || !validKeyType(fields[0]) {
+ errs = append(errs, fmt.Errorf("authorized key %d is not an ssh public key", i+1))
+ }
+ }
+ return errors.Join(errs...)
+}
+
+func validKeyType(t string) bool {
+ return strings.HasPrefix(t, "ssh-") || strings.HasPrefix(t, "ecdsa-sha2-") || strings.HasPrefix(t, "sk-")
+}
+
+func adminMAC(index int) net.HardwareAddr {
+ return net.HardwareAddr{0x02, 0x4d, byte(index >> 8), byte(index), 0x00, 0x00}
+}
+
+func nodeCables(p *topology.Plan, name string) []topology.Cable {
+ var out []topology.Cable
+ for _, c := range p.Cables {
+ if c.Node == name {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+func switchCables(p *topology.Plan, name string) []topology.Cable {
+ var out []topology.Cable
+ for _, c := range p.Cables {
+ if c.Switch == name {
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+func switchSegments(p *topology.Plan, name string) []topology.SegmentPlan {
+ var out []topology.SegmentPlan
+ for _, s := range p.Segments {
+ if s.Switch == name {
+ out = append(out, s)
+ }
+ }
+ return out
+}
diff --git a/internal/lab/render/render_test.go b/internal/lab/render/render_test.go
new file mode 100644
index 0000000..78bb186
--- /dev/null
+++ b/internal/lab/render/render_test.go
@@ -0,0 +1,480 @@
+package render
+
+import (
+ "reflect"
+ "strings"
+ "testing"
+
+ "go.yaml.in/yaml/v3"
+
+ "git.g3e.fr/syonad/two/internal/lab/topology"
+)
+
+const labKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation"
+
+const twoHypervisors = `name: evpn-2hv
+images:
+ deb:
+ url: https://example.invalid/deb.qcow2
+ sums: https://example.invalid/SHA512SUMS
+segments:
+ underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 }
+nodes:
+ sw1: { role: switch, image: deb, cpus: 2, memory: 1024 }
+ rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] }
+ hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
+ hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
+`
+
+const twoSegments = `name: two-seg
+images:
+ deb:
+ url: https://example.invalid/deb.qcow2
+ sums: https://example.invalid/SHA512SUMS
+segments:
+ red: { switch: sw, cidr: 10.1.0.0/24 }
+ blue: { switch: sw, cidr: 10.2.0.0/24, mtu: 1500 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 }
+`
+
+func plan(t *testing.T, doc string) *topology.Plan {
+ t.Helper()
+ topo, err := topology.Parse([]byte(doc))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ p, err := topology.Compute(topo)
+ if err != nil {
+ t.Fatalf("Compute: %v", err)
+ }
+ return p
+}
+
+func renderAll(t *testing.T, doc string) map[string]Node {
+ t.Helper()
+ nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}})
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ out := map[string]Node{}
+ for _, n := range nodes {
+ out[n.Name] = n
+ }
+ return out
+}
+
+func nodeNamed(t *testing.T, nodes map[string]Node, name string) Node {
+ t.Helper()
+ n, ok := nodes[name]
+ if !ok {
+ t.Fatalf("no rendered node %s", name)
+ }
+ return n
+}
+
+func value(t *testing.T, args []string, flag string) []string {
+ t.Helper()
+ var out []string
+ for i := 0; i+1 < len(args); i++ {
+ if args[i] == flag {
+ out = append(out, args[i+1])
+ }
+ }
+ if len(out) == 0 {
+ t.Fatalf("no %s in %v", flag, args)
+ }
+ return out
+}
+
+func hasValue(args []string, flag, want string) bool {
+ for i := 0; i+1 < len(args); i++ {
+ if args[i] == flag && args[i+1] == want {
+ return true
+ }
+ }
+ return false
+}
+
+func network(t *testing.T, n Node) networkDoc {
+ t.Helper()
+ var doc networkDoc
+ if err := yaml.Unmarshal(n.NetworkConfig, &doc); err != nil {
+ t.Fatalf("network-config of %s is not yaml: %v", n.Name, err)
+ }
+ return doc
+}
+
+func iface(t *testing.T, doc networkDoc, name string) ethernet {
+ t.Helper()
+ e, ok := doc.Ethernets[name]
+ if !ok {
+ t.Fatalf("no interface %s in %v", name, doc.Ethernets)
+ }
+ return e
+}
+
+func user(t *testing.T, n Node) cloudConfig {
+ t.Helper()
+ if !strings.HasPrefix(string(n.UserData), "#cloud-config\n") {
+ t.Fatalf("user-data of %s does not start with #cloud-config", n.Name)
+ }
+ var cfg cloudConfig
+ if err := yaml.Unmarshal(n.UserData, &cfg); err != nil {
+ t.Fatalf("user-data of %s is not yaml: %v", n.Name, err)
+ }
+ return cfg
+}
+
+func fileAt(t *testing.T, cfg cloudConfig, path string) writeFile {
+ t.Helper()
+ for _, f := range cfg.WriteFiles {
+ if f.Path == path {
+ return f
+ }
+ }
+ t.Fatalf("no write_files entry %s", path)
+ return writeFile{}
+}
+
+func TestQEMU_HypervisorArguments(t *testing.T) {
+ hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1")
+ for flag, want := range map[string]string{
+ "-name": "hv1",
+ "-machine": "q35",
+ "-accel": "kvm",
+ "-cpu": "host",
+ "-smp": "4",
+ "-m": "16384",
+ "-display": "none",
+ "-serial": "file:/srv/lab/hv1/console.log",
+ "-qmp": "unix:/srv/lab/hv1/qmp.sock,server=on,wait=off",
+ "-pidfile": "/srv/lab/hv1/qemu.pid",
+ } {
+ if !hasValue(hv.QEMU, flag, want) {
+ t.Errorf("%s %s missing from %v", flag, want, hv.QEMU)
+ }
+ }
+ for _, want := range []string{
+ "file=/srv/lab/hv1/disk.qcow2,if=virtio,format=qcow2",
+ "file=/srv/lab/hv1/seed.iso,media=cdrom,readonly=on",
+ } {
+ if !hasValue(hv.QEMU, "-drive", want) {
+ t.Errorf("-drive %s missing", want)
+ }
+ }
+ if hv.Dir != "/srv/lab/hv1" {
+ t.Errorf("Dir = %s", hv.Dir)
+ }
+ nodefaults := 0
+ for _, a := range hv.QEMU {
+ if a == "-nodefaults" {
+ nodefaults++
+ }
+ }
+ if nodefaults != 1 {
+ t.Errorf("-nodefaults appears %d times", nodefaults)
+ }
+}
+
+func TestQEMU_HypervisorNetwork(t *testing.T) {
+ hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1")
+ wantNetdevs := []string{
+ "user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22",
+ "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",
+ }
+ wantDevices := []string{
+ "virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile=",
+ "virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile=",
+ }
+ if got := value(t, hv.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) {
+ t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs)
+ }
+ if got := value(t, hv.QEMU, "-device"); !reflect.DeepEqual(got, wantDevices) {
+ t.Errorf("devices = %v\nwant %v", got, wantDevices)
+ }
+}
+
+func TestQEMU_SwitchNetwork(t *testing.T) {
+ sw := nodeNamed(t, renderAll(t, twoHypervisors), "sw1")
+ wantNetdevs := []string{
+ "user,id=mgmt0,restrict=off,ipv6=off,hostfwd=tcp:127.0.0.1:2200-:22",
+ "dgram,id=p0,local.type=inet,local.host=127.0.0.1,local.port=20001,remote.type=inet,remote.host=127.0.0.1,remote.port=20000",
+ "dgram,id=p1,local.type=inet,local.host=127.0.0.1,local.port=20003,remote.type=inet,remote.host=127.0.0.1,remote.port=20002",
+ "dgram,id=p2,local.type=inet,local.host=127.0.0.1,local.port=20005,remote.type=inet,remote.host=127.0.0.1,remote.port=20004",
+ }
+ if got := value(t, sw.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) {
+ t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs)
+ }
+ if !hasValue(sw.QEMU, "-device", "virtio-net-pci,netdev=p2,mac=02:4c:00:03:00:01,host_mtu=9000,romfile=") {
+ t.Errorf("p2 device missing: %v", sw.QEMU)
+ }
+}
+
+func TestQEMU_OnlyTheSwitchReachesTheOutsideThroughAdministration(t *testing.T) {
+ for name, n := range renderAll(t, twoHypervisors) {
+ admin := value(t, n.QEMU, "-netdev")[0]
+ wantRestrict := "restrict=on"
+ if name == "sw1" {
+ wantRestrict = "restrict=off"
+ }
+ if !strings.Contains(admin, ","+wantRestrict+",") {
+ t.Errorf("%s admin netdev %q, want %s", name, admin, wantRestrict)
+ }
+ if !strings.Contains(admin, "hostfwd=tcp:127.0.0.1:") {
+ t.Errorf("%s ssh forward not bound to loopback: %q", name, admin)
+ }
+ }
+}
+
+func TestQEMU_EveryCableEndsMatch(t *testing.T) {
+ ends := map[string]int{}
+ for _, n := range renderAll(t, twoHypervisors) {
+ for _, nd := range value(t, n.QEMU, "-netdev") {
+ if !strings.HasPrefix(nd, "dgram,") {
+ continue
+ }
+ var local, remote string
+ for _, kv := range strings.Split(nd, ",") {
+ if v, ok := strings.CutPrefix(kv, "local.port="); ok {
+ local = v
+ }
+ if v, ok := strings.CutPrefix(kv, "remote.port="); ok {
+ remote = v
+ }
+ }
+ ends[local+">"+remote]++
+ }
+ }
+ for pair, count := range ends {
+ local, remote, _ := strings.Cut(pair, ">")
+ if count != 1 || ends[remote+">"+local] != 1 {
+ t.Errorf("cable end %s has no single matching end", pair)
+ }
+ }
+ if len(ends) != 6 {
+ t.Errorf("%d cable ends, want 6", len(ends))
+ }
+}
+
+func TestNetworkConfig_Hypervisor(t *testing.T) {
+ doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1"))
+ if doc.Version != 2 || len(doc.Ethernets) != 2 {
+ t.Fatalf("network-config = %+v", doc)
+ }
+ admin := iface(t, doc, "mgmt0")
+ if admin.Match.MACAddress != "02:4d:00:02:00:00" || admin.SetName != "mgmt0" ||
+ !reflect.DeepEqual(admin.Addresses, []string{"10.0.2.15/24"}) || len(admin.Routes) != 0 || admin.Nameservers != nil {
+ t.Errorf("mgmt0 = %+v", admin)
+ }
+ under := iface(t, doc, "underlay")
+ if under.Match.MACAddress != "02:4c:00:02:00:00" || under.SetName != "underlay" || under.MTU != 9000 ||
+ !reflect.DeepEqual(under.Addresses, []string{"10.250.0.3/24"}) {
+ t.Errorf("underlay = %+v", under)
+ }
+ if !reflect.DeepEqual(under.Routes, []route{{To: "0.0.0.0/0", Via: "10.250.0.1"}}) {
+ t.Errorf("underlay routes = %+v", under.Routes)
+ }
+ if under.Nameservers == nil || !reflect.DeepEqual(under.Nameservers.Addresses, []string{"1.1.1.1", "8.8.8.8"}) {
+ t.Errorf("underlay nameservers = %+v", under.Nameservers)
+ }
+}
+
+func TestNetworkConfig_DHCPIsExplicitlyOffEverywhere(t *testing.T) {
+ for name, n := range renderAll(t, twoHypervisors) {
+ if c := strings.Count(string(n.NetworkConfig), "dhcp4: false"); c != len(network(t, n).Ethernets) {
+ t.Errorf("%s: %d explicit dhcp4: false for %d interfaces", name, c, len(network(t, n).Ethernets))
+ }
+ }
+}
+
+func TestNetworkConfig_Switch(t *testing.T) {
+ doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1"))
+ admin := iface(t, doc, "mgmt0")
+ if !reflect.DeepEqual(admin.Routes, []route{{To: "0.0.0.0/0", Via: "10.0.2.2"}}) ||
+ admin.Nameservers == nil || !reflect.DeepEqual(admin.Nameservers.Addresses, []string{"10.0.2.3"}) {
+ t.Errorf("switch mgmt0 = %+v", admin)
+ }
+ for port, mac := range map[string]string{"p0": "02:4c:00:01:00:01", "p1": "02:4c:00:02:00:01", "p2": "02:4c:00:03:00:01"} {
+ e := iface(t, doc, port)
+ if e.Match.MACAddress != mac || e.SetName != port || e.MTU != 9000 || len(e.Addresses) != 0 || len(e.Routes) != 0 {
+ t.Errorf("%s = %+v", port, e)
+ }
+ }
+}
+
+func TestNetworkConfig_DefaultRouteOnlyOnFirstSegment(t *testing.T) {
+ doc := network(t, nodeNamed(t, renderAll(t, twoSegments), "hv"))
+ red, blue := iface(t, doc, "red"), iface(t, doc, "blue")
+ if !reflect.DeepEqual(red.Routes, []route{{To: "0.0.0.0/0", Via: "10.1.0.1"}}) || red.Nameservers == nil {
+ t.Errorf("red = %+v", red)
+ }
+ if len(blue.Routes) != 0 || blue.Nameservers != nil || blue.MTU != 1500 {
+ t.Errorf("blue = %+v", blue)
+ }
+}
+
+func TestUserData_Hypervisor(t *testing.T) {
+ cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1"))
+ if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.Packages) != 0 || !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) {
+ t.Errorf("hv1 user-data = %+v", cfg)
+ }
+ if !reflect.DeepEqual(cfg.SSHAuthorizedKeys, []string{labKey}) {
+ t.Errorf("keys = %v", cfg.SSHAuthorizedKeys)
+ }
+}
+
+func TestUserData_PasswordLoginIsExplicitlyOff(t *testing.T) {
+ for name, n := range renderAll(t, twoHypervisors) {
+ if !strings.Contains(string(n.UserData), "\nssh_pwauth: false\n") || !strings.Contains(string(n.UserData), "\ndisable_root: true\n") {
+ t.Errorf("%s user-data:\n%s", name, n.UserData)
+ }
+ }
+}
+
+func TestUserData_SwitchBuildsBridgeGatewayAndNAT(t *testing.T) {
+ cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1"))
+ if !reflect.DeepEqual(cfg.Packages, []string{"nftables"}) {
+ t.Errorf("packages = %v", cfg.Packages)
+ }
+ script := fileAt(t, cfg, "/usr/local/sbin/lab-switch")
+ if script.Permissions != "0755" {
+ t.Errorf("script permissions = %s", script.Permissions)
+ }
+ wantScript := `#!/bin/sh
+set -eu
+sysctl -qw net.ipv4.ip_forward=1
+ip link add br-underlay type bridge stp_state 0 2>/dev/null || true
+ip link set dev p0 master br-underlay
+ip link set dev p0 up
+ip link set dev p1 master br-underlay
+ip link set dev p1 up
+ip link set dev p2 master br-underlay
+ip link set dev p2 up
+ip link set dev br-underlay mtu 9000
+ip addr replace 10.250.0.1/24 dev br-underlay
+ip link set dev br-underlay up
+nft -f /etc/lab-switch.nft
+`
+ if script.Content != wantScript {
+ t.Errorf("script:\n%s\nwant:\n%s", script.Content, wantScript)
+ }
+ nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content
+ for _, want := range []string{
+ "add table ip lab_nat\ndelete table ip lab_nat\n",
+ "type nat hook postrouting priority srcnat;",
+ `ip saddr { 10.250.0.0/24 } oifname "mgmt0" masquerade`,
+ } {
+ if !strings.Contains(nft, want) {
+ t.Errorf("nft rules miss %q:\n%s", want, nft)
+ }
+ }
+ unit := fileAt(t, cfg, "/etc/systemd/system/lab-switch.service").Content
+ for _, want := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=/usr/local/sbin/lab-switch", "After=network-online.target", "WantedBy=multi-user.target"} {
+ if !strings.Contains(unit, want) {
+ t.Errorf("unit misses %q", want)
+ }
+ }
+ if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) {
+ t.Errorf("runcmd = %v", cfg.Runcmd)
+ }
+ if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-switch.service\n" {
+ t.Errorf("lab-provision:\n%s", got)
+ }
+}
+
+func TestUserData_SwitchWithTwoSegments(t *testing.T) {
+ cfg := user(t, nodeNamed(t, renderAll(t, twoSegments), "sw"))
+ script := fileAt(t, cfg, "/usr/local/sbin/lab-switch").Content
+ for _, want := range []string{
+ "ip link set dev p0 master br-red\n",
+ "ip link set dev p1 master br-blue\n",
+ "ip link set dev br-blue mtu 1500\n",
+ "ip addr replace 10.2.0.1/24 dev br-blue\n",
+ } {
+ if !strings.Contains(script, want) {
+ t.Errorf("script misses %q:\n%s", want, script)
+ }
+ }
+ if strings.Contains(script, "p1 master br-red") || strings.Contains(script, "p0 master br-blue") {
+ t.Errorf("port bridged on the wrong segment:\n%s", script)
+ }
+ if nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content; !strings.Contains(nft, "ip saddr { 10.1.0.0/24, 10.2.0.0/24 }") {
+ t.Errorf("nft:\n%s", nft)
+ }
+}
+
+func TestMetaData(t *testing.T) {
+ var doc metaDoc
+ if err := yaml.Unmarshal(nodeNamed(t, renderAll(t, twoHypervisors), "hv2").MetaData, &doc); err != nil {
+ t.Fatal(err)
+ }
+ if doc.InstanceID != "evpn-2hv-hv2" || doc.LocalHostname != "hv2" {
+ t.Errorf("meta-data = %+v", doc)
+ }
+}
+
+func TestRender_AdminMacsAreUniqueAndApartFromCableMacs(t *testing.T) {
+ seen := map[string]string{}
+ for name, n := range renderAll(t, twoHypervisors) {
+ for _, d := range value(t, n.QEMU, "-device") {
+ for _, kv := range strings.Split(d, ",") {
+ if mac, ok := strings.CutPrefix(kv, "mac="); ok {
+ if other, dup := seen[mac]; dup {
+ t.Errorf("mac %s used by %s and %s", mac, other, name)
+ }
+ seen[mac] = name
+ }
+ }
+ }
+ }
+ if len(seen) != 10 {
+ t.Errorf("%d distinct macs, want 10", len(seen))
+ }
+}
+
+func TestRender_IsStable(t *testing.T) {
+ first := renderAll(t, twoHypervisors)
+ for i := 0; i < 20; i++ {
+ if again := renderAll(t, twoHypervisors); !reflect.DeepEqual(first, again) {
+ t.Fatalf("render differs on run %d", i)
+ }
+ }
+}
+
+func TestRender_RejectsBadOptions(t *testing.T) {
+ p := plan(t, twoHypervisors)
+ cases := []struct {
+ name string
+ opts Options
+ want string
+ }{
+ {"relative run dir", Options{RunDir: "lab", AuthorizedKeys: []string{labKey}}, `run dir "lab" must be an absolute path`},
+ {"no key", Options{RunDir: "/srv/lab"}, "at least one authorized ssh key"},
+ {"multi-line key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey + "\nssh-rsa AAAA x"}}, "authorized key 1 spans several lines"},
+ {"not a key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"hello world"}}, "authorized key 1 is not an ssh public key"},
+ {"type only", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"ssh-ed25519"}}, "authorized key 1 is not an ssh public key"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ _, err := Render(p, c.opts)
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Fatalf("err = %v, want %q", err, c.want)
+ }
+ })
+ }
+}
+
+func TestRender_AcceptsUsualKeyTypes(t *testing.T) {
+ p := plan(t, twoHypervisors)
+ for _, k := range []string{
+ "ssh-rsa AAAAB3NzaC1yc2E user",
+ "ecdsa-sha2-nistp256 AAAAE2VjZHNh user",
+ "sk-ssh-ed25519@openssh.com AAAAGnNr user",
+ } {
+ if _, err := Render(p, Options{RunDir: "/srv/lab", AuthorizedKeys: []string{k}}); err != nil {
+ t.Errorf("%s rejected: %v", strings.Fields(k)[0], err)
+ }
+ }
+}
diff --git a/internal/lab/render/roles_test.go b/internal/lab/render/roles_test.go
new file mode 100644
index 0000000..fd0e0de
--- /dev/null
+++ b/internal/lab/render/roles_test.go
@@ -0,0 +1,298 @@
+package render
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "os"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+const withRoles = `name: evpn-2hv
+images:
+ deb:
+ url: https://example.invalid/deb.qcow2
+ sums: https://example.invalid/SHA512SUMS
+segments:
+ underlay: { switch: sw1, cidr: 192.168.14.0/24 }
+nodes:
+ sw1: { role: switch, image: deb, cpus: 2, memory: 1024, secondary: { underlay: [169.254.0.1/28] }, frr: sw1.conf }
+ rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay], secondary: { underlay: [169.254.0.3/28] }, loopback: 10.255.255.1/32, frr: rr1.conf }
+ hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: hv1.conf, release: 0.2.0rc002 }
+ hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
+`
+
+var frrConfigs = map[string]string{
+ "sw1": "hostname sw1\nrouter bgp 65100\n",
+ "rr1": "hostname rr1\nrouter bgp 65000\n",
+ "hv1": "hostname hv1\nrouter bgp 64600\n",
+}
+
+func renderRoles(t *testing.T) map[string]Node {
+ t.Helper()
+ nodes, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs})
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ out := map[string]Node{}
+ for _, n := range nodes {
+ out[n.Name] = n
+ }
+ return out
+}
+
+func runcmd(t *testing.T, n Node) []string {
+ t.Helper()
+ var out []string
+ for _, c := range user(t, n).Runcmd {
+ out = append(out, strings.Join(c, " "))
+ }
+ return out
+}
+
+const frrScriptHead = `#!/bin/sh
+set -eu
+export DEBIAN_FRONTEND=noninteractive
+. /etc/os-release
+echo "deb [signed-by=/usr/share/keyrings/frrouting.gpg] https://deb.frrouting.org/frr ${VERSION_CODENAME} frr-stable" > /etc/apt/sources.list.d/frr.list
+n=0
+until apt-get update -qq --error-on=any; do
+ n=$((n + 1))
+ [ "$n" -lt 30 ] || exit 1
+ sleep 10
+done
+apt-get install -y -qq --no-install-recommends frr frr-pythontools
+sed -i 's/^bgpd=no/bgpd=yes/' /etc/frr/daemons
+`
+
+func TestRoles_SecondaryAddressFollowsThePrimaryOnTheNode(t *testing.T) {
+ doc := network(t, nodeNamed(t, renderRoles(t), "rr1"))
+ got := iface(t, doc, "underlay").Addresses
+ if !reflect.DeepEqual(got, []string{"192.168.14.2/24", "169.254.0.3/28"}) {
+ t.Errorf("rr1 underlay addresses = %v", got)
+ }
+}
+
+func TestRoles_SwitchCarriesItsSecondaryOnTheBridge(t *testing.T) {
+ script := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "sw1")), "/usr/local/sbin/lab-switch").Content
+ want := "ip addr replace 192.168.14.1/24 dev br-underlay\nip addr replace 169.254.0.1/28 dev br-underlay\nip link set dev br-underlay up\n"
+ if !strings.Contains(script, want) {
+ t.Errorf("lab-switch:\n%s\ndoes not contain:\n%s", script, want)
+ }
+}
+
+func TestRoles_LoopbackOnADummyInterfaceReplayedAtBoot(t *testing.T) {
+ rr1 := nodeNamed(t, renderRoles(t), "rr1")
+ cfg := user(t, rr1)
+
+ script := fileAt(t, cfg, "/usr/local/sbin/lab-node")
+ want := "#!/bin/sh\nset -eu\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.255.1/32 dev lo1\nip link set dev lo1 up\n"
+ if script.Content != want || script.Permissions != "0755" {
+ t.Errorf("lab-node (%s):\n%s", script.Permissions, script.Content)
+ }
+ unit := fileAt(t, cfg, "/etc/systemd/system/lab-node.service").Content
+ if !strings.Contains(unit, "ExecStart=/usr/local/sbin/lab-node\n") || !strings.Contains(unit, "WantedBy=multi-user.target\n") {
+ t.Errorf("lab-node.service:\n%s", unit)
+ }
+ want = "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-node.service\n/usr/local/sbin/lab-frr\n"
+ if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != want {
+ t.Errorf("lab-provision:\n%s\nwant:\n%s", got, want)
+ }
+}
+
+func TestRoles_FRRConfigIsWrittenVerbatimAndPrivately(t *testing.T) {
+ for _, name := range []string{"sw1", "rr1", "hv1"} {
+ f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), name)), "/etc/lab/frr.conf")
+ if f.Content != frrConfigs[name] || f.Permissions != "0640" {
+ t.Errorf("%s frr.conf (%s) = %q", name, f.Permissions, f.Content)
+ }
+ }
+}
+
+func TestRoles_FRRDaemonsDependOnTheRole(t *testing.T) {
+ tail := "install -o frr -g frr -m 0640 /etc/lab/frr.conf /etc/frr/frr.conf\nsystemctl restart frr\n"
+ bfd := "sed -i 's/^bfdd=no/bfdd=yes/' /etc/frr/daemons\n"
+ nodes := renderRoles(t)
+ for name, want := range map[string]string{
+ "sw1": frrScriptHead + bfd + tail,
+ "rr1": frrScriptHead + bfd + tail,
+ "hv1": frrScriptHead + tail,
+ } {
+ f := fileAt(t, user(t, nodeNamed(t, nodes, name)), "/usr/local/sbin/lab-frr")
+ if f.Content != want || f.Permissions != "0755" {
+ t.Errorf("%s lab-frr (%s):\n%s\nwant:\n%s", name, f.Permissions, f.Content, want)
+ }
+ }
+}
+
+func TestRoles_FRRKeyIsThePinnedRepositoryKey(t *testing.T) {
+ f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "hv1")), "/usr/share/keyrings/frrouting.gpg")
+ if f.Encoding != "b64" || f.Permissions != "0644" {
+ t.Errorf("key file: encoding %q, permissions %q", f.Encoding, f.Permissions)
+ }
+ key, err := base64.StdEncoding.DecodeString(f.Content)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(key)
+ if got := hex.EncodeToString(sum[:]); got != "bf10935b9296e2ce7c5d9855fa29ef30c35810b0fc4b1f53005494a04a33554d" {
+ t.Errorf("key sha256 = %s", got)
+ }
+}
+
+func TestRoles_ProvisioningIsOneScriptThatStopsAtTheFirstFailure(t *testing.T) {
+ nodes := renderRoles(t)
+ for name, steps := range map[string]string{
+ "sw1": "systemctl daemon-reload\nsystemctl enable --now lab-switch.service\n/usr/local/sbin/lab-frr\n",
+ "hv1": "/usr/local/sbin/lab-deploy\n/usr/local/sbin/lab-frr\n",
+ "hv2": "/usr/local/sbin/lab-deploy\n",
+ } {
+ cfg := user(t, nodeNamed(t, nodes, name))
+ if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) {
+ t.Errorf("%s runcmd = %q", name, cfg.Runcmd)
+ }
+ f := fileAt(t, cfg, "/usr/local/sbin/lab-provision")
+ if f.Content != "#!/bin/sh\nset -eu\n"+steps || f.Permissions != "0755" {
+ t.Errorf("%s lab-provision (%s):\n%s", name, f.Permissions, f.Content)
+ }
+ }
+}
+
+func TestRoles_HypervisorDeploysTheReleaseThroughTheRepositoryScripts(t *testing.T) {
+ cfg := user(t, nodeNamed(t, renderRoles(t), "hv1"))
+
+ f := fileAt(t, cfg, "/usr/local/sbin/lab-deploy")
+ want := `#!/bin/sh
+set -eu
+n=0
+until curl -fsS -o /dev/null https://git.g3e.fr/; do
+ n=$((n + 1))
+ [ "$n" -lt 30 ] || exit 1
+ sleep 10
+done
+cd /opt/two/scripts
+bash ./deploy.sh --noup_script -i -u underlay -t 0.2.0rc002
+`
+ if f.Content != want || f.Permissions != "0755" {
+ t.Errorf("lab-deploy (%s):\n%s\nwant:\n%s", f.Permissions, f.Content, want)
+ }
+
+ for path, source := range map[string]string{
+ "/opt/two/scripts/deploy.sh": "../../../scripts/deploy.sh",
+ "/opt/two/scripts/bootstrap_kvm.sh": "../../../scripts/bootstrap_kvm.sh",
+ } {
+ f := fileAt(t, cfg, path)
+ got, err := base64.StdEncoding.DecodeString(f.Content)
+ if err != nil {
+ t.Fatal(err)
+ }
+ repo, err := os.ReadFile(source)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if f.Encoding != "b64" || f.Permissions != "0755" || !bytes.Equal(got, repo) {
+ t.Errorf("%s: encoding %q, permissions %q, identical to %s: %v", path, f.Encoding, f.Permissions, source, bytes.Equal(got, repo))
+ }
+ }
+}
+
+func TestRoles_UplinkIsTheInterfaceCarryingTheDefaultRoute(t *testing.T) {
+ hv := nodeNamed(t, renderAll(t, twoSegments), "hv")
+ var withDefault []string
+ for name, e := range network(t, hv).Ethernets {
+ for _, r := range e.Routes {
+ if r.To == "0.0.0.0/0" {
+ withDefault = append(withDefault, name)
+ }
+ }
+ }
+ if !reflect.DeepEqual(withDefault, []string{"red"}) {
+ t.Fatalf("default route on %v, want [red]", withDefault)
+ }
+ if got := fileAt(t, user(t, hv), "/usr/local/sbin/lab-deploy").Content; !strings.HasSuffix(got, "bash ./deploy.sh --noup_script -i -u red -t 0.2.0rc002\n") {
+ t.Errorf("lab-deploy:\n%s", got)
+ }
+}
+
+func TestRoles_OnlyHypervisorsDeployTwo(t *testing.T) {
+ nodes := renderRoles(t)
+ for _, name := range []string{"sw1", "rr1"} {
+ for _, f := range user(t, nodeNamed(t, nodes, name)).WriteFiles {
+ if strings.HasPrefix(f.Path, "/opt/two/") || f.Path == "/usr/local/sbin/lab-deploy" {
+ t.Errorf("%s receives %s", name, f.Path)
+ }
+ }
+ }
+}
+
+func TestRoles_NodeWithoutRoleFieldsGetsNoFRR(t *testing.T) {
+ for _, f := range user(t, nodeNamed(t, renderRoles(t), "hv2")).WriteFiles {
+ if strings.Contains(f.Path, "frr") {
+ t.Errorf("hv2 receives %s", f.Path)
+ }
+ }
+}
+
+func TestRoles_RefusesAnUnreadFRRConfig(t *testing.T) {
+ configs := map[string]string{"sw1": "x", "rr1": "y"}
+ _, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: configs})
+ if err == nil || err.Error() != "node hv1: frr configuration hv1.conf was not read" {
+ t.Errorf("error = %v", err)
+ }
+}
+
+func TestRoles_SwitchLoopbackIsCreatedByTheSwitchScript(t *testing.T) {
+ doc := strings.Replace(withRoles, "secondary: { underlay: [169.254.0.1/28] }, frr: sw1.conf", "secondary: { underlay: [169.254.0.1/28] }, loopback: 10.255.254.1/32, frr: sw1.conf", 1)
+ nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs})
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ var sw1 Node
+ for _, n := range nodes {
+ if n.Name == "sw1" {
+ sw1 = n
+ }
+ }
+ script := fileAt(t, user(t, sw1), "/usr/local/sbin/lab-switch").Content
+ want := "ip link set dev br-underlay up\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.254.1/32 dev lo1\nip link set dev lo1 up\nnft -f /etc/lab-switch.nft\n"
+ if !strings.HasSuffix(script, want) {
+ t.Errorf("lab-switch:\n%s\ndoes not end with:\n%s", script, want)
+ }
+}
+
+func TestRoles_HypervisorAgentConfigIsWrittenBeforeTheDeployment(t *testing.T) {
+ doc := strings.Replace(withRoles, "frr: hv1.conf, release: 0.2.0rc002 }", "frr: hv1.conf, release: 0.2.0rc002, agent: two.yml }", 1)
+ nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs, Agent: map[string]string{"hv1": "dhcp:\n backend: two\n"}})
+ if err != nil {
+ t.Fatalf("Render: %v", err)
+ }
+ var hv1, hv2 Node
+ for _, n := range nodes {
+ switch n.Name {
+ case "hv1":
+ hv1 = n
+ case "hv2":
+ hv2 = n
+ }
+ }
+ f := fileAt(t, user(t, hv1), "/etc/two/agent.yml")
+ if f.Content != "dhcp:\n backend: two\n" || f.Permissions != "0640" {
+ t.Errorf("agent.yml (%s) = %q", f.Permissions, f.Content)
+ }
+ for _, w := range user(t, hv2).WriteFiles {
+ if w.Path == "/etc/two/agent.yml" {
+ t.Error("hv2 receives an agent.yml it does not declare")
+ }
+ }
+}
+
+func TestRoles_RefusesAnUnreadAgentConfig(t *testing.T) {
+ doc := strings.Replace(withRoles, "frr: hv1.conf, release: 0.2.0rc002 }", "frr: hv1.conf, release: 0.2.0rc002, agent: two.yml }", 1)
+ _, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs})
+ if err == nil || err.Error() != "node hv1: agent configuration two.yml was not read" {
+ t.Errorf("error = %v", err)
+ }
+}
diff --git a/internal/lab/render/seed.go b/internal/lab/render/seed.go
new file mode 100644
index 0000000..0255140
--- /dev/null
+++ b/internal/lab/render/seed.go
@@ -0,0 +1,294 @@
+package render
+
+import (
+ _ "embed"
+ "encoding/base64"
+ "fmt"
+ "strings"
+
+ "go.yaml.in/yaml/v3"
+
+ "git.g3e.fr/syonad/two/internal/lab/topology"
+ "git.g3e.fr/syonad/two/scripts"
+)
+
+const (
+ SwitchScript = "/usr/local/sbin/lab-switch"
+ SwitchNFT = "/etc/lab-switch.nft"
+ SwitchUnit = "/etc/systemd/system/lab-switch.service"
+
+ NodeScript = "/usr/local/sbin/lab-node"
+ NodeUnit = "/etc/systemd/system/lab-node.service"
+
+ FRRKey = "/usr/share/keyrings/frrouting.gpg"
+ FRRConfig = "/etc/lab/frr.conf"
+ FRRScript = "/usr/local/sbin/lab-frr"
+ FRRSuite = "frr-stable"
+ FRRRepo = "https://deb.frrouting.org/frr"
+ FRRPackages = "frr frr-pythontools"
+
+ ProvisionScript = "/usr/local/sbin/lab-provision"
+ DeployScript = "/usr/local/sbin/lab-deploy"
+ TwoScriptsDir = "/opt/two/scripts"
+ TwoGitServer = "https://git.g3e.fr/"
+ AgentConfig = "/etc/two/agent.yml"
+)
+
+//go:embed frrouting.gpg
+var frrKey []byte
+
+type metaDoc struct {
+ InstanceID string `yaml:"instance-id"`
+ LocalHostname string `yaml:"local-hostname"`
+}
+
+type writeFile struct {
+ Path string `yaml:"path"`
+ Permissions string `yaml:"permissions"`
+ Encoding string `yaml:"encoding,omitempty"`
+ Content string `yaml:"content"`
+}
+
+type cloudConfig struct {
+ Hostname string `yaml:"hostname"`
+ SSHPwauth bool `yaml:"ssh_pwauth"`
+ DisableRoot bool `yaml:"disable_root"`
+ SSHAuthorizedKeys []string `yaml:"ssh_authorized_keys"`
+ Packages []string `yaml:"packages,omitempty"`
+ WriteFiles []writeFile `yaml:"write_files,omitempty"`
+ Runcmd [][]string `yaml:"runcmd,omitempty"`
+}
+
+type match struct {
+ MACAddress string `yaml:"macaddress"`
+}
+
+type route struct {
+ To string `yaml:"to"`
+ Via string `yaml:"via"`
+}
+
+type nameservers struct {
+ Addresses []string `yaml:"addresses"`
+}
+
+type ethernet struct {
+ Match match `yaml:"match"`
+ SetName string `yaml:"set-name"`
+ DHCP4 bool `yaml:"dhcp4"`
+ MTU int `yaml:"mtu,omitempty"`
+ Addresses []string `yaml:"addresses,omitempty"`
+ Routes []route `yaml:"routes,omitempty"`
+ Nameservers *nameservers `yaml:"nameservers,omitempty"`
+}
+
+type networkDoc struct {
+ Version int `yaml:"version"`
+ Ethernets map[string]ethernet `yaml:"ethernets"`
+}
+
+func metaData(p *topology.Plan, n topology.NodePlan) ([]byte, error) {
+ return yaml.Marshal(metaDoc{InstanceID: p.Name + "-" + n.Name, LocalHostname: n.Name})
+}
+
+func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) {
+ cfg := cloudConfig{
+ Hostname: n.Name,
+ SSHPwauth: false,
+ DisableRoot: true,
+ SSHAuthorizedKeys: o.AuthorizedKeys,
+ }
+ var steps []string
+ switch {
+ case n.Role == topology.RoleSwitch:
+ cfg.Packages = []string{"nftables"}
+ cfg.WriteFiles = []writeFile{
+ {Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n)},
+ {Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)},
+ {Path: SwitchUnit, Permissions: "0644", Content: unit("Lab switch: bridges, gateways and NAT", SwitchScript)},
+ }
+ steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-switch.service")
+ case n.Loopback.IsValid():
+ cfg.WriteFiles = []writeFile{
+ {Path: NodeScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + loopbackLines(n)},
+ {Path: NodeUnit, Permissions: "0644", Content: unit("Lab node: loopback", NodeScript)},
+ }
+ steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-node.service")
+ }
+ if n.Agent != "" {
+ cfg.WriteFiles = append(cfg.WriteFiles, writeFile{Path: AgentConfig, Permissions: "0640", Content: o.Agent[n.Name]})
+ }
+ if n.Role == topology.RoleHypervisor {
+ cfg.WriteFiles = append(cfg.WriteFiles,
+ writeFile{Path: TwoScriptsDir + "/deploy.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.Deploy)},
+ writeFile{Path: TwoScriptsDir + "/bootstrap_kvm.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.BootstrapKVM)},
+ writeFile{Path: DeployScript, Permissions: "0755", Content: deployScript(p, n)},
+ )
+ steps = append(steps, DeployScript)
+ }
+ if n.FRR != "" {
+ cfg.WriteFiles = append(cfg.WriteFiles,
+ writeFile{Path: FRRKey, Permissions: "0644", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(frrKey)},
+ writeFile{Path: FRRConfig, Permissions: "0640", Content: o.FRR[n.Name]},
+ writeFile{Path: FRRScript, Permissions: "0755", Content: frrScript(n)},
+ )
+ steps = append(steps, FRRScript)
+ }
+ if len(steps) > 0 {
+ cfg.WriteFiles = append(cfg.WriteFiles,
+ writeFile{Path: ProvisionScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + strings.Join(steps, "\n") + "\n"},
+ )
+ cfg.Runcmd = [][]string{{ProvisionScript}}
+ }
+ body, err := yaml.Marshal(cfg)
+ if err != nil {
+ return nil, err
+ }
+ return append([]byte("#cloud-config\n"), body...), nil
+}
+
+func retry(command string) string {
+ return "n=0\nuntil " + command + "; do\n n=$((n + 1))\n [ \"$n\" -lt 30 ] || exit 1\n sleep 10\ndone\n"
+}
+
+func deployScript(p *topology.Plan, n topology.NodePlan) string {
+ uplink := nodeCables(p, n.Name)[0].NodeInterface
+ return "#!/bin/sh\nset -eu\n" +
+ retry("curl -fsS -o /dev/null "+TwoGitServer) +
+ "cd " + TwoScriptsDir + "\n" +
+ fmt.Sprintf("bash ./deploy.sh --noup_script -i -u %s -t %s\n", uplink, n.Release)
+}
+
+func frrDaemons(n topology.NodePlan) []string {
+ if n.Role == topology.RoleHypervisor {
+ return []string{"bgpd"}
+ }
+ return []string{"bgpd", "bfdd"}
+}
+
+func frrScript(n topology.NodePlan) string {
+ var b strings.Builder
+ b.WriteString("#!/bin/sh\nset -eu\nexport DEBIAN_FRONTEND=noninteractive\n. /etc/os-release\n")
+ fmt.Fprintf(&b, "echo \"deb [signed-by=%s] %s ${VERSION_CODENAME} %s\" > /etc/apt/sources.list.d/frr.list\n", FRRKey, FRRRepo, FRRSuite)
+ b.WriteString(retry("apt-get update -qq --error-on=any"))
+ fmt.Fprintf(&b, "apt-get install -y -qq --no-install-recommends %s\n", FRRPackages)
+ for _, d := range frrDaemons(n) {
+ fmt.Fprintf(&b, "sed -i 's/^%s=no/%s=yes/' /etc/frr/daemons\n", d, d)
+ }
+ fmt.Fprintf(&b, "install -o frr -g frr -m 0640 %s /etc/frr/frr.conf\n", FRRConfig)
+ b.WriteString("systemctl restart frr\n")
+ return b.String()
+}
+
+func loopbackLines(n topology.NodePlan) string {
+ if !n.Loopback.IsValid() {
+ return ""
+ }
+ return fmt.Sprintf("ip link add %s type dummy 2>/dev/null || true\nip addr replace %s dev %s\nip link set dev %s up\n",
+ topology.LoopbackInterface, n.Loopback, topology.LoopbackInterface, topology.LoopbackInterface)
+}
+
+func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, error) {
+ doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}}
+ admin := ethernet{
+ Match: match{MACAddress: adminMAC(index).String()},
+ SetName: AdminInterface,
+ Addresses: []string{AdminAddress},
+ }
+ if n.Role == topology.RoleSwitch {
+ admin.Routes = []route{{To: "0.0.0.0/0", Via: AdminGateway}}
+ admin.Nameservers = &nameservers{Addresses: []string{AdminDNS}}
+ for _, c := range switchCables(p, n.Name) {
+ doc.Ethernets[c.SwitchInterface] = ethernet{
+ Match: match{MACAddress: c.SwitchMAC.String()},
+ SetName: c.SwitchInterface,
+ MTU: c.MTU,
+ }
+ }
+ }
+ doc.Ethernets[AdminInterface] = admin
+
+ for i, c := range nodeCables(p, n.Name) {
+ e := ethernet{
+ Match: match{MACAddress: c.NodeMAC.String()},
+ SetName: c.NodeInterface,
+ MTU: c.MTU,
+ Addresses: []string{c.NodeAddress.String()},
+ }
+ for _, prefix := range n.Secondary[c.Segment] {
+ e.Addresses = append(e.Addresses, prefix.String())
+ }
+ if i == 0 {
+ e.Routes = []route{{To: "0.0.0.0/0", Via: gatewayOf(p, c.Segment)}}
+ e.Nameservers = &nameservers{Addresses: Nameservers}
+ }
+ doc.Ethernets[c.NodeInterface] = e
+ }
+ return yaml.Marshal(doc)
+}
+
+func gatewayOf(p *topology.Plan, segment string) string {
+ for _, s := range p.Segments {
+ if s.Name == segment {
+ return s.Gateway.String()
+ }
+ }
+ return ""
+}
+
+func switchScript(p *topology.Plan, n topology.NodePlan) string {
+ name := n.Name
+ var b strings.Builder
+ b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n")
+ for _, s := range switchSegments(p, name) {
+ fmt.Fprintf(&b, "ip link add %s type bridge stp_state 0 2>/dev/null || true\n", s.Bridge)
+ for _, c := range switchCables(p, name) {
+ if c.Segment != s.Name {
+ continue
+ }
+ fmt.Fprintf(&b, "ip link set dev %s master %s\n", c.SwitchInterface, s.Bridge)
+ fmt.Fprintf(&b, "ip link set dev %s up\n", c.SwitchInterface)
+ }
+ fmt.Fprintf(&b, "ip link set dev %s mtu %d\n", s.Bridge, s.MTU)
+ fmt.Fprintf(&b, "ip addr replace %s/%d dev %s\n", s.Gateway, s.Network.Bits(), s.Bridge)
+ for _, prefix := range n.Secondary[s.Name] {
+ fmt.Fprintf(&b, "ip addr replace %s dev %s\n", prefix, s.Bridge)
+ }
+ fmt.Fprintf(&b, "ip link set dev %s up\n", s.Bridge)
+ }
+ b.WriteString(loopbackLines(n))
+ fmt.Fprintf(&b, "nft -f %s\n", SwitchNFT)
+ return b.String()
+}
+
+func switchNFT(p *topology.Plan, name string) string {
+ var networks []string
+ for _, s := range switchSegments(p, name) {
+ networks = append(networks, s.Network.String())
+ }
+ return fmt.Sprintf(`add table ip lab_nat
+delete table ip lab_nat
+table ip lab_nat {
+ chain postrouting {
+ type nat hook postrouting priority srcnat; policy accept;
+ ip saddr { %s } oifname "%s" masquerade
+ }
+}
+`, strings.Join(networks, ", "), AdminInterface)
+}
+
+func unit(description, script string) string {
+ return fmt.Sprintf(`[Unit]
+Description=%s
+Wants=network-online.target
+After=network-online.target
+
+[Service]
+Type=oneshot
+RemainAfterExit=yes
+ExecStart=%s
+
+[Install]
+WantedBy=multi-user.target
+`, description, script)
+}
diff --git a/internal/lab/topology/format.go b/internal/lab/topology/format.go
new file mode 100644
index 0000000..1ba73fd
--- /dev/null
+++ b/internal/lab/topology/format.go
@@ -0,0 +1,80 @@
+package topology
+
+import (
+ "fmt"
+ "io"
+ "path/filepath"
+ "sort"
+ "strings"
+ "text/tabwriter"
+)
+
+func (p *Plan) Write(w io.Writer) error {
+ tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
+
+ fmt.Fprintf(tw, "lab %s: nodes %d, segments %d, cables %d\n", p.Name, len(p.Nodes), len(p.Segments), len(p.Cables))
+
+ fmt.Fprintf(tw, "\nnodes\n")
+ fmt.Fprintf(tw, " name\trole\timage\tcpus\tmemory\tssh\n")
+ for _, n := range p.Nodes {
+ fmt.Fprintf(tw, " %s\t%s\t%s\t%d\t%d MiB\t127.0.0.1:%d\n", n.Name, n.Role, n.Image, n.CPUs, n.Memory, n.SSHPort)
+ }
+
+ var extras []NodePlan
+ for _, n := range p.Nodes {
+ if len(n.Secondary) > 0 || n.Loopback.IsValid() || n.FRR != "" || n.Release != "" || n.Agent != "" {
+ extras = append(extras, n)
+ }
+ }
+ if len(extras) > 0 {
+ fmt.Fprintf(tw, "\nroles\n")
+ fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\trelease\tagent\n")
+ for _, n := range extras {
+ fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\t%s\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)), orDash(n.Release), orDash(filepath.Base(n.Agent)))
+ }
+ }
+
+ for _, s := range p.Segments {
+ fmt.Fprintf(tw, "\nsegment %s: %s, mtu %d, switch %s, bridge %s, gateway %s\n",
+ s.Name, s.Network, s.MTU, s.Switch, s.Bridge, s.Gateway)
+ fmt.Fprintf(tw, " node\tinterface\taddress\tmac\tudp\t\tswitch port\tmac\tudp\n")
+ for _, c := range p.Cables {
+ if c.Segment != s.Name {
+ continue
+ }
+ fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%d\t<->\t%s %s\t%s\t%d\n",
+ c.Node, c.NodeInterface, c.NodeAddress, c.NodeMAC, c.NodePort,
+ c.Switch, c.SwitchInterface, c.SwitchMAC, c.SwitchPort)
+ }
+ }
+ return tw.Flush()
+}
+
+func loopback(n NodePlan) string {
+ if !n.Loopback.IsValid() {
+ return ""
+ }
+ return LoopbackInterface + " " + n.Loopback.String()
+}
+
+func secondary(n NodePlan) string {
+ segments := make([]string, 0, len(n.Secondary))
+ for s := range n.Secondary {
+ segments = append(segments, s)
+ }
+ sort.Strings(segments)
+ var parts []string
+ for _, s := range segments {
+ for _, prefix := range n.Secondary[s] {
+ parts = append(parts, s+" "+prefix.String())
+ }
+ }
+ return strings.Join(parts, ", ")
+}
+
+func orDash(s string) string {
+ if s == "" || s == "." {
+ return "-"
+ }
+ return s
+}
diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go
new file mode 100644
index 0000000..e5ba293
--- /dev/null
+++ b/internal/lab/topology/plan.go
@@ -0,0 +1,216 @@
+package topology
+
+import (
+ "errors"
+ "fmt"
+ "net"
+ "net/netip"
+ "strconv"
+)
+
+const (
+ SSHBasePort = 2200
+ CableBasePort = 20000
+
+ maxNodes = 1000
+ maxSegments = 256
+)
+
+type Plan struct {
+ Name string
+ Images []Image
+ Segments []SegmentPlan
+ Nodes []NodePlan
+ Cables []Cable
+}
+
+type SegmentPlan struct {
+ Name string
+ Switch string
+ Bridge string
+ Network netip.Prefix
+ Gateway netip.Addr
+ MTU int
+}
+
+type NodePlan struct {
+ Name string
+ Role string
+ Image string
+ CPUs int
+ Memory int
+ SSHPort int
+ Secondary map[string][]netip.Prefix
+ Loopback netip.Prefix
+ FRR string
+ Release string
+ Agent string
+}
+
+type Cable struct {
+ Segment string
+ MTU int
+
+ Node string
+ NodeInterface string
+ NodeMAC net.HardwareAddr
+ NodeAddress netip.Prefix
+ NodePort int
+
+ Switch string
+ SwitchInterface string
+ SwitchMAC net.HardwareAddr
+ SwitchPort int
+}
+
+func Compute(t *Topology) (*Plan, error) {
+ if err := t.Validate(); err != nil {
+ return nil, err
+ }
+
+ p := &Plan{Name: t.Name, Images: append([]Image(nil), t.Images...)}
+ for i, n := range t.Nodes {
+ node := NodePlan{
+ Name: n.Name,
+ Role: n.Role,
+ Image: n.Image,
+ CPUs: n.CPUs,
+ Memory: n.Memory,
+ SSHPort: SSHBasePort + i,
+ FRR: n.FRR,
+ Release: n.Release,
+ Agent: n.Agent,
+ }
+ for segment, raws := range n.Secondary {
+ for _, raw := range raws {
+ if node.Secondary == nil {
+ node.Secondary = map[string][]netip.Prefix{}
+ }
+ node.Secondary[segment] = append(node.Secondary[segment], netip.MustParsePrefix(raw))
+ }
+ }
+ if n.Loopback != "" {
+ node.Loopback = netip.MustParsePrefix(n.Loopback)
+ }
+ p.Nodes = append(p.Nodes, node)
+ }
+
+ var errs []error
+ for segIndex, s := range t.Segments {
+ network := netip.MustParsePrefix(s.CIDR)
+ gateway := network.Addr().Next()
+ p.Segments = append(p.Segments, SegmentPlan{
+ Name: s.Name,
+ Switch: s.Switch,
+ Bridge: "br-" + s.Name,
+ Network: network,
+ Gateway: gateway,
+ MTU: s.MTU,
+ })
+
+ addresses, err := allocate(t, s, network, gateway)
+ if err != nil {
+ errs = append(errs, err)
+ continue
+ }
+
+ for nodeIndex, n := range t.Nodes {
+ address, ok := addresses[n.Name]
+ if !ok {
+ continue
+ }
+ cable := len(p.Cables)
+ p.Cables = append(p.Cables, Cable{
+ Segment: s.Name,
+ MTU: s.MTU,
+ Node: n.Name,
+ NodeInterface: s.Name,
+ NodeMAC: mac(nodeIndex, segIndex, 0),
+ NodeAddress: netip.PrefixFrom(address, network.Bits()),
+ NodePort: CableBasePort + 2*cable,
+ Switch: s.Switch,
+ SwitchInterface: "p" + strconv.Itoa(cable),
+ SwitchMAC: mac(nodeIndex, segIndex, 1),
+ SwitchPort: CableBasePort + 2*cable + 1,
+ })
+ }
+ }
+
+ if last := CableBasePort + 2*len(p.Cables) - 1; last > 65535 {
+ errs = append(errs, fmt.Errorf("cables: %d cables need udp ports up to %d, beyond 65535", len(p.Cables), last))
+ }
+ if len(errs) > 0 {
+ return nil, errors.Join(errs...)
+ }
+ return p, nil
+}
+
+func allocate(t *Topology, s Segment, network netip.Prefix, gateway netip.Addr) (map[string]netip.Addr, error) {
+ broadcast := lastAddr(network)
+ used := map[netip.Addr]string{gateway: s.Switch + " (gateway)"}
+ result := map[string]netip.Addr{}
+ var errs []error
+
+ var auto []string
+ for _, n := range t.Nodes {
+ if !contains(n.Segments, s.Name) {
+ continue
+ }
+ raw, ok := n.Addresses[s.Name]
+ if !ok {
+ auto = append(auto, n.Name)
+ continue
+ }
+ addr := netip.MustParseAddr(raw)
+ switch {
+ case !network.Contains(addr):
+ errs = append(errs, fmt.Errorf("node %s: address %s is outside segment %s (%s)", n.Name, addr, s.Name, network))
+ case addr == network.Addr() || addr == broadcast:
+ errs = append(errs, fmt.Errorf("node %s: address %s is the network or broadcast address of segment %s", n.Name, addr, s.Name))
+ case used[addr] != "":
+ errs = append(errs, fmt.Errorf("node %s: address %s on segment %s is already taken by %s", n.Name, addr, s.Name, used[addr]))
+ default:
+ used[addr] = n.Name
+ result[n.Name] = addr
+ }
+ }
+
+ next := gateway.Next()
+ for _, name := range auto {
+ for next != broadcast && used[next] != "" {
+ next = next.Next()
+ }
+ if next == broadcast {
+ errs = append(errs, fmt.Errorf("segment %s: no address left in %s for node %s", s.Name, network, name))
+ break
+ }
+ used[next] = name
+ result[name] = next
+ next = next.Next()
+ }
+
+ if len(errs) > 0 {
+ return nil, errors.Join(errs...)
+ }
+ return result, nil
+}
+
+func lastAddr(network netip.Prefix) netip.Addr {
+ a := network.Addr().As4()
+ host := uint32(1)<<(32-network.Bits()) - 1
+ v := (uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3])) | host
+ return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)})
+}
+
+func mac(node, segment, side int) net.HardwareAddr {
+ return net.HardwareAddr{0x02, 0x4c, byte(node >> 8), byte(node), byte(segment), byte(side)}
+}
+
+func contains(list []string, value string) bool {
+ for _, v := range list {
+ if v == value {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go
new file mode 100644
index 0000000..c8d4e05
--- /dev/null
+++ b/internal/lab/topology/plan_test.go
@@ -0,0 +1,364 @@
+package topology
+
+import (
+ "bytes"
+ "fmt"
+ "net/netip"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func compute(t *testing.T, doc string) *Plan {
+ t.Helper()
+ p, err := Compute(parse(t, doc))
+ if err != nil {
+ t.Fatalf("Compute: %v", err)
+ }
+ return p
+}
+
+func computeError(t *testing.T, doc string) string {
+ t.Helper()
+ _, err := Compute(parse(t, doc))
+ if err == nil {
+ t.Fatalf("Compute accepted:\n%s", doc)
+ }
+ return err.Error()
+}
+
+func cableOf(t *testing.T, p *Plan, node, segment string) Cable {
+ t.Helper()
+ for _, c := range p.Cables {
+ if c.Node == node && c.Segment == segment {
+ return c
+ }
+ }
+ t.Fatalf("no cable for %s on %s", node, segment)
+ return Cable{}
+}
+
+func nodeOf(t *testing.T, p *Plan, name string) NodePlan {
+ t.Helper()
+ for _, n := range p.Nodes {
+ if n.Name == name {
+ return n
+ }
+ }
+ t.Fatalf("no node %s", name)
+ return NodePlan{}
+}
+
+const twoHypervisors = header + `
+segments:
+ underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 }
+nodes:
+ sw1: { role: switch, image: deb, cpus: 2, memory: 1024 }
+ rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] }
+ hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
+ hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 }
+`
+
+func TestCompute_TwoHypervisorsPlan(t *testing.T) {
+ p := compute(t, twoHypervisors)
+
+ if len(p.Segments) != 1 {
+ t.Fatalf("%d segments, want 1", len(p.Segments))
+ }
+ s := p.Segments[0]
+ if s.Bridge != "br-underlay" || s.Gateway != netip.MustParseAddr("10.250.0.1") || s.MTU != 9000 || s.Switch != "sw1" {
+ t.Errorf("segment = %+v", s)
+ }
+
+ want := []struct {
+ node, address, nodeMAC, switchMAC, switchIface string
+ nodePort, switchPort int
+ }{
+ {"rr1", "10.250.0.2/24", "02:4c:00:01:00:00", "02:4c:00:01:00:01", "p0", 20000, 20001},
+ {"hv1", "10.250.0.3/24", "02:4c:00:02:00:00", "02:4c:00:02:00:01", "p1", 20002, 20003},
+ {"hv2", "10.250.0.4/24", "02:4c:00:03:00:00", "02:4c:00:03:00:01", "p2", 20004, 20005},
+ }
+ if len(p.Cables) != len(want) {
+ t.Fatalf("%d cables, want %d", len(p.Cables), len(want))
+ }
+ for _, w := range want {
+ c := cableOf(t, p, w.node, "underlay")
+ if c.NodeAddress.String() != w.address {
+ t.Errorf("%s address = %s, want %s", w.node, c.NodeAddress, w.address)
+ }
+ if c.NodeMAC.String() != w.nodeMAC || c.SwitchMAC.String() != w.switchMAC {
+ t.Errorf("%s macs = %s / %s, want %s / %s", w.node, c.NodeMAC, c.SwitchMAC, w.nodeMAC, w.switchMAC)
+ }
+ if c.NodePort != w.nodePort || c.SwitchPort != w.switchPort {
+ t.Errorf("%s ports = %d / %d, want %d / %d", w.node, c.NodePort, c.SwitchPort, w.nodePort, w.switchPort)
+ }
+ if c.NodeInterface != "underlay" || c.SwitchInterface != w.switchIface || c.Switch != "sw1" || c.MTU != 9000 {
+ t.Errorf("%s cable = %+v", w.node, c)
+ }
+ }
+
+ for name, port := range map[string]int{"sw1": 2200, "rr1": 2201, "hv1": 2202, "hv2": 2203} {
+ if got := nodeOf(t, p, name).SSHPort; got != port {
+ t.Errorf("%s ssh port = %d, want %d", name, got, port)
+ }
+ }
+ if hv := nodeOf(t, p, "hv1"); hv.Role != "hypervisor" || hv.CPUs != 4 || hv.Memory != 16384 || hv.Image != "deb" {
+ t.Errorf("hv1 = %+v", hv)
+ }
+}
+
+func TestCompute_CarriesTheDeclaredImages(t *testing.T) {
+ p := compute(t, twoHypervisors)
+ want := []Image{{Name: "deb", URL: "https://example.invalid/deb.qcow2", Sums: "https://example.invalid/SHA512SUMS"}}
+ if !reflect.DeepEqual(p.Images, want) {
+ t.Errorf("images = %+v, want %+v", p.Images, want)
+ }
+}
+
+func TestCompute_IsStableAcrossRuns(t *testing.T) {
+ first := compute(t, twoHypervisors)
+ for i := 0; i < 20; i++ {
+ if again := compute(t, twoHypervisors); !reflect.DeepEqual(first, again) {
+ t.Fatalf("plan differs on run %d", i)
+ }
+ }
+}
+
+func TestCompute_AddressesFollowDeclarationOrderNotNames(t *testing.T) {
+ p := compute(t, header+`
+segments:
+ under: { switch: sw, cidr: 10.0.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ zeta: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] }
+ alfa: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] }
+`)
+ if got := cableOf(t, p, "zeta", "under").NodeAddress.String(); got != "10.0.0.2/24" {
+ t.Errorf("zeta = %s, want 10.0.0.2/24", got)
+ }
+ if got := cableOf(t, p, "alfa", "under").NodeAddress.String(); got != "10.0.0.3/24" {
+ t.Errorf("alfa = %s, want 10.0.0.3/24", got)
+ }
+}
+
+func TestCompute_ExplicitAddressIsKeptAndSkippedByAllocation(t *testing.T) {
+ p := compute(t, header+`
+segments:
+ under: { switch: sw, cidr: 10.0.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] }
+ b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under], addresses: {under: 10.0.0.3} }
+ c: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] }
+`)
+ for node, want := range map[string]string{"a": "10.0.0.2/24", "b": "10.0.0.3/24", "c": "10.0.0.4/24"} {
+ if got := cableOf(t, p, node, "under").NodeAddress.String(); got != want {
+ t.Errorf("%s = %s, want %s", node, got, want)
+ }
+ }
+}
+
+func TestCompute_TwoSegmentsCableOrderMacsAndPorts(t *testing.T) {
+ p := compute(t, header+`
+segments:
+ red: { switch: sw, cidr: 10.1.0.0/24 }
+ blue: { switch: sw, cidr: 10.2.0.0/24, mtu: 1500 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 }
+ rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red] }
+`)
+ got := make([]string, 0, len(p.Cables))
+ for _, c := range p.Cables {
+ got = append(got, c.Segment+"/"+c.Node+"/"+c.SwitchInterface)
+ }
+ if strings.Join(got, ",") != "red/hv/p0,red/rr/p1,blue/hv/p2" {
+ t.Errorf("cable order = %v", got)
+ }
+ blue := cableOf(t, p, "hv", "blue")
+ if blue.NodeMAC.String() != "02:4c:00:01:01:00" || blue.NodePort != 20004 || blue.MTU != 1500 || blue.NodeAddress.String() != "10.2.0.2/24" {
+ t.Errorf("hv on blue = %+v", blue)
+ }
+ if red := cableOf(t, p, "hv", "red"); red.NodeMAC.String() != "02:4c:00:01:00:00" {
+ t.Errorf("hv on red mac = %s", red.NodeMAC)
+ }
+}
+
+func TestCompute_EveryMacAndPortIsUnique(t *testing.T) {
+ p := compute(t, header+`
+segments:
+ a: { switch: sw, cidr: 10.1.0.0/24 }
+ b: { switch: sw, cidr: 10.2.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ n1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [a, b] }
+ n2: { role: rr, image: deb, cpus: 1, memory: 512, segments: [a, b] }
+ n3: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [b, a], release: 0.2.0rc002 }
+`)
+ macs := map[string]bool{}
+ ports := map[int]bool{}
+ for _, n := range p.Nodes {
+ ports[n.SSHPort] = true
+ }
+ for _, c := range p.Cables {
+ for _, m := range []string{c.NodeMAC.String(), c.SwitchMAC.String()} {
+ if macs[m] {
+ t.Errorf("mac %s used twice", m)
+ }
+ macs[m] = true
+ }
+ for _, port := range []int{c.NodePort, c.SwitchPort} {
+ if ports[port] {
+ t.Errorf("port %d used twice", port)
+ }
+ ports[port] = true
+ }
+ }
+ if len(macs) != 12 {
+ t.Errorf("%d distinct macs, want 12", len(macs))
+ }
+}
+
+func TestCompute_MacsAreLocallyAdministeredUnicast(t *testing.T) {
+ for _, c := range compute(t, twoHypervisors).Cables {
+ for _, m := range []string{c.NodeMAC.String(), c.SwitchMAC.String()} {
+ if !strings.HasPrefix(m, "02:") {
+ t.Errorf("mac %s is not locally administered unicast", m)
+ }
+ }
+ }
+}
+
+func TestCompute_AddressRejections(t *testing.T) {
+ base := func(addresses string) string {
+ return header + `
+segments:
+ under: { switch: sw, cidr: 10.0.0.0/29 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under]` + addresses + ` }
+ b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under], addresses: {under: 10.0.0.5} }
+`
+ }
+ cases := []struct{ name, addresses, want string }{
+ {"outside", ", addresses: {under: 10.0.1.2}", "node a: address 10.0.1.2 is outside segment under"},
+ {"network", ", addresses: {under: 10.0.0.0}", "node a: address 10.0.0.0 is the network or broadcast"},
+ {"broadcast", ", addresses: {under: 10.0.0.7}", "node a: address 10.0.0.7 is the network or broadcast"},
+ {"gateway", ", addresses: {under: 10.0.0.1}", "node a: address 10.0.0.1 on segment under is already taken by sw (gateway)"},
+ {"duplicate", ", addresses: {under: 10.0.0.5}", "node b: address 10.0.0.5 on segment under is already taken by a"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ requireContains(t, computeError(t, base(c.addresses)), c.want)
+ })
+ }
+}
+
+func TestCompute_ReportsExhaustedSegment(t *testing.T) {
+ got := computeError(t, header+`
+segments:
+ tiny: { switch: sw, cidr: 10.0.0.0/30 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] }
+ b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] }
+`)
+ requireContains(t, got, "segment tiny: no address left in 10.0.0.0/30 for node b")
+}
+
+func TestCompute_FillsSegmentExactly(t *testing.T) {
+ p := compute(t, header+`
+segments:
+ tiny: { switch: sw, cidr: 10.0.0.0/29 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] }
+ b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] }
+ c: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] }
+ d: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] }
+ e: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] }
+`)
+ if got := cableOf(t, p, "e", "tiny").NodeAddress.String(); got != "10.0.0.6/29" {
+ t.Errorf("last node = %s, want 10.0.0.6/29", got)
+ }
+}
+
+func TestCompute_RefusesInvalidTopology(t *testing.T) {
+ requireContains(t, computeError(t, strings.Replace(valid, "role: rr", "role: router", 1)), `role "router"`)
+}
+
+func TestLastAddr(t *testing.T) {
+ for cidr, want := range map[string]string{
+ "10.0.0.0/24": "10.0.0.255",
+ "10.0.0.0/30": "10.0.0.3",
+ "10.250.0.0/16": "10.250.255.255",
+ "192.168.4.8/29": "192.168.4.15",
+ } {
+ if got := lastAddr(netip.MustParsePrefix(cidr)).String(); got != want {
+ t.Errorf("lastAddr(%s) = %s, want %s", cidr, got, want)
+ }
+ }
+}
+
+func TestWrite_TwoHypervisorsPlan(t *testing.T) {
+ var buf bytes.Buffer
+ if err := compute(t, twoHypervisors).Write(&buf); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ want := `lab lab-test: nodes 4, segments 1, cables 3
+
+nodes
+ name role image cpus memory ssh
+ sw1 switch deb 2 1024 MiB 127.0.0.1:2200
+ rr1 rr deb 1 1024 MiB 127.0.0.1:2201
+ hv1 hypervisor deb 4 16384 MiB 127.0.0.1:2202
+ hv2 hypervisor deb 4 16384 MiB 127.0.0.1:2203
+
+roles
+ name loopback secondary frr release agent
+ hv1 - - - 0.2.0rc002 -
+ hv2 - - - 0.2.0rc002 -
+
+segment underlay: 10.250.0.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 10.250.0.1
+ node interface address mac udp switch port mac udp
+ rr1 underlay 10.250.0.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001
+ hv1 underlay 10.250.0.3/24 02:4c:00:02:00:00 20002 <-> sw1 p1 02:4c:00:02:00:01 20003
+ hv2 underlay 10.250.0.4/24 02:4c:00:03:00:00 20004 <-> sw1 p2 02:4c:00:03:00:01 20005
+`
+ if buf.String() != want {
+ t.Errorf("output:\n%s\nwant:\n%s", buf.String(), want)
+ }
+}
+
+func generated(segments, nodes int) string {
+ var b strings.Builder
+ b.WriteString(header + "segments:\n")
+ names := make([]string, segments)
+ for i := range names {
+ names[i] = fmt.Sprintf("s%d", i)
+ fmt.Fprintf(&b, " %s: { switch: sw, cidr: 10.%d.0.0/16 }\n", names[i], i%250)
+ }
+ b.WriteString("nodes:\n sw: { role: switch, image: deb, cpus: 1, memory: 512 }\n")
+ for i := 1; i < nodes; i++ {
+ fmt.Fprintf(&b, " n%d: { role: rr, image: deb, cpus: 1, memory: 512, segments: [%s] }\n", i, strings.Join(names, ", "))
+ }
+ return b.String()
+}
+
+func TestValidate_RejectsTooManyNodes(t *testing.T) {
+ requireContains(t, validationError(t, generated(1, 1001)), "nodes: 1001 declared, at most 1000")
+}
+
+func TestValidate_AcceptsExactlyMaxNodes(t *testing.T) {
+ if err := parse(t, generated(1, 1000)).Validate(); err != nil {
+ t.Fatalf("1000 nodes rejected: %v", err)
+ }
+}
+
+func TestValidate_RejectsTooManySegments(t *testing.T) {
+ requireContains(t, validationError(t, generated(257, 2)), "segments: 257 declared, at most 256")
+}
+
+func TestCompute_RejectsCablesBeyondUDPPortRange(t *testing.T) {
+ requireContains(t, computeError(t, generated(23, 1000)), "cables: 22977 cables need udp ports up to 65953, beyond 65535")
+}
diff --git a/internal/lab/topology/roles_test.go b/internal/lab/topology/roles_test.go
new file mode 100644
index 0000000..d3e209c
--- /dev/null
+++ b/internal/lab/topology/roles_test.go
@@ -0,0 +1,265 @@
+package topology
+
+import (
+ "bytes"
+ "net/netip"
+ "os"
+ "path/filepath"
+ "reflect"
+ "testing"
+)
+
+const withRoles = header + `
+segments:
+ underlay: { switch: sw1, cidr: 192.168.14.0/24 }
+nodes:
+ sw1: { role: switch, image: deb, cpus: 2, memory: 1024, secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf }
+ rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay], secondary: { underlay: [169.254.0.3/28] }, loopback: 10.255.255.1/32, frr: frr/rr1.conf }
+ hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: /etc/lab/hv1.conf, release: 0.2.0rc002 }
+`
+
+func TestCompute_CarriesTheRoleFields(t *testing.T) {
+ p := compute(t, withRoles)
+
+ rr1 := nodeOf(t, p, "rr1")
+ if !reflect.DeepEqual(rr1.Secondary, map[string][]netip.Prefix{"underlay": {netip.MustParsePrefix("169.254.0.3/28")}}) {
+ t.Errorf("rr1 secondary = %v", rr1.Secondary)
+ }
+ if rr1.Loopback != netip.MustParsePrefix("10.255.255.1/32") {
+ t.Errorf("rr1 loopback = %v", rr1.Loopback)
+ }
+ if rr1.FRR != "frr/rr1.conf" {
+ t.Errorf("rr1 frr = %q", rr1.FRR)
+ }
+ hv1 := nodeOf(t, p, "hv1")
+ if hv1.Secondary != nil || hv1.Loopback.IsValid() {
+ t.Errorf("hv1 = %+v, want no secondary and no loopback", hv1)
+ }
+}
+
+func TestLoad_ResolvesFRRPathsAgainstTheTopologyFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "lab.yml")
+ if err := os.WriteFile(path, []byte(withRoles), 0o600); err != nil {
+ t.Fatal(err)
+ }
+
+ topo, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ want := map[string]string{
+ "sw1": filepath.Join(dir, "frr", "sw1.conf"),
+ "rr1": filepath.Join(dir, "frr", "rr1.conf"),
+ "hv1": "/etc/lab/hv1.conf",
+ }
+ for _, n := range topo.Nodes {
+ if n.FRR != want[n.Name] {
+ t.Errorf("%s frr = %q, want %q", n.Name, n.FRR, want[n.Name])
+ }
+ }
+}
+
+func TestValidate_RoleFieldRejections(t *testing.T) {
+ cases := map[string]struct {
+ node string
+ want string
+ }{
+ "secondary on a segment not attached": {
+ `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { blue: [169.254.0.3/28] } }`,
+ "node rr1: secondary address given for segment blue it is not attached to",
+ },
+ "secondary without prefix length": {
+ `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [169.254.0.3] } }`,
+ `node rr1: secondary address "169.254.0.3" on red`,
+ },
+ "secondary in IPv6": {
+ `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: ["fd00::3/64"] } }`,
+ "node rr1: secondary address fd00::3/64 on red is not IPv4",
+ },
+ "secondary inside the segment": {
+ `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [10.1.0.9/24] } }`,
+ "node rr1: secondary address 10.1.0.9/24 is inside segment red (10.1.0.0/24), use addresses instead",
+ },
+ "loopback without prefix length": {
+ `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: 10.255.255.1 }`,
+ `node rr1: loopback "10.255.255.1"`,
+ },
+ "loopback in IPv6": {
+ `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: "fd00::1/128" }`,
+ "node rr1: loopback fd00::1/128 is not IPv4",
+ },
+ }
+ for name, c := range cases {
+ t.Run(name, func(t *testing.T) {
+ doc := header + `
+segments:
+ red: { switch: sw, cidr: 10.1.0.0/24 }
+ blue: { switch: sw, cidr: 10.2.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 }
+ ` + c.node + `
+`
+ requireContains(t, validationError(t, doc), c.want)
+ })
+ }
+}
+
+func TestValidate_SwitchSecondaryOnlyOnItsOwnSegments(t *testing.T) {
+ doc := header + `
+segments:
+ red: { switch: sw, cidr: 10.1.0.0/24 }
+ blue: { switch: other, cidr: 10.2.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512, secondary: { red: [169.254.0.1/28], blue: [169.254.1.1/28] } }
+ other: { role: switch, image: deb, cpus: 1, memory: 512 }
+ hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 }
+`
+ msg := validationError(t, doc)
+ requireContains(t, msg, "node sw: secondary address given for segment blue it is not attached to")
+ if bytes.Contains([]byte(msg), []byte("segment red")) {
+ t.Errorf("the switch's own segment was refused:\n%s", msg)
+ }
+}
+
+func TestValidate_LoopbackInterfaceNameIsReserved(t *testing.T) {
+ doc := header + `
+segments:
+ lo1: { switch: sw, cidr: 10.1.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [lo1], release: 0.2.0rc002 }
+`
+ requireContains(t, validationError(t, doc), "segment lo1: name is reserved for the loopback interface")
+}
+
+func TestWrite_ShowsTheRoles(t *testing.T) {
+ var buf bytes.Buffer
+ if err := compute(t, withRoles).Write(&buf); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ want := `
+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.0rc002 -
+`
+ if !bytes.Contains(buf.Bytes(), []byte(want)) {
+ t.Errorf("plan:\n%s\ndoes not contain:\n%s", buf.String(), want)
+ }
+}
+
+func TestWrite_NoRolesSectionWithoutRoleFields(t *testing.T) {
+ var buf bytes.Buffer
+ doc := header + `
+segments:
+ underlay: { switch: sw1, cidr: 10.250.0.0/24 }
+nodes:
+ sw1: { role: switch, image: deb, cpus: 1, memory: 512 }
+ rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [underlay] }
+`
+ if err := compute(t, doc).Write(&buf); err != nil {
+ t.Fatalf("Write: %v", err)
+ }
+ if bytes.Contains(buf.Bytes(), []byte("roles")) {
+ t.Errorf("plan shows a roles section:\n%s", buf.String())
+ }
+}
+
+func TestValidate_ReleaseRejections(t *testing.T) {
+ cases := map[string]struct {
+ node string
+ want string
+ }{
+ "hypervisor without release": {
+ `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red] }`,
+ "node hv: a hypervisor needs the release of two to deploy (release: )",
+ },
+ "release with shell characters": {
+ `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: "0.2.0; reboot" }`,
+ `node hv: release "0.2.0; reboot" must match`,
+ },
+ "release on a route reflector": {
+ `hv: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 }`,
+ "node hv: release is only for hypervisors",
+ },
+ "release on a switch": {
+ `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 }
+ sw2: { role: switch, image: deb, cpus: 1, memory: 512, release: 0.2.0rc002 }`,
+ "node sw2: release is only for hypervisors",
+ },
+ }
+ for name, c := range cases {
+ t.Run(name, func(t *testing.T) {
+ doc := header + `
+segments:
+ red: { switch: sw, cidr: 10.1.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ ` + c.node + `
+`
+ requireContains(t, validationError(t, doc), c.want)
+ })
+ }
+}
+
+func TestCompute_CarriesTheRelease(t *testing.T) {
+ if got := nodeOf(t, compute(t, withRoles), "hv1").Release; got != "0.2.0rc002" {
+ t.Errorf("hv1 release = %q", got)
+ }
+ if got := nodeOf(t, compute(t, withRoles), "rr1").Release; got != "" {
+ t.Errorf("rr1 release = %q", got)
+ }
+}
+
+func TestValidate_AgentOnlyForHypervisors(t *testing.T) {
+ doc := header + `
+segments:
+ red: { switch: sw, cidr: 10.1.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], agent: agent.yml }
+ hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: agent.yml }
+`
+ msg := validationError(t, doc)
+ requireContains(t, msg, "node rr: agent is only for hypervisors")
+ if bytes.Contains([]byte(msg), []byte("node hv: agent")) {
+ t.Errorf("a hypervisor agent was refused:\n%s", msg)
+ }
+}
+
+func TestLoad_ResolvesTheAgentPathAgainstTheTopologyFile(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "lab.yml")
+ doc := header + `
+segments:
+ red: { switch: sw, cidr: 10.1.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: agent/two.yml }
+ hv2: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: /etc/lab/agent.yml }
+`
+ if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ topo, err := Load(path)
+ if err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ want := map[string]string{"sw": "", "hv1": filepath.Join(dir, "agent", "two.yml"), "hv2": "/etc/lab/agent.yml"}
+ for _, n := range topo.Nodes {
+ if n.Agent != want[n.Name] {
+ t.Errorf("%s agent = %q, want %q", n.Name, n.Agent, want[n.Name])
+ }
+ }
+ p, err := Compute(topo)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := nodeOf(t, p, "hv1").Agent; got != filepath.Join(dir, "agent", "two.yml") {
+ t.Errorf("plan hv1 agent = %q", got)
+ }
+}
diff --git a/internal/lab/topology/topology.go b/internal/lab/topology/topology.go
new file mode 100644
index 0000000..66337d8
--- /dev/null
+++ b/internal/lab/topology/topology.go
@@ -0,0 +1,175 @@
+package topology
+
+import (
+ "bytes"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+
+ "go.yaml.in/yaml/v3"
+)
+
+const (
+ RoleSwitch = "switch"
+ RoleRR = "rr"
+ RoleHypervisor = "hypervisor"
+
+ DefaultMTU = 9000
+)
+
+type Topology struct {
+ Name string
+ Images []Image
+ Segments []Segment
+ Nodes []Node
+}
+
+type Image struct {
+ Name string
+ URL string
+ Sums string
+}
+
+type Segment struct {
+ Name string
+ Switch string
+ CIDR string
+ MTU int
+}
+
+type Node struct {
+ Name string
+ Role string
+ Image string
+ CPUs int
+ Memory int
+ Segments []string
+ Addresses map[string]string
+ Secondary map[string][]string
+ Loopback string
+ FRR string
+ Release string
+ Agent string
+}
+
+type fileImage struct {
+ URL string `yaml:"url"`
+ Sums string `yaml:"sums"`
+}
+
+type fileSegment struct {
+ Switch string `yaml:"switch"`
+ CIDR string `yaml:"cidr"`
+ MTU int `yaml:"mtu"`
+}
+
+type fileNode struct {
+ Role string `yaml:"role"`
+ Image string `yaml:"image"`
+ CPUs int `yaml:"cpus"`
+ Memory int `yaml:"memory"`
+ Segments []string `yaml:"segments"`
+ Addresses map[string]string `yaml:"addresses"`
+ Secondary map[string][]string `yaml:"secondary"`
+ Loopback string `yaml:"loopback"`
+ FRR string `yaml:"frr"`
+ Release string `yaml:"release"`
+ Agent string `yaml:"agent"`
+}
+
+type file struct {
+ Name string `yaml:"name"`
+ Images map[string]fileImage `yaml:"images"`
+ Segments map[string]fileSegment `yaml:"segments"`
+ Nodes map[string]fileNode `yaml:"nodes"`
+}
+
+func Load(path string) (*Topology, error) {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+ t, err := Parse(data)
+ if err != nil {
+ return nil, fmt.Errorf("%s: %w", path, err)
+ }
+ for i, n := range t.Nodes {
+ if n.FRR != "" && !filepath.IsAbs(n.FRR) {
+ t.Nodes[i].FRR = filepath.Join(filepath.Dir(path), n.FRR)
+ }
+ if n.Agent != "" && !filepath.IsAbs(n.Agent) {
+ t.Nodes[i].Agent = filepath.Join(filepath.Dir(path), n.Agent)
+ }
+ }
+ return t, nil
+}
+
+func Parse(data []byte) (*Topology, error) {
+ var f file
+ dec := yaml.NewDecoder(bytes.NewReader(data))
+ dec.KnownFields(true)
+ if err := dec.Decode(&f); err != nil {
+ return nil, fmt.Errorf("yaml: %w", err)
+ }
+
+ var root yaml.Node
+ if err := yaml.Unmarshal(data, &root); err != nil {
+ return nil, fmt.Errorf("yaml: %w", err)
+ }
+ order, err := keyOrder(&root)
+ if err != nil {
+ return nil, err
+ }
+
+ t := &Topology{Name: f.Name}
+ for _, name := range order["images"] {
+ i := f.Images[name]
+ t.Images = append(t.Images, Image{Name: name, URL: i.URL, Sums: i.Sums})
+ }
+ for _, name := range order["segments"] {
+ s := f.Segments[name]
+ mtu := s.MTU
+ if mtu == 0 {
+ mtu = DefaultMTU
+ }
+ t.Segments = append(t.Segments, Segment{Name: name, Switch: s.Switch, CIDR: s.CIDR, MTU: mtu})
+ }
+ for _, name := range order["nodes"] {
+ n := f.Nodes[name]
+ t.Nodes = append(t.Nodes, Node{
+ Name: name,
+ Role: n.Role,
+ Image: n.Image,
+ CPUs: n.CPUs,
+ Memory: n.Memory,
+ Segments: n.Segments,
+ Addresses: n.Addresses,
+ Secondary: n.Secondary,
+ Loopback: n.Loopback,
+ FRR: n.FRR,
+ Release: n.Release,
+ Agent: n.Agent,
+ })
+ }
+ return t, nil
+}
+
+func keyOrder(root *yaml.Node) (map[string][]string, error) {
+ order := map[string][]string{}
+ if root.Kind != yaml.DocumentNode || len(root.Content) != 1 || root.Content[0].Kind != yaml.MappingNode {
+ return nil, errors.New("yaml: the document must be a mapping")
+ }
+ top := root.Content[0]
+ for i := 0; i+1 < len(top.Content); i += 2 {
+ section := top.Content[i].Value
+ value := top.Content[i+1]
+ if value.Kind != yaml.MappingNode {
+ continue
+ }
+ for j := 0; j+1 < len(value.Content); j += 2 {
+ order[section] = append(order[section], value.Content[j].Value)
+ }
+ }
+ return order, nil
+}
diff --git a/internal/lab/topology/topology_test.go b/internal/lab/topology/topology_test.go
new file mode 100644
index 0000000..d050a0f
--- /dev/null
+++ b/internal/lab/topology/topology_test.go
@@ -0,0 +1,173 @@
+package topology
+
+import (
+ "strings"
+ "testing"
+)
+
+const header = `name: lab-test
+images:
+ deb:
+ url: https://example.invalid/deb.qcow2
+ sums: https://example.invalid/SHA512SUMS
+`
+
+func parse(t *testing.T, doc string) *Topology {
+ t.Helper()
+ topo, err := Parse([]byte(doc))
+ if err != nil {
+ t.Fatalf("Parse: %v", err)
+ }
+ return topo
+}
+
+func validationError(t *testing.T, doc string) string {
+ t.Helper()
+ err := parse(t, doc).Validate()
+ if err == nil {
+ t.Fatalf("Validate accepted an invalid topology:\n%s", doc)
+ }
+ return err.Error()
+}
+
+func requireContains(t *testing.T, got, want string) {
+ t.Helper()
+ if !strings.Contains(got, want) {
+ t.Fatalf("error does not mention %q:\n%s", want, got)
+ }
+}
+
+func TestParse_KeepsDeclarationOrderOfNodesAndSegments(t *testing.T) {
+ topo := parse(t, header+`
+segments:
+ zulu: { switch: sw, cidr: 10.0.1.0/24 }
+ alpha: { switch: sw, cidr: 10.0.2.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ zeta: { role: rr, image: deb, cpus: 1, memory: 512, segments: [zulu, alpha] }
+ beta: { role: rr, image: deb, cpus: 1, memory: 512, segments: [zulu] }
+`)
+ var nodes, segments []string
+ for _, n := range topo.Nodes {
+ nodes = append(nodes, n.Name)
+ }
+ for _, s := range topo.Segments {
+ segments = append(segments, s.Name)
+ }
+ if got := strings.Join(nodes, ","); got != "sw,zeta,beta" {
+ t.Errorf("node order = %s, want sw,zeta,beta", got)
+ }
+ if got := strings.Join(segments, ","); got != "zulu,alpha" {
+ t.Errorf("segment order = %s, want zulu,alpha", got)
+ }
+}
+
+func TestParse_DefaultsMTUTo9000(t *testing.T) {
+ topo := parse(t, header+`
+segments:
+ under: { switch: sw, cidr: 10.0.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+`)
+ if topo.Segments[0].MTU != 9000 {
+ t.Errorf("MTU = %d, want 9000", topo.Segments[0].MTU)
+ }
+}
+
+func TestParse_RejectsUnknownField(t *testing.T) {
+ _, err := Parse([]byte(header + `
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512, ram: 4 }
+`))
+ if err == nil || !strings.Contains(err.Error(), "ram") {
+ t.Fatalf("unknown field not rejected: %v", err)
+ }
+}
+
+func TestParse_RejectsDuplicateNode(t *testing.T) {
+ _, err := Parse([]byte(header + `
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ sw: { role: rr, image: deb, cpus: 1, memory: 512 }
+`))
+ if err == nil || !strings.Contains(err.Error(), "already defined") {
+ t.Fatalf("duplicate node not rejected: %v", err)
+ }
+}
+
+func TestParse_RejectsNonMappingDocument(t *testing.T) {
+ if _, err := Parse([]byte("- a\n- b\n")); err == nil {
+ t.Fatal("a list document was accepted")
+ }
+}
+
+func TestLoad_ReportsPathOnError(t *testing.T) {
+ if _, err := Load("/nonexistent/lab.yml"); err == nil {
+ t.Fatal("missing file accepted")
+ }
+}
+
+const valid = header + `
+segments:
+ under: { switch: sw, cidr: 10.0.0.0/24 }
+nodes:
+ sw: { role: switch, image: deb, cpus: 1, memory: 512 }
+ rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] }
+`
+
+func TestValidate_AcceptsMinimalTopology(t *testing.T) {
+ if err := parse(t, valid).Validate(); err != nil {
+ t.Fatalf("Validate: %v", err)
+ }
+}
+
+func TestValidate_Rejections(t *testing.T) {
+ cases := []struct {
+ name, doc, want string
+ }{
+ {"bad lab name", strings.Replace(valid, "name: lab-test", "name: Lab_Test", 1), `name "Lab_Test"`},
+ {"image url not https", strings.Replace(valid, "url: https://", "url: http://", 1), "image deb: url"},
+ {"image sums not https", strings.Replace(valid, "sums: https://", "sums: ftp://", 1), "image deb: sums"},
+ {"no node", header + "\nsegments: {}\nnodes: {}\n", "at least one node"},
+ {"segment switch missing", strings.Replace(valid, "switch: sw, cidr", "cidr", 1), "segment under: switch is required"},
+ {"segment switch unknown", strings.Replace(valid, "switch: sw, cidr", "switch: ghost, cidr", 1), "switch ghost is not a declared node"},
+ {"segment switch not a switch", strings.Replace(valid, "switch: sw, cidr", "switch: rr, cidr", 1), "rr is a rr, not a switch"},
+ {"segment name too long", strings.ReplaceAll(valid, "under", "underlayunder"), "segment underlayunder: name must match"},
+ {"segment named like the admin interface", strings.ReplaceAll(valid, "under", "mgmt0"), "segment mgmt0: name is reserved"},
+ {"segment name with dash", strings.ReplaceAll(valid, "under", "un-der"), "segment un-der: name must match"},
+ {"mtu too high", strings.Replace(valid, "cidr: 10.0.0.0/24", "cidr: 10.0.0.0/24, mtu: 9001", 1), "mtu 9001 out of range"},
+ {"mtu too low", strings.Replace(valid, "cidr: 10.0.0.0/24", "cidr: 10.0.0.0/24, mtu: 1279", 1), "mtu 1279 out of range"},
+ {"cidr unparsable", strings.Replace(valid, "10.0.0.0/24", "10.0.0/24", 1), `cidr "10.0.0/24"`},
+ {"cidr ipv6", strings.Replace(valid, "10.0.0.0/24", "fd00::/64", 1), "is not IPv4"},
+ {"cidr host bits", strings.Replace(valid, "10.0.0.0/24", "10.0.0.5/24", 1), "network is 10.0.0.0/24"},
+ {"cidr too small", strings.Replace(valid, "10.0.0.0/24", "10.0.0.0/31", 1), "prefix length out of range"},
+ {"cidr too large", strings.Replace(valid, "10.0.0.0/24", "10.0.0.0/7", 1), "prefix length out of range"},
+ {"bad node name", strings.Replace(valid, " rr: {", " RR: {", 1), "node RR: name must match"},
+ {"bad role", strings.Replace(valid, "role: rr", "role: router", 1), `role "router"`},
+ {"unknown image", strings.Replace(valid, "role: rr, image: deb", "role: rr, image: ubuntu", 1), `image "ubuntu" is not declared`},
+ {"no cpu", strings.Replace(valid, "role: rr, image: deb, cpus: 1", "role: rr, image: deb, cpus: 0", 1), "node rr: cpus must be at least 1"},
+ {"memory too low", strings.Replace(valid, "cpus: 1, memory: 512, segments", "cpus: 1, memory: 255, segments", 1), "node rr: memory must be at least 256"},
+ {"switch with segments", strings.Replace(valid, "role: switch, image: deb, cpus: 1, memory: 512 }", "role: switch, image: deb, cpus: 1, memory: 512, segments: [under] }", 1), "node sw: a switch carries its segments"},
+ {"switch with addresses", strings.Replace(valid, "role: switch, image: deb, cpus: 1, memory: 512 }", "role: switch, image: deb, cpus: 1, memory: 512, addresses: {under: 10.0.0.9} }", 1), "node sw: a switch carries its segments"},
+ {"switch without segment", valid + " sw2: { role: switch, image: deb, cpus: 1, memory: 512 }\n", "node sw2: switch carries no segment"},
+ {"node without segment", valid + " rr2: { role: rr, image: deb, cpus: 1, memory: 512 }\n", "node rr2: must be attached to at least one segment"},
+ {"node on unknown segment", strings.Replace(valid, "segments: [under]", "segments: [over]", 1), "node rr: segment over is not declared"},
+ {"node on segment twice", strings.Replace(valid, "segments: [under]", "segments: [under, under]", 1), "node rr: segment under listed twice"},
+ {"segment without node", strings.Replace(valid, " under: { switch: sw, cidr: 10.0.0.0/24 }", " under: { switch: sw, cidr: 10.0.0.0/24 }\n empty: { switch: sw, cidr: 10.0.9.0/24 }", 1), "segment empty: no node is attached"},
+ {"address on foreign segment", strings.Replace(valid, "segments: [under]", "segments: [under], addresses: {over: 10.0.0.9}", 1), "address given for segment over"},
+ {"address unparsable", strings.Replace(valid, "segments: [under]", "segments: [under], addresses: {under: 10.0.0}", 1), `address "10.0.0" on under`},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ requireContains(t, validationError(t, c.doc), c.want)
+ })
+ }
+}
+
+func TestValidate_ReportsEveryErrorAtOnce(t *testing.T) {
+ doc := strings.Replace(valid, "role: rr", "role: router", 1)
+ doc = strings.Replace(doc, "10.0.0.0/24", "10.0.0.0/31", 1)
+ got := validationError(t, doc)
+ requireContains(t, got, `role "router"`)
+ requireContains(t, got, "prefix length out of range")
+}
diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go
new file mode 100644
index 0000000..432fedf
--- /dev/null
+++ b/internal/lab/topology/validate.go
@@ -0,0 +1,239 @@
+package topology
+
+import (
+ "errors"
+ "fmt"
+ "net/netip"
+ "regexp"
+ "sort"
+ "strings"
+)
+
+const (
+ MinMTU = 1280
+ MaxMTU = 9000
+ MinMemory = 256
+ MaxPrefix = 30
+ MinPrefix = 8
+
+ ReservedInterface = "mgmt0"
+ LoopbackInterface = "lo1"
+)
+
+var (
+ namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`)
+ segmentPattern = regexp.MustCompile(`^[a-z][a-z0-9]{0,11}$`)
+ releasePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`)
+)
+
+func (t *Topology) Validate() error {
+ var errs []error
+ add := func(format string, args ...any) {
+ errs = append(errs, fmt.Errorf(format, args...))
+ }
+
+ if !namePattern.MatchString(t.Name) {
+ add("name %q: must match %s", t.Name, namePattern)
+ }
+
+ images := map[string]bool{}
+ for _, i := range t.Images {
+ images[i.Name] = true
+ if !strings.HasPrefix(i.URL, "https://") {
+ add("image %s: url must be an https:// URL", i.Name)
+ }
+ if !strings.HasPrefix(i.Sums, "https://") {
+ add("image %s: sums must be an https:// URL", i.Name)
+ }
+ }
+
+ nodes := map[string]Node{}
+ for _, n := range t.Nodes {
+ nodes[n.Name] = n
+ }
+ if len(t.Nodes) == 0 {
+ add("nodes: at least one node is required")
+ }
+ if len(t.Segments) > maxSegments {
+ add("segments: %d declared, at most %d", len(t.Segments), maxSegments)
+ }
+ if len(t.Nodes) > maxNodes {
+ add("nodes: %d declared, at most %d", len(t.Nodes), maxNodes)
+ }
+
+ segments := map[string]Segment{}
+ for _, s := range t.Segments {
+ segments[s.Name] = s
+ if !segmentPattern.MatchString(s.Name) {
+ add("segment %s: name must match %s", s.Name, segmentPattern)
+ }
+ if s.Name == ReservedInterface {
+ add("segment %s: name is reserved for the administration interface", s.Name)
+ }
+ if s.Name == LoopbackInterface {
+ add("segment %s: name is reserved for the loopback interface", s.Name)
+ }
+ sw, ok := nodes[s.Switch]
+ switch {
+ case s.Switch == "":
+ add("segment %s: switch is required", s.Name)
+ case !ok:
+ add("segment %s: switch %s is not a declared node", s.Name, s.Switch)
+ case sw.Role != RoleSwitch:
+ add("segment %s: %s is a %s, not a switch", s.Name, s.Switch, sw.Role)
+ }
+ if s.MTU < MinMTU || s.MTU > MaxMTU {
+ add("segment %s: mtu %d out of range [%d, %d]", s.Name, s.MTU, MinMTU, MaxMTU)
+ }
+ prefix, err := netip.ParsePrefix(s.CIDR)
+ switch {
+ case err != nil:
+ add("segment %s: cidr %q: %v", s.Name, s.CIDR, err)
+ case !prefix.Addr().Is4():
+ add("segment %s: cidr %s is not IPv4", s.Name, s.CIDR)
+ case prefix.Masked() != prefix:
+ add("segment %s: cidr %s has host bits set, network is %s", s.Name, s.CIDR, prefix.Masked())
+ case prefix.Bits() < MinPrefix || prefix.Bits() > MaxPrefix:
+ add("segment %s: cidr %s prefix length out of range [/%d, /%d]", s.Name, s.CIDR, MinPrefix, MaxPrefix)
+ }
+ }
+
+ attached := map[string]int{}
+ hosting := map[string]int{}
+ for _, s := range t.Segments {
+ hosting[s.Switch]++
+ }
+ for _, n := range t.Nodes {
+ if !namePattern.MatchString(n.Name) {
+ add("node %s: name must match %s", n.Name, namePattern)
+ }
+ switch n.Role {
+ case RoleSwitch, RoleRR, RoleHypervisor:
+ default:
+ add("node %s: role %q must be one of %s, %s, %s", n.Name, n.Role, RoleSwitch, RoleRR, RoleHypervisor)
+ }
+ if !images[n.Image] {
+ add("node %s: image %q is not declared", n.Name, n.Image)
+ }
+ if n.CPUs < 1 {
+ add("node %s: cpus must be at least 1", n.Name)
+ }
+ if n.Memory < MinMemory {
+ add("node %s: memory must be at least %d MiB", n.Name, MinMemory)
+ }
+ validateExtras(n, segments, add)
+ if n.Role == RoleSwitch {
+ if len(n.Segments) > 0 || len(n.Addresses) > 0 {
+ add("node %s: a switch carries its segments through segments..switch, not through segments or addresses", n.Name)
+ }
+ if hosting[n.Name] == 0 {
+ add("node %s: switch carries no segment", n.Name)
+ }
+ continue
+ }
+ if len(n.Segments) == 0 {
+ add("node %s: must be attached to at least one segment", n.Name)
+ }
+ seen := map[string]bool{}
+ for _, name := range n.Segments {
+ if seen[name] {
+ add("node %s: segment %s listed twice", n.Name, name)
+ continue
+ }
+ seen[name] = true
+ if _, ok := segments[name]; !ok {
+ add("node %s: segment %s is not declared", n.Name, name)
+ continue
+ }
+ attached[name]++
+ }
+ for _, name := range sortedKeys(n.Addresses) {
+ address := n.Addresses[name]
+ if !seen[name] {
+ add("node %s: address given for segment %s it is not attached to", n.Name, name)
+ continue
+ }
+ if _, err := netip.ParseAddr(address); err != nil {
+ add("node %s: address %q on %s: %v", n.Name, address, name, err)
+ }
+ }
+ }
+
+ for _, s := range t.Segments {
+ if attached[s.Name] == 0 {
+ add("segment %s: no node is attached", s.Name)
+ }
+ }
+
+ if len(errs) > 0 {
+ return errors.Join(errs...)
+ }
+ return nil
+}
+
+func validateExtras(n Node, segments map[string]Segment, add func(string, ...any)) {
+ carried := map[string]bool{}
+ if n.Role == RoleSwitch {
+ for name, s := range segments {
+ if s.Switch == n.Name {
+ carried[name] = true
+ }
+ }
+ } else {
+ for _, name := range n.Segments {
+ carried[name] = true
+ }
+ }
+ names := make([]string, 0, len(n.Secondary))
+ for name := range n.Secondary {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ for _, name := range names {
+ if !carried[name] {
+ add("node %s: secondary address given for segment %s it is not attached to", n.Name, name)
+ continue
+ }
+ network, _ := netip.ParsePrefix(segments[name].CIDR)
+ for _, raw := range n.Secondary[name] {
+ prefix, err := netip.ParsePrefix(raw)
+ switch {
+ case err != nil:
+ add("node %s: secondary address %q on %s: %v", n.Name, raw, name, err)
+ case !prefix.Addr().Is4():
+ add("node %s: secondary address %s on %s is not IPv4", n.Name, raw, name)
+ case network.IsValid() && network.Contains(prefix.Addr()):
+ add("node %s: secondary address %s is inside segment %s (%s), use addresses instead", n.Name, raw, name, network)
+ }
+ }
+ }
+ switch {
+ case n.Role == RoleHypervisor && n.Release == "":
+ add("node %s: a hypervisor needs the release of two to deploy (release: )", n.Name)
+ case n.Role == RoleHypervisor && !releasePattern.MatchString(n.Release):
+ add("node %s: release %q must match %s", n.Name, n.Release, releasePattern)
+ case n.Role != RoleHypervisor && n.Release != "":
+ add("node %s: release is only for hypervisors", n.Name)
+ }
+ if n.Role != RoleHypervisor && n.Agent != "" {
+ add("node %s: agent is only for hypervisors", n.Name)
+ }
+ if n.Loopback != "" {
+ prefix, err := netip.ParsePrefix(n.Loopback)
+ switch {
+ case err != nil:
+ add("node %s: loopback %q: %v", n.Name, n.Loopback, err)
+ case !prefix.Addr().Is4():
+ add("node %s: loopback %s is not IPv4", n.Name, n.Loopback)
+ }
+ }
+}
+
+func sortedKeys(m map[string]string) []string {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ return keys
+}
diff --git a/internal/netif/vxlan.go b/internal/netif/vxlan.go
index 70740bc..0930cb3 100644
--- a/internal/netif/vxlan.go
+++ b/internal/netif/vxlan.go
@@ -1,14 +1,31 @@
package netif
import (
+ "fmt"
+ "net"
+ "syscall"
+
"github.com/vishvananda/netlink"
)
+const (
+ scopeUniverse = 0
+ addressSecondary = 0x01
+)
+
func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error {
link, err := netlink.LinkByName(localIface)
if err != nil {
return err
}
+ addrs, err := netlink.AddrList(link, syscall.AF_INET)
+ if err != nil {
+ return err
+ }
+ local, err := vtepAddress(localIface, addrs)
+ if err != nil {
+ return err
+ }
vxlan := &netlink.Vxlan{
LinkAttrs: netlink.LinkAttrs{
Name: name,
@@ -17,7 +34,18 @@ func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error {
VxlanId: vxlanID,
Port: 4789,
VtepDevIndex: link.Attrs().Index,
+ SrcAddr: local,
Learning: false,
}
return netlink.LinkAdd(vxlan)
}
+
+func vtepAddress(iface string, addrs []netlink.Addr) (net.IP, error) {
+ for _, a := range addrs {
+ if a.IPNet == nil || a.IP.To4() == nil || a.Scope != scopeUniverse || a.Flags&addressSecondary != 0 {
+ continue
+ }
+ return a.IP.To4(), nil
+ }
+ return nil, fmt.Errorf("no primary global IPv4 address on %s for the VXLAN local endpoint", iface)
+}
diff --git a/internal/netif/vxlan_test.go b/internal/netif/vxlan_test.go
new file mode 100644
index 0000000..6ea7ca4
--- /dev/null
+++ b/internal/netif/vxlan_test.go
@@ -0,0 +1,64 @@
+package netif
+
+import (
+ "net"
+ "strings"
+ "testing"
+
+ "github.com/vishvananda/netlink"
+)
+
+func addr(cidr string, scope, flags int) netlink.Addr {
+ ip, network, err := net.ParseCIDR(cidr)
+ if err != nil {
+ panic(err)
+ }
+ network.IP = ip
+ return netlink.Addr{IPNet: network, Scope: scope, Flags: flags}
+}
+
+func TestVtepAddress_PicksThePrimaryGlobalIPv4(t *testing.T) {
+ addrs := []netlink.Addr{
+ addr("127.0.0.1/8", 254, 0),
+ addr("169.254.0.3/28", 0, 0x01),
+ addr("192.168.14.11/24", 0, 0x80),
+ addr("192.168.14.99/24", 0, 0x01),
+ }
+ got, err := vtepAddress("br-000000", addrs)
+ if err != nil || got.String() != "192.168.14.11" {
+ t.Errorf("vtepAddress = %v, %v, want 192.168.14.11", got, err)
+ }
+}
+
+func TestVtepAddress_SkipsLinkScopeAndSecondaryAddresses(t *testing.T) {
+ addrs := []netlink.Addr{
+ addr("169.254.0.3/28", 253, 0),
+ addr("192.168.14.99/24", 0, 0x01),
+ addr("192.168.14.12/24", 0, 0),
+ }
+ got, err := vtepAddress("br-000000", addrs)
+ if err != nil || got.String() != "192.168.14.12" {
+ t.Errorf("vtepAddress = %v, %v, want 192.168.14.12", got, err)
+ }
+}
+
+func TestVtepAddress_RefusesAnInterfaceWithoutUsableAddress(t *testing.T) {
+ for name, addrs := range map[string][]netlink.Addr{
+ "no address": nil,
+ "only secondary": {addr("192.168.14.99/24", 0, 0x01)},
+ "only link": {addr("169.254.0.3/28", 253, 0)},
+ "only IPv6": {addr("fd00::11/64", 0, 0)},
+ } {
+ _, err := vtepAddress("br-000000", addrs)
+ if err == nil || !strings.Contains(err.Error(), "no primary global IPv4 address on br-000000") {
+ t.Errorf("%s: error = %v", name, err)
+ }
+ }
+}
+
+func TestVtepAddress_ReturnsAFourByteAddress(t *testing.T) {
+ got, err := vtepAddress("br-000000", []netlink.Addr{addr("192.168.14.11/24", 0, 0)})
+ if err != nil || len(got) != 4 {
+ t.Errorf("vtepAddress = %v (%d bytes), %v", got, len(got), err)
+ }
+}
diff --git a/internal/subnet/create.go b/internal/subnet/create.go
index e37b865..1164219 100644
--- a/internal/subnet/create.go
+++ b/internal/subnet/create.go
@@ -3,17 +3,18 @@ package subnet
import (
"fmt"
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
"git.g3e.fr/syonad/two/internal/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/ebtables"
"git.g3e.fr/syonad/two/internal/netif"
"git.g3e.fr/syonad/two/internal/netns"
"git.g3e.fr/syonad/two/internal/state"
- "git.g3e.fr/syonad/two/pkg/systemd"
"github.com/dgraph-io/badger/v4"
)
-func CreateSubnet(db *badger.DB, subnetName string) error {
+func CreateSubnet(db *badger.DB, subnetName string, cfg *configuration.Config) error {
current, err := state.Get(db, "subnet/"+subnetName)
if err != nil {
return err
@@ -27,14 +28,19 @@ func CreateSubnet(db *badger.DB, subnetName string) error {
return err
}
- if err := createSubnet(db, subnetName, d); err != nil {
+ backend, err := dhcpbackend.New(cfg)
+ if err != nil {
+ return err
+ }
+
+ if err := createSubnet(db, subnetName, d, backend); err != nil {
return err
}
return state.Set(db, "subnet/"+subnetName, state.Running)
}
-func createSubnet(db *badger.DB, subnetName string, d subnetData) error {
+func createSubnet(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error {
vethE := "v-" + d.subnetID + "-e"
vethI := "v-" + d.subnetID + "-i"
@@ -110,7 +116,7 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error {
}
}
- return startDHCP(db, subnetName, d)
+ return startDHCP(db, subnetName, d, backend)
}
func setupVxlanHost(d subnetData, vethE string) error {
@@ -136,35 +142,23 @@ func setupVxlanHost(d subnetData, vethE string) error {
return nil
}
-func startDHCP(db *badger.DB, subnetName string, d subnetData) error {
- conf := dhcp.Config{
- Network: d.cidr,
- Name: d.vpc + "_" + d.bridge,
- ConfDir: dhcp.DefaultConfDir,
- InterfaceIP: d.interfaceIP,
- }
+func startDHCP(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error {
defaultGateway, vpcRoute, err := dhcpRouting(d, netif.GetDefaultGateway)
if err != nil {
return err
}
- conf.DefaultGateway = defaultGateway
- conf.VPCRoute = vpcRoute
- _, entries, err := dhcp.GenerateConfig(conf)
- if err != nil {
- return fmt.Errorf("generate dhcp config: %w", err)
- }
- if err := dhcp.StoreDHCPEntries(db, subnetName, entries); err != nil {
+
+ if err := dhcp.StoreDHCPEntries(db, subnetName, dhcp.Entries(d.cidr)); err != nil {
return fmt.Errorf("store dhcp entries: %w", err)
}
- svc, err := systemd.New()
- if err != nil {
- return fmt.Errorf("connect to systemd: %w", err)
- }
- defer svc.Close()
-
- if err := svc.Start("dnsmasq@" + conf.Name + ".service"); err != nil {
- return fmt.Errorf("start dnsmasq: %w", err)
- }
- return nil
+ return backend.ConfigureSubnet(dhcpbackend.Subnet{
+ Name: subnetName,
+ VPC: d.vpc,
+ Bridge: d.bridge,
+ Network: d.cidr,
+ InterfaceIP: d.interfaceIP,
+ VPCRoute: vpcRoute,
+ DefaultGateway: defaultGateway,
+ })
}
diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go
index 7fdf67a..a8f12d0 100644
--- a/internal/subnet/delete.go
+++ b/internal/subnet/delete.go
@@ -2,21 +2,19 @@ package subnet
import (
"fmt"
- "os"
- "path/filepath"
- "git.g3e.fr/syonad/two/internal/dhcp"
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/ebtables"
"git.g3e.fr/syonad/two/internal/netif"
"git.g3e.fr/syonad/two/internal/netns"
"git.g3e.fr/syonad/two/internal/state"
"git.g3e.fr/syonad/two/pkg/db/kv"
- "git.g3e.fr/syonad/two/pkg/systemd"
"github.com/dgraph-io/badger/v4"
)
-func DeleteSubnet(db *badger.DB, subnetName string) error {
+func DeleteSubnet(db *badger.DB, subnetName string, cfg *configuration.Config) error {
current, err := state.Get(db, "subnet/"+subnetName)
if err != nil {
return err
@@ -30,7 +28,12 @@ func DeleteSubnet(db *badger.DB, subnetName string) error {
return err
}
- if err := stopDHCP(db, subnetName, d); err != nil {
+ backend, err := dhcpbackend.New(cfg)
+ if err != nil {
+ return err
+ }
+
+ if err := stopDHCP(db, subnetName, d, backend); err != nil {
return err
}
@@ -50,26 +53,13 @@ func DeleteSubnet(db *badger.DB, subnetName string) error {
return state.Set(db, "subnet/"+subnetName, state.Deleted)
}
-func stopDHCP(db *badger.DB, subnetName string, d subnetData) error {
- svc, err := systemd.New()
- if err != nil {
- return fmt.Errorf("connect to systemd: %w", err)
- }
- defer svc.Close()
-
- svcName := "dnsmasq@" + d.vpc + "_" + d.bridge + ".service"
- if status, err := svc.Status(svcName); err == nil && status.ActiveState == "active" {
- if err := svc.Stop(svcName); err != nil {
- return fmt.Errorf("stop dnsmasq: %w", err)
- }
- }
-
- if err := os.Remove(filepath.Join(dhcp.DefaultConfDir, d.vpc+"_"+d.bridge+".conf")); err != nil && !os.IsNotExist(err) {
- return fmt.Errorf("remove dnsmasq config: %w", err)
- }
-
- if err := dhcp.RemoveSubnetDirs(dhcp.DefaultConfDir, d.vpc+"_"+d.bridge); err != nil {
- return fmt.Errorf("remove dnsmasq dirs: %w", err)
+func stopDHCP(db *badger.DB, subnetName string, d subnetData, backend dhcpbackend.Backend) error {
+ if err := backend.TeardownSubnet(dhcpbackend.Subnet{
+ Name: subnetName,
+ VPC: d.vpc,
+ Bridge: d.bridge,
+ }); err != nil {
+ return err
}
if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil {
diff --git a/internal/vm/create.go b/internal/vm/create.go
index c976e9c..692cc25 100644
--- a/internal/vm/create.go
+++ b/internal/vm/create.go
@@ -8,7 +8,7 @@ import (
"path/filepath"
configuration "git.g3e.fr/syonad/two/internal/config/agent"
- "git.g3e.fr/syonad/two/internal/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/iptables"
"git.g3e.fr/syonad/two/internal/metadata"
"git.g3e.fr/syonad/two/internal/netif"
@@ -34,6 +34,11 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
}
nic := d.primary()
+ backend, err := dhcpbackend.New(cfg)
+ if err != nil {
+ return err
+ }
+
for _, n := range d.nics {
if err := netif.CreateTap(n.tapID, n.bridge, n.vpcName); err != nil {
return fmt.Errorf("create tap of interface %d: %w", n.index, err)
@@ -54,7 +59,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
return fmt.Errorf("add metadata redirect: %w", err)
}
- if err := writeDHCPFiles(d, name); err != nil {
+ if err := writeDHCPFiles(d, name, backend); err != nil {
return err
}
@@ -109,53 +114,37 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
return state.Set(db, "vm/"+name, state.Running)
}
-// writeDHCPFiles écrit, pour chaque subnet touché par la VM, les réservations
-// de ses interfaces et les options qui suppriment la route par défaut sur les
-// interfaces non primaires. Le subnet de l'interface primaire ne reçoit aucune
-// option : les options non taggées du subnet portent déjà la route par défaut.
-func writeDHCPFiles(d vmData, name string) error {
- type subnetFiles struct {
- nic nicData
- reservations []dhcp.Reservation
- tags []string
- }
- bySubnet := make(map[string]*subnetFiles)
+func dhcpReservations(d vmData) map[string]*subnetReservations {
+ bySubnet := make(map[string]*subnetReservations)
for _, n := range d.nics {
- confName := n.vpcName + "_" + n.bridge
- if bySubnet[confName] == nil {
- bySubnet[confName] = &subnetFiles{nic: n}
+ key := n.vpcName + "_" + n.bridge
+ if bySubnet[key] == nil {
+ bySubnet[key] = &subnetReservations{subnet: dhcpbackend.Subnet{
+ Name: n.subnetName,
+ VPC: n.vpcName,
+ Bridge: n.bridge,
+ InterfaceIP: net.ParseIP(n.interfaceIP),
+ VPCRoute: n.vpcCIDR,
+ }}
}
- f := bySubnet[confName]
- f.reservations = append(f.reservations, dhcp.Reservation{
- MAC: n.mac, IP: n.ip, Tag: nicTag(name, n.index),
+ f := bySubnet[key]
+ f.reservations = append(f.reservations, dhcpbackend.Reservation{
+ Index: n.index, MAC: n.mac, IP: n.ip, DefaultRoute: n.primary,
})
- if !n.primary {
- f.tags = append(f.tags, nicTag(name, n.index))
- }
}
+ return bySubnet
+}
- for confName, f := range bySubnet {
- if err := dhcp.WriteReservations(dhcp.DefaultConfDir, confName, name, f.reservations); err != nil {
- return fmt.Errorf("write dhcp reservations on %s: %w", confName, err)
- }
- if err := dhcp.WriteVMOptions(dhcp.DefaultConfDir, confName, name, f.tags, dhcp.Config{
- InterfaceIP: net.ParseIP(f.nic.interfaceIP),
- VPCRoute: f.nic.vpcCIDR,
- }); err != nil {
- return fmt.Errorf("write dhcp options on %s: %w", confName, err)
+func writeDHCPFiles(d vmData, name string, backend dhcpbackend.Backend) error {
+ for _, f := range dhcpReservations(d) {
+ if err := backend.SetVM(f.subnet, name, f.reservations); err != nil {
+ return err
}
}
return nil
}
-// nicTag identifie une interface auprès de dnsmasq. Il est par interface et non
-// par VM : deux interfaces d'une même VM peuvent partager un subnet, et n'y
-// avoir pas le même rôle.
-func nicTag(vmName string, index int) string {
- return fmt.Sprintf("%s-%d", vmName, index)
-}
-
func copyFile(src, dst string) error {
if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil {
return err
diff --git a/internal/vm/delete.go b/internal/vm/delete.go
index 96f921c..9b2a50c 100644
--- a/internal/vm/delete.go
+++ b/internal/vm/delete.go
@@ -7,14 +7,13 @@ import (
"time"
configuration "git.g3e.fr/syonad/two/internal/config/agent"
- "git.g3e.fr/syonad/two/internal/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/iptables"
"git.g3e.fr/syonad/two/internal/metadata"
"git.g3e.fr/syonad/two/internal/netif"
"git.g3e.fr/syonad/two/internal/netns"
"git.g3e.fr/syonad/two/internal/qmp"
"git.g3e.fr/syonad/two/internal/state"
- "git.g3e.fr/syonad/two/pkg/systemd"
"github.com/dgraph-io/badger/v4"
)
@@ -34,6 +33,11 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error {
}
nic := d.primary()
+ backend, err := dhcpbackend.New(cfg)
+ if err != nil {
+ return err
+ }
+
socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock")
if _, err := os.Stat(socketPath); err == nil {
@@ -69,7 +73,7 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error {
}
}
- if err := removeDHCPFiles(d, name); err != nil {
+ if err := removeDHCPFiles(d, name, backend); err != nil {
return err
}
@@ -81,52 +85,15 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error {
return state.Set(db, "vm/"+name, state.Deleted)
}
-// removeDHCPFiles retire les fichiers de la VM dans chaque subnet qu'elle
-// touche, puis redémarre les dnsmasq concernés : un fichier ajouté dans un
-// dhcp-hostsdir est relu à chaud, un fichier retiré ne l'est pas (vérifié sur
-// dnsmasq 2.90).
-func removeDHCPFiles(d vmData, name string) error {
- seen := make(map[string]bool)
- for _, n := range d.nics {
- confName := n.vpcName + "_" + n.bridge
- if seen[confName] {
- continue
- }
- seen[confName] = true
- if err := removeDHCPReservation(confName, name); err != nil {
+func removeDHCPFiles(d vmData, name string, backend dhcpbackend.Backend) error {
+ for _, f := range dhcpReservations(d) {
+ if err := backend.DelVM(f.subnet, name, f.reservations); err != nil {
return err
}
}
return nil
}
-func removeDHCPReservation(confName, name string) error {
- if err := dhcp.RemoveReservations(dhcp.DefaultConfDir, confName, name); err != nil {
- return err
- }
-
- svc, err := systemd.New()
- if err != nil {
- return fmt.Errorf("connect to systemd: %w", err)
- }
- defer svc.Close()
-
- unit := dhcp.UnitName(confName)
- 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 waitQMPDead(socketPath string, timeout, poll time.Duration) {
timer := time.After(timeout)
for {
diff --git a/internal/vm/dhcp.go b/internal/vm/dhcp.go
new file mode 100644
index 0000000..6acc2ae
--- /dev/null
+++ b/internal/vm/dhcp.go
@@ -0,0 +1,10 @@
+package vm
+
+import (
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
+)
+
+type subnetReservations struct {
+ subnet dhcpbackend.Subnet
+ reservations []dhcpbackend.Reservation
+}
diff --git a/internal/vm/dhcp_test.go b/internal/vm/dhcp_test.go
new file mode 100644
index 0000000..6f6e487
--- /dev/null
+++ b/internal/vm/dhcp_test.go
@@ -0,0 +1,122 @@
+package vm
+
+import (
+ "net"
+ "testing"
+)
+
+func nic(idx int, subnet, vpc, bridge, ip, mac string, primary bool) nicData {
+ return nicData{
+ index: idx,
+ subnetName: subnet,
+ vpcName: vpc,
+ bridge: bridge,
+ interfaceIP: "10.0.5.1",
+ ip: ip,
+ mac: mac,
+ primary: primary,
+ }
+}
+
+func group(t *testing.T, groups map[string]*subnetReservations, key string) *subnetReservations {
+ t.Helper()
+ g, ok := groups[key]
+ if !ok || g == nil {
+ t.Fatalf("no group %q, got %v", key, keysOf(groups))
+ }
+ return g
+}
+
+func keysOf(groups map[string]*subnetReservations) []string {
+ keys := make([]string, 0, len(groups))
+ for k := range groups {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
+func TestDHCPReservations_GroupsInterfacesBySubnet(t *testing.T) {
+ d := vmData{nics: []nicData{
+ nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true),
+ nic(1, "sn-000001", "vp-admin", "br-000001", "10.0.5.11", "00:22:33:00:00:0b", false),
+ nic(2, "sn-000002", "vp-admin", "br-000002", "10.0.6.10", "00:22:33:00:00:0c", false),
+ }}
+
+ got := dhcpReservations(d)
+ if len(got) != 2 {
+ t.Fatalf("groups = %d, want one per subnet", len(got))
+ }
+ if n := len(group(t, got, "vp-admin_br-000001").reservations); n != 2 {
+ t.Errorf("br-000001 carries %d reservations, want 2", n)
+ }
+ if n := len(group(t, got, "vp-admin_br-000002").reservations); n != 1 {
+ t.Errorf("br-000002 carries %d reservations, want 1", n)
+ }
+}
+
+func TestDHCPReservations_OnlyThePrimaryCarriesTheDefaultRoute(t *testing.T) {
+ d := vmData{nics: []nicData{
+ nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true),
+ nic(1, "sn-000001", "vp-admin", "br-000001", "10.0.5.11", "00:22:33:00:00:0b", false),
+ }}
+
+ res := group(t, dhcpReservations(d), "vp-admin_br-000001").reservations
+ byMAC := map[string]bool{}
+ for _, r := range res {
+ byMAC[r.MAC] = r.DefaultRoute
+ }
+ if !byMAC["00:22:33:00:00:0a"] {
+ t.Error("the primary interface must carry the default route")
+ }
+ if byMAC["00:22:33:00:00:0b"] {
+ t.Error("a secondary interface must not carry the default route")
+ }
+}
+
+func TestDHCPReservations_KeepsTheInterfaceIndex(t *testing.T) {
+ d := vmData{nics: []nicData{
+ nic(3, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true),
+ }}
+
+ res := group(t, dhcpReservations(d), "vp-admin_br-000001").reservations
+ if res[0].Index != 3 {
+ t.Errorf("index = %d, want 3: the dnsmasq tag is derived from it", res[0].Index)
+ }
+}
+
+func TestDHCPReservations_CarriesTheSubnetIdentity(t *testing.T) {
+ _, vpcCIDR, err := net.ParseCIDR("10.0.0.0/16")
+ if err != nil {
+ t.Fatalf("ParseCIDR: %v", err)
+ }
+ n := nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true)
+ n.vpcCIDR = vpcCIDR
+
+ g := group(t, dhcpReservations(vmData{nics: []nicData{n}}), "vp-admin_br-000001")
+ if g.subnet.Name != "sn-000001" {
+ t.Errorf("subnet name = %s, want sn-000001", g.subnet.Name)
+ }
+ if !g.subnet.InterfaceIP.Equal(net.ParseIP("10.0.5.1")) {
+ t.Errorf("interface ip = %s, want 10.0.5.1", g.subnet.InterfaceIP)
+ }
+ if g.subnet.VPCRoute == nil || g.subnet.VPCRoute.String() != "10.0.0.0/16" {
+ t.Errorf("vpc route = %v, want 10.0.0.0/16", g.subnet.VPCRoute)
+ }
+}
+
+func TestDHCPReservations_SameBridgeInTwoVPCsStaysSeparate(t *testing.T) {
+ d := vmData{nics: []nicData{
+ nic(0, "sn-000001", "vp-admin", "br-000001", "10.0.5.10", "00:22:33:00:00:0a", true),
+ nic(1, "sn-000009", "vp-other", "br-000001", "10.9.5.10", "00:22:33:00:00:0d", false),
+ }}
+
+ if got := len(dhcpReservations(d)); got != 2 {
+ t.Errorf("groups = %d, want 2: the vpc is part of the instance identity", got)
+ }
+}
+
+func TestDHCPReservations_NoNICYieldsNoGroup(t *testing.T) {
+ if got := len(dhcpReservations(vmData{})); got != 0 {
+ t.Errorf("groups = %d, want none", got)
+ }
+}
diff --git a/internal/watchdog/check_dhcp.go b/internal/watchdog/check_dhcp.go
new file mode 100644
index 0000000..5e03e57
--- /dev/null
+++ b/internal/watchdog/check_dhcp.go
@@ -0,0 +1,189 @@
+package watchdog
+
+import (
+ "fmt"
+ "os"
+ "sort"
+ "strconv"
+ "strings"
+
+ dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
+ "git.g3e.fr/syonad/two/internal/state"
+ "git.g3e.fr/syonad/two/internal/watchdog/notify"
+ "git.g3e.fr/syonad/two/pkg/db/kv"
+
+ "github.com/dgraph-io/badger/v4"
+)
+
+type configFileReporter interface {
+ ConfigPath(s dhcpbackend.Subnet) string
+}
+
+type stateReporter interface {
+ State(s dhcpbackend.Subnet) (dhcpapi.State, string, error)
+}
+
+func expectedHosts(db *badger.DB, subnetName string) ([]dhcpapi.Host, error) {
+ pairs, err := kv.ListByPrefix(db, prefixVM)
+ if err != nil {
+ return nil, fmt.Errorf("listing vms: %w", err)
+ }
+
+ hosts := make([]dhcpapi.Host, 0)
+ for _, vmName := range resourceNames(pairs, prefixVM) {
+ st, err := state.Get(db, prefixVM+vmName)
+ if err != nil || st != state.Running {
+ continue
+ }
+
+ vmHosts, err := expectedVMHosts(db, vmName, subnetName)
+ if err != nil {
+ return nil, err
+ }
+ hosts = append(hosts, vmHosts...)
+ }
+
+ dhcpapi.SortHosts(hosts)
+ return hosts, nil
+}
+
+func expectedVMHosts(db *badger.DB, vmName, subnetName string) ([]dhcpapi.Host, error) {
+ prefix := prefixVM + vmName + "/nic/"
+ entries, err := kv.ListByPrefix(db, prefix)
+ if err != nil {
+ return nil, fmt.Errorf("listing nics of vm %s: %w", vmName, err)
+ }
+
+ indexes := make([]int, 0)
+ for key := range entries {
+ parts := strings.Split(strings.TrimPrefix(key, prefix), "/")
+ if len(parts) != 2 || parts[1] != "subnet" {
+ continue
+ }
+ idx, err := strconv.Atoi(parts[0])
+ if err != nil {
+ return nil, fmt.Errorf("invalid nic index %q for vm %s", parts[0], vmName)
+ }
+ indexes = append(indexes, idx)
+ }
+ sort.Ints(indexes)
+
+ hosts := make([]dhcpapi.Host, 0, len(indexes))
+ for _, idx := range indexes {
+ nic := fmt.Sprintf("%s%d/", prefix, idx)
+ if entries[nic+"subnet"] != subnetName {
+ continue
+ }
+
+ ip := entries[nic+"ip"]
+ if ip == "" {
+ return nil, fmt.Errorf("nic %d of vm %s has no ip", idx, vmName)
+ }
+ mac, err := dhcp.GetMACForIP(db, subnetName, ip)
+ if err != nil {
+ return nil, fmt.Errorf("get mac for ip %s: %w", ip, err)
+ }
+
+ hosts = append(hosts, dhcpapi.Host{
+ MAC: mac,
+ IP: ip,
+ VM: vmName,
+ DefaultRoute: entries[nic+"primary"] == "true",
+ })
+ }
+ return hosts, nil
+}
+
+func checkDHCP(db *badger.DB, name string, s dhcpbackend.Subnet, backend dhcpbackend.Backend, u unitChecker, n notify.Notifier) {
+ if backend == nil {
+ return
+ }
+ if r, ok := backend.(configFileReporter); ok {
+ checkDHCPConfigFile(name, r.ConfigPath(s), n)
+ }
+ if r, ok := backend.(stateReporter); ok {
+ checkDHCPState(db, name, s, r, n)
+ }
+ checkUnit(kindSubnet, name, backend.Unit(s), u, n)
+}
+
+func checkDHCPConfigFile(name, path string, n notify.Notifier) {
+ if _, err := os.Stat(path); err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("dnsmasq config missing (%s): %v", path, err))
+ }
+}
+
+func checkDHCPState(db *badger.DB, name string, s dhcpbackend.Subnet, r stateReporter, n notify.Notifier) {
+ served, _, err := r.State(s)
+ if err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("dhcp server unreachable: %v", err))
+ return
+ }
+ if served.Subnet == nil {
+ n.Notify(kindSubnet, name, "dhcp server has no subnet configuration: it serves nothing")
+ }
+
+ expected, err := expectedHosts(db, name)
+ if err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("expected dhcp reservations unreadable in database: %v", err))
+ return
+ }
+
+ expectedDigest, err := dhcpapi.Digest(dhcpapi.State{Hosts: expected})
+ if err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("expected dhcp reservations inconsistent in database: %v", err))
+ return
+ }
+ servedDigest, err := dhcpapi.Digest(dhcpapi.State{Hosts: served.Hosts})
+ if err != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("dhcp reservations reported by the server are inconsistent: %v", err))
+ return
+ }
+ if expectedDigest == servedDigest {
+ return
+ }
+
+ for _, gap := range reservationGaps(expected, served.Hosts) {
+ n.Notify(kindSubnet, name, gap)
+ }
+}
+
+func reservationGaps(expected, served []dhcpapi.Host) []string {
+ index := func(hosts []dhcpapi.Host) map[string]dhcpapi.Host {
+ byMAC := make(map[string]dhcpapi.Host, len(hosts))
+ for _, h := range hosts {
+ if c, err := dhcpapi.CanonicalHost(h); err == nil {
+ byMAC[c.MAC] = c
+ } else {
+ byMAC[h.MAC] = h
+ }
+ }
+ return byMAC
+ }
+
+ want, got := index(expected), index(served)
+
+ gaps := make([]string, 0)
+ for mac, h := range want {
+ s, ok := got[mac]
+ if !ok {
+ gaps = append(gaps, fmt.Sprintf("dhcp reservation missing on the server: %s → %s (vm %s)", mac, h.IP, h.VM))
+ continue
+ }
+ if s.IP != h.IP {
+ gaps = append(gaps, fmt.Sprintf("dhcp reservation diverges for %s: server serves %s, database says %s (vm %s)", mac, s.IP, h.IP, h.VM))
+ }
+ if s.DefaultRoute != h.DefaultRoute {
+ gaps = append(gaps, fmt.Sprintf("dhcp default route diverges for %s: server says %t, database says %t (vm %s)", mac, s.DefaultRoute, h.DefaultRoute, h.VM))
+ }
+ }
+ for mac, h := range got {
+ if _, ok := want[mac]; !ok {
+ gaps = append(gaps, fmt.Sprintf("stale dhcp reservation on the server: %s → %s (vm %s)", mac, h.IP, h.VM))
+ }
+ }
+ sort.Strings(gaps)
+ return gaps
+}
diff --git a/internal/watchdog/check_dhcp_test.go b/internal/watchdog/check_dhcp_test.go
new file mode 100644
index 0000000..a9c4801
--- /dev/null
+++ b/internal/watchdog/check_dhcp_test.go
@@ -0,0 +1,348 @@
+package watchdog
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+
+ dhcpapi "git.g3e.fr/syonad/two/internal/api/dhcp"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
+ "git.g3e.fr/syonad/two/internal/dhcpd"
+ "git.g3e.fr/syonad/two/internal/state"
+
+ "github.com/dgraph-io/badger/v4"
+)
+
+const (
+ testSubnetName = "sn-000001"
+ testVPC = "vp-admin"
+ testBridge = "br-000001"
+)
+
+func twoSubnet() dhcpbackend.Subnet {
+ return dhcpbackend.Subnet{Name: testSubnetName, VPC: testVPC, Bridge: testBridge}
+}
+
+func shortTempDir(t *testing.T) string {
+ t.Helper()
+ dir, err := os.MkdirTemp("", "dhcpd")
+ if err != nil {
+ t.Fatalf("MkdirTemp: %v", err)
+ }
+ t.Cleanup(func() { os.RemoveAll(dir) })
+ return dir
+}
+
+func servedBy(t *testing.T) (dhcpbackend.Two, *dhcpd.Store) {
+ t.Helper()
+
+ b := dhcpbackend.Two{RunDir: shortTempDir(t)}
+ s := twoSubnet()
+
+ store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance()))
+ if err := store.Load(); err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+
+ server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatalf("Listen: %v", err)
+ }
+ go server.Serve()
+ t.Cleanup(func() { server.Close() })
+
+ _, network, err := net.ParseCIDR("10.0.5.0/24")
+ if err != nil {
+ t.Fatalf("ParseCIDR: %v", err)
+ }
+ if err := store.SetSubnet(dhcpd.SubnetConfig{Network: network, InterfaceIP: net.ParseIP("10.0.5.1")}); err != nil {
+ t.Fatalf("SetSubnet: %v", err)
+ }
+ return b, store
+}
+
+func seedSubnetWithVM(t *testing.T, db *badger.DB, vmName, ip, mac string, primary bool) {
+ t.Helper()
+
+ seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC)
+ seedKV(t, db, "subnet/"+testSubnetName+"/dhcp/"+ip, mac)
+
+ if err := state.Set(db, "vm/"+vmName, state.Running); err != nil {
+ t.Fatalf("state.Set: %v", err)
+ }
+ seedKV(t, db, "vm/"+vmName+"/nic/0/subnet", testSubnetName)
+ seedKV(t, db, "vm/"+vmName+"/nic/0/ip", ip)
+ seedKV(t, db, "vm/"+vmName+"/nic/0/primary", fmt.Sprintf("%t", primary))
+}
+
+func TestExpectedHosts_ReadsRunningVMsOnThatSubnet(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ hosts, err := expectedHosts(db, testSubnetName)
+ if err != nil {
+ t.Fatalf("expectedHosts: %v", err)
+ }
+ if len(hosts) != 1 {
+ t.Fatalf("hosts = %+v, want one", hosts)
+ }
+ if hosts[0].MAC != "00:22:33:00:00:0A" || hosts[0].IP != "10.0.5.10" {
+ t.Errorf("host = %+v, want the mac derived from the address plan", hosts[0])
+ }
+ if hosts[0].VM != "vm-web" || !hosts[0].DefaultRoute {
+ t.Errorf("host = %+v, want vm-web carrying the default route", hosts[0])
+ }
+}
+
+func TestExpectedHosts_IgnoresVMsThatAreNotRunning(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+ if err := state.Set(db, "vm/vm-web", state.Deleting); err != nil {
+ t.Fatalf("state.Set: %v", err)
+ }
+
+ hosts, err := expectedHosts(db, testSubnetName)
+ if err != nil {
+ t.Fatalf("expectedHosts: %v", err)
+ }
+ if len(hosts) != 0 {
+ t.Errorf("hosts = %+v, want none: a vm being deleted is not expected to be served", hosts)
+ }
+}
+
+func TestExpectedHosts_IgnoresInterfacesOnOtherSubnets(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+ seedKV(t, db, "vm/vm-web/nic/1/subnet", "sn-000002")
+ seedKV(t, db, "vm/vm-web/nic/1/ip", "10.0.6.10")
+ seedKV(t, db, "vm/vm-web/nic/1/primary", "false")
+
+ hosts, err := expectedHosts(db, testSubnetName)
+ if err != nil {
+ t.Fatalf("expectedHosts: %v", err)
+ }
+ if len(hosts) != 1 {
+ t.Errorf("hosts = %+v, want only the interface on this subnet", hosts)
+ }
+}
+
+func TestExpectedHosts_SortsByMAC(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-b", "10.0.5.11", "00:22:33:00:00:0B", true)
+ seedSubnetWithVM(t, db, "vm-a", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ hosts, err := expectedHosts(db, testSubnetName)
+ if err != nil {
+ t.Fatalf("expectedHosts: %v", err)
+ }
+ if len(hosts) != 2 || hosts[0].MAC != "00:22:33:00:00:0A" {
+ t.Errorf("hosts = %+v, want sorted by mac", hosts)
+ }
+}
+
+func TestExpectedHosts_ReportsAnIPWithoutAMAC(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+ seedKV(t, db, "vm/vm-web/nic/0/ip", "10.0.5.99")
+
+ if _, err := expectedHosts(db, testSubnetName); err == nil {
+ t.Fatal("an ip absent from the address plan must be reported, not skipped")
+ }
+}
+
+func TestCheckDHCPState_MACCaseAloneIsNotADivergence(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if len(r.calls) != 0 {
+ t.Errorf("dhcp.Entries stores uppercase macs and the server normalizes to lowercase: that alone must not read as drift, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ADivergenceIsReportedOnceNotAsBothMissingAndStale(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.99", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if len(r.calls) != 1 {
+ t.Errorf("notifications = %+v, want a single diverging-reservation report", r.calls)
+ }
+ if r.hasProblemContaining("missing on the server") || r.hasProblemContaining("stale dhcp") {
+ t.Errorf("without mac normalization the same host reads as both missing and stale: %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_SilentWhenServerMatchesDatabase(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if len(r.calls) != 0 {
+ t.Errorf("notifications = %+v, want none when the server agrees with the database", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsAReservationTheServerNeverGot(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("dhcp reservation missing on the server") {
+ t.Errorf("a lost set-host order must be reported, got %+v", r.calls)
+ }
+ if !r.hasProblemContaining("vm vm-web") {
+ t.Errorf("the report must name the vm, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsAStaleReservation(t *testing.T) {
+ db := newTestDB(t)
+ seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-gone", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("stale dhcp reservation on the server") {
+ t.Errorf("a lost del-host order must be reported, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsADivergingIP(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.99", DefaultRoute: true},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("dhcp reservation diverges") {
+ t.Errorf("a mac served with the wrong address must be reported, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsADivergingDefaultRoute(t *testing.T) {
+ db := newTestDB(t)
+ seedSubnetWithVM(t, db, "vm-web", "10.0.5.10", "00:22:33:00:00:0A", true)
+
+ b, _ := servedBy(t)
+ if err := b.SetVM(twoSubnet(), "vm-web", []dhcpbackend.Reservation{
+ {Index: 0, MAC: "00:22:33:00:00:0A", IP: "10.0.5.10", DefaultRoute: false},
+ }); err != nil {
+ t.Fatalf("SetVM: %v", err)
+ }
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("dhcp default route diverges") {
+ t.Errorf("a wrong default route would break multi-subnet routing, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsAnUnreachableServer(t *testing.T) {
+ db := newTestDB(t)
+ seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC)
+
+ b := dhcpbackend.Two{RunDir: shortTempDir(t)}
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, twoSubnet(), b, r)
+
+ if !r.hasProblemContaining("dhcp server unreachable") {
+ t.Errorf("a dead server must be reported, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPState_ReportsAServerWithNoSubnetConfiguration(t *testing.T) {
+ db := newTestDB(t)
+ seedKV(t, db, "subnet/"+testSubnetName+"/vpc", testVPC)
+
+ b := dhcpbackend.Two{RunDir: shortTempDir(t)}
+ s := twoSubnet()
+ store := dhcpd.NewStore(dhcpapi.StatePath(b.RunDir, s.Instance()))
+ if err := store.Load(); err != nil {
+ t.Fatalf("Load: %v", err)
+ }
+ server, err := dhcpapi.Listen(store, dhcpapi.SocketPath(b.RunDir, s.Instance()), slog.New(slog.NewTextHandler(io.Discard, nil)))
+ if err != nil {
+ t.Fatalf("Listen: %v", err)
+ }
+ go server.Serve()
+ t.Cleanup(func() { server.Close() })
+
+ r := &recorder{}
+ checkDHCPState(db, testSubnetName, s, b, r)
+
+ if !r.hasProblemContaining("no subnet configuration") {
+ t.Errorf("a server that was never configured serves nothing, got %+v", r.calls)
+ }
+}
+
+func TestCheckDHCPConfigFile_ReportsAMissingDnsmasqConfig(t *testing.T) {
+ r := &recorder{}
+ checkDHCPConfigFile(testSubnetName, filepath.Join(t.TempDir(), "absent.conf"), r)
+
+ if !r.hasProblemContaining("dnsmasq config missing") {
+ t.Errorf("notifications = %+v, want the missing config reported", r.calls)
+ }
+}
+
+func TestCheckDHCP_WithoutABackendChecksNothing(t *testing.T) {
+ db := newTestDB(t)
+ r := &recorder{}
+
+ checkDHCP(db, testSubnetName, twoSubnet(), nil, nil, r)
+
+ if len(r.calls) != 0 {
+ t.Errorf("notifications = %+v, want none: the caller already reported the unusable backend", r.calls)
+ }
+}
diff --git a/internal/watchdog/check_subnet.go b/internal/watchdog/check_subnet.go
index fafb2ff..99dac17 100644
--- a/internal/watchdog/check_subnet.go
+++ b/internal/watchdog/check_subnet.go
@@ -2,12 +2,11 @@ package watchdog
import (
"fmt"
- "os"
- "path/filepath"
"strconv"
"strings"
- "git.g3e.fr/syonad/two/internal/dhcp"
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
+ "git.g3e.fr/syonad/two/internal/dhcpbackend"
"git.g3e.fr/syonad/two/internal/netns"
"git.g3e.fr/syonad/two/internal/state"
"git.g3e.fr/syonad/two/internal/watchdog/notify"
@@ -30,16 +29,14 @@ func subnetIfaceNames(subnetName string) (hostVeth, nsVeth, bridge string, err e
return "v-" + id + "-e", "v-" + id + "-i", "br-" + id, nil
}
-func dnsmasqName(vpc, bridge string) string {
- return vpc + "_" + bridge
-}
-
-func CheckSubnets(db *badger.DB, u unitChecker, n notify.Notifier) error {
+func CheckSubnets(db *badger.DB, cfg *configuration.Config, u unitChecker, n notify.Notifier) error {
pairs, err := kv.ListByPrefix(db, prefixSubnet)
if err != nil {
return fmt.Errorf("watchdog: listing subnets: %w", err)
}
+ backend, backendErr := dhcpbackend.New(cfg)
+
for _, name := range resourceNames(pairs, prefixSubnet) {
st, err := state.Get(db, prefixSubnet+name)
if err != nil {
@@ -49,12 +46,15 @@ func CheckSubnets(db *badger.DB, u unitChecker, n notify.Notifier) error {
if st != state.Running {
continue
}
- checkSubnet(db, name, u, n)
+ checkSubnet(db, name, backend, u, n)
+ if backendErr != nil {
+ n.Notify(kindSubnet, name, fmt.Sprintf("dhcp checks skipped, backend unusable: %v", backendErr))
+ }
}
return nil
}
-func checkSubnet(db *badger.DB, name string, u unitChecker, n notify.Notifier) {
+func checkSubnet(db *badger.DB, name string, backend dhcpbackend.Backend, u unitChecker, n notify.Notifier) {
hostVeth, nsVeth, bridge, err := subnetIfaceNames(name)
if err != nil {
n.Notify(kindSubnet, name, err.Error())
@@ -90,13 +90,7 @@ func checkSubnet(db *badger.DB, name string, u unitChecker, n notify.Notifier) {
checkSubnetNetns(name, vpc, nsVeth, bridge, n)
- dnsName := dnsmasqName(vpc, bridge)
- conf := filepath.Join(dhcp.DefaultConfDir, dnsName+".conf")
- if _, err := os.Stat(conf); err != nil {
- n.Notify(kindSubnet, name, fmt.Sprintf("dnsmasq config missing (%s): %v", conf, err))
- }
-
- checkUnit(kindSubnet, name, "dnsmasq@"+dnsName+".service", u, n)
+ checkDHCP(db, name, dhcpbackend.Subnet{Name: name, VPC: vpc, Bridge: bridge}, backend, u, n)
}
func checkVxlanIface(db *badger.DB, name string, n notify.Notifier) {
diff --git a/internal/watchdog/check_subnet_test.go b/internal/watchdog/check_subnet_test.go
index 6928e6f..ec4acd4 100644
--- a/internal/watchdog/check_subnet_test.go
+++ b/internal/watchdog/check_subnet_test.go
@@ -39,17 +39,11 @@ func TestSubnetIfaceNames_TiretFinal(t *testing.T) {
}
}
-func TestDnsmasqName(t *testing.T) {
- if got := dnsmasqName("vp-admin", "br-000000"); got != "vp-admin_br-000000" {
- t.Errorf("dnsmasqName = %q, attendu vp-admin_br-000000", got)
- }
-}
-
func TestCheckSubnets_BaseVide(t *testing.T) {
db := newTestDB(t)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
if len(r.calls) != 0 {
@@ -64,7 +58,7 @@ func TestCheckSubnets_IgnoreLesEtatsNonRunning(t *testing.T) {
}
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
if len(r.calls) != 0 {
@@ -77,7 +71,7 @@ func TestCheckSubnets_VPCManquantEnBase(t *testing.T) {
seedResource(t, db, prefixSubnet, "br-000042", state.Running)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -96,7 +90,7 @@ func TestCheckSubnets_ModeManquantEnBase(t *testing.T) {
seedKV(t, db, prefixSubnet+"br-000042/vpc", "vp-admin")
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -110,7 +104,7 @@ func TestCheckSubnets_ModeInconnu(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", "macvlan")
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -124,7 +118,7 @@ func TestCheckSubnets_ModeBridgeNeVerifiePasDeVxlan(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -143,7 +137,7 @@ func TestCheckSubnets_ModeVxlanSansVxlanID(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeVxlan)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -158,7 +152,7 @@ func TestCheckSubnets_ModeVxlanVxlanIDInvalide(t *testing.T) {
seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "pas-un-nombre")
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -173,7 +167,7 @@ func TestCheckSubnets_ModeVxlanVerifieLInterfaceVxlan(t *testing.T) {
seedKV(t, db, prefixSubnet+"br-000042/vxlan_id", "42")
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -187,7 +181,7 @@ func TestCheckSubnets_ConfigDnsmasqAbsente(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -205,7 +199,7 @@ func TestCheckSubnets_UnitDnsmasqInterrogee(t *testing.T) {
u := newFakeUnits().active("dnsmasq@vp-admin_br-000042.service")
r := &recorder{}
- if err := CheckSubnets(db, u, r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -223,7 +217,7 @@ func TestCheckSubnets_UnitDnsmasqInactive(t *testing.T) {
u := newFakeUnits().inactive("dnsmasq@vp-admin_br-000042.service", "failed")
r := &recorder{}
- if err := CheckSubnets(db, u, r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -238,7 +232,7 @@ func TestCheckSubnets_UnitIllisible(t *testing.T) {
u := newFakeUnits().failing("dnsmasq@vp-admin_br-000042.service", errors.New("dbus indisponible"))
r := &recorder{}
- if err := CheckSubnets(db, u, r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), u, r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -252,7 +246,7 @@ func TestCheckSubnets_SansUnitCheckerPasDeVerificationDUnit(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
r := &recorder{}
- if err := CheckSubnets(db, nil, r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), nil, r); err != nil {
t.Fatalf("erreur inattendue: %v", err)
}
@@ -267,7 +261,7 @@ func TestCheckSubnets_EtatCorrompuNInterrompPasLaBoucle(t *testing.T) {
seedSubnet(t, db, "br-000042", "vp-admin", modeBridge)
r := &recorder{}
- if err := CheckSubnets(db, newFakeUnits(), r); err != nil {
+ if err := CheckSubnets(db, dnsmasqConfig(), newFakeUnits(), r); err != nil {
t.Fatalf("un état corrompu ne doit pas faire échouer CheckSubnets: %v", err)
}
diff --git a/internal/watchdog/helpers_test.go b/internal/watchdog/helpers_test.go
index 891eeba..84e9c79 100644
--- a/internal/watchdog/helpers_test.go
+++ b/internal/watchdog/helpers_test.go
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
+ configuration "git.g3e.fr/syonad/two/internal/config/agent"
"git.g3e.fr/syonad/two/internal/state"
"git.g3e.fr/syonad/two/internal/watchdog/notify"
"git.g3e.fr/syonad/two/pkg/db/kv"
@@ -108,3 +109,9 @@ func seedResource(t *testing.T, db *badger.DB, prefix, name string, s state.Stat
t.Fatalf("seedResource %s%s: %v", prefix, name, err)
}
}
+
+func dnsmasqConfig() *configuration.Config {
+ cfg := &configuration.Config{}
+ cfg.DHCP.Backend = configuration.BackendDnsmasq
+ return cfg
+}
diff --git a/internal/watchdog/watchdog.go b/internal/watchdog/watchdog.go
index 3ab1d52..6da33b8 100644
--- a/internal/watchdog/watchdog.go
+++ b/internal/watchdog/watchdog.go
@@ -65,7 +65,7 @@ func (w *Watchdog) tick() {
if err := CheckVPCs(w.db, w.notifier); err != nil {
w.logger.Error("watchdog: vpc check failed", "err", err)
}
- if err := CheckSubnets(w.db, u, w.notifier); err != nil {
+ if err := CheckSubnets(w.db, w.cfg, u, w.notifier); err != nil {
w.logger.Error("watchdog: subnet check failed", "err", err)
}
if err := CheckVMs(w.db, w.cfg, u, w.notifier); err != nil {
diff --git a/pkg/db/statefile/statefile.go b/pkg/db/statefile/statefile.go
index fc62524..0d8ba51 100644
--- a/pkg/db/statefile/statefile.go
+++ b/pkg/db/statefile/statefile.go
@@ -85,8 +85,12 @@ func (f *File[T]) Save(value T) error {
}
func (f *File[T]) Remove() error {
- if err := os.Remove(f.path); err != nil && !errors.Is(err, os.ErrNotExist) {
- return fmt.Errorf("remove %s: %w", f.path, err)
+ return Remove(f.path)
+}
+
+func Remove(path string) error {
+ if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("remove %s: %w", path, err)
}
return nil
}
diff --git a/release_notes/0.2.0.md b/release_notes/0.2.0.md
new file mode 100644
index 0000000..5f42489
--- /dev/null
+++ b/release_notes/0.2.0.md
@@ -0,0 +1,87 @@
+# Bael
+
+Serveur DHCP intégré, en coexistence avec dnsmasq, et documentation du projet.
+
+## Fonctionnalités
+
+**Serveur DHCP intégré**
+
+- Nouveau binaire `dhcp`, une instance par subnet lancée dans le netns du VPC par l'unit
+ `dhcp@_`. Il reçoit son bridge et ses deux chemins de fichiers en paramètres :
+ il ne compose aucun chemin et ignore le netns dans lequel il tourne
+- Piloté par l'agent sur une **socket Unix** `/run/two/dhcp/_.sock`, en JSON par
+ ligne. Ordres idempotents en remplacement intégral : configuration du subnet à sa création,
+ une réservation par interface à chaque création ou suppression de VM
+- **Réservations statiques uniquement**, pas de baux : une MAC inconnue n'obtient rien, et le
+ serveur reste silencieux plutôt que de répondre par un refus. Aucun `DHCPNAK` n'est émis
+- État auto-persisté dans `/run/two/dhcp/_.state`, en écriture atomique et lisible
+ par le seul `root`. Le fichier appartient au processus, qui le relit à son démarrage ; l'agent
+ ne l'écrit jamais et se borne à le supprimer — à la création du subnet pour écarter un résidu,
+ à sa suppression après avoir arrêté l'unit
+- La route par défaut est décidée **par interface** et non plus par subnet, ce qui permet de ne
+ l'annoncer que sur une interface d'une VM multi-réseaux
+- L'encodage RFC 3442 de l'option 121 est délégué à `github.com/insomniacslk/dhcp`
+
+**Coexistence avec dnsmasq**
+
+- Nouvelle clé `dhcp.backend`, `dnsmasq` ou `two`, qui choisit le serveur des subnets **créés par
+ cet agent**. Le défaut est `dnsmasq` : un fichier de configuration de la 0.1.0, non modifié, se
+ comporte exactement comme avant
+- La bascule est une **opération manuelle** sur un hyperviseur vide — l'option ne migre rien, un
+ subnet déjà créé reste servi par le serveur qui l'a été. La procédure est documentée
+- Toute valeur autre que `dnsmasq` ou `two` fait échouer le démarrage de l'agent
+
+**Exploitation**
+
+- Le watchdog interroge le serveur DHCP intégré et compare les réservations servies à celles que
+ la base implique. Il nomme ce qui diverge — ordre perdu à la création, ordre perdu à la
+ suppression, adresse ou route par défaut divergente — et reste en lecture seule
+- La socket de contrôle répond à `get-state` et à `probe`, ce dernier montrant sans effet de bord
+ ce qui serait envoyé à une MAC donnée : adresse, masque, routeur, DNS et routes
+- Documentation Sphinx du projet : concepts, architecture, déploiement et exploitation
+
+## Correctifs
+
+- **Un fichier de configuration présent mais invalide fait désormais échouer le démarrage.**
+ Jusqu'en 0.1.0 l'erreur de lecture était ignorée : l'agent tournait alors entièrement sur ses
+ valeurs par défaut sans le dire, ce qui rendait indétectable une simple tabulation d'indentation
+- `probe` annonce explicitement `"served": false` pour une MAC non réservée, au lieu d'omettre le
+ champ et de le rendre indistinguable d'une réponse tronquée
+
+## Changements internes
+
+- Le module passe à **Go 1.25**, exigé par la bibliothèque DHCP retenue. La version de Go du
+ workflow de build, restée à 1.21 alors que le module en demandait davantage, est alignée
+- Nouveau paquet `pkg/db/statefile` : persistance générique d'un état de composant dans un
+ fichier, en écriture atomique. Badger a été écarté pour cet usage — une instance par subnet
+ coûterait une memtable de 64 Mio et quatre goroutines de compaction pour environ un kilo-octet
+ d'état, dans un `tmpfs`, et laisserait un verrou résiduel après un arrêt brutal
+- `internal/subnet` et `internal/vm` ne parlent plus à dnsmasq en direct mais à une interface
+ `Backend` à deux implémentations
+
+## Périmètre et limites connues
+
+Celles de la 0.1.0 restent valables, sauf mention contraire ci-dessus. S'y ajoutent :
+
+- **dnsmasq n'est pas retiré** et reste un paquet requis : le backend intégré ne sert que les
+ subnets créés après la bascule, et le retour arrière suppose dnsmasq installé
+- Pas de DNS, pas de pool dynamique, pas de PXE, pas de DHCPv6 dans le serveur intégré
+- La comparaison faite par le watchdog porte sur les **réservations** et non sur la configuration
+ du subnet : celle-ci dépend de la route par défaut de l'host, dont la lecture au moment du
+ contrôle produirait de faux écarts. Un serveur dépourvu de configuration est en revanche signalé
+- L'option 249 (routes classless de Microsoft) n'est pas émise, comme dnsmasq ne l'émet pas
+- Le serveur intégré écoute UDP/67 sans authentification, comme tout serveur DHCP : l'isolation
+ entre locataires d'un même subnet repose sur les règles ebtables anti-usurpation, inchangées
+- `internal/dhcpbackend` n'est testé sous Linux que pour ses appels systemd ; le reste, y compris
+ le dialogue avec le serveur intégré, est couvert sur toute plateforme
+
+## Mise à jour depuis la 0.1.0
+
+```bash
+curl -O https://git.g3e.fr/syonad/two/raw/branch/main/scripts/deploy.sh
+bash ./deploy.sh -t 0.2.0
+```
+
+Aucune action n'est requise : sans `dhcp.backend` dans `/etc/two/agent.yml`, le comportement est
+celui de la 0.1.0. Pour passer au serveur intégré, suivre la procédure de bascule dans la
+documentation d'exploitation — elle suppose un hyperviseur vidé.
diff --git a/release_notes/codenames.md b/release_notes/codenames.md
index 17161c2..fe77557 100644
--- a/release_notes/codenames.md
+++ b/release_notes/codenames.md
@@ -21,7 +21,7 @@ prochain nom disponible sans tenir de compteur ailleurs : c'est la première lig
| # | Nom | Nature | Version | Date |
|---|---|---|---|---|
| 1 | Michael | ange | [0.1.0](0.1.0.md) | 2026-08-26 |
-| 2 | Bael | démon | 0.2.0 | |
+| 2 | Bael | démon | [0.2.0](0.2.0.md) | |
| 3 | Gabriel | ange | | |
| 4 | Agares | démon | | |
| 5 | Raphael | ange | | |
diff --git a/scripts/deploy.sh b/scripts/deploy.sh
index d6b1828..b654197 100755
--- a/scripts/deploy.sh
+++ b/scripts/deploy.sh
@@ -387,5 +387,6 @@ main () {
switch_binaries "${FLAGS_dryrun}" "${FLAGS_profile}" "${ASSETS}"
}
-[[ "${BASH_SOURCE[0]}" == "${0}" ]] && (main "$@" || exit 1)
-[[ "${BASH_SOURCE[0]}" == "" ]] && (main "$@" || exit 1)
+if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then
+ (main "$@" || exit 1)
+fi
diff --git a/scripts/embed.go b/scripts/embed.go
new file mode 100644
index 0000000..481e8e5
--- /dev/null
+++ b/scripts/embed.go
@@ -0,0 +1,9 @@
+package scripts
+
+import _ "embed"
+
+//go:embed deploy.sh
+var Deploy []byte
+
+//go:embed bootstrap_kvm.sh
+var BootstrapKVM []byte
diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh
new file mode 100755
index 0000000..d06c1d7
--- /dev/null
+++ b/scripts/lab-host.sh
@@ -0,0 +1,466 @@
+#!/usr/bin/env bash
+
+API_URL="${SCW_API_URL:-https://api.scaleway.com}"
+CREDENTIALS_FILE="${LAB_CREDENTIALS_FILE:-${HOME}/.config/two-lab/scaleway.env}"
+OFFER_NAME="${LAB_OFFER:-EM-B212X-SSD}"
+OS_NAME="${LAB_OS_NAME:-Debian}"
+OS_VERSION="${LAB_OS_VERSION:-12}"
+LAB_NAME="${LAB_NAME:-two-lab}"
+LAB_TAG="${LAB_TAG:-two-lab}"
+INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-3600}"
+DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-900}"
+SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-600}"
+POLL_INTERVAL="${LAB_POLL_INTERVAL:-20}"
+HTTP_CONNECT_TIMEOUT="${LAB_HTTP_CONNECT_TIMEOUT:-10}"
+HTTP_TIMEOUT="${LAB_HTTP_TIMEOUT:-60}"
+STATE_DIR="${LAB_STATE_DIR:-${HOME}/.cache/two-lab}"
+SSH_KEY="${LAB_SSH_KEY:-${HOME}/.config/two-lab/ssh/lab_ed25519}"
+REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+CREATED_ID=""
+
+info () { echo "== ${1}" >&2; }
+warn () { echo "!! ${1}" >&2; }
+die () { echo "!! ${1}" >&2; exit 1; }
+
+usage () {
+ cat >&2 < [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 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/'
+ 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
+
+environnement : SCW_SECRET_KEY et SCW_DEFAULT_PROJECT_ID (requis, à défaut lus dans ${CREDENTIALS_FILE},
+ fichier en 0600, lignes CLÉ=valeur), SCW_DEFAULT_ZONE (${ZONE}),
+ LAB_OFFER (${OFFER_NAME}), LAB_OS_NAME (${OS_NAME}), LAB_OS_VERSION (${OS_VERSION}),
+ LAB_SSH_KEY (${SSH_KEY} ; à défaut, l'agent SSH)
+
+ne jamais lancer sous 'bash -x' : la trace afficherait SCW_SECRET_KEY.
+EOF
+ exit 2
+}
+
+load_credentials () {
+ local KEY VALUE
+ if [[ -z "${SCW_SECRET_KEY:-}" || -z "${SCW_DEFAULT_PROJECT_ID:-}" ]] && [[ -f "${CREDENTIALS_FILE}" ]]; then
+ [[ $(ls -l "${CREDENTIALS_FILE}" | cut -c5-10) == "------" ]] \
+ || die "${CREDENTIALS_FILE} est lisible par d'autres que son propriétaire : chmod 600"
+ while IFS='=' read -r KEY VALUE || [[ -n "${KEY}" ]]; do
+ case "${KEY}" in
+ SCW_SECRET_KEY) [[ -n "${SCW_SECRET_KEY:-}" ]] || SCW_SECRET_KEY="${VALUE}" ;;
+ SCW_DEFAULT_PROJECT_ID) [[ -n "${SCW_DEFAULT_PROJECT_ID:-}" ]] || SCW_DEFAULT_PROJECT_ID="${VALUE}" ;;
+ SCW_DEFAULT_ZONE) [[ -n "${SCW_DEFAULT_ZONE:-}" ]] || SCW_DEFAULT_ZONE="${VALUE}" ;;
+ esac
+ done < "${CREDENTIALS_FILE}"
+ fi
+ ZONE="${SCW_DEFAULT_ZONE:-fr-par-1}"
+ PROJECT_ID="${SCW_DEFAULT_PROJECT_ID:-}"
+}
+
+require_env () {
+ [[ -n "${SCW_SECRET_KEY:-}" ]] || die "SCW_SECRET_KEY absent de l'environnement"
+ [[ -n "${PROJECT_ID}" ]] || die "SCW_DEFAULT_PROJECT_ID absent de l'environnement"
+ command -v jq >/dev/null 2>&1 || die "jq est requis"
+ command -v curl >/dev/null 2>&1 || die "curl est requis"
+}
+
+http_code () {
+ cat "${STATE_DIR}/last_http_code" 2>/dev/null
+}
+
+api () {
+ local METHOD="${1}"
+ local URL_PATH="${2}"
+ local BODY="${3:-}"
+ local OUT CODE
+ local -a ARGS=(-sS -X "${METHOD}" -H "Content-Type: application/json" -w '\n%{http_code}'
+ --connect-timeout "${HTTP_CONNECT_TIMEOUT}" --max-time "${HTTP_TIMEOUT}")
+
+ : > "${STATE_DIR}/last_http_code"
+ [[ -n "${BODY}" ]] && ARGS+=(--data "${BODY}")
+ OUT=$(curl "${ARGS[@]}" -H @<(printf 'X-Auth-Token: %s\n' "${SCW_SECRET_KEY}") "${API_URL}${URL_PATH}") \
+ || { warn "${METHOD} ${URL_PATH} : échec réseau"; return 1; }
+ CODE="${OUT##*$'\n'}"
+ OUT="${OUT%$'\n'*}"
+ printf '%s' "${CODE}" > "${STATE_DIR}/last_http_code"
+ if [[ "${CODE}" -lt 200 || "${CODE}" -ge 300 ]]; then
+ warn "${METHOD} ${URL_PATH} : HTTP ${CODE} ${OUT}"
+ return 1
+ fi
+ printf '%s\n' "${OUT}"
+}
+
+http_failure_is_final () {
+ local CODE
+ CODE=$(http_code)
+ [[ "${CODE}" =~ ^4[0-9][0-9]$ && "${CODE}" != "429" ]]
+}
+
+money () {
+ jq -r "${1} | if . == null then \"?\" else \"\\(.units + .nanos / 1000000000) \\(.currency_code)\" end"
+}
+
+resolve_offer () {
+ local JSON OFFER COUNT
+ JSON=$(api GET "/baremetal/v1/zones/${ZONE}/offers?subscription_period=hourly&name=${OFFER_NAME}&page_size=100") \
+ || die "impossible de lister les offres"
+ OFFER=$(jq -c --arg n "${OFFER_NAME}" \
+ '[.offers[] | select(.name == $n and .subscription_period == "hourly")]' <<< "${JSON}")
+ COUNT=$(jq 'length' <<< "${OFFER}")
+ [[ "${COUNT}" -eq 1 ]] || die "offre horaire ${OFFER_NAME} : ${COUNT} correspondance(s) en ${ZONE}, une seule attendue"
+ OFFER=$(jq -c '.[0]' <<< "${OFFER}")
+ [[ $(jq -r '.enable' <<< "${OFFER}") == "true" ]] || die "offre ${OFFER_NAME} désactivée"
+ [[ $(jq -r '.stock' <<< "${OFFER}") != "empty" ]] || die "offre ${OFFER_NAME} en rupture de stock en ${ZONE}"
+ [[ $(jq -r '((.fee.units // 0) == 0) and ((.fee.nanos // 0) == 0)' <<< "${OFFER}") == "true" ]] \
+ || die "offre ${OFFER_NAME} : frais de mise en service non nuls ($(money '.fee' <<< "${OFFER}")), refus"
+ printf '%s\n' "${OFFER}"
+}
+
+resolve_os () {
+ local OFFER_ID="${1}"
+ local JSON OS COUNT
+ JSON=$(api GET "/baremetal/v1/zones/${ZONE}/os?offer_id=${OFFER_ID}&page_size=100") \
+ || die "impossible de lister les OS"
+ OS=$(jq -c --arg n "${OS_NAME}" --arg v "${OS_VERSION}" \
+ '[.os[] | select(.enabled and .allowed and (.name | ascii_downcase | contains($n | ascii_downcase))
+ and (.version | split(" ")[0] | split(".")[0]) == $v)]' <<< "${JSON}")
+ COUNT=$(jq 'length' <<< "${OS}")
+ if [[ "${COUNT}" -ne 1 ]]; then
+ warn "OS disponibles pour ${OFFER_NAME} :"
+ jq -r '.os[] | " \(.name) \(.version) (\(.id))"' <<< "${JSON}" >&2
+ die "OS ${OS_NAME} ${OS_VERSION} : ${COUNT} correspondance(s), une seule attendue"
+ fi
+ jq -c '.[0]' <<< "${OS}"
+}
+
+resolve_ssh_keys () {
+ local JSON
+ JSON=$(api GET "/iam/v1alpha1/ssh-keys?project_id=${PROJECT_ID}&disabled=false&page_size=100") \
+ || die "impossible de lister les clés SSH du projet"
+ JSON=$(jq -c '[.ssh_keys[] | select(.disabled | not) | .id]' <<< "${JSON}")
+ [[ $(jq 'length' <<< "${JSON}") -gt 0 ]] || die "aucune clé SSH active dans le projet ${PROJECT_ID}"
+ printf '%s\n' "${JSON}"
+}
+
+build_request () {
+ local OFFER="${1}"
+ local OS="${2}"
+ local KEYS="${3}"
+ jq -nc --argjson offer "${OFFER}" --argjson os "${OS}" --argjson keys "${KEYS}" \
+ --arg project "${PROJECT_ID}" --arg name "${LAB_NAME}" --arg tag "${LAB_TAG}" \
+ '{offer_id: $offer.id, project_id: $project, name: $name, description: "lab two (#50)",
+ tags: [$tag], install: {os_id: $os.id, hostname: $name, ssh_key_ids: $keys}}'
+}
+
+lab_servers () {
+ local JSON
+ JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers?project_id=${PROJECT_ID}&tags=${LAB_TAG}&page_size=100") \
+ || return 1
+ jq -c --arg p "${PROJECT_ID}" --arg t "${LAB_TAG}" \
+ '[.servers[] | select(.project_id == $p and (.tags | index($t)))]' <<< "${JSON}"
+}
+
+server_ip () {
+ jq -r '[.ips[] | select(.version == "IPv4") | .address][0] // empty'
+}
+
+server_is_installing () {
+ jq -e '.status == "delivering" or .status == "ordered"
+ or (.install.status // "") == "to_install" or (.install.status // "") == "installing"' >/dev/null
+}
+
+cmd_plan () {
+ local OFFER OS KEYS
+ OFFER=$(resolve_offer) || exit 1
+ OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1
+ KEYS=$(resolve_ssh_keys) || exit 1
+ info "offre : ${OFFER_NAME} ($(jq -r '.id' <<< "${OFFER}")), facturation $(jq -r '.subscription_period' <<< "${OFFER}"), stock $(jq -r '.stock' <<< "${OFFER}")"
+ info "prix : $(money '.price_per_hour' <<< "${OFFER}") HT par heure, frais de mise en service $(money '.fee' <<< "${OFFER}")"
+ info "os : $(jq -r '"\(.name) \(.version) (\(.id))"' <<< "${OS}"), utilisateur $(jq -r '.user.default_value // "?"' <<< "${OS}")"
+ info "clés : $(jq -r 'length' <<< "${KEYS}") clé(s) SSH du projet"
+ info "requête : POST /baremetal/v1/zones/${ZONE}/servers"
+ build_request "${OFFER}" "${OS}" "${KEYS}" | jq .
+}
+
+wait_installed () {
+ local ID="${1}"
+ local DEADLINE=$(( SECONDS + INSTALL_TIMEOUT ))
+ local JSON STATUS INSTALL
+ while (( SECONDS < DEADLINE )); do
+ if ! JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}"); then
+ if http_failure_is_final; then
+ warn "erreur définitive HTTP $(http_code) en attendant l'installation"
+ return 1
+ fi
+ sleep "${POLL_INTERVAL}"
+ continue
+ fi
+ STATUS=$(jq -r '.status' <<< "${JSON}")
+ INSTALL=$(jq -r '.install.status // "none"' <<< "${JSON}")
+ info "serveur ${ID} : ${STATUS}, installation ${INSTALL}"
+ case "${STATUS}/${INSTALL}" in
+ ready/completed) printf '%s\n' "${JSON}"; return 0 ;;
+ error/*|*/error|out_of_stock/*|locked/*|deleting/*|stopped/*|stopping/*)
+ warn "état terminal en échec : ${STATUS}/${INSTALL}"; return 1 ;;
+ esac
+ sleep "${POLL_INTERVAL}"
+ done
+ warn "installation non terminée après ${INSTALL_TIMEOUT}s"
+ return 1
+}
+
+ssh_run () {
+ local -a OPTS=()
+ while [[ $# -gt 0 && "${1}" != "--" ]]; do
+ OPTS+=("${1}")
+ shift
+ done
+ [[ "${1:-}" == "--" ]] && shift
+ [[ -f "${SSH_KEY}" ]] && OPTS+=(-i "${SSH_KEY}" -o IdentitiesOnly=yes -o IdentityAgent=none)
+ env -u SCW_SECRET_KEY ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="${STATE_DIR}/known_hosts" \
+ "${OPTS[@]}" "$(cat "${STATE_DIR}/user")@$(cat "${STATE_DIR}/ip")" "$@"
+}
+
+wait_ssh () {
+ local DEADLINE=$(( SECONDS + SSH_TIMEOUT ))
+ while (( SECONDS < DEADLINE )); do
+ ssh_run -o BatchMode=yes -o ConnectTimeout=5 -- true >/dev/null 2>&1 && return 0
+ info "SSH pas encore joignable, nouvel essai dans ${POLL_INTERVAL}s"
+ sleep "${POLL_INTERVAL}"
+ done
+ return 1
+}
+
+require_no_lab_server () {
+ local EXISTING
+ EXISTING=$(lab_servers) || die "impossible de lister les serveurs de lab"
+ [[ $(jq 'length' <<< "${EXISTING}") -eq 0 ]] \
+ || die "un serveur de lab existe déjà dans le projet ; '${0##*/} status' puis '${0##*/} down'"
+}
+
+report_uncertain_creation () {
+ local FOUND
+ if FOUND=$(lab_servers) && [[ $(jq 'length' <<< "${FOUND}") -gt 0 ]]; then
+ warn "un serveur de lab existe bien : $(jq -r '[.[].id] | join(", ")' <<< "${FOUND}")"
+ fi
+ die "création incertaine : le serveur a pu être créé et être facturé ; '${0##*/} status' puis '${0##*/} down'"
+}
+
+cmd_up () {
+ local OFFER OS KEYS BODY JSON ID IP SSH_USER
+ require_no_lab_server
+ OFFER=$(resolve_offer) || exit 1
+ OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1
+ KEYS=$(resolve_ssh_keys) || exit 1
+ BODY=$(build_request "${OFFER}" "${OS}" "${KEYS}")
+ info "création de ${LAB_NAME} (${OFFER_NAME}, $(money '.price_per_hour' <<< "${OFFER}")/h HT)"
+ JSON=$(api POST "/baremetal/v1/zones/${ZONE}/servers" "${BODY}") || report_uncertain_creation
+ ID=$(jq -r '.id // empty' <<< "${JSON}" 2>/dev/null)
+ [[ -n "${ID}" ]] || report_uncertain_creation
+ CREATED_ID="${ID}"
+ info "serveur ${ID} créé, facturé jusqu'à '${0##*/} down'"
+ JSON=$(wait_installed "${ID}") || die "serveur ${ID} inutilisable — il est toujours facturé : '${0##*/} down'"
+ IP=$(server_ip <<< "${JSON}")
+ [[ -n "${IP}" ]] || die "serveur ${ID} sans IPv4 publique — il est toujours facturé : '${0##*/} down'"
+ SSH_USER=$(jq -r '.user.default_value // empty' <<< "${OS}")
+ printf '%s\n' "${IP}" > "${STATE_DIR}/ip"
+ printf '%s\n' "${SSH_USER:-root}" > "${STATE_DIR}/user"
+ : > "${STATE_DIR}/known_hosts"
+ wait_ssh || die "SSH injoignable après ${SSH_TIMEOUT}s — le serveur est toujours facturé : '${0##*/} down'"
+ cmd_prepare || die "préparation du serveur échouée — il est toujours facturé : '${0##*/} down'"
+ info "prêt : ${SSH_USER:-root}@${IP}"
+}
+
+cmd_status () {
+ local SERVERS
+ SERVERS=$(lab_servers) || die "impossible de lister les serveurs de lab"
+ if [[ $(jq 'length' <<< "${SERVERS}") -eq 0 ]]; then
+ info "aucun serveur de lab dans le projet ${PROJECT_ID} (${ZONE})"
+ return 0
+ fi
+ jq -r '.[] | "\(.id) \(.name) \(.offer_name) \(.status) installation \(.install.status // "none") \(.created_at) \([.ips[] | select(.version == "IPv4") | .address] | join(","))"' <<< "${SERVERS}"
+}
+
+require_known_server () {
+ [[ -s "${STATE_DIR}/ip" ]] || die "aucun serveur de lab connu ; '${0##*/} up' d'abord"
+}
+
+cmd_ssh () {
+ require_known_server
+ if [[ $# -gt 0 && -t 0 ]]; then
+ ssh_run -t -- "$@"
+ else
+ ssh_run -- "$@"
+ fi
+}
+
+prepare_script () {
+ cat <<'EOF'
+set -eu
+SUDO=
+[ "$(id -u)" -eq 0 ] || SUDO=sudo
+$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq
+$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-system-x86 qemu-utils genisoimage
+[ "$(id -u)" -eq 0 ] || $SUDO usermod -aG kvm "$(id -un)"
+[ -c /dev/kvm ] || { echo "/dev/kvm absent" >&2; exit 1; }
+NESTED=$(cat /sys/module/kvm_intel/parameters/nested 2>/dev/null || cat /sys/module/kvm_amd/parameters/nested 2>/dev/null || true)
+case "${NESTED}" in
+ Y|1) ;;
+ *) echo "virtualisation imbriquée désactivée (nested=${NESTED:-absent})" >&2; exit 1 ;;
+esac
+echo "qemu $(qemu-system-x86_64 --version | head -n 1), nested=${NESTED}"
+EOF
+}
+
+cmd_prepare () {
+ require_known_server
+ info "préparation du serveur : qemu, genisoimage, KVM imbriqué"
+ prepare_script | ssh_run -- bash -s
+}
+
+push_file () {
+ local SOURCE="${1}" TARGET="${2}" MODE="${3}"
+ ssh_run -- "cat > '${TARGET}.part' && chmod ${MODE} '${TARGET}.part' && mv '${TARGET}.part' '${TARGET}'" < "${SOURCE}"
+}
+
+push_dir () {
+ local SOURCE="${1}"
+ COPYFILE_DISABLE=1 tar --no-xattrs -C "${SOURCE}" -cf - . \
+ | ssh_run -- "rm -rf topology.part && mkdir topology.part && tar -C topology.part --no-same-owner -xf - && rm -rf topology && mv topology.part topology"
+}
+
+cmd_push () {
+ local TOPOLOGY="${1:-}"
+ local NAME="${TOPOLOGY##*/}"
+ local BINARY="${STATE_DIR}/lab"
+ [[ $# -eq 1 && -n "${TOPOLOGY}" ]] || usage
+ [[ -f "${TOPOLOGY}" ]] || die "topologie introuvable : ${TOPOLOGY}"
+ [[ "${NAME}" =~ ^[A-Za-z0-9._-]+$ ]] || die "nom de topologie refusé : ${NAME} (lettres, chiffres, '.', '_', '-')"
+ require_known_server
+ info "compilation de lab (linux/amd64)"
+ (cd "${REPO_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "${BINARY}" ./cmd/lab) \
+ || die "compilation de lab échouée"
+ push_file "${BINARY}" lab 755 || die "envoi de lab échoué"
+ push_dir "$(dirname "${TOPOLOGY}")" || die "envoi du répertoire de ${NAME} échoué"
+ info "déposés sur le serveur : ~/lab, ~/topology/ — ensuite : ${0##*/} ssh './lab up topology/${NAME}'"
+}
+
+delete_server () {
+ local ID="${1}"
+ local DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
+ local HARD_DEADLINE=$(( SECONDS + DELETE_TIMEOUT + INSTALL_TIMEOUT ))
+ local JSON
+ while (( SECONDS < DEADLINE )); do
+ api DELETE "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null && return 0
+ [[ $(http_code) == "404" ]] && return 0
+ if JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" 2>/dev/null) && server_is_installing <<< "${JSON}"; then
+ DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
+ (( DEADLINE > HARD_DEADLINE )) && DEADLINE="${HARD_DEADLINE}"
+ warn "suppression de ${ID} refusée pendant la livraison ou l'installation, nouvel essai dans ${POLL_INTERVAL}s"
+ else
+ warn "suppression de ${ID} refusée, nouvel essai dans ${POLL_INTERVAL}s"
+ fi
+ sleep "${POLL_INTERVAL}"
+ done
+ return 1
+}
+
+wait_gone () {
+ local ID="${1}"
+ local DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
+ while (( SECONDS < DEADLINE )); do
+ if ! api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null 2>&1; then
+ [[ $(http_code) == "404" ]] && return 0
+ fi
+ sleep "${POLL_INTERVAL}"
+ done
+ return 1
+}
+
+lab_server_ids () {
+ local DEADLINE=$(( SECONDS + DELETE_TIMEOUT ))
+ local SERVERS
+ while (( SECONDS < DEADLINE )); do
+ if SERVERS=$(lab_servers); then
+ jq -r '.[].id' <<< "${SERVERS}"
+ return 0
+ fi
+ warn "liste des serveurs de lab indisponible, nouvel essai dans ${POLL_INTERVAL}s"
+ sleep "${POLL_INTERVAL}"
+ done
+ return 1
+}
+
+cmd_down () {
+ local IDS ID FAILED=0
+ if ! IDS=$(lab_server_ids); then
+ warn "impossible de lister les serveurs de lab après ${DELETE_TIMEOUT}s"
+ FAILED=1
+ IDS=""
+ fi
+ IDS=$(printf '%s\n%s\n' "${IDS}" "${CREATED_ID}" | grep -v '^$' | sort -u)
+ for ID in ${IDS}; do
+ info "suppression de ${ID}"
+ if ! delete_server "${ID}"; then
+ warn "suppression de ${ID} toujours refusée"
+ FAILED=1
+ continue
+ fi
+ if ! wait_gone "${ID}"; then
+ warn "${ID} toujours présent après ${DELETE_TIMEOUT}s"
+ FAILED=1
+ fi
+ done
+ rm -f "${STATE_DIR}/ip" "${STATE_DIR}/user" "${STATE_DIR}/known_hosts"
+ [[ "${FAILED}" -eq 0 ]] || die "SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S) — '${0##*/} status', puis la console Scaleway"
+ info "aucun serveur de lab ne reste dans le projet"
+}
+
+cmd_session () {
+ local RC
+ require_no_lab_server
+ trap 'trap - EXIT; trap "" INT TERM HUP; cmd_down' EXIT
+ trap 'exit 130' INT
+ trap 'exit 143' TERM
+ trap 'exit 129' HUP
+ cmd_up
+ cmd_ssh "$@"
+ RC=$?
+ info "session terminée (code ${RC}), suppression du serveur"
+ return "${RC}"
+}
+
+main () {
+ local COMMAND="${1:-}"
+ [[ $# -gt 0 ]] && shift
+ load_credentials
+ [[ -n "${COMMAND}" ]] || usage
+ mkdir -p "${STATE_DIR}" || die "impossible de créer ${STATE_DIR}"
+ chmod 700 "${STATE_DIR}"
+ case "${COMMAND}" in
+ plan) require_env; cmd_plan ;;
+ up) require_env; cmd_up ;;
+ status) require_env; cmd_status ;;
+ ssh) cmd_ssh "$@" ;;
+ prepare) cmd_prepare || die "préparation du serveur échouée" ;;
+ push) cmd_push "$@" ;;
+ down) require_env; cmd_down ;;
+ session) require_env; cmd_session "$@" ;;
+ *) usage ;;
+ esac
+}
+
+if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then
+ main "$@"
+fi
diff --git a/scripts/lab-host_test.sh b/scripts/lab-host_test.sh
new file mode 100755
index 0000000..9953579
--- /dev/null
+++ b/scripts/lab-host_test.sh
@@ -0,0 +1,825 @@
+#!/usr/bin/env bash
+
+SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lab-host.sh"
+PASS=0
+FAIL=0
+SECRET="secret-de-test-a1b2c3"
+PROJECT="11111111-1111-1111-1111-111111111111"
+OTHER_PROJECT="22222222-2222-2222-2222-222222222222"
+HOURLY_ID="offer-hourly-0001"
+MONTHLY_ID="offer-monthly-0001"
+
+fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; }
+
+write_fakes () {
+ local DIR="${1}"
+ mkdir -p "${DIR}/bin"
+ cat > "${DIR}/bin/curl" <<'PY'
+#!/usr/bin/env python3
+import json, os, sys, time, urllib.parse
+
+d = os.environ["FAKE_DIR"]
+args = sys.argv[1:]
+with open(os.path.join(d, "argv.log"), "a") as f:
+ f.write(json.dumps(args) + "\n")
+
+method, body, headers, url = "GET", None, [], None
+i = 0
+while i < len(args):
+ a = args[i]
+ if a == "-X":
+ method = args[i + 1]; i += 2; continue
+ if a == "-H":
+ h = args[i + 1]
+ if h.startswith("@"):
+ with open(h[1:]) as f:
+ headers += [l.strip() for l in f if l.strip()]
+ else:
+ headers.append(h)
+ i += 2; continue
+ if a == "--data":
+ body = args[i + 1]; i += 2; continue
+ if a in ("-w", "--max-time", "--connect-timeout"):
+ i += 2; continue
+ if a.startswith("-"):
+ i += 1; continue
+ url = a; i += 1
+
+state_path = os.path.join(d, "state.json")
+with open(state_path) as f:
+ st = json.load(f)
+
+def reply(code, obj):
+ with open(state_path, "w") as f:
+ json.dump(st, f)
+ sys.stdout.write(json.dumps(obj) + "\n" + str(code))
+ sys.exit(0)
+
+u = urllib.parse.urlparse(url)
+path = u.path
+with open(os.path.join(d, "calls.log"), "a") as f:
+ f.write(method + " " + path + ("?" + u.query if u.query else "") + "\n")
+
+if "X-Auth-Token: " + os.environ["FAKE_SECRET"] not in headers:
+ reply(401, {"message": "unauthorized"})
+
+zone = "/baremetal/v1/zones/fr-par-1"
+if method == "GET" and path == zone + "/offers":
+ reply(200, {"offers": st["offers"], "total_count": len(st["offers"])})
+if method == "GET" and path == zone + "/os":
+ reply(200, {"os": st["os"], "total_count": len(st["os"])})
+if method == "GET" and path == "/iam/v1alpha1/ssh-keys":
+ reply(200, {"ssh_keys": st["keys"], "total_count": len(st["keys"])})
+if method == "GET" and path == zone + "/servers":
+ if st["list_errors"] > 0:
+ st["list_errors"] -= 1
+ reply(503, {"message": "service unavailable"})
+ listed = [v for v in st["servers"].values() if not v.get("hidden")]
+ reply(200, {"servers": listed, "total_count": len(listed)})
+if method == "POST" and path == zone + "/servers":
+ req = json.loads(body)
+ with open(os.path.join(d, "create.json"), "w") as f:
+ json.dump(req, f)
+ st["seq"] += 1
+ sid = "srv-%d" % st["seq"]
+ st["servers"][sid] = {"id": sid, "name": req["name"], "project_id": req["project_id"],
+ "tags": req["tags"], "offer_name": "EM-B212X-SSD", "status": "delivering",
+ "install": {"status": "to_install"}, "ips": [], "created_at": "now",
+ "hidden": st["hide_from_list"]}
+ if st["post_fail_after_create"]:
+ reply(502, {"message": "bad gateway"})
+ if st["post_no_id"]:
+ reply(200, {})
+ reply(200, st["servers"][sid])
+if path.startswith(zone + "/servers/"):
+ sid = path.rsplit("/", 1)[1]
+ if sid not in st["servers"]:
+ reply(404, {"message": "not found"})
+ srv = st["servers"][sid]
+ if method == "GET" and st["get_errors"] > 0:
+ st["get_errors"] -= 1
+ reply(503, {"message": "service unavailable"})
+ if method == "GET" and srv["status"] == "deleting":
+ if st["linger"] > 0:
+ st["linger"] -= 1
+ reply(200, srv)
+ del st["servers"][sid]
+ reply(404, {"message": "not found"})
+ if method == "GET" and st["vanish_after"] == 0:
+ del st["servers"][sid]
+ reply(404, {"message": "not found"})
+ if method == "GET" and st["vanish_after"] > 0:
+ st["vanish_after"] -= 1
+ if method == "GET":
+ if st["progress"]:
+ status, install = st["progress"].pop(0)
+ srv["status"] = status
+ srv["install"]["status"] = install
+ if status == "ready":
+ srv["ips"] = [{"address": "2001:db8::1", "version": "IPv6"},
+ {"address": "203.0.113.7", "version": "IPv4"}]
+ reply(200, srv)
+ if method == "DELETE":
+ if time.time() < st["refuse_until"]:
+ reply(409, {"message": "server is installing"})
+ if st["delete_refusals"] > 0:
+ st["delete_refusals"] -= 1
+ reply(409, {"message": "server is installing"})
+ if st["linger"] > 0:
+ srv["status"] = "deleting"
+ else:
+ del st["servers"][sid]
+ reply(200, srv)
+reply(500, {"message": "route inconnue du faux serveur : " + method + " " + path})
+PY
+ cat > "${DIR}/bin/ssh" <<'SH'
+#!/usr/bin/env bash
+printf '%s\n' "$*" >> "${FAKE_DIR}/ssh.log"
+printf 'key=%s\n' "${SCW_SECRET_KEY:-}" >> "${FAKE_DIR}/ssh-env.log"
+case "$*" in
+ *BatchMode=yes*)
+ N=$(cat "${FAKE_DIR}/probe_fails" 2>/dev/null || echo 0)
+ if [ "${N}" -gt 0 ]; then
+ echo $(( N - 1 )) > "${FAKE_DIR}/probe_fails"
+ exit 255
+ fi
+ exit 0 ;;
+ *"bash -s"*)
+ cat > "${FAKE_DIR}/prepare.sh"
+ exit "${FAKE_PREPARE_RC:-0}" ;;
+ *"cat > "*|*"-xf -"*)
+ N=$(ls "${FAKE_DIR}" | grep -c '^pushed\.')
+ cat > "${FAKE_DIR}/pushed.$(( N + 1 ))"
+ exit "${FAKE_PUSH_RC:-0}" ;;
+esac
+[ -n "${FAKE_SSH_SLEEP:-}" ] && sleep "${FAKE_SSH_SLEEP}"
+exit "${FAKE_SSH_RC:-0}"
+SH
+ cat > "${DIR}/bin/go" <<'SH'
+#!/usr/bin/env bash
+printf '%s|%s|CGO_ENABLED=%s GOOS=%s GOARCH=%s\n' "${PWD}" "$*" "${CGO_ENABLED:-}" "${GOOS:-}" "${GOARCH:-}" >> "${FAKE_DIR}/go.log"
+[ -n "${FAKE_GO_FAIL:-}" ] && exit 1
+while [ $# -gt 0 ]; do
+ [ "${1}" = "-o" ] && printf 'binaire-lab\n' > "${2}"
+ shift
+done
+exit 0
+SH
+ chmod +x "${DIR}/bin/curl" "${DIR}/bin/ssh" "${DIR}/bin/go"
+}
+
+base_state () {
+ jq -n --arg h "${HOURLY_ID}" --arg m "${MONTHLY_ID}" --arg p "${PROJECT}" --arg o "${OTHER_PROJECT}" '{
+ seq: 0, progress: [], delete_refusals: 0, linger: 0, get_errors: 0, list_errors: 0,
+ hide_from_list: false, refuse_until: 0, post_fail_after_create: false, post_no_id: false, vanish_after: -1,
+ offers: [
+ {id: $m, name: "EM-B212X-SSD", subscription_period: "monthly", enable: true, stock: "available",
+ price_per_hour: null, price_per_month: {currency_code: "EUR", units: 115, nanos: 990000000},
+ fee: {currency_code: "EUR", units: 115, nanos: 990000000}},
+ {id: "offer-other", name: "EM-A610R-NVMe", subscription_period: "hourly", enable: true, stock: "available",
+ price_per_hour: {currency_code: "EUR", units: 0, nanos: 110000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}},
+ {id: $h, name: "EM-B212X-SSD", subscription_period: "hourly", enable: true, stock: "available",
+ price_per_hour: {currency_code: "EUR", units: 0, nanos: 321000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}}
+ ],
+ os: [
+ {id: "os-deb11", name: "Debian", version: "11 (Bullseye)", enabled: true, allowed: true, user: {default_value: "debian"}},
+ {id: "os-deb12", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true, user: {default_value: "debian"}},
+ {id: "os-deb13", name: "Debian", version: "13 (Trixie)", enabled: true, allowed: true, user: {default_value: "debian"}},
+ {id: "os-ubu", name: "Ubuntu", version: "24.04 LTS (Noble Numbat)", enabled: true, allowed: true, user: {default_value: "ubuntu"}}
+ ],
+ keys: [{id: "key-1", disabled: false}, {id: "key-off", disabled: true}],
+ servers: {
+ "foreign-1": {id: "foreign-1", name: "prod-db", project_id: $o, tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []},
+ "foreign-2": {id: "foreign-2", name: "autre", project_id: $p, tags: ["autre"], status: "ready", install: {status: "completed"}, ips: []}
+ }
+ }'
+}
+
+setup () {
+ CURRENT="${1}"
+ CASE_OK=1
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/labhost.XXXXXX")
+ write_fakes "${WORK}"
+ base_state > "${WORK}/state.json"
+ : > "${WORK}/calls.log"
+ : > "${WORK}/argv.log"
+ : > "${WORK}/ssh.log"
+ : > "${WORK}/ssh-env.log"
+}
+
+mutate () {
+ local TMP
+ TMP=$(jq "${1}" "${WORK}/state.json") && printf '%s\n' "${TMP}" > "${WORK}/state.json"
+}
+
+lab_env () {
+ local EXEC=""
+ [[ "${1}" == "exec" ]] && { EXEC="exec"; shift; }
+ ${EXEC} env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \
+ FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" FAKE_SSH_RC="${FAKE_SSH_RC:-0}" \
+ FAKE_SSH_SLEEP="${FAKE_SSH_SLEEP:-}" FAKE_PREPARE_RC="${FAKE_PREPARE_RC:-0}" \
+ FAKE_PUSH_RC="${FAKE_PUSH_RC:-0}" FAKE_GO_FAIL="${FAKE_GO_FAIL:-}" \
+ SCW_API_URL="https://api.example.invalid" SCW_SECRET_KEY="${SECRET}" \
+ SCW_DEFAULT_PROJECT_ID="${PROJECT}" LAB_POLL_INTERVAL="${LAB_POLL_INTERVAL:-0}" \
+ LAB_INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-60}" LAB_DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-60}" \
+ LAB_SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-60}" \
+ "$@"
+}
+
+run_lab () {
+ lab_env bash "${SCRIPT}" "$@" > "${WORK}/out.log" 2>&1
+}
+
+remaining () {
+ jq -r '.servers | keys | sort | join(",")' "${WORK}/state.json"
+}
+
+teardown () {
+ [[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}"
+ [[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; }
+ rm -rf "${WORK}"
+}
+
+test_plan_picks_hourly_offer_even_when_monthly_is_listed_first () {
+ setup "plan : offre horaire choisie même si la mensuelle est listée d'abord"
+ run_lab plan || fail "code de sortie $?"
+ grep -q "\"offer_id\": \"${HOURLY_ID}\"" "${WORK}/out.log" || fail "offre horaire absente de la requête"
+ grep -q "${MONTHLY_ID}" "${WORK}/out.log" && fail "offre mensuelle présente dans la sortie"
+ grep -q "^POST" "${WORK}/calls.log" && fail "plan a créé quelque chose"
+ teardown
+}
+
+test_plan_selects_debian_12_only () {
+ setup "plan : Debian 12 et rien d'autre"
+ run_lab plan || fail "code de sortie $?"
+ grep -q '"os_id": "os-deb12"' "${WORK}/out.log" || fail "os-deb12 non retenu"
+ teardown
+}
+
+test_plan_passes_only_enabled_ssh_keys () {
+ setup "plan : seules les clés SSH actives sont transmises"
+ run_lab plan || fail "code de sortie $?"
+ grep -q '"key-1"' "${WORK}/out.log" || fail "key-1 absente"
+ grep -q '"key-off"' "${WORK}/out.log" && fail "clé désactivée transmise"
+ teardown
+}
+
+test_refuses_offer_with_setup_fee () {
+ setup "refus d'une offre horaire avec frais de mise en service"
+ mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .fee.units) = 30'
+ run_lab up && fail "up a réussi malgré des frais"
+ grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé malgré des frais"
+ teardown
+}
+
+test_refuses_when_hourly_offer_missing () {
+ setup "refus quand seule l'offre mensuelle existe"
+ mutate 'del(.offers[] | select(.id == "'"${HOURLY_ID}"'"))'
+ run_lab up && fail "up a réussi sans offre horaire"
+ grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé sur une offre mensuelle"
+ teardown
+}
+
+test_refuses_ambiguous_os () {
+ setup "refus d'un OS ambigu"
+ mutate '.os += [{id: "os-deb12b", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true}]'
+ run_lab plan && fail "plan a réussi avec deux Debian 12"
+ teardown
+}
+
+test_refuses_out_of_stock () {
+ setup "refus d'une offre en rupture"
+ mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .stock) = "empty"'
+ run_lab up && fail "up a réussi en rupture de stock"
+ grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé en rupture de stock"
+ teardown
+}
+
+test_up_creates_and_waits_for_installation () {
+ setup "up : création puis attente de la fin d'installation"
+ mutate '.progress = [["delivering","to_install"],["ready","installing"],["ready","completed"]]'
+ run_lab up || fail "code de sortie $?"
+ [[ $(jq -r '.offer_id' "${WORK}/create.json") == "${HOURLY_ID}" ]] || fail "offre créée incorrecte"
+ [[ $(jq -r '.tags | join(",")' "${WORK}/create.json") == "two-lab" ]] || fail "tag absent"
+ [[ $(jq -r '.project_id' "${WORK}/create.json") == "${PROJECT}" ]] || fail "projet incorrect"
+ [[ $(cat "${WORK}/.cache/two-lab/ip" 2>/dev/null) == "203.0.113.7" ]] || fail "IPv4 non retenue"
+ [[ $(cat "${WORK}/.cache/two-lab/user" 2>/dev/null) == "debian" ]] || fail "utilisateur non retenu"
+ teardown
+}
+
+test_up_refuses_when_lab_server_exists () {
+ setup "up : refus si un serveur de lab existe déjà"
+ mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
+ run_lab up && fail "up a réussi"
+ grep -q "^POST" "${WORK}/calls.log" && fail "second serveur créé"
+ teardown
+}
+
+test_secret_never_in_argv () {
+ setup "la clé d'API n'apparaît jamais dans les arguments de curl"
+ mutate '.progress = [["ready","completed"]]'
+ run_lab up || fail "code de sortie $?"
+ grep -q "${SECRET}" "${WORK}/argv.log" && fail "secret visible dans argv"
+ grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché"
+ teardown
+}
+
+test_down_deletes_only_lab_servers_of_project () {
+ setup "down : ne supprime que les serveurs de lab du projet"
+ mutate '.servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
+ run_lab down || fail "code de sortie $?"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_down_retries_refused_deletion () {
+ setup "down : réessaie une suppression refusée"
+ mutate '.delete_refusals = 2 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}'
+ run_lab down || fail "code de sortie $?"
+ [[ $(grep -c "^DELETE" "${WORK}/calls.log") -eq 3 ]] || fail "$(grep -c "^DELETE" "${WORK}/calls.log") DELETE au lieu de 3"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_down_fails_loudly_when_deletion_keeps_failing () {
+ setup "down : échec bruyant si la suppression n'aboutit jamais"
+ mutate '.delete_refusals = 1000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}'
+ LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi"
+ grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
+ teardown
+}
+
+test_down_waits_until_server_is_gone () {
+ setup "down : attend que le serveur ait disparu"
+ mutate '.linger = 3 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
+ run_lab down || fail "code de sortie $?"
+ [[ $(grep -c "^GET /baremetal/v1/zones/fr-par-1/servers/srv-lab$" "${WORK}/calls.log") -ge 4 ]] || fail "disparition non attendue"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_down_fails_loudly_when_server_never_disappears () {
+ setup "down : échec bruyant si le serveur ne disparaît jamais"
+ mutate '.linger = 100000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
+ LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi"
+ grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
+ teardown
+}
+
+test_down_does_not_mistake_api_error_for_disappearance () {
+ setup "down : une erreur de l'API n'est pas prise pour une disparition"
+ mutate '.linger = 100000 | .get_errors = 1 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
+ LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi alors que le serveur existe encore"
+ grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
+ teardown
+}
+
+test_session_deletes_server_after_remote_command () {
+ setup "session : serveur supprimé après la commande distante"
+ mutate '.progress = [["ready","completed"]]'
+ run_lab session uname -a || fail "code de sortie $?"
+ grep -q "uname -a" "${WORK}/ssh.log" || fail "commande distante non lancée"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_session_deletes_server_and_propagates_failure_of_remote_command () {
+ setup "session : serveur supprimé et code d'échec propagé"
+ mutate '.progress = [["ready","completed"]]'
+ FAKE_SSH_RC=7 run_lab session false
+ [[ $? -eq 7 ]] || fail "code de sortie différent de 7"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_session_deletes_server_when_installation_fails () {
+ setup "session : serveur supprimé quand l'installation échoue"
+ mutate '.progress = [["ready","installing"],["ready","error"]]'
+ run_lab session true && fail "session a réussi"
+ grep -q "true" "${WORK}/ssh.log" && fail "commande lancée sur un serveur en échec"
+ grep -q "état terminal en échec : ready/error" "${WORK}/out.log" || fail "échec d'installation non reconnu comme terminal"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_session_deletes_server_when_installation_times_out () {
+ setup "session : serveur supprimé quand l'installation n'aboutit pas"
+ mutate '.progress = [range(0; 100000) | ["delivering","to_install"]]'
+ LAB_INSTALL_TIMEOUT=1 run_lab session true && fail "session a réussi"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_session_does_not_touch_existing_lab_server () {
+ setup "session : ne supprime pas un serveur de lab lancé à côté"
+ mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
+ run_lab session true && fail "session a réussi"
+ grep -q "^DELETE" "${WORK}/calls.log" && fail "suppression lancée"
+ [[ $(remaining) == "foreign-1,foreign-2,srv-old" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_missing_credentials_are_refused_before_any_call () {
+ setup "refus sans clé d'API, avant tout appel"
+ env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" FAKE_DIR="${WORK}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1 \
+ && fail "plan a réussi sans clé"
+ [[ -s "${WORK}/calls.log" ]] && fail "appel réseau sans clé"
+ teardown
+}
+
+LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}'
+INSTALLING_LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}'
+
+test_down_keeps_retrying_while_server_is_installing () {
+ setup "down : réessaie au-delà du délai tant que le serveur s'installe"
+ mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 5)"
+ LAB_DELETE_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 run_lab down || fail "code de sortie $?"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ grep -q "pendant la livraison ou l'installation" "${WORK}/out.log" || fail "installation non reconnue"
+ teardown
+}
+
+test_down_gives_up_on_installing_server_after_hard_deadline () {
+ local RC
+ setup "down : abandonne quand même après délai d'installation + délai de suppression"
+ mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 100000)"
+ LAB_DELETE_TIMEOUT=2 LAB_INSTALL_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 lab_env timeout 30 bash "${SCRIPT}" down > "${WORK}/out.log" 2>&1
+ RC=$?
+ [[ "${RC}" -ne 124 ]] || fail "aucun plafond : down tourne encore après 30 s"
+ [[ "${RC}" -ne 0 ]] || fail "down a réussi"
+ grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
+ teardown
+}
+
+test_down_retries_listing_servers () {
+ setup "down : réessaie la liste des serveurs"
+ mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 2"
+ run_lab down || fail "code de sortie $?"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_down_fails_loudly_when_listing_never_works () {
+ setup "down : échec bruyant si la liste reste indisponible"
+ mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 100000"
+ LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi"
+ grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation"
+ teardown
+}
+
+test_session_deletes_created_server_even_if_missing_from_list () {
+ setup "session : supprime le serveur créé même absent de la liste"
+ mutate '.hide_from_list = true | .progress = [["ready","completed"]]'
+ run_lab session true || fail "code de sortie $?"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_every_api_call_has_timeouts () {
+ setup "chaque appel à l'API a un délai de connexion et un délai total"
+ mutate '.progress = [["ready","completed"]]'
+ run_lab up || fail "code de sortie $?"
+ [[ -s "${WORK}/argv.log" ]] || fail "aucun appel"
+ [[ $(jq -s '[.[] | select((index("--max-time") | not) or (index("--connect-timeout") | not))] | length' "${WORK}/argv.log") -eq 0 ]] \
+ || fail "appel sans délai"
+ teardown
+}
+
+test_up_reports_uncertain_creation_when_post_fails () {
+ setup "up : création incertaine signalée quand le POST échoue"
+ mutate '.post_fail_after_create = true'
+ run_lab up && fail "up a réussi"
+ grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine"
+ grep -q "un serveur de lab existe bien : srv-1" "${WORK}/out.log" || fail "serveur créé non signalé"
+ teardown
+}
+
+test_session_deletes_server_when_post_fails_after_creation () {
+ setup "session : serveur supprimé quand le POST échoue après création"
+ mutate '.post_fail_after_create = true'
+ run_lab session true && fail "session a réussi"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_up_refuses_response_without_id () {
+ setup "up : réponse de création sans identifiant refusée"
+ mutate '.post_no_id = true'
+ run_lab up && fail "up a réussi"
+ grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine"
+ grep -q "servers/null" "${WORK}/calls.log" && fail "appel sur un identifiant null"
+ teardown
+}
+
+test_up_stops_at_once_when_server_vanishes () {
+ setup "up : arrêt immédiat si le serveur disparaît pendant l'installation"
+ mutate '.vanish_after = 1 | .progress = [["delivering","to_install"]]'
+ LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi"
+ grep -q "erreur définitive HTTP 404" "${WORK}/out.log" || fail "404 non traité comme définitif"
+ teardown
+}
+
+test_up_stops_at_once_when_server_is_stopped_during_installation () {
+ setup "up : arrêt immédiat si le serveur passe à l'arrêt pendant l'installation"
+ mutate '.progress = [["delivering","to_install"],["stopped","installing"]]'
+ LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi"
+ grep -q "état terminal en échec : stopped/installing" "${WORK}/out.log" || fail "arrêt non reconnu comme terminal"
+ teardown
+}
+
+test_session_cleanup_survives_a_second_signal () {
+ local PID RC I
+ setup "session : un second signal n'interrompt pas le nettoyage"
+ mutate '.progress = [["ready","completed"]] | .linger = 15'
+ set -m
+ FAKE_SSH_SLEEP=5 LAB_POLL_INTERVAL=0.2 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 &
+ PID=$!
+ set +m
+ for I in $(seq 1 100); do
+ grep -q "sleep-long" "${WORK}/ssh.log" && break
+ sleep 0.1
+ done
+ kill -TERM -- "-${PID}"
+ for I in $(seq 1 100); do
+ grep -q "suppression de srv-1" "${WORK}/out.log" && break
+ sleep 0.1
+ done
+ grep -q "suppression de srv-1" "${WORK}/out.log" || fail "nettoyage jamais commencé"
+ kill -INT -- "-${PID}" 2>/dev/null
+ kill -TERM -- "-${PID}" 2>/dev/null
+ wait "${PID}"
+ RC=$?
+ [[ "${RC}" -eq 143 ]] || fail "code ${RC} au lieu de 143"
+ grep -q "aucun serveur de lab ne reste" "${WORK}/out.log" || fail "nettoyage interrompu"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_ssh_never_receives_api_key () {
+ setup "ssh ne reçoit jamais la clé d'API dans son environnement"
+ mutate '.progress = [["ready","completed"]]'
+ run_lab session uname || fail "code de sortie $?"
+ [[ -s "${WORK}/ssh-env.log" ]] || fail "ssh jamais lancé"
+ grep -qv '^key=$' "${WORK}/ssh-env.log" && fail "clé transmise à ssh"
+ teardown
+}
+
+test_session_waits_for_ssh_before_running_command () {
+ setup "session : attend que SSH réponde avant la commande"
+ mutate '.progress = [["ready","completed"]]'
+ echo 3 > "${WORK}/probe_fails"
+ run_lab session uname || fail "code de sortie $?"
+ [[ $(grep -c "BatchMode=yes" "${WORK}/ssh.log") -eq 4 ]] || fail "$(grep -c "BatchMode=yes" "${WORK}/ssh.log") sondes au lieu de 4"
+ [[ $(tail -n 1 "${WORK}/ssh.log") == *" uname" ]] || fail "commande non lancée en dernier"
+ teardown
+}
+
+test_session_deletes_server_when_ssh_never_answers () {
+ setup "session : serveur supprimé si SSH ne répond jamais"
+ mutate '.progress = [["ready","completed"]]'
+ echo 100000 > "${WORK}/probe_fails"
+ LAB_SSH_TIMEOUT=1 LAB_POLL_INTERVAL=0.2 run_lab session uname && fail "session a réussi"
+ grep -qv "BatchMode=yes" "${WORK}/ssh.log" && fail "commande lancée sans SSH joignable"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_ssh_uses_dedicated_lab_key_only_when_present () {
+ setup "ssh : clé dédiée du lab utilisée seule quand elle existe"
+ mutate '.progress = [["ready","completed"]]'
+ mkdir -p "${WORK}/.config/two-lab/ssh"
+ : > "${WORK}/.config/two-lab/ssh/lab_ed25519"
+ run_lab session uname || fail "code de sortie $?"
+ grep -q " uname" "${WORK}/ssh.log" || fail "commande non lancée"
+ [[ $(grep -c -- "-i ${WORK}/.config/two-lab/ssh/lab_ed25519 -o IdentitiesOnly=yes -o IdentityAgent=none" "${WORK}/ssh.log") -eq $(wc -l < "${WORK}/ssh.log") ]] \
+ || fail "une connexion n'a pas utilisé la clé dédiée seule"
+ teardown
+}
+
+test_ssh_falls_back_to_agent_without_lab_key () {
+ setup "ssh : agent SSH utilisé quand la clé dédiée est absente"
+ mutate '.progress = [["ready","completed"]]'
+ run_lab session uname || fail "code de sortie $?"
+ grep -q -- "-i " "${WORK}/ssh.log" && fail "clé imposée alors qu'elle n'existe pas"
+ grep -q "IdentityAgent=none" "${WORK}/ssh.log" && fail "agent désactivé sans clé dédiée"
+ teardown
+}
+
+known_server () {
+ mkdir -p "${WORK}/.cache/two-lab"
+ echo 203.0.113.7 > "${WORK}/.cache/two-lab/ip"
+ echo debian > "${WORK}/.cache/two-lab/user"
+ : > "${WORK}/.cache/two-lab/known_hosts"
+}
+
+test_up_prepares_the_server_after_ssh () {
+ setup "up : prépare le serveur (qemu, genisoimage, KVM imbriqué) une fois SSH joignable"
+ mutate '.progress = [["ready","completed"]]'
+ run_lab up || fail "code de sortie $?"
+ [[ $(tail -n 1 "${WORK}/ssh.log") == *"debian@203.0.113.7 bash -s" ]] || fail "préparation non lancée en dernier : $(tail -n 1 "${WORK}/ssh.log")"
+ grep -q "apt-get install -y -qq --no-install-recommends qemu-system-x86 qemu-utils genisoimage" "${WORK}/prepare.sh" || fail "paquets absents du script"
+ grep -q 'usermod -aG kvm' "${WORK}/prepare.sh" || fail "groupe kvm absent du script"
+ grep -q -- '-c /dev/kvm' "${WORK}/prepare.sh" || fail "contrôle de /dev/kvm absent"
+ grep -q 'kvm_intel/parameters/nested' "${WORK}/prepare.sh" || fail "contrôle de nested absent"
+ teardown
+}
+
+test_up_reports_a_billed_server_when_preparation_fails () {
+ setup "up : préparation échouée signalée, serveur toujours facturé"
+ mutate '.progress = [["ready","completed"]]'
+ FAKE_PREPARE_RC=1 run_lab up && fail "up a réussi"
+ grep -q "préparation du serveur échouée — il est toujours facturé" "${WORK}/out.log" || fail "message absent"
+ grep -q "prêt :" "${WORK}/out.log" && fail "serveur annoncé prêt"
+ teardown
+}
+
+test_session_deletes_server_when_preparation_fails () {
+ setup "session : serveur supprimé si la préparation échoue, commande jamais lancée"
+ mutate '.progress = [["ready","completed"]]'
+ FAKE_PREPARE_RC=1 run_lab session uname && fail "session a réussi"
+ grep -q " uname" "${WORK}/ssh.log" && fail "commande lancée malgré la préparation échouée"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_prepare_script_is_valid_shell () {
+ setup "prepare : le script distant est du shell valide"
+ known_server
+ run_lab prepare || fail "code de sortie $?"
+ bash -n "${WORK}/prepare.sh" || fail "bash -n refuse le script"
+ sh -n "${WORK}/prepare.sh" || fail "sh -n refuse le script"
+ teardown
+}
+
+test_prepare_without_known_server_is_refused () {
+ setup "prepare : refusé sans serveur connu"
+ run_lab prepare && fail "prepare a réussi"
+ [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé"
+ teardown
+}
+
+test_push_builds_for_linux_and_sends_binary_and_topology () {
+ setup "push : compile lab pour linux/amd64, envoie le binaire et le répertoire de la topologie"
+ known_server
+ mkdir -p "${WORK}/conf/frr"
+ printf 'name: evpn-2hv\n' > "${WORK}/conf/evpn-2hv.yml"
+ printf 'hostname rr1\n' > "${WORK}/conf/frr/rr1.conf"
+ xattr -w com.apple.test lab "${WORK}/conf/evpn-2hv.yml" 2>/dev/null || true
+ run_lab push "${WORK}/conf/evpn-2hv.yml" || fail "code de sortie $?"
+ local REPO LISTING
+ REPO="$(cd "$(dirname "${SCRIPT}")/.." && pwd)"
+ [[ $(cat "${WORK}/go.log") == "${REPO}|build -o ${WORK}/.cache/two-lab/lab ./cmd/lab|CGO_ENABLED=0 GOOS=linux GOARCH=amd64" ]] \
+ || fail "compilation : $(cat "${WORK}/go.log")"
+ grep -q "debian@203.0.113.7 cat > 'lab.part' && chmod 755 'lab.part' && mv 'lab.part' 'lab'" "${WORK}/ssh.log" || fail "envoi de lab absent"
+ grep -q "debian@203.0.113.7 rm -rf topology.part && mkdir topology.part && tar -C topology.part --no-same-owner -xf - && rm -rf topology && mv topology.part topology" "${WORK}/ssh.log" \
+ || fail "envoi du répertoire absent"
+ [[ $(cat "${WORK}/pushed.1") == "binaire-lab" ]] || fail "contenu de lab : $(cat "${WORK}/pushed.1")"
+ LISTING=$(tar -tf "${WORK}/pushed.2" | sed 's|^\./||' | grep -v '/$' | grep -v '^$' | sort | tr '\n' ' ')
+ [[ "${LISTING}" == "evpn-2hv.yml frr/rr1.conf " ]] || fail "contenu de l'archive : ${LISTING}"
+ grep -aq 'LIBARCHIVE.xattr' "${WORK}/pushed.2" && fail "attributs étendus macOS dans l'archive"
+ mkdir "${WORK}/x" && tar -C "${WORK}/x" -xf "${WORK}/pushed.2"
+ [[ $(cat "${WORK}/x/frr/rr1.conf") == "hostname rr1" ]] || fail "frr/rr1.conf altéré"
+ grep -q "ssh './lab up topology/evpn-2hv.yml'" "${WORK}/out.log" || fail "consigne finale absente"
+ teardown
+}
+
+test_push_stops_when_the_build_fails () {
+ setup "push : rien n'est envoyé si la compilation échoue"
+ known_server
+ printf 'name: x\n' > "${WORK}/x.yml"
+ FAKE_GO_FAIL=1 run_lab push "${WORK}/x.yml" && fail "push a réussi"
+ [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé"
+ grep -q "compilation de lab échouée" "${WORK}/out.log" || fail "message absent"
+ teardown
+}
+
+test_push_fails_when_a_transfer_fails () {
+ setup "push : un envoi échoué fait échouer push"
+ known_server
+ printf 'name: x\n' > "${WORK}/x.yml"
+ FAKE_PUSH_RC=1 run_lab push "${WORK}/x.yml" && fail "push a réussi"
+ grep -q "envoi de lab échoué" "${WORK}/out.log" || fail "message absent"
+ teardown
+}
+
+test_push_refusals () {
+ local CASE
+ for CASE in absent quote nothing; do
+ setup "push : refus (${CASE})"
+ known_server
+ case "${CASE}" in
+ absent) run_lab push "${WORK}/absent.yml" && fail "push a réussi" ;;
+ quote) printf 'x\n' > "${WORK}/a'b.yml"; run_lab push "${WORK}/a'b.yml" && fail "push a réussi" ;;
+ nothing) run_lab push && fail "push a réussi" ;;
+ esac
+ [[ -s "${WORK}/go.log" ]] && fail "compilation lancée"
+ [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé"
+ teardown
+ done
+}
+
+test_ssh_without_terminal_does_not_ask_for_one () {
+ setup "ssh : pas de -t quand l'entrée n'est pas un terminal"
+ known_server
+ run_lab ssh './lab status' < /dev/null || fail "code de sortie $?"
+ [[ $(cat "${WORK}/ssh.log") == *"debian@203.0.113.7 ./lab status" ]] || fail "commande : $(cat "${WORK}/ssh.log")"
+ grep -q -- " -t " "${WORK}/ssh.log" && fail "-t demandé sans terminal"
+ teardown
+}
+
+run_lab_without_env_credentials () {
+ env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \
+ FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" SCW_API_URL="https://api.example.invalid" \
+ LAB_POLL_INTERVAL=0 "$@" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1
+}
+
+write_credentials () {
+ mkdir -p "${WORK}/.config/two-lab"
+ printf '%s\n' "$@" > "${WORK}/.config/two-lab/scaleway.env"
+ chmod 600 "${WORK}/.config/two-lab/scaleway.env"
+}
+
+test_credentials_file_is_used_when_environment_is_empty () {
+ setup "identifiants : fichier lu quand l'environnement est vide"
+ write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" "SCW_DEFAULT_ZONE=fr-par-1"
+ run_lab_without_env_credentials || fail "code de sortie $?"
+ grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet du fichier non utilisé"
+ grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché"
+ teardown
+}
+
+test_credentials_file_without_final_newline_is_read () {
+ setup "identifiants : dernière ligne sans retour à la ligne lue quand même"
+ mkdir -p "${WORK}/.config/two-lab"
+ printf 'SCW_DEFAULT_PROJECT_ID=%s\nSCW_SECRET_KEY=%s' "${PROJECT}" "${SECRET}" > "${WORK}/.config/two-lab/scaleway.env"
+ chmod 600 "${WORK}/.config/two-lab/scaleway.env"
+ run_lab_without_env_credentials || fail "code de sortie $?"
+ teardown
+}
+
+test_environment_wins_over_credentials_file () {
+ setup "identifiants : l'environnement l'emporte sur le fichier"
+ write_credentials "SCW_SECRET_KEY=mauvais-secret" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}"
+ run_lab_without_env_credentials SCW_SECRET_KEY="${SECRET}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?"
+ grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement non retenu"
+ teardown
+}
+
+test_environment_value_kept_when_file_completes_the_rest () {
+ setup "identifiants : une valeur de l'environnement n'est pas écrasée quand le fichier complète"
+ write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}"
+ run_lab_without_env_credentials SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?"
+ grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement écrasé par le fichier"
+ teardown
+}
+
+test_credentials_file_readable_by_others_is_refused () {
+ setup "identifiants : fichier lisible par d'autres refusé, sans aucun appel"
+ write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}"
+ chmod 644 "${WORK}/.config/two-lab/scaleway.env"
+ run_lab_without_env_credentials && fail "plan a réussi"
+ grep -q "chmod 600" "${WORK}/out.log" || fail "pas de consigne de droits"
+ [[ -s "${WORK}/calls.log" ]] && fail "appel réseau avec un fichier exposé"
+ teardown
+}
+
+test_credentials_file_is_never_executed () {
+ setup "identifiants : le fichier est lu, jamais exécuté"
+ write_credentials "SCW_SECRET_KEY=\$(touch ${WORK}/pwned)" "touch ${WORK}/pwned2" "SCW_DEFAULT_PROJECT_ID=${PROJECT}"
+ run_lab_without_env_credentials && fail "plan a réussi avec un secret invalide"
+ [[ -e "${WORK}/pwned" || -e "${WORK}/pwned2" ]] && fail "contenu du fichier exécuté"
+ teardown
+}
+
+signal_during_session () {
+ local SIGNAL="${1}"
+ local EXPECTED="${2}"
+ local PID RC I
+ setup "session : ${SIGNAL} pendant la commande distante, serveur supprimé, code ${EXPECTED}"
+ mutate '.progress = [["ready","completed"]]'
+ set -m
+ FAKE_SSH_SLEEP=5 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 &
+ PID=$!
+ set +m
+ for I in $(seq 1 100); do
+ grep -q "sleep-long" "${WORK}/ssh.log" && break
+ sleep 0.1
+ done
+ grep -q "sleep-long" "${WORK}/ssh.log" || fail "commande distante jamais lancée"
+ kill "-${SIGNAL}" -- "-${PID}"
+ wait "${PID}"
+ RC=$?
+ [[ "${RC}" -eq "${EXPECTED}" ]] || fail "code ${RC} au lieu de ${EXPECTED}"
+ [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)"
+ teardown
+}
+
+test_session_handles_term () { signal_during_session TERM 143; }
+test_session_handles_hup () { signal_during_session HUP 129; }
+test_session_handles_int () { signal_during_session INT 130; }
+
+for T in $(declare -F | awk '{print $3}' | grep '^test_'); do
+ "${T}"
+done
+echo "${PASS} réussi(s), ${FAIL} échec(s)"
+[[ "${FAIL}" -eq 0 ]]
diff --git a/test/e2e/lib/node.sh b/test/e2e/lib/node.sh
new file mode 100644
index 0000000..0a7111c
--- /dev/null
+++ b/test/e2e/lib/node.sh
@@ -0,0 +1,151 @@
+set -u
+API=http://127.0.0.1:8080
+IMAGE_URL=https://cloud.debian.org/images/cloud/bookworm/latest
+IMAGE_FILE=debian-12-genericcloud-amd64.qcow2
+TWO_IMAGE=/var/tmp/deb-two.qcow2
+VM_KEY=/root/.ssh/lab-vm
+VOLUMES=/var/lib/two/volumes
+
+check () {
+ local desc="${1}" out
+ shift
+ if out=$("$@" 2>&1); then
+ echo "RÉUSSI: ${desc}"
+ else
+ echo "ÉCHOUÉ: ${desc}${out:+ — $(printf '%s' "${out}" | tail -n 1)}"
+ fi
+}
+
+info () { echo "INFO: ${1}"; }
+
+api_post () {
+ curl -fsS -o /dev/null -X POST -H 'Content-Type: application/json' -d "${2}" "${API}/${1}"
+}
+
+api_delete () {
+ curl -fsS -o /dev/null -X DELETE "${API}/${1}"
+}
+
+api_state () {
+ curl -fsS "${API}/${1}" 2>/dev/null | jq -r .state
+}
+
+wait_state () {
+ local path="${1}" want="${2}" i s
+ for i in $(seq 1 90); do
+ s=$(api_state "${path}")
+ [ "${s}" = "${want}" ] && return 0
+ [ "${want}" = deleted ] && [ -z "${s}" ] && return 0
+ [ "${s}" = error ] && { echo "${path}: error" >&2; return 1; }
+ sleep 2
+ done
+ echo "${path}: ${s:-absent}, ${want} attendu" >&2
+ return 1
+}
+
+vpc_create () {
+ api_state "vpcs/${1}" | grep -qx running && return 0
+ api_post vpcs "{\"name\":\"${1}\",\"cidr\":\"${2}\"}" && wait_state "vpcs/${1}" running
+}
+
+subnet_create () {
+ local name="${1}" vpc="${2}" vni="${3}" gw="${4}" cidr="${5}" extra="${6:-}"
+ api_state "subnets/${name}" | grep -qx running && return 0
+ api_post subnets "{\"name\":\"${name}\",\"vpc\":\"${vpc}\",\"mode\":\"vxlan\",\"vxlan_id\":${vni},\"iface_type\":\"vms\",\"interface_ip\":\"${gw}\",\"cidr\":\"${cidr}\"${extra}}" \
+ && wait_state "subnets/${name}" running
+}
+
+two_image () {
+ [ -f "${TWO_IMAGE}" ] && return 0
+ local work
+ work=$(mktemp -d)
+ curl -fsSL -o "${work}/${IMAGE_FILE}" "${IMAGE_URL}/${IMAGE_FILE}" || return 1
+ curl -fsSL "${IMAGE_URL}/SHA512SUMS" | grep " ${IMAGE_FILE}\$" > "${work}/sums" || return 1
+ (cd "${work}" && sha512sum -c sums >/dev/null) || { echo "image: somme SHA-512 incorrecte" >&2; return 1; }
+ command -v qemu-nbd >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-utils >/dev/null
+ modprobe nbd max_part=8
+ qemu-nbd -c /dev/nbd0 "${work}/${IMAGE_FILE}" || return 1
+ for i in $(seq 1 20); do [ -b /dev/nbd0p1 ] && break; sleep 0.5; done
+ mkdir -p /mnt/two-image
+ mount /dev/nbd0p1 /mnt/two-image || { qemu-nbd -d /dev/nbd0 >/dev/null; return 1; }
+ printf '%s\n' 'datasource_list: [ NoCloud ]' 'datasource:' ' NoCloud:' " seedfrom: 'http://169.254.169.254:80/'" ' timeout: 5' ' max_wait: 10' \
+ > /mnt/two-image/etc/cloud/cloud.cfg.d/99_metadata.cfg
+ umount /mnt/two-image
+ qemu-nbd -d /dev/nbd0 >/dev/null
+ mv "${work}/${IMAGE_FILE}" "${TWO_IMAGE}"
+ rm -rf "${work}"
+}
+
+vm_key () {
+ [ -f "${VM_KEY}" ] || ssh-keygen -q -t ed25519 -N '' -C lab-vm -f "${VM_KEY}"
+ cat "${VM_KEY}.pub"
+}
+
+vm_create () {
+ local name="${1}" subnet="${2}" ip="${3}" key="${4:-}" meta="{}"
+ [ -n "${key}" ] && meta="{\"sshkey\":\"${key}\"}"
+ mkdir -p "${VOLUMES}"
+ cp "${TWO_IMAGE}" "${VOLUMES}/${name}.qcow2" || return 1
+ api_post vms "{\"name\":\"${name}\",\"memory\":1024,\"cpus\":1,\"metadata\":${meta},\"interfaces\":[{\"subnet\":\"${subnet}\",\"ip\":\"${ip}\",\"primary\":true}],\"storage\":[{\"path\":\"${VOLUMES}/${name}.qcow2\",\"dev\":\"vda\"}]}" \
+ && wait_state "vms/${name}" running
+}
+
+vm_delete () {
+ api_delete "vms/${1}" && wait_state "vms/${1}" deleted
+}
+
+vm_ssh () {
+ local vpc="${1}" ip="${2}"
+ shift 2
+ ip netns exec "${vpc}" ssh -i "${VM_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
+ -o LogLevel=ERROR -o ConnectTimeout=3 -o BatchMode=yes -n "syonad@${ip}" "$@"
+}
+
+vm_wait () {
+ local i
+ for i in $(seq 1 40); do
+ vm_ssh "${1}" "${2}" true 2>/dev/null && return 0
+ sleep 5
+ done
+ echo "${2}: SSH injoignable" >&2
+ return 1
+}
+
+vm_fails () {
+ local out
+ out=$(vm_ssh "${1}" "${2}" "${3} >/dev/null 2>&1; echo rc=\$?") || { echo "SSH vers ${2} en échec"; return 1; }
+ case "${out}" in
+ rc=0) echo "la commande a réussi dans ${2}"; return 1 ;;
+ rc=*) return 0 ;;
+ *) echo "sortie inattendue : ${out}"; return 1 ;;
+ esac
+}
+
+vm_grep () {
+ vm_ssh "${1}" "${2}" "${3} 2>&1" | grep -q -- "${4}"
+}
+
+has_addr () {
+ vm_ssh "${1}" "${2}" 'ip -4 -o addr show dev ens3' | grep -q " ${2}/"
+}
+
+route_via () {
+ vm_ssh "${1}" "${2}" "ip -4 route show ${3}" | grep -q "via ${4} "
+}
+
+dhcp_hosts () {
+ python3 - "/run/two/dhcp/${1}.sock" <<'PY'
+import json, socket, sys
+s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
+s.connect(sys.argv[1])
+s.sendall(b'{"verb":"get-state"}\n')
+buf = b""
+while not buf.endswith(b"\n"):
+ c = s.recv(65536)
+ if not c:
+ break
+ buf += c
+state = json.loads(buf).get("state") or {}
+print(" ".join(sorted(h["mac"].lower() + "=" + h["ip"] for h in state.get("hosts") or [])))
+PY
+}
diff --git a/test/e2e/run.sh b/test/e2e/run.sh
new file mode 100755
index 0000000..4b9e84d
--- /dev/null
+++ b/test/e2e/run.sh
@@ -0,0 +1,67 @@
+#!/usr/bin/env bash
+
+LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+LAB_HOST="${LAB_HOST:-${LAB_DIR}/../../scripts/lab-host.sh}"
+SCENARIO_DIR="${SCENARIO_DIR:-${LAB_DIR}/scenarios}"
+SCENARIO_LOG=""
+
+usage () {
+ cat >&2 <... | all
+
+ scénarios : $(cd "${SCENARIO_DIR}" && ls -- *.sh | sed 's/\.sh$//' | tr '\n' ' ')
+
+Chaque scénario s'exécute depuis le Mac sur le lab en cours (lab-host.sh up, push, ./lab up).
+Une ligne RÉUSSI ou ÉCHOUÉ par vérification ; code de sortie 1 si une vérification échoue ou
+si aucune n'a été faite.
+USAGE
+ exit 2
+}
+
+on () {
+ local node="${1}" assignment
+ shift
+ {
+ for assignment in "$@"; do
+ printf 'export %q=%q\n' "${assignment%%=*}" "${assignment#*=}"
+ done
+ cat "${LAB_DIR}/lib/node.sh"
+ cat
+ } | "${LAB_HOST}" ssh "./lab ssh ${node} 'sudo bash -s'" 2>&1 | tee -a "${SCENARIO_LOG}"
+}
+
+run_scenario () {
+ local file="${1}" name passed failed
+ name="$(basename "${file}" .sh)"
+ SCENARIO_LOG="$(mktemp)"
+ echo "=== ${name}"
+ ( . "${file}" )
+ passed=$(grep -c '^RÉUSSI: ' "${SCENARIO_LOG}")
+ failed=$(grep -c '^ÉCHOUÉ: ' "${SCENARIO_LOG}")
+ rm -f "${SCENARIO_LOG}"
+ echo "=== ${name} : ${passed} réussi(s), ${failed} échoué(s)"
+ [[ "${failed}" -eq 0 && "${passed}" -gt 0 ]]
+}
+
+main () {
+ local -a files=()
+ local arg file rc=0
+ [[ $# -gt 0 ]] || usage
+ for arg in "$@"; do
+ if [[ "${arg}" == all ]]; then
+ files+=("${SCENARIO_DIR}"/*.sh)
+ continue
+ fi
+ file=$(ls "${SCENARIO_DIR}/${arg}"*.sh 2>/dev/null | head -n 1)
+ [[ -n "${file}" ]] || { echo "scénario inconnu : ${arg}" >&2; usage; }
+ files+=("${file}")
+ done
+ for file in "${files[@]}"; do
+ run_scenario "${file}" || rc=1
+ done
+ return "${rc}"
+}
+
+if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then
+ main "$@"
+fi
diff --git a/test/e2e/run_test.sh b/test/e2e/run_test.sh
new file mode 100755
index 0000000..dd8022f
--- /dev/null
+++ b/test/e2e/run_test.sh
@@ -0,0 +1,140 @@
+#!/usr/bin/env bash
+
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PASS=0
+FAIL=0
+
+fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; }
+
+setup () {
+ CURRENT="${1}"
+ CASE_OK=1
+ WORK=$(mktemp -d "${TMPDIR:-/tmp}/scenario.XXXXXX")
+ mkdir -p "${WORK}/scenarios"
+ cat > "${WORK}/lab-host" <<'FAKE'
+#!/usr/bin/env bash
+N=$(ls "${FAKE_DIR}" | grep -c '^payload\.')
+cat > "${FAKE_DIR}/payload.$(( N + 1 ))"
+printf '%s\n' "$*" >> "${FAKE_DIR}/calls.log"
+if ! bash -n "${FAKE_DIR}/payload.$(( N + 1 ))" 2>"${FAKE_DIR}/syntax.$(( N + 1 ))"; then
+ echo "ÉCHOUÉ: syntaxe du bloc $(( N + 1 ))"
+ exit 0
+fi
+[ -n "${FAKE_OUT:-}" ] && printf '%b\n' "${FAKE_OUT}"
+exit 0
+FAKE
+ chmod +x "${WORK}/lab-host"
+ : > "${WORK}/calls.log"
+}
+
+run () {
+ env LAB_HOST="${WORK}/lab-host" FAKE_DIR="${WORK}" SCENARIO_DIR="${SCENARIO_DIR:-${WORK}/scenarios}" \
+ FAKE_OUT="${FAKE_OUT:-}" bash "${DIR}/run.sh" "$@" > "${WORK}/out.log" 2>&1
+}
+
+teardown () {
+ [[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}"
+ [[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; }
+ rm -rf "${WORK}"
+}
+
+node () {
+ bash -c ". '${DIR}/lib/node.sh'; ${1}" 2>&1
+}
+
+test_on_sends_assignments_library_and_block_to_the_node () {
+ setup "on : variables, bibliothèque et bloc envoyés au bon nœud"
+ printf '%s\n' "on hv2 KEY='ssh-ed25519 AAAA x' NAME=vm <<'NODE'" 'echo "${NAME}"' 'NODE' > "${WORK}/scenarios/t1.sh"
+ FAKE_OUT='RÉUSSI: un' run t1 || fail "code de sortie $?"
+ [[ $(cat "${WORK}/calls.log") == "ssh ./lab ssh hv2 'sudo bash -s'" ]] || fail "appel : $(cat "${WORK}/calls.log")"
+ grep -q '^check () {' "${WORK}/payload.1" || fail "node.sh absent du bloc"
+ [[ $(tail -n 1 "${WORK}/payload.1") == 'echo "${NAME}"' ]] || fail "bloc du scénario absent en fin d'envoi"
+ [[ $(bash -c "$(grep '^export ' "${WORK}/payload.1"); printf '%s|%s' \"\${KEY}\" \"\${NAME}\"") == "ssh-ed25519 AAAA x|vm" ]] \
+ || fail "variables mal transmises : $(grep '^export ' "${WORK}/payload.1")"
+ teardown
+}
+
+test_scenario_result_counts_and_exit_codes () {
+ local CASE
+ for CASE in ok fail none; do
+ setup "résultat d'un scénario (${CASE})"
+ printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/t2.sh"
+ case "${CASE}" in
+ ok) FAKE_OUT='RÉUSSI: a\nRÉUSSI: b' run t2; [[ $? -eq 0 ]] || fail "code non nul"
+ grep -q '=== t2 : 2 réussi(s), 0 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;;
+ fail) FAKE_OUT='RÉUSSI: a\nÉCHOUÉ: b — détail' run t2; [[ $? -eq 1 ]] || fail "code différent de 1"
+ grep -q '=== t2 : 1 réussi(s), 1 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;;
+ none) FAKE_OUT='INFO: rien' run t2; [[ $? -eq 1 ]] || fail "un scénario sans vérification a réussi" ;;
+ esac
+ teardown
+ done
+}
+
+test_unknown_scenario_and_no_argument () {
+ setup "scénario inconnu ou absent : usage, code 2"
+ run nope; [[ $? -eq 2 ]] || fail "scénario inconnu : code $?"
+ run; [[ $? -eq 2 ]] || fail "sans argument : code $?"
+ [[ -s "${WORK}/calls.log" ]] && fail "un nœud a été appelé"
+ teardown
+}
+
+test_all_runs_every_scenario_and_fails_if_one_fails () {
+ setup "all : chaque scénario, échec si l'un échoue"
+ printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/a.sh"
+ printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/b.sh"
+ FAKE_OUT='ÉCHOUÉ: x' run all; [[ $? -eq 1 ]] || fail "code différent de 1"
+ [[ $(grep -c '^=== [ab] :' "${WORK}/out.log") -eq 2 ]] || fail "les deux scénarios n'ont pas tourné"
+ teardown
+}
+
+test_every_shipped_block_is_valid_bash () {
+ local f
+ setup "chaque bloc envoyé par les scénarios livrés est du bash valide"
+ SCENARIO_DIR="${DIR}/scenarios" FAKE_OUT='RÉUSSI: x' run all
+ for f in "${WORK}"/syntax.*; do
+ [[ -s "${f}" ]] && fail "${f##*/} : $(cat "${f}")"
+ done
+ [[ $(ls "${WORK}" | grep -c '^payload\.') -ge 10 ]] || fail "trop peu de blocs envoyés : $(ls "${WORK}" | grep -c '^payload\.')"
+ grep -q 'ÉCHOUÉ: syntaxe' "${WORK}/out.log" && fail "bloc invalide"
+ teardown
+}
+
+test_check_reports_success_and_failure_with_reason () {
+ setup "check : RÉUSSI, ou ÉCHOUÉ avec la dernière ligne de sortie"
+ [[ $(node 'check "a" true') == "RÉUSSI: a" ]] || fail "succès : $(node 'check "a" true')"
+ [[ $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"') == "ÉCHOUÉ: b — deux" ]] || fail "échec : $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"')"
+ [[ $(node 'check "c" false') == "ÉCHOUÉ: c" ]] || fail "échec muet : $(node 'check "c" false')"
+ teardown
+}
+
+test_vm_fails_only_when_ssh_worked_and_command_failed () {
+ setup "vm_fails : réussit seulement si SSH marche et la commande échoue"
+ [[ $(node 'vm_ssh () { echo rc=1; }; vm_fails v 1.2.3.4 x && echo OUI') == "OUI" ]] || fail "commande échouée non reconnue"
+ [[ $(node 'vm_ssh () { echo rc=0; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "commande réussie prise pour un échec"
+ [[ $(node 'vm_ssh () { return 255; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "SSH en panne pris pour une isolation"
+ [[ $(node 'vm_ssh () { echo bizarre; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "sortie inattendue acceptée"
+ teardown
+}
+
+test_route_via_matches_the_next_hop_exactly () {
+ setup "route_via : le next-hop exact, pas un préfixe"
+ [[ $(node 'vm_ssh () { echo "default via 10.220.1.1 dev ens3 proto dhcp"; }; route_via v ip default 10.220.1.1 && echo OUI') == OUI ]] || fail "next-hop exact refusé"
+ [[ $(node 'vm_ssh () { echo "default via 10.220.1.10 dev ens3"; }; route_via v ip default 10.220.1.1 || echo NON') == NON ]] || fail "10.220.1.10 pris pour 10.220.1.1"
+ teardown
+}
+
+test_wait_state () {
+ setup "wait_state : état atteint, erreur, suppression"
+ [[ $(node 'api_state () { echo running; }; wait_state vms/x running && echo OUI') == OUI ]] || fail "running"
+ local start=${SECONDS}
+ [[ $(node 'api_state () { echo error; }; wait_state vms/x running 2>&1 || echo NON') == *NON ]] || fail "error accepté"
+ (( SECONDS - start < 5 )) || fail "une ressource en error n'arrête pas l'attente immédiatement ($(( SECONDS - start )) s)"
+ [[ $(node 'api_state () { echo; }; wait_state vms/x deleted && echo OUI') == OUI ]] || fail "absence = supprimé"
+ teardown
+}
+
+for T in $(declare -F | awk '{print $3}' | grep '^test_'); do
+ "${T}"
+done
+echo "${PASS} réussi(s), ${FAIL} échec(s)"
+[[ "${FAIL}" -eq 0 ]]
diff --git a/test/e2e/scenarios/s1-dhcp-two.sh b/test/e2e/scenarios/s1-dhcp-two.sh
new file mode 100644
index 0000000..bbf727c
--- /dev/null
+++ b/test/e2e/scenarios/s1-dhcp-two.sh
@@ -0,0 +1,34 @@
+on hv1 <<'NODE'
+two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; }
+KEY=$(vm_key)
+check "VPC vp-s1" vpc_create vp-s1 10.210.0.0/16
+check "subnet sn-s1a" subnet_create sn-s1a vp-s1 2101 10.210.1.1 10.210.1.0/24
+check "subnet sn-s1b" subnet_create sn-s1b vp-s1 2102 10.210.2.1 10.210.2.0/24
+check "backend two : socket de contrôle de sn-s1a" test -S /run/two/dhcp/vp-s1_br-s1a.sock
+check "backend two : socket de contrôle de sn-s1b" test -S /run/two/dhcp/vp-s1_br-s1b.sock
+check "VM s1-a1, démarrée seule" vm_create s1-a1 sn-s1a 10.210.1.10 "${KEY}"
+check "VM s1-a1 joignable" vm_wait vp-s1 10.210.1.10
+check "VM s1-b1, démarrée seule" vm_create s1-b1 sn-s1b 10.210.2.10 "${KEY}"
+check "VM s1-b1 joignable" vm_wait vp-s1 10.210.2.10
+vm_create s1-a2 sn-s1a 10.210.1.11 "${KEY}" > /tmp/s1-a2.log 2>&1 & A=$!
+vm_create s1-b2 sn-s1b 10.210.2.11 "${KEY}" > /tmp/s1-b2.log 2>&1 & B=$!
+wait "${A}"; RA=$?
+wait "${B}"; RB=$?
+check "VM s1-a2, démarrée en même temps que s1-b2" test "${RA}" -eq 0
+check "VM s1-b2, démarrée en même temps que s1-a2" test "${RB}" -eq 0
+for vm in 10.210.1.10:10.210.1.1 10.210.1.11:10.210.1.1 10.210.2.10:10.210.2.1 10.210.2.11:10.210.2.1; do
+ ip=${vm%%:*}; gw=${vm#*:}
+ check "${ip} joignable" vm_wait vp-s1 "${ip}"
+ check "${ip} : adresse de son propre subnet" has_addr vp-s1 "${ip}"
+ check "${ip} : bail DHCP tenu par systemd-networkd" vm_ssh vp-s1 "${ip}" 'ls /run/systemd/netif/leases/ | grep -q .'
+ check "${ip} : route par défaut via ${gw}" route_via vp-s1 "${ip}" default "${gw}"
+ check "${ip} : route vers la VPC via ${gw}" route_via vp-s1 "${ip}" 10.210.0.0/16 "${gw}"
+ check "${ip} : route /32 vers les métadonnées via ${gw}" route_via vp-s1 "${ip}" 169.254.169.254 "${gw}"
+done
+vm_host () { echo "$(vm_ssh vp-s1 "${1}" 'cat /sys/class/net/ens3/address')=${1}"; }
+A_HOSTS=$(printf '%s\n' "$(vm_host 10.210.1.10)" "$(vm_host 10.210.1.11)" | sort | tr '\n' ' ' | sed 's/ $//')
+B_HOSTS=$(printf '%s\n' "$(vm_host 10.210.2.10)" "$(vm_host 10.210.2.11)" | sort | tr '\n' ' ' | sed 's/ $//')
+info "sn-s1a : ${A_HOSTS} ; sn-s1b : ${B_HOSTS}"
+check "probe sn-s1a : uniquement les couples MAC/IP de sn-s1a" test "$(dhcp_hosts vp-s1_br-s1a)" = "${A_HOSTS}"
+check "probe sn-s1b : uniquement les couples MAC/IP de sn-s1b" test "$(dhcp_hosts vp-s1_br-s1b)" = "${B_HOSTS}"
+NODE
diff --git a/test/e2e/scenarios/s2-gateway.sh b/test/e2e/scenarios/s2-gateway.sh
new file mode 100644
index 0000000..4785731
--- /dev/null
+++ b/test/e2e/scenarios/s2-gateway.sh
@@ -0,0 +1,26 @@
+on hv1 <<'NODE'
+two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; }
+KEY=$(vm_key)
+check "VPC vp-s2" vpc_create vp-s2 10.220.0.0/16
+check "subnet sn-s2a, default_route absent" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24
+check "subnet sn-s2b, default_route et gateway 10.220.2.254" subnet_create sn-s2b vp-s2 2202 10.220.2.1 10.220.2.0/24 ',"default_route":true,"gateway":"10.220.2.254"'
+check "VM s2-a" vm_create s2-a sn-s2a 10.220.1.10 "${KEY}"
+check "VM s2-b" vm_create s2-b sn-s2b 10.220.2.10 "${KEY}"
+check "s2-a joignable" vm_wait vp-s2 10.220.1.10
+check "s2-b joignable" vm_wait vp-s2 10.220.2.10
+check "sn-s2a : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.10 default 10.220.1.1
+check "sn-s2a : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.10 10.220.0.0/16 10.220.1.1
+check "sn-s2a : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.1.10 169.254.169.254 10.220.1.1
+check "sn-s2b : route par défaut via la gateway 10.220.2.254" route_via vp-s2 10.220.2.10 default 10.220.2.254
+check "sn-s2b : route vers la VPC via interface_ip, pas via la gateway" route_via vp-s2 10.220.2.10 10.220.0.0/16 10.220.2.1
+check "sn-s2b : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.2.10 169.254.169.254 10.220.2.1
+check "suppression de s2-a" vm_delete s2-a
+check "suppression de sn-s2a" api_delete subnets/sn-s2a
+check "sn-s2a supprimé" wait_state subnets/sn-s2a deleted
+check "sn-s2a recréé" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24
+check "VM s2-a2 sur le subnet recréé" vm_create s2-a2 sn-s2a 10.220.1.11 "${KEY}"
+check "s2-a2 joignable" vm_wait vp-s2 10.220.1.11
+check "après recréation : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.11 default 10.220.1.1
+check "après recréation : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.11 10.220.0.0/16 10.220.1.1
+check "après recréation : route /32 vers les métadonnées" route_via vp-s2 10.220.1.11 169.254.169.254 10.220.1.1
+NODE
diff --git a/test/e2e/scenarios/s3-isolation-local.sh b/test/e2e/scenarios/s3-isolation-local.sh
new file mode 100644
index 0000000..dc822f5
--- /dev/null
+++ b/test/e2e/scenarios/s3-isolation-local.sh
@@ -0,0 +1,16 @@
+on hv1 <<'NODE'
+two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; }
+KEY=$(vm_key)
+check "VPC vp-s3a" vpc_create vp-s3a 10.230.0.0/16
+check "VPC vp-s3b" vpc_create vp-s3b 10.231.0.0/16
+check "subnet sn-s3a" subnet_create sn-s3a vp-s3a 2301 10.230.1.1 10.230.1.0/24
+check "subnet sn-s3b" subnet_create sn-s3b vp-s3b 2302 10.231.1.1 10.231.1.0/24
+check "VM s3-x dans vp-s3a" vm_create s3-x sn-s3a 10.230.1.10 "${KEY}"
+check "VM s3-y dans vp-s3b" vm_create s3-y sn-s3b 10.231.1.10 "${KEY}"
+check "s3-x joignable depuis sa VPC" vm_wait vp-s3a 10.230.1.10
+check "s3-y joignable depuis sa VPC (témoin : la cible est vivante)" vm_wait vp-s3b 10.231.1.10
+check "s3-x joint sa passerelle (témoin)" vm_ssh vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.230.1.1'
+check "s3-x ne joint pas s3-y en ICMP" vm_fails vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.231.1.10'
+check "s3-x n'ouvre pas de connexion TCP vers s3-y:22" vm_fails vp-s3a 10.230.1.10 'timeout 5 bash -c "/dev/null 2>&1 && break; sleep 5; done
+check "s4-p (hv1) joint s4-q (hv2)" vm_ssh vp-s4 10.240.1.10 'ping -c 3 -W 2 10.240.1.11'
+check "trame pleine taille : 1472 octets en -M do" vm_ssh vp-s4 10.240.1.10 'ping -M do -s 1472 -c 2 -W 2 10.240.1.11'
+check "1473 octets refusés localement (MTU 1500)" vm_grep vp-s4 10.240.1.10 'ping -M do -s 1473 -c 1 -W 2 10.240.1.11' 'message too long'
+NODE
diff --git a/test/e2e/scenarios/s5-isolation-evpn.sh b/test/e2e/scenarios/s5-isolation-evpn.sh
new file mode 100644
index 0000000..a761056
--- /dev/null
+++ b/test/e2e/scenarios/s5-isolation-evpn.sh
@@ -0,0 +1,23 @@
+on hv1 <<'NODE'
+two_image || { echo "ÉCHOUÉ: image compatible two (hv1)"; exit 0; }
+KEY=$(vm_key)
+check "hv1 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16
+check "hv1 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24
+check "hv1 : VM s5-r dans vp-s5a" vm_create s5-r sn-s5a 10.245.1.10 "${KEY}"
+NODE
+on hv2 <<'NODE'
+two_image || { echo "ÉCHOUÉ: image compatible two (hv2)"; exit 0; }
+check "hv2 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16
+check "hv2 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24
+check "hv2 : VM s5-t dans vp-s5a (témoin)" vm_create s5-t sn-s5a 10.245.1.12
+check "hv2 : VPC vp-s5b, même plage" vpc_create vp-s5b 10.245.0.0/16
+check "hv2 : subnet sn-s5b (VNI 2502), même plage" subnet_create sn-s5b vp-s5b 2502 10.245.1.1 10.245.1.0/24
+check "hv2 : VM s5-s dans vp-s5b" vm_create s5-s sn-s5b 10.245.1.11
+NODE
+on hv1 <<'NODE'
+check "s5-r joignable" vm_wait vp-s5a 10.245.1.10
+for i in $(seq 1 24); do vm_ssh vp-s5a 10.245.1.10 'ping -c 1 -W 2 10.245.1.12' >/dev/null 2>&1 && break; sleep 5; done
+check "s5-r joint s5-t, même VPC sur hv2 (témoin)" vm_ssh vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.12'
+check "s5-r ne joint pas s5-s, autre VPC sur hv2, même plage" vm_fails vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.11'
+check "aucune entrée ARP résolue pour 10.245.1.11 dans s5-r" vm_fails vp-s5a 10.245.1.10 'ip neigh show 10.245.1.11 | grep -q lladdr'
+NODE
diff --git a/test/e2e/scenarios/s6-rr-loss.sh b/test/e2e/scenarios/s6-rr-loss.sh
new file mode 100644
index 0000000..e4ea28e
--- /dev/null
+++ b/test/e2e/scenarios/s6-rr-loss.sh
@@ -0,0 +1,36 @@
+on hv1 <<'NODE'
+check "prérequis : VM s4-p de S4 en marche" test "$(api_state vms/s4-p)" = running
+check "session EVPN de hv1 établie avant l'arrêt" sh -c "vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'"
+check "prérequis : s4-p joint s4-q" vm_ssh vp-s4 10.240.1.10 'ping -c 2 -W 2 10.240.1.11'
+vm_ssh vp-s4 10.240.1.10 'rm -f /tmp/s6.log; nohup ping -D -i 0.2 10.240.1.11 > /tmp/s6.log 2>&1 &'
+sleep 5
+NODE
+on rr1 <<'NODE'
+date +%s > /tmp/s6.stop
+check "arrêt de FRR sur le route reflector" systemctl stop frr
+NODE
+sleep 30
+on hv1 <<'NODE'
+recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; }
+check "session EVPN de hv1 tombée" sh -c "! vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'"
+info "30 s après l'arrêt du route reflector : $(recent 10) réponses dans les 10 dernières secondes (50 si le trafic passe intégralement)"
+info "VTEP distant encore connu : $(vtysh -c 'show evpn vni 2401' | grep -c 192.168.14.12) ; entrée d'inondation : $(bridge fdb show dev vxlan-2401 | grep -c '00:00:00:00:00:00 dst')"
+sleep 60
+info "90 s après l'arrêt : $(recent 10) réponses dans les 10 dernières secondes"
+NODE
+on rr1 <<'NODE'
+check "redémarrage de FRR sur le route reflector" systemctl start frr
+date +%s > /tmp/s6.start
+NODE
+on hv1 <<'NODE'
+recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; }
+start=$(date +%s)
+for i in $(seq 1 90); do
+ vtysh -c "show evpn vni 2401" | grep -q 192.168.14.12 && [ "$(recent 3)" -gt 0 ] && break
+ sleep 2
+done
+info "retour du VTEP distant et du trafic $(( $(date +%s) - start )) s après le redémarrage de FRR"
+check "après le retour du route reflector : VTEP distant réappris" sh -c "vtysh -c 'show evpn vni 2401' | grep -q 192.168.14.12"
+check "après le retour du route reflector : le trafic passe" test "$(recent 5)" -gt 0
+vm_ssh vp-s4 10.240.1.10 'pkill -x ping' || true
+NODE
diff --git a/test/e2e/topologies/agent/two.yml b/test/e2e/topologies/agent/two.yml
new file mode 100644
index 0000000..6334d32
--- /dev/null
+++ b/test/e2e/topologies/agent/two.yml
@@ -0,0 +1,2 @@
+dhcp:
+ backend: two
diff --git a/test/e2e/topologies/evpn-2hv.yml b/test/e2e/topologies/evpn-2hv.yml
new file mode 100644
index 0000000..0176ef1
--- /dev/null
+++ b/test/e2e/topologies/evpn-2hv.yml
@@ -0,0 +1,28 @@
+name: evpn-2hv
+
+images:
+ debian12:
+ url: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2
+ sums: https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS
+
+segments:
+ underlay:
+ switch: sw1
+ cidr: 192.168.14.0/24
+ mtu: 9000
+
+nodes:
+ sw1:
+ { role: switch, image: debian12, cpus: 2, memory: 1024,
+ secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf }
+ rr1:
+ { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay],
+ addresses: { underlay: 192.168.14.2 }, secondary: { underlay: [169.254.0.3/28] },
+ loopback: 10.255.255.1/32, frr: frr/rr1.conf }
+ hv1:
+ { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay],
+ addresses: { underlay: 192.168.14.11 }, frr: frr/hv1.conf, release: 0.2.0rc003,
+ agent: agent/two.yml }
+ hv2:
+ { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay],
+ addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf, release: 0.2.0rc003 }
diff --git a/test/e2e/topologies/frr/hv1.conf b/test/e2e/topologies/frr/hv1.conf
new file mode 100644
index 0000000..ecf9e12
--- /dev/null
+++ b/test/e2e/topologies/frr/hv1.conf
@@ -0,0 +1,19 @@
+frr defaults traditional
+hostname hv1
+log syslog informational
+!
+router bgp 64600
+ bgp router-id 192.168.14.11
+ no bgp default ipv4-unicast
+ neighbor fabric peer-group
+ neighbor fabric remote-as 64600
+ neighbor fabric capability extended-nexthop
+ neighbor 10.255.255.1 peer-group fabric
+ !
+ address-family l2vpn evpn
+ neighbor fabric activate
+ advertise-all-vni
+ exit-address-family
+ !
+exit
+!
diff --git a/test/e2e/topologies/frr/hv2.conf b/test/e2e/topologies/frr/hv2.conf
new file mode 100644
index 0000000..5d0e0d9
--- /dev/null
+++ b/test/e2e/topologies/frr/hv2.conf
@@ -0,0 +1,19 @@
+frr defaults traditional
+hostname hv2
+log syslog informational
+!
+router bgp 64600
+ bgp router-id 192.168.14.12
+ no bgp default ipv4-unicast
+ neighbor fabric peer-group
+ neighbor fabric remote-as 64600
+ neighbor fabric capability extended-nexthop
+ neighbor 10.255.255.1 peer-group fabric
+ !
+ address-family l2vpn evpn
+ neighbor fabric activate
+ advertise-all-vni
+ exit-address-family
+ !
+exit
+!
diff --git a/test/e2e/topologies/frr/rr1.conf b/test/e2e/topologies/frr/rr1.conf
new file mode 100644
index 0000000..44081b1
--- /dev/null
+++ b/test/e2e/topologies/frr/rr1.conf
@@ -0,0 +1,43 @@
+frr defaults traditional
+hostname rr1
+log syslog informational
+!
+ip prefix-list RR-LOOPBACK-OUT seq 10 permit 10.255.255.1/32
+!
+route-map NO-IN deny 999
+ description deny
+exit
+!
+router bgp 65000
+ no bgp default ipv4-unicast
+ bgp router-id 10.255.255.1
+ bgp cluster-id 10.255.255.1
+ neighbor CLUSTER peer-group
+ neighbor CLUSTER remote-as 65100
+ neighbor CLUSTER bfd
+ neighbor 169.254.0.1 peer-group CLUSTER
+ neighbor 169.254.0.1 description router-1
+ neighbor 169.254.0.2 peer-group CLUSTER
+ neighbor 169.254.0.2 description router-2
+ neighbor fabric peer-group
+ neighbor fabric remote-as 64600
+ neighbor fabric local-as 64600 no-prepend replace-as
+ neighbor fabric capability extended-nexthop
+ neighbor fabric update-source 10.255.255.1
+ bgp listen range 192.168.14.0/24 peer-group fabric
+ bgp listen limit 200
+ !
+ address-family ipv4 unicast
+ network 10.255.255.1/32
+ neighbor CLUSTER activate
+ neighbor CLUSTER prefix-list RR-LOOPBACK-OUT out
+ neighbor CLUSTER route-map NO-IN in
+ exit-address-family
+ !
+ address-family l2vpn evpn
+ neighbor fabric activate
+ neighbor fabric route-reflector-client
+ exit-address-family
+ !
+exit
+!
diff --git a/test/e2e/topologies/frr/sw1.conf b/test/e2e/topologies/frr/sw1.conf
new file mode 100644
index 0000000..55df925
--- /dev/null
+++ b/test/e2e/topologies/frr/sw1.conf
@@ -0,0 +1,28 @@
+frr defaults traditional
+hostname sw1
+log syslog informational
+!
+ip prefix-list RR-LOOPBACK-IN seq 10 permit 10.255.255.1/32
+!
+route-map RR-IN permit 10
+ match ip address prefix-list RR-LOOPBACK-IN
+exit
+!
+route-map NO-OUT deny 999
+exit
+!
+router bgp 65100
+ no bgp default ipv4-unicast
+ bgp router-id 169.254.0.1
+ neighbor 169.254.0.3 remote-as 65000
+ neighbor 169.254.0.3 description rr1
+ neighbor 169.254.0.3 bfd
+ !
+ address-family ipv4 unicast
+ neighbor 169.254.0.3 activate
+ neighbor 169.254.0.3 route-map RR-IN in
+ neighbor 169.254.0.3 route-map NO-OUT out
+ exit-address-family
+ !
+exit
+!