f-25: api: add a new api for vms

Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
This commit is contained in:
GnomeZworc 2026-04-26 20:50:21 +02:00
commit 593bd42e6a
Signed by: nicolas.boufideline
GPG key ID: 4406BBBF8845D632
4 changed files with 240 additions and 0 deletions

View file

@ -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"`
}

View file

@ -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)

94
internal/api/agent/vm.go Normal file
View file

@ -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
}

112
internal/api/agent/vms.go Normal file
View file

@ -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)
}