Skip to content
Snippets Groups Projects
Select Git revision
  • 2f2a1bdc26ec48b6dd60aa7ac50c7759010e60bb
  • live_exam_os_ubuntu default protected
2 results

templateDel.go

Blame
  • vm.go 12.40 KiB
    package vms
    
    import (
    	"os"
    	"sync"
    	"path"
    	"errors"
    	"syscall"
    	"net/mail"
    	"io/ioutil"
    	"path/filepath"
    	"encoding/json"
    	"encoding/base64"
    	"nexus-server/qga"
    	"nexus-server/caps"
    	"nexus-server/paths"
    	"nexus-server/exec"
    	"github.com/google/uuid"
    	"github.com/go-playground/validator/v10"
    	"github.com/sethvargo/go-password/password"
    )
    
    type (
    	VM struct {
    		ID uuid.UUID          `json:"id"         validate:"required"`
    		Name string           `json:"name"       validate:"required,min=2,max=256"`
    		Cpus int              `json:"cpus"       validate:"required,gte=1,lte=16"`
    		Ram int               `json:"ram"        validate:"required,gte=512,lte=32768"`
    		Nic NicType           `json:"nic"        validate:"required"`  // "none" or "user"
    		TemplateID uuid.UUID  `json:"templateID" validate:"required"`
    		Access map[string]caps.Capabilities `json:"access"  validate:"required"`
    
    		// None of the fields below are serialized to disk.
    		dir string             // VM directory
    		qgaSock string         // QEMU Guest Agent (QGA) UNIX socket
    		Run runStates
    		DiskBusy bool          // If true the VM's disk is busy (cannot be modified or deleted)
    		mutex *sync.Mutex
    	}
    
    	runStates struct {
    		State VMState
    		Pid int
    		Port int
    		Pwd string
    	}
    
    	VMState string
    	NicType string
    
    	endOfExecCallback func(vm *VM)
    )
    
    const (
    	vmDiskFile     = "disk.qcow"
    	vmConfFile     = "vm.json"
    	vmSecretFile   = "secret"
    	vmQGASockFile  = "qga.sock"
    
    	nicUser NicType = "user"
    	nicNone NicType = "none"
    
    	stateStopped VMState  = "STOPPED"
    	stateRunning          = "RUNNING"
    )
    
    var dummyVM = VM{}
    
    // Custom password generator with the following characters removed: 0, O, l, I
    var passwordGen, _ = password.NewGenerator(&password.GeneratorInput{
    	LowerLetters: "abcdefghijkmnopqrstuvwxyz",
    	UpperLetters: "ABCDEFGHJKLMNPQRSTUVWXYZ",
    	Digits: "123456789",
    })
    
    // Creates a VM.
    func NewVM(creatorEmail string, caps caps.Capabilities, name string, cpus, ram int, nic NicType, templateID uuid.UUID) (*VM, error) {
    	vmID, err := uuid.NewRandom()
    	if err != nil {
    		log.Error("Failed creating VM: "+err.Error())
    		return nil, err
    	}
    	vm := newEmptyVM()
    	vm.ID = vmID
    	vm.Name = name
    	vm.Cpus = cpus
    	vm.Ram = ram
    	vm.Nic = nic
    	vm.TemplateID = templateID
    	id := vmID.String()
    	vm.dir = filepath.Join(vms.dir, id[0:3], id[3:6], id)
    	vm.qgaSock = filepath.Join(vm.dir, vmQGASockFile)
    	vm.Access[creatorEmail] = caps
    
    	if err = vm.validate(); err != nil {
    		return nil, errors.New("Failed validating VM: "+err.Error())
    	}
    
    	return vm, nil
    }
    
    func (vm *VM)getDiskPath() string {
    	vmDiskFile, _ := filepath.Abs(filepath.Join(vm.dir, vmDiskFile))
    	return vmDiskFile
    }
    
    // Creates an empty VM.
    func newEmptyVM() *VM {
    	return &VM {
    		ID: uuid.Nil,
    		Name: "",
    		Cpus: 0,
    		Ram: 0,
    		Nic: "",
    		TemplateID: uuid.Nil,
    		Access: make(map[string]caps.Capabilities),
    
    		dir: "",
    		qgaSock: "",
    		Run: runStates { State: stateStopped, Pid: 0, Port: 0, Pwd: "" },
    		DiskBusy: false,
    		mutex: new(sync.Mutex),
    	}
    }
    
    // Creates a VM from a vmConfFile and returns it.
    func newVMFromFile(vmFile string) (*VM, error) {
    	filein, err := os.OpenFile(vmFile, os.O_RDONLY, 0)
    	if err != nil {
            return nil, errors.New("Failed reading VM config file: "+err.Error())
    	}
    	defer filein.Close()
    
    	// Decodes and validates the json file 
    	decoder := json.NewDecoder(filein)
    	decoder.DisallowUnknownFields()
    	vm := newEmptyVM()
    	if err = decoder.Decode(&vm); err != nil {
    		return nil, errors.New("Failed decoding VM config file: "+err.Error())
    	}
    
    	if err = vm.validate(); err != nil {
    		return nil, errors.New("Failed validating VM: "+err.Error())
    	}
    
    	vm.dir = path.Dir(vmFile)
    	vm.qgaSock = filepath.Join(vm.dir, vmQGASockFile)
    	return vm, nil
    }
    
    func (vm *VM)IsRunning() bool {
    	return vm.Run.State == stateRunning
    }
    
    // Checks that the VM structure's fields are valid.
    func (vm *VM)validate() error {
    	// Checks the capabilities are valid
    	for email, accessCaps := range vm.Access {
    		_, err := mail.ParseAddress(email)
    		if err != nil {
    			return errors.New("Invalid email")
    		}
    		if !caps.AreVMAccessCapsValid(accessCaps) {
    			return errors.New("Invalid capability")
    		}
    	}
    
    	if (vm.Nic != nicNone && vm.Nic != nicUser) {
    		return errors.New("Invalid nic value: "+string(vm.Nic))
    	}
    
    	if err := validator.New().Struct(vm); err != nil {
    		return err
    	}
    
    	// Checks that the template referenced by the VM actually exists.
    	_, err := GetTemplatesInstance().GetTemplate(vm.TemplateID)
    	if err != nil {
    		return err
    	}
    
    	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.
    func (vm *VM)writeFiles() error {
    	// Checks the template referenced by the VM exists.
    	template, err := GetTemplatesInstance().GetTemplate(vm.TemplateID)
    	if err != nil {
    		return err
    	}
    
    	// 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())
    	}
    
    	// Writes vmConfFile.
    	if err = vm.writeConfig(); err != nil {
    		return err
    	}
    
    	// Creates vmDiskFile as an overlay on top of the template disk.
    	// NOTE: template and output file must be both specified as absolute paths.
    	templateDiskFile, _ := filepath.Abs(filepath.Join(GetTemplatesInstance().getDir(), template.ID.String(), templateDiskFile))
    
    	if err := exec.QemuImgCreate(templateDiskFile, vm.getDiskPath()); err != nil {
    		vm.delete()
    		return err
    	}
    
    	return nil
    }
    
    // Writes the VM config file (vmConfFile).
    // NOTE: does not check the template's validity!
    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())
    	}
    	defer file.Close()
    	encoder := json.NewEncoder(file)
    	encoder.SetIndent("", "    ")
    
    	// Only serializes the following fields.
    	type VMConf struct {
    		ID uuid.UUID           `json:"id"`
    		Name string            `json:"name"`
    		Cpus int               `json:"cpus"`
    		Ram int                `json:"ram"`
    		Nic NicType            `json:"nic"`
    		TemplateID uuid.UUID   `json:"templateID"`
    		Access map[string]caps.Capabilities `json:"access"`
    	}
    	vmConf := VMConf{ vm.ID, vm.Name, vm.Cpus, vm.Ram, vm.Nic, vm.TemplateID, vm.Access }
    	if err = encoder.Encode(vmConf); err != nil {
    		log.Error("Failed encoding VM config file: "+err.Error())
    		return errors.New("Failed encoding VM config file: "+err.Error())
    	}
    
    	return nil
    }
    
    // Deletes the files associated to a VM.
    // The VM must be stopped before being deleted, otherwise an error is returned.
    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())
            return errors.New("Failed deleting VM files: "+err.Error())
    	}
    
    	// Deletes parents directories if they are empty.
    	// Directories are only deleted if empty (otherwise an error is triggered which we ignore).
    	parentDir := path.Dir(vm.dir)
    	parentParentDir := path.Dir(parentDir)
    	os.Remove(parentDir)
    	os.Remove(parentParentDir)
    	return nil
    }
    
    // Starts a VM and returns the access password.
    // Password is randomly generated.
    func (vm *VM)start(port int, endofExecFn endOfExecCallback) (string, error) {
    	if vm.IsRunning() {
    		return "", errors.New("Failed starting VM: VM is already running")
    	}
    
    	if vm.DiskBusy {
    		return "", errors.New("Failed starting VM: disk is busy")
    	}
    
    	// Generates a 8 characters long password with 4 digits, 0 symbols,
    	// allowing upper and lower case letters, disallowing repeat characters.
    	pwd, err := passwordGen.Generate(8, 4, 0, false, false)
    	if err != nil {
    		log.Error("Failed starting VM, password generation error: "+err.Error())
    		return "", errors.New("Failed starting VM, password generation error: "+err.Error())
    	}
    
    	// 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 pwd, 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 by force 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.SIGINT); 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 VM, which means QEMU Guest Agent must be
    // running in the VM, otherwise it won't work.
    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.Cpus, vm.Ram, string(vm.Nic), 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.ID.String()+": exec.Start error: "+err.Error())
    		log.Error("Failed cmd: "+cmd.String())
    		return err
    	}
    
    	vm.Run = runStates { State: 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.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: stateStopped, Pid: 0, Port: 0, Pwd: "" }
    }