Compare commits

..

12 commits

Author SHA1 Message Date
7078691db6
f-25: bug: fix errors and add verif
All checks were successful
Pre Release Workflow / set-release-target (push) Successful in 1s
Pre Release Workflow / build (metadata, amd64, linux) (push) Successful in 1m33s
Pre Release Workflow / prerelease (push) Successful in 12s
Pre Release Workflow / build (agent, amd64, linux) (push) Successful in 1m35s
Pre Release Workflow / build (db, amd64, linux) (push) Successful in 1m35s
Pre Release Workflow / build (metacli, amd64, linux) (push) Successful in 1m32s
Pre Release Workflow / upload-scripts (run-dnsmasq-in-netns.sh) (push) Successful in 7s
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 20:52:25 +02:00
593bd42e6a
f-25: api: add a new api for vms
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 20:50:21 +02:00
1ea3a986b8
f-25: api: add vms routes
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 20:09:34 +02:00
c17ee79183
f-25: dispatch: add vm dispatcher
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 18:27:19 +02:00
f005d9dde7
f-25: vm: use db data to find mac address
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 18:27:18 +02:00
b4092fbe02
f-25: dhcp: fill db with dhcp data
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 18:27:18 +02:00
371ec75468
f-25: vm: generate tap_id
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 18:27:17 +02:00
cc9998028d
f-25: code: move load data to dedicated function
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 18:27:09 +02:00
2efaf20098
f-25: fix: reduce options number
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 18:25:40 +02:00
504a2a723b
f-25: vms: add iptables gestion
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 18:25:40 +02:00
a5bf748bdf
f-25: vm: ajout de la logique de demarrage des vms
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 18:25:39 +02:00
da0d043846
f-25: qemu: add handle gestion
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-04-26 18:23:27 +02:00
10 changed files with 553 additions and 36 deletions

View file

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

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

View file

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

View file

@ -5,13 +5,10 @@ package qemu
import (
"fmt"
"os/exec"
"git.g3e.fr/syonad/two/internal/netns"
)
type Config struct {
Name string
VpcName string
TapID int
Mac string
VolumePath string
@ -30,24 +27,22 @@ func Start(cfg Config) error {
cpus = 1
}
return netns.Call(cfg.VpcName, func() error {
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
})
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
}

View file

@ -5,10 +5,10 @@ package qemu
import "errors"
type Config struct {
Name, VpcName, Mac, VolumePath string
TapID, Memory, CPUs int
Name, Mac, VolumePath string
TapID, Memory, CPUs int
}
func Start(cfg Config) error {
func Start(_ Config) error {
return errors.New("vm: not supported on this platform")
}

View file

@ -48,14 +48,15 @@ func StartVM(db *badger.DB, name string) error {
return fmt.Errorf("start metadata: %w", err)
}
if err := qemu.Start(qemu.Config{
Name: name,
VpcName: d.vpcName,
TapID: d.tapID,
Mac: d.mac,
VolumePath: d.volumePath,
Memory: d.memory,
CPUs: d.cpus,
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)
}

View file

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