From 5728fdd33c85f7df1781c53370723fac64d3cf20 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 4 Oct 2026 20:43:53 +0200 Subject: [PATCH] =?UTF-8?q?f-50:=20lab:=20sc=C3=A9narios=20E5,=20configura?= =?UTF-8?q?tion=20de=20l'agent=20par=20hyperviseur,=20release=200.2.0rc003?= =?UTF-8?q?=20#50?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: GnomeZworc --- cmd/lab/main.go | 7 +- conf/lab/agent/two.yml | 2 + conf/lab/evpn-2hv.yml | 5 +- docs/developpement/lab.rst | 85 ++++++++++- internal/lab/machine/machine_test.go | 4 +- internal/lab/provision/provision.go | 24 +++- internal/lab/provision/provision_test.go | 20 +++ internal/lab/render/render.go | 4 + internal/lab/render/roles_test.go | 34 +++++ internal/lab/render/seed.go | 4 + internal/lab/topology/format.go | 6 +- internal/lab/topology/plan.go | 2 + internal/lab/topology/plan_test.go | 6 +- internal/lab/topology/roles_test.go | 57 +++++++- internal/lab/topology/topology.go | 6 + internal/lab/topology/validate.go | 3 + scripts/lab/node.sh | 151 ++++++++++++++++++++ scripts/lab/scenario.sh | 67 +++++++++ scripts/lab/scenario_test.sh | 140 ++++++++++++++++++ scripts/lab/scenarios/s1-dhcp-two.sh | 34 +++++ scripts/lab/scenarios/s2-gateway.sh | 26 ++++ scripts/lab/scenarios/s3-isolation-local.sh | 16 +++ scripts/lab/scenarios/s4-evpn.sh | 25 ++++ scripts/lab/scenarios/s5-isolation-evpn.sh | 23 +++ scripts/lab/scenarios/s6-rr-loss.sh | 36 +++++ 25 files changed, 760 insertions(+), 27 deletions(-) create mode 100644 conf/lab/agent/two.yml create mode 100644 scripts/lab/node.sh create mode 100755 scripts/lab/scenario.sh create mode 100755 scripts/lab/scenario_test.sh create mode 100644 scripts/lab/scenarios/s1-dhcp-two.sh create mode 100644 scripts/lab/scenarios/s2-gateway.sh create mode 100644 scripts/lab/scenarios/s3-isolation-local.sh create mode 100644 scripts/lab/scenarios/s4-evpn.sh create mode 100644 scripts/lab/scenarios/s5-isolation-evpn.sh create mode 100644 scripts/lab/scenarios/s6-rr-loss.sh diff --git a/cmd/lab/main.go b/cmd/lab/main.go index df86e39..3ff3cf2 100644 --- a/cmd/lab/main.go +++ b/cmd/lab/main.go @@ -135,7 +135,12 @@ func renderCmd(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "lab: %v\n", err) return 1 } - nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized, FRR: frr}) + 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 diff --git a/conf/lab/agent/two.yml b/conf/lab/agent/two.yml new file mode 100644 index 0000000..6334d32 --- /dev/null +++ b/conf/lab/agent/two.yml @@ -0,0 +1,2 @@ +dhcp: + backend: two diff --git a/conf/lab/evpn-2hv.yml b/conf/lab/evpn-2hv.yml index a3ebfa5..0176ef1 100644 --- a/conf/lab/evpn-2hv.yml +++ b/conf/lab/evpn-2hv.yml @@ -21,7 +21,8 @@ nodes: 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.0rc002 } + 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.0rc002 } + addresses: { underlay: 192.168.14.12 }, frr: frr/hv2.conf, release: 0.2.0rc003 } diff --git a/docs/developpement/lab.rst b/docs/developpement/lab.rst index 2504bc0..6ff6fb1 100644 --- a/docs/developpement/lab.rst +++ b/docs/developpement/lab.rst @@ -230,8 +230,12 @@ Ce que le fichier déclare : * ``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.0rc002``). Sans lui, ``deploy.sh`` prendrait la + two que ``deploy.sh`` installe (``release: 0.2.0rc003``). Sans lui, ``deploy.sh`` prendrait la dernière release, et le lab ne serait plus reproductible. + * ``agent`` — pour un hyperviseur seulement : le chemin d'un ``agent.yml``, relatif au fichier + de topologie, déposé dans ``/etc/two/agent.yml`` avant ``deploy.sh``. Sans lui, l'agent tourne + avec sa configuration par défaut. L'exemple met hv1 sur le serveur DHCP intégré + (``conf/lab/agent/two.yml`` : ``dhcp.backend: two``) et laisse hv2 sur dnsmasq. ``mgmt0`` et ``lo1`` sont réservés : aucun segment ne peut porter ces noms. @@ -278,11 +282,11 @@ Limites : 1000 nœuds, 256 segments, et autant de câbles que la plage UDP le pe hv2 hypervisor debian12 4 16384 MiB 127.0.0.1:2203 roles - name loopback secondary frr release - 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 - hv2 - - hv2.conf 0.2.0rc002 + 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 @@ -660,6 +664,74 @@ Le 2026-10-04, sur un Xeon E5-2640 v3, Debian 12 et QEMU 7.2 sur le serveur, top * - ``lab down`` puis ``lab up`` d'une autre topologie - conforme +Scénarios +--------- + +Les scénarios se lancent **depuis le Mac**, sur un lab démarré (``up``, ``push``, ``./lab up``) : + +.. code-block:: text + + scripts/lab/scenario.sh s1 # un scénario + scripts/lab/scenario.sh s1 s3 # plusieurs + scripts/lab/scenario.sh all # tous, dans l'ordre + +Chacun affiche une ligne ``RÉUSSI`` ou ``ÉCHOUÉ`` par vérification — un échec porte la dernière +ligne de la commande en cause —, des lignes ``INFO`` pour les mesures, puis son bilan. Le code de +sortie vaut 1 si une vérification échoue **ou si aucune n'a été faite**. + +.. list-table:: + :header-rows: 1 + :widths: 22 58 20 + + * - Scénario + - Ce qui doit être vrai + - Hyperviseurs + * - ``s1-dhcp-two`` + - backend DHCP ``two``, une VPC et deux subnets : chaque VM reçoit l'adresse de **son** + subnet, démarrée seule ou en même temps qu'une autre ; le bail est tenu par + systemd-networkd ; route par défaut, route vers la VPC et ``/32`` vers les métadonnées + via ``interface_ip`` ; l'état de chaque serveur DHCP ne connaît que les MAC de son subnet + (#46) + - hv1 + * - ``s2-gateway`` + - route par défaut via ``interface_ip``, ou via ``gateway`` avec ``default_route`` ; route + vers la VPC toujours via ``interface_ip`` ; même résultat après recréation du subnet (#31) + - hv1 + * - ``s3-isolation-local`` + - deux VPC sur le même hyperviseur ne se joignent pas, en ICMP comme en TCP ; chaque VM + joint sa passerelle (témoin) + - hv1 + * - ``s4-evpn`` + - adresse VTEP locale sur les VXLAN (#51), VTEP distant appris par EVPN, ping VM ↔ VM + entre hyperviseurs, trame de 1472 octets en ``-M do``, 1473 refusés + - hv1, hv2 + * - ``s5-isolation-evpn`` + - deux VPC de même plage, VNI différentes, sur deux hyperviseurs : la VM joint celle de sa + VPC sur l'autre hyperviseur (témoin) et pas celle de l'autre VPC — aucune résolution ARP + - hv1, hv2 + * - ``s6-rr-loss`` + - FRR arrêté sur le route reflector, ping continu entre les VM de ``s4`` : la session tombe, + le trafic restant est **mesuré** (``INFO``) à 30 et 90 s ; au retour du route reflector, le + VTEP distant est réappris et le trafic repasse — délai mesuré + - hv1, hv2, rr1 + +``s6`` réutilise les VM de ``s4`` : le lancer après. + +**Comment c'est fait.** ``scripts/lab/scenario.sh`` exécute chaque scénario sur le Mac ; un +scénario envoie des blocs de shell aux nœuds par ``on [VAR=valeur…] <<'NODE'``, précédés de +``scripts/lab/node.sh`` — appels à l'API de l'agent, attente des états, image Debian compatible two +(préparée une fois par hyperviseur, ``seedfrom`` avec barre oblique finale), clé SSH des VM, +``check`` et ``vm_fails``. Une vérification négative (« ne joint pas ») passe par ``vm_fails`` : +elle n'est réussie que si le SSH vers la VM a fonctionné **et** que la commande y a échoué — un +SSH en panne ne passe jamais pour une isolation. + +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'" + Facturation ----------- @@ -751,6 +823,7 @@ Tests .. code-block:: bash bash scripts/lab-host_test.sh + bash scripts/lab/scenario_test.sh go test ./internal/lab/... ./cmd/lab/ Environ une minute et demie, sans réseau : la suite remplace ``curl`` par une fausse API Scaleway diff --git a/internal/lab/machine/machine_test.go b/internal/lab/machine/machine_test.go index d3bc49a..78e2ba4 100644 --- a/internal/lab/machine/machine_test.go +++ b/internal/lab/machine/machine_test.go @@ -32,8 +32,8 @@ 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 + mu sync.Mutex + calls [][]string fail string ssh map[string][]error always map[string]error diff --git a/internal/lab/provision/provision.go b/internal/lab/provision/provision.go index b1ee4d0..b83751a 100644 --- a/internal/lab/provision/provision.go +++ b/internal/lab/provision/provision.go @@ -76,7 +76,11 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e if err != nil { return nil, err } - nodes, err := render.Render(p, render.Options{RunDir: o.RunDir, AuthorizedKeys: []string{key}, FRR: frr}) + 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 } @@ -89,18 +93,26 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e } func ReadFRR(p *topology.Plan) (map[string]string, error) { - configs := map[string]string{} + 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 n.FRR == "" { + if path(n) == "" { continue } - data, err := os.ReadFile(n.FRR) + data, err := os.ReadFile(path(n)) if err != nil { return nil, fmt.Errorf("node %s: %w", n.Name, err) } - configs[n.Name] = string(data) + files[n.Name] = string(data) } - return configs, nil + return files, nil } func EnsureKey(ctx context.Context, r Runner, dir string) (string, error) { diff --git a/internal/lab/provision/provision_test.go b/internal/lab/provision/provision_test.go index 668a0f8..403d3cc 100644 --- a/internal/lab/provision/provision_test.go +++ b/internal/lab/provision/provision_test.go @@ -385,3 +385,23 @@ func TestPrepare_PutsTheFRRConfigIntoTheSeed(t *testing.T) { } 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/render.go b/internal/lab/render/render.go index 6a11f0d..be95453 100644 --- a/internal/lab/render/render.go +++ b/internal/lab/render/render.go @@ -31,6 +31,7 @@ type Options struct { RunDir string AuthorizedKeys []string FRR map[string]string + Agent map[string]string } type Node struct { @@ -50,6 +51,9 @@ func Render(p *topology.Plan, o Options) ([]Node, error) { 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 { diff --git a/internal/lab/render/roles_test.go b/internal/lab/render/roles_test.go index 001677b..fd0e0de 100644 --- a/internal/lab/render/roles_test.go +++ b/internal/lab/render/roles_test.go @@ -262,3 +262,37 @@ func TestRoles_SwitchLoopbackIsCreatedByTheSwitchScript(t *testing.T) { 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 index 804ed45..0255140 100644 --- a/internal/lab/render/seed.go +++ b/internal/lab/render/seed.go @@ -31,6 +31,7 @@ const ( DeployScript = "/usr/local/sbin/lab-deploy" TwoScriptsDir = "/opt/two/scripts" TwoGitServer = "https://git.g3e.fr/" + AgentConfig = "/etc/two/agent.yml" ) //go:embed frrouting.gpg @@ -114,6 +115,9 @@ func userData(p *topology.Plan, n topology.NodePlan, o Options) ([]byte, error) } 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)}, diff --git a/internal/lab/topology/format.go b/internal/lab/topology/format.go index 567933a..1ba73fd 100644 --- a/internal/lab/topology/format.go +++ b/internal/lab/topology/format.go @@ -22,15 +22,15 @@ func (p *Plan) Write(w io.Writer) error { var extras []NodePlan for _, n := range p.Nodes { - if len(n.Secondary) > 0 || n.Loopback.IsValid() || n.FRR != "" || n.Release != "" { + 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\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\n", n.Name, orDash(loopback(n)), orDash(secondary(n)), orDash(filepath.Base(n.FRR)), orDash(n.Release)) + 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))) } } diff --git a/internal/lab/topology/plan.go b/internal/lab/topology/plan.go index 83487ba..e5ba293 100644 --- a/internal/lab/topology/plan.go +++ b/internal/lab/topology/plan.go @@ -44,6 +44,7 @@ type NodePlan struct { Loopback netip.Prefix FRR string Release string + Agent string } type Cable struct { @@ -78,6 +79,7 @@ func Compute(t *Topology) (*Plan, error) { SSHPort: SSHBasePort + i, FRR: n.FRR, Release: n.Release, + Agent: n.Agent, } for segment, raws := range n.Secondary { for _, raw := range raws { diff --git a/internal/lab/topology/plan_test.go b/internal/lab/topology/plan_test.go index 4f23fef..c8d4e05 100644 --- a/internal/lab/topology/plan_test.go +++ b/internal/lab/topology/plan_test.go @@ -315,9 +315,9 @@ nodes hv2 hypervisor deb 4 16384 MiB 127.0.0.1:2203 roles - name loopback secondary frr release - hv1 - - - 0.2.0rc002 - hv2 - - - 0.2.0rc002 + 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 diff --git a/internal/lab/topology/roles_test.go b/internal/lab/topology/roles_test.go index 97ee53c..d3e209c 100644 --- a/internal/lab/topology/roles_test.go +++ b/internal/lab/topology/roles_test.go @@ -142,10 +142,10 @@ func TestWrite_ShowsTheRoles(t *testing.T) { } want := ` roles - name loopback secondary frr release - 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 + 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) @@ -214,3 +214,52 @@ func TestCompute_CarriesTheRelease(t *testing.T) { 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 index 72c532a..66337d8 100644 --- a/internal/lab/topology/topology.go +++ b/internal/lab/topology/topology.go @@ -50,6 +50,7 @@ type Node struct { Loopback string FRR string Release string + Agent string } type fileImage struct { @@ -74,6 +75,7 @@ type fileNode struct { Loopback string `yaml:"loopback"` FRR string `yaml:"frr"` Release string `yaml:"release"` + Agent string `yaml:"agent"` } type file struct { @@ -96,6 +98,9 @@ func Load(path string) (*Topology, error) { 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 } @@ -144,6 +149,7 @@ func Parse(data []byte) (*Topology, error) { Loopback: n.Loopback, FRR: n.FRR, Release: n.Release, + Agent: n.Agent, }) } return t, nil diff --git a/internal/lab/topology/validate.go b/internal/lab/topology/validate.go index 2128c68..432fedf 100644 --- a/internal/lab/topology/validate.go +++ b/internal/lab/topology/validate.go @@ -215,6 +215,9 @@ func validateExtras(n Node, segments map[string]Segment, add func(string, ...any 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 { diff --git a/scripts/lab/node.sh b/scripts/lab/node.sh new file mode 100644 index 0000000..06a8749 --- /dev/null +++ b/scripts/lab/node.sh @@ -0,0 +1,151 @@ +set -u +API=http://127.0.0.1:8080 +IMAGE_URL=https://cloud.debian.org/images/cloud/bookworm/latest +IMAGE_FILE=debian-12-genericcloud-amd64.qcow2 +TWO_IMAGE=/var/tmp/deb-two.qcow2 +VM_KEY=/root/.ssh/lab-vm +VOLUMES=/var/lib/two/volumes + +check () { + local desc="${1}" out + shift + if out=$("$@" 2>&1); then + echo "RÉUSSI: ${desc}" + else + echo "ÉCHOUÉ: ${desc}${out:+ — $(printf '%s' "${out}" | tail -n 1)}" + fi +} + +info () { echo "INFO: ${1}"; } + +api_post () { + curl -fsS -o /dev/null -X POST -H 'Content-Type: application/json' -d "${2}" "${API}/${1}" +} + +api_delete () { + curl -fsS -o /dev/null -X DELETE "${API}/${1}" +} + +api_state () { + curl -fsS "${API}/${1}" 2>/dev/null | jq -r .state +} + +wait_state () { + local path="${1}" want="${2}" i s + for i in $(seq 1 90); do + s=$(api_state "${path}") + [ "${s}" = "${want}" ] && return 0 + [ "${want}" = deleted ] && [ -z "${s}" ] && return 0 + [ "${s}" = error ] && { echo "${path}: error" >&2; return 1; } + sleep 2 + done + echo "${path}: ${s:-absent}, ${want} attendu" >&2 + return 1 +} + +vpc_create () { + api_state "vpcs/${1}" | grep -qx running && return 0 + api_post vpcs "{\"name\":\"${1}\",\"cidr\":\"${2}\"}" && wait_state "vpcs/${1}" running +} + +subnet_create () { + local name="${1}" vpc="${2}" vni="${3}" gw="${4}" cidr="${5}" extra="${6:-}" + api_state "subnets/${name}" | grep -qx running && return 0 + api_post subnets "{\"name\":\"${name}\",\"vpc\":\"${vpc}\",\"mode\":\"vxlan\",\"vxlan_id\":${vni},\"iface_type\":\"vms\",\"interface_ip\":\"${gw}\",\"cidr\":\"${cidr}\"${extra}}" \ + && wait_state "subnets/${name}" running +} + +two_image () { + [ -f "${TWO_IMAGE}" ] && return 0 + local work + work=$(mktemp -d) + curl -fsSL -o "${work}/${IMAGE_FILE}" "${IMAGE_URL}/${IMAGE_FILE}" || return 1 + curl -fsSL "${IMAGE_URL}/SHA512SUMS" | grep " ${IMAGE_FILE}\$" > "${work}/sums" || return 1 + (cd "${work}" && sha512sum -c sums >/dev/null) || { echo "image: somme SHA-512 incorrecte" >&2; return 1; } + command -v qemu-nbd >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-utils >/dev/null + modprobe nbd max_part=8 + qemu-nbd -c /dev/nbd0 "${work}/${IMAGE_FILE}" || return 1 + for i in $(seq 1 20); do [ -b /dev/nbd0p1 ] && break; sleep 0.5; done + mkdir -p /mnt/two-image + mount /dev/nbd0p1 /mnt/two-image || { qemu-nbd -d /dev/nbd0 >/dev/null; return 1; } + printf '%s\n' 'datasource_list: [ NoCloud ]' 'datasource:' ' NoCloud:' " seedfrom: 'http://169.254.169.254:80/'" ' timeout: 5' ' max_wait: 10' \ + > /mnt/two-image/etc/cloud/cloud.cfg.d/99_metadata.cfg + umount /mnt/two-image + qemu-nbd -d /dev/nbd0 >/dev/null + mv "${work}/${IMAGE_FILE}" "${TWO_IMAGE}" + rm -rf "${work}" +} + +vm_key () { + [ -f "${VM_KEY}" ] || ssh-keygen -q -t ed25519 -N '' -C lab-vm -f "${VM_KEY}" + cat "${VM_KEY}.pub" +} + +vm_create () { + local name="${1}" subnet="${2}" ip="${3}" key="${4:-}" meta="{}" + [ -n "${key}" ] && meta="{\"sshkey\":\"${key}\"}" + mkdir -p "${VOLUMES}" + cp "${TWO_IMAGE}" "${VOLUMES}/${name}.qcow2" || return 1 + api_post vms "{\"name\":\"${name}\",\"memory\":1024,\"cpus\":1,\"metadata\":${meta},\"interfaces\":[{\"subnet\":\"${subnet}\",\"ip\":\"${ip}\",\"primary\":true}],\"storage\":[{\"path\":\"${VOLUMES}/${name}.qcow2\",\"dev\":\"vda\"}]}" \ + && wait_state "vms/${name}" running +} + +vm_delete () { + api_delete "vms/${1}" && wait_state "vms/${1}" deleted +} + +vm_ssh () { + local vpc="${1}" ip="${2}" + shift 2 + ip netns exec "${vpc}" ssh -i "${VM_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ + -o LogLevel=ERROR -o ConnectTimeout=3 -o BatchMode=yes -n "syonad@${ip}" "$@" +} + +vm_wait () { + local i + for i in $(seq 1 40); do + vm_ssh "${1}" "${2}" true 2>/dev/null && return 0 + sleep 5 + done + echo "${2}: SSH injoignable" >&2 + return 1 +} + +vm_fails () { + local out + out=$(vm_ssh "${1}" "${2}" "${3} >/dev/null 2>&1; echo rc=\$?") || { echo "SSH vers ${2} en échec"; return 1; } + case "${out}" in + rc=0) echo "la commande a réussi dans ${2}"; return 1 ;; + rc=*) return 0 ;; + *) echo "sortie inattendue : ${out}"; return 1 ;; + esac +} + +vm_grep () { + vm_ssh "${1}" "${2}" "${3} 2>&1" | grep -q -- "${4}" +} + +has_addr () { + vm_ssh "${1}" "${2}" 'ip -4 -o addr show dev ens3' | grep -q " ${2}/" +} + +route_via () { + vm_ssh "${1}" "${2}" "ip -4 route show ${3}" | grep -q "via ${4} " +} + +dhcp_hosts () { + python3 - "/run/two/dhcp/${1}.sock" <<'PY' +import json, socket, sys +s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) +s.connect(sys.argv[1]) +s.sendall(b'{"verb":"get-state"}\n') +buf = b"" +while not buf.endswith(b"\n"): + c = s.recv(65536) + if not c: + break + buf += c +state = json.loads(buf).get("state") or {} +print(" ".join(sorted(h["mac"].lower() for h in state.get("hosts") or []))) +PY +} diff --git a/scripts/lab/scenario.sh b/scripts/lab/scenario.sh new file mode 100755 index 0000000..58a6d24 --- /dev/null +++ b/scripts/lab/scenario.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash + +LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LAB_HOST="${LAB_HOST:-${LAB_DIR}/../lab-host.sh}" +SCENARIO_DIR="${SCENARIO_DIR:-${LAB_DIR}/scenarios}" +SCENARIO_LOG="" + +usage () { + cat >&2 <... | all + + scénarios : $(cd "${SCENARIO_DIR}" && ls -- *.sh | sed 's/\.sh$//' | tr '\n' ' ') + +Chaque scénario s'exécute depuis le Mac sur le lab en cours (lab-host.sh up, push, ./lab up). +Une ligne RÉUSSI ou ÉCHOUÉ par vérification ; code de sortie 1 si une vérification échoue ou +si aucune n'a été faite. +USAGE + exit 2 +} + +on () { + local node="${1}" assignment + shift + { + for assignment in "$@"; do + printf 'export %q=%q\n' "${assignment%%=*}" "${assignment#*=}" + done + cat "${LAB_DIR}/node.sh" + cat + } | "${LAB_HOST}" ssh "./lab ssh ${node} 'sudo bash -s'" 2>&1 | tee -a "${SCENARIO_LOG}" +} + +run_scenario () { + local file="${1}" name passed failed + name="$(basename "${file}" .sh)" + SCENARIO_LOG="$(mktemp)" + echo "=== ${name}" + ( . "${file}" ) + passed=$(grep -c '^RÉUSSI: ' "${SCENARIO_LOG}") + failed=$(grep -c '^ÉCHOUÉ: ' "${SCENARIO_LOG}") + rm -f "${SCENARIO_LOG}" + echo "=== ${name} : ${passed} réussi(s), ${failed} échoué(s)" + [[ "${failed}" -eq 0 && "${passed}" -gt 0 ]] +} + +main () { + local -a files=() + local arg file rc=0 + [[ $# -gt 0 ]] || usage + for arg in "$@"; do + if [[ "${arg}" == all ]]; then + files+=("${SCENARIO_DIR}"/*.sh) + continue + fi + file=$(ls "${SCENARIO_DIR}/${arg}"*.sh 2>/dev/null | head -n 1) + [[ -n "${file}" ]] || { echo "scénario inconnu : ${arg}" >&2; usage; } + files+=("${file}") + done + for file in "${files[@]}"; do + run_scenario "${file}" || rc=1 + done + return "${rc}" +} + +if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then + main "$@" +fi diff --git a/scripts/lab/scenario_test.sh b/scripts/lab/scenario_test.sh new file mode 100755 index 0000000..0ca394c --- /dev/null +++ b/scripts/lab/scenario_test.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PASS=0 +FAIL=0 + +fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; } + +setup () { + CURRENT="${1}" + CASE_OK=1 + WORK=$(mktemp -d "${TMPDIR:-/tmp}/scenario.XXXXXX") + mkdir -p "${WORK}/scenarios" + cat > "${WORK}/lab-host" <<'FAKE' +#!/usr/bin/env bash +N=$(ls "${FAKE_DIR}" | grep -c '^payload\.') +cat > "${FAKE_DIR}/payload.$(( N + 1 ))" +printf '%s\n' "$*" >> "${FAKE_DIR}/calls.log" +if ! bash -n "${FAKE_DIR}/payload.$(( N + 1 ))" 2>"${FAKE_DIR}/syntax.$(( N + 1 ))"; then + echo "ÉCHOUÉ: syntaxe du bloc $(( N + 1 ))" + exit 0 +fi +[ -n "${FAKE_OUT:-}" ] && printf '%b\n' "${FAKE_OUT}" +exit 0 +FAKE + chmod +x "${WORK}/lab-host" + : > "${WORK}/calls.log" +} + +run () { + env LAB_HOST="${WORK}/lab-host" FAKE_DIR="${WORK}" SCENARIO_DIR="${SCENARIO_DIR:-${WORK}/scenarios}" \ + FAKE_OUT="${FAKE_OUT:-}" bash "${DIR}/scenario.sh" "$@" > "${WORK}/out.log" 2>&1 +} + +teardown () { + [[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}" + [[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; } + rm -rf "${WORK}" +} + +node () { + bash -c ". '${DIR}/node.sh'; ${1}" 2>&1 +} + +test_on_sends_assignments_library_and_block_to_the_node () { + setup "on : variables, bibliothèque et bloc envoyés au bon nœud" + printf '%s\n' "on hv2 KEY='ssh-ed25519 AAAA x' NAME=vm <<'NODE'" 'echo "${NAME}"' 'NODE' > "${WORK}/scenarios/t1.sh" + FAKE_OUT='RÉUSSI: un' run t1 || fail "code de sortie $?" + [[ $(cat "${WORK}/calls.log") == "ssh ./lab ssh hv2 'sudo bash -s'" ]] || fail "appel : $(cat "${WORK}/calls.log")" + grep -q '^check () {' "${WORK}/payload.1" || fail "node.sh absent du bloc" + [[ $(tail -n 1 "${WORK}/payload.1") == 'echo "${NAME}"' ]] || fail "bloc du scénario absent en fin d'envoi" + [[ $(bash -c "$(grep '^export ' "${WORK}/payload.1"); printf '%s|%s' \"\${KEY}\" \"\${NAME}\"") == "ssh-ed25519 AAAA x|vm" ]] \ + || fail "variables mal transmises : $(grep '^export ' "${WORK}/payload.1")" + teardown +} + +test_scenario_result_counts_and_exit_codes () { + local CASE + for CASE in ok fail none; do + setup "résultat d'un scénario (${CASE})" + printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/t2.sh" + case "${CASE}" in + ok) FAKE_OUT='RÉUSSI: a\nRÉUSSI: b' run t2; [[ $? -eq 0 ]] || fail "code non nul" + grep -q '=== t2 : 2 réussi(s), 0 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;; + fail) FAKE_OUT='RÉUSSI: a\nÉCHOUÉ: b — détail' run t2; [[ $? -eq 1 ]] || fail "code différent de 1" + grep -q '=== t2 : 1 réussi(s), 1 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;; + none) FAKE_OUT='INFO: rien' run t2; [[ $? -eq 1 ]] || fail "un scénario sans vérification a réussi" ;; + esac + teardown + done +} + +test_unknown_scenario_and_no_argument () { + setup "scénario inconnu ou absent : usage, code 2" + run nope; [[ $? -eq 2 ]] || fail "scénario inconnu : code $?" + run; [[ $? -eq 2 ]] || fail "sans argument : code $?" + [[ -s "${WORK}/calls.log" ]] && fail "un nœud a été appelé" + teardown +} + +test_all_runs_every_scenario_and_fails_if_one_fails () { + setup "all : chaque scénario, échec si l'un échoue" + printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/a.sh" + printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/b.sh" + FAKE_OUT='ÉCHOUÉ: x' run all; [[ $? -eq 1 ]] || fail "code différent de 1" + [[ $(grep -c '^=== [ab] :' "${WORK}/out.log") -eq 2 ]] || fail "les deux scénarios n'ont pas tourné" + teardown +} + +test_every_shipped_block_is_valid_bash () { + local f + setup "chaque bloc envoyé par les scénarios livrés est du bash valide" + SCENARIO_DIR="${DIR}/scenarios" FAKE_OUT='RÉUSSI: x' run all + for f in "${WORK}"/syntax.*; do + [[ -s "${f}" ]] && fail "${f##*/} : $(cat "${f}")" + done + [[ $(ls "${WORK}" | grep -c '^payload\.') -ge 10 ]] || fail "trop peu de blocs envoyés : $(ls "${WORK}" | grep -c '^payload\.')" + grep -q 'ÉCHOUÉ: syntaxe' "${WORK}/out.log" && fail "bloc invalide" + teardown +} + +test_check_reports_success_and_failure_with_reason () { + setup "check : RÉUSSI, ou ÉCHOUÉ avec la dernière ligne de sortie" + [[ $(node 'check "a" true') == "RÉUSSI: a" ]] || fail "succès : $(node 'check "a" true')" + [[ $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"') == "ÉCHOUÉ: b — deux" ]] || fail "échec : $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"')" + [[ $(node 'check "c" false') == "ÉCHOUÉ: c" ]] || fail "échec muet : $(node 'check "c" false')" + teardown +} + +test_vm_fails_only_when_ssh_worked_and_command_failed () { + setup "vm_fails : réussit seulement si SSH marche et la commande échoue" + [[ $(node 'vm_ssh () { echo rc=1; }; vm_fails v 1.2.3.4 x && echo OUI') == "OUI" ]] || fail "commande échouée non reconnue" + [[ $(node 'vm_ssh () { echo rc=0; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "commande réussie prise pour un échec" + [[ $(node 'vm_ssh () { return 255; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "SSH en panne pris pour une isolation" + [[ $(node 'vm_ssh () { echo bizarre; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "sortie inattendue acceptée" + teardown +} + +test_route_via_matches_the_next_hop_exactly () { + setup "route_via : le next-hop exact, pas un préfixe" + [[ $(node 'vm_ssh () { echo "default via 10.220.1.1 dev ens3 proto dhcp"; }; route_via v ip default 10.220.1.1 && echo OUI') == OUI ]] || fail "next-hop exact refusé" + [[ $(node 'vm_ssh () { echo "default via 10.220.1.10 dev ens3"; }; route_via v ip default 10.220.1.1 || echo NON') == NON ]] || fail "10.220.1.10 pris pour 10.220.1.1" + teardown +} + +test_wait_state () { + setup "wait_state : état atteint, erreur, suppression" + [[ $(node 'api_state () { echo running; }; wait_state vms/x running && echo OUI') == OUI ]] || fail "running" + local start=${SECONDS} + [[ $(node 'api_state () { echo error; }; wait_state vms/x running 2>&1 || echo NON') == *NON ]] || fail "error accepté" + (( SECONDS - start < 5 )) || fail "une ressource en error n'arrête pas l'attente immédiatement ($(( SECONDS - start )) s)" + [[ $(node 'api_state () { echo; }; wait_state vms/x deleted && echo OUI') == OUI ]] || fail "absence = supprimé" + teardown +} + +for T in $(declare -F | awk '{print $3}' | grep '^test_'); do + "${T}" +done +echo "${PASS} réussi(s), ${FAIL} échec(s)" +[[ "${FAIL}" -eq 0 ]] diff --git a/scripts/lab/scenarios/s1-dhcp-two.sh b/scripts/lab/scenarios/s1-dhcp-two.sh new file mode 100644 index 0000000..2ac1c2f --- /dev/null +++ b/scripts/lab/scenarios/s1-dhcp-two.sh @@ -0,0 +1,34 @@ +on hv1 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } +KEY=$(vm_key) +check "VPC vp-s1" vpc_create vp-s1 10.210.0.0/16 +check "subnet sn-s1a" subnet_create sn-s1a vp-s1 2101 10.210.1.1 10.210.1.0/24 +check "subnet sn-s1b" subnet_create sn-s1b vp-s1 2102 10.210.2.1 10.210.2.0/24 +check "backend two : socket de contrôle de sn-s1a" test -S /run/two/dhcp/vp-s1_br-s1a.sock +check "backend two : socket de contrôle de sn-s1b" test -S /run/two/dhcp/vp-s1_br-s1b.sock +check "VM s1-a1, démarrée seule" vm_create s1-a1 sn-s1a 10.210.1.10 "${KEY}" +check "VM s1-a1 joignable" vm_wait vp-s1 10.210.1.10 +check "VM s1-b1, démarrée seule" vm_create s1-b1 sn-s1b 10.210.2.10 "${KEY}" +check "VM s1-b1 joignable" vm_wait vp-s1 10.210.2.10 +vm_create s1-a2 sn-s1a 10.210.1.11 "${KEY}" > /tmp/s1-a2.log 2>&1 & A=$! +vm_create s1-b2 sn-s1b 10.210.2.11 "${KEY}" > /tmp/s1-b2.log 2>&1 & B=$! +wait "${A}"; RA=$? +wait "${B}"; RB=$? +check "VM s1-a2, démarrée en même temps que s1-b2" test "${RA}" -eq 0 +check "VM s1-b2, démarrée en même temps que s1-a2" test "${RB}" -eq 0 +for vm in 10.210.1.10:10.210.1.1 10.210.1.11:10.210.1.1 10.210.2.10:10.210.2.1 10.210.2.11:10.210.2.1; do + ip=${vm%%:*}; gw=${vm#*:} + check "${ip} joignable" vm_wait vp-s1 "${ip}" + check "${ip} : adresse de son propre subnet" has_addr vp-s1 "${ip}" + check "${ip} : bail DHCP tenu par systemd-networkd" vm_ssh vp-s1 "${ip}" 'ls /run/systemd/netif/leases/ | grep -q .' + check "${ip} : route par défaut via ${gw}" route_via vp-s1 "${ip}" default "${gw}" + check "${ip} : route vers la VPC via ${gw}" route_via vp-s1 "${ip}" 10.210.0.0/16 "${gw}" + check "${ip} : route /32 vers les métadonnées via ${gw}" route_via vp-s1 "${ip}" 169.254.169.254 "${gw}" +done +mac () { vm_ssh vp-s1 "${1}" 'cat /sys/class/net/ens3/address'; } +A_MACS=$(printf '%s\n' "$(mac 10.210.1.10)" "$(mac 10.210.1.11)" | sort | tr '\n' ' ' | sed 's/ $//') +B_MACS=$(printf '%s\n' "$(mac 10.210.2.10)" "$(mac 10.210.2.11)" | sort | tr '\n' ' ' | sed 's/ $//') +info "MAC de sn-s1a : ${A_MACS} ; de sn-s1b : ${B_MACS}" +check "probe sn-s1a : uniquement les MAC de sn-s1a" test "$(dhcp_hosts vp-s1_br-s1a)" = "${A_MACS}" +check "probe sn-s1b : uniquement les MAC de sn-s1b" test "$(dhcp_hosts vp-s1_br-s1b)" = "${B_MACS}" +NODE diff --git a/scripts/lab/scenarios/s2-gateway.sh b/scripts/lab/scenarios/s2-gateway.sh new file mode 100644 index 0000000..4785731 --- /dev/null +++ b/scripts/lab/scenarios/s2-gateway.sh @@ -0,0 +1,26 @@ +on hv1 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } +KEY=$(vm_key) +check "VPC vp-s2" vpc_create vp-s2 10.220.0.0/16 +check "subnet sn-s2a, default_route absent" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24 +check "subnet sn-s2b, default_route et gateway 10.220.2.254" subnet_create sn-s2b vp-s2 2202 10.220.2.1 10.220.2.0/24 ',"default_route":true,"gateway":"10.220.2.254"' +check "VM s2-a" vm_create s2-a sn-s2a 10.220.1.10 "${KEY}" +check "VM s2-b" vm_create s2-b sn-s2b 10.220.2.10 "${KEY}" +check "s2-a joignable" vm_wait vp-s2 10.220.1.10 +check "s2-b joignable" vm_wait vp-s2 10.220.2.10 +check "sn-s2a : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.10 default 10.220.1.1 +check "sn-s2a : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.10 10.220.0.0/16 10.220.1.1 +check "sn-s2a : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.1.10 169.254.169.254 10.220.1.1 +check "sn-s2b : route par défaut via la gateway 10.220.2.254" route_via vp-s2 10.220.2.10 default 10.220.2.254 +check "sn-s2b : route vers la VPC via interface_ip, pas via la gateway" route_via vp-s2 10.220.2.10 10.220.0.0/16 10.220.2.1 +check "sn-s2b : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.2.10 169.254.169.254 10.220.2.1 +check "suppression de s2-a" vm_delete s2-a +check "suppression de sn-s2a" api_delete subnets/sn-s2a +check "sn-s2a supprimé" wait_state subnets/sn-s2a deleted +check "sn-s2a recréé" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24 +check "VM s2-a2 sur le subnet recréé" vm_create s2-a2 sn-s2a 10.220.1.11 "${KEY}" +check "s2-a2 joignable" vm_wait vp-s2 10.220.1.11 +check "après recréation : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.11 default 10.220.1.1 +check "après recréation : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.11 10.220.0.0/16 10.220.1.1 +check "après recréation : route /32 vers les métadonnées" route_via vp-s2 10.220.1.11 169.254.169.254 10.220.1.1 +NODE diff --git a/scripts/lab/scenarios/s3-isolation-local.sh b/scripts/lab/scenarios/s3-isolation-local.sh new file mode 100644 index 0000000..dc822f5 --- /dev/null +++ b/scripts/lab/scenarios/s3-isolation-local.sh @@ -0,0 +1,16 @@ +on hv1 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; } +KEY=$(vm_key) +check "VPC vp-s3a" vpc_create vp-s3a 10.230.0.0/16 +check "VPC vp-s3b" vpc_create vp-s3b 10.231.0.0/16 +check "subnet sn-s3a" subnet_create sn-s3a vp-s3a 2301 10.230.1.1 10.230.1.0/24 +check "subnet sn-s3b" subnet_create sn-s3b vp-s3b 2302 10.231.1.1 10.231.1.0/24 +check "VM s3-x dans vp-s3a" vm_create s3-x sn-s3a 10.230.1.10 "${KEY}" +check "VM s3-y dans vp-s3b" vm_create s3-y sn-s3b 10.231.1.10 "${KEY}" +check "s3-x joignable depuis sa VPC" vm_wait vp-s3a 10.230.1.10 +check "s3-y joignable depuis sa VPC (témoin : la cible est vivante)" vm_wait vp-s3b 10.231.1.10 +check "s3-x joint sa passerelle (témoin)" vm_ssh vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.230.1.1' +check "s3-x ne joint pas s3-y en ICMP" vm_fails vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.231.1.10' +check "s3-x n'ouvre pas de connexion TCP vers s3-y:22" vm_fails vp-s3a 10.230.1.10 'timeout 5 bash -c "/dev/null 2>&1 && break; sleep 5; done +check "s4-p (hv1) joint s4-q (hv2)" vm_ssh vp-s4 10.240.1.10 'ping -c 3 -W 2 10.240.1.11' +check "trame pleine taille : 1472 octets en -M do" vm_ssh vp-s4 10.240.1.10 'ping -M do -s 1472 -c 2 -W 2 10.240.1.11' +check "1473 octets refusés localement (MTU 1500)" vm_grep vp-s4 10.240.1.10 'ping -M do -s 1473 -c 1 -W 2 10.240.1.11' 'message too long' +NODE diff --git a/scripts/lab/scenarios/s5-isolation-evpn.sh b/scripts/lab/scenarios/s5-isolation-evpn.sh new file mode 100644 index 0000000..a761056 --- /dev/null +++ b/scripts/lab/scenarios/s5-isolation-evpn.sh @@ -0,0 +1,23 @@ +on hv1 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two (hv1)"; exit 0; } +KEY=$(vm_key) +check "hv1 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16 +check "hv1 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24 +check "hv1 : VM s5-r dans vp-s5a" vm_create s5-r sn-s5a 10.245.1.10 "${KEY}" +NODE +on hv2 <<'NODE' +two_image || { echo "ÉCHOUÉ: image compatible two (hv2)"; exit 0; } +check "hv2 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16 +check "hv2 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24 +check "hv2 : VM s5-t dans vp-s5a (témoin)" vm_create s5-t sn-s5a 10.245.1.12 +check "hv2 : VPC vp-s5b, même plage" vpc_create vp-s5b 10.245.0.0/16 +check "hv2 : subnet sn-s5b (VNI 2502), même plage" subnet_create sn-s5b vp-s5b 2502 10.245.1.1 10.245.1.0/24 +check "hv2 : VM s5-s dans vp-s5b" vm_create s5-s sn-s5b 10.245.1.11 +NODE +on hv1 <<'NODE' +check "s5-r joignable" vm_wait vp-s5a 10.245.1.10 +for i in $(seq 1 24); do vm_ssh vp-s5a 10.245.1.10 'ping -c 1 -W 2 10.245.1.12' >/dev/null 2>&1 && break; sleep 5; done +check "s5-r joint s5-t, même VPC sur hv2 (témoin)" vm_ssh vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.12' +check "s5-r ne joint pas s5-s, autre VPC sur hv2, même plage" vm_fails vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.11' +check "aucune entrée ARP résolue pour 10.245.1.11 dans s5-r" vm_fails vp-s5a 10.245.1.10 'ip neigh show 10.245.1.11 | grep -q lladdr' +NODE diff --git a/scripts/lab/scenarios/s6-rr-loss.sh b/scripts/lab/scenarios/s6-rr-loss.sh new file mode 100644 index 0000000..e4ea28e --- /dev/null +++ b/scripts/lab/scenarios/s6-rr-loss.sh @@ -0,0 +1,36 @@ +on hv1 <<'NODE' +check "prérequis : VM s4-p de S4 en marche" test "$(api_state vms/s4-p)" = running +check "session EVPN de hv1 établie avant l'arrêt" sh -c "vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'" +check "prérequis : s4-p joint s4-q" vm_ssh vp-s4 10.240.1.10 'ping -c 2 -W 2 10.240.1.11' +vm_ssh vp-s4 10.240.1.10 'rm -f /tmp/s6.log; nohup ping -D -i 0.2 10.240.1.11 > /tmp/s6.log 2>&1 &' +sleep 5 +NODE +on rr1 <<'NODE' +date +%s > /tmp/s6.stop +check "arrêt de FRR sur le route reflector" systemctl stop frr +NODE +sleep 30 +on hv1 <<'NODE' +recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; } +check "session EVPN de hv1 tombée" sh -c "! vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'" +info "30 s après l'arrêt du route reflector : $(recent 10) réponses dans les 10 dernières secondes (50 si le trafic passe intégralement)" +info "VTEP distant encore connu : $(vtysh -c 'show evpn vni 2401' | grep -c 192.168.14.12) ; entrée d'inondation : $(bridge fdb show dev vxlan-2401 | grep -c '00:00:00:00:00:00 dst')" +sleep 60 +info "90 s après l'arrêt : $(recent 10) réponses dans les 10 dernières secondes" +NODE +on rr1 <<'NODE' +check "redémarrage de FRR sur le route reflector" systemctl start frr +date +%s > /tmp/s6.start +NODE +on hv1 <<'NODE' +recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; } +start=$(date +%s) +for i in $(seq 1 90); do + vtysh -c "show evpn vni 2401" | grep -q 192.168.14.12 && [ "$(recent 3)" -gt 0 ] && break + sleep 2 +done +info "retour du VTEP distant et du trafic $(( $(date +%s) - start )) s après le redémarrage de FRR" +check "après le retour du route reflector : VTEP distant réappris" sh -c "vtysh -c 'show evpn vni 2401' | grep -q 192.168.14.12" +check "après le retour du route reflector : le trafic passe" test "$(recent 5)" -gt 0 +vm_ssh vp-s4 10.240.1.10 'pkill -x ping' || true +NODE