Select Git revision
templateDel.go
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: "" }
}