diff --git a/src/server/router/routerTemplates.go b/src/server/router/routerTemplates.go index 8679d72b2976c36ba893b784850f2c4c052c0181..4535628a55299831f3b816aae806f297389b0306 100644 --- a/src/server/router/routerTemplates.go +++ b/src/server/router/routerTemplates.go @@ -184,7 +184,7 @@ func (r *RouterTemplates)CreateTemplateFromQCOW(c echo.Context) error { // Requires either of: // CAP_TPL_DESTROY_ANY: any template can be deleted. // CAP_TPL_DESTROY: only a template owned by the user can be deleted. -// Remark: a template can only be deleted if no VM references it! +// REMARK: a template can only be deleted if no VM references it! // curl --cacert ca.pem -X DELETE https://localhost:1077/templates/4913a2bb-edfe-4dfe-af53-38197a44523b -H "Authorization: Bearer <AccessToken>" func (r *RouterTemplates)DeleteTemplate(c echo.Context) error { // Retrieves logged user from context. diff --git a/src/server/router/routerVMs.go b/src/server/router/routerVMs.go index ce098997bf44c9e46db7fe535e8a958719147055..4d9d2e34cf729385a96672f7824923bd04fba902 100644 --- a/src/server/router/routerVMs.go +++ b/src/server/router/routerVMs.go @@ -103,7 +103,7 @@ func (r *RouterVMs)GetRebootableVMs(c echo.Context) error { // curl --cacert ca.pem -X GET https://localhost:1077/vms/edit -H "Authorization: Bearer <AccessToken>" func (r *RouterVMs)GetEditableVMs(c echo.Context) error { return r.getFilteredVMs(c, caps.CAP_VM_EDIT_ANY, caps.CAP_VM_EDIT, func(vm *vms.VM) bool { - return !vm.IsRunning() + return true }) } @@ -148,7 +148,7 @@ func (r *RouterVMs)GetModifiableVMAccessVMs(c echo.Context) error { } filterFunc := func(vm *vms.VM) bool { - return isModifyVMAccessAllowed(user, vm) && !vm.IsRunning() + return isModifyVMAccessAllowed(user, vm) } return c.JSONPretty(http.StatusOK, r.vms.GetNetworkSerializedVMs(filterFunc), " ") @@ -161,7 +161,7 @@ func (r *RouterVMs)GetModifiableVMAccessVMs(c echo.Context) error { // curl --cacert ca.pem -X GET https://localhost:1077/vms/exportdir -H "Authorization: Bearer <AccessToken>" func (r *RouterVMs)GetDirExportableVMs(c echo.Context) error { return r.getFilteredVMs(c, caps.CAP_VM_READFS_ANY, caps.CAP_VM_READFS, func(vm *vms.VM) bool { - return true + return !vm.IsRunning() }) } @@ -172,7 +172,7 @@ func (r *RouterVMs)GetDirExportableVMs(c echo.Context) error { // curl --cacert ca.pem -X GET https://localhost:1077/vms/importfiles -H "Authorization: Bearer <AccessToken>" func (r *RouterVMs)GetFilesImportableVMs(c echo.Context) error { return r.getFilteredVMs(c, caps.CAP_VM_WRITEFS_ANY, caps.CAP_VM_WRITEFS, func(vm *vms.VM) bool { - return true + return !vm.IsRunning() }) } @@ -246,7 +246,6 @@ func (r *RouterVMs)StartVM(c echo.Context) error { // curl --cacert ca.pem -X PUT https://localhost:1077/vms/e41f3556-ca24-4658-bd79-8c85bd6bff59/startwithcreds -H "Authorization: Bearer <AccessToken>" func (r *RouterVMs)StartVMWithCreds(c echo.Context) error { return r.applyOnFilteredVMs(c, caps.CAP_VM_START_ANY, caps.CAP_VM_START, func(c echo.Context, vm *vms.VM) error { - // Deserializes and validates client's parameters. p := new(params.VMStartWithCreds) if err := decodeJson(c, &p); err != nil { @@ -551,4 +550,3 @@ func (r *RouterVMs)applyOnFilteredVMs(c echo.Context, userCapabilityAny, vmAcces return action(c, vm) } } - diff --git a/src/server/users/users.go b/src/server/users/users.go index 396866e22a2e0e30ae520119af71038d96a24a22..35422def69d9fbe2addbfde03c5f48d5d394b117 100644 --- a/src/server/users/users.go +++ b/src/server/users/users.go @@ -33,7 +33,6 @@ func InitUsers() error { } // Returns all users. -// Thread-safe fonction. func (users *Users)GetUsers() []User { users.rwlock.RLock() list := users.usersToList() @@ -47,7 +46,6 @@ func (users *Users)GetUsers() []User { } // Returns a user by her email (unique). -// Thread-safe fonction. func (users *Users)GetUserByEmail(email string) (User, error) { users.rwlock.RLock() defer users.rwlock.RUnlock() @@ -62,8 +60,10 @@ func (users *Users)GetUserByEmail(email string) (User, error) { } // Deletes a user by its email and upates the user file. -// TODO: only deletes a user when no VMs and templates are owned by her? -// Thread-safe fonction. +// REMARKS: +// After a user is deleted, their VMs and templates remain. +// Alternative: make sure a user's VMs and templates are first deleted before deleting a user. +// I think the current behavior is preferable. func (users *Users)DeleteUserByEmail(email string) error { users.rwlock.Lock() defer users.rwlock.Unlock() @@ -78,7 +78,6 @@ func (users *Users)DeleteUserByEmail(email string) error { } // Adds a user and updates the users file. -// Thread-safe fonction. func (users *Users)AddUser(user *User) error { users.rwlock.Lock() defer users.rwlock.Unlock() @@ -97,7 +96,6 @@ func (users *Users)AddUser(user *User) error { } // Updates a user and updates the user file. -// Thread-safe fonction. func (users *Users)UpdateUser(user *User) error { users.rwlock.Lock() defer users.rwlock.Unlock() diff --git a/src/server/version/version.go b/src/server/version/version.go index 631aa4f429271a44c2af6b777c89587f663fa84a..70c2359597e4f7b5f8bc3a97ad43391397a2d8f4 100644 --- a/src/server/version/version.go +++ b/src/server/version/version.go @@ -7,7 +7,7 @@ import ( const ( major = 1 minor = 8 - bugfix = 4 + bugfix = 5 ) type Version struct { diff --git a/src/server/vms/template.go b/src/server/vms/template.go index d07e29965af694d1e8244f05074799567280fd7e..01c80800b38cfcf18b043b49fda010795bf332e7 100644 --- a/src/server/vms/template.go +++ b/src/server/vms/template.go @@ -27,19 +27,20 @@ const ( templatePrivate = "private" ) -// Creates a template from a VM's disk. +// Creates a template from a VM's disk and returns a pointer to it. +// Concurrency: safe func NewTemplateFromVM(name, owner, access string, vm *VM) (*Template, error) { vm.mutex.Lock() if vm.IsRunning() { vm.mutex.Unlock() - return nil, errors.New("Failed: VM must be stopped") + return nil, errors.New("Failed creating template: VM must be stopped") } // Marks VM as "busy", meaning its disk file is being accessed for a possibly long time. - if vm.DiskBusy { + if vm.IsDiskBusy() { vm.mutex.Unlock() - return nil, errors.New("Failed: VM disk must not be busy") + return nil, errors.New("Failed creating template: VM disk is in use (busy)") } vm.DiskBusy = true @@ -97,7 +98,8 @@ func NewTemplateFromVM(name, owner, access string, vm *VM) (*Template, error) { return template, nil } -// Creates a template from a qcow file. +// Creates a template from a qcow file and returns a pointer to it. +// Concurrency: safe func NewTemplateFromQCOW(name, owner, access, qcowFile string) (*Template, error) { // Creates the template. template, err := newTemplate(name, owner, access) @@ -123,6 +125,9 @@ func NewTemplateFromQCOW(name, owner, access, qcowFile string) (*Template, error return template, nil } +// Checks whether the specified access is valid. +// Returns an error if it's not. +// Concurrency: safe func ValidateTemplateAccess(access string) error { if access != templatePrivate && access != templatePublic { return errors.New("Wrong template access type") @@ -130,25 +135,33 @@ func ValidateTemplateAccess(access string) error { return nil } +// Returns true if the template is public. +// Concurrency: safe func (template *Template)IsPublic() bool { return template.t.Access == templatePublic } // Returns the absolute path to the template's disk image. +// Concurrency: safe func (template *Template)GetTemplateDiskPath() string { path, _ := filepath.Abs(filepath.Join(template.getTemplateDir(), templateDiskFile)) return path } +// Returns the template's owner. +// Concurrency: safe func (template *Template)GetOwner() string { return template.t.Owner } +// Returns a serialized version of the template. +// Concurrency: safe func (template *Template)SerializeToNetwork() template.TemplateSerialized { return template.t } -// Cr1eates a template. +// Creates a template given the specified name, owner and access and returns a pointer to it. +// Concurrency: safe func newTemplate(name, owner, access string) (*Template, error) { id, err := uuid.NewRandom() if err != nil { @@ -165,7 +178,8 @@ func newTemplate(name, owner, access string) (*Template, error) { return template, nil } -// Creates a template from a templateConfFile and returns it. +// Creates a template from a template configuration file (json) and returns a pointer to it. +// Concurrency: safe func newTemplateFromFile(file string) (*Template, error) { filein, err := os.OpenFile(file, os.O_RDONLY, 0) if err != nil { @@ -190,7 +204,8 @@ func newTemplateFromFile(file string) (*Template, error) { return template, nil } -// Writes a template's config file. +// Writes a template's configuration file (json). +// Concurrency: unsafe func (template *Template)writeConfig() error { templateDir := template.getTemplateDir() templateConfigFile := filepath.Join(templateDir, templateConfFile) @@ -214,7 +229,8 @@ func (template *Template)writeConfig() error { return nil } -// Deletes a template's directory and its content. +// Deletes a template's directory along its content. +// Concurrency: unsafe func (template *Template)delete() error { templateDir := template.getTemplateDir() if err := os.RemoveAll(templateDir); err != nil { @@ -225,11 +241,15 @@ func (template *Template)delete() error { return nil } +// Returns a template's directory. +// Concurrency: safe func (template *Template)getTemplateDir() string { return filepath.Join(templates.dir, template.t.ID.String()) } // Checks that the template's fields are valid. +// Returns an error if they are not. +// Concurrency: safe func (template *Template)validate() error { if err := validator.New().Struct(template.t); err != nil { return err diff --git a/src/server/vms/templates.go b/src/server/vms/templates.go index 3983c018727b9311ecdf0fa6d18a162dd300c2e9..843943b98568e364462c9f2f895dcdfa2634731f 100644 --- a/src/server/vms/templates.go +++ b/src/server/vms/templates.go @@ -25,14 +25,16 @@ type ( var templates *Templates -// Returns a Templates "singleton". +// Returns a Templates singleton to access this module's public functions. // IMPORTANT: the InitTemplates function must have been previously called! +// Concurrency: safe func GetTemplatesInstance() *Templates { return templates } // Create all templates from on-disk files. -// NOTE: path is the root directory where templates reside. +// REMARK: path is the root directory where templates reside. +// Concurrency: unsafe func InitTemplates() error { templatesDir := paths.GetInstance().TemplatesDir templates = &Templates { m: make(map[string]*Template), dir: templatesDir, rwlock: new(sync.RWMutex) } @@ -70,7 +72,8 @@ func InitTemplates() error { return nil } -// Returns the list of templates for which TemplateKeeperFn returns true. +// Returns a list of templates for which TemplateKeeperFn is true. +// Concurrency: safe func (templates *Templates)GetNetworkSerializedTemplates(keepFn TemplateKeeperFn) []t.TemplateSerialized { templates.rwlock.RLock() @@ -89,11 +92,17 @@ func (templates *Templates)GetNetworkSerializedTemplates(keepFn TemplateKeeperFn return list } -// Returns a template by its ID. +// Returns a template by its ID. Concurrency-safe version. +// Concurrency: safe func (templates *Templates)GetTemplate(tplID uuid.UUID) (*Template, error) { templates.rwlock.RLock() defer templates.rwlock.RUnlock() + return templates.getTemplateUnsafe(tplID) +} +// Returns a template by its ID. Concurrency-unsafe version. +// Concurrency: unsafe +func (templates *Templates)getTemplateUnsafe(tplID uuid.UUID) (*Template, error) { template, exists := templates.m[tplID.String()] if !exists { return nil, errors.New("Template not found") @@ -105,8 +114,10 @@ func (templates *Templates)GetTemplate(tplID uuid.UUID) (*Template, error) { // A template can only be deleted if no VM references it! // TODO: prevents deletion if the template's disk image is // being exported by RouterTemplates.ExportDisk +// Concurrency: safe func (templates *Templates)DeleteTemplate(tplID uuid.UUID, vms *VMs) error { id := tplID.String() + templates.rwlock.Lock() defer templates.rwlock.Unlock() template, exists := templates.m[id] @@ -126,7 +137,8 @@ func (templates *Templates)DeleteTemplate(tplID uuid.UUID, vms *VMs) error { return errors.New("Failed: template not found") } -// Adds a template and writes its config file. +// Adds a template, create its on-disk directory and config file. +// Concurrency: safe func (templates *Templates)AddTemplate(template *Template) error { templates.rwlock.Lock() defer templates.rwlock.Unlock() @@ -142,9 +154,13 @@ func (templates *Templates)AddTemplate(template *Template) error { return nil } -// Edit a template's fields: name and/or access. +// Edit a template's fields (name and/or access) and updates its config file accordingly. +// Concurrency: safe func (templates *Templates)EditTemplate(tplID uuid.UUID, name, access string) error { - tpl, err := templates.GetTemplate(tplID) + templates.rwlock.RLock() + defer templates.rwlock.RUnlock() + + tpl, err := templates.getTemplateUnsafe(tplID) if err != nil { return err } diff --git a/src/server/vms/vm.go b/src/server/vms/vm.go index b755a9d320e98289793ecfa9d5906193b29cb2ba..a45c423fd1b0dfa54d5d60af3897d573a782328d 100644 --- a/src/server/vms/vm.go +++ b/src/server/vms/vm.go @@ -6,17 +6,12 @@ import ( "path" "errors" "strings" - "syscall" "net/mail" - "io/ioutil" "path/filepath" "encoding/json" - "encoding/base64" vmc "nexus-common/vm" "nexus-common/caps" - "nexus-server/qga" "nexus-server/exec" - "nexus-server/paths" "github.com/google/uuid" "github.com/go-playground/validator/v10" "github.com/sethvargo/go-password/password" @@ -31,7 +26,7 @@ type ( dir string // VM directory qgaSock string // QEMU Guest Agent (QGA) UNIX socket Run runStates - DiskBusy bool // When true, the disk image must not be modified + DiskBusy bool // When true, the disk image must not be modified (e.g. write access) mutex *sync.Mutex } @@ -59,7 +54,8 @@ var passwordGen, _ = password.NewGenerator(&password.GeneratorInput{ Digits: "123456789", }) -// Creates a VM. +// Creates a VM and returns a pointer to it. +// Concurrency: safe func NewVM(creatorEmail string, name string, cpus, ram int, nic vmc.NicType, usbDevs []string, templateID uuid.UUID, owner string) (*VM, error) { vmID, err := uuid.NewRandom() @@ -88,6 +84,8 @@ func NewVM(creatorEmail string, name string, cpus, ram int, nic vmc.NicType, usb return vm, nil } +// Returns a serialized version (to be written to disk) of the template. +// Concurrency: safe func (vm *VM)SerializeToDisk() vmc.VMDiskSerialized { return vmc.VMDiskSerialized { ID: vm.v.ID, @@ -102,6 +100,8 @@ func (vm *VM)SerializeToDisk() vmc.VMDiskSerialized { } } +// Returns a serialized version (to be sent to the network) of the template. +// Concurrency: safe func (vm *VM)SerializeToNetwork() vmc.VMNetworkSerialized { return vmc.VMNetworkSerialized { ID: vm.v.ID, @@ -120,30 +120,45 @@ func (vm *VM)SerializeToNetwork() vmc.VMNetworkSerialized { } } +// Returns a VM's ID. +// Concurrency: safe func (vm *VM)GetID() uuid.UUID { return vm.v.ID } +// Returns a VM's access. +// Concurrency: safe func (vm *VM)GetAccess() map[string]caps.Capabilities { return vm.v.Access } // Returns true if the specified email is the VM's owner. +// Concurrency: safe func (vm *VM)IsOwner(email string) bool { return email == vm.v.Owner } +// Returns true if the VM is running. +// Concurrency: safe func (vm *VM)IsRunning() bool { return vm.Run.State == vmc.StateRunning } +// Returns true if the VM's disk is being modified (e.g. written). +// Concurrency: safe +func (vm *VM)IsDiskBusy() bool { + return vm.DiskBusy +} + // Returns the absolute path to the VM's disk image. +// Concurrency: safe func (vm *VM)getDiskPath() string { path, _ := filepath.Abs(filepath.Join(vm.dir, vmDiskFile)) return path } -// Creates an empty VM. +// Creates an empty VM and returns a pointer to it. +// Concurrency: safe func newEmptyVM() *VM { return &VM { v: vmc.VMDiskSerialized { @@ -165,7 +180,8 @@ func newEmptyVM() *VM { } } -// Creates a VM from a vmConfFile and returns it. +// Creates a VM from a configuration file (json) and returns a pointer to it. +// Concurrency: safe func newVMFromFile(vmFile string) (*VM, error) { filein, err := os.OpenFile(vmFile, os.O_RDONLY, 0) if err != nil { @@ -191,6 +207,8 @@ func newVMFromFile(vmFile string) (*VM, error) { } // Checks that the VM structure's fields are valid. +// Returns an error if they are not. +// Concurrency: safe func (vm *VM)validate() error { // Checks the capabilities are valid for email, accessCaps := range vm.v.Access { @@ -224,10 +242,11 @@ func (vm *VM)validate() error { return nil } -// Writes a VM's files: -// 1) Creates the 3-directory structure. -// 2) Writes vmConfFile. -// 3) Creates vmDiskFile as an overlay on top of the template disk. +// Creates the VM's directory and writes its files: +// 1) Creates the 3-directory structure based on the VM's ID (UUID). +// 2) Writes the VM configuration file (json). +// 3) Creates the VM disk image as an overlay on top of the template disk image. +// Concurrency: unsafe func (vm *VM)writeFiles() error { // Checks the template referenced by the VM exists. template, err := GetTemplatesInstance().GetTemplate(vm.v.TemplateID) @@ -237,8 +256,9 @@ func (vm *VM)writeFiles() error { // Creates the 3-directory structure. if err = os.MkdirAll(vm.dir, 0750); err != nil { - log.Error("Failed creating VM dirs: "+err.Error()) - return errors.New("Failed creating VM dirs: "+err.Error()) + msg := "Failed creating VM dirs: "+err.Error() + log.Error(msg) + return errors.New(msg) } // Writes vmConfFile. @@ -247,7 +267,7 @@ func (vm *VM)writeFiles() error { } // Creates vmDiskFile as an overlay on top of the template disk. - // NOTE: template and output file must be both specified as absolute paths. + // REMARK: template and output file must be both specified as absolute paths. templateDiskFile := template.GetTemplateDiskPath() if err := exec.QemuImgCreate(templateDiskFile, vm.getDiskPath()); err != nil { @@ -258,38 +278,33 @@ func (vm *VM)writeFiles() error { return nil } -// Writes the VM config file (vmConfFile). -// NOTE: does not check the template's validity! +// Writes the VM config file (json). +// REMARK: does not check whether the template is valid or not. +// Concurrency: unsafe func (vm *VM)writeConfig() error { vmFile := filepath.Join(vm.dir, vmConfFile) file, err := os.Create(vmFile) if err != nil { - log.Error("Failed writing VM config file: "+err.Error()) - return errors.New("Failed writing VM config file: "+err.Error()) + msg := "Failed writing VM config file: "+err.Error() + log.Error(msg) + return errors.New(msg) } defer file.Close() encoder := json.NewEncoder(file) encoder.SetIndent("", " ") if err = encoder.Encode(vm.SerializeToDisk()); err != nil { - log.Error("Failed encoding VM config file: "+err.Error()) - return errors.New("Failed encoding VM config file: "+err.Error()) + msg := "Failed encoding VM config file: "+err.Error() + log.Error(msg) + return errors.New(msg) } return nil } -// Deletes the files associated to a VM. -// The VM must be stopped before being deleted, otherwise an error is returned. +// Deletes all files associated to a VM. +// Concurrency: unsafe func (vm *VM)delete() error { - if vm.IsRunning() { - return errors.New("Failed deleting VM: VM must be stopped") - } - - if vm.DiskBusy { - return errors.New("Failed deleting VM: disk is busy") - } - // Deletes the VM's directory and its content. if err := os.RemoveAll(vm.dir); err != nil { log.Error("Failed deleting VM files: "+err.Error()) @@ -305,169 +320,9 @@ func (vm *VM)delete() error { return nil } -// Starts a VM on the given port with the given password. -func (vm *VM)start(port int, pwd string, endofExecFn endOfExecCallback) error { - if vm.DiskBusy { - return errors.New("Failed starting VM: disk is busy") - } - - // Writes the password in a "secret" file. - pwdFile, err := vm.writeSecretFile(pwd) - if err != nil { - log.Error("Failed starting VM: error creating secret file: "+err.Error()) - return errors.New("Failed starting VM: error creating secret file: "+err.Error()) - } - - if err = vm.runQEMU(port, pwd, pwdFile, endofExecFn); err != nil { - vm.removeSecretFile() - os.Remove(vm.qgaSock) // If QEMU fails it's likely the Guest Agent file it created is still there. - return errors.New("Failed starting VM: error running QEMU: "+err.Error()) - } - - return nil -} - -// Writes the specified password in Base64 in a "secret" file inside the VM directory. -// Returns the file that was written if success. -func (vm *VM)writeSecretFile(pwd string) (string, error) { - // Write the password in a "secret" file inside the VM directory. - pwdBase64 := base64.StdEncoding.EncodeToString([]byte(pwd)) - content := []byte(pwdBase64) - pwdFile := filepath.Join(vm.dir, vmSecretFile) - if err := ioutil.WriteFile(pwdFile, content, 0600); err != nil { - return "", err - } - return pwdFile, nil -} - -func (vm *VM)removeSecretFile() { - pwdFile := filepath.Join(vm.dir, vmSecretFile) - os.Remove(pwdFile) -} - -// Kills a running VM. -func (vm *VM)kill() error { - if !vm.IsRunning() { - return errors.New("Failed stopping VM: VM is not running") - } - - // Sends a SIGINT signal to terminate the QEMU process. - // Note that QEMU terminates with status code 0 in this case (i.e. no error). - if err := syscall.Kill(vm.Run.Pid, syscall.SIGTERM); err != nil { - log.Error("Failed stopping VM: "+err.Error()) - return errors.New("Failed stopping VM: "+err.Error()) - } - - return nil -} - -// Gracefully shutdowns a running VM. -// Uses QGA commands to talk to the guest OS, which means QEMU Guest Agent must be -// running in the guest, otherwise nothing will happen. Furthermore, the guest OS must -// already be running and ready to accept QGA commands. -func (vm *VM)shutdown() error { - prefix := "Shutdown failed: " - - if !vm.IsRunning() { - return errors.New(prefix+"VM is not running") - } - - if vm.DiskBusy { - return errors.New(prefix+"VM disk is busy") - } - - // Sends a QGA command to order the VM to shutdown. - con := qga.New() - if err := con.Open(vm.qgaSock); err != nil { - log.Error(prefix+"(open): "+err.Error()) - return errors.New(prefix+"(open): "+err.Error()) - } - - if err := con.SendShutdown(); err != nil { - con.Close() - log.Error(prefix+"(send): "+err.Error()) - return errors.New(prefix+"(send): "+err.Error()) - } - - if err := con.Close(); err != nil { - log.Error(prefix+"(close): "+err.Error()) - return errors.New(prefix+"(close): "+err.Error()) - } - - return nil -} - -// Reboots a running VM. -// Uses QGA commands to talk to the VM, which means QEMU Guest Agent must be -// running in the VM, otherwise it won't work. -func (vm *VM)reboot() error { - prefix := "Shutdown failed: " - - if !vm.IsRunning() { - return errors.New(prefix+"VM is not running") - } - - if vm.DiskBusy { - return errors.New(prefix+"VM disk is busy") - } - - // Sends a QGA command to order the VM to shutdown. - con := qga.New() - if err := con.Open(vm.qgaSock); err != nil { - log.Error(prefix+"(open): "+err.Error()) - return errors.New(prefix+"(open): "+err.Error()) - } - - if err := con.SendReboot(); err != nil { - con.Close() - log.Error(prefix+"(send): "+err.Error()) - return errors.New(prefix+"(send): "+err.Error()) - } - - if err := con.Close(); err != nil { - log.Error(prefix+"(close): "+err.Error()) - return errors.New(prefix+"(close): "+err.Error()) - } - - return nil -} - -// Executes the VM in QEMU using the specified spice port and password. -func (vm *VM)runQEMU(port int, pwd, pwdFile string, endofExecFn endOfExecCallback) error { - pkiDir := paths.GetInstance().NexusPkiDir - cmd, err := exec.NewQemuSystem(vm.qgaSock, vm.v.Cpus, vm.v.Ram, string(vm.v.Nic), vm.v.UsbDevs, filepath.Join(vm.dir, vmDiskFile), port, pwdFile, pkiDir) - if err != nil { - return err - } - - if err := cmd.Start(); err != nil { - log.Error("Failed executing VM "+vm.v.ID.String()+": exec.Start error: "+err.Error()) - log.Error("Failed cmd: "+cmd.String()) - return err - } - - vm.Run = runStates { State: vmc.StateRunning, Pid: cmd.Process.Pid, Port: port, Pwd: pwd } - - // Execute cmd.Wait() (which is a blocking call) inside a go-routine to avoid blocking. - // From here on, there are 2 flows of execution! - go func() { - if err := cmd.Wait(); err != nil { - log.Error("Failed executing VM "+vm.v.ID.String()+": exec.Wait error: "+err.Error()) - log.Error("Failed cmd: "+cmd.String()) - } - endofExecFn(vm) - } () - - return nil -} - -// Resets a VM's states. -func (vm *VM)resetStates() { - vm.Run = runStates { State: vmc.StateStopped, Pid: 0, Port: 0, Pwd: "" } -} - // Validates and convert a string of USB devices of the form "1fc9:001d,067b:2303" // into a slice of string where each element is a string of the form "1fc9:001d". +// Concurrency: safe func validateUsbDevs(devs []string) error { for _, dev := range devs { ids := strings.Split(dev, ":") // Extracts vendorID (vid) and productID (pid) @@ -484,6 +339,7 @@ func validateUsbDevs(devs []string) error { // A USB ID is either a vendor ID or a product ID. // It's a string containing exactly 4 hexadecimal digits. +// Concurrency: safe func isUsbId(s string) (bool) { if len(s) != 4 { return false diff --git a/src/server/vms/vms.go b/src/server/vms/vms.go index f925002a09f64ae502efe421e453e1105147ccad..b77fa11519a4e001f3f625c70f01026a0012a5b9 100644 --- a/src/server/vms/vms.go +++ b/src/server/vms/vms.go @@ -6,8 +6,12 @@ import ( "sync" "math" "errors" + "syscall" + "io/ioutil" "path/filepath" - "nexus-common/vm" + "encoding/base64" + vmc "nexus-common/vm" + "nexus-server/qga" "nexus-common/caps" "nexus-common/params" "nexus-server/exec" @@ -34,14 +38,16 @@ type ( var log = logger.GetInstance() var vms *VMs -// Returns a VMs "singleton". +// Returns a VMs singleton to access this module's public functions. // IMPORTANT: the InitVMs function must have been previously called! +// Concurrency: safe func GetVMsInstance() *VMs { return vms } // Creates all VMs from their files on disk. -// NOTE: path is the root directory where VMs reside. +// REMARK: path is the root directory where VMs reside. +// Concurrency: unsafe func InitVMs() error { vmsDir := paths.GetInstance().VMsDir vms = &VMs { m: make(map[string]*VM), dir: vmsDir, rwlock: new(sync.RWMutex), usedRAM: 0 } @@ -89,11 +95,12 @@ func InitVMs() error { return nil } -// Returns the list of serialized VMs for which VMKeeperFn returns true. -func (vms *VMs)GetNetworkSerializedVMs(keepFn VMKeeperFn) []vm.VMNetworkSerialized { +// Returns the list of serialized VMs for which VMKeeperFn is true. +// Concurrency: safe +func (vms *VMs)GetNetworkSerializedVMs(keepFn VMKeeperFn) []vmc.VMNetworkSerialized { vms.rwlock.RLock() - list := []vm.VMNetworkSerialized{} + list := []vmc.VMNetworkSerialized{} for _, vm := range vms.m { vm.mutex.Lock() if keepFn(vm) { @@ -110,13 +117,16 @@ func (vms *VMs)GetNetworkSerializedVMs(keepFn VMKeeperFn) []vm.VMNetworkSerializ return list } -// Returns a VM by its ID. +// Returns a VM by its ID. Concurrency-safe version. +// Concurrency: safe func (vms *VMs)GetVM(vmID uuid.UUID) (*VM, error) { vms.rwlock.RLock() defer vms.rwlock.RUnlock() return vms.getVMUnsafe(vmID) } +// Returns a VM by its ID. Concurrency-unsafe version. +// Concurrency: unsafe func (vms *VMs)getVMUnsafe(vmID uuid.UUID) (*VM, error) { vm, exists := vms.m[vmID.String()] if !exists { @@ -125,7 +135,8 @@ func (vms *VMs)getVMUnsafe(vmID uuid.UUID) (*VM, error) { return vm, nil } -// Deletes a VM by its ID and deletes its files. +// Deletes a VM by its ID and deletes its associated files. +// Concurrency: safe func (vms *VMs)DeleteVM(vmID uuid.UUID) error { vms.rwlock.Lock() defer vms.rwlock.Unlock() @@ -136,26 +147,28 @@ func (vms *VMs)DeleteVM(vmID uuid.UUID) error { } vm.mutex.Lock() + defer vm.mutex.Unlock() if vm.IsRunning() { - vm.mutex.Unlock() - return errors.New("VM must be stopped") + return errors.New("Failed deleting VM: VM must be stopped") + } + + if vm.IsDiskBusy() { + return errors.New("Failed deleting VM: disk in use (busy)") } // Deletes the VM's files (and directories). if err := vm.delete(); err != nil { - vm.mutex.Unlock() return err } - vm.mutex.Unlock() - // Removes the VM from the map. delete(vms.m, vmID.String()) return nil } -// Adds a VM and writes its files. +// Adds a new VM and writes its associated files. +// Concurrency: safe func (vms *VMs)AddVM(vm *VM) error { vm.mutex.Lock() defer vm.mutex.Unlock() @@ -178,7 +191,10 @@ func (vms *VMs)AddVM(vm *VM) error { // Starts a VM by its ID, using the specified port and password. // If checkPort is true, a check is performed on the specified port and if it is already // in use, the function fails and returns a corresponding error. +// Concurrency: safe func (vms *VMs)StartVMWithCreds(vmID uuid.UUID, port int, checkPort bool, pwd string) error { + prefix := "Failed starting VM: " + vms.rwlock.Lock() defer vms.rwlock.Unlock() @@ -191,28 +207,27 @@ func (vms *VMs)StartVMWithCreds(vmID uuid.UUID, port int, checkPort bool, pwd st defer vm.mutex.Unlock() if vm.IsRunning() { - return errors.New("Failed starting VM: VM already running") + return errors.New(prefix+"already running") + } + + if vm.IsDiskBusy() { + return errors.New(prefix+"disk in use (busy)") } if checkPort { if vms.usedPorts[port] { - return errors.New("Failed starting VM: port already in use") + return errors.New(prefix+"port already in use") } else if !utils.IsPortAvailable(port) { - return errors.New("Failed starting VM: port not available") - } else { - vms.usedPorts[port] = true + return errors.New(prefix+"port not available") } - } else { - vms.usedPorts[port] = true } totalRAM, availRAM, err := utils.GetRAM() if err != nil { - vms.usedPorts[port] = false - return errors.New("Failed starting VM: failed obtaining memory info: "+err.Error()) + return errors.New(prefix+"failed obtaining memory info: "+err.Error()) } - // We estimate that KVM allows for ~30% RAM saving (due to page sharing across VMs). + // We estimate ~30% of RAM saving thanks to KSM (due to page sharing across VMs). estimatedVmRAM := int(math.Round(float64(vm.v.Ram)*(1.-c.KsmRamSaving))) vms.usedRAM += estimatedVmRAM @@ -221,32 +236,96 @@ func (vms *VMs)StartVMWithCreds(vmID uuid.UUID, port int, checkPort bool, pwd st // otherwise, refuses to run it in order to avoid RAM saturation. if availRAM - vms.usedRAM <= int(math.Round(float64(totalRAM)*(1.-c.RamUsageLimit))) { vms.usedRAM -= estimatedVmRAM - vms.usedPorts[port] = false - return errors.New("Failed starting VM: insufficient free RAM") + return errors.New(prefix+"insufficient free RAM") + } + + // Function that executes the VM in QEMU using the specified spice port and password. + runQemuFn := func(vm *VM, port int, pwd, pwdFile string, endofExecFn endOfExecCallback) error { + pkiDir := paths.GetInstance().NexusPkiDir + cmd, err := exec.NewQemuSystem(vm.qgaSock, vm.v.Cpus, vm.v.Ram, string(vm.v.Nic), vm.v.UsbDevs, filepath.Join(vm.dir, vmDiskFile), port, pwdFile, pkiDir) + if err != nil { + log.Error(prefix+"filepath join error: "+err.Error()) + return err + } + + if err := cmd.Start(); err != nil { + log.Error(prefix+vm.v.ID.String()+": exec.Start error: "+err.Error()) + log.Error("Failed cmd: "+cmd.String()) + return err + } + + vm.Run = runStates { State: vmc.StateRunning, Pid: cmd.Process.Pid, Port: port, Pwd: pwd } + vm.DiskBusy = true + + // Go routine that executes cmd.Wait() which is a blocking call (hence the go routine). + // From here on, there are 2 flows of execution! + go func() { + if err := cmd.Wait(); err != nil { + log.Error(prefix+vm.v.ID.String()+": exec.Wait error: "+err.Error()) + log.Error("Failed cmd: "+cmd.String()) + } + endofExecFn(vm) + } () + + return nil + } + + // Function that deletes a VM's secret file. + removeSecretFileFn := func(vm *VM) { + pwdFile := filepath.Join(vm.dir, vmSecretFile) + os.Remove(pwdFile) } - // This callback is called once the VM started with vm.start terminates. - endofExecFn := func (vm *VM) { + // Function that starts a VM on the given port with the given password. + // endofExecFn is called once the VM's execution is over. + startFn := func(vm *VM, port int, pwd string, endofExecFn endOfExecCallback) error { + // Writes the password in Base64 in a secret file inside the VM's directory. + pwdBase64 := base64.StdEncoding.EncodeToString([]byte(pwd)) + content := []byte(pwdBase64) + pwdFile := filepath.Join(vm.dir, vmSecretFile) + if err := ioutil.WriteFile(pwdFile, content, 0600); err != nil { + msg := prefix+"error creating secret file: "+err.Error() + log.Error(msg) + return errors.New(msg) + } + + if err = runQemuFn(vm, port, pwd, pwdFile, endofExecFn); err != nil { + removeSecretFileFn(vm) + os.Remove(vm.qgaSock) // If QEMU fails it's likely the Guest Agent file it created is still there. + return errors.New(prefix+"error running QEMU: "+err.Error()) + } + + return nil + } + + // Function that resets the VM's states, frees its port and deletes its secret file. + // Called once the VM's execution is over. + endofExecFn := func(vm *VM) { vms.rwlock.Lock() vms.usedPorts[port] = false vms.usedRAM -= estimatedVmRAM vms.rwlock.Unlock() vm.mutex.Lock() - vm.removeSecretFile() - vm.resetStates() + removeSecretFileFn(vm) + // Resets VM states and disk busy flag. + vm.Run = runStates { State: vmc.StateStopped, Pid: 0, Port: 0, Pwd: "" } + vm.DiskBusy = false vm.mutex.Unlock() } - if err = vm.start(port, pwd, endofExecFn); err != nil { + vms.usedPorts[port] = true + + if err = startFn(vm, port, pwd, endofExecFn); err != nil { vms.usedPorts[port] = false return err } + return nil } -// Allocates and returns a free port randomly chosen between VMSpiceMinPort and -// VMSpiceMaxPort (inclusive). -// Beware: this function updates the vms map. +// Allocates and returns a free port randomly chosen within [VMSpiceMinPort,VMSpiceMaxPort]. +// REMARK: this function updates the vms map. +// Concurrency: safe func (vms *VMs)allocateFreeRandomPort() int { vms.rwlock.Lock() defer vms.rwlock.Unlock() @@ -264,6 +343,7 @@ func (vms *VMs)allocateFreeRandomPort() int { // Starts a VM by its ID using randomly generated port number and password. // Returns the port on which the VM is running and the access password. +// Concurrency: safe func (vms *VMs)StartVM(vmID uuid.UUID) (int, string, error) { port := vms.allocateFreeRandomPort() @@ -271,8 +351,9 @@ func (vms *VMs)StartVM(vmID uuid.UUID) (int, string, error) { // allowing upper and lower case letters, disallowing repeat characters. pwd, err := passwordGen.Generate(c.VMPwdLength, c.VMPwdDigitCount, c.VMPwdSymbolCount, false, c.VMPwdRepeatChars) if err != nil { - log.Error("Failed starting VM: password generation error: "+err.Error()) - return -1, "", errors.New("Failed starting VM: password generation error: "+err.Error()) + msg := "Failed starting VM: password generation error: "+err.Error() + log.Error(msg) + return -1, "", errors.New(msg) } if err = vms.StartVMWithCreds(vmID, port, false, pwd); err != nil { @@ -282,6 +363,7 @@ func (vms *VMs)StartVM(vmID uuid.UUID) (int, string, error) { } // Kills a VM by its ID. +// Concurrency: safe func (vms *VMs)KillVM(vmID uuid.UUID) error { vms.rwlock.RLock() defer vms.rwlock.RUnlock() @@ -295,14 +377,22 @@ func (vms *VMs)KillVM(vmID uuid.UUID) error { defer vm.mutex.Unlock() if !vm.IsRunning() { - return errors.New("VM must be running") + return errors.New("Failed killing VM: VM must be running") } - err = vm.kill() - return err + // Sends a SIGINT signal to terminate the QEMU process. + // Note that QEMU terminates with status code 0 in this case (i.e. no error). + if err := syscall.Kill(vm.Run.Pid, syscall.SIGTERM); err != nil { + msg := "Failed killing VM: "+err.Error() + log.Error(msg) + return errors.New(msg) + } + + return nil } // Gracefully stops a VM by its ID. +// Concurrency: safe func (vms *VMs)ShutdownVM(vmID uuid.UUID) error { vms.rwlock.Lock() defer vms.rwlock.Unlock() @@ -316,14 +406,42 @@ func (vms *VMs)ShutdownVM(vmID uuid.UUID) error { defer vm.mutex.Unlock() if !vm.IsRunning() { - return errors.New("VM must be running") + return errors.New("Shutdown failed: VM must be running") } - err = vm.shutdown() - return err + // Function that gracefully shutdowns a running VM. + // Uses QGA commands to talk to the guest OS, which means QEMU Guest Agent must be + // running in the guest, otherwise nothing will happen. Furthermore, the guest OS must + // already be running and ready to accept QGA commands. + shutdownFn := func(vm *VM) error { + prefix := "Shutdown failed: " + + // Sends a QGA command to order the VM to shutdown. + con := qga.New() + if err := con.Open(vm.qgaSock); err != nil { + log.Error(prefix+"(open): "+err.Error()) + return errors.New(prefix+"(open): "+err.Error()) + } + + if err := con.SendShutdown(); err != nil { + con.Close() + log.Error(prefix+"(send): "+err.Error()) + return errors.New(prefix+"(send): "+err.Error()) + } + + if err := con.Close(); err != nil { + log.Error(prefix+"(close): "+err.Error()) + return errors.New(prefix+"(close): "+err.Error()) + } + + return nil + } + + return shutdownFn(vm) } // Reboots a VM by its ID. +// Concurrency: safe func (vms *VMs)RebootVM(vmID uuid.UUID) error { vms.rwlock.Lock() defer vms.rwlock.Unlock() @@ -337,14 +455,41 @@ func (vms *VMs)RebootVM(vmID uuid.UUID) error { defer vm.mutex.Unlock() if !vm.IsRunning() { - return errors.New("VM must be running") + return errors.New("Reboot failed: VM must be running") + } + + // Function that reboots a running VM. + // Uses QGA commands to talk to the VM, which means QEMU Guest Agent must be + // running in the VM, otherwise it won't work. + rebootFn := func(vm *VM) error { + prefix := "Reboot failed: " + + // Sends a QGA command to order the VM to shutdown. + con := qga.New() + if err := con.Open(vm.qgaSock); err != nil { + log.Error(prefix+"(open): "+err.Error()) + return errors.New(prefix+"(open): "+err.Error()) + } + + if err := con.SendReboot(); err != nil { + con.Close() + log.Error(prefix+"(send): "+err.Error()) + return errors.New(prefix+"(send): "+err.Error()) + } + + if err := con.Close(); err != nil { + log.Error(prefix+"(close): "+err.Error()) + return errors.New(prefix+"(close): "+err.Error()) + } + + return nil } - err = vm.reboot() - return err + return rebootFn(vm) } // Returns true if the given template is used by a VM. +// Concurrency: safe func (vms *VMs)IsTemplateUsed(templateID string) bool { vms.rwlock.RLock() defer vms.rwlock.RUnlock() @@ -361,6 +506,7 @@ func (vms *VMs)IsTemplateUsed(templateID string) bool { } // Edit a VM' specs: name, cpus, ram, nic +// Concurrency: safe func (vms *VMs)EditVM(vmID uuid.UUID, p *params.VMEdit) error { vms.rwlock.Lock() defer vms.rwlock.Unlock() @@ -373,10 +519,6 @@ func (vms *VMs)EditVM(vmID uuid.UUID, p *params.VMEdit) error { vm.mutex.Lock() defer vm.mutex.Unlock() - if vm.IsRunning() { - return errors.New("VM must be stopped") - } - // Only updates fields that have changed. oriVal := vm.v // Saves original VM values. @@ -412,6 +554,7 @@ func (vms *VMs)EditVM(vmID uuid.UUID, p *params.VMEdit) error { // Set a VM's Access for a given user (email). // user is the currently logged user // destUserEmail is the email of the user for which to modify the access +// Concurrency: safe func (vms *VMs)SetVMAccess(vmID uuid.UUID, user *users.User, destUserEmail string, newAccess caps.Capabilities) error { if err := caps.ValidateVMAccessCaps(newAccess); err != nil { return err @@ -429,10 +572,6 @@ func (vms *VMs)SetVMAccess(vmID uuid.UUID, user *users.User, destUserEmail strin vm.mutex.Lock() defer vm.mutex.Unlock() - if vm.IsRunning() { - return errors.New("VM must be stopped") - } - // If user has VM_SET_ACCESS_ANY, modify is allowed. if !user.HasCapability(caps.CAP_VM_SET_ACCESS_ANY) { // If user is the VM's owner, modify is allowed. @@ -458,6 +597,7 @@ func (vms *VMs)SetVMAccess(vmID uuid.UUID, user *users.User, destUserEmail strin // Remove a VM's Access for a given user (email). // user is the currently logged user // destUserEmail is the email of the user for which to modify the access +// Concurrency: safe func (vms *VMs)DeleteVMAccess(vmID uuid.UUID, user *users.User, destUserEmail string) error { vms.rwlock.Lock() defer vms.rwlock.Unlock() @@ -471,10 +611,6 @@ func (vms *VMs)DeleteVMAccess(vmID uuid.UUID, user *users.User, destUserEmail st vm.mutex.Lock() defer vm.mutex.Unlock() - if vm.IsRunning() { - return errors.New("VM must be stopped") - } - // If user has VM_SET_ACCESS_ANY, modify is allowed. if !user.HasCapability(caps.CAP_VM_SET_ACCESS_ANY) { // If user is the VM's owner, modify is allowed. @@ -503,31 +639,63 @@ func (vms *VMs)DeleteVMAccess(vmID uuid.UUID, user *users.User, destUserEmail st } // Exports a VM's directory and its subdirectories into a tar.gz archive on the host. +// Technically, extracting files from a running VM should work, but some files might be inconsistent. +// In consequence, we forbid this action on a running VM. +// Concurrency: safe func (vms *VMs)ExportVMFiles(vm *VM, vmDir, tarGzFile string) error { + prefix := "Failed exporting files from VM: " + vm.mutex.Lock() defer vm.mutex.Unlock() + + if vm.IsRunning() { + return errors.New(prefix+"VM must be stopped") + } + + if vm.IsDiskBusy() { + return errors.New(prefix+"disk in use (busy)") + } + + vm.DiskBusy = true + defer func(vm *VM) { vm.DiskBusy = false }(vm) + vmDisk := vm.getDiskPath() - return exec.CopyFromVM(vmDisk, vmDir, tarGzFile) + + if err := exec.CopyFromVM(vmDisk, vmDir, tarGzFile); err != nil { + msg := prefix+err.Error() + log.Error(msg) + return errors.New(msg) + } + + return nil } -// Imports files from a tar.gz archive into a VM's filesystem, in a specified directory. +// Imports files from a tar.gz archive into a VM disk image (guest's filesystem), in a specified directory. +// Concurrency: safe func (vms *VMs)ImportFilesToVM(vm *VM, tarGzFile, vmDir string) error { + prefix := "Failed importing files into VM: " + vm.mutex.Lock() defer vm.mutex.Unlock() if vm.IsRunning() { - return errors.New("VM must be stopped") + return errors.New(prefix+"VM must be stopped") } - // Marks VM as "busy", meaning its disk file is being accessed for a possibly long time. - if vm.DiskBusy { - return errors.New("Cannot import files into VM: disk is already busy") + if vm.IsDiskBusy() { + return errors.New(prefix+"disk in use (busy)") } - vm.DiskBusy = true - // Clears the VM from being busy. + vm.DiskBusy = true defer func(vm *VM) { vm.DiskBusy = false }(vm) vmDisk := vm.getDiskPath() - return exec.CopyToVM(vmDisk, tarGzFile, vmDir) + + if err := exec.CopyToVM(vmDisk, tarGzFile, vmDir); err != nil { + msg := prefix+err.Error() + log.Error(msg) + return errors.New(msg) + } + + return nil }