two/internal/api/agent/vm_test.go
GnomeZworc 0237acba81
f-33: net: add multi net in dispatch et vms #33
Signed-off-by: GnomeZworc <nicolas.boufidjeline@g3e.fr>
2026-08-25 22:48:13 +02:00

350 lines
11 KiB
Go

package agentapi
import (
"bytes"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"sort"
"strings"
"testing"
"git.g3e.fr/syonad/two/pkg/db/kv"
"github.com/dgraph-io/badger/v4"
)
// --- vmFromDB ---
func TestVmFromDB_SingleDisk(t *testing.T) {
entries := map[string]string{
"vm/vm-1/state": "running",
"vm/vm-1/subnet": "sn-1",
"vm/vm-1/ip": "10.0.0.5",
"vm/vm-1/metadata_port": "1234",
"vm/vm-1/memory": "512",
"vm/vm-1/cpus": "1",
"vm/vm-1/disk/sda": "/data/root.qcow2",
}
vm, err := vmFromDB("vm-1", entries)
if err != nil {
t.Fatalf("vmFromDB a échoué : %v", err)
}
if len(vm.Storage) != 1 {
t.Fatalf("attendu 1 disque, obtenu %d", len(vm.Storage))
}
if vm.Storage[0].Dev != "sda" || vm.Storage[0].Path != "/data/root.qcow2" {
t.Errorf("disque inattendu : %+v", vm.Storage[0])
}
}
func TestVmFromDB_MultiDisk(t *testing.T) {
entries := map[string]string{
"vm/vm-2/state": "running",
"vm/vm-2/subnet": "sn-1",
"vm/vm-2/ip": "10.0.0.6",
"vm/vm-2/metadata_port": "1235",
"vm/vm-2/memory": "1024",
"vm/vm-2/cpus": "2",
"vm/vm-2/disk/sda": "/data/root.qcow2",
"vm/vm-2/disk/sdb": "/data/data.qcow2",
}
vm, err := vmFromDB("vm-2", entries)
if err != nil {
t.Fatalf("vmFromDB a échoué : %v", err)
}
if len(vm.Storage) != 2 {
t.Fatalf("attendu 2 disques, obtenu %d", len(vm.Storage))
}
sort.Slice(vm.Storage, func(i, j int) bool { return vm.Storage[i].Dev < vm.Storage[j].Dev })
if vm.Storage[0].Dev != "sda" || vm.Storage[1].Dev != "sdb" {
t.Errorf("devs attendus [sda sdb], obtenus [%s %s]", vm.Storage[0].Dev, vm.Storage[1].Dev)
}
}
func TestVmFromDB_SlotGap(t *testing.T) {
// sdb absent — sda et sdc seulement
entries := map[string]string{
"vm/vm-3/state": "running",
"vm/vm-3/subnet": "sn-1",
"vm/vm-3/ip": "10.0.0.7",
"vm/vm-3/metadata_port": "1236",
"vm/vm-3/memory": "512",
"vm/vm-3/cpus": "1",
"vm/vm-3/disk/sda": "/data/root.qcow2",
"vm/vm-3/disk/sdc": "/data/extra.qcow2",
}
vm, err := vmFromDB("vm-3", entries)
if err != nil {
t.Fatalf("vmFromDB a échoué : %v", err)
}
if len(vm.Storage) != 2 {
t.Fatalf("attendu 2 disques, obtenu %d", len(vm.Storage))
}
devs := map[string]bool{}
for _, s := range vm.Storage {
devs[s.Dev] = true
}
if !devs["sda"] || !devs["sdc"] {
t.Errorf("attendu sda et sdc, obtenus %v", devs)
}
if devs["sdb"] {
t.Error("sdb ne devrait pas apparaître")
}
}
// --- POST /vms ---
func TestStartVM_MultiDisk(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
body, _ := json.Marshal(VMCreateRequest{
Name: "vm-10",
Interfaces: []VMInterface{
{Subnet: "sn-1", IP: "10.0.0.10", Primary: true},
},
Storage: []VMStorage{
{Path: "/data/root.qcow2", Dev: "sda"},
{Path: "/data/data.qcow2", Dev: "sdb"},
},
Memory: 1024,
CPUs: 2,
})
w := httptest.NewRecorder()
s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body)))
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
for _, dev := range []string{"sda", "sdb"} {
if _, err := kv.GetFromDB(db, "vm/vm-10/disk/"+dev); err != nil {
t.Errorf("disk/%s absent en DB après création", dev)
}
}
if _, err := kv.GetFromDB(db, "vm/vm-10/volume_path"); err == nil {
t.Error("volume_path ne devrait plus exister en DB")
}
}
func TestStartVM_StorageReturnedInResponse(t *testing.T) {
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
body, _ := json.Marshal(VMCreateRequest{
Name: "vm-11",
Interfaces: []VMInterface{
{Subnet: "sn-1", IP: "10.0.0.11", Primary: true},
},
Storage: []VMStorage{
{Path: "/data/root.qcow2", Dev: "sda"},
},
})
w := httptest.NewRecorder()
s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body)))
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d", w.Code)
}
var vm VM
json.NewDecoder(w.Body).Decode(&vm)
if len(vm.Storage) != 1 {
t.Fatalf("attendu 1 disque dans la réponse, obtenu %d", len(vm.Storage))
}
if vm.Storage[0].Dev != "sda" || vm.Storage[0].Path != "/data/root.qcow2" {
t.Errorf("disque inattendu dans la réponse : %+v", vm.Storage[0])
}
}
// --- POST /vms : objet metadata ---
func postVM(t *testing.T, name string, meta VMMetadata) (*httptest.ResponseRecorder, *badger.DB) {
t.Helper()
s, db := newTestServer(t)
kv.AddInDB(db, "subnet/sn-1/state", "running")
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
body, _ := json.Marshal(VMCreateRequest{
Name: name,
Interfaces: []VMInterface{{Subnet: "sn-1", IP: "10.0.0.20", Primary: true}},
Storage: []VMStorage{{Path: "/data/root.qcow2", Dev: "vda"}},
Memory: 1024,
CPUs: 2,
Metadata: meta,
})
w := httptest.NewRecorder()
s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body)))
return w, db
}
func TestStartVM_UserDataIsDecodedFromBase64(t *testing.T) {
plain := "#cloud-config\npackages:\n - nginx\n"
encoded := base64.StdEncoding.EncodeToString([]byte(plain))
w, db := postVM(t, "vm-md1", VMMetadata{UserData: encoded})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
got, err := kv.GetFromDB(db, "vm/vm-md1/metadata/user-data")
if err != nil {
t.Fatalf("user-data absent en DB : %v", err)
}
if got != plain {
t.Errorf("user-data décodé attendu %q, obtenu %q", plain, got)
}
}
func TestStartVM_InvalidBase64IsRejected(t *testing.T) {
w, db := postVM(t, "vm-md2", VMMetadata{UserData: "ceci n'est pas du base64 !!"})
if w.Code != http.StatusBadRequest {
t.Fatalf("un base64 invalide doit être refusé en 400, obtenu %d : %s", w.Code, w.Body.String())
}
if _, err := kv.GetFromDB(db, "vm/vm-md2/state"); err == nil {
t.Error("aucune VM ne doit être créée quand la requête est refusée")
}
}
func TestStartVM_InvalidBase64ErrorNamesTheField(t *testing.T) {
w, _ := postVM(t, "vm-md3", VMMetadata{UserData: "@@@"})
if !strings.Contains(w.Body.String(), "user_data") {
t.Errorf("le message doit nommer le champ fautif : %s", w.Body.String())
}
}
func TestStartVM_NoUserDataWritesNoDocument(t *testing.T) {
w, db := postVM(t, "vm-md4", VMMetadata{SSHKey: "ssh-ed25519 AAAA user@host"})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
entries, err := kv.ListByPrefix(db, "vm/vm-md4/metadata/")
if err != nil {
t.Fatalf("ListByPrefix : %v", err)
}
if len(entries) != 0 {
t.Errorf("sans user_data, aucun document ne doit être stocké : %v", entries)
}
}
func TestStartVM_PasswordAndSSHKeyComeFromMetadata(t *testing.T) {
w, db := postVM(t, "vm-md5", VMMetadata{
Password: "$6$rounds$hash",
SSHKey: "ssh-ed25519 AAAA user@host",
})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
if got, _ := kv.GetFromDB(db, "vm/vm-md5/password"); got != "$6$rounds$hash" {
t.Errorf("password attendu depuis metadata, obtenu %q", got)
}
if got, _ := kv.GetFromDB(db, "vm/vm-md5/sshkey"); got != "ssh-ed25519 AAAA user@host" {
t.Errorf("sshkey attendu depuis metadata, obtenu %q", got)
}
}
func TestStartVM_EmptyBase64MeansNoDocument(t *testing.T) {
w, db := postVM(t, "vm-md6", VMMetadata{UserData: base64.StdEncoding.EncodeToString([]byte(""))})
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
entries, _ := kv.ListByPrefix(db, "vm/vm-md6/metadata/")
if len(entries) != 0 {
t.Errorf("un base64 vide est indiscernable d'un champ absent : %v", entries)
}
}
// --- interfaces multiples ---
func TestVmFromDB_MultipleInterfacesSortedByIndex(t *testing.T) {
vm, err := vmFromDB("vm-multi", map[string]string{
"vm/vm-multi/state": "running",
"vm/vm-multi/nic/1/subnet": "sn-2",
"vm/vm-multi/nic/1/ip": "10.2.0.5",
"vm/vm-multi/nic/0/subnet": "sn-1",
"vm/vm-multi/nic/0/ip": "10.1.0.5",
"vm/vm-multi/nic/0/primary": "true",
"vm/vm-multi/disk/vda": "/data/root.qcow2",
})
if err != nil {
t.Fatalf("vmFromDB : %v", err)
}
if len(vm.Interfaces) != 2 {
t.Fatalf("2 interfaces attendues, obtenu %d : %+v", len(vm.Interfaces), vm.Interfaces)
}
if vm.Interfaces[0].Subnet != "sn-1" || !vm.Interfaces[0].Primary {
t.Errorf("la première doit être l'index 0, primaire : %+v", vm.Interfaces[0])
}
if vm.Interfaces[1].Subnet != "sn-2" || vm.Interfaces[1].Primary {
t.Errorf("la seconde doit être l'index 1, non primaire : %+v", vm.Interfaces[1])
}
}
func TestStartVM_StoresAllInterfaces(t *testing.T) {
s, db := newTestServer(t)
for _, sn := range []string{"sn-1", "sn-2"} {
kv.AddInDB(db, "subnet/"+sn+"/state", "running")
kv.AddInDB(db, "subnet/"+sn+"/vpc", "vpc-1")
}
body, _ := json.Marshal(VMCreateRequest{
Name: "vm-multi",
Interfaces: []VMInterface{
{Subnet: "sn-1", IP: "10.1.0.5", Primary: true},
{Subnet: "sn-2", IP: "10.2.0.5"},
},
Storage: []VMStorage{{Path: "/data/root.qcow2", Dev: "vda"}},
})
w := httptest.NewRecorder()
s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body)))
if w.Code != http.StatusAccepted {
t.Fatalf("attendu 202, obtenu %d : %s", w.Code, w.Body.String())
}
if got, _ := kv.GetFromDB(db, "vm/vm-multi/nic/1/subnet"); got != "sn-2" {
t.Errorf("seconde interface non stockée : %q", got)
}
if got, _ := kv.GetFromDB(db, "vm/vm-multi/nic/0/primary"); got != "true" {
t.Errorf("primaire non marquée : %q", got)
}
if _, err := kv.GetFromDB(db, "vm/vm-multi/nic/1/primary"); err == nil {
t.Error("une interface non primaire ne doit pas porter la clé primary")
}
}
func TestStartVM_RejectsZeroOrTwoPrimaries(t *testing.T) {
cases := map[string][]VMInterface{
"aucune primaire": {{Subnet: "sn-1", IP: "10.1.0.5"}},
"deux primaires": {
{Subnet: "sn-1", IP: "10.1.0.5", Primary: true},
{Subnet: "sn-2", IP: "10.2.0.5", Primary: true},
},
}
for label, ifaces := range cases {
s, db := newTestServer(t)
for _, sn := range []string{"sn-1", "sn-2"} {
kv.AddInDB(db, "subnet/"+sn+"/state", "running")
kv.AddInDB(db, "subnet/"+sn+"/vpc", "vpc-1")
}
body, _ := json.Marshal(VMCreateRequest{
Name: "vm-bad",
Interfaces: ifaces,
Storage: []VMStorage{{Path: "/data/root.qcow2", Dev: "vda"}},
})
w := httptest.NewRecorder()
s.VmsHandler(w, httptest.NewRequest(http.MethodPost, "/vms", bytes.NewReader(body)))
if w.Code != http.StatusBadRequest {
t.Errorf("%s : attendu 400, obtenu %d — %s", label, w.Code, w.Body.String())
}
}
}