Repository: vmware/govmomi
Pull request #2021 (2020-06-15 14:23:16)

Vsan Performance Data Collection API
Added VSAN Performance data collection API
https://github.com/vmware/govmomi/issues/2018


Patch:
diff --git a/README.md b/README.md
index 40394f50..db17cc66 100644
--- a/README.md
+++ b/README.md
@@ -87,6 +87,8 @@ Refer to the [CHANGELOG](CHANGELOG.md) for version to version changes.
 
 * [vSphere 7.0](https://docs.vmware.com/en/VMware-vSphere/7.0/rn/vsphere-esxi-vcenter-server-7-vsphere-with-kubernetes-release-notes.html)
 
+* [OPS](https://github.com/nanovms/ops)
+
 ## Related projects
 
 * [rbvmomi](https://github.com/vmware/rbvmomi)
diff --git a/cns/cns_util.go b/cns/cns_util.go
index cafcfae7..ec949250 100644
--- a/cns/cns_util.go
+++ b/cns/cns_util.go
@@ -51,6 +51,23 @@ func GetTaskResult(ctx context.Context, taskInfo *vim25types.TaskInfo) (cnstypes
 	return nil, errors.New("TaskInfo result is empty")
 }
 
+// GetTaskResultArray gets the task result array for a specified task info
+func GetTaskResultArray(ctx context.Context, taskInfo *vim25types.TaskInfo) ([]cnstypes.BaseCnsVolumeOperationResult, error) {
+	if taskInfo == nil {
+		return nil, errors.New("TaskInfo is empty")
+	}
+	if taskInfo.Result != nil {
+		volumeOperationBatchResult := taskInfo.Result.(cnstypes.CnsVolumeOperationBatchResult)
+		if &volumeOperationBatchResult == nil ||
+			volumeOperationBatchResult.VolumeResults == nil ||
+			len(volumeOperationBatchResult.VolumeResults) == 0 {
+			return nil, errors.New("Cannot get VolumeOperationResult")
+		}
+		return volumeOperationBatchResult.VolumeResults, nil
+	}
+	return nil, errors.New("TaskInfo result is empty")
+}
+
 // dropUnknownCreateSpecElements helps drop newly added elements in the CnsVolumeCreateSpec, which are not known to the prior vSphere releases
 func dropUnknownCreateSpecElements(c *Client, createSpecList []cnstypes.CnsVolumeCreateSpec) []cnstypes.CnsVolumeCreateSpec {
 	var updatedcreateSpecList []cnstypes.CnsVolumeCreateSpec
diff --git a/govc/main.go b/govc/main.go
index 1be1658a..0f4f31f0 100644
--- a/govc/main.go
+++ b/govc/main.go
@@ -70,6 +70,7 @@ import (
 	_ "github.com/vmware/govmomi/govc/ls"
 	_ "github.com/vmware/govmomi/govc/metric"
 	_ "github.com/vmware/govmomi/govc/metric/interval"
+	_ "github.com/vmware/govmomi/govc/namespace/cluster"
 	_ "github.com/vmware/govmomi/govc/object"
 	_ "github.com/vmware/govmomi/govc/option"
 	_ "github.com/vmware/govmomi/govc/permissions"
diff --git a/govc/namespace/cluster/ls.go b/govc/namespace/cluster/ls.go
new file mode 100644
index 00000000..1bcfdea8
--- /dev/null
+++ b/govc/namespace/cluster/ls.go
@@ -0,0 +1,132 @@
+/*
+Copyright (c) 2020 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 cluster
+
+import (
+	"context"
+	"encoding/json"
+	"flag"
+	"fmt"
+	"io"
+	"os"
+	"text/tabwriter"
+
+	"github.com/vmware/govmomi/find"
+	"github.com/vmware/govmomi/govc/cli"
+	"github.com/vmware/govmomi/govc/flags"
+	"github.com/vmware/govmomi/object"
+	"github.com/vmware/govmomi/vapi/namespace"
+)
+
+type ls struct {
+	*flags.ClientFlag
+	*flags.OutputFlag
+
+	long bool
+}
+
+func init() {
+	cli.Register("namespace.cluster.ls", &ls{})
+}
+
+func (cmd *ls) Register(ctx context.Context, f *flag.FlagSet) {
+	cmd.ClientFlag, ctx = flags.NewClientFlag(ctx)
+	cmd.OutputFlag, ctx = flags.NewOutputFlag(ctx)
+	cmd.ClientFlag.Register(ctx, f)
+	cmd.OutputFlag.Register(ctx, f)
+
+	f.BoolVar(&cmd.long, "l", false, "Long listing format")
+}
+
+func (cmd *ls) Process(ctx context.Context) error {
+	if err := cmd.ClientFlag.Process(ctx); err != nil {
+		return err
+	}
+	return cmd.OutputFlag.Process(ctx)
+}
+
+func (cmd *ls) Description() string {
+	return `List namepace enabled clusters.
+
+Examples:
+  govc namespace.cluster.ls
+  govc namespace.cluster.ls -l
+  govc namespace.cluster.ls -json | jq .`
+}
+
+type lsWriter struct {
+	cmd     *ls
+	Cluster []namespace.ClusterSummary
+}
+
+func (r *lsWriter) Write(w io.Writer) error {
+	tw := tabwriter.NewWriter(os.Stdout, 2, 0, 2, ' ', 0)
+
+	for _, cluster := range r.Cluster {
+		path, err := r.path(cluster)
+		if err != nil {
+			return err
+		}
+		fmt.Fprintf(tw, "%s", path)
+		if r.cmd.long {
+			fmt.Fprintf(tw, "\t%s", cluster.ConfigStatus)
+			fmt.Fprintf(tw, "\t%s", cluster.KubernetesStatus)
+		}
+		fmt.Fprintf(tw, "\n")
+	}
+	return tw.Flush()
+}
+
+func (r *lsWriter) path(cluster namespace.ClusterSummary) (string, error) {
+	ref := cluster.Reference()
+
+	c, err := r.cmd.Client()
+	if err != nil {
+		return "", err
+	}
+
+	finder := find.NewFinder(c, false)
+	obj, err := finder.ObjectReference(context.Background(), ref)
+	if err != nil {
+		return "", err
+	}
+
+	return obj.(*object.ClusterComputeResource).InventoryPath, nil
+}
+
+func (r *lsWriter) MarshalJSON() ([]byte, error) {
+	return json.Marshal(r.Cluster)
+}
+
+func (r *lsWriter) Dump() interface{} {
+	return r.Cluster
+}
+
+func (cmd *ls) Run(ctx context.Context, f *flag.FlagSet) error {
+	c, err := cmd.RestClient()
+	if err != nil {
+		return err
+	}
+
+	m := namespace.NewManager(c)
+	clusters, err := m.ListClusters(ctx)
+	if err != nil {
+		return err
+	}
+
+	return cmd.WriteResult(&lsWriter{cmd, clusters})
+}
diff --git a/object/virtual_machine.go b/object/virtual_machine.go
index 2bcfab60..ed86a392 100644
--- a/object/virtual_machine.go
+++ b/object/virtual_machine.go
@@ -855,8 +855,10 @@ func (v VirtualMachine) UUID(ctx context.Context) string {
 	if err != nil {
 		return ""
 	}
-
-	return o.Config.Uuid
+	if o.Config != nil {
+		return o.Config.Uuid
+	}
+	return ""
 }
 
 func (v VirtualMachine) QueryChangedDiskAreas(ctx context.Context, baseSnapshot, curSnapshot *types.ManagedObjectReference, disk *types.VirtualDisk, offset int64) (types.DiskChangeInfo, error) {
diff --git a/simulator/host_network_system.go b/simulator/host_network_system.go
index 6dbe7ea1..017837f6 100644
--- a/simulator/host_network_system.go
+++ b/simulator/host_network_system.go
@@ -46,6 +46,16 @@ func NewHostNetworkSystem(host *mo.HostSystem) *HostNetworkSystem {
 	}
 }
 
+func (s *HostNetworkSystem) init(r *Registry) {
+	for _, obj := range r.objects {
+		if h, ok := obj.(*HostSystem); ok {
+			if h.ConfigManager.NetworkSystem.Value == s.Self.Value {
+				s.Host = &h.HostSystem
+			}
+		}
+	}
+}
+
 func (s *HostNetworkSystem) folder() *Folder {
 	f := Map.getEntityDatacenter(s.Host).NetworkFolder
 	return Map.Get(f).(*Folder)
@@ -184,3 +194,19 @@ func (s *HostNetworkSystem) UpdateNetworkConfig(req *types.UpdateNetworkConfig)
 		},
 	}
 }
+
+func (s *HostNetworkSystem) QueryNetworkHint(req *types.QueryNetworkHint) soap.HasFault {
+	var info []types.PhysicalNicHintInfo
+
+	for _, nic := range s.Host.Config.Network.Pnic {
+		info = append(info, types.PhysicalNicHintInfo{
+			Device: nic.Device,
+		})
+	}
+
+	return &methods.QueryNetworkHintBody{
+		Res: &types.QueryNetworkHintResponse{
+			Returnval: info,
+		},
+	}
+}
diff --git a/simulator/host_system.go b/simulator/host_system.go
index c0b2e986..28c4b3e6 100644
--- a/simulator/host_system.go
+++ b/simulator/host_system.go
@@ -17,6 +17,7 @@ limitations under the License.
 package simulator
 
 import (
+	"net"
 	"os"
 	"time"
 
@@ -63,6 +64,10 @@ func NewHostSystem(host mo.HostSystem) *HostSystem {
 	info := *esx.HostHardwareInfo
 	hs.Hardware = &info
 
+	cfg := new(types.HostConfigInfo)
+	deepCopy(hs.Config, cfg)
+	hs.Config = cfg
+
 	config := []struct {
 		ref **types.ManagedObjectReference
 		obj mo.Reference
@@ -88,6 +93,9 @@ func (h *HostSystem) configure(spec types.HostConnectSpec, connected bool) {
 	if connected {
 		h.Runtime.ConnectionState = types.HostSystemConnectionStateConnected
 	}
+	if net.ParseIP(spec.HostName) != nil {
+		h.Config.Network.Vnic[0].Spec.Ip.IpAddress = spec.HostName
+	}
 
 	h.Summary.Config.Name = spec.HostName
 	h.Name = h.Summary.Config.Name
diff --git a/simulator/model.go b/simulator/model.go
index f014f3c2..a551e437 100644
--- a/simulator/model.go
+++ b/simulator/model.go
@@ -237,6 +237,7 @@ var kinds = map[string]reflect.Type{
 	"GuestOperationsManager":         reflect.TypeOf((*GuestOperationsManager)(nil)).Elem(),
 	"HostDatastoreBrowser":           reflect.TypeOf((*HostDatastoreBrowser)(nil)).Elem(),
 	"HostLocalAccountManager":        reflect.TypeOf((*HostLocalAccountManager)(nil)).Elem(),
+	"HostNetworkSystem":              reflect.TypeOf((*HostNetworkSystem)(nil)).Elem(),
 	"HostSystem":                     reflect.TypeOf((*HostSystem)(nil)).Elem(),
 	"IpPoolManager":                  reflect.TypeOf((*IpPoolManager)(nil)).Elem(),
 	"LicenseManager":                 reflect.TypeOf((*LicenseManager)(nil)).Elem(),
diff --git a/simulator/object.go b/simulator/object.go
index d13ef1c1..8a1bed43 100644
--- a/simulator/object.go
+++ b/simulator/object.go
@@ -17,10 +17,13 @@ limitations under the License.
 package simulator
 
 import (
+	"bytes"
+
 	"github.com/google/uuid"
 	"github.com/vmware/govmomi/vim25/methods"
 	"github.com/vmware/govmomi/vim25/soap"
 	"github.com/vmware/govmomi/vim25/types"
+	"github.com/vmware/govmomi/vim25/xml"
 )
 
 func SetCustomValue(ctx *Context, req *types.SetCustomValue) soap.HasFault {
@@ -59,3 +62,18 @@ func newUUID(s string) string {
 func sha1UUID(s string) uuid.UUID {
 	return uuid.NewSHA1(uuid.NameSpaceOID, []byte(s))
 }
+
+// deepCopy uses xml encode/decode to copy src to dst
+func deepCopy(src, dst interface{}) {
+	b, err := xml.Marshal(src)
+	if err != nil {
+		panic(err)
+	}
+
+	dec := xml.NewDecoder(bytes.NewReader(b))
+	dec.TypeFunc = types.TypeFunc()
+	err = dec.Decode(dst)
+	if err != nil {
+		panic(err)
+	}
+}
diff --git a/simulator/virtual_machine.go b/simulator/virtual_machine.go
index 9068d25e..532bfe4e 100644
--- a/simulator/virtual_machine.go
+++ b/simulator/virtual_machine.go
@@ -1629,8 +1629,16 @@ func (vm *VirtualMachine) CloneVMTask(ctx *Context, req *types.CloneVM_Task) soa
 		}
 		if req.Spec.Config != nil {
 			config.ExtraConfig = req.Spec.Config.ExtraConfig
+			config.InstanceUuid = req.Spec.Config.InstanceUuid
 		}
 
+		// Copying hardware properties
+		config.NumCPUs = vm.Config.Hardware.NumCPU
+		config.MemoryMB = int64(vm.Config.Hardware.MemoryMB)
+		config.NumCoresPerSocket = vm.Config.Hardware.NumCoresPerSocket
+		config.VirtualICH7MPresent = vm.Config.Hardware.VirtualICH7MPresent
+		config.VirtualSMCPresent = vm.Config.Hardware.VirtualSMCPresent
+
 		defaultDevices := object.VirtualDeviceList(esx.VirtualDevice)
 		devices := vm.Config.Hardware.Device
 		for _, device := range devices {
diff --git a/vapi/namespace/internal/internal.go b/vapi/namespace/internal/internal.go
new file mode 100644
index 00000000..ab6e4501
--- /dev/null
+++ b/vapi/namespace/internal/internal.go
@@ -0,0 +1,22 @@
+/*
+Copyright (c) 2020 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
+
+const (
+	// NamespaceClusterPath is the rest endpoint for the namespace cluster management API
+	NamespaceClusterPath = "/api/vcenter/namespace-management/clusters"
+)
diff --git a/vapi/namespace/namespace.go b/vapi/namespace/namespace.go
new file mode 100644
index 00000000..6a1b0d59
--- /dev/null
+++ b/vapi/namespace/namespace.go
@@ -0,0 +1,61 @@
+/*
+Copyright (c) 2020 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 namespace
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/vmware/govmomi/vapi/namespace/internal"
+	"github.com/vmware/govmomi/vapi/rest"
+	"github.com/vmware/govmomi/vim25/types"
+)
+
+// Manager extends rest.Client, adding namespace related methods.
+type Manager struct {
+	*rest.Client
+}
+
+// NewManager creates a new Manager instance with the given client.
+func NewManager(client *rest.Client) *Manager {
+	return &Manager{
+		Client: client,
+	}
+}
+
+// ClusterSummary for a cluster with vSphere Namespaces enabled.
+type ClusterSummary struct {
+	ID               string `json:"cluster"`
+	Name             string `json:"cluster_name"`
+	KubernetesStatus string `json:"kubernetes_status"`
+	ConfigStatus     string `json:"config_status"`
+}
+
+// Reference implements the mo.Reference interface
+func (c *ClusterSummary) Reference() types.ManagedObjectReference {
+	return types.ManagedObjectReference{
+		Type:  "ClusterComputeResource",
+		Value: c.ID,
+	}
+}
+
+// ListClusters returns a summary of all clusters with vSphere Namespaces enabled.
+func (c *Manager) ListClusters(ctx context.Context) ([]ClusterSummary, error) {
+	var res []ClusterSummary
+	url := c.Resource(internal.NamespaceClusterPath)
+	return res, c.Do(ctx, url.Request(http.MethodGet), &res)
+}
diff --git a/vapi/namespace/simulator/simulator.go b/vapi/namespace/simulator/simulator.go
new file mode 100644
index 00000000..d5756996
--- /dev/null
+++ b/vapi/namespace/simulator/simulator.go
@@ -0,0 +1,99 @@
+/*
+Copyright (c) 2020 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 simulator
+
+import (
+	"context"
+	"net/http"
+	"net/url"
+
+	"github.com/vmware/govmomi"
+	"github.com/vmware/govmomi/property"
+	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/vapi/namespace"
+	vapi "github.com/vmware/govmomi/vapi/simulator"
+	"github.com/vmware/govmomi/view"
+	"github.com/vmware/govmomi/vim25/types"
+
+	"github.com/vmware/govmomi/vapi/namespace/internal"
+)
+
+func init() {
+	simulator.RegisterEndpoint(func(s *simulator.Service, r *simulator.Registry) {
+		New(s.Listen).Register(s, r)
+	})
+}
+
+// Handler implements the Cluster Modules API simulator
+type Handler struct {
+	URL *url.URL
+}
+
+// New creates a Handler instance
+func New(u *url.URL) *Handler {
+	return &Handler{
+		URL: u,
+	}
+}
+
+// Register Namespace Management API paths with the vapi simulator's http.ServeMux
+func (h *Handler) Register(s *simulator.Service, r *simulator.Registry) {
+	if r.IsVPX() {
+		s.HandleFunc(internal.NamespaceClusterPath, h.clusters)
+	}
+}
+
+// enabledClusters returns refs for cluster names with a "WCP-" prefix.
+// Using the name as a simple hack until we add support for enabling via the API.
+func enabledClusters(c *govmomi.Client) ([]types.ManagedObjectReference, error) {
+	ctx := context.Background()
+	kind := []string{"ClusterComputeResource"}
+
+	m := view.NewManager(c.Client)
+	v, err := m.CreateContainerView(ctx, c.ServiceContent.RootFolder, kind, true)
+	if err != nil {
+		return nil, err
+	}
+	defer func() { _ = v.Destroy(ctx) }()
+
+	return v.Find(ctx, kind, property.Filter{"name": "WCP-*"})
+}
+
+func (h *Handler) clusters(w http.ResponseWriter, r *http.Request) {
+	c, err := govmomi.NewClient(context.Background(), h.URL, true)
+	if err != nil {
+		panic(err)
+	}
+
+	switch r.Method {
+	case http.MethodGet:
+		refs, err := enabledClusters(c)
+		if err != nil {
+			panic(err)
+		}
+
+		clusters := make([]namespace.ClusterSummary, len(refs))
+		for i, ref := range refs {
+			clusters[i] = namespace.ClusterSummary{
+				ID:               ref.Value,
+				ConfigStatus:     "RUNNING",
+				KubernetesStatus: "READY",
+			}
+		}
+		vapi.StatusOK(w, clusters)
+	}
+}
diff --git a/vapi/rest/client.go b/vapi/rest/client.go
index 0cad1fb1..95af8d5e 100644
--- a/vapi/rest/client.go
+++ b/vapi/rest/client.go
@@ -25,6 +25,7 @@ import (
 	"io/ioutil"
 	"net/http"
 	"net/url"
+	"strings"
 	"sync"
 	"time"
 
@@ -106,10 +107,21 @@ func (c *Client) UnmarshalJSON(b []byte) error {
 	return nil
 }
 
+// isAPI returns true if path starts with "/api"
+// This hack allows helpers to support both endpoints:
+// "/rest" - value wrapped responses and structured error responses
+// "/api" - raw responses and no structured error responses
+func isAPI(path string) bool {
+	return strings.HasPrefix(path, "/api")
+}
+
 // Resource helper for the given path.
 func (c *Client) Resource(path string) *Resource {
 	r := &Resource{u: c.URL()}
-	r.u.Path = Path + path
+	if !isAPI(path) {
+		path = Path + path
+	}
+	r.u.Path = path
 	return r
 }
 
@@ -174,12 +186,18 @@ func (c *Client) Do(ctx context.Context, req *http.Request, resBody interface{})
 			_, err := io.Copy(b, res.Body)
 			return err
 		default:
+			d := json.NewDecoder(res.Body)
+			if isAPI(req.URL.Path) {
+				// Responses from the /api endpoint are not wrapped
+				return d.Decode(resBody)
+			}
+			// Responses from the /rest endpoint are wrapped in this structure
 			val := struct {
 				Value interface{} `json:"value,omitempty"`
 			}{
 				resBody,
 			}
-			return json.NewDecoder(res.Body).Decode(&val)
+			return d.Decode(&val)
 		}
 	})
 }
diff --git a/vapi/simulator/simulator.go b/vapi/simulator/simulator.go
index 830ded87..ed6269eb 100644
--- a/vapi/simulator/simulator.go
+++ b/vapi/simulator/simulator.go
@@ -289,26 +289,40 @@ func (s *handler) DetachTag(id vim.ManagedObjectReference, tag vim.VslmTagEntry)
 	return nil
 }
 
-// OK responds with http.StatusOK and json encoded val if given.
-func OK(w http.ResponseWriter, val ...interface{}) {
+// StatusOK responds with http.StatusOK and json encoded val if given.
+// For use with "/api" endpoints.
+func StatusOK(w http.ResponseWriter, val ...interface{}) {
 	w.WriteHeader(http.StatusOK)
+	if len(val) == 0 {
+		return
+	}
 
+	err := json.NewEncoder(w).Encode(val[0])
+
+	if err != nil {
+		log.Panic(err)
+	}
+}
+
+// OK responds with http.StatusOK and json encoded val if given.
+// For use with "/rest" endpoints where the response is a "value" wrapped structure.
+func OK(w http.ResponseWriter, val ...interface{}) {
 	if len(val) == 0 {
+		w.WriteHeader(http.StatusOK)
 		return
 	}
 
-	err := json.NewEncoder(w).Encode(struct {
+	s := struct {
 		Value interface{} `json:"value,omitempty"`
 	}{
 		val[0],
-	})
-
-	if err != nil {
-		log.Panic(err)
 	}
+
+	StatusOK(w, s)
 }
 
 // BadRequest responds with http.StatusBadRequest and json encoded vAPI error of type kind.
+// For use with "/rest" endpoints where the response is a "value" wrapped structure.
 func BadRequest(w http.ResponseWriter, kind string) {
 	w.WriteHeader(http.StatusBadRequest)
 
diff --git a/vcsim/main.go b/vcsim/main.go
index dd644ab5..e57cfef9 100644
--- a/vcsim/main.go
+++ b/vcsim/main.go
@@ -43,6 +43,7 @@ import (
 	_ "github.com/vmware/govmomi/pbm/simulator"
 	_ "github.com/vmware/govmomi/sts/simulator"
 	_ "github.com/vmware/govmomi/vapi/cluster/simulator"
+	_ "github.com/vmware/govmomi/vapi/namespace/simulator"
 	_ "github.com/vmware/govmomi/vapi/simulator"
 )
 
diff --git a/vsan/client.go b/vsan/client.go
index f6ac9860..3648da29 100644
--- a/vsan/client.go
+++ b/vsan/client.go
@@ -38,6 +38,10 @@ var (
 		Type:  "VsanVcClusterConfigSystem",
 		Value: "vsan-cluster-config-system",
 	}
+	VsanPerformanceManagerInstance = vimtypes.ManagedObjectReference{
+		Type:  "VsanPerformanceManager",
+		Value: "vsan-performance-manager",
+	}
 )
 
 // Client used for accessing vsan health APIs.
@@ -65,3 +69,18 @@ func (c *Client) VsanClusterGetConfig(ctx context.Context, cluster vimtypes.Mana
 	}
 	return &res.Returnval, nil
 }
+
+// VsanPerfQueryPerf calls the vsan performance manager API
+func (c *Client) VsanPerfQueryPerf(ctx context.Context, cluster *vimtypes.ManagedObjectReference, qSpecs []vsantypes.VsanPerfQuerySpec) ([]vsantypes.VsanPerfEntityMetricCSV, error) {
+	req := vsantypes.VsanPerfQueryPerf{
+		This:       VsanPerformanceManagerInstance,
+		Cluster:    cluster,
+		QuerySpecs: qSpecs,
+	}
+
+	res, err := methods.VsanPerfQueryPerf(ctx, c.serviceClient, &req)
+	if err != nil {
+		return nil, err
+	}
+	return res.Returnval, nil
+}


Test patch:
diff --git a/cns/client_test.go b/cns/client_test.go
index 141fb0b1..b2e1e2f3 100644
--- a/cns/client_test.go
+++ b/cns/client_test.go
@@ -213,7 +213,7 @@ func TestClient(t *testing.T) {
 		t.Errorf("Failed to query volume. Error: %+v \n", err)
 		t.Fatal(err)
 	}
-	t.Logf("Sucessfully Queried Volumes. queryResult: %+v", pretty.Sprint(queryResult))
+	t.Logf("Successfully Queried Volumes. queryResult: %+v", pretty.Sprint(queryResult))
 
 	// Test QueryVolumeInfo API
 	// QueryVolumeInfo is not supported on ReleaseVSAN67u3 and ReleaseVSAN70
@@ -230,20 +230,22 @@ func TestClient(t *testing.T) {
 			t.Errorf("Failed to query volumes with QueryVolumeInfo. Error: %+v \n", err)
 			t.Fatal(err)
 		}
-		queryVolumeInfoTaskResult, err := GetTaskResult(ctx, queryVolumeInfoTaskInfo)
+		queryVolumeInfoTaskResults, err := GetTaskResultArray(ctx, queryVolumeInfoTaskInfo)
 		if err != nil {
 			t.Errorf("Failed to query volumes with QueryVolumeInfo. Error: %+v \n", err)
 			t.Fatal(err)
 		}
-		if queryVolumeInfoTaskResult == nil {
+		if queryVolumeInfoTaskResults == nil {
 			t.Fatalf("Empty queryVolumeInfoTaskResult")
 			t.FailNow()
 		}
-		queryVolumeInfoOperationRes := queryVolumeInfoTaskResult.GetCnsVolumeOperationResult()
-		if queryVolumeInfoOperationRes.Fault != nil {
-			t.Fatalf("Failed to query volumes with QueryVolumeInfo. fault=%+v", queryVolumeInfoOperationRes.Fault)
+		for _, queryVolumeInfoTaskResult := range queryVolumeInfoTaskResults {
+			queryVolumeInfoOperationRes := queryVolumeInfoTaskResult.GetCnsVolumeOperationResult()
+			if queryVolumeInfoOperationRes.Fault != nil {
+				t.Fatalf("Failed to query volumes with QueryVolumeInfo. fault=%+v", queryVolumeInfoOperationRes.Fault)
+			}
+			t.Logf("Successfully Queried Volumes. queryVolumeInfoTaskResult: %+v", pretty.Sprint(queryVolumeInfoTaskResult))
 		}
-		t.Logf("Sucessfully Queried Volumes. queryVolumeInfoTaskResult: %+v", pretty.Sprint(queryVolumeInfoTaskResult))
 	}
 	// Test ExtendVolume API
 	var newCapacityInMb int64 = 10240
@@ -289,7 +291,7 @@ func TestClient(t *testing.T) {
 		t.Errorf("Failed to query volume. Error: %+v \n", err)
 		t.Fatal(err)
 	}
-	t.Logf("Sucessfully Queried Volumes after ExtendVolume. queryResult: %+v", pretty.Sprint(queryResult))
+	t.Logf("Successfully Queried Volumes after ExtendVolume. queryResult: %+v", pretty.Sprint(queryResult))
 	queryCapacity := queryResult.Volumes[0].BackingObjectDetails.(*cnstypes.CnsBlockBackingDetails).CapacityInMb
 	if newCapacityInMb != queryCapacity {
 		t.Errorf("After extend volume %s, expected new volume size is %d, but actual volume size is %d.", extendVolumeId, newCapacityInMb, queryCapacity)
@@ -395,7 +397,7 @@ func TestClient(t *testing.T) {
 	if updateVolumeOperationRes.Fault != nil {
 		t.Fatalf("Failed to update volume metadata: fault=%+v", updateVolumeOperationRes.Fault)
 	} else {
-		t.Logf("Sucessfully updated volume metadata")
+		t.Logf("Successfully updated volume metadata")
 	}
 
 	t.Logf("Calling QueryVolume using queryFilter: %+v", pretty.Sprint(queryFilter))
@@ -404,7 +406,7 @@ func TestClient(t *testing.T) {
 		t.Errorf("Failed to query volume. Error: %+v \n", err)
 		t.Fatal(err)
 	}
-	t.Logf("Sucessfully Queried Volumes. queryResult: %+v", pretty.Sprint(queryResult))
+	t.Logf("Successfully Queried Volumes. queryResult: %+v", pretty.Sprint(queryResult))
 
 	// Test QueryAll
 	querySelection := cnstypes.CnsQuerySelection{
@@ -421,7 +423,7 @@ func TestClient(t *testing.T) {
 		t.Errorf("Failed to query all volumes. Error: %+v \n", err)
 		t.Fatal(err)
 	}
-	t.Logf("Sucessfully Queried all Volumes. queryResult: %+v", pretty.Sprint(queryResult))
+	t.Logf("Successfully Queried all Volumes. queryResult: %+v", pretty.Sprint(queryResult))
 
 	// Create a VM to test Attach Volume API.
 	virtualMachineConfigSpec := vim25types.VirtualMachineConfigSpec{
@@ -675,7 +677,7 @@ func TestClient(t *testing.T) {
 			t.Errorf("Failed to query volume. Error: %+v \n", err)
 			t.Fatal(err)
 		}
-		t.Logf("Sucessfully Queried Volumes. queryResult: %+v", queryResult)
+		t.Logf("Successfully Queried Volumes. queryResult: %+v", queryResult)
 		fileBackingInfo := queryResult.Volumes[0].BackingObjectDetails.(*cnstypes.CnsVsanFileShareBackingDetails)
 		t.Logf("File Share Name: %s with accessPoints: %+v", fileBackingInfo.Name, fileBackingInfo.AccessPoints)
 
@@ -815,7 +817,7 @@ func TestClient(t *testing.T) {
 			t.Errorf("Failed to query volume. Error: %+v \n", err)
 			t.Fatal(err)
 		}
-		t.Logf("Sucessfully Queried Volumes. queryResult: %+v", pretty.Sprint(queryResult))
+		t.Logf("Successfully Queried Volumes. queryResult: %+v", pretty.Sprint(queryResult))
 
 		t.Logf("Deleting CNS volume created above using BACKING_DISK_URL_PATH: %s with volume: %+v", backingDiskURLPath, volumeIDList)
 		deleteTask, err = cnsClient.DeleteVolume(ctx, volumeIDList, true)
diff --git a/govc/test/cluster.bats b/govc/test/cluster.bats
index 6c0ba60f..d15dc437 100755
--- a/govc/test/cluster.bats
+++ b/govc/test/cluster.bats
@@ -227,3 +227,21 @@ _EOF_
   run govc cluster.override.remove -vm DC0_C0_RP0_VM0
   assert_success
 }
+
+@test "cluster.add" {
+    vcsim_env
+    unset GOVC_HOST
+
+    ip=$(govc object.collect -o -json host/DC0_C0/DC0_C0_H0 | jq -r .Config.Network.Vnic[].Spec.Ip.IpAddress)
+    assert_equal 127.0.0.1 "$ip"
+
+    govc cluster.add -cluster DC0_C0 -hostname 10.0.0.42 -username user -password pass
+    assert_success
+
+    ip=$(govc object.collect -o -json host/DC0_C0/10.0.0.42 | jq -r .Config.Network.Vnic[].Spec.Ip.IpAddress)
+
+    assert_equal 10.0.0.42 "$ip"
+    govc host.info -json '*' | jq -r .HostSystems[].Config.Network.Vnic[].Spec.Ip
+    name=$(govc host.info -json -host.ip 10.0.0.42 | jq -r .HostSystems[].Name)
+    assert_equal 10.0.0.42 "$name"
+}
diff --git a/govc/test/library.bats b/govc/test/library.bats
index 8b761c8e..24b2a21f 100755
--- a/govc/test/library.bats
+++ b/govc/test/library.bats
@@ -394,7 +394,7 @@ EOF
   govc library.subscriber.ls govc-pub-vmtx | grep govc-sub-vmtx-$ds2
 
   # Expect 1 VM: govc-pub-vmtx/ttylinux-pc_i486-16.1
-  govc find vm -type f -name govc-* | xargs -n1 -r govc find -type m
+  govc find vm -type f -name govc-* | $xargs -n1 -r govc find -type m
 
   # Publish entire library
   govc library.publish govc-pub-vmtx
@@ -403,7 +403,7 @@ EOF
   govc library.publish govc-pub-vmtx/ttylinux-pc_i486-16.1
 
   # Expect 2 more VMs: govc-sub-vmtx-{$ds1,$ds2}
-  govc find vm -type f -name govc-* | xargs -n1 govc find -type m
+  govc find vm -type f -name govc-* | $xargs -n1 govc find -type m
 
   for ds in $ds1 $ds2 ; do
     govc vm.clone -link -vm govc-sub-vmtx-$ds/ttylinux-pc_i486-16.1 -ds $ds -pool $pool -folder govc-sub-vmtx-$ds ttylinux
diff --git a/govc/test/namespace.bats b/govc/test/namespace.bats
new file mode 100644
index 00000000..aea4d63c
--- /dev/null
+++ b/govc/test/namespace.bats
@@ -0,0 +1,29 @@
+#!/usr/bin/env bats
+
+load test_helper
+
+@test "namespace.cluster.ls" {
+    vcsim_env
+
+    run govc namespace.cluster.ls
+    assert_success ""
+
+    run govc namespace.cluster.ls -json
+    assert_success "[]"
+
+    run govc cluster.create WCP-cluster
+    assert_success
+
+    run govc namespace.cluster.ls
+    assert_success /DC0/host/WCP-cluster
+
+    run govc namespace.cluster.ls -l
+    assert_success
+    assert_matches RUNNING
+    assert_matches READY
+
+    id=$(govc namespace.cluster.ls -json | jq -r .[].cluster)
+
+    run govc object.collect -s "ClusterComputeResource:$id" name
+    assert_success WCP-cluster
+}
diff --git a/govc/test/network.bats b/govc/test/network.bats
index a5155dd4..367255f1 100755
--- a/govc/test/network.bats
+++ b/govc/test/network.bats
@@ -140,7 +140,7 @@ load test_helper
 
   # validate each NIC has a unique MAC
   macs=$(govc device.info -vm "$vm" -json ethernet-* | jq -r .Devices[].MacAddress | uniq | wc -l)
-  [ "$macs" = "2" ]
+  assert_equal 2 "$macs"
 }
 
 @test "network flag required" {
diff --git a/govc/test/test_helper.bash b/govc/test/test_helper.bash
index 371c1819..843c6d57 100644
--- a/govc/test/test_helper.bash
+++ b/govc/test/test_helper.bash
@@ -206,7 +206,7 @@ assert_failure() {
 }
 
 assert_equal() {
-  if [ "$1" != "$2" ]; then
+  if [ "$(collapse_ws "$1")" != "$(collapse_ws "$2")" ]; then
     { echo "expected: $1"
       echo "actual:   $2"
     } | flunk
diff --git a/govc/test/vcsim.bats b/govc/test/vcsim.bats
index 31f96c40..05cb3ca9 100755
--- a/govc/test/vcsim.bats
+++ b/govc/test/vcsim.bats
@@ -67,7 +67,7 @@ load test_helper
   run govc object.collect -s -type h host/DC0_H0 summary.config.port
   assert_success "$port"
   ports=$(govc object.collect -s -type h / summary.config.port | uniq -u | wc -l)
-  [ "$ports" = "0" ] # all host ports should be the same value
+  assert_equal 0 "$ports" # all host ports should be the same value
 
   vcsim_stop
 
@@ -75,8 +75,8 @@ load test_helper
 
   hosts=$(curl -sk "https://$url/debug/vars" | jq .vcsim.Model.Host)
   ports=$(govc object.collect -s -type h / summary.config.port | uniq -u | wc -l)
-  [ "$ports" = "$hosts" ] # all host ports should be unique
-  [[ "$ports" != *$port* ]] # host ports should not include vcsim port
+  assert_equal "$ports" "$hosts" # all host ports should be unique
+  grep -v "$port" <<<"$ports" # host ports should not include vcsim port
 }
 
 @test "vcsim set vm properties" {
@@ -171,13 +171,13 @@ EOF
   assert_success "$(printf "265104de-1472-547c-b873-6dc7883fb6cb\nb4689bed-97f0-5bcd-8a4c-07477cc8f06f")"
 
   dups=$(govc object.collect -s -type m / config.uuid | sort | uniq -d | wc -l)
-  [ "$dups" = "0" ]
+  assert_equal 0 "$dups"
 
   run govc object.collect -s host/DC0_H0/DC0_H0 summary.hardware.uuid
   assert_success dcf7fb3c-4a1c-5a05-b730-5e09f3704e2f
 
   dups=$(govc object.collect -s -type m / summary.hardware.uuid | sort | uniq -d | wc -l)
-  [ "$dups" = "0" ]
+  assert_equal 0 "$dups"
 
   run govc vm.create foo.yakity
   assert_success
@@ -281,7 +281,7 @@ docker_name() {
   assert_success
 
   ip=$(govc object.collect -s vm/$vm guest.ipAddress)
-  run curl -f "http://$ip/vcsim.bats"
+  run docker run --rm curlimages/curl curl -f "http://$ip/vcsim.bats"
   assert_success
 
   # test suspend/resume
@@ -454,6 +454,9 @@ docker_name() {
 
   objs=$(govc find / | wc -l)
   assert_equal 23 "$objs"
+
+  run govc host.portgroup.add -host DC0_H0 -vswitch vSwitch0 bridge
+  assert_success # issue #2016
 }
 
 @test "vcsim trace file" {
diff --git a/simulator/host_network_system_test.go b/simulator/host_network_system_test.go
index 4fc8aa45..17c81b98 100644
--- a/simulator/host_network_system_test.go
+++ b/simulator/host_network_system_test.go
@@ -164,4 +164,13 @@ func TestHostNetworkSystem(t *testing.T) {
 	if err == nil {
 		t.Error("expected error")
 	}
+
+	info, err := ns.QueryNetworkHint(ctx, nil)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	if len(info) != len(esx.HostConfigInfo.Network.Pnic) {
+		t.Errorf("len=%d", len(info))
+	}
 }
diff --git a/simulator/object_test.go b/simulator/object_test.go
index f51f1b48..5ae4bafd 100644
--- a/simulator/object_test.go
+++ b/simulator/object_test.go
@@ -22,6 +22,7 @@ import (
 
 	"github.com/vmware/govmomi"
 	"github.com/vmware/govmomi/object"
+	"github.com/vmware/govmomi/simulator/esx"
 	"github.com/vmware/govmomi/vim25/types"
 )
 
@@ -188,3 +189,10 @@ func TestObjectCustomFields(t *testing.T) {
 		t.Fatalf("len(vm.AvailableField) expected 0, got %d", len(vm.AvailableField))
 	}
 }
+
+func BenchmarkDeepCopy(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		config := new(types.HostConfigInfo)
+		deepCopy(esx.HostConfigInfo, config)
+	}
+}
diff --git a/vapi/library/example_test.go b/vapi/library/example_test.go
new file mode 100644
index 00000000..5a8831dd
--- /dev/null
+++ b/vapi/library/example_test.go
@@ -0,0 +1,69 @@
+/*
+Copyright (c) 2020 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 library_test
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/vmware/govmomi/find"
+	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/vapi/library"
+	"github.com/vmware/govmomi/vapi/rest"
+	"github.com/vmware/govmomi/vim25"
+
+	_ "github.com/vmware/govmomi/vapi/simulator"
+)
+
+func ExampleManager_CreateLibrary() {
+	simulator.Run(func(ctx context.Context, vc *vim25.Client) error {
+		c := rest.NewClient(vc)
+
+		err := c.Login(ctx, simulator.DefaultLogin)
+		if err != nil {
+			return err
+		}
+
+		ds, err := find.NewFinder(vc).DefaultDatastore(ctx)
+		if err != nil {
+			return err
+		}
+
+		m := library.NewManager(c)
+
+		id, err := m.CreateLibrary(ctx, library.Library{
+			Name: "example",
+			Type: "LOCAL",
+			Storage: []library.StorageBackings{{
+				DatastoreID: ds.Reference().Value,
+				Type:        "DATASTORE",
+			}},
+		})
+		if err != nil {
+			return err
+		}
+
+		l, err := m.GetLibraryByID(ctx, id)
+		if err != nil {
+			return err
+		}
+
+		fmt.Println("created library", l.Name)
+		return nil
+	})
+	// Output: created library example
+}