Compare commits
8 commits
63fc305736
...
032201d631
| Author | SHA1 | Date | |
|---|---|---|---|
|
032201d631 |
|||
|
46596f8142 |
|||
|
d15454d35b |
|||
|
a2170fff08 |
|||
|
8078da0bda |
|||
|
e2fbb5fb7a |
|||
|
ae81158b2d |
|||
|
883d628bf6 |
29 changed files with 1191 additions and 170 deletions
|
|
@ -349,8 +349,10 @@ components:
|
||||||
description: >
|
description: >
|
||||||
Subnet mode. "vxlan" (default): creates a VXLAN tunnel and a host bridge.
|
Subnet mode. "vxlan" (default): creates a VXLAN tunnel and a host bridge.
|
||||||
"bridge": attaches directly to an existing bridge resolved from iface_type in the agent config.
|
"bridge": attaches directly to an existing bridge resolved from iface_type in the agent config.
|
||||||
|
"public_ip": accepted and routed like vxlan for DHCP purposes, but its host network
|
||||||
|
setup is not implemented yet — creating such a subnet currently fails in Execute.
|
||||||
"vlan" is reserved for future use.
|
"vlan" is reserved for future use.
|
||||||
enum: [vxlan, bridge]
|
enum: [vxlan, bridge, public_ip]
|
||||||
default: vxlan
|
default: vxlan
|
||||||
example: vxlan
|
example: vxlan
|
||||||
vxlan_id:
|
vxlan_id:
|
||||||
|
|
@ -373,9 +375,20 @@ components:
|
||||||
default_route:
|
default_route:
|
||||||
type: boolean
|
type: boolean
|
||||||
description: >
|
description: >
|
||||||
If true, advertise a default route via DHCP. For vxlan mode the gateway is the interface IP.
|
A default route is always advertised via DHCP; this flag only chooses its next-hop.
|
||||||
For bridge mode the gateway is read from the host routing table.
|
When false, the next-hop is the subnet interface_ip. When true, it is the `gateway`
|
||||||
|
field if supplied, otherwise the gateway read from the host routing table.
|
||||||
|
The route to the VPC CIDR always keeps interface_ip as its next-hop (except in bridge
|
||||||
|
mode, which has no VPC route), so VPC traffic never leaves through a public gateway.
|
||||||
default: false
|
default: false
|
||||||
|
gateway:
|
||||||
|
type: string
|
||||||
|
format: ipv4
|
||||||
|
description: >
|
||||||
|
Optional next-hop for the default route. Only used when default_route is true;
|
||||||
|
supplied with default_route false, it is ignored. Not validated by the agent:
|
||||||
|
reachability and coherence with the subnet CIDR are the caller's responsibility.
|
||||||
|
example: "10.10.10.254"
|
||||||
|
|
||||||
Subnet:
|
Subnet:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -392,7 +405,7 @@ components:
|
||||||
example: vpc1
|
example: vpc1
|
||||||
mode:
|
mode:
|
||||||
type: string
|
type: string
|
||||||
enum: [vxlan, bridge]
|
enum: [vxlan, bridge, public_ip]
|
||||||
example: vxlan
|
example: vxlan
|
||||||
vxlan_id:
|
vxlan_id:
|
||||||
type: integer
|
type: integer
|
||||||
|
|
@ -411,6 +424,9 @@ components:
|
||||||
default_route:
|
default_route:
|
||||||
type: boolean
|
type: boolean
|
||||||
example: false
|
example: false
|
||||||
|
gateway:
|
||||||
|
type: string
|
||||||
|
example: "10.10.10.254"
|
||||||
|
|
||||||
VMCreateRequest:
|
VMCreateRequest:
|
||||||
type: object
|
type: object
|
||||||
|
|
@ -427,11 +443,8 @@ components:
|
||||||
type: integer
|
type: integer
|
||||||
description: Number of vCPUs (default 1)
|
description: Number of vCPUs (default 1)
|
||||||
example: 2
|
example: 2
|
||||||
password:
|
metadata:
|
||||||
type: string
|
$ref: "#/components/schemas/VMMetadata"
|
||||||
sshkey:
|
|
||||||
type: string
|
|
||||||
example: "ssh-ed25519 AAAA..."
|
|
||||||
interfaces:
|
interfaces:
|
||||||
type: array
|
type: array
|
||||||
minItems: 1
|
minItems: 1
|
||||||
|
|
@ -447,6 +460,37 @@ components:
|
||||||
description: Boot with UEFI firmware (OVMF). Defaults to false (SeaBIOS).
|
description: Boot with UEFI firmware (OVMF). Defaults to false (SeaBIOS).
|
||||||
example: false
|
example: false
|
||||||
|
|
||||||
|
VMMetadata:
|
||||||
|
type: object
|
||||||
|
description: >
|
||||||
|
cloud-init inputs for this VM. Every field is optional. Documents not
|
||||||
|
supplied here fall back to the agent's built-in templates; a document
|
||||||
|
supplied as an empty string is served empty, which is not the same
|
||||||
|
thing.
|
||||||
|
properties:
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: >
|
||||||
|
Password *hash* for the syonad account, as expected by the
|
||||||
|
cloud-config `passwd` key (e.g. `$6$...`) — not a plaintext
|
||||||
|
password. When omitted, the account is created locked
|
||||||
|
(`lock_passwd: true`); when neither password nor sshkey is given,
|
||||||
|
no account is created at all.
|
||||||
|
example: "$6$rounds=4096$saltsalt$hash..."
|
||||||
|
sshkey:
|
||||||
|
type: string
|
||||||
|
description: Public SSH key added to the syonad account. Sent as-is, not encoded.
|
||||||
|
example: "ssh-ed25519 AAAA..."
|
||||||
|
user_data:
|
||||||
|
type: string
|
||||||
|
format: byte
|
||||||
|
description: >
|
||||||
|
cloud-init user-data, **base64-encoded**. Encoding keeps multi-line
|
||||||
|
documents out of JSON escaping and allows gzip+base64 payloads.
|
||||||
|
Invalid base64 is rejected with 400 rather than silently serving an
|
||||||
|
empty document. The agent never interprets this content.
|
||||||
|
example: "I2Nsb3VkLWNvbmZpZwpwYWNrYWdlczoKICAtIG5naW54Cg=="
|
||||||
|
|
||||||
VMInterface:
|
VMInterface:
|
||||||
type: object
|
type: object
|
||||||
required: [subnet, ip, primary]
|
required: [subnet, ip, primary]
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ type SubnetCreateRequest struct {
|
||||||
InterfaceIP string `json:"interface_ip"`
|
InterfaceIP string `json:"interface_ip"`
|
||||||
CIDR string `json:"cidr"`
|
CIDR string `json:"cidr"`
|
||||||
DefaultRoute bool `json:"default_route"`
|
DefaultRoute bool `json:"default_route"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subnet struct {
|
type Subnet struct {
|
||||||
|
|
@ -32,6 +33,7 @@ type Subnet struct {
|
||||||
InterfaceIP string `json:"interface_ip"`
|
InterfaceIP string `json:"interface_ip"`
|
||||||
CIDR string `json:"cidr"`
|
CIDR string `json:"cidr"`
|
||||||
DefaultRoute bool `json:"default_route"`
|
DefaultRoute bool `json:"default_route"`
|
||||||
|
Gateway string `json:"gateway"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VMInterface struct {
|
type VMInterface struct {
|
||||||
|
|
@ -45,13 +47,18 @@ type VMStorage struct {
|
||||||
Dev string `json:"dev"`
|
Dev string `json:"dev"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VMMetadata struct {
|
||||||
|
Password string `json:"password"`
|
||||||
|
SSHKey string `json:"sshkey"`
|
||||||
|
UserData string `json:"user_data"`
|
||||||
|
}
|
||||||
|
|
||||||
type VMCreateRequest struct {
|
type VMCreateRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Memory int `json:"memory"`
|
Memory int `json:"memory"`
|
||||||
CPUs int `json:"cpus"`
|
CPUs int `json:"cpus"`
|
||||||
UEFI bool `json:"uefi"`
|
UEFI bool `json:"uefi"`
|
||||||
Password string `json:"password"`
|
Metadata VMMetadata `json:"metadata"`
|
||||||
SSHKey string `json:"sshkey"`
|
|
||||||
Interfaces []VMInterface `json:"interfaces"`
|
Interfaces []VMInterface `json:"interfaces"`
|
||||||
Storage []VMStorage `json:"storage"`
|
Storage []VMStorage `json:"storage"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,8 @@ func (s *Server) getSubnet(w http.ResponseWriter, _ *http.Request, name string)
|
||||||
sub.CIDR = value
|
sub.CIDR = value
|
||||||
case "default_route":
|
case "default_route":
|
||||||
sub.DefaultRoute = value == "true"
|
sub.DefaultRoute = value == "true"
|
||||||
|
case "gateway":
|
||||||
|
sub.Gateway = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
|
||||||
|
|
@ -306,3 +306,34 @@ func TestSubnetByName_InvalidMethod(t *testing.T) {
|
||||||
t.Errorf("attendu 405, obtenu %d", w.Code)
|
t.Errorf("attendu 405, obtenu %d", w.Code)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCreateSubnet_GatewayRoundTrip(t *testing.T) {
|
||||||
|
s, db := newTestServer(t)
|
||||||
|
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
||||||
|
|
||||||
|
body, _ := json.Marshal(SubnetCreateRequest{
|
||||||
|
Name: "sn-gw", VPC: "vpc-1", Mode: "public_ip",
|
||||||
|
IfaceType: "vms", InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24",
|
||||||
|
DefaultRoute: true, Gateway: "203.0.113.1",
|
||||||
|
})
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
|
||||||
|
if gw, _ := kv.GetFromDB(db, "subnet/sn-gw/gateway"); gw != "203.0.113.1" {
|
||||||
|
t.Errorf("gateway attendue en DB, obtenu %q", gw)
|
||||||
|
}
|
||||||
|
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
s.SubnetByNameHandler(w, httptest.NewRequest(http.MethodGet, "/subnets/sn-gw", nil))
|
||||||
|
var got Subnet
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("réponse illisible : %v", err)
|
||||||
|
}
|
||||||
|
if got.Gateway != "203.0.113.1" {
|
||||||
|
t.Errorf("gateway absente de la réponse GET : %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,7 @@ func (s *Server) postSubnet(w http.ResponseWriter, r *http.Request) {
|
||||||
InterfaceIP: req.InterfaceIP,
|
InterfaceIP: req.InterfaceIP,
|
||||||
CIDR: req.CIDR,
|
CIDR: req.CIDR,
|
||||||
DefaultRoute: req.DefaultRoute,
|
DefaultRoute: req.DefaultRoute,
|
||||||
|
Gateway: req.Gateway,
|
||||||
}
|
}
|
||||||
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 {
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,16 @@ package agentapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
"git.g3e.fr/syonad/two/pkg/db/kv"
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- vmFromDB ---
|
// --- vmFromDB ---
|
||||||
|
|
@ -156,3 +159,106 @@ func TestStartVM_StorageReturnedInResponse(t *testing.T) {
|
||||||
t.Errorf("disque inattendu dans la réponse : %+v", vm.Storage[0])
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package agentapi
|
package agentapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent"
|
dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent"
|
||||||
|
"git.g3e.fr/syonad/two/internal/metadata"
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
"git.g3e.fr/syonad/two/pkg/db/kv"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -82,6 +85,13 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
|
||||||
disks[i] = dispatcher.VMDisk{Path: s.Path, Dev: s.Dev}
|
disks[i] = dispatcher.VMDisk{Path: s.Path, Dev: s.Dev}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
documents, err := decodeDocuments(req.Metadata)
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
json.NewEncoder(w).Encode(ErrorResponse{Error: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cmd := dispatcher.StartVMCommand{
|
cmd := dispatcher.StartVMCommand{
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Subnet: primary.Subnet,
|
Subnet: primary.Subnet,
|
||||||
|
|
@ -90,8 +100,9 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
|
||||||
Memory: req.Memory,
|
Memory: req.Memory,
|
||||||
CPUs: req.CPUs,
|
CPUs: req.CPUs,
|
||||||
UEFI: req.UEFI,
|
UEFI: req.UEFI,
|
||||||
Password: req.Password,
|
Password: req.Metadata.Password,
|
||||||
SSHKey: req.SSHKey,
|
SSHKey: req.Metadata.SSHKey,
|
||||||
|
Documents: documents,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.dispatcher.Prepare(cmd); err != nil {
|
if err := s.dispatcher.Prepare(cmd); err != nil {
|
||||||
|
|
@ -115,3 +126,14 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) {
|
||||||
w.WriteHeader(http.StatusAccepted)
|
w.WriteHeader(http.StatusAccepted)
|
||||||
json.NewEncoder(w).Encode(vm)
|
json.NewEncoder(w).Encode(vm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func decodeDocuments(m VMMetadata) (map[string]string, error) {
|
||||||
|
if m.UserData == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
content, err := base64.StdEncoding.DecodeString(m.UserData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("metadata.user_data is not valid base64: %w", err)
|
||||||
|
}
|
||||||
|
return map[string]string{metadata.DocUserData: string(content)}, nil
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@ func newConf(t *testing.T, cidr string) Config {
|
||||||
gw := net.ParseIP("192.168.1.1").To4()
|
gw := net.ParseIP("192.168.1.1").To4()
|
||||||
return Config{
|
return Config{
|
||||||
Network: network,
|
Network: network,
|
||||||
VPCGateway: gw,
|
InterfaceIP: net.ParseIP("192.168.1.254").To4(),
|
||||||
VPCRoute: vpcNet,
|
VPCRoute: vpcNet,
|
||||||
DefaultGateway: gw,
|
DefaultGateway: gw,
|
||||||
Name: "test",
|
Name: "test",
|
||||||
|
|
@ -120,7 +120,10 @@ func TestGenerateConfig_NoDefaultGatewaySuppressesRouterOption(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateConfig_VxlanEmitsNoRouterOption(t *testing.T) {
|
func TestGenerateConfig_NilDefaultGatewayEmitsNoRoute(t *testing.T) {
|
||||||
|
// Contrat du paquet, pas politique de subnet : depuis 2026-08-24 `startDHCP`
|
||||||
|
// renseigne toujours DefaultGateway, donc ce chemin n'est plus emprunté en
|
||||||
|
// production. Il reste valide — le générateur ne doit rien inventer.
|
||||||
conf := newConf(t, "192.168.1.0/29")
|
conf := newConf(t, "192.168.1.0/29")
|
||||||
conf.DefaultGateway = nil
|
conf.DefaultGateway = nil
|
||||||
|
|
||||||
|
|
@ -128,31 +131,27 @@ func TestGenerateConfig_VxlanEmitsNoRouterOption(t *testing.T) {
|
||||||
content, _ := os.ReadFile(path)
|
content, _ := os.ReadFile(path)
|
||||||
|
|
||||||
if !strings.Contains(string(content), "dhcp-option=121,") {
|
if !strings.Contains(string(content), "dhcp-option=121,") {
|
||||||
t.Fatalf("dhcp-option=121 attendue pour un subnet vxlan :\n%s", content)
|
t.Fatalf("dhcp-option=121 toujours attendue, ne serait-ce que pour la route metadata :\n%s", content)
|
||||||
}
|
}
|
||||||
if strings.Contains(string(content), "dhcp-option=3,") {
|
if strings.Contains(string(content), "dhcp-option=3,") {
|
||||||
t.Errorf("un subnet vxlan est privé : aucune route par défaut ne doit être émise\n%s", content)
|
t.Errorf("DefaultGateway nulle : aucune route par défaut ne doit être émise\n%s", content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateConfig_ContainsVPCRoute(t *testing.T) {
|
func TestGenerateConfig_ContainsVPCRoute(t *testing.T) {
|
||||||
conf := newConf(t, "192.168.1.0/29")
|
routes := route121(t, confLines(t, newConf(t, "192.168.1.0/29")))
|
||||||
path, _, _ := GenerateConfig(conf)
|
if !strings.Contains(routes, "10.0.0.0/16,192.168.1.254") {
|
||||||
content, _ := os.ReadFile(path)
|
t.Errorf("route VPC absente, ou next-hop autre que l'interface_ip du subnet :\n%s", routes)
|
||||||
|
|
||||||
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) {
|
func TestGenerateConfig_NoVPCRoute(t *testing.T) {
|
||||||
conf := newConf(t, "192.168.1.0/29")
|
conf := newConf(t, "192.168.1.0/29")
|
||||||
conf.VPCRoute = nil
|
conf.VPCRoute = nil
|
||||||
path, _, _ := GenerateConfig(conf)
|
|
||||||
content, _ := os.ReadFile(path)
|
|
||||||
|
|
||||||
if strings.Contains(string(content), "dhcp-option=121,") {
|
routes := route121(t, confLines(t, conf))
|
||||||
t.Errorf("dhcp-option=121 présente alors que VPCRoute=nil :\n%s", content)
|
if strings.Contains(routes, "10.0.0.0/16") {
|
||||||
|
t.Errorf("route VPC présente alors que VPCRoute=nil :\n%s", routes)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,6 +159,7 @@ 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,
|
||||||
|
InterfaceIP: net.ParseIP("10.10.0.1").To4(),
|
||||||
Name: "vpc1",
|
Name: "vpc1",
|
||||||
ConfDir: t.TempDir(),
|
ConfDir: t.TempDir(),
|
||||||
}
|
}
|
||||||
|
|
@ -205,6 +205,7 @@ 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,
|
||||||
|
InterfaceIP: net.ParseIP("10.0.0.1").To4(),
|
||||||
Name: "net",
|
Name: "net",
|
||||||
ConfDir: dir,
|
ConfDir: dir,
|
||||||
}
|
}
|
||||||
|
|
@ -215,3 +216,79 @@ func TestGenerateConfig_CreatesConfDir(t *testing.T) {
|
||||||
t.Errorf("répertoire %q non créé", dir)
|
t.Errorf("répertoire %q non créé", dir)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- option 121 : routes classless ---
|
||||||
|
|
||||||
|
func confLines(t *testing.T, c Config) string {
|
||||||
|
t.Helper()
|
||||||
|
path, _, err := GenerateConfig(c)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GenerateConfig : %v", err)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("lecture : %v", err)
|
||||||
|
}
|
||||||
|
return string(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
func route121(t *testing.T, content string) string {
|
||||||
|
t.Helper()
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
if strings.HasPrefix(line, "dhcp-option=121,") {
|
||||||
|
return strings.TrimPrefix(line, "dhcp-option=121,")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("aucune dhcp-option=121 dans :\n%s", content)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateConfig_AlwaysRoutesToMetadata(t *testing.T) {
|
||||||
|
conf := newConf(t, "192.168.1.0/29")
|
||||||
|
conf.VPCRoute = nil
|
||||||
|
conf.DefaultGateway = nil
|
||||||
|
|
||||||
|
routes := route121(t, confLines(t, conf))
|
||||||
|
if !strings.Contains(routes, "169.254.169.254/32,192.168.1.254") {
|
||||||
|
t.Errorf("sans route vers le serveur de métadonnées, cloud-init échoue et la VM n'est pas provisionnée :\n%s", routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateConfig_MetadataRouteEvenInBridgeMode(t *testing.T) {
|
||||||
|
conf := newConf(t, "192.168.1.0/29")
|
||||||
|
conf.VPCRoute = nil
|
||||||
|
|
||||||
|
routes := route121(t, confLines(t, conf))
|
||||||
|
if !strings.Contains(routes, "169.254.169.254/32") {
|
||||||
|
t.Errorf("le mode bridge a besoin de la même route :\n%s", routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateConfig_DefaultRouteAlsoInOption121(t *testing.T) {
|
||||||
|
conf := newConf(t, "192.168.1.0/29")
|
||||||
|
conf.VPCRoute = nil
|
||||||
|
|
||||||
|
routes := route121(t, confLines(t, conf))
|
||||||
|
if !strings.Contains(routes, "0.0.0.0/0,192.168.1.1") {
|
||||||
|
t.Errorf("RFC 3442 : un client qui lit l'option 121 ignore l'option 3, la route par défaut doit donc figurer dans la 121 :\n%s", routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateConfig_NoDefaultRouteMeansNoCatchAllInOption121(t *testing.T) {
|
||||||
|
conf := newConf(t, "192.168.1.0/29")
|
||||||
|
conf.DefaultGateway = nil
|
||||||
|
|
||||||
|
routes := route121(t, confLines(t, conf))
|
||||||
|
if strings.Contains(routes, "0.0.0.0/0") {
|
||||||
|
t.Errorf("aucune route par défaut demandée, la 121 ne doit pas en contenir :\n%s", routes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateConfig_MissingInterfaceIPIsAnError(t *testing.T) {
|
||||||
|
conf := newConf(t, "192.168.1.0/29")
|
||||||
|
conf.InterfaceIP = nil
|
||||||
|
|
||||||
|
if _, _, err := GenerateConfig(conf); err == nil {
|
||||||
|
t.Error("sans interface_ip aucune route metadata n'est possible : il faut échouer, pas écrire une conf muette")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,17 +6,20 @@ import (
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.g3e.fr/syonad/two/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func GenerateConfig(c Config) (string, map[string]string, error) {
|
func GenerateConfig(c Config) (string, map[string]string, error) {
|
||||||
|
if c.InterfaceIP == nil {
|
||||||
|
return "", nil, fmt.Errorf("interface ip is required: guests would have no route to the metadata server")
|
||||||
|
}
|
||||||
mask := fmt.Sprintf("%d.%d.%d.%d", c.Network.Mask[0], c.Network.Mask[1], c.Network.Mask[2], c.Network.Mask[3])
|
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
|
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)
|
||||||
if c.VPCRoute != nil {
|
fmt.Fprintf(&sb, "dhcp-option=121,%s\n", strings.Join(classlessRoutes(c), ","))
|
||||||
fmt.Fprintf(&sb, "dhcp-option=121,%s,%s\n", c.VPCRoute.String(), c.VPCGateway.String())
|
|
||||||
}
|
|
||||||
if c.DefaultGateway != nil {
|
if c.DefaultGateway != nil {
|
||||||
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.DefaultGateway.String())
|
fmt.Fprintf(&sb, "dhcp-option=3,%s\n", c.DefaultGateway.String())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -40,6 +43,19 @@ func GenerateConfig(c Config) (string, map[string]string, error) {
|
||||||
return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644)
|
return outPath, entries, os.WriteFile(outPath, []byte(sb.String()), 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func classlessRoutes(c Config) []string {
|
||||||
|
nextHop := c.InterfaceIP.String()
|
||||||
|
|
||||||
|
routes := []string{metadata.ServiceIP + "/32," + nextHop}
|
||||||
|
if c.VPCRoute != nil {
|
||||||
|
routes = append(routes, c.VPCRoute.String()+","+nextHop)
|
||||||
|
}
|
||||||
|
if c.DefaultGateway != nil {
|
||||||
|
routes = append(routes, "0.0.0.0/0,"+c.DefaultGateway.String())
|
||||||
|
}
|
||||||
|
return routes
|
||||||
|
}
|
||||||
|
|
||||||
func incrementIP(ip net.IP) {
|
func incrementIP(ip net.IP) {
|
||||||
for j := len(ip) - 1; j >= 0; j-- {
|
for j := len(ip) - 1; j >= 0; j-- {
|
||||||
ip[j]++
|
ip[j]++
|
||||||
|
|
|
||||||
|
|
@ -8,9 +8,9 @@ const DefaultConfDir = "/etc/dnsmasq.d"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Network *net.IPNet
|
Network *net.IPNet
|
||||||
VPCGateway net.IP // next-hop for VPCRoute (option 121)
|
InterfaceIP net.IP // subnet gateway; next-hop for the metadata and VPC routes
|
||||||
VPCRoute *net.IPNet // if non-nil, emit dhcp-option=121,VPCRoute,VPCGateway
|
VPCRoute *net.IPNet // if non-nil, routed via InterfaceIP in option 121
|
||||||
DefaultGateway net.IP // if non-nil, emit dhcp-option=3,DefaultGateway; if nil, emit a bare dhcp-option=3 to suppress the dnsmasq default
|
DefaultGateway net.IP // if non-nil, default route via option 3 and 0.0.0.0/0 in option 121
|
||||||
Name string
|
Name string
|
||||||
ConfDir string
|
ConfDir string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,15 +21,16 @@ type CreateSubnetCommand struct {
|
||||||
InterfaceIP string
|
InterfaceIP string
|
||||||
CIDR string
|
CIDR string
|
||||||
DefaultRoute bool
|
DefaultRoute bool
|
||||||
|
Gateway string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c CreateSubnetCommand) Key() string { return "subnet/" + c.Name }
|
func (c CreateSubnetCommand) Key() string { return "subnet/" + c.Name }
|
||||||
|
|
||||||
func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error {
|
func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) error {
|
||||||
if c.Mode == "" {
|
if c.Mode == "" {
|
||||||
c.Mode = "vxlan"
|
c.Mode = subnet.ModeVxlan
|
||||||
}
|
}
|
||||||
if c.Mode != "vxlan" && c.Mode != "bridge" {
|
if !subnet.ValidMode(c.Mode) {
|
||||||
return fmt.Errorf("unknown subnet mode %q", c.Mode)
|
return fmt.Errorf("unknown subnet mode %q", c.Mode)
|
||||||
}
|
}
|
||||||
if _, err := kv.GetFromDB(db, "subnet/"+c.Name+"/state"); err == nil {
|
if _, err := kv.GetFromDB(db, "subnet/"+c.Name+"/state"); err == nil {
|
||||||
|
|
@ -53,9 +54,14 @@ func (c CreateSubnetCommand) Prepare(db *badger.DB, cfg *configuration.Config) e
|
||||||
kv.AddInDB(db, "subnet/"+c.Name+"/interface_ip", c.InterfaceIP)
|
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))
|
kv.AddInDB(db, "subnet/"+c.Name+"/default_route", strconv.FormatBool(c.DefaultRoute))
|
||||||
if c.Mode == "vxlan" {
|
if c.Mode == subnet.ModeVxlan {
|
||||||
kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID))
|
kv.AddInDB(db, "subnet/"+c.Name+"/vxlan_id", strconv.Itoa(c.VxlanID))
|
||||||
}
|
}
|
||||||
|
if c.Gateway != "" {
|
||||||
|
if err := kv.AddInDB(db, "subnet/"+c.Name+"/gateway", c.Gateway); err != nil {
|
||||||
|
return fmt.Errorf("store gateway: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
configuration "git.g3e.fr/syonad/two/internal/config/agent"
|
configuration "git.g3e.fr/syonad/two/internal/config/agent"
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
"git.g3e.fr/syonad/two/pkg/db/kv"
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
func testCfg() *configuration.Config {
|
func testCfg() *configuration.Config {
|
||||||
|
|
@ -255,3 +256,74 @@ func TestDeleteSubnetCommand_Prepare_NotFound(t *testing.T) {
|
||||||
t.Error("Prepare devrait échouer si le subnet n'existe pas")
|
t.Error("Prepare devrait échouer si le subnet n'existe pas")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- gateway optionnelle et mode public_ip ---
|
||||||
|
|
||||||
|
func prepareSubnet(t *testing.T, cmd CreateSubnetCommand) (*badger.DB, error) {
|
||||||
|
t.Helper()
|
||||||
|
_, db := newTestDispatcher(t)
|
||||||
|
kv.AddInDB(db, "vpc/vpc-1/state", "running")
|
||||||
|
if cmd.VPC == "" {
|
||||||
|
cmd.VPC = "vpc-1"
|
||||||
|
}
|
||||||
|
return db, cmd.Prepare(db, testCfg())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSubnetCommand_Prepare_StoresGateway(t *testing.T) {
|
||||||
|
db, err := prepareSubnet(t, CreateSubnetCommand{
|
||||||
|
Name: "sn-gw", VxlanID: 100, IfaceType: "vms",
|
||||||
|
InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24",
|
||||||
|
DefaultRoute: true, Gateway: "10.0.0.254",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Prepare a échoué : %v", err)
|
||||||
|
}
|
||||||
|
gw, err := kv.GetFromDB(db, "subnet/sn-gw/gateway")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clé gateway absente : %v", err)
|
||||||
|
}
|
||||||
|
if gw != "10.0.0.254" {
|
||||||
|
t.Errorf("gateway attendue 10.0.0.254, obtenu %q", gw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSubnetCommand_Prepare_NoGatewayWritesNoKey(t *testing.T) {
|
||||||
|
db, err := prepareSubnet(t, CreateSubnetCommand{
|
||||||
|
Name: "sn-nogw", VxlanID: 100, IfaceType: "vms",
|
||||||
|
InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Prepare a échoué : %v", err)
|
||||||
|
}
|
||||||
|
if _, err := kv.GetFromDB(db, "subnet/sn-nogw/gateway"); err == nil {
|
||||||
|
t.Error("aucune gateway fournie, aucune clé ne doit être écrite")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSubnetCommand_Prepare_AcceptsPublicIPMode(t *testing.T) {
|
||||||
|
db, err := prepareSubnet(t, CreateSubnetCommand{
|
||||||
|
Name: "sn-pub", Mode: "public_ip", IfaceType: "vms",
|
||||||
|
InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24",
|
||||||
|
DefaultRoute: true, Gateway: "203.0.113.1",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("le mode public_ip doit être accepté : %v", err)
|
||||||
|
}
|
||||||
|
mode, _ := kv.GetFromDB(db, "subnet/sn-pub/mode")
|
||||||
|
if mode != "public_ip" {
|
||||||
|
t.Errorf("mode attendu public_ip, obtenu %q", mode)
|
||||||
|
}
|
||||||
|
if _, err := kv.GetFromDB(db, "subnet/sn-pub/vxlan_id"); err == nil {
|
||||||
|
t.Error("vxlan_id ne doit être écrit que pour le mode vxlan")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCreateSubnetCommand_Prepare_RejectsUnknownMode(t *testing.T) {
|
||||||
|
_, err := prepareSubnet(t, CreateSubnetCommand{
|
||||||
|
Name: "sn-bad", Mode: "public", IfaceType: "vms",
|
||||||
|
InterfaceIP: "10.0.0.1", CIDR: "10.0.0.0/24",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("un mode inconnu doit être refusé")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ type StartVMCommand struct {
|
||||||
UEFI bool
|
UEFI bool
|
||||||
Password string
|
Password string
|
||||||
SSHKey string
|
SSHKey string
|
||||||
|
Documents map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c StartVMCommand) Key() string { return "vm/" + c.Name }
|
func (c StartVMCommand) Key() string { return "vm/" + c.Name }
|
||||||
|
|
@ -66,6 +67,11 @@ func (c StartVMCommand) Prepare(db *badger.DB, _ *configuration.Config) error {
|
||||||
if c.SSHKey != "" {
|
if c.SSHKey != "" {
|
||||||
kv.AddInDB(db, "vm/"+c.Name+"/sshkey", c.SSHKey)
|
kv.AddInDB(db, "vm/"+c.Name+"/sshkey", c.SSHKey)
|
||||||
}
|
}
|
||||||
|
for doc, content := range c.Documents {
|
||||||
|
if err := kv.AddInDB(db, "vm/"+c.Name+"/metadata/"+doc, content); err != nil {
|
||||||
|
return fmt.Errorf("store metadata %s: %w", doc, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.g3e.fr/syonad/two/pkg/db/kv"
|
"git.g3e.fr/syonad/two/pkg/db/kv"
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- StartVMCommand.Prepare : écriture des disques en DB ---
|
// --- StartVMCommand.Prepare : écriture des disques en DB ---
|
||||||
|
|
@ -173,3 +174,98 @@ func TestStartVMCommand_Prepare_Duplicate(t *testing.T) {
|
||||||
t.Error("Prepare devrait échouer si la VM existe déjà")
|
t.Error("Prepare devrait échouer si la VM existe déjà")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- StartVMCommand.Prepare : documents cloud-init ---
|
||||||
|
|
||||||
|
func prepareWithDocuments(t *testing.T, docs map[string]string) *badger.DB {
|
||||||
|
t.Helper()
|
||||||
|
_, db := newTestDispatcher(t)
|
||||||
|
kv.AddInDB(db, "subnet/sn-1/state", "running")
|
||||||
|
kv.AddInDB(db, "subnet/sn-1/vpc", "vpc-1")
|
||||||
|
|
||||||
|
cmd := StartVMCommand{
|
||||||
|
Name: "vm-doc",
|
||||||
|
Subnet: "sn-1",
|
||||||
|
IP: "10.0.0.5",
|
||||||
|
Disks: []VMDisk{{Path: "/data/root.qcow2", Dev: "vda"}},
|
||||||
|
Documents: docs,
|
||||||
|
}
|
||||||
|
if err := cmd.Prepare(db, nil); err != nil {
|
||||||
|
t.Fatalf("Prepare a échoué : %v", err)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartVMCommand_Prepare_StoresDocuments(t *testing.T) {
|
||||||
|
userData := "#cloud-config\npackages:\n - nginx\n"
|
||||||
|
db := prepareWithDocuments(t, map[string]string{"user-data": userData})
|
||||||
|
|
||||||
|
got, err := kv.GetFromDB(db, "vm/vm-doc/metadata/user-data")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("clé metadata/user-data absente : %v", err)
|
||||||
|
}
|
||||||
|
if got != userData {
|
||||||
|
t.Errorf("user-data attendu %q, obtenu %q", userData, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartVMCommand_Prepare_NoDocumentsWritesNoKey(t *testing.T) {
|
||||||
|
db := prepareWithDocuments(t, nil)
|
||||||
|
|
||||||
|
entries, err := kv.ListByPrefix(db, "vm/vm-doc/metadata/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListByPrefix : %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("aucun document fourni, aucune clé ne doit être écrite : %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartVMCommand_Prepare_EmptyDocumentIsStored(t *testing.T) {
|
||||||
|
db := prepareWithDocuments(t, map[string]string{"vendor-data": ""})
|
||||||
|
|
||||||
|
entries, err := kv.ListByPrefix(db, "vm/vm-doc/metadata/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListByPrefix : %v", err)
|
||||||
|
}
|
||||||
|
if _, ok := entries["vm/vm-doc/metadata/vendor-data"]; !ok {
|
||||||
|
t.Error("un document explicitement vide doit être stocké : c'est une demande de ne rien servir, pas une absence de demande")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStartVMCommand_Prepare_AllDocumentKinds(t *testing.T) {
|
||||||
|
docs := map[string]string{
|
||||||
|
"user-data": "u",
|
||||||
|
"meta-data": "m",
|
||||||
|
"network-config": "n",
|
||||||
|
"vendor-data": "v",
|
||||||
|
}
|
||||||
|
db := prepareWithDocuments(t, docs)
|
||||||
|
|
||||||
|
for doc, want := range docs {
|
||||||
|
got, err := kv.GetFromDB(db, "vm/vm-doc/metadata/"+doc)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("clé metadata/%s absente : %v", doc, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%s attendu %q, obtenu %q", doc, want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteInDB_RemovesMetadataDocuments(t *testing.T) {
|
||||||
|
db := prepareWithDocuments(t, map[string]string{"user-data": "u", "vendor-data": "v"})
|
||||||
|
|
||||||
|
if err := kv.DeleteInDB(db, "vm/vm-doc"); err != nil {
|
||||||
|
t.Fatalf("DeleteInDB : %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := kv.ListByPrefix(db, "vm/vm-doc/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListByPrefix : %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 0 {
|
||||||
|
t.Errorf("la suppression de la VM doit emporter ses documents : %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ package iptables
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
|
"git.g3e.fr/syonad/two/internal/metadata"
|
||||||
)
|
)
|
||||||
|
|
||||||
func addRule(args ...string) error {
|
func addRule(args ...string) error {
|
||||||
|
|
@ -16,7 +18,7 @@ func deleteRule(args ...string) error {
|
||||||
func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error {
|
func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error {
|
||||||
if err := addRule("PREROUTING",
|
if err := addRule("PREROUTING",
|
||||||
"-s", vmIP+"/32",
|
"-s", vmIP+"/32",
|
||||||
"-d", "169.254.169.254/32",
|
"-d", metadata.ServiceIP+"/32",
|
||||||
"-p", "tcp", "-m", "tcp",
|
"-p", "tcp", "-m", "tcp",
|
||||||
"--dport", "80",
|
"--dport", "80",
|
||||||
"-j", "DNAT",
|
"-j", "DNAT",
|
||||||
|
|
@ -30,7 +32,7 @@ func AddMetadataRedirect(vmIP, gatewayIP, metadataPort string) error {
|
||||||
func DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort string) error {
|
func DeleteMetadataRedirect(vmIP, gatewayIP, metadataPort string) error {
|
||||||
if err := deleteRule("PREROUTING",
|
if err := deleteRule("PREROUTING",
|
||||||
"-s", vmIP+"/32",
|
"-s", vmIP+"/32",
|
||||||
"-d", "169.254.169.254/32",
|
"-d", metadata.ServiceIP+"/32",
|
||||||
"-p", "tcp", "-m", "tcp",
|
"-p", "tcp", "-m", "tcp",
|
||||||
"--dport", "80",
|
"--dport", "80",
|
||||||
"-j", "DNAT",
|
"-j", "DNAT",
|
||||||
|
|
|
||||||
|
|
@ -8,33 +8,37 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
func StartMetadata(config NoCloudConfig, cfg *configuration.Config, dryrun bool) error {
|
func StartMetadata(config NoCloudConfig, cfg *configuration.Config, dryrun bool) error {
|
||||||
|
if err := WriteNoCloudFiles(config, cfg.Metadata.RunDir); err != nil {
|
||||||
|
return fmt.Errorf("write nocloud files for %s: %w", config.Name, err)
|
||||||
|
}
|
||||||
|
if dryrun {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
service, err := systemd.New()
|
service, err := systemd.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect to systemd: %w", err)
|
return fmt.Errorf("failed to connect to systemd: %w", err)
|
||||||
}
|
}
|
||||||
defer service.Close()
|
defer service.Close()
|
||||||
|
|
||||||
LoadNcCloudInDB(config, cfg.Metadata.RunDir)
|
|
||||||
if !dryrun {
|
|
||||||
if err := service.Start("metadata@" + config.Name + ".service"); err != nil {
|
if err := service.Start("metadata@" + config.Name + ".service"); err != nil {
|
||||||
return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err)
|
return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func StopMetadata(vmName string, cfg *configuration.Config, dryrun bool) error {
|
func StopMetadata(vmName string, cfg *configuration.Config, dryrun bool) error {
|
||||||
|
if !dryrun {
|
||||||
service, err := systemd.New()
|
service, err := systemd.New()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to connect to systemd: %w", err)
|
return fmt.Errorf("failed to connect to systemd: %w", err)
|
||||||
}
|
}
|
||||||
defer service.Close()
|
defer service.Close()
|
||||||
|
|
||||||
UnLoadNoCloudInDB(vmName, cfg.Metadata.RunDir)
|
|
||||||
if !dryrun {
|
|
||||||
if err := service.Stop("metadata@" + vmName + ".service"); err != nil {
|
if err := service.Stop("metadata@" + vmName + ".service"); err != nil {
|
||||||
return fmt.Errorf("failed to stop metadata@%s: %w", vmName, err)
|
return fmt.Errorf("failed to stop metadata@%s: %w", vmName, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
return RemoveNoCloudFiles(vmName, cfg.Metadata.RunDir)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,21 @@ func useTestDir(t *testing.T) string {
|
||||||
return t.TempDir()
|
return t.TempDir()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
func mustWrite(t *testing.T, cfg NoCloudConfig, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := WriteNoCloudFiles(cfg, dir); err != nil {
|
||||||
|
t.Fatalf("WriteNoCloudFiles : %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustRemove(t *testing.T, vmName, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
if err := RemoveNoCloudFiles(vmName, dir); err != nil {
|
||||||
|
t.Fatalf("RemoveNoCloudFiles : %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- RenderConfig ---
|
// --- RenderConfig ---
|
||||||
|
|
||||||
func TestRenderConfig_MetaData(t *testing.T) {
|
func TestRenderConfig_MetaData(t *testing.T) {
|
||||||
|
|
@ -74,14 +89,23 @@ func TestRenderConfig_NetworkConfig(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRenderConfig_UserData(t *testing.T) {
|
func TestRenderConfig_UserData_DefaultIsEmpty(t *testing.T) {
|
||||||
cfg := newCfg()
|
doc, err := renderDocument(DocUserData, newCfg())
|
||||||
out, err := RenderConfig("templates/user-data.tmpl", cfg)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("RenderConfig user-data : %v", err)
|
t.Fatalf("renderDocument user-data : %v", err)
|
||||||
}
|
}
|
||||||
if !strings.Contains(out, "passwd -d root") {
|
if doc != "" {
|
||||||
t.Errorf("user-data inattendu :\n%s", out)
|
t.Errorf("le user-data par défaut doit être vide, obtenu :\n%q", doc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderConfig_UserData_NeverTouchesRootPassword(t *testing.T) {
|
||||||
|
doc, err := renderDocument(DocUserData, newCfg())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderDocument user-data : %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(doc, "passwd -d root") {
|
||||||
|
t.Errorf("l'agent ne doit imposer aucune modification du compte root :\n%s", doc)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -104,21 +128,21 @@ func TestRenderConfig_SpecialCharsInName(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- LoadNcCloudInDB / UnLoadNoCloudInDB ---
|
// --- WriteNoCloudFiles / RemoveNoCloudFiles ---
|
||||||
|
|
||||||
func readTestFile(t *testing.T, dir, vmName, name string) string {
|
func readTestFile(t *testing.T, dir, vmName, name string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
b, err := os.ReadFile(filepath.Join(dir, vmName, name))
|
b, err := os.ReadFile(filepath.Join(dir, vmName, name))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("fichier %q absent après LoadNcCloudInDB : %v", name, err)
|
t.Errorf("fichier %q absent après WriteNoCloudFiles : %v", name, err)
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadNcCloudInDB_StoresAllFiles(t *testing.T) {
|
func TestWriteNoCloudFiles_StoresAllFiles(t *testing.T) {
|
||||||
dir := useTestDir(t)
|
dir := useTestDir(t)
|
||||||
LoadNcCloudInDB(newCfg(), dir)
|
mustWrite(t, newCfg(), dir)
|
||||||
|
|
||||||
files := []string{"meta-data", "user-data", "network-config", "vendor-data", "vpc", "bind_ip", "bind_port"}
|
files := []string{"meta-data", "user-data", "network-config", "vendor-data", "vpc", "bind_ip", "bind_port"}
|
||||||
for _, f := range files {
|
for _, f := range files {
|
||||||
|
|
@ -129,9 +153,9 @@ func TestLoadNcCloudInDB_StoresAllFiles(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLoadNcCloudInDB_VpcAndBindValues(t *testing.T) {
|
func TestWriteNoCloudFiles_VpcAndBindValues(t *testing.T) {
|
||||||
dir := useTestDir(t)
|
dir := useTestDir(t)
|
||||||
LoadNcCloudInDB(newCfg(), dir)
|
mustWrite(t, newCfg(), dir)
|
||||||
|
|
||||||
if vpc := readTestFile(t, dir, "vm1", "vpc"); vpc != "vpc-test" {
|
if vpc := readTestFile(t, dir, "vm1", "vpc"); vpc != "vpc-test" {
|
||||||
t.Errorf("vpc attendu %q, obtenu %q", "vpc-test", vpc)
|
t.Errorf("vpc attendu %q, obtenu %q", "vpc-test", vpc)
|
||||||
|
|
@ -144,26 +168,26 @@ func TestLoadNcCloudInDB_VpcAndBindValues(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnLoadNoCloudInDB_RemovesAllFiles(t *testing.T) {
|
func TestRemoveNoCloudFiles_RemovesAllFiles(t *testing.T) {
|
||||||
dir := useTestDir(t)
|
dir := useTestDir(t)
|
||||||
LoadNcCloudInDB(newCfg(), dir)
|
mustWrite(t, newCfg(), dir)
|
||||||
UnLoadNoCloudInDB("vm1", dir)
|
mustRemove(t, "vm1", dir)
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(dir, "vm1")); !os.IsNotExist(err) {
|
if _, err := os.Stat(filepath.Join(dir, "vm1")); !os.IsNotExist(err) {
|
||||||
t.Error("répertoire vm1 devrait être supprimé après UnLoadNoCloudInDB")
|
t.Error("répertoire vm1 devrait être supprimé après RemoveNoCloudFiles")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) {
|
func TestRemoveNoCloudFiles_DoesNotAffectOtherVMs(t *testing.T) {
|
||||||
dir := useTestDir(t)
|
dir := useTestDir(t)
|
||||||
|
|
||||||
cfg1 := newCfg()
|
cfg1 := newCfg()
|
||||||
cfg2 := newCfg()
|
cfg2 := newCfg()
|
||||||
cfg2.Name = "vm2"
|
cfg2.Name = "vm2"
|
||||||
LoadNcCloudInDB(cfg1, dir)
|
mustWrite(t, cfg1, dir)
|
||||||
LoadNcCloudInDB(cfg2, dir)
|
mustWrite(t, cfg2, dir)
|
||||||
|
|
||||||
UnLoadNoCloudInDB("vm1", dir)
|
mustRemove(t, "vm1", dir)
|
||||||
|
|
||||||
if _, err := os.Stat(filepath.Join(dir, "vm2", "vpc")); err != nil {
|
if _, err := os.Stat(filepath.Join(dir, "vm2", "vpc")); err != nil {
|
||||||
t.Errorf("vm2 ne devrait pas être supprimée : %v", err)
|
t.Errorf("vm2 ne devrait pas être supprimée : %v", err)
|
||||||
|
|
@ -264,3 +288,138 @@ func TestRootHandler_ContentType(t *testing.T) {
|
||||||
t.Errorf("Content-Type attendu text/yaml, obtenu %q", ct)
|
t.Errorf("Content-Type attendu text/yaml, obtenu %q", ct)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- vendor-data : blocs conditionnels ---
|
||||||
|
|
||||||
|
func vendorData(t *testing.T, password, sshkey string) string {
|
||||||
|
t.Helper()
|
||||||
|
cfg := newCfg()
|
||||||
|
cfg.Password = password
|
||||||
|
cfg.SSHKEY = sshkey
|
||||||
|
out, err := renderDocument(DocVendorData, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderDocument vendor-data : %v", err)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorData_NoCredentials_EmitsNothing(t *testing.T) {
|
||||||
|
out := vendorData(t, "", "")
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("sans mot de passe ni clé, aucun compte ne doit être créé :\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorData_NoCredentials_NeverEmitsEmptyValues(t *testing.T) {
|
||||||
|
out := vendorData(t, "", "")
|
||||||
|
for _, forbidden := range []string{`passwd: ""`, `- ""`, "lock_passwd: false"} {
|
||||||
|
if strings.Contains(out, forbidden) {
|
||||||
|
t.Errorf("valeur vide %q émise :\n%s", forbidden, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorData_PasswordOnly(t *testing.T) {
|
||||||
|
out := vendorData(t, "$6$rounds$hash", "")
|
||||||
|
if !strings.Contains(out, `passwd: "$6$rounds$hash"`) {
|
||||||
|
t.Errorf("hash absent :\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "lock_passwd: false") {
|
||||||
|
t.Errorf("un compte avec mot de passe doit être déverrouillé :\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "ssh_authorized_keys") {
|
||||||
|
t.Errorf("aucune clé fournie, le bloc ne doit pas apparaître :\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorData_SSHKeyOnly_LocksPassword(t *testing.T) {
|
||||||
|
out := vendorData(t, "", "ssh-ed25519 AAAA user@host")
|
||||||
|
if !strings.Contains(out, "ssh-ed25519 AAAA user@host") {
|
||||||
|
t.Errorf("clé absente :\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, "\n passwd:") {
|
||||||
|
t.Errorf("aucun mot de passe fourni, le champ ne doit pas apparaître :\n%s", out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "lock_passwd: true") {
|
||||||
|
t.Errorf("sans mot de passe le compte doit rester verrouillé, sinon il devient un compte sudo sans mot de passe :\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorData_BothCredentials(t *testing.T) {
|
||||||
|
out := vendorData(t, "$6$hash", "ssh-ed25519 AAAA user@host")
|
||||||
|
for _, expected := range []string{"#cloud-config", "name: syonad", `passwd: "$6$hash"`, "lock_passwd: false", "ssh-ed25519 AAAA user@host"} {
|
||||||
|
if !strings.Contains(out, expected) {
|
||||||
|
t.Errorf("%q absent :\n%s", expected, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVendorData_CloudConfigHeaderIsFirstLine(t *testing.T) {
|
||||||
|
out := vendorData(t, "$6$hash", "")
|
||||||
|
if !strings.HasPrefix(out, "#cloud-config\n") {
|
||||||
|
t.Errorf("cloud-init exige #cloud-config en première ligne :\n%q", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- documents fournis par l'appelant ---
|
||||||
|
|
||||||
|
func TestRenderDocument_VerbatimOverridesTemplate(t *testing.T) {
|
||||||
|
cfg := newCfg()
|
||||||
|
supplied := "#cloud-config\npackages:\n - nginx\n"
|
||||||
|
cfg.Documents = map[string]string{DocUserData: supplied}
|
||||||
|
|
||||||
|
out, err := renderDocument(DocUserData, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderDocument : %v", err)
|
||||||
|
}
|
||||||
|
if out != supplied {
|
||||||
|
t.Errorf("le document fourni doit être écrit verbatim :\nattendu %q\nobtenu %q", supplied, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderDocument_EmptySuppliedDocumentIsHonoured(t *testing.T) {
|
||||||
|
cfg := newCfg()
|
||||||
|
cfg.Documents = map[string]string{DocVendorData: ""}
|
||||||
|
|
||||||
|
out, err := renderDocument(DocVendorData, cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("renderDocument : %v", err)
|
||||||
|
}
|
||||||
|
if out != "" {
|
||||||
|
t.Errorf("un document explicitement vide ne doit pas retomber sur le template :\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteNoCloudFiles_WritesSuppliedDocument(t *testing.T) {
|
||||||
|
dir := useTestDir(t)
|
||||||
|
cfg := newCfg()
|
||||||
|
cfg.Documents = map[string]string{DocUserData: "#cloud-config\nruncmd:\n - [ls]\n"}
|
||||||
|
mustWrite(t, cfg, dir)
|
||||||
|
|
||||||
|
if got := readTestFile(t, dir, "vm1", "user-data"); got != cfg.Documents[DocUserData] {
|
||||||
|
t.Errorf("user-data servi différent de celui fourni :\n%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- remontée des erreurs ---
|
||||||
|
|
||||||
|
func TestWriteNoCloudFiles_ReturnsErrorOnUnwritableDir(t *testing.T) {
|
||||||
|
if os.Geteuid() == 0 {
|
||||||
|
t.Skip("root ignore les permissions de fichiers")
|
||||||
|
}
|
||||||
|
dir := useTestDir(t)
|
||||||
|
if err := os.Chmod(dir, 0500); err != nil {
|
||||||
|
t.Fatalf("chmod : %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { os.Chmod(dir, 0700) })
|
||||||
|
|
||||||
|
if err := WriteNoCloudFiles(newCfg(), dir); err == nil {
|
||||||
|
t.Error("une écriture impossible doit remonter une erreur : une VM ne doit jamais démarrer sans métadonnées en silence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRemoveNoCloudFiles_AbsentDirIsNotAnError(t *testing.T) {
|
||||||
|
if err := RemoveNoCloudFiles("jamais-creee", useTestDir(t)); err != nil {
|
||||||
|
t.Errorf("supprimer une VM sans fichiers ne doit pas échouer : %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,14 +3,27 @@ package metadata
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.tmpl
|
//go:embed templates/*.tmpl
|
||||||
var templateFS embed.FS
|
var templateFS embed.FS
|
||||||
|
|
||||||
|
const (
|
||||||
|
DocMetaData = "meta-data"
|
||||||
|
DocUserData = "user-data"
|
||||||
|
DocNetworkConfig = "network-config"
|
||||||
|
DocVendorData = "vendor-data"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Documents() []string {
|
||||||
|
return []string{DocMetaData, DocUserData, DocNetworkConfig, DocVendorData}
|
||||||
|
}
|
||||||
|
|
||||||
func RenderConfig(path string, cfg NoCloudConfig) (string, error) {
|
func RenderConfig(path string, cfg NoCloudConfig) (string, error) {
|
||||||
tpl, err := template.ParseFS(templateFS, path)
|
tpl, err := template.ParseFS(templateFS, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -25,25 +38,55 @@ func RenderConfig(path string, cfg NoCloudConfig) (string, error) {
|
||||||
return buf.String(), nil
|
return buf.String(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func LoadNcCloudInDB(config NoCloudConfig, runDir string) {
|
func renderDocument(name string, cfg NoCloudConfig) (string, error) {
|
||||||
meta_data, _ := RenderConfig("templates/meta-data.tmpl", config)
|
if doc, ok := cfg.Documents[name]; ok {
|
||||||
user_data, _ := RenderConfig("templates/user-data.tmpl", config)
|
return doc, nil
|
||||||
network_config, _ := RenderConfig("templates/network-config.tmpl", config)
|
}
|
||||||
vendor_data, _ := RenderConfig("templates/vendor-data.tmpl", config)
|
|
||||||
|
|
||||||
|
out, err := RenderConfig("templates/"+name+".tmpl", cfg)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("render %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out = strings.TrimSpace(out)
|
||||||
|
if out == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return out + "\n", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteNoCloudFiles(config NoCloudConfig, runDir string) error {
|
||||||
dir := filepath.Join(runDir, config.Name)
|
dir := filepath.Join(runDir, config.Name)
|
||||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
return
|
return fmt.Errorf("create %s: %w", dir, err)
|
||||||
}
|
}
|
||||||
os.WriteFile(filepath.Join(dir, "meta-data"), []byte(meta_data), 0644)
|
|
||||||
os.WriteFile(filepath.Join(dir, "user-data"), []byte(user_data), 0644)
|
files := map[string]string{
|
||||||
os.WriteFile(filepath.Join(dir, "network-config"), []byte(network_config), 0644)
|
"vpc": config.VpcName,
|
||||||
os.WriteFile(filepath.Join(dir, "vendor-data"), []byte(vendor_data), 0644)
|
"bind_ip": config.BindIP,
|
||||||
os.WriteFile(filepath.Join(dir, "vpc"), []byte(config.VpcName), 0644)
|
"bind_port": config.BindPort,
|
||||||
os.WriteFile(filepath.Join(dir, "bind_ip"), []byte(config.BindIP), 0644)
|
}
|
||||||
os.WriteFile(filepath.Join(dir, "bind_port"), []byte(config.BindPort), 0644)
|
for _, name := range Documents() {
|
||||||
|
doc, err := renderDocument(name, config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files[name] = doc
|
||||||
|
}
|
||||||
|
|
||||||
|
for name, content := range files {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||||
|
return fmt.Errorf("write %s: %w", path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func UnLoadNoCloudInDB(vmName string, runDir string) {
|
func RemoveNoCloudFiles(vmName string, runDir string) error {
|
||||||
os.RemoveAll(filepath.Join(runDir, vmName))
|
dir := filepath.Join(runDir, vmName)
|
||||||
|
if err := os.RemoveAll(dir); err != nil {
|
||||||
|
return fmt.Errorf("remove %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
package metadata
|
package metadata
|
||||||
|
|
||||||
|
// ServiceIP is the well-known address guests use to reach the metadata server.
|
||||||
|
// Traffic to it is DNATed to the subnet gateway by internal/iptables, and the
|
||||||
|
// route to it is advertised by internal/dhcp.
|
||||||
|
const ServiceIP = "169.254.169.254"
|
||||||
|
|
||||||
type NoCloudData struct {
|
type NoCloudData struct {
|
||||||
MetaData string
|
MetaData string
|
||||||
UserData string
|
UserData string
|
||||||
|
|
@ -22,4 +27,5 @@ type NoCloudConfig struct {
|
||||||
Name string
|
Name string
|
||||||
Password string
|
Password string
|
||||||
SSHKEY string
|
SSHKEY string
|
||||||
|
Documents map[string]string
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,3 +0,0 @@
|
||||||
#!/bin/sh
|
|
||||||
|
|
||||||
passwd -d root
|
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
|
{{- if or .Password .SSHKEY -}}
|
||||||
#cloud-config
|
#cloud-config
|
||||||
users:
|
users:
|
||||||
- name: syonad
|
- name: syonad
|
||||||
lock_passwd: false
|
|
||||||
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
|
||||||
shell: /bin/bash
|
shell: /bin/bash
|
||||||
|
{{- if .Password }}
|
||||||
|
lock_passwd: false
|
||||||
passwd: "{{ .Password }}"
|
passwd: "{{ .Password }}"
|
||||||
|
{{- else }}
|
||||||
|
lock_passwd: true
|
||||||
|
{{- end }}
|
||||||
|
{{- if .SSHKEY }}
|
||||||
ssh_authorized_keys:
|
ssh_authorized_keys:
|
||||||
- "{{ .SSHKEY }}"
|
- "{{ .SSHKEY }}"
|
||||||
|
{{- end }}
|
||||||
|
{{- end }}
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,13 @@ func createSubnet(db *badger.DB, subnetName string, d subnetData) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch d.mode {
|
switch d.mode {
|
||||||
case "vxlan":
|
case ModeVxlan:
|
||||||
if err := setupVxlanHost(d, vethE); err != nil {
|
if err := setupVxlanHost(d, vethE); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
case "bridge":
|
case ModePublicIP:
|
||||||
|
return fmt.Errorf("subnet mode %q: host network setup is not implemented yet", d.mode)
|
||||||
|
case ModeBridge:
|
||||||
if err := netif.BridgeSetMaster(vethE, d.localIface); err != nil {
|
if err := netif.BridgeSetMaster(vethE, d.localIface); err != nil {
|
||||||
return fmt.Errorf("add veth-e to bridge: %w", err)
|
return fmt.Errorf("add veth-e to bridge: %w", err)
|
||||||
}
|
}
|
||||||
|
|
@ -139,20 +141,14 @@ func startDHCP(db *badger.DB, subnetName string, d subnetData) error {
|
||||||
Network: d.cidr,
|
Network: d.cidr,
|
||||||
Name: d.vpc + "_" + d.bridge,
|
Name: d.vpc + "_" + d.bridge,
|
||||||
ConfDir: dhcp.DefaultConfDir,
|
ConfDir: dhcp.DefaultConfDir,
|
||||||
|
InterfaceIP: d.interfaceIP,
|
||||||
}
|
}
|
||||||
switch d.mode {
|
defaultGateway, vpcRoute, err := dhcpRouting(d, netif.GetDefaultGateway)
|
||||||
case "vxlan":
|
|
||||||
conf.VPCGateway = d.interfaceIP
|
|
||||||
conf.VPCRoute = d.vpcCIDR
|
|
||||||
case "bridge":
|
|
||||||
if d.defaultRoute {
|
|
||||||
gw, err := netif.GetDefaultGateway()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("get default gateway: %w", err)
|
return err
|
||||||
}
|
|
||||||
conf.DefaultGateway = gw
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
conf.DefaultGateway = defaultGateway
|
||||||
|
conf.VPCRoute = vpcRoute
|
||||||
_, 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)
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ type subnetData struct {
|
||||||
cidr *net.IPNet
|
cidr *net.IPNet
|
||||||
vpcCIDR *net.IPNet
|
vpcCIDR *net.IPNet
|
||||||
defaultRoute bool
|
defaultRoute bool
|
||||||
|
gateway net.IP
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSubnet(db *badger.DB, name string) (subnetData, error) {
|
func loadSubnet(db *badger.DB, name string) (subnetData, error) {
|
||||||
|
|
@ -85,6 +86,14 @@ func loadSubnet(db *badger.DB, name string) (subnetData, error) {
|
||||||
}
|
}
|
||||||
d.defaultRoute = defaultRouteStr == "true"
|
d.defaultRoute = defaultRouteStr == "true"
|
||||||
|
|
||||||
|
if gatewayStr, err := kv.GetFromDB(db, "subnet/"+name+"/gateway"); err == nil && gatewayStr != "" {
|
||||||
|
gateway := net.ParseIP(gatewayStr)
|
||||||
|
if gateway == nil {
|
||||||
|
return d, fmt.Errorf("invalid gateway: %s", gatewayStr)
|
||||||
|
}
|
||||||
|
d.gateway = gateway
|
||||||
|
}
|
||||||
|
|
||||||
vpcCIDRStr, err := kv.GetFromDB(db, "vpc/"+d.vpc+"/cidr")
|
vpcCIDRStr, err := kv.GetFromDB(db, "vpc/"+d.vpc+"/cidr")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return d, fmt.Errorf("get vpc cidr: %w", err)
|
return d, fmt.Errorf("get vpc cidr: %w", err)
|
||||||
|
|
|
||||||
15
internal/subnet/mode.go
Normal file
15
internal/subnet/mode.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package subnet
|
||||||
|
|
||||||
|
const (
|
||||||
|
ModeVxlan = "vxlan"
|
||||||
|
ModeBridge = "bridge"
|
||||||
|
ModePublicIP = "public_ip"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ValidMode(mode string) bool {
|
||||||
|
switch mode {
|
||||||
|
case ModeVxlan, ModeBridge, ModePublicIP:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
34
internal/subnet/routing.go
Normal file
34
internal/subnet/routing.go
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package subnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dhcpRouting resolves what the DHCP server advertises to the guests of a subnet.
|
||||||
|
//
|
||||||
|
// The default route always points at the subnet gateway (interface_ip); default_route
|
||||||
|
// swaps that next-hop for the supplied gateway, or for the deduced one when none was
|
||||||
|
// supplied. The VPC route keeps interface_ip as its next-hop in every mode but bridge,
|
||||||
|
// so that traffic to the VPC ranges never leaves through a public gateway.
|
||||||
|
func dhcpRouting(d subnetData, deduceGateway func() (net.IP, error)) (net.IP, *net.IPNet, error) {
|
||||||
|
defaultGateway := d.interfaceIP
|
||||||
|
if d.defaultRoute {
|
||||||
|
if d.gateway != nil {
|
||||||
|
defaultGateway = d.gateway
|
||||||
|
} else {
|
||||||
|
deduced, err := deduceGateway()
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("get default gateway: %w", err)
|
||||||
|
}
|
||||||
|
defaultGateway = deduced
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var vpcRoute *net.IPNet
|
||||||
|
if d.mode != ModeBridge {
|
||||||
|
vpcRoute = d.vpcCIDR
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultGateway, vpcRoute, nil
|
||||||
|
}
|
||||||
158
internal/subnet/routing_test.go
Normal file
158
internal/subnet/routing_test.go
Normal file
|
|
@ -0,0 +1,158 @@
|
||||||
|
package subnet
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func cidr(t *testing.T, s string) *net.IPNet {
|
||||||
|
t.Helper()
|
||||||
|
_, n, err := net.ParseCIDR(s)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseCIDR(%q) : %v", s, err)
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
func baseSubnet(t *testing.T, mode string) subnetData {
|
||||||
|
t.Helper()
|
||||||
|
return subnetData{
|
||||||
|
mode: mode,
|
||||||
|
interfaceIP: net.ParseIP("10.1.1.1").To4(),
|
||||||
|
cidr: cidr(t, "10.1.0.0/23"),
|
||||||
|
vpcCIDR: cidr(t, "192.168.0.0/16"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func deduced(ip string) func() (net.IP, error) {
|
||||||
|
return func() (net.IP, error) { return net.ParseIP(ip).To4(), nil }
|
||||||
|
}
|
||||||
|
|
||||||
|
func neverDeduced(t *testing.T) func() (net.IP, error) {
|
||||||
|
t.Helper()
|
||||||
|
return func() (net.IP, error) {
|
||||||
|
t.Error("la gateway de l'host ne doit pas être interrogée dans ce cas")
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- next-hop de la route par défaut ---
|
||||||
|
|
||||||
|
func TestDhcpRouting_DefaultRouteUsesInterfaceIP(t *testing.T) {
|
||||||
|
d := baseSubnet(t, ModeVxlan)
|
||||||
|
|
||||||
|
gw, _, err := dhcpRouting(d, neverDeduced(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dhcpRouting : %v", err)
|
||||||
|
}
|
||||||
|
if gw.String() != "10.1.1.1" {
|
||||||
|
t.Errorf("sans default_route le next-hop doit être l'interface_ip, obtenu %s", gw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDhcpRouting_DefaultRouteUsesSuppliedGateway(t *testing.T) {
|
||||||
|
d := baseSubnet(t, ModeVxlan)
|
||||||
|
d.defaultRoute = true
|
||||||
|
d.gateway = net.ParseIP("10.1.1.254").To4()
|
||||||
|
|
||||||
|
gw, _, err := dhcpRouting(d, neverDeduced(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dhcpRouting : %v", err)
|
||||||
|
}
|
||||||
|
if gw.String() != "10.1.1.254" {
|
||||||
|
t.Errorf("gateway fournie attendue, obtenu %s", gw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDhcpRouting_DefaultRouteFallsBackToDeducedGateway(t *testing.T) {
|
||||||
|
d := baseSubnet(t, ModeBridge)
|
||||||
|
d.defaultRoute = true
|
||||||
|
|
||||||
|
gw, _, err := dhcpRouting(d, deduced("192.0.2.1"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dhcpRouting : %v", err)
|
||||||
|
}
|
||||||
|
if gw.String() != "192.0.2.1" {
|
||||||
|
t.Errorf("gateway déduite attendue, obtenu %s", gw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDhcpRouting_SuppliedGatewayIgnoredWithoutDefaultRoute(t *testing.T) {
|
||||||
|
d := baseSubnet(t, ModeVxlan)
|
||||||
|
d.gateway = net.ParseIP("10.1.1.254").To4()
|
||||||
|
|
||||||
|
gw, _, err := dhcpRouting(d, neverDeduced(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dhcpRouting : %v", err)
|
||||||
|
}
|
||||||
|
if gw.String() != "10.1.1.1" {
|
||||||
|
t.Errorf("gateway fournie sans default_route : ignorée en silence, next-hop attendu 10.1.1.1, obtenu %s", gw)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDhcpRouting_DeductionFailureIsReported(t *testing.T) {
|
||||||
|
d := baseSubnet(t, ModeBridge)
|
||||||
|
d.defaultRoute = true
|
||||||
|
|
||||||
|
_, _, err := dhcpRouting(d, func() (net.IP, error) { return nil, errors.New("pas de route") })
|
||||||
|
if err == nil {
|
||||||
|
t.Error("l'échec de déduction de la gateway doit remonter, pas produire une route muette")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- route VPC ---
|
||||||
|
|
||||||
|
func TestDhcpRouting_VPCRouteKeptInVxlan(t *testing.T) {
|
||||||
|
_, route, err := dhcpRouting(baseSubnet(t, ModeVxlan), neverDeduced(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dhcpRouting : %v", err)
|
||||||
|
}
|
||||||
|
if route == nil || route.String() != "192.168.0.0/16" {
|
||||||
|
t.Errorf("route VPC attendue, obtenu %v", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDhcpRouting_VPCRouteKeptInPublicIP(t *testing.T) {
|
||||||
|
d := baseSubnet(t, ModePublicIP)
|
||||||
|
d.defaultRoute = true
|
||||||
|
d.gateway = net.ParseIP("203.0.113.1").To4()
|
||||||
|
|
||||||
|
gw, route, err := dhcpRouting(d, neverDeduced(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dhcpRouting : %v", err)
|
||||||
|
}
|
||||||
|
if route == nil || route.String() != "192.168.0.0/16" {
|
||||||
|
t.Fatalf("route VPC attendue sur un subnet public, obtenu %v", route)
|
||||||
|
}
|
||||||
|
if gw.String() != "203.0.113.1" {
|
||||||
|
t.Errorf("next-hop par défaut attendu 203.0.113.1, obtenu %s", gw)
|
||||||
|
}
|
||||||
|
// C'est tout l'intérêt du mode : la route VPC garde interface_ip comme next-hop,
|
||||||
|
// donc le trafic interne ne sort jamais par la gateway publique.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDhcpRouting_NoVPCRouteInBridge(t *testing.T) {
|
||||||
|
_, route, err := dhcpRouting(baseSubnet(t, ModeBridge), neverDeduced(t))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("dhcpRouting : %v", err)
|
||||||
|
}
|
||||||
|
if route != nil {
|
||||||
|
t.Errorf("le mode bridge n'a pas de route VPC, obtenu %v", route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- modes ---
|
||||||
|
|
||||||
|
func TestValidMode(t *testing.T) {
|
||||||
|
for _, m := range []string{ModeVxlan, ModeBridge, ModePublicIP} {
|
||||||
|
if !ValidMode(m) {
|
||||||
|
t.Errorf("%q devrait être un mode valide", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, m := range []string{"", "public", "vxlan ", "VXLAN"} {
|
||||||
|
if ValidMode(m) {
|
||||||
|
t.Errorf("%q ne devrait pas être un mode valide", m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -48,6 +48,7 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error {
|
||||||
BindPort: d.metadataPort,
|
BindPort: d.metadataPort,
|
||||||
Password: d.password,
|
Password: d.password,
|
||||||
SSHKEY: d.sshkey,
|
SSHKEY: d.sshkey,
|
||||||
|
Documents: d.documents,
|
||||||
}, cfg, false); err != nil {
|
}, cfg, false); err != nil {
|
||||||
return fmt.Errorf("start metadata: %w", err)
|
return fmt.Errorf("start metadata: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ type vmData struct {
|
||||||
uefi bool
|
uefi bool
|
||||||
password string
|
password string
|
||||||
sshkey string
|
sshkey string
|
||||||
|
documents map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadVM(db *badger.DB, name string) (vmData, error) {
|
func loadVM(db *badger.DB, name string) (vmData, error) {
|
||||||
|
|
@ -124,5 +125,17 @@ func loadVM(db *badger.DB, name string) (vmData, error) {
|
||||||
d.password, _ = kv.GetFromDB(db, "vm/"+name+"/password")
|
d.password, _ = kv.GetFromDB(db, "vm/"+name+"/password")
|
||||||
d.sshkey, _ = kv.GetFromDB(db, "vm/"+name+"/sshkey")
|
d.sshkey, _ = kv.GetFromDB(db, "vm/"+name+"/sshkey")
|
||||||
|
|
||||||
|
docPrefix := "vm/" + name + "/metadata/"
|
||||||
|
docEntries, err := kv.ListByPrefix(db, docPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return d, fmt.Errorf("list metadata documents: %w", err)
|
||||||
|
}
|
||||||
|
if len(docEntries) > 0 {
|
||||||
|
d.documents = make(map[string]string, len(docEntries))
|
||||||
|
for key, content := range docEntries {
|
||||||
|
d.documents[strings.TrimPrefix(key, docPrefix)] = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
90
internal/vm/data_test.go
Normal file
90
internal/vm/data_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
package vm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.g3e.fr/syonad/two/pkg/db/kv"
|
||||||
|
"github.com/dgraph-io/badger/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newVMInDB(t *testing.T) *badger.DB {
|
||||||
|
t.Helper()
|
||||||
|
db := kv.InitDB(kv.Config{Path: t.TempDir()}, false)
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
|
||||||
|
kv.AddInDB(db, "vm/vm-1/subnet", "sn-000001")
|
||||||
|
kv.AddInDB(db, "subnet/sn-000001/vpc", "vp-admin")
|
||||||
|
kv.AddInDB(db, "subnet/sn-000001/interface_ip", "10.1.1.1")
|
||||||
|
kv.AddInDB(db, "subnet/sn-000001/dhcp/10.1.1.2", "00:22:33:00:01:02")
|
||||||
|
kv.AddInDB(db, "vm/vm-1/ip", "10.1.1.2")
|
||||||
|
kv.AddInDB(db, "vm/vm-1/metadata_port", "8081")
|
||||||
|
kv.AddInDB(db, "vm/vm-1/disk/vda", "/data/root.qcow2")
|
||||||
|
kv.AddInDB(db, "vm/vm-1/memory", "2048")
|
||||||
|
kv.AddInDB(db, "vm/vm-1/cpus", "2")
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVM_NoDocuments(t *testing.T) {
|
||||||
|
db := newVMInDB(t)
|
||||||
|
|
||||||
|
d, err := loadVM(db, "vm-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadVM : %v", err)
|
||||||
|
}
|
||||||
|
if d.documents != nil {
|
||||||
|
t.Errorf("aucun document en base, la map doit rester nil : %v", d.documents)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVM_ReadsDocuments(t *testing.T) {
|
||||||
|
db := newVMInDB(t)
|
||||||
|
userData := "#cloud-config\npackages:\n - nginx\n"
|
||||||
|
kv.AddInDB(db, "vm/vm-1/metadata/user-data", userData)
|
||||||
|
kv.AddInDB(db, "vm/vm-1/metadata/network-config", "version: 2\n")
|
||||||
|
|
||||||
|
d, err := loadVM(db, "vm-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadVM : %v", err)
|
||||||
|
}
|
||||||
|
if len(d.documents) != 2 {
|
||||||
|
t.Fatalf("2 documents attendus, obtenu %d : %v", len(d.documents), d.documents)
|
||||||
|
}
|
||||||
|
if d.documents["user-data"] != userData {
|
||||||
|
t.Errorf("user-data altéré au passage en base :\nattendu %q\nobtenu %q", userData, d.documents["user-data"])
|
||||||
|
}
|
||||||
|
if d.documents["network-config"] != "version: 2\n" {
|
||||||
|
t.Errorf("network-config inattendu : %q", d.documents["network-config"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVM_DocumentKeysAreStrippedOfPrefix(t *testing.T) {
|
||||||
|
db := newVMInDB(t)
|
||||||
|
kv.AddInDB(db, "vm/vm-1/metadata/vendor-data", "v")
|
||||||
|
|
||||||
|
d, err := loadVM(db, "vm-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadVM : %v", err)
|
||||||
|
}
|
||||||
|
for key := range d.documents {
|
||||||
|
if key != "vendor-data" {
|
||||||
|
t.Errorf("clé de document attendue %q, obtenue %q — le préfixe doit être retiré", "vendor-data", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVM_EmptyDocumentIsPreserved(t *testing.T) {
|
||||||
|
db := newVMInDB(t)
|
||||||
|
kv.AddInDB(db, "vm/vm-1/metadata/user-data", "")
|
||||||
|
|
||||||
|
d, err := loadVM(db, "vm-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("loadVM : %v", err)
|
||||||
|
}
|
||||||
|
content, ok := d.documents["user-data"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("un document vide doit survivre au passage en base : c'est une demande de ne rien servir")
|
||||||
|
}
|
||||||
|
if content != "" {
|
||||||
|
t.Errorf("contenu attendu vide, obtenu %q", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue