diff --git a/cmd/lab/lifecycle.go b/cmd/lab/lifecycle.go deleted file mode 100644 index c91205e..0000000 --- a/cmd/lab/lifecycle.go +++ /dev/null @@ -1,193 +0,0 @@ -package main - -import ( - "context" - "flag" - "fmt" - "io" - "net/http" - "os" - "os/exec" - "os/signal" - "path/filepath" - "syscall" - "time" - - "git.g3e.fr/syonad/two/internal/lab/machine" - "git.g3e.fr/syonad/two/internal/lab/provision" -) - -const ( - topologyFile = "topology.yml" - pollInterval = 5 * time.Second - stopTimeout = 30 * time.Second -) - -var ( - execve = syscall.Exec - procDir = "/proc" -) - -func defaultDir(parts ...string) string { - home, err := os.UserHomeDir() - if err != nil { - return "" - } - return filepath.Join(append([]string{home}, parts...)...) -} - -func flags(name string, stderr io.Writer) (*flag.FlagSet, *string) { - fs := flag.NewFlagSet(name, flag.ContinueOnError) - fs.SetOutput(stderr) - fs.Usage = func() { fmt.Fprint(stderr, usage) } - return fs, fs.String("run", defaultDir("lab-run"), "run directory of the lab") -} - -func lab(runDir string, plan string, stdout, stderr io.Writer) (machine.Lab, bool) { - dir, err := filepath.Abs(runDir) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return machine.Lab{}, false - } - p, ok := load(plan, stderr) - if !ok { - return machine.Lab{}, false - } - return machine.Lab{ - Plan: p, - RunDir: dir, - ProcDir: procDir, - Runner: provision.ExecRunner{}, - Poll: pollInterval, - Stop: stopTimeout, - Out: stdout, - }, true -} - -func current(runDir string, stdout, stderr io.Writer) (machine.Lab, bool) { - saved := filepath.Join(runDir, topologyFile) - if _, err := os.Stat(saved); err != nil { - fmt.Fprintf(stderr, "lab: no lab in %s: %v\n", runDir, err) - return machine.Lab{}, false - } - return lab(runDir, saved, stdout, stderr) -} - -func upCmd(args []string, stdout, stderr io.Writer) int { - fs, runDir := flags("up", stderr) - cacheDir := fs.String("cache", defaultDir(".cache", "two-lab"), "image cache directory") - timeout := fs.Duration("timeout", 20*time.Minute, "how long to wait for the nodes to be ready") - if err := fs.Parse(args); err != nil || fs.NArg() != 1 { - fs.Usage() - return 2 - } - cache, err := filepath.Abs(*cacheDir) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - - if _, err := os.Stat(filepath.Join(*runDir, topologyFile)); err == nil { - previous, ok := current(*runDir, stdout, stderr) - if !ok { - return 1 - } - running, err := previous.Running() - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - if len(running) > 0 { - fmt.Fprintf(stderr, "lab: lab %s is still running in %s: 'lab down' first\n", previous.Plan.Name, previous.RunDir) - return 1 - } - } - - l, ok := lab(*runDir, fs.Arg(0), stdout, stderr) - if !ok { - return 1 - } - source, err := os.ReadFile(fs.Arg(0)) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - if err := os.MkdirAll(l.RunDir, 0o700); err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - if err := os.WriteFile(filepath.Join(l.RunDir, topologyFile), source, 0o600); err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - - ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) - defer stop() - fetcher := provision.Fetcher{Client: &http.Client{}, CacheDir: cache} - if err := l.Up(ctx, fetcher, *timeout); err != nil { - fmt.Fprintf(stderr, "lab: %v\nlab: started nodes keep running: 'lab status', 'lab down'\n", err) - return 1 - } - return 0 -} - -func statusCmd(args []string, stdout, stderr io.Writer) int { - fs, runDir := flags("status", stderr) - if err := fs.Parse(args); err != nil || fs.NArg() != 0 { - fs.Usage() - return 2 - } - l, ok := current(*runDir, stdout, stderr) - if !ok { - return 1 - } - if err := l.Status(stdout); err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - return 0 -} - -func downCmd(args []string, stdout, stderr io.Writer) int { - fs, runDir := flags("down", stderr) - if err := fs.Parse(args); err != nil || fs.NArg() != 0 { - fs.Usage() - return 2 - } - l, ok := current(*runDir, stdout, stderr) - if !ok { - return 1 - } - if err := l.Down(context.Background()); err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - return 0 -} - -func sshCmd(args []string, stdout, stderr io.Writer) int { - fs, runDir := flags("ssh", stderr) - if err := fs.Parse(args); err != nil || fs.NArg() < 1 { - fs.Usage() - return 2 - } - l, ok := current(*runDir, stdout, stderr) - if !ok { - return 1 - } - argv, err := l.SSH(fs.Arg(0), isTerminal(os.Stdin), fs.Args()[1:]) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - path, err := exec.LookPath(argv[0]) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - if err := execve(path, argv, os.Environ()); err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - return 0 -} diff --git a/cmd/lab/main.go b/cmd/lab/main.go deleted file mode 100644 index 3ff3cf2..0000000 --- a/cmd/lab/main.go +++ /dev/null @@ -1,174 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "io" - "os" - "path/filepath" - "strings" - - "git.g3e.fr/syonad/two/internal/lab/provision" - "git.g3e.fr/syonad/two/internal/lab/render" - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const usage = `usage: lab [options] [dir] - - plan - validate the topology and print the deterministic plan: addresses, cables, ports - - render -key - write, for each node, //qemu.args (one argument per line) and the - cloud-init seed files meta-data, user-data and network-config - - up [-run dir] [-cache dir] [-timeout duration] - download and verify the images, create fresh disks and seeds, start every node - (switches first) and wait until cloud-init has finished on each of them; - the topology is kept in /topology.yml for the commands below - - status [-run dir] - list the nodes of the lab and whether their qemu process is running - - down [-run dir] - stop every qemu process of the lab (SIGTERM, then SIGKILL) - - ssh [-run dir] [command...] - open a shell on a node, or run a command on it, with the key generated by up - - defaults: -run $HOME/lab-run, -cache $HOME/.cache/two-lab, -timeout 20m -` - -type keyFiles []string - -func (k *keyFiles) String() string { return strings.Join(*k, ",") } -func (k *keyFiles) Set(v string) error { *k = append(*k, v); return nil } - -func main() { - os.Exit(run(os.Args[1:], os.Stdout, os.Stderr)) -} - -func run(args []string, stdout, stderr io.Writer) int { - if len(args) == 0 { - fmt.Fprint(stderr, usage) - return 2 - } - switch args[0] { - case "plan": - if len(args) != 2 { - fmt.Fprint(stderr, usage) - return 2 - } - return plan(args[1], stdout, stderr) - case "render": - return renderCmd(args[1:], stdout, stderr) - case "up": - return upCmd(args[1:], stdout, stderr) - case "status": - return statusCmd(args[1:], stdout, stderr) - case "down": - return downCmd(args[1:], stdout, stderr) - case "ssh": - return sshCmd(args[1:], stdout, stderr) - default: - fmt.Fprint(stderr, usage) - return 2 - } -} - -func load(path string, stderr io.Writer) (*topology.Plan, bool) { - t, err := topology.Load(path) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return nil, false - } - p, err := topology.Compute(t) - if err != nil { - fmt.Fprintf(stderr, "lab: %s:\n%v\n", path, err) - return nil, false - } - return p, true -} - -func plan(path string, stdout, stderr io.Writer) int { - p, ok := load(path, stderr) - if !ok { - return 1 - } - if err := p.Write(stdout); err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - return 0 -} - -func renderCmd(args []string, stdout, stderr io.Writer) int { - fs := flag.NewFlagSet("render", flag.ContinueOnError) - fs.SetOutput(stderr) - fs.Usage = func() { fmt.Fprint(stderr, usage) } - var keys keyFiles - fs.Var(&keys, "key", "public key file allowed to log in, repeatable") - if err := fs.Parse(args); err != nil { - return 2 - } - if fs.NArg() != 2 || len(keys) == 0 { - fmt.Fprint(stderr, usage) - return 2 - } - - p, ok := load(fs.Arg(0), stderr) - if !ok { - return 1 - } - dir, err := filepath.Abs(fs.Arg(1)) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - authorized, err := readKeys(keys) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - frr, err := provision.ReadFRR(p) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - agent, err := provision.ReadAgent(p) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized, FRR: frr, Agent: agent}) - if err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - for _, n := range nodes { - if err := provision.WriteFiles(n); err != nil { - fmt.Fprintf(stderr, "lab: %v\n", err) - return 1 - } - fmt.Fprintf(stdout, "%s\n", n.Dir) - } - return 0 -} - -func readKeys(files []string) ([]string, error) { - var keys []string - for _, f := range files { - data, err := os.ReadFile(f) - if err != nil { - return nil, err - } - for _, line := range strings.Split(string(data), "\n") { - line = strings.TrimSpace(line) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - keys = append(keys, line) - } - } - return keys, nil -} diff --git a/cmd/lab/main_test.go b/cmd/lab/main_test.go deleted file mode 100644 index 733fd3c..0000000 --- a/cmd/lab/main_test.go +++ /dev/null @@ -1,354 +0,0 @@ -package main - -import ( - "bytes" - "os" - "os/exec" - "path/filepath" - "strconv" - "strings" - "syscall" - "testing" -) - -func runLab(args ...string) (int, string, string) { - var stdout, stderr bytes.Buffer - code := run(args, &stdout, &stderr) - return code, stdout.String(), stderr.String() -} - -func TestRun_UsageOnMissingArguments(t *testing.T) { - for _, args := range [][]string{nil, {"plan"}, {"deploy", "x.yml"}, {"plan", "a.yml", "b.yml"}, {"render", "a.yml", "dir"}, {"render", "-key", "k.pub", "a.yml"}, {"render", "-bogus"}} { - code, _, stderr := runLab(args...) - if code != 2 || !strings.Contains(stderr, "usage: lab") { - t.Errorf("args %v: code %d, stderr %q", args, code, stderr) - } - } -} - -func TestRun_PlanOfTheShippedExampleTopology(t *testing.T) { - code, stdout, stderr := runLab("plan", filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml")) - if code != 0 { - t.Fatalf("code %d, stderr %s", code, stderr) - } - for _, want := range []string{ - "lab evpn-2hv: nodes 4, segments 1, cables 3", - "gateway 192.168.14.1", - "hv2 underlay 192.168.14.12/24 02:4c:00:03:00:00 20004 <-> sw1 p2", - "rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf", - } { - if !strings.Contains(stdout, want) { - t.Errorf("output does not contain %q:\n%s", want, stdout) - } - } -} - -func TestRun_InvalidTopologyExitsWithErrors(t *testing.T) { - path := filepath.Join(t.TempDir(), "bad.yml") - doc := `name: bad -images: - deb: { url: https://example.invalid/a, sums: https://example.invalid/b } -segments: - under: { switch: sw, cidr: 10.0.0.0/31 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - rr: { role: router, image: deb, cpus: 1, memory: 512, segments: [under] } -` - if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { - t.Fatal(err) - } - code, stdout, stderr := runLab("plan", path) - if code != 1 || stdout != "" { - t.Fatalf("code %d, stdout %q", code, stdout) - } - for _, want := range []string{path, `role "router"`, "prefix length out of range"} { - if !strings.Contains(stderr, want) { - t.Errorf("stderr does not mention %q:\n%s", want, stderr) - } - } -} - -func TestRun_MissingFile(t *testing.T) { - code, _, stderr := runLab("plan", filepath.Join(t.TempDir(), "absent.yml")) - if code != 1 || !strings.Contains(stderr, "absent.yml") { - t.Errorf("code %d, stderr %q", code, stderr) - } -} - -func TestRun_RenderWritesEveryNodeFiles(t *testing.T) { - dir := t.TempDir() - key := filepath.Join(dir, "lab.pub") - if err := os.WriteFile(key, []byte("# lab key\n\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation\n"), 0o600); err != nil { - t.Fatal(err) - } - out := filepath.Join(dir, "run") - code, stdout, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"), out) - if code != 0 { - t.Fatalf("code %d, stderr %s", code, stderr) - } - for _, node := range []string{"sw1", "rr1", "hv1", "hv2"} { - if !strings.Contains(stdout, filepath.Join(out, node)) { - t.Errorf("stdout does not list %s:\n%s", node, stdout) - } - for _, f := range []string{"qemu.args", "meta-data", "user-data", "network-config"} { - info, err := os.Stat(filepath.Join(out, node, f)) - if err != nil { - t.Errorf("%s/%s: %v", node, f, err) - continue - } - if info.Mode().Perm() != 0o600 { - t.Errorf("%s/%s mode %o, want 600", node, f, info.Mode().Perm()) - } - } - } - args, err := os.ReadFile(filepath.Join(out, "hv1", "qemu.args")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(args), "\n-netdev\nuser,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22\n") { - t.Errorf("qemu.args is not one argument per line:\n%s", args) - } - userData, err := os.ReadFile(filepath.Join(out, "hv1", "user-data")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(userData), "two-lab-automation") || strings.Contains(string(userData), "# lab key") { - t.Errorf("keys not read as an authorized_keys file:\n%s", userData) - } -} - -func TestRun_RenderRefusesMissingKeyFile(t *testing.T) { - code, _, stderr := runLab("render", "-key", filepath.Join(t.TempDir(), "absent.pub"), filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"), t.TempDir()) - if code != 1 || !strings.Contains(stderr, "absent.pub") { - t.Errorf("code %d, stderr %q", code, stderr) - } -} - -func savedLab(t *testing.T) string { - t.Helper() - run := filepath.Join(t.TempDir(), "run") - if err := os.MkdirAll(run, 0o700); err != nil { - t.Fatal(err) - } - example, err := os.ReadFile(filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml")) - if err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(run, "topology.yml"), example, 0o600); err != nil { - t.Fatal(err) - } - return run -} - -func TestRun_LifecycleUsage(t *testing.T) { - for _, args := range [][]string{{"up"}, {"up", "a.yml", "b.yml"}, {"status", "x"}, {"down", "x"}, {"ssh"}, {"up", "-bogus", "a.yml"}} { - code, _, stderr := runLab(args...) - if code != 2 || !strings.Contains(stderr, "usage: lab") { - t.Errorf("args %v: code %d, stderr %q", args, code, stderr) - } - } -} - -func TestRun_CommandsWithoutALabInTheRunDir(t *testing.T) { - run := t.TempDir() - for _, cmd := range []string{"status", "down", "ssh"} { - args := []string{cmd, "-run", run} - if cmd == "ssh" { - args = append(args, "hv1") - } - code, _, stderr := runLab(args...) - if code != 1 || !strings.Contains(stderr, "lab: no lab in "+run) { - t.Errorf("%s: code %d, stderr %q", cmd, code, stderr) - } - } -} - -func TestRun_StatusOfAStoppedLab(t *testing.T) { - code, stdout, stderr := runLab("status", "-run", savedLab(t)) - want := `node role state pid ssh -sw1 switch stopped - 127.0.0.1:2200 -rr1 rr stopped - 127.0.0.1:2201 -hv1 hypervisor stopped - 127.0.0.1:2202 -hv2 hypervisor stopped - 127.0.0.1:2203 -` - if code != 0 || stdout != want { - t.Errorf("code %d, stderr %q, stdout:\n%s", code, stderr, stdout) - } -} - -func TestRun_DownOfAStoppedLab(t *testing.T) { - code, stdout, stderr := runLab("down", "-run", savedLab(t)) - if code != 0 || stdout != "" || stderr != "" { - t.Errorf("code %d, stdout %q, stderr %q", code, stdout, stderr) - } -} - -func TestRun_SSHExecsSSHWithTheNodePort(t *testing.T) { - run := savedLab(t) - var gotPath string - var gotArgv []string - execve = func(path string, argv []string, env []string) error { - gotPath, gotArgv = path, argv - return nil - } - t.Cleanup(func() { execve = syscall.Exec }) - - code, _, stderr := runLab("ssh", "-run", run, "hv2", "ip", "-br", "a") - - if code != 0 { - t.Fatalf("code %d, stderr %q", code, stderr) - } - if filepath.Base(gotPath) != "ssh" { - t.Errorf("path = %q", gotPath) - } - want := []string{"ssh", - "-i", filepath.Join(run, "lab_ed25519"), - "-o", "IdentitiesOnly=yes", - "-o", "IdentityAgent=none", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-p", "2203", - "debian@127.0.0.1", - "ip", "-br", "a", - } - if strings.Join(gotArgv, " ") != strings.Join(want, " ") { - t.Errorf("\n got %q\nwant %q", gotArgv, want) - } -} - -func TestRun_SSHUnknownNode(t *testing.T) { - code, _, stderr := runLab("ssh", "-run", savedLab(t), "hv9") - if code != 1 || !strings.Contains(stderr, `node "hv9" is not in lab evpn-2hv`) { - t.Errorf("code %d, stderr %q", code, stderr) - } -} - -func TestRun_UpRefusesAnInvalidTopologyAndKeepsTheSavedOne(t *testing.T) { - run := savedLab(t) - before, _ := os.ReadFile(filepath.Join(run, "topology.yml")) - bad := filepath.Join(t.TempDir(), "bad.yml") - if err := os.WriteFile(bad, []byte("name: x\n"), 0o600); err != nil { - t.Fatal(err) - } - - code, _, stderr := runLab("up", "-run", run, bad) - - if code != 1 || !strings.Contains(stderr, "at least one node is required") { - t.Errorf("code %d, stderr %q", code, stderr) - } - if after, _ := os.ReadFile(filepath.Join(run, "topology.yml")); string(after) != string(before) { - t.Error("the saved topology was replaced by an invalid one") - } -} - -func TestIsTerminal_DevNullAndPipesAreNotTerminals(t *testing.T) { - null, err := os.Open(os.DevNull) - if err != nil { - t.Fatal(err) - } - defer null.Close() - r, w, err := os.Pipe() - if err != nil { - t.Fatal(err) - } - defer r.Close() - defer w.Close() - for name, f := range map[string]*os.File{"/dev/null": null, "pipe": r} { - if isTerminal(f) { - t.Errorf("%s is detected as a terminal", name) - } - } -} - -func TestRun_UpRefusesToReplaceARunningLab(t *testing.T) { - run := savedLab(t) - before, _ := os.ReadFile(filepath.Join(run, "topology.yml")) - proc := t.TempDir() - procDir = proc - t.Cleanup(func() { procDir = "/proc" }) - - cmd := exec.Command("sleep", "30") - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - t.Cleanup(func() { cmd.Process.Kill(); cmd.Wait() }) - pid := strconv.Itoa(cmd.Process.Pid) - if err := os.MkdirAll(filepath.Join(proc, pid), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(proc, pid, "cmdline"), []byte("qemu-system-x86_64\x00-name\x00hv1\x00"), 0o644); err != nil { - t.Fatal(err) - } - if err := os.MkdirAll(filepath.Join(run, "hv1"), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(run, "hv1", "qemu.pid"), []byte(pid+"\n"), 0o600); err != nil { - t.Fatal(err) - } - - other := filepath.Join(t.TempDir(), "other.yml") - if err := os.WriteFile(other, bytes.ReplaceAll(before, []byte("evpn-2hv"), []byte("other")), 0o600); err != nil { - t.Fatal(err) - } - code, _, stderr := runLab("up", "-run", run, other) - - if code != 1 || !strings.Contains(stderr, "lab evpn-2hv is still running in "+run) { - t.Errorf("code %d, stderr %q", code, stderr) - } - if after, _ := os.ReadFile(filepath.Join(run, "topology.yml")); string(after) != string(before) { - t.Error("the topology of a running lab was replaced") - } -} - -func TestRun_RenderShipsTheExampleFRRConfigs(t *testing.T) { - dir := t.TempDir() - key := filepath.Join(dir, "lab.pub") - if err := os.WriteFile(key, []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation\n"), 0o600); err != nil { - t.Fatal(err) - } - out := filepath.Join(dir, "run") - code, _, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "test", "e2e", "topologies", "evpn-2hv.yml"), out) - if code != 0 { - t.Fatalf("code %d, stderr %s", code, stderr) - } - for node, want := range map[string]string{ - "sw1": "router bgp 65100", - "rr1": "bgp listen range 192.168.14.0/24 peer-group fabric", - "hv1": "bgp router-id 192.168.14.11", - "hv2": "bgp router-id 192.168.14.12", - } { - data, err := os.ReadFile(filepath.Join(out, node, "user-data")) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), want) { - t.Errorf("%s user-data does not carry %q", node, want) - } - } -} - -func TestRun_RenderReportsAMissingFRRConfig(t *testing.T) { - dir := t.TempDir() - key := filepath.Join(dir, "lab.pub") - if err := os.WriteFile(key, []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O x\n"), 0o600); err != nil { - t.Fatal(err) - } - topo := filepath.Join(dir, "lab.yml") - doc := `name: x -images: - deb: { url: https://example.invalid/deb.qcow2, sums: https://example.invalid/SHA512SUMS } -segments: - underlay: { switch: sw1, cidr: 10.1.0.0/24 } -nodes: - sw1: { role: switch, image: deb, cpus: 1, memory: 512, frr: frr/absent.conf } - hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [underlay], release: 0.2.0rc002 } -` - if err := os.WriteFile(topo, []byte(doc), 0o600); err != nil { - t.Fatal(err) - } - code, _, stderr := runLab("render", "-key", key, topo, filepath.Join(dir, "run")) - if code != 1 || !strings.Contains(stderr, "node sw1: ") || !strings.Contains(stderr, filepath.Join(dir, "frr", "absent.conf")) { - t.Errorf("code %d, stderr %q", code, stderr) - } -} diff --git a/cmd/lab/terminal.go b/cmd/lab/terminal.go deleted file mode 100644 index fa2cb06..0000000 --- a/cmd/lab/terminal.go +++ /dev/null @@ -1,14 +0,0 @@ -//go:build linux || darwin - -package main - -import ( - "os" - - "golang.org/x/sys/unix" -) - -func isTerminal(f *os.File) bool { - _, err := unix.IoctlGetTermios(int(f.Fd()), ioctlReadTermios) - return err == nil -} diff --git a/cmd/lab/terminal_darwin.go b/cmd/lab/terminal_darwin.go deleted file mode 100644 index 4c493c7..0000000 --- a/cmd/lab/terminal_darwin.go +++ /dev/null @@ -1,5 +0,0 @@ -package main - -import "golang.org/x/sys/unix" - -const ioctlReadTermios = unix.TIOCGETA diff --git a/cmd/lab/terminal_linux.go b/cmd/lab/terminal_linux.go deleted file mode 100644 index ba4230d..0000000 --- a/cmd/lab/terminal_linux.go +++ /dev/null @@ -1,5 +0,0 @@ -package main - -import "golang.org/x/sys/unix" - -const ioctlReadTermios = unix.TCGETS diff --git a/cmd/lab/terminal_other.go b/cmd/lab/terminal_other.go deleted file mode 100644 index a929525..0000000 --- a/cmd/lab/terminal_other.go +++ /dev/null @@ -1,7 +0,0 @@ -//go:build !linux && !darwin - -package main - -import "os" - -func isTerminal(*os.File) bool { return false } diff --git a/docs/deploiement/architecture-cluster.rst b/docs/deploiement/architecture-cluster.rst index 3956bab..d1eb9bf 100644 --- a/docs/deploiement/architecture-cluster.rst +++ b/docs/deploiement/architecture-cluster.rst @@ -4,23 +4,21 @@ Architecture du cluster Topologie --------- -.. figure:: /schemas/architecture-cluster.svg - :alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle +.. figure:: /schemas/topologie-cluster.svg + :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs :align: center :width: 100% :class: only-light - Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle - (sessions BGP). Pointillé gris : interfaces créées par l'agent. + Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle. -.. figure:: /schemas/architecture-cluster-dark.svg - :alt: Architecture du cluster : routeurs, route reflector, hyperviseurs, plans de données et de contrôle +.. figure:: /schemas/topologie-cluster-dark.svg + :alt: Topologie du cluster : routeurs, route reflector et hyperviseurs :align: center :width: 100% :class: only-dark - Architecture cible. Trait bleu : plan de données (VXLAN). Tirets violets : plan de contrôle - (sessions BGP). Pointillé gris : interfaces créées par l'agent. + Topologie cible. Trait plein : plan de données. Trait pointillé : plan de contrôle. Deux plans distincts, à ne pas confondre au moment du diagnostic : diff --git a/docs/deploiement/image-qcow2.rst b/docs/deploiement/image-qcow2.rst index 1bf8866..1283f52 100644 --- a/docs/deploiement/image-qcow2.rst +++ b/docs/deploiement/image-qcow2.rst @@ -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,16 +315,12 @@ 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. -.. warning:: +.. note:: - **La barre oblique finale de** ``seedfrom`` **est indispensable avant cloud-init 23.1.** - cloud-init construit l'URL des documents en concaténant ``seedfrom`` avec ``meta-data``, - ``user-data`` et ``vendor-data`` (``util.read_seeded``). Jusqu'à la 22.4 — celle de Debian 12, - 22.4.2 —, sans barre oblique finale il demande ``http://169.254.169.254:80meta-data`` : la - source échoue, la VM démarre en ``DataSourceNone``, sans nom d'hôte ni user-data. À partir de - 23.1, un drapeau actif par défaut (``NOCLOUD_SEED_URL_APPEND_FORWARD_SLASH``) ajoute la barre - oblique manquante, ce qui explique qu'une image récente fonctionne sans elle. Avec la barre - oblique, la configuration fonctionne quelle que soit la version de cloud-init. + **À 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. .. note:: diff --git a/docs/deploiement/premier-hyperviseur.rst b/docs/deploiement/premier-hyperviseur.rst index dc480fe..ae70bc1 100644 --- a/docs/deploiement/premier-hyperviseur.rst +++ b/docs/deploiement/premier-hyperviseur.rst @@ -24,47 +24,19 @@ créées par l'agent. C'est ce qui rend un subnet utilisable au-delà d'un seul l'agent désactive l'apprentissage et ne configure aucun voisin — cf. :doc:`architecture-cluster`. -Configuration de FRR -~~~~~~~~~~~~~~~~~~~~ - -Seul ``bgpd`` est activé dans ``/etc/frr/daemons``. D'un hyperviseur à l'autre ne changent que -``hostname`` et ``router-id``, l'adresse de l'hyperviseur sur son segment. - -.. literalinclude:: ../../test/e2e/topologies/frr/hv1.conf - :language: text - -Ce qu'elle établit : - -* **Une seule session**, en iBGP dans l'AS 64600, vers la loopback du route reflector - (``10.255.255.1``), jointe par la passerelle par défaut — le routeur de cluster l'a apprise du - route reflector (:doc:`routeurs`). Aucune adresse d'un autre hyperviseur n'est écrite : ajouter - un nœud ne touche pas la configuration des autres. -* **EVPN seulement** : l'IPv4 unicast n'est pas activé (``no bgp default ipv4-unicast``). - ``advertise-all-vni`` annonce les VNI de toutes les interfaces VXLAN que FRR voit, et donc - celles que l'agent crée. -* **Pas de BFD** sur cette session, contrairement à la session entre route reflector et routeur. - -Articulation avec l'agent -~~~~~~~~~~~~~~~~~~~~~~~~~ - -Rien à configurer à la création d'un subnet : la VXLAN apparaît, FRR la voit et l'annonce, sans -action ni redémarrage. Il faut en revanche que la VXLAN porte une **adresse VTEP locale** : sans -elle, FRR voit la VNI mais n'annonce rien, et deux hyperviseurs gérés par two ne se joignent pas. -L'agent la pose à partir de la release ``0.2.0rc003`` -(`#51 `_) : l'adresse IPv4 primaire du ``local_iface`` -du subnet. - .. note:: - **À rédiger.** Reste à documenter : + **À rédiger.** À documenter : - * la version de FRR de référence et son installation sur l'hyperviseur **sans état** : paquet - et configuration doivent être posés à chaque démarrage, par le bootstrap ou un mécanisme - équivalent (`#52 `_) ; - * le démarrage à froid, selon que FRR démarre avant ou après l'agent ; - * les subnets ``vxlan`` créés avant ``0.2.0rc003``, dont la VXLAN n'a pas d'adresse VTEP - locale : les recréer ou poser l'adresse à chaud, à trancher dans - `#51 `_ ; + * la version de FRR de référence et son mode d'installation, sachant que l'hyperviseur est + sans état : le paquet et la configuration doivent être posés à chaque démarrage, par le + bootstrap ou par un mécanisme équivalent ; + * les démons activés dans ``/etc/frr/daemons`` ; + * la configuration de référence : numéro d'AS, session vers le route reflector, famille + d'adresses utilisée pour annoncer les MAC et les VNI ; + * l'articulation avec les interfaces créées par l'agent : comment FRR découvre une interface + VXLAN qui apparaît à la création d'un subnet, et si une action est nécessaire ensuite ; + * ce qui se passe au démarrage à froid, quand FRR démarre avant ou après l'agent ; * le cas particulier du **premier** hyperviseur, dont la session ne peut pas s'établir tant que le route reflector n'existe pas. diff --git a/docs/deploiement/route-reflector.rst b/docs/deploiement/route-reflector.rst index e4143a4..e0095d2 100644 --- a/docs/deploiement/route-reflector.rst +++ b/docs/deploiement/route-reflector.rst @@ -8,90 +8,19 @@ reflector, qui redistribue. Il tourne lui-même en machine virtuelle, ce qui crée une dépendance circulaire à traiter explicitement : la VM qui porte le plan de contrôle du cluster est hébergée par le cluster. -Adressage ---------- - -Le route reflector est **sur le même segment L2 que les hyperviseurs**, avec trois adresses : - -.. list-table:: - :header-rows: 1 - :widths: 30 25 45 - - * - Adresse - - Interface - - Rôle - * - une adresse de ``192.168.14.0/24`` - - interface principale - - celle de n'importe quelle machine du segment ; les réponses aux hyperviseurs en partent - * - ``169.254.0.3/28`` - - interface principale, **adresse secondaire** - - le lien avec les routeurs de cluster (``169.254.0.1`` et ``.2``), sur le même L2 - * - ``10.255.255.1/32`` - - ``lo1``, interface ``dummy`` - - ``router-id``, ``cluster-id`` et source des sessions EVPN ; seule route annoncée aux routeurs - -Les hyperviseurs ne connaissent que la loopback : ils ouvrent leur session vers ``10.255.255.1`` -par leur passerelle (``192.168.14.1``), les routeurs l'ayant apprise du route reflector en eBGP. -Aucune adresse d'hyperviseur n'est déclarée côté route reflector : il accepte toute session venant -du segment (``bgp listen range``). - -L'adresse du lien est une **adresse secondaire** de l'interface principale, pas une interface à -part : même L2, même MAC sur le fil, une interface de moins qu'avec un ``ipvlan``. Elle doit -survivre aux renouvellements DHCP de l'adresse principale : la déclarer dans la configuration -réseau du système, pas la poser à la main. - .. note:: - **Non vérifié sur l'image de production.** Avec NetworkManager, la forme attendue est : + **À rédiger.** À documenter : - .. code-block:: bash - - nmcli connection modify +ipv4.addresses 169.254.0.3/28 - nmcli connection add type dummy ifname lo1 con-name lo1 \ - ipv4.method manual ipv4.addresses 10.255.255.1/32 ipv6.method disabled - - Ces commandes restent à valider sur l'image golden. - -Configuration de FRR --------------------- - -``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. - -.. literalinclude:: ../../test/e2e/topologies/frr/rr1.conf - :language: text - -Ce qu'elle établit : - -* **Routeurs de cluster** (groupe ``CLUSTER``) : eBGP vers l'AS 65100, avec BFD, en IPv4 - unicast. Le route reflector n'annonce que sa loopback (``RR-LOOPBACK-OUT``) et **n'accepte - rien** (``NO-IN``) : il ne reçoit aucune route des routeurs. -* **Hyperviseurs** (groupe ``fabric``) : voisins dynamiques, toute session venant de - ``192.168.14.0/24`` étant acceptée, jusqu'à 200. ``local-as 64600 no-prepend replace-as`` fait - que, du point de vue des hyperviseurs, la session est en iBGP dans l'AS 64600 — celui de leur - configuration — alors que le route reflector est en AS 65000 face aux routeurs. -* **EVPN** : seule famille activée vers les hyperviseurs, qui sont ses clients - (``route-reflector-client``) : il réfléchit les routes EVPN de chacun vers tous les autres. - -.. warning:: - - **Les tunnels ne survivent pas à la perte du route reflector.** Avec un seul route reflector - et sans ``graceful-restart``, à l'arrêt de FRR sur le route reflector, la session EVPN des - hyperviseurs tombe aussitôt, FRR retire les routes apprises et, avec elles, le VTEP distant et l'entrée d'inondation du VXLAN — **plus - aucun paquet ne passe** entre hyperviseurs, à 30 s comme à 90 s. Au redémarrage de FRR sur le - route reflector, VTEP distant et trafic reviennent **31 s** plus tard. Le trafic entre VM d'un - même hyperviseur n'est pas concerné. La redondance (deux route reflectors) ou - ``graceful-restart`` sont les deux leviers ; ni l'un ni l'autre n'est encore qualifié. - -.. note:: - - **À rédiger.** Reste à documenter : - - * la création de la VM : ressources, subnet et mode utilisés (``bridge``, pour pouvoir la - lancer sur n'importe quel hyperviseur), et s'il s'agit d'une VM créée par l'agent comme les - autres ou d'un cas particulier — l'image, elle, est l'image golden de :doc:`image-qcow2` ; + * la 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 ; * la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ; - * la procédure de reconstruction — l'état du cluster pendant l'absence du route reflector est - décrit ci-dessus : plus de trafic entre hyperviseurs ; + * la procédure 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 d'amorçage : ce qui fonctionne, et dans quel ordre, quand on démarre un cluster entier depuis zéro — le premier hyperviseur n'a pas de session FRR établie tant que cette VM n'existe pas, cf. :doc:`premier-hyperviseur`. diff --git a/docs/deploiement/routeurs.rst b/docs/deploiement/routeurs.rst index f8e8dbf..76ec2e7 100644 --- a/docs/deploiement/routeurs.rst +++ b/docs/deploiement/routeurs.rst @@ -15,66 +15,16 @@ Routeurs de datacentre Routeurs de bordure terminent le routage vers l'extérieur. -Routeur de cluster ------------------- - -Le routeur de cluster est la passerelle des hyperviseurs et le voisin eBGP du route reflector, dont -il apprend la loopback : c'est par lui que chaque hyperviseur joint ``10.255.255.1`` pour ouvrir sa -session EVPN (:doc:`route-reflector`). - -Adressage -~~~~~~~~~ - -.. list-table:: - :header-rows: 1 - :widths: 30 25 45 - - * - Adresse - - Interface - - Rôle - * - ``192.168.14.1/24`` - - interface du segment des hyperviseurs - - passerelle par défaut des hyperviseurs et du route reflector - * - ``169.254.0.1/28`` - - même interface, **adresse secondaire** - - lien avec le route reflector (``169.254.0.3``) ; ``router-id`` ; le second routeur prend - ``169.254.0.2`` - -Le MTU minimal du segment, imposé par VXLAN, est donné plus bas. - -Configuration de FRR -~~~~~~~~~~~~~~~~~~~~ - -``bgpd`` et ``bfdd`` activés dans ``/etc/frr/daemons``. - -.. literalinclude:: ../../test/e2e/topologies/frr/sw1.conf - :language: text - -Ce qu'elle établit : - -* **Route reflector** : eBGP de l'AS 65100 vers l'AS 65000, avec BFD, en IPv4 unicast. -* **En entrée**, seule la loopback du route reflector est acceptée (``RR-IN``) ; **en sortie**, - rien n'est annoncé (``NO-OUT``). Symétrique de ``RR-LOOPBACK-OUT`` et ``NO-IN`` côté route - reflector : chaque côté filtre, aucun ne dépend du filtre de l'autre. -* **Aucune session avec les hyperviseurs** : le routeur leur sert de passerelle, pas de voisin BGP. - -Le route reflector déclare aussi le second routeur (``169.254.0.2``). La redondance est l'objet de -`#54 `_. - .. note:: - Ce qui précède est le contrat du routeur de cluster : adresses, ASN, session, filtres. Sa - traduction dans la configuration d'un constructeur n'a pas sa place ici. + **À rédiger.** Cette page attend les éléments de terrain. Pour chacun des trois niveaux : -.. note:: - - **À rédiger.** Reste à documenter, faute d'éléments de terrain : - - * pour le routeur de cluster : le matériel employé et sa version de référence ; la redondance - — deux routeurs, mécanisme de bascule, comportement attendu pendant une bascule - (`#54 `_) ; - * pour les routeurs de datacentre et de bordure : tout — équipement, configuration de - référence, ce qui est annoncé et filtré à chaque niveau, redondance ; + * le matériel ou le logiciel employé, et la version de référence ; + * la configuration de référence : interfaces, adressage, protocole de routage et numéros + d'AS ; + * la redondance : combien d'équipements, quel mécanisme de bascule, quel comportement attendu + pendant une bascule ; + * ce qui est annoncé et ce qui est filtré à chaque niveau ; * l'ordre de mise en service, et ce qui doit être opérationnel avant de préparer le premier hyperviseur. diff --git a/docs/developpement/index.rst b/docs/developpement/index.rst deleted file mode 100644 index 2d78704..0000000 --- a/docs/developpement/index.rst +++ /dev/null @@ -1,10 +0,0 @@ -Développement -============= - -Outillage réservé au développement de two : il ne s'installe sur aucun hyperviseur et ne sert -pas à exploiter un cluster. - -.. toctree:: - :maxdepth: 1 - - lab diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst deleted file mode 100644 index 6e01936..0000000 --- a/docs/developpement/lab.rst +++ /dev/null @@ -1,899 +0,0 @@ -Lab de test multi-nœud -====================== - -Ce qui fait l'intérêt de two ne se voit qu'à partir de **deux hyperviseurs** : sur un nœud isolé, -le trafic reste sur le bridge local et l'absence de plan de contrôle passe inaperçue (voir -:doc:`/deploiement/architecture-cluster`). Le lab reproduit la topologie du cluster — -hyperviseurs, route reflector, switch L3 — sous forme de VM, sur un serveur physique loué à -l'heure. - -Le pourquoi des choix (serveur physique plutôt que VM cloud, câbles QEMU, MTU 9000, versions) est -consigné sur le ticket `#50 `_. Cette page décrit -comment s'en servir. - -.. note:: - - État actuel : le lab de #50 est livré — serveur loué à l'heure (``scripts/lab-host.sh``), - topologie déclarative et plan déterministe (``lab plan``), VM rendues et lancées sur le - serveur (``lab render``, ``lab up`` / ``status`` / ``down`` / ``ssh``), rôles installés au - démarrage (FRR sur le switch et le route reflector, two par ``deploy.sh`` sur les - hyperviseurs) et scénarios versionnés (``test/e2e/run.sh``). La redondance du plan de - contrôle — deux route reflectors, deux switchs — est l'objet de - `#54 `_. - -Le serveur de lab ------------------ - -Un serveur **Scaleway Elastic Metal**, créé pour une campagne de tests puis supprimé. - -.. list-table:: - :widths: 25 75 - - * - Offre - - ``EM-B212X-SSD``, zone ``fr-par-1``, **facturation horaire** : 0,321 € HT de l'heure, sans - frais de mise en service - * - Matériel - - 2 × Xeon E5-2620 v4 *or equivalent*, 256 Go, 2 × 1 To SSD - * - Système - - Debian 12, installé par Scaleway à la création - * - Pourquoi Intel - - lab3 est en Intel : two lance ses VM en ``-cpu host``, et KVM a deux implémentations - distinctes (``kvm_intel``, ``kvm_amd``) - -*Or equivalent* n'est pas une clause de style : le premier serveur livré était un -**Xeon E5-2640 v3** (Haswell, la génération de lab3), pas le E5-2620 v4 annoncé. Relever -``lscpu`` au début de chaque campagne. - -Prérequis côté Scaleway ------------------------ - -#. **Un projet dédié au lab**, séparé de toute autre ressource. ``lab-host.sh down`` supprime - tout serveur de lab du projet : il ne doit rien y avoir d'autre. -#. **Une clé d'API limitée à ce projet**, avec les droits Elastic Metal et la lecture des clés SSH - du projet. Rien d'autre. -#. **Les clés SSH publiques enregistrées dans le projet**, injectées à l'installation : sans elle, - le serveur serait facturé sans que personne puisse s'y connecter, et ``plan`` refuse de - continuer. -#. **Le quota Elastic Metal.** L'``EM-B212X-SSD`` exige un compte dont le moyen de paiement *et* - l'identité sont validés ; le quota est alors de 2. Vérifier dans la console : Organisation → - Quotas → Elastic Metal. Voir `les quotas Scaleway - `_. - -Fichiers locaux ---------------- - -Tout ce dont le script a besoin vit sous ``~/.config/two-lab/``, hors du dépôt. - -``~/.config/two-lab/scaleway.env`` - Identifiants Scaleway, une ligne ``CLÉ=valeur`` chacun : - - .. code-block:: bash - - SCW_SECRET_KEY= - SCW_DEFAULT_PROJECT_ID= - SCW_DEFAULT_ZONE=fr-par-1 - - * le fichier doit être en ``0600`` : le script refuse de s'en servir s'il est lisible par - d'autres que son propriétaire ; - * il est **lu, jamais exécuté** — pas de ``source`` ; seules ces trois clés sont reconnues ; - * une variable d'environnement du même nom l'emporte sur le fichier ; - * la clé d'accès (``SCW…``) n'est pas nécessaire : l'API REST n'authentifie que par la clé - secrète, dans l'en-tête ``X-Auth-Token``. - - Pour changer de clé, remplacer la ligne ``SCW_SECRET_KEY=`` ; rien d'autre à modifier. - -``~/.config/two-lab/ssh/lab_ed25519`` - Clé SSH dédiée au lab, **sans phrase de passe**, pour que les sessions tournent sans - intervention. Sa partie publique doit être enregistrée dans le projet. Quand elle existe, le - script l'utilise **seule** (``IdentitiesOnly``, agent désactivé) ; sinon il retombe sur - l'agent SSH. - - Elle ne doit ouvrir que les serveurs éphémères du projet de lab : **ne jamais l'installer sur - lab3 ni sur une machine durable**. En cas de doute, la retirer du projet et en générer une - autre : - - .. code-block:: bash - - mkdir -p ~/.config/two-lab/ssh && chmod 700 ~/.config/two-lab ~/.config/two-lab/ssh - ssh-keygen -t ed25519 -N '' -C two-lab-automation -f ~/.config/two-lab/ssh/lab_ed25519 - -``~/.cache/two-lab/`` - État de la session en cours : adresse et utilisateur du serveur, ``known_hosts`` dédié. Vidé - par ``down``. - -Commandes ---------- - -.. code-block:: text - - usage: lab-host.sh [arguments] - - plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création - et le prix ; ne crée rien - up crée le serveur de lab, attend la fin de son installation et son SSH, - puis le prépare (voir prepare) - status liste les serveurs de lab du projet - ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé - que si l'entrée standard en est un - prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie - /dev/kvm et la virtualisation imbriquée ; lancé aussi par up - push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le - répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle - référence) ; ensuite : ssh './lab up topology/' - down supprime tous les serveurs de lab du projet et attend leur disparition - session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive - -``plan`` et ``status`` sont **gratuits** ; ``up`` et ``session`` **créent un serveur facturé**. - -Toujours commencer par ``plan``. Il valide la clé d'API, le quota d'offre, l'OS et les clés SSH, -et montre exactement ce qui serait commandé : - -.. code-block:: text - - $ scripts/lab-host.sh plan - == offre : EM-B212X-SSD (ddaf8ba6-b2b2-4279-8af3-51930fb602f8), facturation hourly, stock available - == prix : 0.321 EUR HT par heure, frais de mise en service 0 EUR - == os : Debian 12 (Bookworm) (83640d93-a0b8-45ad-9c9f-30cae48380a4), utilisateur root - == clés : 2 clé(s) SSH du projet - == requête : POST /baremetal/v1/zones/fr-par-1/servers - -``session`` est la forme normale d'usage : le serveur est supprimé à la fin, que la commande -réussisse, échoue, ou que la session soit interrompue (Ctrl-C, ``TERM``, fermeture du terminal). - -.. code-block:: text - - $ scripts/lab-host.sh session 'uname -a; lscpu | grep -E "Model name|^CPU\(s\)|Virtualization"; free -g | head -2; echo "nested=$(cat /sys/module/kvm_intel/parameters/nested)"; ls -l /dev/kvm' - == création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT) - == serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 créé, facturé jusqu'à 'lab-host.sh down' - == serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ordered, installation to_install - == serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation installing - … - == serveur 2ecc1e6a-0de8-48c0-a198-93857eee5957 : ready, installation completed - == SSH pas encore joignable, nouvel essai dans 20s - … - == prêt : root@ - Linux two-lab 6.1.0-53-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.187-1 (2026-09-07) x86_64 GNU/Linux - CPU(s): 32 - Model name: Intel(R) Xeon(R) CPU E5-2640 v3 @ 2.60GHz - … - Virtualization: VT-x - total used free shared buff/cache available - Mem: 251 1 250 0 0 249 - nested=Y - crw-rw---- 1 root kvm 10, 232 Oct 3 17:23 /dev/kvm - == session terminée (code 0), suppression du serveur - == suppression de 2ecc1e6a-0de8-48c0-a198-93857eee5957 - == aucun serveur de lab ne reste dans le projet - -Extrait de la première campagne (lignes répétées remplacées par ``…``). Compter **environ 15 minutes** entre la création et le SSH disponible : 13 min 30 à la première -campagne, suppression comprise. SSH ne répond pas tout de suite après la fin de l'installation — -environ 100 secondes la première fois — d'où l'attente intégrée à ``up``. Le code de sortie de -``session`` est celui de la commande distante. - -``up``, ``ssh`` et ``down`` séparément servent au debug interactif — et laissent la suppression -à la charge de l'utilisateur. - -Une campagne sur le lab enchaîne ces commandes depuis le Mac ; ``lab`` s'exécute sur le serveur -(voir `Lancement des VM`_) : - -.. code-block:: text - - scripts/lab-host.sh up - scripts/lab-host.sh push test/e2e/topologies/evpn-2hv.yml - scripts/lab-host.sh ssh './lab up topology/evpn-2hv.yml' - scripts/lab-host.sh ssh './lab ssh hv1' # shell interactif sur hv1 - scripts/lab-host.sh ssh './lab ssh hv1 ip -br a' # commande, code de retour propagé - scripts/lab-host.sh down - -``push`` transfère par la connexion SSH du script — mêmes options, même clé, même -``known_hosts`` que ``ssh`` : le binaire par ``cat``, le répertoire de la topologie par ``tar`` -(sans les métadonnées macOS), chacun renommé une fois complet. Tout le répertoire part, pour que -les fichiers que la topologie référence (``frr/*.conf``) arrivent avec elle. - -Topologie ---------- - -Un lab est décrit par un fichier YAML : des **nœuds** (les VM) et des **segments** (des réseaux L2 -portés par un switch). Exemple livré, ``test/e2e/topologies/evpn-2hv.yml`` : - -.. literalinclude:: ../../test/e2e/topologies/evpn-2hv.yml - :language: yaml - -Chaque nœud non-switch est relié au switch de chacun de ses segments par un câble virtuel QEMU ; -le switch met ces câbles dans un bridge et porte la passerelle du segment. - -Ce que le fichier déclare : - -``images`` - ``url`` de l'image qcow2 et ``sums`` du fichier de sommes à vérifier, tous deux en ``https://``. - -``segments`` - ``switch`` (un nœud de rôle ``switch``), ``cidr`` IPv4 entre ``/8`` et ``/30``, ``mtu`` - facultatif — 9000 par défaut, entre 1280 et 9000. Nom : 12 caractères au plus, minuscules et - chiffres, parce qu'il devient le nom d'interface dans les VM et, préfixé de ``br-``, celui du - bridge (15 caractères au plus sous Linux). - -``nodes`` - ``role`` (``switch``, ``rr`` ou ``hypervisor``), ``image``, ``cpus``, ``memory`` en Mio - (256 au moins), ``segments`` auxquels le nœud est relié, et ``addresses`` pour fixer - l'adresse d'un nœud sur un segment (``addresses: {underlay: 192.168.14.50}``). Un switch ne - déclare ni ``segments`` ni ``addresses`` : il porte ceux dont il est le ``switch``. - - Champs de rôle, facultatifs : - - * ``secondary`` — des adresses supplémentaires par segment, avec leur longueur de préfixe - (``secondary: {underlay: [169.254.0.3/28]}``), posées sur la même interface que l'adresse - principale : même L2, même MAC. Elles doivent être **hors** du CIDR du segment, pour ne - jamais croiser l'attribution automatique. Sur un switch, elles vont sur le bridge du - segment ; - * ``loopback`` — une adresse sur une interface ``dummy`` nommée ``lo1`` - (``loopback: 10.255.255.1/32``) ; - * ``frr`` — le chemin d'un ``frr.conf``, relatif au fichier de topologie : FRR est installé - au démarrage et la configuration déposée **telle quelle** (voir `Rôles`_). - * ``release`` — **obligatoire pour un hyperviseur**, refusé ailleurs : le tag de la release de - two que ``deploy.sh`` installe (``release: 0.2.0rc003``). Sans lui, ``deploy.sh`` prendrait la - dernière release, et le lab ne serait plus reproductible. - * ``agent`` — pour un hyperviseur seulement : le chemin d'un ``agent.yml``, relatif au fichier - de topologie, déposé dans ``/etc/two/agent.yml`` avant ``deploy.sh``. Sans lui, l'agent tourne - avec sa configuration par défaut. L'exemple met hv1 sur le serveur DHCP intégré - (``test/e2e/topologies/agent/two.yml`` : ``dhcp.backend: two``) et laisse hv2 sur dnsmasq. - - ``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms. - -Ce que l'outil en déduit, de façon déterministe — même fichier, même plan : - -.. list-table:: - :widths: 30 70 - - * - Passerelle d'un segment - - la première adresse du CIDR, portée par le switch sur ``br-`` - * - Adresse d'un nœud - - les suivantes, **dans l'ordre de déclaration des nœuds** ; une adresse fixée par - ``addresses`` est réservée d'abord et sautée par l'attribution automatique - * - Câbles - - un par couple (segment, nœud), segments puis nœuds dans l'ordre de déclaration ; le - câble *i* utilise les ports UDP ``20000 + 2i`` (côté nœud) et ``20001 + 2i`` (côté switch) - * - MAC - - ``02:4c::::`` — préfixe localement administré, rang du nœud - sur deux octets, rang du segment, ``00`` côté nœud et ``01`` côté switch - * - Interfaces - - côté nœud, le nom du segment ; côté switch, ``p``, du rang du câble - * - SSH d'administration - - ``127.0.0.1:<2200 + rang du nœud>`` sur l'hôte du lab - -.. warning:: - - Réordonner les nœuds ou les segments dans le fichier **change les adresses, les MAC et les - ports**. C'est assumé pour un lab ; ``lab plan`` montre le résultat avant tout lancement. - -Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le permet (22 768). - -``lab plan`` valide le fichier et affiche le plan, sans rien lancer : - -.. code-block:: text - - $ go run ./cmd/lab plan test/e2e/topologies/evpn-2hv.yml - lab evpn-2hv: nodes 4, segments 1, cables 3 - - nodes - name role image cpus memory ssh - sw1 switch debian12 2 1024 MiB 127.0.0.1:2200 - rr1 rr debian12 1 1024 MiB 127.0.0.1:2201 - hv1 hypervisor debian12 4 16384 MiB 127.0.0.1:2202 - hv2 hypervisor debian12 4 16384 MiB 127.0.0.1:2203 - - roles - name loopback secondary frr release agent - sw1 - underlay 169.254.0.1/28 sw1.conf - - - rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf - - - hv1 - - hv1.conf 0.2.0rc003 two.yml - hv2 - - hv2.conf 0.2.0rc003 - - - segment underlay: 192.168.14.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 192.168.14.1 - node interface address mac udp switch port mac udp - rr1 underlay 192.168.14.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001 - hv1 underlay 192.168.14.11/24 02:4c:00:02:00:00 20002 <-> sw1 p1 02:4c:00:02:00:01 20003 - hv2 underlay 192.168.14.12/24 02:4c:00:03:00:00 20004 <-> sw1 p2 02:4c:00:03:00:01 20005 - -Un fichier invalide est refusé avec **toutes** ses erreurs à la fois, et un code de sortie 1. Les -champs inconnus et les clés en double sont refusés aussi : - -.. code-block:: text - - $ lab plan cassee.yml - lab: cassee.yml: - segment underlay: rr1 is a rr, not a switch - segment underlay: cidr 10.250.0.0/31 prefix length out of range [/8, /30] - node sw1: switch carries no segment - -Les ASN, la loopback du route reflector, le lien ``169.254.0.0/28`` et le subnet des hyperviseurs -de l'exemple sont **ceux de la production** (décision du 2026-10-04, #50) : les fichiers de -``test/e2e/topologies/`` restent ainsi au plus près de ce qui tourne réellement. Toutes les adresses y sont -**fixées** par ``addresses`` — le route reflector en ``.2``, les hyperviseurs à partir de ``.11`` — -pour que le modèle se lise sans le plan et ne dépende pas de l'ordre de déclaration : le -``frr.conf`` d'un hyperviseur, écrit à la main, porte son adresse en ``router-id``. Seul le switch -n'en déclare pas : il porte toujours la passerelle, la première adresse du segment. - -Rôles -~~~~~ - -Les configurations FRR du lab vivent dans ``test/e2e/topologies/frr/``, une par nœud, **écrites à la main** : -ce sont les mêmes fichiers que la documentation de déploiement inclut, pour que le lab qualifie -exactement ce qu'elle prescrit. Celle du route reflector : - -.. literalinclude:: ../../test/e2e/topologies/frr/rr1.conf - :language: text - -Au premier démarrage, cloud-init installe FRR (``frr-stable`` de ``deb.frrouting.org``, sans les -paquets recommandés), active ``bgpd`` — et ``bfdd`` sur le switch et le route reflector —, puis -dépose le ``frr.conf`` du nœud et redémarre FRR. La mise à jour des index de paquets est réessayée -pendant cinq minutes : un nœud peut démarrer avant que le switch, par lequel il sort, n'ait posé -son NAT. - -La **clé du dépôt FRR** n'est pas téléchargée au démarrage : elle est enregistrée dans ``lab`` -(``internal/lab/render/frrouting.gpg``) et déposée par cloud-init. Elle a été récupérée le -2026-10-04 sur ``deb.frrouting.org`` ; les empreintes de ses clés primaires sont publiées sous la -même valeur sur ``keys.openpgp.org`` et ``keyserver.ubuntu.com`` : - -.. code-block:: text - - 3D99 68AC 9AE7 BE11 6928 8DDB 1FD5 8398 95F5 7FDA David Lamparter - 4A56 C773 8BB3 F815 95A8 05D2 A832 7699 08F1 3ED1 FRRouting Debian Repository - A90F C36D 9429 4097 98E9 C2D8 74DE ED43 AB19 4DBF Jafar Al-Gharaibeh - -Une clé renouvelée par FRR fera échouer l'installation (signature inconnue) : remplacer le fichier -après avoir vérifié les nouvelles empreintes. - -**Hyperviseurs.** Ils se déploient comme en production, par ``deploy.sh`` — celui **du dépôt**, -embarqué dans ``lab`` avec ``bootstrap_kvm.sh`` (paquet ``scripts``) et déposé dans -``/opt/two/scripts/``, où ``deploy.sh`` cherche d'abord ``bootstrap_kvm.sh`` : - -.. code-block:: text - - deploy.sh --noup_script -i -u -t - -``--noup_script`` empêche l'auto-mise à jour de remplacer le script par celui de ``main`` : le lab -teste les scripts de sa branche. L'uplink ``-u`` est l'interface qui porte la route par défaut — -celle du premier segment de l'hyperviseur dans l'ordre de déclaration des segments — parce que -``deploy.sh`` y lit l'adresse et la passerelle qu'il déplace sur ``br-000000``. ``deploy.sh`` -télécharge la release sur ``git.g3e.fr`` sans réessayer : le lancement attend d'abord que le serveur -réponde, à travers le switch. FRR est installé **après** : il démarre sur le réseau final. - -**Un seul script de provisionnement par nœud.** cloud-init exécute ``runcmd`` comme un script -``sh`` sans ``set -e`` : seule la dernière commande compte, et un ``deploy.sh`` en échec suivi d'un -FRR installé avec succès passerait pour un démarrage réussi. Chaque nœud reçoit donc -``/usr/local/sbin/lab-provision``, en ``set -eu``, qui enchaîne ses étapes ; ``runcmd`` n'appelle -que lui, et la première étape en échec met cloud-init en erreur. - -**Ce que vérifie** ``lab up``, une fois cloud-init terminé sans erreur : ``agent.service`` actif -sur chaque hyperviseur, ``frr`` actif sur chaque nœud qui en a un. Ce contrôle couvre ce que -cloud-init ne voit pas — si la migration réseau échoue, ``deploy.sh`` arme un redémarrage de -secours, et la VM redémarrée ne rejoue pas ``runcmd``. - -.. warning:: - - **Un hyperviseur du lab ne survit pas à un redémarrage.** En production, la racine est en - tmpfs et ``deploy.sh --bootstrap`` est rejoué à chaque démarrage ; dans le lab, ``-i`` n'est - exécuté qu'au premier, et la migration réseau, qui n'est pas persistée, est perdue. Recréer le - lab : ``lab down`` puis ``lab up``. - -Vérifié le 2026-10-04 sur le serveur de lab, topologie ``evpn-2hv``, release ``0.2.0rc002`` : - -.. code-block:: text - - $ scripts/lab-host.sh ssh './lab up -timeout 25m topology/evpn-2hv.yml' - sw1: started - rr1: started - hv1: started - hv2: started - sw1: ready - rr1: ready - hv1: ready - hv2: ready - -.. list-table:: - :header-rows: 1 - :widths: 55 45 - - * - Vérification - - Résultat - * - ``lab up`` complet : FRR, ``deploy.sh`` et vérification des services de chaque rôle - - 6 min 15 - * - session switch ↔ route reflector, IPv4 unicast, BFD - - Established, BFD up ; le switch reçoit la seule loopback du route reflector - * - sessions EVPN des deux hyperviseurs vers la loopback du route reflector - - Established, voisins dynamiques, stables - * - réseau d'un hyperviseur après ``deploy.sh`` - - adresse sur ``br-000000``, MTU 9000, API de l'agent qui répond - * - VPC, subnet ``vxlan`` et VM Debian ``genericcloud`` créés par l'API de hv1 - - ``login:`` en 20 s, en KVM imbriqué - * - DHCP et routes (option 121) servis par two à la VM - - conformes, route ``/32`` vers ``169.254.169.254`` comprise - * - métadonnées, image configurée selon :doc:`/deploiement/image-qcow2` - - ``DataSourceNoCloudNet``, nom d'hôte appliqué — avec la barre oblique finale de - ``seedfrom`` (voir cette page) - * - VM ↔ VM entre les deux hyperviseurs, même subnet ``vxlan`` - - **échec** : les VXLAN de two n'ont pas d'adresse VTEP locale, rien n'est annoncé en EVPN — - `#51 `_ ; avec l'adresse posée, ping et MTU 1500 - passent - -.. note:: - - **Le switch du lab est un routeur Linux avec FRR, par choix.** Il joue le rôle générique de - routeur de cluster : passerelle des hyperviseurs, session eBGP avec BFD vers le route reflector, - dont il n'accepte que la loopback (``test/e2e/topologies/frr/sw1.conf``). L'équipement réel dépend de qui - déploie l'infrastructure (MikroTik aujourd'hui ; Cisco, Juniper, Arista… demain) : il n'a besoin - que de BGP et d'EVPN, et sa configuration propre au constructeur n'a pas sa place dans le lab. - -Rendu des VM ------------- - -``lab render`` produit, pour chaque nœud, ce qu'il faut pour démarrer sa VM — sans rien lancer : - -.. code-block:: text - - $ go run ./cmd/lab render -key ~/.config/two-lab/ssh/lab_ed25519.pub test/e2e/topologies/evpn-2hv.yml - -``//`` reçoit : - -``qemu.args`` - Les arguments de ``qemu-system-x86_64``, **un par ligne** : rien à échapper, rien à - interpréter par un shell. - -``meta-data``, ``user-data``, ``network-config`` - Les trois fichiers NoCloud de cloud-init, à mettre dans une image de volume ``cidata``. - -Les chemins de la VM (``disk.qcow2``, ``seed.iso``, ``console.log``, ``qmp.sock``, ``qemu.pid``) -sont ceux du répertoire du nœud ; ``-key`` peut être répété, et accepte un fichier -``authorized_keys`` (lignes vides et commentaires ignorés). Les fichiers sont créés en ``0600``. - -Ce que contiennent les arguments QEMU d'un hyperviseur — extrait réel, côté réseau : - -.. code-block:: text - - -netdev - user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22 - -device - virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile= - -netdev - dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003 - -device - virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile= - -Les choix qui s'y lisent : - -* **machine** ``q35``, ``-accel kvm -cpu host`` — le KVM imbriqué des hyperviseurs du lab en - dépend ; ``-nodefaults`` pour qu'aucun périphérique implicite ne s'ajoute ; -* **administration** (``mgmt0``) : le NAT de QEMU, MAC ``02:4d:::00:00``, SSH redirigé - sur la boucle locale de l'hôte. ``restrict=on`` pour tous les nœuds **sauf le switch** : un - nœud isolé ne joint ni l'hôte ni l'extérieur par là, seule la redirection SSH passe. - ``ipv6=off`` partout (voir plus bas) ; -* **câbles** : ``dgram`` sur ``127.0.0.1``, les deux extrémités d'un câble se répondent - (port local de l'une = port distant de l'autre), ``host_mtu`` annonce le MTU du segment au - guest ; -* ``romfile=`` vide sur toutes les cartes : pas de ROM de démarrage réseau, donc pas de repli - sur un démarrage PXE si le firmware ne trouve pas le disque. Pendant les essais de #50, une VM - restée bloquée sans rien écrire sur sa console, CPU au repos, avait toutes les apparences de - ce repli ; la cause n'a pas été isolée, l'option est une précaution. - -Ce que fait cloud-init : - -* **toutes les VM** : interfaces nommées d'après leur MAC (``mgmt0``, nom du segment, ``p``), - ``dhcp4: false`` partout, ``mgmt0`` en ``10.0.2.15/24`` **sans passerelle** ; connexion SSH par - clé seulement, utilisateur ``debian``, ``root`` désactivé, mot de passe refusé ; -* **un nœud** : adresse sur chaque segment, MTU du segment, route par défaut et DNS - (``1.1.1.1``, ``8.8.8.8``) sur son **premier** segment — la sortie Internet passe par le - switch ; -* **le switch** : route par défaut par ``mgmt0`` ; un service ``lab-switch`` crée un bridge - ``br-`` par segment (STP désactivé, MTU du segment), y branche ses ports, porte la - passerelle, active le routage et masque (NAT nftables) les segments vers ``mgmt0``. Le - service est rejoué à chaque démarrage. - -Vérifié sur de vraies VM -~~~~~~~~~~~~~~~~~~~~~~~~ - -Le switch et le route reflector n'ont pas besoin de KVM imbriqué : ``sw1`` et ``rr1`` de -l'exemple ont été démarrés **sur un Mac**, en émulation (TCG), avec Debian 12 ``generic`` et -les fichiers produits par ``lab render``. - -.. list-table:: - :header-rows: 1 - :widths: 60 40 - - * - Vérification - - Résultat - * - interfaces nommées et adressées, bridge ``br-underlay`` en ``10.250.0.1/24`` - - conforme - * - service ``lab-switch`` actif, y compris après redémarrage - - conforme - * - ``ping -M do -s 8972`` de ``rr1`` vers le switch (MTU 9000, sans fragmentation) - - passe - * - ``ping -M do -s 8973`` (MTU 9001) - - refusé : ``message too long, mtu=9000`` - * - Internet depuis ``rr1`` en IPv4 - - passe, par ``10.250.0.1`` - * - ``rr1`` vers un service TCP de l'hôte par ``mgmt0`` — le switch, témoin, y parvient - - bloqué - * - ``rr1`` vers Internet par ``mgmt0`` - - bloqué - -Un défaut trouvé par cet essai, et corrigé : sans ``ipv6=off``, le NAT de QEMU annonce un -préfixe IPv6 et ``mgmt0`` reçoit une **route IPv6 par défaut** — vers une impasse, puisque -``restrict=on`` bloque tout. Pas de fuite, mais chaque programme qui tente l'IPv6 d'abord (le DNS -renvoie d'abord des adresses IPv6) attend un délai avant de se rabattre sur l'IPv4. - -.. note:: - - Un ``ping`` vers ``10.0.2.2`` n'est pas un test d'isolation : c'est la passerelle virtuelle de - QEMU qui répond elle-même, ``restrict=on`` ou non. Seule une connexion vers un vrai service de - l'hôte, avec un témoin qui y parvient, le prouve. - -Reste à vérifier sur le serveur de lab : les hyperviseurs, qui exigent KVM imbriqué. - -Lancement des VM ----------------- - -``lab`` s'exécute **sur le serveur de lab**. Il garde l'état du lab dans un répertoire -(``-run``, par défaut ``~/lab-run``) : ``status``, ``down`` et ``ssh`` n'ont donc pas besoin de la -topologie. - -.. code-block:: text - - lab up [-run dir] [-cache dir] [-timeout 20m] - lab status [-run dir] - lab down [-run dir] - lab ssh [-run dir] [commande…] - -``lab up`` - 1. refuse de continuer si un lab tourne déjà dans le répertoire ; - 2. télécharge chaque image dans le cache (``-cache``, par défaut ``~/.cache/two-lab``) et la - vérifie contre ``SHA512SUMS`` ; une image déjà présente et toujours conforme n'est pas - retéléchargée, la liste des sommes est relue à chaque fois ; - 3. génère une paire de clés SSH dans le répertoire du lab, si elle n'existe pas encore ; - 4. pour chaque nœud : fichiers de ``lab render``, disque **neuf** en overlay qcow2 sur - l'image (``qemu-img create -b``, 20 Gio annoncés), image ``cidata`` (``genisoimage``) ; - 5. démarre les QEMU, **switchs d'abord**, détachés (``-daemonize``) : ils survivent à la - session SSH qui les a lancés ; - 6. attend sur chaque nœud la fin de cloud-init (``cloud-init status --wait`` par SSH), - jusqu'au délai ``-timeout``. - - La topologie est copiée dans ``/topology.yml``. Un échec laisse les nœuds démarrés en - place : ``lab status``, puis ``lab down``. - -``lab status`` - Pour chaque nœud : rôle, état du processus QEMU, PID, port SSH sur la boucle locale. - -``lab down`` - Arrête chaque QEMU par ``SIGTERM``, puis ``SIGKILL`` au bout de 30 s. Les disques sont - conservés jusqu'au prochain ``up``, qui les recrée. - -``lab ssh`` - Ouvre un shell sur un nœud, ou y exécute une commande, avec la clé générée par ``up``. ``lab`` - cède la place à ``ssh``, dont le code de retour est donc celui de la commande. Un terminal - n'est demandé (``-t``) que si l'entrée de ``lab`` en est un : depuis un script, ni - pseudo-terminal ni ``\r\n`` dans la sortie. - - Comme ``ssh``, ``lab ssh`` recolle ses arguments par des espaces et les confie à un shell - distant — et depuis le Mac, il y en a **deux** : celui du serveur, puis celui de la VM. - Une commande qui contient elle-même des guillemets se passe en une seule chaîne : - - .. code-block:: text - - $ echo | scripts/lab-host.sh ssh './lab ssh hv1 sh -c "exit 42"'; echo "rc 42=$?" - rc 42=0 - $ echo | scripts/lab-host.sh ssh "./lab ssh hv1 'sh -c \"exit 42\"'"; echo "rc 42=$?" - rc 42=42 - - Dans le premier cas, la VM reçoit ``sh -c exit 42`` : ``exit`` sans argument, ``42`` en - ``$0``. Pour plus d'une commande, passer un script sur l'entrée standard : - ``scripts/lab-host.sh ssh "./lab ssh hv1 'sudo bash -s'" < script.sh``. - -Un processus n'est tenu pour celui d'un nœud que si son PID, lu dans ``qemu.pid``, désigne un -processus vivant dont la ligne de commande (``/proc//cmdline``) contient ``-name ``. -Un PID réutilisé par un autre programme n'est donc jamais signalé. - -.. warning:: - - Le cache range une image sous son nom de fichier, et l'URL de Debian est ``latest`` : une - nouvelle publication remplace le fichier, et les overlays existants pointeraient sur une - base différente. ``up`` recrée toujours les disques, ce qui suffit avec un lab par serveur ; - **ne pas relancer un QEMU à la main** à partir d'un ``qemu.args`` après un ``up`` ultérieur. - -Une campagne réelle, de la création du serveur à la première commande sur une VM — sorties du -2026-10-04 : - -.. code-block:: text - - $ scripts/lab-host.sh up - == création de two-lab (EM-B212X-SSD, 0.321 EUR/h HT) - … - == préparation du serveur : qemu, genisoimage, KVM imbriqué - … - qemu QEMU emulator version 7.2.22 (Debian 1:7.2+dfsg-7+deb12u18+b3), nested=Y - == prêt : root@ - $ scripts/lab-host.sh push test/e2e/topologies/evpn-2hv.yml - == compilation de lab (linux/amd64) - == déposés sur le serveur : ~/lab, ~/evpn-2hv.yml — ensuite : lab-host.sh ssh './lab up evpn-2hv.yml' - $ scripts/lab-host.sh ssh './lab up evpn-2hv.yml' - sw1: started - rr1: started - hv1: started - hv2: started - sw1: ready - rr1: ready - hv1: ready - hv2: ready - $ scripts/lab-host.sh ssh './lab status' - node role state pid ssh - sw1 switch running 5158 127.0.0.1:2200 - rr1 rr running 5171 127.0.0.1:2201 - hv1 hypervisor running 5182 127.0.0.1:2202 - hv2 hypervisor running 5196 127.0.0.1:2203 - -``lab up`` a pris **49 secondes**, téléchargement et vérification de l'image (427 Mio) compris ; -l'essentiel du temps d'une campagne est la livraison du serveur (environ 25 minutes avec -``prepare``). - -Vérifié sur le serveur de lab -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -Le 2026-10-04, sur un Xeon E5-2640 v3, Debian 12 et QEMU 7.2 sur le serveur, topologie -``evpn-2hv`` : - -.. list-table:: - :header-rows: 1 - :widths: 60 40 - - * - Vérification - - Résultat - * - ``ping -M do -s 8972`` de hv1 à hv2 à travers le switch - - passe - * - ``ping -M do -s 8973`` - - refusé : ``message too long, mtu=9000`` - * - sortie Internet de hv1 - - par ``10.250.0.1`` (le switch), HTTPS 200 ; aucune route IPv6 globale - * - hv1 vers un service TCP du serveur par ``mgmt0`` — sw1, témoin, y parvient (200) - - refusé - * - hv1 vers Internet par ``mgmt0``, route forcée via ``10.0.2.2`` — sw1 y parvient - - refusé - * - ``/dev/kvm`` et ``nested`` dans hv1 - - présent, ``Y`` - * - racine de hv1 (overlay de 20 Gio) - - 20 Go : ``growpart`` agrandit la partition au premier démarrage - * - code de retour à travers ``lab-host.sh ssh`` et ``lab ssh``, sans terminal - - propagé jusqu'au Mac - * - ``scripts/lab-host.sh ssh './lab ssh hv1'`` depuis un terminal - - shell interactif - * - ``lab down`` puis ``lab up`` d'une autre topologie - - conforme - -Scénarios ---------- - -Les scénarios se lancent **depuis le Mac**, sur un lab démarré (``up``, ``push``, ``./lab up``) : - -.. code-block:: text - - test/e2e/run.sh s1 # un scénario - test/e2e/run.sh s1 s3 # plusieurs - test/e2e/run.sh all # tous, dans l'ordre - -Chacun affiche une ligne ``RÉUSSI`` ou ``ÉCHOUÉ`` par vérification — un échec porte la dernière -ligne de la commande en cause —, des lignes ``INFO`` pour les mesures, puis son bilan. Le code de -sortie vaut 1 si une vérification échoue **ou si aucune n'a été faite**. - -.. list-table:: - :header-rows: 1 - :widths: 22 58 20 - - * - Scénario - - Ce qui doit être vrai - - Hyperviseurs - * - ``s1-dhcp-two`` - - backend DHCP ``two``, une VPC et deux subnets : chaque VM reçoit l'adresse de **son** - subnet, démarrée seule ou en même temps qu'une autre ; le bail est tenu par - systemd-networkd ; route par défaut, route vers la VPC et ``/32`` vers les métadonnées - via ``interface_ip`` ; l'état de chaque serveur DHCP ne connaît que les MAC de son subnet - (#46) - - hv1 - * - ``s2-gateway`` - - route par défaut via ``interface_ip``, ou via ``gateway`` avec ``default_route`` ; route - vers la VPC toujours via ``interface_ip`` ; même résultat après recréation du subnet (#31) - - hv1 - * - ``s3-isolation-local`` - - deux VPC sur le même hyperviseur ne se joignent pas, en ICMP comme en TCP ; chaque VM - joint sa passerelle (témoin) - - hv1 - * - ``s4-evpn`` - - adresse VTEP locale sur les VXLAN (#51), VTEP distant appris par EVPN, ping VM ↔ VM - entre hyperviseurs, trame de 1472 octets en ``-M do``, 1473 refusés - - hv1, hv2 - * - ``s5-isolation-evpn`` - - deux VPC de même plage, VNI différentes, sur deux hyperviseurs : la VM joint celle de sa - VPC sur l'autre hyperviseur (témoin) et pas celle de l'autre VPC — aucune résolution ARP - - hv1, hv2 - * - ``s6-rr-loss`` - - FRR arrêté sur le route reflector, ping continu entre les VM de ``s4`` : la session tombe, - le trafic restant est **mesuré** (``INFO``) à 30 et 90 s ; au retour du route reflector, le - VTEP distant est réappris et le trafic repasse — délai mesuré - - hv1, hv2, rr1 - -``s6`` réutilise les VM de ``s4`` : le lancer après. - -**Comment c'est fait.** ``test/e2e/run.sh`` exécute chaque scénario sur le Mac ; un -scénario envoie des blocs de shell aux nœuds par ``on [VAR=valeur…] <<'NODE'``, précédés de -``test/e2e/lib/node.sh`` — appels à l'API de l'agent, attente des états, image Debian compatible two -(préparée une fois par hyperviseur, ``seedfrom`` avec barre oblique finale), clé SSH des VM, -``check`` et ``vm_fails``. Une vérification négative (« ne joint pas ») passe par ``vm_fails`` : -elle n'est réussie que si le SSH vers la VM a fonctionné **et** que la commande y a échoué — un -SSH en panne ne passe jamais pour une isolation. - -Résultats du 2026-10-04 sur le serveur de lab, release ``0.2.0rc003``, hv1 sur le DHCP intégré et -hv2 sur dnsmasq — toute la série en 8 minutes : - -.. code-block:: text - - $ test/e2e/run.sh all | grep -E '^(=== s[0-9].* : |INFO)' - INFO: MAC de sn-s1a : 00:22:33:00:00:0a 00:22:33:00:00:0b ; de sn-s1b : 00:22:33:00:00:0a 00:22:33:00:00:0b - === s1-dhcp-two : 37 réussi(s), 0 échoué(s) - === s2-gateway : 22 réussi(s), 0 échoué(s) - === s3-isolation-local : 12 réussi(s), 0 échoué(s) - === s4-evpn : 14 réussi(s), 0 échoué(s) - === s5-isolation-evpn : 13 réussi(s), 0 échoué(s) - INFO: 30 s après l'arrêt du route reflector : 0 réponses dans les 10 dernières secondes (50 si le trafic passe intégralement) - INFO: VTEP distant encore connu : 0 ; entrée d'inondation : 0 - INFO: 90 s après l'arrêt : 0 réponses dans les 10 dernières secondes - INFO: retour du VTEP distant et du trafic 31 s après le redémarrage de FRR - === s6-rr-loss : 8 réussi(s), 0 échoué(s) - -Dans ce run, le contrôle « uniquement les MAC de son subnet » de ``s1`` ne prouvait rien : two -dérive la MAC du rang de l'IP, et les VM ``.10``/``.11`` des deux subnets avaient les mêmes MAC. -Vérifié à la main sur le lab, chaque serveur DHCP ne connaissait que les couples MAC/IP de son -subnet ; le scénario compare désormais ces couples. - -Les VM sont accessibles depuis le netns de leur VPC, sur l'hyperviseur, avec l'utilisateur -``syonad`` créé par les métadonnées de two : - -.. code-block:: text - - scripts/lab-host.sh ssh "./lab ssh hv1 'sudo ip netns exec vp-s4 ssh -i /root/.ssh/lab-vm syonad@10.240.1.10'" - -Écrire un scénario -~~~~~~~~~~~~~~~~~~ - -Le lab sert à qualifier des comportements qui ne se voient qu'à plusieurs hyperviseurs — la -campagne L3VNI de `#41 `_ en est le prochain exemple. Un -scénario est un fichier ``test/e2e/scenarios/-.sh``, exécuté par ``scenario.sh`` sur le -Mac ; il envoie des blocs aux nœuds : - -.. code-block:: bash - - on hv1 <<'NODE' - two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } - KEY=$(vm_key) - check "VPC vp-x" vpc_create vp-x 10.250.0.0/16 - check "subnet sn-x" subnet_create sn-x vp-x 2601 10.250.1.1 10.250.1.0/24 - check "VM x1" vm_create x1 sn-x 10.250.1.10 "${KEY}" - check "x1 joignable" vm_wait vp-x 10.250.1.10 - check "x1 joint sa passerelle" vm_ssh vp-x 10.250.1.10 'ping -c 2 -W 2 10.250.1.1' - check "x1 ne joint pas 10.251.1.10" vm_fails vp-x 10.250.1.10 'ping -c 2 -W 2 10.251.1.10' - info "mesure : $(vtysh -c 'show evpn vni 2601' | grep -c 'flood')" - NODE - -Les règles qui ont fait leurs preuves en E5 : - -* **une vérification par ligne**, avec ``check`` — jamais un ``echo RÉUSSI`` écrit à la main ; -* **toute vérification négative a son témoin** : avant « ne joint pas », une ligne qui prouve que - la cible est vivante et que le chemin du test fonctionne ; -* **``vm_fails`` pour le négatif**, jamais ``!`` devant un ``vm_ssh`` : un SSH en panne doit - échouer, pas passer pour une isolation ; -* **une donnée qui distingue réellement les cas** : two dérivant la MAC du rang de l'IP, deux - subnets ont les mêmes MAC — comparer des couples MAC/IP, pas des MAC ; -* **les mesures en ``INFO``**, les attentes en ``check`` : un temps de reconvergence se mesure, - il ne se décrète pas ; -* chaque scénario crée ses propres VPC, plages et VNI, distinctes de celles des autres, pour que - ``all`` les enchaîne sur le même lab ; un scénario qui dépend d'un autre le vérifie en tête - (``check "prérequis : …"``) ; -* variables vers un nœud : ``on hv1 NOM=valeur <<'NODE'`` (valeurs échappées par - ``scenario.sh``) ; -* ``bash test/e2e/run_test.sh`` vérifie la syntaxe de chaque bloc réellement envoyé — à - lancer avant toute session. - -Facturation ------------ - -.. warning:: - - Un serveur Elastic Metal est facturé **de sa création à sa suppression, éteint compris**. - Éteindre ne suffit pas : il faut supprimer. La granularité n'est pas documentée par Scaleway — - compter chaque heure entamée comme une heure pleine. - -Ce que le script garantit : - -* il ne commande **jamais** d'offre mensuelle : il exige une seule offre au nom demandé, en - facturation horaire, en stock et sans frais de mise en service, sinon il refuse avant toute - création. La CLI ``scw`` n'est pas utilisée pour cette raison : son ``server create type=…`` - choisit l'offre par son seul nom et peut tomber sur la mensuelle, qui engage un mois ; -* il refuse de créer un second serveur si un serveur de lab existe déjà ; -* ``down`` agit sur **tous** les serveurs portant le tag ``two-lab`` dans le projet, et - ``session`` y ajoute l'identifiant reçu à la création : un serveur créé juste avant une - interruption est rattrapé ; -* une suppression refusée pendant la livraison ou l'installation est réessayée tant qu'elle dure, - dans la limite du délai d'installation augmenté du délai de suppression ; -* le serveur n'est déclaré supprimé qu'au 404 de l'API, jamais sur une erreur passagère ; -* un échec de suppression se termine par ``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)`` et un code - d'erreur. - -Ce qu'il ne peut pas garantir : un ``SIGKILL``, une coupure de courant ou une mise en veille du -poste qui lance la session. En cas de doute, toujours : - -.. code-block:: bash - - scripts/lab-host.sh status - scripts/lab-host.sh down - -Diagnostic ----------- - -``aucune clé SSH active dans le projet`` - Aucune clé SSH n'est enregistrée dans le projet de lab. En ajouter une dans la console (projet - → Clés SSH). - -``offre horaire EM-B212X-SSD : 0 correspondance(s)`` - L'offre n'existe pas dans la zone en facturation horaire. Vérifier ``SCW_DEFAULT_ZONE``. - -``création incertaine`` - La création a échoué ou n'a pas rendu d'identifiant. Le serveur a pu être créé malgré tout — - cas typique : le quota (identité non validée), ou une réponse perdue. Le script indique s'il - voit un serveur de lab ; dans tous les cas, lancer ``status`` puis ``down``. - -``SSH injoignable`` - L'installation est terminée mais SSH ne répond pas après 10 minutes. Avec une clé matérielle - (Yubikey), chaque connexion demande un PIN ou un toucher : utiliser la clé dédiée du lab. Le - serveur est toujours facturé : ``down``. - -``SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S)`` - La suppression n'a pas abouti dans les délais. Relancer ``down`` ; si l'erreur persiste, - supprimer depuis la console Scaleway. - -``HTTP 403 insufficient permissions`` - La clé d'API est authentifiée mais n'a pas le droit demandé — en général la lecture des clés - SSH du projet. Compléter la politique de la clé, limitée au projet. - -Sécurité --------- - -* La clé secrète n'apparaît ni dans les arguments des processus (elle est passée à ``curl`` par - un descripteur de fichier), ni dans les journaux, ni dans l'environnement de ``ssh``. -* **Ne jamais lancer le script sous** ``bash -x`` : la trace afficherait la clé. -* Le serveur n'expose que SSH, par clé. Le lab n'a aucune donnée personnelle ni secret de - production. -* Une clé secrète qui a circulé ailleurs que dans ``scaleway.env`` (conversation, terminal - partagé, capture d'écran) se régénère. -* **Clé SSH des VM** : générée par ``lab up`` sur le serveur, c'est la seule clé autorisée dans - les VM. Elle ne quitte jamais le serveur, n'ouvre que les VM du lab — qui n'écoutent qu'en - boucle locale — et disparaît avec lui. La clé publique du Mac n'est jamais envoyée aux VM. -* **Clés d'hôte des VM non vérifiées** par ``lab ssh`` (``known_hosts`` jetable) : elles changent - à chaque ``up``. Acceptable uniquement parce que la connexion reste sur la boucle locale d'un - serveur auquel on s'est authentifié. -* **Code exécuté sans épinglage par** ``deploy.sh`` : la bibliothèque ``shflags`` est récupérée - par ``curl`` sur la branche ``main`` d'un autre dépôt (``H6N/tools``) et exécutée par ``eval``, - sans vérification d'intégrité — dans le lab comme en production. Les artefacts de la release - sont, eux, vérifiés contre ``SHA256SUMS``. -* **Image** : ``SHA512SUMS`` vient de la même origine que l'image, en HTTPS. La vérification - protège contre la corruption, pas contre une origine compromise ; la signature GPG de Debian - (``SHA512SUMS.sign``) n'est pas encore vérifiée. - -Tests ------ - -.. code-block:: bash - - bash scripts/lab-host_test.sh - bash test/e2e/run_test.sh - go test ./internal/lab/... ./cmd/lab/ - -Environ une minute et demie, sans réseau : la suite remplace ``curl`` par une fausse API Scaleway -qui se place dans le pire cas (offre mensuelle listée avant l'horaire, serveurs d'autres projets, -suppressions refusées, erreurs 503, serveur qui tarde à disparaître) et ``ssh`` par un faux client. -Elle tourne sous bash 5 comme sous le bash 3.2 de macOS. diff --git a/docs/index.rst b/docs/index.rst index 8eba581..b72be04 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -40,9 +40,6 @@ 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. @@ -60,12 +57,6 @@ Par où commencer exploitation/index -.. toctree:: - :hidden: - :caption: Développement - - developpement/index - .. toctree:: :hidden: :caption: Interne diff --git a/docs/schemas/architecture-cluster-dark.svg b/docs/schemas/architecture-cluster-dark.svg deleted file mode 100644 index 483d0f0..0000000 --- a/docs/schemas/architecture-cluster-dark.svg +++ /dev/null @@ -1,155 +0,0 @@ - - - - - - - - - - ROUTEURS DE CLUSTER · AS 65100 - - routeur 1 - 169.254.0.1/28 - - routeur 2 - 169.254.0.2/28 - passerelle 192.168.14.1/24 - - - VM ROUTE REFLECTOR · AS 65000 - lo1 · 10.255.255.1/32 - router-id, cluster-id, source des sessions EVPN - 192.168.14.2/24 · 169.254.0.3/28 - principale, et secondaire pour le lien avec les routeurs - hébergée par le cluster lui-même - - - eBGP · BFD (×2) - annonce lo1 seule - n'accepte rien - - - - - - - - segment underlay · L2 - 192.168.14.0/24 · 169.254.0.0/28 - - - HYPERVISEUR 1 - - - br-000000 - uplink · 192.168.14.11 - - vxlan-<vni> - dans br-<subnet>, sans learning - - netns du VPC - passerelle, DHCP - - VM - QEMU / KVM - - VTEP - - veth - - tap - - - FRR - bgpd · AS 64600 - advertise-all-vni - - agent - API REST · Badger - - - FDB - - crée - - - HYPERVISEUR 2 - - - br-000000 - uplink · 192.168.14.12 - - vxlan-<vni> - dans br-<subnet>, sans learning - - netns du VPC - passerelle, DHCP - - VM - QEMU / KVM - - VTEP - - veth - - tap - - - FRR - bgpd · AS 64600 - advertise-all-vni - - agent - API REST · Badger - - - FDB - - crée - - - - - - - - iBGP l2vpn evpn · AS 64600 - iBGP - - - VXLAN - UDP 4789 - - - lien L2 / interface - - plan de données : tunnels VXLAN - - plan de contrôle : sessions BGP - - créé par l'agent - - Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. - Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs. - Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5). - MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab. - ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger. - diff --git a/docs/schemas/architecture-cluster.svg b/docs/schemas/architecture-cluster.svg deleted file mode 100644 index 25028d6..0000000 --- a/docs/schemas/architecture-cluster.svg +++ /dev/null @@ -1,155 +0,0 @@ - - - - - - - - - - ROUTEURS DE CLUSTER · AS 65100 - - routeur 1 - 169.254.0.1/28 - - routeur 2 - 169.254.0.2/28 - passerelle 192.168.14.1/24 - - - VM ROUTE REFLECTOR · AS 65000 - lo1 · 10.255.255.1/32 - router-id, cluster-id, source des sessions EVPN - 192.168.14.2/24 · 169.254.0.3/28 - principale, et secondaire pour le lien avec les routeurs - hébergée par le cluster lui-même - - - eBGP · BFD (×2) - annonce lo1 seule - n'accepte rien - - - - - - - - segment underlay · L2 - 192.168.14.0/24 · 169.254.0.0/28 - - - HYPERVISEUR 1 - - - br-000000 - uplink · 192.168.14.11 - - vxlan-<vni> - dans br-<subnet>, sans learning - - netns du VPC - passerelle, DHCP - - VM - QEMU / KVM - - VTEP - - veth - - tap - - - FRR - bgpd · AS 64600 - advertise-all-vni - - agent - API REST · Badger - - - FDB - - crée - - - HYPERVISEUR 2 - - - br-000000 - uplink · 192.168.14.12 - - vxlan-<vni> - dans br-<subnet>, sans learning - - netns du VPC - passerelle, DHCP - - VM - QEMU / KVM - - VTEP - - veth - - tap - - - FRR - bgpd · AS 64600 - advertise-all-vni - - agent - API REST · Badger - - - FDB - - crée - - - - - - - - iBGP l2vpn evpn · AS 64600 - iBGP - - - VXLAN - UDP 4789 - - - lien L2 / interface - - plan de données : tunnels VXLAN - - plan de contrôle : sessions BGP - - créé par l'agent - - Les hyperviseurs joignent 10.255.255.1 par leur passerelle 192.168.14.1 : les routeurs l'ont apprise du route reflector en eBGP, sans rien lui renvoyer. - Le route reflector accepte toute session venant de 192.168.14.0/24 (bgp listen range) et réfléchit les routes EVPN entre hyperviseurs. - Chaque FRR en peuple la FDB des VXLAN locaux — intégration de FRR avec les interfaces créées par two : à qualifier (#50, E5). - MTU du segment : 1550 au moins (VXLAN figé à 1500 par l'agent, + 50 octets d'encapsulation) ; 9000 dans le lab. - ASN, loopback et plages : valeurs de production (#50). Adresses en 192.168.14.0/24 : celles du lab. Placement de la VM route reflector : à rédiger. - diff --git a/docs/schemas/topologie-cluster-dark.svg b/docs/schemas/topologie-cluster-dark.svg new file mode 100644 index 0000000..48a3916 --- /dev/null +++ b/docs/schemas/topologie-cluster-dark.svg @@ -0,0 +1,52 @@ + + + + + ROUTEURS DE CLUSTER + + routeur A + + routeur B + + + VM route reflector + plan de contrôle + + + HYPERVISEUR 1 + + br-000000 + + FRR + + agent + + + + + HYPERVISEUR 2 + + br-000000 + + FRR + + agent + + + + + + uplink + uplink + + + + diff --git a/docs/schemas/topologie-cluster.svg b/docs/schemas/topologie-cluster.svg new file mode 100644 index 0000000..858d18a --- /dev/null +++ b/docs/schemas/topologie-cluster.svg @@ -0,0 +1,52 @@ + + + + + ROUTEURS DE CLUSTER + + routeur A + + routeur B + + + VM route reflector + plan de contrôle + + + HYPERVISEUR 1 + + br-000000 + + FRR + + agent + + + + + HYPERVISEUR 2 + + br-000000 + + FRR + + agent + + + + + + uplink + uplink + + + + diff --git a/go.mod b/go.mod index 9bcc86b..0e59eb9 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,6 @@ 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 ) @@ -47,6 +46,7 @@ require ( go.opentelemetry.io/otel/metric v1.37.0 // indirect go.opentelemetry.io/otel/trace v1.37.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/text v0.37.0 // indirect google.golang.org/protobuf v1.36.8 // indirect diff --git a/internal/lab/machine/machine.go b/internal/lab/machine/machine.go deleted file mode 100644 index 29e1312..0000000 --- a/internal/lab/machine/machine.go +++ /dev/null @@ -1,293 +0,0 @@ -package machine - -import ( - "bytes" - "context" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strconv" - "strings" - "syscall" - "text/tabwriter" - "time" - - "git.g3e.fr/syonad/two/internal/lab/provision" - "git.g3e.fr/syonad/two/internal/lab/render" - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const ( - User = "debian" - - sshUnreachable = 255 - cloudInitRecoverable = 2 -) - -type Lab struct { - Plan *topology.Plan - RunDir string - ProcDir string - Runner provision.Runner - Poll time.Duration - Stop time.Duration - Out io.Writer - - signal func(pid int, sig syscall.Signal) error -} - -func (l Lab) node(name string) (topology.NodePlan, error) { - for _, n := range l.Plan.Nodes { - if n.Name == name { - return n, nil - } - } - return topology.NodePlan{}, fmt.Errorf("node %q is not in lab %s", name, l.Plan.Name) -} - -func (l Lab) pidFile(name string) string { - return filepath.Join(l.RunDir, name, render.PIDFile) -} - -func (l Lab) PID(name string) (int, error) { - data, err := os.ReadFile(l.pidFile(name)) - if errors.Is(err, os.ErrNotExist) { - return 0, nil - } - if err != nil { - return 0, err - } - pid, err := strconv.Atoi(strings.TrimSpace(string(data))) - if err != nil || pid <= 0 { - return 0, fmt.Errorf("%s: not a pid: %q", l.pidFile(name), data) - } - if !l.runs(pid, name) { - return 0, nil - } - return pid, nil -} - -func (l Lab) runs(pid int, name string) bool { - if err := syscall.Kill(pid, 0); err != nil && !errors.Is(err, syscall.EPERM) { - return false - } - cmdline, err := os.ReadFile(filepath.Join(l.ProcDir, strconv.Itoa(pid), "cmdline")) - if err != nil { - return false - } - return bytes.Contains(cmdline, []byte("\x00-name\x00"+name+"\x00")) -} - -func (l Lab) Running() ([]string, error) { - var running []string - for _, n := range l.Plan.Nodes { - pid, err := l.PID(n.Name) - if err != nil { - return nil, err - } - if pid != 0 { - running = append(running, n.Name) - } - } - return running, nil -} - -func (l Lab) Up(ctx context.Context, fetcher provision.Fetcher, timeout time.Duration) error { - running, err := l.Running() - if err != nil { - return err - } - if len(running) > 0 { - return fmt.Errorf("lab %s is already running (%s): 'lab down' first", l.Plan.Name, strings.Join(running, ", ")) - } - - nodes, err := provision.Prepare(ctx, l.Plan, provision.Options{RunDir: l.RunDir, Fetcher: fetcher, Runner: l.Runner}) - if err != nil { - return err - } - for _, n := range switchesFirst(l.Plan, nodes) { - if err := os.Remove(l.pidFile(n.Name)); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - if err := l.Runner.Run(ctx, render.QEMUBinary, append(n.QEMU, "-daemonize")...); err != nil { - return fmt.Errorf("node %s: %w", n.Name, err) - } - fmt.Fprintf(l.Out, "%s: started\n", n.Name) - } - - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - var errs []error - for _, n := range l.Plan.Nodes { - if err := l.waitReady(ctx, n); err != nil { - errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err)) - continue - } - if err := l.checkServices(ctx, n); err != nil { - errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err)) - continue - } - fmt.Fprintf(l.Out, "%s: ready\n", n.Name) - } - return errors.Join(errs...) -} - -func switchesFirst(p *topology.Plan, nodes []render.Node) []render.Node { - var first, rest []render.Node - for i, n := range nodes { - if p.Nodes[i].Role == topology.RoleSwitch { - first = append(first, n) - } else { - rest = append(rest, n) - } - } - return append(first, rest...) -} - -func (l Lab) waitReady(ctx context.Context, n topology.NodePlan) error { - args := l.sshArgs(n, true, []string{"cloud-init", "status", "--wait"}) - for { - err := l.Runner.Run(ctx, "ssh", args...) - switch code := exitCode(err); { - case err == nil: - return nil - case code == cloudInitRecoverable: - fmt.Fprintf(l.Out, "%s: cloud-init finished with recoverable errors: %v\n", n.Name, err) - return nil - case code != sshUnreachable: - return fmt.Errorf("cloud-init failed: %w", err) - } - select { - case <-ctx.Done(): - return fmt.Errorf("not reachable over ssh: %w", ctx.Err()) - case <-time.After(l.Poll): - } - } -} - -func services(n topology.NodePlan) []string { - var units []string - if n.Role == topology.RoleHypervisor { - units = append(units, "agent.service") - } - if n.FRR != "" { - units = append(units, "frr.service") - } - return units -} - -func (l Lab) checkServices(ctx context.Context, n topology.NodePlan) error { - for _, unit := range services(n) { - if err := l.Runner.Run(ctx, "ssh", l.sshArgs(n, true, []string{"systemctl", "is-active", "--quiet", unit})...); err != nil { - return fmt.Errorf("%s is not active: %w", unit, err) - } - } - return nil -} - -func exitCode(err error) int { - var coded interface{ ExitCode() int } - if errors.As(err, &coded) { - return coded.ExitCode() - } - return -1 -} - -func (l Lab) sshArgs(n topology.NodePlan, batch bool, command []string) []string { - args := []string{ - "-i", filepath.Join(l.RunDir, provision.KeyFile), - "-o", "IdentitiesOnly=yes", - "-o", "IdentityAgent=none", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-p", strconv.Itoa(n.SSHPort), - } - if batch { - args = append(args, "-o", "BatchMode=yes", "-o", "ConnectTimeout=5") - } - args = append(args, User+"@127.0.0.1") - return append(args, command...) -} - -func (l Lab) SSH(name string, terminal bool, command []string) ([]string, error) { - n, err := l.node(name) - if err != nil { - return nil, err - } - args := []string{"ssh"} - if terminal && len(command) > 0 { - args = append(args, "-t") - } - return append(args, l.sshArgs(n, false, command)...), nil -} - -func (l Lab) Status(w io.Writer) error { - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - fmt.Fprintf(tw, "node\trole\tstate\tpid\tssh\n") - for _, n := range l.Plan.Nodes { - pid, err := l.PID(n.Name) - if err != nil { - return err - } - state, shown := "stopped", "-" - if pid != 0 { - state, shown = "running", strconv.Itoa(pid) - } - fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t127.0.0.1:%d\n", n.Name, n.Role, state, shown, n.SSHPort) - } - return tw.Flush() -} - -func (l Lab) Down(ctx context.Context) error { - var errs []error - for _, n := range l.Plan.Nodes { - pid, err := l.PID(n.Name) - if err != nil { - errs = append(errs, err) - continue - } - if pid == 0 { - continue - } - if err := l.stop(ctx, n.Name, pid); err != nil { - errs = append(errs, fmt.Errorf("node %s: %w", n.Name, err)) - continue - } - if err := os.Remove(l.pidFile(n.Name)); err != nil && !errors.Is(err, os.ErrNotExist) { - errs = append(errs, err) - continue - } - fmt.Fprintf(l.Out, "%s: stopped\n", n.Name) - } - return errors.Join(errs...) -} - -func (l Lab) stop(ctx context.Context, name string, pid int) error { - if pid <= 0 { - return fmt.Errorf("refusing to signal pid %d", pid) - } - send := l.signal - if send == nil { - send = syscall.Kill - } - for _, sig := range []syscall.Signal{syscall.SIGTERM, syscall.SIGKILL} { - if err := send(pid, sig); err != nil && !errors.Is(err, syscall.ESRCH) { - return err - } - deadline := time.Now().Add(l.Stop) - for l.runs(pid, name) && time.Now().Before(deadline) { - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(l.Poll): - } - } - if !l.runs(pid, name) { - return nil - } - } - return fmt.Errorf("pid %d still running after SIGKILL", pid) -} diff --git a/internal/lab/machine/machine_test.go b/internal/lab/machine/machine_test.go deleted file mode 100644 index 78e2ba4..0000000 --- a/internal/lab/machine/machine_test.go +++ /dev/null @@ -1,588 +0,0 @@ -package machine - -import ( - "bytes" - "context" - "crypto/sha512" - "encoding/hex" - "errors" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "path/filepath" - "reflect" - "strconv" - "strings" - "sync" - "syscall" - "testing" - "time" - - "git.g3e.fr/syonad/two/internal/lab/provision" - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const labKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab" - -type exitErr int - -func (e exitErr) Error() string { return "exit status " + strconv.Itoa(int(e)) } -func (e exitErr) ExitCode() int { return int(e) } - -type fakeRunner struct { - mu sync.Mutex - calls [][]string - fail string - ssh map[string][]error - always map[string]error - inactive string -} - -func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { - f.mu.Lock() - defer f.mu.Unlock() - f.calls = append(f.calls, append([]string{name}, args...)) - if name == f.fail { - return errors.New(name + " failed") - } - switch name { - case "ssh-keygen": - private := args[len(args)-1] - if err := os.WriteFile(private, []byte("private"), 0o600); err != nil { - return err - } - return os.WriteFile(private+".pub", []byte(labKey+"\n"), 0o644) - case "ssh": - port := args[indexOf(args, "-p")+1] - if args[len(args)-1] == f.inactive { - return exitErr(3) - } - answers := f.ssh[port] - if len(answers) == 0 { - return f.always[port] - } - f.ssh[port] = answers[1:] - return answers[0] - } - return nil -} - -func (f *fakeRunner) commands(name string) [][]string { - f.mu.Lock() - defer f.mu.Unlock() - var out [][]string - for _, c := range f.calls { - if c[0] == name { - out = append(out, c) - } - } - return out -} - -func indexOf(list []string, value string) int { - for i, v := range list { - if v == value { - return i - } - } - return -1 -} - -func mirror(t *testing.T) (*httptest.Server, string) { - t.Helper() - content := []byte("qcow2 image") - h := sha512.Sum512(content) - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, "SHA512SUMS") { - fmt.Fprintf(w, "%s deb.qcow2\n", hex.EncodeToString(h[:])) - return - } - w.Write(content) - })) - t.Cleanup(server.Close) - return server, server.URL -} - -func labPlan(t *testing.T, base, nodes string) *topology.Plan { - t.Helper() - doc := `name: evpn-2hv -images: - deb: - url: ` + base + `/deb.qcow2 - sums: ` + base + `/SHA512SUMS -segments: - underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 } -nodes: -` + nodes - topo, err := topology.Parse([]byte(doc)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - p, err := topology.Compute(topo) - if err != nil { - t.Fatalf("Compute: %v", err) - } - return p -} - -const switchLast = ` rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] } - hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } - sw1: { role: switch, image: deb, cpus: 2, memory: 1024 } -` - -type fixture struct { - lab Lab - runner *fakeRunner - out *bytes.Buffer - fetcher provision.Fetcher -} - -func newFixture(t *testing.T, nodes string) *fixture { - t.Helper() - server, base := mirror(t) - root := t.TempDir() - runner := &fakeRunner{ssh: map[string][]error{}, always: map[string]error{}} - out := &bytes.Buffer{} - return &fixture{ - lab: Lab{ - Plan: labPlan(t, base, nodes), - RunDir: filepath.Join(root, "run"), - ProcDir: filepath.Join(root, "proc"), - Runner: runner, - Poll: time.Millisecond, - Stop: 500 * time.Millisecond, - Out: out, - }, - runner: runner, - out: out, - fetcher: provision.Fetcher{Client: server.Client(), CacheDir: filepath.Join(root, "cache")}, - } -} - -func (f *fixture) process(t *testing.T, node, script string, cmdline ...string) int { - t.Helper() - cmd := exec.Command("sh", "-c", script) - if err := cmd.Start(); err != nil { - t.Fatal(err) - } - done := make(chan struct{}) - go func() { cmd.Wait(); close(done) }() - t.Cleanup(func() { - cmd.Process.Kill() - <-done - }) - pid := cmd.Process.Pid - proc := filepath.Join(f.lab.ProcDir, strconv.Itoa(pid)) - if err := os.MkdirAll(proc, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(proc, "cmdline"), []byte(strings.Join(cmdline, "\x00")+"\x00"), 0o644); err != nil { - t.Fatal(err) - } - f.pidFile(t, node, strconv.Itoa(pid)+"\n") - return pid -} - -func (f *fixture) pidFile(t *testing.T, node, content string) { - t.Helper() - dir := filepath.Join(f.lab.RunDir, node) - if err := os.MkdirAll(dir, 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "qemu.pid"), []byte(content), 0o600); err != nil { - t.Fatal(err) - } -} - -func qemu(node string) []string { - return []string{"qemu-system-x86_64", "-name", node, "-machine", "q35"} -} - -func alive(pid int) bool { - err := syscall.Kill(pid, 0) - return err == nil || errors.Is(err, syscall.EPERM) -} - -func waitDead(t *testing.T, pid int) { - t.Helper() - deadline := time.Now().Add(2 * time.Second) - for alive(pid) && time.Now().Before(deadline) { - time.Sleep(5 * time.Millisecond) - } - if alive(pid) { - t.Errorf("pid %d is still alive", pid) - } -} - -func TestPID_NoPidFileMeansStopped(t *testing.T) { - f := newFixture(t, switchLast) - if pid, err := f.lab.PID("hv1"); pid != 0 || err != nil { - t.Errorf("PID = %d, %v", pid, err) - } -} - -func TestPID_RefusesACorruptedPidFile(t *testing.T) { - for _, content := range []string{"", "abc", "0", "-12"} { - f := newFixture(t, switchLast) - f.pidFile(t, "hv1", content) - if _, err := f.lab.PID("hv1"); err == nil || !strings.Contains(err.Error(), "not a pid") { - t.Errorf("pid file %q: error = %v", content, err) - } - } -} - -func TestPID_RecognisesTheNodeProcess(t *testing.T) { - f := newFixture(t, switchLast) - want := f.process(t, "hv1", "sleep 30", qemu("hv1")...) - if pid, err := f.lab.PID("hv1"); pid != want || err != nil { - t.Errorf("PID = %d, %v, want %d", pid, err, want) - } -} - -func TestPID_IgnoresAPidReusedByAnotherProcess(t *testing.T) { - cases := map[string][]string{ - "another program": {"/usr/sbin/sshd", "-D"}, - "another node": qemu("hv10"), - "name as a value": {"qemu-system-x86_64", "-serial", "-name", "-name", "hv2"}, - } - for name, cmdline := range cases { - t.Run(name, func(t *testing.T) { - f := newFixture(t, switchLast) - f.process(t, "hv1", "sleep 30", cmdline...) - if pid, err := f.lab.PID("hv1"); pid != 0 || err != nil { - t.Errorf("PID = %d, %v, want 0", pid, err) - } - }) - } -} - -func TestPID_IgnoresADeadProcess(t *testing.T) { - f := newFixture(t, switchLast) - pid := f.process(t, "hv1", "exit 0", qemu("hv1")...) - waitDead(t, pid) - if got, err := f.lab.PID("hv1"); got != 0 || err != nil { - t.Errorf("PID = %d, %v, want 0", got, err) - } -} - -func TestStatus(t *testing.T) { - f := newFixture(t, switchLast) - pid := strconv.Itoa(f.process(t, "hv1", "sleep 30", qemu("hv1")...)) - var out bytes.Buffer - if err := f.lab.Status(&out); err != nil { - t.Fatal(err) - } - w := len(pid) - if w < 3 { - w = 3 - } - want := fmt.Sprintf("node role state %-*s ssh\n", w, "pid") + - fmt.Sprintf("rr1 rr stopped %-*s 127.0.0.1:2200\n", w, "-") + - fmt.Sprintf("hv1 hypervisor running %-*s 127.0.0.1:2201\n", w, pid) + - fmt.Sprintf("sw1 switch stopped %-*s 127.0.0.1:2202\n", w, "-") - if out.String() != want { - t.Errorf("status:\n%s\nwant:\n%s", out.String(), want) - } -} - -func TestDown_StopsWithSIGTERM(t *testing.T) { - f := newFixture(t, switchLast) - pid := f.process(t, "hv1", "sleep 30", qemu("hv1")...) - - if err := f.lab.Down(context.Background()); err != nil { - t.Fatalf("Down: %v", err) - } - - waitDead(t, pid) - if _, err := os.Stat(filepath.Join(f.lab.RunDir, "hv1", "qemu.pid")); !os.IsNotExist(err) { - t.Errorf("pid file still there: %v", err) - } - if f.out.String() != "hv1: stopped\n" { - t.Errorf("output = %q", f.out.String()) - } -} - -func TestDown_FallsBackToSIGKILL(t *testing.T) { - f := newFixture(t, switchLast) - f.lab.Stop = 100 * time.Millisecond - pid := f.process(t, "hv1", `trap "" TERM; while :; do sleep 0.05; done`, qemu("hv1")...) - time.Sleep(50 * time.Millisecond) - - start := time.Now() - if err := f.lab.Down(context.Background()); err != nil { - t.Fatalf("Down: %v", err) - } - - waitDead(t, pid) - if elapsed := time.Since(start); elapsed < 100*time.Millisecond { - t.Errorf("SIGKILL after %v, want at least the 100ms grace period", elapsed) - } -} - -func TestDown_NeverSignalsAProcessThatIsNotTheNode(t *testing.T) { - f := newFixture(t, switchLast) - pid := f.process(t, "hv1", "sleep 30", "/usr/sbin/sshd", "-D") - - if err := f.lab.Down(context.Background()); err != nil { - t.Fatalf("Down: %v", err) - } - - time.Sleep(50 * time.Millisecond) - if !alive(pid) { - t.Error("a process that is not the node was killed") - } - if f.out.Len() != 0 { - t.Errorf("output = %q", f.out.String()) - } -} - -func TestDown_ReportsACorruptedPidFileAndStopsTheOthers(t *testing.T) { - f := newFixture(t, switchLast) - f.pidFile(t, "rr1", "garbage") - pid := f.process(t, "hv1", "sleep 30", qemu("hv1")...) - - err := f.lab.Down(context.Background()) - - if err == nil || !strings.Contains(err.Error(), "not a pid") { - t.Errorf("error = %v", err) - } - waitDead(t, pid) -} - -func TestSSH_Arguments(t *testing.T) { - f := newFixture(t, switchLast) - key := filepath.Join(f.lab.RunDir, "lab_ed25519") - base := []string{ - "-i", key, - "-o", "IdentitiesOnly=yes", - "-o", "IdentityAgent=none", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-p", "2201", - "debian@127.0.0.1", - } - cases := map[string]struct { - terminal bool - command []string - want []string - }{ - "interactive shell": {true, nil, append([]string{"ssh"}, base...)}, - "command from a terminal": {true, []string{"top"}, append(append([]string{"ssh", "-t"}, base...), "top")}, - "command from a script": {false, []string{"ip", "-br", "a"}, append(append([]string{"ssh"}, base...), "ip", "-br", "a")}, - "shell from a script": {false, nil, append([]string{"ssh"}, base...)}, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - got, err := f.lab.SSH("hv1", c.terminal, c.command) - if err != nil { - t.Fatal(err) - } - if !reflect.DeepEqual(got, c.want) { - t.Errorf("\n got %q\nwant %q", got, c.want) - } - }) - } -} - -func TestSSH_UnknownNode(t *testing.T) { - f := newFixture(t, switchLast) - if _, err := f.lab.SSH("hv9", true, nil); err == nil || !strings.Contains(err.Error(), `node "hv9" is not in lab evpn-2hv`) { - t.Errorf("error = %v", err) - } -} - -func TestUp_StartsSwitchesFirstAndWaitsForEveryNode(t *testing.T) { - f := newFixture(t, switchLast) - - if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { - t.Fatalf("Up: %v", err) - } - - var started []string - for _, c := range f.runner.commands("qemu-system-x86_64") { - if c[len(c)-1] != "-daemonize" { - t.Errorf("qemu not daemonized: %q", c) - } - started = append(started, c[indexOf(c, "-name")+1]) - } - if !reflect.DeepEqual(started, []string{"sw1", "rr1", "hv1"}) { - t.Errorf("start order = %v, want the switch first", started) - } - - ssh := f.runner.commands("ssh") - if len(ssh) != 4 { - t.Fatalf("%d ssh calls, want 4", len(ssh)) - } - want := []string{"ssh", - "-i", filepath.Join(f.lab.RunDir, "lab_ed25519"), - "-o", "IdentitiesOnly=yes", - "-o", "IdentityAgent=none", - "-o", "StrictHostKeyChecking=no", - "-o", "UserKnownHostsFile=/dev/null", - "-o", "LogLevel=ERROR", - "-p", "2200", - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=5", - "debian@127.0.0.1", - "cloud-init", "status", "--wait", - } - if !reflect.DeepEqual(ssh[0], want) { - t.Errorf("readiness check:\n got %q\nwant %q", ssh[0], want) - } - if f.out.String() != "sw1: started\nrr1: started\nhv1: started\nrr1: ready\nhv1: ready\nsw1: ready\n" { - t.Errorf("output = %q", f.out.String()) - } -} - -func TestUp_RetriesWhileSSHIsUnreachable(t *testing.T) { - f := newFixture(t, switchLast) - f.runner.ssh["2201"] = []error{exitErr(255), exitErr(255), nil} - - if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { - t.Fatalf("Up: %v", err) - } - if n := len(f.runner.commands("ssh")); n != 6 { - t.Errorf("%d ssh calls, want 6", n) - } -} - -func TestUp_ReadinessFailures(t *testing.T) { - cases := map[string]struct { - answers []error - want string - }{ - "cloud-init error": {[]error{exitErr(1)}, "node hv1: cloud-init failed: exit status 1"}, - "not an exit status": {[]error{errors.New("fork failed")}, "node hv1: cloud-init failed: fork failed"}, - "ssh never reachable": {nil, "node hv1: not reachable over ssh: context deadline exceeded"}, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - f := newFixture(t, switchLast) - f.lab.Poll = 10 * time.Millisecond - f.runner.ssh["2201"] = c.answers - f.runner.always["2201"] = exitErr(255) - - err := f.lab.Up(context.Background(), f.fetcher, 300*time.Millisecond) - - if err == nil || err.Error() != c.want { - t.Errorf("error = %v, want %q", err, c.want) - } - if !strings.Contains(f.out.String(), "rr1: ready\n") || !strings.Contains(f.out.String(), "sw1: ready\n") { - t.Errorf("the other nodes were not waited for: %q", f.out.String()) - } - }) - } -} - -func TestUp_RecoverableCloudInitErrorsAreReportedNotFatal(t *testing.T) { - f := newFixture(t, switchLast) - f.runner.ssh["2201"] = []error{exitErr(2)} - - if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { - t.Fatalf("Up: %v", err) - } - if !strings.Contains(f.out.String(), "hv1: cloud-init finished with recoverable errors: exit status 2\nhv1: ready\n") { - t.Errorf("output = %q", f.out.String()) - } -} - -func TestUp_RefusesARunningLab(t *testing.T) { - f := newFixture(t, switchLast) - f.process(t, "hv1", "sleep 30", qemu("hv1")...) - - err := f.lab.Up(context.Background(), f.fetcher, time.Second) - - if err == nil || err.Error() != "lab evpn-2hv is already running (hv1): 'lab down' first" { - t.Errorf("error = %v", err) - } - if len(f.runner.calls) != 0 { - t.Errorf("commands ran: %q", f.runner.calls) - } -} - -func TestUp_RemovesAStalePidFileBeforeStarting(t *testing.T) { - f := newFixture(t, switchLast) - pid := f.process(t, "hv1", "exit 0", qemu("hv1")...) - waitDead(t, pid) - - if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { - t.Fatalf("Up: %v", err) - } - if _, err := os.Stat(filepath.Join(f.lab.RunDir, "hv1", "qemu.pid")); !os.IsNotExist(err) { - t.Errorf("stale pid file kept: %v", err) - } -} - -func TestUp_StopsAtTheFirstQEMUFailure(t *testing.T) { - f := newFixture(t, switchLast) - f.runner.fail = "qemu-system-x86_64" - - err := f.lab.Up(context.Background(), f.fetcher, time.Second) - - if err == nil || err.Error() != "node sw1: qemu-system-x86_64 failed" { - t.Errorf("error = %v", err) - } - if n := len(f.runner.commands("qemu-system-x86_64")); n != 1 { - t.Errorf("%d qemu starts, want 1", n) - } - if n := len(f.runner.commands("ssh")); n != 0 { - t.Errorf("%d ssh calls after a failed start", n) - } -} - -func TestStop_RefusesPidsThatTargetAGroup(t *testing.T) { - f := newFixture(t, switchLast) - f.lab.signal = func(pid int, sig syscall.Signal) error { - t.Fatalf("signal %v sent to pid %d", sig, pid) - return nil - } - for _, pid := range []int{0, -1} { - if err := f.lab.stop(context.Background(), "hv1", pid); err == nil || err.Error() != fmt.Sprintf("refusing to signal pid %d", pid) { - t.Errorf("pid %d: error = %v", pid, err) - } - } -} - -func TestUp_ChecksTheServicesOfEachRole(t *testing.T) { - f := newFixture(t, switchLast) - conf := filepath.Join(t.TempDir(), "sw1.conf") - if err := os.WriteFile(conf, []byte("hostname sw1\n"), 0o600); err != nil { - t.Fatal(err) - } - f.lab.Plan.Nodes[2].FRR = conf - - if err := f.lab.Up(context.Background(), f.fetcher, time.Second); err != nil { - t.Fatalf("Up: %v", err) - } - - var checks []string - for _, c := range f.runner.commands("ssh") { - if c[len(c)-4] == "systemctl" { - checks = append(checks, c[indexOf(c, "-p")+1]+" "+strings.Join(c[len(c)-4:], " ")) - } - } - want := []string{ - "2201 systemctl is-active --quiet agent.service", - "2202 systemctl is-active --quiet frr.service", - } - if !reflect.DeepEqual(checks, want) { - t.Errorf("checks = %q, want %q", checks, want) - } -} - -func TestUp_ReportsAnInactiveServiceAndWaitsForTheOthers(t *testing.T) { - f := newFixture(t, switchLast) - f.runner.inactive = "agent.service" - - err := f.lab.Up(context.Background(), f.fetcher, time.Second) - - if err == nil || err.Error() != "node hv1: agent.service is not active: exit status 3" { - t.Errorf("error = %v", err) - } - if !strings.Contains(f.out.String(), "rr1: ready\n") || !strings.Contains(f.out.String(), "sw1: ready\n") || strings.Contains(f.out.String(), "hv1: ready") { - t.Errorf("output = %q", f.out.String()) - } -} diff --git a/internal/lab/provision/image.go b/internal/lab/provision/image.go deleted file mode 100644 index c4e8347..0000000 --- a/internal/lab/provision/image.go +++ /dev/null @@ -1,185 +0,0 @@ -package provision - -import ( - "bufio" - "bytes" - "context" - "crypto/sha512" - "encoding/hex" - "errors" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" - "path/filepath" - "regexp" - "strings" - - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const maxSumsSize = 1 << 20 - -var sha512Pattern = regexp.MustCompile(`^[0-9a-f]{128}$`) - -type Fetcher struct { - Client *http.Client - CacheDir string -} - -func (f Fetcher) Image(ctx context.Context, img topology.Image) (string, error) { - if !filepath.IsAbs(f.CacheDir) { - return "", fmt.Errorf("image cache dir %q must be an absolute path", f.CacheDir) - } - name, err := fileName(img.URL) - if err != nil { - return "", fmt.Errorf("image %s: %w", img.Name, err) - } - - sums, err := f.get(ctx, img.Sums, maxSumsSize) - if err != nil { - return "", fmt.Errorf("image %s: sums: %w", img.Name, err) - } - want, err := expectedSum(sums, name) - if err != nil { - return "", fmt.Errorf("image %s: %s: %w", img.Name, img.Sums, err) - } - - dir := filepath.Join(f.CacheDir, img.Name) - target := filepath.Join(dir, name) - if got, err := fileSum(target); err == nil && got == want { - return target, nil - } else if err != nil && !errors.Is(err, os.ErrNotExist) { - return "", fmt.Errorf("image %s: %w", img.Name, err) - } - - if err := os.MkdirAll(dir, 0o755); err != nil { - return "", err - } - if err := f.download(ctx, img.URL, dir, target, want); err != nil { - return "", fmt.Errorf("image %s: %w", img.Name, err) - } - return target, nil -} - -func (f Fetcher) client() *http.Client { - if f.Client != nil { - return f.Client - } - return http.DefaultClient -} - -func (f Fetcher) open(ctx context.Context, rawURL string) (*http.Response, error) { - req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) - if err != nil { - return nil, err - } - resp, err := f.client().Do(req) - if err != nil { - return nil, err - } - if resp.StatusCode != http.StatusOK { - resp.Body.Close() - return nil, fmt.Errorf("GET %s: %s", rawURL, resp.Status) - } - return resp, nil -} - -func (f Fetcher) get(ctx context.Context, rawURL string, limit int64) ([]byte, error) { - resp, err := f.open(ctx, rawURL) - if err != nil { - return nil, err - } - defer resp.Body.Close() - data, err := io.ReadAll(io.LimitReader(resp.Body, limit+1)) - if err != nil { - return nil, err - } - if int64(len(data)) > limit { - return nil, fmt.Errorf("GET %s: larger than %d bytes", rawURL, limit) - } - return data, nil -} - -func (f Fetcher) download(ctx context.Context, rawURL, dir, target, want string) error { - resp, err := f.open(ctx, rawURL) - if err != nil { - return err - } - defer resp.Body.Close() - - tmp, err := os.CreateTemp(dir, filepath.Base(target)+".part-*") - if err != nil { - return err - } - defer os.Remove(tmp.Name()) - - h := sha512.New() - if _, err := io.Copy(io.MultiWriter(tmp, h), resp.Body); err != nil { - tmp.Close() - return fmt.Errorf("GET %s: %w", rawURL, err) - } - if err := tmp.Close(); err != nil { - return err - } - if got := hex.EncodeToString(h.Sum(nil)); got != want { - return fmt.Errorf("GET %s: sha512 %s, want %s", rawURL, got, want) - } - if err := os.Chmod(tmp.Name(), 0o644); err != nil { - return err - } - return os.Rename(tmp.Name(), target) -} - -func fileName(rawURL string) (string, error) { - u, err := url.Parse(rawURL) - if err != nil { - return "", err - } - name := path.Base(u.Path) - if name == "" || name == "." || name == "/" { - return "", fmt.Errorf("url %q does not name a file", rawURL) - } - return name, nil -} - -func expectedSum(sums []byte, name string) (string, error) { - var found []string - sc := bufio.NewScanner(bytes.NewReader(sums)) - for sc.Scan() { - fields := strings.Fields(sc.Text()) - if len(fields) != 2 || strings.TrimPrefix(fields[1], "*") != name { - continue - } - sum := strings.ToLower(fields[0]) - if !sha512Pattern.MatchString(sum) { - return "", fmt.Errorf("entry for %s is not a sha512 sum", name) - } - found = append(found, sum) - } - if err := sc.Err(); err != nil { - return "", err - } - switch { - case len(found) == 0: - return "", fmt.Errorf("no sum for %s", name) - case len(found) > 1: - return "", fmt.Errorf("%d sums for %s", len(found), name) - } - return found[0], nil -} - -func fileSum(p string) (string, error) { - file, err := os.Open(p) - if err != nil { - return "", err - } - defer file.Close() - h := sha512.New() - if _, err := io.Copy(h, file); err != nil { - return "", err - } - return hex.EncodeToString(h.Sum(nil)), nil -} diff --git a/internal/lab/provision/image_test.go b/internal/lab/provision/image_test.go deleted file mode 100644 index cd1ab20..0000000 --- a/internal/lab/provision/image_test.go +++ /dev/null @@ -1,334 +0,0 @@ -package provision - -import ( - "context" - "crypto/sha512" - "encoding/hex" - "net/http" - "net/http/httptest" - "os" - "path/filepath" - "strings" - "sync" - "testing" - - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const imageName = "debian-12-generic-amd64.qcow2" - -type mirror struct { - mu sync.Mutex - files map[string][]byte - status map[string]int - hits map[string]int - server *httptest.Server -} - -func newMirror(t *testing.T) *mirror { - t.Helper() - m := &mirror{files: map[string][]byte{}, status: map[string]int{}, hits: map[string]int{}} - m.server = httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - m.mu.Lock() - defer m.mu.Unlock() - m.hits[r.URL.Path]++ - if code := m.status[r.URL.Path]; code != 0 { - w.WriteHeader(code) - return - } - data, ok := m.files[r.URL.Path] - if !ok { - http.NotFound(w, r) - return - } - w.Write(data) - })) - t.Cleanup(m.server.Close) - return m -} - -func (m *mirror) put(path string, data []byte) { - m.mu.Lock() - defer m.mu.Unlock() - m.files[path] = data -} - -func (m *mirror) count(path string) int { - m.mu.Lock() - defer m.mu.Unlock() - return m.hits[path] -} - -func (m *mirror) image() topology.Image { - return topology.Image{ - Name: "debian12", - URL: m.server.URL + "/bookworm/latest/" + imageName, - Sums: m.server.URL + "/bookworm/latest/SHA512SUMS", - } -} - -func sum(data []byte) string { - h := sha512.Sum512(data) - return hex.EncodeToString(h[:]) -} - -func publish(m *mirror, content []byte) { - m.put("/bookworm/latest/"+imageName, content) - m.put("/bookworm/latest/SHA512SUMS", []byte( - sum([]byte("other"))+" debian-12-genericcloud-amd64.qcow2\n"+ - sum(content)+" "+imageName+"\n"+ - sum([]byte("raw"))+" debian-12-generic-amd64.raw\n")) -} - -func fetch(t *testing.T, m *mirror, cache string) string { - t.Helper() - got, err := Fetcher{Client: m.server.Client(), CacheDir: cache}.Image(context.Background(), m.image()) - if err != nil { - t.Fatalf("Image: %v", err) - } - return got -} - -func fetchError(t *testing.T, m *mirror, cache string) string { - t.Helper() - _, err := Fetcher{Client: m.server.Client(), CacheDir: cache}.Image(context.Background(), m.image()) - if err == nil { - t.Fatal("Image: no error") - } - return err.Error() -} - -func entries(t *testing.T, dir string) []string { - t.Helper() - list, err := os.ReadDir(dir) - if err != nil && !os.IsNotExist(err) { - t.Fatal(err) - } - var names []string - for _, e := range list { - names = append(names, e.Name()) - } - return names -} - -func TestImage_DownloadsAndVerifies(t *testing.T) { - m := newMirror(t) - publish(m, []byte("qcow2 image")) - cache := t.TempDir() - - got := fetch(t, m, cache) - - if want := filepath.Join(cache, "debian12", "debian-12-generic-amd64.qcow2"); got != want { - t.Errorf("path = %s, want %s", got, want) - } - data, err := os.ReadFile(got) - if err != nil || string(data) != "qcow2 image" { - t.Errorf("content = %q, %v", data, err) - } - info, err := os.Stat(got) - if err != nil || info.Mode().Perm() != 0o644 { - t.Errorf("mode = %v, %v", info.Mode().Perm(), err) - } - if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 1 { - t.Errorf("cache dir holds %v, want only the image", names) - } -} - -func TestImage_ReusesAVerifiedCacheButAlwaysRereadsTheSums(t *testing.T) { - m := newMirror(t) - publish(m, []byte("qcow2 image")) - cache := t.TempDir() - - fetch(t, m, cache) - fetch(t, m, cache) - - if n := m.count("/bookworm/latest/" + imageName); n != 1 { - t.Errorf("image downloaded %d times, want 1", n) - } - if n := m.count("/bookworm/latest/SHA512SUMS"); n != 2 { - t.Errorf("sums read %d times, want 2", n) - } -} - -func TestImage_ReplacesACorruptedCache(t *testing.T) { - m := newMirror(t) - publish(m, []byte("qcow2 image")) - cache := t.TempDir() - path := fetch(t, m, cache) - if err := os.WriteFile(path, []byte("bit rot"), 0o644); err != nil { - t.Fatal(err) - } - - fetch(t, m, cache) - - if data, _ := os.ReadFile(path); string(data) != "qcow2 image" { - t.Errorf("content = %q, want the published image", data) - } - if n := m.count("/bookworm/latest/" + imageName); n != 2 { - t.Errorf("image downloaded %d times, want 2", n) - } -} - -func TestImage_FollowsANewReleaseOfTheSameFile(t *testing.T) { - m := newMirror(t) - publish(m, []byte("release 1")) - cache := t.TempDir() - path := fetch(t, m, cache) - - publish(m, []byte("release 2")) - fetch(t, m, cache) - - if data, _ := os.ReadFile(path); string(data) != "release 2" { - t.Errorf("content = %q, want release 2", data) - } -} - -func TestImage_RejectsAMismatchAndLeavesNothing(t *testing.T) { - m := newMirror(t) - publish(m, []byte("qcow2 image")) - m.put("/bookworm/latest/"+imageName, []byte("tampered")) - cache := t.TempDir() - - msg := fetchError(t, m, cache) - - if !strings.Contains(msg, "sha512") { - t.Errorf("error = %q, want a sha512 mismatch", msg) - } - if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 { - t.Errorf("cache dir holds %v after a mismatch, want nothing", names) - } -} - -func TestImage_KeepsTheVerifiedCacheWhenANewDownloadFails(t *testing.T) { - m := newMirror(t) - publish(m, []byte("release 1")) - cache := t.TempDir() - path := fetch(t, m, cache) - - publish(m, []byte("release 2")) - m.put("/bookworm/latest/"+imageName, []byte("truncated")) - fetchError(t, m, cache) - - if data, _ := os.ReadFile(path); string(data) != "release 1" { - t.Errorf("content = %q, want release 1 kept", data) - } - if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 1 { - t.Errorf("cache dir holds %v, want only the image", names) - } -} - -func TestImage_AcceptsBinaryModeSumLines(t *testing.T) { - m := newMirror(t) - content := []byte("qcow2 image") - m.put("/bookworm/latest/"+imageName, content) - m.put("/bookworm/latest/SHA512SUMS", []byte(strings.ToUpper(sum(content))+" *"+imageName+"\n")) - - fetch(t, m, t.TempDir()) -} - -func TestImage_SumsRejections(t *testing.T) { - content := []byte("qcow2 image") - cases := map[string]struct { - sums string - want string - }{ - "no entry": {sum(content) + " debian-12-generic-arm64.qcow2\n", "no sum for " + imageName}, - "two entries": {sum(content) + " " + imageName + "\n" + sum(content) + " " + imageName + "\n", "2 sums for " + imageName}, - "not sha512": {"d41d8cd98f00b204e9800998ecf8427e " + imageName + "\n", "is not a sha512 sum"}, - "prefix of name": {sum(content) + " " + imageName + ".sig\n", "no sum for " + imageName}, - "name in directory": {sum(content) + " nested/" + imageName + "\n", "no sum for " + imageName}, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - m := newMirror(t) - m.put("/bookworm/latest/"+imageName, content) - m.put("/bookworm/latest/SHA512SUMS", []byte(c.sums)) - if msg := fetchError(t, m, t.TempDir()); !strings.Contains(msg, c.want) { - t.Errorf("error = %q, want %q", msg, c.want) - } - if n := m.count("/bookworm/latest/" + imageName); n != 0 { - t.Errorf("image downloaded %d times before the sums were trusted", n) - } - }) - } -} - -func TestImage_RefusesOversizedSums(t *testing.T) { - m := newMirror(t) - m.put("/bookworm/latest/SHA512SUMS", make([]byte, 1<<20+1)) - if msg := fetchError(t, m, t.TempDir()); !strings.Contains(msg, "larger than 1048576 bytes") { - t.Errorf("error = %q", msg) - } -} - -func TestImage_AcceptsSumsOfExactlyTheLimit(t *testing.T) { - m := newMirror(t) - content := []byte("qcow2 image") - line := sum(content) + " " + imageName + "\n" - m.put("/bookworm/latest/"+imageName, content) - m.put("/bookworm/latest/SHA512SUMS", []byte(strings.Repeat("\n", 1<<20-len(line))+line)) - - fetch(t, m, t.TempDir()) -} - -func TestImage_HTTPErrors(t *testing.T) { - for _, path := range []string{"/bookworm/latest/SHA512SUMS", "/bookworm/latest/" + imageName} { - t.Run(path, func(t *testing.T) { - m := newMirror(t) - publish(m, []byte("qcow2 image")) - m.status[path] = http.StatusServiceUnavailable - cache := t.TempDir() - if msg := fetchError(t, m, cache); !strings.Contains(msg, "503") { - t.Errorf("error = %q, want the http status", msg) - } - if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 { - t.Errorf("cache dir holds %v, want nothing", names) - } - }) - } -} - -func TestImage_RefusesARelativeCacheDir(t *testing.T) { - m := newMirror(t) - publish(m, []byte("qcow2 image")) - if msg := fetchError(t, m, "cache"); !strings.Contains(msg, "must be an absolute path") { - t.Errorf("error = %q", msg) - } - if n := m.count("/bookworm/latest/SHA512SUMS"); n != 0 { - t.Errorf("sums read %d times", n) - } -} - -func TestImage_RefusesAURLWithoutFileName(t *testing.T) { - m := newMirror(t) - img := m.image() - img.URL = m.server.URL + "/" - _, err := Fetcher{Client: m.server.Client(), CacheDir: t.TempDir()}.Image(context.Background(), img) - if err == nil || !strings.Contains(err.Error(), "does not name a file") { - t.Errorf("error = %v", err) - } -} - -func TestImage_RejectsAnInterruptedDownloadAndLeavesNothing(t *testing.T) { - content := []byte("qcow2 image") - server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if strings.HasSuffix(r.URL.Path, "SHA512SUMS") { - w.Write([]byte(sum(content) + " " + imageName + "\n")) - return - } - w.Header().Set("Content-Length", "1000") - w.Write(content[:4]) - })) - defer server.Close() - cache := t.TempDir() - img := topology.Image{Name: "debian12", URL: server.URL + "/" + imageName, Sums: server.URL + "/SHA512SUMS"} - - _, err := Fetcher{Client: server.Client(), CacheDir: cache}.Image(context.Background(), img) - - if err == nil || !strings.Contains(err.Error(), "unexpected EOF") { - t.Errorf("error = %v, want an interrupted transfer", err) - } - if names := entries(t, filepath.Join(cache, "debian12")); len(names) != 0 { - t.Errorf("cache dir holds %v, want nothing", names) - } -} diff --git a/internal/lab/provision/provision.go b/internal/lab/provision/provision.go deleted file mode 100644 index b83751a..0000000 --- a/internal/lab/provision/provision.go +++ /dev/null @@ -1,182 +0,0 @@ -package provision - -import ( - "context" - "errors" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "git.g3e.fr/syonad/two/internal/lab/render" - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const ( - KeyFile = "lab_ed25519" - KeyLabel = "two-lab" - DiskSize = "20G" - SeedLabel = "cidata" - - ArgsFile = "qemu.args" -) - -var SeedFiles = []string{"user-data", "meta-data", "network-config"} - -type Runner interface { - Run(ctx context.Context, name string, args ...string) error -} - -type ExecRunner struct{} - -func (ExecRunner) Run(ctx context.Context, name string, args ...string) error { - out, err := exec.CommandContext(ctx, name, args...).CombinedOutput() - if err != nil { - return fmt.Errorf("%s: %w: %s", name, err, strings.TrimSpace(string(out))) - } - return nil -} - -type Options struct { - RunDir string - Fetcher Fetcher - Runner Runner -} - -func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, error) { - if !filepath.IsAbs(o.RunDir) { - return nil, fmt.Errorf("run dir %q must be an absolute path", o.RunDir) - } - if err := os.MkdirAll(o.RunDir, 0o700); err != nil { - return nil, err - } - - images := map[string]string{} - for _, n := range p.Nodes { - if _, ok := images[n.Image]; ok { - continue - } - img, ok := imageNamed(p, n.Image) - if !ok { - return nil, fmt.Errorf("node %s: image %q is not declared", n.Name, n.Image) - } - base, err := o.Fetcher.Image(ctx, img) - if err != nil { - return nil, err - } - images[n.Image] = base - } - - key, err := EnsureKey(ctx, o.Runner, o.RunDir) - if err != nil { - return nil, err - } - frr, err := ReadFRR(p) - if err != nil { - return nil, err - } - agent, err := ReadAgent(p) - if err != nil { - return nil, err - } - nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}, FRR: frr, Agent: agent}) - if err != nil { - return nil, err - } - for i, n := range nodes { - if err := Stage(ctx, o.Runner, n, images[p.Nodes[i].Image]); err != nil { - return nil, fmt.Errorf("node %s: %w", n.Name, err) - } - } - return nodes, nil -} - -func ReadFRR(p *topology.Plan) (map[string]string, error) { - return readNodeFiles(p, func(n topology.NodePlan) string { return n.FRR }) -} - -func ReadAgent(p *topology.Plan) (map[string]string, error) { - return readNodeFiles(p, func(n topology.NodePlan) string { return n.Agent }) -} - -func readNodeFiles(p *topology.Plan, path func(topology.NodePlan) string) (map[string]string, error) { - files := map[string]string{} - for _, n := range p.Nodes { - if path(n) == "" { - continue - } - data, err := os.ReadFile(path(n)) - if err != nil { - return nil, fmt.Errorf("node %s: %w", n.Name, err) - } - files[n.Name] = string(data) - } - return files, nil -} - -func EnsureKey(ctx context.Context, r Runner, dir string) (string, error) { - private := filepath.Join(dir, KeyFile) - if _, err := os.Stat(private); errors.Is(err, os.ErrNotExist) { - if err := r.Run(ctx, "ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", KeyLabel, "-f", private); err != nil { - return "", err - } - } else if err != nil { - return "", err - } - data, err := os.ReadFile(private + ".pub") - if err != nil { - return "", err - } - key := strings.TrimSpace(string(data)) - if key == "" || strings.ContainsAny(key, "\r\n") { - return "", fmt.Errorf("%s.pub: not a single public key", private) - } - return key, nil -} - -func WriteFiles(n render.Node) error { - if err := os.MkdirAll(n.Dir, 0o700); err != nil { - return err - } - files := map[string][]byte{ - ArgsFile: []byte(strings.Join(n.QEMU, "\n") + "\n"), - "meta-data": n.MetaData, - "user-data": n.UserData, - "network-config": n.NetworkConfig, - } - for name, content := range files { - if err := os.WriteFile(filepath.Join(n.Dir, name), content, 0o600); err != nil { - return err - } - } - return nil -} - -func Stage(ctx context.Context, r Runner, n render.Node, base string) error { - if !filepath.IsAbs(base) { - return fmt.Errorf("base image %q must be an absolute path", base) - } - if err := WriteFiles(n); err != nil { - return err - } - disk := filepath.Join(n.Dir, render.DiskFile) - seed := filepath.Join(n.Dir, render.SeedFile) - if err := r.Run(ctx, "qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", base, disk, DiskSize); err != nil { - return err - } - args := []string{"-quiet", "-output", seed, "-volid", SeedLabel, "-joliet", "-rock"} - for _, f := range SeedFiles { - args = append(args, filepath.Join(n.Dir, f)) - } - return r.Run(ctx, "genisoimage", args...) -} - -func imageNamed(p *topology.Plan, name string) (topology.Image, bool) { - for _, i := range p.Images { - if i.Name == name { - return i, true - } - } - return topology.Image{}, false -} diff --git a/internal/lab/provision/provision_test.go b/internal/lab/provision/provision_test.go deleted file mode 100644 index 403d3cc..0000000 --- a/internal/lab/provision/provision_test.go +++ /dev/null @@ -1,407 +0,0 @@ -package provision - -import ( - "context" - "errors" - "os" - "path/filepath" - "reflect" - "strings" - "testing" - - "go.yaml.in/yaml/v3" - - "git.g3e.fr/syonad/two/internal/lab/render" - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const generatedKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab" - -type fakeRunner struct { - calls [][]string - fail string -} - -func (f *fakeRunner) Run(_ context.Context, name string, args ...string) error { - f.calls = append(f.calls, append([]string{name}, args...)) - if name == f.fail { - return errors.New(name + " failed") - } - if name == "ssh-keygen" { - private := args[len(args)-1] - if err := os.WriteFile(private, []byte("private"), 0o600); err != nil { - return err - } - return os.WriteFile(private+".pub", []byte(generatedKey+"\n"), 0o644) - } - return nil -} - -func (f *fakeRunner) commands(name string) [][]string { - var out [][]string - for _, c := range f.calls { - if c[0] == name { - out = append(out, c) - } - } - return out -} - -func labPlan(t *testing.T, m *mirror) *topology.Plan { - t.Helper() - img := m.image() - doc := `name: evpn-2hv -images: - debian12: - url: ` + img.URL + ` - sums: ` + img.Sums + ` -segments: - underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 } -nodes: - sw1: { role: switch, image: debian12, cpus: 2, memory: 1024 } - rr1: { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay] } - hv1: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } - hv2: { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } -` - topo, err := topology.Parse([]byte(doc)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - p, err := topology.Compute(topo) - if err != nil { - t.Fatalf("Compute: %v", err) - } - return p -} - -func prepare(t *testing.T, runner *fakeRunner) (string, string, *mirror, []render.Node, error) { - t.Helper() - m := newMirror(t) - publish(m, []byte("qcow2 image")) - root := t.TempDir() - run := filepath.Join(root, "run") - cache := filepath.Join(root, "cache") - nodes, err := Prepare(context.Background(), labPlan(t, m), Options{ - RunDir: run, - Fetcher: Fetcher{Client: m.server.Client(), CacheDir: cache}, - Runner: runner, - }) - return run, cache, m, nodes, err -} - -func TestPrepare_StagesEveryNode(t *testing.T) { - runner := &fakeRunner{} - run, cache, _, nodes, err := prepare(t, runner) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - - base := filepath.Join(cache, "debian12", "debian-12-generic-amd64.qcow2") - want := [][]string{ - {"ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "two-lab", "-f", filepath.Join(run, "lab_ed25519")}, - } - for _, n := range []string{"sw1", "rr1", "hv1", "hv2"} { - dir := filepath.Join(run, n) - want = append(want, - []string{"qemu-img", "create", "-q", "-f", "qcow2", "-F", "qcow2", "-b", base, filepath.Join(dir, "disk.qcow2"), "20G"}, - []string{"genisoimage", "-quiet", "-output", filepath.Join(dir, "seed.iso"), "-volid", "cidata", "-joliet", "-rock", - filepath.Join(dir, "user-data"), filepath.Join(dir, "meta-data"), filepath.Join(dir, "network-config")}, - ) - } - if !reflect.DeepEqual(runner.calls, want) { - t.Errorf("commands:\n got %q\nwant %q", runner.calls, want) - } - - var names []string - for _, n := range nodes { - names = append(names, n.Name) - } - if !reflect.DeepEqual(names, []string{"sw1", "rr1", "hv1", "hv2"}) { - t.Errorf("nodes = %v", names) - } - if info, err := os.Stat(run); err != nil || info.Mode().Perm() != 0o700 { - t.Errorf("run dir mode = %v, %v", info.Mode().Perm(), err) - } -} - -func TestPrepare_DownloadsASharedImageOnce(t *testing.T) { - _, _, m, _, err := prepare(t, &fakeRunner{}) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - if n := m.count("/bookworm/latest/" + imageName); n != 1 { - t.Errorf("image downloaded %d times for four nodes, want 1", n) - } - if n := m.count("/bookworm/latest/SHA512SUMS"); n != 1 { - t.Errorf("sums read %d times for four nodes, want 1", n) - } -} - -func TestPrepare_TheGeneratedKeyIsTheOnlyAuthorizedKey(t *testing.T) { - _, _, _, nodes, err := prepare(t, &fakeRunner{}) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - for _, n := range nodes { - data, err := os.ReadFile(filepath.Join(n.Dir, "user-data")) - if err != nil { - t.Fatal(err) - } - var cfg struct { - Keys []string `yaml:"ssh_authorized_keys"` - } - if err := yaml.Unmarshal(data, &cfg); err != nil { - t.Fatalf("%s user-data: %v", n.Name, err) - } - if !reflect.DeepEqual(cfg.Keys, []string{generatedKey}) { - t.Errorf("%s authorized keys = %q, want only the generated key", n.Name, cfg.Keys) - } - } -} - -func TestPrepare_WritesTheRenderedFilesPrivately(t *testing.T) { - run, _, _, nodes, err := prepare(t, &fakeRunner{}) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - hv1 := filepath.Join(run, "hv1") - if nodes[2].Dir != hv1 { - t.Fatalf("hv1 dir = %s, want %s", nodes[2].Dir, hv1) - } - for _, f := range []string{"qemu.args", "user-data", "meta-data", "network-config"} { - info, err := os.Stat(filepath.Join(hv1, f)) - if err != nil || info.Mode().Perm() != 0o600 { - t.Errorf("%s mode = %v, %v", f, info.Mode().Perm(), err) - } - } - args, err := os.ReadFile(filepath.Join(hv1, "qemu.args")) - if err != nil || !strings.HasPrefix(string(args), "-name\nhv1\n") || !strings.HasSuffix(string(args), "\n") { - t.Errorf("qemu.args = %q, %v", args, err) - } -} - -func TestPrepare_StopsAtTheFirstFailedCommand(t *testing.T) { - runner := &fakeRunner{fail: "qemu-img"} - _, _, _, _, err := prepare(t, runner) - if err == nil || !strings.Contains(err.Error(), "node sw1: qemu-img failed") { - t.Errorf("error = %v", err) - } - if n := len(runner.commands("genisoimage")); n != 0 { - t.Errorf("genisoimage ran %d times after qemu-img failed", n) - } -} - -func TestPrepare_StopsBeforeAnyCommandWhenTheImageIsWrong(t *testing.T) { - m := newMirror(t) - publish(m, []byte("qcow2 image")) - m.put("/bookworm/latest/"+imageName, []byte("tampered")) - runner := &fakeRunner{} - root := t.TempDir() - _, err := Prepare(context.Background(), labPlan(t, m), Options{ - RunDir: filepath.Join(root, "run"), - Fetcher: Fetcher{Client: m.server.Client(), CacheDir: filepath.Join(root, "cache")}, - Runner: runner, - }) - if err == nil || !strings.Contains(err.Error(), "sha512") { - t.Errorf("error = %v", err) - } - if len(runner.calls) != 0 { - t.Errorf("commands ran after a bad image: %q", runner.calls) - } -} - -func TestPrepare_RefusesARelativeRunDir(t *testing.T) { - m := newMirror(t) - publish(m, []byte("qcow2 image")) - runner := &fakeRunner{} - _, err := Prepare(context.Background(), labPlan(t, m), Options{ - RunDir: "run", - Fetcher: Fetcher{Client: m.server.Client(), CacheDir: t.TempDir()}, - Runner: runner, - }) - if err == nil || !strings.Contains(err.Error(), `run dir "run" must be an absolute path`) { - t.Errorf("error = %v", err) - } - if n := m.count("/bookworm/latest/SHA512SUMS"); n != 0 { - t.Errorf("sums read %d times before the run dir was checked", n) - } -} - -func TestPrepare_RefusesAnUndeclaredImage(t *testing.T) { - m := newMirror(t) - p := labPlan(t, m) - p.Images = nil - _, err := Prepare(context.Background(), p, Options{RunDir: t.TempDir(), Runner: &fakeRunner{}}) - if err == nil || !strings.Contains(err.Error(), `node sw1: image "debian12" is not declared`) { - t.Errorf("error = %v", err) - } -} - -func TestEnsureKey_GeneratesOnlyOnce(t *testing.T) { - dir := t.TempDir() - runner := &fakeRunner{} - - for i := 0; i < 2; i++ { - key, err := EnsureKey(context.Background(), runner, dir) - if err != nil || key != generatedKey { - t.Fatalf("EnsureKey #%d = %q, %v", i+1, key, err) - } - } - if n := len(runner.commands("ssh-keygen")); n != 1 { - t.Errorf("ssh-keygen ran %d times, want 1", n) - } -} - -func TestEnsureKey_Rejections(t *testing.T) { - cases := map[string]string{ - "empty": "\n", - "two keys": generatedKey + "\n" + generatedKey + "\n", - "stray break": "ssh-ed25519\rAAAA", - } - for name, pub := range cases { - t.Run(name, func(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "lab_ed25519"), []byte("private"), 0o600); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "lab_ed25519.pub"), []byte(pub), 0o644); err != nil { - t.Fatal(err) - } - if _, err := EnsureKey(context.Background(), &fakeRunner{}, dir); err == nil || !strings.Contains(err.Error(), "not a single public key") { - t.Errorf("error = %v", err) - } - }) - } -} - -func TestEnsureKey_ReportsAFailedGeneration(t *testing.T) { - if _, err := EnsureKey(context.Background(), &fakeRunner{fail: "ssh-keygen"}, t.TempDir()); err == nil || !strings.Contains(err.Error(), "ssh-keygen failed") { - t.Errorf("error = %v", err) - } -} - -func TestStage_RefusesARelativeBaseImage(t *testing.T) { - runner := &fakeRunner{} - err := Stage(context.Background(), runner, render.Node{Name: "hv1", Dir: t.TempDir()}, "debian.qcow2") - if err == nil || !strings.Contains(err.Error(), "must be an absolute path") { - t.Errorf("error = %v", err) - } - if len(runner.calls) != 0 { - t.Errorf("commands ran: %q", runner.calls) - } -} - -func TestExecRunner_ReportsTheCommandOutput(t *testing.T) { - err := ExecRunner{}.Run(context.Background(), "sh", "-c", "echo boom >&2; exit 3") - if err == nil || !strings.Contains(err.Error(), "sh: exit status 3: boom") { - t.Errorf("error = %v", err) - } - if err := (ExecRunner{}).Run(context.Background(), "true"); err != nil { - t.Errorf("true: %v", err) - } -} - -func TestEnsureKey_WithTheRealSSHKeygen(t *testing.T) { - dir := t.TempDir() - key, err := EnsureKey(context.Background(), ExecRunner{}, dir) - if err != nil { - t.Fatalf("EnsureKey: %v", err) - } - if !strings.HasPrefix(key, "ssh-ed25519 ") || !strings.HasSuffix(key, " two-lab") { - t.Errorf("key = %q", key) - } - info, err := os.Stat(filepath.Join(dir, "lab_ed25519")) - if err != nil || info.Mode().Perm() != 0o600 { - t.Errorf("private key mode = %v, %v", info.Mode().Perm(), err) - } -} - -func TestReadFRR_ReadsOnlyTheNodesThatDeclareOne(t *testing.T) { - dir := t.TempDir() - conf := filepath.Join(dir, "rr1.conf") - if err := os.WriteFile(conf, []byte("hostname rr1\n"), 0o600); err != nil { - t.Fatal(err) - } - m := newMirror(t) - p := labPlan(t, m) - p.Nodes[1].FRR = conf - - got, err := ReadFRR(p) - - if err != nil || !reflect.DeepEqual(got, map[string]string{"rr1": "hostname rr1\n"}) { - t.Errorf("ReadFRR = %q, %v", got, err) - } -} - -func TestReadFRR_NamesTheNodeOfAMissingFile(t *testing.T) { - m := newMirror(t) - p := labPlan(t, m) - p.Nodes[2].FRR = filepath.Join(t.TempDir(), "absent.conf") - - if _, err := ReadFRR(p); err == nil || !strings.Contains(err.Error(), "node hv1: ") || !strings.Contains(err.Error(), "absent.conf") { - t.Errorf("error = %v", err) - } -} - -func TestPrepare_PutsTheFRRConfigIntoTheSeed(t *testing.T) { - m := newMirror(t) - publish(m, []byte("qcow2 image")) - root := t.TempDir() - conf := filepath.Join(root, "hv1.conf") - if err := os.WriteFile(conf, []byte("hostname hv1\n"), 0o600); err != nil { - t.Fatal(err) - } - p := labPlan(t, m) - p.Nodes[2].FRR = conf - - nodes, err := Prepare(context.Background(), p, Options{ - RunDir: filepath.Join(root, "run"), - Fetcher: Fetcher{Client: m.server.Client(), CacheDir: filepath.Join(root, "cache")}, - Runner: &fakeRunner{}, - }) - if err != nil { - t.Fatalf("Prepare: %v", err) - } - data, err := os.ReadFile(filepath.Join(nodes[2].Dir, "user-data")) - if err != nil { - t.Fatal(err) - } - var cfg struct { - Files []struct { - Path string `yaml:"path"` - Content string `yaml:"content"` - } `yaml:"write_files"` - } - if err := yaml.Unmarshal(data, &cfg); err != nil { - t.Fatal(err) - } - for _, f := range cfg.Files { - if f.Path == "/etc/lab/frr.conf" { - if f.Content != "hostname hv1\n" { - t.Errorf("frr.conf = %q", f.Content) - } - return - } - } - t.Errorf("hv1 user-data has no /etc/lab/frr.conf:\n%s", data) -} - -func TestReadAgent_ReadsOnlyTheNodesThatDeclareOne(t *testing.T) { - conf := filepath.Join(t.TempDir(), "two.yml") - if err := os.WriteFile(conf, []byte("dhcp:\n backend: two\n"), 0o600); err != nil { - t.Fatal(err) - } - m := newMirror(t) - p := labPlan(t, m) - p.Nodes[2].Agent = conf - - got, err := ReadAgent(p) - - if err != nil || !reflect.DeepEqual(got, map[string]string{"hv1": "dhcp:\n backend: two\n"}) { - t.Errorf("ReadAgent = %q, %v", got, err) - } - p.Nodes[3].Agent = filepath.Join(t.TempDir(), "absent.yml") - if _, err := ReadAgent(p); err == nil || !strings.Contains(err.Error(), "node hv2: ") { - t.Errorf("error = %v", err) - } -} diff --git a/internal/lab/render/frrouting.gpg b/internal/lab/render/frrouting.gpg deleted file mode 100644 index c38f82b..0000000 Binary files a/internal/lab/render/frrouting.gpg and /dev/null differ diff --git a/internal/lab/render/qemu.go b/internal/lab/render/qemu.go deleted file mode 100644 index 9a870d2..0000000 --- a/internal/lab/render/qemu.go +++ /dev/null @@ -1,54 +0,0 @@ -package render - -import ( - "fmt" - "path/filepath" - "strconv" - - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const loopback = "127.0.0.1" - -func qemuArgs(p *topology.Plan, n topology.NodePlan, index int, dir string) []string { - args := []string{ - "-name", n.Name, - "-machine", "q35", - "-accel", "kvm", - "-cpu", "host", - "-smp", strconv.Itoa(n.CPUs), - "-m", strconv.Itoa(n.Memory), - "-nodefaults", - "-display", "none", - "-serial", "file:" + filepath.Join(dir, ConsoleFile), - "-qmp", "unix:" + filepath.Join(dir, QMPFile) + ",server=on,wait=off", - "-pidfile", filepath.Join(dir, PIDFile), - "-drive", "file=" + filepath.Join(dir, DiskFile) + ",if=virtio,format=qcow2", - "-drive", "file=" + filepath.Join(dir, SeedFile) + ",media=cdrom,readonly=on", - } - - restrict := "on" - if n.Role == topology.RoleSwitch { - restrict = "off" - } - args = append(args, - "-netdev", fmt.Sprintf("user,id=%s,restrict=%s,ipv6=off,hostfwd=tcp:%s:%d-:22", AdminInterface, restrict, loopback, n.SSHPort), - "-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,romfile=", AdminInterface, adminMAC(index)), - ) - - for _, c := range nodeCables(p, n.Name) { - args = append(args, cable(c.NodeInterface, c.NodePort, c.SwitchPort, c.NodeMAC.String(), c.MTU)...) - } - for _, c := range switchCables(p, n.Name) { - args = append(args, cable(c.SwitchInterface, c.SwitchPort, c.NodePort, c.SwitchMAC.String(), c.MTU)...) - } - return args -} - -func cable(id string, local, remote int, mac string, mtu int) []string { - return []string{ - "-netdev", fmt.Sprintf("dgram,id=%s,local.type=inet,local.host=%s,local.port=%d,remote.type=inet,remote.host=%s,remote.port=%d", - id, loopback, local, loopback, remote), - "-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,host_mtu=%d,romfile=", id, mac, mtu), - } -} diff --git a/internal/lab/render/render.go b/internal/lab/render/render.go deleted file mode 100644 index be95453..0000000 --- a/internal/lab/render/render.go +++ /dev/null @@ -1,142 +0,0 @@ -package render - -import ( - "errors" - "fmt" - "net" - "path/filepath" - "strings" - - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const ( - QEMUBinary = "qemu-system-x86_64" - - AdminInterface = topology.ReservedInterface - AdminAddress = "10.0.2.15/24" - AdminGateway = "10.0.2.2" - AdminDNS = "10.0.2.3" - - DiskFile = "disk.qcow2" - SeedFile = "seed.iso" - ConsoleFile = "console.log" - QMPFile = "qmp.sock" - PIDFile = "qemu.pid" -) - -var Nameservers = []string{"1.1.1.1", "8.8.8.8"} - -type Options struct { - RunDir string - AuthorizedKeys []string - FRR map[string]string - Agent map[string]string -} - -type Node struct { - Name string - Dir string - QEMU []string - MetaData []byte - UserData []byte - NetworkConfig []byte -} - -func Render(p *topology.Plan, o Options) ([]Node, error) { - if err := o.validate(); err != nil { - return nil, err - } - for _, n := range p.Nodes { - if _, ok := o.FRR[n.Name]; n.FRR != "" && !ok { - return nil, fmt.Errorf("node %s: frr configuration %s was not read", n.Name, n.FRR) - } - if _, ok := o.Agent[n.Name]; n.Agent != "" && !ok { - return nil, fmt.Errorf("node %s: agent configuration %s was not read", n.Name, n.Agent) - } - } - var nodes []Node - for index, n := range p.Nodes { - dir := filepath.Join(o.RunDir, n.Name) - meta, err := metaData(p, n) - if err != nil { - return nil, err - } - user, err := userData(p, n, o) - if err != nil { - return nil, err - } - network, err := networkConfig(p, n, index) - if err != nil { - return nil, err - } - nodes = append(nodes, Node{ - Name: n.Name, - Dir: dir, - QEMU: qemuArgs(p, n, index, dir), - MetaData: meta, - UserData: user, - NetworkConfig: network, - }) - } - return nodes, nil -} - -func (o Options) validate() error { - var errs []error - if !filepath.IsAbs(o.RunDir) { - errs = append(errs, fmt.Errorf("run dir %q must be an absolute path", o.RunDir)) - } - if len(o.AuthorizedKeys) == 0 { - errs = append(errs, errors.New("at least one authorized ssh key is required")) - } - for i, k := range o.AuthorizedKeys { - if strings.ContainsAny(k, "\r\n") { - errs = append(errs, fmt.Errorf("authorized key %d spans several lines", i+1)) - continue - } - fields := strings.Fields(k) - if len(fields) < 2 || !validKeyType(fields[0]) { - errs = append(errs, fmt.Errorf("authorized key %d is not an ssh public key", i+1)) - } - } - return errors.Join(errs...) -} - -func validKeyType(t string) bool { - return strings.HasPrefix(t, "ssh-") || strings.HasPrefix(t, "ecdsa-sha2-") || strings.HasPrefix(t, "sk-") -} - -func adminMAC(index int) net.HardwareAddr { - return net.HardwareAddr{0x02, 0x4d, byte(index >> 8), byte(index), 0x00, 0x00} -} - -func nodeCables(p *topology.Plan, name string) []topology.Cable { - var out []topology.Cable - for _, c := range p.Cables { - if c.Node == name { - out = append(out, c) - } - } - return out -} - -func switchCables(p *topology.Plan, name string) []topology.Cable { - var out []topology.Cable - for _, c := range p.Cables { - if c.Switch == name { - out = append(out, c) - } - } - return out -} - -func switchSegments(p *topology.Plan, name string) []topology.SegmentPlan { - var out []topology.SegmentPlan - for _, s := range p.Segments { - if s.Switch == name { - out = append(out, s) - } - } - return out -} diff --git a/internal/lab/render/render_test.go b/internal/lab/render/render_test.go deleted file mode 100644 index 78bb186..0000000 --- a/internal/lab/render/render_test.go +++ /dev/null @@ -1,480 +0,0 @@ -package render - -import ( - "reflect" - "strings" - "testing" - - "go.yaml.in/yaml/v3" - - "git.g3e.fr/syonad/two/internal/lab/topology" -) - -const labKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation" - -const twoHypervisors = `name: evpn-2hv -images: - deb: - url: https://example.invalid/deb.qcow2 - sums: https://example.invalid/SHA512SUMS -segments: - underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 } -nodes: - sw1: { role: switch, image: deb, cpus: 2, memory: 1024 } - rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] } - hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } - hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } -` - -const twoSegments = `name: two-seg -images: - deb: - url: https://example.invalid/deb.qcow2 - sums: https://example.invalid/SHA512SUMS -segments: - red: { switch: sw, cidr: 10.1.0.0/24 } - blue: { switch: sw, cidr: 10.2.0.0/24, mtu: 1500 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 } -` - -func plan(t *testing.T, doc string) *topology.Plan { - t.Helper() - topo, err := topology.Parse([]byte(doc)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - p, err := topology.Compute(topo) - if err != nil { - t.Fatalf("Compute: %v", err) - } - return p -} - -func renderAll(t *testing.T, doc string) map[string]Node { - t.Helper() - nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}}) - if err != nil { - t.Fatalf("Render: %v", err) - } - out := map[string]Node{} - for _, n := range nodes { - out[n.Name] = n - } - return out -} - -func nodeNamed(t *testing.T, nodes map[string]Node, name string) Node { - t.Helper() - n, ok := nodes[name] - if !ok { - t.Fatalf("no rendered node %s", name) - } - return n -} - -func value(t *testing.T, args []string, flag string) []string { - t.Helper() - var out []string - for i := 0; i+1 < len(args); i++ { - if args[i] == flag { - out = append(out, args[i+1]) - } - } - if len(out) == 0 { - t.Fatalf("no %s in %v", flag, args) - } - return out -} - -func hasValue(args []string, flag, want string) bool { - for i := 0; i+1 < len(args); i++ { - if args[i] == flag && args[i+1] == want { - return true - } - } - return false -} - -func network(t *testing.T, n Node) networkDoc { - t.Helper() - var doc networkDoc - if err := yaml.Unmarshal(n.NetworkConfig, &doc); err != nil { - t.Fatalf("network-config of %s is not yaml: %v", n.Name, err) - } - return doc -} - -func iface(t *testing.T, doc networkDoc, name string) ethernet { - t.Helper() - e, ok := doc.Ethernets[name] - if !ok { - t.Fatalf("no interface %s in %v", name, doc.Ethernets) - } - return e -} - -func user(t *testing.T, n Node) cloudConfig { - t.Helper() - if !strings.HasPrefix(string(n.UserData), "#cloud-config\n") { - t.Fatalf("user-data of %s does not start with #cloud-config", n.Name) - } - var cfg cloudConfig - if err := yaml.Unmarshal(n.UserData, &cfg); err != nil { - t.Fatalf("user-data of %s is not yaml: %v", n.Name, err) - } - return cfg -} - -func fileAt(t *testing.T, cfg cloudConfig, path string) writeFile { - t.Helper() - for _, f := range cfg.WriteFiles { - if f.Path == path { - return f - } - } - t.Fatalf("no write_files entry %s", path) - return writeFile{} -} - -func TestQEMU_HypervisorArguments(t *testing.T) { - hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1") - for flag, want := range map[string]string{ - "-name": "hv1", - "-machine": "q35", - "-accel": "kvm", - "-cpu": "host", - "-smp": "4", - "-m": "16384", - "-display": "none", - "-serial": "file:/srv/lab/hv1/console.log", - "-qmp": "unix:/srv/lab/hv1/qmp.sock,server=on,wait=off", - "-pidfile": "/srv/lab/hv1/qemu.pid", - } { - if !hasValue(hv.QEMU, flag, want) { - t.Errorf("%s %s missing from %v", flag, want, hv.QEMU) - } - } - for _, want := range []string{ - "file=/srv/lab/hv1/disk.qcow2,if=virtio,format=qcow2", - "file=/srv/lab/hv1/seed.iso,media=cdrom,readonly=on", - } { - if !hasValue(hv.QEMU, "-drive", want) { - t.Errorf("-drive %s missing", want) - } - } - if hv.Dir != "/srv/lab/hv1" { - t.Errorf("Dir = %s", hv.Dir) - } - nodefaults := 0 - for _, a := range hv.QEMU { - if a == "-nodefaults" { - nodefaults++ - } - } - if nodefaults != 1 { - t.Errorf("-nodefaults appears %d times", nodefaults) - } -} - -func TestQEMU_HypervisorNetwork(t *testing.T) { - hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1") - wantNetdevs := []string{ - "user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22", - "dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003", - } - wantDevices := []string{ - "virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile=", - "virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile=", - } - if got := value(t, hv.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) { - t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs) - } - if got := value(t, hv.QEMU, "-device"); !reflect.DeepEqual(got, wantDevices) { - t.Errorf("devices = %v\nwant %v", got, wantDevices) - } -} - -func TestQEMU_SwitchNetwork(t *testing.T) { - sw := nodeNamed(t, renderAll(t, twoHypervisors), "sw1") - wantNetdevs := []string{ - "user,id=mgmt0,restrict=off,ipv6=off,hostfwd=tcp:127.0.0.1:2200-:22", - "dgram,id=p0,local.type=inet,local.host=127.0.0.1,local.port=20001,remote.type=inet,remote.host=127.0.0.1,remote.port=20000", - "dgram,id=p1,local.type=inet,local.host=127.0.0.1,local.port=20003,remote.type=inet,remote.host=127.0.0.1,remote.port=20002", - "dgram,id=p2,local.type=inet,local.host=127.0.0.1,local.port=20005,remote.type=inet,remote.host=127.0.0.1,remote.port=20004", - } - if got := value(t, sw.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) { - t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs) - } - if !hasValue(sw.QEMU, "-device", "virtio-net-pci,netdev=p2,mac=02:4c:00:03:00:01,host_mtu=9000,romfile=") { - t.Errorf("p2 device missing: %v", sw.QEMU) - } -} - -func TestQEMU_OnlyTheSwitchReachesTheOutsideThroughAdministration(t *testing.T) { - for name, n := range renderAll(t, twoHypervisors) { - admin := value(t, n.QEMU, "-netdev")[0] - wantRestrict := "restrict=on" - if name == "sw1" { - wantRestrict = "restrict=off" - } - if !strings.Contains(admin, ","+wantRestrict+",") { - t.Errorf("%s admin netdev %q, want %s", name, admin, wantRestrict) - } - if !strings.Contains(admin, "hostfwd=tcp:127.0.0.1:") { - t.Errorf("%s ssh forward not bound to loopback: %q", name, admin) - } - } -} - -func TestQEMU_EveryCableEndsMatch(t *testing.T) { - ends := map[string]int{} - for _, n := range renderAll(t, twoHypervisors) { - for _, nd := range value(t, n.QEMU, "-netdev") { - if !strings.HasPrefix(nd, "dgram,") { - continue - } - var local, remote string - for _, kv := range strings.Split(nd, ",") { - if v, ok := strings.CutPrefix(kv, "local.port="); ok { - local = v - } - if v, ok := strings.CutPrefix(kv, "remote.port="); ok { - remote = v - } - } - ends[local+">"+remote]++ - } - } - for pair, count := range ends { - local, remote, _ := strings.Cut(pair, ">") - if count != 1 || ends[remote+">"+local] != 1 { - t.Errorf("cable end %s has no single matching end", pair) - } - } - if len(ends) != 6 { - t.Errorf("%d cable ends, want 6", len(ends)) - } -} - -func TestNetworkConfig_Hypervisor(t *testing.T) { - doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1")) - if doc.Version != 2 || len(doc.Ethernets) != 2 { - t.Fatalf("network-config = %+v", doc) - } - admin := iface(t, doc, "mgmt0") - if admin.Match.MACAddress != "02:4d:00:02:00:00" || admin.SetName != "mgmt0" || - !reflect.DeepEqual(admin.Addresses, []string{"10.0.2.15/24"}) || len(admin.Routes) != 0 || admin.Nameservers != nil { - t.Errorf("mgmt0 = %+v", admin) - } - under := iface(t, doc, "underlay") - if under.Match.MACAddress != "02:4c:00:02:00:00" || under.SetName != "underlay" || under.MTU != 9000 || - !reflect.DeepEqual(under.Addresses, []string{"10.250.0.3/24"}) { - t.Errorf("underlay = %+v", under) - } - if !reflect.DeepEqual(under.Routes, []route{{To: "0.0.0.0/0", Via: "10.250.0.1"}}) { - t.Errorf("underlay routes = %+v", under.Routes) - } - if under.Nameservers == nil || !reflect.DeepEqual(under.Nameservers.Addresses, []string{"1.1.1.1", "8.8.8.8"}) { - t.Errorf("underlay nameservers = %+v", under.Nameservers) - } -} - -func TestNetworkConfig_DHCPIsExplicitlyOffEverywhere(t *testing.T) { - for name, n := range renderAll(t, twoHypervisors) { - if c := strings.Count(string(n.NetworkConfig), "dhcp4: false"); c != len(network(t, n).Ethernets) { - t.Errorf("%s: %d explicit dhcp4: false for %d interfaces", name, c, len(network(t, n).Ethernets)) - } - } -} - -func TestNetworkConfig_Switch(t *testing.T) { - doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1")) - admin := iface(t, doc, "mgmt0") - if !reflect.DeepEqual(admin.Routes, []route{{To: "0.0.0.0/0", Via: "10.0.2.2"}}) || - admin.Nameservers == nil || !reflect.DeepEqual(admin.Nameservers.Addresses, []string{"10.0.2.3"}) { - t.Errorf("switch mgmt0 = %+v", admin) - } - for port, mac := range map[string]string{"p0": "02:4c:00:01:00:01", "p1": "02:4c:00:02:00:01", "p2": "02:4c:00:03:00:01"} { - e := iface(t, doc, port) - if e.Match.MACAddress != mac || e.SetName != port || e.MTU != 9000 || len(e.Addresses) != 0 || len(e.Routes) != 0 { - t.Errorf("%s = %+v", port, e) - } - } -} - -func TestNetworkConfig_DefaultRouteOnlyOnFirstSegment(t *testing.T) { - doc := network(t, nodeNamed(t, renderAll(t, twoSegments), "hv")) - red, blue := iface(t, doc, "red"), iface(t, doc, "blue") - if !reflect.DeepEqual(red.Routes, []route{{To: "0.0.0.0/0", Via: "10.1.0.1"}}) || red.Nameservers == nil { - t.Errorf("red = %+v", red) - } - if len(blue.Routes) != 0 || blue.Nameservers != nil || blue.MTU != 1500 { - t.Errorf("blue = %+v", blue) - } -} - -func TestUserData_Hypervisor(t *testing.T) { - cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1")) - if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.Packages) != 0 || !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) { - t.Errorf("hv1 user-data = %+v", cfg) - } - if !reflect.DeepEqual(cfg.SSHAuthorizedKeys, []string{labKey}) { - t.Errorf("keys = %v", cfg.SSHAuthorizedKeys) - } -} - -func TestUserData_PasswordLoginIsExplicitlyOff(t *testing.T) { - for name, n := range renderAll(t, twoHypervisors) { - if !strings.Contains(string(n.UserData), "\nssh_pwauth: false\n") || !strings.Contains(string(n.UserData), "\ndisable_root: true\n") { - t.Errorf("%s user-data:\n%s", name, n.UserData) - } - } -} - -func TestUserData_SwitchBuildsBridgeGatewayAndNAT(t *testing.T) { - cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1")) - if !reflect.DeepEqual(cfg.Packages, []string{"nftables"}) { - t.Errorf("packages = %v", cfg.Packages) - } - script := fileAt(t, cfg, "/usr/local/sbin/lab-switch") - if script.Permissions != "0755" { - t.Errorf("script permissions = %s", script.Permissions) - } - wantScript := `#!/bin/sh -set -eu -sysctl -qw net.ipv4.ip_forward=1 -ip link add br-underlay type bridge stp_state 0 2>/dev/null || true -ip link set dev p0 master br-underlay -ip link set dev p0 up -ip link set dev p1 master br-underlay -ip link set dev p1 up -ip link set dev p2 master br-underlay -ip link set dev p2 up -ip link set dev br-underlay mtu 9000 -ip addr replace 10.250.0.1/24 dev br-underlay -ip link set dev br-underlay up -nft -f /etc/lab-switch.nft -` - if script.Content != wantScript { - t.Errorf("script:\n%s\nwant:\n%s", script.Content, wantScript) - } - nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content - for _, want := range []string{ - "add table ip lab_nat\ndelete table ip lab_nat\n", - "type nat hook postrouting priority srcnat;", - `ip saddr { 10.250.0.0/24 } oifname "mgmt0" masquerade`, - } { - if !strings.Contains(nft, want) { - t.Errorf("nft rules miss %q:\n%s", want, nft) - } - } - unit := fileAt(t, cfg, "/etc/systemd/system/lab-switch.service").Content - for _, want := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=/usr/local/sbin/lab-switch", "After=network-online.target", "WantedBy=multi-user.target"} { - if !strings.Contains(unit, want) { - t.Errorf("unit misses %q", want) - } - } - if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) { - t.Errorf("runcmd = %v", cfg.Runcmd) - } - if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-switch.service\n" { - t.Errorf("lab-provision:\n%s", got) - } -} - -func TestUserData_SwitchWithTwoSegments(t *testing.T) { - cfg := user(t, nodeNamed(t, renderAll(t, twoSegments), "sw")) - script := fileAt(t, cfg, "/usr/local/sbin/lab-switch").Content - for _, want := range []string{ - "ip link set dev p0 master br-red\n", - "ip link set dev p1 master br-blue\n", - "ip link set dev br-blue mtu 1500\n", - "ip addr replace 10.2.0.1/24 dev br-blue\n", - } { - if !strings.Contains(script, want) { - t.Errorf("script misses %q:\n%s", want, script) - } - } - if strings.Contains(script, "p1 master br-red") || strings.Contains(script, "p0 master br-blue") { - t.Errorf("port bridged on the wrong segment:\n%s", script) - } - if nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content; !strings.Contains(nft, "ip saddr { 10.1.0.0/24, 10.2.0.0/24 }") { - t.Errorf("nft:\n%s", nft) - } -} - -func TestMetaData(t *testing.T) { - var doc metaDoc - if err := yaml.Unmarshal(nodeNamed(t, renderAll(t, twoHypervisors), "hv2").MetaData, &doc); err != nil { - t.Fatal(err) - } - if doc.InstanceID != "evpn-2hv-hv2" || doc.LocalHostname != "hv2" { - t.Errorf("meta-data = %+v", doc) - } -} - -func TestRender_AdminMacsAreUniqueAndApartFromCableMacs(t *testing.T) { - seen := map[string]string{} - for name, n := range renderAll(t, twoHypervisors) { - for _, d := range value(t, n.QEMU, "-device") { - for _, kv := range strings.Split(d, ",") { - if mac, ok := strings.CutPrefix(kv, "mac="); ok { - if other, dup := seen[mac]; dup { - t.Errorf("mac %s used by %s and %s", mac, other, name) - } - seen[mac] = name - } - } - } - } - if len(seen) != 10 { - t.Errorf("%d distinct macs, want 10", len(seen)) - } -} - -func TestRender_IsStable(t *testing.T) { - first := renderAll(t, twoHypervisors) - for i := 0; i < 20; i++ { - if again := renderAll(t, twoHypervisors); !reflect.DeepEqual(first, again) { - t.Fatalf("render differs on run %d", i) - } - } -} - -func TestRender_RejectsBadOptions(t *testing.T) { - p := plan(t, twoHypervisors) - cases := []struct { - name string - opts Options - want string - }{ - {"relative run dir", Options{RunDir: "lab", AuthorizedKeys: []string{labKey}}, `run dir "lab" must be an absolute path`}, - {"no key", Options{RunDir: "/srv/lab"}, "at least one authorized ssh key"}, - {"multi-line key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey + "\nssh-rsa AAAA x"}}, "authorized key 1 spans several lines"}, - {"not a key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"hello world"}}, "authorized key 1 is not an ssh public key"}, - {"type only", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"ssh-ed25519"}}, "authorized key 1 is not an ssh public key"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - _, err := Render(p, c.opts) - if err == nil || !strings.Contains(err.Error(), c.want) { - t.Fatalf("err = %v, want %q", err, c.want) - } - }) - } -} - -func TestRender_AcceptsUsualKeyTypes(t *testing.T) { - p := plan(t, twoHypervisors) - for _, k := range []string{ - "ssh-rsa AAAAB3NzaC1yc2E user", - "ecdsa-sha2-nistp256 AAAAE2VjZHNh user", - "sk-ssh-ed25519@openssh.com AAAAGnNr user", - } { - if _, err := Render(p, Options{RunDir: "/srv/lab", AuthorizedKeys: []string{k}}); err != nil { - t.Errorf("%s rejected: %v", strings.Fields(k)[0], err) - } - } -} diff --git a/internal/lab/render/roles_test.go b/internal/lab/render/roles_test.go deleted file mode 100644 index fd0e0de..0000000 --- a/internal/lab/render/roles_test.go +++ /dev/null @@ -1,298 +0,0 @@ -package render - -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "encoding/hex" - "os" - "reflect" - "strings" - "testing" -) - -const withRoles = `name: evpn-2hv -images: - deb: - url: https://example.invalid/deb.qcow2 - sums: https://example.invalid/SHA512SUMS -segments: - underlay: { switch: sw1, cidr: 192.168.14.0/24 } -nodes: - sw1: { role: switch, image: deb, cpus: 2, memory: 1024, secondary: { underlay: [169.254.0.1/28] }, frr: sw1.conf } - rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay], secondary: { underlay: [169.254.0.3/28] }, loopback: 10.255.255.1/32, frr: rr1.conf } - hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: hv1.conf, release: 0.2.0rc002 } - hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } -` - -var frrConfigs = map[string]string{ - "sw1": "hostname sw1\nrouter bgp 65100\n", - "rr1": "hostname rr1\nrouter bgp 65000\n", - "hv1": "hostname hv1\nrouter bgp 64600\n", -} - -func renderRoles(t *testing.T) map[string]Node { - t.Helper() - nodes, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs}) - if err != nil { - t.Fatalf("Render: %v", err) - } - out := map[string]Node{} - for _, n := range nodes { - out[n.Name] = n - } - return out -} - -func runcmd(t *testing.T, n Node) []string { - t.Helper() - var out []string - for _, c := range user(t, n).Runcmd { - out = append(out, strings.Join(c, " ")) - } - return out -} - -const frrScriptHead = `#!/bin/sh -set -eu -export DEBIAN_FRONTEND=noninteractive -. /etc/os-release -echo "deb [signed-by=/usr/share/keyrings/frrouting.gpg] https://deb.frrouting.org/frr ${VERSION_CODENAME} frr-stable" > /etc/apt/sources.list.d/frr.list -n=0 -until apt-get update -qq --error-on=any; do - n=$((n + 1)) - [ "$n" -lt 30 ] || exit 1 - sleep 10 -done -apt-get install -y -qq --no-install-recommends frr frr-pythontools -sed -i 's/^bgpd=no/bgpd=yes/' /etc/frr/daemons -` - -func TestRoles_SecondaryAddressFollowsThePrimaryOnTheNode(t *testing.T) { - doc := network(t, nodeNamed(t, renderRoles(t), "rr1")) - got := iface(t, doc, "underlay").Addresses - if !reflect.DeepEqual(got, []string{"192.168.14.2/24", "169.254.0.3/28"}) { - t.Errorf("rr1 underlay addresses = %v", got) - } -} - -func TestRoles_SwitchCarriesItsSecondaryOnTheBridge(t *testing.T) { - script := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "sw1")), "/usr/local/sbin/lab-switch").Content - want := "ip addr replace 192.168.14.1/24 dev br-underlay\nip addr replace 169.254.0.1/28 dev br-underlay\nip link set dev br-underlay up\n" - if !strings.Contains(script, want) { - t.Errorf("lab-switch:\n%s\ndoes not contain:\n%s", script, want) - } -} - -func TestRoles_LoopbackOnADummyInterfaceReplayedAtBoot(t *testing.T) { - rr1 := nodeNamed(t, renderRoles(t), "rr1") - cfg := user(t, rr1) - - script := fileAt(t, cfg, "/usr/local/sbin/lab-node") - want := "#!/bin/sh\nset -eu\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.255.1/32 dev lo1\nip link set dev lo1 up\n" - if script.Content != want || script.Permissions != "0755" { - t.Errorf("lab-node (%s):\n%s", script.Permissions, script.Content) - } - unit := fileAt(t, cfg, "/etc/systemd/system/lab-node.service").Content - if !strings.Contains(unit, "ExecStart=/usr/local/sbin/lab-node\n") || !strings.Contains(unit, "WantedBy=multi-user.target\n") { - t.Errorf("lab-node.service:\n%s", unit) - } - want = "#!/bin/sh\nset -eu\nsystemctl daemon-reload\nsystemctl enable --now lab-node.service\n/usr/local/sbin/lab-frr\n" - if got := fileAt(t, cfg, "/usr/local/sbin/lab-provision").Content; got != want { - t.Errorf("lab-provision:\n%s\nwant:\n%s", got, want) - } -} - -func TestRoles_FRRConfigIsWrittenVerbatimAndPrivately(t *testing.T) { - for _, name := range []string{"sw1", "rr1", "hv1"} { - f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), name)), "/etc/lab/frr.conf") - if f.Content != frrConfigs[name] || f.Permissions != "0640" { - t.Errorf("%s frr.conf (%s) = %q", name, f.Permissions, f.Content) - } - } -} - -func TestRoles_FRRDaemonsDependOnTheRole(t *testing.T) { - tail := "install -o frr -g frr -m 0640 /etc/lab/frr.conf /etc/frr/frr.conf\nsystemctl restart frr\n" - bfd := "sed -i 's/^bfdd=no/bfdd=yes/' /etc/frr/daemons\n" - nodes := renderRoles(t) - for name, want := range map[string]string{ - "sw1": frrScriptHead + bfd + tail, - "rr1": frrScriptHead + bfd + tail, - "hv1": frrScriptHead + tail, - } { - f := fileAt(t, user(t, nodeNamed(t, nodes, name)), "/usr/local/sbin/lab-frr") - if f.Content != want || f.Permissions != "0755" { - t.Errorf("%s lab-frr (%s):\n%s\nwant:\n%s", name, f.Permissions, f.Content, want) - } - } -} - -func TestRoles_FRRKeyIsThePinnedRepositoryKey(t *testing.T) { - f := fileAt(t, user(t, nodeNamed(t, renderRoles(t), "hv1")), "/usr/share/keyrings/frrouting.gpg") - if f.Encoding != "b64" || f.Permissions != "0644" { - t.Errorf("key file: encoding %q, permissions %q", f.Encoding, f.Permissions) - } - key, err := base64.StdEncoding.DecodeString(f.Content) - if err != nil { - t.Fatal(err) - } - sum := sha256.Sum256(key) - if got := hex.EncodeToString(sum[:]); got != "bf10935b9296e2ce7c5d9855fa29ef30c35810b0fc4b1f53005494a04a33554d" { - t.Errorf("key sha256 = %s", got) - } -} - -func TestRoles_ProvisioningIsOneScriptThatStopsAtTheFirstFailure(t *testing.T) { - nodes := renderRoles(t) - for name, steps := range map[string]string{ - "sw1": "systemctl daemon-reload\nsystemctl enable --now lab-switch.service\n/usr/local/sbin/lab-frr\n", - "hv1": "/usr/local/sbin/lab-deploy\n/usr/local/sbin/lab-frr\n", - "hv2": "/usr/local/sbin/lab-deploy\n", - } { - cfg := user(t, nodeNamed(t, nodes, name)) - if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"/usr/local/sbin/lab-provision"}}) { - t.Errorf("%s runcmd = %q", name, cfg.Runcmd) - } - f := fileAt(t, cfg, "/usr/local/sbin/lab-provision") - if f.Content != "#!/bin/sh\nset -eu\n"+steps || f.Permissions != "0755" { - t.Errorf("%s lab-provision (%s):\n%s", name, f.Permissions, f.Content) - } - } -} - -func TestRoles_HypervisorDeploysTheReleaseThroughTheRepositoryScripts(t *testing.T) { - cfg := user(t, nodeNamed(t, renderRoles(t), "hv1")) - - f := fileAt(t, cfg, "/usr/local/sbin/lab-deploy") - want := `#!/bin/sh -set -eu -n=0 -until curl -fsS -o /dev/null https://git.g3e.fr/; do - n=$((n + 1)) - [ "$n" -lt 30 ] || exit 1 - sleep 10 -done -cd /opt/two/scripts -bash ./deploy.sh --noup_script -i -u underlay -t 0.2.0rc002 -` - if f.Content != want || f.Permissions != "0755" { - t.Errorf("lab-deploy (%s):\n%s\nwant:\n%s", f.Permissions, f.Content, want) - } - - for path, source := range map[string]string{ - "/opt/two/scripts/deploy.sh": "../../../scripts/deploy.sh", - "/opt/two/scripts/bootstrap_kvm.sh": "../../../scripts/bootstrap_kvm.sh", - } { - f := fileAt(t, cfg, path) - got, err := base64.StdEncoding.DecodeString(f.Content) - if err != nil { - t.Fatal(err) - } - repo, err := os.ReadFile(source) - if err != nil { - t.Fatal(err) - } - if f.Encoding != "b64" || f.Permissions != "0755" || !bytes.Equal(got, repo) { - t.Errorf("%s: encoding %q, permissions %q, identical to %s: %v", path, f.Encoding, f.Permissions, source, bytes.Equal(got, repo)) - } - } -} - -func TestRoles_UplinkIsTheInterfaceCarryingTheDefaultRoute(t *testing.T) { - hv := nodeNamed(t, renderAll(t, twoSegments), "hv") - var withDefault []string - for name, e := range network(t, hv).Ethernets { - for _, r := range e.Routes { - if r.To == "0.0.0.0/0" { - withDefault = append(withDefault, name) - } - } - } - if !reflect.DeepEqual(withDefault, []string{"red"}) { - t.Fatalf("default route on %v, want [red]", withDefault) - } - if got := fileAt(t, user(t, hv), "/usr/local/sbin/lab-deploy").Content; !strings.HasSuffix(got, "bash ./deploy.sh --noup_script -i -u red -t 0.2.0rc002\n") { - t.Errorf("lab-deploy:\n%s", got) - } -} - -func TestRoles_OnlyHypervisorsDeployTwo(t *testing.T) { - nodes := renderRoles(t) - for _, name := range []string{"sw1", "rr1"} { - for _, f := range user(t, nodeNamed(t, nodes, name)).WriteFiles { - if strings.HasPrefix(f.Path, "/opt/two/") || f.Path == "/usr/local/sbin/lab-deploy" { - t.Errorf("%s receives %s", name, f.Path) - } - } - } -} - -func TestRoles_NodeWithoutRoleFieldsGetsNoFRR(t *testing.T) { - for _, f := range user(t, nodeNamed(t, renderRoles(t), "hv2")).WriteFiles { - if strings.Contains(f.Path, "frr") { - t.Errorf("hv2 receives %s", f.Path) - } - } -} - -func TestRoles_RefusesAnUnreadFRRConfig(t *testing.T) { - configs := map[string]string{"sw1": "x", "rr1": "y"} - _, err := Render(plan(t, withRoles), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: configs}) - if err == nil || err.Error() != "node hv1: frr configuration hv1.conf was not read" { - t.Errorf("error = %v", err) - } -} - -func TestRoles_SwitchLoopbackIsCreatedByTheSwitchScript(t *testing.T) { - doc := strings.Replace(withRoles, "secondary: { underlay: [169.254.0.1/28] }, frr: sw1.conf", "secondary: { underlay: [169.254.0.1/28] }, loopback: 10.255.254.1/32, frr: sw1.conf", 1) - nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs}) - if err != nil { - t.Fatalf("Render: %v", err) - } - var sw1 Node - for _, n := range nodes { - if n.Name == "sw1" { - sw1 = n - } - } - script := fileAt(t, user(t, sw1), "/usr/local/sbin/lab-switch").Content - want := "ip link set dev br-underlay up\nip link add lo1 type dummy 2>/dev/null || true\nip addr replace 10.255.254.1/32 dev lo1\nip link set dev lo1 up\nnft -f /etc/lab-switch.nft\n" - if !strings.HasSuffix(script, want) { - t.Errorf("lab-switch:\n%s\ndoes not end with:\n%s", script, want) - } -} - -func TestRoles_HypervisorAgentConfigIsWrittenBeforeTheDeployment(t *testing.T) { - doc := strings.Replace(withRoles, "frr: hv1.conf, release: 0.2.0rc002 }", "frr: hv1.conf, release: 0.2.0rc002, agent: two.yml }", 1) - nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs, Agent: map[string]string{"hv1": "dhcp:\n backend: two\n"}}) - if err != nil { - t.Fatalf("Render: %v", err) - } - var hv1, hv2 Node - for _, n := range nodes { - switch n.Name { - case "hv1": - hv1 = n - case "hv2": - hv2 = n - } - } - f := fileAt(t, user(t, hv1), "/etc/two/agent.yml") - if f.Content != "dhcp:\n backend: two\n" || f.Permissions != "0640" { - t.Errorf("agent.yml (%s) = %q", f.Permissions, f.Content) - } - for _, w := range user(t, hv2).WriteFiles { - if w.Path == "/etc/two/agent.yml" { - t.Error("hv2 receives an agent.yml it does not declare") - } - } -} - -func TestRoles_RefusesAnUnreadAgentConfig(t *testing.T) { - doc := strings.Replace(withRoles, "frr: hv1.conf, release: 0.2.0rc002 }", "frr: hv1.conf, release: 0.2.0rc002, agent: two.yml }", 1) - _, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}, FRR: frrConfigs}) - if err == nil || err.Error() != "node hv1: agent configuration two.yml was not read" { - t.Errorf("error = %v", err) - } -} diff --git a/internal/lab/render/seed.go b/internal/lab/render/seed.go deleted file mode 100644 index 0255140..0000000 --- a/internal/lab/render/seed.go +++ /dev/null @@ -1,294 +0,0 @@ -package render - -import ( - _ "embed" - "encoding/base64" - "fmt" - "strings" - - "go.yaml.in/yaml/v3" - - "git.g3e.fr/syonad/two/internal/lab/topology" - "git.g3e.fr/syonad/two/scripts" -) - -const ( - SwitchScript = "/usr/local/sbin/lab-switch" - SwitchNFT = "/etc/lab-switch.nft" - SwitchUnit = "/etc/systemd/system/lab-switch.service" - - NodeScript = "/usr/local/sbin/lab-node" - NodeUnit = "/etc/systemd/system/lab-node.service" - - FRRKey = "/usr/share/keyrings/frrouting.gpg" - FRRConfig = "/etc/lab/frr.conf" - FRRScript = "/usr/local/sbin/lab-frr" - FRRSuite = "frr-stable" - FRRRepo = "https://deb.frrouting.org/frr" - FRRPackages = "frr frr-pythontools" - - ProvisionScript = "/usr/local/sbin/lab-provision" - DeployScript = "/usr/local/sbin/lab-deploy" - TwoScriptsDir = "/opt/two/scripts" - TwoGitServer = "https://git.g3e.fr/" - AgentConfig = "/etc/two/agent.yml" -) - -//go:embed frrouting.gpg -var frrKey []byte - -type metaDoc struct { - InstanceID string `yaml:"instance-id"` - LocalHostname string `yaml:"local-hostname"` -} - -type writeFile struct { - Path string `yaml:"path"` - Permissions string `yaml:"permissions"` - Encoding string `yaml:"encoding,omitempty"` - Content string `yaml:"content"` -} - -type cloudConfig struct { - Hostname string `yaml:"hostname"` - SSHPwauth bool `yaml:"ssh_pwauth"` - DisableRoot bool `yaml:"disable_root"` - SSHAuthorizedKeys []string `yaml:"ssh_authorized_keys"` - Packages []string `yaml:"packages,omitempty"` - WriteFiles []writeFile `yaml:"write_files,omitempty"` - Runcmd [][]string `yaml:"runcmd,omitempty"` -} - -type match struct { - MACAddress string `yaml:"macaddress"` -} - -type route struct { - To string `yaml:"to"` - Via string `yaml:"via"` -} - -type nameservers struct { - Addresses []string `yaml:"addresses"` -} - -type ethernet struct { - Match match `yaml:"match"` - SetName string `yaml:"set-name"` - DHCP4 bool `yaml:"dhcp4"` - MTU int `yaml:"mtu,omitempty"` - Addresses []string `yaml:"addresses,omitempty"` - Routes []route `yaml:"routes,omitempty"` - Nameservers *nameservers `yaml:"nameservers,omitempty"` -} - -type networkDoc struct { - Version int `yaml:"version"` - Ethernets map[string]ethernet `yaml:"ethernets"` -} - -func metaData(p *topology.Plan, n topology.NodePlan) ([]byte, error) { - return yaml.Marshal(metaDoc{InstanceID: p.Name + "-" + n.Name, LocalHostname: n.Name}) -} - -func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) { - cfg := cloudConfig{ - Hostname: n.Name, - SSHPwauth: false, - DisableRoot: true, - SSHAuthorizedKeys: o.AuthorizedKeys, - } - var steps []string - switch { - case n.Role == topology.RoleSwitch: - cfg.Packages = []string{"nftables"} - cfg.WriteFiles = []writeFile{ - {Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n)}, - {Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)}, - {Path: SwitchUnit, Permissions: "0644", Content: unit("Lab switch: bridges, gateways and NAT", SwitchScript)}, - } - steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-switch.service") - case n.Loopback.IsValid(): - cfg.WriteFiles = []writeFile{ - {Path: NodeScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + loopbackLines(n)}, - {Path: NodeUnit, Permissions: "0644", Content: unit("Lab node: loopback", NodeScript)}, - } - steps = append(steps, "systemctl daemon-reload", "systemctl enable --now lab-node.service") - } - if n.Agent != "" { - cfg.WriteFiles = append(cfg.WriteFiles, writeFile{Path: AgentConfig, Permissions: "0640", Content: o.Agent[n.Name]}) - } - if n.Role == topology.RoleHypervisor { - cfg.WriteFiles = append(cfg.WriteFiles, - writeFile{Path: TwoScriptsDir + "/deploy.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.Deploy)}, - writeFile{Path: TwoScriptsDir + "/bootstrap_kvm.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.BootstrapKVM)}, - writeFile{Path: DeployScript, Permissions: "0755", Content: deployScript(p, n)}, - ) - steps = append(steps, DeployScript) - } - if n.FRR != "" { - cfg.WriteFiles = append(cfg.WriteFiles, - writeFile{Path: FRRKey, Permissions: "0644", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(frrKey)}, - writeFile{Path: FRRConfig, Permissions: "0640", Content: o.FRR[n.Name]}, - writeFile{Path: FRRScript, Permissions: "0755", Content: frrScript(n)}, - ) - steps = append(steps, FRRScript) - } - if len(steps) > 0 { - cfg.WriteFiles = append(cfg.WriteFiles, - writeFile{Path: ProvisionScript, Permissions: "0755", Content: "#!/bin/sh\nset -eu\n" + strings.Join(steps, "\n") + "\n"}, - ) - cfg.Runcmd = [][]string{{ProvisionScript}} - } - body, err := yaml.Marshal(cfg) - if err != nil { - return nil, err - } - return append([]byte("#cloud-config\n"), body...), nil -} - -func retry(command string) string { - return "n=0\nuntil " + command + "; do\n n=$((n + 1))\n [ \"$n\" -lt 30 ] || exit 1\n sleep 10\ndone\n" -} - -func deployScript(p *topology.Plan, n topology.NodePlan) string { - uplink := nodeCables(p, n.Name)[0].NodeInterface - return "#!/bin/sh\nset -eu\n" + - retry("curl -fsS -o /dev/null "+TwoGitServer) + - "cd " + TwoScriptsDir + "\n" + - fmt.Sprintf("bash ./deploy.sh --noup_script -i -u %s -t %s\n", uplink, n.Release) -} - -func frrDaemons(n topology.NodePlan) []string { - if n.Role == topology.RoleHypervisor { - return []string{"bgpd"} - } - return []string{"bgpd", "bfdd"} -} - -func frrScript(n topology.NodePlan) string { - var b strings.Builder - b.WriteString("#!/bin/sh\nset -eu\nexport DEBIAN_FRONTEND=noninteractive\n. /etc/os-release\n") - fmt.Fprintf(&b, "echo \"deb [signed-by=%s] %s ${VERSION_CODENAME} %s\" > /etc/apt/sources.list.d/frr.list\n", FRRKey, FRRRepo, FRRSuite) - b.WriteString(retry("apt-get update -qq --error-on=any")) - fmt.Fprintf(&b, "apt-get install -y -qq --no-install-recommends %s\n", FRRPackages) - for _, d := range frrDaemons(n) { - fmt.Fprintf(&b, "sed -i 's/^%s=no/%s=yes/' /etc/frr/daemons\n", d, d) - } - fmt.Fprintf(&b, "install -o frr -g frr -m 0640 %s /etc/frr/frr.conf\n", FRRConfig) - b.WriteString("systemctl restart frr\n") - return b.String() -} - -func loopbackLines(n topology.NodePlan) string { - if !n.Loopback.IsValid() { - return "" - } - return fmt.Sprintf("ip link add %s type dummy 2>/dev/null || true\nip addr replace %s dev %s\nip link set dev %s up\n", - topology.LoopbackInterface, n.Loopback, topology.LoopbackInterface, topology.LoopbackInterface) -} - -func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, error) { - doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}} - admin := ethernet{ - Match: match{MACAddress: adminMAC(index).String()}, - SetName: AdminInterface, - Addresses: []string{AdminAddress}, - } - if n.Role == topology.RoleSwitch { - admin.Routes = []route{{To: "0.0.0.0/0", Via: AdminGateway}} - admin.Nameservers = &nameservers{Addresses: []string{AdminDNS}} - for _, c := range switchCables(p, n.Name) { - doc.Ethernets[c.SwitchInterface] = ethernet{ - Match: match{MACAddress: c.SwitchMAC.String()}, - SetName: c.SwitchInterface, - MTU: c.MTU, - } - } - } - doc.Ethernets[AdminInterface] = admin - - for i, c := range nodeCables(p, n.Name) { - e := ethernet{ - Match: match{MACAddress: c.NodeMAC.String()}, - SetName: c.NodeInterface, - MTU: c.MTU, - Addresses: []string{c.NodeAddress.String()}, - } - for _, prefix := range n.Secondary[c.Segment] { - e.Addresses = append(e.Addresses, prefix.String()) - } - if i == 0 { - e.Routes = []route{{To: "0.0.0.0/0", Via: gatewayOf(p, c.Segment)}} - e.Nameservers = &nameservers{Addresses: Nameservers} - } - doc.Ethernets[c.NodeInterface] = e - } - return yaml.Marshal(doc) -} - -func gatewayOf(p *topology.Plan, segment string) string { - for _, s := range p.Segments { - if s.Name == segment { - return s.Gateway.String() - } - } - return "" -} - -func switchScript(p *topology.Plan, n topology.NodePlan) string { - name := n.Name - var b strings.Builder - b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n") - for _, s := range switchSegments(p, name) { - fmt.Fprintf(&b, "ip link add %s type bridge stp_state 0 2>/dev/null || true\n", s.Bridge) - for _, c := range switchCables(p, name) { - if c.Segment != s.Name { - continue - } - fmt.Fprintf(&b, "ip link set dev %s master %s\n", c.SwitchInterface, s.Bridge) - fmt.Fprintf(&b, "ip link set dev %s up\n", c.SwitchInterface) - } - fmt.Fprintf(&b, "ip link set dev %s mtu %d\n", s.Bridge, s.MTU) - fmt.Fprintf(&b, "ip addr replace %s/%d dev %s\n", s.Gateway, s.Network.Bits(), s.Bridge) - for _, prefix := range n.Secondary[s.Name] { - fmt.Fprintf(&b, "ip addr replace %s dev %s\n", prefix, s.Bridge) - } - fmt.Fprintf(&b, "ip link set dev %s up\n", s.Bridge) - } - b.WriteString(loopbackLines(n)) - fmt.Fprintf(&b, "nft -f %s\n", SwitchNFT) - return b.String() -} - -func switchNFT(p *topology.Plan, name string) string { - var networks []string - for _, s := range switchSegments(p, name) { - networks = append(networks, s.Network.String()) - } - return fmt.Sprintf(`add table ip lab_nat -delete table ip lab_nat -table ip lab_nat { - chain postrouting { - type nat hook postrouting priority srcnat; policy accept; - ip saddr { %s } oifname "%s" masquerade - } -} -`, strings.Join(networks, ", "), AdminInterface) -} - -func unit(description, script string) string { - return fmt.Sprintf(`[Unit] -Description=%s -Wants=network-online.target -After=network-online.target - -[Service] -Type=oneshot -RemainAfterExit=yes -ExecStart=%s - -[Install] -WantedBy=multi-user.target -`, description, script) -} diff --git a/internal/lab/topology/format.go b/internal/lab/topology/format.go deleted file mode 100644 index 1ba73fd..0000000 --- a/internal/lab/topology/format.go +++ /dev/null @@ -1,80 +0,0 @@ -package topology - -import ( - "fmt" - "io" - "path/filepath" - "sort" - "strings" - "text/tabwriter" -) - -func (p *Plan) Write(w io.Writer) error { - tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - - fmt.Fprintf(tw, "lab %s: nodes %d, segments %d, cables %d\n", p.Name, len(p.Nodes), len(p.Segments), len(p.Cables)) - - fmt.Fprintf(tw, "\nnodes\n") - fmt.Fprintf(tw, " name\trole\timage\tcpus\tmemory\tssh\n") - for _, n := range p.Nodes { - fmt.Fprintf(tw, " %s\t%s\t%s\t%d\t%d MiB\t127.0.0.1:%d\n", n.Name, n.Role, n.Image, n.CPUs, n.Memory, n.SSHPort) - } - - var extras []NodePlan - for _, n := range p.Nodes { - if len(n.Secondary) > 0 || n.Loopback.IsValid() || n.FRR != "" || n.Release != "" || n.Agent != "" { - extras = append(extras, n) - } - } - if len(extras) > 0 { - fmt.Fprintf(tw, "\nroles\n") - fmt.Fprintf(tw, " name\tloopback\tsecondary\tfrr\trelease\tagent\n") - for _, n := range extras { - fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%s\t%s\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)), orDash(n.Release), orDash(filepath.Base(n.Agent))) - } - } - - for _, s := range p.Segments { - fmt.Fprintf(tw, "\nsegment %s: %s, mtu %d, switch %s, bridge %s, gateway %s\n", - s.Name, s.Network, s.MTU, s.Switch, s.Bridge, s.Gateway) - fmt.Fprintf(tw, " node\tinterface\taddress\tmac\tudp\t\tswitch port\tmac\tudp\n") - for _, c := range p.Cables { - if c.Segment != s.Name { - continue - } - fmt.Fprintf(tw, " %s\t%s\t%s\t%s\t%d\t<->\t%s %s\t%s\t%d\n", - c.Node, c.NodeInterface, c.NodeAddress, c.NodeMAC, c.NodePort, - c.Switch, c.SwitchInterface, c.SwitchMAC, c.SwitchPort) - } - } - return tw.Flush() -} - -func loopback(n NodePlan) string { - if !n.Loopback.IsValid() { - return "" - } - return LoopbackInterface + " " + n.Loopback.String() -} - -func secondary(n NodePlan) string { - segments := make([]string, 0, len(n.Secondary)) - for s := range n.Secondary { - segments = append(segments, s) - } - sort.Strings(segments) - var parts []string - for _, s := range segments { - for _, prefix := range n.Secondary[s] { - parts = append(parts, s+" "+prefix.String()) - } - } - return strings.Join(parts, ", ") -} - -func orDash(s string) string { - if s == "" || s == "." { - return "-" - } - return s -} diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go deleted file mode 100644 index e5ba293..0000000 --- a/internal/lab/topology/plan.go +++ /dev/null @@ -1,216 +0,0 @@ -package topology - -import ( - "errors" - "fmt" - "net" - "net/netip" - "strconv" -) - -const ( - SSHBasePort = 2200 - CableBasePort = 20000 - - maxNodes = 1000 - maxSegments = 256 -) - -type Plan struct { - Name string - Images []Image - Segments []SegmentPlan - Nodes []NodePlan - Cables []Cable -} - -type SegmentPlan struct { - Name string - Switch string - Bridge string - Network netip.Prefix - Gateway netip.Addr - MTU int -} - -type NodePlan struct { - Name string - Role string - Image string - CPUs int - Memory int - SSHPort int - Secondary map[string][]netip.Prefix - Loopback netip.Prefix - FRR string - Release string - Agent string -} - -type Cable struct { - Segment string - MTU int - - Node string - NodeInterface string - NodeMAC net.HardwareAddr - NodeAddress netip.Prefix - NodePort int - - Switch string - SwitchInterface string - SwitchMAC net.HardwareAddr - SwitchPort int -} - -func Compute(t *Topology) (*Plan, error) { - if err := t.Validate(); err != nil { - return nil, err - } - - p := &Plan{Name: t.Name, Images: append([]Image(nil), t.Images...)} - for i, n := range t.Nodes { - node := NodePlan{ - Name: n.Name, - Role: n.Role, - Image: n.Image, - CPUs: n.CPUs, - Memory: n.Memory, - SSHPort: SSHBasePort + i, - FRR: n.FRR, - Release: n.Release, - Agent: n.Agent, - } - for segment, raws := range n.Secondary { - for _, raw := range raws { - if node.Secondary == nil { - node.Secondary = map[string][]netip.Prefix{} - } - node.Secondary[segment] = append(node.Secondary[segment], netip.MustParsePrefix(raw)) - } - } - if n.Loopback != "" { - node.Loopback = netip.MustParsePrefix(n.Loopback) - } - p.Nodes = append(p.Nodes, node) - } - - var errs []error - for segIndex, s := range t.Segments { - network := netip.MustParsePrefix(s.CIDR) - gateway := network.Addr().Next() - p.Segments = append(p.Segments, SegmentPlan{ - Name: s.Name, - Switch: s.Switch, - Bridge: "br-" + s.Name, - Network: network, - Gateway: gateway, - MTU: s.MTU, - }) - - addresses, err := allocate(t, s, network, gateway) - if err != nil { - errs = append(errs, err) - continue - } - - for nodeIndex, n := range t.Nodes { - address, ok := addresses[n.Name] - if !ok { - continue - } - cable := len(p.Cables) - p.Cables = append(p.Cables, Cable{ - Segment: s.Name, - MTU: s.MTU, - Node: n.Name, - NodeInterface: s.Name, - NodeMAC: mac(nodeIndex, segIndex, 0), - NodeAddress: netip.PrefixFrom(address, network.Bits()), - NodePort: CableBasePort + 2*cable, - Switch: s.Switch, - SwitchInterface: "p" + strconv.Itoa(cable), - SwitchMAC: mac(nodeIndex, segIndex, 1), - SwitchPort: CableBasePort + 2*cable + 1, - }) - } - } - - if last := CableBasePort + 2*len(p.Cables) - 1; last > 65535 { - errs = append(errs, fmt.Errorf("cables: %d cables need udp ports up to %d, beyond 65535", len(p.Cables), last)) - } - if len(errs) > 0 { - return nil, errors.Join(errs...) - } - return p, nil -} - -func allocate(t *Topology, s Segment, network netip.Prefix, gateway netip.Addr) (map[string]netip.Addr, error) { - broadcast := lastAddr(network) - used := map[netip.Addr]string{gateway: s.Switch + " (gateway)"} - result := map[string]netip.Addr{} - var errs []error - - var auto []string - for _, n := range t.Nodes { - if !contains(n.Segments, s.Name) { - continue - } - raw, ok := n.Addresses[s.Name] - if !ok { - auto = append(auto, n.Name) - continue - } - addr := netip.MustParseAddr(raw) - switch { - case !network.Contains(addr): - errs = append(errs, fmt.Errorf("node %s: address %s is outside segment %s (%s)", n.Name, addr, s.Name, network)) - case addr == network.Addr() || addr == broadcast: - errs = append(errs, fmt.Errorf("node %s: address %s is the network or broadcast address of segment %s", n.Name, addr, s.Name)) - case used[addr] != "": - errs = append(errs, fmt.Errorf("node %s: address %s on segment %s is already taken by %s", n.Name, addr, s.Name, used[addr])) - default: - used[addr] = n.Name - result[n.Name] = addr - } - } - - next := gateway.Next() - for _, name := range auto { - for next != broadcast && used[next] != "" { - next = next.Next() - } - if next == broadcast { - errs = append(errs, fmt.Errorf("segment %s: no address left in %s for node %s", s.Name, network, name)) - break - } - used[next] = name - result[name] = next - next = next.Next() - } - - if len(errs) > 0 { - return nil, errors.Join(errs...) - } - return result, nil -} - -func lastAddr(network netip.Prefix) netip.Addr { - a := network.Addr().As4() - host := uint32(1)<<(32-network.Bits()) - 1 - v := (uint32(a[0])<<24 | uint32(a[1])<<16 | uint32(a[2])<<8 | uint32(a[3])) | host - return netip.AddrFrom4([4]byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)}) -} - -func mac(node, segment, side int) net.HardwareAddr { - return net.HardwareAddr{0x02, 0x4c, byte(node >> 8), byte(node), byte(segment), byte(side)} -} - -func contains(list []string, value string) bool { - for _, v := range list { - if v == value { - return true - } - } - return false -} diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go deleted file mode 100644 index c8d4e05..0000000 --- a/internal/lab/topology/plan_test.go +++ /dev/null @@ -1,364 +0,0 @@ -package topology - -import ( - "bytes" - "fmt" - "net/netip" - "reflect" - "strings" - "testing" -) - -func compute(t *testing.T, doc string) *Plan { - t.Helper() - p, err := Compute(parse(t, doc)) - if err != nil { - t.Fatalf("Compute: %v", err) - } - return p -} - -func computeError(t *testing.T, doc string) string { - t.Helper() - _, err := Compute(parse(t, doc)) - if err == nil { - t.Fatalf("Compute accepted:\n%s", doc) - } - return err.Error() -} - -func cableOf(t *testing.T, p *Plan, node, segment string) Cable { - t.Helper() - for _, c := range p.Cables { - if c.Node == node && c.Segment == segment { - return c - } - } - t.Fatalf("no cable for %s on %s", node, segment) - return Cable{} -} - -func nodeOf(t *testing.T, p *Plan, name string) NodePlan { - t.Helper() - for _, n := range p.Nodes { - if n.Name == name { - return n - } - } - t.Fatalf("no node %s", name) - return NodePlan{} -} - -const twoHypervisors = header + ` -segments: - underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 } -nodes: - sw1: { role: switch, image: deb, cpus: 2, memory: 1024 } - rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] } - hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } - hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], release: 0.2.0rc002 } -` - -func TestCompute_TwoHypervisorsPlan(t *testing.T) { - p := compute(t, twoHypervisors) - - if len(p.Segments) != 1 { - t.Fatalf("%d segments, want 1", len(p.Segments)) - } - s := p.Segments[0] - if s.Bridge != "br-underlay" || s.Gateway != netip.MustParseAddr("10.250.0.1") || s.MTU != 9000 || s.Switch != "sw1" { - t.Errorf("segment = %+v", s) - } - - want := []struct { - node, address, nodeMAC, switchMAC, switchIface string - nodePort, switchPort int - }{ - {"rr1", "10.250.0.2/24", "02:4c:00:01:00:00", "02:4c:00:01:00:01", "p0", 20000, 20001}, - {"hv1", "10.250.0.3/24", "02:4c:00:02:00:00", "02:4c:00:02:00:01", "p1", 20002, 20003}, - {"hv2", "10.250.0.4/24", "02:4c:00:03:00:00", "02:4c:00:03:00:01", "p2", 20004, 20005}, - } - if len(p.Cables) != len(want) { - t.Fatalf("%d cables, want %d", len(p.Cables), len(want)) - } - for _, w := range want { - c := cableOf(t, p, w.node, "underlay") - if c.NodeAddress.String() != w.address { - t.Errorf("%s address = %s, want %s", w.node, c.NodeAddress, w.address) - } - if c.NodeMAC.String() != w.nodeMAC || c.SwitchMAC.String() != w.switchMAC { - t.Errorf("%s macs = %s / %s, want %s / %s", w.node, c.NodeMAC, c.SwitchMAC, w.nodeMAC, w.switchMAC) - } - if c.NodePort != w.nodePort || c.SwitchPort != w.switchPort { - t.Errorf("%s ports = %d / %d, want %d / %d", w.node, c.NodePort, c.SwitchPort, w.nodePort, w.switchPort) - } - if c.NodeInterface != "underlay" || c.SwitchInterface != w.switchIface || c.Switch != "sw1" || c.MTU != 9000 { - t.Errorf("%s cable = %+v", w.node, c) - } - } - - for name, port := range map[string]int{"sw1": 2200, "rr1": 2201, "hv1": 2202, "hv2": 2203} { - if got := nodeOf(t, p, name).SSHPort; got != port { - t.Errorf("%s ssh port = %d, want %d", name, got, port) - } - } - if hv := nodeOf(t, p, "hv1"); hv.Role != "hypervisor" || hv.CPUs != 4 || hv.Memory != 16384 || hv.Image != "deb" { - t.Errorf("hv1 = %+v", hv) - } -} - -func TestCompute_CarriesTheDeclaredImages(t *testing.T) { - p := compute(t, twoHypervisors) - want := []Image{{Name: "deb", URL: "https://example.invalid/deb.qcow2", Sums: "https://example.invalid/SHA512SUMS"}} - if !reflect.DeepEqual(p.Images, want) { - t.Errorf("images = %+v, want %+v", p.Images, want) - } -} - -func TestCompute_IsStableAcrossRuns(t *testing.T) { - first := compute(t, twoHypervisors) - for i := 0; i < 20; i++ { - if again := compute(t, twoHypervisors); !reflect.DeepEqual(first, again) { - t.Fatalf("plan differs on run %d", i) - } - } -} - -func TestCompute_AddressesFollowDeclarationOrderNotNames(t *testing.T) { - p := compute(t, header+` -segments: - under: { switch: sw, cidr: 10.0.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - zeta: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } - alfa: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } -`) - if got := cableOf(t, p, "zeta", "under").NodeAddress.String(); got != "10.0.0.2/24" { - t.Errorf("zeta = %s, want 10.0.0.2/24", got) - } - if got := cableOf(t, p, "alfa", "under").NodeAddress.String(); got != "10.0.0.3/24" { - t.Errorf("alfa = %s, want 10.0.0.3/24", got) - } -} - -func TestCompute_ExplicitAddressIsKeptAndSkippedByAllocation(t *testing.T) { - p := compute(t, header+` -segments: - under: { switch: sw, cidr: 10.0.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } - b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under], addresses: {under: 10.0.0.3} } - c: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } -`) - for node, want := range map[string]string{"a": "10.0.0.2/24", "b": "10.0.0.3/24", "c": "10.0.0.4/24"} { - if got := cableOf(t, p, node, "under").NodeAddress.String(); got != want { - t.Errorf("%s = %s, want %s", node, got, want) - } - } -} - -func TestCompute_TwoSegmentsCableOrderMacsAndPorts(t *testing.T) { - p := compute(t, header+` -segments: - red: { switch: sw, cidr: 10.1.0.0/24 } - blue: { switch: sw, cidr: 10.2.0.0/24, mtu: 1500 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red], release: 0.2.0rc002 } - rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red] } -`) - got := make([]string, 0, len(p.Cables)) - for _, c := range p.Cables { - got = append(got, c.Segment+"/"+c.Node+"/"+c.SwitchInterface) - } - if strings.Join(got, ",") != "red/hv/p0,red/rr/p1,blue/hv/p2" { - t.Errorf("cable order = %v", got) - } - blue := cableOf(t, p, "hv", "blue") - if blue.NodeMAC.String() != "02:4c:00:01:01:00" || blue.NodePort != 20004 || blue.MTU != 1500 || blue.NodeAddress.String() != "10.2.0.2/24" { - t.Errorf("hv on blue = %+v", blue) - } - if red := cableOf(t, p, "hv", "red"); red.NodeMAC.String() != "02:4c:00:01:00:00" { - t.Errorf("hv on red mac = %s", red.NodeMAC) - } -} - -func TestCompute_EveryMacAndPortIsUnique(t *testing.T) { - p := compute(t, header+` -segments: - a: { switch: sw, cidr: 10.1.0.0/24 } - b: { switch: sw, cidr: 10.2.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - n1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [a, b] } - n2: { role: rr, image: deb, cpus: 1, memory: 512, segments: [a, b] } - n3: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [b, a], release: 0.2.0rc002 } -`) - macs := map[string]bool{} - ports := map[int]bool{} - for _, n := range p.Nodes { - ports[n.SSHPort] = true - } - for _, c := range p.Cables { - for _, m := range []string{c.NodeMAC.String(), c.SwitchMAC.String()} { - if macs[m] { - t.Errorf("mac %s used twice", m) - } - macs[m] = true - } - for _, port := range []int{c.NodePort, c.SwitchPort} { - if ports[port] { - t.Errorf("port %d used twice", port) - } - ports[port] = true - } - } - if len(macs) != 12 { - t.Errorf("%d distinct macs, want 12", len(macs)) - } -} - -func TestCompute_MacsAreLocallyAdministeredUnicast(t *testing.T) { - for _, c := range compute(t, twoHypervisors).Cables { - for _, m := range []string{c.NodeMAC.String(), c.SwitchMAC.String()} { - if !strings.HasPrefix(m, "02:") { - t.Errorf("mac %s is not locally administered unicast", m) - } - } - } -} - -func TestCompute_AddressRejections(t *testing.T) { - base := func(addresses string) string { - return header + ` -segments: - under: { switch: sw, cidr: 10.0.0.0/29 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under]` + addresses + ` } - b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under], addresses: {under: 10.0.0.5} } -` - } - cases := []struct{ name, addresses, want string }{ - {"outside", ", addresses: {under: 10.0.1.2}", "node a: address 10.0.1.2 is outside segment under"}, - {"network", ", addresses: {under: 10.0.0.0}", "node a: address 10.0.0.0 is the network or broadcast"}, - {"broadcast", ", addresses: {under: 10.0.0.7}", "node a: address 10.0.0.7 is the network or broadcast"}, - {"gateway", ", addresses: {under: 10.0.0.1}", "node a: address 10.0.0.1 on segment under is already taken by sw (gateway)"}, - {"duplicate", ", addresses: {under: 10.0.0.5}", "node b: address 10.0.0.5 on segment under is already taken by a"}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - requireContains(t, computeError(t, base(c.addresses)), c.want) - }) - } -} - -func TestCompute_ReportsExhaustedSegment(t *testing.T) { - got := computeError(t, header+` -segments: - tiny: { switch: sw, cidr: 10.0.0.0/30 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } - b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } -`) - requireContains(t, got, "segment tiny: no address left in 10.0.0.0/30 for node b") -} - -func TestCompute_FillsSegmentExactly(t *testing.T) { - p := compute(t, header+` -segments: - tiny: { switch: sw, cidr: 10.0.0.0/29 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - a: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } - b: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } - c: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } - d: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } - e: { role: rr, image: deb, cpus: 1, memory: 512, segments: [tiny] } -`) - if got := cableOf(t, p, "e", "tiny").NodeAddress.String(); got != "10.0.0.6/29" { - t.Errorf("last node = %s, want 10.0.0.6/29", got) - } -} - -func TestCompute_RefusesInvalidTopology(t *testing.T) { - requireContains(t, computeError(t, strings.Replace(valid, "role: rr", "role: router", 1)), `role "router"`) -} - -func TestLastAddr(t *testing.T) { - for cidr, want := range map[string]string{ - "10.0.0.0/24": "10.0.0.255", - "10.0.0.0/30": "10.0.0.3", - "10.250.0.0/16": "10.250.255.255", - "192.168.4.8/29": "192.168.4.15", - } { - if got := lastAddr(netip.MustParsePrefix(cidr)).String(); got != want { - t.Errorf("lastAddr(%s) = %s, want %s", cidr, got, want) - } - } -} - -func TestWrite_TwoHypervisorsPlan(t *testing.T) { - var buf bytes.Buffer - if err := compute(t, twoHypervisors).Write(&buf); err != nil { - t.Fatalf("Write: %v", err) - } - want := `lab lab-test: nodes 4, segments 1, cables 3 - -nodes - name role image cpus memory ssh - sw1 switch deb 2 1024 MiB 127.0.0.1:2200 - rr1 rr deb 1 1024 MiB 127.0.0.1:2201 - hv1 hypervisor deb 4 16384 MiB 127.0.0.1:2202 - hv2 hypervisor deb 4 16384 MiB 127.0.0.1:2203 - -roles - name loopback secondary frr release agent - hv1 - - - 0.2.0rc002 - - hv2 - - - 0.2.0rc002 - - -segment underlay: 10.250.0.0/24, mtu 9000, switch sw1, bridge br-underlay, gateway 10.250.0.1 - node interface address mac udp switch port mac udp - rr1 underlay 10.250.0.2/24 02:4c:00:01:00:00 20000 <-> sw1 p0 02:4c:00:01:00:01 20001 - hv1 underlay 10.250.0.3/24 02:4c:00:02:00:00 20002 <-> sw1 p1 02:4c:00:02:00:01 20003 - hv2 underlay 10.250.0.4/24 02:4c:00:03:00:00 20004 <-> sw1 p2 02:4c:00:03:00:01 20005 -` - if buf.String() != want { - t.Errorf("output:\n%s\nwant:\n%s", buf.String(), want) - } -} - -func generated(segments, nodes int) string { - var b strings.Builder - b.WriteString(header + "segments:\n") - names := make([]string, segments) - for i := range names { - names[i] = fmt.Sprintf("s%d", i) - fmt.Fprintf(&b, " %s: { switch: sw, cidr: 10.%d.0.0/16 }\n", names[i], i%250) - } - b.WriteString("nodes:\n sw: { role: switch, image: deb, cpus: 1, memory: 512 }\n") - for i := 1; i < nodes; i++ { - fmt.Fprintf(&b, " n%d: { role: rr, image: deb, cpus: 1, memory: 512, segments: [%s] }\n", i, strings.Join(names, ", ")) - } - return b.String() -} - -func TestValidate_RejectsTooManyNodes(t *testing.T) { - requireContains(t, validationError(t, generated(1, 1001)), "nodes: 1001 declared, at most 1000") -} - -func TestValidate_AcceptsExactlyMaxNodes(t *testing.T) { - if err := parse(t, generated(1, 1000)).Validate(); err != nil { - t.Fatalf("1000 nodes rejected: %v", err) - } -} - -func TestValidate_RejectsTooManySegments(t *testing.T) { - requireContains(t, validationError(t, generated(257, 2)), "segments: 257 declared, at most 256") -} - -func TestCompute_RejectsCablesBeyondUDPPortRange(t *testing.T) { - requireContains(t, computeError(t, generated(23, 1000)), "cables: 22977 cables need udp ports up to 65953, beyond 65535") -} diff --git a/internal/lab/topology/roles_test.go b/internal/lab/topology/roles_test.go deleted file mode 100644 index d3e209c..0000000 --- a/internal/lab/topology/roles_test.go +++ /dev/null @@ -1,265 +0,0 @@ -package topology - -import ( - "bytes" - "net/netip" - "os" - "path/filepath" - "reflect" - "testing" -) - -const withRoles = header + ` -segments: - underlay: { switch: sw1, cidr: 192.168.14.0/24 } -nodes: - sw1: { role: switch, image: deb, cpus: 2, memory: 1024, secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf } - rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay], secondary: { underlay: [169.254.0.3/28] }, loopback: 10.255.255.1/32, frr: frr/rr1.conf } - hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay], frr: /etc/lab/hv1.conf, release: 0.2.0rc002 } -` - -func TestCompute_CarriesTheRoleFields(t *testing.T) { - p := compute(t, withRoles) - - rr1 := nodeOf(t, p, "rr1") - if !reflect.DeepEqual(rr1.Secondary, map[string][]netip.Prefix{"underlay": {netip.MustParsePrefix("169.254.0.3/28")}}) { - t.Errorf("rr1 secondary = %v", rr1.Secondary) - } - if rr1.Loopback != netip.MustParsePrefix("10.255.255.1/32") { - t.Errorf("rr1 loopback = %v", rr1.Loopback) - } - if rr1.FRR != "frr/rr1.conf" { - t.Errorf("rr1 frr = %q", rr1.FRR) - } - hv1 := nodeOf(t, p, "hv1") - if hv1.Secondary != nil || hv1.Loopback.IsValid() { - t.Errorf("hv1 = %+v, want no secondary and no loopback", hv1) - } -} - -func TestLoad_ResolvesFRRPathsAgainstTheTopologyFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "lab.yml") - if err := os.WriteFile(path, []byte(withRoles), 0o600); err != nil { - t.Fatal(err) - } - - topo, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - - want := map[string]string{ - "sw1": filepath.Join(dir, "frr", "sw1.conf"), - "rr1": filepath.Join(dir, "frr", "rr1.conf"), - "hv1": "/etc/lab/hv1.conf", - } - for _, n := range topo.Nodes { - if n.FRR != want[n.Name] { - t.Errorf("%s frr = %q, want %q", n.Name, n.FRR, want[n.Name]) - } - } -} - -func TestValidate_RoleFieldRejections(t *testing.T) { - cases := map[string]struct { - node string - want string - }{ - "secondary on a segment not attached": { - `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { blue: [169.254.0.3/28] } }`, - "node rr1: secondary address given for segment blue it is not attached to", - }, - "secondary without prefix length": { - `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [169.254.0.3] } }`, - `node rr1: secondary address "169.254.0.3" on red`, - }, - "secondary in IPv6": { - `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: ["fd00::3/64"] } }`, - "node rr1: secondary address fd00::3/64 on red is not IPv4", - }, - "secondary inside the segment": { - `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], secondary: { red: [10.1.0.9/24] } }`, - "node rr1: secondary address 10.1.0.9/24 is inside segment red (10.1.0.0/24), use addresses instead", - }, - "loopback without prefix length": { - `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: 10.255.255.1 }`, - `node rr1: loopback "10.255.255.1"`, - }, - "loopback in IPv6": { - `rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], loopback: "fd00::1/128" }`, - "node rr1: loopback fd00::1/128 is not IPv4", - }, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - doc := header + ` -segments: - red: { switch: sw, cidr: 10.1.0.0/24 } - blue: { switch: sw, cidr: 10.2.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 } - ` + c.node + ` -` - requireContains(t, validationError(t, doc), c.want) - }) - } -} - -func TestValidate_SwitchSecondaryOnlyOnItsOwnSegments(t *testing.T) { - doc := header + ` -segments: - red: { switch: sw, cidr: 10.1.0.0/24 } - blue: { switch: other, cidr: 10.2.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512, secondary: { red: [169.254.0.1/28], blue: [169.254.1.1/28] } } - other: { role: switch, image: deb, cpus: 1, memory: 512 } - hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red, blue], release: 0.2.0rc002 } -` - msg := validationError(t, doc) - requireContains(t, msg, "node sw: secondary address given for segment blue it is not attached to") - if bytes.Contains([]byte(msg), []byte("segment red")) { - t.Errorf("the switch's own segment was refused:\n%s", msg) - } -} - -func TestValidate_LoopbackInterfaceNameIsReserved(t *testing.T) { - doc := header + ` -segments: - lo1: { switch: sw, cidr: 10.1.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [lo1], release: 0.2.0rc002 } -` - requireContains(t, validationError(t, doc), "segment lo1: name is reserved for the loopback interface") -} - -func TestWrite_ShowsTheRoles(t *testing.T) { - var buf bytes.Buffer - if err := compute(t, withRoles).Write(&buf); err != nil { - t.Fatalf("Write: %v", err) - } - want := ` -roles - name loopback secondary frr release agent - sw1 - underlay 169.254.0.1/28 sw1.conf - - - rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf - - - hv1 - - hv1.conf 0.2.0rc002 - -` - if !bytes.Contains(buf.Bytes(), []byte(want)) { - t.Errorf("plan:\n%s\ndoes not contain:\n%s", buf.String(), want) - } -} - -func TestWrite_NoRolesSectionWithoutRoleFields(t *testing.T) { - var buf bytes.Buffer - doc := header + ` -segments: - underlay: { switch: sw1, cidr: 10.250.0.0/24 } -nodes: - sw1: { role: switch, image: deb, cpus: 1, memory: 512 } - rr1: { role: rr, image: deb, cpus: 1, memory: 512, segments: [underlay] } -` - if err := compute(t, doc).Write(&buf); err != nil { - t.Fatalf("Write: %v", err) - } - if bytes.Contains(buf.Bytes(), []byte("roles")) { - t.Errorf("plan shows a roles section:\n%s", buf.String()) - } -} - -func TestValidate_ReleaseRejections(t *testing.T) { - cases := map[string]struct { - node string - want string - }{ - "hypervisor without release": { - `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red] }`, - "node hv: a hypervisor needs the release of two to deploy (release: )", - }, - "release with shell characters": { - `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: "0.2.0; reboot" }`, - `node hv: release "0.2.0; reboot" must match`, - }, - "release on a route reflector": { - `hv: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 }`, - "node hv: release is only for hypervisors", - }, - "release on a switch": { - `hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc002 } - sw2: { role: switch, image: deb, cpus: 1, memory: 512, release: 0.2.0rc002 }`, - "node sw2: release is only for hypervisors", - }, - } - for name, c := range cases { - t.Run(name, func(t *testing.T) { - doc := header + ` -segments: - red: { switch: sw, cidr: 10.1.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - ` + c.node + ` -` - requireContains(t, validationError(t, doc), c.want) - }) - } -} - -func TestCompute_CarriesTheRelease(t *testing.T) { - if got := nodeOf(t, compute(t, withRoles), "hv1").Release; got != "0.2.0rc002" { - t.Errorf("hv1 release = %q", got) - } - if got := nodeOf(t, compute(t, withRoles), "rr1").Release; got != "" { - t.Errorf("rr1 release = %q", got) - } -} - -func TestValidate_AgentOnlyForHypervisors(t *testing.T) { - doc := header + ` -segments: - red: { switch: sw, cidr: 10.1.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], agent: agent.yml } - hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: agent.yml } -` - msg := validationError(t, doc) - requireContains(t, msg, "node rr: agent is only for hypervisors") - if bytes.Contains([]byte(msg), []byte("node hv: agent")) { - t.Errorf("a hypervisor agent was refused:\n%s", msg) - } -} - -func TestLoad_ResolvesTheAgentPathAgainstTheTopologyFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "lab.yml") - doc := header + ` -segments: - red: { switch: sw, cidr: 10.1.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: agent/two.yml } - hv2: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: /etc/lab/agent.yml } -` - if err := os.WriteFile(path, []byte(doc), 0o600); err != nil { - t.Fatal(err) - } - topo, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - want := map[string]string{"sw": "", "hv1": filepath.Join(dir, "agent", "two.yml"), "hv2": "/etc/lab/agent.yml"} - for _, n := range topo.Nodes { - if n.Agent != want[n.Name] { - t.Errorf("%s agent = %q, want %q", n.Name, n.Agent, want[n.Name]) - } - } - p, err := Compute(topo) - if err != nil { - t.Fatal(err) - } - if got := nodeOf(t, p, "hv1").Agent; got != filepath.Join(dir, "agent", "two.yml") { - t.Errorf("plan hv1 agent = %q", got) - } -} diff --git a/internal/lab/topology/topology.go b/internal/lab/topology/topology.go deleted file mode 100644 index 66337d8..0000000 --- a/internal/lab/topology/topology.go +++ /dev/null @@ -1,175 +0,0 @@ -package topology - -import ( - "bytes" - "errors" - "fmt" - "os" - "path/filepath" - - "go.yaml.in/yaml/v3" -) - -const ( - RoleSwitch = "switch" - RoleRR = "rr" - RoleHypervisor = "hypervisor" - - DefaultMTU = 9000 -) - -type Topology struct { - Name string - Images []Image - Segments []Segment - Nodes []Node -} - -type Image struct { - Name string - URL string - Sums string -} - -type Segment struct { - Name string - Switch string - CIDR string - MTU int -} - -type Node struct { - Name string - Role string - Image string - CPUs int - Memory int - Segments []string - Addresses map[string]string - Secondary map[string][]string - Loopback string - FRR string - Release string - Agent string -} - -type fileImage struct { - URL string `yaml:"url"` - Sums string `yaml:"sums"` -} - -type fileSegment struct { - Switch string `yaml:"switch"` - CIDR string `yaml:"cidr"` - MTU int `yaml:"mtu"` -} - -type fileNode struct { - Role string `yaml:"role"` - Image string `yaml:"image"` - CPUs int `yaml:"cpus"` - Memory int `yaml:"memory"` - Segments []string `yaml:"segments"` - Addresses map[string]string `yaml:"addresses"` - Secondary map[string][]string `yaml:"secondary"` - Loopback string `yaml:"loopback"` - FRR string `yaml:"frr"` - Release string `yaml:"release"` - Agent string `yaml:"agent"` -} - -type file struct { - Name string `yaml:"name"` - Images map[string]fileImage `yaml:"images"` - Segments map[string]fileSegment `yaml:"segments"` - Nodes map[string]fileNode `yaml:"nodes"` -} - -func Load(path string) (*Topology, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - t, err := Parse(data) - if err != nil { - return nil, fmt.Errorf("%s: %w", path, err) - } - for i, n := range t.Nodes { - if n.FRR != "" && !filepath.IsAbs(n.FRR) { - t.Nodes[i].FRR = filepath.Join(filepath.Dir(path), n.FRR) - } - if n.Agent != "" && !filepath.IsAbs(n.Agent) { - t.Nodes[i].Agent = filepath.Join(filepath.Dir(path), n.Agent) - } - } - return t, nil -} - -func Parse(data []byte) (*Topology, error) { - var f file - dec := yaml.NewDecoder(bytes.NewReader(data)) - dec.KnownFields(true) - if err := dec.Decode(&f); err != nil { - return nil, fmt.Errorf("yaml: %w", err) - } - - var root yaml.Node - if err := yaml.Unmarshal(data, &root); err != nil { - return nil, fmt.Errorf("yaml: %w", err) - } - order, err := keyOrder(&root) - if err != nil { - return nil, err - } - - t := &Topology{Name: f.Name} - for _, name := range order["images"] { - i := f.Images[name] - t.Images = append(t.Images, Image{Name: name, URL: i.URL, Sums: i.Sums}) - } - for _, name := range order["segments"] { - s := f.Segments[name] - mtu := s.MTU - if mtu == 0 { - mtu = DefaultMTU - } - t.Segments = append(t.Segments, Segment{Name: name, Switch: s.Switch, CIDR: s.CIDR, MTU: mtu}) - } - for _, name := range order["nodes"] { - n := f.Nodes[name] - t.Nodes = append(t.Nodes, Node{ - Name: name, - Role: n.Role, - Image: n.Image, - CPUs: n.CPUs, - Memory: n.Memory, - Segments: n.Segments, - Addresses: n.Addresses, - Secondary: n.Secondary, - Loopback: n.Loopback, - FRR: n.FRR, - Release: n.Release, - Agent: n.Agent, - }) - } - return t, nil -} - -func keyOrder(root *yaml.Node) (map[string][]string, error) { - order := map[string][]string{} - if root.Kind != yaml.DocumentNode || len(root.Content) != 1 || root.Content[0].Kind != yaml.MappingNode { - return nil, errors.New("yaml: the document must be a mapping") - } - top := root.Content[0] - for i := 0; i+1 < len(top.Content); i += 2 { - section := top.Content[i].Value - value := top.Content[i+1] - if value.Kind != yaml.MappingNode { - continue - } - for j := 0; j+1 < len(value.Content); j += 2 { - order[section] = append(order[section], value.Content[j].Value) - } - } - return order, nil -} diff --git a/internal/lab/topology/topology_test.go b/internal/lab/topology/topology_test.go deleted file mode 100644 index d050a0f..0000000 --- a/internal/lab/topology/topology_test.go +++ /dev/null @@ -1,173 +0,0 @@ -package topology - -import ( - "strings" - "testing" -) - -const header = `name: lab-test -images: - deb: - url: https://example.invalid/deb.qcow2 - sums: https://example.invalid/SHA512SUMS -` - -func parse(t *testing.T, doc string) *Topology { - t.Helper() - topo, err := Parse([]byte(doc)) - if err != nil { - t.Fatalf("Parse: %v", err) - } - return topo -} - -func validationError(t *testing.T, doc string) string { - t.Helper() - err := parse(t, doc).Validate() - if err == nil { - t.Fatalf("Validate accepted an invalid topology:\n%s", doc) - } - return err.Error() -} - -func requireContains(t *testing.T, got, want string) { - t.Helper() - if !strings.Contains(got, want) { - t.Fatalf("error does not mention %q:\n%s", want, got) - } -} - -func TestParse_KeepsDeclarationOrderOfNodesAndSegments(t *testing.T) { - topo := parse(t, header+` -segments: - zulu: { switch: sw, cidr: 10.0.1.0/24 } - alpha: { switch: sw, cidr: 10.0.2.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - zeta: { role: rr, image: deb, cpus: 1, memory: 512, segments: [zulu, alpha] } - beta: { role: rr, image: deb, cpus: 1, memory: 512, segments: [zulu] } -`) - var nodes, segments []string - for _, n := range topo.Nodes { - nodes = append(nodes, n.Name) - } - for _, s := range topo.Segments { - segments = append(segments, s.Name) - } - if got := strings.Join(nodes, ","); got != "sw,zeta,beta" { - t.Errorf("node order = %s, want sw,zeta,beta", got) - } - if got := strings.Join(segments, ","); got != "zulu,alpha" { - t.Errorf("segment order = %s, want zulu,alpha", got) - } -} - -func TestParse_DefaultsMTUTo9000(t *testing.T) { - topo := parse(t, header+` -segments: - under: { switch: sw, cidr: 10.0.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } -`) - if topo.Segments[0].MTU != 9000 { - t.Errorf("MTU = %d, want 9000", topo.Segments[0].MTU) - } -} - -func TestParse_RejectsUnknownField(t *testing.T) { - _, err := Parse([]byte(header + ` -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512, ram: 4 } -`)) - if err == nil || !strings.Contains(err.Error(), "ram") { - t.Fatalf("unknown field not rejected: %v", err) - } -} - -func TestParse_RejectsDuplicateNode(t *testing.T) { - _, err := Parse([]byte(header + ` -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - sw: { role: rr, image: deb, cpus: 1, memory: 512 } -`)) - if err == nil || !strings.Contains(err.Error(), "already defined") { - t.Fatalf("duplicate node not rejected: %v", err) - } -} - -func TestParse_RejectsNonMappingDocument(t *testing.T) { - if _, err := Parse([]byte("- a\n- b\n")); err == nil { - t.Fatal("a list document was accepted") - } -} - -func TestLoad_ReportsPathOnError(t *testing.T) { - if _, err := Load("/nonexistent/lab.yml"); err == nil { - t.Fatal("missing file accepted") - } -} - -const valid = header + ` -segments: - under: { switch: sw, cidr: 10.0.0.0/24 } -nodes: - sw: { role: switch, image: deb, cpus: 1, memory: 512 } - rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [under] } -` - -func TestValidate_AcceptsMinimalTopology(t *testing.T) { - if err := parse(t, valid).Validate(); err != nil { - t.Fatalf("Validate: %v", err) - } -} - -func TestValidate_Rejections(t *testing.T) { - cases := []struct { - name, doc, want string - }{ - {"bad lab name", strings.Replace(valid, "name: lab-test", "name: Lab_Test", 1), `name "Lab_Test"`}, - {"image url not https", strings.Replace(valid, "url: https://", "url: http://", 1), "image deb: url"}, - {"image sums not https", strings.Replace(valid, "sums: https://", "sums: ftp://", 1), "image deb: sums"}, - {"no node", header + "\nsegments: {}\nnodes: {}\n", "at least one node"}, - {"segment switch missing", strings.Replace(valid, "switch: sw, cidr", "cidr", 1), "segment under: switch is required"}, - {"segment switch unknown", strings.Replace(valid, "switch: sw, cidr", "switch: ghost, cidr", 1), "switch ghost is not a declared node"}, - {"segment switch not a switch", strings.Replace(valid, "switch: sw, cidr", "switch: rr, cidr", 1), "rr is a rr, not a switch"}, - {"segment name too long", strings.ReplaceAll(valid, "under", "underlayunder"), "segment underlayunder: name must match"}, - {"segment named like the admin interface", strings.ReplaceAll(valid, "under", "mgmt0"), "segment mgmt0: name is reserved"}, - {"segment name with dash", strings.ReplaceAll(valid, "under", "un-der"), "segment un-der: name must match"}, - {"mtu too high", strings.Replace(valid, "cidr: 10.0.0.0/24", "cidr: 10.0.0.0/24, mtu: 9001", 1), "mtu 9001 out of range"}, - {"mtu too low", strings.Replace(valid, "cidr: 10.0.0.0/24", "cidr: 10.0.0.0/24, mtu: 1279", 1), "mtu 1279 out of range"}, - {"cidr unparsable", strings.Replace(valid, "10.0.0.0/24", "10.0.0/24", 1), `cidr "10.0.0/24"`}, - {"cidr ipv6", strings.Replace(valid, "10.0.0.0/24", "fd00::/64", 1), "is not IPv4"}, - {"cidr host bits", strings.Replace(valid, "10.0.0.0/24", "10.0.0.5/24", 1), "network is 10.0.0.0/24"}, - {"cidr too small", strings.Replace(valid, "10.0.0.0/24", "10.0.0.0/31", 1), "prefix length out of range"}, - {"cidr too large", strings.Replace(valid, "10.0.0.0/24", "10.0.0.0/7", 1), "prefix length out of range"}, - {"bad node name", strings.Replace(valid, " rr: {", " RR: {", 1), "node RR: name must match"}, - {"bad role", strings.Replace(valid, "role: rr", "role: router", 1), `role "router"`}, - {"unknown image", strings.Replace(valid, "role: rr, image: deb", "role: rr, image: ubuntu", 1), `image "ubuntu" is not declared`}, - {"no cpu", strings.Replace(valid, "role: rr, image: deb, cpus: 1", "role: rr, image: deb, cpus: 0", 1), "node rr: cpus must be at least 1"}, - {"memory too low", strings.Replace(valid, "cpus: 1, memory: 512, segments", "cpus: 1, memory: 255, segments", 1), "node rr: memory must be at least 256"}, - {"switch with segments", strings.Replace(valid, "role: switch, image: deb, cpus: 1, memory: 512 }", "role: switch, image: deb, cpus: 1, memory: 512, segments: [under] }", 1), "node sw: a switch carries its segments"}, - {"switch with addresses", strings.Replace(valid, "role: switch, image: deb, cpus: 1, memory: 512 }", "role: switch, image: deb, cpus: 1, memory: 512, addresses: {under: 10.0.0.9} }", 1), "node sw: a switch carries its segments"}, - {"switch without segment", valid + " sw2: { role: switch, image: deb, cpus: 1, memory: 512 }\n", "node sw2: switch carries no segment"}, - {"node without segment", valid + " rr2: { role: rr, image: deb, cpus: 1, memory: 512 }\n", "node rr2: must be attached to at least one segment"}, - {"node on unknown segment", strings.Replace(valid, "segments: [under]", "segments: [over]", 1), "node rr: segment over is not declared"}, - {"node on segment twice", strings.Replace(valid, "segments: [under]", "segments: [under, under]", 1), "node rr: segment under listed twice"}, - {"segment without node", strings.Replace(valid, " under: { switch: sw, cidr: 10.0.0.0/24 }", " under: { switch: sw, cidr: 10.0.0.0/24 }\n empty: { switch: sw, cidr: 10.0.9.0/24 }", 1), "segment empty: no node is attached"}, - {"address on foreign segment", strings.Replace(valid, "segments: [under]", "segments: [under], addresses: {over: 10.0.0.9}", 1), "address given for segment over"}, - {"address unparsable", strings.Replace(valid, "segments: [under]", "segments: [under], addresses: {under: 10.0.0}", 1), `address "10.0.0" on under`}, - } - for _, c := range cases { - t.Run(c.name, func(t *testing.T) { - requireContains(t, validationError(t, c.doc), c.want) - }) - } -} - -func TestValidate_ReportsEveryErrorAtOnce(t *testing.T) { - doc := strings.Replace(valid, "role: rr", "role: router", 1) - doc = strings.Replace(doc, "10.0.0.0/24", "10.0.0.0/31", 1) - got := validationError(t, doc) - requireContains(t, got, `role "router"`) - requireContains(t, got, "prefix length out of range") -} diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go deleted file mode 100644 index 432fedf..0000000 --- a/internal/lab/topology/validate.go +++ /dev/null @@ -1,239 +0,0 @@ -package topology - -import ( - "errors" - "fmt" - "net/netip" - "regexp" - "sort" - "strings" -) - -const ( - MinMTU = 1280 - MaxMTU = 9000 - MinMemory = 256 - MaxPrefix = 30 - MinPrefix = 8 - - ReservedInterface = "mgmt0" - LoopbackInterface = "lo1" -) - -var ( - namePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,14}$`) - segmentPattern = regexp.MustCompile(`^[a-z][a-z0-9]{0,11}$`) - releasePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`) -) - -func (t *Topology) Validate() error { - var errs []error - add := func(format string, args ...any) { - errs = append(errs, fmt.Errorf(format, args...)) - } - - if !namePattern.MatchString(t.Name) { - add("name %q: must match %s", t.Name, namePattern) - } - - images := map[string]bool{} - for _, i := range t.Images { - images[i.Name] = true - if !strings.HasPrefix(i.URL, "https://") { - add("image %s: url must be an https:// URL", i.Name) - } - if !strings.HasPrefix(i.Sums, "https://") { - add("image %s: sums must be an https:// URL", i.Name) - } - } - - nodes := map[string]Node{} - for _, n := range t.Nodes { - nodes[n.Name] = n - } - if len(t.Nodes) == 0 { - add("nodes: at least one node is required") - } - if len(t.Segments) > maxSegments { - add("segments: %d declared, at most %d", len(t.Segments), maxSegments) - } - if len(t.Nodes) > maxNodes { - add("nodes: %d declared, at most %d", len(t.Nodes), maxNodes) - } - - segments := map[string]Segment{} - for _, s := range t.Segments { - segments[s.Name] = s - if !segmentPattern.MatchString(s.Name) { - add("segment %s: name must match %s", s.Name, segmentPattern) - } - if s.Name == ReservedInterface { - add("segment %s: name is reserved for the administration interface", s.Name) - } - if s.Name == LoopbackInterface { - add("segment %s: name is reserved for the loopback interface", s.Name) - } - sw, ok := nodes[s.Switch] - switch { - case s.Switch == "": - add("segment %s: switch is required", s.Name) - case !ok: - add("segment %s: switch %s is not a declared node", s.Name, s.Switch) - case sw.Role != RoleSwitch: - add("segment %s: %s is a %s, not a switch", s.Name, s.Switch, sw.Role) - } - if s.MTU < MinMTU || s.MTU > MaxMTU { - add("segment %s: mtu %d out of range [%d, %d]", s.Name, s.MTU, MinMTU, MaxMTU) - } - prefix, err := netip.ParsePrefix(s.CIDR) - switch { - case err != nil: - add("segment %s: cidr %q: %v", s.Name, s.CIDR, err) - case !prefix.Addr().Is4(): - add("segment %s: cidr %s is not IPv4", s.Name, s.CIDR) - case prefix.Masked() != prefix: - add("segment %s: cidr %s has host bits set, network is %s", s.Name, s.CIDR, prefix.Masked()) - case prefix.Bits() < MinPrefix || prefix.Bits() > MaxPrefix: - add("segment %s: cidr %s prefix length out of range [/%d, /%d]", s.Name, s.CIDR, MinPrefix, MaxPrefix) - } - } - - attached := map[string]int{} - hosting := map[string]int{} - for _, s := range t.Segments { - hosting[s.Switch]++ - } - for _, n := range t.Nodes { - if !namePattern.MatchString(n.Name) { - add("node %s: name must match %s", n.Name, namePattern) - } - switch n.Role { - case RoleSwitch, RoleRR, RoleHypervisor: - default: - add("node %s: role %q must be one of %s, %s, %s", n.Name, n.Role, RoleSwitch, RoleRR, RoleHypervisor) - } - if !images[n.Image] { - add("node %s: image %q is not declared", n.Name, n.Image) - } - if n.CPUs < 1 { - add("node %s: cpus must be at least 1", n.Name) - } - if n.Memory < MinMemory { - add("node %s: memory must be at least %d MiB", n.Name, MinMemory) - } - validateExtras(n, segments, add) - if n.Role == RoleSwitch { - if len(n.Segments) > 0 || len(n.Addresses) > 0 { - add("node %s: a switch carries its segments through segments..switch, not through segments or addresses", n.Name) - } - if hosting[n.Name] == 0 { - add("node %s: switch carries no segment", n.Name) - } - continue - } - if len(n.Segments) == 0 { - add("node %s: must be attached to at least one segment", n.Name) - } - seen := map[string]bool{} - for _, name := range n.Segments { - if seen[name] { - add("node %s: segment %s listed twice", n.Name, name) - continue - } - seen[name] = true - if _, ok := segments[name]; !ok { - add("node %s: segment %s is not declared", n.Name, name) - continue - } - attached[name]++ - } - for _, name := range sortedKeys(n.Addresses) { - address := n.Addresses[name] - if !seen[name] { - add("node %s: address given for segment %s it is not attached to", n.Name, name) - continue - } - if _, err := netip.ParseAddr(address); err != nil { - add("node %s: address %q on %s: %v", n.Name, address, name, err) - } - } - } - - for _, s := range t.Segments { - if attached[s.Name] == 0 { - add("segment %s: no node is attached", s.Name) - } - } - - if len(errs) > 0 { - return errors.Join(errs...) - } - return nil -} - -func validateExtras(n Node, segments map[string]Segment, add func(string, ...any)) { - carried := map[string]bool{} - if n.Role == RoleSwitch { - for name, s := range segments { - if s.Switch == n.Name { - carried[name] = true - } - } - } else { - for _, name := range n.Segments { - carried[name] = true - } - } - names := make([]string, 0, len(n.Secondary)) - for name := range n.Secondary { - names = append(names, name) - } - sort.Strings(names) - for _, name := range names { - if !carried[name] { - add("node %s: secondary address given for segment %s it is not attached to", n.Name, name) - continue - } - network, _ := netip.ParsePrefix(segments[name].CIDR) - for _, raw := range n.Secondary[name] { - prefix, err := netip.ParsePrefix(raw) - switch { - case err != nil: - add("node %s: secondary address %q on %s: %v", n.Name, raw, name, err) - case !prefix.Addr().Is4(): - add("node %s: secondary address %s on %s is not IPv4", n.Name, raw, name) - case network.IsValid() && network.Contains(prefix.Addr()): - add("node %s: secondary address %s is inside segment %s (%s), use addresses instead", n.Name, raw, name, network) - } - } - } - switch { - case n.Role == RoleHypervisor && n.Release == "": - add("node %s: a hypervisor needs the release of two to deploy (release: )", n.Name) - case n.Role == RoleHypervisor && !releasePattern.MatchString(n.Release): - add("node %s: release %q must match %s", n.Name, n.Release, releasePattern) - case n.Role != RoleHypervisor && n.Release != "": - add("node %s: release is only for hypervisors", n.Name) - } - if n.Role != RoleHypervisor && n.Agent != "" { - add("node %s: agent is only for hypervisors", n.Name) - } - if n.Loopback != "" { - prefix, err := netip.ParsePrefix(n.Loopback) - switch { - case err != nil: - add("node %s: loopback %q: %v", n.Name, n.Loopback, err) - case !prefix.Addr().Is4(): - add("node %s: loopback %s is not IPv4", n.Name, n.Loopback) - } - } -} - -func sortedKeys(m map[string]string) []string { - keys := make([]string, 0, len(m)) - for k := range m { - keys = append(keys, k) - } - sort.Strings(keys) - return keys -} diff --git a/scripts/deploy.sh b/scripts/deploy.sh index b654197..d6b1828 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -387,6 +387,5 @@ main () { switch_binaries "${FLAGS_dryrun}" "${FLAGS_profile}" "${ASSETS}" } -if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then - (main "$@" || exit 1) -fi +[[ "${BASH_SOURCE[0]}" == "${0}" ]] && (main "$@" || exit 1) +[[ "${BASH_SOURCE[0]}" == "" ]] && (main "$@" || exit 1) diff --git a/scripts/embed.go b/scripts/embed.go deleted file mode 100644 index 481e8e5..0000000 --- a/scripts/embed.go +++ /dev/null @@ -1,9 +0,0 @@ -package scripts - -import _ "embed" - -//go:embed deploy.sh -var Deploy []byte - -//go:embed bootstrap_kvm.sh -var BootstrapKVM []byte diff --git a/scripts/lab-host.sh b/scripts/lab-host.sh deleted file mode 100755 index d06c1d7..0000000 --- a/scripts/lab-host.sh +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env bash - -API_URL="${SCW_API_URL:-https://api.scaleway.com}" -CREDENTIALS_FILE="${LAB_CREDENTIALS_FILE:-${HOME}/.config/two-lab/scaleway.env}" -OFFER_NAME="${LAB_OFFER:-EM-B212X-SSD}" -OS_NAME="${LAB_OS_NAME:-Debian}" -OS_VERSION="${LAB_OS_VERSION:-12}" -LAB_NAME="${LAB_NAME:-two-lab}" -LAB_TAG="${LAB_TAG:-two-lab}" -INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-3600}" -DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-900}" -SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-600}" -POLL_INTERVAL="${LAB_POLL_INTERVAL:-20}" -HTTP_CONNECT_TIMEOUT="${LAB_HTTP_CONNECT_TIMEOUT:-10}" -HTTP_TIMEOUT="${LAB_HTTP_TIMEOUT:-60}" -STATE_DIR="${LAB_STATE_DIR:-${HOME}/.cache/two-lab}" -SSH_KEY="${LAB_SSH_KEY:-${HOME}/.config/two-lab/ssh/lab_ed25519}" -REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -CREATED_ID="" - -info () { echo "== ${1}" >&2; } -warn () { echo "!! ${1}" >&2; } -die () { echo "!! ${1}" >&2; exit 1; } - -usage () { - cat >&2 < [arguments] - - plan résout l'offre horaire, l'OS et les clés SSH, affiche la requête de création - et le prix ; ne crée rien - up crée le serveur de lab, attend la fin de son installation et son SSH, - puis le prépare (voir prepare) - status liste les serveurs de lab du projet - ssh [commande] se connecte au serveur de lab ; avec une commande, un terminal n'est demandé - que si l'entrée standard en est un - prepare installe sur le serveur ce dont lab a besoin (qemu, genisoimage), vérifie - /dev/kvm et la virtualisation imbriquée ; lancé aussi par up - push compile cmd/lab pour linux/amd64 et dépose sur le serveur ~/lab et le - répertoire de la topologie dans ~/topology/ (avec les fichiers qu'elle - référence) ; ensuite : ssh './lab up topology/' - down supprime tous les serveurs de lab du projet et attend leur disparition - session [cmd] up, puis la commande distante (ou un shell), puis down quoi qu'il arrive - -environnement : SCW_SECRET_KEY et SCW_DEFAULT_PROJECT_ID (requis, à défaut lus dans ${CREDENTIALS_FILE}, - fichier en 0600, lignes CLÉ=valeur), SCW_DEFAULT_ZONE (${ZONE}), - LAB_OFFER (${OFFER_NAME}), LAB_OS_NAME (${OS_NAME}), LAB_OS_VERSION (${OS_VERSION}), - LAB_SSH_KEY (${SSH_KEY} ; à défaut, l'agent SSH) - -ne jamais lancer sous 'bash -x' : la trace afficherait SCW_SECRET_KEY. -EOF - exit 2 -} - -load_credentials () { - local KEY VALUE - if [[ -z "${SCW_SECRET_KEY:-}" || -z "${SCW_DEFAULT_PROJECT_ID:-}" ]] && [[ -f "${CREDENTIALS_FILE}" ]]; then - [[ $(ls -l "${CREDENTIALS_FILE}" | cut -c5-10) == "------" ]] \ - || die "${CREDENTIALS_FILE} est lisible par d'autres que son propriétaire : chmod 600" - while IFS='=' read -r KEY VALUE || [[ -n "${KEY}" ]]; do - case "${KEY}" in - SCW_SECRET_KEY) [[ -n "${SCW_SECRET_KEY:-}" ]] || SCW_SECRET_KEY="${VALUE}" ;; - SCW_DEFAULT_PROJECT_ID) [[ -n "${SCW_DEFAULT_PROJECT_ID:-}" ]] || SCW_DEFAULT_PROJECT_ID="${VALUE}" ;; - SCW_DEFAULT_ZONE) [[ -n "${SCW_DEFAULT_ZONE:-}" ]] || SCW_DEFAULT_ZONE="${VALUE}" ;; - esac - done < "${CREDENTIALS_FILE}" - fi - ZONE="${SCW_DEFAULT_ZONE:-fr-par-1}" - PROJECT_ID="${SCW_DEFAULT_PROJECT_ID:-}" -} - -require_env () { - [[ -n "${SCW_SECRET_KEY:-}" ]] || die "SCW_SECRET_KEY absent de l'environnement" - [[ -n "${PROJECT_ID}" ]] || die "SCW_DEFAULT_PROJECT_ID absent de l'environnement" - command -v jq >/dev/null 2>&1 || die "jq est requis" - command -v curl >/dev/null 2>&1 || die "curl est requis" -} - -http_code () { - cat "${STATE_DIR}/last_http_code" 2>/dev/null -} - -api () { - local METHOD="${1}" - local URL_PATH="${2}" - local BODY="${3:-}" - local OUT CODE - local -a ARGS=(-sS -X "${METHOD}" -H "Content-Type: application/json" -w '\n%{http_code}' - --connect-timeout "${HTTP_CONNECT_TIMEOUT}" --max-time "${HTTP_TIMEOUT}") - - : > "${STATE_DIR}/last_http_code" - [[ -n "${BODY}" ]] && ARGS+=(--data "${BODY}") - OUT=$(curl "${ARGS[@]}" -H @<(printf 'X-Auth-Token: %s\n' "${SCW_SECRET_KEY}") "${API_URL}${URL_PATH}") \ - || { warn "${METHOD} ${URL_PATH} : échec réseau"; return 1; } - CODE="${OUT##*$'\n'}" - OUT="${OUT%$'\n'*}" - printf '%s' "${CODE}" > "${STATE_DIR}/last_http_code" - if [[ "${CODE}" -lt 200 || "${CODE}" -ge 300 ]]; then - warn "${METHOD} ${URL_PATH} : HTTP ${CODE} ${OUT}" - return 1 - fi - printf '%s\n' "${OUT}" -} - -http_failure_is_final () { - local CODE - CODE=$(http_code) - [[ "${CODE}" =~ ^4[0-9][0-9]$ && "${CODE}" != "429" ]] -} - -money () { - jq -r "${1} | if . == null then \"?\" else \"\\(.units + .nanos / 1000000000) \\(.currency_code)\" end" -} - -resolve_offer () { - local JSON OFFER COUNT - JSON=$(api GET "/baremetal/v1/zones/${ZONE}/offers?subscription_period=hourly&name=${OFFER_NAME}&page_size=100") \ - || die "impossible de lister les offres" - OFFER=$(jq -c --arg n "${OFFER_NAME}" \ - '[.offers[] | select(.name == $n and .subscription_period == "hourly")]' <<< "${JSON}") - COUNT=$(jq 'length' <<< "${OFFER}") - [[ "${COUNT}" -eq 1 ]] || die "offre horaire ${OFFER_NAME} : ${COUNT} correspondance(s) en ${ZONE}, une seule attendue" - OFFER=$(jq -c '.[0]' <<< "${OFFER}") - [[ $(jq -r '.enable' <<< "${OFFER}") == "true" ]] || die "offre ${OFFER_NAME} désactivée" - [[ $(jq -r '.stock' <<< "${OFFER}") != "empty" ]] || die "offre ${OFFER_NAME} en rupture de stock en ${ZONE}" - [[ $(jq -r '((.fee.units // 0) == 0) and ((.fee.nanos // 0) == 0)' <<< "${OFFER}") == "true" ]] \ - || die "offre ${OFFER_NAME} : frais de mise en service non nuls ($(money '.fee' <<< "${OFFER}")), refus" - printf '%s\n' "${OFFER}" -} - -resolve_os () { - local OFFER_ID="${1}" - local JSON OS COUNT - JSON=$(api GET "/baremetal/v1/zones/${ZONE}/os?offer_id=${OFFER_ID}&page_size=100") \ - || die "impossible de lister les OS" - OS=$(jq -c --arg n "${OS_NAME}" --arg v "${OS_VERSION}" \ - '[.os[] | select(.enabled and .allowed and (.name | ascii_downcase | contains($n | ascii_downcase)) - and (.version | split(" ")[0] | split(".")[0]) == $v)]' <<< "${JSON}") - COUNT=$(jq 'length' <<< "${OS}") - if [[ "${COUNT}" -ne 1 ]]; then - warn "OS disponibles pour ${OFFER_NAME} :" - jq -r '.os[] | " \(.name) \(.version) (\(.id))"' <<< "${JSON}" >&2 - die "OS ${OS_NAME} ${OS_VERSION} : ${COUNT} correspondance(s), une seule attendue" - fi - jq -c '.[0]' <<< "${OS}" -} - -resolve_ssh_keys () { - local JSON - JSON=$(api GET "/iam/v1alpha1/ssh-keys?project_id=${PROJECT_ID}&disabled=false&page_size=100") \ - || die "impossible de lister les clés SSH du projet" - JSON=$(jq -c '[.ssh_keys[] | select(.disabled | not) | .id]' <<< "${JSON}") - [[ $(jq 'length' <<< "${JSON}") -gt 0 ]] || die "aucune clé SSH active dans le projet ${PROJECT_ID}" - printf '%s\n' "${JSON}" -} - -build_request () { - local OFFER="${1}" - local OS="${2}" - local KEYS="${3}" - jq -nc --argjson offer "${OFFER}" --argjson os "${OS}" --argjson keys "${KEYS}" \ - --arg project "${PROJECT_ID}" --arg name "${LAB_NAME}" --arg tag "${LAB_TAG}" \ - '{offer_id: $offer.id, project_id: $project, name: $name, description: "lab two (#50)", - tags: [$tag], install: {os_id: $os.id, hostname: $name, ssh_key_ids: $keys}}' -} - -lab_servers () { - local JSON - JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers?project_id=${PROJECT_ID}&tags=${LAB_TAG}&page_size=100") \ - || return 1 - jq -c --arg p "${PROJECT_ID}" --arg t "${LAB_TAG}" \ - '[.servers[] | select(.project_id == $p and (.tags | index($t)))]' <<< "${JSON}" -} - -server_ip () { - jq -r '[.ips[] | select(.version == "IPv4") | .address][0] // empty' -} - -server_is_installing () { - jq -e '.status == "delivering" or .status == "ordered" - or (.install.status // "") == "to_install" or (.install.status // "") == "installing"' >/dev/null -} - -cmd_plan () { - local OFFER OS KEYS - OFFER=$(resolve_offer) || exit 1 - OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1 - KEYS=$(resolve_ssh_keys) || exit 1 - info "offre : ${OFFER_NAME} ($(jq -r '.id' <<< "${OFFER}")), facturation $(jq -r '.subscription_period' <<< "${OFFER}"), stock $(jq -r '.stock' <<< "${OFFER}")" - info "prix : $(money '.price_per_hour' <<< "${OFFER}") HT par heure, frais de mise en service $(money '.fee' <<< "${OFFER}")" - info "os : $(jq -r '"\(.name) \(.version) (\(.id))"' <<< "${OS}"), utilisateur $(jq -r '.user.default_value // "?"' <<< "${OS}")" - info "clés : $(jq -r 'length' <<< "${KEYS}") clé(s) SSH du projet" - info "requête : POST /baremetal/v1/zones/${ZONE}/servers" - build_request "${OFFER}" "${OS}" "${KEYS}" | jq . -} - -wait_installed () { - local ID="${1}" - local DEADLINE=$(( SECONDS + INSTALL_TIMEOUT )) - local JSON STATUS INSTALL - while (( SECONDS < DEADLINE )); do - if ! JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}"); then - if http_failure_is_final; then - warn "erreur définitive HTTP $(http_code) en attendant l'installation" - return 1 - fi - sleep "${POLL_INTERVAL}" - continue - fi - STATUS=$(jq -r '.status' <<< "${JSON}") - INSTALL=$(jq -r '.install.status // "none"' <<< "${JSON}") - info "serveur ${ID} : ${STATUS}, installation ${INSTALL}" - case "${STATUS}/${INSTALL}" in - ready/completed) printf '%s\n' "${JSON}"; return 0 ;; - error/*|*/error|out_of_stock/*|locked/*|deleting/*|stopped/*|stopping/*) - warn "état terminal en échec : ${STATUS}/${INSTALL}"; return 1 ;; - esac - sleep "${POLL_INTERVAL}" - done - warn "installation non terminée après ${INSTALL_TIMEOUT}s" - return 1 -} - -ssh_run () { - local -a OPTS=() - while [[ $# -gt 0 && "${1}" != "--" ]]; do - OPTS+=("${1}") - shift - done - [[ "${1:-}" == "--" ]] && shift - [[ -f "${SSH_KEY}" ]] && OPTS+=(-i "${SSH_KEY}" -o IdentitiesOnly=yes -o IdentityAgent=none) - env -u SCW_SECRET_KEY ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile="${STATE_DIR}/known_hosts" \ - "${OPTS[@]}" "$(cat "${STATE_DIR}/user")@$(cat "${STATE_DIR}/ip")" "$@" -} - -wait_ssh () { - local DEADLINE=$(( SECONDS + SSH_TIMEOUT )) - while (( SECONDS < DEADLINE )); do - ssh_run -o BatchMode=yes -o ConnectTimeout=5 -- true >/dev/null 2>&1 && return 0 - info "SSH pas encore joignable, nouvel essai dans ${POLL_INTERVAL}s" - sleep "${POLL_INTERVAL}" - done - return 1 -} - -require_no_lab_server () { - local EXISTING - EXISTING=$(lab_servers) || die "impossible de lister les serveurs de lab" - [[ $(jq 'length' <<< "${EXISTING}") -eq 0 ]] \ - || die "un serveur de lab existe déjà dans le projet ; '${0##*/} status' puis '${0##*/} down'" -} - -report_uncertain_creation () { - local FOUND - if FOUND=$(lab_servers) && [[ $(jq 'length' <<< "${FOUND}") -gt 0 ]]; then - warn "un serveur de lab existe bien : $(jq -r '[.[].id] | join(", ")' <<< "${FOUND}")" - fi - die "création incertaine : le serveur a pu être créé et être facturé ; '${0##*/} status' puis '${0##*/} down'" -} - -cmd_up () { - local OFFER OS KEYS BODY JSON ID IP SSH_USER - require_no_lab_server - OFFER=$(resolve_offer) || exit 1 - OS=$(resolve_os "$(jq -r '.id' <<< "${OFFER}")") || exit 1 - KEYS=$(resolve_ssh_keys) || exit 1 - BODY=$(build_request "${OFFER}" "${OS}" "${KEYS}") - info "création de ${LAB_NAME} (${OFFER_NAME}, $(money '.price_per_hour' <<< "${OFFER}")/h HT)" - JSON=$(api POST "/baremetal/v1/zones/${ZONE}/servers" "${BODY}") || report_uncertain_creation - ID=$(jq -r '.id // empty' <<< "${JSON}" 2>/dev/null) - [[ -n "${ID}" ]] || report_uncertain_creation - CREATED_ID="${ID}" - info "serveur ${ID} créé, facturé jusqu'à '${0##*/} down'" - JSON=$(wait_installed "${ID}") || die "serveur ${ID} inutilisable — il est toujours facturé : '${0##*/} down'" - IP=$(server_ip <<< "${JSON}") - [[ -n "${IP}" ]] || die "serveur ${ID} sans IPv4 publique — il est toujours facturé : '${0##*/} down'" - SSH_USER=$(jq -r '.user.default_value // empty' <<< "${OS}") - printf '%s\n' "${IP}" > "${STATE_DIR}/ip" - printf '%s\n' "${SSH_USER:-root}" > "${STATE_DIR}/user" - : > "${STATE_DIR}/known_hosts" - wait_ssh || die "SSH injoignable après ${SSH_TIMEOUT}s — le serveur est toujours facturé : '${0##*/} down'" - cmd_prepare || die "préparation du serveur échouée — il est toujours facturé : '${0##*/} down'" - info "prêt : ${SSH_USER:-root}@${IP}" -} - -cmd_status () { - local SERVERS - SERVERS=$(lab_servers) || die "impossible de lister les serveurs de lab" - if [[ $(jq 'length' <<< "${SERVERS}") -eq 0 ]]; then - info "aucun serveur de lab dans le projet ${PROJECT_ID} (${ZONE})" - return 0 - fi - jq -r '.[] | "\(.id) \(.name) \(.offer_name) \(.status) installation \(.install.status // "none") \(.created_at) \([.ips[] | select(.version == "IPv4") | .address] | join(","))"' <<< "${SERVERS}" -} - -require_known_server () { - [[ -s "${STATE_DIR}/ip" ]] || die "aucun serveur de lab connu ; '${0##*/} up' d'abord" -} - -cmd_ssh () { - require_known_server - if [[ $# -gt 0 && -t 0 ]]; then - ssh_run -t -- "$@" - else - ssh_run -- "$@" - fi -} - -prepare_script () { - cat <<'EOF' -set -eu -SUDO= -[ "$(id -u)" -eq 0 ] || SUDO=sudo -$SUDO env DEBIAN_FRONTEND=noninteractive apt-get update -qq -$SUDO env DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-system-x86 qemu-utils genisoimage -[ "$(id -u)" -eq 0 ] || $SUDO usermod -aG kvm "$(id -un)" -[ -c /dev/kvm ] || { echo "/dev/kvm absent" >&2; exit 1; } -NESTED=$(cat /sys/module/kvm_intel/parameters/nested 2>/dev/null || cat /sys/module/kvm_amd/parameters/nested 2>/dev/null || true) -case "${NESTED}" in - Y|1) ;; - *) echo "virtualisation imbriquée désactivée (nested=${NESTED:-absent})" >&2; exit 1 ;; -esac -echo "qemu $(qemu-system-x86_64 --version | head -n 1), nested=${NESTED}" -EOF -} - -cmd_prepare () { - require_known_server - info "préparation du serveur : qemu, genisoimage, KVM imbriqué" - prepare_script | ssh_run -- bash -s -} - -push_file () { - local SOURCE="${1}" TARGET="${2}" MODE="${3}" - ssh_run -- "cat > '${TARGET}.part' && chmod ${MODE} '${TARGET}.part' && mv '${TARGET}.part' '${TARGET}'" < "${SOURCE}" -} - -push_dir () { - local SOURCE="${1}" - COPYFILE_DISABLE=1 tar --no-xattrs -C "${SOURCE}" -cf - . \ - | ssh_run -- "rm -rf topology.part && mkdir topology.part && tar -C topology.part --no-same-owner -xf - && rm -rf topology && mv topology.part topology" -} - -cmd_push () { - local TOPOLOGY="${1:-}" - local NAME="${TOPOLOGY##*/}" - local BINARY="${STATE_DIR}/lab" - [[ $# -eq 1 && -n "${TOPOLOGY}" ]] || usage - [[ -f "${TOPOLOGY}" ]] || die "topologie introuvable : ${TOPOLOGY}" - [[ "${NAME}" =~ ^[A-Za-z0-9._-]+$ ]] || die "nom de topologie refusé : ${NAME} (lettres, chiffres, '.', '_', '-')" - require_known_server - info "compilation de lab (linux/amd64)" - (cd "${REPO_DIR}" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o "${BINARY}" ./cmd/lab) \ - || die "compilation de lab échouée" - push_file "${BINARY}" lab 755 || die "envoi de lab échoué" - push_dir "$(dirname "${TOPOLOGY}")" || die "envoi du répertoire de ${NAME} échoué" - info "déposés sur le serveur : ~/lab, ~/topology/ — ensuite : ${0##*/} ssh './lab up topology/${NAME}'" -} - -delete_server () { - local ID="${1}" - local DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) - local HARD_DEADLINE=$(( SECONDS + DELETE_TIMEOUT + INSTALL_TIMEOUT )) - local JSON - while (( SECONDS < DEADLINE )); do - api DELETE "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null && return 0 - [[ $(http_code) == "404" ]] && return 0 - if JSON=$(api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" 2>/dev/null) && server_is_installing <<< "${JSON}"; then - DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) - (( DEADLINE > HARD_DEADLINE )) && DEADLINE="${HARD_DEADLINE}" - warn "suppression de ${ID} refusée pendant la livraison ou l'installation, nouvel essai dans ${POLL_INTERVAL}s" - else - warn "suppression de ${ID} refusée, nouvel essai dans ${POLL_INTERVAL}s" - fi - sleep "${POLL_INTERVAL}" - done - return 1 -} - -wait_gone () { - local ID="${1}" - local DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) - while (( SECONDS < DEADLINE )); do - if ! api GET "/baremetal/v1/zones/${ZONE}/servers/${ID}" >/dev/null 2>&1; then - [[ $(http_code) == "404" ]] && return 0 - fi - sleep "${POLL_INTERVAL}" - done - return 1 -} - -lab_server_ids () { - local DEADLINE=$(( SECONDS + DELETE_TIMEOUT )) - local SERVERS - while (( SECONDS < DEADLINE )); do - if SERVERS=$(lab_servers); then - jq -r '.[].id' <<< "${SERVERS}" - return 0 - fi - warn "liste des serveurs de lab indisponible, nouvel essai dans ${POLL_INTERVAL}s" - sleep "${POLL_INTERVAL}" - done - return 1 -} - -cmd_down () { - local IDS ID FAILED=0 - if ! IDS=$(lab_server_ids); then - warn "impossible de lister les serveurs de lab après ${DELETE_TIMEOUT}s" - FAILED=1 - IDS="" - fi - IDS=$(printf '%s\n%s\n' "${IDS}" "${CREATED_ID}" | grep -v '^$' | sort -u) - for ID in ${IDS}; do - info "suppression de ${ID}" - if ! delete_server "${ID}"; then - warn "suppression de ${ID} toujours refusée" - FAILED=1 - continue - fi - if ! wait_gone "${ID}"; then - warn "${ID} toujours présent après ${DELETE_TIMEOUT}s" - FAILED=1 - fi - done - rm -f "${STATE_DIR}/ip" "${STATE_DIR}/user" "${STATE_DIR}/known_hosts" - [[ "${FAILED}" -eq 0 ]] || die "SERVEUR(S) DE LAB TOUJOURS FACTURÉ(S) — '${0##*/} status', puis la console Scaleway" - info "aucun serveur de lab ne reste dans le projet" -} - -cmd_session () { - local RC - require_no_lab_server - trap 'trap - EXIT; trap "" INT TERM HUP; cmd_down' EXIT - trap 'exit 130' INT - trap 'exit 143' TERM - trap 'exit 129' HUP - cmd_up - cmd_ssh "$@" - RC=$? - info "session terminée (code ${RC}), suppression du serveur" - return "${RC}" -} - -main () { - local COMMAND="${1:-}" - [[ $# -gt 0 ]] && shift - load_credentials - [[ -n "${COMMAND}" ]] || usage - mkdir -p "${STATE_DIR}" || die "impossible de créer ${STATE_DIR}" - chmod 700 "${STATE_DIR}" - case "${COMMAND}" in - plan) require_env; cmd_plan ;; - up) require_env; cmd_up ;; - status) require_env; cmd_status ;; - ssh) cmd_ssh "$@" ;; - prepare) cmd_prepare || die "préparation du serveur échouée" ;; - push) cmd_push "$@" ;; - down) require_env; cmd_down ;; - session) require_env; cmd_session "$@" ;; - *) usage ;; - esac -} - -if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then - main "$@" -fi diff --git a/scripts/lab-host_test.sh b/scripts/lab-host_test.sh deleted file mode 100755 index 9953579..0000000 --- a/scripts/lab-host_test.sh +++ /dev/null @@ -1,825 +0,0 @@ -#!/usr/bin/env bash - -SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lab-host.sh" -PASS=0 -FAIL=0 -SECRET="secret-de-test-a1b2c3" -PROJECT="11111111-1111-1111-1111-111111111111" -OTHER_PROJECT="22222222-2222-2222-2222-222222222222" -HOURLY_ID="offer-hourly-0001" -MONTHLY_ID="offer-monthly-0001" - -fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; } - -write_fakes () { - local DIR="${1}" - mkdir -p "${DIR}/bin" - cat > "${DIR}/bin/curl" <<'PY' -#!/usr/bin/env python3 -import json, os, sys, time, urllib.parse - -d = os.environ["FAKE_DIR"] -args = sys.argv[1:] -with open(os.path.join(d, "argv.log"), "a") as f: - f.write(json.dumps(args) + "\n") - -method, body, headers, url = "GET", None, [], None -i = 0 -while i < len(args): - a = args[i] - if a == "-X": - method = args[i + 1]; i += 2; continue - if a == "-H": - h = args[i + 1] - if h.startswith("@"): - with open(h[1:]) as f: - headers += [l.strip() for l in f if l.strip()] - else: - headers.append(h) - i += 2; continue - if a == "--data": - body = args[i + 1]; i += 2; continue - if a in ("-w", "--max-time", "--connect-timeout"): - i += 2; continue - if a.startswith("-"): - i += 1; continue - url = a; i += 1 - -state_path = os.path.join(d, "state.json") -with open(state_path) as f: - st = json.load(f) - -def reply(code, obj): - with open(state_path, "w") as f: - json.dump(st, f) - sys.stdout.write(json.dumps(obj) + "\n" + str(code)) - sys.exit(0) - -u = urllib.parse.urlparse(url) -path = u.path -with open(os.path.join(d, "calls.log"), "a") as f: - f.write(method + " " + path + ("?" + u.query if u.query else "") + "\n") - -if "X-Auth-Token: " + os.environ["FAKE_SECRET"] not in headers: - reply(401, {"message": "unauthorized"}) - -zone = "/baremetal/v1/zones/fr-par-1" -if method == "GET" and path == zone + "/offers": - reply(200, {"offers": st["offers"], "total_count": len(st["offers"])}) -if method == "GET" and path == zone + "/os": - reply(200, {"os": st["os"], "total_count": len(st["os"])}) -if method == "GET" and path == "/iam/v1alpha1/ssh-keys": - reply(200, {"ssh_keys": st["keys"], "total_count": len(st["keys"])}) -if method == "GET" and path == zone + "/servers": - if st["list_errors"] > 0: - st["list_errors"] -= 1 - reply(503, {"message": "service unavailable"}) - listed = [v for v in st["servers"].values() if not v.get("hidden")] - reply(200, {"servers": listed, "total_count": len(listed)}) -if method == "POST" and path == zone + "/servers": - req = json.loads(body) - with open(os.path.join(d, "create.json"), "w") as f: - json.dump(req, f) - st["seq"] += 1 - sid = "srv-%d" % st["seq"] - st["servers"][sid] = {"id": sid, "name": req["name"], "project_id": req["project_id"], - "tags": req["tags"], "offer_name": "EM-B212X-SSD", "status": "delivering", - "install": {"status": "to_install"}, "ips": [], "created_at": "now", - "hidden": st["hide_from_list"]} - if st["post_fail_after_create"]: - reply(502, {"message": "bad gateway"}) - if st["post_no_id"]: - reply(200, {}) - reply(200, st["servers"][sid]) -if path.startswith(zone + "/servers/"): - sid = path.rsplit("/", 1)[1] - if sid not in st["servers"]: - reply(404, {"message": "not found"}) - srv = st["servers"][sid] - if method == "GET" and st["get_errors"] > 0: - st["get_errors"] -= 1 - reply(503, {"message": "service unavailable"}) - if method == "GET" and srv["status"] == "deleting": - if st["linger"] > 0: - st["linger"] -= 1 - reply(200, srv) - del st["servers"][sid] - reply(404, {"message": "not found"}) - if method == "GET" and st["vanish_after"] == 0: - del st["servers"][sid] - reply(404, {"message": "not found"}) - if method == "GET" and st["vanish_after"] > 0: - st["vanish_after"] -= 1 - if method == "GET": - if st["progress"]: - status, install = st["progress"].pop(0) - srv["status"] = status - srv["install"]["status"] = install - if status == "ready": - srv["ips"] = [{"address": "2001:db8::1", "version": "IPv6"}, - {"address": "203.0.113.7", "version": "IPv4"}] - reply(200, srv) - if method == "DELETE": - if time.time() < st["refuse_until"]: - reply(409, {"message": "server is installing"}) - if st["delete_refusals"] > 0: - st["delete_refusals"] -= 1 - reply(409, {"message": "server is installing"}) - if st["linger"] > 0: - srv["status"] = "deleting" - else: - del st["servers"][sid] - reply(200, srv) -reply(500, {"message": "route inconnue du faux serveur : " + method + " " + path}) -PY - cat > "${DIR}/bin/ssh" <<'SH' -#!/usr/bin/env bash -printf '%s\n' "$*" >> "${FAKE_DIR}/ssh.log" -printf 'key=%s\n' "${SCW_SECRET_KEY:-}" >> "${FAKE_DIR}/ssh-env.log" -case "$*" in - *BatchMode=yes*) - N=$(cat "${FAKE_DIR}/probe_fails" 2>/dev/null || echo 0) - if [ "${N}" -gt 0 ]; then - echo $(( N - 1 )) > "${FAKE_DIR}/probe_fails" - exit 255 - fi - exit 0 ;; - *"bash -s"*) - cat > "${FAKE_DIR}/prepare.sh" - exit "${FAKE_PREPARE_RC:-0}" ;; - *"cat > "*|*"-xf -"*) - N=$(ls "${FAKE_DIR}" | grep -c '^pushed\.') - cat > "${FAKE_DIR}/pushed.$(( N + 1 ))" - exit "${FAKE_PUSH_RC:-0}" ;; -esac -[ -n "${FAKE_SSH_SLEEP:-}" ] && sleep "${FAKE_SSH_SLEEP}" -exit "${FAKE_SSH_RC:-0}" -SH - cat > "${DIR}/bin/go" <<'SH' -#!/usr/bin/env bash -printf '%s|%s|CGO_ENABLED=%s GOOS=%s GOARCH=%s\n' "${PWD}" "$*" "${CGO_ENABLED:-}" "${GOOS:-}" "${GOARCH:-}" >> "${FAKE_DIR}/go.log" -[ -n "${FAKE_GO_FAIL:-}" ] && exit 1 -while [ $# -gt 0 ]; do - [ "${1}" = "-o" ] && printf 'binaire-lab\n' > "${2}" - shift -done -exit 0 -SH - chmod +x "${DIR}/bin/curl" "${DIR}/bin/ssh" "${DIR}/bin/go" -} - -base_state () { - jq -n --arg h "${HOURLY_ID}" --arg m "${MONTHLY_ID}" --arg p "${PROJECT}" --arg o "${OTHER_PROJECT}" '{ - seq: 0, progress: [], delete_refusals: 0, linger: 0, get_errors: 0, list_errors: 0, - hide_from_list: false, refuse_until: 0, post_fail_after_create: false, post_no_id: false, vanish_after: -1, - offers: [ - {id: $m, name: "EM-B212X-SSD", subscription_period: "monthly", enable: true, stock: "available", - price_per_hour: null, price_per_month: {currency_code: "EUR", units: 115, nanos: 990000000}, - fee: {currency_code: "EUR", units: 115, nanos: 990000000}}, - {id: "offer-other", name: "EM-A610R-NVMe", subscription_period: "hourly", enable: true, stock: "available", - price_per_hour: {currency_code: "EUR", units: 0, nanos: 110000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}}, - {id: $h, name: "EM-B212X-SSD", subscription_period: "hourly", enable: true, stock: "available", - price_per_hour: {currency_code: "EUR", units: 0, nanos: 321000000}, fee: {currency_code: "EUR", units: 0, nanos: 0}} - ], - os: [ - {id: "os-deb11", name: "Debian", version: "11 (Bullseye)", enabled: true, allowed: true, user: {default_value: "debian"}}, - {id: "os-deb12", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true, user: {default_value: "debian"}}, - {id: "os-deb13", name: "Debian", version: "13 (Trixie)", enabled: true, allowed: true, user: {default_value: "debian"}}, - {id: "os-ubu", name: "Ubuntu", version: "24.04 LTS (Noble Numbat)", enabled: true, allowed: true, user: {default_value: "ubuntu"}} - ], - keys: [{id: "key-1", disabled: false}, {id: "key-off", disabled: true}], - servers: { - "foreign-1": {id: "foreign-1", name: "prod-db", project_id: $o, tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}, - "foreign-2": {id: "foreign-2", name: "autre", project_id: $p, tags: ["autre"], status: "ready", install: {status: "completed"}, ips: []} - } - }' -} - -setup () { - CURRENT="${1}" - CASE_OK=1 - WORK=$(mktemp -d "${TMPDIR:-/tmp}/labhost.XXXXXX") - write_fakes "${WORK}" - base_state > "${WORK}/state.json" - : > "${WORK}/calls.log" - : > "${WORK}/argv.log" - : > "${WORK}/ssh.log" - : > "${WORK}/ssh-env.log" -} - -mutate () { - local TMP - TMP=$(jq "${1}" "${WORK}/state.json") && printf '%s\n' "${TMP}" > "${WORK}/state.json" -} - -lab_env () { - local EXEC="" - [[ "${1}" == "exec" ]] && { EXEC="exec"; shift; } - ${EXEC} env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \ - FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" FAKE_SSH_RC="${FAKE_SSH_RC:-0}" \ - FAKE_SSH_SLEEP="${FAKE_SSH_SLEEP:-}" FAKE_PREPARE_RC="${FAKE_PREPARE_RC:-0}" \ - FAKE_PUSH_RC="${FAKE_PUSH_RC:-0}" FAKE_GO_FAIL="${FAKE_GO_FAIL:-}" \ - SCW_API_URL="https://api.example.invalid" SCW_SECRET_KEY="${SECRET}" \ - SCW_DEFAULT_PROJECT_ID="${PROJECT}" LAB_POLL_INTERVAL="${LAB_POLL_INTERVAL:-0}" \ - LAB_INSTALL_TIMEOUT="${LAB_INSTALL_TIMEOUT:-60}" LAB_DELETE_TIMEOUT="${LAB_DELETE_TIMEOUT:-60}" \ - LAB_SSH_TIMEOUT="${LAB_SSH_TIMEOUT:-60}" \ - "$@" -} - -run_lab () { - lab_env bash "${SCRIPT}" "$@" > "${WORK}/out.log" 2>&1 -} - -remaining () { - jq -r '.servers | keys | sort | join(",")' "${WORK}/state.json" -} - -teardown () { - [[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}" - [[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; } - rm -rf "${WORK}" -} - -test_plan_picks_hourly_offer_even_when_monthly_is_listed_first () { - setup "plan : offre horaire choisie même si la mensuelle est listée d'abord" - run_lab plan || fail "code de sortie $?" - grep -q "\"offer_id\": \"${HOURLY_ID}\"" "${WORK}/out.log" || fail "offre horaire absente de la requête" - grep -q "${MONTHLY_ID}" "${WORK}/out.log" && fail "offre mensuelle présente dans la sortie" - grep -q "^POST" "${WORK}/calls.log" && fail "plan a créé quelque chose" - teardown -} - -test_plan_selects_debian_12_only () { - setup "plan : Debian 12 et rien d'autre" - run_lab plan || fail "code de sortie $?" - grep -q '"os_id": "os-deb12"' "${WORK}/out.log" || fail "os-deb12 non retenu" - teardown -} - -test_plan_passes_only_enabled_ssh_keys () { - setup "plan : seules les clés SSH actives sont transmises" - run_lab plan || fail "code de sortie $?" - grep -q '"key-1"' "${WORK}/out.log" || fail "key-1 absente" - grep -q '"key-off"' "${WORK}/out.log" && fail "clé désactivée transmise" - teardown -} - -test_refuses_offer_with_setup_fee () { - setup "refus d'une offre horaire avec frais de mise en service" - mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .fee.units) = 30' - run_lab up && fail "up a réussi malgré des frais" - grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé malgré des frais" - teardown -} - -test_refuses_when_hourly_offer_missing () { - setup "refus quand seule l'offre mensuelle existe" - mutate 'del(.offers[] | select(.id == "'"${HOURLY_ID}"'"))' - run_lab up && fail "up a réussi sans offre horaire" - grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé sur une offre mensuelle" - teardown -} - -test_refuses_ambiguous_os () { - setup "refus d'un OS ambigu" - mutate '.os += [{id: "os-deb12b", name: "Debian", version: "12 (Bookworm)", enabled: true, allowed: true}]' - run_lab plan && fail "plan a réussi avec deux Debian 12" - teardown -} - -test_refuses_out_of_stock () { - setup "refus d'une offre en rupture" - mutate '(.offers[] | select(.id == "'"${HOURLY_ID}"'") | .stock) = "empty"' - run_lab up && fail "up a réussi en rupture de stock" - grep -q "^POST" "${WORK}/calls.log" && fail "serveur créé en rupture de stock" - teardown -} - -test_up_creates_and_waits_for_installation () { - setup "up : création puis attente de la fin d'installation" - mutate '.progress = [["delivering","to_install"],["ready","installing"],["ready","completed"]]' - run_lab up || fail "code de sortie $?" - [[ $(jq -r '.offer_id' "${WORK}/create.json") == "${HOURLY_ID}" ]] || fail "offre créée incorrecte" - [[ $(jq -r '.tags | join(",")' "${WORK}/create.json") == "two-lab" ]] || fail "tag absent" - [[ $(jq -r '.project_id' "${WORK}/create.json") == "${PROJECT}" ]] || fail "projet incorrect" - [[ $(cat "${WORK}/.cache/two-lab/ip" 2>/dev/null) == "203.0.113.7" ]] || fail "IPv4 non retenue" - [[ $(cat "${WORK}/.cache/two-lab/user" 2>/dev/null) == "debian" ]] || fail "utilisateur non retenu" - teardown -} - -test_up_refuses_when_lab_server_exists () { - setup "up : refus si un serveur de lab existe déjà" - mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' - run_lab up && fail "up a réussi" - grep -q "^POST" "${WORK}/calls.log" && fail "second serveur créé" - teardown -} - -test_secret_never_in_argv () { - setup "la clé d'API n'apparaît jamais dans les arguments de curl" - mutate '.progress = [["ready","completed"]]' - run_lab up || fail "code de sortie $?" - grep -q "${SECRET}" "${WORK}/argv.log" && fail "secret visible dans argv" - grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché" - teardown -} - -test_down_deletes_only_lab_servers_of_project () { - setup "down : ne supprime que les serveurs de lab du projet" - mutate '.servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' - run_lab down || fail "code de sortie $?" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_down_retries_refused_deletion () { - setup "down : réessaie une suppression refusée" - mutate '.delete_refusals = 2 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}' - run_lab down || fail "code de sortie $?" - [[ $(grep -c "^DELETE" "${WORK}/calls.log") -eq 3 ]] || fail "$(grep -c "^DELETE" "${WORK}/calls.log") DELETE au lieu de 3" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_down_fails_loudly_when_deletion_keeps_failing () { - setup "down : échec bruyant si la suppression n'aboutit jamais" - mutate '.delete_refusals = 1000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}' - LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi" - grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" - teardown -} - -test_down_waits_until_server_is_gone () { - setup "down : attend que le serveur ait disparu" - mutate '.linger = 3 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' - run_lab down || fail "code de sortie $?" - [[ $(grep -c "^GET /baremetal/v1/zones/fr-par-1/servers/srv-lab$" "${WORK}/calls.log") -ge 4 ]] || fail "disparition non attendue" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_down_fails_loudly_when_server_never_disappears () { - setup "down : échec bruyant si le serveur ne disparaît jamais" - mutate '.linger = 100000 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' - LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi" - grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" - teardown -} - -test_down_does_not_mistake_api_error_for_disappearance () { - setup "down : une erreur de l'API n'est pas prise pour une disparition" - mutate '.linger = 100000 | .get_errors = 1 | .servers["srv-lab"] = {id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' - LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi alors que le serveur existe encore" - grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" - teardown -} - -test_session_deletes_server_after_remote_command () { - setup "session : serveur supprimé après la commande distante" - mutate '.progress = [["ready","completed"]]' - run_lab session uname -a || fail "code de sortie $?" - grep -q "uname -a" "${WORK}/ssh.log" || fail "commande distante non lancée" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_session_deletes_server_and_propagates_failure_of_remote_command () { - setup "session : serveur supprimé et code d'échec propagé" - mutate '.progress = [["ready","completed"]]' - FAKE_SSH_RC=7 run_lab session false - [[ $? -eq 7 ]] || fail "code de sortie différent de 7" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_session_deletes_server_when_installation_fails () { - setup "session : serveur supprimé quand l'installation échoue" - mutate '.progress = [["ready","installing"],["ready","error"]]' - run_lab session true && fail "session a réussi" - grep -q "true" "${WORK}/ssh.log" && fail "commande lancée sur un serveur en échec" - grep -q "état terminal en échec : ready/error" "${WORK}/out.log" || fail "échec d'installation non reconnu comme terminal" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_session_deletes_server_when_installation_times_out () { - setup "session : serveur supprimé quand l'installation n'aboutit pas" - mutate '.progress = [range(0; 100000) | ["delivering","to_install"]]' - LAB_INSTALL_TIMEOUT=1 run_lab session true && fail "session a réussi" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_session_does_not_touch_existing_lab_server () { - setup "session : ne supprime pas un serveur de lab lancé à côté" - mutate '.servers["srv-old"] = {id: "srv-old", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' - run_lab session true && fail "session a réussi" - grep -q "^DELETE" "${WORK}/calls.log" && fail "suppression lancée" - [[ $(remaining) == "foreign-1,foreign-2,srv-old" ]] || fail "restants : $(remaining)" - teardown -} - -test_missing_credentials_are_refused_before_any_call () { - setup "refus sans clé d'API, avant tout appel" - env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" FAKE_DIR="${WORK}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1 \ - && fail "plan a réussi sans clé" - [[ -s "${WORK}/calls.log" ]] && fail "appel réseau sans clé" - teardown -} - -LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "completed"}, ips: []}' -INSTALLING_LAB_SERVER='{id: "srv-lab", name: "two-lab", project_id: "'"${PROJECT}"'", tags: ["two-lab"], status: "ready", install: {status: "installing"}, ips: []}' - -test_down_keeps_retrying_while_server_is_installing () { - setup "down : réessaie au-delà du délai tant que le serveur s'installe" - mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 5)" - LAB_DELETE_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 run_lab down || fail "code de sortie $?" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - grep -q "pendant la livraison ou l'installation" "${WORK}/out.log" || fail "installation non reconnue" - teardown -} - -test_down_gives_up_on_installing_server_after_hard_deadline () { - local RC - setup "down : abandonne quand même après délai d'installation + délai de suppression" - mutate ".servers[\"srv-lab\"] = ${INSTALLING_LAB_SERVER} | .refuse_until = (now + 100000)" - LAB_DELETE_TIMEOUT=2 LAB_INSTALL_TIMEOUT=2 LAB_POLL_INTERVAL=0.2 lab_env timeout 30 bash "${SCRIPT}" down > "${WORK}/out.log" 2>&1 - RC=$? - [[ "${RC}" -ne 124 ]] || fail "aucun plafond : down tourne encore après 30 s" - [[ "${RC}" -ne 0 ]] || fail "down a réussi" - grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" - teardown -} - -test_down_retries_listing_servers () { - setup "down : réessaie la liste des serveurs" - mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 2" - run_lab down || fail "code de sortie $?" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_down_fails_loudly_when_listing_never_works () { - setup "down : échec bruyant si la liste reste indisponible" - mutate ".servers[\"srv-lab\"] = ${LAB_SERVER} | .list_errors = 100000" - LAB_DELETE_TIMEOUT=1 run_lab down && fail "down a réussi" - grep -q "TOUJOURS FACTURÉ" "${WORK}/out.log" || fail "pas d'alerte de facturation" - teardown -} - -test_session_deletes_created_server_even_if_missing_from_list () { - setup "session : supprime le serveur créé même absent de la liste" - mutate '.hide_from_list = true | .progress = [["ready","completed"]]' - run_lab session true || fail "code de sortie $?" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_every_api_call_has_timeouts () { - setup "chaque appel à l'API a un délai de connexion et un délai total" - mutate '.progress = [["ready","completed"]]' - run_lab up || fail "code de sortie $?" - [[ -s "${WORK}/argv.log" ]] || fail "aucun appel" - [[ $(jq -s '[.[] | select((index("--max-time") | not) or (index("--connect-timeout") | not))] | length' "${WORK}/argv.log") -eq 0 ]] \ - || fail "appel sans délai" - teardown -} - -test_up_reports_uncertain_creation_when_post_fails () { - setup "up : création incertaine signalée quand le POST échoue" - mutate '.post_fail_after_create = true' - run_lab up && fail "up a réussi" - grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine" - grep -q "un serveur de lab existe bien : srv-1" "${WORK}/out.log" || fail "serveur créé non signalé" - teardown -} - -test_session_deletes_server_when_post_fails_after_creation () { - setup "session : serveur supprimé quand le POST échoue après création" - mutate '.post_fail_after_create = true' - run_lab session true && fail "session a réussi" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_up_refuses_response_without_id () { - setup "up : réponse de création sans identifiant refusée" - mutate '.post_no_id = true' - run_lab up && fail "up a réussi" - grep -q "création incertaine" "${WORK}/out.log" || fail "pas de message de création incertaine" - grep -q "servers/null" "${WORK}/calls.log" && fail "appel sur un identifiant null" - teardown -} - -test_up_stops_at_once_when_server_vanishes () { - setup "up : arrêt immédiat si le serveur disparaît pendant l'installation" - mutate '.vanish_after = 1 | .progress = [["delivering","to_install"]]' - LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi" - grep -q "erreur définitive HTTP 404" "${WORK}/out.log" || fail "404 non traité comme définitif" - teardown -} - -test_up_stops_at_once_when_server_is_stopped_during_installation () { - setup "up : arrêt immédiat si le serveur passe à l'arrêt pendant l'installation" - mutate '.progress = [["delivering","to_install"],["stopped","installing"]]' - LAB_INSTALL_TIMEOUT=5 run_lab up && fail "up a réussi" - grep -q "état terminal en échec : stopped/installing" "${WORK}/out.log" || fail "arrêt non reconnu comme terminal" - teardown -} - -test_session_cleanup_survives_a_second_signal () { - local PID RC I - setup "session : un second signal n'interrompt pas le nettoyage" - mutate '.progress = [["ready","completed"]] | .linger = 15' - set -m - FAKE_SSH_SLEEP=5 LAB_POLL_INTERVAL=0.2 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 & - PID=$! - set +m - for I in $(seq 1 100); do - grep -q "sleep-long" "${WORK}/ssh.log" && break - sleep 0.1 - done - kill -TERM -- "-${PID}" - for I in $(seq 1 100); do - grep -q "suppression de srv-1" "${WORK}/out.log" && break - sleep 0.1 - done - grep -q "suppression de srv-1" "${WORK}/out.log" || fail "nettoyage jamais commencé" - kill -INT -- "-${PID}" 2>/dev/null - kill -TERM -- "-${PID}" 2>/dev/null - wait "${PID}" - RC=$? - [[ "${RC}" -eq 143 ]] || fail "code ${RC} au lieu de 143" - grep -q "aucun serveur de lab ne reste" "${WORK}/out.log" || fail "nettoyage interrompu" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_ssh_never_receives_api_key () { - setup "ssh ne reçoit jamais la clé d'API dans son environnement" - mutate '.progress = [["ready","completed"]]' - run_lab session uname || fail "code de sortie $?" - [[ -s "${WORK}/ssh-env.log" ]] || fail "ssh jamais lancé" - grep -qv '^key=$' "${WORK}/ssh-env.log" && fail "clé transmise à ssh" - teardown -} - -test_session_waits_for_ssh_before_running_command () { - setup "session : attend que SSH réponde avant la commande" - mutate '.progress = [["ready","completed"]]' - echo 3 > "${WORK}/probe_fails" - run_lab session uname || fail "code de sortie $?" - [[ $(grep -c "BatchMode=yes" "${WORK}/ssh.log") -eq 4 ]] || fail "$(grep -c "BatchMode=yes" "${WORK}/ssh.log") sondes au lieu de 4" - [[ $(tail -n 1 "${WORK}/ssh.log") == *" uname" ]] || fail "commande non lancée en dernier" - teardown -} - -test_session_deletes_server_when_ssh_never_answers () { - setup "session : serveur supprimé si SSH ne répond jamais" - mutate '.progress = [["ready","completed"]]' - echo 100000 > "${WORK}/probe_fails" - LAB_SSH_TIMEOUT=1 LAB_POLL_INTERVAL=0.2 run_lab session uname && fail "session a réussi" - grep -qv "BatchMode=yes" "${WORK}/ssh.log" && fail "commande lancée sans SSH joignable" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_ssh_uses_dedicated_lab_key_only_when_present () { - setup "ssh : clé dédiée du lab utilisée seule quand elle existe" - mutate '.progress = [["ready","completed"]]' - mkdir -p "${WORK}/.config/two-lab/ssh" - : > "${WORK}/.config/two-lab/ssh/lab_ed25519" - run_lab session uname || fail "code de sortie $?" - grep -q " uname" "${WORK}/ssh.log" || fail "commande non lancée" - [[ $(grep -c -- "-i ${WORK}/.config/two-lab/ssh/lab_ed25519 -o IdentitiesOnly=yes -o IdentityAgent=none" "${WORK}/ssh.log") -eq $(wc -l < "${WORK}/ssh.log") ]] \ - || fail "une connexion n'a pas utilisé la clé dédiée seule" - teardown -} - -test_ssh_falls_back_to_agent_without_lab_key () { - setup "ssh : agent SSH utilisé quand la clé dédiée est absente" - mutate '.progress = [["ready","completed"]]' - run_lab session uname || fail "code de sortie $?" - grep -q -- "-i " "${WORK}/ssh.log" && fail "clé imposée alors qu'elle n'existe pas" - grep -q "IdentityAgent=none" "${WORK}/ssh.log" && fail "agent désactivé sans clé dédiée" - teardown -} - -known_server () { - mkdir -p "${WORK}/.cache/two-lab" - echo 203.0.113.7 > "${WORK}/.cache/two-lab/ip" - echo debian > "${WORK}/.cache/two-lab/user" - : > "${WORK}/.cache/two-lab/known_hosts" -} - -test_up_prepares_the_server_after_ssh () { - setup "up : prépare le serveur (qemu, genisoimage, KVM imbriqué) une fois SSH joignable" - mutate '.progress = [["ready","completed"]]' - run_lab up || fail "code de sortie $?" - [[ $(tail -n 1 "${WORK}/ssh.log") == *"debian@203.0.113.7 bash -s" ]] || fail "préparation non lancée en dernier : $(tail -n 1 "${WORK}/ssh.log")" - grep -q "apt-get install -y -qq --no-install-recommends qemu-system-x86 qemu-utils genisoimage" "${WORK}/prepare.sh" || fail "paquets absents du script" - grep -q 'usermod -aG kvm' "${WORK}/prepare.sh" || fail "groupe kvm absent du script" - grep -q -- '-c /dev/kvm' "${WORK}/prepare.sh" || fail "contrôle de /dev/kvm absent" - grep -q 'kvm_intel/parameters/nested' "${WORK}/prepare.sh" || fail "contrôle de nested absent" - teardown -} - -test_up_reports_a_billed_server_when_preparation_fails () { - setup "up : préparation échouée signalée, serveur toujours facturé" - mutate '.progress = [["ready","completed"]]' - FAKE_PREPARE_RC=1 run_lab up && fail "up a réussi" - grep -q "préparation du serveur échouée — il est toujours facturé" "${WORK}/out.log" || fail "message absent" - grep -q "prêt :" "${WORK}/out.log" && fail "serveur annoncé prêt" - teardown -} - -test_session_deletes_server_when_preparation_fails () { - setup "session : serveur supprimé si la préparation échoue, commande jamais lancée" - mutate '.progress = [["ready","completed"]]' - FAKE_PREPARE_RC=1 run_lab session uname && fail "session a réussi" - grep -q " uname" "${WORK}/ssh.log" && fail "commande lancée malgré la préparation échouée" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_prepare_script_is_valid_shell () { - setup "prepare : le script distant est du shell valide" - known_server - run_lab prepare || fail "code de sortie $?" - bash -n "${WORK}/prepare.sh" || fail "bash -n refuse le script" - sh -n "${WORK}/prepare.sh" || fail "sh -n refuse le script" - teardown -} - -test_prepare_without_known_server_is_refused () { - setup "prepare : refusé sans serveur connu" - run_lab prepare && fail "prepare a réussi" - [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé" - teardown -} - -test_push_builds_for_linux_and_sends_binary_and_topology () { - setup "push : compile lab pour linux/amd64, envoie le binaire et le répertoire de la topologie" - known_server - mkdir -p "${WORK}/conf/frr" - printf 'name: evpn-2hv\n' > "${WORK}/conf/evpn-2hv.yml" - printf 'hostname rr1\n' > "${WORK}/conf/frr/rr1.conf" - xattr -w com.apple.test lab "${WORK}/conf/evpn-2hv.yml" 2>/dev/null || true - run_lab push "${WORK}/conf/evpn-2hv.yml" || fail "code de sortie $?" - local REPO LISTING - REPO="$(cd "$(dirname "${SCRIPT}")/.." && pwd)" - [[ $(cat "${WORK}/go.log") == "${REPO}|build -o ${WORK}/.cache/two-lab/lab ./cmd/lab|CGO_ENABLED=0 GOOS=linux GOARCH=amd64" ]] \ - || fail "compilation : $(cat "${WORK}/go.log")" - grep -q "debian@203.0.113.7 cat > 'lab.part' && chmod 755 'lab.part' && mv 'lab.part' 'lab'" "${WORK}/ssh.log" || fail "envoi de lab absent" - grep -q "debian@203.0.113.7 rm -rf topology.part && mkdir topology.part && tar -C topology.part --no-same-owner -xf - && rm -rf topology && mv topology.part topology" "${WORK}/ssh.log" \ - || fail "envoi du répertoire absent" - [[ $(cat "${WORK}/pushed.1") == "binaire-lab" ]] || fail "contenu de lab : $(cat "${WORK}/pushed.1")" - LISTING=$(tar -tf "${WORK}/pushed.2" | sed 's|^\./||' | grep -v '/$' | grep -v '^$' | sort | tr '\n' ' ') - [[ "${LISTING}" == "evpn-2hv.yml frr/rr1.conf " ]] || fail "contenu de l'archive : ${LISTING}" - grep -aq 'LIBARCHIVE.xattr' "${WORK}/pushed.2" && fail "attributs étendus macOS dans l'archive" - mkdir "${WORK}/x" && tar -C "${WORK}/x" -xf "${WORK}/pushed.2" - [[ $(cat "${WORK}/x/frr/rr1.conf") == "hostname rr1" ]] || fail "frr/rr1.conf altéré" - grep -q "ssh './lab up topology/evpn-2hv.yml'" "${WORK}/out.log" || fail "consigne finale absente" - teardown -} - -test_push_stops_when_the_build_fails () { - setup "push : rien n'est envoyé si la compilation échoue" - known_server - printf 'name: x\n' > "${WORK}/x.yml" - FAKE_GO_FAIL=1 run_lab push "${WORK}/x.yml" && fail "push a réussi" - [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé" - grep -q "compilation de lab échouée" "${WORK}/out.log" || fail "message absent" - teardown -} - -test_push_fails_when_a_transfer_fails () { - setup "push : un envoi échoué fait échouer push" - known_server - printf 'name: x\n' > "${WORK}/x.yml" - FAKE_PUSH_RC=1 run_lab push "${WORK}/x.yml" && fail "push a réussi" - grep -q "envoi de lab échoué" "${WORK}/out.log" || fail "message absent" - teardown -} - -test_push_refusals () { - local CASE - for CASE in absent quote nothing; do - setup "push : refus (${CASE})" - known_server - case "${CASE}" in - absent) run_lab push "${WORK}/absent.yml" && fail "push a réussi" ;; - quote) printf 'x\n' > "${WORK}/a'b.yml"; run_lab push "${WORK}/a'b.yml" && fail "push a réussi" ;; - nothing) run_lab push && fail "push a réussi" ;; - esac - [[ -s "${WORK}/go.log" ]] && fail "compilation lancée" - [[ -s "${WORK}/ssh.log" ]] && fail "ssh lancé" - teardown - done -} - -test_ssh_without_terminal_does_not_ask_for_one () { - setup "ssh : pas de -t quand l'entrée n'est pas un terminal" - known_server - run_lab ssh './lab status' < /dev/null || fail "code de sortie $?" - [[ $(cat "${WORK}/ssh.log") == *"debian@203.0.113.7 ./lab status" ]] || fail "commande : $(cat "${WORK}/ssh.log")" - grep -q -- " -t " "${WORK}/ssh.log" && fail "-t demandé sans terminal" - teardown -} - -run_lab_without_env_credentials () { - env -i PATH="${WORK}/bin:${PATH}" HOME="${WORK}" TMPDIR="${TMPDIR:-/tmp}" \ - FAKE_DIR="${WORK}" FAKE_SECRET="${SECRET}" SCW_API_URL="https://api.example.invalid" \ - LAB_POLL_INTERVAL=0 "$@" bash "${SCRIPT}" plan > "${WORK}/out.log" 2>&1 -} - -write_credentials () { - mkdir -p "${WORK}/.config/two-lab" - printf '%s\n' "$@" > "${WORK}/.config/two-lab/scaleway.env" - chmod 600 "${WORK}/.config/two-lab/scaleway.env" -} - -test_credentials_file_is_used_when_environment_is_empty () { - setup "identifiants : fichier lu quand l'environnement est vide" - write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" "SCW_DEFAULT_ZONE=fr-par-1" - run_lab_without_env_credentials || fail "code de sortie $?" - grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet du fichier non utilisé" - grep -q "${SECRET}" "${WORK}/out.log" && fail "secret affiché" - teardown -} - -test_credentials_file_without_final_newline_is_read () { - setup "identifiants : dernière ligne sans retour à la ligne lue quand même" - mkdir -p "${WORK}/.config/two-lab" - printf 'SCW_DEFAULT_PROJECT_ID=%s\nSCW_SECRET_KEY=%s' "${PROJECT}" "${SECRET}" > "${WORK}/.config/two-lab/scaleway.env" - chmod 600 "${WORK}/.config/two-lab/scaleway.env" - run_lab_without_env_credentials || fail "code de sortie $?" - teardown -} - -test_environment_wins_over_credentials_file () { - setup "identifiants : l'environnement l'emporte sur le fichier" - write_credentials "SCW_SECRET_KEY=mauvais-secret" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}" - run_lab_without_env_credentials SCW_SECRET_KEY="${SECRET}" SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?" - grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement non retenu" - teardown -} - -test_environment_value_kept_when_file_completes_the_rest () { - setup "identifiants : une valeur de l'environnement n'est pas écrasée quand le fichier complète" - write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${OTHER_PROJECT}" - run_lab_without_env_credentials SCW_DEFAULT_PROJECT_ID="${PROJECT}" || fail "code de sortie $?" - grep -q "\"project_id\": \"${PROJECT}\"" "${WORK}/out.log" || fail "projet de l'environnement écrasé par le fichier" - teardown -} - -test_credentials_file_readable_by_others_is_refused () { - setup "identifiants : fichier lisible par d'autres refusé, sans aucun appel" - write_credentials "SCW_SECRET_KEY=${SECRET}" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" - chmod 644 "${WORK}/.config/two-lab/scaleway.env" - run_lab_without_env_credentials && fail "plan a réussi" - grep -q "chmod 600" "${WORK}/out.log" || fail "pas de consigne de droits" - [[ -s "${WORK}/calls.log" ]] && fail "appel réseau avec un fichier exposé" - teardown -} - -test_credentials_file_is_never_executed () { - setup "identifiants : le fichier est lu, jamais exécuté" - write_credentials "SCW_SECRET_KEY=\$(touch ${WORK}/pwned)" "touch ${WORK}/pwned2" "SCW_DEFAULT_PROJECT_ID=${PROJECT}" - run_lab_without_env_credentials && fail "plan a réussi avec un secret invalide" - [[ -e "${WORK}/pwned" || -e "${WORK}/pwned2" ]] && fail "contenu du fichier exécuté" - teardown -} - -signal_during_session () { - local SIGNAL="${1}" - local EXPECTED="${2}" - local PID RC I - setup "session : ${SIGNAL} pendant la commande distante, serveur supprimé, code ${EXPECTED}" - mutate '.progress = [["ready","completed"]]' - set -m - FAKE_SSH_SLEEP=5 lab_env exec bash "${SCRIPT}" session sleep-long > "${WORK}/out.log" 2>&1 & - PID=$! - set +m - for I in $(seq 1 100); do - grep -q "sleep-long" "${WORK}/ssh.log" && break - sleep 0.1 - done - grep -q "sleep-long" "${WORK}/ssh.log" || fail "commande distante jamais lancée" - kill "-${SIGNAL}" -- "-${PID}" - wait "${PID}" - RC=$? - [[ "${RC}" -eq "${EXPECTED}" ]] || fail "code ${RC} au lieu de ${EXPECTED}" - [[ $(remaining) == "foreign-1,foreign-2" ]] || fail "restants : $(remaining)" - teardown -} - -test_session_handles_term () { signal_during_session TERM 143; } -test_session_handles_hup () { signal_during_session HUP 129; } -test_session_handles_int () { signal_during_session INT 130; } - -for T in $(declare -F | awk '{print $3}' | grep '^test_'); do - "${T}" -done -echo "${PASS} réussi(s), ${FAIL} échec(s)" -[[ "${FAIL}" -eq 0 ]] diff --git a/test/e2e/lib/node.sh b/test/e2e/lib/node.sh deleted file mode 100644 index 0a7111c..0000000 --- a/test/e2e/lib/node.sh +++ /dev/null @@ -1,151 +0,0 @@ -set -u -API=http://127.0.0.1:8080 -IMAGE_URL=https://cloud.debian.org/images/cloud/bookworm/latest -IMAGE_FILE=debian-12-genericcloud-amd64.qcow2 -TWO_IMAGE=/var/tmp/deb-two.qcow2 -VM_KEY=/root/.ssh/lab-vm -VOLUMES=/var/lib/two/volumes - -check () { - local desc="${1}" out - shift - if out=$("$@" 2>&1); then - echo "RÉUSSI: ${desc}" - else - echo "ÉCHOUÉ: ${desc}${out:+ — $(printf '%s' "${out}" | tail -n 1)}" - fi -} - -info () { echo "INFO: ${1}"; } - -api_post () { - curl -fsS -o /dev/null -X POST -H 'Content-Type: application/json' -d "${2}" "${API}/${1}" -} - -api_delete () { - curl -fsS -o /dev/null -X DELETE "${API}/${1}" -} - -api_state () { - curl -fsS "${API}/${1}" 2>/dev/null | jq -r .state -} - -wait_state () { - local path="${1}" want="${2}" i s - for i in $(seq 1 90); do - s=$(api_state "${path}") - [ "${s}" = "${want}" ] && return 0 - [ "${want}" = deleted ] && [ -z "${s}" ] && return 0 - [ "${s}" = error ] && { echo "${path}: error" >&2; return 1; } - sleep 2 - done - echo "${path}: ${s:-absent}, ${want} attendu" >&2 - return 1 -} - -vpc_create () { - api_state "vpcs/${1}" | grep -qx running && return 0 - api_post vpcs "{\"name\":\"${1}\",\"cidr\":\"${2}\"}" && wait_state "vpcs/${1}" running -} - -subnet_create () { - local name="${1}" vpc="${2}" vni="${3}" gw="${4}" cidr="${5}" extra="${6:-}" - api_state "subnets/${name}" | grep -qx running && return 0 - api_post subnets "{\"name\":\"${name}\",\"vpc\":\"${vpc}\",\"mode\":\"vxlan\",\"vxlan_id\":${vni},\"iface_type\":\"vms\",\"interface_ip\":\"${gw}\",\"cidr\":\"${cidr}\"${extra}}" \ - && wait_state "subnets/${name}" running -} - -two_image () { - [ -f "${TWO_IMAGE}" ] && return 0 - local work - work=$(mktemp -d) - curl -fsSL -o "${work}/${IMAGE_FILE}" "${IMAGE_URL}/${IMAGE_FILE}" || return 1 - curl -fsSL "${IMAGE_URL}/SHA512SUMS" | grep " ${IMAGE_FILE}\$" > "${work}/sums" || return 1 - (cd "${work}" && sha512sum -c sums >/dev/null) || { echo "image: somme SHA-512 incorrecte" >&2; return 1; } - command -v qemu-nbd >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-utils >/dev/null - modprobe nbd max_part=8 - qemu-nbd -c /dev/nbd0 "${work}/${IMAGE_FILE}" || return 1 - for i in $(seq 1 20); do [ -b /dev/nbd0p1 ] && break; sleep 0.5; done - mkdir -p /mnt/two-image - mount /dev/nbd0p1 /mnt/two-image || { qemu-nbd -d /dev/nbd0 >/dev/null; return 1; } - printf '%s\n' 'datasource_list: [ NoCloud ]' 'datasource:' ' NoCloud:' " seedfrom: 'http://169.254.169.254:80/'" ' timeout: 5' ' max_wait: 10' \ - > /mnt/two-image/etc/cloud/cloud.cfg.d/99_metadata.cfg - umount /mnt/two-image - qemu-nbd -d /dev/nbd0 >/dev/null - mv "${work}/${IMAGE_FILE}" "${TWO_IMAGE}" - rm -rf "${work}" -} - -vm_key () { - [ -f "${VM_KEY}" ] || ssh-keygen -q -t ed25519 -N '' -C lab-vm -f "${VM_KEY}" - cat "${VM_KEY}.pub" -} - -vm_create () { - local name="${1}" subnet="${2}" ip="${3}" key="${4:-}" meta="{}" - [ -n "${key}" ] && meta="{\"sshkey\":\"${key}\"}" - mkdir -p "${VOLUMES}" - cp "${TWO_IMAGE}" "${VOLUMES}/${name}.qcow2" || return 1 - api_post vms "{\"name\":\"${name}\",\"memory\":1024,\"cpus\":1,\"metadata\":${meta},\"interfaces\":[{\"subnet\":\"${subnet}\",\"ip\":\"${ip}\",\"primary\":true}],\"storage\":[{\"path\":\"${VOLUMES}/${name}.qcow2\",\"dev\":\"vda\"}]}" \ - && wait_state "vms/${name}" running -} - -vm_delete () { - api_delete "vms/${1}" && wait_state "vms/${1}" deleted -} - -vm_ssh () { - local vpc="${1}" ip="${2}" - shift 2 - ip netns exec "${vpc}" ssh -i "${VM_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ - -o LogLevel=ERROR -o ConnectTimeout=3 -o BatchMode=yes -n "syonad@${ip}" "$@" -} - -vm_wait () { - local i - for i in $(seq 1 40); do - vm_ssh "${1}" "${2}" true 2>/dev/null && return 0 - sleep 5 - done - echo "${2}: SSH injoignable" >&2 - return 1 -} - -vm_fails () { - local out - out=$(vm_ssh "${1}" "${2}" "${3} >/dev/null 2>&1; echo rc=\$?") || { echo "SSH vers ${2} en échec"; return 1; } - case "${out}" in - rc=0) echo "la commande a réussi dans ${2}"; return 1 ;; - rc=*) return 0 ;; - *) echo "sortie inattendue : ${out}"; return 1 ;; - esac -} - -vm_grep () { - vm_ssh "${1}" "${2}" "${3} 2>&1" | grep -q -- "${4}" -} - -has_addr () { - vm_ssh "${1}" "${2}" 'ip -4 -o addr show dev ens3' | grep -q " ${2}/" -} - -route_via () { - vm_ssh "${1}" "${2}" "ip -4 route show ${3}" | grep -q "via ${4} " -} - -dhcp_hosts () { - python3 - "/run/two/dhcp/${1}.sock" <<'PY' -import json, socket, sys -s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) -s.connect(sys.argv[1]) -s.sendall(b'{"verb":"get-state"}\n') -buf = b"" -while not buf.endswith(b"\n"): - c = s.recv(65536) - if not c: - break - buf += c -state = json.loads(buf).get("state") or {} -print(" ".join(sorted(h["mac"].lower() + "=" + h["ip"] for h in state.get("hosts") or []))) -PY -} diff --git a/test/e2e/run.sh b/test/e2e/run.sh deleted file mode 100755 index 4b9e84d..0000000 --- a/test/e2e/run.sh +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env bash - -LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -LAB_HOST="${LAB_HOST:-${LAB_DIR}/../../scripts/lab-host.sh}" -SCENARIO_DIR="${SCENARIO_DIR:-${LAB_DIR}/scenarios}" -SCENARIO_LOG="" - -usage () { - cat >&2 <... | all - - scénarios : $(cd "${SCENARIO_DIR}" && ls -- *.sh | sed 's/\.sh$//' | tr '\n' ' ') - -Chaque scénario s'exécute depuis le Mac sur le lab en cours (lab-host.sh up, push, ./lab up). -Une ligne RÉUSSI ou ÉCHOUÉ par vérification ; code de sortie 1 si une vérification échoue ou -si aucune n'a été faite. -USAGE - exit 2 -} - -on () { - local node="${1}" assignment - shift - { - for assignment in "$@"; do - printf 'export %q=%q\n' "${assignment%%=*}" "${assignment#*=}" - done - cat "${LAB_DIR}/lib/node.sh" - cat - } | "${LAB_HOST}" ssh "./lab ssh ${node} 'sudo bash -s'" 2>&1 | tee -a "${SCENARIO_LOG}" -} - -run_scenario () { - local file="${1}" name passed failed - name="$(basename "${file}" .sh)" - SCENARIO_LOG="$(mktemp)" - echo "=== ${name}" - ( . "${file}" ) - passed=$(grep -c '^RÉUSSI: ' "${SCENARIO_LOG}") - failed=$(grep -c '^ÉCHOUÉ: ' "${SCENARIO_LOG}") - rm -f "${SCENARIO_LOG}" - echo "=== ${name} : ${passed} réussi(s), ${failed} échoué(s)" - [[ "${failed}" -eq 0 && "${passed}" -gt 0 ]] -} - -main () { - local -a files=() - local arg file rc=0 - [[ $# -gt 0 ]] || usage - for arg in "$@"; do - if [[ "${arg}" == all ]]; then - files+=("${SCENARIO_DIR}"/*.sh) - continue - fi - file=$(ls "${SCENARIO_DIR}/${arg}"*.sh 2>/dev/null | head -n 1) - [[ -n "${file}" ]] || { echo "scénario inconnu : ${arg}" >&2; usage; } - files+=("${file}") - done - for file in "${files[@]}"; do - run_scenario "${file}" || rc=1 - done - return "${rc}" -} - -if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then - main "$@" -fi diff --git a/test/e2e/run_test.sh b/test/e2e/run_test.sh deleted file mode 100755 index dd8022f..0000000 --- a/test/e2e/run_test.sh +++ /dev/null @@ -1,140 +0,0 @@ -#!/usr/bin/env bash - -DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PASS=0 -FAIL=0 - -fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; } - -setup () { - CURRENT="${1}" - CASE_OK=1 - WORK=$(mktemp -d "${TMPDIR:-/tmp}/scenario.XXXXXX") - mkdir -p "${WORK}/scenarios" - cat > "${WORK}/lab-host" <<'FAKE' -#!/usr/bin/env bash -N=$(ls "${FAKE_DIR}" | grep -c '^payload\.') -cat > "${FAKE_DIR}/payload.$(( N + 1 ))" -printf '%s\n' "$*" >> "${FAKE_DIR}/calls.log" -if ! bash -n "${FAKE_DIR}/payload.$(( N + 1 ))" 2>"${FAKE_DIR}/syntax.$(( N + 1 ))"; then - echo "ÉCHOUÉ: syntaxe du bloc $(( N + 1 ))" - exit 0 -fi -[ -n "${FAKE_OUT:-}" ] && printf '%b\n' "${FAKE_OUT}" -exit 0 -FAKE - chmod +x "${WORK}/lab-host" - : > "${WORK}/calls.log" -} - -run () { - env LAB_HOST="${WORK}/lab-host" FAKE_DIR="${WORK}" SCENARIO_DIR="${SCENARIO_DIR:-${WORK}/scenarios}" \ - FAKE_OUT="${FAKE_OUT:-}" bash "${DIR}/run.sh" "$@" > "${WORK}/out.log" 2>&1 -} - -teardown () { - [[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}" - [[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; } - rm -rf "${WORK}" -} - -node () { - bash -c ". '${DIR}/lib/node.sh'; ${1}" 2>&1 -} - -test_on_sends_assignments_library_and_block_to_the_node () { - setup "on : variables, bibliothèque et bloc envoyés au bon nœud" - printf '%s\n' "on hv2 KEY='ssh-ed25519 AAAA x' NAME=vm <<'NODE'" 'echo "${NAME}"' 'NODE' > "${WORK}/scenarios/t1.sh" - FAKE_OUT='RÉUSSI: un' run t1 || fail "code de sortie $?" - [[ $(cat "${WORK}/calls.log") == "ssh ./lab ssh hv2 'sudo bash -s'" ]] || fail "appel : $(cat "${WORK}/calls.log")" - grep -q '^check () {' "${WORK}/payload.1" || fail "node.sh absent du bloc" - [[ $(tail -n 1 "${WORK}/payload.1") == 'echo "${NAME}"' ]] || fail "bloc du scénario absent en fin d'envoi" - [[ $(bash -c "$(grep '^export ' "${WORK}/payload.1"); printf '%s|%s' \"\${KEY}\" \"\${NAME}\"") == "ssh-ed25519 AAAA x|vm" ]] \ - || fail "variables mal transmises : $(grep '^export ' "${WORK}/payload.1")" - teardown -} - -test_scenario_result_counts_and_exit_codes () { - local CASE - for CASE in ok fail none; do - setup "résultat d'un scénario (${CASE})" - printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/t2.sh" - case "${CASE}" in - ok) FAKE_OUT='RÉUSSI: a\nRÉUSSI: b' run t2; [[ $? -eq 0 ]] || fail "code non nul" - grep -q '=== t2 : 2 réussi(s), 0 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;; - fail) FAKE_OUT='RÉUSSI: a\nÉCHOUÉ: b — détail' run t2; [[ $? -eq 1 ]] || fail "code différent de 1" - grep -q '=== t2 : 1 réussi(s), 1 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;; - none) FAKE_OUT='INFO: rien' run t2; [[ $? -eq 1 ]] || fail "un scénario sans vérification a réussi" ;; - esac - teardown - done -} - -test_unknown_scenario_and_no_argument () { - setup "scénario inconnu ou absent : usage, code 2" - run nope; [[ $? -eq 2 ]] || fail "scénario inconnu : code $?" - run; [[ $? -eq 2 ]] || fail "sans argument : code $?" - [[ -s "${WORK}/calls.log" ]] && fail "un nœud a été appelé" - teardown -} - -test_all_runs_every_scenario_and_fails_if_one_fails () { - setup "all : chaque scénario, échec si l'un échoue" - printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/a.sh" - printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/b.sh" - FAKE_OUT='ÉCHOUÉ: x' run all; [[ $? -eq 1 ]] || fail "code différent de 1" - [[ $(grep -c '^=== [ab] :' "${WORK}/out.log") -eq 2 ]] || fail "les deux scénarios n'ont pas tourné" - teardown -} - -test_every_shipped_block_is_valid_bash () { - local f - setup "chaque bloc envoyé par les scénarios livrés est du bash valide" - SCENARIO_DIR="${DIR}/scenarios" FAKE_OUT='RÉUSSI: x' run all - for f in "${WORK}"/syntax.*; do - [[ -s "${f}" ]] && fail "${f##*/} : $(cat "${f}")" - done - [[ $(ls "${WORK}" | grep -c '^payload\.') -ge 10 ]] || fail "trop peu de blocs envoyés : $(ls "${WORK}" | grep -c '^payload\.')" - grep -q 'ÉCHOUÉ: syntaxe' "${WORK}/out.log" && fail "bloc invalide" - teardown -} - -test_check_reports_success_and_failure_with_reason () { - setup "check : RÉUSSI, ou ÉCHOUÉ avec la dernière ligne de sortie" - [[ $(node 'check "a" true') == "RÉUSSI: a" ]] || fail "succès : $(node 'check "a" true')" - [[ $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"') == "ÉCHOUÉ: b — deux" ]] || fail "échec : $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"')" - [[ $(node 'check "c" false') == "ÉCHOUÉ: c" ]] || fail "échec muet : $(node 'check "c" false')" - teardown -} - -test_vm_fails_only_when_ssh_worked_and_command_failed () { - setup "vm_fails : réussit seulement si SSH marche et la commande échoue" - [[ $(node 'vm_ssh () { echo rc=1; }; vm_fails v 1.2.3.4 x && echo OUI') == "OUI" ]] || fail "commande échouée non reconnue" - [[ $(node 'vm_ssh () { echo rc=0; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "commande réussie prise pour un échec" - [[ $(node 'vm_ssh () { return 255; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "SSH en panne pris pour une isolation" - [[ $(node 'vm_ssh () { echo bizarre; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "sortie inattendue acceptée" - teardown -} - -test_route_via_matches_the_next_hop_exactly () { - setup "route_via : le next-hop exact, pas un préfixe" - [[ $(node 'vm_ssh () { echo "default via 10.220.1.1 dev ens3 proto dhcp"; }; route_via v ip default 10.220.1.1 && echo OUI') == OUI ]] || fail "next-hop exact refusé" - [[ $(node 'vm_ssh () { echo "default via 10.220.1.10 dev ens3"; }; route_via v ip default 10.220.1.1 || echo NON') == NON ]] || fail "10.220.1.10 pris pour 10.220.1.1" - teardown -} - -test_wait_state () { - setup "wait_state : état atteint, erreur, suppression" - [[ $(node 'api_state () { echo running; }; wait_state vms/x running && echo OUI') == OUI ]] || fail "running" - local start=${SECONDS} - [[ $(node 'api_state () { echo error; }; wait_state vms/x running 2>&1 || echo NON') == *NON ]] || fail "error accepté" - (( SECONDS - start < 5 )) || fail "une ressource en error n'arrête pas l'attente immédiatement ($(( SECONDS - start )) s)" - [[ $(node 'api_state () { echo; }; wait_state vms/x deleted && echo OUI') == OUI ]] || fail "absence = supprimé" - teardown -} - -for T in $(declare -F | awk '{print $3}' | grep '^test_'); do - "${T}" -done -echo "${PASS} réussi(s), ${FAIL} échec(s)" -[[ "${FAIL}" -eq 0 ]] diff --git a/test/e2e/scenarios/s1-dhcp-two.sh b/test/e2e/scenarios/s1-dhcp-two.sh deleted file mode 100644 index bbf727c..0000000 --- a/test/e2e/scenarios/s1-dhcp-two.sh +++ /dev/null @@ -1,34 +0,0 @@ -on hv1 <<'NODE' -two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } -KEY=$(vm_key) -check "VPC vp-s1" vpc_create vp-s1 10.210.0.0/16 -check "subnet sn-s1a" subnet_create sn-s1a vp-s1 2101 10.210.1.1 10.210.1.0/24 -check "subnet sn-s1b" subnet_create sn-s1b vp-s1 2102 10.210.2.1 10.210.2.0/24 -check "backend two : socket de contrôle de sn-s1a" test -S /run/two/dhcp/vp-s1_br-s1a.sock -check "backend two : socket de contrôle de sn-s1b" test -S /run/two/dhcp/vp-s1_br-s1b.sock -check "VM s1-a1, démarrée seule" vm_create s1-a1 sn-s1a 10.210.1.10 "${KEY}" -check "VM s1-a1 joignable" vm_wait vp-s1 10.210.1.10 -check "VM s1-b1, démarrée seule" vm_create s1-b1 sn-s1b 10.210.2.10 "${KEY}" -check "VM s1-b1 joignable" vm_wait vp-s1 10.210.2.10 -vm_create s1-a2 sn-s1a 10.210.1.11 "${KEY}" > /tmp/s1-a2.log 2>&1 & A=$! -vm_create s1-b2 sn-s1b 10.210.2.11 "${KEY}" > /tmp/s1-b2.log 2>&1 & B=$! -wait "${A}"; RA=$? -wait "${B}"; RB=$? -check "VM s1-a2, démarrée en même temps que s1-b2" test "${RA}" -eq 0 -check "VM s1-b2, démarrée en même temps que s1-a2" test "${RB}" -eq 0 -for vm in 10.210.1.10:10.210.1.1 10.210.1.11:10.210.1.1 10.210.2.10:10.210.2.1 10.210.2.11:10.210.2.1; do - ip=${vm%%:*}; gw=${vm#*:} - check "${ip} joignable" vm_wait vp-s1 "${ip}" - check "${ip} : adresse de son propre subnet" has_addr vp-s1 "${ip}" - check "${ip} : bail DHCP tenu par systemd-networkd" vm_ssh vp-s1 "${ip}" 'ls /run/systemd/netif/leases/ | grep -q .' - check "${ip} : route par défaut via ${gw}" route_via vp-s1 "${ip}" default "${gw}" - check "${ip} : route vers la VPC via ${gw}" route_via vp-s1 "${ip}" 10.210.0.0/16 "${gw}" - check "${ip} : route /32 vers les métadonnées via ${gw}" route_via vp-s1 "${ip}" 169.254.169.254 "${gw}" -done -vm_host () { echo "$(vm_ssh vp-s1 "${1}" 'cat /sys/class/net/ens3/address')=${1}"; } -A_HOSTS=$(printf '%s\n' "$(vm_host 10.210.1.10)" "$(vm_host 10.210.1.11)" | sort | tr '\n' ' ' | sed 's/ $//') -B_HOSTS=$(printf '%s\n' "$(vm_host 10.210.2.10)" "$(vm_host 10.210.2.11)" | sort | tr '\n' ' ' | sed 's/ $//') -info "sn-s1a : ${A_HOSTS} ; sn-s1b : ${B_HOSTS}" -check "probe sn-s1a : uniquement les couples MAC/IP de sn-s1a" test "$(dhcp_hosts vp-s1_br-s1a)" = "${A_HOSTS}" -check "probe sn-s1b : uniquement les couples MAC/IP de sn-s1b" test "$(dhcp_hosts vp-s1_br-s1b)" = "${B_HOSTS}" -NODE diff --git a/test/e2e/scenarios/s2-gateway.sh b/test/e2e/scenarios/s2-gateway.sh deleted file mode 100644 index 4785731..0000000 --- a/test/e2e/scenarios/s2-gateway.sh +++ /dev/null @@ -1,26 +0,0 @@ -on hv1 <<'NODE' -two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } -KEY=$(vm_key) -check "VPC vp-s2" vpc_create vp-s2 10.220.0.0/16 -check "subnet sn-s2a, default_route absent" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24 -check "subnet sn-s2b, default_route et gateway 10.220.2.254" subnet_create sn-s2b vp-s2 2202 10.220.2.1 10.220.2.0/24 ',"default_route":true,"gateway":"10.220.2.254"' -check "VM s2-a" vm_create s2-a sn-s2a 10.220.1.10 "${KEY}" -check "VM s2-b" vm_create s2-b sn-s2b 10.220.2.10 "${KEY}" -check "s2-a joignable" vm_wait vp-s2 10.220.1.10 -check "s2-b joignable" vm_wait vp-s2 10.220.2.10 -check "sn-s2a : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.10 default 10.220.1.1 -check "sn-s2a : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.10 10.220.0.0/16 10.220.1.1 -check "sn-s2a : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.1.10 169.254.169.254 10.220.1.1 -check "sn-s2b : route par défaut via la gateway 10.220.2.254" route_via vp-s2 10.220.2.10 default 10.220.2.254 -check "sn-s2b : route vers la VPC via interface_ip, pas via la gateway" route_via vp-s2 10.220.2.10 10.220.0.0/16 10.220.2.1 -check "sn-s2b : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.2.10 169.254.169.254 10.220.2.1 -check "suppression de s2-a" vm_delete s2-a -check "suppression de sn-s2a" api_delete subnets/sn-s2a -check "sn-s2a supprimé" wait_state subnets/sn-s2a deleted -check "sn-s2a recréé" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24 -check "VM s2-a2 sur le subnet recréé" vm_create s2-a2 sn-s2a 10.220.1.11 "${KEY}" -check "s2-a2 joignable" vm_wait vp-s2 10.220.1.11 -check "après recréation : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.11 default 10.220.1.1 -check "après recréation : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.11 10.220.0.0/16 10.220.1.1 -check "après recréation : route /32 vers les métadonnées" route_via vp-s2 10.220.1.11 169.254.169.254 10.220.1.1 -NODE diff --git a/test/e2e/scenarios/s3-isolation-local.sh b/test/e2e/scenarios/s3-isolation-local.sh deleted file mode 100644 index dc822f5..0000000 --- a/test/e2e/scenarios/s3-isolation-local.sh +++ /dev/null @@ -1,16 +0,0 @@ -on hv1 <<'NODE' -two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } -KEY=$(vm_key) -check "VPC vp-s3a" vpc_create vp-s3a 10.230.0.0/16 -check "VPC vp-s3b" vpc_create vp-s3b 10.231.0.0/16 -check "subnet sn-s3a" subnet_create sn-s3a vp-s3a 2301 10.230.1.1 10.230.1.0/24 -check "subnet sn-s3b" subnet_create sn-s3b vp-s3b 2302 10.231.1.1 10.231.1.0/24 -check "VM s3-x dans vp-s3a" vm_create s3-x sn-s3a 10.230.1.10 "${KEY}" -check "VM s3-y dans vp-s3b" vm_create s3-y sn-s3b 10.231.1.10 "${KEY}" -check "s3-x joignable depuis sa VPC" vm_wait vp-s3a 10.230.1.10 -check "s3-y joignable depuis sa VPC (témoin : la cible est vivante)" vm_wait vp-s3b 10.231.1.10 -check "s3-x joint sa passerelle (témoin)" vm_ssh vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.230.1.1' -check "s3-x ne joint pas s3-y en ICMP" vm_fails vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.231.1.10' -check "s3-x n'ouvre pas de connexion TCP vers s3-y:22" vm_fails vp-s3a 10.230.1.10 'timeout 5 bash -c "/dev/null 2>&1 && break; sleep 5; done -check "s4-p (hv1) joint s4-q (hv2)" vm_ssh vp-s4 10.240.1.10 'ping -c 3 -W 2 10.240.1.11' -check "trame pleine taille : 1472 octets en -M do" vm_ssh vp-s4 10.240.1.10 'ping -M do -s 1472 -c 2 -W 2 10.240.1.11' -check "1473 octets refusés localement (MTU 1500)" vm_grep vp-s4 10.240.1.10 'ping -M do -s 1473 -c 1 -W 2 10.240.1.11' 'message too long' -NODE diff --git a/test/e2e/scenarios/s5-isolation-evpn.sh b/test/e2e/scenarios/s5-isolation-evpn.sh deleted file mode 100644 index a761056..0000000 --- a/test/e2e/scenarios/s5-isolation-evpn.sh +++ /dev/null @@ -1,23 +0,0 @@ -on hv1 <<'NODE' -two_image || { echo "ÉCHOUÉ: image compatible two (hv1)"; exit 0; } -KEY=$(vm_key) -check "hv1 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16 -check "hv1 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24 -check "hv1 : VM s5-r dans vp-s5a" vm_create s5-r sn-s5a 10.245.1.10 "${KEY}" -NODE -on hv2 <<'NODE' -two_image || { echo "ÉCHOUÉ: image compatible two (hv2)"; exit 0; } -check "hv2 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16 -check "hv2 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24 -check "hv2 : VM s5-t dans vp-s5a (témoin)" vm_create s5-t sn-s5a 10.245.1.12 -check "hv2 : VPC vp-s5b, même plage" vpc_create vp-s5b 10.245.0.0/16 -check "hv2 : subnet sn-s5b (VNI 2502), même plage" subnet_create sn-s5b vp-s5b 2502 10.245.1.1 10.245.1.0/24 -check "hv2 : VM s5-s dans vp-s5b" vm_create s5-s sn-s5b 10.245.1.11 -NODE -on hv1 <<'NODE' -check "s5-r joignable" vm_wait vp-s5a 10.245.1.10 -for i in $(seq 1 24); do vm_ssh vp-s5a 10.245.1.10 'ping -c 1 -W 2 10.245.1.12' >/dev/null 2>&1 && break; sleep 5; done -check "s5-r joint s5-t, même VPC sur hv2 (témoin)" vm_ssh vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.12' -check "s5-r ne joint pas s5-s, autre VPC sur hv2, même plage" vm_fails vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.11' -check "aucune entrée ARP résolue pour 10.245.1.11 dans s5-r" vm_fails vp-s5a 10.245.1.10 'ip neigh show 10.245.1.11 | grep -q lladdr' -NODE diff --git a/test/e2e/scenarios/s6-rr-loss.sh b/test/e2e/scenarios/s6-rr-loss.sh deleted file mode 100644 index e4ea28e..0000000 --- a/test/e2e/scenarios/s6-rr-loss.sh +++ /dev/null @@ -1,36 +0,0 @@ -on hv1 <<'NODE' -check "prérequis : VM s4-p de S4 en marche" test "$(api_state vms/s4-p)" = running -check "session EVPN de hv1 établie avant l'arrêt" sh -c "vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'" -check "prérequis : s4-p joint s4-q" vm_ssh vp-s4 10.240.1.10 'ping -c 2 -W 2 10.240.1.11' -vm_ssh vp-s4 10.240.1.10 'rm -f /tmp/s6.log; nohup ping -D -i 0.2 10.240.1.11 > /tmp/s6.log 2>&1 &' -sleep 5 -NODE -on rr1 <<'NODE' -date +%s > /tmp/s6.stop -check "arrêt de FRR sur le route reflector" systemctl stop frr -NODE -sleep 30 -on hv1 <<'NODE' -recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; } -check "session EVPN de hv1 tombée" sh -c "! vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'" -info "30 s après l'arrêt du route reflector : $(recent 10) réponses dans les 10 dernières secondes (50 si le trafic passe intégralement)" -info "VTEP distant encore connu : $(vtysh -c 'show evpn vni 2401' | grep -c 192.168.14.12) ; entrée d'inondation : $(bridge fdb show dev vxlan-2401 | grep -c '00:00:00:00:00:00 dst')" -sleep 60 -info "90 s après l'arrêt : $(recent 10) réponses dans les 10 dernières secondes" -NODE -on rr1 <<'NODE' -check "redémarrage de FRR sur le route reflector" systemctl start frr -date +%s > /tmp/s6.start -NODE -on hv1 <<'NODE' -recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; } -start=$(date +%s) -for i in $(seq 1 90); do - vtysh -c "show evpn vni 2401" | grep -q 192.168.14.12 && [ "$(recent 3)" -gt 0 ] && break - sleep 2 -done -info "retour du VTEP distant et du trafic $(( $(date +%s) - start )) s après le redémarrage de FRR" -check "après le retour du route reflector : VTEP distant réappris" sh -c "vtysh -c 'show evpn vni 2401' | grep -q 192.168.14.12" -check "après le retour du route reflector : le trafic passe" test "$(recent 5)" -gt 0 -vm_ssh vp-s4 10.240.1.10 'pkill -x ping' || true -NODE diff --git a/test/e2e/topologies/agent/two.yml b/test/e2e/topologies/agent/two.yml deleted file mode 100644 index 6334d32..0000000 --- a/test/e2e/topologies/agent/two.yml +++ /dev/null @@ -1,2 +0,0 @@ -dhcp: - backend: two diff --git a/test/e2e/topologies/evpn-2hv.yml b/test/e2e/topologies/evpn-2hv.yml deleted file mode 100644 index 0176ef1..0000000 --- a/test/e2e/topologies/evpn-2hv.yml +++ /dev/null @@ -1,28 +0,0 @@ -name: evpn-2hv - -images: - debian12: - url: https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-generic-amd64.qcow2 - sums: https://cloud.debian.org/images/cloud/bookworm/latest/SHA512SUMS - -segments: - underlay: - switch: sw1 - cidr: 192.168.14.0/24 - mtu: 9000 - -nodes: - sw1: - { role: switch, image: debian12, cpus: 2, memory: 1024, - secondary: { underlay: [169.254.0.1/28] }, frr: frr/sw1.conf } - rr1: - { role: rr, image: debian12, cpus: 1, memory: 1024, segments: [underlay], - addresses: { underlay: 192.168.14.2 }, secondary: { underlay: [169.254.0.3/28] }, - loopback: 10.255.255.1/32, frr: frr/rr1.conf } - hv1: - { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], - addresses: { underlay: 192.168.14.11 }, frr: frr/hv1.conf, release: 0.2.0rc003, - agent: agent/two.yml } - hv2: - { role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], - addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf, release: 0.2.0rc003 } diff --git a/test/e2e/topologies/frr/hv1.conf b/test/e2e/topologies/frr/hv1.conf deleted file mode 100644 index ecf9e12..0000000 --- a/test/e2e/topologies/frr/hv1.conf +++ /dev/null @@ -1,19 +0,0 @@ -frr defaults traditional -hostname hv1 -log syslog informational -! -router bgp 64600 - bgp router-id 192.168.14.11 - no bgp default ipv4-unicast - neighbor fabric peer-group - neighbor fabric remote-as 64600 - neighbor fabric capability extended-nexthop - neighbor 10.255.255.1 peer-group fabric - ! - address-family l2vpn evpn - neighbor fabric activate - advertise-all-vni - exit-address-family - ! -exit -! diff --git a/test/e2e/topologies/frr/hv2.conf b/test/e2e/topologies/frr/hv2.conf deleted file mode 100644 index 5d0e0d9..0000000 --- a/test/e2e/topologies/frr/hv2.conf +++ /dev/null @@ -1,19 +0,0 @@ -frr defaults traditional -hostname hv2 -log syslog informational -! -router bgp 64600 - bgp router-id 192.168.14.12 - no bgp default ipv4-unicast - neighbor fabric peer-group - neighbor fabric remote-as 64600 - neighbor fabric capability extended-nexthop - neighbor 10.255.255.1 peer-group fabric - ! - address-family l2vpn evpn - neighbor fabric activate - advertise-all-vni - exit-address-family - ! -exit -! diff --git a/test/e2e/topologies/frr/rr1.conf b/test/e2e/topologies/frr/rr1.conf deleted file mode 100644 index 44081b1..0000000 --- a/test/e2e/topologies/frr/rr1.conf +++ /dev/null @@ -1,43 +0,0 @@ -frr defaults traditional -hostname rr1 -log syslog informational -! -ip prefix-list RR-LOOPBACK-OUT seq 10 permit 10.255.255.1/32 -! -route-map NO-IN deny 999 - description deny -exit -! -router bgp 65000 - no bgp default ipv4-unicast - bgp router-id 10.255.255.1 - bgp cluster-id 10.255.255.1 - neighbor CLUSTER peer-group - neighbor CLUSTER remote-as 65100 - neighbor CLUSTER bfd - neighbor 169.254.0.1 peer-group CLUSTER - neighbor 169.254.0.1 description router-1 - neighbor 169.254.0.2 peer-group CLUSTER - neighbor 169.254.0.2 description router-2 - neighbor fabric peer-group - neighbor fabric remote-as 64600 - neighbor fabric local-as 64600 no-prepend replace-as - neighbor fabric capability extended-nexthop - neighbor fabric update-source 10.255.255.1 - bgp listen range 192.168.14.0/24 peer-group fabric - bgp listen limit 200 - ! - address-family ipv4 unicast - network 10.255.255.1/32 - neighbor CLUSTER activate - neighbor CLUSTER prefix-list RR-LOOPBACK-OUT out - neighbor CLUSTER route-map NO-IN in - exit-address-family - ! - address-family l2vpn evpn - neighbor fabric activate - neighbor fabric route-reflector-client - exit-address-family - ! -exit -! diff --git a/test/e2e/topologies/frr/sw1.conf b/test/e2e/topologies/frr/sw1.conf deleted file mode 100644 index 55df925..0000000 --- a/test/e2e/topologies/frr/sw1.conf +++ /dev/null @@ -1,28 +0,0 @@ -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 -!