f-50: lab: arguments QEMU et seeds cloud-init des VM #50
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
parent
4d0fc7e18d
commit
f3515ca370
9 changed files with 1132 additions and 10 deletions
117
cmd/lab/main.go
117
cmd/lab/main.go
|
|
@ -1,45 +1,73 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.g3e.fr/syonad/two/internal/lab/render"
|
||||
"git.g3e.fr/syonad/two/internal/lab/topology"
|
||||
)
|
||||
|
||||
const usage = `usage: lab <command> <topology.yml>
|
||||
const usage = `usage: lab <command> [options] <topology.yml> [dir]
|
||||
|
||||
plan validate the topology and print the deterministic plan: addresses, cables, ports
|
||||
plan <topology.yml>
|
||||
validate the topology and print the deterministic plan: addresses, cables, ports
|
||||
|
||||
render -key <public key file> <topology.yml> <dir>
|
||||
write, for each node, <dir>/<node>/qemu.args (one argument per line) and the
|
||||
cloud-init seed files meta-data, user-data and network-config
|
||||
`
|
||||
|
||||
type keyFiles []string
|
||||
|
||||
func (k *keyFiles) String() string { return strings.Join(*k, ",") }
|
||||
func (k *keyFiles) Set(v string) error { *k = append(*k, v); return nil }
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stdout, stderr io.Writer) int {
|
||||
if len(args) != 2 {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
switch args[0] {
|
||||
case "plan":
|
||||
if len(args) != 2 {
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
return plan(args[1], stdout, stderr)
|
||||
case "render":
|
||||
return renderCmd(args[1:], stdout, stderr)
|
||||
default:
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
func plan(path string, stdout, stderr io.Writer) int {
|
||||
func load(path string, stderr io.Writer) (*topology.Plan, bool) {
|
||||
t, err := topology.Load(path)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
return nil, false
|
||||
}
|
||||
p, err := topology.Compute(t)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %s:\n%v\n", path, err)
|
||||
return nil, false
|
||||
}
|
||||
return p, true
|
||||
}
|
||||
|
||||
func plan(path string, stdout, stderr io.Writer) int {
|
||||
p, ok := load(path, stderr)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
if err := p.Write(stdout); err != nil {
|
||||
|
|
@ -48,3 +76,82 @@ func plan(path string, stdout, stderr io.Writer) int {
|
|||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func renderCmd(args []string, stdout, stderr io.Writer) int {
|
||||
fs := flag.NewFlagSet("render", flag.ContinueOnError)
|
||||
fs.SetOutput(stderr)
|
||||
fs.Usage = func() { fmt.Fprint(stderr, usage) }
|
||||
var keys keyFiles
|
||||
fs.Var(&keys, "key", "public key file allowed to log in, repeatable")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if fs.NArg() != 2 || len(keys) == 0 {
|
||||
fmt.Fprint(stderr, usage)
|
||||
return 2
|
||||
}
|
||||
|
||||
p, ok := load(fs.Arg(0), stderr)
|
||||
if !ok {
|
||||
return 1
|
||||
}
|
||||
dir, err := filepath.Abs(fs.Arg(1))
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
authorized, err := readKeys(keys)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
nodes, err := render.Render(p, render.Options{RunDir: dir, AuthorizedKeys: authorized})
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
for _, n := range nodes {
|
||||
if err := writeNode(n); err != nil {
|
||||
fmt.Fprintf(stderr, "lab: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stdout, "%s\n", n.Dir)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func readKeys(files []string) ([]string, error) {
|
||||
var keys []string
|
||||
for _, f := range files {
|
||||
data, err := os.ReadFile(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
keys = append(keys, line)
|
||||
}
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func writeNode(n render.Node) error {
|
||||
if err := os.MkdirAll(n.Dir, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
files := map[string][]byte{
|
||||
"qemu.args": []byte(strings.Join(n.QEMU, "\n") + "\n"),
|
||||
"meta-data": n.MetaData,
|
||||
"user-data": n.UserData,
|
||||
"network-config": n.NetworkConfig,
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(n.Dir, name), content, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ func runLab(args ...string) (int, string, string) {
|
|||
}
|
||||
|
||||
func TestRun_UsageOnMissingArguments(t *testing.T) {
|
||||
for _, args := range [][]string{nil, {"plan"}, {"deploy", "x.yml"}, {"plan", "a.yml", "b.yml"}} {
|
||||
for _, args := range [][]string{nil, {"plan"}, {"deploy", "x.yml"}, {"plan", "a.yml", "b.yml"}, {"render", "a.yml", "dir"}, {"render", "-key", "k.pub", "a.yml"}, {"render", "-bogus"}} {
|
||||
code, _, stderr := runLab(args...)
|
||||
if code != 2 || !strings.Contains(stderr, "usage: lab") {
|
||||
t.Errorf("args %v: code %d, stderr %q", args, code, stderr)
|
||||
|
|
@ -70,3 +70,52 @@ func TestRun_MissingFile(t *testing.T) {
|
|||
t.Errorf("code %d, stderr %q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RenderWritesEveryNodeFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
key := filepath.Join(dir, "lab.pub")
|
||||
if err := os.WriteFile(key, []byte("# lab key\n\nssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(dir, "run")
|
||||
code, stdout, stderr := runLab("render", "-key", key, filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), out)
|
||||
if code != 0 {
|
||||
t.Fatalf("code %d, stderr %s", code, stderr)
|
||||
}
|
||||
for _, node := range []string{"sw1", "rr1", "hv1", "hv2"} {
|
||||
if !strings.Contains(stdout, filepath.Join(out, node)) {
|
||||
t.Errorf("stdout does not list %s:\n%s", node, stdout)
|
||||
}
|
||||
for _, f := range []string{"qemu.args", "meta-data", "user-data", "network-config"} {
|
||||
info, err := os.Stat(filepath.Join(out, node, f))
|
||||
if err != nil {
|
||||
t.Errorf("%s/%s: %v", node, f, err)
|
||||
continue
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
t.Errorf("%s/%s mode %o, want 600", node, f, info.Mode().Perm())
|
||||
}
|
||||
}
|
||||
}
|
||||
args, err := os.ReadFile(filepath.Join(out, "hv1", "qemu.args"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(args), "\n-netdev\nuser,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22\n") {
|
||||
t.Errorf("qemu.args is not one argument per line:\n%s", args)
|
||||
}
|
||||
userData, err := os.ReadFile(filepath.Join(out, "hv1", "user-data"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(userData), "two-lab-automation") || strings.Contains(string(userData), "# lab key") {
|
||||
t.Errorf("keys not read as an authorized_keys file:\n%s", userData)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RenderRefusesMissingKeyFile(t *testing.T) {
|
||||
code, _, stderr := runLab("render", "-key", filepath.Join(t.TempDir(), "absent.pub"), filepath.Join("..", "..", "conf", "lab", "evpn-2hv.yml"), t.TempDir())
|
||||
if code != 1 || !strings.Contains(stderr, "absent.pub") {
|
||||
t.Errorf("code %d, stderr %q", code, stderr)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,10 @@ comment s'en servir.
|
|||
|
||||
.. note::
|
||||
|
||||
État actuel : étapes **E0** et **E1** livrées — le cycle de vie du serveur qui portera le lab
|
||||
(``scripts/lab-host.sh``), puis la description de la topologie et le calcul de son plan
|
||||
(``lab plan``). Le lancement des VM viendra avec les étapes suivantes, et cette page avec
|
||||
elles.
|
||||
État actuel : étapes **E0** à **E2** livrées — le cycle de vie du serveur qui portera le lab
|
||||
(``scripts/lab-host.sh``), la description de la topologie et le calcul de son plan
|
||||
(``lab plan``), puis la génération des arguments QEMU et des fichiers cloud-init de chaque VM
|
||||
(``lab render``). Le lancement des VM viendra avec l'étape suivante, et cette page avec elle.
|
||||
|
||||
Le serveur de lab
|
||||
-----------------
|
||||
|
|
@ -254,6 +254,111 @@ champs inconnus et les clés en double sont refusés aussi :
|
|||
Les plages d'adresses de l'exemple sont des valeurs de travail : le plan d'adressage du lab reste à
|
||||
définir (#50).
|
||||
|
||||
Rendu des VM
|
||||
------------
|
||||
|
||||
``lab render`` produit, pour chaque nœud, ce qu'il faut pour démarrer sa VM — sans rien lancer :
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
$ go run ./cmd/lab render -key ~/.config/two-lab/ssh/lab_ed25519.pub conf/lab/evpn-2hv.yml <répertoire>
|
||||
|
||||
``<répertoire>/<nœud>/`` reçoit :
|
||||
|
||||
``qemu.args``
|
||||
Les arguments de ``qemu-system-x86_64``, **un par ligne** : rien à échapper, rien à
|
||||
interpréter par un shell.
|
||||
|
||||
``meta-data``, ``user-data``, ``network-config``
|
||||
Les trois fichiers NoCloud de cloud-init, à mettre dans une image de volume ``cidata``.
|
||||
|
||||
Les chemins de la VM (``disk.qcow2``, ``seed.iso``, ``console.log``, ``qmp.sock``, ``qemu.pid``)
|
||||
sont ceux du répertoire du nœud ; ``-key`` peut être répété, et accepte un fichier
|
||||
``authorized_keys`` (lignes vides et commentaires ignorés). Les fichiers sont créés en ``0600``.
|
||||
|
||||
Ce que contiennent les arguments QEMU d'un hyperviseur — extrait réel, côté réseau :
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
-netdev
|
||||
user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22
|
||||
-device
|
||||
virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile=
|
||||
-netdev
|
||||
dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003
|
||||
-device
|
||||
virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile=
|
||||
|
||||
Les choix qui s'y lisent :
|
||||
|
||||
* **machine** ``q35``, ``-accel kvm -cpu host`` — le KVM imbriqué des hyperviseurs du lab en
|
||||
dépend ; ``-nodefaults`` pour qu'aucun périphérique implicite ne s'ajoute ;
|
||||
* **administration** (``mgmt0``) : le NAT de QEMU, MAC ``02:4d:<nœud>:<nœud>:00:00``, SSH redirigé
|
||||
sur la boucle locale de l'hôte. ``restrict=on`` pour tous les nœuds **sauf le switch** : un
|
||||
nœud isolé ne joint ni l'hôte ni l'extérieur par là, seule la redirection SSH passe.
|
||||
``ipv6=off`` partout (voir plus bas) ;
|
||||
* **câbles** : ``dgram`` sur ``127.0.0.1``, les deux extrémités d'un câble se répondent
|
||||
(port local de l'une = port distant de l'autre), ``host_mtu`` annonce le MTU du segment au
|
||||
guest ;
|
||||
* ``romfile=`` vide sur toutes les cartes : pas de ROM de démarrage réseau, donc pas de repli
|
||||
sur un démarrage PXE si le firmware ne trouve pas le disque. Pendant les essais de #50, une VM
|
||||
restée bloquée sans rien écrire sur sa console, CPU au repos, avait toutes les apparences de
|
||||
ce repli ; la cause n'a pas été isolée, l'option est une précaution.
|
||||
|
||||
Ce que fait cloud-init :
|
||||
|
||||
* **toutes les VM** : interfaces nommées d'après leur MAC (``mgmt0``, nom du segment, ``p<i>``),
|
||||
``dhcp4: false`` partout, ``mgmt0`` en ``10.0.2.15/24`` **sans passerelle** ; connexion SSH par
|
||||
clé seulement, utilisateur ``debian``, ``root`` désactivé, mot de passe refusé ;
|
||||
* **un nœud** : adresse sur chaque segment, MTU du segment, route par défaut et DNS
|
||||
(``1.1.1.1``, ``8.8.8.8``) sur son **premier** segment — la sortie Internet passe par le
|
||||
switch ;
|
||||
* **le switch** : route par défaut par ``mgmt0`` ; un service ``lab-switch`` crée un bridge
|
||||
``br-<segment>`` par segment (STP désactivé, MTU du segment), y branche ses ports, porte la
|
||||
passerelle, active le routage et masque (NAT nftables) les segments vers ``mgmt0``. Le
|
||||
service est rejoué à chaque démarrage.
|
||||
|
||||
Vérifié sur de vraies VM
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
Le switch et le route reflector n'ont pas besoin de KVM imbriqué : ``sw1`` et ``rr1`` de
|
||||
l'exemple ont été démarrés **sur un Mac**, en émulation (TCG), avec Debian 12 ``generic`` et
|
||||
les fichiers produits par ``lab render``.
|
||||
|
||||
.. list-table::
|
||||
:header-rows: 1
|
||||
:widths: 60 40
|
||||
|
||||
* - Vérification
|
||||
- Résultat
|
||||
* - interfaces nommées et adressées, bridge ``br-underlay`` en ``10.250.0.1/24``
|
||||
- conforme
|
||||
* - service ``lab-switch`` actif, y compris après redémarrage
|
||||
- conforme
|
||||
* - ``ping -M do -s 8972`` de ``rr1`` vers le switch (MTU 9000, sans fragmentation)
|
||||
- passe
|
||||
* - ``ping -M do -s 8973`` (MTU 9001)
|
||||
- refusé : ``message too long, mtu=9000``
|
||||
* - Internet depuis ``rr1`` en IPv4
|
||||
- passe, par ``10.250.0.1``
|
||||
* - ``rr1`` vers un service TCP de l'hôte par ``mgmt0`` — le switch, témoin, y parvient
|
||||
- bloqué
|
||||
* - ``rr1`` vers Internet par ``mgmt0``
|
||||
- bloqué
|
||||
|
||||
Un défaut trouvé par cet essai, et corrigé : sans ``ipv6=off``, le NAT de QEMU annonce un
|
||||
préfixe IPv6 et ``mgmt0`` reçoit une **route IPv6 par défaut** — vers une impasse, puisque
|
||||
``restrict=on`` bloque tout. Pas de fuite, mais chaque programme qui tente l'IPv6 d'abord (le DNS
|
||||
renvoie d'abord des adresses IPv6) attend un délai avant de se rabattre sur l'IPv4.
|
||||
|
||||
.. note::
|
||||
|
||||
Un ``ping`` vers ``10.0.2.2`` n'est pas un test d'isolation : c'est la passerelle virtuelle de
|
||||
QEMU qui répond elle-même, ``restrict=on`` ou non. Seule une connexion vers un vrai service de
|
||||
l'hôte, avec un témoin qui y parvient, le prouve.
|
||||
|
||||
Reste à vérifier sur le serveur de lab : les hyperviseurs, qui exigent KVM imbriqué.
|
||||
|
||||
Facturation
|
||||
-----------
|
||||
|
||||
|
|
|
|||
54
internal/lab/render/qemu.go
Normal file
54
internal/lab/render/qemu.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"git.g3e.fr/syonad/two/internal/lab/topology"
|
||||
)
|
||||
|
||||
const loopback = "127.0.0.1"
|
||||
|
||||
func qemuArgs(p *topology.Plan, n topology.NodePlan, index int, dir string) []string {
|
||||
args := []string{
|
||||
"-name", n.Name,
|
||||
"-machine", "q35",
|
||||
"-accel", "kvm",
|
||||
"-cpu", "host",
|
||||
"-smp", strconv.Itoa(n.CPUs),
|
||||
"-m", strconv.Itoa(n.Memory),
|
||||
"-nodefaults",
|
||||
"-display", "none",
|
||||
"-serial", "file:" + filepath.Join(dir, ConsoleFile),
|
||||
"-qmp", "unix:" + filepath.Join(dir, QMPFile) + ",server=on,wait=off",
|
||||
"-pidfile", filepath.Join(dir, PIDFile),
|
||||
"-drive", "file=" + filepath.Join(dir, DiskFile) + ",if=virtio,format=qcow2",
|
||||
"-drive", "file=" + filepath.Join(dir, SeedFile) + ",media=cdrom,readonly=on",
|
||||
}
|
||||
|
||||
restrict := "on"
|
||||
if n.Role == topology.RoleSwitch {
|
||||
restrict = "off"
|
||||
}
|
||||
args = append(args,
|
||||
"-netdev", fmt.Sprintf("user,id=%s,restrict=%s,ipv6=off,hostfwd=tcp:%s:%d-:22", AdminInterface, restrict, loopback, n.SSHPort),
|
||||
"-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,romfile=", AdminInterface, adminMAC(index)),
|
||||
)
|
||||
|
||||
for _, c := range nodeCables(p, n.Name) {
|
||||
args = append(args, cable(c.NodeInterface, c.NodePort, c.SwitchPort, c.NodeMAC.String(), c.MTU)...)
|
||||
}
|
||||
for _, c := range switchCables(p, n.Name) {
|
||||
args = append(args, cable(c.SwitchInterface, c.SwitchPort, c.NodePort, c.SwitchMAC.String(), c.MTU)...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func cable(id string, local, remote int, mac string, mtu int) []string {
|
||||
return []string{
|
||||
"-netdev", fmt.Sprintf("dgram,id=%s,local.type=inet,local.host=%s,local.port=%d,remote.type=inet,remote.host=%s,remote.port=%d",
|
||||
id, loopback, local, loopback, remote),
|
||||
"-device", fmt.Sprintf("virtio-net-pci,netdev=%s,mac=%s,host_mtu=%d,romfile=", id, mac, mtu),
|
||||
}
|
||||
}
|
||||
132
internal/lab/render/render.go
Normal file
132
internal/lab/render/render.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
package render
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"git.g3e.fr/syonad/two/internal/lab/topology"
|
||||
)
|
||||
|
||||
const (
|
||||
QEMUBinary = "qemu-system-x86_64"
|
||||
|
||||
AdminInterface = topology.ReservedInterface
|
||||
AdminAddress = "10.0.2.15/24"
|
||||
AdminGateway = "10.0.2.2"
|
||||
AdminDNS = "10.0.2.3"
|
||||
|
||||
DiskFile = "disk.qcow2"
|
||||
SeedFile = "seed.iso"
|
||||
ConsoleFile = "console.log"
|
||||
QMPFile = "qmp.sock"
|
||||
PIDFile = "qemu.pid"
|
||||
)
|
||||
|
||||
var Nameservers = []string{"1.1.1.1", "8.8.8.8"}
|
||||
|
||||
type Options struct {
|
||||
RunDir string
|
||||
AuthorizedKeys []string
|
||||
}
|
||||
|
||||
type Node struct {
|
||||
Name string
|
||||
Dir string
|
||||
QEMU []string
|
||||
MetaData []byte
|
||||
UserData []byte
|
||||
NetworkConfig []byte
|
||||
}
|
||||
|
||||
func Render(p *topology.Plan, o Options) ([]Node, error) {
|
||||
if err := o.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var nodes []Node
|
||||
for index, n := range p.Nodes {
|
||||
dir := filepath.Join(o.RunDir, n.Name)
|
||||
meta, err := metaData(p, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
user, err := userData(p, n, o.AuthorizedKeys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
network, err := networkConfig(p, n, index)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
nodes = append(nodes, Node{
|
||||
Name: n.Name,
|
||||
Dir: dir,
|
||||
QEMU: qemuArgs(p, n, index, dir),
|
||||
MetaData: meta,
|
||||
UserData: user,
|
||||
NetworkConfig: network,
|
||||
})
|
||||
}
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
func (o Options) validate() error {
|
||||
var errs []error
|
||||
if !filepath.IsAbs(o.RunDir) {
|
||||
errs = append(errs, fmt.Errorf("run dir %q must be an absolute path", o.RunDir))
|
||||
}
|
||||
if len(o.AuthorizedKeys) == 0 {
|
||||
errs = append(errs, errors.New("at least one authorized ssh key is required"))
|
||||
}
|
||||
for i, k := range o.AuthorizedKeys {
|
||||
if strings.ContainsAny(k, "\r\n") {
|
||||
errs = append(errs, fmt.Errorf("authorized key %d spans several lines", i+1))
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(k)
|
||||
if len(fields) < 2 || !validKeyType(fields[0]) {
|
||||
errs = append(errs, fmt.Errorf("authorized key %d is not an ssh public key", i+1))
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func validKeyType(t string) bool {
|
||||
return strings.HasPrefix(t, "ssh-") || strings.HasPrefix(t, "ecdsa-sha2-") || strings.HasPrefix(t, "sk-")
|
||||
}
|
||||
|
||||
func adminMAC(index int) net.HardwareAddr {
|
||||
return net.HardwareAddr{0x02, 0x4d, byte(index >> 8), byte(index), 0x00, 0x00}
|
||||
}
|
||||
|
||||
func nodeCables(p *topology.Plan, name string) []topology.Cable {
|
||||
var out []topology.Cable
|
||||
for _, c := range p.Cables {
|
||||
if c.Node == name {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func switchCables(p *topology.Plan, name string) []topology.Cable {
|
||||
var out []topology.Cable
|
||||
for _, c := range p.Cables {
|
||||
if c.Switch == name {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func switchSegments(p *topology.Plan, name string) []topology.SegmentPlan {
|
||||
var out []topology.SegmentPlan
|
||||
for _, s := range p.Segments {
|
||||
if s.Switch == name {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
477
internal/lab/render/render_test.go
Normal file
477
internal/lab/render/render_test.go
Normal file
|
|
@ -0,0 +1,477 @@
|
|||
package render
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.g3e.fr/syonad/two/internal/lab/topology"
|
||||
)
|
||||
|
||||
const labKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFG/JMmjfko96WkJV8DiL6rip/H/q/R++y8s27Z+Cj6O two-lab-automation"
|
||||
|
||||
const twoHypervisors = `name: evpn-2hv
|
||||
images:
|
||||
deb:
|
||||
url: https://example.invalid/deb.qcow2
|
||||
sums: https://example.invalid/SHA512SUMS
|
||||
segments:
|
||||
underlay: { switch: sw1, cidr: 10.250.0.0/24, mtu: 9000 }
|
||||
nodes:
|
||||
sw1: { role: switch, image: deb, cpus: 2, memory: 1024 }
|
||||
rr1: { role: rr, image: deb, cpus: 1, memory: 1024, segments: [underlay] }
|
||||
hv1: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] }
|
||||
hv2: { role: hypervisor, image: deb, cpus: 4, memory: 16384, segments: [underlay] }
|
||||
`
|
||||
|
||||
const twoSegments = `name: two-seg
|
||||
images:
|
||||
deb:
|
||||
url: https://example.invalid/deb.qcow2
|
||||
sums: https://example.invalid/SHA512SUMS
|
||||
segments:
|
||||
red: { switch: sw, cidr: 10.1.0.0/24 }
|
||||
blue: { switch: sw, cidr: 10.2.0.0/24, mtu: 1500 }
|
||||
nodes:
|
||||
sw: { role: switch, image: deb, cpus: 1, memory: 512 }
|
||||
hv: { role: hypervisor, image: deb, cpus: 1, memory: 512, segments: [blue, red] }
|
||||
`
|
||||
|
||||
func plan(t *testing.T, doc string) *topology.Plan {
|
||||
t.Helper()
|
||||
topo, err := topology.Parse([]byte(doc))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
p, err := topology.Compute(topo)
|
||||
if err != nil {
|
||||
t.Fatalf("Compute: %v", err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func renderAll(t *testing.T, doc string) map[string]Node {
|
||||
t.Helper()
|
||||
nodes, err := Render(plan(t, doc), Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey}})
|
||||
if err != nil {
|
||||
t.Fatalf("Render: %v", err)
|
||||
}
|
||||
out := map[string]Node{}
|
||||
for _, n := range nodes {
|
||||
out[n.Name] = n
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func nodeNamed(t *testing.T, nodes map[string]Node, name string) Node {
|
||||
t.Helper()
|
||||
n, ok := nodes[name]
|
||||
if !ok {
|
||||
t.Fatalf("no rendered node %s", name)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func value(t *testing.T, args []string, flag string) []string {
|
||||
t.Helper()
|
||||
var out []string
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag {
|
||||
out = append(out, args[i+1])
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
t.Fatalf("no %s in %v", flag, args)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func hasValue(args []string, flag, want string) bool {
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == flag && args[i+1] == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func network(t *testing.T, n Node) networkDoc {
|
||||
t.Helper()
|
||||
var doc networkDoc
|
||||
if err := yaml.Unmarshal(n.NetworkConfig, &doc); err != nil {
|
||||
t.Fatalf("network-config of %s is not yaml: %v", n.Name, err)
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
func iface(t *testing.T, doc networkDoc, name string) ethernet {
|
||||
t.Helper()
|
||||
e, ok := doc.Ethernets[name]
|
||||
if !ok {
|
||||
t.Fatalf("no interface %s in %v", name, doc.Ethernets)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func user(t *testing.T, n Node) cloudConfig {
|
||||
t.Helper()
|
||||
if !strings.HasPrefix(string(n.UserData), "#cloud-config\n") {
|
||||
t.Fatalf("user-data of %s does not start with #cloud-config", n.Name)
|
||||
}
|
||||
var cfg cloudConfig
|
||||
if err := yaml.Unmarshal(n.UserData, &cfg); err != nil {
|
||||
t.Fatalf("user-data of %s is not yaml: %v", n.Name, err)
|
||||
}
|
||||
return cfg
|
||||
}
|
||||
|
||||
func fileAt(t *testing.T, cfg cloudConfig, path string) writeFile {
|
||||
t.Helper()
|
||||
for _, f := range cfg.WriteFiles {
|
||||
if f.Path == path {
|
||||
return f
|
||||
}
|
||||
}
|
||||
t.Fatalf("no write_files entry %s", path)
|
||||
return writeFile{}
|
||||
}
|
||||
|
||||
func TestQEMU_HypervisorArguments(t *testing.T) {
|
||||
hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1")
|
||||
for flag, want := range map[string]string{
|
||||
"-name": "hv1",
|
||||
"-machine": "q35",
|
||||
"-accel": "kvm",
|
||||
"-cpu": "host",
|
||||
"-smp": "4",
|
||||
"-m": "16384",
|
||||
"-display": "none",
|
||||
"-serial": "file:/srv/lab/hv1/console.log",
|
||||
"-qmp": "unix:/srv/lab/hv1/qmp.sock,server=on,wait=off",
|
||||
"-pidfile": "/srv/lab/hv1/qemu.pid",
|
||||
} {
|
||||
if !hasValue(hv.QEMU, flag, want) {
|
||||
t.Errorf("%s %s missing from %v", flag, want, hv.QEMU)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"file=/srv/lab/hv1/disk.qcow2,if=virtio,format=qcow2",
|
||||
"file=/srv/lab/hv1/seed.iso,media=cdrom,readonly=on",
|
||||
} {
|
||||
if !hasValue(hv.QEMU, "-drive", want) {
|
||||
t.Errorf("-drive %s missing", want)
|
||||
}
|
||||
}
|
||||
if hv.Dir != "/srv/lab/hv1" {
|
||||
t.Errorf("Dir = %s", hv.Dir)
|
||||
}
|
||||
nodefaults := 0
|
||||
for _, a := range hv.QEMU {
|
||||
if a == "-nodefaults" {
|
||||
nodefaults++
|
||||
}
|
||||
}
|
||||
if nodefaults != 1 {
|
||||
t.Errorf("-nodefaults appears %d times", nodefaults)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQEMU_HypervisorNetwork(t *testing.T) {
|
||||
hv := nodeNamed(t, renderAll(t, twoHypervisors), "hv1")
|
||||
wantNetdevs := []string{
|
||||
"user,id=mgmt0,restrict=on,ipv6=off,hostfwd=tcp:127.0.0.1:2202-:22",
|
||||
"dgram,id=underlay,local.type=inet,local.host=127.0.0.1,local.port=20002,remote.type=inet,remote.host=127.0.0.1,remote.port=20003",
|
||||
}
|
||||
wantDevices := []string{
|
||||
"virtio-net-pci,netdev=mgmt0,mac=02:4d:00:02:00:00,romfile=",
|
||||
"virtio-net-pci,netdev=underlay,mac=02:4c:00:02:00:00,host_mtu=9000,romfile=",
|
||||
}
|
||||
if got := value(t, hv.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) {
|
||||
t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs)
|
||||
}
|
||||
if got := value(t, hv.QEMU, "-device"); !reflect.DeepEqual(got, wantDevices) {
|
||||
t.Errorf("devices = %v\nwant %v", got, wantDevices)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQEMU_SwitchNetwork(t *testing.T) {
|
||||
sw := nodeNamed(t, renderAll(t, twoHypervisors), "sw1")
|
||||
wantNetdevs := []string{
|
||||
"user,id=mgmt0,restrict=off,ipv6=off,hostfwd=tcp:127.0.0.1:2200-:22",
|
||||
"dgram,id=p0,local.type=inet,local.host=127.0.0.1,local.port=20001,remote.type=inet,remote.host=127.0.0.1,remote.port=20000",
|
||||
"dgram,id=p1,local.type=inet,local.host=127.0.0.1,local.port=20003,remote.type=inet,remote.host=127.0.0.1,remote.port=20002",
|
||||
"dgram,id=p2,local.type=inet,local.host=127.0.0.1,local.port=20005,remote.type=inet,remote.host=127.0.0.1,remote.port=20004",
|
||||
}
|
||||
if got := value(t, sw.QEMU, "-netdev"); !reflect.DeepEqual(got, wantNetdevs) {
|
||||
t.Errorf("netdevs = %v\nwant %v", got, wantNetdevs)
|
||||
}
|
||||
if !hasValue(sw.QEMU, "-device", "virtio-net-pci,netdev=p2,mac=02:4c:00:03:00:01,host_mtu=9000,romfile=") {
|
||||
t.Errorf("p2 device missing: %v", sw.QEMU)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQEMU_OnlyTheSwitchReachesTheOutsideThroughAdministration(t *testing.T) {
|
||||
for name, n := range renderAll(t, twoHypervisors) {
|
||||
admin := value(t, n.QEMU, "-netdev")[0]
|
||||
wantRestrict := "restrict=on"
|
||||
if name == "sw1" {
|
||||
wantRestrict = "restrict=off"
|
||||
}
|
||||
if !strings.Contains(admin, ","+wantRestrict+",") {
|
||||
t.Errorf("%s admin netdev %q, want %s", name, admin, wantRestrict)
|
||||
}
|
||||
if !strings.Contains(admin, "hostfwd=tcp:127.0.0.1:") {
|
||||
t.Errorf("%s ssh forward not bound to loopback: %q", name, admin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestQEMU_EveryCableEndsMatch(t *testing.T) {
|
||||
ends := map[string]int{}
|
||||
for _, n := range renderAll(t, twoHypervisors) {
|
||||
for _, nd := range value(t, n.QEMU, "-netdev") {
|
||||
if !strings.HasPrefix(nd, "dgram,") {
|
||||
continue
|
||||
}
|
||||
var local, remote string
|
||||
for _, kv := range strings.Split(nd, ",") {
|
||||
if v, ok := strings.CutPrefix(kv, "local.port="); ok {
|
||||
local = v
|
||||
}
|
||||
if v, ok := strings.CutPrefix(kv, "remote.port="); ok {
|
||||
remote = v
|
||||
}
|
||||
}
|
||||
ends[local+">"+remote]++
|
||||
}
|
||||
}
|
||||
for pair, count := range ends {
|
||||
local, remote, _ := strings.Cut(pair, ">")
|
||||
if count != 1 || ends[remote+">"+local] != 1 {
|
||||
t.Errorf("cable end %s has no single matching end", pair)
|
||||
}
|
||||
}
|
||||
if len(ends) != 6 {
|
||||
t.Errorf("%d cable ends, want 6", len(ends))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkConfig_Hypervisor(t *testing.T) {
|
||||
doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1"))
|
||||
if doc.Version != 2 || len(doc.Ethernets) != 2 {
|
||||
t.Fatalf("network-config = %+v", doc)
|
||||
}
|
||||
admin := iface(t, doc, "mgmt0")
|
||||
if admin.Match.MACAddress != "02:4d:00:02:00:00" || admin.SetName != "mgmt0" ||
|
||||
!reflect.DeepEqual(admin.Addresses, []string{"10.0.2.15/24"}) || len(admin.Routes) != 0 || admin.Nameservers != nil {
|
||||
t.Errorf("mgmt0 = %+v", admin)
|
||||
}
|
||||
under := iface(t, doc, "underlay")
|
||||
if under.Match.MACAddress != "02:4c:00:02:00:00" || under.SetName != "underlay" || under.MTU != 9000 ||
|
||||
!reflect.DeepEqual(under.Addresses, []string{"10.250.0.3/24"}) {
|
||||
t.Errorf("underlay = %+v", under)
|
||||
}
|
||||
if !reflect.DeepEqual(under.Routes, []route{{To: "0.0.0.0/0", Via: "10.250.0.1"}}) {
|
||||
t.Errorf("underlay routes = %+v", under.Routes)
|
||||
}
|
||||
if under.Nameservers == nil || !reflect.DeepEqual(under.Nameservers.Addresses, []string{"1.1.1.1", "8.8.8.8"}) {
|
||||
t.Errorf("underlay nameservers = %+v", under.Nameservers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkConfig_DHCPIsExplicitlyOffEverywhere(t *testing.T) {
|
||||
for name, n := range renderAll(t, twoHypervisors) {
|
||||
if c := strings.Count(string(n.NetworkConfig), "dhcp4: false"); c != len(network(t, n).Ethernets) {
|
||||
t.Errorf("%s: %d explicit dhcp4: false for %d interfaces", name, c, len(network(t, n).Ethernets))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkConfig_Switch(t *testing.T) {
|
||||
doc := network(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1"))
|
||||
admin := iface(t, doc, "mgmt0")
|
||||
if !reflect.DeepEqual(admin.Routes, []route{{To: "0.0.0.0/0", Via: "10.0.2.2"}}) ||
|
||||
admin.Nameservers == nil || !reflect.DeepEqual(admin.Nameservers.Addresses, []string{"10.0.2.3"}) {
|
||||
t.Errorf("switch mgmt0 = %+v", admin)
|
||||
}
|
||||
for port, mac := range map[string]string{"p0": "02:4c:00:01:00:01", "p1": "02:4c:00:02:00:01", "p2": "02:4c:00:03:00:01"} {
|
||||
e := iface(t, doc, port)
|
||||
if e.Match.MACAddress != mac || e.SetName != port || e.MTU != 9000 || len(e.Addresses) != 0 || len(e.Routes) != 0 {
|
||||
t.Errorf("%s = %+v", port, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNetworkConfig_DefaultRouteOnlyOnFirstSegment(t *testing.T) {
|
||||
doc := network(t, nodeNamed(t, renderAll(t, twoSegments), "hv"))
|
||||
red, blue := iface(t, doc, "red"), iface(t, doc, "blue")
|
||||
if !reflect.DeepEqual(red.Routes, []route{{To: "0.0.0.0/0", Via: "10.1.0.1"}}) || red.Nameservers == nil {
|
||||
t.Errorf("red = %+v", red)
|
||||
}
|
||||
if len(blue.Routes) != 0 || blue.Nameservers != nil || blue.MTU != 1500 {
|
||||
t.Errorf("blue = %+v", blue)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserData_Hypervisor(t *testing.T) {
|
||||
cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "hv1"))
|
||||
if cfg.Hostname != "hv1" || !cfg.DisableRoot || len(cfg.WriteFiles) != 0 || len(cfg.Runcmd) != 0 || len(cfg.Packages) != 0 {
|
||||
t.Errorf("hv1 user-data = %+v", cfg)
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.SSHAuthorizedKeys, []string{labKey}) {
|
||||
t.Errorf("keys = %v", cfg.SSHAuthorizedKeys)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserData_PasswordLoginIsExplicitlyOff(t *testing.T) {
|
||||
for name, n := range renderAll(t, twoHypervisors) {
|
||||
if !strings.Contains(string(n.UserData), "\nssh_pwauth: false\n") || !strings.Contains(string(n.UserData), "\ndisable_root: true\n") {
|
||||
t.Errorf("%s user-data:\n%s", name, n.UserData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserData_SwitchBuildsBridgeGatewayAndNAT(t *testing.T) {
|
||||
cfg := user(t, nodeNamed(t, renderAll(t, twoHypervisors), "sw1"))
|
||||
if !reflect.DeepEqual(cfg.Packages, []string{"nftables"}) {
|
||||
t.Errorf("packages = %v", cfg.Packages)
|
||||
}
|
||||
script := fileAt(t, cfg, "/usr/local/sbin/lab-switch")
|
||||
if script.Permissions != "0755" {
|
||||
t.Errorf("script permissions = %s", script.Permissions)
|
||||
}
|
||||
wantScript := `#!/bin/sh
|
||||
set -eu
|
||||
sysctl -qw net.ipv4.ip_forward=1
|
||||
ip link add br-underlay type bridge stp_state 0 2>/dev/null || true
|
||||
ip link set dev p0 master br-underlay
|
||||
ip link set dev p0 up
|
||||
ip link set dev p1 master br-underlay
|
||||
ip link set dev p1 up
|
||||
ip link set dev p2 master br-underlay
|
||||
ip link set dev p2 up
|
||||
ip link set dev br-underlay mtu 9000
|
||||
ip addr replace 10.250.0.1/24 dev br-underlay
|
||||
ip link set dev br-underlay up
|
||||
nft -f /etc/lab-switch.nft
|
||||
`
|
||||
if script.Content != wantScript {
|
||||
t.Errorf("script:\n%s\nwant:\n%s", script.Content, wantScript)
|
||||
}
|
||||
nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content
|
||||
for _, want := range []string{
|
||||
"add table ip lab_nat\ndelete table ip lab_nat\n",
|
||||
"type nat hook postrouting priority srcnat;",
|
||||
`ip saddr { 10.250.0.0/24 } oifname "mgmt0" masquerade`,
|
||||
} {
|
||||
if !strings.Contains(nft, want) {
|
||||
t.Errorf("nft rules miss %q:\n%s", want, nft)
|
||||
}
|
||||
}
|
||||
unit := fileAt(t, cfg, "/etc/systemd/system/lab-switch.service").Content
|
||||
for _, want := range []string{"Type=oneshot", "RemainAfterExit=yes", "ExecStart=/usr/local/sbin/lab-switch", "After=network-online.target", "WantedBy=multi-user.target"} {
|
||||
if !strings.Contains(unit, want) {
|
||||
t.Errorf("unit misses %q", want)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(cfg.Runcmd, [][]string{{"systemctl", "daemon-reload"}, {"systemctl", "enable", "--now", "lab-switch.service"}}) {
|
||||
t.Errorf("runcmd = %v", cfg.Runcmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserData_SwitchWithTwoSegments(t *testing.T) {
|
||||
cfg := user(t, nodeNamed(t, renderAll(t, twoSegments), "sw"))
|
||||
script := fileAt(t, cfg, "/usr/local/sbin/lab-switch").Content
|
||||
for _, want := range []string{
|
||||
"ip link set dev p0 master br-red\n",
|
||||
"ip link set dev p1 master br-blue\n",
|
||||
"ip link set dev br-blue mtu 1500\n",
|
||||
"ip addr replace 10.2.0.1/24 dev br-blue\n",
|
||||
} {
|
||||
if !strings.Contains(script, want) {
|
||||
t.Errorf("script misses %q:\n%s", want, script)
|
||||
}
|
||||
}
|
||||
if strings.Contains(script, "p1 master br-red") || strings.Contains(script, "p0 master br-blue") {
|
||||
t.Errorf("port bridged on the wrong segment:\n%s", script)
|
||||
}
|
||||
if nft := fileAt(t, cfg, "/etc/lab-switch.nft").Content; !strings.Contains(nft, "ip saddr { 10.1.0.0/24, 10.2.0.0/24 }") {
|
||||
t.Errorf("nft:\n%s", nft)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetaData(t *testing.T) {
|
||||
var doc metaDoc
|
||||
if err := yaml.Unmarshal(nodeNamed(t, renderAll(t, twoHypervisors), "hv2").MetaData, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if doc.InstanceID != "evpn-2hv-hv2" || doc.LocalHostname != "hv2" {
|
||||
t.Errorf("meta-data = %+v", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_AdminMacsAreUniqueAndApartFromCableMacs(t *testing.T) {
|
||||
seen := map[string]string{}
|
||||
for name, n := range renderAll(t, twoHypervisors) {
|
||||
for _, d := range value(t, n.QEMU, "-device") {
|
||||
for _, kv := range strings.Split(d, ",") {
|
||||
if mac, ok := strings.CutPrefix(kv, "mac="); ok {
|
||||
if other, dup := seen[mac]; dup {
|
||||
t.Errorf("mac %s used by %s and %s", mac, other, name)
|
||||
}
|
||||
seen[mac] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(seen) != 10 {
|
||||
t.Errorf("%d distinct macs, want 10", len(seen))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_IsStable(t *testing.T) {
|
||||
first := renderAll(t, twoHypervisors)
|
||||
for i := 0; i < 20; i++ {
|
||||
if again := renderAll(t, twoHypervisors); !reflect.DeepEqual(first, again) {
|
||||
t.Fatalf("render differs on run %d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_RejectsBadOptions(t *testing.T) {
|
||||
p := plan(t, twoHypervisors)
|
||||
cases := []struct {
|
||||
name string
|
||||
opts Options
|
||||
want string
|
||||
}{
|
||||
{"relative run dir", Options{RunDir: "lab", AuthorizedKeys: []string{labKey}}, `run dir "lab" must be an absolute path`},
|
||||
{"no key", Options{RunDir: "/srv/lab"}, "at least one authorized ssh key"},
|
||||
{"multi-line key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{labKey + "\nssh-rsa AAAA x"}}, "authorized key 1 spans several lines"},
|
||||
{"not a key", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"hello world"}}, "authorized key 1 is not an ssh public key"},
|
||||
{"type only", Options{RunDir: "/srv/lab", AuthorizedKeys: []string{"ssh-ed25519"}}, "authorized key 1 is not an ssh public key"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := Render(p, c.opts)
|
||||
if err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Fatalf("err = %v, want %q", err, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_AcceptsUsualKeyTypes(t *testing.T) {
|
||||
p := plan(t, twoHypervisors)
|
||||
for _, k := range []string{
|
||||
"ssh-rsa AAAAB3NzaC1yc2E user",
|
||||
"ecdsa-sha2-nistp256 AAAAE2VjZHNh user",
|
||||
"sk-ssh-ed25519@openssh.com AAAAGnNr user",
|
||||
} {
|
||||
if _, err := Render(p, Options{RunDir: "/srv/lab", AuthorizedKeys: []string{k}}); err != nil {
|
||||
t.Errorf("%s rejected: %v", strings.Fields(k)[0], err)
|
||||
}
|
||||
}
|
||||
}
|
||||
192
internal/lab/render/seed.go
Normal file
192
internal/lab/render/seed.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package render
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"git.g3e.fr/syonad/two/internal/lab/topology"
|
||||
)
|
||||
|
||||
const (
|
||||
SwitchScript = "/usr/local/sbin/lab-switch"
|
||||
SwitchNFT = "/etc/lab-switch.nft"
|
||||
SwitchUnit = "/etc/systemd/system/lab-switch.service"
|
||||
)
|
||||
|
||||
type metaDoc struct {
|
||||
InstanceID string `yaml:"instance-id"`
|
||||
LocalHostname string `yaml:"local-hostname"`
|
||||
}
|
||||
|
||||
type writeFile struct {
|
||||
Path string `yaml:"path"`
|
||||
Permissions string `yaml:"permissions"`
|
||||
Content string `yaml:"content"`
|
||||
}
|
||||
|
||||
type cloudConfig struct {
|
||||
Hostname string `yaml:"hostname"`
|
||||
SSHPwauth bool `yaml:"ssh_pwauth"`
|
||||
DisableRoot bool `yaml:"disable_root"`
|
||||
SSHAuthorizedKeys []string `yaml:"ssh_authorized_keys"`
|
||||
Packages []string `yaml:"packages,omitempty"`
|
||||
WriteFiles []writeFile `yaml:"write_files,omitempty"`
|
||||
Runcmd [][]string `yaml:"runcmd,omitempty"`
|
||||
}
|
||||
|
||||
type match struct {
|
||||
MACAddress string `yaml:"macaddress"`
|
||||
}
|
||||
|
||||
type route struct {
|
||||
To string `yaml:"to"`
|
||||
Via string `yaml:"via"`
|
||||
}
|
||||
|
||||
type nameservers struct {
|
||||
Addresses []string `yaml:"addresses"`
|
||||
}
|
||||
|
||||
type ethernet struct {
|
||||
Match match `yaml:"match"`
|
||||
SetName string `yaml:"set-name"`
|
||||
DHCP4 bool `yaml:"dhcp4"`
|
||||
MTU int `yaml:"mtu,omitempty"`
|
||||
Addresses []string `yaml:"addresses,omitempty"`
|
||||
Routes []route `yaml:"routes,omitempty"`
|
||||
Nameservers *nameservers `yaml:"nameservers,omitempty"`
|
||||
}
|
||||
|
||||
type networkDoc struct {
|
||||
Version int `yaml:"version"`
|
||||
Ethernets map[string]ethernet `yaml:"ethernets"`
|
||||
}
|
||||
|
||||
func metaData(p *topology.Plan, n topology.NodePlan) ([]byte, error) {
|
||||
return yaml.Marshal(metaDoc{InstanceID: p.Name + "-" + n.Name, LocalHostname: n.Name})
|
||||
}
|
||||
|
||||
func userData(p *topology.Plan, n topology.NodePlan, keys []string) ([]byte, error) {
|
||||
cfg := cloudConfig{
|
||||
Hostname: n.Name,
|
||||
SSHPwauth: false,
|
||||
DisableRoot: true,
|
||||
SSHAuthorizedKeys: keys,
|
||||
}
|
||||
if n.Role == topology.RoleSwitch {
|
||||
cfg.Packages = []string{"nftables"}
|
||||
cfg.WriteFiles = []writeFile{
|
||||
{Path: SwitchScript, Permissions: "0755", Content: switchScript(p, n.Name)},
|
||||
{Path: SwitchNFT, Permissions: "0644", Content: switchNFT(p, n.Name)},
|
||||
{Path: SwitchUnit, Permissions: "0644", Content: switchUnit()},
|
||||
}
|
||||
cfg.Runcmd = [][]string{
|
||||
{"systemctl", "daemon-reload"},
|
||||
{"systemctl", "enable", "--now", "lab-switch.service"},
|
||||
}
|
||||
}
|
||||
body, err := yaml.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return append([]byte("#cloud-config\n"), body...), nil
|
||||
}
|
||||
|
||||
func networkConfig(p *topology.Plan, n topology.NodePlan, index int) ([]byte, error) {
|
||||
doc := networkDoc{Version: 2, Ethernets: map[string]ethernet{}}
|
||||
admin := ethernet{
|
||||
Match: match{MACAddress: adminMAC(index).String()},
|
||||
SetName: AdminInterface,
|
||||
Addresses: []string{AdminAddress},
|
||||
}
|
||||
if n.Role == topology.RoleSwitch {
|
||||
admin.Routes = []route{{To: "0.0.0.0/0", Via: AdminGateway}}
|
||||
admin.Nameservers = &nameservers{Addresses: []string{AdminDNS}}
|
||||
for _, c := range switchCables(p, n.Name) {
|
||||
doc.Ethernets[c.SwitchInterface] = ethernet{
|
||||
Match: match{MACAddress: c.SwitchMAC.String()},
|
||||
SetName: c.SwitchInterface,
|
||||
MTU: c.MTU,
|
||||
}
|
||||
}
|
||||
}
|
||||
doc.Ethernets[AdminInterface] = admin
|
||||
|
||||
for i, c := range nodeCables(p, n.Name) {
|
||||
e := ethernet{
|
||||
Match: match{MACAddress: c.NodeMAC.String()},
|
||||
SetName: c.NodeInterface,
|
||||
MTU: c.MTU,
|
||||
Addresses: []string{c.NodeAddress.String()},
|
||||
}
|
||||
if i == 0 {
|
||||
e.Routes = []route{{To: "0.0.0.0/0", Via: gatewayOf(p, c.Segment)}}
|
||||
e.Nameservers = &nameservers{Addresses: Nameservers}
|
||||
}
|
||||
doc.Ethernets[c.NodeInterface] = e
|
||||
}
|
||||
return yaml.Marshal(doc)
|
||||
}
|
||||
|
||||
func gatewayOf(p *topology.Plan, segment string) string {
|
||||
for _, s := range p.Segments {
|
||||
if s.Name == segment {
|
||||
return s.Gateway.String()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func switchScript(p *topology.Plan, name string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("#!/bin/sh\nset -eu\nsysctl -qw net.ipv4.ip_forward=1\n")
|
||||
for _, s := range switchSegments(p, name) {
|
||||
fmt.Fprintf(&b, "ip link add %s type bridge stp_state 0 2>/dev/null || true\n", s.Bridge)
|
||||
for _, c := range switchCables(p, name) {
|
||||
if c.Segment != s.Name {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&b, "ip link set dev %s master %s\n", c.SwitchInterface, s.Bridge)
|
||||
fmt.Fprintf(&b, "ip link set dev %s up\n", c.SwitchInterface)
|
||||
}
|
||||
fmt.Fprintf(&b, "ip link set dev %s mtu %d\n", s.Bridge, s.MTU)
|
||||
fmt.Fprintf(&b, "ip addr replace %s/%d dev %s\n", s.Gateway, s.Network.Bits(), s.Bridge)
|
||||
fmt.Fprintf(&b, "ip link set dev %s up\n", s.Bridge)
|
||||
}
|
||||
fmt.Fprintf(&b, "nft -f %s\n", SwitchNFT)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func switchNFT(p *topology.Plan, name string) string {
|
||||
var networks []string
|
||||
for _, s := range switchSegments(p, name) {
|
||||
networks = append(networks, s.Network.String())
|
||||
}
|
||||
return fmt.Sprintf(`add table ip lab_nat
|
||||
delete table ip lab_nat
|
||||
table ip lab_nat {
|
||||
chain postrouting {
|
||||
type nat hook postrouting priority srcnat; policy accept;
|
||||
ip saddr { %s } oifname "%s" masquerade
|
||||
}
|
||||
}
|
||||
`, strings.Join(networks, ", "), AdminInterface)
|
||||
}
|
||||
|
||||
func switchUnit() string {
|
||||
return fmt.Sprintf(`[Unit]
|
||||
Description=Lab switch: bridges, gateways and NAT
|
||||
Wants=network-online.target
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
RemainAfterExit=yes
|
||||
ExecStart=%s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`, SwitchScript)
|
||||
}
|
||||
|
|
@ -133,6 +133,7 @@ func TestValidate_Rejections(t *testing.T) {
|
|||
{"segment switch unknown", strings.Replace(valid, "switch: sw, cidr", "switch: ghost, cidr", 1), "switch ghost is not a declared node"},
|
||||
{"segment switch not a switch", strings.Replace(valid, "switch: sw, cidr", "switch: rr, cidr", 1), "rr is a rr, not a switch"},
|
||||
{"segment name too long", strings.ReplaceAll(valid, "under", "underlayunder"), "segment underlayunder: name must match"},
|
||||
{"segment named like the admin interface", strings.ReplaceAll(valid, "under", "mgmt0"), "segment mgmt0: name is reserved"},
|
||||
{"segment name with dash", strings.ReplaceAll(valid, "under", "un-der"), "segment un-der: name must match"},
|
||||
{"mtu too high", strings.Replace(valid, "cidr: 10.0.0.0/24", "cidr: 10.0.0.0/24, mtu: 9001", 1), "mtu 9001 out of range"},
|
||||
{"mtu too low", strings.Replace(valid, "cidr: 10.0.0.0/24", "cidr: 10.0.0.0/24, mtu: 1279", 1), "mtu 1279 out of range"},
|
||||
|
|
|
|||
|
|
@ -15,6 +15,8 @@ const (
|
|||
MinMemory = 256
|
||||
MaxPrefix = 30
|
||||
MinPrefix = 8
|
||||
|
||||
ReservedInterface = "mgmt0"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -63,6 +65,9 @@ func (t *Topology) Validate() error {
|
|||
if !segmentPattern.MatchString(s.Name) {
|
||||
add("segment %s: name must match %s", s.Name, segmentPattern)
|
||||
}
|
||||
if s.Name == ReservedInterface {
|
||||
add("segment %s: name is reserved for the administration interface", s.Name)
|
||||
}
|
||||
sw, ok := nodes[s.Switch]
|
||||
switch {
|
||||
case s.Switch == "":
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue