Compare commits

...

18 commits

Author SHA1 Message Date
5620da4d54
f-50: docs: lab livré, routeur Linux par choix, écrire un scénario #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:50 +02:00
b6933d3919
f-50: lab: résultats d'E5, contrôle DHCP de s1 sur les couples MAC/IP, perte du route reflector #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:50 +02:00
5728fdd33c
f-50: lab: scénarios E5, configuration de l'agent par hyperviseur, release 0.2.0rc003 #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:50 +02:00
0659dc69a5
f-50: lab: seedfrom avec barre oblique finale, push sans propriétaire, résultats d'E4 #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:49 +02:00
1d793b03e3
f-50: docs: route reflector — adressage et configuration FRR validés dans le lab #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:49 +02:00
1403db4077
f-50: docs: adresses 192.168.14.0/24 du route reflector et de la passerelle sur le schéma #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:49 +02:00
24f6e40a5d
f-50: docs: schéma d'architecture du cluster — routeurs, route reflector, hyperviseurs, plans de données et de contrôle #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:48 +02:00
f424d15213
f-50: lab: déploiement de two sur les hyperviseurs et provisionnement en un script #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:48 +02:00
8d1691ae94
f-50: lab: rôles des nœuds, adresses du lien, loopback et installation de FRR #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:47 +02:00
0c36ed8977
f-50: deploy: exit 0 when the deployment succeeds
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:47 +02:00
f28a8c2211
f-50: lab: préparation du serveur, dépôt de lab et validation sur Scaleway #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:47 +02:00
d5dbbe6c66
f-50: lab: lancement, état, arrêt et accès SSH des VM #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:46 +02:00
4b176c22d7
f-50: lab: image vérifiée, disques et seeds des VM #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:46 +02:00
f3515ca370
f-50: lab: arguments QEMU et seeds cloud-init des VM #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:46 +02:00
4d0fc7e18d
f-50: lab: topologie et calcul du plan #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:45 +02:00
81eb0ebe3b
f-50: lab: cycle de vie du serveur de lab Scaleway #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-05 20:15:45 +02:00
79eb8cc175
Merge branch 'feature-51'
All checks were successful
Documentation / publish (push) Successful in 1m28s
2026-10-05 20:15:19 +02:00
a73bf97697
f-51: netif: poser l'adresse VTEP locale des VXLAN #51
All checks were successful
Documentation / publish (push) Successful in 2m55s
Release Pipeline / set-release-target (push) Successful in 3s
Release Pipeline / upload-assets (run-dnsmasq-in-netns.sh, scripts/run-dnsmasq-in-netns.sh) (push) Successful in 7s
Release Pipeline / checksums (push) Successful in 9s
Release Pipeline / upload-assets (agent.service, systemd/agent.service) (push) Successful in 8s
Release Pipeline / upload-assets (dhcp@.service, systemd/dhcp@.service) (push) Successful in 7s
Release Pipeline / upload-assets (dnsmasq@.service, systemd/dnsmasq@.service) (push) Successful in 7s
Release Pipeline / upload-assets (metadata@.service, systemd/metadata@.service) (push) Successful in 7s
Release Pipeline / upload-assets (run-dhcp-in-netns.sh, scripts/run-dhcp-in-netns.sh) (push) Successful in 7s
Release Pipeline / build (agent, amd64, linux) (push) Successful in 0s
Release Pipeline / build (dhcp, amd64, linux) (push) Successful in 0s
Release Pipeline / build (metadata, amd64, linux) (push) Successful in 0s
Release Pipeline / release (push) Successful in 32s
Release Pipeline / publish (push) Successful in 0s
Release Pipeline / build (push) Successful in 2m12s
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-04 19:56:42 +02:00
58 changed files with 8935 additions and 128 deletions

193
cmd/lab/lifecycle.go Normal file
View file

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

174
cmd/lab/main.go Normal file
View file

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

354
cmd/lab/main_test.go Normal file
View file

@ -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("..", "..", "conf", "lab", "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("..", "..", "conf", "lab", "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("..", "..", "conf", "lab", "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("..", "..", "conf", "lab", "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("..", "..", "conf", "lab", "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)
}
}

14
cmd/lab/terminal.go Normal file
View file

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

View file

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

View file

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

View file

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

2
conf/lab/agent/two.yml Normal file
View file

@ -0,0 +1,2 @@
dhcp:
backend: two

28
conf/lab/evpn-2hv.yml Normal file
View file

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

19
conf/lab/frr/hv1.conf Normal file
View file

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

19
conf/lab/frr/hv2.conf Normal file
View file

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

43
conf/lab/frr/rr1.conf Normal file
View file

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

28
conf/lab/frr/sw1.conf Normal file
View file

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

View file

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

View file

@ -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,20 @@ 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.
Vérifié le 2026-10-04 dans le lab (#50), sur une VM Debian 12 lancée par l'agent : sans barre
oblique, ``Datasource DataSourceNone`` ; avec, ``DataSourceNoCloudNet
[seed=…http://169.254.169.254…]`` et le nom d'hôte de la VM. Avec la barre oblique, la
configuration fonctionne quelle que soit la version de cloud-init.
.. note::

View file

@ -8,19 +8,121 @@ 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 <connexion> +ipv4.addresses 169.254.0.3/28
nmcli connection add type dummy ifname lo1 con-name lo1 \
ipv4.method manual ipv4.addresses 10.255.255.1/32 ipv6.method disabled
Le lab, en Debian, pose ces adresses par cloud-init et un script rejoué au démarrage ; ces
commandes restent à valider sur l'image golden.
Configuration de FRR
--------------------
``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. La configuration ci-dessous est **celle du
lab** (``conf/lab/frr/rr1.conf``) : ASN, loopback et plages sont ceux de la production, seul le nom
d'hôte diffère. Le lab qualifie donc exactement ce fichier.
.. literalinclude:: ../../conf/lab/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.
Vérifié dans le lab
~~~~~~~~~~~~~~~~~~~
Le 2026-10-04, FRR 10.7.1, route reflector en Debian 12 sur le segment des hyperviseurs, avec
l'adresse du lien en secondaire et la loopback sur ``lo1`` :
.. list-table::
:header-rows: 1
:widths: 55 45
* - Vérification
- Résultat
* - session avec le routeur, IPv4 unicast
- Established ; le routeur reçoit **un seul** préfixe, la loopback, et l'installe via
``169.254.0.3``
* - BFD avec le routeur
- up des deux côtés
* - sessions des deux hyperviseurs vers ``10.255.255.1``
- Established, voisins dynamiques, iBGP AS 64600, famille L2VPN EVPN négociée
* - routes EVPN échangées, VM ↔ VM entre deux hyperviseurs
- routes de type 3 échangées, ping et MTU 1500 de bout en bout — à partir de la release
``0.2.0rc003``, qui pose l'adresse VTEP locale des VXLAN
(`#51 <https://git.g3e.fr/syonad/two/issues/51>`_)
.. warning::
**Les tunnels ne survivent pas à la perte du route reflector.** Mesuré dans le lab
(2026-10-04, FRR 10.7.1, un seul route reflector, 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é.
Le routeur du lab est un Linux avec FRR, par choix : il joue le rôle générique de routeur de
cluster, l'équipement réel n'ayant besoin que de BGP et d'EVPN (:doc:`routeurs`).
.. 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
mesuré 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`.

View file

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

899
docs/developpement/lab.rst Normal file
View file

@ -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 <https://git.g3e.fr/syonad/two/issues/50>`_. Cette page décrit
comment s'en servir.
.. note::
État actuel : le lab de #50 est livré — serveur loué à l'heure (``scripts/lab-host.sh``),
topologie déclarative et plan déterministe (``lab plan``), VM rendues et lancées sur le
serveur (``lab render``, ``lab up`` / ``status`` / ``down`` / ``ssh``), rôles installés au
démarrage (FRR sur le switch et le route reflector, two par ``deploy.sh`` sur les
hyperviseurs) et scénarios versionnés (``scripts/lab/scenario.sh``). La redondance du plan de
contrôle — deux route reflectors, deux switchs — est l'objet de
`#54 <https://git.g3e.fr/syonad/two/issues/54>`_.
Le serveur de lab
-----------------
Un serveur **Scaleway Elastic Metal**, créé pour une campagne de tests puis supprimé.
.. list-table::
:widths: 25 75
* - Offre
- ``EM-B212X-SSD``, zone ``fr-par-1``, **facturation horaire** : 0,321 € HT de l'heure, sans
frais de mise en service
* - Matériel
- 2 × Xeon E5-2620 v4 *or equivalent*, 256 Go, 2 × 1 To SSD
* - Système
- Debian 12, installé par Scaleway à la création
* - Pourquoi Intel
- lab3 est en Intel : two lance ses VM en ``-cpu host``, et KVM a deux implémentations
distinctes (``kvm_intel``, ``kvm_amd``)
*Or equivalent* n'est pas une clause de style : le premier serveur livré était un
**Xeon E5-2640 v3** (Haswell, la génération de lab3), pas le E5-2620 v4 annoncé. Relever
``lscpu`` au début de chaque campagne.
Prérequis côté Scaleway
-----------------------
#. **Un projet dédié au lab**, séparé de toute autre ressource. ``lab-host.sh down`` supprime
tout serveur de lab du projet : il ne doit rien y avoir d'autre.
#. **Une clé d'API limitée à ce projet**, avec les droits Elastic Metal et la lecture des clés SSH
du projet. Rien d'autre.
#. **Les clés SSH publiques enregistrées dans le projet**, injectées à l'installation : sans elle,
le serveur serait facturé sans que personne puisse s'y connecter, et ``plan`` refuse de
continuer.
#. **Le quota Elastic Metal.** L'``EM-B212X-SSD`` exige un compte dont le moyen de paiement *et*
l'identité sont validés ; le quota est alors de 2. Vérifier dans la console : Organisation →
Quotas → Elastic Metal. Voir `les quotas Scaleway
<https://www.scaleway.com/en/docs/organizations-and-projects/organization/organization-quotas/>`_.
Fichiers locaux
---------------
Tout ce dont le script a besoin vit sous ``~/.config/two-lab/``, hors du dépôt.
``~/.config/two-lab/scaleway.env``
Identifiants Scaleway, une ligne ``CLÉ=valeur`` chacun :
.. code-block:: bash
SCW_SECRET_KEY=<clé secrète>
SCW_DEFAULT_PROJECT_ID=<identifiant du projet de lab>
SCW_DEFAULT_ZONE=fr-par-1
* le fichier doit être en ``0600`` : le script refuse de s'en servir s'il est lisible par
d'autres que son propriétaire ;
* il est **lu, jamais exécuté** — pas de ``source`` ; seules ces trois clés sont reconnues ;
* une variable d'environnement du même nom l'emporte sur le fichier ;
* la clé d'accès (``SCW…``) n'est pas nécessaire : l'API REST n'authentifie que par la clé
secrète, dans l'en-tête ``X-Auth-Token``.
Pour changer de clé, remplacer la ligne ``SCW_SECRET_KEY=`` ; rien d'autre à modifier.
``~/.config/two-lab/ssh/lab_ed25519``
Clé SSH dédiée au lab, **sans phrase de passe**, pour que les sessions tournent sans
intervention. Sa partie publique doit être enregistrée dans le projet. Quand elle existe, le
script l'utilise **seule** (``IdentitiesOnly``, agent désactivé) ; sinon il retombe sur
l'agent SSH.
Elle ne doit ouvrir que les serveurs éphémères du projet de lab : **ne jamais l'installer sur
lab3 ni sur une machine durable**. En cas de doute, la retirer du projet et en générer une
autre :
.. code-block:: bash
mkdir -p ~/.config/two-lab/ssh && chmod 700 ~/.config/two-lab ~/.config/two-lab/ssh
ssh-keygen -t ed25519 -N '' -C two-lab-automation -f ~/.config/two-lab/ssh/lab_ed25519
``~/.cache/two-lab/``
État de la session en cours : adresse et utilisateur du serveur, ``known_hosts`` dédié. Vidé
par ``down``.
Commandes
---------
.. code-block:: text
usage: lab-host.sh <commande> [arguments]
plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création
et le prix ; ne crée rien
up crée le serveur de lab, attend la fin de son installation et son SSH,
puis le prépare (voir prepare)
status liste les serveurs de lab du projet
ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé
que si l'entrée standard en est un
prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie
/dev/kvm et la virtualisation imbriquée ; lancé aussi par up
push <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le
répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle
référence) ; ensuite : ssh './lab up topology/<topologie>'
down supprime tous les serveurs de lab du projet et attend leur disparition
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
``plan`` et ``status`` sont **gratuits** ; ``up`` et ``session`` **créent un serveur facturé**.
Toujours commencer par ``plan``. Il valide la clé d'API, le quota d'offre, l'OS et les clés SSH,
et montre exactement ce qui serait commandé :
.. code-block:: text
$ scripts/lab-host.sh plan
== offre : EM-B212X-SSD (ddaf8ba6-b2b2-4279-8af3-51930fb602f8), facturation hourly, stock available
== prix : 0.321 EUR HT par heure, frais de mise en service 0 EUR
== os : Debian 12 (Bookworm) (83640d93-a0b8-45ad-9c9f-30cae48380a4), utilisateur root
== clés : 2 clé(s) SSH du projet
== requête : POST /baremetal/v1/zones/fr-par-1/servers
``session`` est la forme normale d'usage : le serveur est supprimé à la fin, que la commande
réussisse, échoue, ou que la session soit interrompue (Ctrl-C, ``TERM``, fermeture du terminal).
.. code-block:: text
$ scripts/lab-host.sh session 'uname -a; lscpu | grep -E "Model name|^CPU\(s\)|Virtualization"; free -g | head -2; echo "nested=$(cat /sys/module/kvm_intel/parameters/nested)"; ls -l /dev/kvm'
== création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT)
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 créé, facturé jusqu'à 'lab-host.sh down'
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ordered, installation to_install
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation installing
…
== serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation completed
== SSH pas encore joignable, nouvel essai dans 20s
…
== prêt : root@<adresse>
Linux two-lab 6.1.0-53-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.187-1 (2026-09-07) x86_64 GNU/Linux
CPU(s): 32
Model name: Intel(R) Xeon(R) CPU E5-2640 v3 @ 2.60GHz
…
Virtualization: VT-x
total used free shared buff/cache available
Mem: 251 1 250 0 0 249
nested=Y
crw-rw---- 1 root kvm 10, 232 Oct 3 17:23 /dev/kvm
== session terminée (code 0), suppression du serveur
== suppression de 2ecc1e6a-0de8-48c0-a198-93857eee5957
== aucun serveur de lab ne reste dans le projet
Extrait de la première campagne (lignes répétées remplacées par ``…``). Compter **environ 15 minutes** entre la création et le SSH disponible : 13 min 30 à la première
campagne, suppression comprise. SSH ne répond pas tout de suite après la fin de l'installation —
environ 100 secondes la première fois — d'où l'attente intégrée à ``up``. Le code de sortie de
``session`` est celui de la commande distante.
``up``, ``ssh`` et ``down`` séparément servent au debug interactif — et laissent la suppression
à la charge de l'utilisateur.
Une campagne sur le lab enchaîne ces commandes depuis le Mac ; ``lab`` s'exécute sur le serveur
(voir `Lancement des VM`_) :
.. code-block:: text
scripts/lab-host.sh up
scripts/lab-host.sh push conf/lab/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é, ``conf/lab/evpn-2hv.yml`` :
.. literalinclude:: ../../conf/lab/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é
(``conf/lab/agent/two.yml`` : ``dhcp.backend: two``) et laisse hv2 sur dnsmasq.
``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms.
Ce que l'outil en déduit, de façon déterministe — même fichier, même plan :
.. list-table::
:widths: 30 70
* - Passerelle d'un segment
- la première adresse du CIDR, portée par le switch sur ``br-<segment>``
* - Adresse d'un nœud
- les suivantes, **dans l'ordre de déclaration des nœuds** ; une adresse fixée par
``addresses`` est réservée d'abord et sautée par l'attribution automatique
* - Câbles
- un par couple (segment, nœud), segments puis nœuds dans l'ordre de déclaration ; le
câble *i* utilise les ports UDP ``20000 + 2i`` (côté nœud) et ``20001 + 2i`` (côté switch)
* - MAC
- ``02:4c:<nœud>:<nœud>:<segment>:<côté>`` — préfixe localement administré, rang du nœud
sur deux octets, rang du segment, ``00`` côté nœud et ``01`` côté switch
* - Interfaces
- côté nœud, le nom du segment ; côté switch, ``p<i>``, du rang du câble
* - SSH d'administration
- ``127.0.0.1:<2200 + rang du nœud>`` sur l'hôte du lab
.. warning::
Réordonner les nœuds ou les segments dans le fichier **change les adresses, les MAC et les
ports**. C'est assumé pour un lab ; ``lab plan`` montre le résultat avant tout lancement.
Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le permet (22 768).
``lab plan`` valide le fichier et affiche le plan, sans rien lancer :
.. code-block:: text
$ go run ./cmd/lab plan conf/lab/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
``conf/lab/`` 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 ``conf/lab/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:: ../../conf/lab/frr/rr1.conf
:language: text
Au premier démarrage, cloud-init installe FRR (``frr-stable`` de ``deb.frrouting.org``, sans les
paquets recommandés), active ``bgpd`` — et ``bfdd`` sur le switch et le route reflector —, puis
dépose le ``frr.conf`` du nœud et redémarre FRR. La mise à jour des index de paquets est réessayée
pendant cinq minutes : un nœud peut démarrer avant que le switch, par lequel il sort, n'ait posé
son NAT.
La **clé du dépôt FRR** n'est pas téléchargée au démarrage : elle est enregistrée dans ``lab``
(``internal/lab/render/frrouting.gpg``) et déposée par cloud-init. Elle a été récupérée le
2026-10-04 sur ``deb.frrouting.org`` ; les empreintes de ses clés primaires sont publiées sous la
même valeur sur ``keys.openpgp.org`` et ``keyserver.ubuntu.com`` :
.. code-block:: text
3D99 68AC 9AE7 BE11 6928 8DDB 1FD5 8398 95F5 7FDA David Lamparter
4A56 C773 8BB3 F815 95A8 05D2 A832 7699 08F1 3ED1 FRRouting Debian Repository
A90F C36D 9429 4097 98E9 C2D8 74DE ED43 AB19 4DBF Jafar Al-Gharaibeh
Une clé renouvelée par FRR fera échouer l'installation (signature inconnue) : remplacer le fichier
après avoir vérifié les nouvelles empreintes.
**Hyperviseurs.** Ils se déploient comme en production, par ``deploy.sh`` — celui **du dépôt**,
embarqué dans ``lab`` avec ``bootstrap_kvm.sh`` (paquet ``scripts``) et déposé dans
``/opt/two/scripts/``, où ``deploy.sh`` cherche d'abord ``bootstrap_kvm.sh`` :
.. code-block:: text
deploy.sh --noup_script -i -u <segment> -t <release>
``--noup_script`` empêche l'auto-mise à jour de remplacer le script par celui de ``main`` : le lab
teste les scripts de sa branche. L'uplink ``-u`` est l'interface qui porte la route par défaut —
celle du premier segment de l'hyperviseur dans l'ordre de déclaration des segments — parce que
``deploy.sh`` y lit l'adresse et la passerelle qu'il déplace sur ``br-000000``. ``deploy.sh``
télécharge la release sur ``git.g3e.fr`` sans réessayer : le lancement attend d'abord que le serveur
réponde, à travers le switch. FRR est installé **après** : il démarre sur le réseau final.
**Un seul script de provisionnement par nœud.** cloud-init exécute ``runcmd`` comme un script
``sh`` sans ``set -e`` : seule la dernière commande compte, et un ``deploy.sh`` en échec suivi d'un
FRR installé avec succès passerait pour un démarrage réussi. Chaque nœud reçoit donc
``/usr/local/sbin/lab-provision``, en ``set -eu``, qui enchaîne ses étapes ; ``runcmd`` n'appelle
que lui, et la première étape en échec met cloud-init en erreur.
**Ce que vérifie** ``lab up``, une fois cloud-init terminé sans erreur : ``agent.service`` actif
sur chaque hyperviseur, ``frr`` actif sur chaque nœud qui en a un. Ce contrôle couvre ce que
cloud-init ne voit pas — si la migration réseau échoue, ``deploy.sh`` arme un redémarrage de
secours, et la VM redémarrée ne rejoue pas ``runcmd``.
.. warning::
**Un hyperviseur du lab ne survit pas à un redémarrage.** En production, la racine est en
tmpfs et ``deploy.sh --bootstrap`` est rejoué à chaque démarrage ; dans le lab, ``-i`` n'est
exécuté qu'au premier, et la migration réseau, qui n'est pas persistée, est perdue. Recréer le
lab : ``lab down`` puis ``lab up``.
Vérifié le 2026-10-04 sur le serveur de lab, topologie ``evpn-2hv``, release ``0.2.0rc002`` :
.. code-block:: text
$ scripts/lab-host.sh ssh './lab up -timeout 25m topology/evpn-2hv.yml'
sw1: started
rr1: started
hv1: started
hv2: started
sw1: ready
rr1: ready
hv1: ready
hv2: ready
.. list-table::
:header-rows: 1
:widths: 55 45
* - Vérification
- Résultat
* - ``lab up`` complet : FRR, ``deploy.sh`` et vérification des services de chaque rôle
- 6 min 15
* - session switch ↔ route reflector, IPv4 unicast, BFD
- Established, BFD up ; le switch reçoit la seule loopback du route reflector
* - sessions EVPN des deux hyperviseurs vers la loopback du route reflector
- Established, voisins dynamiques, stables
* - réseau d'un hyperviseur après ``deploy.sh``
- adresse sur ``br-000000``, MTU 9000, API de l'agent qui répond
* - VPC, subnet ``vxlan`` et VM Debian ``genericcloud`` créés par l'API de hv1
- ``login:`` en 20 s, en KVM imbriqué
* - DHCP et routes (option 121) servis par two à la VM
- conformes, route ``/32`` vers ``169.254.169.254`` comprise
* - métadonnées, image configurée selon :doc:`/deploiement/image-qcow2`
- ``DataSourceNoCloudNet``, nom d'hôte appliqué — avec la barre oblique finale de
``seedfrom`` (voir cette page)
* - VM ↔ VM entre les deux hyperviseurs, même subnet ``vxlan``
- **échec** : les VXLAN de two n'ont pas d'adresse VTEP locale, rien n'est annoncé en EVPN —
`#51 <https://git.g3e.fr/syonad/two/issues/51>`_ ; avec l'adresse posée, ping et MTU 1500
passent
.. note::
**Le switch du lab est un routeur Linux avec FRR, par choix.** Il joue le rôle générique de
routeur de cluster : passerelle des hyperviseurs, session eBGP avec BFD vers le route reflector,
dont il n'accepte que la loopback (``conf/lab/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 conf/lab/evpn-2hv.yml <répertoire>
``<répertoire>/<nœud>/`` reçoit :
``qemu.args``
Les arguments de ``qemu-system-x86_64``, **un par ligne** : rien à échapper, rien à
interpréter par un shell.
``meta-data``, ``user-data``, ``network-config``
Les trois fichiers NoCloud de cloud-init, à mettre dans une image de volume ``cidata``.
Les chemins de la VM (``disk.qcow2``, ``seed.iso``, ``console.log``, ``qmp.sock``, ``qemu.pid``)
sont ceux du répertoire du nœud ; ``-key`` peut être répété, et accepte un fichier
``authorized_keys`` (lignes vides et commentaires ignorés). Les fichiers sont créés en ``0600``.
Ce que contiennent les arguments QEMU d'un hyperviseur — extrait réel, côté réseau :
.. code-block:: text
-netdev
user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22
-device
virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile=
-netdev
dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003
-device
virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile=
Les choix qui s'y lisent :
* **machine** ``q35``, ``-accel kvm -cpu host`` — le KVM imbriqué des hyperviseurs du lab en
dépend ; ``-nodefaults`` pour qu'aucun périphérique implicite ne s'ajoute ;
* **administration** (``mgmt0``) : le NAT de QEMU, MAC ``02:4d:<nœud>:<nœud>:00:00``, SSH redirigé
sur la boucle locale de l'hôte. ``restrict=on`` pour tous les nœuds **sauf le switch** : un
nœud isolé ne joint ni l'hôte ni l'extérieur par là, seule la redirection SSH passe.
``ipv6=off`` partout (voir plus bas) ;
* **câbles** : ``dgram`` sur ``127.0.0.1``, les deux extrémités d'un câble se répondent
(port local de l'une = port distant de l'autre), ``host_mtu`` annonce le MTU du segment au
guest ;
* ``romfile=`` vide sur toutes les cartes : pas de ROM de démarrage réseau, donc pas de repli
sur un démarrage PXE si le firmware ne trouve pas le disque. Pendant les essais de #50, une VM
restée bloquée sans rien écrire sur sa console, CPU au repos, avait toutes les apparences de
ce repli ; la cause n'a pas été isolée, l'option est une précaution.
Ce que fait cloud-init :
* **toutes les VM** : interfaces nommées d'après leur MAC (``mgmt0``, nom du segment, ``p<i>``),
``dhcp4: false`` partout, ``mgmt0`` en ``10.0.2.15/24`` **sans passerelle** ; connexion SSH par
clé seulement, utilisateur ``debian``, ``root`` désactivé, mot de passe refusé ;
* **un nœud** : adresse sur chaque segment, MTU du segment, route par défaut et DNS
(``1.1.1.1``, ``8.8.8.8``) sur son **premier** segment — la sortie Internet passe par le
switch ;
* **le switch** : route par défaut par ``mgmt0`` ; un service ``lab-switch`` crée un bridge
``br-<segment>`` par segment (STP désactivé, MTU du segment), y branche ses ports, porte la
passerelle, active le routage et masque (NAT nftables) les segments vers ``mgmt0``. Le
service est rejoué à chaque démarrage.
Vérifié sur de vraies VM
~~~~~~~~~~~~~~~~~~~~~~~~
Le switch et le route reflector n'ont pas besoin de KVM imbriqué : ``sw1`` et ``rr1`` de
l'exemple ont été démarrés **sur un Mac**, en émulation (TCG), avec Debian 12 ``generic`` et
les fichiers produits par ``lab render``.
.. list-table::
:header-rows: 1
:widths: 60 40
* - Vérification
- Résultat
* - interfaces nommées et adressées, bridge ``br-underlay`` en ``10.250.0.1/24``
- conforme
* - service ``lab-switch`` actif, y compris après redémarrage
- conforme
* - ``ping -M do -s 8972`` de ``rr1`` vers le switch (MTU 9000, sans fragmentation)
- passe
* - ``ping -M do -s 8973`` (MTU 9001)
- refusé : ``message too long, mtu=9000``
* - Internet depuis ``rr1`` en IPv4
- passe, par ``10.250.0.1``
* - ``rr1`` vers un service TCP de l'hôte par ``mgmt0`` — le switch, témoin, y parvient
- bloqué
* - ``rr1`` vers Internet par ``mgmt0``
- bloqué
Un défaut trouvé par cet essai, et corrigé : sans ``ipv6=off``, le NAT de QEMU annonce un
préfixe IPv6 et ``mgmt0`` reçoit une **route IPv6 par défaut** — vers une impasse, puisque
``restrict=on`` bloque tout. Pas de fuite, mais chaque programme qui tente l'IPv6 d'abord (le DNS
renvoie d'abord des adresses IPv6) attend un délai avant de se rabattre sur l'IPv4.
.. note::
Un ``ping`` vers ``10.0.2.2`` n'est pas un test d'isolation : c'est la passerelle virtuelle de
QEMU qui répond elle-même, ``restrict=on`` ou non. Seule une connexion vers un vrai service de
l'hôte, avec un témoin qui y parvient, le prouve.
Reste à vérifier sur le serveur de lab : les hyperviseurs, qui exigent KVM imbriqué.
Lancement des VM
----------------
``lab`` s'exécute **sur le serveur de lab**. Il garde l'état du lab dans un répertoire
(``-run``, par défaut ``~/lab-run``) : ``status``, ``down`` et ``ssh`` n'ont donc pas besoin de la
topologie.
.. code-block:: text
lab up [-run dir] [-cache dir] [-timeout 20m] <topologie.yml>
lab status [-run dir]
lab down [-run dir]
lab ssh [-run dir] <nœud> [commande…]
``lab up``
1. refuse de continuer si un lab tourne déjà dans le répertoire ;
2. télécharge chaque image dans le cache (``-cache``, par défaut ``~/.cache/two-lab``) et la
vérifie contre ``SHA512SUMS`` ; une image déjà présente et toujours conforme n'est pas
retéléchargée, la liste des sommes est relue à chaque fois ;
3. génère une paire de clés SSH dans le répertoire du lab, si elle n'existe pas encore ;
4. pour chaque nœud : fichiers de ``lab render``, disque **neuf** en overlay qcow2 sur
l'image (``qemu-img create -b``, 20 Gio annoncés), image ``cidata`` (``genisoimage``) ;
5. démarre les QEMU, **switchs d'abord**, détachés (``-daemonize``) : ils survivent à la
session SSH qui les a lancés ;
6. attend sur chaque nœud la fin de cloud-init (``cloud-init status --wait`` par SSH),
jusqu'au délai ``-timeout``.
La topologie est copiée dans ``<run>/topology.yml``. Un échec laisse les nœuds démarrés en
place : ``lab status``, puis ``lab down``.
``lab status``
Pour chaque nœud : rôle, état du processus QEMU, PID, port SSH sur la boucle locale.
``lab down``
Arrête chaque QEMU par ``SIGTERM``, puis ``SIGKILL`` au bout de 30 s. Les disques sont
conservés jusqu'au prochain ``up``, qui les recrée.
``lab ssh``
Ouvre un shell sur un nœud, ou y exécute une commande, avec la clé générée par ``up``. ``lab``
cède la place à ``ssh``, dont le code de retour est donc celui de la commande. Un terminal
n'est demandé (``-t``) que si l'entrée de ``lab`` en est un : depuis un script, ni
pseudo-terminal ni ``\r\n`` dans la sortie.
Comme ``ssh``, ``lab ssh`` recolle ses arguments par des espaces et les confie à un shell
distant — et depuis le Mac, il y en a **deux** : celui du serveur, puis celui de la VM.
Une commande qui contient elle-même des guillemets se passe en une seule chaîne :
.. code-block:: text
$ echo | scripts/lab-host.sh ssh './lab ssh hv1 sh -c "exit 42"'; echo "rc 42=$?"
rc 42=0
$ echo | scripts/lab-host.sh ssh "./lab ssh hv1 'sh -c \"exit 42\"'"; echo "rc 42=$?"
rc 42=42
Dans le premier cas, la VM reçoit ``sh -c exit 42`` : ``exit`` sans argument, ``42`` en
``$0``. Pour plus d'une commande, passer un script sur l'entrée standard :
``scripts/lab-host.sh ssh "./lab ssh hv1 'sudo bash -s'" < script.sh``.
Un processus n'est tenu pour celui d'un nœud que si son PID, lu dans ``qemu.pid``, désigne un
processus vivant dont la ligne de commande (``/proc/<pid>/cmdline``) contient ``-name <nœud>``.
Un PID réutilisé par un autre programme n'est donc jamais signalé.
.. warning::
Le cache range une image sous son nom de fichier, et l'URL de Debian est ``latest`` : une
nouvelle publication remplace le fichier, et les overlays existants pointeraient sur une
base différente. ``up`` recrée toujours les disques, ce qui suffit avec un lab par serveur ;
**ne pas relancer un QEMU à la main** à partir d'un ``qemu.args`` après un ``up`` ultérieur.
Une campagne réelle, de la création du serveur à la première commande sur une VM — sorties du
2026-10-04 :
.. code-block:: text
$ scripts/lab-host.sh up
== création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT)
…
== préparation du serveur : qemu, genisoimage, KVM imbriqué
…
qemu QEMU emulator version 7.2.22 (Debian 1:7.2+dfsg-7+deb12u18+b3), nested=Y
== prêt : root@<adresse>
$ scripts/lab-host.sh push conf/lab/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
scripts/lab/scenario.sh s1 # un scénario
scripts/lab/scenario.sh s1 s3 # plusieurs
scripts/lab/scenario.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.** ``scripts/lab/scenario.sh`` exécute chaque scénario sur le Mac ; un
scénario envoie des blocs de shell aux nœuds par ``on <nœud> [VAR=valeur…] <<'NODE'``, précédés de
``scripts/lab/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
$ scripts/lab/scenario.sh all | grep -E '^(=== s[0-9].* : |INFO)'
INFO: MAC de sn-s1a : 00:22:33:00:00:0a 00:22:33:00:00:0b ; de sn-s1b : 00:22:33:00:00:0a 00:22:33:00:00:0b
=== s1-dhcp-two : 37 réussi(s), 0 échoué(s)
=== s2-gateway : 22 réussi(s), 0 échoué(s)
=== s3-isolation-local : 12 réussi(s), 0 échoué(s)
=== s4-evpn : 14 réussi(s), 0 échoué(s)
=== s5-isolation-evpn : 13 réussi(s), 0 échoué(s)
INFO: 30 s après l'arrêt du route reflector : 0 réponses dans les 10 dernières secondes (50 si le trafic passe intégralement)
INFO: VTEP distant encore connu : 0 ; entrée d'inondation : 0
INFO: 90 s après l'arrêt : 0 réponses dans les 10 dernières secondes
INFO: retour du VTEP distant et du trafic 31 s après le redémarrage de FRR
=== s6-rr-loss : 8 réussi(s), 0 échoué(s)
Dans ce run, le contrôle « uniquement les MAC de son subnet » de ``s1`` ne prouvait rien : two
dérive la MAC du rang de l'IP, et les VM ``.10``/``.11`` des deux subnets avaient les mêmes MAC.
Vérifié à la main sur le lab, chaque serveur DHCP ne connaissait que les couples MAC/IP de son
subnet ; le scénario compare désormais ces couples.
Les VM sont accessibles depuis le netns de leur VPC, sur l'hyperviseur, avec l'utilisateur
``syonad`` créé par les métadonnées de two :
.. code-block:: text
scripts/lab-host.sh ssh "./lab ssh hv1 'sudo ip netns exec vp-s4 ssh -i /root/.ssh/lab-vm syonad@10.240.1.10'"
Écrire un scénario
~~~~~~~~~~~~~~~~~~
Le lab sert à qualifier des comportements qui ne se voient qu'à plusieurs hyperviseurs — la
campagne L3VNI de `#41 <https://git.g3e.fr/syonad/two/issues/41>`_ en est le prochain exemple. Un
scénario est un fichier ``scripts/lab/scenarios/<n>-<nom>.sh``, exécuté par ``scenario.sh`` sur le
Mac ; il envoie des blocs aux nœuds :
.. code-block:: bash
on hv1 <<'NODE'
two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; }
KEY=$(vm_key)
check "VPC vp-x" vpc_create vp-x 10.250.0.0/16
check "subnet sn-x" subnet_create sn-x vp-x 2601 10.250.1.1 10.250.1.0/24
check "VM x1" vm_create x1 sn-x 10.250.1.10 "${KEY}"
check "x1 joignable" vm_wait vp-x 10.250.1.10
check "x1 joint sa passerelle" vm_ssh vp-x 10.250.1.10 'ping -c 2 -W 2 10.250.1.1'
check "x1 ne joint pas 10.251.1.10" vm_fails vp-x 10.250.1.10 'ping -c 2 -W 2 10.251.1.10'
info "mesure : $(vtysh -c 'show evpn vni 2601' | grep -c 'flood')"
NODE
Les règles qui ont fait leurs preuves en E5 :
* **une vérification par ligne**, avec ``check`` — jamais un ``echo RÉUSSI`` écrit à la main ;
* **toute vérification négative a son témoin** : avant « ne joint pas », une ligne qui prouve que
la cible est vivante et que le chemin du test fonctionne ;
* **``vm_fails`` pour le négatif**, jamais ``!`` devant un ``vm_ssh`` : un SSH en panne doit
échouer, pas passer pour une isolation ;
* **une donnée qui distingue réellement les cas** : two dérivant la MAC du rang de l'IP, deux
subnets ont les mêmes MAC — comparer des couples MAC/IP, pas des MAC ;
* **les mesures en ``INFO``**, les attentes en ``check`` : un temps de reconvergence se mesure,
il ne se décrète pas ;
* chaque scénario crée ses propres VPC, plages et VNI, distinctes de celles des autres, pour que
``all`` les enchaîne sur le même lab ; un scénario qui dépend d'un autre le vérifie en tête
(``check "prérequis : …"``) ;
* variables vers un nœud : ``on hv1 NOM=valeur <<'NODE'`` (valeurs échappées par
``scenario.sh``) ;
* ``bash scripts/lab/scenario_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 scripts/lab/scenario_test.sh
go test ./internal/lab/... ./cmd/lab/
Environ une minute et demie, sans réseau : la suite remplace ``curl`` par une fausse API Scaleway
qui se place dans le pire cas (offre mensuelle listée avant l'horaire, serveurs d'autres projets,
suppressions refusées, erreurs 503, serveur qui tarde à disparaître) et ``ssh`` par un faux client.
Elle tourne sous bash 5 comme sous le bash 3.2 de macOS.

View file

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

View file

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

After

Width:  |  Height:  |  Size: 10 KiB

View file

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

After

Width:  |  Height:  |  Size: 10 KiB

View file

@ -1,52 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 500" width="900" height="500" role="img" aria-label="Topologie du cluster">
<style>
.lbl { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; fill: #e6edf3; }
.sub { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #9198a1; }
.grp { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; font-weight: 600; fill: #9198a1; letter-spacing: .04em; }
.box { fill: #22272e; stroke: #6e7781; stroke-width: 1.5; }
.group { fill: #1c2128; stroke: #444c56; stroke-width: 1.5; stroke-dasharray: 0; }
.link { stroke: #9198a1; stroke-width: 1.8; fill: none; }
.ctrl { stroke: #c297ff; stroke-width: 1.6; fill: none; stroke-dasharray: 5 4; }
</style>
<rect class="group" x="250" y="24" width="400" height="96" rx="8"/>
<text class="grp" x="270" y="46">ROUTEURS DE CLUSTER</text>
<rect class="box" x="275" y="58" width="160" height="42" rx="6"/>
<text class="lbl" x="355" y="84" text-anchor="middle">routeur A</text>
<rect class="box" x="465" y="58" width="160" height="42" rx="6"/>
<text class="lbl" x="545" y="84" text-anchor="middle">routeur B</text>
<rect class="box" x="350" y="168" width="200" height="56" rx="6"/>
<text class="lbl" x="450" y="191" text-anchor="middle">VM route reflector</text>
<text class="sub" x="450" y="210" text-anchor="middle">plan de contrôle</text>
<rect class="group" x="40" y="278" width="370" height="196" rx="8"/>
<text class="grp" x="60" y="300">HYPERVISEUR 1</text>
<rect class="box" x="145" y="312" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="337" text-anchor="middle">br-000000</text>
<rect class="box" x="145" y="372" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="397" text-anchor="middle">FRR</text>
<rect class="box" x="145" y="424" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="449" text-anchor="middle">agent</text>
<path class="link" d="M225 352 L225 372"/>
<path class="link" d="M225 412 L225 424"/>
<rect class="group" x="490" y="278" width="370" height="196" rx="8"/>
<text class="grp" x="510" y="300">HYPERVISEUR 2</text>
<rect class="box" x="595" y="312" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="337" text-anchor="middle">br-000000</text>
<rect class="box" x="595" y="372" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="397" text-anchor="middle">FRR</text>
<rect class="box" x="595" y="424" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="449" text-anchor="middle">agent</text>
<path class="link" d="M675 352 L675 372"/>
<path class="link" d="M675 412 L675 424"/>
<path class="link" d="M225 312 L225 148 L330 148 L330 120"/>
<path class="link" d="M675 312 L675 148 L570 148 L570 120"/>
<text class="sub" x="232" y="266">uplink</text>
<text class="sub" x="682" y="266">uplink</text>
<path class="ctrl" d="M350 196 L322 196 L322 392 L305 392"/>
<path class="ctrl" d="M550 196 L578 196 L578 392 L595 392"/>
</svg>

Before

Width:  |  Height:  |  Size: 3 KiB

View file

@ -1,52 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 500" width="900" height="500" role="img" aria-label="Topologie du cluster">
<style>
.lbl { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 14px; fill: #1f2328; }
.sub { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 11px; fill: #57606a; }
.grp { font-family: -apple-system, "Segoe UI", Roboto, sans-serif; font-size: 12px; font-weight: 600; fill: #57606a; letter-spacing: .04em; }
.box { fill: #ffffff; stroke: #8c959f; stroke-width: 1.5; }
.group { fill: #f6f8fa; stroke: #d0d7de; stroke-width: 1.5; stroke-dasharray: 0; }
.link { stroke: #57606a; stroke-width: 1.8; fill: none; }
.ctrl { stroke: #8250df; stroke-width: 1.6; fill: none; stroke-dasharray: 5 4; }
</style>
<rect class="group" x="250" y="24" width="400" height="96" rx="8"/>
<text class="grp" x="270" y="46">ROUTEURS DE CLUSTER</text>
<rect class="box" x="275" y="58" width="160" height="42" rx="6"/>
<text class="lbl" x="355" y="84" text-anchor="middle">routeur A</text>
<rect class="box" x="465" y="58" width="160" height="42" rx="6"/>
<text class="lbl" x="545" y="84" text-anchor="middle">routeur B</text>
<rect class="box" x="350" y="168" width="200" height="56" rx="6"/>
<text class="lbl" x="450" y="191" text-anchor="middle">VM route reflector</text>
<text class="sub" x="450" y="210" text-anchor="middle">plan de contrôle</text>
<rect class="group" x="40" y="278" width="370" height="196" rx="8"/>
<text class="grp" x="60" y="300">HYPERVISEUR 1</text>
<rect class="box" x="145" y="312" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="337" text-anchor="middle">br-000000</text>
<rect class="box" x="145" y="372" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="397" text-anchor="middle">FRR</text>
<rect class="box" x="145" y="424" width="160" height="40" rx="6"/>
<text class="lbl" x="225" y="449" text-anchor="middle">agent</text>
<path class="link" d="M225 352 L225 372"/>
<path class="link" d="M225 412 L225 424"/>
<rect class="group" x="490" y="278" width="370" height="196" rx="8"/>
<text class="grp" x="510" y="300">HYPERVISEUR 2</text>
<rect class="box" x="595" y="312" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="337" text-anchor="middle">br-000000</text>
<rect class="box" x="595" y="372" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="397" text-anchor="middle">FRR</text>
<rect class="box" x="595" y="424" width="160" height="40" rx="6"/>
<text class="lbl" x="675" y="449" text-anchor="middle">agent</text>
<path class="link" d="M675 352 L675 372"/>
<path class="link" d="M675 412 L675 424"/>
<path class="link" d="M225 312 L225 148 L330 148 L330 120"/>
<path class="link" d="M675 312 L675 148 L570 148 L570 120"/>
<text class="sub" x="232" y="266">uplink</text>
<text class="sub" x="682" y="266">uplink</text>
<path class="ctrl" d="M350 196 L322 196 L322 392 L305 392"/>
<path class="ctrl" d="M550 196 L578 196 L578 392 L595 392"/>
</svg>

Before

Width:  |  Height:  |  Size: 3 KiB

2
go.mod
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

View file

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

View file

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

View file

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

294
internal/lab/render/seed.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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.<name>.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: <tag>)", 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
}

View file

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

View file

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

View file

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

9
scripts/embed.go Normal file
View file

@ -0,0 +1,9 @@
package scripts
import _ "embed"
//go:embed deploy.sh
var Deploy []byte
//go:embed bootstrap_kvm.sh
var BootstrapKVM []byte

466
scripts/lab-host.sh Executable file
View file

@ -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 <<EOF
usage: ${0##*/} <commande> [arguments]
plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création
et le prix ; ne crée rien
up crée le serveur de lab, attend la fin de son installation et son SSH,
puis le prépare (voir prepare)
status liste les serveurs de lab du projet
ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé
que si l'entrée standard en est un
prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie
/dev/kvm et la virtualisation imbriquée ; lancé aussi par up
push <topologie> compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le
répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle
référence) ; ensuite : ssh './lab up topology/<topologie>'
down supprime tous les serveurs de lab du projet et attend leur disparition
session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive
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

825
scripts/lab-host_test.sh Executable file
View file

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

151
scripts/lab/node.sh Normal file
View file

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

67
scripts/lab/scenario.sh Executable file
View file

@ -0,0 +1,67 @@
#!/usr/bin/env bash
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LAB_HOST="${LAB_HOST:-${LAB_DIR}/../lab-host.sh}"
SCENARIO_DIR="${SCENARIO_DIR:-${LAB_DIR}/scenarios}"
SCENARIO_LOG=""
usage () {
cat >&2 <<USAGE
usage: ${0##*/} <scénario>... | 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}/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

140
scripts/lab/scenario_test.sh Executable file
View file

@ -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}/scenario.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}/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 ]]

View file

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

View file

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

View file

@ -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/tcp/10.231.1.10/22"'
check "s3-y ne joint pas s3-x en ICMP" vm_fails vp-s3b 10.231.1.10 'ping -c 2 -W 2 10.230.1.10'
NODE

View file

@ -0,0 +1,25 @@
on hv1 <<'NODE'
two_image || { echo "ÉCHOUÉ: image compatible two (hv1)"; exit 0; }
KEY=$(vm_key)
check "hv1 : VPC vp-s4" vpc_create vp-s4 10.240.0.0/16
check "hv1 : subnet sn-s4" subnet_create sn-s4 vp-s4 2401 10.240.1.1 10.240.1.0/24
check "hv1 : vxlan-2401 porte l'adresse VTEP locale 192.168.14.11" sh -c "ip -d link show vxlan-2401 | grep -q 'local 192.168.14.11 '"
check "hv1 : VM s4-p" vm_create s4-p sn-s4 10.240.1.10 "${KEY}"
NODE
on hv2 <<'NODE'
two_image || { echo "ÉCHOUÉ: image compatible two (hv2)"; exit 0; }
check "hv2 : VPC vp-s4" vpc_create vp-s4 10.240.0.0/16
check "hv2 : subnet sn-s4" subnet_create sn-s4 vp-s4 2401 10.240.1.1 10.240.1.0/24
check "hv2 : vxlan-2401 porte l'adresse VTEP locale 192.168.14.12" sh -c "ip -d link show vxlan-2401 | grep -q 'local 192.168.14.12 '"
check "hv2 : VM s4-q" vm_create s4-q sn-s4 10.240.1.11
NODE
on hv1 <<'NODE'
check "s4-p joignable" vm_wait vp-s4 10.240.1.10
for i in $(seq 1 30); do vtysh -c "show evpn vni 2401" | grep -q 192.168.14.12 && break; sleep 2; done
check "hv1 : VTEP distant 192.168.14.12 appris par EVPN pour la VNI 2401" sh -c "vtysh -c 'show evpn vni 2401' | grep -q '192.168.14.12'"
check "hv1 : entrée d'inondation vers 192.168.14.12" sh -c "bridge fdb show dev vxlan-2401 | grep -q '00:00:00:00:00:00 dst 192.168.14.12'"
for i in $(seq 1 24); do vm_ssh vp-s4 10.240.1.10 'ping -c 1 -W 2 10.240.1.11' >/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

View file

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

View file

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