From 3edc319969db16bc189f5e44691dd0f19f905c87 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Thu, 13 Aug 2026 23:02:36 +0200 Subject: [PATCH] f-37: add new lib for state handle #37 Signed-off-by: GnomeZworc --- internal/state/state.go | 68 ++++++++++++++ internal/state/state_test.go | 168 +++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 internal/state/state.go create mode 100644 internal/state/state_test.go diff --git a/internal/state/state.go b/internal/state/state.go new file mode 100644 index 0000000..c2fc9f4 --- /dev/null +++ b/internal/state/state.go @@ -0,0 +1,68 @@ +// Package state définit les états possibles d'une ressource (VPC, subnet, VM) +// et centralise leur lecture/écriture en DB. +package state + +import ( + "fmt" + + "git.g3e.fr/syonad/two/pkg/db/kv" + + "github.com/dgraph-io/badger/v4" +) + +type State string + +const ( + Creating State = "creating" + Running State = "running" + Error State = "error" + Deleting State = "deleting" + Deleted State = "deleted" +) + +// All retourne tous les états valides, dans l'ordre du cycle de vie. +func All() []State { + return []State{Creating, Running, Error, Deleting, Deleted} +} + +// Parse convertit une valeur brute lue en DB en State. +func Parse(s string) (State, error) { + for _, valid := range All() { + if State(s) == valid { + return valid, nil + } + } + return "", fmt.Errorf("unknown state %q", s) +} + +// CanDelete indique si une ressource dans cet état peut être supprimée. +// Une ressource en cours de création ou déjà en cours de suppression ne l'est pas ; +// une ressource en erreur l'est, pour permettre le nettoyage d'un échec partiel. +func CanDelete(s State) bool { + return s == Running || s == Error +} + +// IsTransient indique si l'état suppose une commande en cours d'exécution. +// Au démarrage de l'agent, la queue worker est vide : une ressource dans un état +// transitoire est donc orpheline. +func IsTransient(s State) bool { + return s == Creating || s == Deleting +} + +// Get lit l'état d'une ressource. prefix est la racine de la ressource, +// sans le suffixe /state (ex: "vpc/vpc-1"). +func Get(db *badger.DB, prefix string) (State, error) { + raw, err := kv.GetFromDB(db, prefix+"/state") + if err != nil { + return "", err + } + return Parse(raw) +} + +// Set écrit l'état d'une ressource. prefix suit la même convention que Get. +func Set(db *badger.DB, prefix string, s State) error { + if _, err := Parse(string(s)); err != nil { + return err + } + return kv.AddInDB(db, prefix+"/state", string(s)) +} diff --git a/internal/state/state_test.go b/internal/state/state_test.go new file mode 100644 index 0000000..bcc0cf4 --- /dev/null +++ b/internal/state/state_test.go @@ -0,0 +1,168 @@ +package state + +import ( + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" + + "github.com/dgraph-io/badger/v4" +) + +// newTestDB ouvre une base BadgerDB dans un répertoire temporaire. +// La base est fermée automatiquement en fin de test. +func newTestDB(t *testing.T) *badger.DB { + t.Helper() + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + return db +} + +// --- Parse --- + +func TestParse_ValidStates(t *testing.T) { + for _, s := range All() { + got, err := Parse(string(s)) + if err != nil { + t.Errorf("Parse(%q) a échoué : %v", s, err) + } + if got != s { + t.Errorf("Parse(%q) = %q, attendu %q", s, got, s) + } + } +} + +func TestParse_UnknownState(t *testing.T) { + // "created" et "started" sont les anciens états : ils ne doivent plus être + // acceptés une fois la migration passée. + for _, raw := range []string{"created", "started", "stopping", "", "RUNNING"} { + if _, err := Parse(raw); err == nil { + t.Errorf("Parse(%q) devrait échouer", raw) + } + } +} + +// --- All --- + +func TestAll_ContainsEveryState(t *testing.T) { + want := map[State]bool{Creating: false, Running: false, Error: false, Deleting: false, Deleted: false} + for _, s := range All() { + if _, ok := want[s]; !ok { + t.Errorf("All() retourne un état inattendu %q", s) + } + want[s] = true + } + for s, seen := range want { + if !seen { + t.Errorf("All() ne contient pas %q", s) + } + } +} + +// --- CanDelete --- + +func TestCanDelete(t *testing.T) { + cases := map[State]bool{ + Running: true, + Error: true, + Creating: false, + Deleting: false, + Deleted: false, + } + for s, want := range cases { + if got := CanDelete(s); got != want { + t.Errorf("CanDelete(%q) = %v, attendu %v", s, got, want) + } + } +} + +// --- IsTransient --- + +func TestIsTransient(t *testing.T) { + cases := map[State]bool{ + Creating: true, + Deleting: true, + Running: false, + Error: false, + Deleted: false, + } + for s, want := range cases { + if got := IsTransient(s); got != want { + t.Errorf("IsTransient(%q) = %v, attendu %v", s, got, want) + } + } +} + +// --- Get / Set --- + +func TestSetGet_RoundTrip(t *testing.T) { + db := newTestDB(t) + + if err := Set(db, "vpc/vpc-1", Creating); err != nil { + t.Fatalf("Set a échoué : %v", err) + } + got, err := Get(db, "vpc/vpc-1") + if err != nil { + t.Fatalf("Get a échoué : %v", err) + } + if got != Creating { + t.Errorf("Get = %q, attendu %q", got, Creating) + } + + if err := Set(db, "vpc/vpc-1", Running); err != nil { + t.Fatalf("Set a échoué : %v", err) + } + got, err = Get(db, "vpc/vpc-1") + if err != nil { + t.Fatalf("Get a échoué : %v", err) + } + if got != Running { + t.Errorf("Get après écrasement = %q, attendu %q", got, Running) + } +} + +func TestSet_WritesStateSuffix(t *testing.T) { + db := newTestDB(t) + + if err := Set(db, "vm/vm-1", Running); err != nil { + t.Fatalf("Set a échoué : %v", err) + } + raw, err := kv.GetFromDB(db, "vm/vm-1/state") + if err != nil { + t.Fatalf("la clé vm/vm-1/state devrait exister : %v", err) + } + if raw != "running" { + t.Errorf("valeur en DB = %q, attendu \"running\"", raw) + } +} + +func TestSet_InvalidState(t *testing.T) { + db := newTestDB(t) + + if err := Set(db, "vpc/vpc-1", State("created")); err == nil { + t.Error("Set devrait refuser un état inconnu") + } + if _, err := kv.GetFromDB(db, "vpc/vpc-1/state"); err == nil { + t.Error("aucune clé ne devrait être écrite pour un état invalide") + } +} + +func TestGet_MissingKey(t *testing.T) { + db := newTestDB(t) + + if _, err := Get(db, "vpc/inconnu"); err == nil { + t.Error("Get devrait échouer sur une ressource absente") + } +} + +func TestGet_CorruptedValue(t *testing.T) { + db := newTestDB(t) + + // Valeur écrite hors du package (ancien état, corruption) : Get doit + // remonter l'erreur plutôt que de retourner un State silencieusement vide. + if err := kv.AddInDB(db, "subnet/sn-1/state", "created"); err != nil { + t.Fatalf("préparation du test : %v", err) + } + if _, err := Get(db, "subnet/sn-1"); err == nil { + t.Error("Get devrait échouer sur une valeur non reconnue") + } +}