diff --git a/.gitignore b/.gitignore index 66fd13c..a371dbc 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ # Dependency directories (remove the comment below to include it) # vendor/ + +# Directory used for tests +out/ \ No newline at end of file diff --git a/doc.go b/doc.go new file mode 100644 index 0000000..425ec20 --- /dev/null +++ b/doc.go @@ -0,0 +1 @@ +package drivervbox diff --git a/driver-image.go b/driver-image.go new file mode 100644 index 0000000..5f19984 --- /dev/null +++ b/driver-image.go @@ -0,0 +1,72 @@ +package drivervbox + +import ( + "fmt" + + "github.com/kuttiproject/drivercore" +) + +// UpdateImageList fetches the latest list of VM images from the driver source URL. +func (vd *Driver) UpdateImageList() error { + return fetchimagelist() +} + +// ValidK8sVersion returns true if the specified Kubernetes version is available. +func (vd *Driver) ValidK8sVersion(k8sversion string) bool { + err := imageconfigmanager.Load() + if err != nil { + return false + } + + _, ok := imagedata.images[k8sversion] + return ok +} + +// K8sVersions returns all Kubernetes versions currently supported by kutti. +func (vd *Driver) K8sVersions() []string { + err := imageconfigmanager.Load() + if err != nil { + return []string{} + } + + result := make([]string, len(imagedata.images)) + index := 0 + for _, value := range imagedata.images { + result[index] = value.ImageK8sVersion + index++ + } + + return result +} + +// ListImages lists the currently available Images. +func (vd *Driver) ListImages() ([]drivercore.Image, error) { + err := imageconfigmanager.Load() + if err != nil { + return []drivercore.Image{}, err + } + + result := make([]drivercore.Image, len(imagedata.images)) + index := 0 + for _, value := range imagedata.images { + result[index] = value + index++ + } + + return result, nil +} + +// GetImage returns an image corresponding to a Kubernetes version, or an error. +func (vd *Driver) GetImage(k8sversion string) (drivercore.Image, error) { + err := imageconfigmanager.Load() + if err != nil { + return nil, err + } + + img, ok := imagedata.images[k8sversion] + if !ok { + return img, fmt.Errorf("no image present for K8s version %s", k8sversion) + } + + return img, nil +} diff --git a/driver-machine.go b/driver-machine.go new file mode 100644 index 0000000..1430545 --- /dev/null +++ b/driver-machine.go @@ -0,0 +1,251 @@ +package drivervbox + +import ( + "fmt" + "os" + "regexp" + "strings" + "time" + + "github.com/kuttiproject/drivercore" + "github.com/kuttiproject/kuttilog" + "github.com/kuttiproject/workspace" +) + +// QualifiedMachineName returns a name in the form - +func (vd *Driver) QualifiedMachineName(machinename string, clustername string) string { + return clustername + "-" + machinename +} + +/*ListMachines parses the list of VMs returned by + VBoxManage list vms +As of VBoxManage 6.0.8r130520, the format is: + + "Matsya" {e3509073-d188-4cca-8eaf-cb9f3be7ac4a} + "Krishna" {5d9b1b16-5059-42ae-a160-e93b470f940e} + "one" {06748689-7f4e-4915-8fbf-6111596f85a2} + "two" {eee169a7-09eb-473e-96be-5d37868c5d5e} + "minikube" {5bf78b43-3240-4f50-911b-fbc111d4d085} + "Node 1" {53b82a61-ae52-44c2-86d5-4c686502dd64} + +*/ +func (vd *Driver) ListMachines() ([]drivercore.Machine, error) { + output, err := workspace.Runwithresults( + vd.vboxmanagepath, + "list", + "vms", + ) + if err != nil { + return nil, fmt.Errorf("could not get list of VMs: %v", err) + } + + // TODO: Write a better parser + result := []drivercore.Machine{} + lines := strings.Split(output, "\n") + if len(lines) < 1 { + return result, nil + } + + actualcount := 0 + for _, value := range lines { + line := strings.Split(value, " ") + if len(line) == 2 { + result = append(result, &Machine{ + driver: vd, + name: trimQuotes(line[0]), + status: drivercore.MachineStatusUnknown, + }) + actualcount++ + } + } + + return result[:actualcount], err +} + +// GetMachine returns the named machine, or an error. +// It does this by running the command: +// VBoxManage guestproperty enumerate --patterns "/VirtualBox/GuestInfo/Net/0/*|/kutti/*|/VirtualBox/GuestInfo/OS/LoggedInUsers" +// and parsing the enumerated properties. +func (vd *Driver) GetMachine(machinename string, clustername string) (drivercore.Machine, error) { + machine := &Machine{ + driver: vd, + name: machinename, + clustername: clustername, + status: drivercore.MachineStatusUnknown, + } + + err := machine.get() + + if err != nil { + return nil, err + } + + return machine, nil +} + +// DeleteMachine completely deletes a Machine. +// It does this by running the command: +// VBoxManage unregistervm "" --delete +func (vd *Driver) DeleteMachine(machinename string, clustername string) error { + qualifiedmachinename := vd.QualifiedMachineName(machinename, clustername) + output, err := workspace.Runwithresults( + vd.vboxmanagepath, + "unregistervm", + qualifiedmachinename, + "--delete", + ) + + if err != nil { + return fmt.Errorf("could not delete machine %s: %v:%s", machinename, err, output) + } + + return nil +} + +var ipRegex, _ = regexp.Compile(`^(([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])$`) + +// NewMachine creates a VM, and connects it to a previously created NAT network. +// It also starts the VM, changes the hostname, saves the IP address, and stops +// it again. +// It runs the following two VBoxManage commands, in order: +// VBoxManage import --vsys 0 --vmname "" +// VBoxManage modifyvm "" --nic1 natnetwork --nat-network1 +// The first imports from an .ova file (easiest way to get fully configured VM), while +// setting the VM name. The second connects the first network interface card to +// the NAT network. +func (vd *Driver) NewMachine(machinename string, clustername string, k8sversion string) (drivercore.Machine, error) { + qualifiedmachinename := vd.QualifiedMachineName(machinename, clustername) + + kuttilog.Println(2, "Importing image...") + + ovafile, err := imagepathfromk8sversion(k8sversion) + if err != nil { + return nil, err + } + + if _, err = os.Stat(ovafile); err != nil { + return nil, fmt.Errorf("could not retrieve image %s: %v", ovafile, err) + } + + l, err := workspace.Runwithresults( + vd.vboxmanagepath, + "import", + ovafile, + "--vsys", + "0", + "--vmname", + qualifiedmachinename, + "--vsys", + "0", + "--group", + "/"+clustername, + ) + + if err != nil { + return nil, fmt.Errorf("could not import ovafile %s: %v(%v)", ovafile, err, l) + } + + // Attach newly created VM to NAT Network + kuttilog.Println(2, "Attaching host to network...") + newmachine := &Machine{ + driver: vd, + name: machinename, + clustername: clustername, + status: drivercore.MachineStatusStopped, + } + networkname := vd.QualifiedNetworkName(clustername) + + _, err = workspace.Runwithresults( + vd.vboxmanagepath, + "modifyvm", + newmachine.qname(), + "--nic1", + "natnetwork", + "--nat-network1", + networkname, + ) + + if err != nil { + newmachine.status = drivercore.MachineStatusError + newmachine.errormessage = fmt.Sprintf("Could not attach node %s to network %s: %v", machinename, networkname, err) + return newmachine, fmt.Errorf("could not attach node %s to network %s: %v", machinename, networkname, err) + } + + // Start the host + kuttilog.Println(2, "Starting host...") + err = newmachine.Start() + if err != nil { + return newmachine, err + } + // TODO: Try to parameterize the timeout + newmachine.WaitForStateChange(25) + + // Change the name + for renameretries := 1; renameretries < 4; renameretries++ { + kuttilog.Printf(2, "Renaming host (attempt %v/3)...", renameretries) + err = renamemachine(newmachine, machinename) + if err == nil { + break + } + kuttilog.Printf(2, "Failed. Waiting %v seconds before retry...", renameretries*10) + time.Sleep(time.Duration(renameretries*10) * time.Second) + } + + if err != nil { + return newmachine, err + } + kuttilog.Println(2, "Host renamed.") + + // Save the IP Address + // The first IP address should be DHCP-assigned, and therefore start with + // ipNetAddr (192.168.125 by default). This may fail if we check too soon. + // In some cases, VirtualBox picks up other interfaces first. So, we check + // up to three interfaces for the correct IP address, and do this up to 3 + // times. + ipSet := false + for ipretries := 1; ipretries < 4; ipretries++ { + kuttilog.Printf(2, "Fetching IP address (attempt %v/3)...", ipretries) + + var ipaddress string + ipprops := []string{propIPAddress, propIPAddress2, propIPAddress3} + + for _, ipprop := range ipprops { + ipaddr, present := newmachine.getproperty(ipprop) + + if present { + ipaddr = trimpropend(ipaddr) + if ipRegex.MatchString(ipaddr) && strings.HasPrefix(ipaddr, ipNetAddr) { + ipaddress = ipaddr + break + } + } + + if kuttilog.V(4) { + kuttilog.Printf(4, "value of property %v is %v, and present is %v.", ipprop, ipaddr, present) + kuttilog.Printf(4, "Regex match is %v, and prefix match is %v.", ipRegex.MatchString(ipaddr), strings.HasPrefix(ipaddr, ipNetAddr)) + } + } + + if ipaddress != "" { + kuttilog.Printf(2, "Obtained IP address '%v'", ipaddress) + newmachine.setproperty(propSavedIPAddress, ipaddress) + ipSet = true + break + } + + kuttilog.Printf(2, "Failed. Waiting %v seconds before retry...", ipretries*10) + time.Sleep(time.Duration(ipretries*10) * time.Second) + } + + if !ipSet { + kuttilog.Printf(0, "Error: Failed to get IP address. You may have to delete this node and recreate it manually.") + } + + kuttilog.Println(2, "Stopping host...") + newmachine.Stop() + // newhost.WaitForStateChange(25) + + newmachine.status = drivercore.MachineStatusStopped + + return newmachine, nil +} diff --git a/driver-network.go b/driver-network.go new file mode 100644 index 0000000..f4b0a3c --- /dev/null +++ b/driver-network.go @@ -0,0 +1,222 @@ +package drivervbox + +import ( + "errors" + "fmt" + "strings" + + "github.com/kuttiproject/drivercore" + "github.com/kuttiproject/workspace" +) + +// QualifiedNetworkName adds a 'kuttinet' prefix to the specified cluster name. +func (vd *Driver) QualifiedNetworkName(clustername string) string { + return clustername + "kuttinet" +} + +/*ListNetworks parses the list of NAT networks returned by + VBoxManage natnetwork list +As of VBoxManage 6.0.8r130520, the format is: + + NAT Networks: + + Name: KubeNet + Network: 10.0.2.0/24 + Gateway: 10.0.2.1 + IPv6: No + Enabled: Yes + + + Name: NatNetwork + Network: 10.0.2.0/24 + Gateway: 10.0.2.1 + IPv6: No + Enabled: Yes + + + Name: NatNetwork1 + Network: 10.0.2.0/24 + Gateway: 10.0.2.1 + IPv6: No + Enabled: Yes + + 3 networks found + +Note the blank lines: one before and after +each network. If there are zero networks, the output is: + + NAT Networks: + + 0 networks found + + +*/ +func (vd *Driver) ListNetworks() ([]drivercore.Network, error) { + // The default pattern for all our network names is "*kuttinet" + output, err := workspace.Runwithresults( + vd.vboxmanagepath, + "natnetwork", + "list", + "*kuttinet", + ) + if err != nil { + return nil, err + } + + // TODO: write a better parser + lines := strings.Split(output, "\n") + numlines := len(lines) + if numlines < 4 { + // Bare mininum output should be + // NAT Networks: + // + // 0 networks found + // + return nil, errors.New("could not recognise VBoxManage output for natnetworks list while getting lines") + } + + var numnetworks int + + _, err = fmt.Sscanf(lines[numlines-2], "%d", &numnetworks) + if err != nil { + return nil, errors.New("could not recognise VBoxManage output for natnetworks list while getting count") + } + + justlines := lines[2 : numlines-2] + numlines = len(justlines) + + result := make([]drivercore.Network, numnetworks) + + for i, j := 0, 0; i < numlines; i, j = i+7, j+1 { + result[j] = &Network{ + name: justlines[i][13:], + netCIDR: justlines[i+1][13:], + } + } + + return result, nil +} + +// GetNetwork returns a network, or an error. +func (vd *Driver) GetNetwork(clustername string) (drivercore.Network, error) { + netname := vd.QualifiedNetworkName(clustername) + + networks, err := vd.ListNetworks() + if err != nil { + return nil, err + } + + for _, network := range networks { + if network.Name() == netname { + return network, nil + } + } + + return nil, fmt.Errorf("network %s not found", netname) +} + +// DeleteNetwork deletes a network. +// It does this by running the command: +// VBoxManage natnetwork remove --netname +func (vd *Driver) DeleteNetwork(clustername string) error { + netname := vd.QualifiedNetworkName(clustername) + + output, err := workspace.Runwithresults( + vd.vboxmanagepath, + "natnetwork", + "remove", + "--netname", + netname, + ) + if err != nil { + return fmt.Errorf( + "could not delete NAT network %s:%v:%s", + netname, + err, + output, + ) + } + + // Associated dhcpserver must also be deleted + output, err = workspace.Runwithresults( + vd.vboxmanagepath, + "dhcpserver", + "remove", + "--netname", + netname, + ) + if err != nil { + return fmt.Errorf( + "could not delete DHCP server %s:%v:%s", + netname, + err, + output, + ) + } + + return nil +} + +// NewNetwork creates a new VirtualBox NAT network. +// It uses the CIDR common to all Kutti networks, and is dhcp-enabled at start. +func (vd *Driver) NewNetwork(clustername string) (drivercore.Network, error) { + netname := vd.QualifiedNetworkName(clustername) + + // Multiple VirtualBox NAT Networks can have the same IP range + // So, all Kutti networks will use the same network CIDR + // We start with dhcp enabled. + output, err := workspace.Runwithresults( + vd.vboxmanagepath, + "natnetwork", + "add", + "--netname", + netname, + "--network", + DefaultNetCIDR, + "--enable", + "--dhcp", + "on", + ) + if err != nil { + return nil, fmt.Errorf( + "could not create NAT network %s:%v:%s", + netname, + err, + output, + ) + } + + // Manually create the associated DHCP server + // Hard-coding a thirty-node limit for now + output, err = workspace.Runwithresults( + vd.vboxmanagepath, + "dhcpserver", + "add", + "--netname", + netname, + "--ip", + dhcpaddress, + "--netmask", + dhcpnetmask, + "--lowerip", + fmt.Sprintf("%s.%d", ipNetAddr, iphostbase), + "--upperip", + fmt.Sprintf("%s.%d", ipNetAddr, iphostbase+29), + "--enable", + ) + if err != nil { + return nil, fmt.Errorf( + "could not create DHCP server for network %s:%v:%s", + netname, + err, + output, + ) + } + + newnetwork := &Network{ + name: netname, + netCIDR: DefaultNetCIDR, + } + + return newnetwork, err +} diff --git a/driver.go b/driver.go new file mode 100644 index 0000000..d189bb9 --- /dev/null +++ b/driver.go @@ -0,0 +1,56 @@ +package drivervbox + +const ( + driverName = "vbox" + driverDescription = "Kutti driver for VirtualBox >=6.0" + networkNameSuffix = "kuttinet" + networkNamePattern = "*" + networkNameSuffix + dhcpaddress = "192.168.125.3" + dhcpnetmask = "255.255.255.0" + ipNetAddr = "192.168.125" + iphostbase = 10 + forwardedPortBase = 10000 +) + +// DefaultNetCIDR is the address range used by NAT networks. +var DefaultNetCIDR = "192.168.125.0/24" + +// Driver implements the drivercore.Driver interface for VirtualBox. +type Driver struct { + vboxmanagepath string + status string + errormessage string +} + +// Name returns the "vbox" +func (vd *Driver) Name() string { + return driverName +} + +// Description returns "Kutti driver for VirtualBox >=6.0" +func (vd *Driver) Description() string { + return driverDescription +} + +// UsesNATNetworking returns true +func (vd *Driver) UsesNATNetworking() bool { + return true +} + +// Status returns current driver status +func (vd *Driver) Status() string { + return vd.status +} + +func (vd *Driver) Error() string { + panic("not implemented") // TODO: Implement +} + +func trimQuotes(s string) string { + if len(s) >= 2 { + if s[0] == '"' && s[len(s)-1] == '"' { + return s[1 : len(s)-1] + } + } + return s +} diff --git a/drivervbox-findvboxmanage.go b/drivervbox-findvboxmanage.go new file mode 100644 index 0000000..505d3a2 --- /dev/null +++ b/drivervbox-findvboxmanage.go @@ -0,0 +1,17 @@ +// +build !windows + +package drivervbox + +import "os/exec" + +func findvboxmanage() (string, error) { + + // Try looking it up on the path + toolpath, err := exec.LookPath("VBoxManage") + if err == nil { + return toolpath, nil + } + + // Give up + return "", err +} diff --git a/drivervbox-findvboxmanage_windows.go b/drivervbox-findvboxmanage_windows.go new file mode 100644 index 0000000..4241124 --- /dev/null +++ b/drivervbox-findvboxmanage_windows.go @@ -0,0 +1,35 @@ +// +build windows + +package drivervbox + +import ( + "os" + "os/exec" + "path" +) + +func findvboxmanage() (string, error) { + + // First, try looking it up on the path + toolpath, err := exec.LookPath("VBoxManage.exe") + if err == nil { + return toolpath, nil + } + + // Then try looking in well-known places + // Try %ProgramFiles% first, then hardcode + progfileslocation := os.Getenv("ProgramFiles") + if progfileslocation != "" { + toolpath = path.Join(progfileslocation, "Oracle", "VirtualBox", "VBoxManage.exe") + } else { + toolpath = "C:\\Program Files\\Oracle\\VirtualBox\\VBoxManage.exe" + } + + // If it exists. we're good + if _, err = os.Stat(toolpath); err == nil { + return toolpath, nil + } + + // Give up + return "", err +} diff --git a/drivervbox-imagemanagement.go b/drivervbox-imagemanagement.go new file mode 100644 index 0000000..b306453 --- /dev/null +++ b/drivervbox-imagemanagement.go @@ -0,0 +1,151 @@ +package drivervbox + +import ( + "encoding/json" + "errors" + "path" + + "github.com/kuttiproject/drivercore" + "github.com/kuttiproject/kuttilog" + "github.com/kuttiproject/workspace" +) + +const imagesConfigFile = "vboximages.json" + +// ImagesSourceURL is the location where the master list of images can be found +var ImagesSourceURL = "https://github.com/rajch/kutti-images/releases/download/v0.1.13-beta/kutti-images.json" + +var ( + imagedata = &imageconfigdata{} + imageconfigmanager, _ = workspace.NewFileConfigmanager(imagesConfigFile, imagedata) +) + +type imageconfigdata struct { + images map[string]*Image +} + +func (icd *imageconfigdata) Serialize() ([]byte, error) { + return json.Marshal(icd.images) +} + +func (icd *imageconfigdata) Deserialize(data []byte) error { + loaddata := make(map[string]*Image) + err := json.Unmarshal(data, &loaddata) + if err == nil { + icd.images = loaddata + } + return err +} + +func (icd *imageconfigdata) Setdefaults() { + icd.images = defaultimages() +} + +func vboxCacheDir() (string, error) { + return workspace.Cachesubdir("vbox") +} + +func vboxConfigDir() (string, error) { + //return workspace.Configsubdir("vbox") + return workspace.Configdir() +} + +func defaultimages() map[string]*Image { + return map[string]*Image{} +} + +func imagenamefromk8sversion(k8sversion string) string { + return "kutti-" + k8sversion + ".ova" +} + +func imagepathfromk8sversion(k8sversion string) (string, error) { + cachedir, err := vboxCacheDir() + if err != nil { + return "", err + } + + result := path.Join(cachedir, imagenamefromk8sversion(k8sversion)) + return result, nil +} + +func addfromfile(k8sversion string, filepath string, checksum string) error { + filechecksum, err := workspace.ChecksumFile(filepath) + if err != nil { + return err + } + + if filechecksum != checksum { + return errors.New("file is not valid") + } + + localfilepath, err := imagepathfromk8sversion(k8sversion) + if err != nil { + return err + } + + err = workspace.CopyFile(filepath, localfilepath, 1000, true) + if err != nil { + return err + } + + return nil +} + +func removefile(k8sversion string) error { + filename, err := imagepathfromk8sversion(k8sversion) + if err != nil { + return err + } + + return workspace.RemoveFile(filename) +} + +func fetchimagelist() error { + // Download image list into temp directory + confdir, _ := vboxConfigDir() + tempfilename := "vboximagesnewlist.json" + tempfilepath := path.Join(confdir, tempfilename) + + kuttilog.Printf(kuttilog.Debug, "confdir: %v\ntempfilepath: %v\n", confdir, tempfilepath) + + kuttilog.Println(kuttilog.Info, "Fetching image list...") + kuttilog.Printf(kuttilog.Debug, "Fetching from %v into %v.", ImagesSourceURL, tempfilepath) + err := workspace.DownloadFile(ImagesSourceURL, tempfilepath) + kuttilog.Printf(kuttilog.Debug, "Error: %v", err) + if err != nil { + return err + } + defer workspace.RemoveFile(tempfilepath) + + // Load into object + tempimagedata := &imageconfigdata{} + tempconfigmanager, err := workspace.NewFileConfigmanager(tempfilename, tempimagedata) + if err != nil { + return err + } + + err = tempconfigmanager.Load() + if err != nil { + return err + } + + // Compare against current and update + for key, newimage := range tempimagedata.images { + oldimage := imagedata.images[key] + if oldimage != nil && + newimage.ImageChecksum == oldimage.ImageChecksum && + newimage.ImageSourceURL == oldimage.ImageSourceURL && + oldimage.ImageStatus == drivercore.ImageStatusDownloaded { + + newimage.ImageStatus = drivercore.ImageStatusDownloaded + } + } + + // Make it current + imagedata.images = tempimagedata.images + + // Save as local configuration + imageconfigmanager.Save() + + return nil +} diff --git a/drivervbox.go b/drivervbox.go new file mode 100644 index 0000000..e188e47 --- /dev/null +++ b/drivervbox.go @@ -0,0 +1,47 @@ +package drivervbox + +import ( + "fmt" + + "github.com/kuttiproject/drivercore" + "github.com/kuttiproject/workspace" +) + +func init() { + driver, err := newvboxdriver() + if err == nil { + drivercore.RegisterDriver("vbox", driver) + } +} + +func newvboxdriver() (*Driver, error) { + result := &Driver{} + + // find VBoxManage tool and set it + vbmpath, err := findvboxmanage() + if err != nil { + result.status = "Error" + result.errormessage = err.Error() + return result, err + } + result.vboxmanagepath = vbmpath + + // test VBoxManage version + vbmversion, err := workspace.Runwithresults(vbmpath, "--version") + if err != nil { + result.status = "Error" + result.errormessage = err.Error() + return result, err + } + var majorversion int + _, err = fmt.Sscanf(vbmversion, "%d", &majorversion) + if err != nil || majorversion < 6 { + err = fmt.Errorf("unsupported VBoxManage version %v. 6.0 and above are supported", vbmversion) + result.status = "Error" + result.errormessage = err.Error() + return result, err + } + + result.status = "Ready" + return result, nil +} diff --git a/drivervbox_test.go b/drivervbox_test.go new file mode 100644 index 0000000..5cf0c57 --- /dev/null +++ b/drivervbox_test.go @@ -0,0 +1,71 @@ +package drivervbox_test + +import ( + "context" + "fmt" + "net/http" + "os" + "testing" + "time" + + drivervbox "github.com/kuttiproject/driver-vbox" + "github.com/kuttiproject/drivercore/drivercoretest" + "github.com/kuttiproject/workspace" +) + +func TestDriverVBox(t *testing.T) { + // kuttilog.Setloglevel(kuttilog.Debug) + + // Set up dummy web server for updating image list + // and downloading image + _, err := os.Stat("out/testserver/kutti-1.18.ova") + if err != nil { + t.Fatal( + "Please download the version 1.18 kutti image, and place it in the path out/testserver/kutti-1.18.ova", + ) + } + + serverMux := http.NewServeMux() + server := http.Server{Addr: "localhost:8181", Handler: serverMux} + defer server.Shutdown(context.Background()) + + serverMux.HandleFunc( + "/images.json", + func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, `{"1.18":{"ImageK8sVersion":"1.18","ImageChecksum":"a053e6910c55e19bbd2093b0129f25aa69ceee9b0e0a52505dfd9d8b3eb24090","ImageStatus":"NotDownloaded", "ImageSourceURL":"http://localhost:8181/kutti-1.18.ova"}}`) + }, + ) + + serverMux.HandleFunc( + "/kutti-1.18.ova", + func(rw http.ResponseWriter, r *http.Request) { + http.ServeFile( + rw, + r, + "out/testserver/kutti-1.18.ova", + ) + }, + ) + + go func() { + t.Log("Server starting...") + err := server.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + t.Logf("ERROR:%v", err) + } + t.Log("Server stopped.") + }() + + t.Log("Waiting 5 seconds for dummy server to start.") + + <-time.After(5 * time.Second) + + err = workspace.Set("/home/raj/projects/kuttiproject/driver-vbox/out") + if err != nil { + t.Fatalf("Error: %v", err) + } + + drivervbox.ImagesSourceURL = "http://localhost:8181/images.json" + + drivercoretest.TestDriver(t, "vbox", "1.18") +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0c36dc0 --- /dev/null +++ b/go.mod @@ -0,0 +1,9 @@ +module github.com/kuttiproject/driver-vbox + +go 1.16 + +require ( + github.com/kuttiproject/drivercore v0.1.2 + github.com/kuttiproject/kuttilog v0.1.1 + github.com/kuttiproject/workspace v0.2.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..99a9d9d --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/kuttiproject/drivercore v0.1.2-0.20210521114624-f0db5bad0e8d h1:wX/LYanDPqvz1YGRpB5O/eHDN5pN3JpUXdAsBoccrHw= +github.com/kuttiproject/drivercore v0.1.2-0.20210521114624-f0db5bad0e8d/go.mod h1:1C1TMGfQ4u/5ltHUtl4hL7gYzXZyZQVhkOha4GuOzk0= +github.com/kuttiproject/drivercore v0.1.2-0.20210521121704-f5c15ad420be h1:IpPvDQaU1RecFuWYN2z57aUmkZNikqzKkCED2nU+oao= +github.com/kuttiproject/drivercore v0.1.2-0.20210521121704-f5c15ad420be/go.mod h1:1C1TMGfQ4u/5ltHUtl4hL7gYzXZyZQVhkOha4GuOzk0= +github.com/kuttiproject/drivercore v0.1.2-0.20210522083351-9e2ab7988ba8 h1:73chuO91KB2NsO//oswwnO67hPq5yV1h79+LA6qpYUk= +github.com/kuttiproject/drivercore v0.1.2-0.20210522083351-9e2ab7988ba8/go.mod h1:1C1TMGfQ4u/5ltHUtl4hL7gYzXZyZQVhkOha4GuOzk0= +github.com/kuttiproject/drivercore v0.1.2-0.20210522083802-3f98a45d766b h1:UZ4oQoBJS/zvlepjokY7IW0kPHiQBGe+McIrGM83wOI= +github.com/kuttiproject/drivercore v0.1.2-0.20210522083802-3f98a45d766b/go.mod h1:1C1TMGfQ4u/5ltHUtl4hL7gYzXZyZQVhkOha4GuOzk0= +github.com/kuttiproject/drivercore v0.1.2 h1:t/AKAh48130SgOGi2kNmuoi3KkaIpHZx+vL6tqKsPGY= +github.com/kuttiproject/drivercore v0.1.2/go.mod h1:1C1TMGfQ4u/5ltHUtl4hL7gYzXZyZQVhkOha4GuOzk0= +github.com/kuttiproject/kuttilog v0.1.0/go.mod h1:OO3dHpXm1/Pjlc57R4c0e/C+ZWkYlY3Fd9Ikn8xPXi4= +github.com/kuttiproject/kuttilog v0.1.1-0.20210521082833-2433af846351 h1:yRzDwvKLoNafjwswrt5uB4GUJxvZbM0DiUFwpzjWrQE= +github.com/kuttiproject/kuttilog v0.1.1-0.20210521082833-2433af846351/go.mod h1:OO3dHpXm1/Pjlc57R4c0e/C+ZWkYlY3Fd9Ikn8xPXi4= +github.com/kuttiproject/kuttilog v0.1.1 h1:cXF0zSVnVBUJzT2DSnIF8SKAJ4GUHazkiOLOFgeKXbg= +github.com/kuttiproject/kuttilog v0.1.1/go.mod h1:OO3dHpXm1/Pjlc57R4c0e/C+ZWkYlY3Fd9Ikn8xPXi4= +github.com/kuttiproject/workspace v0.1.0 h1:lwv8Nc9oMgVif2Vki626YhpZdPkYQgPqyx6xTSAGwsA= +github.com/kuttiproject/workspace v0.1.0/go.mod h1:qXc3SjY9Hl/VjCzSAN+euXe8NvyOXtYlpCRT+hp8OTQ= +github.com/kuttiproject/workspace v0.1.1-0.20210521090229-01bd56f7afb8 h1:nHoC52F9QUupuGjIiihmGqAfhUytRVYbTrs4VaZuw4Q= +github.com/kuttiproject/workspace v0.1.1-0.20210521090229-01bd56f7afb8/go.mod h1:qXc3SjY9Hl/VjCzSAN+euXe8NvyOXtYlpCRT+hp8OTQ= +github.com/kuttiproject/workspace v0.2.0 h1:PCD9TXA2eDFCeChRYRBKbe2ZPvj+AOZylZb/qGGZJKQ= +github.com/kuttiproject/workspace v0.2.0/go.mod h1:OY89kCzN3mlCk+hnXlkniYT5F9v4jqQxj0cdgKG4scI= diff --git a/image.go b/image.go new file mode 100644 index 0000000..dc7cde6 --- /dev/null +++ b/image.go @@ -0,0 +1,84 @@ +package drivervbox + +import ( + "fmt" + "path" + + "github.com/kuttiproject/drivercore" + "github.com/kuttiproject/workspace" +) + +// Image implements the drivercore.Image interface for VirtualBox. +type Image struct { + ImageK8sVersion string + ImageChecksum string + ImageSourceURL string + ImageStatus drivercore.ImageStatus + ImageDeprecated bool +} + +// K8sVersion returns the version of Kubernetes present in the image. +func (i *Image) K8sVersion() string { + return i.ImageK8sVersion +} + +// Status returns the status of the image. +// Status can be Downloaded, meaning the image exists in the local cache and can +// be used to create Machines, or Notdownloaded, meaning it has to be downloaded +// using Fetch. +func (i *Image) Status() drivercore.ImageStatus { + return i.ImageStatus +} + +// Deprecated returns true if the image's version of Kubenetes is deprecated. +// New Macines should not be created from such an image. +func (i *Image) Deprecated() bool { + return i.ImageDeprecated +} + +// Fetch downloads the image from its source URL. +func (i *Image) Fetch() error { + cachedir, err := vboxCacheDir() + if err != nil { + return err + } + + tempfilename := fmt.Sprintf("kutti-k8s-%s.ovadownload", i.ImageK8sVersion) + tempfilepath := path.Join(cachedir, tempfilename) + + // Download file + err = workspace.DownloadFile(i.ImageSourceURL, tempfilepath) + if err != nil { + return err + } + defer workspace.RemoveFile(tempfilepath) + + // Add + return i.FromFile(tempfilepath) +} + +// FromFile verifies an image file on a local path and copies it to the cache. +func (i *Image) FromFile(filepath string) error { + err := addfromfile(i.ImageK8sVersion, filepath, i.ImageChecksum) + if err != nil { + return err + } + + i.ImageStatus = drivercore.ImageStatusDownloaded + return imageconfigmanager.Save() +} + +// PurgeLocal removes the local cached copy of an image. +func (i *Image) PurgeLocal() error { + if i.ImageStatus == drivercore.ImageStatusDownloaded { + err := removefile(i.K8sVersion()) + if err == nil { + i.ImageStatus = drivercore.ImageStatusNotDownloaded + + return imageconfigmanager.Save() + } + return err + } + + return nil +} diff --git a/machine-commands.go b/machine-commands.go new file mode 100644 index 0000000..447426f --- /dev/null +++ b/machine-commands.go @@ -0,0 +1,58 @@ +package drivervbox + +import ( + "fmt" + + "github.com/kuttiproject/drivercore" + "github.com/kuttiproject/workspace" +) + +// TODO: Look at parameterizing these +var ( + vboxUsername = "kuttiadmin" + vboxPassword = "Pass@word1" +) + +// runwithresults allows running commands inside a VM Host. +// It does this by running the command: +// - VBoxManage guestcontrol --username --password run -- +// This requires Virtual Machine Additions to be running in the guest operating system. +// The guest OS should be fully booted up. +func (vh *Machine) runwithresults(execpath string, paramarray ...string) (string, error) { + params := []string{ + "guestcontrol", + vh.qname(), + "--username", + vboxUsername, + "--password", + vboxPassword, + "run", + "--", + execpath, + } + params = append(params, paramarray...) + + output, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + params..., + ) + + return output, err +} + +var vboxCommands = map[drivercore.PredefinedCommand]func(*Machine, ...string) error{ + drivercore.RenameMachine: renamemachine, +} + +func renamemachine(vh *Machine, params ...string) error { + newname := params[0] + execname := fmt.Sprintf("/home/%s/kutti-installscripts/set-hostname.sh", vboxUsername) + + _, err := vh.runwithresults( + "/usr/bin/sudo", + execname, + newname, + ) + + return err +} diff --git a/machine-properties.go b/machine-properties.go new file mode 100644 index 0000000..e596dfe --- /dev/null +++ b/machine-properties.go @@ -0,0 +1,135 @@ +package drivervbox + +import ( + "fmt" + "regexp" + "strings" + + "github.com/kuttiproject/drivercore" + "github.com/kuttiproject/workspace" +) + +const ( + propIPAddress = "/VirtualBox/GuestInfo/Net/0/V4/IP" + propIPAddress2 = "/VirtualBox/GuestInfo/Net/1/V4/IP" + propIPAddress3 = "/VirtualBox/GuestInfo/Net/2/V4/IP" + propLoggedInUsers = "/VirtualBox/GuestInfo/OS/LoggedInUsers" + propSSHAddress = "/kutti/VMInfo/SSHAddress" + propSavedIPAddress = "/kutti/VMInfo/SavedIPAddress" +) + +var ( + properrorpattern, _ = regexp.Compile("error: (.*)\n") + proppattern, _ = regexp.Compile("Name: (.*), value: (.*), timestamp: (.*), flags:(.*)\n") +) + +// When properties are parsed by parseprop(), certain properties +// can cause an action to be taken. This map contains the names of some +// VirtualBox properties, and correspoding actions. +var propMap = map[string]func(*Machine, string){ + propLoggedInUsers: func(vh *Machine, value string) { + vh.status = drivercore.MachineStatusRunning + }, + propSavedIPAddress: func(vh *Machine, value string) { + vh.savedipaddress = trimpropend(value) + }, +} + +func (vh *Machine) getproperty(propname string) (string, bool) { + output, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + "guestproperty", + "get", + vh.qname(), + propname, + ) + + // VBoxManage guestproperty gets the hardcoded value "No value set!" + // if the property value cannot be retrieved + if err != nil || output == "No value set!" || output == "No value set!\n" { + return "", false + } + + // Output is in the format + // Value: + // So, 7th rune onwards + return output[7:], true +} + +func (vh *Machine) setproperty(propname string, value string) error { + _, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + "guestproperty", + "set", + vh.qname(), + propname, + value, + ) + + if err != nil { + // TODO: Error consolidation + return fmt.Errorf( + "could not set property %s for host %s: %v", + propname, + vh.name, + err, + ) + } + + return nil +} + +func (vh *Machine) unsetproperty(propname string) error { + _, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + "guestproperty", + "unset", + vh.qname(), + propname, + ) + + if err != nil { + return fmt.Errorf( + "could not unset property %s for host %s: %v", + propname, + vh.name, + err, + ) + } + + return nil +} + +func trimpropend(s string) string { + return strings.TrimSpace(s) +} + +func (vh *Machine) parseProps(propstr string) { + // There are two possibilities. Either: + // VBoxManage: error: Could not find a registered machine named 'xxx' + // ... + // Or: + // Name: /VirtualBox/GuestInfo/Net/0/V4/IP, value: 10.0.2.15, timestamp: 1568552111298588000, flags: + // ... + + // This should not have made it this far. Still, + // belt and suspenders... + errorsfound := properrorpattern.FindAllStringSubmatch(propstr, 1) + if len(errorsfound) != 0 { + // deal with the error with: + // errorsfound[0][1] + vh.status = drivercore.MachineStatusError + vh.errormessage = errorsfound[0][1] + return + } + + results := proppattern.FindAllStringSubmatch(propstr, -1) + for _, record := range results { + // record[1] - Name and record[2] - Value + // Run any configured action for a property + action, ok := propMap[record[1]] + if ok { + action(vh, record[2]) + } + } +} diff --git a/machine.go b/machine.go new file mode 100644 index 0000000..7a27299 --- /dev/null +++ b/machine.go @@ -0,0 +1,314 @@ +package drivervbox + +import ( + "fmt" + + "github.com/kuttiproject/drivercore" + "github.com/kuttiproject/workspace" +) + +// Machine implements the drivercore.Machine interface for VirtualBox +type Machine struct { + driver *Driver + + name string + // netname string + clustername string + savedipaddress string + status drivercore.MachineStatus + errormessage string +} + +// Name is the name of the machine. +func (vh *Machine) Name() string { + return vh.name +} + +func (vh *Machine) qname() string { + return vh.driver.QualifiedMachineName(vh.name, vh.clustername) +} + +func (vh *Machine) netname() string { + return vh.driver.QualifiedNetworkName(vh.clustername) +} + +// Status can be drivercore.MachineStatusRunning, drivercore.MachineStatusStopped +// drivercore.MachineStatusUnknown or drivercore.MachineStatusError. +func (vh *Machine) Status() drivercore.MachineStatus { + return vh.status +} + +// Error returns the last error caused when manipulating this machine. +// A valid value can be expected only when Status() returns +// drivercore.MachineStatusError. +func (vh *Machine) Error() string { + return vh.errormessage +} + +// IPAddress returns the current IP Address of this Machine. +// The Machine status has to be Running. +func (vh *Machine) IPAddress() string { + // This guestproperty is only available if the VM is + // running, and has the Virtual Machine additions enabled + result, _ := vh.getproperty(propIPAddress) + return trimpropend(result) +} + +// SSHAddress returns the address and port number to SSH into this Machine. +func (vh *Machine) SSHAddress() string { + // This guestproperty is set when the SSH port is forwarded + result, _ := vh.getproperty(propSSHAddress) + return trimpropend(result) +} + +// Start starts a Machine. +// It does this by running the command: +// VBoxManage startvm --type headless +// Note that a Machine may not be ready for further operations at the end of this, +// and therefore its status will not change. +// See WaitForStateChange(). +func (vh *Machine) Start() error { + output, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + "startvm", + vh.qname(), + "--type", + "headless", + ) + + if err != nil { + return fmt.Errorf("could not start the host '%s': %v. Output was %s", vh.name, err, output) + } + + return nil +} + +// Stop stops a Machine. +// It does this by running the command: +// VBoxManage controlvm acpipowerbutton +// Note that a Machine may not be ready for further operations at the end of this, +// and therefore its status will not change. +// See WaitForStateChange(). +func (vh *Machine) Stop() error { + _, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + "controlvm", + vh.qname(), + "acpipowerbutton", + ) + + if err != nil { + return fmt.Errorf("could not stop the host '%s': %v", vh.name, err) + } + + // Big risk. Deleteing the LoggedInUser property that is used + // to check running status. Should be ok, because starting a + // VirtualBox VM is supposed to recreate that property. + vh.unsetproperty(propLoggedInUsers) + + return nil +} + +// ForceStop stops a Machine forcibly. +// It does this by running the command: +// VBoxManage controlvm poweroff +// This operation will set the status to drivercore.MachineStatusStopped. +func (vh *Machine) ForceStop() error { + _, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + "controlvm", + vh.qname(), + "poweroff", + ) + + if err != nil { + return fmt.Errorf("could not force stop the host '%s': %v", vh.name, err) + } + + // Big risk. Deleteing the LoggedInUser property that is used + // to check running status. Should be ok, because starting a + // VM host is supposed to recreate that property. + vh.unsetproperty(propLoggedInUsers) + + vh.status = drivercore.MachineStatusStopped + return nil +} + +// WaitForStateChange waits the specified number of seconds, +// or until the Machine status changes. +// It does this by running the command: +// VBoxManage guestproperty wait /VirtualBox/GuestInfo/OS/LoggedInUsers --timeout --fail-on-timeout +// WaitForStateChange should be called after a call to Start, before +// any other operation. From observation, it should not be called _before_ Stop. +func (vh *Machine) WaitForStateChange(timeoutinseconds int) { + workspace.Runwithresults( + vh.driver.vboxmanagepath, + "guestproperty", + "wait", + vh.qname(), + propLoggedInUsers, + "--timeout", + fmt.Sprintf("%v", timeoutinseconds*1000), + "--fail-on-timeout", + ) + + vh.get() +} + +func (vh *Machine) forwardingrulename(machineport int) string { + return fmt.Sprintf("Node %s Port %d", vh.qname(), machineport) +} + +// ForwardPort creates a rule to forward the specified VM host port to the +// specified physical host port. It does this by running the command: +// VBoxManage natnetwork modify --netname --port-forward-4 +// Port forwarding rule format is: +// ::[]::[]: +// The brackets [] are to be taken literally. +// This driver writes the rule name as "Node Port ". +// +// So a sample rule would look like this: +// +// Node node1 Port 80:TCP:[]:18080:[192.168.125.11]:80 +func (vh *Machine) ForwardPort(hostport int, machineport int) error { + forwardingrule := fmt.Sprintf( + "%s:tcp:[]:%d:[%s]:%d", + vh.forwardingrulename(machineport), + hostport, + vh.savedipAddress(), + machineport, + ) + + _, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + "natnetwork", + "modify", + "--netname", + vh.netname(), + "--port-forward-4", + forwardingrule, + ) + + if err != nil { + return fmt.Errorf( + "could not create port forwarding rule %s for node %s on network %s: %v", + forwardingrule, + vh.name, + vh.netname(), + err, + ) + } + + return nil +} + +// UnforwardPort removes the rule which forwarded the specified VM host port. +// It does this by running the command: +// VBoxManage natnetwork modify --netname --port-forward-4 delete +// This driver writes the rule name as "Node Port ". +func (vh *Machine) UnforwardPort(machineport int) error { + rulename := vh.forwardingrulename(machineport) + _, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + "natnetwork", + "modify", + "--netname", + vh.netname(), + "--port-forward-4", + "delete", + rulename, + ) + + if err != nil { + return fmt.Errorf( + "driver returned error while removing port forwarding rule %s for VM %s on network %s: %v", + rulename, + vh.name, + vh.netname(), + err, + ) + } + + return nil +} + +// ForwardSSHPort forwards the SSH port of this Machine to the specified +// physical host port. See ForwardPort() for details. +func (vh *Machine) ForwardSSHPort(hostport int) error { + err := vh.ForwardPort(hostport, 22) + if err != nil { + return fmt.Errorf( + "could not create SSH port forwarding rule for node %s on network %s: %v", + vh.name, + vh.netname(), + err, + ) + } + + sshaddress := fmt.Sprintf("localhost:%d", hostport) + err = vh.setproperty( + propSSHAddress, + sshaddress, + ) + if err != nil { + return fmt.Errorf( + "could not save SSH address for node %s : %v", + vh.name, + err, + ) + } + + return nil +} + +// ImplementsCommand returns true if the driver implements the specified predefined command. +// The vbox driver implements drivercore.RenameMachine +func (vh *Machine) ImplementsCommand(command drivercore.PredefinedCommand) bool { + _, ok := vboxCommands[command] + return ok + +} + +// ExecuteCommand executes the specified predefined command. +func (vh *Machine) ExecuteCommand(command drivercore.PredefinedCommand, params ...string) error { + commandfunc, ok := vboxCommands[command] + if !ok { + return fmt.Errorf( + "command '%v' not implemented", + command, + ) + } + + return commandfunc(vh, params...) +} + +func (vh *Machine) get() error { + output, err := workspace.Runwithresults( + vh.driver.vboxmanagepath, + "guestproperty", + "enumerate", + vh.qname(), + "--patterns", + "/VirtualBox/GuestInfo/Net/0/*|/kutti/*|/VirtualBox/GuestInfo/OS/LoggedInUsers", + ) + + if err != nil { + return fmt.Errorf("machine %s not found", vh.name) + } + + if output != "" { + vh.parseProps(output) + } + + return nil +} + +func (vh *Machine) savedipAddress() string { + // This guestproperty is set when the VM is created + if vh.savedipaddress != "" { + return vh.savedipaddress + } + + result, _ := vh.getproperty(propSavedIPAddress) + return trimpropend(result) +} diff --git a/network.go b/network.go new file mode 100644 index 0000000..0347535 --- /dev/null +++ b/network.go @@ -0,0 +1,22 @@ +package drivervbox + +// Network implements the VMNetwork interface for VirtualBox. +type Network struct { + name string + netCIDR string +} + +// Name is the name of the network. +func (vn *Network) Name() string { + return vn.name +} + +// CIDR is the network's IPv4 address range. +func (vn *Network) CIDR() string { + return vn.netCIDR +} + +// SetCIDR is not implemented for the VirtualBox driver. +func (vn *Network) SetCIDR(cidr string) { + panic("not implemented") +}