From fe6792be0d4829c25c2755ae28763f946da15bf4 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Fri, 24 Apr 2026 00:10:25 +0200 Subject: [PATCH 01/57] f-21: fix: use of exit become return Signed-off-by: GnomeZworc --- internal/dispatcher/agent/subnet_commands.go | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go index 7bc214d..18f2cd3 100644 --- a/internal/dispatcher/agent/subnet_commands.go +++ b/internal/dispatcher/agent/subnet_commands.go @@ -2,7 +2,6 @@ package dispatcher import ( "fmt" - "os" "strconv" "time" @@ -77,13 +76,13 @@ func (c DeleteSubnetCommand) Prepare(db *badger.DB, _ *configuration.Config) err func (c DeleteSubnetCommand) Execute(db *badger.DB, _ *configuration.Config) error { if err := subnet.DeleteSubnet(db, c.Name); err != nil { - fmt.Println(err) - os.Exit(1) + return err } - if state, err := kv.GetFromDB(db, "subnet/"+c.Name+"/state"); err != nil { - fmt.Println(err) - os.Exit(1) - } else if state == "deleted" { + state, err := kv.GetFromDB(db, "subnet/"+c.Name+"/state") + if err != nil { + return err + } + if state == "deleted" { kv.DeleteInDB(db, "subnet/"+c.Name) } return nil From 921a5ca96e8f48a24c76e8174b2bbc45f306609c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Fri, 24 Apr 2026 00:31:34 +0200 Subject: [PATCH 02/57] f-21: log: add full log info Signed-off-by: GnomeZworc --- cmd/agent/main.go | 20 ++++++++--- conf/agent/config.exemple.yml | 7 ++++ internal/api/agent/server.go | 46 ++++++++++++++++++++----- internal/config/agent/struct.go | 10 ++++-- internal/dispatcher/agent/dispatcher.go | 30 +++++++++++----- pkg/logger/logger.go | 25 ++++++++++++++ 6 files changed, 116 insertions(+), 22 deletions(-) create mode 100644 pkg/logger/logger.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index bb56aba..7b87bde 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -3,13 +3,14 @@ package main import ( "flag" "fmt" - "log" + "log/slog" agentapi "git.g3e.fr/syonad/two/internal/api/agent" configuration "git.g3e.fr/syonad/two/internal/config/agent" dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" agentmetrics "git.g3e.fr/syonad/two/internal/prometheus/agent" "git.g3e.fr/syonad/two/pkg/db/kv" + "git.g3e.fr/syonad/two/pkg/logger" promserver "git.g3e.fr/syonad/two/pkg/prometheus" "git.g3e.fr/syonad/two/pkg/worker" "github.com/prometheus/client_golang/prometheus" @@ -21,9 +22,12 @@ func main() { cfg, err := configuration.LoadConfig(*confFile) if err != nil { - log.Fatalf("failed to load config: %v", err) + slog.Error("failed to load config", "error", err) + return } + log := logger.New(cfg.Logger.Level, cfg.Logger.Debug) + db := kv.InitDB(kv.Config{Path: cfg.Database.Path}, false) defer db.Close() @@ -36,8 +40,16 @@ func main() { apiAddr := fmt.Sprintf("%s:%d", cfg.Api.Address, cfg.Api.Port) promAddr := fmt.Sprintf("%s:%d", cfg.Prometheus.Address, cfg.Prometheus.Port) - d := dispatcher.New(q, db, cfg) - go agentapi.New(d, db).Start(apiAddr) + log.Info("starting agent", + "api", apiAddr, + "prometheus", promAddr, + "workers", cfg.Worker.Count, + "log_level", cfg.Logger.Level, + "debug", cfg.Logger.Debug, + ) + + d := dispatcher.New(q, db, cfg, log.With(slog.String("component", "dispatcher"))) + go agentapi.New(d, db, log.With(slog.String("component", "api"))).Start(apiAddr) go promserver.Start(promAddr, registry) select {} diff --git a/conf/agent/config.exemple.yml b/conf/agent/config.exemple.yml index 0a966dd..fb8e604 100644 --- a/conf/agent/config.exemple.yml +++ b/conf/agent/config.exemple.yml @@ -34,3 +34,10 @@ interfaces: vms: br-000000 internet: br-000000 admin: br-000000 + +# Logging configuration +logger: + # Log level: debug, info, warn, error (default: info) + level: info + # Force debug level regardless of level setting (default: false) + debug: false diff --git a/internal/api/agent/server.go b/internal/api/agent/server.go index 276b416..8e3c4e3 100644 --- a/internal/api/agent/server.go +++ b/internal/api/agent/server.go @@ -1,8 +1,11 @@ package agentapi import ( - "log" + "crypto/rand" + "encoding/hex" + "log/slog" "net/http" + "time" dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" "github.com/dgraph-io/badger/v4" @@ -11,10 +14,11 @@ import ( type Server struct { dispatcher *dispatcher.Dispatcher db *badger.DB + logger *slog.Logger } -func New(d *dispatcher.Dispatcher, db *badger.DB) *Server { - return &Server{dispatcher: d, db: db} +func New(d *dispatcher.Dispatcher, db *badger.DB, logger *slog.Logger) *Server { + return &Server{dispatcher: d, db: db, logger: logger} } func (s *Server) Start(address string) { @@ -23,13 +27,39 @@ func (s *Server) Start(address string) { mux.HandleFunc("/vpcs/", s.VpcByNameHandler) mux.HandleFunc("/subnets", s.SubnetsHandler) mux.HandleFunc("/subnets/", s.SubnetByNameHandler) - log.Printf("API server listening on %s", address) - log.Fatal(http.ListenAndServe(address, logMiddleware(mux))) + s.logger.Info("API server listening", "address", address) + if err := http.ListenAndServe(address, s.logMiddleware(mux)); err != nil { + s.logger.Error("API server stopped", "error", err) + } } -func logMiddleware(next http.Handler) http.Handler { +type statusWriter struct { + http.ResponseWriter + status int +} + +func (sw *statusWriter) WriteHeader(code int) { + sw.status = code + sw.ResponseWriter.WriteHeader(code) +} + +func (s *Server) logMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log.Printf("%s %s %s", r.RemoteAddr, r.Method, r.URL.Path) - next.ServeHTTP(w, r) + var b [4]byte + rand.Read(b[:]) + reqID := hex.EncodeToString(b[:]) + + sw := &statusWriter{ResponseWriter: w, status: http.StatusOK} + start := time.Now() + next.ServeHTTP(sw, r) + + s.logger.Info("request", + "request_id", reqID, + "method", r.Method, + "path", r.URL.Path, + "status", sw.status, + "duration_ms", time.Since(start).Milliseconds(), + "remote", r.RemoteAddr, + ) }) } diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index 8818541..d8e4ee5 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -21,9 +21,13 @@ type Config struct { BufferSize int `mapstructure:"buffer_size"` } `mapstructure:"worker"` Dispatcher struct { - TimeoutSeconds int `mapstructure:"timeout_seconds"` - PollSeconds int `mapstructure:"poll_seconds"` + TimeoutSeconds int `mapstructure:"timeout_seconds"` + PollSeconds int `mapstructure:"poll_seconds"` } `mapstructure:"dispatcher"` + Logger struct { + Level string `mapstructure:"level"` + Debug bool `mapstructure:"debug"` + } `mapstructure:"logger"` DefaultInterface string `mapstructure:"default_interface"` Interfaces map[string]string `mapstructure:"interfaces"` } @@ -43,6 +47,8 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("dispatcher.timeout_seconds", 300) v.SetDefault("dispatcher.poll_seconds", 2) v.SetDefault("default_interface", "br-000000") + v.SetDefault("logger.level", "info") + v.SetDefault("logger.debug", false) v.ReadInConfig() diff --git a/internal/dispatcher/agent/dispatcher.go b/internal/dispatcher/agent/dispatcher.go index 6b1ca67..2897c8a 100644 --- a/internal/dispatcher/agent/dispatcher.go +++ b/internal/dispatcher/agent/dispatcher.go @@ -1,7 +1,9 @@ package dispatcher import ( - "log" + "fmt" + "log/slog" + "time" configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/pkg/worker" @@ -14,23 +16,35 @@ type Command interface { } type Dispatcher struct { - queue *worker.Queue - db *badger.DB - cfg *configuration.Config + queue *worker.Queue + db *badger.DB + cfg *configuration.Config + logger *slog.Logger } -func New(queue *worker.Queue, db *badger.DB, cfg *configuration.Config) *Dispatcher { - return &Dispatcher{queue: queue, db: db, cfg: cfg} +func New(queue *worker.Queue, db *badger.DB, cfg *configuration.Config, logger *slog.Logger) *Dispatcher { + return &Dispatcher{queue: queue, db: db, cfg: cfg, logger: logger} } func (d *Dispatcher) Prepare(cmd Command) error { + d.logger.Debug("prepare", "command", fmt.Sprintf("%T", cmd)) return cmd.Prepare(d.db, d.cfg) } func (d *Dispatcher) Dispatch(cmd Command) { + cmdType := fmt.Sprintf("%T", cmd) + d.logger.Debug("dispatch", "command", cmdType) d.queue.Submit(func() { - if err := cmd.Execute(d.db, d.cfg); err != nil { - log.Printf("command error (%T): %v", cmd, err) + start := time.Now() + err := cmd.Execute(d.db, d.cfg) + attrs := []any{ + "command", cmdType, + "duration_ms", time.Since(start).Milliseconds(), + } + if err != nil { + d.logger.Error("command failed", append(attrs, "error", err)...) + } else { + d.logger.Info("command done", attrs...) } }) } diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go new file mode 100644 index 0000000..0d6fa47 --- /dev/null +++ b/pkg/logger/logger.go @@ -0,0 +1,25 @@ +package logger + +import ( + "log/slog" + "os" +) + +var Level = new(slog.LevelVar) + +func New(level string, debug bool) *slog.Logger { + switch level { + case "debug": + Level.Set(slog.LevelDebug) + case "warn": + Level.Set(slog.LevelWarn) + case "error": + Level.Set(slog.LevelError) + default: + Level.Set(slog.LevelInfo) + } + if debug { + Level.Set(slog.LevelDebug) + } + return slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: Level})) +} From 6b104c4784b49219f68fc9af8ee613cb180d1eaf Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 14:52:14 +0200 Subject: [PATCH 03/57] f-21: debug: use err correct returne for kv db Signed-off-by: GnomeZworc --- internal/api/agent/subnet.go | 7 ++++++- internal/api/agent/subnets.go | 7 ++++++- internal/api/agent/vpc.go | 7 ++++++- internal/api/agent/vpcs.go | 7 ++++++- pkg/db/kv/deleteInDB.go | 4 +--- 5 files changed, 25 insertions(+), 7 deletions(-) diff --git a/internal/api/agent/subnet.go b/internal/api/agent/subnet.go index 556b7c3..31eddc8 100644 --- a/internal/api/agent/subnet.go +++ b/internal/api/agent/subnet.go @@ -69,7 +69,12 @@ func (s *Server) deleteSubnet(w http.ResponseWriter, _ *http.Request, name strin return } s.dispatcher.Dispatch(cmd) - state, _ := kv.GetFromDB(s.db, "subnet/"+name+"/state") + state, err := kv.GetFromDB(s.db, "subnet/"+name+"/state") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read subnet state"}) + return + } w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(Subnet{Name: name, State: state}) } diff --git a/internal/api/agent/subnets.go b/internal/api/agent/subnets.go index 9e99a45..b0fb2fb 100644 --- a/internal/api/agent/subnets.go +++ b/internal/api/agent/subnets.go @@ -88,7 +88,12 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { return } s.dispatcher.Dispatch(cmd) - entries, _ := kv.ListByPrefix(s.db, "subnet/"+req.Name+"/") + entries, err := kv.ListByPrefix(s.db, "subnet/"+req.Name+"/") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read subnet state"}) + return + } sub := Subnet{Name: req.Name} for key, value := range entries { parts := strings.Split(key, "/") diff --git a/internal/api/agent/vpc.go b/internal/api/agent/vpc.go index be0724e..df2d47c 100644 --- a/internal/api/agent/vpc.go +++ b/internal/api/agent/vpc.go @@ -47,7 +47,12 @@ func (s *Server) deleteVpc(w http.ResponseWriter, _ *http.Request, name string) return } s.dispatcher.Dispatch(cmd) - state, _ := kv.GetFromDB(s.db, "vpc/"+name+"/state") + state, err := kv.GetFromDB(s.db, "vpc/"+name+"/state") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vpc state"}) + return + } w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(VPC{Name: name, State: state}) } diff --git a/internal/api/agent/vpcs.go b/internal/api/agent/vpcs.go index 3087456..b062d1c 100644 --- a/internal/api/agent/vpcs.go +++ b/internal/api/agent/vpcs.go @@ -69,7 +69,12 @@ func (s *Server) postVpc(w http.ResponseWriter, r *http.Request) { return } s.dispatcher.Dispatch(cmd) - state, _ := kv.GetFromDB(s.db, "vpc/"+req.Name+"/state") + state, err := kv.GetFromDB(s.db, "vpc/"+req.Name+"/state") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vpc state"}) + return + } w.WriteHeader(http.StatusAccepted) json.NewEncoder(w).Encode(VPC{Name: req.Name, State: state}) } diff --git a/pkg/db/kv/deleteInDB.go b/pkg/db/kv/deleteInDB.go index 1943c81..e1335de 100644 --- a/pkg/db/kv/deleteInDB.go +++ b/pkg/db/kv/deleteInDB.go @@ -1,8 +1,6 @@ package kv import ( - "log" - "github.com/dgraph-io/badger/v4" ) @@ -35,7 +33,7 @@ func DeleteInDB(db *badger.DB, key string) error { return nil }) if err != nil { - log.Fatal(err) + return err } return deleteKey(db, key) From 71aaaacf7ba61fa1c84ce887b7d411be82723d9b Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 14:56:17 +0200 Subject: [PATCH 04/57] f-21: debug: make mtu parametrable in full netif Signed-off-by: GnomeZworc --- internal/netif/vxlan.go | 3 ++- internal/subnet/create.go | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/netif/vxlan.go b/internal/netif/vxlan.go index 6523f37..70740bc 100644 --- a/internal/netif/vxlan.go +++ b/internal/netif/vxlan.go @@ -4,7 +4,7 @@ import ( "github.com/vishvananda/netlink" ) -func CreateVxlan(name string, vxlanID int, localIface string) error { +func CreateVxlan(name string, vxlanID int, localIface string, mtu int) error { link, err := netlink.LinkByName(localIface) if err != nil { return err @@ -12,6 +12,7 @@ func CreateVxlan(name string, vxlanID int, localIface string) error { vxlan := &netlink.Vxlan{ LinkAttrs: netlink.LinkAttrs{ Name: name, + MTU: mtu, }, VxlanId: vxlanID, Port: 4789, diff --git a/internal/subnet/create.go b/internal/subnet/create.go index 1ed514e..d376d7f 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -86,7 +86,7 @@ func CreateSubnet(db *badger.DB, subnetName string) error { } // vxlan - if err := netif.CreateVxlan(vxlanIface, vxlanID, localIface); err != nil { + if err := netif.CreateVxlan(vxlanIface, vxlanID, localIface, 1500); err != nil { return fmt.Errorf("create vxlan: %w", err) } From 1791196c87f7dae5a9d84e0c648c83c1c366cb90 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 15:04:24 +0200 Subject: [PATCH 05/57] f-21: lib: add ebatable wrapper Signed-off-by: GnomeZworc --- internal/ebtables/ebtables.go | 58 +++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 internal/ebtables/ebtables.go diff --git a/internal/ebtables/ebtables.go b/internal/ebtables/ebtables.go new file mode 100644 index 0000000..31e276b --- /dev/null +++ b/internal/ebtables/ebtables.go @@ -0,0 +1,58 @@ +package ebtables + +import ( + "fmt" + "os/exec" +) + +func addRule(args ...string) error { + return exec.Command("ebtables", append([]string{"-A"}, args...)...).Run() +} + +func deleteRule(args ...string) error { + return exec.Command("ebtables", append([]string{"-D"}, args...)...).Run() +} + +func DropARPToGateway(bridge, gatewayIP string) error { + if err := addRule("FORWARD", + "--out-interface", bridge, + "-p", "arp", + "--arp-op", "Request", + "--arp-ip-dst", gatewayIP, + "-j", "DROP"); err != nil { + return fmt.Errorf("ebtables arp rule: %w", err) + } + return nil +} + +func DropDHCP(bridge string) error { + if err := addRule("FORWARD", + "--out-interface", bridge, + "-p", "IPv4", + "--ip-protocol", "udp", + "--ip-source-port", "67:68", + "--ip-destination-port", "67:68", + "-j", "DROP"); err != nil { + return fmt.Errorf("ebtables dhcp rule: %w", err) + } + return nil +} + +func DeleteARPToGateway(bridge, gatewayIP string) error { + return deleteRule("FORWARD", + "--out-interface", bridge, + "-p", "arp", + "--arp-op", "Request", + "--arp-ip-dst", gatewayIP, + "-j", "DROP") +} + +func DeleteDHCP(bridge string) error { + return deleteRule("FORWARD", + "--out-interface", bridge, + "-p", "IPv4", + "--ip-protocol", "udp", + "--ip-source-port", "67:68", + "--ip-destination-port", "67:68", + "-j", "DROP") +} From 396f2842e5f805646cdf5a777c609737cb85e4f7 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 15:04:45 +0200 Subject: [PATCH 06/57] f-21: code: use ebtable parse Signed-off-by: GnomeZworc --- internal/subnet/create.go | 24 +++++------------------- internal/subnet/delete.go | 26 ++++++++++++-------------- 2 files changed, 17 insertions(+), 33 deletions(-) diff --git a/internal/subnet/create.go b/internal/subnet/create.go index d376d7f..496c257 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -3,11 +3,11 @@ package subnet import ( "fmt" "net" - "os/exec" "strconv" "strings" "git.g3e.fr/syonad/two/internal/dhcp" + "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/pkg/db/kv" @@ -136,25 +136,11 @@ func CreateSubnet(db *badger.DB, subnetName string) error { return fmt.Errorf("add route in netns: %w", err) } - // ebtables : drop ARP Request vers la gateway sur ce bridge - if err := exec.Command("ebtables", "-A", "FORWARD", - "--out-interface", bridge, - "-p", "arp", - "--arp-op", "Request", - "--arp-ip-dst", gatewayIP.String(), - "-j", "DROP").Run(); err != nil { - return fmt.Errorf("ebtables arp rule: %w", err) + if err := ebtables.DropARPToGateway(bridge, gatewayIP.String()); err != nil { + return err } - - // ebtables : drop trafic DHCP sur ce bridge - if err := exec.Command("ebtables", "-A", "FORWARD", - "--out-interface", bridge, - "-p", "IPv4", - "--ip-protocol", "udp", - "--ip-source-port", "67:68", - "--ip-destination-port", "67:68", - "-j", "DROP").Run(); err != nil { - return fmt.Errorf("ebtables dhcp rule: %w", err) + if err := ebtables.DropDHCP(bridge); err != nil { + return err } // génération de la config dnsmasq et démarrage du service diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index 650aa6f..8ce91ff 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -3,9 +3,9 @@ package subnet import ( "fmt" "os" - "os/exec" "strings" + "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" "git.g3e.fr/syonad/two/internal/netns" "git.g3e.fr/syonad/two/pkg/db/kv" @@ -33,6 +33,11 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { return fmt.Errorf("get vxlan_id: %w", err) } + gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") + if err != nil { + return fmt.Errorf("get gateway_ip: %w", err) + } + subnetID := strings.SplitN(subnetName, "-", 2)[1] bridge := "br-" + subnetID vxlanIface := "vxlan-" + vxlanIDStr @@ -55,19 +60,12 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { } // suppression des règles ebtables - exec.Command("ebtables", "-D", "FORWARD", - "--out-interface", bridge, - "-p", "arp", - "--arp-op", "Request", - "-j", "DROP").Run() - - exec.Command("ebtables", "-D", "FORWARD", - "--out-interface", bridge, - "-p", "IPv4", - "--ip-protocol", "udp", - "--ip-source-port", "67:68", - "--ip-destination-port", "67:68", - "-j", "DROP").Run() + if err := ebtables.DeleteARPToGateway(bridge, gatewayIP); err != nil { + return fmt.Errorf("delete ebtables arp rule: %w", err) + } + if err := ebtables.DeleteDHCP(bridge); err != nil { + return fmt.Errorf("delete ebtables dhcp rule: %w", err) + } // suppression du bridge dans le netns VPC if err := netns.Call(vpcName, func() error { From b420217f2b91cd87de8e788ebcc2a75ae3193cb8 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 15:12:34 +0200 Subject: [PATCH 07/57] f-21: test: increase coverage test for db Signed-off-by: GnomeZworc --- pkg/db/kv/kv_test.go | 66 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/pkg/db/kv/kv_test.go b/pkg/db/kv/kv_test.go index de4fd68..b45422b 100644 --- a/pkg/db/kv/kv_test.go +++ b/pkg/db/kv/kv_test.go @@ -151,3 +151,69 @@ func TestDeleteInDB_MissingKey(t *testing.T) { t.Logf("DeleteInDB clé inexistante retourne : %v (non bloquant)", err) } } + +// --- ListByPrefix --- + +func TestListByPrefix_MatchingKeys(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "subnet/sn1/state", "created") + AddInDB(db, "subnet/sn1/vpc", "vpc-1") + AddInDB(db, "subnet/sn1/cidr", "10.0.0.0/24") + + entries, err := ListByPrefix(db, "subnet/sn1/") + if err != nil { + t.Fatalf("ListByPrefix a échoué : %v", err) + } + if len(entries) != 3 { + t.Fatalf("attendu 3 entrées, obtenu %d", len(entries)) + } + if entries["subnet/sn1/state"] != "created" { + t.Errorf("valeur inattendue pour state : %q", entries["subnet/sn1/state"]) + } + if entries["subnet/sn1/vpc"] != "vpc-1" { + t.Errorf("valeur inattendue pour vpc : %q", entries["subnet/sn1/vpc"]) + } +} + +func TestListByPrefix_NoMatch(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "vpc/v1/state", "created") + + entries, err := ListByPrefix(db, "subnet/") + if err != nil { + t.Fatalf("ListByPrefix a échoué : %v", err) + } + if len(entries) != 0 { + t.Errorf("attendu 0 entrées, obtenu %d", len(entries)) + } +} + +func TestListByPrefix_IsolatesPrefix(t *testing.T) { + db := newTestDB(t) + AddInDB(db, "subnet/sn1/state", "created") + AddInDB(db, "subnet/sn2/state", "creating") + AddInDB(db, "vpc/v1/state", "created") + + entries, err := ListByPrefix(db, "subnet/sn1/") + if err != nil { + t.Fatalf("ListByPrefix a échoué : %v", err) + } + if len(entries) != 1 { + t.Errorf("attendu 1 entrée, obtenu %d : %v", len(entries), entries) + } + if _, ok := entries["subnet/sn1/state"]; !ok { + t.Error("subnet/sn1/state devrait être présent") + } +} + +func TestListByPrefix_EmptyDB(t *testing.T) { + db := newTestDB(t) + + entries, err := ListByPrefix(db, "subnet/") + if err != nil { + t.Fatalf("ListByPrefix a échoué : %v", err) + } + if len(entries) != 0 { + t.Errorf("attendu 0 entrées sur DB vide, obtenu %d", len(entries)) + } +} From 9950e0e24af0e99f94b5addbfe52ee529a12d59c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 15:32:46 +0200 Subject: [PATCH 08/57] f-21: test: add test for agent api Signed-off-by: GnomeZworc --- internal/api/agent/helpers_test.go | 27 ++++ internal/api/agent/subnet_test.go | 234 +++++++++++++++++++++++++++++ internal/api/agent/vpc_test.go | 188 +++++++++++++++++++++++ 3 files changed, 449 insertions(+) create mode 100644 internal/api/agent/helpers_test.go create mode 100644 internal/api/agent/subnet_test.go create mode 100644 internal/api/agent/vpc_test.go diff --git a/internal/api/agent/helpers_test.go b/internal/api/agent/helpers_test.go new file mode 100644 index 0000000..206874c --- /dev/null +++ b/internal/api/agent/helpers_test.go @@ -0,0 +1,27 @@ +package agentapi + +import ( + "io" + "log/slog" + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" + "git.g3e.fr/syonad/two/pkg/db/kv" + "git.g3e.fr/syonad/two/pkg/worker" + "github.com/dgraph-io/badger/v4" +) + +// newTestServer builds a Server backed by an in-memory Badger DB. +// The worker queue is buffered but has no running goroutines: Dispatch enqueues +// without blocking and Execute never runs, so DB state reflects only Prepare writes. +func newTestServer(t *testing.T) (*Server, *badger.DB) { + t.Helper() + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + q := worker.New(100) + cfg := &configuration.Config{DefaultInterface: "br-test"} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + d := dispatcher.New(q, db, cfg, logger) + return New(d, db, logger), db +} diff --git a/internal/api/agent/subnet_test.go b/internal/api/agent/subnet_test.go new file mode 100644 index 0000000..0fdd18b --- /dev/null +++ b/internal/api/agent/subnet_test.go @@ -0,0 +1,234 @@ +package agentapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- SubnetsHandler --- + +func TestListSubnets_Empty(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodGet, "/subnets", nil)) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result []Subnet + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 0 { + t.Errorf("attendu liste vide, obtenu %v", result) + } +} + +func TestListSubnets_WithData(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + kv.AddInDB(db, "subnet/sn-2/state", "creating") + kv.AddInDB(db, "subnet/sn-2/vpc", "vpc-1") + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodGet, "/subnets", nil)) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result []Subnet + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 2 { + t.Errorf("attendu 2 subnets, obtenu %d", len(result)) + } +} + +func TestListSubnets_InvalidMethod(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPut, "/subnets", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("attendu 405, obtenu %d", w.Code) + } +} + +func TestPostSubnet_Created(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + req := SubnetCreateRequest{ + Name: "sn-new", + VPC: "vpc-1", + IfaceType: "vms", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } + var result Subnet + json.NewDecoder(w.Body).Decode(&result) + if result.Name != "sn-new" { + t.Errorf("name attendu sn-new, obtenu %q", result.Name) + } + if result.State != "creating" { + t.Errorf("state attendu creating, obtenu %q", result.State) + } +} + +func TestPostSubnet_MissingFields(t *testing.T) { + s, _ := newTestServer(t) + body, _ := json.Marshal(SubnetCreateRequest{Name: "sn-1"}) // vpc, iface_type, gateway_ip, cidr manquants + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +func TestPostSubnet_VPCNotFound(t *testing.T) { + s, _ := newTestServer(t) + req := SubnetCreateRequest{ + Name: "sn-1", + VPC: "vpc-inexistant", + IfaceType: "vms", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusConflict { + t.Errorf("attendu 409, obtenu %d", w.Code) + } +} + +func TestPostSubnet_Duplicate(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + kv.AddInDB(db, "subnet/sn-exist/state", "created") + req := SubnetCreateRequest{ + Name: "sn-exist", + VPC: "vpc-1", + IfaceType: "vms", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusConflict { + t.Errorf("attendu 409, obtenu %d", w.Code) + } +} + +func TestPostSubnet_VPCDeleting(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-dying/state", "deleting") + req := SubnetCreateRequest{ + Name: "sn-1", + VPC: "vpc-dying", + IfaceType: "vms", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusConflict { + t.Errorf("attendu 409, obtenu %d", w.Code) + } +} + +func TestPostSubnet_InvalidBody(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader([]byte("not json")))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +// --- SubnetByNameHandler --- + +func TestGetSubnet_Found(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + kv.AddInDB(db, "subnet/sn-1/cidr", "10.0.0.0/24") + kv.AddInDB(db, "subnet/sn-1/gateway_ip", "10.0.0.1") + req := httptest.NewRequest(http.MethodGet, "/subnets/sn-1", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result Subnet + json.NewDecoder(w.Body).Decode(&result) + if result.Name != "sn-1" || result.State != "created" { + t.Errorf("résultat inattendu : %+v", result) + } + if result.VPC != "vpc-1" { + t.Errorf("vpc attendu vpc-1, obtenu %q", result.VPC) + } +} + +func TestGetSubnet_NotFound(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/subnets/inexistant", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestGetSubnet_EmptyName(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/subnets/", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestDeleteSubnet_Success(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-del/state", "created") + req := httptest.NewRequest(http.MethodDelete, "/subnets/sn-del", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } + var result Subnet + json.NewDecoder(w.Body).Decode(&result) + if result.State != "deleting" { + t.Errorf("state attendu deleting, obtenu %q", result.State) + } +} + +func TestDeleteSubnet_NotFound(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodDelete, "/subnets/inexistant", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestSubnetByName_InvalidMethod(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + req := httptest.NewRequest(http.MethodPut, "/subnets/sn-1", nil) + w := httptest.NewRecorder() + s.SubnetByNameHandler(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("attendu 405, obtenu %d", w.Code) + } +} diff --git a/internal/api/agent/vpc_test.go b/internal/api/agent/vpc_test.go new file mode 100644 index 0000000..0edcd5b --- /dev/null +++ b/internal/api/agent/vpc_test.go @@ -0,0 +1,188 @@ +package agentapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- VpcsHandler --- + +func TestListVpcs_Empty(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodGet, "/vpcs", nil)) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result []VPC + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 0 { + t.Errorf("attendu liste vide, obtenu %v", result) + } +} + +func TestListVpcs_WithData(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/v1/state", "created") + kv.AddInDB(db, "vpc/v2/state", "creating") + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodGet, "/vpcs", nil)) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result []VPC + json.NewDecoder(w.Body).Decode(&result) + if len(result) != 2 { + t.Errorf("attendu 2 VPCs, obtenu %d", len(result)) + } +} + +func TestListVpcs_InvalidMethod(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPut, "/vpcs", nil)) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("attendu 405, obtenu %d", w.Code) + } +} + +func TestPostVpc_Created(t *testing.T) { + s, _ := newTestServer(t) + body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-new"}) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } + var result VPC + json.NewDecoder(w.Body).Decode(&result) + if result.Name != "vpc-new" { + t.Errorf("name attendu vpc-new, obtenu %q", result.Name) + } + if result.State != "creating" { + t.Errorf("state attendu creating, obtenu %q", result.State) + } +} + +func TestPostVpc_MissingName(t *testing.T) { + s, _ := newTestServer(t) + body, _ := json.Marshal(VPCCreateRequest{}) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +func TestPostVpc_Duplicate(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-exist/state", "created") + body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-exist"}) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) + if w.Code != http.StatusConflict { + t.Errorf("attendu 409, obtenu %d", w.Code) + } +} + +func TestPostVpc_InvalidBody(t *testing.T) { + s, _ := newTestServer(t) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader([]byte("not json")))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +// --- VpcByNameHandler --- + +func TestGetVpc_Found(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + req := httptest.NewRequest(http.MethodGet, "/vpcs/vpc-1", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusOK { + t.Fatalf("attendu 200, obtenu %d", w.Code) + } + var result VPC + json.NewDecoder(w.Body).Decode(&result) + if result.Name != "vpc-1" || result.State != "created" { + t.Errorf("résultat inattendu : %+v", result) + } +} + +func TestGetVpc_NotFound(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/vpcs/inexistant", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestGetVpc_EmptyName(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodGet, "/vpcs/", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestDeleteVpc_Success(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-del/state", "created") + req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-del", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } + var result VPC + json.NewDecoder(w.Body).Decode(&result) + if result.State != "deleting" { + t.Errorf("state attendu deleting, obtenu %q", result.State) + } +} + +func TestDeleteVpc_NotFound(t *testing.T) { + s, _ := newTestServer(t) + req := httptest.NewRequest(http.MethodDelete, "/vpcs/inexistant", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestDeleteVpc_BlockedByActiveSubnet(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-busy/state", "created") + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-busy") + req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-busy", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404 (Prepare échoue), obtenu %d: %s", w.Code, w.Body.String()) + } +} + +func TestVpcByName_InvalidMethod(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + req := httptest.NewRequest(http.MethodPut, "/vpcs/vpc-1", nil) + w := httptest.NewRecorder() + s.VpcByNameHandler(w, req) + if w.Code != http.StatusMethodNotAllowed { + t.Errorf("attendu 405, obtenu %d", w.Code) + } +} From 712692d414218666f02b3a1dacfa3e58b468babf Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 15:34:40 +0200 Subject: [PATCH 09/57] f-21: test: add test for worker pkg Signed-off-by: GnomeZworc --- pkg/worker/queue_test.go | 103 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 pkg/worker/queue_test.go diff --git a/pkg/worker/queue_test.go b/pkg/worker/queue_test.go new file mode 100644 index 0000000..5353b06 --- /dev/null +++ b/pkg/worker/queue_test.go @@ -0,0 +1,103 @@ +package worker + +import ( + "sync" + "sync/atomic" + "testing" + "time" +) + +func TestNew_ReturnsQueue(t *testing.T) { + q := New(10) + if q == nil { + t.Fatal("New devrait retourner une queue non-nil") + } +} + +func TestQueue_SingleTaskExecuted(t *testing.T) { + q := New(1) + q.Start(1) + + var done atomic.Bool + var wg sync.WaitGroup + wg.Add(1) + q.Submit(func() { + done.Store(true) + wg.Done() + }) + + wg.Wait() + if !done.Load() { + t.Error("la tâche n'a pas été exécutée") + } +} + +func TestQueue_AllTasksExecuted(t *testing.T) { + const n = 50 + q := New(n) + q.Start(1) + + var count atomic.Int32 + var wg sync.WaitGroup + wg.Add(n) + for range n { + q.Submit(func() { + count.Add(1) + wg.Done() + }) + } + + wg.Wait() + if count.Load() != n { + t.Errorf("attendu %d exécutions, obtenu %d", n, count.Load()) + } +} + +func TestQueue_MultipleWorkers(t *testing.T) { + const n = 100 + q := New(n) + q.Start(4) + + var count atomic.Int32 + var wg sync.WaitGroup + wg.Add(n) + for range n { + q.Submit(func() { + count.Add(1) + wg.Done() + }) + } + + wg.Wait() + if count.Load() != n { + t.Errorf("attendu %d exécutions, obtenu %d", n, count.Load()) + } +} + +func TestQueue_SubmitBlocksWhenFull(t *testing.T) { + q := New(1) + // Remplit le buffer sans worker + q.Submit(func() {}) + + submitted := make(chan struct{}) + go func() { + q.Submit(func() {}) // doit bloquer jusqu'à ce qu'un worker consomme + close(submitted) + }() + + select { + case <-submitted: + t.Error("Submit aurait dû bloquer sur une queue pleine") + case <-time.After(50 * time.Millisecond): + // comportement attendu : goroutine bloquée + } + + // Démarre un worker pour débloquer + q.Start(1) + select { + case <-submitted: + // Submit a pu avancer + case <-time.After(time.Second): + t.Error("Submit aurait dû se débloquer après démarrage d'un worker") + } +} From ba2f8080be4b669b5e76c36ced2a11f096959a2b Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 15:39:00 +0200 Subject: [PATCH 10/57] f-21: test: add test for agent dispatcher Signed-off-by: GnomeZworc --- internal/dispatcher/agent/dispatcher_test.go | 63 ++++++++ internal/dispatcher/agent/helpers_test.go | 38 +++++ .../dispatcher/agent/subnet_commands_test.go | 135 ++++++++++++++++++ .../dispatcher/agent/vpc_commands_test.go | 89 ++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 internal/dispatcher/agent/dispatcher_test.go create mode 100644 internal/dispatcher/agent/helpers_test.go create mode 100644 internal/dispatcher/agent/subnet_commands_test.go create mode 100644 internal/dispatcher/agent/vpc_commands_test.go diff --git a/internal/dispatcher/agent/dispatcher_test.go b/internal/dispatcher/agent/dispatcher_test.go new file mode 100644 index 0000000..b4881d9 --- /dev/null +++ b/internal/dispatcher/agent/dispatcher_test.go @@ -0,0 +1,63 @@ +package dispatcher + +import ( + "errors" + "sync" + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "github.com/dgraph-io/badger/v4" +) + +func TestDispatcher_Prepare_Success(t *testing.T) { + d, _ := newTestDispatcher(t) + cmd := mockCmd{ + prepareFn: func(*badger.DB, *configuration.Config) error { return nil }, + executeFn: func(*badger.DB, *configuration.Config) error { return nil }, + } + if err := d.Prepare(cmd); err != nil { + t.Errorf("Prepare devrait retourner nil, obtenu : %v", err) + } +} + +func TestDispatcher_Prepare_PropagatesError(t *testing.T) { + d, _ := newTestDispatcher(t) + want := errors.New("prepare failed") + cmd := mockCmd{ + prepareFn: func(*badger.DB, *configuration.Config) error { return want }, + executeFn: func(*badger.DB, *configuration.Config) error { return nil }, + } + if err := d.Prepare(cmd); !errors.Is(err, want) { + t.Errorf("attendu %v, obtenu %v", want, err) + } +} + +func TestDispatcher_Dispatch_ExecutesCommand(t *testing.T) { + d, _ := newTestDispatcher(t) + var wg sync.WaitGroup + wg.Add(1) + cmd := mockCmd{ + prepareFn: func(*badger.DB, *configuration.Config) error { return nil }, + executeFn: func(*badger.DB, *configuration.Config) error { + wg.Done() + return nil + }, + } + d.Dispatch(cmd) + wg.Wait() +} + +func TestDispatcher_Dispatch_ExecuteErrorLogged(t *testing.T) { + d, _ := newTestDispatcher(t) + var wg sync.WaitGroup + wg.Add(1) + cmd := mockCmd{ + prepareFn: func(*badger.DB, *configuration.Config) error { return nil }, + executeFn: func(*badger.DB, *configuration.Config) error { + defer wg.Done() + return errors.New("execute failed") + }, + } + d.Dispatch(cmd) + wg.Wait() // Execute s'est terminé — l'erreur est loggée, pas propagée +} diff --git a/internal/dispatcher/agent/helpers_test.go b/internal/dispatcher/agent/helpers_test.go new file mode 100644 index 0000000..2cee6fc --- /dev/null +++ b/internal/dispatcher/agent/helpers_test.go @@ -0,0 +1,38 @@ +package dispatcher + +import ( + "io" + "log/slog" + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/pkg/db/kv" + "git.g3e.fr/syonad/two/pkg/worker" + "github.com/dgraph-io/badger/v4" +) + +func newTestDispatcher(t *testing.T) (*Dispatcher, *badger.DB) { + t.Helper() + db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) + t.Cleanup(func() { db.Close() }) + q := worker.New(100) + q.Start(2) + cfg := &configuration.Config{DefaultInterface: "br-default"} + cfg.Interfaces = map[string]string{"vms": "br-vms"} + logger := slog.New(slog.NewTextHandler(io.Discard, nil)) + return New(q, db, cfg, logger), db +} + +// mockCmd implémente Command sans aucune dépendance système. +type mockCmd struct { + prepareFn func(*badger.DB, *configuration.Config) error + executeFn func(*badger.DB, *configuration.Config) error +} + +func (m mockCmd) Prepare(db *badger.DB, cfg *configuration.Config) error { + return m.prepareFn(db, cfg) +} + +func (m mockCmd) Execute(db *badger.DB, cfg *configuration.Config) error { + return m.executeFn(db, cfg) +} diff --git a/internal/dispatcher/agent/subnet_commands_test.go b/internal/dispatcher/agent/subnet_commands_test.go new file mode 100644 index 0000000..3b118bc --- /dev/null +++ b/internal/dispatcher/agent/subnet_commands_test.go @@ -0,0 +1,135 @@ +package dispatcher + +import ( + "testing" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +func testCfg() *configuration.Config { + cfg := &configuration.Config{DefaultInterface: "br-default"} + cfg.Interfaces = map[string]string{"vms": "br-vms"} + return cfg +} + +// --- CreateSubnetCommand.Prepare --- + +func TestCreateSubnetCommand_Prepare_Success(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + state, _ := kv.GetFromDB(db, "subnet/sn-1/state") + if state != "creating" { + t.Errorf("state attendu creating, obtenu %q", state) + } + vpc, _ := kv.GetFromDB(db, "subnet/sn-1/vpc") + if vpc != "vpc-1" { + t.Errorf("vpc attendu vpc-1, obtenu %q", vpc) + } +} + +func TestCreateSubnetCommand_Prepare_UsesIfaceTypeMapping(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + cmd.Prepare(db, testCfg()) + iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") + if iface != "br-vms" { + t.Errorf("local_iface attendu br-vms, obtenu %q", iface) + } +} + +func TestCreateSubnetCommand_Prepare_UsesDefaultIfaceWhenTypeUnknown(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", VxlanID: 100, + IfaceType: "inconnu", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + cmd.Prepare(db, testCfg()) + iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") + if iface != "br-default" { + t.Errorf("local_iface attendu br-default, obtenu %q", iface) + } +} + +func TestCreateSubnetCommand_Prepare_Duplicate(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + kv.AddInDB(db, "subnet/sn-exist/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-exist", VPC: "vpc-1", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err == nil { + t.Error("Prepare devrait échouer sur un subnet déjà existant") + } +} + +func TestCreateSubnetCommand_Prepare_VPCNotFound(t *testing.T) { + _, db := newTestDispatcher(t) + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-inexistant", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err == nil { + t.Error("Prepare devrait échouer si le VPC n'existe pas") + } +} + +func TestCreateSubnetCommand_Prepare_VPCDeleting(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-dying/state", "deleting") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-dying", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err == nil { + t.Error("Prepare devrait échouer si le VPC est en cours de suppression") + } +} + +func TestCreateSubnetCommand_Prepare_VPCDeleted(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-gone/state", "deleted") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-gone", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err == nil { + t.Error("Prepare devrait échouer si le VPC est supprimé") + } +} + +// --- DeleteSubnetCommand.Prepare --- + +func TestDeleteSubnetCommand_Prepare_Success(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-del/state", "created") + cmd := DeleteSubnetCommand{Name: "sn-del"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + state, _ := kv.GetFromDB(db, "subnet/sn-del/state") + if state != "deleting" { + t.Errorf("state attendu deleting, obtenu %q", state) + } +} + +func TestDeleteSubnetCommand_Prepare_NotFound(t *testing.T) { + _, db := newTestDispatcher(t) + cmd := DeleteSubnetCommand{Name: "sn-inexistant"} + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer si le subnet n'existe pas") + } +} diff --git a/internal/dispatcher/agent/vpc_commands_test.go b/internal/dispatcher/agent/vpc_commands_test.go new file mode 100644 index 0000000..e2f2162 --- /dev/null +++ b/internal/dispatcher/agent/vpc_commands_test.go @@ -0,0 +1,89 @@ +package dispatcher + +import ( + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- CreateVPCCommand.Prepare --- + +func TestCreateVPCCommand_Prepare_NewVPC(t *testing.T) { + _, db := newTestDispatcher(t) + cmd := CreateVPCCommand{Name: "vpc-1"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + state, err := kv.GetFromDB(db, "vpc/vpc-1/state") + if err != nil { + t.Fatalf("état non écrit en DB : %v", err) + } + if state != "creating" { + t.Errorf("state attendu creating, obtenu %q", state) + } +} + +func TestCreateVPCCommand_Prepare_Duplicate(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-exist/state", "created") + cmd := CreateVPCCommand{Name: "vpc-exist"} + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer sur un VPC déjà existant") + } +} + +// --- DeleteVPCCommand.Prepare --- + +func TestDeleteVPCCommand_Prepare_Success(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-del/state", "created") + cmd := DeleteVPCCommand{Name: "vpc-del"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + state, _ := kv.GetFromDB(db, "vpc/vpc-del/state") + if state != "deleting" { + t.Errorf("state attendu deleting, obtenu %q", state) + } +} + +func TestDeleteVPCCommand_Prepare_NotFound(t *testing.T) { + _, db := newTestDispatcher(t) + cmd := DeleteVPCCommand{Name: "vpc-inexistant"} + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer si le VPC n'existe pas") + } +} + +func TestDeleteVPCCommand_Prepare_BlockedByActiveSubnet(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-busy/state", "created") + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-busy") + cmd := DeleteVPCCommand{Name: "vpc-busy"} + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer si un subnet actif existe") + } +} + +func TestDeleteVPCCommand_Prepare_AllowedWhenSubnetDeleted(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-ok/state", "created") + kv.AddInDB(db, "subnet/sn-1/state", "deleted") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-ok") + cmd := DeleteVPCCommand{Name: "vpc-ok"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare devrait réussir si le subnet est deleted : %v", err) + } +} + +func TestDeleteVPCCommand_Prepare_AllowedWhenSubnetDeleting(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-ok/state", "created") + kv.AddInDB(db, "subnet/sn-1/state", "deleting") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-ok") + cmd := DeleteVPCCommand{Name: "vpc-ok"} + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare devrait réussir si le subnet est deleting : %v", err) + } +} From 649ca66bf94f608e273e2ff6a675a24fc7ab204f Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 15:51:22 +0200 Subject: [PATCH 11/57] f-21: test: add test for agent metadata Signed-off-by: GnomeZworc --- internal/metadata/metadata_test.go | 99 ++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/internal/metadata/metadata_test.go b/internal/metadata/metadata_test.go index 62a2830..5884151 100644 --- a/internal/metadata/metadata_test.go +++ b/internal/metadata/metadata_test.go @@ -1,6 +1,8 @@ package metadata import ( + "net/http" + "net/http/httptest" "strings" "testing" @@ -181,6 +183,103 @@ func TestUnLoadNoCloudInDB_RemovesAllKeys(t *testing.T) { } } +// --- getIP --- + +func TestGetIP_ValidHostPort(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1:4567" + if ip := getIP(req); ip != "10.0.0.1" { + t.Errorf("attendu 10.0.0.1, obtenu %q", ip) + } +} + +func TestGetIP_IPv6(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "[::1]:8080" + if ip := getIP(req); ip != "::1" { + t.Errorf("attendu ::1, obtenu %q", ip) + } +} + +func TestGetIP_NoPort(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.RemoteAddr = "10.0.0.1" + if ip := getIP(req); ip != "10.0.0.1" { + t.Errorf("attendu RemoteAddr brut, obtenu %q", ip) + } +} + +// --- rootHandler --- + +func TestRootHandler_UserData(t *testing.T) { + data = NoCloudData{UserData: "userdata-content"} + w := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/user-data", nil) + rootHandler(w, req) + if w.Code != http.StatusOK { + t.Errorf("attendu 200, obtenu %d", w.Code) + } + if body := w.Body.String(); body != "userdata-content" { + t.Errorf("body inattendu : %q", body) + } +} + +func TestRootHandler_MetaData(t *testing.T) { + data = NoCloudData{MetaData: "metadata-content"} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/meta-data", nil)) + if w.Code != http.StatusOK { + t.Errorf("attendu 200, obtenu %d", w.Code) + } + if body := w.Body.String(); body != "metadata-content" { + t.Errorf("body inattendu : %q", body) + } +} + +func TestRootHandler_NetworkConfig(t *testing.T) { + data = NoCloudData{NetworkConfig: "network-content"} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/network-config", nil)) + if w.Code != http.StatusOK { + t.Errorf("attendu 200, obtenu %d", w.Code) + } + if body := w.Body.String(); body != "network-content" { + t.Errorf("body inattendu : %q", body) + } +} + +func TestRootHandler_VendorData(t *testing.T) { + data = NoCloudData{VendorData: "vendor-content"} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/vendor-data", nil)) + if w.Code != http.StatusOK { + t.Errorf("attendu 200, obtenu %d", w.Code) + } + if body := w.Body.String(); body != "vendor-content" { + t.Errorf("body inattendu : %q", body) + } +} + +func TestRootHandler_UnknownPath(t *testing.T) { + data = NoCloudData{} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/unknown", nil)) + if w.Code != http.StatusNotFound { + t.Errorf("attendu 404, obtenu %d", w.Code) + } +} + +func TestRootHandler_ContentType(t *testing.T) { + data = NoCloudData{MetaData: "x"} + w := httptest.NewRecorder() + rootHandler(w, httptest.NewRequest(http.MethodGet, "/meta-data", nil)) + if ct := w.Header().Get("Content-Type"); ct != "text/yaml" { + t.Errorf("Content-Type attendu text/yaml, obtenu %q", ct) + } +} + +// --- UnLoadNoCloudInDB_DoesNotAffectOtherVMs --- + func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) { db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) t.Cleanup(func() { db.Close() }) From e939467abfee96ef7eaf3b79a1840c603fbf8953 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 16:14:18 +0200 Subject: [PATCH 12/57] f-21: api coherence Signed-off-by: GnomeZworc --- api/agent.yaml | 12 ++++++------ internal/api/agent/subnet_test.go | 28 +++++++++++++++++++++++----- internal/api/agent/subnets.go | 10 +++++++--- internal/api/agent/vpc.go | 6 +++++- internal/api/agent/vpc_test.go | 4 ++-- 5 files changed, 43 insertions(+), 17 deletions(-) diff --git a/api/agent.yaml b/api/agent.yaml index 1d3a77f..da20853 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -44,6 +44,12 @@ paths: application/json: schema: $ref: "#/components/schemas/VPC" + "400": + description: Missing required field or invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/Error" "409": description: VPC already exists content: @@ -179,12 +185,6 @@ paths: $ref: "#/components/schemas/Subnet" "404": $ref: "#/components/responses/NotFound" - "409": - description: Subnet not in a deletable state - content: - application/json: - schema: - $ref: "#/components/schemas/Error" "500": $ref: "#/components/responses/InternalError" diff --git a/internal/api/agent/subnet_test.go b/internal/api/agent/subnet_test.go index 0fdd18b..23745a0 100644 --- a/internal/api/agent/subnet_test.go +++ b/internal/api/agent/subnet_test.go @@ -81,7 +81,7 @@ func TestPostSubnet_Created(t *testing.T) { func TestPostSubnet_MissingFields(t *testing.T) { s, _ := newTestServer(t) - body, _ := json.Marshal(SubnetCreateRequest{Name: "sn-1"}) // vpc, iface_type, gateway_ip, cidr manquants + body, _ := json.Marshal(SubnetCreateRequest{Name: "sn-1"}) // vpc, gateway_ip, cidr manquants w := httptest.NewRecorder() s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) if w.Code != http.StatusBadRequest { @@ -89,6 +89,24 @@ func TestPostSubnet_MissingFields(t *testing.T) { } } +func TestPostSubnet_IfaceTypeOptional(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + req := SubnetCreateRequest{ + Name: "sn-opt", + VPC: "vpc-1", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + // IfaceType omis — doit utiliser default_interface + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } +} + func TestPostSubnet_VPCNotFound(t *testing.T) { s, _ := newTestServer(t) req := SubnetCreateRequest{ @@ -101,8 +119,8 @@ func TestPostSubnet_VPCNotFound(t *testing.T) { body, _ := json.Marshal(req) w := httptest.NewRecorder() s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusConflict { - t.Errorf("attendu 409, obtenu %d", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("attendu 422, obtenu %d", w.Code) } } @@ -138,8 +156,8 @@ func TestPostSubnet_VPCDeleting(t *testing.T) { body, _ := json.Marshal(req) w := httptest.NewRecorder() s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) - if w.Code != http.StatusConflict { - t.Errorf("attendu 409, obtenu %d", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("attendu 422, obtenu %d", w.Code) } } diff --git a/internal/api/agent/subnets.go b/internal/api/agent/subnets.go index b0fb2fb..60467aa 100644 --- a/internal/api/agent/subnets.go +++ b/internal/api/agent/subnets.go @@ -69,9 +69,9 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"}) return } - if req.Name == "" || req.VPC == "" || req.IfaceType == "" || req.GatewayIP == "" || req.CIDR == "" { + if req.Name == "" || req.VPC == "" || req.GatewayIP == "" || req.CIDR == "" { w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: "name, vpc, iface_type, gateway_ip and cidr are required"}) + json.NewEncoder(w).Encode(ErrorResponse{Error: "name, vpc, gateway_ip and cidr are required"}) return } cmd := dispatcher.CreateSubnetCommand{ @@ -83,7 +83,11 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { CIDR: req.CIDR, } if err := s.dispatcher.Prepare(cmd); err != nil { - w.WriteHeader(http.StatusConflict) + if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil { + w.WriteHeader(http.StatusConflict) + } else { + w.WriteHeader(http.StatusUnprocessableEntity) + } json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) return } diff --git a/internal/api/agent/vpc.go b/internal/api/agent/vpc.go index df2d47c..43cc33c 100644 --- a/internal/api/agent/vpc.go +++ b/internal/api/agent/vpc.go @@ -42,7 +42,11 @@ func (s *Server) getVpc(w http.ResponseWriter, _ *http.Request, name string) { func (s *Server) deleteVpc(w http.ResponseWriter, _ *http.Request, name string) { cmd := dispatcher.DeleteVPCCommand{Name: name} if err := s.dispatcher.Prepare(cmd); err != nil { - w.WriteHeader(http.StatusNotFound) + if _, dbErr := kv.GetFromDB(s.db, "vpc/"+name+"/state"); dbErr != nil { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusConflict) + } json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) return } diff --git a/internal/api/agent/vpc_test.go b/internal/api/agent/vpc_test.go index 0edcd5b..01308a5 100644 --- a/internal/api/agent/vpc_test.go +++ b/internal/api/agent/vpc_test.go @@ -171,8 +171,8 @@ func TestDeleteVpc_BlockedByActiveSubnet(t *testing.T) { req := httptest.NewRequest(http.MethodDelete, "/vpcs/vpc-busy", nil) w := httptest.NewRecorder() s.VpcByNameHandler(w, req) - if w.Code != http.StatusNotFound { - t.Errorf("attendu 404 (Prepare échoue), obtenu %d: %s", w.Code, w.Body.String()) + if w.Code != http.StatusConflict { + t.Errorf("attendu 409, obtenu %d: %s", w.Code, w.Body.String()) } } From bf9f871fe13058a9b7ba878228a57d43225a45f4 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 16:28:54 +0200 Subject: [PATCH 13/57] f-25: netif: Add tap handle Signed-off-by: GnomeZworc --- internal/netif/tap_linux.go | 28 ++++++++++++++++++++++++++++ internal/netif/tap_other.go | 9 +++++++++ 2 files changed, 37 insertions(+) create mode 100644 internal/netif/tap_linux.go create mode 100644 internal/netif/tap_other.go diff --git a/internal/netif/tap_linux.go b/internal/netif/tap_linux.go new file mode 100644 index 0000000..a5abc9c --- /dev/null +++ b/internal/netif/tap_linux.go @@ -0,0 +1,28 @@ +//go:build linux + +package netif + +import ( + "fmt" + + "git.g3e.fr/syonad/two/internal/netns" + "github.com/vishvananda/netlink" +) + +func CreateTap(tapID int, bridgeName, vpcName string) error { + name := fmt.Sprintf("tap%d", tapID) + + return netns.Call(vpcName, func() error { + tap := &netlink.Tuntap{ + LinkAttrs: netlink.LinkAttrs{Name: name}, + Mode: netlink.TUNTAP_MODE_TAP, + } + if err := netlink.LinkAdd(tap); err != nil { + return err + } + if err := BridgeSetMaster(name, bridgeName); err != nil { + return err + } + return LinkSetUp(name) + }) +} diff --git a/internal/netif/tap_other.go b/internal/netif/tap_other.go new file mode 100644 index 0000000..a81fefb --- /dev/null +++ b/internal/netif/tap_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package netif + +import "errors" + +func CreateTap(_ int, _, _ string) error { + return errors.New("netif: tap not supported on this platform") +} From b84e0e9cbe974820e0bf10d5291b7b48ab437536 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 16:29:19 +0200 Subject: [PATCH 14/57] f-25: qmp add interaction lib Signed-off-by: GnomeZworc --- internal/qmp/send.go | 50 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 internal/qmp/send.go diff --git a/internal/qmp/send.go b/internal/qmp/send.go new file mode 100644 index 0000000..8ce6711 --- /dev/null +++ b/internal/qmp/send.go @@ -0,0 +1,50 @@ +package qmp + +import ( + "bufio" + "encoding/json" + "fmt" + "net" + "strings" +) + +func Send(socketPath string, commands []string) ([]json.RawMessage, error) { + conn, err := net.Dial("unix", socketPath) + if err != nil { + return nil, fmt.Errorf("qmp dial: %w", err) + } + defer conn.Close() + + r := bufio.NewReader(conn) + + if _, err := r.ReadString('\n'); err != nil { + return nil, fmt.Errorf("qmp read greeting: %w", err) + } + + var sb strings.Builder + sb.WriteString(`{ "execute": "qmp_capabilities" }`) + for _, cmd := range commands { + sb.WriteByte('\n') + sb.WriteString(cmd) + } + sb.WriteByte('\n') + + if _, err := fmt.Fprint(conn, sb.String()); err != nil { + return nil, fmt.Errorf("qmp write: %w", err) + } + + if _, err := r.ReadString('\n'); err != nil { + return nil, fmt.Errorf("qmp read capabilities response: %w", err) + } + + var results []json.RawMessage + for range commands { + line, err := r.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("qmp read response: %w", err) + } + results = append(results, json.RawMessage(line)) + } + + return results, nil +} From da0d043846d7611d3ae90c2c12858c22ae4d937c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 16:29:45 +0200 Subject: [PATCH 15/57] f-25: qemu: add handle gestion Signed-off-by: GnomeZworc --- internal/qemu/start_linux.go | 48 ++++++++++++++++++++++++++++++++++++ internal/qemu/start_other.go | 14 +++++++++++ 2 files changed, 62 insertions(+) create mode 100644 internal/qemu/start_linux.go create mode 100644 internal/qemu/start_other.go diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go new file mode 100644 index 0000000..6119278 --- /dev/null +++ b/internal/qemu/start_linux.go @@ -0,0 +1,48 @@ +//go:build linux + +package qemu + +import ( + "fmt" + "os/exec" +) + +type Config struct { + Name string + TapID int + Mac string + VolumePath string + Memory int + CPUs int +} + +func Start(cfg Config) error { + memory := cfg.Memory + if memory == 0 { + memory = 512 + } + + cpus := cfg.CPUs + if cpus == 0 { + cpus = 1 + } + + cmd := exec.Command("qemu-system-x86_64", + "-enable-kvm", + "-cpu", "host", + "-m", fmt.Sprintf("%d", memory), + "-smp", fmt.Sprintf("%d", cpus), + "-serial", fmt.Sprintf("unix:/tmp/%s.sock,server,nowait", cfg.Name), + "-monitor", fmt.Sprintf("unix:/tmp/%s.mon-sock,server,nowait", cfg.Name), + "-qmp", fmt.Sprintf("unix:/tmp/%s.qmp-sock,server,nowait", cfg.Name), + "-display", "none", + "-drive", fmt.Sprintf("file=%s,if=virtio", cfg.VolumePath), + "-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", cfg.Mac), + "-daemonize", + ) + if err := cmd.Run(); err != nil { + return fmt.Errorf("qemu-system-x86_64: %w", err) + } + return nil +} diff --git a/internal/qemu/start_other.go b/internal/qemu/start_other.go new file mode 100644 index 0000000..9201a2d --- /dev/null +++ b/internal/qemu/start_other.go @@ -0,0 +1,14 @@ +//go:build !linux + +package qemu + +import "errors" + +type Config struct { + Name, Mac, VolumePath string + TapID, Memory, CPUs int +} + +func Start(cfg Config) error { + return errors.New("vm: not supported on this platform") +} From a5bf748bdfc8e8adc89786b5c194d403511d4674 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 17:27:01 +0200 Subject: [PATCH 16/57] f-25: vm: ajout de la logique de demarrage des vms Signed-off-by: GnomeZworc --- internal/netif/tap_linux.go | 6 ++ internal/netif/tap_other.go | 4 ++ internal/vm/create.go | 108 ++++++++++++++++++++++++++++++++++++ internal/vm/delete.go | 71 ++++++++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 internal/vm/create.go create mode 100644 internal/vm/delete.go diff --git a/internal/netif/tap_linux.go b/internal/netif/tap_linux.go index a5abc9c..15313af 100644 --- a/internal/netif/tap_linux.go +++ b/internal/netif/tap_linux.go @@ -26,3 +26,9 @@ func CreateTap(tapID int, bridgeName, vpcName string) error { return LinkSetUp(name) }) } + +func DeleteTap(tapID int, vpcName string) error { + return netns.Call(vpcName, func() error { + return DeleteLink(fmt.Sprintf("tap%d", tapID)) + }) +} diff --git a/internal/netif/tap_other.go b/internal/netif/tap_other.go index a81fefb..18afcbe 100644 --- a/internal/netif/tap_other.go +++ b/internal/netif/tap_other.go @@ -7,3 +7,7 @@ import "errors" func CreateTap(_ int, _, _ string) error { return errors.New("netif: tap not supported on this platform") } + +func DeleteTap(_ int, _ string) error { + return errors.New("netif: tap not supported on this platform") +} diff --git a/internal/vm/create.go b/internal/vm/create.go new file mode 100644 index 0000000..3fc22af --- /dev/null +++ b/internal/vm/create.go @@ -0,0 +1,108 @@ +package vm + +import ( + "fmt" + "strconv" + + "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/qemu" + "git.g3e.fr/syonad/two/pkg/db/kv" + + "github.com/dgraph-io/badger/v4" +) + +func StartVM(db *badger.DB, name string) error { + state, err := kv.GetFromDB(db, "vm/"+name+"/state") + if err != nil { + return err + } + if state != "starting" { + return nil + } + + vpcName, err := kv.GetFromDB(db, "vm/"+name+"/vpc") + if err != nil { + return fmt.Errorf("get vpc: %w", err) + } + + bridge, err := kv.GetFromDB(db, "vm/"+name+"/bridge") + if err != nil { + return fmt.Errorf("get bridge: %w", err) + } + + tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") + if err != nil { + return fmt.Errorf("get tap_id: %w", err) + } + tapID, err := strconv.Atoi(tapIDStr) + if err != nil { + return fmt.Errorf("parse tap_id: %w", err) + } + + mac, err := kv.GetFromDB(db, "vm/"+name+"/mac") + if err != nil { + return fmt.Errorf("get mac: %w", err) + } + + volumePath, err := kv.GetFromDB(db, "vm/"+name+"/volume_path") + if err != nil { + return fmt.Errorf("get volume_path: %w", err) + } + + memoryStr, err := kv.GetFromDB(db, "vm/"+name+"/memory") + if err != nil { + return fmt.Errorf("get memory: %w", err) + } + memory, _ := strconv.Atoi(memoryStr) + + cpusStr, err := kv.GetFromDB(db, "vm/"+name+"/cpus") + if err != nil { + return fmt.Errorf("get cpus: %w", err) + } + cpus, _ := strconv.Atoi(cpusStr) + + bindIP, err := kv.GetFromDB(db, "vm/"+name+"/metadata_bind_ip") + if err != nil { + return fmt.Errorf("get metadata_bind_ip: %w", err) + } + + bindPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_bind_port") + if err != nil { + return fmt.Errorf("get metadata_bind_port: %w", err) + } + + password, _ := kv.GetFromDB(db, "vm/"+name+"/password") + sshkey, _ := kv.GetFromDB(db, "vm/"+name+"/sshkey") + + if err := netif.CreateTap(tapID, bridge, vpcName); err != nil { + return fmt.Errorf("create tap: %w", err) + } + + if err := metadata.StartMetadata(metadata.NoCloudConfig{ + Name: name, + VpcName: vpcName, + BindIP: bindIP, + BindPort: bindPort, + Password: password, + SSHKEY: sshkey, + }, db, false); err != nil { + return fmt.Errorf("start metadata: %w", err) + } + + if err := netns.Call(d.vpcName, func() error { + return qemu.Start(qemu.Config{ + Name: name, + TapID: d.tapID, + Mac: d.mac, + VolumePath: d.volumePath, + Memory: d.memory, + CPUs: d.cpus, + }) + }); err != nil { + return fmt.Errorf("start qemu: %w", err) + } + + return kv.AddInDB(db, "vm/"+name+"/state", "started") +} diff --git a/internal/vm/delete.go b/internal/vm/delete.go new file mode 100644 index 0000000..d3f376c --- /dev/null +++ b/internal/vm/delete.go @@ -0,0 +1,71 @@ +package vm + +import ( + "fmt" + "strconv" + "time" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/metadata" + "git.g3e.fr/syonad/two/internal/netif" + "git.g3e.fr/syonad/two/internal/qmp" + "git.g3e.fr/syonad/two/pkg/db/kv" + + "github.com/dgraph-io/badger/v4" +) + +func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { + state, err := kv.GetFromDB(db, "vm/"+name+"/state") + if err != nil { + return err + } + if state != "stopping" { + return nil + } + + vpcName, err := kv.GetFromDB(db, "vm/"+name+"/vpc") + if err != nil { + return fmt.Errorf("get vpc: %w", err) + } + + tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") + if err != nil { + return fmt.Errorf("get tap_id: %w", err) + } + tapID, err := strconv.Atoi(tapIDStr) + if err != nil { + return fmt.Errorf("parse tap_id: %w", err) + } + + socketPath := fmt.Sprintf("/tmp/%s.qmp-sock", name) + + if _, err := qmp.Send(socketPath, []string{`{"execute":"system_powerdown"}`}); err != nil { + return fmt.Errorf("qmp system_powerdown: %w", err) + } + + // attendre l'arrêt effectif de la VM ; forcer via quit après timeout + timeout := time.After(time.Duration(cfg.Dispatcher.TimeoutSeconds) * time.Second) + poll := time.Duration(cfg.Dispatcher.PollSeconds) * time.Second + stopped := false + for !stopped { + select { + case <-timeout: + qmp.Send(socketPath, []string{`{"execute":"quit"}`}) + stopped = true + case <-time.After(poll): + if _, err := qmp.Send(socketPath, nil); err != nil { + stopped = true + } + } + } + + if err := metadata.StopMetadata(name, db, false); err != nil { + return fmt.Errorf("stop metadata: %w", err) + } + + if err := netif.DeleteTap(tapID, vpcName); err != nil { + return fmt.Errorf("delete tap: %w", err) + } + + return kv.AddInDB(db, "vm/"+name+"/state", "stopped") +} From 504a2a723befadb0a69c11ef10e0f86ba4bfa653 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 17:37:25 +0200 Subject: [PATCH 17/57] f-25: vms: add iptables gestion Signed-off-by: GnomeZworc --- internal/iptables/iptables.go | 42 +++++++++++++++++++++++++++++++++++ internal/vm/create.go | 36 ++++++++++++++++++++---------- internal/vm/delete.go | 23 +++++++++++++++++++ 3 files changed, 89 insertions(+), 12 deletions(-) create mode 100644 internal/iptables/iptables.go diff --git a/internal/iptables/iptables.go b/internal/iptables/iptables.go new file mode 100644 index 0000000..1d156a7 --- /dev/null +++ b/internal/iptables/iptables.go @@ -0,0 +1,42 @@ +package iptables + +import ( + "fmt" + "os/exec" +) + +func addRule(args ...string) error { + return exec.Command("iptables", append([]string{"-t", "nat", "-A"}, args...)...).Run() +} + +func deleteRule(args ...string) error { + return exec.Command("iptables", append([]string{"-t", "nat", "-D"}, args...)...).Run() +} + +func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { + if err := addRule("PREROUTING", + "-s", vmIP+"/32", + "-d", "169.254.169.254/32", + "-p", "tcp", "-m", "tcp", + "--dport", "80", + "-j", "DNAT", + "--to-destination", gatewayIP+":"+metadataPort, + ); err != nil { + return fmt.Errorf("iptables metadata redirect: %w", err) + } + return nil +} + +func DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort string) error { + if err := deleteRule("PREROUTING", + "-s", vmIP+"/32", + "-d", "169.254.169.254/32", + "-p", "tcp", "-m", "tcp", + "--dport", "80", + "-j", "DNAT", + "--to-destination", gatewayIP+":"+metadataPort, + ); err != nil { + return fmt.Errorf("iptables delete metadata redirect: %w", err) + } + return nil +} diff --git a/internal/vm/create.go b/internal/vm/create.go index 3fc22af..243a8d6 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -4,6 +4,7 @@ import ( "fmt" "strconv" + "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" @@ -41,6 +42,21 @@ func StartVM(db *badger.DB, name string) error { return fmt.Errorf("parse tap_id: %w", err) } + vmIP, err := kv.GetFromDB(db, "vm/"+name+"/ip") + if err != nil { + return fmt.Errorf("get ip: %w", err) + } + + gatewayIP, err := kv.GetFromDB(db, "vm/"+name+"/gateway_ip") + if err != nil { + return fmt.Errorf("get gateway_ip: %w", err) + } + + metadataPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_port") + if err != nil { + return fmt.Errorf("get metadata_port: %w", err) + } + mac, err := kv.GetFromDB(db, "vm/"+name+"/mac") if err != nil { return fmt.Errorf("get mac: %w", err) @@ -63,16 +79,6 @@ func StartVM(db *badger.DB, name string) error { } cpus, _ := strconv.Atoi(cpusStr) - bindIP, err := kv.GetFromDB(db, "vm/"+name+"/metadata_bind_ip") - if err != nil { - return fmt.Errorf("get metadata_bind_ip: %w", err) - } - - bindPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_bind_port") - if err != nil { - return fmt.Errorf("get metadata_bind_port: %w", err) - } - password, _ := kv.GetFromDB(db, "vm/"+name+"/password") sshkey, _ := kv.GetFromDB(db, "vm/"+name+"/sshkey") @@ -80,11 +86,17 @@ func StartVM(db *badger.DB, name string) error { return fmt.Errorf("create tap: %w", err) } + if err := netns.Call(vpcName, func() error { + return iptables.AddMetadataRedirect(vmIP, gatewayIP, metadataPort) + }); err != nil { + return fmt.Errorf("add metadata redirect: %w", err) + } + if err := metadata.StartMetadata(metadata.NoCloudConfig{ Name: name, VpcName: vpcName, - BindIP: bindIP, - BindPort: bindPort, + BindIP: gatewayIP, + BindPort: metadataPort, Password: password, SSHKEY: sshkey, }, db, false); err != nil { diff --git a/internal/vm/delete.go b/internal/vm/delete.go index d3f376c..7e89424 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -6,8 +6,10 @@ import ( "time" configuration "git.g3e.fr/syonad/two/internal/config/agent" + "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/pkg/db/kv" @@ -37,6 +39,21 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("parse tap_id: %w", err) } + vmIP, err := kv.GetFromDB(db, "vm/"+name+"/ip") + if err != nil { + return fmt.Errorf("get ip: %w", err) + } + + gatewayIP, err := kv.GetFromDB(db, "vm/"+name+"/gateway_ip") + if err != nil { + return fmt.Errorf("get gateway_ip: %w", err) + } + + metadataPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_port") + if err != nil { + return fmt.Errorf("get metadata_port: %w", err) + } + socketPath := fmt.Sprintf("/tmp/%s.qmp-sock", name) if _, err := qmp.Send(socketPath, []string{`{"execute":"system_powerdown"}`}); err != nil { @@ -59,6 +76,12 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { } } + if err := netns.Call(vpcName, func() error { + return iptables.DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort) + }); err != nil { + return fmt.Errorf("delete metadata redirect: %w", err) + } + if err := metadata.StopMetadata(name, db, false); err != nil { return fmt.Errorf("stop metadata: %w", err) } From 2efaf2009834144c886553d0386503c7873b193b Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 17:49:32 +0200 Subject: [PATCH 18/57] f-25: fix: reduce options number Signed-off-by: GnomeZworc --- internal/vm/create.go | 19 +++++++++++-------- internal/vm/delete.go | 17 +++++++++++------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/internal/vm/create.go b/internal/vm/create.go index 243a8d6..49d0dd8 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -3,6 +3,7 @@ package vm import ( "fmt" "strconv" + "strings" "git.g3e.fr/syonad/two/internal/iptables" "git.g3e.fr/syonad/two/internal/metadata" @@ -23,16 +24,23 @@ func StartVM(db *badger.DB, name string) error { return nil } - vpcName, err := kv.GetFromDB(db, "vm/"+name+"/vpc") + subnetName, err := kv.GetFromDB(db, "vm/"+name+"/subnet") + if err != nil { + return fmt.Errorf("get subnet: %w", err) + } + + vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") if err != nil { return fmt.Errorf("get vpc: %w", err) } - bridge, err := kv.GetFromDB(db, "vm/"+name+"/bridge") + gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") if err != nil { - return fmt.Errorf("get bridge: %w", err) + return fmt.Errorf("get gateway_ip: %w", err) } + bridge := "br-" + strings.SplitN(subnetName, "-", 2)[1] + tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") if err != nil { return fmt.Errorf("get tap_id: %w", err) @@ -47,11 +55,6 @@ func StartVM(db *badger.DB, name string) error { return fmt.Errorf("get ip: %w", err) } - gatewayIP, err := kv.GetFromDB(db, "vm/"+name+"/gateway_ip") - if err != nil { - return fmt.Errorf("get gateway_ip: %w", err) - } - metadataPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_port") if err != nil { return fmt.Errorf("get metadata_port: %w", err) diff --git a/internal/vm/delete.go b/internal/vm/delete.go index 7e89424..0de2b73 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -25,11 +25,21 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return nil } - vpcName, err := kv.GetFromDB(db, "vm/"+name+"/vpc") + subnetName, err := kv.GetFromDB(db, "vm/"+name+"/subnet") + if err != nil { + return fmt.Errorf("get subnet: %w", err) + } + + vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") if err != nil { return fmt.Errorf("get vpc: %w", err) } + gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") + if err != nil { + return fmt.Errorf("get gateway_ip: %w", err) + } + tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") if err != nil { return fmt.Errorf("get tap_id: %w", err) @@ -44,11 +54,6 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("get ip: %w", err) } - gatewayIP, err := kv.GetFromDB(db, "vm/"+name+"/gateway_ip") - if err != nil { - return fmt.Errorf("get gateway_ip: %w", err) - } - metadataPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_port") if err != nil { return fmt.Errorf("get metadata_port: %w", err) From cc9998028dfd5f7e1c7a3e08a100e9cfb4d60776 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 17:57:16 +0200 Subject: [PATCH 19/57] f-25: code: move load data to dedicated function Signed-off-by: GnomeZworc --- internal/subnet/create.go | 97 +++++++++--------------------------- internal/subnet/data.go | 72 +++++++++++++++++++++++++++ internal/subnet/delete.go | 43 +++++----------- internal/vm/create.go | 78 ++++------------------------- internal/vm/data.go | 100 ++++++++++++++++++++++++++++++++++++++ internal/vm/delete.go | 40 ++------------- 6 files changed, 222 insertions(+), 208 deletions(-) create mode 100644 internal/subnet/data.go create mode 100644 internal/vm/data.go diff --git a/internal/subnet/create.go b/internal/subnet/create.go index 496c257..07658c6 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -2,9 +2,6 @@ package subnet import ( "fmt" - "net" - "strconv" - "strings" "git.g3e.fr/syonad/two/internal/dhcp" "git.g3e.fr/syonad/two/internal/ebtables" @@ -25,94 +22,51 @@ func CreateSubnet(db *badger.DB, subnetName string) error { return nil } - // lecture des paramètres depuis la DB - vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") + d, err := loadSubnet(db, subnetName) if err != nil { - return fmt.Errorf("get vpc: %w", err) + return err } - vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vxlan_id") - if err != nil { - return fmt.Errorf("get vxlan_id: %w", err) - } - vxlanID, err := strconv.Atoi(vxlanIDStr) - if err != nil { - return fmt.Errorf("parse vxlan_id: %w", err) - } + vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) - localIface, err := kv.GetFromDB(db, "subnet/"+subnetName+"/local_iface") - if err != nil { - return fmt.Errorf("get local_iface: %w", err) - } - - gatewayIPStr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") - if err != nil { - return fmt.Errorf("get gateway_ip: %w", err) - } - gatewayIP := net.ParseIP(gatewayIPStr) - if gatewayIP == nil { - return fmt.Errorf("invalid gateway_ip: %s", gatewayIPStr) - } - - cidr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/cidr") - if err != nil { - return fmt.Errorf("get cidr: %w", err) - } - _, subnet, err := net.ParseCIDR(cidr) - if err != nil { - return fmt.Errorf("parse cidr: %w", err) - } - - // subnet_id = partie après le premier '-' (ex: "sn-00001" -> "00001") - subnetID := strings.SplitN(subnetName, "-", 2)[1] - bridge := "br-" + subnetID - vxlanIface := fmt.Sprintf("vxlan-%d", vxlanID) - - // veth pair - if err := netif.CreateVethToNetns("v-"+subnetID+"-e", "v-"+subnetID+"-i", "/var/run/netns/"+vpcName, 1500); err != nil { + if err := netif.CreateVethToNetns("v-"+d.subnetID+"-e", "v-"+d.subnetID+"-i", "/var/run/netns/"+d.vpc, 1500); err != nil { return fmt.Errorf("create veth: %w", err) } - // bridge dans le root netns - if err := netif.CreateBridge(bridge, 1500); err != nil { + if err := netif.CreateBridge(d.bridge, 1500); err != nil { return fmt.Errorf("create bridge: %w", err) } - // bridge dans le netns VPC - if err := netns.Call(vpcName, func() error { - return netif.CreateBridge(bridge, 1500) + if err := netns.Call(d.vpc, func() error { + return netif.CreateBridge(d.bridge, 1500) }); err != nil { return fmt.Errorf("create bridge in netns: %w", err) } - // vxlan - if err := netif.CreateVxlan(vxlanIface, vxlanID, localIface, 1500); err != nil { + if err := netif.CreateVxlan(vxlanIface, d.vxlanID, d.localIface, 1500); err != nil { return fmt.Errorf("create vxlan: %w", err) } - // ajout des interfaces dans les bridges - if err := netif.BridgeSetMaster("v-"+subnetID+"-e", bridge); err != nil { + if err := netif.BridgeSetMaster("v-"+d.subnetID+"-e", d.bridge); err != nil { return fmt.Errorf("add veth-e to bridge: %w", err) } - if err := netns.Call(vpcName, func() error { - return netif.BridgeSetMaster("v-"+subnetID+"-i", bridge) + if err := netns.Call(d.vpc, func() error { + return netif.BridgeSetMaster("v-"+d.subnetID+"-i", d.bridge) }); err != nil { return fmt.Errorf("add veth-i to bridge in netns: %w", err) } - if err := netif.BridgeSetMaster(vxlanIface, bridge); err != nil { + if err := netif.BridgeSetMaster(vxlanIface, d.bridge); err != nil { return fmt.Errorf("add vxlan to bridge: %w", err) } - // montée des interfaces dans le root netns - for _, iface := range []string{"v-" + subnetID + "-e", vxlanIface, bridge} { + for _, iface := range []string{"v-" + d.subnetID + "-e", vxlanIface, d.bridge} { if err := netif.LinkSetUp(iface); err != nil { return fmt.Errorf("set up %s: %w", iface, err) } } - // montée des interfaces dans le netns VPC - if err := netns.Call(vpcName, func() error { - for _, iface := range []string{"v-" + subnetID + "-i", bridge} { + if err := netns.Call(d.vpc, func() error { + for _, iface := range []string{"v-" + d.subnetID + "-i", d.bridge} { if err := netif.LinkSetUp(iface); err != nil { return fmt.Errorf("set up %s: %w", iface, err) } @@ -122,32 +76,29 @@ func CreateSubnet(db *badger.DB, subnetName string) error { return fmt.Errorf("set up interfaces in netns: %w", err) } - // IP gateway (/32) sur le bridge interne - if err := netns.Call(vpcName, func() error { - return netif.AddrAdd(bridge, gatewayIP) + if err := netns.Call(d.vpc, func() error { + return netif.AddrAdd(d.bridge, d.gatewayIP) }); err != nil { return fmt.Errorf("add addr to bridge in netns: %w", err) } - // route subnet (scope link) dans le netns VPC - if err := netns.Call(vpcName, func() error { - return netif.RouteAdd(bridge, subnet) + if err := netns.Call(d.vpc, func() error { + return netif.RouteAdd(d.bridge, d.cidr) }); err != nil { return fmt.Errorf("add route in netns: %w", err) } - if err := ebtables.DropARPToGateway(bridge, gatewayIP.String()); err != nil { + if err := ebtables.DropARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { return err } - if err := ebtables.DropDHCP(bridge); err != nil { + if err := ebtables.DropDHCP(d.bridge); err != nil { return err } - // génération de la config dnsmasq et démarrage du service conf := dhcp.Config{ - Network: subnet, - Gateway: gatewayIP, - Name: vpcName + "_" + bridge, + Network: d.cidr, + Gateway: d.gatewayIP, + Name: d.vpc + "_" + d.bridge, ConfDir: "/etc/dnsmasq.d", } if _, err := dhcp.GenerateConfig(conf); err != nil { diff --git a/internal/subnet/data.go b/internal/subnet/data.go new file mode 100644 index 0000000..31f5472 --- /dev/null +++ b/internal/subnet/data.go @@ -0,0 +1,72 @@ +package subnet + +import ( + "fmt" + "net" + "strconv" + "strings" + + "git.g3e.fr/syonad/two/pkg/db/kv" + "github.com/dgraph-io/badger/v4" +) + +type subnetData struct { + vpc string + subnetID string + bridge string + vxlanID int + localIface string + gatewayIP net.IP + cidr *net.IPNet +} + +func loadSubnet(db *badger.DB, name string) (subnetData, error) { + var d subnetData + + d.subnetID = strings.SplitN(name, "-", 2)[1] + d.bridge = "br-" + d.subnetID + + vpc, err := kv.GetFromDB(db, "subnet/"+name+"/vpc") + if err != nil { + return d, fmt.Errorf("get vpc: %w", err) + } + d.vpc = vpc + + vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+name+"/vxlan_id") + if err != nil { + return d, fmt.Errorf("get vxlan_id: %w", err) + } + vxlanID, err := strconv.Atoi(vxlanIDStr) + if err != nil { + return d, fmt.Errorf("parse vxlan_id: %w", err) + } + d.vxlanID = vxlanID + + localIface, err := kv.GetFromDB(db, "subnet/"+name+"/local_iface") + if err != nil { + return d, fmt.Errorf("get local_iface: %w", err) + } + d.localIface = localIface + + gatewayIPStr, err := kv.GetFromDB(db, "subnet/"+name+"/gateway_ip") + if err != nil { + return d, fmt.Errorf("get gateway_ip: %w", err) + } + gatewayIP := net.ParseIP(gatewayIPStr) + if gatewayIP == nil { + return d, fmt.Errorf("invalid gateway_ip: %s", gatewayIPStr) + } + d.gatewayIP = gatewayIP + + cidrStr, err := kv.GetFromDB(db, "subnet/"+name+"/cidr") + if err != nil { + return d, fmt.Errorf("get cidr: %w", err) + } + _, ipNet, err := net.ParseCIDR(cidrStr) + if err != nil { + return d, fmt.Errorf("parse cidr: %w", err) + } + d.cidr = ipNet + + return d, nil +} diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index 8ce91ff..8606cb9 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -3,7 +3,6 @@ package subnet import ( "fmt" "os" - "strings" "git.g3e.fr/syonad/two/internal/ebtables" "git.g3e.fr/syonad/two/internal/netif" @@ -23,69 +22,49 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { return nil } - vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") + d, err := loadSubnet(db, subnetName) if err != nil { - return fmt.Errorf("get vpc: %w", err) + return err } - vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vxlan_id") - if err != nil { - return fmt.Errorf("get vxlan_id: %w", err) - } + vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) - gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") - if err != nil { - return fmt.Errorf("get gateway_ip: %w", err) - } - - subnetID := strings.SplitN(subnetName, "-", 2)[1] - bridge := "br-" + subnetID - vxlanIface := "vxlan-" + vxlanIDStr - - // arrêt du service dnsmasq svc, err := systemd.New() if err != nil { return fmt.Errorf("connect to systemd: %w", err) } defer svc.Close() - svcName := "dnsmasq@" + vpcName + "_" + bridge + ".service" - if err := svc.Stop(svcName); err != nil { + if err := svc.Stop("dnsmasq@" + d.vpc + "_" + d.bridge + ".service"); err != nil { return fmt.Errorf("stop dnsmasq: %w", err) } - // suppression de la config dnsmasq - if err := os.Remove("/etc/dnsmasq.d/" + vpcName + "_" + bridge + ".conf"); err != nil && !os.IsNotExist(err) { + if err := os.Remove("/etc/dnsmasq.d/" + d.vpc + "_" + d.bridge + ".conf"); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove dnsmasq config: %w", err) } - // suppression des règles ebtables - if err := ebtables.DeleteARPToGateway(bridge, gatewayIP); err != nil { + if err := ebtables.DeleteARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { return fmt.Errorf("delete ebtables arp rule: %w", err) } - if err := ebtables.DeleteDHCP(bridge); err != nil { + if err := ebtables.DeleteDHCP(d.bridge); err != nil { return fmt.Errorf("delete ebtables dhcp rule: %w", err) } - // suppression du bridge dans le netns VPC - if err := netns.Call(vpcName, func() error { - return netif.DeleteLink(bridge) + if err := netns.Call(d.vpc, func() error { + return netif.DeleteLink(d.bridge) }); err != nil { return fmt.Errorf("delete bridge in netns: %w", err) } - // suppression du vxlan if err := netif.DeleteLink(vxlanIface); err != nil { return fmt.Errorf("delete vxlan: %w", err) } - // suppression du veth pair (supprime les deux côtés) - if err := netif.DeleteLink("v-" + subnetID + "-e"); err != nil { + if err := netif.DeleteLink("v-" + d.subnetID + "-e"); err != nil { return fmt.Errorf("delete veth: %w", err) } - // suppression du bridge dans le root netns - if err := netif.DeleteLink(bridge); err != nil { + if err := netif.DeleteLink(d.bridge); err != nil { return fmt.Errorf("delete bridge: %w", err) } diff --git a/internal/vm/create.go b/internal/vm/create.go index 49d0dd8..4737e62 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -2,8 +2,6 @@ package vm import ( "fmt" - "strconv" - "strings" "git.g3e.fr/syonad/two/internal/iptables" "git.g3e.fr/syonad/two/internal/metadata" @@ -24,84 +22,28 @@ func StartVM(db *badger.DB, name string) error { return nil } - subnetName, err := kv.GetFromDB(db, "vm/"+name+"/subnet") + d, err := loadVM(db, name) if err != nil { - return fmt.Errorf("get subnet: %w", err) + return err } - vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") - if err != nil { - return fmt.Errorf("get vpc: %w", err) - } - - gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") - if err != nil { - return fmt.Errorf("get gateway_ip: %w", err) - } - - bridge := "br-" + strings.SplitN(subnetName, "-", 2)[1] - - tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") - if err != nil { - return fmt.Errorf("get tap_id: %w", err) - } - tapID, err := strconv.Atoi(tapIDStr) - if err != nil { - return fmt.Errorf("parse tap_id: %w", err) - } - - vmIP, err := kv.GetFromDB(db, "vm/"+name+"/ip") - if err != nil { - return fmt.Errorf("get ip: %w", err) - } - - metadataPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_port") - if err != nil { - return fmt.Errorf("get metadata_port: %w", err) - } - - mac, err := kv.GetFromDB(db, "vm/"+name+"/mac") - if err != nil { - return fmt.Errorf("get mac: %w", err) - } - - volumePath, err := kv.GetFromDB(db, "vm/"+name+"/volume_path") - if err != nil { - return fmt.Errorf("get volume_path: %w", err) - } - - memoryStr, err := kv.GetFromDB(db, "vm/"+name+"/memory") - if err != nil { - return fmt.Errorf("get memory: %w", err) - } - memory, _ := strconv.Atoi(memoryStr) - - cpusStr, err := kv.GetFromDB(db, "vm/"+name+"/cpus") - if err != nil { - return fmt.Errorf("get cpus: %w", err) - } - cpus, _ := strconv.Atoi(cpusStr) - - password, _ := kv.GetFromDB(db, "vm/"+name+"/password") - sshkey, _ := kv.GetFromDB(db, "vm/"+name+"/sshkey") - - if err := netif.CreateTap(tapID, bridge, vpcName); err != nil { + if err := netif.CreateTap(d.tapID, d.bridge, d.vpcName); err != nil { return fmt.Errorf("create tap: %w", err) } - if err := netns.Call(vpcName, func() error { - return iptables.AddMetadataRedirect(vmIP, gatewayIP, metadataPort) + if err := netns.Call(d.vpcName, func() error { + return iptables.AddMetadataRedirect(d.ip, d.gatewayIP, d.metadataPort) }); err != nil { return fmt.Errorf("add metadata redirect: %w", err) } if err := metadata.StartMetadata(metadata.NoCloudConfig{ Name: name, - VpcName: vpcName, - BindIP: gatewayIP, - BindPort: metadataPort, - Password: password, - SSHKEY: sshkey, + VpcName: d.vpcName, + BindIP: d.gatewayIP, + BindPort: d.metadataPort, + Password: d.password, + SSHKEY: d.sshkey, }, db, false); err != nil { return fmt.Errorf("start metadata: %w", err) } diff --git a/internal/vm/data.go b/internal/vm/data.go new file mode 100644 index 0000000..379ce50 --- /dev/null +++ b/internal/vm/data.go @@ -0,0 +1,100 @@ +package vm + +import ( + "fmt" + "strconv" + "strings" + + "git.g3e.fr/syonad/two/pkg/db/kv" + "github.com/dgraph-io/badger/v4" +) + +type vmData struct { + subnetName string + vpcName string + gatewayIP string + bridge string + tapID int + ip string + metadataPort string + mac string + volumePath string + memory int + cpus int + password string + sshkey string +} + +func loadVM(db *badger.DB, name string) (vmData, error) { + var d vmData + + subnetName, err := kv.GetFromDB(db, "vm/"+name+"/subnet") + if err != nil { + return d, fmt.Errorf("get subnet: %w", err) + } + d.subnetName = subnetName + d.bridge = "br-" + strings.SplitN(subnetName, "-", 2)[1] + + vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") + if err != nil { + return d, fmt.Errorf("get vpc: %w", err) + } + d.vpcName = vpcName + + gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") + if err != nil { + return d, fmt.Errorf("get gateway_ip: %w", err) + } + d.gatewayIP = gatewayIP + + tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") + if err != nil { + return d, fmt.Errorf("get tap_id: %w", err) + } + tapID, err := strconv.Atoi(tapIDStr) + if err != nil { + return d, fmt.Errorf("parse tap_id: %w", err) + } + d.tapID = tapID + + ip, err := kv.GetFromDB(db, "vm/"+name+"/ip") + if err != nil { + return d, fmt.Errorf("get ip: %w", err) + } + d.ip = ip + + metadataPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_port") + if err != nil { + return d, fmt.Errorf("get metadata_port: %w", err) + } + d.metadataPort = metadataPort + + mac, err := kv.GetFromDB(db, "vm/"+name+"/mac") + if err != nil { + return d, fmt.Errorf("get mac: %w", err) + } + d.mac = mac + + volumePath, err := kv.GetFromDB(db, "vm/"+name+"/volume_path") + if err != nil { + return d, fmt.Errorf("get volume_path: %w", err) + } + d.volumePath = volumePath + + memoryStr, err := kv.GetFromDB(db, "vm/"+name+"/memory") + if err != nil { + return d, fmt.Errorf("get memory: %w", err) + } + d.memory, _ = strconv.Atoi(memoryStr) + + cpusStr, err := kv.GetFromDB(db, "vm/"+name+"/cpus") + if err != nil { + return d, fmt.Errorf("get cpus: %w", err) + } + d.cpus, _ = strconv.Atoi(cpusStr) + + d.password, _ = kv.GetFromDB(db, "vm/"+name+"/password") + d.sshkey, _ = kv.GetFromDB(db, "vm/"+name+"/sshkey") + + return d, nil +} diff --git a/internal/vm/delete.go b/internal/vm/delete.go index 0de2b73..faadb4b 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -2,7 +2,6 @@ package vm import ( "fmt" - "strconv" "time" configuration "git.g3e.fr/syonad/two/internal/config/agent" @@ -25,38 +24,9 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return nil } - subnetName, err := kv.GetFromDB(db, "vm/"+name+"/subnet") + d, err := loadVM(db, name) if err != nil { - return fmt.Errorf("get subnet: %w", err) - } - - vpcName, err := kv.GetFromDB(db, "subnet/"+subnetName+"/vpc") - if err != nil { - return fmt.Errorf("get vpc: %w", err) - } - - gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") - if err != nil { - return fmt.Errorf("get gateway_ip: %w", err) - } - - tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") - if err != nil { - return fmt.Errorf("get tap_id: %w", err) - } - tapID, err := strconv.Atoi(tapIDStr) - if err != nil { - return fmt.Errorf("parse tap_id: %w", err) - } - - vmIP, err := kv.GetFromDB(db, "vm/"+name+"/ip") - if err != nil { - return fmt.Errorf("get ip: %w", err) - } - - metadataPort, err := kv.GetFromDB(db, "vm/"+name+"/metadata_port") - if err != nil { - return fmt.Errorf("get metadata_port: %w", err) + return err } socketPath := fmt.Sprintf("/tmp/%s.qmp-sock", name) @@ -81,8 +51,8 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { } } - if err := netns.Call(vpcName, func() error { - return iptables.DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort) + if err := netns.Call(d.vpcName, func() error { + return iptables.DeleteMetadataRedirect(d.ip, d.gatewayIP, d.metadataPort) }); err != nil { return fmt.Errorf("delete metadata redirect: %w", err) } @@ -91,7 +61,7 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("stop metadata: %w", err) } - if err := netif.DeleteTap(tapID, vpcName); err != nil { + if err := netif.DeleteTap(d.tapID, d.vpcName); err != nil { return fmt.Errorf("delete tap: %w", err) } From 371ec7546895bc6af7f5cbc4c4408675dede6c3d Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 18:01:39 +0200 Subject: [PATCH 20/57] f-25: vm: generate tap_id Signed-off-by: GnomeZworc --- internal/vm/data.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/vm/data.go b/internal/vm/data.go index 379ce50..04184bd 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -2,6 +2,7 @@ package vm import ( "fmt" + "math/rand" "strconv" "strings" @@ -49,13 +50,17 @@ func loadVM(db *badger.DB, name string) (vmData, error) { tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") if err != nil { - return d, fmt.Errorf("get tap_id: %w", err) + d.tapID = rand.Intn(90000000) + 10000000 + if err := kv.AddInDB(db, "vm/"+name+"/tap_id", strconv.Itoa(d.tapID)); err != nil { + return d, fmt.Errorf("store tap_id: %w", err) + } + } else { + tapID, err := strconv.Atoi(tapIDStr) + if err != nil { + return d, fmt.Errorf("parse tap_id: %w", err) + } + d.tapID = tapID } - tapID, err := strconv.Atoi(tapIDStr) - if err != nil { - return d, fmt.Errorf("parse tap_id: %w", err) - } - d.tapID = tapID ip, err := kv.GetFromDB(db, "vm/"+name+"/ip") if err != nil { From b4092fbe02b7a98e1c42d8eb6e0d23b883d48836 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 18:08:04 +0200 Subject: [PATCH 21/57] f-25: dhcp: fill db with dhcp data Signed-off-by: GnomeZworc --- internal/dhcp/db.go | 19 +++++++++++++++++++ internal/dhcp/dhcp_test.go | 14 +++++++------- internal/dhcp/generate.go | 12 +++++++----- internal/subnet/create.go | 6 +++++- internal/subnet/delete.go | 3 +++ 5 files changed, 41 insertions(+), 13 deletions(-) create mode 100644 internal/dhcp/db.go diff --git a/internal/dhcp/db.go b/internal/dhcp/db.go new file mode 100644 index 0000000..c93aa1b --- /dev/null +++ b/internal/dhcp/db.go @@ -0,0 +1,19 @@ +package dhcp + +import ( + "git.g3e.fr/syonad/two/pkg/db/kv" + "github.com/dgraph-io/badger/v4" +) + +func StoreDHCPEntries(db *badger.DB, subnetName string, entries map[string]string) error { + for ip, mac := range entries { + if err := kv.AddInDB(db, "subnet/"+subnetName+"/dhcp/"+ip, mac); err != nil { + return err + } + } + return nil +} + +func GetMACForIP(db *badger.DB, subnetName, ip string) (string, error) { + return kv.GetFromDB(db, "subnet/"+subnetName+"/dhcp/"+ip) +} diff --git a/internal/dhcp/dhcp_test.go b/internal/dhcp/dhcp_test.go index c4a659f..1681fec 100644 --- a/internal/dhcp/dhcp_test.go +++ b/internal/dhcp/dhcp_test.go @@ -61,7 +61,7 @@ func newConf(t *testing.T, cidr string) Config { func TestGenerateConfig_CreatesFile(t *testing.T) { conf := newConf(t, "192.168.1.0/29") // 6 hôtes - path, err := GenerateConfig(conf) + path, _, err := GenerateConfig(conf) if err != nil { t.Fatalf("GenerateConfig a échoué : %v", err) } @@ -73,7 +73,7 @@ func TestGenerateConfig_CreatesFile(t *testing.T) { func TestGenerateConfig_FilenameMatchesName(t *testing.T) { conf := newConf(t, "192.168.1.0/29") - path, err := GenerateConfig(conf) + path, _, err := GenerateConfig(conf) if err != nil { t.Fatalf("GenerateConfig a échoué : %v", err) } @@ -86,7 +86,7 @@ func TestGenerateConfig_FilenameMatchesName(t *testing.T) { func TestGenerateConfig_ContainsGateway(t *testing.T) { conf := newConf(t, "192.168.1.0/29") - path, _ := GenerateConfig(conf) + path, _, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) if !strings.Contains(string(content), "dhcp-option=3,192.168.1.1") { @@ -102,7 +102,7 @@ func TestGenerateConfig_ContainsDhcpRange(t *testing.T) { Name: "vpc1", ConfDir: t.TempDir(), } - path, _ := GenerateConfig(conf) + path, _, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) if !strings.Contains(string(content), "dhcp-range=10.10.0.0,static,255.255.255.0,12h") { @@ -113,7 +113,7 @@ 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) + path, _, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) lines := strings.Split(string(content), "\n") @@ -131,7 +131,7 @@ func TestGenerateConfig_OneHostEntryPerIP(t *testing.T) { func TestGenerateConfig_MACPrefix(t *testing.T) { conf := newConf(t, "10.0.0.0/30") // 4 adresses - path, _ := GenerateConfig(conf) + path, _, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) if !strings.Contains(string(content), "00:22:33:") { @@ -148,7 +148,7 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) { Name: "net", ConfDir: dir, } - if _, err := GenerateConfig(conf); err != nil { + if _, _, err := GenerateConfig(conf); err != nil { t.Fatalf("GenerateConfig devrait créer les répertoires manquants : %v", err) } if _, err := os.Stat(dir); os.IsNotExist(err) { diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go index 01bfe20..3c46fe2 100644 --- a/internal/dhcp/generate.go +++ b/internal/dhcp/generate.go @@ -8,7 +8,7 @@ import ( "strings" ) -func GenerateConfig(c Config) (string, error) { +func GenerateConfig(c Config) (string, map[string]string, error) { mask := fmt.Sprintf("%d.%d.%d.%d", c.Network.Mask[0], c.Network.Mask[1], c.Network.Mask[2], c.Network.Mask[3]) var sb strings.Builder @@ -17,18 +17,20 @@ func GenerateConfig(c Config) (string, error) { fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.Gateway.String()) fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n\n") + entries := make(map[string]string) i := 0 for ip := cloneIP(c.Network.IP); c.Network.Contains(ip); incrementIP(ip) { - fmt.Fprintf(&sb, "dhcp-host=00:22:33:%02X:%02X:%02X,%s\n", - (i>>16)&0xFF, (i>>8)&0xFF, i&0xFF, 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 i++ } outPath := filepath.Join(c.ConfDir, c.Name+".conf") if err := os.MkdirAll(c.ConfDir, 0755); err != nil { - return "", err + return "", nil, err } - return outPath, os.WriteFile(outPath, []byte(sb.String()), 0644) + return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644) } func incrementIP(ip net.IP) { diff --git a/internal/subnet/create.go b/internal/subnet/create.go index 07658c6..5e80634 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -101,9 +101,13 @@ func CreateSubnet(db *badger.DB, subnetName string) error { Name: d.vpc + "_" + d.bridge, ConfDir: "/etc/dnsmasq.d", } - if _, err := dhcp.GenerateConfig(conf); err != nil { + _, entries, err := dhcp.GenerateConfig(conf) + if err != nil { return fmt.Errorf("generate dhcp config: %w", err) } + if err := dhcp.StoreDHCPEntries(db, subnetName, entries); err != nil { + return fmt.Errorf("store dhcp entries: %w", err) + } svc, err := systemd.New() if err != nil { diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index 8606cb9..e7e3929 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -42,6 +42,9 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { if err := os.Remove("/etc/dnsmasq.d/" + d.vpc + "_" + d.bridge + ".conf"); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove dnsmasq config: %w", err) } + if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil { + return fmt.Errorf("delete dhcp entries: %w", err) + } if err := ebtables.DeleteARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { return fmt.Errorf("delete ebtables arp rule: %w", err) From f005d9dde7a75896d6053cf715e61a709574f86a Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 18:08:50 +0200 Subject: [PATCH 22/57] f-25: vm: use db data to find mac address Signed-off-by: GnomeZworc --- internal/vm/data.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/vm/data.go b/internal/vm/data.go index 04184bd..dfceb49 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -6,6 +6,7 @@ import ( "strconv" "strings" + "git.g3e.fr/syonad/two/internal/dhcp" "git.g3e.fr/syonad/two/pkg/db/kv" "github.com/dgraph-io/badger/v4" ) @@ -74,9 +75,9 @@ func loadVM(db *badger.DB, name string) (vmData, error) { } d.metadataPort = metadataPort - mac, err := kv.GetFromDB(db, "vm/"+name+"/mac") + mac, err := dhcp.GetMACForIP(db, d.subnetName, d.ip) if err != nil { - return d, fmt.Errorf("get mac: %w", err) + return d, fmt.Errorf("get mac for ip %s: %w", d.ip, err) } d.mac = mac From c17ee79183d3740828710a3c487d12b02cfe7df4 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 18:17:15 +0200 Subject: [PATCH 23/57] f-25: dispatch: add vm dispatcher Signed-off-by: GnomeZworc --- internal/dispatcher/agent/vm_commands.go | 95 ++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 internal/dispatcher/agent/vm_commands.go diff --git a/internal/dispatcher/agent/vm_commands.go b/internal/dispatcher/agent/vm_commands.go new file mode 100644 index 0000000..c0245ca --- /dev/null +++ b/internal/dispatcher/agent/vm_commands.go @@ -0,0 +1,95 @@ +package dispatcher + +import ( + "fmt" + "strconv" + "time" + + configuration "git.g3e.fr/syonad/two/internal/config/agent" + "git.g3e.fr/syonad/two/internal/vm" + "git.g3e.fr/syonad/two/pkg/db/kv" + "github.com/dgraph-io/badger/v4" +) + +type StartVMCommand struct { + Name string + Subnet string + IP string + MetadataPort string + VolumePath string + Memory int + CPUs int + Password string + SSHKey string +} + +func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { + if _, err := kv.GetFromDB(db, "vm/"+c.Name+"/state"); err == nil { + return fmt.Errorf("vm %q already exists", c.Name) + } + subnetState, err := kv.GetFromDB(db, "subnet/"+c.Subnet+"/state") + if err != nil { + return fmt.Errorf("subnet %q not found", c.Subnet) + } + if subnetState == "deleting" || subnetState == "deleted" { + return fmt.Errorf("subnet %q is %s", c.Subnet, subnetState) + } + kv.AddInDB(db, "vm/"+c.Name+"/state", "starting") + kv.AddInDB(db, "vm/"+c.Name+"/subnet", c.Subnet) + kv.AddInDB(db, "vm/"+c.Name+"/ip", c.IP) + kv.AddInDB(db, "vm/"+c.Name+"/metadata_port", c.MetadataPort) + kv.AddInDB(db, "vm/"+c.Name+"/volume_path", c.VolumePath) + kv.AddInDB(db, "vm/"+c.Name+"/memory", strconv.Itoa(c.Memory)) + kv.AddInDB(db, "vm/"+c.Name+"/cpus", strconv.Itoa(c.CPUs)) + if c.Password != "" { + kv.AddInDB(db, "vm/"+c.Name+"/password", c.Password) + } + if c.SSHKey != "" { + kv.AddInDB(db, "vm/"+c.Name+"/sshkey", c.SSHKey) + } + return nil +} + +func (c StartVMCommand) Execute(db *badger.DB, cfg *configuration.Config) error { + timeout := time.After(time.Duration(cfg.Dispatcher.TimeoutSeconds) * time.Second) + for { + state, err := kv.GetFromDB(db, "subnet/"+c.Subnet+"/state") + if err != nil { + return fmt.Errorf("subnet %q not found while waiting", c.Subnet) + } + if state == "created" { + break + } + select { + case <-timeout: + return fmt.Errorf("timed out waiting for subnet %q to be created", c.Subnet) + case <-time.After(time.Duration(cfg.Dispatcher.PollSeconds) * time.Second): + } + } + return vm.StartVM(db, c.Name) +} + +type StopVMCommand struct { + Name string +} + +func (c StopVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { + if _, err := kv.GetFromDB(db, "vm/"+c.Name+"/state"); err != nil { + return fmt.Errorf("vm %q not found", c.Name) + } + return kv.AddInDB(db, "vm/"+c.Name+"/state", "stopping") +} + +func (c StopVMCommand) Execute(db *badger.DB, cfg *configuration.Config) error { + if err := vm.StopVM(db, c.Name, cfg); err != nil { + return err + } + state, err := kv.GetFromDB(db, "vm/"+c.Name+"/state") + if err != nil { + return err + } + if state == "stopped" { + kv.DeleteInDB(db, "vm/"+c.Name) + } + return nil +} From 1ea3a986b8717a9de5abbcd3d6c69776cfd05d1c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 20:09:34 +0200 Subject: [PATCH 24/57] f-25: api: add vms routes Signed-off-by: GnomeZworc --- api/agent.yaml | 180 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/api/agent.yaml b/api/agent.yaml index da20853..00d4fd8 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -99,6 +99,95 @@ paths: "500": $ref: "#/components/responses/InternalError" + # ── VM ───────────────────────────────────────────────────────────────────── + + /vms: + get: + summary: List all VMs + operationId: listVMs + responses: + "200": + description: List of VMs + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/VM" + "500": + $ref: "#/components/responses/InternalError" + + post: + summary: Start a VM + operationId: startVM + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/VMCreateRequest" + responses: + "202": + description: VM start accepted + content: + application/json: + schema: + $ref: "#/components/schemas/VM" + "400": + description: Missing required field or invalid request body + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "409": + description: VM already exists + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "422": + description: Subnet not found or not in created state + content: + application/json: + schema: + $ref: "#/components/schemas/Error" + "500": + $ref: "#/components/responses/InternalError" + + /vms/{name}: + parameters: + - $ref: "#/components/parameters/ResourceName" + + get: + summary: Get VM status and info + operationId: getVM + responses: + "200": + description: VM found + content: + application/json: + schema: + $ref: "#/components/schemas/VM" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + + delete: + summary: Stop a VM + operationId: stopVM + responses: + "202": + description: VM stop accepted + content: + application/json: + schema: + $ref: "#/components/schemas/VM" + "404": + $ref: "#/components/responses/NotFound" + "500": + $ref: "#/components/responses/InternalError" + # ── Subnet ───────────────────────────────────────────────────────────────── /subnets: @@ -281,6 +370,97 @@ components: type: string example: "10.10.10.0/24" + VMCreateRequest: + type: object + required: [name, metadata_port, interfaces, storage] + properties: + name: + type: string + example: vm-00001 + metadata_port: + type: string + example: "80" + memory: + type: integer + description: Memory in MB (default 512) + example: 1024 + cpus: + type: integer + description: Number of vCPUs (default 1) + example: 2 + password: + type: string + sshkey: + type: string + example: "ssh-ed25519 AAAA..." + interfaces: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/VMInterface" + storage: + type: array + minItems: 1 + items: + $ref: "#/components/schemas/VMStorage" + + VMInterface: + type: object + required: [subnet, ip, primary] + properties: + subnet: + type: string + example: sn-00001 + ip: + type: string + format: ipv4 + example: "10.0.0.5" + primary: + type: boolean + example: true + + VMStorage: + type: object + required: [path, dev] + properties: + path: + type: string + description: Path to the disk image on the host + example: /var/lib/two/volumes/abc.qcow2 + dev: + type: string + description: Device name inside the VM + pattern: '^[sv]d[a-z]$' + example: vda + + VM: + type: object + properties: + name: + type: string + example: vm-00001 + state: + type: string + enum: [starting, started, stopping, stopped] + example: started + metadata_port: + type: string + example: "80" + memory: + type: integer + example: 1024 + cpus: + type: integer + example: 2 + interfaces: + type: array + items: + $ref: "#/components/schemas/VMInterface" + storage: + type: array + items: + $ref: "#/components/schemas/VMStorage" + Error: type: object properties: From 593bd42e6a3f5db01dbd0ff42f62eb189108a58f Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 20:50:21 +0200 Subject: [PATCH 25/57] f-25: api: add a new api for vms Signed-off-by: GnomeZworc --- internal/api/agent/models.go | 32 ++++++++++ internal/api/agent/server.go | 2 + internal/api/agent/vm.go | 94 +++++++++++++++++++++++++++++ internal/api/agent/vms.go | 112 +++++++++++++++++++++++++++++++++++ 4 files changed, 240 insertions(+) create mode 100644 internal/api/agent/vm.go create mode 100644 internal/api/agent/vms.go diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index e826082..f13407b 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -28,6 +28,38 @@ type Subnet struct { CIDR string `json:"cidr"` } +type VMInterface struct { + Subnet string `json:"subnet"` + IP string `json:"ip"` + Primary bool `json:"primary"` +} + +type VMStorage struct { + Path string `json:"path"` + Dev string `json:"dev"` +} + +type VMCreateRequest struct { + Name string `json:"name"` + MetadataPort string `json:"metadata_port"` + Memory int `json:"memory"` + CPUs int `json:"cpus"` + Password string `json:"password"` + SSHKey string `json:"sshkey"` + Interfaces []VMInterface `json:"interfaces"` + Storage []VMStorage `json:"storage"` +} + +type VM struct { + Name string `json:"name"` + State string `json:"state"` + MetadataPort string `json:"metadata_port"` + Memory int `json:"memory"` + CPUs int `json:"cpus"` + Interfaces []VMInterface `json:"interfaces"` + Storage []VMStorage `json:"storage"` +} + type ErrorResponse struct { Error string `json:"error"` } diff --git a/internal/api/agent/server.go b/internal/api/agent/server.go index 8e3c4e3..4a0fba4 100644 --- a/internal/api/agent/server.go +++ b/internal/api/agent/server.go @@ -27,6 +27,8 @@ func (s *Server) Start(address string) { mux.HandleFunc("/vpcs/", s.VpcByNameHandler) mux.HandleFunc("/subnets", s.SubnetsHandler) mux.HandleFunc("/subnets/", s.SubnetByNameHandler) + mux.HandleFunc("/vms", s.VmsHandler) + mux.HandleFunc("/vms/", s.VmByNameHandler) s.logger.Info("API server listening", "address", address) if err := http.ListenAndServe(address, s.logMiddleware(mux)); err != nil { s.logger.Error("API server stopped", "error", err) diff --git a/internal/api/agent/vm.go b/internal/api/agent/vm.go new file mode 100644 index 0000000..a150d16 --- /dev/null +++ b/internal/api/agent/vm.go @@ -0,0 +1,94 @@ +package agentapi + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + + dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +func (s *Server) VmByNameHandler(w http.ResponseWriter, r *http.Request) { + name := strings.TrimPrefix(r.URL.Path, "/vms/") + if name == "" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(ErrorResponse{Error: "resource not found"}) + return + } + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + s.getVM(w, r, name) + case http.MethodDelete: + s.stopVM(w, r, name) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(ErrorResponse{Error: "method not allowed"}) + } +} + +func (s *Server) getVM(w http.ResponseWriter, _ *http.Request, name string) { + entries, err := kv.ListByPrefix(s.db, "vm/"+name+"/") + if err != nil || len(entries) == 0 { + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(ErrorResponse{Error: "vm not found"}) + return + } + vm, err := vmFromDB(name, entries) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vm"}) + return + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(vm) +} + +func (s *Server) stopVM(w http.ResponseWriter, _ *http.Request, name string) { + cmd := dispatcher.StopVMCommand{Name: name} + if err := s.dispatcher.Prepare(cmd); err != nil { + if _, dbErr := kv.GetFromDB(s.db, "vm/"+name+"/state"); dbErr != nil { + w.WriteHeader(http.StatusNotFound) + } else { + w.WriteHeader(http.StatusConflict) + } + json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) + return + } + s.dispatcher.Dispatch(cmd) + + entries, _ := kv.ListByPrefix(s.db, "vm/"+name+"/") + vm, err := vmFromDB(name, entries) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vm state"}) + return + } + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(vm) +} + +func vmFromDB(name string, entries map[string]string) (VM, error) { + prefix := "vm/" + name + "/" + vm := VM{Name: name} + + vm.State = entries[prefix+"state"] + vm.MetadataPort = entries[prefix+"metadata_port"] + vm.Memory, _ = strconv.Atoi(entries[prefix+"memory"]) + vm.CPUs, _ = strconv.Atoi(entries[prefix+"cpus"]) + + subnet := entries[prefix+"subnet"] + ip := entries[prefix+"ip"] + if subnet != "" || ip != "" { + vm.Interfaces = []VMInterface{{Subnet: subnet, IP: ip, Primary: true}} + } + + if path := entries[prefix+"volume_path"]; path != "" { + vm.Storage = []VMStorage{{Path: path}} + } + + return vm, nil +} diff --git a/internal/api/agent/vms.go b/internal/api/agent/vms.go new file mode 100644 index 0000000..841c429 --- /dev/null +++ b/internal/api/agent/vms.go @@ -0,0 +1,112 @@ +package agentapi + +import ( + "encoding/json" + "net/http" + "strings" + + dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +func (s *Server) VmsHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.Method { + case http.MethodGet: + s.listVMs(w, r) + case http.MethodPost: + s.startVM(w, r) + default: + w.WriteHeader(http.StatusMethodNotAllowed) + json.NewEncoder(w).Encode(ErrorResponse{Error: "method not allowed"}) + } +} + +func (s *Server) listVMs(w http.ResponseWriter, _ *http.Request) { + entries, err := kv.ListByPrefix(s.db, "vm/") + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to list vms"}) + return + } + + names := map[string]struct{}{} + for key := range entries { + parts := strings.Split(key, "/") + if len(parts) >= 2 { + names[parts[1]] = struct{}{} + } + } + + result := make([]VM, 0, len(names)) + for name := range names { + vm, err := vmFromDB(name, entries) + if err != nil { + continue + } + result = append(result, vm) + } + + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(result) +} + +func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { + var req VMCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"}) + return + } + if req.Name == "" || req.MetadataPort == "" || len(req.Interfaces) == 0 || len(req.Storage) == 0 { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "name, metadata_port, interfaces and storage are required"}) + return + } + + var primary *VMInterface + for i := range req.Interfaces { + if req.Interfaces[i].Primary { + primary = &req.Interfaces[i] + break + } + } + if primary == nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "one interface must be primary"}) + return + } + + cmd := dispatcher.StartVMCommand{ + Name: req.Name, + Subnet: primary.Subnet, + IP: primary.IP, + MetadataPort: req.MetadataPort, + VolumePath: req.Storage[0].Path, + Memory: req.Memory, + CPUs: req.CPUs, + Password: req.Password, + SSHKey: req.SSHKey, + } + + if err := s.dispatcher.Prepare(cmd); err != nil { + if _, dbErr := kv.GetFromDB(s.db, "vm/"+req.Name+"/state"); dbErr == nil { + w.WriteHeader(http.StatusConflict) + } else { + w.WriteHeader(http.StatusUnprocessableEntity) + } + json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) + return + } + s.dispatcher.Dispatch(cmd) + + entries, _ := kv.ListByPrefix(s.db, "vm/"+req.Name+"/") + vm, err := vmFromDB(req.Name, entries) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + json.NewEncoder(w).Encode(ErrorResponse{Error: "failed to read vm state"}) + return + } + w.WriteHeader(http.StatusAccepted) + json.NewEncoder(w).Encode(vm) +} From 7078691db6a6b41eee891a9b775c5036082e0dc3 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 20:52:25 +0200 Subject: [PATCH 26/57] f-25: bug: fix errors and add verif Signed-off-by: GnomeZworc --- internal/qemu/start_other.go | 2 +- internal/vm/data.go | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/qemu/start_other.go b/internal/qemu/start_other.go index 9201a2d..782a7ee 100644 --- a/internal/qemu/start_other.go +++ b/internal/qemu/start_other.go @@ -9,6 +9,6 @@ type Config struct { TapID, Memory, CPUs int } -func Start(cfg Config) error { +func Start(_ Config) error { return errors.New("vm: not supported on this platform") } diff --git a/internal/vm/data.go b/internal/vm/data.go index dfceb49..0563fc0 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -91,13 +91,19 @@ func loadVM(db *badger.DB, name string) (vmData, error) { if err != nil { return d, fmt.Errorf("get memory: %w", err) } - d.memory, _ = strconv.Atoi(memoryStr) + d.memory, err = strconv.Atoi(memoryStr) + if err != nil { + return d, fmt.Errorf("parse memory: %w", err) + } cpusStr, err := kv.GetFromDB(db, "vm/"+name+"/cpus") if err != nil { return d, fmt.Errorf("get cpus: %w", err) } - d.cpus, _ = strconv.Atoi(cpusStr) + d.cpus, err = strconv.Atoi(cpusStr) + if err != nil { + return d, fmt.Errorf("parse cpus: %w", err) + } d.password, _ = kv.GetFromDB(db, "vm/"+name+"/password") d.sshkey, _ = kv.GetFromDB(db, "vm/"+name+"/sshkey") From e545b70d5330ebdfd4e9737f8951017d796092b4 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 29 Apr 2026 00:21:55 +0200 Subject: [PATCH 27/57] f-25: add propre log for init db Signed-off-by: GnomeZworc --- pkg/db/kv/init.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pkg/db/kv/init.go b/pkg/db/kv/init.go index 677c656..0ee2374 100644 --- a/pkg/db/kv/init.go +++ b/pkg/db/kv/init.go @@ -1,6 +1,8 @@ package kv import ( + "log" + "github.com/dgraph-io/badger/v4" ) @@ -15,7 +17,7 @@ func InitDB(conf Config, readonly bool) *badger.DB { opts.NumLevelZeroTablesStall = 2 db, err := badger.Open(opts) if err != nil { - panic(err) + log.Fatalf("kv.InitDB (readonly=%v, path=%s): %v", readonly, conf.Path, err) } return db } From 4bef0f9d5f90c39f16114f01d601277564155f01 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 29 Apr 2026 00:33:22 +0200 Subject: [PATCH 28/57] f-25: config: add metadata dir Signed-off-by: GnomeZworc --- conf/agent/config.exemple.yml | 4 ++++ internal/config/agent/struct.go | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/conf/agent/config.exemple.yml b/conf/agent/config.exemple.yml index fb8e604..89eb90f 100644 --- a/conf/agent/config.exemple.yml +++ b/conf/agent/config.exemple.yml @@ -35,6 +35,10 @@ interfaces: internet: br-000000 admin: br-000000 +# Metadata server runtime directory (cloud-init files per VM) +metadata: + run_dir: "/run/two/metadata" + # Logging configuration logger: # Log level: debug, info, warn, error (default: info) diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index d8e4ee5..69a4492 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -28,6 +28,9 @@ type Config struct { Level string `mapstructure:"level"` Debug bool `mapstructure:"debug"` } `mapstructure:"logger"` + Metadata struct { + RunDir string `mapstructure:"run_dir"` + } `mapstructure:"metadata"` DefaultInterface string `mapstructure:"default_interface"` Interfaces map[string]string `mapstructure:"interfaces"` } @@ -46,6 +49,7 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("worker.buffer_size", 100) v.SetDefault("dispatcher.timeout_seconds", 300) v.SetDefault("dispatcher.poll_seconds", 2) + v.SetDefault("metadata.run_dir", "/run/two/metadata") v.SetDefault("default_interface", "br-000000") v.SetDefault("logger.level", "info") v.SetDefault("logger.debug", false) From 915581904c9e38dc7bf7332bbcb8a54838b51481 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 29 Apr 2026 00:36:01 +0200 Subject: [PATCH 29/57] f-25: code: add cfg to start vm Signed-off-by: GnomeZworc --- internal/dispatcher/agent/vm_commands.go | 2 +- internal/vm/create.go | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/dispatcher/agent/vm_commands.go b/internal/dispatcher/agent/vm_commands.go index c0245ca..aade998 100644 --- a/internal/dispatcher/agent/vm_commands.go +++ b/internal/dispatcher/agent/vm_commands.go @@ -66,7 +66,7 @@ func (c StartVMCommand) Execute(db *badger.DB, cfg *configuration.Config) error case <-time.After(time.Duration(cfg.Dispatcher.PollSeconds) * time.Second): } } - return vm.StartVM(db, c.Name) + return vm.StartVM(db, c.Name, cfg) } type StopVMCommand struct { diff --git a/internal/vm/create.go b/internal/vm/create.go index 4737e62..b24fa80 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -3,6 +3,7 @@ package vm import ( "fmt" + configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/iptables" "git.g3e.fr/syonad/two/internal/metadata" "git.g3e.fr/syonad/two/internal/netif" @@ -13,7 +14,7 @@ import ( "github.com/dgraph-io/badger/v4" ) -func StartVM(db *badger.DB, name string) error { +func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { state, err := kv.GetFromDB(db, "vm/"+name+"/state") if err != nil { return err From f5707c343c4251c3e162200675d4982205ae11c7 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 29 Apr 2026 00:41:43 +0200 Subject: [PATCH 30/57] f-25: add load files Signed-off-by: GnomeZworc --- internal/metadata/handle.go | 14 +++++++------- internal/metadata/render.go | 29 ++++++++++++++++------------- internal/vm/create.go | 2 +- internal/vm/delete.go | 2 +- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/internal/metadata/handle.go b/internal/metadata/handle.go index 9ec5486..d34dd20 100644 --- a/internal/metadata/handle.go +++ b/internal/metadata/handle.go @@ -3,18 +3,18 @@ package metadata import ( "fmt" + configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/pkg/systemd" - "github.com/dgraph-io/badger/v4" ) -func StartMetadata(config NoCloudConfig, db *badger.DB, dryrun bool) error { +func StartMetadata(config NoCloudConfig, cfg *configuration.Config, dryrun bool) error { service, err := systemd.New() if err != nil { return fmt.Errorf("failed to connect to systemd: %w", err) } defer service.Close() - LoadNcCloudInDB(config, db) + LoadNcCloudInDB(config, cfg.Metadata.RunDir) if !dryrun { if err := service.Start("metadata@" + config.Name + ".service"); err != nil { return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err) @@ -23,17 +23,17 @@ func StartMetadata(config NoCloudConfig, db *badger.DB, dryrun bool) error { return nil } -func StopMetadata(vm_name string, db *badger.DB, dryrun bool) error { +func StopMetadata(vmName string, cfg *configuration.Config, dryrun bool) error { service, err := systemd.New() if err != nil { return fmt.Errorf("failed to connect to systemd: %w", err) } defer service.Close() - UnLoadNoCloudInDB(vm_name, db) + UnLoadNoCloudInDB(vmName, cfg.Metadata.RunDir) if !dryrun { - if err := service.Stop("metadata@" + vm_name + ".service"); err != nil { - return fmt.Errorf("failed to stop metadata@%s: %w", vm_name, err) + if err := service.Stop("metadata@" + vmName + ".service"); err != nil { + return fmt.Errorf("failed to stop metadata@%s: %w", vmName, err) } } return nil diff --git a/internal/metadata/render.go b/internal/metadata/render.go index f6c3cae..0d4fd59 100644 --- a/internal/metadata/render.go +++ b/internal/metadata/render.go @@ -3,10 +3,9 @@ package metadata import ( "bytes" "embed" + "os" + "path/filepath" "text/template" - - "git.g3e.fr/syonad/two/pkg/db/kv" - "github.com/dgraph-io/badger/v4" ) //go:embed templates/*.tmpl @@ -26,21 +25,25 @@ func RenderConfig(path string, cfg NoCloudConfig) (string, error) { return buf.String(), nil } -func LoadNcCloudInDB(config NoCloudConfig, db *badger.DB) { +func LoadNcCloudInDB(config NoCloudConfig, runDir string) { meta_data, _ := RenderConfig("templates/meta-data.tmpl", config) user_data, _ := RenderConfig("templates/user-data.tmpl", config) network_config, _ := RenderConfig("templates/network-config.tmpl", config) vendor_data, _ := RenderConfig("templates/vendor-data.tmpl", config) - kv.AddInDB(db, "metadata/"+config.Name+"/meta-data", meta_data) - kv.AddInDB(db, "metadata/"+config.Name+"/user-data", user_data) - kv.AddInDB(db, "metadata/"+config.Name+"/network-config", network_config) - kv.AddInDB(db, "metadata/"+config.Name+"/vendor-data", vendor_data) - kv.AddInDB(db, "metadata/"+config.Name+"/vpc", config.VpcName) - kv.AddInDB(db, "metadata/"+config.Name+"/bind_ip", config.BindIP) - kv.AddInDB(db, "metadata/"+config.Name+"/bind_port", config.BindPort) + dir := filepath.Join(runDir, config.Name) + if err := os.MkdirAll(dir, 0755); err != nil { + return + } + os.WriteFile(filepath.Join(dir, "meta-data"), []byte(meta_data), 0644) + os.WriteFile(filepath.Join(dir, "user-data"), []byte(user_data), 0644) + os.WriteFile(filepath.Join(dir, "network-config"), []byte(network_config), 0644) + os.WriteFile(filepath.Join(dir, "vendor-data"), []byte(vendor_data), 0644) + os.WriteFile(filepath.Join(dir, "vpc"), []byte(config.VpcName), 0644) + os.WriteFile(filepath.Join(dir, "bind_ip"), []byte(config.BindIP), 0644) + os.WriteFile(filepath.Join(dir, "bind_port"), []byte(config.BindPort), 0644) } -func UnLoadNoCloudInDB(vm_name string, db *badger.DB) { - kv.DeleteInDB(db, "metadata/"+vm_name) +func UnLoadNoCloudInDB(vmName string, runDir string) { + os.RemoveAll(filepath.Join(runDir, vmName)) } diff --git a/internal/vm/create.go b/internal/vm/create.go index b24fa80..53e2e96 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -45,7 +45,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { BindPort: d.metadataPort, Password: d.password, SSHKEY: d.sshkey, - }, db, false); err != nil { + }, cfg, false); err != nil { return fmt.Errorf("start metadata: %w", err) } diff --git a/internal/vm/delete.go b/internal/vm/delete.go index faadb4b..5030339 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -57,7 +57,7 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("delete metadata redirect: %w", err) } - if err := metadata.StopMetadata(name, db, false); err != nil { + if err := metadata.StopMetadata(name, cfg, false); err != nil { return fmt.Errorf("stop metadata: %w", err) } From 862406f0419fd39b872b56e16d304e298e96408c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 29 Apr 2026 00:48:55 +0200 Subject: [PATCH 31/57] f-25: use file for metadata Signed-off-by: GnomeZworc --- cmd/metadata/main.go | 23 ++--- internal/metadata/metadata_test.go | 129 +++++++++++------------------ internal/metadata/server.go | 57 ++++--------- internal/metadata/struct.go | 8 +- 4 files changed, 80 insertions(+), 137 deletions(-) diff --git a/cmd/metadata/main.go b/cmd/metadata/main.go index 96e3f75..82abaed 100644 --- a/cmd/metadata/main.go +++ b/cmd/metadata/main.go @@ -2,26 +2,29 @@ package main import ( "flag" + "fmt" + "os" + configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/metadata" ) var ( - iface = flag.String("interface", "0.0.0.0", "Interface IP à écouter") - port = flag.Int("port", 0, "Port à utiliser") - netns_name = flag.String("netns", "", "Network namespace à utiliser") - conf_file = flag.String("conf", "/etc/two/agent.yml", "configuration file") - vm_name = flag.String("vm", "", "Name of the vm") + confFile = flag.String("conf", "/etc/two/agent.yml", "configuration file") + vm_name = flag.String("vm", "", "Name of the vm") ) func main() { flag.Parse() + cfg, err := configuration.LoadConfig(*confFile) + if err != nil { + fmt.Fprintf(os.Stderr, "failed to load config: %v\n", err) + os.Exit(1) + } + metadata.StartServer(metadata.ServerConfig{ - Netns: *netns_name, - Iface: *iface, - Port: *port, - ConfFile: *conf_file, - VmName: *vm_name, + VmName: *vm_name, + RunDir: cfg.Metadata.RunDir, }) } diff --git a/internal/metadata/metadata_test.go b/internal/metadata/metadata_test.go index 5884151..cc54530 100644 --- a/internal/metadata/metadata_test.go +++ b/internal/metadata/metadata_test.go @@ -3,10 +3,10 @@ package metadata import ( "net/http" "net/http/httptest" + "os" + "path/filepath" "strings" "testing" - - "git.g3e.fr/syonad/two/pkg/db/kv" ) func newCfg() NoCloudConfig { @@ -20,11 +20,9 @@ func newCfg() NoCloudConfig { } } -func newTestDB(t *testing.T) interface{ Close() error } { +func useTestDir(t *testing.T) string { t.Helper() - db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) - t.Cleanup(func() { db.Close() }) - return db + return t.TempDir() } // --- RenderConfig --- @@ -108,78 +106,67 @@ func TestRenderConfig_SpecialCharsInName(t *testing.T) { // --- LoadNcCloudInDB / UnLoadNoCloudInDB --- -func TestLoadNcCloudInDB_StoresAllKeys(t *testing.T) { - db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) - t.Cleanup(func() { db.Close() }) - - cfg := newCfg() - LoadNcCloudInDB(cfg, db) - - keys := []string{ - "metadata/vm1/meta-data", - "metadata/vm1/user-data", - "metadata/vm1/network-config", - "metadata/vm1/vendor-data", - "metadata/vm1/vpc", - "metadata/vm1/bind_ip", - "metadata/vm1/bind_port", +func readTestFile(t *testing.T, dir, vmName, name string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, vmName, name)) + if err != nil { + t.Errorf("fichier %q absent après LoadNcCloudInDB : %v", name, err) + return "" } - for _, key := range keys { - val, err := kv.GetFromDB(db, key) - if err != nil { - t.Errorf("clé %q absente après LoadNcCloudInDB : %v", key, err) - } - if val == "" && key != "metadata/vm1/user-data" { - t.Errorf("clé %q vide après LoadNcCloudInDB", key) + return string(b) +} + +func TestLoadNcCloudInDB_StoresAllFiles(t *testing.T) { + dir := useTestDir(t) + LoadNcCloudInDB(newCfg(), dir) + + files := []string{"meta-data", "user-data", "network-config", "vendor-data", "vpc", "bind_ip", "bind_port"} + for _, f := range files { + path := filepath.Join(dir, "vm1", f) + if _, err := os.Stat(path); err != nil { + t.Errorf("fichier %q absent : %v", f, err) } } } func TestLoadNcCloudInDB_VpcAndBindValues(t *testing.T) { - db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) - t.Cleanup(func() { db.Close() }) + dir := useTestDir(t) + LoadNcCloudInDB(newCfg(), dir) - cfg := newCfg() - LoadNcCloudInDB(cfg, db) - - vpc, _ := kv.GetFromDB(db, "metadata/vm1/vpc") - if vpc != "vpc-test" { + if vpc := readTestFile(t, dir, "vm1", "vpc"); vpc != "vpc-test" { t.Errorf("vpc attendu %q, obtenu %q", "vpc-test", vpc) } - - ip, _ := kv.GetFromDB(db, "metadata/vm1/bind_ip") - if ip != "169.254.169.254" { + if ip := readTestFile(t, dir, "vm1", "bind_ip"); ip != "169.254.169.254" { t.Errorf("bind_ip attendu %q, obtenu %q", "169.254.169.254", ip) } - - port, _ := kv.GetFromDB(db, "metadata/vm1/bind_port") - if port != "80" { + if port := readTestFile(t, dir, "vm1", "bind_port"); port != "80" { t.Errorf("bind_port attendu %q, obtenu %q", "80", port) } } -func TestUnLoadNoCloudInDB_RemovesAllKeys(t *testing.T) { - db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) - t.Cleanup(func() { db.Close() }) +func TestUnLoadNoCloudInDB_RemovesAllFiles(t *testing.T) { + dir := useTestDir(t) + LoadNcCloudInDB(newCfg(), dir) + UnLoadNoCloudInDB("vm1", dir) - cfg := newCfg() - LoadNcCloudInDB(cfg, db) - UnLoadNoCloudInDB("vm1", db) - - keys := []string{ - "metadata/vm1/meta-data", - "metadata/vm1/user-data", - "metadata/vm1/network-config", - "metadata/vm1/vendor-data", - "metadata/vm1/vpc", - "metadata/vm1/bind_ip", - "metadata/vm1/bind_port", + if _, err := os.Stat(filepath.Join(dir, "vm1")); !os.IsNotExist(err) { + t.Error("répertoire vm1 devrait être supprimé après UnLoadNoCloudInDB") } - for _, key := range keys { - _, err := kv.GetFromDB(db, key) - if err == nil { - t.Errorf("clé %q devrait être supprimée après UnLoadNoCloudInDB", key) - } +} + +func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) { + dir := useTestDir(t) + + cfg1 := newCfg() + cfg2 := newCfg() + cfg2.Name = "vm2" + LoadNcCloudInDB(cfg1, dir) + LoadNcCloudInDB(cfg2, dir) + + UnLoadNoCloudInDB("vm1", dir) + + if _, err := os.Stat(filepath.Join(dir, "vm2", "vpc")); err != nil { + t.Errorf("vm2 ne devrait pas être supprimée : %v", err) } } @@ -277,23 +264,3 @@ func TestRootHandler_ContentType(t *testing.T) { t.Errorf("Content-Type attendu text/yaml, obtenu %q", ct) } } - -// --- UnLoadNoCloudInDB_DoesNotAffectOtherVMs --- - -func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) { - db := kv.InitDB(kv.Config{Path: t.TempDir()}, false) - t.Cleanup(func() { db.Close() }) - - cfg1 := newCfg() - cfg2 := newCfg() - cfg2.Name = "vm2" - LoadNcCloudInDB(cfg1, db) - LoadNcCloudInDB(cfg2, db) - - UnLoadNoCloudInDB("vm1", db) - - _, err := kv.GetFromDB(db, "metadata/vm2/vpc") - if err != nil { - t.Errorf("vm2 ne devrait pas être supprimée : %v", err) - } -} diff --git a/internal/metadata/server.go b/internal/metadata/server.go index 3b4e12f..b1f8943 100644 --- a/internal/metadata/server.go +++ b/internal/metadata/server.go @@ -5,12 +5,13 @@ import ( "log" "net" "net/http" + "os" + "path/filepath" "strconv" + "strings" "time" - configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/netns" - "git.g3e.fr/syonad/two/pkg/db/kv" ) var data NoCloudData @@ -23,47 +24,23 @@ func getIP(r *http.Request) string { return ip } -func getFromDB(config ServerConfig) NoCloudData { - var netns_name string - var port int - var iface string +func readFile(dir, name string) string { + b, _ := os.ReadFile(filepath.Join(dir, name)) + return strings.TrimRight(string(b), "\n") +} - conf_db, _ := configuration.LoadConfig(config.ConfFile) +func getFromFiles(config ServerConfig) NoCloudData { + dir := filepath.Join(config.RunDir, config.VmName) - db := kv.InitDB(kv.Config{Path: conf_db.Database.Path}, true) - defer db.Close() - - metadata, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/meta-data") - userdata, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/user-data") - networkconfig, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/network-config") - vendordata, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/vendor-data") - - if config.Netns == "" { - netns_name, _ = kv.GetFromDB(db, "metadata/"+config.VmName+"/vpc") - } else { - netns_name = config.Netns - } - - if config.Iface == "" { - iface, _ = kv.GetFromDB(db, "metadata/"+config.VmName+"/bind_ip") - } else { - iface = config.Iface - } - - if config.Port == 0 { - sport, _ := kv.GetFromDB(db, "metadata/"+config.VmName+"/bind_port") - port, _ = strconv.Atoi(sport) - } else { - port = config.Port - } + port, _ := strconv.Atoi(readFile(dir, "bind_port")) return NoCloudData{ - MetaData: metadata, - UserData: userdata, - NetworkConfig: networkconfig, - VendorData: vendordata, - NetNs: netns_name, - Iface: iface, + MetaData: readFile(dir, "meta-data"), + UserData: readFile(dir, "user-data"), + NetworkConfig: readFile(dir, "network-config"), + VendorData: readFile(dir, "vendor-data"), + NetNs: readFile(dir, "vpc"), + Iface: readFile(dir, "bind_ip"), Port: port, } } @@ -93,7 +70,7 @@ func rootHandler(w http.ResponseWriter, r *http.Request) { } func StartServer(config ServerConfig) { - data = getFromDB(config) + data = getFromFiles(config) if data.NetNs != "" { if err := netns.Enter(data.NetNs); err != nil { diff --git a/internal/metadata/struct.go b/internal/metadata/struct.go index 5810ff4..e706087 100644 --- a/internal/metadata/struct.go +++ b/internal/metadata/struct.go @@ -11,12 +11,8 @@ type NoCloudData struct { } type ServerConfig struct { - Netns string - File string - Iface string - Port int - ConfFile string - VmName string + VmName string + RunDir string } type NoCloudConfig struct { From 87808312f46dfd32a8d9b3bde0c56af55f817f19 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Wed, 29 Apr 2026 00:50:04 +0200 Subject: [PATCH 32/57] f-25: bin: remove old binaries Signed-off-by: GnomeZworc --- .forgejo/workflows/prerelease.yml | 2 -- cmd/metacli/main.go | 53 ------------------------------- 2 files changed, 55 deletions(-) delete mode 100644 cmd/metacli/main.go diff --git a/.forgejo/workflows/prerelease.yml b/.forgejo/workflows/prerelease.yml index 2440fbd..1869374 100644 --- a/.forgejo/workflows/prerelease.yml +++ b/.forgejo/workflows/prerelease.yml @@ -33,9 +33,7 @@ jobs: goos: [linux] goarch: [amd64] binaries: - - db - metadata - - metacli - agent uses: ./.forgejo/workflows/build.yml with: diff --git a/cmd/metacli/main.go b/cmd/metacli/main.go deleted file mode 100644 index 140bde6..0000000 --- a/cmd/metacli/main.go +++ /dev/null @@ -1,53 +0,0 @@ -package main - -import ( - "flag" - "fmt" - - configuration "git.g3e.fr/syonad/two/internal/config/agent" - "git.g3e.fr/syonad/two/internal/metadata" - "git.g3e.fr/syonad/two/pkg/db/kv" -) - -func main() { - conf_file := flag.String("conf", "/etc/two/agent.yml", "configuration file") - vm_name := flag.String("vm_name", "", "Nom de la vm") - vpc := flag.String("vpc_name", "", "vpc name") - bind_ip := flag.String("ip", "", "bind ip") - bind_port := flag.String("port", "", "bind port") - ssh_key := flag.String("key", "", "Clef ssh") - password := flag.String("pass", "", "password user") - start := flag.Bool("start", false, "start metadata server") - stop := flag.Bool("stop", false, "stop metadata server") - dryrun := flag.Bool("dryrun", false, "launch in dry node") - - flag.Parse() - - conf, err := configuration.LoadConfig(*conf_file) - if err != nil { - fmt.Println(err) - return - } - - db := kv.InitDB(kv.Config{ - Path: conf.Database.Path, - }, false) - defer db.Close() - - if *start { - if err := metadata.StartMetadata(metadata.NoCloudConfig{ - VpcName: *vpc, - Name: *vm_name, - BindIP: *bind_ip, - BindPort: *bind_port, - Password: *password, - SSHKEY: *ssh_key, - }, db, *dryrun); err != nil { - fmt.Println(err) - } - } else if *stop { - if err := metadata.StopMetadata(*vm_name, db, *dryrun); err != nil { - fmt.Println(err) - } - } -} From 0506be8a8705105e1050f3730c103eeda6d21a33 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Thu, 30 Apr 2026 23:29:07 +0200 Subject: [PATCH 33/57] f-25: code: add db api Signed-off-by: GnomeZworc --- cmd/agent/main.go | 4 +++ conf/agent/config.exemple.yml | 6 ++++ internal/config/agent/struct.go | 8 +++++ pkg/db/kv/admin_server.go | 52 +++++++++++++++++++++++++++++++++ pkg/db/kv/init.go | 3 +- 5 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 pkg/db/kv/admin_server.go diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 7b87bde..7083076 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -51,6 +51,10 @@ func main() { d := dispatcher.New(q, db, cfg, log.With(slog.String("component", "dispatcher"))) go agentapi.New(d, db, log.With(slog.String("component", "api"))).Start(apiAddr) go promserver.Start(promAddr, registry) + if cfg.Admin.Enabled { + adminAddr := fmt.Sprintf("%s:%d", cfg.Admin.Address, cfg.Admin.Port) + go kv.NewAdminServer(db, log.With(slog.String("component", "admin"))).Start(adminAddr) + } select {} } diff --git a/conf/agent/config.exemple.yml b/conf/agent/config.exemple.yml index 89eb90f..fdef682 100644 --- a/conf/agent/config.exemple.yml +++ b/conf/agent/config.exemple.yml @@ -39,6 +39,12 @@ interfaces: metadata: run_dir: "/run/two/metadata" +# Admin API (read-only DB inspection, loopback only) +admin: + enabled: false + address: "127.0.0.1" + port: 9091 + # Logging configuration logger: # Log level: debug, info, warn, error (default: info) diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index 69a4492..9889c79 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -31,6 +31,11 @@ type Config struct { Metadata struct { RunDir string `mapstructure:"run_dir"` } `mapstructure:"metadata"` + Admin struct { + Enabled bool `mapstructure:"enabled"` + Address string `mapstructure:"address"` + Port int `mapstructure:"port"` + } `mapstructure:"admin"` DefaultInterface string `mapstructure:"default_interface"` Interfaces map[string]string `mapstructure:"interfaces"` } @@ -50,6 +55,9 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("dispatcher.timeout_seconds", 300) v.SetDefault("dispatcher.poll_seconds", 2) v.SetDefault("metadata.run_dir", "/run/two/metadata") + v.SetDefault("admin.enabled", false) + v.SetDefault("admin.address", "127.0.0.1") + v.SetDefault("admin.port", 9091) v.SetDefault("default_interface", "br-000000") v.SetDefault("logger.level", "info") v.SetDefault("logger.debug", false) diff --git a/pkg/db/kv/admin_server.go b/pkg/db/kv/admin_server.go new file mode 100644 index 0000000..1216244 --- /dev/null +++ b/pkg/db/kv/admin_server.go @@ -0,0 +1,52 @@ +package kv + +import ( + "fmt" + "log/slog" + "net/http" + "sort" + + "github.com/dgraph-io/badger/v4" +) + +type AdminServer struct { + db *badger.DB + logger *slog.Logger +} + +func NewAdminServer(db *badger.DB, logger *slog.Logger) *AdminServer { + return &AdminServer{db: db, logger: logger} +} + +func (s *AdminServer) Start(address string) { + mux := http.NewServeMux() + mux.HandleFunc("/db", s.dbHandler) + s.logger.Info("admin server listening", "address", address) + if err := http.ListenAndServe(address, mux); err != nil { + s.logger.Error("admin server stopped", "error", err) + } +} + +func (s *AdminServer) dbHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + + entries, err := ListByPrefix(s.db, r.URL.Query().Get("prefix")) + if err != nil { + http.Error(w, "db error: "+err.Error(), http.StatusInternalServerError) + return + } + + keys := make([]string, 0, len(entries)) + for k := range entries { + keys = append(keys, k) + } + sort.Strings(keys) + + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + for _, k := range keys { + fmt.Fprintf(w, "%s=%s\n", k, entries[k]) + } +} diff --git a/pkg/db/kv/init.go b/pkg/db/kv/init.go index 0ee2374..e250096 100644 --- a/pkg/db/kv/init.go +++ b/pkg/db/kv/init.go @@ -17,7 +17,8 @@ func InitDB(conf Config, readonly bool) *badger.DB { opts.NumLevelZeroTablesStall = 2 db, err := badger.Open(opts) if err != nil { - log.Fatalf("kv.InitDB (readonly=%v, path=%s): %v", readonly, conf.Path, err) + log.Printf("kv.InitDB (readonly=%v, path=%s): %v", readonly, conf.Path, err) + panic(err) } return db } From cef465da2e5a32e801769e5a243b08fa9fedc386 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 20:11:33 +0200 Subject: [PATCH 34/57] f-28: api: update api comportement and model Signed-off-by: GnomeZworc --- api/agent.yaml | 20 +++++++++++++++++--- internal/api/agent/models.go | 2 ++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/api/agent.yaml b/api/agent.yaml index 00d4fd8..95a0339 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -315,7 +315,7 @@ components: SubnetCreateRequest: type: object - required: [name, vpc, vxlan_id, gateway_ip, cidr] + required: [name, vpc, gateway_ip, cidr] properties: name: type: string @@ -325,9 +325,18 @@ components: type: string description: Parent VPC name example: vpc1 + mode: + type: string + description: > + Subnet mode. "vxlan" (default): creates a VXLAN tunnel and a host bridge. + "bridge": attaches directly to an existing bridge resolved from iface_type in the agent config. + "vlan" is reserved for future use. + enum: [vxlan, bridge] + default: vxlan + example: vxlan vxlan_id: type: integer - description: VXLAN VNI identifier + description: VXLAN VNI identifier. Required when mode is "vxlan", ignored otherwise. example: 100 iface_type: type: string @@ -356,12 +365,17 @@ components: vpc: type: string example: vpc1 + mode: + type: string + enum: [vxlan, bridge] + example: vxlan vxlan_id: type: integer + description: VXLAN VNI. Present only when mode is "vxlan". example: 100 local_iface: type: string - description: Resolved interface name + description: Resolved interface name from agent config example: br-000000 gateway_ip: type: string diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index f13407b..9622ef9 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -12,6 +12,7 @@ type VPC struct { type SubnetCreateRequest struct { Name string `json:"name"` VPC string `json:"vpc"` + Mode string `json:"mode"` VxlanID int `json:"vxlan_id"` IfaceType string `json:"iface_type"` GatewayIP string `json:"gateway_ip"` @@ -22,6 +23,7 @@ type Subnet struct { Name string `json:"name"` State string `json:"state"` VPC string `json:"vpc"` + Mode string `json:"mode"` VxlanID int `json:"vxlan_id"` LocalIface string `json:"local_iface"` GatewayIP string `json:"gateway_ip"` From 9a16cf011a2f3aa269deab7dc0df6318f5c707c8 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 20:15:16 +0200 Subject: [PATCH 35/57] f-28: api: implement new model usage Signed-off-by: GnomeZworc --- internal/api/agent/subnet.go | 2 ++ internal/api/agent/subnets.go | 5 +++++ internal/dispatcher/agent/subnet_commands.go | 12 +++++++++++- 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/internal/api/agent/subnet.go b/internal/api/agent/subnet.go index 31eddc8..21bebf8 100644 --- a/internal/api/agent/subnet.go +++ b/internal/api/agent/subnet.go @@ -47,6 +47,8 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string) sub.State = value case "vpc": sub.VPC = value + case "mode": + sub.Mode = value case "vxlan_id": sub.VxlanID, _ = strconv.Atoi(value) case "local_iface": diff --git a/internal/api/agent/subnets.go b/internal/api/agent/subnets.go index 60467aa..61082f1 100644 --- a/internal/api/agent/subnets.go +++ b/internal/api/agent/subnets.go @@ -44,6 +44,8 @@ func (s *Server) listSubnets(w http.ResponseWriter, _ *http.Request) { subnets[name].State = value case "vpc": subnets[name].VPC = value + case "mode": + subnets[name].Mode = value case "vxlan_id": subnets[name].VxlanID, _ = strconv.Atoi(value) case "local_iface": @@ -77,6 +79,7 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { cmd := dispatcher.CreateSubnetCommand{ Name: req.Name, VPC: req.VPC, + Mode: req.Mode, VxlanID: req.VxlanID, IfaceType: req.IfaceType, GatewayIP: req.GatewayIP, @@ -109,6 +112,8 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { sub.State = value case "vpc": sub.VPC = value + case "mode": + sub.Mode = value case "vxlan_id": sub.VxlanID, _ = strconv.Atoi(value) case "local_iface": diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go index 18f2cd3..b454974 100644 --- a/internal/dispatcher/agent/subnet_commands.go +++ b/internal/dispatcher/agent/subnet_commands.go @@ -14,6 +14,7 @@ import ( type CreateSubnetCommand struct { Name string VPC string + Mode string VxlanID int IfaceType string GatewayIP string @@ -21,6 +22,12 @@ type CreateSubnetCommand struct { } func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error { + if c.Mode == "" { + c.Mode = "vxlan" + } + if c.Mode != "vxlan" && c.Mode != "bridge" { + return fmt.Errorf("unknown subnet mode %q", c.Mode) + } if _, err := kv.GetFromDB(db, "subnet/"+c.Name+"/state"); err == nil { return fmt.Errorf("subnet %q already exists", c.Name) } @@ -37,10 +44,13 @@ func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) e } kv.AddInDB(db, "subnet/"+c.Name+"/state", "creating") kv.AddInDB(db, "subnet/"+c.Name+"/vpc", c.VPC) - kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID)) + kv.AddInDB(db, "subnet/"+c.Name+"/mode", c.Mode) kv.AddInDB(db, "subnet/"+c.Name+"/local_iface", localIface) kv.AddInDB(db, "subnet/"+c.Name+"/gateway_ip", c.GatewayIP) kv.AddInDB(db, "subnet/"+c.Name+"/cidr", c.CIDR) + if c.Mode == "vxlan" { + kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID)) + } return nil } From d457c73198427e3d83c7bb55eecc83bdda8b26f7 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 20:45:39 +0200 Subject: [PATCH 36/57] f-28: bridge: first split bridge vxlan Signed-off-by: GnomeZworc --- internal/subnet/create.go | 137 +++++++++++++++++++++++++------------- internal/subnet/data.go | 21 ++++-- internal/subnet/delete.go | 52 ++++++++++++++- 3 files changed, 153 insertions(+), 57 deletions(-) diff --git a/internal/subnet/create.go b/internal/subnet/create.go index 5e80634..53349ca 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -27,46 +27,48 @@ func CreateSubnet(db *badger.DB, subnetName string) error { return err } - vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) + if err := createSubnet(db, subnetName, d); err != nil { + return err + } - if err := netif.CreateVethToNetns("v-"+d.subnetID+"-e", "v-"+d.subnetID+"-i", "/var/run/netns/"+d.vpc, 1500); err != nil { + return kv.AddInDB(db, "subnet/"+subnetName+"/state", "created") +} + +func createSubnet(db *badger.DB, subnetName string, d subnetData) error { + vethE := "v-" + d.subnetID + "-e" + vethI := "v-" + d.subnetID + "-i" + + if err := netif.CreateVethToNetns(vethE, vethI, "/var/run/netns/"+d.vpc, 1500); err != nil { return fmt.Errorf("create veth: %w", err) } - if err := netif.CreateBridge(d.bridge, 1500); err != nil { - return fmt.Errorf("create bridge: %w", err) - } - - if err := netns.Call(d.vpc, func() error { - return netif.CreateBridge(d.bridge, 1500) - }); err != nil { - return fmt.Errorf("create bridge in netns: %w", err) - } - - if err := netif.CreateVxlan(vxlanIface, d.vxlanID, d.localIface, 1500); err != nil { - return fmt.Errorf("create vxlan: %w", err) - } - - if err := netif.BridgeSetMaster("v-"+d.subnetID+"-e", d.bridge); err != nil { - return fmt.Errorf("add veth-e to bridge: %w", err) - } - if err := netns.Call(d.vpc, func() error { - return netif.BridgeSetMaster("v-"+d.subnetID+"-i", d.bridge) - }); err != nil { - return fmt.Errorf("add veth-i to bridge in netns: %w", err) - } - if err := netif.BridgeSetMaster(vxlanIface, d.bridge); err != nil { - return fmt.Errorf("add vxlan to bridge: %w", err) - } - - for _, iface := range []string{"v-" + d.subnetID + "-e", vxlanIface, d.bridge} { - if err := netif.LinkSetUp(iface); err != nil { - return fmt.Errorf("set up %s: %w", iface, err) + switch d.mode { + case "vxlan": + if err := setupVxlanHost(d, vethE); err != nil { + return err } + case "bridge": + if err := netif.BridgeSetMaster(vethE, d.localIface); err != nil { + return fmt.Errorf("add veth-e to bridge: %w", err) + } + if err := netif.LinkSetUp(vethE); err != nil { + return fmt.Errorf("set up %s: %w", vethE, err) + } + default: + return fmt.Errorf("unknown subnet mode %q", d.mode) } if err := netns.Call(d.vpc, func() error { - for _, iface := range []string{"v-" + d.subnetID + "-i", d.bridge} { + if err := netif.CreateBridge(d.bridge, 1500); err != nil { + return fmt.Errorf("create bridge: %w", err) + } + return netif.BridgeSetMaster(vethI, d.bridge) + }); err != nil { + return fmt.Errorf("setup bridge in netns: %w", err) + } + + if err := netns.Call(d.vpc, func() error { + for _, iface := range []string{vethI, d.bridge} { if err := netif.LinkSetUp(iface); err != nil { return fmt.Errorf("set up %s: %w", iface, err) } @@ -76,25 +78,65 @@ func CreateSubnet(db *badger.DB, subnetName string) error { return fmt.Errorf("set up interfaces in netns: %w", err) } - if err := netns.Call(d.vpc, func() error { - return netif.AddrAdd(d.bridge, d.gatewayIP) - }); err != nil { - return fmt.Errorf("add addr to bridge in netns: %w", err) + switch d.mode { + case "vxlan": + if err := netns.Call(d.vpc, func() error { + return netif.AddrAdd(d.bridge, d.gatewayIP) + }); err != nil { + return fmt.Errorf("add addr to bridge in netns: %w", err) + } + if err := netns.Call(d.vpc, func() error { + return netif.RouteAdd(d.bridge, d.cidr) + }); err != nil { + return fmt.Errorf("add route in netns: %w", err) + } + case "bridge": } - if err := netns.Call(d.vpc, func() error { - return netif.RouteAdd(d.bridge, d.cidr) - }); err != nil { - return fmt.Errorf("add route in netns: %w", err) + applyEbtables := func() error { + if err := ebtables.DropARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { + return err + } + return ebtables.DropDHCP(d.bridge) + } + switch d.mode { + case "vxlan": + if err := applyEbtables(); err != nil { + return err + } + case "bridge": + if err := netns.Call(d.vpc, applyEbtables); err != nil { + return fmt.Errorf("set ebtables in netns: %w", err) + } } - if err := ebtables.DropARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { - return err - } - if err := ebtables.DropDHCP(d.bridge); err != nil { - return err - } + return startDHCP(db, subnetName, d) +} +func setupVxlanHost(d subnetData, vethE string) error { + vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) + + if err := netif.CreateBridge(d.bridge, 1500); err != nil { + return fmt.Errorf("create bridge: %w", err) + } + if err := netif.CreateVxlan(vxlanIface, d.vxlanID, d.localIface, 1500); err != nil { + return fmt.Errorf("create vxlan: %w", err) + } + if err := netif.BridgeSetMaster(vethE, d.bridge); err != nil { + return fmt.Errorf("add veth-e to bridge: %w", err) + } + if err := netif.BridgeSetMaster(vxlanIface, d.bridge); err != nil { + return fmt.Errorf("add vxlan to bridge: %w", err) + } + for _, iface := range []string{vethE, vxlanIface, d.bridge} { + if err := netif.LinkSetUp(iface); err != nil { + return fmt.Errorf("set up %s: %w", iface, err) + } + } + return nil +} + +func startDHCP(db *badger.DB, subnetName string, d subnetData) error { conf := dhcp.Config{ Network: d.cidr, Gateway: d.gatewayIP, @@ -118,6 +160,5 @@ func CreateSubnet(db *badger.DB, subnetName string) error { if err := svc.Start("dnsmasq@" + conf.Name + ".service"); err != nil { return fmt.Errorf("start dnsmasq: %w", err) } - - return kv.AddInDB(db, "subnet/"+subnetName+"/state", "created") + return nil } diff --git a/internal/subnet/data.go b/internal/subnet/data.go index 31f5472..2936e21 100644 --- a/internal/subnet/data.go +++ b/internal/subnet/data.go @@ -14,6 +14,7 @@ type subnetData struct { vpc string subnetID string bridge string + mode string vxlanID int localIface string gatewayIP net.IP @@ -32,15 +33,23 @@ func loadSubnet(db *badger.DB, name string) (subnetData, error) { } d.vpc = vpc - vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+name+"/vxlan_id") + mode, err := kv.GetFromDB(db, "subnet/"+name+"/mode") if err != nil { - return d, fmt.Errorf("get vxlan_id: %w", err) + return d, fmt.Errorf("get mode: %w", err) } - vxlanID, err := strconv.Atoi(vxlanIDStr) - if err != nil { - return d, fmt.Errorf("parse vxlan_id: %w", err) + d.mode = mode + + if d.mode == "vxlan" { + vxlanIDStr, err := kv.GetFromDB(db, "subnet/"+name+"/vxlan_id") + if err != nil { + return d, fmt.Errorf("get vxlan_id: %w", err) + } + vxlanID, err := strconv.Atoi(vxlanIDStr) + if err != nil { + return d, fmt.Errorf("parse vxlan_id: %w", err) + } + d.vxlanID = vxlanID } - d.vxlanID = vxlanID localIface, err := kv.GetFromDB(db, "subnet/"+name+"/local_iface") if err != nil { diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index e7e3929..bc62af7 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -27,8 +27,27 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { return err } - vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) + if err := stopDHCP(db, subnetName, d); err != nil { + return err + } + switch d.mode { + case "vxlan": + if err := deleteSubnetVxlan(d); err != nil { + return err + } + case "bridge": + if err := deleteSubnetBridge(d); err != nil { + return err + } + default: + return fmt.Errorf("unknown subnet mode %q", d.mode) + } + + return kv.AddInDB(db, "subnet/"+subnetName+"/state", "deleted") +} + +func stopDHCP(db *badger.DB, subnetName string, d subnetData) error { svc, err := systemd.New() if err != nil { return fmt.Errorf("connect to systemd: %w", err) @@ -42,9 +61,15 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { if err := os.Remove("/etc/dnsmasq.d/" + d.vpc + "_" + d.bridge + ".conf"); err != nil && !os.IsNotExist(err) { return fmt.Errorf("remove dnsmasq config: %w", err) } + if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil { return fmt.Errorf("delete dhcp entries: %w", err) } + return nil +} + +func deleteSubnetVxlan(d subnetData) error { + vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) if err := ebtables.DeleteARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { return fmt.Errorf("delete ebtables arp rule: %w", err) @@ -70,6 +95,27 @@ func DeleteSubnet(db *badger.DB, subnetName string) error { if err := netif.DeleteLink(d.bridge); err != nil { return fmt.Errorf("delete bridge: %w", err) } - - return kv.AddInDB(db, "subnet/"+subnetName+"/state", "deleted") + return nil +} + +func deleteSubnetBridge(d subnetData) error { + if err := netns.Call(d.vpc, func() error { + if err := ebtables.DeleteARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { + return fmt.Errorf("delete ebtables arp rule: %w", err) + } + return ebtables.DeleteDHCP(d.bridge) + }); err != nil { + return fmt.Errorf("delete ebtables in netns: %w", err) + } + + if err := netns.Call(d.vpc, func() error { + return netif.DeleteLink(d.bridge) + }); err != nil { + return fmt.Errorf("delete bridge in netns: %w", err) + } + + if err := netif.DeleteLink("v-" + d.subnetID + "-e"); err != nil { + return fmt.Errorf("delete veth: %w", err) + } + return nil } From 91a5d7ac786b3639f37c86b2cc0ed50d3874ab54 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 21:11:09 +0200 Subject: [PATCH 37/57] f-28: bridge: fix ebtables Signed-off-by: GnomeZworc --- internal/subnet/create.go | 12 +++--------- internal/subnet/delete.go | 9 --------- 2 files changed, 3 insertions(+), 18 deletions(-) diff --git a/internal/subnet/create.go b/internal/subnet/create.go index 53349ca..3fbd31b 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -93,21 +93,15 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error { case "bridge": } - applyEbtables := func() error { + switch d.mode { + case "vxlan": if err := ebtables.DropARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { return err } - return ebtables.DropDHCP(d.bridge) - } - switch d.mode { - case "vxlan": - if err := applyEbtables(); err != nil { + if err := ebtables.DropDHCP(d.bridge); err != nil { return err } case "bridge": - if err := netns.Call(d.vpc, applyEbtables); err != nil { - return fmt.Errorf("set ebtables in netns: %w", err) - } } return startDHCP(db, subnetName, d) diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index bc62af7..51fac1a 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -99,15 +99,6 @@ func deleteSubnetVxlan(d subnetData) error { } func deleteSubnetBridge(d subnetData) error { - if err := netns.Call(d.vpc, func() error { - if err := ebtables.DeleteARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { - return fmt.Errorf("delete ebtables arp rule: %w", err) - } - return ebtables.DeleteDHCP(d.bridge) - }); err != nil { - return fmt.Errorf("delete ebtables in netns: %w", err) - } - if err := netns.Call(d.vpc, func() error { return netif.DeleteLink(d.bridge) }); err != nil { From 9492de7a2bdd585d0444c43e58fc46b070d6394a Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 21:50:29 +0200 Subject: [PATCH 38/57] f-28: test: add tests Signed-off-by: GnomeZworc --- internal/api/agent/subnet_test.go | 45 ++++++++++++++ .../dispatcher/agent/subnet_commands_test.go | 62 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/internal/api/agent/subnet_test.go b/internal/api/agent/subnet_test.go index 23745a0..ca6ec2c 100644 --- a/internal/api/agent/subnet_test.go +++ b/internal/api/agent/subnet_test.go @@ -161,6 +161,51 @@ func TestPostSubnet_VPCDeleting(t *testing.T) { } } +func TestPostSubnet_BridgeMode_Success(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + req := SubnetCreateRequest{ + Name: "sn-br", + VPC: "vpc-1", + Mode: "bridge", + IfaceType: "vms", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d: %s", w.Code, w.Body.String()) + } + var result Subnet + json.NewDecoder(w.Body).Decode(&result) + if result.Mode != "bridge" { + t.Errorf("mode attendu bridge, obtenu %q", result.Mode) + } + if result.VxlanID != 0 { + t.Errorf("vxlan_id devrait être 0 en mode bridge, obtenu %d", result.VxlanID) + } +} + +func TestPostSubnet_UnknownMode(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + req := SubnetCreateRequest{ + Name: "sn-1", + VPC: "vpc-1", + Mode: "vlan", + GatewayIP: "10.0.0.1", + CIDR: "10.0.0.0/24", + } + body, _ := json.Marshal(req) + w := httptest.NewRecorder() + s.SubnetsHandler(w, httptest.NewRequest(http.MethodPost, "/subnets", bytes.NewReader(body))) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("attendu 422, obtenu %d", w.Code) + } +} + func TestPostSubnet_InvalidBody(t *testing.T) { s, _ := newTestServer(t) w := httptest.NewRecorder() diff --git a/internal/dispatcher/agent/subnet_commands_test.go b/internal/dispatcher/agent/subnet_commands_test.go index 3b118bc..b0fba3d 100644 --- a/internal/dispatcher/agent/subnet_commands_test.go +++ b/internal/dispatcher/agent/subnet_commands_test.go @@ -111,6 +111,68 @@ func TestCreateSubnetCommand_Prepare_VPCDeleted(t *testing.T) { } } +func TestCreateSubnetCommand_Prepare_DefaultsToVxlanMode(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", VxlanID: 100, + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + cmd.Prepare(db, testCfg()) + mode, _ := kv.GetFromDB(db, "subnet/sn-1/mode") + if mode != "vxlan" { + t.Errorf("mode attendu vxlan, obtenu %q", mode) + } + if _, err := kv.GetFromDB(db, "subnet/sn-1/vxlan_id"); err != nil { + t.Error("vxlan_id devrait être écrit en mode vxlan") + } +} + +func TestCreateSubnetCommand_Prepare_BridgeMode_Success(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", Mode: "bridge", + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + mode, _ := kv.GetFromDB(db, "subnet/sn-1/mode") + if mode != "bridge" { + t.Errorf("mode attendu bridge, obtenu %q", mode) + } + iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") + if iface != "br-vms" { + t.Errorf("local_iface attendu br-vms, obtenu %q", iface) + } +} + +func TestCreateSubnetCommand_Prepare_BridgeMode_NoVxlanID(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", Mode: "bridge", + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + cmd.Prepare(db, testCfg()) + if _, err := kv.GetFromDB(db, "subnet/sn-1/vxlan_id"); err == nil { + t.Error("vxlan_id ne devrait pas être écrit en mode bridge") + } +} + +func TestCreateSubnetCommand_Prepare_UnknownMode(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", Mode: "vlan", + IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + if err := cmd.Prepare(db, testCfg()); err == nil { + t.Error("Prepare devrait échouer pour un mode inconnu") + } +} + // --- DeleteSubnetCommand.Prepare --- func TestDeleteSubnetCommand_Prepare_Success(t *testing.T) { From 848f965883f46ab533e54653d1a098990e5613cf Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 21:58:33 +0200 Subject: [PATCH 39/57] f-28: fix: renomage api param Signed-off-by: GnomeZworc --- api/agent.yaml | 6 ++--- internal/api/agent/models.go | 4 ++-- internal/api/agent/subnet.go | 4 ++-- internal/api/agent/subnet_test.go | 16 +++++++------- internal/api/agent/subnets.go | 14 ++++++------ internal/dispatcher/agent/subnet_commands.go | 4 ++-- .../dispatcher/agent/subnet_commands_test.go | 22 +++++++++---------- internal/subnet/create.go | 6 ++--- internal/subnet/data.go | 14 ++++++------ internal/subnet/delete.go | 2 +- internal/vm/create.go | 4 ++-- internal/vm/data.go | 8 +++---- internal/vm/delete.go | 2 +- 13 files changed, 53 insertions(+), 53 deletions(-) diff --git a/api/agent.yaml b/api/agent.yaml index 95a0339..44a5c80 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -315,7 +315,7 @@ components: SubnetCreateRequest: type: object - required: [name, vpc, gateway_ip, cidr] + required: [name, vpc, interface_ip, cidr] properties: name: type: string @@ -342,7 +342,7 @@ components: type: string description: Interface type key defined in the agent config (e.g. vms, internet, admin). Falls back to default_interface if omitted or unknown. example: vms - gateway_ip: + interface_ip: type: string format: ipv4 description: Gateway IP for the subnet @@ -377,7 +377,7 @@ components: type: string description: Resolved interface name from agent config example: br-000000 - gateway_ip: + interface_ip: type: string example: "10.10.10.1" cidr: diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index 9622ef9..4621f1c 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -15,7 +15,7 @@ type SubnetCreateRequest struct { Mode string `json:"mode"` VxlanID int `json:"vxlan_id"` IfaceType string `json:"iface_type"` - GatewayIP string `json:"gateway_ip"` + InterfaceIP string `json:"interface_ip"` CIDR string `json:"cidr"` } @@ -26,7 +26,7 @@ type Subnet struct { Mode string `json:"mode"` VxlanID int `json:"vxlan_id"` LocalIface string `json:"local_iface"` - GatewayIP string `json:"gateway_ip"` + InterfaceIP string `json:"interface_ip"` CIDR string `json:"cidr"` } diff --git a/internal/api/agent/subnet.go b/internal/api/agent/subnet.go index 21bebf8..95b0199 100644 --- a/internal/api/agent/subnet.go +++ b/internal/api/agent/subnet.go @@ -53,8 +53,8 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string) sub.VxlanID, _ = strconv.Atoi(value) case "local_iface": sub.LocalIface = value - case "gateway_ip": - sub.GatewayIP = value + case "interface_ip": + sub.InterfaceIP = value case "cidr": sub.CIDR = value } diff --git a/internal/api/agent/subnet_test.go b/internal/api/agent/subnet_test.go index ca6ec2c..36e42f7 100644 --- a/internal/api/agent/subnet_test.go +++ b/internal/api/agent/subnet_test.go @@ -60,7 +60,7 @@ func TestPostSubnet_Created(t *testing.T) { Name: "sn-new", VPC: "vpc-1", IfaceType: "vms", - GatewayIP: "10.0.0.1", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) @@ -95,7 +95,7 @@ func TestPostSubnet_IfaceTypeOptional(t *testing.T) { req := SubnetCreateRequest{ Name: "sn-opt", VPC: "vpc-1", - GatewayIP: "10.0.0.1", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", // IfaceType omis — doit utiliser default_interface } @@ -113,7 +113,7 @@ func TestPostSubnet_VPCNotFound(t *testing.T) { Name: "sn-1", VPC: "vpc-inexistant", IfaceType: "vms", - GatewayIP: "10.0.0.1", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) @@ -132,7 +132,7 @@ func TestPostSubnet_Duplicate(t *testing.T) { Name: "sn-exist", VPC: "vpc-1", IfaceType: "vms", - GatewayIP: "10.0.0.1", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) @@ -150,7 +150,7 @@ func TestPostSubnet_VPCDeleting(t *testing.T) { Name: "sn-1", VPC: "vpc-dying", IfaceType: "vms", - GatewayIP: "10.0.0.1", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) @@ -169,7 +169,7 @@ func TestPostSubnet_BridgeMode_Success(t *testing.T) { VPC: "vpc-1", Mode: "bridge", IfaceType: "vms", - GatewayIP: "10.0.0.1", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) @@ -195,7 +195,7 @@ func TestPostSubnet_UnknownMode(t *testing.T) { Name: "sn-1", VPC: "vpc-1", Mode: "vlan", - GatewayIP: "10.0.0.1", + InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } body, _ := json.Marshal(req) @@ -222,7 +222,7 @@ func TestGetSubnet_Found(t *testing.T) { kv.AddInDB(db, "subnet/sn-1/state", "created") kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") kv.AddInDB(db, "subnet/sn-1/cidr", "10.0.0.0/24") - kv.AddInDB(db, "subnet/sn-1/gateway_ip", "10.0.0.1") + kv.AddInDB(db, "subnet/sn-1/interface_ip", "10.0.0.1") req := httptest.NewRequest(http.MethodGet, "/subnets/sn-1", nil) w := httptest.NewRecorder() s.SubnetByNameHandler(w, req) diff --git a/internal/api/agent/subnets.go b/internal/api/agent/subnets.go index 61082f1..a70e618 100644 --- a/internal/api/agent/subnets.go +++ b/internal/api/agent/subnets.go @@ -50,8 +50,8 @@ func (s *Server) listSubnets(w http.ResponseWriter, _ *http.Request) { subnets[name].VxlanID, _ = strconv.Atoi(value) case "local_iface": subnets[name].LocalIface = value - case "gateway_ip": - subnets[name].GatewayIP = value + case "interface_ip": + subnets[name].InterfaceIP = value case "cidr": subnets[name].CIDR = value } @@ -71,9 +71,9 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"}) return } - if req.Name == "" || req.VPC == "" || req.GatewayIP == "" || req.CIDR == "" { + if req.Name == "" || req.VPC == "" || req.InterfaceIP == "" || req.CIDR == "" { w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: "name, vpc, gateway_ip and cidr are required"}) + json.NewEncoder(w).Encode(ErrorResponse{Error: "name, vpc, interface_ip and cidr are required"}) return } cmd := dispatcher.CreateSubnetCommand{ @@ -82,7 +82,7 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { Mode: req.Mode, VxlanID: req.VxlanID, IfaceType: req.IfaceType, - GatewayIP: req.GatewayIP, + InterfaceIP: req.InterfaceIP, CIDR: req.CIDR, } if err := s.dispatcher.Prepare(cmd); err != nil { @@ -118,8 +118,8 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { sub.VxlanID, _ = strconv.Atoi(value) case "local_iface": sub.LocalIface = value - case "gateway_ip": - sub.GatewayIP = value + case "interface_ip": + sub.InterfaceIP = value case "cidr": sub.CIDR = value } diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go index b454974..db07c6c 100644 --- a/internal/dispatcher/agent/subnet_commands.go +++ b/internal/dispatcher/agent/subnet_commands.go @@ -17,7 +17,7 @@ type CreateSubnetCommand struct { Mode string VxlanID int IfaceType string - GatewayIP string + InterfaceIP string CIDR string } @@ -46,7 +46,7 @@ func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) e kv.AddInDB(db, "subnet/"+c.Name+"/vpc", c.VPC) kv.AddInDB(db, "subnet/"+c.Name+"/mode", c.Mode) kv.AddInDB(db, "subnet/"+c.Name+"/local_iface", localIface) - kv.AddInDB(db, "subnet/"+c.Name+"/gateway_ip", c.GatewayIP) + kv.AddInDB(db, "subnet/"+c.Name+"/interface_ip", c.InterfaceIP) kv.AddInDB(db, "subnet/"+c.Name+"/cidr", c.CIDR) if c.Mode == "vxlan" { kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID)) diff --git a/internal/dispatcher/agent/subnet_commands_test.go b/internal/dispatcher/agent/subnet_commands_test.go index b0fba3d..94f2d4c 100644 --- a/internal/dispatcher/agent/subnet_commands_test.go +++ b/internal/dispatcher/agent/subnet_commands_test.go @@ -20,7 +20,7 @@ func TestCreateSubnetCommand_Prepare_Success(t *testing.T) { kv.AddInDB(db, "vpc/vpc-1/state", "created") cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } if err := cmd.Prepare(db, testCfg()); err != nil { t.Fatalf("Prepare a échoué : %v", err) @@ -40,7 +40,7 @@ func TestCreateSubnetCommand_Prepare_UsesIfaceTypeMapping(t *testing.T) { kv.AddInDB(db, "vpc/vpc-1/state", "created") cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } cmd.Prepare(db, testCfg()) iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") @@ -54,7 +54,7 @@ func TestCreateSubnetCommand_Prepare_UsesDefaultIfaceWhenTypeUnknown(t *testing. kv.AddInDB(db, "vpc/vpc-1/state", "created") cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "inconnu", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "inconnu", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } cmd.Prepare(db, testCfg()) iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") @@ -69,7 +69,7 @@ func TestCreateSubnetCommand_Prepare_Duplicate(t *testing.T) { kv.AddInDB(db, "subnet/sn-exist/state", "created") cmd := CreateSubnetCommand{ Name: "sn-exist", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } if err := cmd.Prepare(db, testCfg()); err == nil { t.Error("Prepare devrait échouer sur un subnet déjà existant") @@ -80,7 +80,7 @@ func TestCreateSubnetCommand_Prepare_VPCNotFound(t *testing.T) { _, db := newTestDispatcher(t) cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-inexistant", VxlanID: 100, - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } if err := cmd.Prepare(db, testCfg()); err == nil { t.Error("Prepare devrait échouer si le VPC n'existe pas") @@ -92,7 +92,7 @@ func TestCreateSubnetCommand_Prepare_VPCDeleting(t *testing.T) { kv.AddInDB(db, "vpc/vpc-dying/state", "deleting") cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-dying", VxlanID: 100, - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } if err := cmd.Prepare(db, testCfg()); err == nil { t.Error("Prepare devrait échouer si le VPC est en cours de suppression") @@ -104,7 +104,7 @@ func TestCreateSubnetCommand_Prepare_VPCDeleted(t *testing.T) { kv.AddInDB(db, "vpc/vpc-gone/state", "deleted") cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-gone", VxlanID: 100, - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } if err := cmd.Prepare(db, testCfg()); err == nil { t.Error("Prepare devrait échouer si le VPC est supprimé") @@ -116,7 +116,7 @@ func TestCreateSubnetCommand_Prepare_DefaultsToVxlanMode(t *testing.T) { kv.AddInDB(db, "vpc/vpc-1/state", "created") cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-1", VxlanID: 100, - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } cmd.Prepare(db, testCfg()) mode, _ := kv.GetFromDB(db, "subnet/sn-1/mode") @@ -133,7 +133,7 @@ func TestCreateSubnetCommand_Prepare_BridgeMode_Success(t *testing.T) { kv.AddInDB(db, "vpc/vpc-1/state", "created") cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-1", Mode: "bridge", - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } if err := cmd.Prepare(db, testCfg()); err != nil { t.Fatalf("Prepare a échoué : %v", err) @@ -153,7 +153,7 @@ func TestCreateSubnetCommand_Prepare_BridgeMode_NoVxlanID(t *testing.T) { kv.AddInDB(db, "vpc/vpc-1/state", "created") cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-1", Mode: "bridge", - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } cmd.Prepare(db, testCfg()) if _, err := kv.GetFromDB(db, "subnet/sn-1/vxlan_id"); err == nil { @@ -166,7 +166,7 @@ func TestCreateSubnetCommand_Prepare_UnknownMode(t *testing.T) { kv.AddInDB(db, "vpc/vpc-1/state", "created") cmd := CreateSubnetCommand{ Name: "sn-1", VPC: "vpc-1", Mode: "vlan", - IfaceType: "vms", GatewayIP: "10.0.0.1", CIDR: "10.0.0.0/24", + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", } if err := cmd.Prepare(db, testCfg()); err == nil { t.Error("Prepare devrait échouer pour un mode inconnu") diff --git a/internal/subnet/create.go b/internal/subnet/create.go index 3fbd31b..e660814 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -81,7 +81,7 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error { switch d.mode { case "vxlan": if err := netns.Call(d.vpc, func() error { - return netif.AddrAdd(d.bridge, d.gatewayIP) + return netif.AddrAdd(d.bridge, d.interfaceIP) }); err != nil { return fmt.Errorf("add addr to bridge in netns: %w", err) } @@ -95,7 +95,7 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error { switch d.mode { case "vxlan": - if err := ebtables.DropARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { + if err := ebtables.DropARPToGateway(d.bridge, d.interfaceIP.String()); err != nil { return err } if err := ebtables.DropDHCP(d.bridge); err != nil { @@ -133,7 +133,7 @@ func setupVxlanHost(d subnetData, vethE string) error { func startDHCP(db *badger.DB, subnetName string, d subnetData) error { conf := dhcp.Config{ Network: d.cidr, - Gateway: d.gatewayIP, + Gateway: d.interfaceIP, Name: d.vpc + "_" + d.bridge, ConfDir: "/etc/dnsmasq.d", } diff --git a/internal/subnet/data.go b/internal/subnet/data.go index 2936e21..849b141 100644 --- a/internal/subnet/data.go +++ b/internal/subnet/data.go @@ -17,7 +17,7 @@ type subnetData struct { mode string vxlanID int localIface string - gatewayIP net.IP + interfaceIP net.IP cidr *net.IPNet } @@ -57,15 +57,15 @@ func loadSubnet(db *badger.DB, name string) (subnetData, error) { } d.localIface = localIface - gatewayIPStr, err := kv.GetFromDB(db, "subnet/"+name+"/gateway_ip") + interfaceIPStr, err := kv.GetFromDB(db, "subnet/"+name+"/interface_ip") if err != nil { - return d, fmt.Errorf("get gateway_ip: %w", err) + return d, fmt.Errorf("get interface_ip: %w", err) } - gatewayIP := net.ParseIP(gatewayIPStr) - if gatewayIP == nil { - return d, fmt.Errorf("invalid gateway_ip: %s", gatewayIPStr) + interfaceIP := net.ParseIP(interfaceIPStr) + if interfaceIP == nil { + return d, fmt.Errorf("invalid interface_ip: %s", interfaceIPStr) } - d.gatewayIP = gatewayIP + d.interfaceIP = interfaceIP cidrStr, err := kv.GetFromDB(db, "subnet/"+name+"/cidr") if err != nil { diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index 51fac1a..abe2fdf 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -71,7 +71,7 @@ func stopDHCP(db *badger.DB, subnetName string, d subnetData) error { func deleteSubnetVxlan(d subnetData) error { vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) - if err := ebtables.DeleteARPToGateway(d.bridge, d.gatewayIP.String()); err != nil { + if err := ebtables.DeleteARPToGateway(d.bridge, d.interfaceIP.String()); err != nil { return fmt.Errorf("delete ebtables arp rule: %w", err) } if err := ebtables.DeleteDHCP(d.bridge); err != nil { diff --git a/internal/vm/create.go b/internal/vm/create.go index 53e2e96..f3e9d34 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -33,7 +33,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { } if err := netns.Call(d.vpcName, func() error { - return iptables.AddMetadataRedirect(d.ip, d.gatewayIP, d.metadataPort) + return iptables.AddMetadataRedirect(d.ip, d.interfaceIP, d.metadataPort) }); err != nil { return fmt.Errorf("add metadata redirect: %w", err) } @@ -41,7 +41,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { if err := metadata.StartMetadata(metadata.NoCloudConfig{ Name: name, VpcName: d.vpcName, - BindIP: d.gatewayIP, + BindIP: d.interfaceIP, BindPort: d.metadataPort, Password: d.password, SSHKEY: d.sshkey, diff --git a/internal/vm/data.go b/internal/vm/data.go index 0563fc0..df5c051 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -14,7 +14,7 @@ import ( type vmData struct { subnetName string vpcName string - gatewayIP string + interfaceIP string bridge string tapID int ip string @@ -43,11 +43,11 @@ func loadVM(db *badger.DB, name string) (vmData, error) { } d.vpcName = vpcName - gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") + interfaceIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/interface_ip") if err != nil { - return d, fmt.Errorf("get gateway_ip: %w", err) + return d, fmt.Errorf("get interface_ip: %w", err) } - d.gatewayIP = gatewayIP + d.interfaceIP = interfaceIP tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id") if err != nil { diff --git a/internal/vm/delete.go b/internal/vm/delete.go index 5030339..79a4c72 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -52,7 +52,7 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { } if err := netns.Call(d.vpcName, func() error { - return iptables.DeleteMetadataRedirect(d.ip, d.gatewayIP, d.metadataPort) + return iptables.DeleteMetadataRedirect(d.ip, d.interfaceIP, d.metadataPort) }); err != nil { return fmt.Errorf("delete metadata redirect: %w", err) } From 32b78a84f98b034ee731c6cd3b35cfcab90d4cbe Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 22:14:21 +0200 Subject: [PATCH 40/57] f-28: fix: add proper dhcp handle Signed-off-by: GnomeZworc --- internal/ebtables/ebtables.go | 22 +++++++++++---------- internal/subnet/create.go | 36 +++++++++++++++++++---------------- internal/subnet/delete.go | 23 +++++++++++++--------- 3 files changed, 46 insertions(+), 35 deletions(-) diff --git a/internal/ebtables/ebtables.go b/internal/ebtables/ebtables.go index 31e276b..40ea0f0 100644 --- a/internal/ebtables/ebtables.go +++ b/internal/ebtables/ebtables.go @@ -13,46 +13,48 @@ func deleteRule(args ...string) error { return exec.Command("ebtables", append([]string{"-D"}, args...)...).Run() } -func DropARPToGateway(bridge, gatewayIP string) error { +func DropARPToGateway(iface, ip string) error { if err := addRule("FORWARD", - "--out-interface", bridge, + "--out-interface", iface, "-p", "arp", "--arp-op", "Request", - "--arp-ip-dst", gatewayIP, + "--arp-ip-dst", ip, "-j", "DROP"); err != nil { return fmt.Errorf("ebtables arp rule: %w", err) } return nil } -func DropDHCP(bridge string) error { +func DropDHCP(iface, ip string) error { if err := addRule("FORWARD", - "--out-interface", bridge, + "--out-interface", iface, "-p", "IPv4", "--ip-protocol", "udp", "--ip-source-port", "67:68", "--ip-destination-port", "67:68", + "--ip-source", ip, "-j", "DROP"); err != nil { return fmt.Errorf("ebtables dhcp rule: %w", err) } return nil } -func DeleteARPToGateway(bridge, gatewayIP string) error { +func DeleteARPToGateway(iface, ip string) error { return deleteRule("FORWARD", - "--out-interface", bridge, + "--out-interface", iface, "-p", "arp", "--arp-op", "Request", - "--arp-ip-dst", gatewayIP, + "--arp-ip-dst", ip, "-j", "DROP") } -func DeleteDHCP(bridge string) error { +func DeleteDHCP(iface, ip string) error { return deleteRule("FORWARD", - "--out-interface", bridge, + "--out-interface", iface, "-p", "IPv4", "--ip-protocol", "udp", "--ip-source-port", "67:68", "--ip-destination-port", "67:68", + "--ip-source", ip, "-j", "DROP") } diff --git a/internal/subnet/create.go b/internal/subnet/create.go index e660814..f161d98 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -81,27 +81,31 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error { switch d.mode { case "vxlan": if err := netns.Call(d.vpc, func() error { - return netif.AddrAdd(d.bridge, d.interfaceIP) + if err := netif.AddrAdd(d.bridge, d.interfaceIP); err != nil { + return fmt.Errorf("add addr: %w", err) + } + if err := netif.RouteAdd(d.bridge, d.cidr); err != nil { + return fmt.Errorf("add route: %w", err) + } + if err := ebtables.DropARPToGateway(vethI, d.interfaceIP.String()); err != nil { + return err + } + return ebtables.DropDHCP(vethI, d.interfaceIP.String()) }); err != nil { - return fmt.Errorf("add addr to bridge in netns: %w", err) + return fmt.Errorf("configure netns: %w", err) } + case "bridge": if err := netns.Call(d.vpc, func() error { - return netif.RouteAdd(d.bridge, d.cidr) + if err := netif.AddrAdd(d.bridge, d.interfaceIP); err != nil { + return fmt.Errorf("add addr: %w", err) + } + if err := netif.RouteAdd(d.bridge, d.cidr); err != nil { + return fmt.Errorf("add route: %w", err) + } + return ebtables.DropDHCP(vethI, d.interfaceIP.String()) }); err != nil { - return fmt.Errorf("add route in netns: %w", err) + return fmt.Errorf("configure netns: %w", err) } - case "bridge": - } - - switch d.mode { - case "vxlan": - if err := ebtables.DropARPToGateway(d.bridge, d.interfaceIP.String()); err != nil { - return err - } - if err := ebtables.DropDHCP(d.bridge); err != nil { - return err - } - case "bridge": } return startDHCP(db, subnetName, d) diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index abe2fdf..0ae30b5 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -70,18 +70,18 @@ func stopDHCP(db *badger.DB, subnetName string, d subnetData) error { func deleteSubnetVxlan(d subnetData) error { vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) - - if err := ebtables.DeleteARPToGateway(d.bridge, d.interfaceIP.String()); err != nil { - return fmt.Errorf("delete ebtables arp rule: %w", err) - } - if err := ebtables.DeleteDHCP(d.bridge); err != nil { - return fmt.Errorf("delete ebtables dhcp rule: %w", err) - } + vethI := "v-" + d.subnetID + "-i" if err := netns.Call(d.vpc, func() error { + if err := ebtables.DeleteARPToGateway(vethI, d.interfaceIP.String()); err != nil { + return fmt.Errorf("delete ebtables arp rule: %w", err) + } + if err := ebtables.DeleteDHCP(vethI, d.interfaceIP.String()); err != nil { + return fmt.Errorf("delete ebtables dhcp rule: %w", err) + } return netif.DeleteLink(d.bridge) }); err != nil { - return fmt.Errorf("delete bridge in netns: %w", err) + return fmt.Errorf("delete netns resources: %w", err) } if err := netif.DeleteLink(vxlanIface); err != nil { @@ -99,10 +99,15 @@ func deleteSubnetVxlan(d subnetData) error { } func deleteSubnetBridge(d subnetData) error { + vethI := "v-" + d.subnetID + "-i" + if err := netns.Call(d.vpc, func() error { + if err := ebtables.DeleteDHCP(vethI, d.interfaceIP.String()); err != nil { + return fmt.Errorf("delete ebtables dhcp rule: %w", err) + } return netif.DeleteLink(d.bridge) }); err != nil { - return fmt.Errorf("delete bridge in netns: %w", err) + return fmt.Errorf("delete netns resources: %w", err) } if err := netif.DeleteLink("v-" + d.subnetID + "-e"); err != nil { From 2504b435a33de818532b8761915111a1656ad493 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 22:19:00 +0200 Subject: [PATCH 41/57] f-28: fix: dhcp do not emit local default route Signed-off-by: GnomeZworc --- internal/dhcp/dhcp_test.go | 20 ++++++++++++++++---- internal/dhcp/generate.go | 4 +++- internal/dhcp/struct.go | 9 +++++---- internal/subnet/create.go | 9 +++++---- 4 files changed, 29 insertions(+), 13 deletions(-) diff --git a/internal/dhcp/dhcp_test.go b/internal/dhcp/dhcp_test.go index 1681fec..94f1b78 100644 --- a/internal/dhcp/dhcp_test.go +++ b/internal/dhcp/dhcp_test.go @@ -52,10 +52,11 @@ func newConf(t *testing.T, cidr string) Config { t.Helper() _, network, _ := net.ParseCIDR(cidr) return Config{ - Network: network, - Gateway: net.ParseIP("192.168.1.1").To4(), - Name: "test", - ConfDir: t.TempDir(), + Network: network, + Gateway: net.ParseIP("192.168.1.1").To4(), + DefaultRoute: true, + Name: "test", + ConfDir: t.TempDir(), } } @@ -94,6 +95,17 @@ func TestGenerateConfig_ContainsGateway(t *testing.T) { } } +func TestGenerateConfig_NoDefaultRoute(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + conf.DefaultRoute = false + path, _, _ := GenerateConfig(conf) + content, _ := os.ReadFile(path) + + if strings.Contains(string(content), "dhcp-option=3,") { + t.Errorf("dhcp-option=3 présente alors que DefaultRoute=false :\n%s", content) + } +} + func TestGenerateConfig_ContainsDhcpRange(t *testing.T) { _, network, _ := net.ParseCIDR("10.10.0.0/24") conf := Config{ diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go index 3c46fe2..90608f3 100644 --- a/internal/dhcp/generate.go +++ b/internal/dhcp/generate.go @@ -14,7 +14,9 @@ func GenerateConfig(c Config) (string, map[string]string, error) { var sb strings.Builder fmt.Fprintf(&sb, "no-resolv\n") fmt.Fprintf(&sb, "dhcp-range=%s,static,%s,12h\n", c.Network.IP.String(), mask) - fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.Gateway.String()) + if c.DefaultRoute { + fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.Gateway.String()) + } fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n\n") entries := make(map[string]string) diff --git a/internal/dhcp/struct.go b/internal/dhcp/struct.go index 4c69b9c..d73e052 100644 --- a/internal/dhcp/struct.go +++ b/internal/dhcp/struct.go @@ -5,8 +5,9 @@ import ( ) type Config struct { - Network *net.IPNet - Gateway net.IP - Name string - ConfDir string + Network *net.IPNet + Gateway net.IP + DefaultRoute bool + Name string + ConfDir string } diff --git a/internal/subnet/create.go b/internal/subnet/create.go index f161d98..8607328 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -136,10 +136,11 @@ func setupVxlanHost(d subnetData, vethE string) error { func startDHCP(db *badger.DB, subnetName string, d subnetData) error { conf := dhcp.Config{ - Network: d.cidr, - Gateway: d.interfaceIP, - Name: d.vpc + "_" + d.bridge, - ConfDir: "/etc/dnsmasq.d", + Network: d.cidr, + Gateway: d.interfaceIP, + DefaultRoute: d.mode == "vxlan", + Name: d.vpc + "_" + d.bridge, + ConfDir: "/etc/dnsmasq.d", } _, entries, err := dhcp.GenerateConfig(conf) if err != nil { From 76a840b80ad44ff3f5cb048d5cdea229630a1d70 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 22:47:28 +0200 Subject: [PATCH 42/57] f-28: add vpc cidr field Signed-off-by: GnomeZworc --- api/agent.yaml | 9 +++++- internal/api/agent/models.go | 2 ++ internal/api/agent/vpc.go | 3 +- internal/api/agent/vpc_test.go | 29 +++++++++++++++++-- internal/api/agent/vpcs.go | 20 +++++++++++-- internal/dispatcher/agent/vpc_commands.go | 8 +++++ .../dispatcher/agent/vpc_commands_test.go | 19 ++++++++++-- internal/subnet/data.go | 25 +++++++++++----- 8 files changed, 98 insertions(+), 17 deletions(-) diff --git a/api/agent.yaml b/api/agent.yaml index 44a5c80..e3bf8b9 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -294,13 +294,17 @@ components: VPCCreateRequest: type: object - required: [name] + required: [name, cidr] properties: name: type: string description: Unique name for the VPC, must follow the format vp-[id] pattern: '^vp-.+' example: vp-00001 + cidr: + type: string + description: CIDR block for the entire VPC address space + example: "10.0.0.0/16" VPC: type: object @@ -312,6 +316,9 @@ components: type: string enum: [creating, created, deleting, deleted] example: created + cidr: + type: string + example: "10.0.0.0/16" SubnetCreateRequest: type: object diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index 4621f1c..ffa994a 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -2,11 +2,13 @@ package agentapi type VPCCreateRequest struct { Name string `json:"name"` + CIDR string `json:"cidr"` } type VPC struct { Name string `json:"name"` State string `json:"state"` + CIDR string `json:"cidr"` } type SubnetCreateRequest struct { diff --git a/internal/api/agent/vpc.go b/internal/api/agent/vpc.go index 43cc33c..bb3f5d7 100644 --- a/internal/api/agent/vpc.go +++ b/internal/api/agent/vpc.go @@ -35,8 +35,9 @@ func (s *Server) getVpc(w http.ResponseWriter, _ *http.Request, name string) { json.NewEncoder(w).Encode(ErrorResponse{Error: "vpc not found"}) return } + cidr, _ := kv.GetFromDB(s.db, "vpc/"+name+"/cidr") w.WriteHeader(http.StatusOK) - json.NewEncoder(w).Encode(VPC{Name: name, State: state}) + json.NewEncoder(w).Encode(VPC{Name: name, State: state, CIDR: cidr}) } func (s *Server) deleteVpc(w http.ResponseWriter, _ *http.Request, name string) { diff --git a/internal/api/agent/vpc_test.go b/internal/api/agent/vpc_test.go index 01308a5..3516305 100644 --- a/internal/api/agent/vpc_test.go +++ b/internal/api/agent/vpc_test.go @@ -53,7 +53,7 @@ func TestListVpcs_InvalidMethod(t *testing.T) { func TestPostVpc_Created(t *testing.T) { s, _ := newTestServer(t) - body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-new"}) + body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-new", CIDR: "10.0.0.0/16"}) w := httptest.NewRecorder() s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) if w.Code != http.StatusAccepted { @@ -67,11 +67,34 @@ func TestPostVpc_Created(t *testing.T) { if result.State != "creating" { t.Errorf("state attendu creating, obtenu %q", result.State) } + if result.CIDR != "10.0.0.0/16" { + t.Errorf("cidr attendu 10.0.0.0/16, obtenu %q", result.CIDR) + } } func TestPostVpc_MissingName(t *testing.T) { s, _ := newTestServer(t) - body, _ := json.Marshal(VPCCreateRequest{}) + body, _ := json.Marshal(VPCCreateRequest{CIDR: "10.0.0.0/16"}) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +func TestPostVpc_MissingCIDR(t *testing.T) { + s, _ := newTestServer(t) + body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-new"}) + w := httptest.NewRecorder() + s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) + if w.Code != http.StatusBadRequest { + t.Errorf("attendu 400, obtenu %d", w.Code) + } +} + +func TestPostVpc_InvalidCIDR(t *testing.T) { + s, _ := newTestServer(t) + body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-new", CIDR: "not-a-cidr"}) w := httptest.NewRecorder() s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) if w.Code != http.StatusBadRequest { @@ -82,7 +105,7 @@ func TestPostVpc_MissingName(t *testing.T) { func TestPostVpc_Duplicate(t *testing.T) { s, db := newTestServer(t) kv.AddInDB(db, "vpc/vpc-exist/state", "created") - body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-exist"}) + body, _ := json.Marshal(VPCCreateRequest{Name: "vpc-exist", CIDR: "10.0.0.0/16"}) w := httptest.NewRecorder() s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) if w.Code != http.StatusConflict { diff --git a/internal/api/agent/vpcs.go b/internal/api/agent/vpcs.go index b062d1c..905cdee 100644 --- a/internal/api/agent/vpcs.go +++ b/internal/api/agent/vpcs.go @@ -2,6 +2,7 @@ package agentapi import ( "encoding/json" + "net" "net/http" "strings" @@ -38,8 +39,11 @@ func (s *Server) listVpcs(w http.ResponseWriter, _ *http.Request) { if _, ok := vpcs[name]; !ok { vpcs[name] = &VPC{Name: name} } - if parts[2] == "state" { + switch parts[2] { + case "state": vpcs[name].State = value + case "cidr": + vpcs[name].CIDR = value } } result := make([]VPC, 0, len(vpcs)) @@ -62,7 +66,17 @@ func (s *Server) postVpc(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(ErrorResponse{Error: "name is required"}) return } - cmd := dispatcher.CreateVPCCommand{Name: req.Name} + if req.CIDR == "" { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "cidr is required"}) + return + } + if _, _, err := net.ParseCIDR(req.CIDR); err != nil { + w.WriteHeader(http.StatusBadRequest) + json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid cidr"}) + return + } + cmd := dispatcher.CreateVPCCommand{Name: req.Name, CIDR: req.CIDR} if err := s.dispatcher.Prepare(cmd); err != nil { w.WriteHeader(http.StatusConflict) json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) @@ -76,5 +90,5 @@ func (s *Server) postVpc(w http.ResponseWriter, r *http.Request) { return } w.WriteHeader(http.StatusAccepted) - json.NewEncoder(w).Encode(VPC{Name: req.Name, State: state}) + json.NewEncoder(w).Encode(VPC{Name: req.Name, State: state, CIDR: req.CIDR}) } diff --git a/internal/dispatcher/agent/vpc_commands.go b/internal/dispatcher/agent/vpc_commands.go index c03dd77..a129d88 100644 --- a/internal/dispatcher/agent/vpc_commands.go +++ b/internal/dispatcher/agent/vpc_commands.go @@ -2,6 +2,7 @@ package dispatcher import ( "fmt" + "net" "strings" "time" @@ -13,12 +14,19 @@ import ( type CreateVPCCommand struct { Name string + CIDR string } func (c CreateVPCCommand) Prepare(db *badger.DB, _ *configuration.Config) error { if _, err := kv.GetFromDB(db, "vpc/"+c.Name+"/state"); err == nil { return fmt.Errorf("vpc %q already exists", c.Name) } + if _, _, err := net.ParseCIDR(c.CIDR); err != nil { + return fmt.Errorf("invalid cidr %q: %w", c.CIDR, err) + } + if err := kv.AddInDB(db, "vpc/"+c.Name+"/cidr", c.CIDR); err != nil { + return err + } return kv.AddInDB(db, "vpc/"+c.Name+"/state", "creating") } diff --git a/internal/dispatcher/agent/vpc_commands_test.go b/internal/dispatcher/agent/vpc_commands_test.go index e2f2162..9633733 100644 --- a/internal/dispatcher/agent/vpc_commands_test.go +++ b/internal/dispatcher/agent/vpc_commands_test.go @@ -10,7 +10,7 @@ import ( func TestCreateVPCCommand_Prepare_NewVPC(t *testing.T) { _, db := newTestDispatcher(t) - cmd := CreateVPCCommand{Name: "vpc-1"} + cmd := CreateVPCCommand{Name: "vpc-1", CIDR: "10.0.0.0/16"} if err := cmd.Prepare(db, nil); err != nil { t.Fatalf("Prepare a échoué : %v", err) } @@ -21,17 +21,32 @@ func TestCreateVPCCommand_Prepare_NewVPC(t *testing.T) { if state != "creating" { t.Errorf("state attendu creating, obtenu %q", state) } + cidr, err := kv.GetFromDB(db, "vpc/vpc-1/cidr") + if err != nil { + t.Fatalf("cidr non écrit en DB : %v", err) + } + if cidr != "10.0.0.0/16" { + t.Errorf("cidr attendu 10.0.0.0/16, obtenu %q", cidr) + } } func TestCreateVPCCommand_Prepare_Duplicate(t *testing.T) { _, db := newTestDispatcher(t) kv.AddInDB(db, "vpc/vpc-exist/state", "created") - cmd := CreateVPCCommand{Name: "vpc-exist"} + cmd := CreateVPCCommand{Name: "vpc-exist", CIDR: "10.0.0.0/16"} if err := cmd.Prepare(db, nil); err == nil { t.Error("Prepare devrait échouer sur un VPC déjà existant") } } +func TestCreateVPCCommand_Prepare_InvalidCIDR(t *testing.T) { + _, db := newTestDispatcher(t) + cmd := CreateVPCCommand{Name: "vpc-bad", CIDR: "not-a-cidr"} + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer avec un CIDR invalide") + } +} + // --- DeleteVPCCommand.Prepare --- func TestDeleteVPCCommand_Prepare_Success(t *testing.T) { diff --git a/internal/subnet/data.go b/internal/subnet/data.go index 849b141..fbc45ff 100644 --- a/internal/subnet/data.go +++ b/internal/subnet/data.go @@ -11,14 +11,15 @@ import ( ) type subnetData struct { - vpc string - subnetID string - bridge string - mode string - vxlanID int - localIface string + vpc string + subnetID string + bridge string + mode string + vxlanID int + localIface string interfaceIP net.IP - cidr *net.IPNet + cidr *net.IPNet + vpcCIDR *net.IPNet } func loadSubnet(db *badger.DB, name string) (subnetData, error) { @@ -77,5 +78,15 @@ func loadSubnet(db *badger.DB, name string) (subnetData, error) { } d.cidr = ipNet + vpcCIDRStr, err := kv.GetFromDB(db, "vpc/"+d.vpc+"/cidr") + if err != nil { + return d, fmt.Errorf("get vpc cidr: %w", err) + } + _, vpcIPNet, err := net.ParseCIDR(vpcCIDRStr) + if err != nil { + return d, fmt.Errorf("parse vpc cidr: %w", err) + } + d.vpcCIDR = vpcIPNet + return d, nil } From bb5698fddae2333e017192bd9516e165ea025b21 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 22:49:01 +0200 Subject: [PATCH 43/57] f-28: add subnet default_route field Signed-off-by: GnomeZworc --- api/agent.yaml | 9 +++++ internal/api/agent/models.go | 32 +++++++++-------- internal/api/agent/subnet.go | 2 ++ internal/api/agent/subnets.go | 19 +++++++---- internal/dispatcher/agent/subnet_commands.go | 16 +++++---- .../dispatcher/agent/subnet_commands_test.go | 34 +++++++++++++++++++ internal/subnet/data.go | 25 +++++++++----- 7 files changed, 99 insertions(+), 38 deletions(-) diff --git a/api/agent.yaml b/api/agent.yaml index e3bf8b9..1476859 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -358,6 +358,12 @@ components: type: string description: Subnet CIDR block example: "10.10.10.0/24" + default_route: + type: boolean + description: > + If true, advertise a default route via DHCP. For vxlan mode the gateway is the interface IP. + For bridge mode the gateway is read from the host routing table. + default: false Subnet: type: object @@ -390,6 +396,9 @@ components: cidr: type: string example: "10.10.10.0/24" + default_route: + type: boolean + example: false VMCreateRequest: type: object diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index ffa994a..6535c4c 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -12,24 +12,26 @@ type VPC struct { } type SubnetCreateRequest struct { - Name string `json:"name"` - VPC string `json:"vpc"` - Mode string `json:"mode"` - VxlanID int `json:"vxlan_id"` - IfaceType string `json:"iface_type"` - InterfaceIP string `json:"interface_ip"` - CIDR string `json:"cidr"` + Name string `json:"name"` + VPC string `json:"vpc"` + Mode string `json:"mode"` + VxlanID int `json:"vxlan_id"` + IfaceType string `json:"iface_type"` + InterfaceIP string `json:"interface_ip"` + CIDR string `json:"cidr"` + DefaultRoute bool `json:"default_route"` } type Subnet struct { - Name string `json:"name"` - State string `json:"state"` - VPC string `json:"vpc"` - Mode string `json:"mode"` - VxlanID int `json:"vxlan_id"` - LocalIface string `json:"local_iface"` - InterfaceIP string `json:"interface_ip"` - CIDR string `json:"cidr"` + Name string `json:"name"` + State string `json:"state"` + VPC string `json:"vpc"` + Mode string `json:"mode"` + VxlanID int `json:"vxlan_id"` + LocalIface string `json:"local_iface"` + InterfaceIP string `json:"interface_ip"` + CIDR string `json:"cidr"` + DefaultRoute bool `json:"default_route"` } type VMInterface struct { diff --git a/internal/api/agent/subnet.go b/internal/api/agent/subnet.go index 95b0199..36ee9d7 100644 --- a/internal/api/agent/subnet.go +++ b/internal/api/agent/subnet.go @@ -57,6 +57,8 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string) sub.InterfaceIP = value case "cidr": sub.CIDR = value + case "default_route": + sub.DefaultRoute = value == "true" } } w.WriteHeader(http.StatusOK) diff --git a/internal/api/agent/subnets.go b/internal/api/agent/subnets.go index a70e618..3cea247 100644 --- a/internal/api/agent/subnets.go +++ b/internal/api/agent/subnets.go @@ -54,6 +54,8 @@ func (s *Server) listSubnets(w http.ResponseWriter, _ *http.Request) { subnets[name].InterfaceIP = value case "cidr": subnets[name].CIDR = value + case "default_route": + subnets[name].DefaultRoute = value == "true" } } result := make([]Subnet, 0, len(subnets)) @@ -77,13 +79,14 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { return } cmd := dispatcher.CreateSubnetCommand{ - Name: req.Name, - VPC: req.VPC, - Mode: req.Mode, - VxlanID: req.VxlanID, - IfaceType: req.IfaceType, - InterfaceIP: req.InterfaceIP, - CIDR: req.CIDR, + Name: req.Name, + VPC: req.VPC, + Mode: req.Mode, + VxlanID: req.VxlanID, + IfaceType: req.IfaceType, + InterfaceIP: req.InterfaceIP, + CIDR: req.CIDR, + DefaultRoute: req.DefaultRoute, } if err := s.dispatcher.Prepare(cmd); err != nil { if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil { @@ -122,6 +125,8 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) { sub.InterfaceIP = value case "cidr": sub.CIDR = value + case "default_route": + sub.DefaultRoute = value == "true" } } w.WriteHeader(http.StatusAccepted) diff --git a/internal/dispatcher/agent/subnet_commands.go b/internal/dispatcher/agent/subnet_commands.go index db07c6c..e488842 100644 --- a/internal/dispatcher/agent/subnet_commands.go +++ b/internal/dispatcher/agent/subnet_commands.go @@ -12,13 +12,14 @@ import ( ) type CreateSubnetCommand struct { - Name string - VPC string - Mode string - VxlanID int - IfaceType string - InterfaceIP string - CIDR string + Name string + VPC string + Mode string + VxlanID int + IfaceType string + InterfaceIP string + CIDR string + DefaultRoute bool } func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error { @@ -48,6 +49,7 @@ func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) e kv.AddInDB(db, "subnet/"+c.Name+"/local_iface", localIface) kv.AddInDB(db, "subnet/"+c.Name+"/interface_ip", c.InterfaceIP) kv.AddInDB(db, "subnet/"+c.Name+"/cidr", c.CIDR) + kv.AddInDB(db, "subnet/"+c.Name+"/default_route", strconv.FormatBool(c.DefaultRoute)) if c.Mode == "vxlan" { kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID)) } diff --git a/internal/dispatcher/agent/subnet_commands_test.go b/internal/dispatcher/agent/subnet_commands_test.go index 94f2d4c..b6aacee 100644 --- a/internal/dispatcher/agent/subnet_commands_test.go +++ b/internal/dispatcher/agent/subnet_commands_test.go @@ -173,6 +173,40 @@ func TestCreateSubnetCommand_Prepare_UnknownMode(t *testing.T) { } } +func TestCreateSubnetCommand_Prepare_DefaultRouteStored(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", VxlanID: 100, + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", + DefaultRoute: true, + } + if err := cmd.Prepare(db, testCfg()); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + val, err := kv.GetFromDB(db, "subnet/sn-1/default_route") + if err != nil { + t.Fatalf("default_route non écrit en DB : %v", err) + } + if val != "true" { + t.Errorf("default_route attendu true, obtenu %q", val) + } +} + +func TestCreateSubnetCommand_Prepare_DefaultRouteFalseByDefault(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vpc/vpc-1/state", "created") + cmd := CreateSubnetCommand{ + Name: "sn-1", VPC: "vpc-1", VxlanID: 100, + IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24", + } + cmd.Prepare(db, testCfg()) + val, _ := kv.GetFromDB(db, "subnet/sn-1/default_route") + if val != "false" { + t.Errorf("default_route attendu false, obtenu %q", val) + } +} + // --- DeleteSubnetCommand.Prepare --- func TestDeleteSubnetCommand_Prepare_Success(t *testing.T) { diff --git a/internal/subnet/data.go b/internal/subnet/data.go index fbc45ff..0f1f62a 100644 --- a/internal/subnet/data.go +++ b/internal/subnet/data.go @@ -11,15 +11,16 @@ import ( ) type subnetData struct { - vpc string - subnetID string - bridge string - mode string - vxlanID int - localIface string - interfaceIP net.IP - cidr *net.IPNet - vpcCIDR *net.IPNet + vpc string + subnetID string + bridge string + mode string + vxlanID int + localIface string + interfaceIP net.IP + cidr *net.IPNet + vpcCIDR *net.IPNet + defaultRoute bool } func loadSubnet(db *badger.DB, name string) (subnetData, error) { @@ -78,6 +79,12 @@ func loadSubnet(db *badger.DB, name string) (subnetData, error) { } d.cidr = ipNet + defaultRouteStr, err := kv.GetFromDB(db, "subnet/"+name+"/default_route") + if err != nil { + return d, fmt.Errorf("get default_route: %w", err) + } + d.defaultRoute = defaultRouteStr == "true" + vpcCIDRStr, err := kv.GetFromDB(db, "vpc/"+d.vpc+"/cidr") if err != nil { return d, fmt.Errorf("get vpc cidr: %w", err) From c0caf1a24cb9022d6259e0f4f42c332d9ad787b7 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 22:49:21 +0200 Subject: [PATCH 44/57] f-28: add GetDefaultGateway to netif Signed-off-by: GnomeZworc --- internal/netif/gateway_linux.go | 23 +++++++++++++++++++++++ internal/netif/gateway_other.go | 12 ++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 internal/netif/gateway_linux.go create mode 100644 internal/netif/gateway_other.go diff --git a/internal/netif/gateway_linux.go b/internal/netif/gateway_linux.go new file mode 100644 index 0000000..7636b7a --- /dev/null +++ b/internal/netif/gateway_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package netif + +import ( + "fmt" + "net" + + "github.com/vishvananda/netlink" +) + +func GetDefaultGateway() (net.IP, error) { + routes, err := netlink.RouteList(nil, netlink.FAMILY_V4) + if err != nil { + return nil, fmt.Errorf("list routes: %w", err) + } + for _, r := range routes { + if r.Dst == nil && r.Gw != nil { + return r.Gw, nil + } + } + return nil, fmt.Errorf("no default gateway found") +} diff --git a/internal/netif/gateway_other.go b/internal/netif/gateway_other.go new file mode 100644 index 0000000..764867c --- /dev/null +++ b/internal/netif/gateway_other.go @@ -0,0 +1,12 @@ +//go:build !linux + +package netif + +import ( + "fmt" + "net" +) + +func GetDefaultGateway() (net.IP, error) { + return nil, fmt.Errorf("not supported on this platform") +} From 1b56a42627351e80d9f7de3f68e4383d0af115b3 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 22:50:39 +0200 Subject: [PATCH 45/57] f-28: refactor dhcp config to use VPCRoute and DefaultGateway Signed-off-by: GnomeZworc --- internal/dhcp/dhcp_test.go | 46 ++++++++++++++++++++++++++++---------- internal/dhcp/generate.go | 7 ++++-- internal/dhcp/struct.go | 11 ++++----- internal/subnet/create.go | 21 ++++++++++++----- 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/internal/dhcp/dhcp_test.go b/internal/dhcp/dhcp_test.go index 94f1b78..a2d5e60 100644 --- a/internal/dhcp/dhcp_test.go +++ b/internal/dhcp/dhcp_test.go @@ -51,12 +51,15 @@ func TestIncrementIP_Carry(t *testing.T) { func newConf(t *testing.T, cidr string) Config { t.Helper() _, network, _ := net.ParseCIDR(cidr) + _, vpcNet, _ := net.ParseCIDR("10.0.0.0/16") + gw := net.ParseIP("192.168.1.1").To4() return Config{ - Network: network, - Gateway: net.ParseIP("192.168.1.1").To4(), - DefaultRoute: true, - Name: "test", - ConfDir: t.TempDir(), + Network: network, + VPCGateway: gw, + VPCRoute: vpcNet, + DefaultGateway: gw, + Name: "test", + ConfDir: t.TempDir(), } } @@ -85,24 +88,45 @@ func TestGenerateConfig_FilenameMatchesName(t *testing.T) { } } -func TestGenerateConfig_ContainsGateway(t *testing.T) { +func TestGenerateConfig_ContainsDefaultGateway(t *testing.T) { conf := newConf(t, "192.168.1.0/29") path, _, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) if !strings.Contains(string(content), "dhcp-option=3,192.168.1.1") { - t.Errorf("gateway absente du fichier généré :\n%s", content) + t.Errorf("dhcp-option=3 absente du fichier généré :\n%s", content) } } -func TestGenerateConfig_NoDefaultRoute(t *testing.T) { +func TestGenerateConfig_NoDefaultGateway(t *testing.T) { conf := newConf(t, "192.168.1.0/29") - conf.DefaultRoute = false + conf.DefaultGateway = nil path, _, _ := GenerateConfig(conf) content, _ := os.ReadFile(path) if strings.Contains(string(content), "dhcp-option=3,") { - t.Errorf("dhcp-option=3 présente alors que DefaultRoute=false :\n%s", content) + t.Errorf("dhcp-option=3 présente alors que DefaultGateway=nil :\n%s", content) + } +} + +func TestGenerateConfig_ContainsVPCRoute(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + path, _, _ := GenerateConfig(conf) + content, _ := os.ReadFile(path) + + if !strings.Contains(string(content), "dhcp-option=121,10.0.0.0/16,192.168.1.1") { + t.Errorf("dhcp-option=121 absente ou incorrecte :\n%s", content) + } +} + +func TestGenerateConfig_NoVPCRoute(t *testing.T) { + conf := newConf(t, "192.168.1.0/29") + conf.VPCRoute = nil + path, _, _ := GenerateConfig(conf) + content, _ := os.ReadFile(path) + + if strings.Contains(string(content), "dhcp-option=121,") { + t.Errorf("dhcp-option=121 présente alors que VPCRoute=nil :\n%s", content) } } @@ -110,7 +134,6 @@ func TestGenerateConfig_ContainsDhcpRange(t *testing.T) { _, network, _ := net.ParseCIDR("10.10.0.0/24") conf := Config{ Network: network, - Gateway: net.ParseIP("10.10.0.1").To4(), Name: "vpc1", ConfDir: t.TempDir(), } @@ -156,7 +179,6 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) { _, network, _ := net.ParseCIDR("10.0.0.0/30") conf := Config{ Network: network, - Gateway: net.ParseIP("10.0.0.1").To4(), Name: "net", ConfDir: dir, } diff --git a/internal/dhcp/generate.go b/internal/dhcp/generate.go index 90608f3..2b6c72f 100644 --- a/internal/dhcp/generate.go +++ b/internal/dhcp/generate.go @@ -14,8 +14,11 @@ func GenerateConfig(c Config) (string, map[string]string, error) { var sb strings.Builder fmt.Fprintf(&sb, "no-resolv\n") fmt.Fprintf(&sb, "dhcp-range=%s,static,%s,12h\n", c.Network.IP.String(), mask) - if c.DefaultRoute { - fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.Gateway.String()) + if c.VPCRoute != nil { + fmt.Fprintf(&sb, "dhcp-option=121,%s,%s\n", c.VPCRoute.String(), c.VPCGateway.String()) + } + if c.DefaultGateway != nil { + fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.DefaultGateway.String()) } fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n\n") diff --git a/internal/dhcp/struct.go b/internal/dhcp/struct.go index d73e052..316667c 100644 --- a/internal/dhcp/struct.go +++ b/internal/dhcp/struct.go @@ -5,9 +5,10 @@ import ( ) type Config struct { - Network *net.IPNet - Gateway net.IP - DefaultRoute bool - Name string - ConfDir string + Network *net.IPNet + VPCGateway net.IP // next-hop for VPCRoute (option 121) + VPCRoute *net.IPNet // if non-nil, emit dhcp-option=121,VPCRoute,VPCGateway + DefaultGateway net.IP // if non-nil, emit dhcp-option=3,DefaultGateway + Name string + ConfDir string } diff --git a/internal/subnet/create.go b/internal/subnet/create.go index 8607328..a3faf46 100644 --- a/internal/subnet/create.go +++ b/internal/subnet/create.go @@ -136,11 +136,22 @@ func setupVxlanHost(d subnetData, vethE string) error { func startDHCP(db *badger.DB, subnetName string, d subnetData) error { conf := dhcp.Config{ - Network: d.cidr, - Gateway: d.interfaceIP, - DefaultRoute: d.mode == "vxlan", - Name: d.vpc + "_" + d.bridge, - ConfDir: "/etc/dnsmasq.d", + Network: d.cidr, + Name: d.vpc + "_" + d.bridge, + ConfDir: "/etc/dnsmasq.d", + } + switch d.mode { + case "vxlan": + conf.VPCGateway = d.interfaceIP + conf.VPCRoute = d.vpcCIDR + case "bridge": + if d.defaultRoute { + gw, err := netif.GetDefaultGateway() + if err != nil { + return fmt.Errorf("get default gateway: %w", err) + } + conf.DefaultGateway = gw + } } _, entries, err := dhcp.GenerateConfig(conf) if err != nil { From b41b4f251802ae3afb59d593d3bab590c3e81f31 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Mon, 18 May 2026 23:25:39 +0200 Subject: [PATCH 46/57] f-28: generate metadata_port automatically at vm creation Signed-off-by: GnomeZworc --- api/agent.yaml | 5 +-- internal/api/agent/models.go | 15 ++++---- internal/api/agent/vms.go | 21 +++++------ internal/dispatcher/agent/vm_commands.go | 47 +++++++++++++++++++----- 4 files changed, 55 insertions(+), 33 deletions(-) diff --git a/api/agent.yaml b/api/agent.yaml index 1476859..e75601d 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -402,14 +402,11 @@ components: VMCreateRequest: type: object - required: [name, metadata_port, interfaces, storage] + required: [name, interfaces, storage] properties: name: type: string example: vm-00001 - metadata_port: - type: string - example: "80" memory: type: integer description: Memory in MB (default 512) diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index 6535c4c..dc0c47f 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -46,14 +46,13 @@ type VMStorage struct { } type VMCreateRequest struct { - Name string `json:"name"` - MetadataPort string `json:"metadata_port"` - Memory int `json:"memory"` - CPUs int `json:"cpus"` - Password string `json:"password"` - SSHKey string `json:"sshkey"` - Interfaces []VMInterface `json:"interfaces"` - Storage []VMStorage `json:"storage"` + Name string `json:"name"` + Memory int `json:"memory"` + CPUs int `json:"cpus"` + Password string `json:"password"` + SSHKey string `json:"sshkey"` + Interfaces []VMInterface `json:"interfaces"` + Storage []VMStorage `json:"storage"` } type VM struct { diff --git a/internal/api/agent/vms.go b/internal/api/agent/vms.go index 841c429..95f2c85 100644 --- a/internal/api/agent/vms.go +++ b/internal/api/agent/vms.go @@ -58,9 +58,9 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"}) return } - if req.Name == "" || req.MetadataPort == "" || len(req.Interfaces) == 0 || len(req.Storage) == 0 { + if req.Name == "" || len(req.Interfaces) == 0 || len(req.Storage) == 0 { w.WriteHeader(http.StatusBadRequest) - json.NewEncoder(w).Encode(ErrorResponse{Error: "name, metadata_port, interfaces and storage are required"}) + json.NewEncoder(w).Encode(ErrorResponse{Error: "name, interfaces and storage are required"}) return } @@ -78,15 +78,14 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { } cmd := dispatcher.StartVMCommand{ - Name: req.Name, - Subnet: primary.Subnet, - IP: primary.IP, - MetadataPort: req.MetadataPort, - VolumePath: req.Storage[0].Path, - Memory: req.Memory, - CPUs: req.CPUs, - Password: req.Password, - SSHKey: req.SSHKey, + Name: req.Name, + Subnet: primary.Subnet, + IP: primary.IP, + VolumePath: req.Storage[0].Path, + Memory: req.Memory, + CPUs: req.CPUs, + Password: req.Password, + SSHKey: req.SSHKey, } if err := s.dispatcher.Prepare(cmd); err != nil { diff --git a/internal/dispatcher/agent/vm_commands.go b/internal/dispatcher/agent/vm_commands.go index aade998..1bb55d4 100644 --- a/internal/dispatcher/agent/vm_commands.go +++ b/internal/dispatcher/agent/vm_commands.go @@ -2,7 +2,9 @@ package dispatcher import ( "fmt" + "math/rand" "strconv" + "strings" "time" configuration "git.g3e.fr/syonad/two/internal/config/agent" @@ -12,15 +14,14 @@ import ( ) type StartVMCommand struct { - Name string - Subnet string - IP string - MetadataPort string - VolumePath string - Memory int - CPUs int - Password string - SSHKey string + Name string + Subnet string + IP string + VolumePath string + Memory int + CPUs int + Password string + SSHKey string } func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { @@ -34,10 +35,14 @@ func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { if subnetState == "deleting" || subnetState == "deleted" { return fmt.Errorf("subnet %q is %s", c.Subnet, subnetState) } + port, err := allocateMetadataPort(db) + if err != nil { + return fmt.Errorf("allocate metadata port: %w", err) + } kv.AddInDB(db, "vm/"+c.Name+"/state", "starting") kv.AddInDB(db, "vm/"+c.Name+"/subnet", c.Subnet) kv.AddInDB(db, "vm/"+c.Name+"/ip", c.IP) - kv.AddInDB(db, "vm/"+c.Name+"/metadata_port", c.MetadataPort) + kv.AddInDB(db, "vm/"+c.Name+"/metadata_port", strconv.Itoa(port)) kv.AddInDB(db, "vm/"+c.Name+"/volume_path", c.VolumePath) kv.AddInDB(db, "vm/"+c.Name+"/memory", strconv.Itoa(c.Memory)) kv.AddInDB(db, "vm/"+c.Name+"/cpus", strconv.Itoa(c.CPUs)) @@ -50,6 +55,28 @@ func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { return nil } +func allocateMetadataPort(db *badger.DB) (int, error) { + entries, err := kv.ListByPrefix(db, "vm/") + if err != nil { + return 0, err + } + used := make(map[int]struct{}) + for key, value := range entries { + if strings.HasSuffix(key, "/metadata_port") { + if p, err := strconv.Atoi(value); err == nil { + used[p] = struct{}{} + } + } + } + for range 100 { + p := rand.Intn(9000) + 1000 + if _, taken := used[p]; !taken { + return p, nil + } + } + return 0, fmt.Errorf("no free metadata port available in [1000, 9999]") +} + func (c StartVMCommand) Execute(db *badger.DB, cfg *configuration.Config) error { timeout := time.After(time.Duration(cfg.Dispatcher.TimeoutSeconds) * time.Second) for { From 732a2938570d146f51bc984776483c4bc46869af Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Thu, 21 May 2026 00:15:45 +0200 Subject: [PATCH 47/57] f-28: fix: debug somme minor errors Signed-off-by: GnomeZworc --- internal/metadata/templates/vendor-data.tmpl | 8 ++------ internal/netif/gateway_linux.go | 8 +++++++- internal/subnet/delete.go | 7 +++++-- 3 files changed, 14 insertions(+), 9 deletions(-) diff --git a/internal/metadata/templates/vendor-data.tmpl b/internal/metadata/templates/vendor-data.tmpl index 148d6db..fe0dcd8 100644 --- a/internal/metadata/templates/vendor-data.tmpl +++ b/internal/metadata/templates/vendor-data.tmpl @@ -2,12 +2,8 @@ users: - name: syonad lock_passwd: false - gecos: alpine Cloud User - groups: [adm, wheel] - doas: - - permit nopass syonad sudo: ["ALL=(ALL) NOPASSWD:ALL"] - shell: /bin/ash + shell: /bin/bash passwd: "{{ .Password }}" ssh_authorized_keys: - - "{{ .SSHKEY }}" \ No newline at end of file + - "{{ .SSHKEY }}" diff --git a/internal/netif/gateway_linux.go b/internal/netif/gateway_linux.go index 7636b7a..6141c64 100644 --- a/internal/netif/gateway_linux.go +++ b/internal/netif/gateway_linux.go @@ -15,7 +15,13 @@ func GetDefaultGateway() (net.IP, error) { return nil, fmt.Errorf("list routes: %w", err) } for _, r := range routes { - if r.Dst == nil && r.Gw != nil { + if r.Gw == nil { + continue + } + if r.Dst == nil { + return r.Gw, nil + } + if ones, _ := r.Dst.Mask.Size(); ones == 0 { return r.Gw, nil } } diff --git a/internal/subnet/delete.go b/internal/subnet/delete.go index 0ae30b5..f745eda 100644 --- a/internal/subnet/delete.go +++ b/internal/subnet/delete.go @@ -54,8 +54,11 @@ func stopDHCP(db *badger.DB, subnetName string, d subnetData) error { } defer svc.Close() - if err := svc.Stop("dnsmasq@" + d.vpc + "_" + d.bridge + ".service"); err != nil { - return fmt.Errorf("stop dnsmasq: %w", err) + svcName := "dnsmasq@" + d.vpc + "_" + d.bridge + ".service" + if status, err := svc.Status(svcName); err == nil && status.ActiveState == "active" { + if err := svc.Stop(svcName); err != nil { + return fmt.Errorf("stop dnsmasq: %w", err) + } } if err := os.Remove("/etc/dnsmasq.d/" + d.vpc + "_" + d.bridge + ".conf"); err != nil && !os.IsNotExist(err) { From c602725de90f319fc83b6e8f92227aae2f575b1d Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 23 May 2026 22:22:40 +0200 Subject: [PATCH 48/57] f-28: vm: add param in config file Signed-off-by: GnomeZworc --- conf/agent/config.exemple.yml | 12 ++++++++++++ internal/config/agent/struct.go | 14 ++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/conf/agent/config.exemple.yml b/conf/agent/config.exemple.yml index fdef682..0517eac 100644 --- a/conf/agent/config.exemple.yml +++ b/conf/agent/config.exemple.yml @@ -39,6 +39,18 @@ interfaces: metadata: run_dir: "/run/two/metadata" +# QEMU runtime paths +qemu: + # UEFI firmware (requires apt install ovmf on Debian/Ubuntu) + ovmf_code_path: "/usr/share/OVMF/OVMF_CODE.fd" + ovmf_vars_template: "/usr/share/OVMF/OVMF_VARS.fd" + # Per-VM UEFI variable store (writable copy, created at start / deleted at stop) + uefi_vars_dir: "/run/two/vms/efi" + # QEMU Unix socket directories + serial_dir: "/run/two/vms/serial" + monitor_dir: "/run/two/vms/monitor" + qmp_dir: "/run/two/vms/qmp" + # Admin API (read-only DB inspection, loopback only) admin: enabled: false diff --git a/internal/config/agent/struct.go b/internal/config/agent/struct.go index 9889c79..8b5d406 100644 --- a/internal/config/agent/struct.go +++ b/internal/config/agent/struct.go @@ -36,6 +36,14 @@ type Config struct { Address string `mapstructure:"address"` Port int `mapstructure:"port"` } `mapstructure:"admin"` + QEMU struct { + OVMFCodePath string `mapstructure:"ovmf_code_path"` + OVMFVarsTemplate string `mapstructure:"ovmf_vars_template"` + UEFIVarsDir string `mapstructure:"uefi_vars_dir"` + SerialDir string `mapstructure:"serial_dir"` + MonitorDir string `mapstructure:"monitor_dir"` + QMPDir string `mapstructure:"qmp_dir"` + } `mapstructure:"qemu"` DefaultInterface string `mapstructure:"default_interface"` Interfaces map[string]string `mapstructure:"interfaces"` } @@ -55,6 +63,12 @@ func LoadConfig(path string) (*Config, error) { v.SetDefault("dispatcher.timeout_seconds", 300) v.SetDefault("dispatcher.poll_seconds", 2) v.SetDefault("metadata.run_dir", "/run/two/metadata") + v.SetDefault("qemu.ovmf_code_path", "/usr/share/OVMF/OVMF_CODE.fd") + v.SetDefault("qemu.ovmf_vars_template", "/usr/share/OVMF/OVMF_VARS.fd") + v.SetDefault("qemu.uefi_vars_dir", "/run/two/vms/uefi") + v.SetDefault("qemu.serial_dir", "/run/two/vms/serial") + v.SetDefault("qemu.monitor_dir", "/run/two/vms/monitor") + v.SetDefault("qemu.qmp_dir", "/run/two/vms/qmp") v.SetDefault("admin.enabled", false) v.SetDefault("admin.address", "127.0.0.1") v.SetDefault("admin.port", 9091) From 325f1acff530123851148966f501f3a2c303ecde Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 23 May 2026 22:26:21 +0200 Subject: [PATCH 49/57] f-28: api: add uefi params Signed-off-by: GnomeZworc --- api/agent.yaml | 7 +++++++ internal/api/agent/models.go | 2 ++ internal/api/agent/vm.go | 1 + internal/api/agent/vms.go | 1 + internal/dispatcher/agent/vm_commands.go | 4 ++++ 5 files changed, 15 insertions(+) diff --git a/api/agent.yaml b/api/agent.yaml index e75601d..7e9c461 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -430,6 +430,10 @@ components: minItems: 1 items: $ref: "#/components/schemas/VMStorage" + uefi: + type: boolean + description: Boot with UEFI firmware (OVMF). Defaults to false (SeaBIOS). + example: false VMInterface: type: object @@ -487,6 +491,9 @@ components: type: array items: $ref: "#/components/schemas/VMStorage" + uefi: + type: boolean + example: false Error: type: object diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index dc0c47f..079d8db 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -49,6 +49,7 @@ type VMCreateRequest struct { Name string `json:"name"` Memory int `json:"memory"` CPUs int `json:"cpus"` + UEFI bool `json:"uefi"` Password string `json:"password"` SSHKey string `json:"sshkey"` Interfaces []VMInterface `json:"interfaces"` @@ -61,6 +62,7 @@ type VM struct { MetadataPort string `json:"metadata_port"` Memory int `json:"memory"` CPUs int `json:"cpus"` + UEFI bool `json:"uefi"` Interfaces []VMInterface `json:"interfaces"` Storage []VMStorage `json:"storage"` } diff --git a/internal/api/agent/vm.go b/internal/api/agent/vm.go index a150d16..f932766 100644 --- a/internal/api/agent/vm.go +++ b/internal/api/agent/vm.go @@ -79,6 +79,7 @@ func vmFromDB(name string, entries map[string]string) (VM, error) { vm.MetadataPort = entries[prefix+"metadata_port"] vm.Memory, _ = strconv.Atoi(entries[prefix+"memory"]) vm.CPUs, _ = strconv.Atoi(entries[prefix+"cpus"]) + vm.UEFI = entries[prefix+"uefi"] == "true" subnet := entries[prefix+"subnet"] ip := entries[prefix+"ip"] diff --git a/internal/api/agent/vms.go b/internal/api/agent/vms.go index 95f2c85..4dc6136 100644 --- a/internal/api/agent/vms.go +++ b/internal/api/agent/vms.go @@ -84,6 +84,7 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { VolumePath: req.Storage[0].Path, Memory: req.Memory, CPUs: req.CPUs, + UEFI: req.UEFI, Password: req.Password, SSHKey: req.SSHKey, } diff --git a/internal/dispatcher/agent/vm_commands.go b/internal/dispatcher/agent/vm_commands.go index 1bb55d4..1c4bbce 100644 --- a/internal/dispatcher/agent/vm_commands.go +++ b/internal/dispatcher/agent/vm_commands.go @@ -20,6 +20,7 @@ type StartVMCommand struct { VolumePath string Memory int CPUs int + UEFI bool Password string SSHKey string } @@ -46,6 +47,9 @@ func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { kv.AddInDB(db, "vm/"+c.Name+"/volume_path", c.VolumePath) kv.AddInDB(db, "vm/"+c.Name+"/memory", strconv.Itoa(c.Memory)) kv.AddInDB(db, "vm/"+c.Name+"/cpus", strconv.Itoa(c.CPUs)) + if c.UEFI { + kv.AddInDB(db, "vm/"+c.Name+"/uefi", "true") + } if c.Password != "" { kv.AddInDB(db, "vm/"+c.Name+"/password", c.Password) } From ec613996a05bf5cda0dd86bb187662924b7f672e Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 23 May 2026 22:28:19 +0200 Subject: [PATCH 50/57] f-28: vms: Add uefi param Signed-off-by: GnomeZworc --- internal/vm/create.go | 55 ++++++++++++++++++++++++++++++++++++------- internal/vm/data.go | 6 ++++- internal/vm/delete.go | 9 ++++++- 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/internal/vm/create.go b/internal/vm/create.go index f3e9d34..529c73c 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -2,6 +2,9 @@ package vm import ( "fmt" + "io" + "os" + "path/filepath" configuration "git.g3e.fr/syonad/two/internal/config/agent" "git.g3e.fr/syonad/two/internal/iptables" @@ -49,18 +52,54 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("start metadata: %w", err) } + qcfg := qemu.Config{ + Name: name, + TapID: d.tapID, + Mac: d.mac, + VolumePath: d.volumePath, + Memory: d.memory, + CPUs: d.cpus, + SerialDir: cfg.QEMU.SerialDir, + MonitorDir: cfg.QEMU.MonitorDir, + QMPDir: cfg.QEMU.QMPDir, + } + + if d.uefi { + varsPath := filepath.Join(cfg.QEMU.UEFIVarsDir, name+"-uefi-vars.fd") + if err := copyFile(cfg.QEMU.OVMFVarsTemplate, varsPath); err != nil { + return fmt.Errorf("copy uefi vars: %w", err) + } + qcfg.UEFICodePath = cfg.QEMU.OVMFCodePath + qcfg.UEFIVarsPath = varsPath + } + if err := netns.Call(d.vpcName, func() error { - return qemu.Start(qemu.Config{ - Name: name, - TapID: d.tapID, - Mac: d.mac, - VolumePath: d.volumePath, - Memory: d.memory, - CPUs: d.cpus, - }) + return qemu.Start(qcfg) }); err != nil { return fmt.Errorf("start qemu: %w", err) } return kv.AddInDB(db, "vm/"+name+"/state", "started") } + +func copyFile(src, dst string) error { + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + return err + } + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + + if _, err := io.Copy(out, in); err != nil { + return err + } + return out.Sync() +} diff --git a/internal/vm/data.go b/internal/vm/data.go index df5c051..1e2644e 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -14,7 +14,7 @@ import ( type vmData struct { subnetName string vpcName string - interfaceIP string + interfaceIP string bridge string tapID int ip string @@ -23,6 +23,7 @@ type vmData struct { volumePath string memory int cpus int + uefi bool password string sshkey string } @@ -105,6 +106,9 @@ func loadVM(db *badger.DB, name string) (vmData, error) { return d, fmt.Errorf("parse cpus: %w", err) } + if v, _ := kv.GetFromDB(db, "vm/"+name+"/uefi"); v == "true" { + d.uefi = true + } d.password, _ = kv.GetFromDB(db, "vm/"+name+"/password") d.sshkey, _ = kv.GetFromDB(db, "vm/"+name+"/sshkey") diff --git a/internal/vm/delete.go b/internal/vm/delete.go index 79a4c72..7e47f3f 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -2,6 +2,8 @@ package vm import ( "fmt" + "os" + "path/filepath" "time" configuration "git.g3e.fr/syonad/two/internal/config/agent" @@ -29,7 +31,7 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return err } - socketPath := fmt.Sprintf("/tmp/%s.qmp-sock", name) + socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock") if _, err := qmp.Send(socketPath, []string{`{"execute":"system_powerdown"}`}); err != nil { return fmt.Errorf("qmp system_powerdown: %w", err) @@ -65,5 +67,10 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("delete tap: %w", err) } + if d.uefi { + varsPath := filepath.Join(cfg.QEMU.UEFIVarsDir, name+"-uefi-vars.fd") + os.Remove(varsPath) + } + return kv.AddInDB(db, "vm/"+name+"/state", "stopped") } From 2a5473eb22cd65a0bd1dbf9faf991d9ebfb9fd19 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sat, 23 May 2026 22:30:32 +0200 Subject: [PATCH 51/57] f-28: qemu: execute with uefi vars Signed-off-by: GnomeZworc --- internal/qemu/start_linux.go | 39 ++++++++++++++++++++++++++++++++---- internal/qemu/start_other.go | 5 +++++ 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go index 6119278..3f40e73 100644 --- a/internal/qemu/start_linux.go +++ b/internal/qemu/start_linux.go @@ -4,7 +4,9 @@ package qemu import ( "fmt" + "os" "os/exec" + "path/filepath" ) type Config struct { @@ -14,6 +16,11 @@ type Config struct { VolumePath string Memory int CPUs int + UEFICodePath string + UEFIVarsPath string + SerialDir string + MonitorDir string + QMPDir string } func Start(cfg Config) error { @@ -27,20 +34,44 @@ func Start(cfg Config) error { cpus = 1 } - cmd := exec.Command("qemu-system-x86_64", + for _, dir := range []string{cfg.SerialDir, cfg.MonitorDir, cfg.QMPDir} { + if dir != "" { + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("mkdir %s: %w", dir, err) + } + } + } + + serialSock := filepath.Join(cfg.SerialDir, cfg.Name+".sock") + monitorSock := filepath.Join(cfg.MonitorDir, cfg.Name+".sock") + qmpSock := filepath.Join(cfg.QMPDir, cfg.Name+".sock") + + args := []string{ "-enable-kvm", "-cpu", "host", "-m", fmt.Sprintf("%d", memory), "-smp", fmt.Sprintf("%d", cpus), - "-serial", fmt.Sprintf("unix:/tmp/%s.sock,server,nowait", cfg.Name), - "-monitor", fmt.Sprintf("unix:/tmp/%s.mon-sock,server,nowait", cfg.Name), - "-qmp", fmt.Sprintf("unix:/tmp/%s.qmp-sock,server,nowait", cfg.Name), + "-serial", fmt.Sprintf("unix:%s,server,nowait", serialSock), + "-monitor", fmt.Sprintf("unix:%s,server,nowait", monitorSock), + "-qmp", fmt.Sprintf("unix:%s,server,nowait", qmpSock), "-display", "none", + } + + if cfg.UEFICodePath != "" && cfg.UEFIVarsPath != "" { + args = append(args, + "-drive", fmt.Sprintf("if=pflash,format=raw,readonly=on,file=%s", cfg.UEFICodePath), + "-drive", fmt.Sprintf("if=pflash,format=raw,file=%s", cfg.UEFIVarsPath), + ) + } + + args = append(args, "-drive", fmt.Sprintf("file=%s,if=virtio", cfg.VolumePath), "-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", cfg.Mac), "-daemonize", ) + + cmd := exec.Command("qemu-system-x86_64", args...) if err := cmd.Run(); err != nil { return fmt.Errorf("qemu-system-x86_64: %w", err) } diff --git a/internal/qemu/start_other.go b/internal/qemu/start_other.go index 782a7ee..c9c9192 100644 --- a/internal/qemu/start_other.go +++ b/internal/qemu/start_other.go @@ -7,6 +7,11 @@ import "errors" type Config struct { Name, Mac, VolumePath string TapID, Memory, CPUs int + UEFICodePath string + UEFIVarsPath string + SerialDir string + MonitorDir string + QMPDir string } func Start(_ Config) error { From 85c8c4e590625148faf75859072fe9fae3e1f302 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 15:44:48 +0200 Subject: [PATCH 52/57] f-32: disk: add boot multidisk handle Signed-off-by: GnomeZworc --- internal/api/agent/vms.go | 23 ++++++++++++-------- internal/dispatcher/agent/vm_commands.go | 27 +++++++++++++++--------- internal/vm/create.go | 2 +- internal/vm/data.go | 20 ++++++++++++++---- 4 files changed, 48 insertions(+), 24 deletions(-) diff --git a/internal/api/agent/vms.go b/internal/api/agent/vms.go index 4dc6136..2ea5748 100644 --- a/internal/api/agent/vms.go +++ b/internal/api/agent/vms.go @@ -77,16 +77,21 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { return } + disks := make([]dispatcher.VMDisk, len(req.Storage)) + for i, s := range req.Storage { + disks[i] = dispatcher.VMDisk{Path: s.Path, Dev: s.Dev} + } + cmd := dispatcher.StartVMCommand{ - Name: req.Name, - Subnet: primary.Subnet, - IP: primary.IP, - VolumePath: req.Storage[0].Path, - Memory: req.Memory, - CPUs: req.CPUs, - UEFI: req.UEFI, - Password: req.Password, - SSHKey: req.SSHKey, + Name: req.Name, + Subnet: primary.Subnet, + IP: primary.IP, + Disks: disks, + Memory: req.Memory, + CPUs: req.CPUs, + UEFI: req.UEFI, + Password: req.Password, + SSHKey: req.SSHKey, } if err := s.dispatcher.Prepare(cmd); err != nil { diff --git a/internal/dispatcher/agent/vm_commands.go b/internal/dispatcher/agent/vm_commands.go index 1c4bbce..bdfeaec 100644 --- a/internal/dispatcher/agent/vm_commands.go +++ b/internal/dispatcher/agent/vm_commands.go @@ -13,16 +13,21 @@ import ( "github.com/dgraph-io/badger/v4" ) +type VMDisk struct { + Path string + Dev string +} + type StartVMCommand struct { - Name string - Subnet string - IP string - VolumePath string - Memory int - CPUs int - UEFI bool - Password string - SSHKey string + Name string + Subnet string + IP string + Disks []VMDisk + Memory int + CPUs int + UEFI bool + Password string + SSHKey string } func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { @@ -44,7 +49,9 @@ func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { kv.AddInDB(db, "vm/"+c.Name+"/subnet", c.Subnet) kv.AddInDB(db, "vm/"+c.Name+"/ip", c.IP) kv.AddInDB(db, "vm/"+c.Name+"/metadata_port", strconv.Itoa(port)) - kv.AddInDB(db, "vm/"+c.Name+"/volume_path", c.VolumePath) + for _, d := range c.Disks { + kv.AddInDB(db, "vm/"+c.Name+"/disk/"+d.Dev, d.Path) + } kv.AddInDB(db, "vm/"+c.Name+"/memory", strconv.Itoa(c.Memory)) kv.AddInDB(db, "vm/"+c.Name+"/cpus", strconv.Itoa(c.CPUs)) if c.UEFI { diff --git a/internal/vm/create.go b/internal/vm/create.go index 529c73c..267dab5 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -56,7 +56,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { Name: name, TapID: d.tapID, Mac: d.mac, - VolumePath: d.volumePath, + VolumePath: d.disks[0].path, Memory: d.memory, CPUs: d.cpus, SerialDir: cfg.QEMU.SerialDir, diff --git a/internal/vm/data.go b/internal/vm/data.go index 1e2644e..3435fb1 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -11,6 +11,11 @@ import ( "github.com/dgraph-io/badger/v4" ) +type diskEntry struct { + path string + dev string +} + type vmData struct { subnetName string vpcName string @@ -20,7 +25,7 @@ type vmData struct { ip string metadataPort string mac string - volumePath string + disks []diskEntry memory int cpus int uefi bool @@ -82,11 +87,18 @@ func loadVM(db *badger.DB, name string) (vmData, error) { } d.mac = mac - volumePath, err := kv.GetFromDB(db, "vm/"+name+"/volume_path") + diskEntries, err := kv.ListByPrefix(db, "vm/"+name+"/disk/") if err != nil { - return d, fmt.Errorf("get volume_path: %w", err) + return d, fmt.Errorf("list disks: %w", err) + } + if len(diskEntries) == 0 { + return d, fmt.Errorf("no disks found for vm %q", name) + } + diskPrefix := "vm/" + name + "/disk/" + for key, path := range diskEntries { + dev := strings.TrimPrefix(key, diskPrefix) + d.disks = append(d.disks, diskEntry{path: path, dev: dev}) } - d.volumePath = volumePath memoryStr, err := kv.GetFromDB(db, "vm/"+name+"/memory") if err != nil { From 89899005d9eb2b9f2e84fe68f5230990090ad956 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 15:56:45 +0200 Subject: [PATCH 53/57] f-32: disk: add disk in qemu start Signed-off-by: GnomeZworc --- internal/api/agent/vm.go | 7 +++-- internal/qemu/start_linux.go | 55 +++++++++++++++++++++++++++++------- internal/qemu/start_other.go | 23 ++++++++++----- internal/vm/create.go | 7 ++++- 4 files changed, 72 insertions(+), 20 deletions(-) diff --git a/internal/api/agent/vm.go b/internal/api/agent/vm.go index f932766..d9f6bf5 100644 --- a/internal/api/agent/vm.go +++ b/internal/api/agent/vm.go @@ -87,8 +87,11 @@ func vmFromDB(name string, entries map[string]string) (VM, error) { vm.Interfaces = []VMInterface{{Subnet: subnet, IP: ip, Primary: true}} } - if path := entries[prefix+"volume_path"]; path != "" { - vm.Storage = []VMStorage{{Path: path}} + diskPrefix := prefix + "disk/" + for key, path := range entries { + if dev := strings.TrimPrefix(key, diskPrefix); dev != key { + vm.Storage = append(vm.Storage, VMStorage{Path: path, Dev: dev}) + } } return vm, nil diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go index 3f40e73..2043b64 100644 --- a/internal/qemu/start_linux.go +++ b/internal/qemu/start_linux.go @@ -7,20 +7,27 @@ import ( "os" "os/exec" "path/filepath" + "sort" + "strings" ) +type DiskConfig struct { + Path string + Dev string +} + type Config struct { - Name string - TapID int - Mac string - VolumePath string - Memory int - CPUs int + Name string + TapID int + Mac string + Disks []DiskConfig + Memory int + CPUs int UEFICodePath string UEFIVarsPath string - SerialDir string - MonitorDir string - QMPDir string + SerialDir string + MonitorDir string + QMPDir string } func Start(cfg Config) error { @@ -64,8 +71,36 @@ func Start(cfg Config) error { ) } + hasScsi := false + for _, d := range cfg.Disks { + if strings.HasPrefix(d.Dev, "sd") { + hasScsi = true + break + } + } + if hasScsi { + args = append(args, "-device", "virtio-scsi-pci,id=scsi0") + } + + sorted := make([]DiskConfig, len(cfg.Disks)) + copy(sorted, cfg.Disks) + sort.Slice(sorted, func(i, j int) bool { return sorted[i].Dev < sorted[j].Dev }) + + for _, d := range sorted { + if strings.HasPrefix(d.Dev, "sd") { + scsiID := int(d.Dev[2] - 'a') + args = append(args, + "-drive", fmt.Sprintf("file=%s,if=none,id=%s", d.Path, d.Dev), + "-device", fmt.Sprintf("scsi-hd,drive=%s,bus=scsi0.0,scsi-id=%d", d.Dev, scsiID), + ) + } else { + args = append(args, + "-drive", fmt.Sprintf("file=%s,if=virtio,id=%s", d.Path, d.Dev), + ) + } + } + args = append(args, - "-drive", fmt.Sprintf("file=%s,if=virtio", cfg.VolumePath), "-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", cfg.Mac), "-daemonize", diff --git a/internal/qemu/start_other.go b/internal/qemu/start_other.go index c9c9192..f48d95a 100644 --- a/internal/qemu/start_other.go +++ b/internal/qemu/start_other.go @@ -4,14 +4,23 @@ package qemu import "errors" +type DiskConfig struct { + Path string + Dev string +} + type Config struct { - Name, Mac, VolumePath string - TapID, Memory, CPUs int - UEFICodePath string - UEFIVarsPath string - SerialDir string - MonitorDir string - QMPDir string + Name string + TapID int + Mac string + Disks []DiskConfig + Memory int + CPUs int + UEFICodePath string + UEFIVarsPath string + SerialDir string + MonitorDir string + QMPDir string } func Start(_ Config) error { diff --git a/internal/vm/create.go b/internal/vm/create.go index 267dab5..3e0d4a7 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -52,11 +52,16 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { return fmt.Errorf("start metadata: %w", err) } + qDisks := make([]qemu.DiskConfig, len(d.disks)) + for i, disk := range d.disks { + qDisks[i] = qemu.DiskConfig{Path: disk.path, Dev: disk.dev} + } + qcfg := qemu.Config{ Name: name, TapID: d.tapID, Mac: d.mac, - VolumePath: d.disks[0].path, + Disks: qDisks, Memory: d.memory, CPUs: d.cpus, SerialDir: cfg.QEMU.SerialDir, From a0637d827ad16cda9457e8085221af29e59d205d Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 16:13:11 +0200 Subject: [PATCH 54/57] f-32: syntax: fix duplicated code Signed-off-by: GnomeZworc --- internal/qemu/config.go | 20 ++++++++++++++++++++ internal/qemu/start_linux.go | 19 ------------------- internal/qemu/start_other.go | 23 +++-------------------- 3 files changed, 23 insertions(+), 39 deletions(-) create mode 100644 internal/qemu/config.go diff --git a/internal/qemu/config.go b/internal/qemu/config.go new file mode 100644 index 0000000..9b50add --- /dev/null +++ b/internal/qemu/config.go @@ -0,0 +1,20 @@ +package qemu + +type DiskConfig struct { + Path string + Dev string +} + +type Config struct { + Name string + TapID int + Mac string + Disks []DiskConfig + Memory int + CPUs int + UEFICodePath string + UEFIVarsPath string + SerialDir string + MonitorDir string + QMPDir string +} diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go index 2043b64..ae03254 100644 --- a/internal/qemu/start_linux.go +++ b/internal/qemu/start_linux.go @@ -11,25 +11,6 @@ import ( "strings" ) -type DiskConfig struct { - Path string - Dev string -} - -type Config struct { - Name string - TapID int - Mac string - Disks []DiskConfig - Memory int - CPUs int - UEFICodePath string - UEFIVarsPath string - SerialDir string - MonitorDir string - QMPDir string -} - func Start(cfg Config) error { memory := cfg.Memory if memory == 0 { diff --git a/internal/qemu/start_other.go b/internal/qemu/start_other.go index f48d95a..c28411c 100644 --- a/internal/qemu/start_other.go +++ b/internal/qemu/start_other.go @@ -2,26 +2,9 @@ package qemu -import "errors" - -type DiskConfig struct { - Path string - Dev string -} - -type Config struct { - Name string - TapID int - Mac string - Disks []DiskConfig - Memory int - CPUs int - UEFICodePath string - UEFIVarsPath string - SerialDir string - MonitorDir string - QMPDir string -} +import ( + "errors" +) func Start(_ Config) error { return errors.New("vm: not supported on this platform") From 9111045417e3a5bd1148d0fe2a0f1ef58d27df3c Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 16:18:53 +0200 Subject: [PATCH 55/57] f-32: test: add test for vms Signed-off-by: GnomeZworc --- internal/api/agent/vm_test.go | 158 ++++++++++++++++++ internal/dispatcher/agent/vm_commands_test.go | 130 ++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 internal/api/agent/vm_test.go create mode 100644 internal/dispatcher/agent/vm_commands_test.go diff --git a/internal/api/agent/vm_test.go b/internal/api/agent/vm_test.go new file mode 100644 index 0000000..3af5968 --- /dev/null +++ b/internal/api/agent/vm_test.go @@ -0,0 +1,158 @@ +package agentapi + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "sort" + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- vmFromDB --- + +func TestVmFromDB_SingleDisk(t *testing.T) { + entries := map[string]string{ + "vm/vm-1/state": "started", + "vm/vm-1/subnet": "sn-1", + "vm/vm-1/ip": "10.0.0.5", + "vm/vm-1/metadata_port": "1234", + "vm/vm-1/memory": "512", + "vm/vm-1/cpus": "1", + "vm/vm-1/disk/sda": "/data/root.qcow2", + } + vm, err := vmFromDB("vm-1", entries) + if err != nil { + t.Fatalf("vmFromDB a échoué : %v", err) + } + if len(vm.Storage) != 1 { + t.Fatalf("attendu 1 disque, obtenu %d", len(vm.Storage)) + } + if vm.Storage[0].Dev != "sda" || vm.Storage[0].Path != "/data/root.qcow2" { + t.Errorf("disque inattendu : %+v", vm.Storage[0]) + } +} + +func TestVmFromDB_MultiDisk(t *testing.T) { + entries := map[string]string{ + "vm/vm-2/state": "started", + "vm/vm-2/subnet": "sn-1", + "vm/vm-2/ip": "10.0.0.6", + "vm/vm-2/metadata_port": "1235", + "vm/vm-2/memory": "1024", + "vm/vm-2/cpus": "2", + "vm/vm-2/disk/sda": "/data/root.qcow2", + "vm/vm-2/disk/sdb": "/data/data.qcow2", + } + vm, err := vmFromDB("vm-2", entries) + if err != nil { + t.Fatalf("vmFromDB a échoué : %v", err) + } + if len(vm.Storage) != 2 { + t.Fatalf("attendu 2 disques, obtenu %d", len(vm.Storage)) + } + sort.Slice(vm.Storage, func(i, j int) bool { return vm.Storage[i].Dev < vm.Storage[j].Dev }) + if vm.Storage[0].Dev != "sda" || vm.Storage[1].Dev != "sdb" { + t.Errorf("devs attendus [sda sdb], obtenus [%s %s]", vm.Storage[0].Dev, vm.Storage[1].Dev) + } +} + +func TestVmFromDB_SlotGap(t *testing.T) { + // sdb absent — sda et sdc seulement + entries := map[string]string{ + "vm/vm-3/state": "started", + "vm/vm-3/subnet": "sn-1", + "vm/vm-3/ip": "10.0.0.7", + "vm/vm-3/metadata_port": "1236", + "vm/vm-3/memory": "512", + "vm/vm-3/cpus": "1", + "vm/vm-3/disk/sda": "/data/root.qcow2", + "vm/vm-3/disk/sdc": "/data/extra.qcow2", + } + vm, err := vmFromDB("vm-3", entries) + if err != nil { + t.Fatalf("vmFromDB a échoué : %v", err) + } + if len(vm.Storage) != 2 { + t.Fatalf("attendu 2 disques, obtenu %d", len(vm.Storage)) + } + devs := map[string]bool{} + for _, s := range vm.Storage { + devs[s.Dev] = true + } + if !devs["sda"] || !devs["sdc"] { + t.Errorf("attendu sda et sdc, obtenus %v", devs) + } + if devs["sdb"] { + t.Error("sdb ne devrait pas apparaître") + } +} + +// --- POST /vms --- + +func TestStartVM_MultiDisk(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + body, _ := json.Marshal(VMCreateRequest{ + Name: "vm-10", + Interfaces: []VMInterface{ + {Subnet: "sn-1", IP: "10.0.0.10", Primary: true}, + }, + Storage: []VMStorage{ + {Path: "/data/root.qcow2", Dev: "sda"}, + {Path: "/data/data.qcow2", Dev: "sdb"}, + }, + Memory: 1024, + CPUs: 2, + }) + + w := httptest.NewRecorder() + s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String()) + } + + for _, dev := range []string{"sda", "sdb"} { + if _, err := kv.GetFromDB(db, "vm/vm-10/disk/"+dev); err != nil { + t.Errorf("disk/%s absent en DB après création", dev) + } + } + if _, err := kv.GetFromDB(db, "vm/vm-10/volume_path"); err == nil { + t.Error("volume_path ne devrait plus exister en DB") + } +} + +func TestStartVM_StorageReturnedInResponse(t *testing.T) { + s, db := newTestServer(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + body, _ := json.Marshal(VMCreateRequest{ + Name: "vm-11", + Interfaces: []VMInterface{ + {Subnet: "sn-1", IP: "10.0.0.11", Primary: true}, + }, + Storage: []VMStorage{ + {Path: "/data/root.qcow2", Dev: "sda"}, + }, + }) + + w := httptest.NewRecorder() + s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body))) + if w.Code != http.StatusAccepted { + t.Fatalf("attendu 202, obtenu %d", w.Code) + } + + var vm VM + json.NewDecoder(w.Body).Decode(&vm) + if len(vm.Storage) != 1 { + t.Fatalf("attendu 1 disque dans la réponse, obtenu %d", len(vm.Storage)) + } + if vm.Storage[0].Dev != "sda" || vm.Storage[0].Path != "/data/root.qcow2" { + t.Errorf("disque inattendu dans la réponse : %+v", vm.Storage[0]) + } +} diff --git a/internal/dispatcher/agent/vm_commands_test.go b/internal/dispatcher/agent/vm_commands_test.go new file mode 100644 index 0000000..ba400b9 --- /dev/null +++ b/internal/dispatcher/agent/vm_commands_test.go @@ -0,0 +1,130 @@ +package dispatcher + +import ( + "testing" + + "git.g3e.fr/syonad/two/pkg/db/kv" +) + +// --- StartVMCommand.Prepare : écriture des disques en DB --- + +func TestStartVMCommand_Prepare_SingleDisk(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + cmd := StartVMCommand{ + Name: "vm-1", + Subnet: "sn-1", + IP: "10.0.0.5", + Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "sda"}}, + } + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + + path, err := kv.GetFromDB(db, "vm/vm-1/disk/sda") + if err != nil { + t.Fatalf("clé disk/sda absente en DB : %v", err) + } + if path != "/data/root.qcow2" { + t.Errorf("path attendu /data/root.qcow2, obtenu %q", path) + } +} + +func TestStartVMCommand_Prepare_MultiDisk(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + cmd := StartVMCommand{ + Name: "vm-2", + Subnet: "sn-1", + IP: "10.0.0.6", + Disks: []VMDisk{ + {Path: "/data/root.qcow2", Dev: "sda"}, + {Path: "/data/data.qcow2", Dev: "sdb"}, + }, + } + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + + for dev, want := range map[string]string{ + "sda": "/data/root.qcow2", + "sdb": "/data/data.qcow2", + } { + got, err := kv.GetFromDB(db, "vm/vm-2/disk/"+dev) + if err != nil { + t.Fatalf("clé disk/%s absente en DB : %v", dev, err) + } + if got != want { + t.Errorf("disk/%s : attendu %q, obtenu %q", dev, want, got) + } + } +} + +func TestStartVMCommand_Prepare_SlotGap(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + // sdb absent au boot — slot réservé pour hotplug + cmd := StartVMCommand{ + Name: "vm-3", + Subnet: "sn-1", + IP: "10.0.0.7", + Disks: []VMDisk{ + {Path: "/data/root.qcow2", Dev: "sda"}, + {Path: "/data/extra.qcow2", Dev: "sdc"}, + }, + } + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + + if _, err := kv.GetFromDB(db, "vm/vm-3/disk/sda"); err != nil { + t.Fatalf("disk/sda absent : %v", err) + } + if _, err := kv.GetFromDB(db, "vm/vm-3/disk/sdc"); err != nil { + t.Fatalf("disk/sdc absent : %v", err) + } + if _, err := kv.GetFromDB(db, "vm/vm-3/disk/sdb"); err == nil { + t.Error("disk/sdb ne devrait pas exister en DB") + } +} + +func TestStartVMCommand_Prepare_NoVolumePath(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "subnet/sn-1/state", "created") + kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") + + cmd := StartVMCommand{ + Name: "vm-4", + Subnet: "sn-1", + IP: "10.0.0.8", + Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "sda"}}, + } + if err := cmd.Prepare(db, nil); err != nil { + t.Fatalf("Prepare a échoué : %v", err) + } + + if _, err := kv.GetFromDB(db, "vm/vm-4/volume_path"); err == nil { + t.Error("volume_path ne devrait plus être écrit en DB") + } +} + +func TestStartVMCommand_Prepare_Duplicate(t *testing.T) { + _, db := newTestDispatcher(t) + kv.AddInDB(db, "vm/vm-exist/state", "started") + + cmd := StartVMCommand{ + Name: "vm-exist", + Subnet: "sn-1", + IP: "10.0.0.9", + Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "sda"}}, + } + if err := cmd.Prepare(db, nil); err == nil { + t.Error("Prepare devrait échouer si la VM existe déjà") + } +} From c3f26836cd60ab94bac2293ce767a8f4041cba41 Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 16:47:49 +0200 Subject: [PATCH 56/57] f-32: fix: update boot order Signed-off-by: GnomeZworc --- internal/qemu/start_linux.go | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/internal/qemu/start_linux.go b/internal/qemu/start_linux.go index ae03254..a7869a2 100644 --- a/internal/qemu/start_linux.go +++ b/internal/qemu/start_linux.go @@ -65,18 +65,29 @@ func Start(cfg Config) error { sorted := make([]DiskConfig, len(cfg.Disks)) copy(sorted, cfg.Disks) - sort.Slice(sorted, func(i, j int) bool { return sorted[i].Dev < sorted[j].Dev }) + // vd* avant sd* : les disques virtio-blk bootent en premier. + // À lettre égale de type, ordre alphabétique. + sort.Slice(sorted, func(i, j int) bool { + iVirtio := strings.HasPrefix(sorted[i].Dev, "vd") + jVirtio := strings.HasPrefix(sorted[j].Dev, "vd") + if iVirtio != jVirtio { + return iVirtio + } + return sorted[i].Dev < sorted[j].Dev + }) - for _, d := range sorted { + for idx, d := range sorted { + bootindex := idx + 1 if strings.HasPrefix(d.Dev, "sd") { scsiID := int(d.Dev[2] - 'a') args = append(args, "-drive", fmt.Sprintf("file=%s,if=none,id=%s", d.Path, d.Dev), - "-device", fmt.Sprintf("scsi-hd,drive=%s,bus=scsi0.0,scsi-id=%d", d.Dev, scsiID), + "-device", fmt.Sprintf("scsi-hd,drive=%s,bus=scsi0.0,scsi-id=%d,bootindex=%d", d.Dev, scsiID, bootindex), ) } else { args = append(args, - "-drive", fmt.Sprintf("file=%s,if=virtio,id=%s", d.Path, d.Dev), + "-drive", fmt.Sprintf("file=%s,if=none,id=%s", d.Path, d.Dev), + "-device", fmt.Sprintf("virtio-blk-pci,drive=%s,bootindex=%d", d.Dev, bootindex), ) } } From 4ab880a32d8d4871a275af6fcc19bf58abfb2cfa Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 24 May 2026 19:02:31 +0200 Subject: [PATCH 57/57] f-32: vm: fix incomplete stop vm Signed-off-by: GnomeZworc --- internal/vm/delete.go | 42 +++++++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/internal/vm/delete.go b/internal/vm/delete.go index 7e47f3f..3d808e6 100644 --- a/internal/vm/delete.go +++ b/internal/vm/delete.go @@ -33,25 +33,18 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { socketPath := filepath.Join(cfg.QEMU.QMPDir, name+".sock") - if _, err := qmp.Send(socketPath, []string{`{"execute":"system_powerdown"}`}); err != nil { - return fmt.Errorf("qmp system_powerdown: %w", err) - } - - // attendre l'arrêt effectif de la VM ; forcer via quit après timeout - timeout := time.After(time.Duration(cfg.Dispatcher.TimeoutSeconds) * time.Second) - poll := time.Duration(cfg.Dispatcher.PollSeconds) * time.Second - stopped := false - for !stopped { - select { - case <-timeout: - qmp.Send(socketPath, []string{`{"execute":"quit"}`}) - stopped = true - case <-time.After(poll): - if _, err := qmp.Send(socketPath, nil); err != nil { - stopped = true - } + if _, err := os.Stat(socketPath); err == nil { + // socket présent : tenter l'arrêt gracieux + if _, err := qmp.Send(socketPath, []string{`{"execute":"system_powerdown"}`}); err == nil { + waitQMPDead(socketPath, + time.Duration(cfg.Dispatcher.TimeoutSeconds)*time.Second, + time.Duration(cfg.Dispatcher.PollSeconds)*time.Second, + ) } + // connexion QMP échouée : QEMU déjà mort } + // 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) @@ -74,3 +67,18 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error { return kv.AddInDB(db, "vm/"+name+"/state", "stopped") } + +func waitQMPDead(socketPath string, timeout, poll time.Duration) { + timer := time.After(timeout) + for { + select { + case <-timer: + qmp.Send(socketPath, []string{`{"execute":"quit"}`}) + return + case <-time.After(poll): + if _, err := qmp.Send(socketPath, nil); err != nil { + return + } + } + } +}