vorteil/vorteil

View on GitHub
pkg/virtualizers/virtualbox/virtualizer.go

Summary

Maintainability
D
1 day
Test Coverage
F
4%
package virtualbox

/**
 * SPDX-License-Identifier: Apache-2.0
 * Copyright 2020 vorteil.io Pty Ltd
 */

import (
    "bytes"
    "context"
    "errors"
    "fmt"
    "net"
    "os"
    "os/exec"
    "path/filepath"
    "runtime"
    "strconv"
    "strings"
    "sync"
    "time"

    "github.com/thanhpk/randstr"
    "github.com/vorteil/vorteil/pkg/elog"
    "github.com/vorteil/vorteil/pkg/vcfg"
    "github.com/vorteil/vorteil/pkg/vio"
    "github.com/vorteil/vorteil/pkg/virtualizers"
    logger "github.com/vorteil/vorteil/pkg/virtualizers/logging"
    "github.com/vorteil/vorteil/pkg/virtualizers/util"
)

// Virtualizer is a struct which will implement the interface so the manager can create VMs
type Virtualizer struct {
    id            string         // rando has for named pipes and folder names
    name          string         // name of vm
    pname         string         // name of virtualizer
    source        interface{}    // details about how the vm was made
    state         string         // status of vm
    headless      bool           // to display gui or not
    created       time.Time      // time the vm was created
    networkType   string         // type of network to spawn on
    networkDevice string         // type of network device to use
    folder        string         // folder to store vm details
    disk          *os.File       // disk of the machine
    serialLogger  *logger.Logger // serial logger for serial output of app
    logger        elog.View      // logger for the CLI
    // subServer *graph.Graph
    routes []virtualizers.NetworkInterface // api network interface that displays ports
    config *vcfg.VCFG                      // config for the vm
    sock   net.Conn                        // Connection to listen to for serial output

    vmdrive string // store disks in this directory

}

// Type returns the type of virtualizer
func (v *Virtualizer) Type() string {
    return VirtualizerID
}

// State returns the state of the virtualizer
func (v *Virtualizer) State() string {
    return v.state
}

// getState fetches the state to maintain a polling request incase it gets cleaned up from a different gui
func (v *Virtualizer) getState() (string, error) {
    cmd := exec.Command("VBoxManage", "showvminfo", v.name)

    stdout := new(bytes.Buffer)
    cmd.Stdout = stdout

    err := v.execute(cmd)
    if err != nil {
        if strings.Contains(err.Error(), "Could not find a registered machine named") || strings.Contains(err.Error(), "The object is not ready") {
            return "", nil
        }
        return "", err
    }

    stdoutStr := stdout.String()
    if len(stdoutStr) == 0 {
        return "", fmt.Errorf("failed to get stdout from state check")
    }

    var parsedState string
    lines := strings.Split(stdoutStr, "\n")
    for _, l := range lines {
        if strings.HasPrefix(l, "State:") {
            parsedState = strings.TrimSpace(strings.Split(strings.TrimPrefix(l, "State:"), "(")[0])
            break
        }
    }
    return parsedState, nil
}

// Stop stops the virtual machine
func (v *Virtualizer) Stop() error {
    v.logger.Debugf("Stopping VM")
    if v.state != virtualizers.Ready {
        v.state = virtualizers.Changing
        err := v.execute(exec.Command("VBoxManage", "controlvm", v.name, "acpipowerbutton"))
        if err != nil {
            if !strings.Contains(err.Error(), "100%") {
                return err
            }
        }
        count := 0
        // wait till in powered off state
        for {
            state, err := v.getState()
            if err != nil {
                return err
            }
            if state == "powered off" {
                break
            }
            if count > 10 {
                v.state = virtualizers.Broken
                v.logger.Errorf("Unable to stop virtual machine within 10 seconds powering off...")
                err = v.ForceStop()
                if err != nil {
                    return err
                }
                break
            }
            count++
            time.Sleep(time.Second * 1)
        }
        v.state = virtualizers.Ready

    }
    return nil
}

// Start starts the virtual machine
func (v *Virtualizer) Start() error {
    v.logger.Debugf("Starting VM")
    switch v.State() {
    case "ready":
        v.state = virtualizers.Changing
        // This needs to be routined as its waiting for the pipe to start
        go v.initLogging()
        // go func() {
        args := "gui"
        if v.headless {
            args = "headless"
        }
        var startVM func() error
        startVM = func() error {
            cmd := exec.Command("VBoxManage", "startvm", v.name, "--type", args)
            err := v.execute(cmd)
            if err != nil {
                // Return function to retry as the machine is not ready yet
                if strings.Contains(err.Error(), "is already locked by a session (or being locked or unlocked)") {
                    return startVM()
                }
                v.state = virtualizers.Broken
                return err
            }
            return nil

        }
        err := startVM()
        if err != nil {
            return err
        }
        if v.networkType != "nat" {
            go func() {
                v.routes = util.LookForIP(v.serialLogger, v.routes)
            }()
        }
        v.state = virtualizers.Alive
        // }()
    default:
        return fmt.Errorf("vm not in a state to be started currently in: %s", v.State())
    }
    return nil
}

// Download returns disk as file.File
func (v *Virtualizer) Download() (vio.File, error) {
    if !(v.state == virtualizers.Ready) {
        return nil, fmt.Errorf("virtual machine must be in state ready to be downloaded")
    }
    f, err := vio.LazyOpen(v.disk.Name())
    if err != nil {
        return nil, err
    }
    return f, nil
}

// ForceStop is only used when ctrl-cing the daemon as its the quickers way to unlock the machine to delete.
func (v *Virtualizer) ForceStop() error {
    err := v.execute(exec.Command("VBoxManage", "controlvm", v.name, "poweroff"))
    if err != nil {
        if !strings.Contains(err.Error(), "100%") {
            return err
        }
    }
    v.state = virtualizers.Ready
    return nil
}

// Details returns data to for the ConverToVM function on util
func (v *Virtualizer) Details() (string, string, string, []virtualizers.NetworkInterface, time.Time, *vcfg.VCFG, interface{}) {
    return v.name, v.pname, v.state, v.routes, v.created, v.config, v.source
}

// Close shuts down the virtual machine and cleans up the disk and folders
func (v *Virtualizer) Close(force bool) error {
    v.logger.Debugf("Deleting VM")
    if force && !(v.state == virtualizers.Ready) {
        err := v.ForceStop()
        if err != nil {
            return err
        }
    } else if !(v.state == virtualizers.Ready) {
        err := v.Stop()
        if err != nil {
            if !strings.Contains(err.Error(), "not currently running") {
                return err
            }
        }
    }
    v.state = virtualizers.Deleted

    var stopVM func() error
    stopVM = func() error {
        err := v.execute(exec.Command("VBoxManage", "unregistervm", v.name))
        if err != nil {
            if !strings.Contains(err.Error(),
                fmt.Sprintf("Could not find a registered machine named")) {
                // VM still shutting down
                if strings.Contains(err.Error(), "is already locked by a session (or being locked or unlocked)") {
                    time.Sleep(time.Millisecond * 500)
                    return stopVM()
                }
                return err
            }
        }
        return nil
    }
    err := stopVM()
    if err != nil {
        if !strings.Contains(err.Error(),
            fmt.Sprintf("Cannot unregister the machine '%s' while it is locked",
                v.name)) && !strings.Contains(err.Error(),
            fmt.Sprintf("Could not find a registered machine")) && !strings.Contains(err.Error(), "(MISSING)") {
            return err
        }
    }

    if v.sock != nil {
        v.sock.Close()
    }
    v.disk.Close()
    virtualizers.ActiveVMs.Delete(v.name)

    return nil
}

// Initialize passes the arguments from creation to create a virtualizer
func (v *Virtualizer) Initialize(data []byte) error {
    c := new(Config)
    err := c.Unmarshal(data)
    if err != nil {
        return err
    }

    v.headless = c.Headless
    v.networkType = c.NetworkType
    v.networkDevice = c.NetworkDevice
    return nil
}

// operation is the job progress that gets tracked via APIs
type operation struct {
    finishedLock sync.Mutex
    isFinished   bool
    *Virtualizer
    Logs   chan string
    Status chan string
    Error  chan error
    ctx    context.Context
}

// log writes a log to the channel for the job
func (o *operation) log(text string, v ...interface{}) {
    o.Logs <- fmt.Sprintf(text, v...)
}

// finished completes the operation and lets the user know and cleans up channels
func (o *operation) finished(err error) {
    o.finishedLock.Lock()
    defer o.finishedLock.Unlock()
    if o.isFinished {
        return
    }

    o.isFinished = true

    if err != nil {
        o.Logs <- fmt.Sprintf("Error: %v", err)
        o.Status <- fmt.Sprintf("Failed: %v", err)
        o.Error <- err
    }

    close(o.Logs)
    close(o.Status)
    close(o.Error)
}

// updateStatus updates the status of the job to provide more feedback to the user currently reading the job.
func (o *operation) updateStatus(text string) {
    o.Status <- text
    o.Logs <- text
}

// Serial returns the serial logger which contains the serial output of the app.
func (v *Virtualizer) Serial() *logger.Logger {
    return v.serialLogger
}

// Prepare prepares the virtualizer with the appropriate fields to run the virtual machine
func (v *Virtualizer) Prepare(args *virtualizers.PrepareArgs) *virtualizers.VirtualizeOperation {

    op := new(operation)
    op.Virtualizer = v
    v.name = args.Name
    v.pname = args.PName
    v.vmdrive = args.VMDrive
    // v.source = args.Source
    v.config = args.Config
    v.state = virtualizers.Changing
    v.created = time.Now()
    v.logger = args.Logger
    v.serialLogger = logger.NewLogger(2048 * 10)
    v.logger.Debugf("Preparing VM")
    v.routes = util.Routes(args.Config.Networks)

    op.Logs = make(chan string, 128)
    op.Error = make(chan error, 1)
    op.Status = make(chan string, 10)
    op.ctx = args.Context

    o := new(virtualizers.VirtualizeOperation)
    o.Logs = op.Logs
    o.Error = op.Error
    o.Status = op.Status

    go op.prepare(args)

    return o
}

// Detach ... removes vm from active vms list and moves content to source directory
// func (v *Virtualizer) Detach(source string) error {
//     if v.state != virtualizers.Ready {
//         return errors.New("virtual machine must be in a ready state to detach")
//     }

//     // remove "-" from the source directory as clonevm doesn't seem to work with it replace with space
//     source = strings.ReplaceAll(source, "-", " ")
//     // make directory to put it in as it doesn't exist anymore
//     err := os.MkdirAll(source, 0777)
//     if err != nil {
//         return err
//     }
//     _, err = os.Stat(filepath.Join(filepath.ToSlash(source), fmt.Sprintf("%s Clone", v.name)))
//     if err == nil {
//         return errors.New("source directory where you want to copy already exists")
//     } else if !os.IsNotExist(err) {
//         return err
//     }
//     // clone vm from temp directory to source
//     cmd := exec.Command("VBoxManage", "clonevm", v.name, fmt.Sprintf("--basefolder=%s", filepath.ToSlash(source)), "--register")
//     err = v.execute(cmd)
//     if err != nil {
//         if !strings.Contains(err.Error(), "100%") {
//             return err
//         }
//     }

//     time.Sleep(time.Second * 5)
//     // remove vm entirely from here might as well force it as the cloned vm has a non corrupt disk
//     err = v.Close(true)
//     if err != nil {
//         return err
//     }

//     if runtime.GOOS != "windows" {
//         cmd := exec.Command("VBoxManage", "modifyvm", fmt.Sprintf("%s Clone", v.name), "--uartmode1", "server", filepath.Join(filepath.ToSlash(source), fmt.Sprintf("%s Clone", v.name), "monitor.sock"))
//         err = v.execute(cmd)
//         if err != nil {
//             if !strings.Contains(err.Error(), "100%") {
//                 return err
//             }
//         }
//     }

//     return nil
// }

// execute is generic wrapping function to run command execs
func (v *Virtualizer) execute(cmd *exec.Cmd) error {
    if !strings.Contains(strings.Join(cmd.Args, " "), "showvminfo") {
        v.logger.Infof("Executing %s", strings.Join(cmd.Args, " "))
    }

    stderr := new(bytes.Buffer)
    cmd.Stderr = stderr

    err := cmd.Start()
    if err != nil {
        return err
    }

    e := cmd.Wait()
    if len(stderr.String()) == 0 {
        return e
    }

    return fmt.Errorf(stderr.String())
}

// checkState polls getState for a state monitoring solution when an app crashes
func (v *Virtualizer) checkState() {
    for {
        state, err := v.getState()
        if err != nil {
            // Supressing errors that don't affect getting the state of the machine / errors that may error out because the virtual machine is gone.
            if !strings.Contains(err.Error(), "signal: interrupt") && !strings.Contains(err.Error(), "Could not find a registered machine") && !strings.Contains(err.Error(), "exit status 3221225786") && !strings.Contains(err.Error(), "The object is not ready") {
                v.logger.Errorf("Getting VM State: %s", err.Error())
            }
        }
        if state == "" {
            break
        }
        if state == "running" {
            v.state = virtualizers.Alive
        }
        if state == "powered off" {
            v.state = virtualizers.Ready
        }
        time.Sleep(time.Second * 1)
    }
}

func createVM(basefolder, name string) []string {
    return []string{"createvm", "--basefolder", basefolder, "--name", name, "--register"}
}

func modifyVM(name string, ram, cpus, sock string) []string {
    vboxArgs := []string{"modifyvm", name,
        "--memory", ram, "--acpi", "on",
        "--ioapic", "on", "--cpus", cpus,
        "--longmode", "on", "--largepages", "on", "--chipset", "ich9",
        "--bioslogofadein", "off", "--bioslogofadeout", "off",
        "--bioslogodisplaytime", "1", "--biosbootmenu", "disabled",
        "--rtcuseutc", "on", "--uart1", "0x3F8", "4", "--uartmode1",
        "server", sock}

    if runtime.GOOS == "windows" {
        hyperVEnabled := false
        virts, _ := virtualizers.Backends()
        for _, v := range virts {
            if v == "hyperv" {
                hyperVEnabled = true
            }
        }
        if !hyperVEnabled {
            vboxArgs = append(vboxArgs, "--nested-hw-virt", "on")
        }
    } else {
        vboxArgs = append(vboxArgs, "--nested-hw-virt", "on")
    }
    return vboxArgs
}

// prepareVM executes the modify function with appropriate arguments for storage
func (v *Virtualizer) prepareVM(diskpath string) error {
    cpus := int(v.config.VM.CPUs)
    if cpus == 0 {
        cpus = 1
    }
    mVMArgs := modifyVM(v.name, strconv.Itoa(v.config.VM.RAM.Units(vcfg.MiB)), strconv.Itoa(cpus), filepath.ToSlash(filepath.Join(v.folder, "monitor.sock")))
    if runtime.GOOS == "windows" {
        mVMArgs = modifyVM(v.name, strconv.Itoa(v.config.VM.RAM.Units(vcfg.MiB)), strconv.Itoa(cpus), fmt.Sprintf("\\\\.\\pipe\\%s", v.id))
    }
    cmd := exec.Command("VBoxManage", mVMArgs...)
    err := v.execute(cmd)
    if err != nil {
        return err
    }

    cmd = exec.Command("VBoxManage", "storagectl", v.name,
        "--name", fmt.Sprintf("SCSI-%s", filepath.Base(diskpath)), "--add", "virtio-scsi", "--portcount", "16",
        "--bootable", "on")
    err = v.execute(cmd)
    if err != nil {
        return err
    }

    cmd = exec.Command("VBoxManage", "storageattach", v.name,
        "--storagectl", fmt.Sprintf("SCSI-%s", filepath.Base(diskpath)), "--port", "0", "--device", "0",
        "--type", "hdd", "--medium", diskpath)
    err = v.execute(cmd)
    if err != nil {
        return err
    }

    return nil
}

func (v *Virtualizer) Bind(args []string, i int, j int, protocol string, port virtualizers.RouteMap, networkType string) ([]string, string, bool, error) {
    var hasDefinedPorts bool
    bind, nr, err := virtualizers.BindPort(v.networkType, protocol, port.Port)
    if err != nil {
        return nil, "", false, err
    }
    hasDefinedPorts = true
    args = append(args, fmt.Sprintf("--natpf%s", strconv.Itoa(i+1)))
    args = append(args, fmt.Sprintf("nat%s%s,%s,,%s,,%s", bind, networkType, protocol, bind, port.Port))
    return args, nr, hasDefinedPorts, nil
}

func (v *Virtualizer) handlePorts(args []string, route virtualizers.NetworkInterface, protocol string, i int) ([]string, bool, error) {
    var err error
    var nr string
    var hasDefinedPorts bool
    for j, port := range route.HTTP {
        args, nr, hasDefinedPorts, err = v.Bind(args, i, j, protocol, port, "http")
        if err != nil {
            return nil, false, err
        }
        v.routes[i].HTTP[j].Address = nr
    }
    for j, port := range route.HTTPS {
        args, nr, hasDefinedPorts, err = v.Bind(args, i, j, protocol, port, "https")
        if err != nil {
            return nil, false, err
        }
        v.routes[i].HTTPS[j].Address = nr
    }
    for j, port := range route.TCP {
        args, nr, hasDefinedPorts, err = v.Bind(args, i, j, protocol, port, "tcp")
        if err != nil {
            return nil, false, err
        }
        v.routes[i].TCP[j].Address = nr
    }
    for j, port := range route.UDP {
        args, nr, hasDefinedPorts, err = v.Bind(args, i, j, protocol, port, "udp")
        if err != nil {
            return nil, false, err
        }
        v.routes[i].UDP[j].Address = nr
    }
    return args, hasDefinedPorts, nil
}

func (v *Virtualizer) gatherNetworkDetails() error {
    hasDefinedPorts := false
    var noNic int
    var err error
    for i, route := range v.routes {
        args := make([]string, 0)
        args = append(args, "modifyvm", v.name)
        noNic++
        protocol := "tcp"
        if v.networkType == "nat" {
            var nargs []string

            nargs, hasDefinedPorts, err = v.handlePorts(args, route, protocol, i)
            if err != nil {
                return err
            }
            if hasDefinedPorts {
                cmd := exec.Command("VBoxManage", nargs...)
                err := v.execute(cmd)
                if err != nil {
                    return err
                }
            }
        }
        if len(v.routes) != 0 && !hasDefinedPorts {
            v.logger.Warnf("Warning VM has network cards but no defined ports")
        }
    }

    args := make([]string, 0)
    args = append(args, "modifyvm", v.name)

    for i := 1; i <= noNic; i++ {
        args = append(args, "--nic"+strconv.Itoa(i), v.networkType)

        switch v.networkType {
        case "bridged":
            args = append(args, "--bridgeadapter"+strconv.Itoa(i), v.networkDevice)
        case "hostonly":
            args = append(args, "--hostonlyadapter"+strconv.Itoa(i), v.networkDevice)
        default:
        }
        args = append(args, "--nictype"+strconv.Itoa(i), "virtio", "--cableconnected"+strconv.Itoa(i), "on")
    }

    cmd := exec.Command("VBoxManage", args...)
    err = v.execute(cmd)
    if err != nil {
        return err
    }
    return nil
}

func (v *Virtualizer) createAndConfigure(diskpath string) error {
    cVMArgs := createVM(v.folder, v.name)
    cmd := exec.Command("VBoxManage", cVMArgs...)
    err := v.execute(cmd)
    if err != nil {
        return err
    }

    err = v.prepareVM(diskpath)
    if err != nil {
        return err
    }

    cmd = exec.Command("VBoxManage", "setextradata", v.name,
        "VBoxInternal/Devices/serial/0/Config/YieldOnLSRRead", "1")
    err = v.execute(cmd)
    if err != nil {
        return err
    }

    err = v.gatherNetworkDetails()
    if err != nil {
        return err
    }
    return nil
}

// checkIfBridged checks if bridged device exists on virtualbox
func (o *operation) checkIfBridged() (bool, error) {
    var deviceIsBridged bool
    if o.networkType == "bridged" {
        devices, err := virtualizers.BridgedDevices()
        if err != nil {
            return false, err
        }
        for _, device := range devices {
            if device == o.networkDevice {
                deviceIsBridged = true
                break
            }
        }
        if !deviceIsBridged {
            return false, fmt.Errorf("error: network device '%s' is not a valid bridge interface", o.networkDevice)
        }
    }
    return deviceIsBridged, nil
}

// checkIfHost checks if host device exists on virtualbox
func (o *operation) checkIfHost() (bool, error) {
    var deviceIsHost bool
    if o.networkType == "hostonly" {
        devices, err := virtualizers.HostDevices()
        if err != nil {
            return false, err
        }
        for _, device := range devices {
            if device == o.networkDevice {
                deviceIsHost = true
                break
            }
        }
        if !deviceIsHost {
            return false, fmt.Errorf("error: network device '%s' is not a valid host interface", o.networkDevice)

        }
    }
    return deviceIsHost, nil
}

// prepare sets the fields and arguments to spawn the virtual machine
func (o *operation) prepare(args *virtualizers.PrepareArgs) {
    var returnErr error
    var err error
    progress := o.logger.NewProgress("Preparing VirtualBox machine", "", 0)
    defer progress.Finish(false)
    o.updateStatus(fmt.Sprintf("Preparing virtualbox files..."))
    defer func() {
        o.finished(returnErr)
    }()

    o.name = args.Name
    o.id = randstr.Hex(5)
    o.folder = filepath.Dir(args.ImagePath)

    _, err = o.checkIfBridged()
    if err != nil {
        returnErr = err
        return
    }

    _, err = o.checkIfHost()
    if err != nil {
        returnErr = err
        return
    }

    _, loaded := virtualizers.ActiveVMs.LoadOrStore(o.name, o.Virtualizer)
    if loaded {
        returnErr = errors.New("virtual machine already exists")
        return
    }

    err = o.startupVM(args.ImagePath, args.Start)
    if err != nil {
        returnErr = err
        return
    }

}

func (o *operation) startupVM(path string, start bool) error {
    err := o.createAndConfigure(path)
    if err != nil {
        return fmt.Errorf("Error configuring vm: %s", err.Error())
    }

    o.state = "ready"
    go o.checkState()

    if start {
        err = o.Start()
        if err != nil {
            return fmt.Errorf("Error starting vm: %s", err.Error())
        }
    }
    return nil
}