From 26d580574738e920603748e58c8f5c6e241497ad Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Tue, 25 Aug 2026 23:12:55 +0200 Subject: [PATCH] f-33: vm: start vms with multiple nic Signed-off-by: GnomeZworc --- internal/dhcp/reservation.go | 52 ++++++++++++++- internal/dhcp/reservation_test.go | 102 ++++++++++++++++++++++++++++++ internal/qemu/config.go | 10 ++- internal/qemu/start_linux.go | 29 +++++++-- internal/vm/create.go | 77 +++++++++++++++++++--- internal/vm/data.go | 20 ++++++ internal/vm/data_test.go | 5 ++ internal/vm/delete.go | 39 +++++++++--- 8 files changed, 309 insertions(+), 25 deletions(-) diff --git a/internal/dhcp/reservation.go b/internal/dhcp/reservation.go index b17b56c..0cd875c 100644 --- a/internal/dhcp/reservation.go +++ b/internal/dhcp/reservation.go @@ -10,6 +10,7 @@ import ( type Reservation struct { MAC string IP string + Tag string // pose set: sur l'entrée, pour cibler les options par interface } func HostsDir(confDir, name string) string { @@ -39,7 +40,11 @@ func WriteReservations(confDir, name, vmName string, res []Reservation) error { if r.MAC == "" || r.IP == "" { return fmt.Errorf("incomplete reservation for vm %q: mac=%q ip=%q", vmName, r.MAC, r.IP) } - fmt.Fprintf(&sb, "%s,%s\n", r.MAC, r.IP) + if r.Tag == "" { + fmt.Fprintf(&sb, "%s,%s\n", r.MAC, r.IP) + continue + } + fmt.Fprintf(&sb, "%s,%s,set:%s\n", r.MAC, r.IP, r.Tag) } path := filepath.Join(dir, vmName) @@ -49,6 +54,51 @@ func WriteReservations(confDir, name, vmName string, res []Reservation) error { return nil } +// WriteVMOptions écrit les options DHCP propres à des interfaces de cette VM +// sur ce subnet : elles suppriment la route par défaut — option 3 nue — et +// réémettent les autres routes. +// +// Un override de l'option 121 remplace la précédente en entier, il ne s'y +// ajoute pas (vérifié sur dnsmasq 2.90) : omettre la route vers le serveur de +// métadonnées la ferait disparaître, et la VM ne se provisionnerait pas. +func WriteVMOptions(confDir, name, vmName string, tags []string, c Config) error { + if len(tags) == 0 { + return RemoveVMOptions(confDir, name, vmName) + } + if c.InterfaceIP == nil { + return fmt.Errorf("interface ip is required to build options for vm %q", vmName) + } + if c.DefaultGateway != nil { + return fmt.Errorf("vm options for %q must not carry a default route", vmName) + } + + dir := OptsDir(confDir, name) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + routes := strings.Join(classlessRoutes(c), ",") + var sb strings.Builder + for _, tag := range tags { + fmt.Fprintf(&sb, "tag:%s,3\n", tag) + fmt.Fprintf(&sb, "tag:%s,121,%s\n", tag, routes) + } + + path := filepath.Join(dir, vmName) + if err := os.WriteFile(path, []byte(sb.String()), 0644); err != nil { + return fmt.Errorf("write %s: %w", path, err) + } + return nil +} + +func RemoveVMOptions(confDir, name, vmName string) error { + path := filepath.Join(OptsDir(confDir, name), vmName) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", path, err) + } + return nil +} + func RemoveReservations(confDir, name, vmName string) error { for _, dir := range []string{HostsDir(confDir, name), OptsDir(confDir, name)} { path := filepath.Join(dir, vmName) diff --git a/internal/dhcp/reservation_test.go b/internal/dhcp/reservation_test.go index 073ac9d..e5cad21 100644 --- a/internal/dhcp/reservation_test.go +++ b/internal/dhcp/reservation_test.go @@ -1,8 +1,10 @@ package dhcp import ( + "net" "os" "path/filepath" + "strings" "testing" ) @@ -124,3 +126,103 @@ func TestUnitName(t *testing.T) { t.Errorf("unit attendue dnsmasq@%s.service, obtenu %s", subName, got) } } + +// --- options par interface --- + +func TestWriteReservations_WithTags(t *testing.T) { + dir := t.TempDir() + res := []Reservation{ + {MAC: "00:22:33:00:01:02", IP: "10.1.1.2", Tag: "i-web-0"}, + {MAC: "00:22:33:00:02:07", IP: "10.1.2.7", Tag: "i-web-1"}, + } + if err := WriteReservations(dir, subName, "i-web", res); err != nil { + t.Fatalf("WriteReservations : %v", err) + } + content, _ := os.ReadFile(filepath.Join(HostsDir(dir, subName), "i-web")) + want := "00:22:33:00:01:02,10.1.1.2,set:i-web-0\n00:22:33:00:02:07,10.1.2.7,set:i-web-1\n" + if string(content) != want { + t.Errorf("attendu %q, obtenu %q", want, content) + } +} + +func vmOptions(t *testing.T, dir string, tags []string, vpcRoute *net.IPNet) string { + t.Helper() + if err := WriteVMOptions(dir, subName, "i-web", tags, Config{ + InterfaceIP: net.ParseIP("10.1.1.1").To4(), + VPCRoute: vpcRoute, + }); err != nil { + t.Fatalf("WriteVMOptions : %v", err) + } + b, err := os.ReadFile(filepath.Join(OptsDir(dir, subName), "i-web")) + if err != nil { + return "" + } + return string(b) +} + +func TestWriteVMOptions_SuppressesDefaultRoute(t *testing.T) { + _, vpcNet, _ := net.ParseCIDR("192.168.0.0/16") + got := vmOptions(t, t.TempDir(), []string{"i-web-1"}, vpcNet) + + if !strings.Contains(got, "tag:i-web-1,3\n") { + t.Errorf("l'option 3 nue doit supprimer la route par défaut :\n%s", got) + } + if strings.Contains(got, "0.0.0.0/0") { + t.Errorf("la route par défaut ne doit pas figurer dans l'override :\n%s", got) + } +} + +func TestWriteVMOptions_ReEmitsMetadataRoute(t *testing.T) { + _, vpcNet, _ := net.ParseCIDR("192.168.0.0/16") + got := vmOptions(t, t.TempDir(), []string{"i-web-1"}, vpcNet) + + if !strings.Contains(got, "169.254.169.254/32,10.1.1.1") { + t.Errorf("un override de l'option 121 remplace la précédente en entier : sans la route metadata, la VM ne se provisionne pas\n%s", got) + } + if !strings.Contains(got, "192.168.0.0/16,10.1.1.1") { + t.Errorf("la route VPC doit être réémise elle aussi :\n%s", got) + } +} + +func TestWriteVMOptions_BridgeHasNoVPCRoute(t *testing.T) { + got := vmOptions(t, t.TempDir(), []string{"i-web-1"}, nil) + + if !strings.Contains(got, "169.254.169.254/32") { + t.Errorf("route metadata absente :\n%s", got) + } + if strings.Contains(got, "192.168") { + t.Errorf("aucune route VPC attendue en mode bridge :\n%s", got) + } +} + +func TestWriteVMOptions_OneBlockPerTag(t *testing.T) { + got := vmOptions(t, t.TempDir(), []string{"i-web-1", "i-web-2"}, nil) + + for _, tag := range []string{"tag:i-web-1,3", "tag:i-web-2,3"} { + if !strings.Contains(got, tag) { + t.Errorf("%q absent :\n%s", tag, got) + } + } +} + +func TestWriteVMOptions_NoTagRemovesFile(t *testing.T) { + dir := t.TempDir() + _ = vmOptions(t, dir, []string{"i-web-1"}, nil) + + if err := WriteVMOptions(dir, subName, "i-web", nil, Config{}); err != nil { + t.Fatalf("WriteVMOptions : %v", err) + } + if _, err := os.Stat(filepath.Join(OptsDir(dir, subName), "i-web")); !os.IsNotExist(err) { + t.Error("sans interface non primaire, aucun fichier d'options ne doit subsister") + } +} + +func TestWriteVMOptions_RefusesDefaultGateway(t *testing.T) { + err := WriteVMOptions(t.TempDir(), subName, "i-web", []string{"i-web-1"}, Config{ + InterfaceIP: net.ParseIP("10.1.1.1").To4(), + DefaultGateway: net.ParseIP("10.1.1.254").To4(), + }) + if err == nil { + t.Error("ces options servent à retirer la route par défaut : en porter une est une incohérence") + } +} diff --git a/internal/qemu/config.go b/internal/qemu/config.go index 2177066..ed4f445 100644 --- a/internal/qemu/config.go +++ b/internal/qemu/config.go @@ -9,10 +9,16 @@ type DiskConfig struct { Dev string } +// NICConfig décrit une interface réseau. Sa position dans Config.NICs +// détermine le slot PCI, donc le nom de l'interface dans le guest. +type NICConfig struct { + TapID int + Mac string +} + type Config struct { Name string - TapID int - Mac string + NICs []NICConfig Disks []DiskConfig Memory int CPUs int diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go index fa7bcf3..1a8f918 100644 --- a/internal/qemu/start_linux.go +++ b/internal/qemu/start_linux.go @@ -11,6 +11,12 @@ import ( "strings" ) +const ( + firstNICSlot = 0x03 + lastNICSlot = 0x1d + maxNICs = lastNICSlot - firstNICSlot + 1 +) + func Start(cfg Config) error { memory := cfg.Memory if memory == 0 { @@ -97,11 +103,24 @@ func Start(cfg Config) error { } } - args = append(args, - "-netdev", fmt.Sprintf("tap,id=net0,ifname=tap%d,script=no,downscript=no", cfg.TapID), - "-device", fmt.Sprintf("virtio-net-pci,netdev=net0,mac=%s,bus=pci.0,addr=0x03", cfg.Mac), - "-daemonize", - ) + // Slots 0x03 à 0x1d réservés au réseau par la carte PCI (#36). Le slot est + // dérivé de l'index et non laissé à QEMU : c'est lui qui fixe le nom de + // l'interface dans le guest, et un slot flottant la renomme d'un démarrage + // à l'autre. + if len(cfg.NICs) == 0 { + return fmt.Errorf("vm %s has no network interface", cfg.Name) + } + if len(cfg.NICs) > maxNICs { + return fmt.Errorf("vm %s has %d interfaces, the pci map holds %d", cfg.Name, len(cfg.NICs), maxNICs) + } + for i, n := range cfg.NICs { + args = append(args, + "-netdev", fmt.Sprintf("tap,id=net%d,ifname=tap%d,script=no,downscript=no", i, n.TapID), + "-device", fmt.Sprintf("virtio-net-pci,netdev=net%d,mac=%s,bus=pci.0,addr=0x%02x", i, n.Mac, firstNICSlot+i), + ) + } + + args = append(args, "-daemonize") scopeArgs := append([]string{ "--scope", diff --git a/internal/vm/create.go b/internal/vm/create.go index fad9b6c..c976e9c 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -3,6 +3,7 @@ package vm import ( "fmt" "io" + "net" "os" "path/filepath" @@ -33,19 +34,28 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { } nic := d.primary() - if err := netif.CreateTap(nic.tapID, nic.bridge, nic.vpcName); err != nil { - return fmt.Errorf("create tap: %w", err) + for _, n := range d.nics { + if err := netif.CreateTap(n.tapID, n.bridge, n.vpcName); err != nil { + return fmt.Errorf("create tap of interface %d: %w", n.index, err) + } } + // La redirection est posée pour chaque IP de la VM vers le serveur de + // métadonnées de l'interface primaire. Toutes les interfaces étant dans le + // même VPC, donc le même netns, il est joignable depuis n'importe laquelle. if err := netns.Call(nic.vpcName, func() error { - return iptables.AddMetadataRedirect(nic.ip, nic.interfaceIP, d.metadataPort) + for _, n := range d.nics { + if err := iptables.AddMetadataRedirect(n.ip, nic.interfaceIP, d.metadataPort); err != nil { + return fmt.Errorf("interface %d: %w", n.index, err) + } + } + return nil }); err != nil { return fmt.Errorf("add metadata redirect: %w", err) } - if err := dhcp.WriteReservations(dhcp.DefaultConfDir, nic.vpcName+"_"+nic.bridge, name, - []dhcp.Reservation{{MAC: nic.mac, IP: nic.ip}}); err != nil { - return fmt.Errorf("write dhcp reservation: %w", err) + if err := writeDHCPFiles(d, name); err != nil { + return err } if err := metadata.StartMetadata(metadata.NoCloudConfig{ @@ -65,10 +75,14 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { qDisks[i] = qemu.DiskConfig{Path: disk.path, Dev: disk.dev} } + qNICs := make([]qemu.NICConfig, len(d.nics)) + for i, n := range d.nics { + qNICs[i] = qemu.NICConfig{TapID: n.tapID, Mac: n.mac} + } + qcfg := qemu.Config{ Name: name, - TapID: nic.tapID, - Mac: nic.mac, + NICs: qNICs, Disks: qDisks, Memory: d.memory, CPUs: d.cpus, @@ -95,6 +109,53 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { return state.Set(db, "vm/"+name, state.Running) } +// writeDHCPFiles écrit, pour chaque subnet touché par la VM, les réservations +// de ses interfaces et les options qui suppriment la route par défaut sur les +// interfaces non primaires. Le subnet de l'interface primaire ne reçoit aucune +// option : les options non taggées du subnet portent déjà la route par défaut. +func writeDHCPFiles(d vmData, name string) error { + type subnetFiles struct { + nic nicData + reservations []dhcp.Reservation + tags []string + } + bySubnet := make(map[string]*subnetFiles) + + for _, n := range d.nics { + confName := n.vpcName + "_" + n.bridge + if bySubnet[confName] == nil { + bySubnet[confName] = &subnetFiles{nic: n} + } + f := bySubnet[confName] + f.reservations = append(f.reservations, dhcp.Reservation{ + MAC: n.mac, IP: n.ip, Tag: nicTag(name, n.index), + }) + if !n.primary { + f.tags = append(f.tags, nicTag(name, n.index)) + } + } + + for confName, f := range bySubnet { + if err := dhcp.WriteReservations(dhcp.DefaultConfDir, confName, name, f.reservations); err != nil { + return fmt.Errorf("write dhcp reservations on %s: %w", confName, err) + } + if err := dhcp.WriteVMOptions(dhcp.DefaultConfDir, confName, name, f.tags, dhcp.Config{ + InterfaceIP: net.ParseIP(f.nic.interfaceIP), + VPCRoute: f.nic.vpcCIDR, + }); err != nil { + return fmt.Errorf("write dhcp options on %s: %w", confName, err) + } + } + return nil +} + +// nicTag identifie une interface auprès de dnsmasq. Il est par interface et non +// par VM : deux interfaces d'une même VM peuvent partager un subnet, et n'y +// avoir pas le même rôle. +func nicTag(vmName string, index int) string { + return fmt.Sprintf("%s-%d", vmName, index) +} + func copyFile(src, dst string) error { if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { return err diff --git a/internal/vm/data.go b/internal/vm/data.go index 13a3900..78b88a4 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -3,6 +3,7 @@ package vm import ( "fmt" "math/rand" + "net" "sort" "strconv" "strings" @@ -27,6 +28,8 @@ type nicData struct { mac string tapID int primary bool + mode string + vpcCIDR *net.IPNet } type vmData struct { @@ -190,6 +193,23 @@ func loadNIC(db *badger.DB, name string, idx int, entries map[string]string) (ni } n.interfaceIP = interfaceIP + n.mode, err = kv.GetFromDB(db, "subnet/"+n.subnetName+"/mode") + if err != nil { + return n, fmt.Errorf("get mode of subnet %s: %w", n.subnetName, err) + } + + if n.mode != "bridge" { + cidrStr, err := kv.GetFromDB(db, "vpc/"+n.vpcName+"/cidr") + if err != nil { + return n, fmt.Errorf("get cidr of vpc %s: %w", n.vpcName, err) + } + _, vpcCIDR, err := net.ParseCIDR(cidrStr) + if err != nil { + return n, fmt.Errorf("parse cidr of vpc %s: %w", n.vpcName, err) + } + n.vpcCIDR = vpcCIDR + } + n.ip = entries[prefix+"ip"] if n.ip == "" { return n, fmt.Errorf("nic %d of vm %s has no ip", idx, name) diff --git a/internal/vm/data_test.go b/internal/vm/data_test.go index 74ff76b..be4ffcf 100644 --- a/internal/vm/data_test.go +++ b/internal/vm/data_test.go @@ -18,6 +18,8 @@ func newVMInDB(t *testing.T) *badger.DB { kv.AddInDB(db, "vm/vm-1/nic/0/primary", "true") kv.AddInDB(db, "subnet/sn-000001/vpc", "vp-admin") kv.AddInDB(db, "subnet/sn-000001/interface_ip", "10.1.1.1") + kv.AddInDB(db, "subnet/sn-000001/mode", "vxlan") + kv.AddInDB(db, "vpc/vp-admin/cidr", "192.168.0.0/16") kv.AddInDB(db, "subnet/sn-000001/dhcp/10.1.1.2", "00:22:33:00:01:02") kv.AddInDB(db, "vm/vm-1/nic/0/ip", "10.1.1.2") kv.AddInDB(db, "vm/vm-1/metadata_port", "8081") @@ -104,6 +106,8 @@ func addNIC(t *testing.T, db *badger.DB, idx int, subnet, ip, mac string, primar } kv.AddInDB(db, "subnet/"+subnet+"/vpc", "vp-admin") kv.AddInDB(db, "subnet/"+subnet+"/interface_ip", "10.1.1.1") + kv.AddInDB(db, "subnet/"+subnet+"/mode", "vxlan") + kv.AddInDB(db, "vpc/vp-admin/cidr", "192.168.0.0/16") kv.AddInDB(db, "subnet/"+subnet+"/dhcp/"+ip, mac) } @@ -170,6 +174,7 @@ func TestLoadVM_NoPrimaryIsAnError(t *testing.T) { kv.AddInDB(db, "vm/vm-1/memory", "2048") kv.AddInDB(db, "vm/vm-1/cpus", "2") addNIC(t, db, 0, "sn-000001", "10.1.1.2", "00:22:33:00:01:02", false) + kv.AddInDB(db, "subnet/sn-000001/mode", "vxlan") if _, err := loadVM(db, "vm-1"); err == nil { t.Error("aucune interface primaire : loadVM doit échouer plutôt que de laisser StartVM choisir au hasard") diff --git a/internal/vm/delete.go b/internal/vm/delete.go index d9b376a..96f921c 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -49,7 +49,12 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { // socket absent ou QEMU déjà arrêté : cleanup direct if err := netns.Call(nic.vpcName, func() error { - return iptables.DeleteMetadataRedirect(nic.ip, nic.interfaceIP, d.metadataPort) + for _, n := range d.nics { + if err := iptables.DeleteMetadataRedirect(n.ip, nic.interfaceIP, d.metadataPort); err != nil { + return fmt.Errorf("interface %d: %w", n.index, err) + } + } + return nil }); err != nil { return fmt.Errorf("delete metadata redirect: %w", err) } @@ -58,11 +63,13 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("stop metadata: %w", err) } - if err := netif.DeleteTap(nic.tapID, nic.vpcName); err != nil { - return fmt.Errorf("delete tap: %w", err) + for _, n := range d.nics { + if err := netif.DeleteTap(n.tapID, n.vpcName); err != nil { + return fmt.Errorf("delete tap of interface %d: %w", n.index, err) + } } - if err := removeDHCPReservation(nic, name); err != nil { + if err := removeDHCPFiles(d, name); err != nil { return err } @@ -74,12 +81,26 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return state.Set(db, "vm/"+name, state.Deleted) } -// removeDHCPReservation retire le fichier de réservation puis redémarre dnsmasq : -// un fichier ajouté dans un dhcp-hostsdir est relu à chaud, un fichier retiré ne -// l'est pas (vérifié sur dnsmasq 2.90). -func removeDHCPReservation(nic nicData, name string) error { - confName := nic.vpcName + "_" + nic.bridge +// removeDHCPFiles retire les fichiers de la VM dans chaque subnet qu'elle +// touche, puis redémarre les dnsmasq concernés : un fichier ajouté dans un +// dhcp-hostsdir est relu à chaud, un fichier retiré ne l'est pas (vérifié sur +// dnsmasq 2.90). +func removeDHCPFiles(d vmData, name string) error { + seen := make(map[string]bool) + for _, n := range d.nics { + confName := n.vpcName + "_" + n.bridge + if seen[confName] { + continue + } + seen[confName] = true + if err := removeDHCPReservation(confName, name); err != nil { + return err + } + } + return nil +} +func removeDHCPReservation(confName, name string) error { if err := dhcp.RemoveReservations(dhcp.DefaultConfDir, confName, name); err != nil { return err }