Compare commits

..

No commits in common. "8078da0bda7c4f350c5887a9e796e07aaa1ea18a" and "63fc3057364fbad1b16bfd9f63c19d3cbd6b6f7e" have entirely different histories.

15 changed files with 90 additions and 669 deletions

View file

@ -427,8 +427,11 @@ components:
type: integer type: integer
description: Number of vCPUs (default 1) description: Number of vCPUs (default 1)
example: 2 example: 2
metadata: password:
$ref: "#/components/schemas/VMMetadata" type: string
sshkey:
type: string
example: "ssh-ed25519 AAAA..."
interfaces: interfaces:
type: array type: array
minItems: 1 minItems: 1
@ -444,37 +447,6 @@ 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]

View file

@ -45,18 +45,13 @@ 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"`
Metadata VMMetadata `json:"metadata"` Password string `json:"password"`
SSHKey string `json:"sshkey"`
Interfaces []VMInterface `json:"interfaces"` Interfaces []VMInterface `json:"interfaces"`
Storage []VMStorage `json:"storage"` Storage []VMStorage `json:"storage"`
} }

View file

@ -2,16 +2,13 @@ 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 ---
@ -159,106 +156,3 @@ 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)
}
}

View file

@ -1,14 +1,11 @@
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"
) )
@ -85,13 +82,6 @@ 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,
@ -100,9 +90,8 @@ 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.Metadata.Password, Password: req.Password,
SSHKey: req.Metadata.SSHKey, SSHKey: req.SSHKey,
Documents: documents,
} }
if err := s.dispatcher.Prepare(cmd); err != nil { if err := s.dispatcher.Prepare(cmd); err != nil {
@ -126,14 +115,3 @@ 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
}

View file

@ -29,7 +29,6 @@ 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 }
@ -67,11 +66,6 @@ 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
} }

View file

@ -4,7 +4,6 @@ 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 ---
@ -174,98 +173,3 @@ 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)
}
}

View file

@ -8,37 +8,33 @@ 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)
} }

View file

@ -25,21 +25,6 @@ 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) {
@ -89,23 +74,14 @@ func TestRenderConfig_NetworkConfig(t *testing.T) {
} }
} }
func TestRenderConfig_UserData_DefaultIsEmpty(t *testing.T) { func TestRenderConfig_UserData(t *testing.T) {
doc, err := renderDocument(DocUserData, newCfg()) cfg := newCfg()
out, err := RenderConfig("templates/user-data.tmpl", cfg)
if err != nil { if err != nil {
t.Fatalf("renderDocument user-data : %v", err) t.Fatalf("RenderConfig user-data : %v", err)
} }
if doc != "" { if !strings.Contains(out, "passwd -d root") {
t.Errorf("le user-data par défaut doit être vide, obtenu :\n%q", doc) t.Errorf("user-data inattendu :\n%s", out)
}
}
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)
} }
} }
@ -128,21 +104,21 @@ func TestRenderConfig_SpecialCharsInName(t *testing.T) {
} }
} }
// --- WriteNoCloudFiles / RemoveNoCloudFiles --- // --- LoadNcCloudInDB / UnLoadNoCloudInDB ---
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 WriteNoCloudFiles : %v", name, err) t.Errorf("fichier %q absent après LoadNcCloudInDB : %v", name, err)
return "" return ""
} }
return string(b) return string(b)
} }
func TestWriteNoCloudFiles_StoresAllFiles(t *testing.T) { func TestLoadNcCloudInDB_StoresAllFiles(t *testing.T) {
dir := useTestDir(t) dir := useTestDir(t)
mustWrite(t, newCfg(), dir) LoadNcCloudInDB(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 {
@ -153,9 +129,9 @@ func TestWriteNoCloudFiles_StoresAllFiles(t *testing.T) {
} }
} }
func TestWriteNoCloudFiles_VpcAndBindValues(t *testing.T) { func TestLoadNcCloudInDB_VpcAndBindValues(t *testing.T) {
dir := useTestDir(t) dir := useTestDir(t)
mustWrite(t, newCfg(), dir) LoadNcCloudInDB(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)
@ -168,26 +144,26 @@ func TestWriteNoCloudFiles_VpcAndBindValues(t *testing.T) {
} }
} }
func TestRemoveNoCloudFiles_RemovesAllFiles(t *testing.T) { func TestUnLoadNoCloudInDB_RemovesAllFiles(t *testing.T) {
dir := useTestDir(t) dir := useTestDir(t)
mustWrite(t, newCfg(), dir) LoadNcCloudInDB(newCfg(), dir)
mustRemove(t, "vm1", dir) UnLoadNoCloudInDB("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 RemoveNoCloudFiles") t.Error("répertoire vm1 devrait être supprimé après UnLoadNoCloudInDB")
} }
} }
func TestRemoveNoCloudFiles_DoesNotAffectOtherVMs(t *testing.T) { func TestUnLoadNoCloudInDB_DoesNotAffectOtherVMs(t *testing.T) {
dir := useTestDir(t) dir := useTestDir(t)
cfg1 := newCfg() cfg1 := newCfg()
cfg2 := newCfg() cfg2 := newCfg()
cfg2.Name = "vm2" cfg2.Name = "vm2"
mustWrite(t, cfg1, dir) LoadNcCloudInDB(cfg1, dir)
mustWrite(t, cfg2, dir) LoadNcCloudInDB(cfg2, dir)
mustRemove(t, "vm1", dir) UnLoadNoCloudInDB("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)
@ -288,138 +264,3 @@ 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)
}
}

View file

@ -3,27 +3,14 @@ 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 {
@ -38,55 +25,25 @@ func RenderConfig(path string, cfg NoCloudConfig) (string, error) {
return buf.String(), nil return buf.String(), nil
} }
func renderDocument(name string, cfg NoCloudConfig) (string, error) { func LoadNcCloudInDB(config NoCloudConfig, runDir string) {
if doc, ok := cfg.Documents[name]; ok { meta_data, _ := RenderConfig("templates/meta-data.tmpl", config)
return doc, nil user_data, _ := RenderConfig("templates/user-data.tmpl", config)
} 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 fmt.Errorf("create %s: %w", dir, err) return
} }
os.WriteFile(filepath.Join(dir, "meta-data"), []byte(meta_data), 0644)
files := map[string]string{ os.WriteFile(filepath.Join(dir, "user-data"), []byte(user_data), 0644)
"vpc": config.VpcName, os.WriteFile(filepath.Join(dir, "network-config"), []byte(network_config), 0644)
"bind_ip": config.BindIP, os.WriteFile(filepath.Join(dir, "vendor-data"), []byte(vendor_data), 0644)
"bind_port": config.BindPort, os.WriteFile(filepath.Join(dir, "vpc"), []byte(config.VpcName), 0644)
} os.WriteFile(filepath.Join(dir, "bind_ip"), []byte(config.BindIP), 0644)
for _, name := range Documents() { os.WriteFile(filepath.Join(dir, "bind_port"), []byte(config.BindPort), 0644)
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 RemoveNoCloudFiles(vmName string, runDir string) error { func UnLoadNoCloudInDB(vmName string, runDir string) {
dir := filepath.Join(runDir, vmName) os.RemoveAll(filepath.Join(runDir, vmName))
if err := os.RemoveAll(dir); err != nil {
return fmt.Errorf("remove %s: %w", dir, err)
}
return nil
} }

View file

@ -22,5 +22,4 @@ type NoCloudConfig struct {
Name string Name string
Password string Password string
SSHKEY string SSHKEY string
Documents map[string]string
} }

View file

@ -0,0 +1,3 @@
#!/bin/sh
passwd -d root

View file

@ -1,17 +1,9 @@
{{- 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 }}

View file

@ -48,7 +48,6 @@ 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)
} }

View file

@ -31,7 +31,6 @@ 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) {
@ -125,17 +124,5 @@ 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
} }

View file

@ -1,90 +0,0 @@
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)
}
}