Compare commits
16 commits
5620da4d54
...
ddcbd2b6fa
| Author | SHA1 | Date | |
|---|---|---|---|
|
ddcbd2b6fa |
|||
|
909b4110c2 |
|||
|
b4660c95d3 |
|||
|
f9aadc00fc |
|||
|
bc05fd9c89 |
|||
|
50fb84dc85 |
|||
|
6bf0435ea4 |
|||
|
4410f30afa |
|||
|
b348652995 |
|||
|
32940c57b4 |
|||
|
90d8128b6c |
|||
|
1146d13d45 |
|||
|
d1cd943d86 |
|||
|
99ecb59aad |
|||
|
a706e24b1c |
|||
|
28ce00cfa3 |
56 changed files with 8843 additions and 128 deletions
193
cmd/lab/lifecycle.go
Normal file
193
cmd/lab/lifecycle.go
Normal 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
174
cmd/lab/main.go
Normal 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
354
cmd/lab/main_test.go
Normal 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
14
cmd/lab/terminal.go
Normal 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
|
||||
}
|
||||
5
cmd/lab/terminal_darwin.go
Normal file
5
cmd/lab/terminal_darwin.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package main
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
const ioctlReadTermios = unix.TIOCGETA
|
||||
5
cmd/lab/terminal_linux.go
Normal file
5
cmd/lab/terminal_linux.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package main
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
const ioctlReadTermios = unix.TCGETS
|
||||
7
cmd/lab/terminal_other.go
Normal file
7
cmd/lab/terminal_other.go
Normal 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
2
conf/lab/agent/two.yml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
dhcp:
|
||||
backend: two
|
||||
28
conf/lab/evpn-2hv.yml
Normal file
28
conf/lab/evpn-2hv.yml
Normal 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
19
conf/lab/frr/hv1.conf
Normal 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
19
conf/lab/frr/hv2.conf
Normal 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
43
conf/lab/frr/rr1.conf
Normal 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
28
conf/lab/frr/sw1.conf
Normal 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
|
||||
!
|
||||
|
|
@ -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 :
|
||||
|
||||
|
|
|
|||
|
|
@ -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::
|
||||
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
10
docs/developpement/index.rst
Normal file
10
docs/developpement/index.rst
Normal 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
899
docs/developpement/lab.rst
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
155
docs/schemas/architecture-cluster-dark.svg
Normal file
155
docs/schemas/architecture-cluster-dark.svg
Normal 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-<vni></text>
|
||||
<text class="sub" x="364" y="402" text-anchor="middle">dans br-<subnet>, sans learning</text>
|
||||
<rect class="box" x="274" y="434" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="364" y="453" text-anchor="middle">netns du VPC</text>
|
||||
<text class="sub" x="364" y="470" text-anchor="middle">passerelle, DHCP</text>
|
||||
<rect class="box" x="274" y="502" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="364" y="521" text-anchor="middle">VM</text>
|
||||
<text class="sub" x="364" y="538" text-anchor="middle">QEMU / KVM</text>
|
||||
<path class="link" d="M364 344 L364 366"/>
|
||||
<text class="sub" x="372" y="359">VTEP</text>
|
||||
<path class="link" d="M364 412 L364 434"/>
|
||||
<text class="sub" x="372" y="427">veth</text>
|
||||
<path class="link" d="M364 480 L364 502"/>
|
||||
<text class="sub" x="372" y="495">tap</text>
|
||||
|
||||
<rect class="box" x="56" y="298" width="180" height="70" rx="6"/>
|
||||
<text class="lbl" x="146" y="321" text-anchor="middle">FRR</text>
|
||||
<text class="sub" x="146" y="340" text-anchor="middle">bgpd · AS 64600</text>
|
||||
<text class="sub" x="146" y="356" text-anchor="middle">advertise-all-vni</text>
|
||||
<rect class="box" x="56" y="434" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="146" y="453" text-anchor="middle">agent</text>
|
||||
<text class="sub" x="146" y="470" text-anchor="middle">API REST · Badger</text>
|
||||
|
||||
<path class="ctrl" d="M236 352 L255 352 L255 389 L272 389" marker-end="url(#a-ctrl)"/>
|
||||
<text class="ctrlt" x="255" y="345" text-anchor="middle">FDB</text>
|
||||
<path class="made" d="M236 457 L272 457" marker-end="url(#a-made)"/>
|
||||
<text class="sub" x="255" y="450" text-anchor="middle">crée</text>
|
||||
|
||||
<rect class="group" x="530" y="262" width="430" height="306" rx="8"/>
|
||||
<text class="grp" x="656" y="284">HYPERVISEUR 2</text>
|
||||
|
||||
<rect class="box" x="546" y="298" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="636" y="317" text-anchor="middle">br-000000</text>
|
||||
<text class="sub" x="636" y="334" text-anchor="middle">uplink · 192.168.14.12</text>
|
||||
<rect class="box" x="546" y="366" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="636" y="385" text-anchor="middle">vxlan-<vni></text>
|
||||
<text class="sub" x="636" y="402" text-anchor="middle">dans br-<subnet>, sans learning</text>
|
||||
<rect class="box" x="546" y="434" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="636" y="453" text-anchor="middle">netns du VPC</text>
|
||||
<text class="sub" x="636" y="470" text-anchor="middle">passerelle, DHCP</text>
|
||||
<rect class="box" x="546" y="502" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="636" y="521" text-anchor="middle">VM</text>
|
||||
<text class="sub" x="636" y="538" text-anchor="middle">QEMU / KVM</text>
|
||||
<path class="link" d="M636 344 L636 366"/>
|
||||
<text class="sub" x="628" y="359" text-anchor="end">VTEP</text>
|
||||
<path class="link" d="M636 412 L636 434"/>
|
||||
<text class="sub" x="628" y="427" text-anchor="end">veth</text>
|
||||
<path class="link" d="M636 480 L636 502"/>
|
||||
<text class="sub" x="628" y="495" text-anchor="end">tap</text>
|
||||
|
||||
<rect class="box" x="764" y="298" width="180" height="70" rx="6"/>
|
||||
<text class="lbl" x="854" y="321" text-anchor="middle">FRR</text>
|
||||
<text class="sub" x="854" y="340" text-anchor="middle">bgpd · AS 64600</text>
|
||||
<text class="sub" x="854" y="356" text-anchor="middle">advertise-all-vni</text>
|
||||
<rect class="box" x="764" y="434" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="854" y="453" text-anchor="middle">agent</text>
|
||||
<text class="sub" x="854" y="470" text-anchor="middle">API REST · Badger</text>
|
||||
|
||||
<path class="ctrl" d="M764 352 L745 352 L745 389 L728 389" marker-end="url(#a-ctrl)"/>
|
||||
<text class="ctrlt" x="745" y="345" text-anchor="middle">FDB</text>
|
||||
<path class="made" d="M764 457 L728 457" marker-end="url(#a-made)"/>
|
||||
<text class="sub" x="745" y="450" text-anchor="middle">crée</text>
|
||||
|
||||
<path class="link" d="M364 298 L364 196"/>
|
||||
<path class="link" d="M636 298 L636 196"/>
|
||||
|
||||
<path class="ctrl" d="M210 298 L210 246 L357 246"/>
|
||||
<path class="ctrl" d="M371 246 L612 246 L612 164"/>
|
||||
<path class="ctrl" d="M854 298 L854 164"/>
|
||||
<text class="ctrlt" x="384" y="240">iBGP l2vpn evpn · AS 64600</text>
|
||||
<text class="ctrlt" x="862" y="240">iBGP</text>
|
||||
|
||||
<path class="data" d="M458 389 L542 389" marker-start="url(#a-data)" marker-end="url(#a-data)"/>
|
||||
<text class="datat" x="500" y="378" text-anchor="middle">VXLAN</text>
|
||||
<text class="datat" x="500" y="406" text-anchor="middle">UDP 4789</text>
|
||||
|
||||
<path class="link" d="M40 604 L76 604"/>
|
||||
<text class="sub" x="84" y="608">lien L2 / interface</text>
|
||||
<path class="data" d="M220 604 L256 604"/>
|
||||
<text class="sub" x="264" y="608">plan de données : tunnels VXLAN</text>
|
||||
<path class="ctrl" d="M470 604 L506 604"/>
|
||||
<text class="sub" x="514" y="608">plan de contrôle : sessions BGP</text>
|
||||
<path class="made" d="M716 604 L752 604"/>
|
||||
<text class="sub" x="760" y="608">créé par l'agent</text>
|
||||
|
||||
<text class="sub" x="40" y="640">Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer.</text>
|
||||
<text class="sub" x="40" y="658">Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs.</text>
|
||||
<text class="sub" x="40" y="676">Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5).</text>
|
||||
<text class="sub" x="40" y="694">MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab.</text>
|
||||
<text class="sub" x="40" y="712">ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
155
docs/schemas/architecture-cluster.svg
Normal file
155
docs/schemas/architecture-cluster.svg
Normal 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-<vni></text>
|
||||
<text class="sub" x="364" y="402" text-anchor="middle">dans br-<subnet>, sans learning</text>
|
||||
<rect class="box" x="274" y="434" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="364" y="453" text-anchor="middle">netns du VPC</text>
|
||||
<text class="sub" x="364" y="470" text-anchor="middle">passerelle, DHCP</text>
|
||||
<rect class="box" x="274" y="502" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="364" y="521" text-anchor="middle">VM</text>
|
||||
<text class="sub" x="364" y="538" text-anchor="middle">QEMU / KVM</text>
|
||||
<path class="link" d="M364 344 L364 366"/>
|
||||
<text class="sub" x="372" y="359">VTEP</text>
|
||||
<path class="link" d="M364 412 L364 434"/>
|
||||
<text class="sub" x="372" y="427">veth</text>
|
||||
<path class="link" d="M364 480 L364 502"/>
|
||||
<text class="sub" x="372" y="495">tap</text>
|
||||
|
||||
<rect class="box" x="56" y="298" width="180" height="70" rx="6"/>
|
||||
<text class="lbl" x="146" y="321" text-anchor="middle">FRR</text>
|
||||
<text class="sub" x="146" y="340" text-anchor="middle">bgpd · AS 64600</text>
|
||||
<text class="sub" x="146" y="356" text-anchor="middle">advertise-all-vni</text>
|
||||
<rect class="box" x="56" y="434" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="146" y="453" text-anchor="middle">agent</text>
|
||||
<text class="sub" x="146" y="470" text-anchor="middle">API REST · Badger</text>
|
||||
|
||||
<path class="ctrl" d="M236 352 L255 352 L255 389 L272 389" marker-end="url(#a-ctrl)"/>
|
||||
<text class="ctrlt" x="255" y="345" text-anchor="middle">FDB</text>
|
||||
<path class="made" d="M236 457 L272 457" marker-end="url(#a-made)"/>
|
||||
<text class="sub" x="255" y="450" text-anchor="middle">crée</text>
|
||||
|
||||
<rect class="group" x="530" y="262" width="430" height="306" rx="8"/>
|
||||
<text class="grp" x="656" y="284">HYPERVISEUR 2</text>
|
||||
|
||||
<rect class="box" x="546" y="298" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="636" y="317" text-anchor="middle">br-000000</text>
|
||||
<text class="sub" x="636" y="334" text-anchor="middle">uplink · 192.168.14.12</text>
|
||||
<rect class="box" x="546" y="366" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="636" y="385" text-anchor="middle">vxlan-<vni></text>
|
||||
<text class="sub" x="636" y="402" text-anchor="middle">dans br-<subnet>, sans learning</text>
|
||||
<rect class="box" x="546" y="434" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="636" y="453" text-anchor="middle">netns du VPC</text>
|
||||
<text class="sub" x="636" y="470" text-anchor="middle">passerelle, DHCP</text>
|
||||
<rect class="box" x="546" y="502" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="636" y="521" text-anchor="middle">VM</text>
|
||||
<text class="sub" x="636" y="538" text-anchor="middle">QEMU / KVM</text>
|
||||
<path class="link" d="M636 344 L636 366"/>
|
||||
<text class="sub" x="628" y="359" text-anchor="end">VTEP</text>
|
||||
<path class="link" d="M636 412 L636 434"/>
|
||||
<text class="sub" x="628" y="427" text-anchor="end">veth</text>
|
||||
<path class="link" d="M636 480 L636 502"/>
|
||||
<text class="sub" x="628" y="495" text-anchor="end">tap</text>
|
||||
|
||||
<rect class="box" x="764" y="298" width="180" height="70" rx="6"/>
|
||||
<text class="lbl" x="854" y="321" text-anchor="middle">FRR</text>
|
||||
<text class="sub" x="854" y="340" text-anchor="middle">bgpd · AS 64600</text>
|
||||
<text class="sub" x="854" y="356" text-anchor="middle">advertise-all-vni</text>
|
||||
<rect class="box" x="764" y="434" width="180" height="46" rx="6"/>
|
||||
<text class="lbl" x="854" y="453" text-anchor="middle">agent</text>
|
||||
<text class="sub" x="854" y="470" text-anchor="middle">API REST · Badger</text>
|
||||
|
||||
<path class="ctrl" d="M764 352 L745 352 L745 389 L728 389" marker-end="url(#a-ctrl)"/>
|
||||
<text class="ctrlt" x="745" y="345" text-anchor="middle">FDB</text>
|
||||
<path class="made" d="M764 457 L728 457" marker-end="url(#a-made)"/>
|
||||
<text class="sub" x="745" y="450" text-anchor="middle">crée</text>
|
||||
|
||||
<path class="link" d="M364 298 L364 196"/>
|
||||
<path class="link" d="M636 298 L636 196"/>
|
||||
|
||||
<path class="ctrl" d="M210 298 L210 246 L357 246"/>
|
||||
<path class="ctrl" d="M371 246 L612 246 L612 164"/>
|
||||
<path class="ctrl" d="M854 298 L854 164"/>
|
||||
<text class="ctrlt" x="384" y="240">iBGP l2vpn evpn · AS 64600</text>
|
||||
<text class="ctrlt" x="862" y="240">iBGP</text>
|
||||
|
||||
<path class="data" d="M458 389 L542 389" marker-start="url(#a-data)" marker-end="url(#a-data)"/>
|
||||
<text class="datat" x="500" y="378" text-anchor="middle">VXLAN</text>
|
||||
<text class="datat" x="500" y="406" text-anchor="middle">UDP 4789</text>
|
||||
|
||||
<path class="link" d="M40 604 L76 604"/>
|
||||
<text class="sub" x="84" y="608">lien L2 / interface</text>
|
||||
<path class="data" d="M220 604 L256 604"/>
|
||||
<text class="sub" x="264" y="608">plan de données : tunnels VXLAN</text>
|
||||
<path class="ctrl" d="M470 604 L506 604"/>
|
||||
<text class="sub" x="514" y="608">plan de contrôle : sessions BGP</text>
|
||||
<path class="made" d="M716 604 L752 604"/>
|
||||
<text class="sub" x="760" y="608">créé par l'agent</text>
|
||||
|
||||
<text class="sub" x="40" y="640">Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer.</text>
|
||||
<text class="sub" x="40" y="658">Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs.</text>
|
||||
<text class="sub" x="40" y="676">Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5).</text>
|
||||
<text class="sub" x="40" y="694">MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab.</text>
|
||||
<text class="sub" x="40" y="712">ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger.</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
|
@ -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 |
|
|
@ -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
2
go.mod
|
|
@ -13,6 +13,7 @@ require (
|
|||
github.com/spf13/viper v1.21.0
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
github.com/vishvananda/netns v0.0.5
|
||||
go.yaml.in/yaml/v3 v3.0.4
|
||||
golang.org/x/sys v0.45.0
|
||||
)
|
||||
|
||||
|
|
@ -46,7 +47,6 @@ require (
|
|||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
|
|
|
|||
293
internal/lab/machine/machine.go
Normal file
293
internal/lab/machine/machine.go
Normal 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)
|
||||
}
|
||||
588
internal/lab/machine/machine_test.go
Normal file
588
internal/lab/machine/machine_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
185
internal/lab/provision/image.go
Normal file
185
internal/lab/provision/image.go
Normal 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
|
||||
}
|
||||
334
internal/lab/provision/image_test.go
Normal file
334
internal/lab/provision/image_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
182
internal/lab/provision/provision.go
Normal file
182
internal/lab/provision/provision.go
Normal 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
|
||||
}
|
||||
407
internal/lab/provision/provision_test.go
Normal file
407
internal/lab/provision/provision_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
BIN
internal/lab/render/frrouting.gpg
Normal file
BIN
internal/lab/render/frrouting.gpg
Normal file
Binary file not shown.
54
internal/lab/render/qemu.go
Normal file
54
internal/lab/render/qemu.go
Normal 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),
|
||||
}
|
||||
}
|
||||
142
internal/lab/render/render.go
Normal file
142
internal/lab/render/render.go
Normal 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
|
||||
}
|
||||
480
internal/lab/render/render_test.go
Normal file
480
internal/lab/render/render_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
298
internal/lab/render/roles_test.go
Normal file
298
internal/lab/render/roles_test.go
Normal 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
294
internal/lab/render/seed.go
Normal 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)
|
||||
}
|
||||
80
internal/lab/topology/format.go
Normal file
80
internal/lab/topology/format.go
Normal 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
|
||||
}
|
||||
216
internal/lab/topology/plan.go
Normal file
216
internal/lab/topology/plan.go
Normal 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
|
||||
}
|
||||
364
internal/lab/topology/plan_test.go
Normal file
364
internal/lab/topology/plan_test.go
Normal 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")
|
||||
}
|
||||
265
internal/lab/topology/roles_test.go
Normal file
265
internal/lab/topology/roles_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
175
internal/lab/topology/topology.go
Normal file
175
internal/lab/topology/topology.go
Normal 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
|
||||
}
|
||||
173
internal/lab/topology/topology_test.go
Normal file
173
internal/lab/topology/topology_test.go
Normal 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")
|
||||
}
|
||||
239
internal/lab/topology/validate.go
Normal file
239
internal/lab/topology/validate.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
9
scripts/embed.go
Normal 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
466
scripts/lab-host.sh
Executable 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
825
scripts/lab-host_test.sh
Executable 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
151
scripts/lab/node.sh
Normal 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
67
scripts/lab/scenario.sh
Executable 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
140
scripts/lab/scenario_test.sh
Executable 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 ]]
|
||||
34
scripts/lab/scenarios/s1-dhcp-two.sh
Normal file
34
scripts/lab/scenarios/s1-dhcp-two.sh
Normal 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
|
||||
26
scripts/lab/scenarios/s2-gateway.sh
Normal file
26
scripts/lab/scenarios/s2-gateway.sh
Normal 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
|
||||
16
scripts/lab/scenarios/s3-isolation-local.sh
Normal file
16
scripts/lab/scenarios/s3-isolation-local.sh
Normal 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
|
||||
25
scripts/lab/scenarios/s4-evpn.sh
Normal file
25
scripts/lab/scenarios/s4-evpn.sh
Normal 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
|
||||
23
scripts/lab/scenarios/s5-isolation-evpn.sh
Normal file
23
scripts/lab/scenarios/s5-isolation-evpn.sh
Normal 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
|
||||
36
scripts/lab/scenarios/s6-rr-loss.sh
Normal file
36
scripts/lab/scenarios/s6-rr-loss.sh
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue