From 593bd42e6a3f5db01dbd0ff42f62eb189108a58f Mon Sep 17 00:00:00 2001 From: GnomeZworc Date: Sun, 26 Apr 2026 20:50:21 +0200 Subject: [PATCH] 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) +}