674 lines
19 KiB
Go
674 lines
19 KiB
Go
package common
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"net"
|
|
"os"
|
|
"os/exec"
|
|
"regexp"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/hashicorp/packer-plugin-sdk/multistep"
|
|
)
|
|
|
|
// A driver is able to talk to VMware, control virtual machines, etc.
|
|
type Driver interface {
|
|
// Clone clones the VMX and the disk to the destination path. The
|
|
// destination is a path to the VMX file. The disk will be copied
|
|
// to that same directory.
|
|
Clone(dst string, src string, cloneType bool, snapshot string) error
|
|
|
|
// CompactDisk compacts a virtual disk.
|
|
CompactDisk(string) error
|
|
|
|
// CreateDisk creates a virtual disk with the given size.
|
|
CreateDisk(string, string, string, string) error
|
|
|
|
// Checks if the VMX file at the given path is running.
|
|
IsRunning(string) (bool, error)
|
|
|
|
// Start starts a VM specified by the path to the VMX given.
|
|
Start(string, bool) error
|
|
|
|
// Stop stops a VM specified by the path to the VMX given.
|
|
Stop(string) error
|
|
|
|
// SuppressMessages modifies the VMX or surrounding directory so that
|
|
// VMware doesn't show any annoying messages.
|
|
SuppressMessages(string) error
|
|
|
|
// Get the path to the VMware ISO for the given flavor.
|
|
ToolsIsoPath(string) string
|
|
|
|
// Attach the VMware tools ISO
|
|
ToolsInstall() error
|
|
|
|
// Verify checks to make sure that this driver should function
|
|
// properly. This should check that all the files it will use
|
|
// appear to exist and so on. If everything is okay, this doesn't
|
|
// return an error. Otherwise, this returns an error. Each vmware
|
|
// driver should assign the VmwareMachine callback functions for locating
|
|
// paths within this function.
|
|
Verify() error
|
|
|
|
/// This is to establish a connection to the guest
|
|
CommHost(multistep.StateBag) (string, error)
|
|
|
|
/// These methods are generally implemented by the VmwareDriver
|
|
/// structure within this file. A driver implementation can
|
|
/// reimplement these, though, if it wants.
|
|
GetVmwareDriver() VmwareDriver
|
|
|
|
// Get the guest hw address for the vm
|
|
GuestAddress(multistep.StateBag) (string, error)
|
|
|
|
// Get the guest ip address for the vm
|
|
PotentialGuestIP(multistep.StateBag) ([]string, error)
|
|
|
|
// Get the host hw address for the vm
|
|
HostAddress(multistep.StateBag) (string, error)
|
|
|
|
// Get the host ip address for the vm
|
|
HostIP(multistep.StateBag) (string, error)
|
|
|
|
// Export the vm to ovf or ova format using ovftool
|
|
Export([]string) error
|
|
|
|
// OvfTool
|
|
VerifyOvfTool(bool, bool) error
|
|
}
|
|
|
|
// NewDriver returns a new driver implementation for this operating
|
|
// system, or an error if the driver couldn't be initialized.
|
|
func NewDriver(dconfig *DriverConfig, config *SSHConfig, vmName string) (Driver, error) {
|
|
drivers := []Driver{}
|
|
|
|
if dconfig.RemoteType != "" {
|
|
esx5Driver, err := NewESX5Driver(dconfig, config, vmName)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
drivers = []Driver{esx5Driver}
|
|
|
|
} else {
|
|
switch runtime.GOOS {
|
|
case "darwin":
|
|
drivers = []Driver{
|
|
NewFusion6Driver(dconfig, config),
|
|
NewFusion5Driver(dconfig, config),
|
|
}
|
|
case "linux":
|
|
fallthrough
|
|
case "windows":
|
|
drivers = []Driver{
|
|
NewWorkstation10Driver(config),
|
|
NewWorkstation9Driver(config),
|
|
NewPlayer6Driver(config),
|
|
NewPlayer5Driver(config),
|
|
}
|
|
default:
|
|
return nil, fmt.Errorf("can't find driver for OS: %s", runtime.GOOS)
|
|
}
|
|
}
|
|
|
|
errs := ""
|
|
for _, driver := range drivers {
|
|
err := driver.Verify()
|
|
|
|
log.Printf("Testing against vmware driver %T, Success: %t", driver, err == nil)
|
|
if err == nil {
|
|
return driver, nil
|
|
}
|
|
|
|
log.Printf("skipping %T because it failed with the following error %s", driver, err)
|
|
errs += "* " + err.Error() + "\n"
|
|
}
|
|
|
|
return nil, fmt.Errorf(
|
|
"Unable to initialize any driver for this platform. The errors\n"+
|
|
"from each driver are shown below. Please fix at least one driver\n"+
|
|
"to continue:\n%s", errs)
|
|
}
|
|
|
|
func runAndLog(cmd *exec.Cmd) (string, string, error) {
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
log.Printf("Executing: %s %s", cmd.Path, strings.Join(cmd.Args[1:], " "))
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
err := cmd.Run()
|
|
|
|
stdoutString := strings.TrimSpace(stdout.String())
|
|
stderrString := strings.TrimSpace(stderr.String())
|
|
|
|
if _, ok := err.(*exec.ExitError); ok {
|
|
message := stderrString
|
|
if message == "" {
|
|
message = stdoutString
|
|
}
|
|
|
|
err = fmt.Errorf("VMware error: %s", message)
|
|
|
|
// If "unknown error" is in there, add some additional notes
|
|
re := regexp.MustCompile(`(?i)unknown error`)
|
|
if re.MatchString(message) {
|
|
err = fmt.Errorf(
|
|
"%s\n\n%s", err,
|
|
"Packer detected a VMware 'Unknown Error'. Unfortunately VMware\n"+
|
|
"often has extremely vague error messages such as this and Packer\n"+
|
|
"itself can't do much about that. Please check the vmware.log files\n"+
|
|
"created by VMware when a VM is started (in the directory of the\n"+
|
|
"vmx file), which often contains more detailed error information.\n\n"+
|
|
"You may need to set the command line flag --on-error=abort to\n\n"+
|
|
"prevent Packer from cleaning up the vmx file directory.")
|
|
}
|
|
}
|
|
|
|
log.Printf("stdout: %s", stdoutString)
|
|
log.Printf("stderr: %s", stderrString)
|
|
|
|
// Replace these for Windows, we only want to deal with Unix
|
|
// style line endings.
|
|
returnStdout := strings.Replace(stdout.String(), "\r\n", "\n", -1)
|
|
returnStderr := strings.Replace(stderr.String(), "\r\n", "\n", -1)
|
|
|
|
return returnStdout, returnStderr, err
|
|
}
|
|
|
|
func normalizeVersion(version string) (string, error) {
|
|
i, err := strconv.Atoi(version)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"VMware version '%s' is not numeric", version)
|
|
}
|
|
|
|
return fmt.Sprintf("%02d", i), nil
|
|
}
|
|
|
|
func compareVersions(versionFound string, versionWanted string, product string) error {
|
|
found, err := normalizeVersion(versionFound)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
wanted, err := normalizeVersion(versionWanted)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if found < wanted {
|
|
return fmt.Errorf(
|
|
"VMware %s version %s, or greater, is required. Found version: %s", product, versionWanted, versionFound)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// helper functions that read configuration information from a file
|
|
// read the network<->device configuration out of the specified path
|
|
func ReadNetmapConfig(path string) (NetworkMap, error) {
|
|
fd, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer fd.Close()
|
|
return ReadNetworkMap(fd)
|
|
}
|
|
|
|
// read the dhcp configuration out of the specified path
|
|
func ReadDhcpConfig(path string) (DhcpConfiguration, error) {
|
|
fd, err := os.Open(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer fd.Close()
|
|
return ReadDhcpConfiguration(fd)
|
|
}
|
|
|
|
// read the VMX configuration from the specified path
|
|
func readVMXConfig(path string) (map[string]string, error) {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return map[string]string{}, err
|
|
}
|
|
defer f.Close()
|
|
|
|
vmxBytes, err := ioutil.ReadAll(f)
|
|
if err != nil {
|
|
return map[string]string{}, err
|
|
}
|
|
return ParseVMX(string(vmxBytes)), nil
|
|
}
|
|
|
|
// read the connection type out of a vmx configuration
|
|
func readCustomDeviceName(vmxData map[string]string) (string, error) {
|
|
|
|
connectionType, ok := vmxData["ethernet0.connectiontype"]
|
|
if !ok || connectionType != "custom" {
|
|
return "", fmt.Errorf("Unable to determine the device name for the connection type : %s", connectionType)
|
|
}
|
|
|
|
device, ok := vmxData["ethernet0.vnet"]
|
|
if !ok || device == "" {
|
|
return "", fmt.Errorf("Unable to determine the device name for the connection type \"%s\" : %s", connectionType, device)
|
|
}
|
|
return device, nil
|
|
}
|
|
|
|
// This VmwareDriver is a base class that contains default methods
|
|
// that a Driver can use or implement themselves.
|
|
type VmwareDriver struct {
|
|
/// These methods define paths that are utilized by the driver
|
|
/// A driver must overload these in order to point to the correct
|
|
/// files so that the address detection (ip and ethernet) machinery
|
|
/// works.
|
|
DhcpLeasesPath func(string) string
|
|
DhcpConfPath func(string) string
|
|
VmnetnatConfPath func(string) string
|
|
|
|
/// This method returns an object with the NetworkNameMapper interface
|
|
/// that maps network to device and vice-versa.
|
|
NetworkMapper func() (NetworkNameMapper, error)
|
|
}
|
|
|
|
func (d *VmwareDriver) GuestAddress(state multistep.StateBag) (string, error) {
|
|
vmxPath := state.Get("vmx_path").(string)
|
|
|
|
log.Println("Lookup up IP information...")
|
|
vmxData, err := readVMXConfig(vmxPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var ok bool
|
|
macAddress := ""
|
|
if macAddress, ok = vmxData["ethernet0.address"]; !ok || macAddress == "" {
|
|
if macAddress, ok = vmxData["ethernet0.generatedaddress"]; !ok || macAddress == "" {
|
|
return "", errors.New("couldn't find MAC address in VMX")
|
|
}
|
|
}
|
|
log.Printf("GuestAddress found MAC address in VMX: %s", macAddress)
|
|
|
|
res, err := net.ParseMAC(macAddress)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return res.String(), nil
|
|
}
|
|
|
|
func (d *VmwareDriver) PotentialGuestIP(state multistep.StateBag) ([]string, error) {
|
|
|
|
// grab network mapper
|
|
netmap, err := d.NetworkMapper()
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
|
|
// convert the stashed network to a device
|
|
network := state.Get("vmnetwork").(string)
|
|
devices, err := netmap.NameIntoDevices(network)
|
|
|
|
// log them to see what was detected
|
|
for _, device := range devices {
|
|
log.Printf("GuestIP discovered device matching %s: %s", network, device)
|
|
}
|
|
|
|
// we were unable to find the device, maybe it's a custom one...
|
|
// so, check to see if it's in the .vmx configuration
|
|
if err != nil || network == "custom" {
|
|
vmxPath := state.Get("vmx_path").(string)
|
|
vmxData, err := readVMXConfig(vmxPath)
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
|
|
var device string
|
|
device, err = readCustomDeviceName(vmxData)
|
|
devices = append(devices, device)
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
log.Printf("GuestIP discovered custom device matching %s: %s", network, device)
|
|
}
|
|
|
|
// figure out our MAC address for looking up the guest address
|
|
MACAddress, err := d.GuestAddress(state)
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
|
|
// iterate through all of the devices and collect all the dhcp lease entries
|
|
// that we possibly cacn.
|
|
var available_lease_entries []dhcpLeaseEntry
|
|
|
|
for _, device := range devices {
|
|
// figure out the correct dhcp leases
|
|
dhcpLeasesPath := d.DhcpLeasesPath(device)
|
|
log.Printf("Trying DHCP leases path: %s", dhcpLeasesPath)
|
|
if dhcpLeasesPath == "" {
|
|
return []string{}, fmt.Errorf("no DHCP leases path found for device %s", device)
|
|
}
|
|
|
|
// open up the path to the dhcpd leases
|
|
fh, err := os.Open(dhcpLeasesPath)
|
|
if err != nil {
|
|
log.Printf("Error while reading DHCP lease path file %s: %s", dhcpLeasesPath, err.Error())
|
|
continue
|
|
}
|
|
defer fh.Close()
|
|
|
|
// and then read its contents
|
|
leaseEntries, err := ReadDhcpdLeaseEntries(fh)
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
|
|
// Parse our MAC address again. There's no need to check for an
|
|
// error because we've already parsed this successfully.
|
|
hwaddr, _ := net.ParseMAC(MACAddress)
|
|
|
|
// Go through our available lease entries and see which ones are within
|
|
// scope, and that match to our hardware address.
|
|
results := make([]dhcpLeaseEntry, 0)
|
|
for _, entry := range leaseEntries {
|
|
|
|
// First check for leases that are still valid. The timestamp for
|
|
// each lease should be in UTC according to the documentation at
|
|
// the top of VMWare's dhcpd.leases file.
|
|
now := time.Now().UTC()
|
|
if !(now.After(entry.starts) && now.Before(entry.ends)) {
|
|
continue
|
|
}
|
|
|
|
// Next check for any where the hardware address matches.
|
|
if !bytes.Equal(hwaddr, entry.ether) {
|
|
continue
|
|
}
|
|
|
|
// This entry fits within our constraints, so store it so we can
|
|
// check it out later.
|
|
results = append(results, entry)
|
|
}
|
|
|
|
// If we weren't able to grab any results, then we'll do a "loose"-match
|
|
// where we only look for anything where the hardware address matches.
|
|
if len(results) == 0 {
|
|
log.Printf("Unable to find an exact match for DHCP lease. Falling back to a loose match for hw address %v", MACAddress)
|
|
for _, entry := range leaseEntries {
|
|
if bytes.Equal(hwaddr, entry.ether) {
|
|
results = append(results, entry)
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we found something, then we need to add it to our current list
|
|
// of lease entries.
|
|
if len(results) > 0 {
|
|
available_lease_entries = append(available_lease_entries, results...)
|
|
}
|
|
|
|
// Now we need to map our results to get the address so we can return it.iterate through our results and figure out which one
|
|
// is actually up...and should be relevant.
|
|
}
|
|
|
|
// Check if we found any lease entries that correspond to us. If so, then we
|
|
// need to map() them in order to extract the address field to return to the
|
|
// caller.
|
|
if len(available_lease_entries) > 0 {
|
|
addrs := make([]string, 0)
|
|
for _, entry := range available_lease_entries {
|
|
addrs = append(addrs, entry.address)
|
|
}
|
|
return addrs, nil
|
|
}
|
|
|
|
if runtime.GOOS == "darwin" {
|
|
// We have match no vmware DHCP lease for this MAC. We'll try to match it in Apple DHCP leases.
|
|
// As a remember, VMWare is no longer able to rely on its own dhcpd server on MacOS BigSur and is
|
|
// forced to use Apple DHCPD server instead.
|
|
// https://communities.vmware.com/t5/VMware-Fusion-Discussions/Big-Sur-hosts-with-Fusion-Is-vmnet-dhcpd-vmnet8-leases-file/m-p/2298927/highlight/true#M140003
|
|
|
|
// set the apple dhcp leases path
|
|
appleDhcpLeasesPath := "/var/db/dhcpd_leases"
|
|
log.Printf("Trying Apple DHCP leases path: %s", appleDhcpLeasesPath)
|
|
|
|
// open up the path to the apple dhcpd leases
|
|
fh, err := os.Open(appleDhcpLeasesPath)
|
|
if err != nil {
|
|
log.Printf("Error while reading apple DHCP lease path file %s: %s", appleDhcpLeasesPath, err.Error())
|
|
} else {
|
|
defer fh.Close()
|
|
|
|
// and then read its contents
|
|
leaseEntries, err := ReadAppleDhcpdLeaseEntries(fh)
|
|
if err != nil {
|
|
return []string{}, err
|
|
}
|
|
|
|
// Parse our MAC address again. There's no need to check for an
|
|
// error because we've already parsed this successfully.
|
|
hwaddr, _ := net.ParseMAC(MACAddress)
|
|
|
|
// Go through our available lease entries and see which ones are within
|
|
// scope, and that match to our hardware address.
|
|
available_lease_entries := make([]appleDhcpLeaseEntry, 0)
|
|
for _, entry := range leaseEntries {
|
|
// Next check for any where the hardware address matches.
|
|
if bytes.Equal(hwaddr, entry.hwAddress) {
|
|
available_lease_entries = append(available_lease_entries, entry)
|
|
}
|
|
}
|
|
|
|
// Check if we found any lease entries that correspond to us. If so, then we
|
|
// need to map() them in order to extract the address field to return to the
|
|
// caller.
|
|
if len(available_lease_entries) > 0 {
|
|
addrs := make([]string, 0)
|
|
for _, entry := range available_lease_entries {
|
|
addrs = append(addrs, entry.ipAddress)
|
|
}
|
|
return addrs, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return []string{}, fmt.Errorf("None of the found device(s) %v has a DHCP lease for MAC %s", devices, MACAddress)
|
|
}
|
|
|
|
func (d *VmwareDriver) HostAddress(state multistep.StateBag) (string, error) {
|
|
|
|
// grab mapper for converting network<->device
|
|
netmap, err := d.NetworkMapper()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// convert network to name
|
|
network := state.Get("vmnetwork").(string)
|
|
devices, err := netmap.NameIntoDevices(network)
|
|
|
|
// log them to see what was detected
|
|
for _, device := range devices {
|
|
log.Printf("HostAddress discovered device matching %s: %s", network, device)
|
|
}
|
|
|
|
// we were unable to find the device, maybe it's a custom one...
|
|
// so, check to see if it's in the .vmx configuration
|
|
if err != nil || network == "custom" {
|
|
vmxPath := state.Get("vmx_path").(string)
|
|
vmxData, err := readVMXConfig(vmxPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var device string
|
|
device, err = readCustomDeviceName(vmxData)
|
|
devices = append(devices, device)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
log.Printf("HostAddress discovered custom device matching %s: %s", network, device)
|
|
}
|
|
|
|
var lastError error
|
|
for _, device := range devices {
|
|
// parse dhcpd configuration
|
|
pathDhcpConfig := d.DhcpConfPath(device)
|
|
if _, err := os.Stat(pathDhcpConfig); err != nil {
|
|
return "", fmt.Errorf("Could not find vmnetdhcp conf file: %s", pathDhcpConfig)
|
|
}
|
|
|
|
config, err := ReadDhcpConfig(pathDhcpConfig)
|
|
if err != nil {
|
|
lastError = err
|
|
continue
|
|
}
|
|
|
|
// find the entry configured in the dhcpd
|
|
interfaceConfig, err := config.HostByName(device)
|
|
if err != nil {
|
|
lastError = err
|
|
continue
|
|
}
|
|
|
|
// finally grab the hardware address
|
|
address, err := interfaceConfig.Hardware()
|
|
if err == nil {
|
|
return address.String(), nil
|
|
}
|
|
|
|
// we didn't find it, so search through our interfaces for the device name
|
|
interfaceList, err := net.Interfaces()
|
|
if err == nil {
|
|
return "", err
|
|
}
|
|
|
|
names := make([]string, 0)
|
|
for _, intf := range interfaceList {
|
|
if strings.HasSuffix(strings.ToLower(intf.Name), device) {
|
|
return intf.HardwareAddr.String(), nil
|
|
}
|
|
names = append(names, intf.Name)
|
|
}
|
|
}
|
|
return "", fmt.Errorf("Unable to find host address from devices %v, last error: %s", devices, lastError)
|
|
}
|
|
|
|
func (d *VmwareDriver) HostIP(state multistep.StateBag) (string, error) {
|
|
|
|
// grab mapper for converting network<->device
|
|
netmap, err := d.NetworkMapper()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// convert network to name
|
|
network := state.Get("vmnetwork").(string)
|
|
devices, err := netmap.NameIntoDevices(network)
|
|
|
|
// log them to see what was detected
|
|
for _, device := range devices {
|
|
log.Printf("HostIP discovered device matching %s: %s", network, device)
|
|
}
|
|
|
|
// we were unable to find the device, maybe it's a custom one...
|
|
// so, check to see if it's in the .vmx configuration
|
|
if err != nil || network == "custom" {
|
|
vmxPath := state.Get("vmx_path").(string)
|
|
vmxData, err := readVMXConfig(vmxPath)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
var device string
|
|
device, err = readCustomDeviceName(vmxData)
|
|
devices = append(devices, device)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
log.Printf("HostIP discovered custom device matching %s: %s", network, device)
|
|
}
|
|
|
|
var lastError error
|
|
for _, device := range devices {
|
|
// parse dhcpd configuration
|
|
pathDhcpConfig := d.DhcpConfPath(device)
|
|
if _, err := os.Stat(pathDhcpConfig); err != nil {
|
|
return "", fmt.Errorf("Could not find vmnetdhcp conf file: %s", pathDhcpConfig)
|
|
}
|
|
config, err := ReadDhcpConfig(pathDhcpConfig)
|
|
if err != nil {
|
|
lastError = err
|
|
continue
|
|
}
|
|
|
|
// find the entry configured in the dhcpd
|
|
interfaceConfig, err := config.HostByName(device)
|
|
if err != nil {
|
|
lastError = err
|
|
continue
|
|
}
|
|
|
|
address, err := interfaceConfig.IP4()
|
|
if err != nil {
|
|
lastError = err
|
|
continue
|
|
}
|
|
|
|
return address.String(), nil
|
|
}
|
|
return "", fmt.Errorf("Unable to find host IP from devices %v, last error: %s", devices, lastError)
|
|
}
|
|
|
|
func GetOVFTool() string {
|
|
ovftool := "ovftool"
|
|
if runtime.GOOS == "windows" {
|
|
ovftool = "ovftool.exe"
|
|
}
|
|
|
|
if _, err := exec.LookPath(ovftool); err != nil {
|
|
return ""
|
|
}
|
|
return ovftool
|
|
}
|
|
|
|
func (d *VmwareDriver) Export(args []string) error {
|
|
ovftool := GetOVFTool()
|
|
if ovftool == "" {
|
|
return fmt.Errorf("Error: ovftool not found")
|
|
}
|
|
cmd := exec.Command(ovftool, args...)
|
|
if _, _, err := runAndLog(cmd); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (d *VmwareDriver) VerifyOvfTool(SkipExport, _ bool) error {
|
|
if SkipExport {
|
|
return nil
|
|
}
|
|
|
|
log.Printf("Verifying that ovftool exists...")
|
|
// Validate that tool exists, but no need to validate credentials.
|
|
ovftool := GetOVFTool()
|
|
if ovftool != "" {
|
|
return nil
|
|
} else {
|
|
return fmt.Errorf("Couldn't find ovftool in path! Please either " +
|
|
"set `skip_export = true` and remove the `format` option " +
|
|
"from your template, or make sure ovftool is installed on " +
|
|
"your build system. ")
|
|
}
|
|
|
|
}
|