From cb812c97ab8de8a7198137efc4a1813e0a0339a3 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 17 Aug 2026 23:38:07 +0200 Subject: [PATCH] f-29: watchdog: add checkvms #29 Signed-off-by: GnomeZworc --- internal/watchdog/check_vm.go | 99 ++++++++++++ internal/watchdog/check_vm_test.go | 246 +++++++++++++++++++++++++++++ pkg/systemd/main.go | 16 +- 3 files changed, 355 insertions(+), 6 deletions(-) create mode 100644 internal/watchdog/check_vm.go create mode 100644 internal/watchdog/check_vm_test.go diff --git a/internal/watchdog/check_vm.go b/internal/watchdog/check_vm.go new file mode 100644 index 0000000..8660eb1 --- /dev/null +++ b/internal/watchdog/check_vm.go @@ -0,0 +1,99 @@ +package watchdog + +import ( + "errors" + "fmt" + "path/filepath" + "strconv" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/netns" + "git.g3e.fr/syonad/two/internal/qemu" + "git.g3e.fr/syonad/two/internal/qmp" + "git.g3e.fr/syonad/two/internal/state" + "git.g3e.fr/syonad/two/internal/watchdog/notify" + "git.g3e.fr/syonad/two/pkg/db/kv" + + "github.com/dgraph-io/badger/v4" +) + +func tapName(tapID int) string { + return fmt.Sprintf("tap%d", tapID) +} + +func CheckVMs(db *badger.DB, cfg *configuration.Config, u unitChecker, n notify.Notifier) error { + if cfg == nil { + return errors.New("watchdog: configuration requise pour vérifier les VMs") + } + + pairs, err := kv.ListByPrefix(db, prefixVM) + if err != nil { + return fmt.Errorf("watchdog: lecture des vm: %w", err) + } + + for _, name := range resourceNames(pairs, prefixVM) { + st, err := state.Get(db, prefixVM+name) + if err != nil { + n.Notify(kindVM, name, fmt.Sprintf("état illisible en base: %v", err)) + continue + } + if st != state.Running { + continue + } + checkVM(db, cfg, name, u, n) + } + return nil +} + +func checkVM(db *badger.DB, cfg *configuration.Config, name string, u unitChecker, n notify.Notifier) { + subnetName, err := kv.GetFromDB(db, prefixVM+name+"/subnet") + if err != nil { + n.Notify(kindVM, name, fmt.Sprintf("subnet illisible en base: %v", err)) + return + } + + vpc, err := kv.GetFromDB(db, prefixSubnet+subnetName+"/vpc") + if err != nil { + n.Notify(kindVM, name, fmt.Sprintf("vpc du subnet %s illisible en base: %v", subnetName, err)) + return + } + + checkVMTap(db, name, vpc, n) + checkVMQemu(cfg, name, n) + checkUnit(kindVM, name, "metadata@"+name+".service", u, n) + checkUnit(kindVM, name, qemu.ScopeName(name), u, n) +} + +func checkVMTap(db *badger.DB, name, vpc string, n notify.Notifier) { + raw, err := kv.GetFromDB(db, prefixVM+name+"/tap_id") + if err != nil { + n.Notify(kindVM, name, fmt.Sprintf("tap_id illisible en base: %v", err)) + return + } + tapID, err := strconv.Atoi(raw) + if err != nil { + n.Notify(kindVM, name, fmt.Sprintf("tap_id invalide %q: %v", raw, err)) + return + } + + if !netns.Exist(vpc) { + n.Notify(kindVM, name, "netns "+vpc+" absent (/var/run/netns/"+vpc+")") + return + } + + if err := netns.Call(vpc, func() error { + if p := linkProblem(tapName(tapID)); p != "" { + n.Notify(kindVM, name, p+" (dans le netns "+vpc+")") + } + return nil + }); err != nil { + n.Notify(kindVM, name, fmt.Sprintf("entrée dans le netns %s impossible: %v", vpc, err)) + } +} + +func checkVMQemu(cfg *configuration.Config, name string, n notify.Notifier) { + sock := filepath.Join(cfg.QEMU.QMPDir, name+".sock") + if _, err := qmp.Send(sock, nil); err != nil { + n.Notify(kindVM, name, fmt.Sprintf("qemu ne répond pas sur %s: %v", sock, err)) + } +} diff --git a/internal/watchdog/check_vm_test.go b/internal/watchdog/check_vm_test.go new file mode 100644 index 0000000..8bf24a4 --- /dev/null +++ b/internal/watchdog/check_vm_test.go @@ -0,0 +1,246 @@ +package watchdog + +import ( + "strings" + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/state" + + "github.com/dgraph-io/badger/v4" +) + +func testCfg(t *testing.T) *configuration.Config { + t.Helper() + cfg := &configuration.Config{} + cfg.QEMU.QMPDir = t.TempDir() + return cfg +} + +func seedVM(t *testing.T, db *badger.DB, name, subnetName, vpc, tapID string) { + t.Helper() + seedResource(t, db, prefixVM, name, state.Running) + seedKV(t, db, prefixVM+name+"/subnet", subnetName) + seedKV(t, db, prefixSubnet+subnetName+"/vpc", vpc) + if tapID != "" { + seedKV(t, db, prefixVM+name+"/tap_id", tapID) + } +} + +func TestTapName(t *testing.T) { + if got := tapName(12345678); got != "tap12345678" { + t.Errorf("tapName = %q, attendu tap12345678", got) + } +} + +func TestCheckVMs_ConfigNil(t *testing.T) { + db := newTestDB(t) + r := &recorder{} + + err := CheckVMs(db, nil, newFakeUnits(), r) + if err == nil { + t.Fatal("une config nil doit produire une erreur, pas une panique") + } + if len(r.calls) != 0 { + t.Errorf("aucune notification attendue, obtenu %v", r.calls) + } +} + +func TestCheckVMs_BaseVide(t *testing.T) { + db := newTestDB(t) + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), newFakeUnits(), r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + if len(r.calls) != 0 { + t.Errorf("aucune notification attendue, obtenu %v", r.calls) + } +} + +func TestCheckVMs_IgnoreLesEtatsNonRunning(t *testing.T) { + db := newTestDB(t) + for _, s := range []state.State{state.Creating, state.Deleting, state.Error, state.Deleted} { + seedResource(t, db, prefixVM, "i-"+string(s), s) + } + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), newFakeUnits(), r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + if len(r.calls) != 0 { + t.Errorf("aucune notification attendue, obtenu %v", r.calls) + } +} + +func TestCheckVMs_SubnetManquantEnBase(t *testing.T) { + db := newTestDB(t) + seedResource(t, db, prefixVM, "i-test1", state.Running) + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), newFakeUnits(), r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + + got := r.forName("i-test1") + if len(got) != 1 { + t.Fatalf("attendu 1 notification, obtenu %d : %v", len(got), got) + } + if !strings.Contains(got[0].problem, "subnet illisible") { + t.Errorf("problem = %q, devrait porter sur le subnet", got[0].problem) + } +} + +func TestCheckVMs_VPCDuSubnetManquant(t *testing.T) { + db := newTestDB(t) + seedResource(t, db, prefixVM, "i-test1", state.Running) + seedKV(t, db, prefixVM+"i-test1/subnet", "br-000042") + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), newFakeUnits(), r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + + if !r.hasProblemContaining("vpc du subnet br-000042 illisible") { + t.Errorf("devrait signaler le vpc introuvable, obtenu %v", r.calls) + } +} + +func TestCheckVMs_TapIDManquant(t *testing.T) { + db := newTestDB(t) + seedVM(t, db, "i-test1", "br-000042", "vp-admin", "") + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), newFakeUnits(), r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + + if !r.hasProblemContaining("tap_id illisible") { + t.Errorf("devrait signaler un tap_id illisible, obtenu %v", r.calls) + } +} + +func TestCheckVMs_TapIDInvalide(t *testing.T) { + db := newTestDB(t) + seedVM(t, db, "i-test1", "br-000042", "vp-admin", "pas-un-nombre") + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), newFakeUnits(), r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + + if !r.hasProblemContaining("tap_id invalide") { + t.Errorf("devrait signaler un tap_id invalide, obtenu %v", r.calls) + } +} + +func TestCheckVMs_QemuNeRepondPas(t *testing.T) { + db := newTestDB(t) + seedVM(t, db, "i-test1", "br-000042", "vp-admin", "12345678") + cfg := testCfg(t) + r := &recorder{} + + if err := CheckVMs(db, cfg, newFakeUnits(), r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + + if !r.hasProblemContaining("qemu ne répond pas") { + t.Errorf("devrait signaler que qemu ne répond pas, obtenu %v", r.calls) + } + if !r.hasProblemContaining("i-test1.sock") { + t.Errorf("devrait nommer la socket attendue, obtenu %v", r.calls) + } +} + +func TestCheckVMs_UnitsInterrogees(t *testing.T) { + db := newTestDB(t) + seedVM(t, db, "i-test1", "br-000042", "vp-admin", "12345678") + u := newFakeUnits().active("metadata@i-test1.service").active("two-vm-i-test1.scope") + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), u, r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + + want := map[string]bool{"metadata@i-test1.service": false, "two-vm-i-test1.scope": false} + for _, asked := range u.asked { + if _, ok := want[asked]; !ok { + t.Errorf("unit inattendue interrogée: %q", asked) + continue + } + want[asked] = true + } + for unit, seen := range want { + if !seen { + t.Errorf("unit %q non interrogée (interrogées: %v)", unit, u.asked) + } + } + if r.hasProblemContaining("unit ") { + t.Errorf("des units actives ne doivent rien signaler, obtenu %v", r.calls) + } +} + +func TestCheckVMs_MetadataInactive(t *testing.T) { + db := newTestDB(t) + seedVM(t, db, "i-test1", "br-000042", "vp-admin", "12345678") + u := newFakeUnits().inactive("metadata@i-test1.service", "dead").active("two-vm-i-test1.scope") + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), u, r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + + if !r.hasProblemContaining("unit metadata@i-test1.service inactive (dead)") { + t.Errorf("devrait signaler metadata inactive, obtenu %v", r.calls) + } +} + +func TestCheckVMs_ScopeAbsent(t *testing.T) { + db := newTestDB(t) + seedVM(t, db, "i-test1", "br-000042", "vp-admin", "12345678") + u := newFakeUnits().active("metadata@i-test1.service") + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), u, r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + + if !r.hasProblemContaining("unit two-vm-i-test1.scope inactive") { + t.Errorf("devrait signaler le scope absent, obtenu %v", r.calls) + } +} + +func TestCheckVMs_EtatCorrompuNInterrompPasLaBoucle(t *testing.T) { + db := newTestDB(t) + seedKV(t, db, prefixVM+"i-corrompu"+stateSuffix, "n_importe_quoi") + seedVM(t, db, "i-test1", "br-000042", "vp-admin", "12345678") + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), newFakeUnits(), r); err != nil { + t.Fatalf("un état corrompu ne doit pas faire échouer CheckVMs: %v", err) + } + + if !strings.Contains(strings.Join(problems(r.forName("i-corrompu")), " "), "état illisible") { + t.Errorf("devrait signaler l'état corrompu, obtenu %v", r.calls) + } + if len(r.forName("i-test1")) == 0 { + t.Error("une clé corrompue ne doit pas empêcher l'examen des VMs suivantes") + } +} + +func TestCheckVMs_ClesDeDisqueNeCreentPasDeFausseVM(t *testing.T) { + db := newTestDB(t) + seedVM(t, db, "i-test1", "br-000042", "vp-admin", "12345678") + seedKV(t, db, prefixVM+"i-test1/disk/vda/state", "attached") + r := &recorder{} + + if err := CheckVMs(db, testCfg(t), newFakeUnits(), r); err != nil { + t.Fatalf("erreur inattendue: %v", err) + } + + for _, c := range r.calls { + if c.name != "i-test1" { + t.Errorf("ressource inattendue signalée: %q", c.name) + } + } +} diff --git a/pkg/systemd/main.go b/pkg/systemd/main.go index e89af6e..768294f 100644 --- a/pkg/systemd/main.go +++ b/pkg/systemd/main.go @@ -99,13 +99,17 @@ func (m *Manager) Status(service string) (*ServiceStatus, error) { return nil, err } - status := &ServiceStatus{ - Name: service, - LoadState: props["LoadState"].(string), - ActiveState: props["ActiveState"].(string), - SubState: props["SubState"].(string), - } + status := &ServiceStatus{Name: service} + if v, ok := props["LoadState"].(string); ok { + status.LoadState = v + } + if v, ok := props["ActiveState"].(string); ok { + status.ActiveState = v + } + if v, ok := props["SubState"].(string); ok { + status.SubState = v + } if pid, ok := props["MainPID"].(uint32); ok { status.MainPID = pid }