Compare commits

...

10 commits

Author SHA1 Message Date
b41b4f2518
f-28: generate metadata_port automatically at vm creation
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 23:28:18 +02:00
1b56a42627
f-28: refactor dhcp config to use VPCRoute and DefaultGateway
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 23:06:52 +02:00
c0caf1a24c
f-28: add GetDefaultGateway to netif
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 23:06:45 +02:00
bb5698fdda
f-28: add subnet default_route field
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 23:06:41 +02:00
76a840b80a
f-28: add vpc cidr field
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 23:06:36 +02:00
2504b435a3
f-28: fix: dhcp do not emit local default route
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 23:06:29 +02:00
32b78a84f9
f-28: fix: add proper dhcp handle
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 22:28:51 +02:00
848f965883
f-28: fix: renomage api param
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 21:58:33 +02:00
9492de7a2b
f-28: test: add tests
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 21:50:29 +02:00
91a5d7ac78
f-28: bridge: fix ebtables
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-05-18 21:11:09 +02:00
26 changed files with 536 additions and 181 deletions

View file

@ -294,13 +294,17 @@ components:
VPCCreateRequest: VPCCreateRequest:
type: object type: object
required: [name] required: [name, cidr]
properties: properties:
name: name:
type: string type: string
description: Unique name for the VPC, must follow the format vp-[id] description: Unique name for the VPC, must follow the format vp-[id]
pattern: '^vp-.+' pattern: '^vp-.+'
example: vp-00001 example: vp-00001
cidr:
type: string
description: CIDR block for the entire VPC address space
example: "10.0.0.0/16"
VPC: VPC:
type: object type: object
@ -312,10 +316,13 @@ components:
type: string type: string
enum: [creating, created, deleting, deleted] enum: [creating, created, deleting, deleted]
example: created example: created
cidr:
type: string
example: "10.0.0.0/16"
SubnetCreateRequest: SubnetCreateRequest:
type: object type: object
required: [name, vpc, gateway_ip, cidr] required: [name, vpc, interface_ip, cidr]
properties: properties:
name: name:
type: string type: string
@ -342,7 +349,7 @@ components:
type: string 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. 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 example: vms
gateway_ip: interface_ip:
type: string type: string
format: ipv4 format: ipv4
description: Gateway IP for the subnet description: Gateway IP for the subnet
@ -351,6 +358,12 @@ components:
type: string type: string
description: Subnet CIDR block description: Subnet CIDR block
example: "10.10.10.0/24" 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: Subnet:
type: object type: object
@ -377,23 +390,23 @@ components:
type: string type: string
description: Resolved interface name from agent config description: Resolved interface name from agent config
example: br-000000 example: br-000000
gateway_ip: interface_ip:
type: string type: string
example: "10.10.10.1" example: "10.10.10.1"
cidr: cidr:
type: string type: string
example: "10.10.10.0/24" example: "10.10.10.0/24"
default_route:
type: boolean
example: false
VMCreateRequest: VMCreateRequest:
type: object type: object
required: [name, metadata_port, interfaces, storage] required: [name, interfaces, storage]
properties: properties:
name: name:
type: string type: string
example: vm-00001 example: vm-00001
metadata_port:
type: string
example: "80"
memory: memory:
type: integer type: integer
description: Memory in MB (default 512) description: Memory in MB (default 512)

View file

@ -2,32 +2,36 @@ package agentapi
type VPCCreateRequest struct { type VPCCreateRequest struct {
Name string `json:"name"` Name string `json:"name"`
CIDR string `json:"cidr"`
} }
type VPC struct { type VPC struct {
Name string `json:"name"` Name string `json:"name"`
State string `json:"state"` State string `json:"state"`
CIDR string `json:"cidr"`
} }
type SubnetCreateRequest struct { type SubnetCreateRequest struct {
Name string `json:"name"` Name string `json:"name"`
VPC string `json:"vpc"` VPC string `json:"vpc"`
Mode string `json:"mode"` Mode string `json:"mode"`
VxlanID int `json:"vxlan_id"` VxlanID int `json:"vxlan_id"`
IfaceType string `json:"iface_type"` IfaceType string `json:"iface_type"`
GatewayIP string `json:"gateway_ip"` InterfaceIP string `json:"interface_ip"`
CIDR string `json:"cidr"` CIDR string `json:"cidr"`
DefaultRoute bool `json:"default_route"`
} }
type Subnet struct { type Subnet struct {
Name string `json:"name"` Name string `json:"name"`
State string `json:"state"` State string `json:"state"`
VPC string `json:"vpc"` VPC string `json:"vpc"`
Mode string `json:"mode"` Mode string `json:"mode"`
VxlanID int `json:"vxlan_id"` VxlanID int `json:"vxlan_id"`
LocalIface string `json:"local_iface"` LocalIface string `json:"local_iface"`
GatewayIP string `json:"gateway_ip"` InterfaceIP string `json:"interface_ip"`
CIDR string `json:"cidr"` CIDR string `json:"cidr"`
DefaultRoute bool `json:"default_route"`
} }
type VMInterface struct { type VMInterface struct {
@ -42,14 +46,13 @@ type VMStorage struct {
} }
type VMCreateRequest struct { type VMCreateRequest struct {
Name string `json:"name"` Name string `json:"name"`
MetadataPort string `json:"metadata_port"` Memory int `json:"memory"`
Memory int `json:"memory"` CPUs int `json:"cpus"`
CPUs int `json:"cpus"` Password string `json:"password"`
Password string `json:"password"` SSHKey string `json:"sshkey"`
SSHKey string `json:"sshkey"` Interfaces []VMInterface `json:"interfaces"`
Interfaces []VMInterface `json:"interfaces"` Storage []VMStorage `json:"storage"`
Storage []VMStorage `json:"storage"`
} }
type VM struct { type VM struct {

View file

@ -53,10 +53,12 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string)
sub.VxlanID, _ = strconv.Atoi(value) sub.VxlanID, _ = strconv.Atoi(value)
case "local_iface": case "local_iface":
sub.LocalIface = value sub.LocalIface = value
case "gateway_ip": case "interface_ip":
sub.GatewayIP = value sub.InterfaceIP = value
case "cidr": case "cidr":
sub.CIDR = value sub.CIDR = value
case "default_route":
sub.DefaultRoute = value == "true"
} }
} }
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View file

@ -60,7 +60,7 @@ func TestPostSubnet_Created(t *testing.T) {
Name: "sn-new", Name: "sn-new",
VPC: "vpc-1", VPC: "vpc-1",
IfaceType: "vms", IfaceType: "vms",
GatewayIP: "10.0.0.1", InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24", CIDR: "10.0.0.0/24",
} }
body, _ := json.Marshal(req) body, _ := json.Marshal(req)
@ -95,7 +95,7 @@ func TestPostSubnet_IfaceTypeOptional(t *testing.T) {
req := SubnetCreateRequest{ req := SubnetCreateRequest{
Name: "sn-opt", Name: "sn-opt",
VPC: "vpc-1", VPC: "vpc-1",
GatewayIP: "10.0.0.1", InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24", CIDR: "10.0.0.0/24",
// IfaceType omis — doit utiliser default_interface // IfaceType omis — doit utiliser default_interface
} }
@ -113,7 +113,7 @@ func TestPostSubnet_VPCNotFound(t *testing.T) {
Name: "sn-1", Name: "sn-1",
VPC: "vpc-inexistant", VPC: "vpc-inexistant",
IfaceType: "vms", IfaceType: "vms",
GatewayIP: "10.0.0.1", InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24", CIDR: "10.0.0.0/24",
} }
body, _ := json.Marshal(req) body, _ := json.Marshal(req)
@ -132,7 +132,7 @@ func TestPostSubnet_Duplicate(t *testing.T) {
Name: "sn-exist", Name: "sn-exist",
VPC: "vpc-1", VPC: "vpc-1",
IfaceType: "vms", IfaceType: "vms",
GatewayIP: "10.0.0.1", InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24", CIDR: "10.0.0.0/24",
} }
body, _ := json.Marshal(req) body, _ := json.Marshal(req)
@ -150,7 +150,52 @@ func TestPostSubnet_VPCDeleting(t *testing.T) {
Name: "sn-1", Name: "sn-1",
VPC: "vpc-dying", VPC: "vpc-dying",
IfaceType: "vms", IfaceType: "vms",
GatewayIP: "10.0.0.1", InterfaceIP: "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_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",
InterfaceIP: "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",
InterfaceIP: "10.0.0.1",
CIDR: "10.0.0.0/24", CIDR: "10.0.0.0/24",
} }
body, _ := json.Marshal(req) body, _ := json.Marshal(req)
@ -177,7 +222,7 @@ func TestGetSubnet_Found(t *testing.T) {
kv.AddInDB(db, "subnet/sn-1/state", "created") kv.AddInDB(db, "subnet/sn-1/state", "created")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1") 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/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) req := httptest.NewRequest(http.MethodGet, "/subnets/sn-1", nil)
w := httptest.NewRecorder() w := httptest.NewRecorder()
s.SubnetByNameHandler(w, req) s.SubnetByNameHandler(w, req)

View file

@ -50,10 +50,12 @@ func (s *Server) listSubnets(w http.ResponseWriter, _ *http.Request) {
subnets[name].VxlanID, _ = strconv.Atoi(value) subnets[name].VxlanID, _ = strconv.Atoi(value)
case "local_iface": case "local_iface":
subnets[name].LocalIface = value subnets[name].LocalIface = value
case "gateway_ip": case "interface_ip":
subnets[name].GatewayIP = value subnets[name].InterfaceIP = value
case "cidr": case "cidr":
subnets[name].CIDR = value subnets[name].CIDR = value
case "default_route":
subnets[name].DefaultRoute = value == "true"
} }
} }
result := make([]Subnet, 0, len(subnets)) result := make([]Subnet, 0, len(subnets))
@ -71,19 +73,20 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"}) json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"})
return return
} }
if req.Name == "" || req.VPC == "" || req.GatewayIP == "" || req.CIDR == "" { if req.Name == "" || req.VPC == "" || req.InterfaceIP == "" || req.CIDR == "" {
w.WriteHeader(http.StatusBadRequest) 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 return
} }
cmd := dispatcher.CreateSubnetCommand{ cmd := dispatcher.CreateSubnetCommand{
Name: req.Name, Name: req.Name,
VPC: req.VPC, VPC: req.VPC,
Mode: req.Mode, Mode: req.Mode,
VxlanID: req.VxlanID, VxlanID: req.VxlanID,
IfaceType: req.IfaceType, IfaceType: req.IfaceType,
GatewayIP: req.GatewayIP, InterfaceIP: req.InterfaceIP,
CIDR: req.CIDR, CIDR: req.CIDR,
DefaultRoute: req.DefaultRoute,
} }
if err := s.dispatcher.Prepare(cmd); err != nil { if err := s.dispatcher.Prepare(cmd); err != nil {
if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil { if _, dbErr := kv.GetFromDB(s.db, "subnet/"+req.Name+"/state"); dbErr == nil {
@ -118,10 +121,12 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) {
sub.VxlanID, _ = strconv.Atoi(value) sub.VxlanID, _ = strconv.Atoi(value)
case "local_iface": case "local_iface":
sub.LocalIface = value sub.LocalIface = value
case "gateway_ip": case "interface_ip":
sub.GatewayIP = value sub.InterfaceIP = value
case "cidr": case "cidr":
sub.CIDR = value sub.CIDR = value
case "default_route":
sub.DefaultRoute = value == "true"
} }
} }
w.WriteHeader(http.StatusAccepted) w.WriteHeader(http.StatusAccepted)

View file

@ -58,9 +58,9 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"}) json.NewEncoder(w).Encode(ErrorResponse{Error: "invalid request body"})
return 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) 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 return
} }
@ -78,15 +78,14 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
} }
cmd := dispatcher.StartVMCommand{ cmd := dispatcher.StartVMCommand{
Name: req.Name, Name: req.Name,
Subnet: primary.Subnet, Subnet: primary.Subnet,
IP: primary.IP, IP: primary.IP,
MetadataPort: req.MetadataPort, VolumePath: req.Storage[0].Path,
VolumePath: req.Storage[0].Path, Memory: req.Memory,
Memory: req.Memory, CPUs: req.CPUs,
CPUs: req.CPUs, Password: req.Password,
Password: req.Password, SSHKey: req.SSHKey,
SSHKey: req.SSHKey,
} }
if err := s.dispatcher.Prepare(cmd); err != nil { if err := s.dispatcher.Prepare(cmd); err != nil {

View file

@ -35,8 +35,9 @@ func (s *Server) getVpc(w http.ResponseWriter, _ *http.Request, name string) {
json.NewEncoder(w).Encode(ErrorResponse{Error: "vpc not found"}) json.NewEncoder(w).Encode(ErrorResponse{Error: "vpc not found"})
return return
} }
cidr, _ := kv.GetFromDB(s.db, "vpc/"+name+"/cidr")
w.WriteHeader(http.StatusOK) 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) { func (s *Server) deleteVpc(w http.ResponseWriter, _ *http.Request, name string) {

View file

@ -53,7 +53,7 @@ func TestListVpcs_InvalidMethod(t *testing.T) {
func TestPostVpc_Created(t *testing.T) { func TestPostVpc_Created(t *testing.T) {
s, _ := newTestServer(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() w := httptest.NewRecorder()
s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body)))
if w.Code != http.StatusAccepted { if w.Code != http.StatusAccepted {
@ -67,11 +67,34 @@ func TestPostVpc_Created(t *testing.T) {
if result.State != "creating" { if result.State != "creating" {
t.Errorf("state attendu creating, obtenu %q", result.State) 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) { func TestPostVpc_MissingName(t *testing.T) {
s, _ := newTestServer(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() w := httptest.NewRecorder()
s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body)))
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
@ -82,7 +105,7 @@ func TestPostVpc_MissingName(t *testing.T) {
func TestPostVpc_Duplicate(t *testing.T) { func TestPostVpc_Duplicate(t *testing.T) {
s, db := newTestServer(t) s, db := newTestServer(t)
kv.AddInDB(db, "vpc/vpc-exist/state", "created") 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() w := httptest.NewRecorder()
s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body))) s.VpcsHandler(w, httptest.NewRequest(http.MethodPost, "/vpcs", bytes.NewReader(body)))
if w.Code != http.StatusConflict { if w.Code != http.StatusConflict {

View file

@ -2,6 +2,7 @@ package agentapi
import ( import (
"encoding/json" "encoding/json"
"net"
"net/http" "net/http"
"strings" "strings"
@ -38,8 +39,11 @@ func (s *Server) listVpcs(w http.ResponseWriter, _ *http.Request) {
if _, ok := vpcs[name]; !ok { if _, ok := vpcs[name]; !ok {
vpcs[name] = &VPC{Name: name} vpcs[name] = &VPC{Name: name}
} }
if parts[2] == "state" { switch parts[2] {
case "state":
vpcs[name].State = value vpcs[name].State = value
case "cidr":
vpcs[name].CIDR = value
} }
} }
result := make([]VPC, 0, len(vpcs)) 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"}) json.NewEncoder(w).Encode(ErrorResponse{Error: "name is required"})
return 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 { if err := s.dispatcher.Prepare(cmd); err != nil {
w.WriteHeader(http.StatusConflict) w.WriteHeader(http.StatusConflict)
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()}) json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
@ -76,5 +90,5 @@ func (s *Server) postVpc(w http.ResponseWriter, r *http.Request) {
return return
} }
w.WriteHeader(http.StatusAccepted) 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})
} }

View file

@ -51,11 +51,15 @@ func TestIncrementIP_Carry(t *testing.T) {
func newConf(t *testing.T, cidr string) Config { func newConf(t *testing.T, cidr string) Config {
t.Helper() t.Helper()
_, network, _ := net.ParseCIDR(cidr) _, network, _ := net.ParseCIDR(cidr)
_, vpcNet, _ := net.ParseCIDR("10.0.0.0/16")
gw := net.ParseIP("192.168.1.1").To4()
return Config{ return Config{
Network: network, Network: network,
Gateway: net.ParseIP("192.168.1.1").To4(), VPCGateway: gw,
Name: "test", VPCRoute: vpcNet,
ConfDir: t.TempDir(), DefaultGateway: gw,
Name: "test",
ConfDir: t.TempDir(),
} }
} }
@ -84,13 +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") conf := newConf(t, "192.168.1.0/29")
path, _, _ := GenerateConfig(conf) path, _, _ := GenerateConfig(conf)
content, _ := os.ReadFile(path) content, _ := os.ReadFile(path)
if !strings.Contains(string(content), "dhcp-option=3,192.168.1.1") { 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_NoDefaultGateway(t *testing.T) {
conf := newConf(t, "192.168.1.0/29")
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 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)
} }
} }
@ -98,7 +134,6 @@ func TestGenerateConfig_ContainsDhcpRange(t *testing.T) {
_, network, _ := net.ParseCIDR("10.10.0.0/24") _, network, _ := net.ParseCIDR("10.10.0.0/24")
conf := Config{ conf := Config{
Network: network, Network: network,
Gateway: net.ParseIP("10.10.0.1").To4(),
Name: "vpc1", Name: "vpc1",
ConfDir: t.TempDir(), ConfDir: t.TempDir(),
} }
@ -144,7 +179,6 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) {
_, network, _ := net.ParseCIDR("10.0.0.0/30") _, network, _ := net.ParseCIDR("10.0.0.0/30")
conf := Config{ conf := Config{
Network: network, Network: network,
Gateway: net.ParseIP("10.0.0.1").To4(),
Name: "net", Name: "net",
ConfDir: dir, ConfDir: dir,
} }

View file

@ -14,7 +14,12 @@ func GenerateConfig(c Config) (string, map[string]string, error) {
var sb strings.Builder var sb strings.Builder
fmt.Fprintf(&sb, "no-resolv\n") 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-range=%s,static,%s,12h\n", c.Network.IP.String(), mask)
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") fmt.Fprintf(&sb, "dhcp-option=6,1.1.1.1,8.8.8.8\n\n")
entries := make(map[string]string) entries := make(map[string]string)

View file

@ -5,8 +5,10 @@ import (
) )
type Config struct { type Config struct {
Network *net.IPNet Network *net.IPNet
Gateway net.IP VPCGateway net.IP // next-hop for VPCRoute (option 121)
Name string VPCRoute *net.IPNet // if non-nil, emit dhcp-option=121,VPCRoute,VPCGateway
ConfDir string DefaultGateway net.IP // if non-nil, emit dhcp-option=3,DefaultGateway
Name string
ConfDir string
} }

View file

@ -12,13 +12,14 @@ import (
) )
type CreateSubnetCommand struct { type CreateSubnetCommand struct {
Name string Name string
VPC string VPC string
Mode string Mode string
VxlanID int VxlanID int
IfaceType string IfaceType string
GatewayIP string InterfaceIP string
CIDR string CIDR string
DefaultRoute bool
} }
func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error { func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error {
@ -46,8 +47,9 @@ 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+"/vpc", c.VPC)
kv.AddInDB(db, "subnet/"+c.Name+"/mode", c.Mode) kv.AddInDB(db, "subnet/"+c.Name+"/mode", c.Mode)
kv.AddInDB(db, "subnet/"+c.Name+"/local_iface", localIface) 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) 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" { if c.Mode == "vxlan" {
kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID)) kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID))
} }

View file

@ -20,7 +20,7 @@ func TestCreateSubnetCommand_Prepare_Success(t *testing.T) {
kv.AddInDB(db, "vpc/vpc-1/state", "created") kv.AddInDB(db, "vpc/vpc-1/state", "created")
cmd := CreateSubnetCommand{ cmd := CreateSubnetCommand{
Name: "sn-1", VPC: "vpc-1", VxlanID: 100, 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 { if err := cmd.Prepare(db, testCfg()); err != nil {
t.Fatalf("Prepare a échoué : %v", err) 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") kv.AddInDB(db, "vpc/vpc-1/state", "created")
cmd := CreateSubnetCommand{ cmd := CreateSubnetCommand{
Name: "sn-1", VPC: "vpc-1", VxlanID: 100, 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()) cmd.Prepare(db, testCfg())
iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") 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") kv.AddInDB(db, "vpc/vpc-1/state", "created")
cmd := CreateSubnetCommand{ cmd := CreateSubnetCommand{
Name: "sn-1", VPC: "vpc-1", VxlanID: 100, 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()) cmd.Prepare(db, testCfg())
iface, _ := kv.GetFromDB(db, "subnet/sn-1/local_iface") 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") kv.AddInDB(db, "subnet/sn-exist/state", "created")
cmd := CreateSubnetCommand{ cmd := CreateSubnetCommand{
Name: "sn-exist", VPC: "vpc-1", VxlanID: 100, 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 { if err := cmd.Prepare(db, testCfg()); err == nil {
t.Error("Prepare devrait échouer sur un subnet déjà existant") 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) _, db := newTestDispatcher(t)
cmd := CreateSubnetCommand{ cmd := CreateSubnetCommand{
Name: "sn-1", VPC: "vpc-inexistant", VxlanID: 100, 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 { if err := cmd.Prepare(db, testCfg()); err == nil {
t.Error("Prepare devrait échouer si le VPC n'existe pas") 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") kv.AddInDB(db, "vpc/vpc-dying/state", "deleting")
cmd := CreateSubnetCommand{ cmd := CreateSubnetCommand{
Name: "sn-1", VPC: "vpc-dying", VxlanID: 100, 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 { if err := cmd.Prepare(db, testCfg()); err == nil {
t.Error("Prepare devrait échouer si le VPC est en cours de suppression") t.Error("Prepare devrait échouer si le VPC est en cours de suppression")
@ -104,13 +104,109 @@ func TestCreateSubnetCommand_Prepare_VPCDeleted(t *testing.T) {
kv.AddInDB(db, "vpc/vpc-gone/state", "deleted") kv.AddInDB(db, "vpc/vpc-gone/state", "deleted")
cmd := CreateSubnetCommand{ cmd := CreateSubnetCommand{
Name: "sn-1", VPC: "vpc-gone", VxlanID: 100, 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 { if err := cmd.Prepare(db, testCfg()); err == nil {
t.Error("Prepare devrait échouer si le VPC est supprimé") t.Error("Prepare devrait échouer si le VPC est supprimé")
} }
} }
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", InterfaceIP: "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", 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)
}
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", 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 {
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", 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")
}
}
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 --- // --- DeleteSubnetCommand.Prepare ---
func TestDeleteSubnetCommand_Prepare_Success(t *testing.T) { func TestDeleteSubnetCommand_Prepare_Success(t *testing.T) {

View file

@ -2,7 +2,9 @@ package dispatcher
import ( import (
"fmt" "fmt"
"math/rand"
"strconv" "strconv"
"strings"
"time" "time"
configuration "git.g3e.fr/syonad/two/internal/config/agent" configuration "git.g3e.fr/syonad/two/internal/config/agent"
@ -12,15 +14,14 @@ import (
) )
type StartVMCommand struct { type StartVMCommand struct {
Name string Name string
Subnet string Subnet string
IP string IP string
MetadataPort string VolumePath string
VolumePath string Memory int
Memory int CPUs int
CPUs int Password string
Password string SSHKey string
SSHKey string
} }
func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error { 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" { if subnetState == "deleting" || subnetState == "deleted" {
return fmt.Errorf("subnet %q is %s", c.Subnet, subnetState) 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+"/state", "starting")
kv.AddInDB(db, "vm/"+c.Name+"/subnet", c.Subnet) kv.AddInDB(db, "vm/"+c.Name+"/subnet", c.Subnet)
kv.AddInDB(db, "vm/"+c.Name+"/ip", c.IP) 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+"/volume_path", c.VolumePath)
kv.AddInDB(db, "vm/"+c.Name+"/memory", strconv.Itoa(c.Memory)) kv.AddInDB(db, "vm/"+c.Name+"/memory", strconv.Itoa(c.Memory))
kv.AddInDB(db, "vm/"+c.Name+"/cpus", strconv.Itoa(c.CPUs)) 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 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 { func (c StartVMCommand) Execute(db *badger.DB, cfg *configuration.Config) error {
timeout := time.After(time.Duration(cfg.Dispatcher.TimeoutSeconds) * time.Second) timeout := time.After(time.Duration(cfg.Dispatcher.TimeoutSeconds) * time.Second)
for { for {

View file

@ -2,6 +2,7 @@ package dispatcher
import ( import (
"fmt" "fmt"
"net"
"strings" "strings"
"time" "time"
@ -13,12 +14,19 @@ import (
type CreateVPCCommand struct { type CreateVPCCommand struct {
Name string Name string
CIDR string
} }
func (c CreateVPCCommand) Prepare(db *badger.DB, _ *configuration.Config) error { func (c CreateVPCCommand) Prepare(db *badger.DB, _ *configuration.Config) error {
if _, err := kv.GetFromDB(db, "vpc/"+c.Name+"/state"); err == nil { if _, err := kv.GetFromDB(db, "vpc/"+c.Name+"/state"); err == nil {
return fmt.Errorf("vpc %q already exists", c.Name) 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") return kv.AddInDB(db, "vpc/"+c.Name+"/state", "creating")
} }

View file

@ -10,7 +10,7 @@ import (
func TestCreateVPCCommand_Prepare_NewVPC(t *testing.T) { func TestCreateVPCCommand_Prepare_NewVPC(t *testing.T) {
_, db := newTestDispatcher(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 { if err := cmd.Prepare(db, nil); err != nil {
t.Fatalf("Prepare a échoué : %v", err) t.Fatalf("Prepare a échoué : %v", err)
} }
@ -21,17 +21,32 @@ func TestCreateVPCCommand_Prepare_NewVPC(t *testing.T) {
if state != "creating" { if state != "creating" {
t.Errorf("state attendu creating, obtenu %q", state) 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) { func TestCreateVPCCommand_Prepare_Duplicate(t *testing.T) {
_, db := newTestDispatcher(t) _, db := newTestDispatcher(t)
kv.AddInDB(db, "vpc/vpc-exist/state", "created") 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 { if err := cmd.Prepare(db, nil); err == nil {
t.Error("Prepare devrait échouer sur un VPC déjà existant") 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 --- // --- DeleteVPCCommand.Prepare ---
func TestDeleteVPCCommand_Prepare_Success(t *testing.T) { func TestDeleteVPCCommand_Prepare_Success(t *testing.T) {

View file

@ -13,46 +13,48 @@ func deleteRule(args ...string) error {
return exec.Command("ebtables", append([]string{"-D"}, args...)...).Run() 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", if err := addRule("FORWARD",
"--out-interface", bridge, "--out-interface", iface,
"-p", "arp", "-p", "arp",
"--arp-op", "Request", "--arp-op", "Request",
"--arp-ip-dst", gatewayIP, "--arp-ip-dst", ip,
"-j", "DROP"); err != nil { "-j", "DROP"); err != nil {
return fmt.Errorf("ebtables arp rule: %w", err) return fmt.Errorf("ebtables arp rule: %w", err)
} }
return nil return nil
} }
func DropDHCP(bridge string) error { func DropDHCP(iface, ip string) error {
if err := addRule("FORWARD", if err := addRule("FORWARD",
"--out-interface", bridge, "--out-interface", iface,
"-p", "IPv4", "-p", "IPv4",
"--ip-protocol", "udp", "--ip-protocol", "udp",
"--ip-source-port", "67:68", "--ip-source-port", "67:68",
"--ip-destination-port", "67:68", "--ip-destination-port", "67:68",
"--ip-source", ip,
"-j", "DROP"); err != nil { "-j", "DROP"); err != nil {
return fmt.Errorf("ebtables dhcp rule: %w", err) return fmt.Errorf("ebtables dhcp rule: %w", err)
} }
return nil return nil
} }
func DeleteARPToGateway(bridge, gatewayIP string) error { func DeleteARPToGateway(iface, ip string) error {
return deleteRule("FORWARD", return deleteRule("FORWARD",
"--out-interface", bridge, "--out-interface", iface,
"-p", "arp", "-p", "arp",
"--arp-op", "Request", "--arp-op", "Request",
"--arp-ip-dst", gatewayIP, "--arp-ip-dst", ip,
"-j", "DROP") "-j", "DROP")
} }
func DeleteDHCP(bridge string) error { func DeleteDHCP(iface, ip string) error {
return deleteRule("FORWARD", return deleteRule("FORWARD",
"--out-interface", bridge, "--out-interface", iface,
"-p", "IPv4", "-p", "IPv4",
"--ip-protocol", "udp", "--ip-protocol", "udp",
"--ip-source-port", "67:68", "--ip-source-port", "67:68",
"--ip-destination-port", "67:68", "--ip-destination-port", "67:68",
"--ip-source", ip,
"-j", "DROP") "-j", "DROP")
} }

View file

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

View file

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

View file

@ -81,32 +81,30 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error {
switch d.mode { switch d.mode {
case "vxlan": case "vxlan":
if err := netns.Call(d.vpc, func() error { if err := netns.Call(d.vpc, func() error {
return netif.AddrAdd(d.bridge, d.gatewayIP) 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 { }); 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 { 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 { }); err != nil {
return fmt.Errorf("add route in netns: %w", err) return fmt.Errorf("configure netns: %w", err)
}
case "bridge":
}
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)
} }
} }
@ -139,10 +137,22 @@ func setupVxlanHost(d subnetData, vethE string) error {
func startDHCP(db *badger.DB, subnetName string, d subnetData) error { func startDHCP(db *badger.DB, subnetName string, d subnetData) error {
conf := dhcp.Config{ conf := dhcp.Config{
Network: d.cidr, Network: d.cidr,
Gateway: d.gatewayIP,
Name: d.vpc + "_" + d.bridge, Name: d.vpc + "_" + d.bridge,
ConfDir: "/etc/dnsmasq.d", 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) _, entries, err := dhcp.GenerateConfig(conf)
if err != nil { if err != nil {
return fmt.Errorf("generate dhcp config: %w", err) return fmt.Errorf("generate dhcp config: %w", err)

View file

@ -11,14 +11,16 @@ import (
) )
type subnetData struct { type subnetData struct {
vpc string vpc string
subnetID string subnetID string
bridge string bridge string
mode string mode string
vxlanID int vxlanID int
localIface string localIface string
gatewayIP net.IP interfaceIP net.IP
cidr *net.IPNet cidr *net.IPNet
vpcCIDR *net.IPNet
defaultRoute bool
} }
func loadSubnet(db *badger.DB, name string) (subnetData, error) { func loadSubnet(db *badger.DB, name string) (subnetData, error) {
@ -57,15 +59,15 @@ func loadSubnet(db *badger.DB, name string) (subnetData, error) {
} }
d.localIface = localIface d.localIface = localIface
gatewayIPStr, err := kv.GetFromDB(db, "subnet/"+name+"/gateway_ip") interfaceIPStr, err := kv.GetFromDB(db, "subnet/"+name+"/interface_ip")
if err != nil { 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) interfaceIP := net.ParseIP(interfaceIPStr)
if gatewayIP == nil { if interfaceIP == nil {
return d, fmt.Errorf("invalid gateway_ip: %s", gatewayIPStr) return d, fmt.Errorf("invalid interface_ip: %s", interfaceIPStr)
} }
d.gatewayIP = gatewayIP d.interfaceIP = interfaceIP
cidrStr, err := kv.GetFromDB(db, "subnet/"+name+"/cidr") cidrStr, err := kv.GetFromDB(db, "subnet/"+name+"/cidr")
if err != nil { if err != nil {
@ -77,5 +79,21 @@ func loadSubnet(db *badger.DB, name string) (subnetData, error) {
} }
d.cidr = ipNet 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)
}
_, vpcIPNet, err := net.ParseCIDR(vpcCIDRStr)
if err != nil {
return d, fmt.Errorf("parse vpc cidr: %w", err)
}
d.vpcCIDR = vpcIPNet
return d, nil return d, nil
} }

View file

@ -70,18 +70,18 @@ func stopDHCP(db *badger.DB, subnetName string, d subnetData) error {
func deleteSubnetVxlan(d subnetData) error { func deleteSubnetVxlan(d subnetData) error {
vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID) vxlanIface := fmt.Sprintf("vxlan-%d", d.vxlanID)
vethI := "v-" + d.subnetID + "-i"
if err := ebtables.DeleteARPToGateway(d.bridge, d.gatewayIP.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)
}
if err := netns.Call(d.vpc, func() error { 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) return netif.DeleteLink(d.bridge)
}); err != nil { }); 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 { if err := netif.DeleteLink(vxlanIface); err != nil {
@ -99,19 +99,15 @@ func deleteSubnetVxlan(d subnetData) error {
} }
func deleteSubnetBridge(d subnetData) error { func deleteSubnetBridge(d subnetData) error {
if err := netns.Call(d.vpc, func() error { vethI := "v-" + d.subnetID + "-i"
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 { 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) return netif.DeleteLink(d.bridge)
}); err != nil { }); 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 { if err := netif.DeleteLink("v-" + d.subnetID + "-e"); err != nil {

View file

@ -33,7 +33,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
} }
if err := netns.Call(d.vpcName, func() 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 { }); err != nil {
return fmt.Errorf("add metadata redirect: %w", err) 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{ if err := metadata.StartMetadata(metadata.NoCloudConfig{
Name: name, Name: name,
VpcName: d.vpcName, VpcName: d.vpcName,
BindIP: d.gatewayIP, BindIP: d.interfaceIP,
BindPort: d.metadataPort, BindPort: d.metadataPort,
Password: d.password, Password: d.password,
SSHKEY: d.sshkey, SSHKEY: d.sshkey,

View file

@ -14,7 +14,7 @@ import (
type vmData struct { type vmData struct {
subnetName string subnetName string
vpcName string vpcName string
gatewayIP string interfaceIP string
bridge string bridge string
tapID int tapID int
ip string ip string
@ -43,11 +43,11 @@ func loadVM(db *badger.DB, name string) (vmData, error) {
} }
d.vpcName = vpcName d.vpcName = vpcName
gatewayIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/gateway_ip") interfaceIP, err := kv.GetFromDB(db, "subnet/"+subnetName+"/interface_ip")
if err != nil { 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") tapIDStr, err := kv.GetFromDB(db, "vm/"+name+"/tap_id")
if err != nil { if err != nil {

View file

@ -52,7 +52,7 @@ func StopVM(db *badger.DB, name string, cfg *configuration.Config) error {
} }
if err := netns.Call(d.vpcName, func() 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 { }); err != nil {
return fmt.Errorf("delete metadata redirect: %w", err) return fmt.Errorf("delete metadata redirect: %w", err)
} }