Repository: vmware/govmomi
Pull request #2610 (2021-10-04 15:23:31)

Set custom HTTP headers for VAPI calls
## Description

This patch adds support for setting custom HTTP headers when using the VAPI REST client. This is useful for setting header tokens in order to make specific calls idempotent, like the one for deploying a VM from an OVF in content library.

Closes: NA

## Type of change

Please mark options that are relevant:

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to
  not work as expected)
- [ ] This change requires a documentation update
- [ ] Build related change

## How Has This Been Tested?

- [x] Added a unit test to `vapi/rest/client_test.go` that validates the client headers are successfully passed to the server.

## Checklist:

- [x] My code follows the CONTRIBUTION [guidelines](./../CONTRIBUTING.md) of
  this project
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] I have added tests that prove my fix is effective or that my feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged


Patch:
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index ba8105ff..93f06de1 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -15,7 +15,6 @@ Please mark options that are relevant:
 - [ ] Breaking change (fix or feature that would cause existing functionality to
   not work as expected)
 - [ ] This change requires a documentation update
-- [ ] Build related change
 
 ## How Has This Been Tested?
 
@@ -26,6 +25,11 @@ details for your test configuration.
 - [ ] Test Description 1
 - [ ] Test Description 2
 
+**Test Configuration**:
+* Toolchain:
+* SDK:
+* (add more if needed)
+
 ## Checklist:
 
 - [ ] My code follows the CONTRIBUTION [guidelines](./../CONTRIBUTING.md) of
diff --git a/RELEASE.md b/RELEASE.md
index 45ed3900..2d3c33d0 100644
--- a/RELEASE.md
+++ b/RELEASE.md
@@ -19,33 +19,18 @@ of this project.
 ## Verify `master` branch is up to date with the remote
 
 ```console
-git checkout master
-git fetch -avp
-git diff master origin/master
+$ git checkout master
+$ git fetch -avp
+$ git diff master origin/master
 
 # if your local and remote branches diverge run
-git pull origin/master
+$ git pull origin/master
 ```
 
 ⚠️ **Note:** These steps assume `origin` to point to the remote
 `https://github.com/vmware/govmomi`, respectively
 `git@github.com:vmware/govmomi`.
 
-## Verify `make docs` and `CONTRIBUTORS` are up to date
-
-⚠️ Run the following commands and commit (PR) any changes before proceeding with
-the release.
-
-```console
-make doc
-./scripts/contributors.sh
-if [ -z "$(git status --porcelain)" ]; then 
-  echo "working directory clean: proceed with release"
-else 
-  echo "working directory dirty: please commit changes"
-fi
-```
-
 
 ## Set `RELEASE_VERSION` variable
 
@@ -54,25 +39,26 @@ This variable is used and referenced in the subsequent commands. Set it to the
 versioning](https://semver.org/) scheme:
 
 ```console
-export RELEASE_VERSION=v0.27.0
+$ export RELEASE_VERSION=v0.25.0
 ```
 
 ## Create the Git Tag
 
 ```console
-git tag -a ${RELEASE_VERSION} -m "Release ${RELEASE_VERSION}"
+$ git tag -a ${RELEASE_VERSION} -m "Release ${RELEASE_VERSION}"
 ```
 
 ## Push the new Tag
 
 ```console
 # Will trigger Github Actions Release Workflow
-git push origin refs/tags/${RELEASE_VERSION}
+$ git push origin refs/tags/${RELEASE_VERSION}
 ```
 
 ## Verify Github Action Release Workflow
 
-After pushing a new release tag, the status of the workflow can be inspected
+After pushing a new release tag, the status of the
+workflow can be inspected
 [here](https://github.com/vmware/govmomi/actions/workflows/govmomi-release.yaml).
 
 ![Release](static/release-workflow.png "Successful Release Run")
diff --git a/gen/Gemfile.lock b/gen/Gemfile.lock
index 4e7ec80b..b05f83e6 100644
--- a/gen/Gemfile.lock
+++ b/gen/Gemfile.lock
@@ -1,9 +1,9 @@
 GEM
   remote: https://rubygems.org/
   specs:
-    mini_portile2 (2.6.1)
-    nokogiri (1.12.5)
-      mini_portile2 (~> 2.6.1)
+    mini_portile2 (2.5.1)
+    nokogiri (1.11.4)
+      mini_portile2 (~> 2.5.0)
       racc (~> 1.4)
     power_assert (0.4.1)
     racc (1.5.2)
diff --git a/govc/USAGE.md b/govc/USAGE.md
index e7ac3240..272778bb 100644
--- a/govc/USAGE.md
+++ b/govc/USAGE.md
@@ -324,8 +324,6 @@ but appear via `govc $cmd -h`:
  - [vm.vnc](#vmvnc)
  - [volume.ls](#volumels)
  - [volume.rm](#volumerm)
- - [vsan.change](#vsanchange)
- - [vsan.info](#vsaninfo)
 
 </details>
 
@@ -404,12 +402,10 @@ Change configuration of the given clusters.
 Examples:
   govc cluster.change -drs-enabled -vsan-enabled -vsan-autoclaim ClusterA
   govc cluster.change -drs-enabled=false ClusterB
-  govc cluster.change -drs-vmotion-rate=4 ClusterC
 
 Options:
   -drs-enabled=<nil>     Enable DRS
   -drs-mode=             DRS behavior for virtual machines: manual, partiallyAutomated, fullyAutomated
-  -drs-vmotion-rate=0    Aggressiveness of vMotions (1-5)
   -ha-enabled=<nil>      Enable HA
   -vsan-autoclaim=<nil>  Autoclaim storage on cluster hosts
   -vsan-enabled=<nil>    Enable vSAN
@@ -3047,10 +3043,8 @@ Examples:
 Options:
   -cluster=              Cluster [GOVC_CLUSTER]
   -ds=                   Datastore [GOVC_DATASTORE]
-  -e=false               Include extra configuration
   -folder=               Inventory folder [GOVC_FOLDER]
   -host=                 Host system [GOVC_HOST]
-  -m=false               Preserve MAC-addresses on network adapters
   -ovf=false             Clone as OVF (default is VM Template)
   -pool=                 Resource pool [GOVC_RESOURCE_POOL]
   -profile=              Storage profile
@@ -3353,17 +3347,16 @@ Options:
 ## library.update
 
 ```
-Usage: govc library.update [OPTIONS] PATH
+Usage: govc library.update [OPTIONS] NAME
 
-Update library or item PATH.
+Update library.
 
 Examples:
-  govc library.update -d "new library description" -n "new-name" my-library
-  govc library.update -d "new item description" -n "new-item-name" my-library/my-item
+  govc library.update -d "new description" -n "new-name" current-name
 
 Options:
-  -d=                    Library or item description
-  -n=                    Library or item name
+  -d=                    Library description
+  -n=                    Library name
 ```
 
 ## license.add
@@ -3790,22 +3783,6 @@ followed by the value to match.
 The '-R' flag sets the Filter using the given XML encoded request, which can be captured by 'vcsim -trace' for example.
 It can be useful for replaying property filters created by other clients and converting filters to Go code via '-O -dump'.
 
-The '-type' flag value can be a managed entity type or one of the following aliases:
-
-  a    VirtualApp
-  c    ClusterComputeResource
-  d    Datacenter
-  f    Folder
-  g    DistributedVirtualPortgroup
-  h    HostSystem
-  m    VirtualMachine
-  n    Network
-  o    OpaqueNetwork
-  p    ResourcePool
-  r    ComputeResource
-  s    Datastore
-  w    DistributedVirtualSwitch
-
 Examples:
   govc object.collect - content
   govc object.collect -s HostSystem:ha-host hardware.systemInfo.uuid
@@ -4985,8 +4962,6 @@ Examples:
   # Enable both cpu and memory hotplug on a guest:
   govc vm.change -vm $vm -cpu-hot-add-enabled -memory-hot-add-enabled
   govc vm.change -vm $vm -e guestinfo.vmname $vm
-  # Read the contents of a file and use them as ExtraConfig value
-  govc vm.change -vm $vm -f guestinfo.data="$(realpath .)/vmdata.config"
   # Read the variable set above inside the guest:
   vmware-rpctool "info-get guestinfo.vmname"
   govc vm.change -vm $vm -latency high
@@ -5001,7 +4976,6 @@ Options:
   -cpu.reservation=<nil>         CPU reservation in MHz
   -cpu.shares=                   CPU shares level or number
   -e=[]                          ExtraConfig. <key>=<value>
-  -f=[]                          ExtraConfig. <key>=<absolute path to file>
   -g=                            Guest OS
   -latency=                      Latency sensitivity (low|normal|high)
   -m=0                           Size in MB of memory
@@ -5707,33 +5681,3 @@ Examples:
 Options:
 ```
 
-## vsan.change
-
-```
-Usage: govc vsan.change [OPTIONS] CLUSTER
-
-Change vSAN configuration.
-
-Examples:
-  govc vsan.change -unmap-enabled ClusterA # enable unmap
-  govc vsan.change -unmap-enabled=false ClusterA # disable unmap
-
-Options:
-  -unmap-enabled=<nil>   Enable Unmap
-```
-
-## vsan.info
-
-```
-Usage: govc vsan.info [OPTIONS] CLUSTER...
-
-Display vSAN configuration.
-
-Examples:
-  govc vsan.info
-  govc vsan.info ClusterA
-  govc vsan.info -json
-
-Options:
-```
-
diff --git a/govc/task/recent.go b/govc/task/recent.go
index bdb41199..b880030c 100644
--- a/govc/task/recent.go
+++ b/govc/task/recent.go
@@ -20,7 +20,6 @@ import (
 	"context"
 	"flag"
 	"fmt"
-	"io"
 	"strings"
 
 	"github.com/vmware/govmomi/govc/cli"
@@ -153,15 +152,13 @@ func (cmd *recent) Run(ctx context.Context, f *flag.FlagSet) error {
 		_ = v.Destroy(context.Background())
 	}()
 
-	// writes dump/json/xml once even if follow is specified, otherwise syntax error occurs
-	writesPlain := !(cmd.Dump || cmd.JSON || cmd.XML)
-	v.Follow = cmd.follow && writesPlain
+	v.Follow = cmd.follow
 
-	res := &taskResult{name: tn}
-	if writesPlain {
-		res.WriteHeader(cmd.Out)
-	}
+	stamp := "15:04:05"
+	tmpl := "%-40s %-30s %-30s %9s %9s %9s %s\n"
+	fmt.Fprintf(cmd.Out, tmpl, "Task", "Target", "Initiator", "Queued", "Started", "Completed", "Result")
 
+	var last string
 	updated := false
 
 	return v.Collect(ctx, func(tasks []types.TaskInfo) {
@@ -170,79 +167,55 @@ func (cmd *recent) Run(ctx context.Context, f *flag.FlagSet) error {
 		}
 		updated = true
 
-		res.Tasks = tasks
-		cmd.WriteResult(res)
-	})
-}
-
-type taskResult struct {
-	Tasks []types.TaskInfo
-	last  string
-	name  func(info *types.TaskInfo) string
-}
-
-func (t *taskResult) WriteHeader(w io.Writer) {
-	fmt.Fprintf(w, t.format("Task", "Target", "Initiator", "Queued", "Started", "Completed", "Result"))
-}
-
-func (t *taskResult) Write(w io.Writer) error {
-	stamp := "15:04:05"
+		for _, info := range tasks {
+			var user string
 
-	for _, info := range t.Tasks {
-		var user string
+			switch x := info.Reason.(type) {
+			case *types.TaskReasonUser:
+				user = x.UserName
+			}
 
-		switch x := info.Reason.(type) {
-		case *types.TaskReasonUser:
-			user = x.UserName
-		}
+			if info.EntityName == "" || user == "" {
+				continue
+			}
 
-		if info.EntityName == "" || user == "" {
-			continue
-		}
+			ruser := strings.SplitN(user, "\\", 2)
+			if len(ruser) == 2 {
+				user = ruser[1] // discard domain
+			} else {
+				user = strings.TrimPrefix(user, "com.vmware.") // e.g. com.vmware.vsan.health
+			}
 
-		ruser := strings.SplitN(user, "\\", 2)
-		if len(ruser) == 2 {
-			user = ruser[1] // discard domain
-		} else {
-			user = strings.TrimPrefix(user, "com.vmware.") // e.g. com.vmware.vsan.health
-		}
+			queued := info.QueueTime.Format(stamp)
+			start := "-"
+			end := start
 
-		queued := info.QueueTime.Format(stamp)
-		start := "-"
-		end := start
+			if info.StartTime != nil {
+				start = info.StartTime.Format(stamp)
+			}
 
-		if info.StartTime != nil {
-			start = info.StartTime.Format(stamp)
-		}
+			msg := fmt.Sprintf("%2d%% %s", info.Progress, info.Task)
 
-		msg := fmt.Sprintf("%2d%% %s", info.Progress, info.Task)
+			if info.CompleteTime != nil {
+				msg = info.CompleteTime.Sub(*info.StartTime).String()
 
-		if info.CompleteTime != nil {
-			msg = info.CompleteTime.Sub(*info.StartTime).String()
+				if info.State == types.TaskInfoStateError {
+					msg = strings.TrimSuffix(info.Error.LocalizedMessage, ".")
+				}
 
-			if info.State == types.TaskInfoStateError {
-				msg = strings.TrimSuffix(info.Error.LocalizedMessage, ".")
+				end = info.CompleteTime.Format(stamp)
 			}
 
-			end = info.CompleteTime.Format(stamp)
-		}
+			result := fmt.Sprintf("%-7s [%s]", info.State, msg)
 
-		result := fmt.Sprintf("%-7s [%s]", info.State, msg)
+			item := fmt.Sprintf(tmpl, chop(tn(&info), 40), chop(info.EntityName, 30), chop(user, 30), queued, start, end, result)
 
-		item := t.format(chop(t.name(&info), 40), chop(info.EntityName, 30), chop(user, 30), queued, start, end, result)
+			if item == last {
+				continue // task info was updated, but the fields we display were not
+			}
+			last = item
 
-		if item == t.last {
-			continue // task info was updated, but the fields we display were not
+			fmt.Fprint(cmd.Out, item)
 		}
-		t.last = item
-
-		fmt.Fprint(w, item)
-	}
-
-	return nil
-}
-
-func (t *taskResult) format(task, target, initiator, queued, started, completed, result string) string {
-	return fmt.Sprintf("%-40s %-30s %-30s %9s %9s %9s %s\n",
-		task, target, initiator, queued, started, completed, result)
+	})
 }
diff --git a/govc/vm/change.go b/govc/vm/change.go
index f986a51b..a08df173 100644
--- a/govc/vm/change.go
+++ b/govc/vm/change.go
@@ -20,7 +20,6 @@ import (
 	"context"
 	"flag"
 	"fmt"
-	"io/ioutil"
 	"reflect"
 	"strings"
 
@@ -44,39 +43,13 @@ func (e *extraConfig) Set(v string) error {
 	return nil
 }
 
-type extraConfigFile []types.BaseOptionValue
-
-func (e *extraConfigFile) String() string {
-	return fmt.Sprintf("%v", *e)
-}
-
-func (e *extraConfigFile) Set(v string) error {
-	r := strings.SplitN(v, "=", 2)
-	if len(r) < 2 {
-		return fmt.Errorf("failed to parse extraConfigFile: %s", v)
-	}
-
-	var fileContents = ""
-	if len(r[1]) > 0 {
-		contents, err := ioutil.ReadFile(r[1])
-		if err != nil {
-			return fmt.Errorf("failed to parse extraConfigFile '%s': %w", v, err)
-		}
-		fileContents = string(contents)
-	}
-
-	*e = append(*e, &types.OptionValue{Key: r[0], Value: fileContents})
-	return nil
-}
-
 type change struct {
 	*flags.VirtualMachineFlag
 	*flags.ResourceAllocationFlag
 
 	types.VirtualMachineConfigSpec
-	extraConfig     extraConfig
-	extraConfigFile extraConfigFile
-	Latency         string
+	extraConfig extraConfig
+	Latency     string
 }
 
 func init() {
@@ -146,7 +119,6 @@ func (cmd *change) Register(ctx context.Context, f *flag.FlagSet) {
 	f.StringVar(&cmd.Annotation, "annotation", "", "VM description")
 	f.StringVar(&cmd.Uuid, "uuid", "", "BIOS UUID")
 	f.Var(&cmd.extraConfig, "e", "ExtraConfig. <key>=<value>")
-	f.Var(&cmd.extraConfigFile, "f", "ExtraConfig. <key>=<absolute path to file>")
 
 	f.Var(flags.NewOptionalBool(&cmd.NestedHVEnabled), "nested-hv-enabled", "Enable nested hardware-assisted virtualization")
 	cmd.Tools = &types.ToolsConfigInfo{}
@@ -168,8 +140,6 @@ Examples:
   # Enable both cpu and memory hotplug on a guest:
   govc vm.change -vm $vm -cpu-hot-add-enabled -memory-hot-add-enabled
   govc vm.change -vm $vm -e guestinfo.vmname $vm
-  # Read the contents of a file and use them as ExtraConfig value
-  govc vm.change -vm $vm -f guestinfo.data="$(realpath .)/vmdata.config"
   # Read the variable set above inside the guest:
   vmware-rpctool "info-get guestinfo.vmname"
   govc vm.change -vm $vm -latency high
@@ -194,7 +164,9 @@ func (cmd *change) Run(ctx context.Context, f *flag.FlagSet) error {
 		return flag.ErrHelp
 	}
 
-	cmd.VirtualMachineConfigSpec.ExtraConfig = append(cmd.extraConfig, cmd.extraConfigFile...)
+	if len(cmd.extraConfig) > 0 {
+		cmd.VirtualMachineConfigSpec.ExtraConfig = cmd.extraConfig
+	}
 
 	setAllocation(&cmd.CpuAllocation)
 	setAllocation(&cmd.MemoryAllocation)
diff --git a/guest/file_manager.go b/guest/file_manager.go
index 922c6d95..94b58316 100644
--- a/guest/file_manager.go
+++ b/guest/file_manager.go
@@ -21,10 +21,8 @@ import (
 	"fmt"
 	"net"
 	"net/url"
-	"os"
 	"sync"
 
-	"github.com/vmware/govmomi/internal"
 	"github.com/vmware/govmomi/property"
 	"github.com/vmware/govmomi/vim25"
 	"github.com/vmware/govmomi/vim25/methods"
@@ -121,9 +119,6 @@ func (m FileManager) DeleteFile(ctx context.Context, auth types.BaseGuestAuthent
 	return err
 }
 
-// escape hatch to disable the preference to use ESX host management IP for guest file transfer
-var useGuestTransferIP = os.Getenv("GOVMOMI_USE_GUEST_TRANSFER_IP") != "false"
-
 // TransferURL rewrites the url with a valid hostname and adds the host's thumbprint.
 // The InitiateFileTransfer{From,To}Guest methods return a URL with the host set to "*" when connected directly to ESX,
 // but return the address of VM's runtime host when connected to vCenter.
@@ -184,19 +179,29 @@ func (m FileManager) TransferURL(ctx context.Context, u string) (*url.URL, error
 			host.Name, host.Self, host.Runtime.ConnectionState)
 	}
 
-	// InitiateFileTransfer{To,From}Guest methods return an ESX host's inventory name (HostSystem.Name).
-	// This name was used to add the host to vCenter and cannot be changed (unless the host is removed from inventory and added back with another name).
-	// The name used when adding to VC may not resolvable by this client's DNS, so we prefer an ESX management IP.
-	// However, if there is more than one management vNIC, we don't know which IP(s) the client has a route to.
-	// Leave the hostname as-is in that case or if the env var has disabled the preference.
-	ips := internal.HostSystemManagementIPs(host.Config.VirtualNicManagerInfo.NetConfig)
-	if len(ips) == 1 && useGuestTransferIP {
-		mname = ips[0].String()
-		m.mu.Lock()
-		m.hosts[name] = mname
-		m.mu.Unlock()
-
-		name = mname
+	// prefer an ESX management IP, as the hostname used when adding to VC may not be valid for this client
+	// See also object.HostSystem.ManagementIPs which we can't use here due to import cycle
+	for _, nc := range host.Config.VirtualNicManagerInfo.NetConfig {
+		if nc.NicType != string(types.HostVirtualNicManagerNicTypeManagement) {
+			continue
+		}
+		for ix := range nc.CandidateVnic {
+			for _, selectedVnicKey := range nc.SelectedVnic {
+				if nc.CandidateVnic[ix].Key != selectedVnicKey {
+					continue
+				}
+				ip := net.ParseIP(nc.CandidateVnic[ix].Spec.Ip.IpAddress)
+				if ip != nil {
+					mname = ip.String()
+					m.mu.Lock()
+					m.hosts[name] = mname
+					m.mu.Unlock()
+
+					name = mname
+					break
+				}
+			}
+		}
 	}
 
 	turl.Host = net.JoinHostPort(name, port)
diff --git a/internal/helpers.go b/internal/helpers.go
index b3eafead..945f1d47 100644
--- a/internal/helpers.go
+++ b/internal/helpers.go
@@ -17,11 +17,9 @@ limitations under the License.
 package internal
 
 import (
-	"net"
 	"path"
 
 	"github.com/vmware/govmomi/vim25/mo"
-	"github.com/vmware/govmomi/vim25/types"
 )
 
 // InventoryPath composed of entities by Name
@@ -38,26 +36,3 @@ func InventoryPath(entities []mo.ManagedEntity) string {
 
 	return val
 }
-
-func HostSystemManagementIPs(config []types.VirtualNicManagerNetConfig) []net.IP {
-	var ips []net.IP
-
-	for _, nc := range config {
-		if nc.NicType != string(types.HostVirtualNicManagerNicTypeManagement) {
-			continue
-		}
-		for ix := range nc.CandidateVnic {
-			for _, selectedVnicKey := range nc.SelectedVnic {
-				if nc.CandidateVnic[ix].Key != selectedVnicKey {
-					continue
-				}
-				ip := net.ParseIP(nc.CandidateVnic[ix].Spec.Ip.IpAddress)
-				if ip != nil {
-					ips = append(ips, ip)
-				}
-			}
-		}
-	}
-
-	return ips
-}
diff --git a/object/host_system.go b/object/host_system.go
index 7d1fa841..6cb9b7a5 100644
--- a/object/host_system.go
+++ b/object/host_system.go
@@ -21,7 +21,6 @@ import (
 	"fmt"
 	"net"
 
-	"github.com/vmware/govmomi/internal"
 	"github.com/vmware/govmomi/vim25"
 	"github.com/vmware/govmomi/vim25/methods"
 	"github.com/vmware/govmomi/vim25/mo"
@@ -82,7 +81,24 @@ func (h HostSystem) ManagementIPs(ctx context.Context) ([]net.IP, error) {
 		return nil, err
 	}
 
-	return internal.HostSystemManagementIPs(mh.Config.VirtualNicManagerInfo.NetConfig), nil
+	var ips []net.IP
+	for _, nc := range mh.Config.VirtualNicManagerInfo.NetConfig {
+		if nc.NicType != string(types.HostVirtualNicManagerNicTypeManagement) {
+			continue
+		}
+		for ix := range nc.CandidateVnic {
+			for _, selectedVnicKey := range nc.SelectedVnic {
+				if nc.CandidateVnic[ix].Key != selectedVnicKey {
+					continue
+				}
+				ip := net.ParseIP(nc.CandidateVnic[ix].Spec.Ip.IpAddress)
+				if ip != nil {
+					ips = append(ips, ip)
+				}
+			}
+		}
+	}
+	return ips, nil
 }
 
 func (h HostSystem) Disconnect(ctx context.Context) (*Task, error) {
diff --git a/object/virtual_device_list.go b/object/virtual_device_list.go
index c45f553a..f25f6f2c 100644
--- a/object/virtual_device_list.go
+++ b/object/virtual_device_list.go
@@ -68,13 +68,7 @@ func EthernetCardTypes() VirtualDeviceList {
 		&types.VirtualSriovEthernetCard{},
 	}).Select(func(device types.BaseVirtualDevice) bool {
 		c := device.(types.BaseVirtualEthernetCard).GetVirtualEthernetCard()
-
-		key := rand.Int31() * -1
-		if key == 0 {
-			key = -1
-		}
-
-		c.GetVirtualDevice().Key = key
+		c.GetVirtualDevice().Key = int32(rand.Uint32()) * -1
 		return true
 	})
 }
diff --git a/program.mk b/program.mk
index f2493993..f25021a3 100644
--- a/program.mk
+++ b/program.mk
@@ -33,7 +33,7 @@ $(PROGRAM):
 	CGO_ENABLED=0 go build -a $(BUILD_ARGS) -o $@
 
 install:
-	CGO_ENABLED=0 go install -v $(BUILD_ARGS)
+	CGO_ENABLED=0 go install -i -v $(BUILD_ARGS)
 
 ifneq (,$(strip $(BUILD_OS)))
 ifneq (,$(strip $(BUILD_ARCH)))
diff --git a/simulator/datacenter.go b/simulator/datacenter.go
index 1ab6df47..e4294175 100644
--- a/simulator/datacenter.go
+++ b/simulator/datacenter.go
@@ -153,25 +153,14 @@ func (dc *Datacenter) PowerOnMultiVMTask(ctx *Context, req *types.PowerOnMultiVM
 			return nil, new(types.NotImplemented)
 		}
 
-		// Return per-VM tasks, structured as:
-		// thisTask.result - DC level task
-		//    +- []Attempted
-		//        +- subTask.result - VM level powerOn task result
-		//        +- ...
-		res := types.ClusterPowerOnVmResult{}
-		res.Attempted = []types.ClusterAttemptedVmInfo{}
-
 		for _, ref := range req.Vm {
 			vm := Map.Get(ref).(*VirtualMachine)
-			// NOTE: Simulator does not actually perform any specific host-level placement
-			// (equivalent to vSphere DRS).
 			ctx.WithLock(vm, func() {
-				vmTaskBody := vm.PowerOnVMTask(ctx, &types.PowerOnVM_Task{}).(*methods.PowerOnVM_TaskBody)
-				res.Attempted = append(res.Attempted, types.ClusterAttemptedVmInfo{Vm: ref, Task: &vmTaskBody.Res.Returnval})
+				vm.PowerOnVMTask(ctx, &types.PowerOnVM_Task{})
 			})
 		}
 
-		return res, nil
+		return nil, nil
 	})
 
 	return &methods.PowerOnMultiVM_TaskBody{
diff --git a/simulator/virtual_machine.go b/simulator/virtual_machine.go
index 38dcb3d8..186d85f0 100644
--- a/simulator/virtual_machine.go
+++ b/simulator/virtual_machine.go
@@ -1114,17 +1114,6 @@ func (vm *VirtualMachine) configureDevice(ctx *Context, devices object.VirtualDe
 				Connected:      true,
 				DeviceConfigId: c.Key,
 			})
-
-			if c.ResourceAllocation == nil {
-				c.ResourceAllocation = &types.VirtualEthernetCardResourceAllocation{
-					Reservation: types.NewInt64(0),
-					Share: types.SharesInfo{
-						Shares: 50,
-						Level:  "normal",
-					},
-					Limit: types.NewInt64(-1),
-				}
-			}
 		}
 	case *types.VirtualDisk:
 		summary = fmt.Sprintf("%s KB", numberToString(x.CapacityInKB, ','))
diff --git a/vapi/internal/internal.go b/vapi/internal/internal.go
index 3203712c..68fe7975 100644
--- a/vapi/internal/internal.go
+++ b/vapi/internal/internal.go
@@ -44,6 +44,7 @@ const (
 	VCenterVM                      = "/vcenter/vm"
 	SessionCookieName              = "vmware-api-session-id"
 	UseHeaderAuthn                 = "vmware-use-header-authn"
+	DebugEcho                      = "/vc-sim/debug/echo"
 )
 
 // AssociatedObject is the same structure as types.ManagedObjectReference,
diff --git a/vapi/rest/client.go b/vapi/rest/client.go
index 6b69c094..3c4b148d 100644
--- a/vapi/rest/client.go
+++ b/vapi/rest/client.go
@@ -135,6 +135,18 @@ func (c *Client) WithSigner(ctx context.Context, s Signer) context.Context {
 	return context.WithValue(ctx, signerContext{}, s)
 }
 
+type headersContext struct{}
+
+// WithHeader returns a new Context populated with the provided headers map.
+// Calls to a VAPI REST client with this context will populate the HTTP headers
+// map using the provided headers.
+func (c *Client) WithHeader(
+	ctx context.Context,
+	headers http.Header) context.Context {
+
+	return context.WithValue(ctx, headersContext{}, headers)
+}
+
 type statusError struct {
 	res *http.Response
 }
@@ -143,6 +155,12 @@ func (e *statusError) Error() string {
 	return fmt.Sprintf("%s %s: %s", e.res.Request.Method, e.res.Request.URL, e.res.Status)
 }
 
+// RawResponse may be used with the Do method as the resBody argument in order
+// to capture the raw response data.
+type RawResponse struct {
+	bytes.Buffer
+}
+
 // Do sends the http.Request, decoding resBody if provided.
 func (c *Client) Do(ctx context.Context, req *http.Request, resBody interface{}) error {
 	switch req.Method {
@@ -162,6 +180,14 @@ func (c *Client) Do(ctx context.Context, req *http.Request, resBody interface{})
 		}
 	}
 
+	if headers, ok := ctx.Value(headersContext{}).(http.Header); ok {
+		for k, v := range headers {
+			for _, v := range v {
+				req.Header.Add(k, v)
+			}
+		}
+	}
+
 	return c.Client.Do(ctx, req, func(res *http.Response) error {
 		switch res.StatusCode {
 		case http.StatusOK:
@@ -183,6 +209,8 @@ func (c *Client) Do(ctx context.Context, req *http.Request, resBody interface{})
 		}
 
 		switch b := resBody.(type) {
+		case *RawResponse:
+			return res.Write(b)
 		case io.Writer:
 			_, err := io.Copy(b, res.Body)
 			return err
diff --git a/vapi/simulator/simulator.go b/vapi/simulator/simulator.go
index d25843b6..66d3497e 100644
--- a/vapi/simulator/simulator.go
+++ b/vapi/simulator/simulator.go
@@ -154,6 +154,7 @@ func New(u *url.URL, settings []vim.BaseOptionValue) (string, http.Handler) {
 		{internal.VCenterVMTXLibraryItem, s.libraryItemCreateTemplate},
 		{internal.VCenterVMTXLibraryItem + "/", s.libraryItemTemplateID},
 		{internal.VCenterVM + "/", s.vmID},
+		{internal.DebugEcho, s.debugEcho},
 	}
 
 	for i := range handlers {
@@ -2095,3 +2096,7 @@ func (s *handler) vmID(w http.ResponseWriter, r *http.Request) {
 		http.NotFound(w, r)
 	}
 }
+
+func (s *handler) debugEcho(w http.ResponseWriter, r *http.Request) {
+	r.Write(w)
+}


Test patch:
diff --git a/.github/workflows/govmomi-govc-tests.yaml b/.github/workflows/govmomi-govc-tests.yaml
index aa95de88..c9e6efc7 100644
--- a/.github/workflows/govmomi-govc-tests.yaml
+++ b/.github/workflows/govmomi-govc-tests.yaml
@@ -59,31 +59,3 @@ jobs:
 
       - name: Run ${{ matrix.cmd }}
         run: make ${{ matrix.cmd }}
-
-  govc-docs:
-    name: Verify govc docs are up2date
-    strategy:
-      matrix:
-        go-version: ["1.16"]
-        platform: ["ubuntu-18.04"]
-    runs-on: ${{ matrix.platform }}
-    timeout-minutes: 3
-    steps:
-      - name: Set up Go ${{ matrix.go-version }}
-        uses: actions/setup-go@v2
-        with:
-          go-version: ${{ matrix.go-version }}
-        id: go
-      - name: Check out code
-        uses: actions/checkout@v2
-      - name: Run "make doc"
-        run: |
-          make doc
-          if [ -z "$(git status --porcelain)" ]; then 
-            echo "govc/USAGE.md is up2date..."
-          else 
-            echo "dirty working directory (diff):"
-            git --no-pager diff
-            echo "::error file=govc/USAGE.md::Run make doc to update USAGE.md"
-            exit 1
-          fi
diff --git a/govc/test/vm.bats b/govc/test/vm.bats
index f5f8dbe3..a6d79c60 100755
--- a/govc/test/vm.bats
+++ b/govc/test/vm.bats
@@ -142,7 +142,6 @@ load test_helper
   assert_line "Memory: 1024MB"
   assert_line "CPU: 2 vCPU(s)"
 
-  # test extraConfig
   run govc vm.change -e "guestinfo.a=1" -e "guestinfo.b=2" -vm $id
   assert_success
 
@@ -151,18 +150,6 @@ load test_helper
   assert_line "guestinfo.a: 1"
   assert_line "guestinfo.b: 2"
 
-  # test extraConfigFile
-  run govc vm.change -f "guestinfo.c=this_is_not_an_existing.file" -vm $id
-  assert_failure
-
-  echo -n "3" > "$BATS_TMPDIR/extraConfigFile.conf"
-  run govc vm.change -f "guestinfo.d=$BATS_TMPDIR/extraConfigFile.conf" -vm $id
-  assert_success
-
-  run govc vm.info -e $id
-  assert_success
-  assert_line "guestinfo.d: 3"
-
   run govc vm.change -sync-time-with-host=false -vm $id
   assert_success
 
@@ -432,19 +419,6 @@ load test_helper
   assert_success
   refute_line "guestinfo.a: 2"
 
-  # test extraConfigFile
-  run govc vm.change -f "guestinfo.b=this_is_not_an_existing.file" -vm $id
-  assert_failure
-  echo -n "3" > "$BATS_TMPDIR/extraConfigFile.conf"
-  run govc vm.change -f "guestinfo.b=$BATS_TMPDIR/extraConfigFile.conf" -vm $id
-  assert_success
-  run govc vm.info -e $id
-  assert_success
-  assert_line "guestinfo.b: 3"
-  run govc vm.change -f "guestinfo.b=" -vm $id
-  assert_success
-  refute_line "guestinfo.b: 3"
-
   # test optional bool Config
   run govc vm.change -nested-hv-enabled=true -vm "$id"
   assert_success
diff --git a/guest/file_manager_test.go b/guest/file_manager_test.go
index c950ff25..8e1880c4 100644
--- a/guest/file_manager_test.go
+++ b/guest/file_manager_test.go
@@ -23,7 +23,6 @@ import (
 	"github.com/vmware/govmomi/guest"
 	"github.com/vmware/govmomi/simulator"
 	"github.com/vmware/govmomi/vim25"
-	"github.com/vmware/govmomi/vim25/types"
 )
 
 func TestTranferURL(t *testing.T) {
@@ -46,24 +45,6 @@ func TestTranferURL(t *testing.T) {
 			t.Errorf("hostname=%s", u.Hostname())
 		}
 
-		// hostname should be returned by TransferURL when there are multiple management IPs
-		for _, nc := range host.Config.VirtualNicManagerInfo.NetConfig {
-			if nc.NicType == string(types.HostVirtualNicManagerNicTypeManagement) {
-				host.Config.VirtualNicManagerInfo.NetConfig = append(host.Config.VirtualNicManagerInfo.NetConfig, nc)
-				break
-			}
-		}
-
-		turl = "https://esx2:443/foo/bar"
-		u, err = m.TransferURL(ctx, turl)
-		if err != nil {
-			t.Fatal(err)
-		}
-		if u.Hostname() != "esx2" {
-			t.Errorf("hostname=%s", u.Hostname())
-		}
-
-		// error should be returned if HostSystem.Config is nil
 		host.Config = nil
 		m, err = ops.FileManager(ctx)
 		if err != nil {
diff --git a/internal/helpers_test.go b/internal/helpers_test.go
deleted file mode 100644
index 4cbb8cd5..00000000
--- a/internal/helpers_test.go
+++ /dev/null
@@ -1,32 +0,0 @@
-/*
-Copyright (c) 2021 VMware, Inc. All Rights Reserved.
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
-    http://www.apache.org/licenses/LICENSE-2.0
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
-*/
-
-package internal_test
-
-import (
-	"testing"
-
-	"github.com/vmware/govmomi/internal"
-	"github.com/vmware/govmomi/simulator/esx"
-)
-
-func TestHostSystemManagementIPs(t *testing.T) {
-	ips := internal.HostSystemManagementIPs(esx.HostSystem.Config.VirtualNicManagerInfo.NetConfig)
-
-	if len(ips) != 1 {
-		t.Fatalf("no mgmt ip found")
-	}
-	if ips[0].String() != "127.0.0.1" {
-		t.Fatalf("Expected management ip %s, got %s", "127.0.0.1", ips[0].String())
-	}
-}
diff --git a/object/virtual_device_list_test.go b/object/virtual_device_list_test.go
index a50867f0..cf2fc47c 100644
--- a/object/virtual_device_list_test.go
+++ b/object/virtual_device_list_test.go
@@ -688,14 +688,10 @@ func TestCreateEthernetCard(t *testing.T) {
 	}
 
 	for _, name := range []string{"", "e1000", "e1000e", "vmxnet2", "vmxnet3", "pcnet32", "sriov"} {
-		c, err := EthernetCardTypes().CreateEthernetCard(name, nil)
+		_, err := EthernetCardTypes().CreateEthernetCard(name, nil)
 		if err != nil {
 			t.Error(err)
 		}
-
-		if key := c.GetVirtualDevice().Key; key >= 0 {
-			t.Errorf("device key %d should be negative", key)
-		}
 	}
 }
 
diff --git a/simulator/datacenter_test.go b/simulator/datacenter_test.go
index c6899548..f6639843 100644
--- a/simulator/datacenter_test.go
+++ b/simulator/datacenter_test.go
@@ -17,12 +17,8 @@ limitations under the License.
 package simulator
 
 import (
-	"context"
 	"testing"
 
-	"github.com/vmware/govmomi"
-	"github.com/vmware/govmomi/find"
-	"github.com/vmware/govmomi/object"
 	"github.com/vmware/govmomi/simulator/esx"
 	"github.com/vmware/govmomi/simulator/vpx"
 	"github.com/vmware/govmomi/vim25/mo"
@@ -32,11 +28,11 @@ import (
 func TestDatacenterCreateFolders(t *testing.T) {
 	// For this test we only want the RootFolder, 1 Datacenter and its child folders
 	models := []Model{
-		{
+		Model{
 			ServiceContent: esx.ServiceContent,
 			RootFolder:     esx.RootFolder,
 		},
-		{
+		Model{
 			ServiceContent: vpx.ServiceContent,
 			RootFolder:     vpx.RootFolder,
 			Datacenter:     1,
@@ -87,86 +83,3 @@ func TestDatacenterCreateFolders(t *testing.T) {
 		}
 	}
 }
-
-func TestDatacenterPowerOnMultiVMs(t *testing.T) {
-	ctx := context.Background()
-
-	m := VPX()
-	defer m.Remove()
-	err := m.Create()
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	s := m.Service.NewServer()
-	defer s.Close()
-
-	c, err := govmomi.NewClient(ctx, s.URL, true)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	finder := find.NewFinder(c.Client, false)
-	dc, err := finder.DefaultDatacenter(ctx)
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	finder.SetDatacenter(dc)
-
-	vms, err := finder.VirtualMachineList(ctx, "*")
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	// Default inventory could change in future to many VMs, ensure we have
-	// at least these many VMs to test.
-	numTestVMs := 2
-	if len(vms) < numTestVMs {
-		t.Fatalf("Need at least %v VMs in a datacenter for this test", numTestVMs)
-	}
-	testVMs := []types.ManagedObjectReference{}
-	for _, vm := range vms[:numTestVMs] {
-		testVMs = append(testVMs, vm.Reference())
-	}
-
-	// Ensure VMs are powered off first before testing multi-VM power-on.
-	for _, vm := range vms[:numTestVMs] {
-		task, err := vm.PowerOff(ctx)
-		if err != nil {
-			t.Fatal(err)
-		}
-		err = task.Wait(ctx)
-		if err != nil {
-			t.Fatal(err)
-		}
-	}
-
-	dcTask, err := dc.PowerOnVM(ctx, testVMs)
-	if err != nil {
-		t.Fatal(err)
-	}
-	info, err := dcTask.WaitForResult(ctx, nil)
-	if err != nil {
-		t.Fatal(err)
-	}
-	switch dcResult := info.Result.(type) {
-	case types.ClusterPowerOnVmResult:
-		if len(dcResult.Attempted) != len(testVMs) {
-			t.Fatalf("Unexpected per-vm tasks in results, found %v, expected %v",
-				len(dcResult.Attempted), len(testVMs))
-		}
-		for i, vmResult := range dcResult.Attempted {
-			if vmResult.Task == nil {
-				t.Fatalf("Found per-vm task nil for VM #%v", i)
-			}
-			vmTask := object.NewTask(c.Client, *vmResult.Task)
-			err := vmTask.Wait(ctx)
-			if err != nil {
-				t.Fatalf("%v", err)
-			}
-		}
-	default:
-		t.Fatalf("Unexpected result type %T returned for DC PowerOnMultiVM", dcResult)
-	}
-}
diff --git a/vapi/rest/client_test.go b/vapi/rest/client_test.go
index fda7d349..ebe50229 100644
--- a/vapi/rest/client_test.go
+++ b/vapi/rest/client_test.go
@@ -17,10 +17,15 @@ limitations under the License.
 package rest_test
 
 import (
+	"bytes"
 	"context"
+	"io/ioutil"
+	"net/http"
+	"strings"
 	"testing"
 
 	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/vapi/internal"
 	"github.com/vmware/govmomi/vapi/rest"
 	"github.com/vmware/govmomi/vim25"
 
@@ -55,3 +60,53 @@ func TestSession(t *testing.T) {
 		}
 	})
 }
+
+func TestWithHeaders(t *testing.T) {
+	simulator.Test(func(ctx context.Context, vc *vim25.Client) {
+		c := rest.NewClient(vc)
+		if err := c.Login(ctx, simulator.DefaultLogin); err != nil {
+			t.Fatal(err)
+		}
+
+		// Assign the headers.
+		ctx = c.WithHeader(ctx, http.Header{
+			"client-token": []string{"unique-token"},
+			"pid":          []string{"2", "3", "4"},
+		})
+
+		req, err := http.NewRequest(
+			http.MethodPost,
+			c.Resource(internal.DebugEcho).String(),
+			strings.NewReader("Hello, world."))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Send a rest.RawResponse into the decoder to capture the raw
+		// response data.
+		var res rest.RawResponse
+		if err := c.Do(ctx, req, &res); err != nil {
+			t.Fatal(err)
+		}
+
+		// Read the raw response.
+		data, err := ioutil.ReadAll(&res.Buffer)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		// Assert all of the request headers were echoed back to the client.
+		if !bytes.Contains(data, []byte("Client-Token: unique-token")) {
+			t.Fatal("missing Client-Token: unique-token")
+		}
+		if !bytes.Contains(data, []byte("Pid: 2")) {
+			t.Fatal("missing Pid: 2")
+		}
+		if !bytes.Contains(data, []byte("Pid: 3")) {
+			t.Fatal("missing Pid: 3")
+		}
+		if !bytes.Contains(data, []byte("Pid: 4")) {
+			t.Fatal("missing Pid: 4")
+		}
+	})
+}