Compare commits

...

2 commits

Author SHA1 Message Date
909b4110c2
f-50: lab: résultats d'E5, contrôle DHCP de s1 sur les couples MAC/IP, perte du route reflector #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-04 21:17:48 +02:00
b4660c95d3
f-50: lab: scénarios E5, configuration de l'agent par hyperviseur, release 0.2.0rc003 #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-10-04 20:43:53 +02:00
26 changed files with 800 additions and 33 deletions

View file

@ -135,7 +135,12 @@ func renderCmd(args []string, stdout, stderr io.Writer) int {
fmt.Fprintf(stderr, "lab: %v\n", err) fmt.Fprintf(stderr, "lab: %v\n", err)
return 1 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 { if err != nil {
fmt.Fprintf(stderr, "lab: %v\n", err) fmt.Fprintf(stderr, "lab: %v\n", err)
return 1 return 1

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

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

View file

@ -21,7 +21,8 @@ nodes:
loopback: 10.255.255.1/32, frr: frr/rr1.conf } loopback: 10.255.255.1/32, frr: frr/rr1.conf }
hv1: hv1:
{ role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], { 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: hv2:
{ role: hypervisor, image: debian12, cpus: 4, memory: 16384, segments: [underlay], { 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 }

View file

@ -94,9 +94,21 @@ l'adresse du lien en secondaire et la loopback sur ``lo1`` :
- up des deux côtés - up des deux côtés
* - sessions des deux hyperviseurs vers ``10.255.255.1`` * - sessions des deux hyperviseurs vers ``10.255.255.1``
- Established, voisins dynamiques, iBGP AS 64600, famille L2VPN EVPN négociée - Established, voisins dynamiques, iBGP AS 64600, famille L2VPN EVPN négociée
* - routes EVPN échangées * - routes EVPN échangées, VM ↔ VM entre deux hyperviseurs
- aucune : rien à annoncer tant que two n'a pas créé de VXLAN — voir - routes de type 3 échangées, ping et MTU 1500 de bout en bout — à partir de la release
:doc:`premier-hyperviseur` ``0.2.0rc003``, qui pose l'adresse VTEP locale des VXLAN
(`#51 <https://git.g3e.fr/syonad/two/issues/51>`_)
.. warning::
**Les tunnels ne survivent pas à la perte du route reflector.** Mesuré dans le lab
(2026-10-04, FRR 10.7.1, un seul route reflector, sans ``graceful-restart``) : à l'arrêt de
FRR sur le route reflector, la session EVPN des hyperviseurs tombe aussitôt, FRR retire les
routes apprises et, avec elles, le VTEP distant et l'entrée d'inondation du VXLAN — **plus
aucun paquet ne passe** entre hyperviseurs, à 30 s comme à 90 s. Au redémarrage de FRR sur le
route reflector, VTEP distant et trafic reviennent **31 s** plus tard. Le trafic entre VM d'un
même hyperviseur n'est pas concerné. La redondance (deux route reflectors) ou
``graceful-restart`` sont les deux leviers ; ni l'un ni l'autre n'est encore qualifié.
Le routeur du lab n'est qu'une configuration minimale écrite pour l'essai, pas celle des routeurs Le routeur du lab n'est qu'une configuration minimale écrite pour l'essai, pas celle des routeurs
de cluster (:doc:`routeurs`). de cluster (:doc:`routeurs`).
@ -109,9 +121,8 @@ de cluster (:doc:`routeurs`).
lancer sur n'importe quel hyperviseur), et s'il s'agit d'une VM créée par l'agent comme les 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` ; autres ou d'un cas particulier — l'image, elle, est l'image golden de :doc:`image-qcow2` ;
* la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ; * la redondance : une seule VM route reflector, ou deux, et sur quels hyperviseurs ;
* la procédure de reconstruction, et l'état du cluster pendant que le route reflector est * la procédure de reconstruction — l'état du cluster pendant l'absence du route reflector est
absent — les tunnels déjà établis continuent-ils de fonctionner, et pendant combien de mesuré ci-dessus : plus de trafic entre hyperviseurs ;
temps ;
* la procédure d'amorçage : ce qui fonctionne, et dans quel ordre, quand on démarre un cluster * 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 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`. n'existe pas, cf. :doc:`premier-hyperviseur`.

View file

@ -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é * ``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`_). 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 * ``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. 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. ``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 hv2 hypervisor debian12 4 16384 MiB 127.0.0.1:2203
roles roles
name loopback secondary frr release name loopback secondary frr release agent
sw1 - underlay 169.254.0.1/28 sw1.conf - sw1 - underlay 169.254.0.1/28 sw1.conf - -
rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf - rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf - -
hv1 - - hv1.conf 0.2.0rc002 hv1 - - hv1.conf 0.2.0rc003 two.yml
hv2 - - hv2.conf 0.2.0rc002 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 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 node interface address mac udp switch port mac udp
@ -660,6 +664,97 @@ 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 * - ``lab down`` puis ``lab up`` d'une autre topologie
- conforme - conforme
Scénarios
---------
Les scénarios se lancent **depuis le Mac**, sur un lab démarré (``up``, ``push``, ``./lab up``) :
.. code-block:: text
scripts/lab/scenario.sh s1 # un scénario
scripts/lab/scenario.sh s1 s3 # plusieurs
scripts/lab/scenario.sh all # tous, dans l'ordre
Chacun affiche une ligne ``RÉUSSI`` ou ``ÉCHOUÉ`` par vérification — un échec porte la dernière
ligne de la commande en cause —, des lignes ``INFO`` pour les mesures, puis son bilan. Le code de
sortie vaut 1 si une vérification échoue **ou si aucune n'a été faite**.
.. list-table::
:header-rows: 1
:widths: 22 58 20
* - Scénario
- Ce qui doit être vrai
- Hyperviseurs
* - ``s1-dhcp-two``
- backend DHCP ``two``, une VPC et deux subnets : chaque VM reçoit l'adresse de **son**
subnet, démarrée seule ou en même temps qu'une autre ; le bail est tenu par
systemd-networkd ; route par défaut, route vers la VPC et ``/32`` vers les métadonnées
via ``interface_ip`` ; l'état de chaque serveur DHCP ne connaît que les MAC de son subnet
(#46)
- hv1
* - ``s2-gateway``
- route par défaut via ``interface_ip``, ou via ``gateway`` avec ``default_route`` ; route
vers la VPC toujours via ``interface_ip`` ; même résultat après recréation du subnet (#31)
- hv1
* - ``s3-isolation-local``
- deux VPC sur le même hyperviseur ne se joignent pas, en ICMP comme en TCP ; chaque VM
joint sa passerelle (témoin)
- hv1
* - ``s4-evpn``
- adresse VTEP locale sur les VXLAN (#51), VTEP distant appris par EVPN, ping VM ↔ VM
entre hyperviseurs, trame de 1472 octets en ``-M do``, 1473 refusés
- hv1, hv2
* - ``s5-isolation-evpn``
- deux VPC de même plage, VNI différentes, sur deux hyperviseurs : la VM joint celle de sa
VPC sur l'autre hyperviseur (témoin) et pas celle de l'autre VPC — aucune résolution ARP
- hv1, hv2
* - ``s6-rr-loss``
- FRR arrêté sur le route reflector, ping continu entre les VM de ``s4`` : la session tombe,
le trafic restant est **mesuré** (``INFO``) à 30 et 90 s ; au retour du route reflector, le
VTEP distant est réappris et le trafic repasse — délai mesuré
- hv1, hv2, rr1
``s6`` réutilise les VM de ``s4`` : le lancer après.
**Comment c'est fait.** ``scripts/lab/scenario.sh`` exécute chaque scénario sur le Mac ; un
scénario envoie des blocs de shell aux nœuds par ``on <nœud> [VAR=valeur…] <<'NODE'``, précédés de
``scripts/lab/node.sh`` — appels à l'API de l'agent, attente des états, image Debian compatible two
(préparée une fois par hyperviseur, ``seedfrom`` avec barre oblique finale), clé SSH des VM,
``check`` et ``vm_fails``. Une vérification négative (« ne joint pas ») passe par ``vm_fails`` :
elle n'est réussie que si le SSH vers la VM a fonctionné **et** que la commande y a échoué — un
SSH en panne ne passe jamais pour une isolation.
Résultats du 2026-10-04 sur le serveur de lab, release ``0.2.0rc003``, hv1 sur le DHCP intégré et
hv2 sur dnsmasq — toute la série en 8 minutes :
.. code-block:: text
$ scripts/lab/scenario.sh all | grep -E '^(=== s[0-9].* : |INFO)'
INFO: MAC de sn-s1a : 00:22:33:00:00:0a 00:22:33:00:00:0b ; de sn-s1b : 00:22:33:00:00:0a 00:22:33:00:00:0b
=== s1-dhcp-two : 37 réussi(s), 0 échoué(s)
=== s2-gateway : 22 réussi(s), 0 échoué(s)
=== s3-isolation-local : 12 réussi(s), 0 échoué(s)
=== s4-evpn : 14 réussi(s), 0 échoué(s)
=== s5-isolation-evpn : 13 réussi(s), 0 échoué(s)
INFO: 30 s après l'arrêt du route reflector : 0 réponses dans les 10 dernières secondes (50 si le trafic passe intégralement)
INFO: VTEP distant encore connu : 0 ; entrée d'inondation : 0
INFO: 90 s après l'arrêt : 0 réponses dans les 10 dernières secondes
INFO: retour du VTEP distant et du trafic 31 s après le redémarrage de FRR
=== s6-rr-loss : 8 réussi(s), 0 échoué(s)
Dans ce run, le contrôle « uniquement les MAC de son subnet » de ``s1`` ne prouvait rien : two
dérive la MAC du rang de l'IP, et les VM ``.10``/``.11`` des deux subnets avaient les mêmes MAC.
Vérifié à la main sur le lab, chaque serveur DHCP ne connaissait que les couples MAC/IP de son
subnet ; le scénario compare désormais ces couples.
Les VM sont accessibles depuis le netns de leur VPC, sur l'hyperviseur, avec l'utilisateur
``syonad`` créé par les métadonnées de two :
.. code-block:: text
scripts/lab-host.sh ssh "./lab ssh hv1 'sudo ip netns exec vp-s4 ssh -i /root/.ssh/lab-vm syonad@10.240.1.10'"
Facturation Facturation
----------- -----------
@ -751,6 +846,7 @@ Tests
.. code-block:: bash .. code-block:: bash
bash scripts/lab-host_test.sh bash scripts/lab-host_test.sh
bash scripts/lab/scenario_test.sh
go test ./internal/lab/... ./cmd/lab/ go test ./internal/lab/... ./cmd/lab/
Environ une minute et demie, sans réseau : la suite remplace ``curl`` par une fausse API Scaleway Environ une minute et demie, sans réseau : la suite remplace ``curl`` par une fausse API Scaleway

View file

@ -76,7 +76,11 @@ func Prepare(ctx context.Context, p *topology.Plan, o Options) ([]render.Node, e
if err != nil { if err != nil {
return nil, err 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 { if err != nil {
return nil, err 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) { 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 { for _, n := range p.Nodes {
if n.FRR == "" { if path(n) == "" {
continue continue
} }
data, err := os.ReadFile(n.FRR) data, err := os.ReadFile(path(n))
if err != nil { if err != nil {
return nil, fmt.Errorf("node %s: %w", n.Name, err) 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) { func EnsureKey(ctx context.Context, r Runner, dir string) (string, error) {

View file

@ -385,3 +385,23 @@ func TestPrepare_PutsTheFRRConfigIntoTheSeed(t *testing.T) {
} }
t.Errorf("hv1 user-data has no /etc/lab/frr.conf:\n%s", data) 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)
}
}

View file

@ -31,6 +31,7 @@ type Options struct {
RunDir string RunDir string
AuthorizedKeys []string AuthorizedKeys []string
FRR map[string]string FRR map[string]string
Agent map[string]string
} }
type Node struct { 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 { 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) 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 var nodes []Node
for index, n := range p.Nodes { for index, n := range p.Nodes {

View file

@ -262,3 +262,37 @@ func TestRoles_SwitchLoopbackIsCreatedByTheSwitchScript(t *testing.T) {
t.Errorf("lab-switch:\n%s\ndoes not end with:\n%s", 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)
}
}

View file

@ -31,6 +31,7 @@ const (
DeployScript = "/usr/local/sbin/lab-deploy" DeployScript = "/usr/local/sbin/lab-deploy"
TwoScriptsDir = "/opt/two/scripts" TwoScriptsDir = "/opt/two/scripts"
TwoGitServer = "https://git.g3e.fr/" TwoGitServer = "https://git.g3e.fr/"
AgentConfig = "/etc/two/agent.yml"
) )
//go:embed frrouting.gpg //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") 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 { if n.Role == topology.RoleHypervisor {
cfg.WriteFiles = append(cfg.WriteFiles, cfg.WriteFiles = append(cfg.WriteFiles,
writeFile{Path: TwoScriptsDir + "/deploy.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.Deploy)}, writeFile{Path: TwoScriptsDir + "/deploy.sh", Permissions: "0755", Encoding: "b64", Content: base64.StdEncoding.EncodeToString(scripts.Deploy)},

View file

@ -22,15 +22,15 @@ func (p *Plan) Write(w io.Writer) error {
var extras []NodePlan var extras []NodePlan
for _, n := range p.Nodes { 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) extras = append(extras, n)
} }
} }
if len(extras) > 0 { if len(extras) > 0 {
fmt.Fprintf(tw, "\nroles\n") 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 { 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)))
} }
} }

View file

@ -44,6 +44,7 @@ type NodePlan struct {
Loopback netip.Prefix Loopback netip.Prefix
FRR string FRR string
Release string Release string
Agent string
} }
type Cable struct { type Cable struct {
@ -78,6 +79,7 @@ func Compute(t *Topology) (*Plan, error) {
SSHPort: SSHBasePort + i, SSHPort: SSHBasePort + i,
FRR: n.FRR, FRR: n.FRR,
Release: n.Release, Release: n.Release,
Agent: n.Agent,
} }
for segment, raws := range n.Secondary { for segment, raws := range n.Secondary {
for _, raw := range raws { for _, raw := range raws {

View file

@ -315,9 +315,9 @@ nodes
hv2 hypervisor deb 4 16384 MiB 127.0.0.1:2203 hv2 hypervisor deb 4 16384 MiB 127.0.0.1:2203
roles roles
name loopback secondary frr release name loopback secondary frr release agent
hv1 - - - 0.2.0rc002 hv1 - - - 0.2.0rc002 -
hv2 - - - 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 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 node interface address mac udp switch port mac udp

View file

@ -142,10 +142,10 @@ func TestWrite_ShowsTheRoles(t *testing.T) {
} }
want := ` want := `
roles roles
name loopback secondary frr release name loopback secondary frr release agent
sw1 - underlay 169.254.0.1/28 sw1.conf - sw1 - underlay 169.254.0.1/28 sw1.conf - -
rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf - rr1 lo1 10.255.255.1/32 underlay 169.254.0.3/28 rr1.conf - -
hv1 - - hv1.conf 0.2.0rc002 hv1 - - hv1.conf 0.2.0rc002 -
` `
if !bytes.Contains(buf.Bytes(), []byte(want)) { if !bytes.Contains(buf.Bytes(), []byte(want)) {
t.Errorf("plan:\n%s\ndoes not contain:\n%s", buf.String(), 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) t.Errorf("rr1 release = %q", got)
} }
} }
func TestValidate_AgentOnlyForHypervisors(t *testing.T) {
doc := header + `
segments:
red: { switch: sw, cidr: 10.1.0.0/24 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
rr: { role: rr, image: deb, cpus: 1, memory: 512, segments: [red], agent: agent.yml }
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: agent.yml }
`
msg := validationError(t, doc)
requireContains(t, msg, "node rr: agent is only for hypervisors")
if bytes.Contains([]byte(msg), []byte("node hv: agent")) {
t.Errorf("a hypervisor agent was refused:\n%s", msg)
}
}
func TestLoad_ResolvesTheAgentPathAgainstTheTopologyFile(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "lab.yml")
doc := header + `
segments:
red: { switch: sw, cidr: 10.1.0.0/24 }
nodes:
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
hv1: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: agent/two.yml }
hv2: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [red], release: 0.2.0rc003, agent: /etc/lab/agent.yml }
`
if err := os.WriteFile(path, []byte(doc), 0o600); err != nil {
t.Fatal(err)
}
topo, err := Load(path)
if err != nil {
t.Fatalf("Load: %v", err)
}
want := map[string]string{"sw": "", "hv1": filepath.Join(dir, "agent", "two.yml"), "hv2": "/etc/lab/agent.yml"}
for _, n := range topo.Nodes {
if n.Agent != want[n.Name] {
t.Errorf("%s agent = %q, want %q", n.Name, n.Agent, want[n.Name])
}
}
p, err := Compute(topo)
if err != nil {
t.Fatal(err)
}
if got := nodeOf(t, p, "hv1").Agent; got != filepath.Join(dir, "agent", "two.yml") {
t.Errorf("plan hv1 agent = %q", got)
}
}

View file

@ -50,6 +50,7 @@ type Node struct {
Loopback string Loopback string
FRR string FRR string
Release string Release string
Agent string
} }
type fileImage struct { type fileImage struct {
@ -74,6 +75,7 @@ type fileNode struct {
Loopback string `yaml:"loopback"` Loopback string `yaml:"loopback"`
FRR string `yaml:"frr"` FRR string `yaml:"frr"`
Release string `yaml:"release"` Release string `yaml:"release"`
Agent string `yaml:"agent"`
} }
type file struct { type file struct {
@ -96,6 +98,9 @@ func Load(path string) (*Topology, error) {
if n.FRR != "" && !filepath.IsAbs(n.FRR) { if n.FRR != "" && !filepath.IsAbs(n.FRR) {
t.Nodes[i].FRR = filepath.Join(filepath.Dir(path), 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 return t, nil
} }
@ -144,6 +149,7 @@ func Parse(data []byte) (*Topology, error) {
Loopback: n.Loopback, Loopback: n.Loopback,
FRR: n.FRR, FRR: n.FRR,
Release: n.Release, Release: n.Release,
Agent: n.Agent,
}) })
} }
return t, nil return t, nil

View file

@ -215,6 +215,9 @@ func validateExtras(n Node, segments map[string]Segment, add func(string, ...any
case n.Role != RoleHypervisor && n.Release != "": case n.Role != RoleHypervisor && n.Release != "":
add("node %s: release is only for hypervisors", n.Name) 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 != "" { if n.Loopback != "" {
prefix, err := netip.ParsePrefix(n.Loopback) prefix, err := netip.ParsePrefix(n.Loopback)
switch { switch {

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

@ -0,0 +1,151 @@
set -u
API=http://127.0.0.1:8080
IMAGE_URL=https://cloud.debian.org/images/cloud/bookworm/latest
IMAGE_FILE=debian-12-genericcloud-amd64.qcow2
TWO_IMAGE=/var/tmp/deb-two.qcow2
VM_KEY=/root/.ssh/lab-vm
VOLUMES=/var/lib/two/volumes
check () {
local desc="${1}" out
shift
if out=$("$@" 2>&1); then
echo "RÉUSSI: ${desc}"
else
echo "ÉCHOUÉ: ${desc}${out:+ — $(printf '%s' "${out}" | tail -n 1)}"
fi
}
info () { echo "INFO: ${1}"; }
api_post () {
curl -fsS -o /dev/null -X POST -H 'Content-Type: application/json' -d "${2}" "${API}/${1}"
}
api_delete () {
curl -fsS -o /dev/null -X DELETE "${API}/${1}"
}
api_state () {
curl -fsS "${API}/${1}" 2>/dev/null | jq -r .state
}
wait_state () {
local path="${1}" want="${2}" i s
for i in $(seq 1 90); do
s=$(api_state "${path}")
[ "${s}" = "${want}" ] && return 0
[ "${want}" = deleted ] && [ -z "${s}" ] && return 0
[ "${s}" = error ] && { echo "${path}: error" >&2; return 1; }
sleep 2
done
echo "${path}: ${s:-absent}, ${want} attendu" >&2
return 1
}
vpc_create () {
api_state "vpcs/${1}" | grep -qx running && return 0
api_post vpcs "{\"name\":\"${1}\",\"cidr\":\"${2}\"}" && wait_state "vpcs/${1}" running
}
subnet_create () {
local name="${1}" vpc="${2}" vni="${3}" gw="${4}" cidr="${5}" extra="${6:-}"
api_state "subnets/${name}" | grep -qx running && return 0
api_post subnets "{\"name\":\"${name}\",\"vpc\":\"${vpc}\",\"mode\":\"vxlan\",\"vxlan_id\":${vni},\"iface_type\":\"vms\",\"interface_ip\":\"${gw}\",\"cidr\":\"${cidr}\"${extra}}" \
&& wait_state "subnets/${name}" running
}
two_image () {
[ -f "${TWO_IMAGE}" ] && return 0
local work
work=$(mktemp -d)
curl -fsSL -o "${work}/${IMAGE_FILE}" "${IMAGE_URL}/${IMAGE_FILE}" || return 1
curl -fsSL "${IMAGE_URL}/SHA512SUMS" | grep " ${IMAGE_FILE}\$" > "${work}/sums" || return 1
(cd "${work}" && sha512sum -c sums >/dev/null) || { echo "image: somme SHA-512 incorrecte" >&2; return 1; }
command -v qemu-nbd >/dev/null || DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qemu-utils >/dev/null
modprobe nbd max_part=8
qemu-nbd -c /dev/nbd0 "${work}/${IMAGE_FILE}" || return 1
for i in $(seq 1 20); do [ -b /dev/nbd0p1 ] && break; sleep 0.5; done
mkdir -p /mnt/two-image
mount /dev/nbd0p1 /mnt/two-image || { qemu-nbd -d /dev/nbd0 >/dev/null; return 1; }
printf '%s\n' 'datasource_list: [ NoCloud ]' 'datasource:' ' NoCloud:' " seedfrom: 'http://169.254.169.254:80/'" ' timeout: 5' ' max_wait: 10' \
> /mnt/two-image/etc/cloud/cloud.cfg.d/99_metadata.cfg
umount /mnt/two-image
qemu-nbd -d /dev/nbd0 >/dev/null
mv "${work}/${IMAGE_FILE}" "${TWO_IMAGE}"
rm -rf "${work}"
}
vm_key () {
[ -f "${VM_KEY}" ] || ssh-keygen -q -t ed25519 -N '' -C lab-vm -f "${VM_KEY}"
cat "${VM_KEY}.pub"
}
vm_create () {
local name="${1}" subnet="${2}" ip="${3}" key="${4:-}" meta="{}"
[ -n "${key}" ] && meta="{\"sshkey\":\"${key}\"}"
mkdir -p "${VOLUMES}"
cp "${TWO_IMAGE}" "${VOLUMES}/${name}.qcow2" || return 1
api_post vms "{\"name\":\"${name}\",\"memory\":1024,\"cpus\":1,\"metadata\":${meta},\"interfaces\":[{\"subnet\":\"${subnet}\",\"ip\":\"${ip}\",\"primary\":true}],\"storage\":[{\"path\":\"${VOLUMES}/${name}.qcow2\",\"dev\":\"vda\"}]}" \
&& wait_state "vms/${name}" running
}
vm_delete () {
api_delete "vms/${1}" && wait_state "vms/${1}" deleted
}
vm_ssh () {
local vpc="${1}" ip="${2}"
shift 2
ip netns exec "${vpc}" ssh -i "${VM_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -o ConnectTimeout=3 -o BatchMode=yes -n "syonad@${ip}" "$@"
}
vm_wait () {
local i
for i in $(seq 1 40); do
vm_ssh "${1}" "${2}" true 2>/dev/null && return 0
sleep 5
done
echo "${2}: SSH injoignable" >&2
return 1
}
vm_fails () {
local out
out=$(vm_ssh "${1}" "${2}" "${3} >/dev/null 2>&1; echo rc=\$?") || { echo "SSH vers ${2} en échec"; return 1; }
case "${out}" in
rc=0) echo "la commande a réussi dans ${2}"; return 1 ;;
rc=*) return 0 ;;
*) echo "sortie inattendue : ${out}"; return 1 ;;
esac
}
vm_grep () {
vm_ssh "${1}" "${2}" "${3} 2>&1" | grep -q -- "${4}"
}
has_addr () {
vm_ssh "${1}" "${2}" 'ip -4 -o addr show dev ens3' | grep -q " ${2}/"
}
route_via () {
vm_ssh "${1}" "${2}" "ip -4 route show ${3}" | grep -q "via ${4} "
}
dhcp_hosts () {
python3 - "/run/two/dhcp/${1}.sock" <<'PY'
import json, socket, sys
s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
s.connect(sys.argv[1])
s.sendall(b'{"verb":"get-state"}\n')
buf = b""
while not buf.endswith(b"\n"):
c = s.recv(65536)
if not c:
break
buf += c
state = json.loads(buf).get("state") or {}
print(" ".join(sorted(h["mac"].lower() + "=" + h["ip"] for h in state.get("hosts") or [])))
PY
}

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

@ -0,0 +1,67 @@
#!/usr/bin/env bash
LAB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
LAB_HOST="${LAB_HOST:-${LAB_DIR}/../lab-host.sh}"
SCENARIO_DIR="${SCENARIO_DIR:-${LAB_DIR}/scenarios}"
SCENARIO_LOG=""
usage () {
cat >&2 <<USAGE
usage: ${0##*/} <scénario>... | all
scénarios : $(cd "${SCENARIO_DIR}" && ls -- *.sh | sed 's/\.sh$//' | tr '\n' ' ')
Chaque scénario s'exécute depuis le Mac sur le lab en cours (lab-host.sh up, push, ./lab up).
Une ligne RÉUSSI ou ÉCHOUÉ par vérification ; code de sortie 1 si une vérification échoue ou
si aucune n'a été faite.
USAGE
exit 2
}
on () {
local node="${1}" assignment
shift
{
for assignment in "$@"; do
printf 'export %q=%q\n' "${assignment%%=*}" "${assignment#*=}"
done
cat "${LAB_DIR}/node.sh"
cat
} | "${LAB_HOST}" ssh "./lab ssh ${node} 'sudo bash -s'" 2>&1 | tee -a "${SCENARIO_LOG}"
}
run_scenario () {
local file="${1}" name passed failed
name="$(basename "${file}" .sh)"
SCENARIO_LOG="$(mktemp)"
echo "=== ${name}"
( . "${file}" )
passed=$(grep -c '^RÉUSSI: ' "${SCENARIO_LOG}")
failed=$(grep -c '^ÉCHOUÉ: ' "${SCENARIO_LOG}")
rm -f "${SCENARIO_LOG}"
echo "=== ${name} : ${passed} réussi(s), ${failed} échoué(s)"
[[ "${failed}" -eq 0 && "${passed}" -gt 0 ]]
}
main () {
local -a files=()
local arg file rc=0
[[ $# -gt 0 ]] || usage
for arg in "$@"; do
if [[ "${arg}" == all ]]; then
files+=("${SCENARIO_DIR}"/*.sh)
continue
fi
file=$(ls "${SCENARIO_DIR}/${arg}"*.sh 2>/dev/null | head -n 1)
[[ -n "${file}" ]] || { echo "scénario inconnu : ${arg}" >&2; usage; }
files+=("${file}")
done
for file in "${files[@]}"; do
run_scenario "${file}" || rc=1
done
return "${rc}"
}
if [[ "${BASH_SOURCE[0]}" == "${0}" || -z "${BASH_SOURCE[0]}" ]]; then
main "$@"
fi

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

@ -0,0 +1,140 @@
#!/usr/bin/env bash
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PASS=0
FAIL=0
fail () { echo "FAIL: ${CURRENT}: ${1}"; FAIL=$(( FAIL + 1 )); CASE_OK=0; }
setup () {
CURRENT="${1}"
CASE_OK=1
WORK=$(mktemp -d "${TMPDIR:-/tmp}/scenario.XXXXXX")
mkdir -p "${WORK}/scenarios"
cat > "${WORK}/lab-host" <<'FAKE'
#!/usr/bin/env bash
N=$(ls "${FAKE_DIR}" | grep -c '^payload\.')
cat > "${FAKE_DIR}/payload.$(( N + 1 ))"
printf '%s\n' "$*" >> "${FAKE_DIR}/calls.log"
if ! bash -n "${FAKE_DIR}/payload.$(( N + 1 ))" 2>"${FAKE_DIR}/syntax.$(( N + 1 ))"; then
echo "ÉCHOUÉ: syntaxe du bloc $(( N + 1 ))"
exit 0
fi
[ -n "${FAKE_OUT:-}" ] && printf '%b\n' "${FAKE_OUT}"
exit 0
FAKE
chmod +x "${WORK}/lab-host"
: > "${WORK}/calls.log"
}
run () {
env LAB_HOST="${WORK}/lab-host" FAKE_DIR="${WORK}" SCENARIO_DIR="${SCENARIO_DIR:-${WORK}/scenarios}" \
FAKE_OUT="${FAKE_OUT:-}" bash "${DIR}/scenario.sh" "$@" > "${WORK}/out.log" 2>&1
}
teardown () {
[[ "${CASE_OK}" -eq 1 ]] && PASS=$(( PASS + 1 )) && echo "ok: ${CURRENT}"
[[ "${CASE_OK}" -eq 1 ]] || { echo " sortie :"; sed 's/^/ | /' "${WORK}/out.log"; }
rm -rf "${WORK}"
}
node () {
bash -c ". '${DIR}/node.sh'; ${1}" 2>&1
}
test_on_sends_assignments_library_and_block_to_the_node () {
setup "on : variables, bibliothèque et bloc envoyés au bon nœud"
printf '%s\n' "on hv2 KEY='ssh-ed25519 AAAA x' NAME=vm <<'NODE'" 'echo "${NAME}"' 'NODE' > "${WORK}/scenarios/t1.sh"
FAKE_OUT='RÉUSSI: un' run t1 || fail "code de sortie $?"
[[ $(cat "${WORK}/calls.log") == "ssh ./lab ssh hv2 'sudo bash -s'" ]] || fail "appel : $(cat "${WORK}/calls.log")"
grep -q '^check () {' "${WORK}/payload.1" || fail "node.sh absent du bloc"
[[ $(tail -n 1 "${WORK}/payload.1") == 'echo "${NAME}"' ]] || fail "bloc du scénario absent en fin d'envoi"
[[ $(bash -c "$(grep '^export ' "${WORK}/payload.1"); printf '%s|%s' \"\${KEY}\" \"\${NAME}\"") == "ssh-ed25519 AAAA x|vm" ]] \
|| fail "variables mal transmises : $(grep '^export ' "${WORK}/payload.1")"
teardown
}
test_scenario_result_counts_and_exit_codes () {
local CASE
for CASE in ok fail none; do
setup "résultat d'un scénario (${CASE})"
printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/t2.sh"
case "${CASE}" in
ok) FAKE_OUT='RÉUSSI: a\nRÉUSSI: b' run t2; [[ $? -eq 0 ]] || fail "code non nul"
grep -q '=== t2 : 2 réussi(s), 0 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;;
fail) FAKE_OUT='RÉUSSI: a\nÉCHOUÉ: b — détail' run t2; [[ $? -eq 1 ]] || fail "code différent de 1"
grep -q '=== t2 : 1 réussi(s), 1 échoué(s)' "${WORK}/out.log" || fail "bilan absent" ;;
none) FAKE_OUT='INFO: rien' run t2; [[ $? -eq 1 ]] || fail "un scénario sans vérification a réussi" ;;
esac
teardown
done
}
test_unknown_scenario_and_no_argument () {
setup "scénario inconnu ou absent : usage, code 2"
run nope; [[ $? -eq 2 ]] || fail "scénario inconnu : code $?"
run; [[ $? -eq 2 ]] || fail "sans argument : code $?"
[[ -s "${WORK}/calls.log" ]] && fail "un nœud a été appelé"
teardown
}
test_all_runs_every_scenario_and_fails_if_one_fails () {
setup "all : chaque scénario, échec si l'un échoue"
printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/a.sh"
printf '%s\n' "on hv1 <<'NODE'" 'true' 'NODE' > "${WORK}/scenarios/b.sh"
FAKE_OUT='ÉCHOUÉ: x' run all; [[ $? -eq 1 ]] || fail "code différent de 1"
[[ $(grep -c '^=== [ab] :' "${WORK}/out.log") -eq 2 ]] || fail "les deux scénarios n'ont pas tourné"
teardown
}
test_every_shipped_block_is_valid_bash () {
local f
setup "chaque bloc envoyé par les scénarios livrés est du bash valide"
SCENARIO_DIR="${DIR}/scenarios" FAKE_OUT='RÉUSSI: x' run all
for f in "${WORK}"/syntax.*; do
[[ -s "${f}" ]] && fail "${f##*/} : $(cat "${f}")"
done
[[ $(ls "${WORK}" | grep -c '^payload\.') -ge 10 ]] || fail "trop peu de blocs envoyés : $(ls "${WORK}" | grep -c '^payload\.')"
grep -q 'ÉCHOUÉ: syntaxe' "${WORK}/out.log" && fail "bloc invalide"
teardown
}
test_check_reports_success_and_failure_with_reason () {
setup "check : RÉUSSI, ou ÉCHOUÉ avec la dernière ligne de sortie"
[[ $(node 'check "a" true') == "RÉUSSI: a" ]] || fail "succès : $(node 'check "a" true')"
[[ $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"') == "ÉCHOUÉ: b — deux" ]] || fail "échec : $(node 'check "b" sh -c "echo un; echo deux >&2; exit 3"')"
[[ $(node 'check "c" false') == "ÉCHOUÉ: c" ]] || fail "échec muet : $(node 'check "c" false')"
teardown
}
test_vm_fails_only_when_ssh_worked_and_command_failed () {
setup "vm_fails : réussit seulement si SSH marche et la commande échoue"
[[ $(node 'vm_ssh () { echo rc=1; }; vm_fails v 1.2.3.4 x && echo OUI') == "OUI" ]] || fail "commande échouée non reconnue"
[[ $(node 'vm_ssh () { echo rc=0; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "commande réussie prise pour un échec"
[[ $(node 'vm_ssh () { return 255; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "SSH en panne pris pour une isolation"
[[ $(node 'vm_ssh () { echo bizarre; }; vm_fails v 1.2.3.4 x || echo NON') == *NON ]] || fail "sortie inattendue acceptée"
teardown
}
test_route_via_matches_the_next_hop_exactly () {
setup "route_via : le next-hop exact, pas un préfixe"
[[ $(node 'vm_ssh () { echo "default via 10.220.1.1 dev ens3 proto dhcp"; }; route_via v ip default 10.220.1.1 && echo OUI') == OUI ]] || fail "next-hop exact refusé"
[[ $(node 'vm_ssh () { echo "default via 10.220.1.10 dev ens3"; }; route_via v ip default 10.220.1.1 || echo NON') == NON ]] || fail "10.220.1.10 pris pour 10.220.1.1"
teardown
}
test_wait_state () {
setup "wait_state : état atteint, erreur, suppression"
[[ $(node 'api_state () { echo running; }; wait_state vms/x running && echo OUI') == OUI ]] || fail "running"
local start=${SECONDS}
[[ $(node 'api_state () { echo error; }; wait_state vms/x running 2>&1 || echo NON') == *NON ]] || fail "error accepté"
(( SECONDS - start < 5 )) || fail "une ressource en error n'arrête pas l'attente immédiatement ($(( SECONDS - start )) s)"
[[ $(node 'api_state () { echo; }; wait_state vms/x deleted && echo OUI') == OUI ]] || fail "absence = supprimé"
teardown
}
for T in $(declare -F | awk '{print $3}' | grep '^test_'); do
"${T}"
done
echo "${PASS} réussi(s), ${FAIL} échec(s)"
[[ "${FAIL}" -eq 0 ]]

View file

@ -0,0 +1,34 @@
on hv1 <<'NODE'
two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; }
KEY=$(vm_key)
check "VPC vp-s1" vpc_create vp-s1 10.210.0.0/16
check "subnet sn-s1a" subnet_create sn-s1a vp-s1 2101 10.210.1.1 10.210.1.0/24
check "subnet sn-s1b" subnet_create sn-s1b vp-s1 2102 10.210.2.1 10.210.2.0/24
check "backend two : socket de contrôle de sn-s1a" test -S /run/two/dhcp/vp-s1_br-s1a.sock
check "backend two : socket de contrôle de sn-s1b" test -S /run/two/dhcp/vp-s1_br-s1b.sock
check "VM s1-a1, démarrée seule" vm_create s1-a1 sn-s1a 10.210.1.10 "${KEY}"
check "VM s1-a1 joignable" vm_wait vp-s1 10.210.1.10
check "VM s1-b1, démarrée seule" vm_create s1-b1 sn-s1b 10.210.2.10 "${KEY}"
check "VM s1-b1 joignable" vm_wait vp-s1 10.210.2.10
vm_create s1-a2 sn-s1a 10.210.1.11 "${KEY}" > /tmp/s1-a2.log 2>&1 & A=$!
vm_create s1-b2 sn-s1b 10.210.2.11 "${KEY}" > /tmp/s1-b2.log 2>&1 & B=$!
wait "${A}"; RA=$?
wait "${B}"; RB=$?
check "VM s1-a2, démarrée en même temps que s1-b2" test "${RA}" -eq 0
check "VM s1-b2, démarrée en même temps que s1-a2" test "${RB}" -eq 0
for vm in 10.210.1.10:10.210.1.1 10.210.1.11:10.210.1.1 10.210.2.10:10.210.2.1 10.210.2.11:10.210.2.1; do
ip=${vm%%:*}; gw=${vm#*:}
check "${ip} joignable" vm_wait vp-s1 "${ip}"
check "${ip} : adresse de son propre subnet" has_addr vp-s1 "${ip}"
check "${ip} : bail DHCP tenu par systemd-networkd" vm_ssh vp-s1 "${ip}" 'ls /run/systemd/netif/leases/ | grep -q .'
check "${ip} : route par défaut via ${gw}" route_via vp-s1 "${ip}" default "${gw}"
check "${ip} : route vers la VPC via ${gw}" route_via vp-s1 "${ip}" 10.210.0.0/16 "${gw}"
check "${ip} : route /32 vers les métadonnées via ${gw}" route_via vp-s1 "${ip}" 169.254.169.254 "${gw}"
done
vm_host () { echo "$(vm_ssh vp-s1 "${1}" 'cat /sys/class/net/ens3/address')=${1}"; }
A_HOSTS=$(printf '%s\n' "$(vm_host 10.210.1.10)" "$(vm_host 10.210.1.11)" | sort | tr '\n' ' ' | sed 's/ $//')
B_HOSTS=$(printf '%s\n' "$(vm_host 10.210.2.10)" "$(vm_host 10.210.2.11)" | sort | tr '\n' ' ' | sed 's/ $//')
info "sn-s1a : ${A_HOSTS} ; sn-s1b : ${B_HOSTS}"
check "probe sn-s1a : uniquement les couples MAC/IP de sn-s1a" test "$(dhcp_hosts vp-s1_br-s1a)" = "${A_HOSTS}"
check "probe sn-s1b : uniquement les couples MAC/IP de sn-s1b" test "$(dhcp_hosts vp-s1_br-s1b)" = "${B_HOSTS}"
NODE

View file

@ -0,0 +1,26 @@
on hv1 <<'NODE'
two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; }
KEY=$(vm_key)
check "VPC vp-s2" vpc_create vp-s2 10.220.0.0/16
check "subnet sn-s2a, default_route absent" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24
check "subnet sn-s2b, default_route et gateway 10.220.2.254" subnet_create sn-s2b vp-s2 2202 10.220.2.1 10.220.2.0/24 ',"default_route":true,"gateway":"10.220.2.254"'
check "VM s2-a" vm_create s2-a sn-s2a 10.220.1.10 "${KEY}"
check "VM s2-b" vm_create s2-b sn-s2b 10.220.2.10 "${KEY}"
check "s2-a joignable" vm_wait vp-s2 10.220.1.10
check "s2-b joignable" vm_wait vp-s2 10.220.2.10
check "sn-s2a : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.10 default 10.220.1.1
check "sn-s2a : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.10 10.220.0.0/16 10.220.1.1
check "sn-s2a : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.1.10 169.254.169.254 10.220.1.1
check "sn-s2b : route par défaut via la gateway 10.220.2.254" route_via vp-s2 10.220.2.10 default 10.220.2.254
check "sn-s2b : route vers la VPC via interface_ip, pas via la gateway" route_via vp-s2 10.220.2.10 10.220.0.0/16 10.220.2.1
check "sn-s2b : route /32 vers les métadonnées via interface_ip" route_via vp-s2 10.220.2.10 169.254.169.254 10.220.2.1
check "suppression de s2-a" vm_delete s2-a
check "suppression de sn-s2a" api_delete subnets/sn-s2a
check "sn-s2a supprimé" wait_state subnets/sn-s2a deleted
check "sn-s2a recréé" subnet_create sn-s2a vp-s2 2201 10.220.1.1 10.220.1.0/24
check "VM s2-a2 sur le subnet recréé" vm_create s2-a2 sn-s2a 10.220.1.11 "${KEY}"
check "s2-a2 joignable" vm_wait vp-s2 10.220.1.11
check "après recréation : route par défaut via interface_ip 10.220.1.1" route_via vp-s2 10.220.1.11 default 10.220.1.1
check "après recréation : route vers la VPC via interface_ip" route_via vp-s2 10.220.1.11 10.220.0.0/16 10.220.1.1
check "après recréation : route /32 vers les métadonnées" route_via vp-s2 10.220.1.11 169.254.169.254 10.220.1.1
NODE

View file

@ -0,0 +1,16 @@
on hv1 <<'NODE'
two_image || { echo "ÉCHOUÉ: image compatible two"; exit 0; }
KEY=$(vm_key)
check "VPC vp-s3a" vpc_create vp-s3a 10.230.0.0/16
check "VPC vp-s3b" vpc_create vp-s3b 10.231.0.0/16
check "subnet sn-s3a" subnet_create sn-s3a vp-s3a 2301 10.230.1.1 10.230.1.0/24
check "subnet sn-s3b" subnet_create sn-s3b vp-s3b 2302 10.231.1.1 10.231.1.0/24
check "VM s3-x dans vp-s3a" vm_create s3-x sn-s3a 10.230.1.10 "${KEY}"
check "VM s3-y dans vp-s3b" vm_create s3-y sn-s3b 10.231.1.10 "${KEY}"
check "s3-x joignable depuis sa VPC" vm_wait vp-s3a 10.230.1.10
check "s3-y joignable depuis sa VPC (témoin : la cible est vivante)" vm_wait vp-s3b 10.231.1.10
check "s3-x joint sa passerelle (témoin)" vm_ssh vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.230.1.1'
check "s3-x ne joint pas s3-y en ICMP" vm_fails vp-s3a 10.230.1.10 'ping -c 2 -W 2 10.231.1.10'
check "s3-x n'ouvre pas de connexion TCP vers s3-y:22" vm_fails vp-s3a 10.230.1.10 'timeout 5 bash -c "</dev/tcp/10.231.1.10/22"'
check "s3-y ne joint pas s3-x en ICMP" vm_fails vp-s3b 10.231.1.10 'ping -c 2 -W 2 10.230.1.10'
NODE

View file

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

View file

@ -0,0 +1,23 @@
on hv1 <<'NODE'
two_image || { echo "ÉCHOUÉ: image compatible two (hv1)"; exit 0; }
KEY=$(vm_key)
check "hv1 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16
check "hv1 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24
check "hv1 : VM s5-r dans vp-s5a" vm_create s5-r sn-s5a 10.245.1.10 "${KEY}"
NODE
on hv2 <<'NODE'
two_image || { echo "ÉCHOUÉ: image compatible two (hv2)"; exit 0; }
check "hv2 : VPC vp-s5a" vpc_create vp-s5a 10.245.0.0/16
check "hv2 : subnet sn-s5a (VNI 2501)" subnet_create sn-s5a vp-s5a 2501 10.245.1.1 10.245.1.0/24
check "hv2 : VM s5-t dans vp-s5a (témoin)" vm_create s5-t sn-s5a 10.245.1.12
check "hv2 : VPC vp-s5b, même plage" vpc_create vp-s5b 10.245.0.0/16
check "hv2 : subnet sn-s5b (VNI 2502), même plage" subnet_create sn-s5b vp-s5b 2502 10.245.1.1 10.245.1.0/24
check "hv2 : VM s5-s dans vp-s5b" vm_create s5-s sn-s5b 10.245.1.11
NODE
on hv1 <<'NODE'
check "s5-r joignable" vm_wait vp-s5a 10.245.1.10
for i in $(seq 1 24); do vm_ssh vp-s5a 10.245.1.10 'ping -c 1 -W 2 10.245.1.12' >/dev/null 2>&1 && break; sleep 5; done
check "s5-r joint s5-t, même VPC sur hv2 (témoin)" vm_ssh vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.12'
check "s5-r ne joint pas s5-s, autre VPC sur hv2, même plage" vm_fails vp-s5a 10.245.1.10 'ping -c 3 -W 2 10.245.1.11'
check "aucune entrée ARP résolue pour 10.245.1.11 dans s5-r" vm_fails vp-s5a 10.245.1.10 'ip neigh show 10.245.1.11 | grep -q lladdr'
NODE

View file

@ -0,0 +1,36 @@
on hv1 <<'NODE'
check "prérequis : VM s4-p de S4 en marche" test "$(api_state vms/s4-p)" = running
check "session EVPN de hv1 établie avant l'arrêt" sh -c "vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'"
check "prérequis : s4-p joint s4-q" vm_ssh vp-s4 10.240.1.10 'ping -c 2 -W 2 10.240.1.11'
vm_ssh vp-s4 10.240.1.10 'rm -f /tmp/s6.log; nohup ping -D -i 0.2 10.240.1.11 > /tmp/s6.log 2>&1 &'
sleep 5
NODE
on rr1 <<'NODE'
date +%s > /tmp/s6.stop
check "arrêt de FRR sur le route reflector" systemctl stop frr
NODE
sleep 30
on hv1 <<'NODE'
recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; }
check "session EVPN de hv1 tombée" sh -c "! vtysh -c 'show bgp neighbors 10.255.255.1' | grep -q 'BGP state = Established'"
info "30 s après l'arrêt du route reflector : $(recent 10) réponses dans les 10 dernières secondes (50 si le trafic passe intégralement)"
info "VTEP distant encore connu : $(vtysh -c 'show evpn vni 2401' | grep -c 192.168.14.12) ; entrée d'inondation : $(bridge fdb show dev vxlan-2401 | grep -c '00:00:00:00:00:00 dst')"
sleep 60
info "90 s après l'arrêt : $(recent 10) réponses dans les 10 dernières secondes"
NODE
on rr1 <<'NODE'
check "redémarrage de FRR sur le route reflector" systemctl start frr
date +%s > /tmp/s6.start
NODE
on hv1 <<'NODE'
recent () { vm_ssh vp-s4 10.240.1.10 "now=\$(date +%s); awk -F'[][]' -v now=\$now '/bytes from/ && \$2 > now - ${1}' /tmp/s6.log | wc -l"; }
start=$(date +%s)
for i in $(seq 1 90); do
vtysh -c "show evpn vni 2401" | grep -q 192.168.14.12 && [ "$(recent 3)" -gt 0 ] && break
sleep 2
done
info "retour du VTEP distant et du trafic $(( $(date +%s) - start )) s après le redémarrage de FRR"
check "après le retour du route reflector : VTEP distant réappris" sh -c "vtysh -c 'show evpn vni 2401' | grep -q 192.168.14.12"
check "après le retour du route reflector : le trafic passe" test "$(recent 5)" -gt 0
vm_ssh vp-s4 10.240.1.10 'pkill -x ping' || true
NODE