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
20 changed files with 1023 additions and 118 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)
}

19
internal/dhcp/db.go Normal file
View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,14 @@
//go:build !linux
package qemu
import "errors"
type Config struct {
Name, Mac, VolumePath string
TapID, Memory, CPUs int
}
func Start(_ Config) error {
return errors.New("vm: not supported on this platform")
}

View file

@ -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,37 +76,38 @@ 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 {
_, 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 {

72
internal/subnet/data.go Normal file
View file

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

View file

@ -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,52 @@ 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)
}
if err := kv.DeleteInDB(db, "subnet/"+subnetName+"/dhcp"); err != nil {
return fmt.Errorf("delete dhcp entries: %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)
}

65
internal/vm/create.go Normal file
View file

@ -0,0 +1,65 @@
package vm
import (
"fmt"
"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/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
}
d, err := loadVM(db, name)
if err != nil {
return err
}
if err := netif.CreateTap(d.tapID, d.bridge, d.vpcName); err != nil {
return fmt.Errorf("create tap: %w", err)
}
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: 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)
}
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")
}

112
internal/vm/data.go Normal file
View file

@ -0,0 +1,112 @@
package vm
import (
"fmt"
"math/rand"
"strconv"
"strings"
"git.g3e.fr/syonad/two/internal/dhcp"
"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 {
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
}
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 := dhcp.GetMACForIP(db, d.subnetName, d.ip)
if err != nil {
return d, fmt.Errorf("get mac for ip %s: %w", d.ip, 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, 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, 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")
return d, nil
}

69
internal/vm/delete.go Normal file
View file

@ -0,0 +1,69 @@
package vm
import (
"fmt"
"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"
"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
}
d, err := loadVM(db, name)
if err != nil {
return 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 := 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)
}
if err := metadata.StopMetadata(name, db, false); err != nil {
return fmt.Errorf("stop metadata: %w", err)
}
if err := netif.DeleteTap(d.tapID, d.vpcName); err != nil {
return fmt.Errorf("delete tap: %w", err)
}
return kv.AddInDB(db, "vm/"+name+"/state", "stopped")
}