From 1ec1d44c1a5195868f238011bc68d59ffbb4e29c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Tue, 25 Aug 2026 22:37:07 +0200 Subject: [PATCH] f-33: mise en place d'un nouveau system de reservation #33 Signed-off-by: GnomeZworc --- internal/dhcp/dhcp_test.go | 62 ++++++++++----- internal/dhcp/generate.go | 17 ++-- internal/dhcp/reservation.go | 69 ++++++++++++++++ internal/dhcp/reservation_test.go | 126 ++++++++++++++++++++++++++++++ internal/subnet/delete.go | 4 + internal/vm/create.go | 6 ++ internal/vm/delete.go | 39 ++++++++- pkg/systemd/main.go | 7 ++ 8 files changed, 303 insertions(+), 27 deletions(-) create mode 100644 internal/dhcp/reservation.go create mode 100644 internal/dhcp/reservation_test.go diff --git a/internal/dhcp/dhcp_test.go b/internal/dhcp/dhcp_test.go index 2597457..1fd3731 100644 --- a/internal/dhcp/dhcp_test.go +++ b/internal/dhcp/dhcp_test.go @@ -171,32 +171,56 @@ func TestGenerateConfig_ContainsDhcpRange(t *testing.T) { } } -func TestGenerateConfig_OneHostEntryPerIP(t *testing.T) { - // /29 = réseau + broadcast + 6 hôtes → 8 adresses - conf := newConf(t, "10.0.0.0/29") - path, _, _ := GenerateConfig(conf) - content, _ := os.ReadFile(path) +func TestGenerateConfig_OneEntryPerIP(t *testing.T) { + // /29 = 8 adresses. Les entrées ne vont plus dans le fichier dnsmasq mais + // dans la map retournée, que StoreDHCPEntries écrit en base pour GetMACForIP. + _, entries, err := GenerateConfig(newConf(t, "10.0.0.0/29")) + if err != nil { + t.Fatalf("GenerateConfig : %v", err) + } + if len(entries) != 8 { + t.Errorf("attendu 8 entrées ip->mac, obtenu %d", len(entries)) + } +} - lines := strings.Split(string(content), "\n") - count := 0 - for _, l := range lines { - if strings.HasPrefix(l, "dhcp-host=") { - count++ +func TestGenerateConfig_NoPreGeneratedHosts(t *testing.T) { + // Une entrée dhcp-host pré-générée fait rejeter celle du dhcp-hostsdir + // (« duplicate dhcp-host IP address »), sans erreur : la VM reçoit alors + // les options non taggées. Vérifié sur dnsmasq 2.90. + content := confLines(t, newConf(t, "10.0.0.0/29")) + if strings.Contains(content, "dhcp-host=") { + t.Errorf("aucune entrée dhcp-host ne doit être pré-générée :\n%s", content) + } +} + +func TestGenerateConfig_PointsToDirs(t *testing.T) { + conf := newConf(t, "10.0.0.0/29") + content := confLines(t, conf) + + for _, want := range []string{ + "dhcp-hostsdir=" + HostsDir(conf.ConfDir, conf.Name), + "dhcp-optsdir=" + OptsDir(conf.ConfDir, conf.Name), + } { + if !strings.Contains(content, want) { + t.Errorf("%q absent :\n%s", want, content) } } - // /29 contient 8 adresses (0 à 7) - if count != 8 { - t.Errorf("attendu 8 entrées dhcp-host, obtenu %d", count) + for _, dir := range []string{HostsDir(conf.ConfDir, conf.Name), OptsDir(conf.ConfDir, conf.Name)} { + if fi, err := os.Stat(dir); err != nil || !fi.IsDir() { + t.Errorf("répertoire %q non créé : %v", dir, err) + } } } func TestGenerateConfig_MACPrefix(t *testing.T) { - conf := newConf(t, "10.0.0.0/30") // 4 adresses - path, _, _ := GenerateConfig(conf) - content, _ := os.ReadFile(path) - - if !strings.Contains(string(content), "00:22:33:") { - t.Errorf("préfixe MAC 00:22:33: absent :\n%s", content) + _, entries, err := GenerateConfig(newConf(t, "10.0.0.0/30")) + if err != nil { + t.Fatalf("GenerateConfig : %v", err) + } + for ip, mac := range entries { + if !strings.HasPrefix(mac, "00:22:33:") { + t.Errorf("mac de %s sans le préfixe 00:22:33: : %s", ip, mac) + } } } diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go index ebcb914..dc125a5 100644 --- a/internal/dhcp/generate.go +++ b/internal/dhcp/generate.go @@ -25,21 +25,24 @@ func GenerateConfig(c Config) (string, map[string]string, error) { } else { fmt.Fprintf(&sb, "dhcp-option=3\n") } - fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n\n") + fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n") + fmt.Fprintf(&sb, "dhcp-hostsdir=%s\n", HostsDir(c.ConfDir, c.Name)) + fmt.Fprintf(&sb, "dhcp-optsdir=%s\n", OptsDir(c.ConfDir, c.Name)) entries := make(map[string]string) i := 0 for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) { - mac := fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF) - fmt.Fprintf(&sb, "dhcp-host=%s,%s\n", mac, ip) - entries[ip.String()] = mac + entries[ip.String()] = fmt.Sprintf("00:22:33:%02X:%02X:%02X", (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF) i++ } - outPath := filepath.Join(c.ConfDir, c.Name+".conf") - if err := os.MkdirAll(c.ConfDir, 0755); err != nil { - return "", nil, err + for _, dir := range []string{c.ConfDir, HostsDir(c.ConfDir, c.Name), OptsDir(c.ConfDir, c.Name)} { + if err := os.MkdirAll(dir, 0755); err != nil { + return "", nil, fmt.Errorf("create %s: %w", dir, err) + } } + + outPath := filepath.Join(c.ConfDir, c.Name+".conf") return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644) } diff --git a/internal/dhcp/reservation.go b/internal/dhcp/reservation.go new file mode 100644 index 0000000..b17b56c --- /dev/null +++ b/internal/dhcp/reservation.go @@ -0,0 +1,69 @@ +package dhcp + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +type Reservation struct { + MAC string + IP string +} + +func HostsDir(confDir, name string) string { + return filepath.Join(confDir, name+".hosts.d") +} + +func OptsDir(confDir, name string) string { + return filepath.Join(confDir, name+".opts.d") +} + +func UnitName(name string) string { + return "dnsmasq@" + name + ".service" +} + +func WriteReservations(confDir, name, vmName string, res []Reservation) error { + if len(res) == 0 { + return fmt.Errorf("no reservation for vm %q: it would get no address", vmName) + } + + dir := HostsDir(confDir, name) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("create %s: %w", dir, err) + } + + var sb strings.Builder + for _, r := range res { + 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) + } + + 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 RemoveReservations(confDir, name, vmName string) error { + for _, dir := range []string{HostsDir(confDir, name), OptsDir(confDir, name)} { + path := filepath.Join(dir, vmName) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("remove %s: %w", path, err) + } + } + return nil +} + +func RemoveSubnetDirs(confDir, name string) error { + for _, dir := range []string{HostsDir(confDir, name), OptsDir(confDir, name)} { + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("remove %s: %w", dir, err) + } + } + return nil +} diff --git a/internal/dhcp/reservation_test.go b/internal/dhcp/reservation_test.go new file mode 100644 index 0000000..073ac9d --- /dev/null +++ b/internal/dhcp/reservation_test.go @@ -0,0 +1,126 @@ +package dhcp + +import ( + "os" + "path/filepath" + "testing" +) + +const subName = "vp-admin_br-000001" + +func TestWriteReservations_WritesOneLinePerInterface(t *testing.T) { + dir := t.TempDir() + res := []Reservation{ + {MAC: "00:22:33:00:01:02", IP: "10.1.1.2"}, + {MAC: "00:22:33:00:02:07", IP: "10.1.2.7"}, + } + if err := WriteReservations(dir, subName, "i-web", res); err != nil { + t.Fatalf("WriteReservations : %v", err) + } + + content, err := os.ReadFile(filepath.Join(HostsDir(dir, subName), "i-web")) + if err != nil { + t.Fatalf("fichier absent : %v", err) + } + want := "00:22:33:00:01:02,10.1.1.2\n00:22:33:00:02:07,10.1.2.7\n" + if string(content) != want { + t.Errorf("contenu attendu %q, obtenu %q", want, content) + } +} + +func TestWriteReservations_CreatesDir(t *testing.T) { + dir := t.TempDir() + if err := WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}}); err != nil { + t.Fatalf("WriteReservations : %v", err) + } + if fi, err := os.Stat(HostsDir(dir, subName)); err != nil || !fi.IsDir() { + t.Errorf("hosts.d non créé : %v", err) + } +} + +func TestWriteReservations_EmptyIsAnError(t *testing.T) { + if err := WriteReservations(t.TempDir(), subName, "i-web", nil); err == nil { + t.Error("sans réservation la VM n'obtiendrait aucune adresse : il faut échouer, pas écrire un fichier vide") + } +} + +func TestWriteReservations_IncompleteIsAnError(t *testing.T) { + cases := []Reservation{{MAC: "", IP: "10.0.0.1"}, {MAC: "aa:bb", IP: ""}} + for _, r := range cases { + if err := WriteReservations(t.TempDir(), subName, "i-web", []Reservation{r}); err == nil { + t.Errorf("réservation incomplète acceptée : %+v", r) + } + } +} + +func TestWriteReservations_Overwrites(t *testing.T) { + dir := t.TempDir() + _ = WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}}) + if err := WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "bb", IP: "10.0.0.2"}}); err != nil { + t.Fatalf("WriteReservations : %v", err) + } + content, _ := os.ReadFile(filepath.Join(HostsDir(dir, subName), "i-web")) + if string(content) != "bb,10.0.0.2\n" { + t.Errorf("la réécriture doit remplacer, obtenu %q", content) + } +} + +func TestRemoveReservations_RemovesBothFiles(t *testing.T) { + dir := t.TempDir() + _ = WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}}) + if err := os.MkdirAll(OptsDir(dir, subName), 0755); err != nil { + t.Fatal(err) + } + optsFile := filepath.Join(OptsDir(dir, subName), "i-web") + if err := os.WriteFile(optsFile, []byte("tag:i-web,3,10.0.0.1\n"), 0644); err != nil { + t.Fatal(err) + } + + if err := RemoveReservations(dir, subName, "i-web"); err != nil { + t.Fatalf("RemoveReservations : %v", err) + } + for _, p := range []string{filepath.Join(HostsDir(dir, subName), "i-web"), optsFile} { + if _, err := os.Stat(p); !os.IsNotExist(err) { + t.Errorf("%s aurait dû être supprimé", p) + } + } +} + +func TestRemoveReservations_AbsentIsNotAnError(t *testing.T) { + if err := RemoveReservations(t.TempDir(), subName, "jamais-creee"); err != nil { + t.Errorf("supprimer une VM sans réservation ne doit pas échouer : %v", err) + } +} + +func TestRemoveReservations_LeavesOtherVMs(t *testing.T) { + dir := t.TempDir() + _ = WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}}) + _ = WriteReservations(dir, subName, "i-db", []Reservation{{MAC: "bb", IP: "10.0.0.2"}}) + + if err := RemoveReservations(dir, subName, "i-web"); err != nil { + t.Fatalf("RemoveReservations : %v", err) + } + if _, err := os.Stat(filepath.Join(HostsDir(dir, subName), "i-db")); err != nil { + t.Errorf("i-db ne devait pas être touchée : %v", err) + } +} + +func TestRemoveSubnetDirs(t *testing.T) { + dir := t.TempDir() + _ = WriteReservations(dir, subName, "i-web", []Reservation{{MAC: "aa", IP: "10.0.0.1"}}) + + if err := RemoveSubnetDirs(dir, subName); err != nil { + t.Fatalf("RemoveSubnetDirs : %v", err) + } + for _, d := range []string{HostsDir(dir, subName), OptsDir(dir, subName)} { + if _, err := os.Stat(d); !os.IsNotExist(err) { + t.Errorf("%s aurait dû être supprimé", d) + } + } +} + +func TestUnitName(t *testing.T) { + if got := UnitName(subName); got != "dnsmasq@vp-admin_br-000001.service" { + t.Errorf("unit attendue dnsmasq@%s.service, obtenu %s", subName, got) + } +} diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index 7c26a71..7fdf67a 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -68,6 +68,10 @@ func stopDHCP(db *badger.DB, subnetName string, d subnetData) error { return fmt.Errorf("remove dnsmasq config: %w", err) } + if err := dhcp.RemoveSubnetDirs(dhcp.DefaultConfDir, d.vpc+"_"+d.bridge); err != nil { + return fmt.Errorf("remove dnsmasq dirs: %w", err) + } + if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil { return fmt.Errorf("delete dhcp entries: %w", err) } diff --git a/internal/vm/create.go b/internal/vm/create.go index 7ddc04c..a715851 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -7,6 +7,7 @@ import ( "path/filepath" configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/dhcp" "git.g3e.fr/syonad/two/internal/iptables" "git.g3e.fr/syonad/two/internal/metadata" "git.g3e.fr/syonad/two/internal/netif" @@ -41,6 +42,11 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("add metadata redirect: %w", err) } + if err := dhcp.WriteReservations(dhcp.DefaultConfDir, d.vpcName+"_"+d.bridge, name, + []dhcp.Reservation{{MAC: d.mac, IP: d.ip}}); err != nil { + return fmt.Errorf("write dhcp reservation: %w", err) + } + if err := metadata.StartMetadata(metadata.NoCloudConfig{ Name: name, VpcName: d.vpcName, diff --git a/internal/vm/delete.go b/internal/vm/delete.go index f1490f0..b9ab7e0 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -7,12 +7,14 @@ import ( "time" configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/dhcp" "git.g3e.fr/syonad/two/internal/iptables" "git.g3e.fr/syonad/two/internal/metadata" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/internal/qmp" "git.g3e.fr/syonad/two/internal/state" + "git.g3e.fr/syonad/two/pkg/systemd" "github.com/dgraph-io/badger/v4" ) @@ -45,7 +47,6 @@ 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(d.vpcName, func() error { return iptables.DeleteMetadataRedirect(d.ip, d.interfaceIP, d.metadataPort) }); err != nil { @@ -60,6 +61,10 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("delete tap: %w", err) } + if err := removeDHCPReservation(d, name); err != nil { + return err + } + if d.uefi { varsPath := filepath.Join(cfg.QEMU.UEFIVarsDir, name+"-uefi-vars.fd") os.Remove(varsPath) @@ -68,6 +73,38 @@ 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(d vmData, name string) error { + confName := d.vpcName + "_" + d.bridge + + if err := dhcp.RemoveReservations(dhcp.DefaultConfDir, confName, name); err != nil { + return err + } + + svc, err := systemd.New() + if err != nil { + return fmt.Errorf("connect to systemd: %w", err) + } + defer svc.Close() + + unit := dhcp.UnitName(confName) + status, err := svc.Status(unit) + if err != nil || status.ActiveState != "active" { + return nil + } + if err := svc.Restart(unit); err != nil { + return fmt.Errorf("restart %s: %w", unit, err) + } + if status, err := svc.Status(unit); err != nil { + return fmt.Errorf("status %s after restart: %w", unit, err) + } else if status.ActiveState != "active" { + return fmt.Errorf("%s is %s after restart", unit, status.ActiveState) + } + return nil +} + func waitQMPDead(socketPath string, timeout, poll time.Duration) { timer := time.After(timeout) for { diff --git a/pkg/systemd/main.go b/pkg/systemd/main.go index 768294f..f916ca2 100644 --- a/pkg/systemd/main.go +++ b/pkg/systemd/main.go @@ -54,6 +54,11 @@ func (m *Manager) Stop(service string) error { return m.job("StopUnit", service) } +// Restart redémarre un service systemd +func (m *Manager) Restart(service string) error { + return m.job("RestartUnit", service) +} + func (m *Manager) job(method, service string) error { callCtx, callCancel := context.WithTimeout(context.Background(), defaultTimeout) defer callCancel() @@ -66,6 +71,8 @@ func (m *Manager) job(method, service string) error { _, err = m.conn.StartUnitContext(callCtx, service, jobMode, ch) case "StopUnit": _, err = m.conn.StopUnitContext(callCtx, service, jobMode, ch) + case "RestartUnit": + _, err = m.conn.RestartUnitContext(callCtx, service, jobMode, ch) default: return errors.New("unsupported job method") }