diff --git a/api/agent.yaml b/api/agent.yaml index 0861353..b9bebf5 100644 --- a/api/agent.yaml +++ b/api/agent.yaml @@ -427,11 +427,8 @@ components: type: integer description: Number of vCPUs (default 1) example: 2 - password: - type: string - sshkey: - type: string - example: "ssh-ed25519 AAAA..." + metadata: + $ref: "#/components/schemas/VMMetadata" interfaces: type: array minItems: 1 @@ -447,6 +444,37 @@ components: description: Boot with UEFI firmware (OVMF). Defaults to false (SeaBIOS). 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: type: object required: [subnet, ip, primary] diff --git a/internal/api/agent/models.go b/internal/api/agent/models.go index 079d8db..a7114c2 100644 --- a/internal/api/agent/models.go +++ b/internal/api/agent/models.go @@ -45,13 +45,18 @@ type VMStorage struct { Dev string `json:"dev"` } +type VMMetadata struct { + Password string `json:"password"` + SSHKey string `json:"sshkey"` + UserData string `json:"user_data"` +} + type VMCreateRequest struct { Name string `json:"name"` Memory int `json:"memory"` CPUs int `json:"cpus"` UEFI bool `json:"uefi"` - Password string `json:"password"` - SSHKey string `json:"sshkey"` + Metadata VMMetadata `json:"metadata"` Interfaces []VMInterface `json:"interfaces"` Storage []VMStorage `json:"storage"` } diff --git a/internal/api/agent/vm_test.go b/internal/api/agent/vm_test.go index db8bbd5..629c315 100644 --- a/internal/api/agent/vm_test.go +++ b/internal/api/agent/vm_test.go @@ -2,13 +2,16 @@ package agentapi import ( "bytes" + "encoding/base64" "encoding/json" "net/http" "net/http/httptest" "sort" + "strings" "testing" "git.g3e.fr/syonad/two/pkg/db/kv" + "github.com/dgraph-io/badger/v4" ) // --- vmFromDB --- @@ -156,3 +159,106 @@ func TestStartVM_StorageReturnedInResponse(t *testing.T) { 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) + } +} diff --git a/internal/api/agent/vms.go b/internal/api/agent/vms.go index 2ea5748..a061a00 100644 --- a/internal/api/agent/vms.go +++ b/internal/api/agent/vms.go @@ -1,11 +1,14 @@ package agentapi import ( + "encoding/base64" "encoding/json" + "fmt" "net/http" "strings" dispatcher "git.g3e.fr/syonad/two/internal/dispatcher/agent" + "git.g3e.fr/syonad/two/internal/metadata" "git.g3e.fr/syonad/two/pkg/db/kv" ) @@ -82,16 +85,24 @@ func (s *Server) startVM(w http.ResponseWriter, r *http.Request) { 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{ - Name: req.Name, - Subnet: primary.Subnet, - IP: primary.IP, - Disks: disks, - Memory: req.Memory, - CPUs: req.CPUs, - UEFI: req.UEFI, - Password: req.Password, - SSHKey: req.SSHKey, + Name: req.Name, + Subnet: primary.Subnet, + IP: primary.IP, + Disks: disks, + Memory: req.Memory, + CPUs: req.CPUs, + UEFI: req.UEFI, + Password: req.Metadata.Password, + SSHKey: req.Metadata.SSHKey, + Documents: documents, } 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) 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 +} diff --git a/internal/dispatcher/agent/vm_commands.go b/internal/dispatcher/agent/vm_commands.go index 6b50629..9b42289 100644 --- a/internal/dispatcher/agent/vm_commands.go +++ b/internal/dispatcher/agent/vm_commands.go @@ -20,15 +20,16 @@ type VMDisk struct { } type StartVMCommand struct { - Name string - Subnet string - IP string - Disks []VMDisk - Memory int - CPUs int - UEFI bool - Password string - SSHKey string + Name string + Subnet string + IP string + Disks []VMDisk + Memory int + CPUs int + UEFI bool + Password string + SSHKey string + Documents map[string]string } 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 != "" { 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 } diff --git a/internal/dispatcher/agent/vm_commands_test.go b/internal/dispatcher/agent/vm_commands_test.go index 2747b0f..cd483f4 100644 --- a/internal/dispatcher/agent/vm_commands_test.go +++ b/internal/dispatcher/agent/vm_commands_test.go @@ -4,6 +4,7 @@ import ( "testing" "git.g3e.fr/syonad/two/pkg/db/kv" + "github.com/dgraph-io/badger/v4" ) // --- 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à") } } + +// --- 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) + } +} diff --git a/internal/metadata/handle.go b/internal/metadata/handle.go index d34dd20..8f3d1f4 100644 --- a/internal/metadata/handle.go +++ b/internal/metadata/handle.go @@ -8,33 +8,37 @@ import ( ) 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() if err != nil { return fmt.Errorf("failed to connect to systemd: %w", err) } defer service.Close() - LoadNcCloudInDB(config, cfg.Metadata.RunDir) - if !dryrun { - if err := service.Start("metadata@" + config.Name + ".service"); err != nil { - return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err) - } + if err := service.Start("metadata@" + config.Name + ".service"); err != nil { + return fmt.Errorf("failed to start metadata@%s: %w", config.Name, err) } return nil } func StopMetadata(vmName string, cfg *configuration.Config, dryrun bool) error { - service, err := systemd.New() - if err != nil { - return fmt.Errorf("failed to connect to systemd: %w", err) - } - defer service.Close() - - UnLoadNoCloudInDB(vmName, cfg.Metadata.RunDir) if !dryrun { + service, err := systemd.New() + if err != nil { + return fmt.Errorf("failed to connect to systemd: %w", err) + } + defer service.Close() + if err := service.Stop("metadata@" + vmName + ".service"); err != nil { return fmt.Errorf("failed to stop metadata@%s: %w", vmName, err) } } - return nil + + return RemoveNoCloudFiles(vmName, cfg.Metadata.RunDir) } diff --git a/internal/metadata/metadata_test.go b/internal/metadata/metadata_test.go index cc54530..4efff81 100644 --- a/internal/metadata/metadata_test.go +++ b/internal/metadata/metadata_test.go @@ -25,6 +25,21 @@ func useTestDir(t *testing.T) string { 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 --- func TestRenderConfig_MetaData(t *testing.T) { @@ -74,14 +89,23 @@ func TestRenderConfig_NetworkConfig(t *testing.T) { } } -func TestRenderConfig_UserData(t *testing.T) { - cfg := newCfg() - out, err := RenderConfig("templates/user-data.tmpl", cfg) +func TestRenderConfig_UserData_DefaultIsEmpty(t *testing.T) { + doc, err := renderDocument(DocUserData, newCfg()) if err != nil { - t.Fatalf("RenderConfig user-data : %v", err) + t.Fatalf("renderDocument user-data : %v", err) } - if !strings.Contains(out, "passwd -d root") { - t.Errorf("user-data inattendu :\n%s", out) + if doc != "" { + 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 { t.Helper() b, err := os.ReadFile(filepath.Join(dir, vmName, name)) 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 string(b) } -func TestLoadNcCloudInDB_StoresAllFiles(t *testing.T) { +func TestWriteNoCloudFiles_StoresAllFiles(t *testing.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"} 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) - LoadNcCloudInDB(newCfg(), dir) + mustWrite(t, newCfg(), dir) if vpc := readTestFile(t, dir, "vm1", "vpc"); vpc != "vpc-test" { 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) - LoadNcCloudInDB(newCfg(), dir) - UnLoadNoCloudInDB("vm1", dir) + mustWrite(t, newCfg(), dir) + mustRemove(t, "vm1", dir) 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) cfg1 := newCfg() cfg2 := newCfg() cfg2.Name = "vm2" - LoadNcCloudInDB(cfg1, dir) - LoadNcCloudInDB(cfg2, dir) + mustWrite(t, cfg1, dir) + mustWrite(t, cfg2, dir) - UnLoadNoCloudInDB("vm1", dir) + mustRemove(t, "vm1", dir) if _, err := os.Stat(filepath.Join(dir, "vm2", "vpc")); err != nil { 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) } } + +// --- 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) + } +} diff --git a/internal/metadata/render.go b/internal/metadata/render.go index 0d4fd59..a038097 100644 --- a/internal/metadata/render.go +++ b/internal/metadata/render.go @@ -3,14 +3,27 @@ package metadata import ( "bytes" "embed" + "fmt" "os" "path/filepath" + "strings" "text/template" ) //go:embed templates/*.tmpl 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) { tpl, err := template.ParseFS(templateFS, path) if err != nil { @@ -25,25 +38,55 @@ func RenderConfig(path string, cfg NoCloudConfig) (string, error) { return buf.String(), nil } -func LoadNcCloudInDB(config NoCloudConfig, runDir string) { - meta_data, _ := RenderConfig("templates/meta-data.tmpl", config) - user_data, _ := RenderConfig("templates/user-data.tmpl", config) - network_config, _ := RenderConfig("templates/network-config.tmpl", config) - vendor_data, _ := RenderConfig("templates/vendor-data.tmpl", config) +func renderDocument(name string, cfg NoCloudConfig) (string, error) { + if doc, ok := cfg.Documents[name]; ok { + return doc, nil + } + 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) 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) - os.WriteFile(filepath.Join(dir, "network-config"), []byte(network_config), 0644) - os.WriteFile(filepath.Join(dir, "vendor-data"), []byte(vendor_data), 0644) - os.WriteFile(filepath.Join(dir, "vpc"), []byte(config.VpcName), 0644) - os.WriteFile(filepath.Join(dir, "bind_ip"), []byte(config.BindIP), 0644) - os.WriteFile(filepath.Join(dir, "bind_port"), []byte(config.BindPort), 0644) + + files := map[string]string{ + "vpc": config.VpcName, + "bind_ip": config.BindIP, + "bind_port": config.BindPort, + } + 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) { - os.RemoveAll(filepath.Join(runDir, vmName)) +func RemoveNoCloudFiles(vmName string, runDir string) error { + dir := filepath.Join(runDir, vmName) + if err := os.RemoveAll(dir); err != nil { + return fmt.Errorf("remove %s: %w", dir, err) + } + return nil } diff --git a/internal/metadata/struct.go b/internal/metadata/struct.go index e706087..2e94cfc 100644 --- a/internal/metadata/struct.go +++ b/internal/metadata/struct.go @@ -16,10 +16,11 @@ type ServerConfig struct { } type NoCloudConfig struct { - VpcName string - BindIP string - BindPort string - Name string - Password string - SSHKEY string + VpcName string + BindIP string + BindPort string + Name string + Password string + SSHKEY string + Documents map[string]string } diff --git a/internal/metadata/templates/user-data.tmpl b/internal/metadata/templates/user-data.tmpl index 84195ab..e69de29 100644 --- a/internal/metadata/templates/user-data.tmpl +++ b/internal/metadata/templates/user-data.tmpl @@ -1,3 +0,0 @@ -#!/bin/sh - -passwd -d root diff --git a/internal/metadata/templates/vendor-data.tmpl b/internal/metadata/templates/vendor-data.tmpl index fe0dcd8..c358f69 100644 --- a/internal/metadata/templates/vendor-data.tmpl +++ b/internal/metadata/templates/vendor-data.tmpl @@ -1,9 +1,17 @@ +{{- if or .Password .SSHKEY -}} #cloud-config users: - name: syonad - lock_passwd: false sudo: ["ALL=(ALL) NOPASSWD:ALL"] shell: /bin/bash +{{- if .Password }} + lock_passwd: false passwd: "{{ .Password }}" +{{- else }} + lock_passwd: true +{{- end }} +{{- if .SSHKEY }} ssh_authorized_keys: - "{{ .SSHKEY }}" +{{- end }} +{{- end }} diff --git a/internal/vm/create.go b/internal/vm/create.go index 74ed0bb..7ddc04c 100644 --- a/internal/vm/create.go +++ b/internal/vm/create.go @@ -42,12 +42,13 @@ func StartVM(db *badger.DB, name string, cfg *configuration.Config) error { } if err := metadata.StartMetadata(metadata.NoCloudConfig{ - Name: name, - VpcName: d.vpcName, - BindIP: d.interfaceIP, - BindPort: d.metadataPort, - Password: d.password, - SSHKEY: d.sshkey, + Name: name, + VpcName: d.vpcName, + BindIP: d.interfaceIP, + BindPort: d.metadataPort, + Password: d.password, + SSHKEY: d.sshkey, + Documents: d.documents, }, cfg, false); err != nil { return fmt.Errorf("start metadata: %w", err) } diff --git a/internal/vm/data.go b/internal/vm/data.go index 3435fb1..b4b9327 100644 --- a/internal/vm/data.go +++ b/internal/vm/data.go @@ -31,6 +31,7 @@ type vmData struct { uefi bool password string sshkey string + documents map[string]string } 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.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 } diff --git a/internal/vm/data_test.go b/internal/vm/data_test.go new file mode 100644 index 0000000..9c04934 --- /dev/null +++ b/internal/vm/data_test.go @@ -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) + } +}