Repository: vmware/govmomi
Pull request #2143 (2020-10-05 08:43:32)

govc (+ vapi): namespace.cluster.enable for enabling namespaces on a cluster
Hi!

This covers some functionality I wanted to use for programmatically enabling Kubernetes in Vsphere. I saw some TODO comments around this so hopefully this PR is welcome.

This did turn out to be more involved than I initially expected, this covers:
- Structs corresponding to the enable spec from the API added to the VAPI package, along with EnableCluster and DisableCluster functions
- Corresponding cluster.namespace.enable and cluster.namespace.disable commands
- Also added ListCompatibleDistributedSwitches and ListCompatibleEdgeClusters to VAPI, which are needed to get the IDs for these objects in the format expected by the API
- Simple E2E test via govc/bats with the vcsim returning success on the relevant endpoint
- Ran the corresponding commands against my own Vsphere installation, and the supervisor cluster came up and was working

There is more that could be done (e.g. DefaultKubernetesServiceContentLibrary, validating some cases that the API doesn't), but hopefully this is enough for a first pass


Patch:
diff --git a/govc/namespace/cluster/disable.go b/govc/namespace/cluster/disable.go
new file mode 100644
index 00000000..b5f69c54
--- /dev/null
+++ b/govc/namespace/cluster/disable.go
@@ -0,0 +1,84 @@
+/*
+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"
+	"flag"
+	"fmt"
+
+	"github.com/vmware/govmomi/govc/cli"
+	"github.com/vmware/govmomi/govc/flags"
+	"github.com/vmware/govmomi/vapi/namespace"
+)
+
+type disableCluster struct {
+	*flags.ClusterFlag
+}
+
+func init() {
+	cli.Register("namespace.cluster.disable", &disableCluster{})
+}
+
+func (cmd *disableCluster) Register(ctx context.Context, f *flag.FlagSet) {
+	cmd.ClusterFlag, ctx = flags.NewClusterFlag(ctx)
+	cmd.ClusterFlag.Register(ctx, f)
+}
+
+func (cmd *disableCluster) Process(ctx context.Context) error {
+	if err := cmd.ClusterFlag.Process(ctx); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (cmd *disableCluster) Usage() string {
+	return ""
+}
+
+func (cmd *disableCluster) Description() string {
+	return `Disables vSphere Namespaces on the specified cluster.
+
+Example:
+      govc namespace.cluster.disable \
+      --cluster "Workload-Cluster"
+  `
+}
+
+func (cmd *disableCluster) Run(ctx context.Context, f *flag.FlagSet) error {
+	c, err := cmd.RestClient()
+	if err != nil {
+		return err
+	}
+
+	// Cluster object reference lookup
+	cluster, err := cmd.Cluster()
+	if err != nil {
+		return err
+	}
+
+	m := namespace.NewManager(c)
+	clusterId := cluster.Reference().Value
+
+	err = m.DisableCluster(ctx, clusterId)
+	if err != nil {
+		return fmt.Errorf("error disabling cluster: %s", err)
+	}
+
+	return nil
+}
diff --git a/govc/namespace/cluster/enable.go b/govc/namespace/cluster/enable.go
new file mode 100644
index 00000000..d5dbaa63
--- /dev/null
+++ b/govc/namespace/cluster/enable.go
@@ -0,0 +1,407 @@
+/*
+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"
+	"flag"
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/vmware/govmomi/govc/cli"
+	"github.com/vmware/govmomi/govc/flags"
+	"github.com/vmware/govmomi/govc/storage/policy"
+	"github.com/vmware/govmomi/pbm"
+	"github.com/vmware/govmomi/vapi/namespace"
+)
+
+type enableCluster struct {
+	ControlPlaneDNSSearchDomains           string
+	ImageStoragePolicy                     string
+	NcpClusterNetworkSpec                  workloadNetwork
+	ControlPlaneManagementNetwork          namespace.MasterManagementNetwork
+	ControlPlaneDNSNames                   string
+	ControlPlaneNTPServers                 string
+	EphemeralStoragePolicy                 string
+	DefaultImageRepository                 string
+	ServiceCidr                            string
+	LoginBanner                            string
+	SizeHint                               string
+	WorkerDNS                              string
+	DefaultImageRegistry                   string
+	ControlPlaneDNS                        string
+	NetworkProvider                        string
+	ControlPlaneStoragePolicy              string
+	DefaultKubernetesServiceContentLibrary string
+
+	*flags.ClusterFlag
+}
+
+type workloadNetwork struct {
+	NsxEdgeCluster string
+	PodCidrs       string
+	EgressCidrs    string
+	Switch         string
+	IngressCidrs   string
+}
+
+type objectReferences struct {
+	Cluster                   string
+	Network                   string
+	ImageStoragePolicy        string
+	ControlPlaneStoragePolicy string
+	EphemeralStoragePolicy    string
+	WorkerNetworkSwitch       string
+	EdgeCluster               string
+}
+
+func init() {
+	newEnableCluster := &enableCluster{
+		ControlPlaneManagementNetwork: namespace.MasterManagementNetwork{
+			AddressRange: &namespace.AddressRange{},
+		},
+	}
+	cli.Register("namespace.cluster.enable", newEnableCluster)
+}
+
+func (cmd *enableCluster) Register(ctx context.Context, f *flag.FlagSet) {
+	cmd.ClusterFlag, ctx = flags.NewClusterFlag(ctx)
+	// this brings in some additional flags
+	// Datacenter flag, which we do want in order to configure a Finder we use to do some lookups
+	// ClientFlag, which we do need to control auth and connection details
+	// OutputFlag, which doesn't have any effect in this case as there's no response to output
+	cmd.ClusterFlag.Register(ctx, f)
+	// Descriptions are mostly extracted from:
+	// https://vmware.github.io/vsphere-automation-sdk-rest/vsphere/operations/com/vmware/vcenter/namespace_management/clusters.enable-operation.html
+	f.StringVar(&cmd.SizeHint, "size", "",
+		"The size of the Kubernetes API server and the worker nodes. Value is one of: TINY, SMALL, MEDIUM, LARGE.")
+	f.StringVar(&cmd.ServiceCidr, "service-cidr", "",
+		"CIDR block from which Kubernetes allocates service cluster IP addresses. Shouldn't overlap with pod, ingress or egress CIDRs")
+	f.StringVar(&cmd.NetworkProvider, "network-provider", "NSXT_CONTAINER_PLUGIN",
+		"Optional. Provider of cluster networking for this vSphere Namespaces cluster. Currently only value supported is: NSXT_CONTAINER_PLUGIN.")
+	f.StringVar(&cmd.NcpClusterNetworkSpec.PodCidrs, "pod-cidrs", "",
+		"CIDR blocks from which Kubernetes allocates pod IP addresses. Comma-separated list. Shouldn't overlap with service, ingress or egress CIDRs.")
+	f.StringVar(&cmd.NcpClusterNetworkSpec.IngressCidrs, "workload-network.ingress-cidrs", "",
+		"CIDR blocks from which NSX assigns IP addresses for Kubernetes Ingresses and Kubernetes Services of type LoadBalancer. Comma-separated list. Shouldn't overlap with pod, service or egress CIDRs.")
+	f.StringVar(&cmd.NcpClusterNetworkSpec.EgressCidrs, "workload-network.egress-cidrs", "",
+		"CIDR blocks from which NSX assigns IP addresses used for performing SNAT from container IPs to external IPs. Comma-separated list. Shouldn't overlap with pod, service or ingress CIDRs.")
+	f.StringVar(&cmd.NcpClusterNetworkSpec.Switch, "workload-network.switch", "",
+		"vSphere Distributed Switch used to connect this cluster.")
+	f.StringVar(&cmd.NcpClusterNetworkSpec.NsxEdgeCluster, "workload-network.edge-cluster", "",
+		"NSX Edge Cluster to be used for Kubernetes Services of type LoadBalancer, Kubernetes Ingresses, and NSX SNAT.")
+	f.StringVar(&cmd.ControlPlaneManagementNetwork.Network, "mgmt-network.network", "",
+		"Identifier for the management network.")
+	f.StringVar(&cmd.ControlPlaneManagementNetwork.Mode, "mgmt-network.mode", "STATICRANGE",
+		" IPv4 address assignment modes. Value is one of: DHCP, STATICRANGE")
+	f.StringVar(&cmd.ControlPlaneManagementNetwork.FloatingIP, "mgmt-network.floating-IP", "",
+		"Optional. The Floating IP used by the HA master cluster in the when network Mode is DHCP.")
+	f.StringVar(&cmd.ControlPlaneManagementNetwork.AddressRange.StartingAddress, "mgmt-network.starting-address", "",
+		"Denotes the start of the IP range to be used. Optional, but required with network mode STATICRANGE.")
+	f.IntVar(&cmd.ControlPlaneManagementNetwork.AddressRange.AddressCount, "mgmt-network.address-count", 5,
+		"The number of IP addresses in the management range. Optional, but required with network mode STATICRANGE.")
+	f.StringVar(&cmd.ControlPlaneManagementNetwork.AddressRange.SubnetMask, "mgmt-network.subnet-mask", "",
+		"Subnet mask of the management network. Optional, but required with network mode STATICRANGE.")
+	f.StringVar(&cmd.ControlPlaneManagementNetwork.AddressRange.Gateway, "mgmt-network.gateway", "",
+		"Gateway to be used for the management IP range")
+	f.StringVar(&cmd.ControlPlaneDNS, "control-plane-dns", "",
+		"Comma-separated list of DNS server IP addresses to use on Kubernetes API server, specified in order of preference.")
+	f.StringVar(&cmd.ControlPlaneDNSNames, "control-plane-dns-names", "",
+		"Comma-separated list of DNS names to associate with the Kubernetes API server. These DNS names are embedded in the TLS certificate presented by the API server.")
+	f.StringVar(&cmd.ControlPlaneDNSSearchDomains, "control-plane-dns-search-domains", "",
+		"Comma-separated list of domains to be searched when trying to lookup a host name on Kubernetes API server, specified in order of preference.")
+	f.StringVar(&cmd.ControlPlaneNTPServers, "control-plane-ntp-servers", "",
+		"Optional. Comma-separated list of NTP server DNS names or IP addresses to use on Kubernetes API server, specified in order of preference. If unset, VMware Tools based time synchronization is enabled.")
+	f.StringVar(&cmd.WorkerDNS, "worker-dns", "",
+		"Comma-separated list of DNS server IP addresses to use on the worker nodes, specified in order of preference.")
+	f.StringVar(&cmd.ControlPlaneStoragePolicy, "control-plane-storage-policy", "",
+		"Storage policy associated with Kubernetes API server.")
+	f.StringVar(&cmd.EphemeralStoragePolicy, "ephemeral-storage-policy", "",
+		"Storage policy associated with ephemeral disks of all the Kubernetes Pods in the cluster.")
+	f.StringVar(&cmd.ImageStoragePolicy, "image-storage-policy", "",
+		"Storage policy to be used for container images.")
+	f.StringVar(&cmd.LoginBanner, "login-banner", "",
+		"Optional. Disclaimer to be displayed prior to login via the Kubectl plugin.")
+	// documented API is currently ambiguous with these duplicated fields, need to wait for this to be resolved
+	//f.StringVar(&cmd.DefaultImageRegistry, "default-image-registry", "",
+	//  "Optional. Default image registry to use when unspecified in the container image name. Defaults to Docker Hub.")
+	//f.StringVar(&cmd.DefaultImageRepository, "default-image-repository", "",
+	//  "Optional. Default image registry to use when unspecified in the container image name. Defaults to Docker Hub.")
+	// TODO
+	// f.StringVar(&cmd.DefaultKubernetesServiceContentLibrary, "default-kubernetes-service-content-library", "",
+	//  "Optional. Content Library which holds the VM Images for vSphere Kubernetes Service. This Content Library should be subscribed to VMware's hosted vSphere Kubernetes Service Repository.")
+}
+
+func (cmd *enableCluster) Process(ctx context.Context) error {
+	if err := cmd.ClusterFlag.Process(ctx); err != nil {
+		return err
+	}
+	// relying on Vsphere API to check mandatory values are not empty
+
+	return nil
+}
+
+func (cmd *enableCluster) Usage() string {
+	return ""
+}
+
+func (cmd *enableCluster) Description() string {
+	return `Enable vSphere Namespaces on the cluster. 
+This operation sets up Kubernetes instance for the cluster along with worker nodes.
+
+Example:
+      govc namespace.cluster.enable \
+      --cluster "Workload-Cluster" \
+      --service-cidr 10.96.0.0/23 \
+      --pod-cidrs 10.244.0.0/20 \
+      --control-plane-dns-names wcp.example.com \
+      --workload-network.egress-cidrs 10.0.0.128/26 \
+      --workload-network.ingress-cidrs "10.0.0.64/26" \
+      --workload-network.switch VDS \
+      --workload-network.edge-cluster Edge-Cluster-1 \
+      --size TINY   \
+      --mgmt-network.network "DVPG-Management Network" \
+      --mgmt-network.gateway 10.0.0.1 \
+      --mgmt-network.starting-address 10.0.0.45 \
+      --mgmt-network.subnet-mask 255.255.255.0 \
+      --ephemeral-storage-policy "vSAN Default Storage Policy" \
+      --control-plane-storage-policy "vSAN Default Storage Policy" \
+      --image-storage-policy "vSAN Default Storage Policy" 
+  `
+}
+
+func (cmd *enableCluster) Run(ctx context.Context, f *flag.FlagSet) error {
+	c, err := cmd.RestClient()
+	if err != nil {
+		return err
+	}
+
+	// Cluster object reference lookup
+	cluster, err := cmd.Cluster()
+	if err != nil {
+		return err
+	}
+	id := cluster.Reference().Value
+
+	finder, err := cmd.ClusterFlag.Finder()
+	if err != nil {
+		return err
+	}
+
+	// Network object reference lookup.
+	networkRef, err := finder.Network(ctx, cmd.ControlPlaneManagementNetwork.Network)
+	if err != nil {
+		return err
+	}
+
+	// Storage policy object references lookup
+	vc, err := cmd.Client()
+	if err != nil {
+		return fmt.Errorf("error creating soap client: %s", err)
+	}
+
+	pbmc, err := pbm.NewClient(ctx, vc)
+	if err != nil {
+		return fmt.Errorf("error creating client for storage policy lookup: %s", err)
+	}
+
+	storagePolicyNames := make(map[string]string)
+	storagePolicyNames["control-plane"] = cmd.ControlPlaneStoragePolicy
+	storagePolicyNames["ephemeral"] = cmd.EphemeralStoragePolicy
+	storagePolicyNames["image"] = cmd.ImageStoragePolicy
+	// keep track of names we looked up, so we don't repeat lookups
+	visited := make(map[string]string)
+	storagePolicyRefs := make(map[string]string)
+	for k, v := range storagePolicyNames {
+		if _, exists := visited[v]; !exists {
+			policies, err := policy.ListProfiles(ctx, pbmc, v)
+			if err != nil {
+				return fmt.Errorf("error looking up storage policy %q: %s", v, err)
+			} else if len(policies) != 1 {
+				return fmt.Errorf("could not find a unique storage policy ID for query %q", v)
+			}
+			visited[v] = policies[0].GetPbmProfile().ProfileId.UniqueId
+			storagePolicyRefs[k] = policies[0].GetPbmProfile().ProfileId.UniqueId
+		} else {
+			storagePolicyRefs[k] = visited[v]
+		}
+
+	}
+
+	// DVS Object reference lookup
+	// We need an id returned from the namespace lookup here, not a regular managed object reference.
+	// Similar approach in powerCLI here:
+	// https://github.com/lamw/PowerCLI-Example-Scripts/blob/7e4b9b9c93c5ffaa0ac2fefa8e02e5f751c044b7/Modules/VMware.WorkloadManagement/VMware.WorkloadManagement.psm1#L123
+	// Note that the data model returned means we get no chance to choose the switch by name.
+	// We assume there's just one switch per cluster and bail out otherwise.
+	m := namespace.NewManager(c)
+	clusterId := cluster.Reference().Value
+	switches, err := m.ListCompatibleDistributedSwitches(ctx, clusterId)
+	if err != nil {
+		return fmt.Errorf("error in compatible switch lookup: %s", err)
+	} else if len(switches) != 1 {
+		return fmt.Errorf("expected to find 1 namespace compatible switch in cluster %q, found %d",
+			clusterId, len(switches))
+	}
+
+	switchId := switches[0].DistributedSwitch
+
+	edgeClusterDisplayName := cmd.NcpClusterNetworkSpec.NsxEdgeCluster
+	edgeClusters, err := m.ListCompatibleEdgeClusters(ctx, clusterId, switchId)
+	if err != nil {
+		return fmt.Errorf("error in compatible switch lookup: %s", err)
+	}
+
+	matchingEdgeClusters := make([]string, 0)
+	for _, v := range edgeClusters {
+		if v.DisplayName == edgeClusterDisplayName {
+			matchingEdgeClusters = append(matchingEdgeClusters, v.EdgeCluster)
+		}
+	}
+	if len(matchingEdgeClusters) != 1 {
+		return fmt.Errorf("Didn't find unique match for edge cluster %q, found %d objects",
+			edgeClusterDisplayName, len(matchingEdgeClusters))
+	}
+
+	resolvedObjectRefs := objectReferences{
+		Cluster:                   cluster.Reference().Value,
+		Network:                   networkRef.Reference().Value,
+		ControlPlaneStoragePolicy: storagePolicyRefs["control-plane"],
+		EphemeralStoragePolicy:    storagePolicyRefs["ephemeral"],
+		ImageStoragePolicy:        storagePolicyRefs["image"],
+		WorkerNetworkSwitch:       switchId,
+		EdgeCluster:               matchingEdgeClusters[0],
+	}
+
+	enableClusterSpec, err := cmd.toVapiSpec(resolvedObjectRefs)
+	if err != nil {
+		return err
+	}
+
+	err = m.EnableCluster(ctx, id, enableClusterSpec)
+	if err != nil {
+		return err
+	}
+
+	return nil
+}
+
+func (cmd *enableCluster) toVapiSpec(refs objectReferences) (*namespace.EnableClusterSpec, error) {
+
+	podCidrs, err := splitCidrList(splitCommaSeparatedList(cmd.NcpClusterNetworkSpec.PodCidrs))
+	if err != nil {
+		return nil, fmt.Errorf("invalid workload-network.pod-cidrs value: %s", err)
+	}
+	egressCidrs, err := splitCidrList(splitCommaSeparatedList(cmd.NcpClusterNetworkSpec.EgressCidrs))
+	if err != nil {
+		return nil, fmt.Errorf("invalid workload-network.egress-cidrs value: %s", err)
+	}
+	ingressCidrs, err := splitCidrList(splitCommaSeparatedList(cmd.NcpClusterNetworkSpec.IngressCidrs))
+	if err != nil {
+		return nil, fmt.Errorf("invalid workload-network.ingress-cidrs value: %s", err)
+	}
+	serviceCidr, err := splitCidr(cmd.ServiceCidr)
+	if err != nil {
+		return nil, fmt.Errorf("invalid service-cidr value: %s", err)
+	}
+	var masterManagementNetwork *namespace.MasterManagementNetwork
+	if (cmd.ControlPlaneManagementNetwork.Mode != "") ||
+		(cmd.ControlPlaneManagementNetwork.FloatingIP != "") ||
+		(cmd.ControlPlaneManagementNetwork.Network != "") {
+		masterManagementNetwork = &cmd.ControlPlaneManagementNetwork
+	}
+	if masterManagementNetwork != nil {
+		if (masterManagementNetwork.AddressRange.SubnetMask == "") &&
+			(masterManagementNetwork.AddressRange.StartingAddress == "") &&
+			(masterManagementNetwork.AddressRange.Gateway == "") &&
+			(masterManagementNetwork.AddressRange.AddressCount == 0) {
+			masterManagementNetwork.AddressRange = nil
+		}
+		masterManagementNetwork.Network = refs.Network
+	}
+
+	spec := namespace.EnableClusterSpec{
+		MasterDNSSearchDomains: splitCommaSeparatedList(cmd.ControlPlaneDNSSearchDomains),
+		ImageStorage:           namespace.ImageStorage{StoragePolicy: refs.ImageStoragePolicy},
+		NcpClusterNetworkSpec: &namespace.NcpClusterNetworkSpec{
+			NsxEdgeCluster:           refs.EdgeCluster,
+			PodCidrs:                 podCidrs,
+			EgressCidrs:              egressCidrs,
+			ClusterDistributedSwitch: refs.WorkerNetworkSwitch,
+			IngressCidrs:             ingressCidrs,
+		},
+		MasterManagementNetwork:                masterManagementNetwork,
+		MasterDNSNames:                         splitCommaSeparatedList(cmd.ControlPlaneDNSNames),
+		MasterNTPServers:                       splitCommaSeparatedList(cmd.ControlPlaneNTPServers),
+		EphemeralStoragePolicy:                 refs.EphemeralStoragePolicy,
+		DefaultImageRepository:                 cmd.DefaultImageRepository,
+		ServiceCidr:                            serviceCidr,
+		LoginBanner:                            cmd.LoginBanner,
+		SizeHint:                               cmd.SizeHint,
+		WorkerDNS:                              splitCommaSeparatedList(cmd.WorkerDNS),
+		DefaultImageRegistry:                   nil,
+		MasterDNS:                              splitCommaSeparatedList(cmd.ControlPlaneDNS),
+		NetworkProvider:                        cmd.NetworkProvider,
+		MasterStoragePolicy:                    refs.ControlPlaneStoragePolicy,
+		DefaultKubernetesServiceContentLibrary: cmd.DefaultKubernetesServiceContentLibrary,
+	}
+	return &spec, nil
+}
+
+func splitCidr(input string) (*namespace.Cidr, error) {
+	parts := strings.Split(input, "/")
+	if !(len(parts) == 2) || parts[0] == "" {
+		return nil, fmt.Errorf("invalid cidr %q supplied, needs to be in form '192.168.1.0/24'", input)
+	}
+
+	prefix, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return nil, err
+	}
+
+	result := namespace.Cidr{
+		Address: parts[0],
+		Prefix:  prefix,
+	}
+	return &result, nil
+}
+
+func splitCidrList(input []string) ([]namespace.Cidr, error) {
+	var result []namespace.Cidr
+	for i, cidrIn := range input {
+		cidr, err := splitCidr(cidrIn)
+		if err != nil {
+			return nil, fmt.Errorf("parsing cidr %q in list position %d : %q", cidrIn, i, err)
+		}
+		result = append(result, *cidr)
+	}
+	return result, nil
+}
+
+func splitCommaSeparatedList(cslist string) []string {
+	return deleteEmpty(strings.Split(cslist, ","))
+}
+
+func deleteEmpty(s []string) []string {
+	var r []string
+	for _, str := range s {
+		if str != "" {
+			r = append(r, str)
+		}
+	}
+	return r
+}
diff --git a/govc/storage/policy/info.go b/govc/storage/policy/info.go
index a5964306..fc2db5d5 100644
--- a/govc/storage/policy/info.go
+++ b/govc/storage/policy/info.go
@@ -121,7 +121,7 @@ func (cmd *info) Run(ctx context.Context, f *flag.FlagSet) error {
 		return err
 	}
 
-	profiles, err := listProfiles(ctx, c, f.Arg(0))
+	profiles, err := ListProfiles(ctx, c, f.Arg(0))
 	if err != nil {
 		return err
 	}
diff --git a/govc/storage/policy/ls.go b/govc/storage/policy/ls.go
index 4f2acc81..7a633213 100644
--- a/govc/storage/policy/ls.go
+++ b/govc/storage/policy/ls.go
@@ -73,7 +73,7 @@ Examples:
   govc storage.policy.ls -i "vSAN Default Storage Policy"`
 }
 
-func listProfiles(ctx context.Context, c *pbm.Client, name string) ([]types.BasePbmProfile, error) {
+func ListProfiles(ctx context.Context, c *pbm.Client, name string) ([]types.BasePbmProfile, error) {
 	rtype := types.PbmProfileResourceType{
 		ResourceType: string(types.PbmProfileResourceTypeEnumSTORAGE),
 	}
@@ -134,7 +134,7 @@ func (cmd *ls) Run(ctx context.Context, f *flag.FlagSet) error {
 		return err
 	}
 
-	profiles, err := listProfiles(ctx, c, f.Arg(0))
+	profiles, err := ListProfiles(ctx, c, f.Arg(0))
 	if err != nil {
 		return err
 	}
diff --git a/govc/storage/policy/rm.go b/govc/storage/policy/rm.go
index dc90566a..891375fe 100644
--- a/govc/storage/policy/rm.go
+++ b/govc/storage/policy/rm.go
@@ -70,7 +70,7 @@ func (cmd *rm) Run(ctx context.Context, f *flag.FlagSet) error {
 	arg := f.Arg(0)
 	id := types.PbmProfileId{UniqueId: arg}
 
-	profile, err := listProfiles(ctx, c, arg)
+	profile, err := ListProfiles(ctx, c, arg)
 	if err != nil {
 		return err
 	}
diff --git a/simulator/virtual_machine.go b/simulator/virtual_machine.go
index 5b151459..532bfe4e 100644
--- a/simulator/virtual_machine.go
+++ b/simulator/virtual_machine.go
@@ -1116,22 +1116,6 @@ func (vm *VirtualMachine) configureDevice(devices object.VirtualDeviceList, spec
 			})
 
 			vm.updateDiskLayouts()
-
-			if disk, ok := b.(*types.VirtualDiskFlatVer2BackingInfo); ok {
-				// These properties default to false
-				props := []**bool{
-					&disk.EagerlyScrub,
-					&disk.ThinProvisioned,
-					&disk.WriteThrough,
-					&disk.Split,
-					&disk.DigestEnabled,
-				}
-				for _, prop := range props {
-					if *prop == nil {
-						*prop = types.NewBool(false)
-					}
-				}
-			}
 		}
 	case *types.VirtualCdrom:
 		if b, ok := d.Backing.(types.BaseVirtualDeviceFileBackingInfo); ok {
@@ -1158,13 +1142,6 @@ func (vm *VirtualMachine) configureDevice(devices object.VirtualDeviceList, spec
 		}
 	}
 
-	switch device.(type) {
-	case types.BaseVirtualEthernetCard, *types.VirtualCdrom, *types.VirtualFloppy, *types.VirtualUSB:
-		if d.Connectable == nil {
-			d.Connectable = &types.VirtualDeviceConnectInfo{StartConnected: true, Connected: true}
-		}
-	}
-
 	return nil
 }
 
diff --git a/vapi/namespace/internal/internal.go b/vapi/namespace/internal/internal.go
index dc430e55..32d436e9 100644
--- a/vapi/namespace/internal/internal.go
+++ b/vapi/namespace/internal/internal.go
@@ -18,7 +18,9 @@ package internal
 
 const (
 	// NamespaceClusterPath is the rest endpoint for the namespace cluster management API
-	NamespaceClusterPath = "/api/vcenter/namespace-management/clusters"
+	NamespaceClusterPath                    = "/api/vcenter/namespace-management/clusters"
+	NamespaceDistributedSwitchCompatibility = "/api/vcenter/namespace-management/distributed-switch-compatibility"
+	NamespaceEdgeClusterCompatibility       = "/api/vcenter/namespace-management/edge-cluster-compatibility"
 )
 
 type SupportBundleToken struct {
diff --git a/vapi/namespace/namespace.go b/vapi/namespace/namespace.go
index 9d017528..3e1c21d5 100644
--- a/vapi/namespace/namespace.go
+++ b/vapi/namespace/namespace.go
@@ -40,6 +40,78 @@ func NewManager(client *rest.Client) *Manager {
 	}
 }
 
+type EnableClusterSpec struct {
+	MasterDNSSearchDomains                 []string                 `json:"master_DNS_search_domains,omitempty"`
+	ImageStorage                           ImageStorage             `json:"image_storage"`
+	NcpClusterNetworkSpec                  *NcpClusterNetworkSpec   `json:"ncp_cluster_network_spec"`
+	MasterManagementNetwork                *MasterManagementNetwork `json:"master_management_network"`
+	MasterDNSNames                         []string                 `json:"Master_DNS_names,omitempty"`
+	MasterNTPServers                       []string                 `json:"master_NTP_servers,omitempty"`
+	EphemeralStoragePolicy                 string                   `json:"ephemeral_storage_policy,omitempty"`
+	DefaultImageRepository                 string                   `json:"default_image_repository,omitempty"`
+	ServiceCidr                            *Cidr                    `json:"service_cidr"`
+	LoginBanner                            string                   `json:"login_banner,omitempty"`
+	SizeHint                               string                   `json:"size_hint"`
+	WorkerDNS                              []string                 `json:"worker_DNS,omitempty"`
+	DefaultImageRegistry                   *DefaultImageRegistry    `json:"default_image_registry,omitempty"`
+	MasterDNS                              []string                 `json:"master_DNS,omitempty"`
+	NetworkProvider                        string                   `json:"network_provider"`
+	MasterStoragePolicy                    string                   `json:"master_storage_policy,omitempty"`
+	DefaultKubernetesServiceContentLibrary string                   `json:"default_kubernetes_service_content_library,omitempty"`
+}
+
+type ImageStorage struct {
+	StoragePolicy string `json:"storage_policy"`
+}
+
+type Cidr struct {
+	Address string `json:"address"`
+	Prefix  int    `json:"prefix"`
+}
+
+type NcpClusterNetworkSpec struct {
+	NsxEdgeCluster           string `json:"nsx_edge_cluster,omitempty"`
+	PodCidrs                 []Cidr `json:"pod_cidrs"`
+	EgressCidrs              []Cidr `json:"egress_cidrs"`
+	ClusterDistributedSwitch string `json:"cluster_distributed_switch,omitempty"`
+	IngressCidrs             []Cidr `json:"ingress_cidrs"`
+}
+
+type AddressRange struct {
+	SubnetMask      string `json:"subnet_mask,omitempty"`
+	StartingAddress string `json:"starting_address"`
+	Gateway         string `json:"gateway"`
+	AddressCount    int    `json:"address_count,omitempty"`
+}
+
+type MasterManagementNetwork struct {
+	Mode         string        `json:"mode"`
+	FloatingIP   string        `json:"floating_IP,omitempty"`
+	AddressRange *AddressRange `json:"address_range,omitempty"`
+	Network      string        `json:"network"`
+}
+
+type DefaultImageRegistry struct {
+	Hostname string `json:"hostname"`
+	Port     int    `json:"port,omitempty"`
+}
+
+// EnableCluster enables vSphere Namespaces on the specified cluster, using the given spec.
+func (c *Manager) EnableCluster(ctx context.Context, id string, spec *EnableClusterSpec) error {
+	var response interface{}
+	url := c.Resource(path.Join(internal.NamespaceClusterPath, id)).WithParam("action", "enable")
+	err := c.Do(ctx, url.Request(http.MethodPost, spec), response)
+	return err
+}
+
+// EnableCluster enables vSphere Namespaces on the specified cluster, using the given spec.
+func (c *Manager) DisableCluster(ctx context.Context, id string) error {
+	var response interface{}
+	url := c.Resource(path.Join(internal.NamespaceClusterPath, id)).WithParam("action", "disable")
+	err := c.Do(ctx, url.Request(http.MethodPost), response)
+	return err
+}
+
 // ClusterSummary for a cluster with vSphere Namespaces enabled.
 type ClusterSummary struct {
 	ID               string `json:"cluster"`
@@ -94,3 +166,29 @@ func (c *Manager) SupportBundleRequest(ctx context.Context, bundle *SupportBundl
 
 	return http.NewRequest(http.MethodPost, bundle.URL, &b)
 }
+
+type DistributedSwitchCompatibilitySummary struct {
+	Compatible        bool   `json:"compatible"`
+	DistributedSwitch string `json:"distributed_switch"`
+}
+
+func (c *Manager) ListCompatibleDistributedSwitches(ctx context.Context, clusterId string) (result []DistributedSwitchCompatibilitySummary, err error) {
+	listUrl := c.Resource(internal.NamespaceDistributedSwitchCompatibility).
+		WithParam("cluster", clusterId).
+		WithParam("compatible", "true")
+	return result, c.Do(ctx, listUrl.Request(http.MethodGet), &result)
+}
+
+type EdgeClusterCompatibilitySummary struct {
+	Compatible  bool   `json:"compatible"`
+	EdgeCluster string `json:"edge_cluster"`
+	DisplayName string `json:"display_name"`
+}
+
+func (c *Manager) ListCompatibleEdgeClusters(ctx context.Context, clusterId string, switchId string) (result []EdgeClusterCompatibilitySummary, err error) {
+	listUrl := c.Resource(internal.NamespaceEdgeClusterCompatibility).
+		WithParam("cluster", clusterId).
+		WithParam("distributed_switch", switchId).
+		WithParam("compatible", "true")
+	return result, c.Do(ctx, listUrl.Request(http.MethodGet), &result)
+}
diff --git a/vapi/namespace/simulator/simulator.go b/vapi/namespace/simulator/simulator.go
index fa093dad..ea2b9fdb 100644
--- a/vapi/namespace/simulator/simulator.go
+++ b/vapi/namespace/simulator/simulator.go
@@ -61,6 +61,8 @@ func (h *Handler) Register(s *simulator.Service, r *simulator.Registry) {
 	if r.IsVPX() {
 		s.HandleFunc(internal.NamespaceClusterPath, h.clusters)
 		s.HandleFunc(internal.NamespaceClusterPath+"/", h.clustersID)
+		s.HandleFunc(internal.NamespaceDistributedSwitchCompatibility+"/", h.listCompatibleDistributedSwitches)
+		s.HandleFunc(internal.NamespaceEdgeClusterCompatibility+"/", h.listCompatibleEdgeClusters)
 	}
 }
 
@@ -158,3 +160,39 @@ func (h *Handler) clustersID(w http.ResponseWriter, r *http.Request) {
 	// TODO:
 	// https://vmware.github.io/vsphere-automation-sdk-rest/vsphere/index.html#SVC_com.vmware.vcenter.namespace_management.clusters
 }
+
+func (h *Handler) listCompatibleDistributedSwitches(w http.ResponseWriter, r *http.Request) {
+	switch r.Method {
+	case http.MethodGet:
+
+		// normally expect to get exactly one result back
+		switches := []namespace.DistributedSwitchCompatibilitySummary{
+			{
+				Compatible:        true,
+				DistributedSwitch: "Compatible-DVS-1",
+			},
+		}
+		vapi.StatusOK(w, switches)
+	}
+}
+
+func (h *Handler) listCompatibleEdgeClusters(w http.ResponseWriter, r *http.Request) {
+	switch r.Method {
+	case http.MethodGet:
+
+		// CLI is able to filter in case we get multiple results
+		switches := []namespace.EdgeClusterCompatibilitySummary{
+			{
+				Compatible:  true,
+				EdgeCluster: "Compat-Edge-ID1",
+				DisplayName: "Edge-Cluster-1",
+			},
+			{
+				Compatible:  true,
+				EdgeCluster: "Compat-Edge-ID2",
+				DisplayName: "Edge-Cluster-2",
+			},
+		}
+		vapi.StatusOK(w, switches)
+	}
+}
diff --git a/vapi/rest/resource.go b/vapi/rest/resource.go
index 24378963..060a1c84 100644
--- a/vapi/rest/resource.go
+++ b/vapi/rest/resource.go
@@ -48,11 +48,12 @@ func (r *Resource) WithAction(action string) *Resource {
 	return r.WithParam("~action", action)
 }
 
-// WithParam sets adds a parameter to the URL.RawQuery
+// WithParam adds one parameter on the URL.RawQuery
 func (r *Resource) WithParam(name string, value string) *Resource {
-	r.u.RawQuery = url.Values{
-		name: []string{value},
-	}.Encode()
+	// ParseQuery handles empty case, and we control access to query string so shouldn't encounter an error case
+	params, _ := url.ParseQuery(r.u.RawQuery)
+	params[name] = []string{value}
+	r.u.RawQuery = params.Encode()
 	return r
 }
 


Test patch:
diff --git a/govc/namespace/cluster/enable_test.go b/govc/namespace/cluster/enable_test.go
new file mode 100644
index 00000000..6e0c11af
--- /dev/null
+++ b/govc/namespace/cluster/enable_test.go
@@ -0,0 +1,85 @@
+/*
+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 (
+	"fmt"
+	"strings"
+	"testing"
+)
+
+func TestSplitCommaSeparated_Empty(t *testing.T) {
+	list := splitCommaSeparatedList("")
+	if list != nil || len(list) != 0 {
+		t.Fatalf("Nonempty list produced from empty string: %#v", list)
+	}
+	list = splitCommaSeparatedList(",,,")
+	if list != nil || len(list) != 0 {
+		t.Fatalf("Nonempty list produced from empty list items: %#v", list)
+	}
+}
+
+func TestSplitCommaSeparated_Values(t *testing.T) {
+	list := splitCommaSeparatedList("0.0.0.0/0")
+	if list == nil || len(list) != 1 || list[0] != "0.0.0.0/0" {
+		t.Fatalf("Incorrect list produced for single value: %#v", list)
+	}
+	list = splitCommaSeparatedList(",0.0.0.0/0,1.1.1.1/1,2.2.2.2/2,")
+	if list == nil || len(list) != 3 || list[0] != "0.0.0.0/0" || list[1] != "1.1.1.1/1" || list[2] != "2.2.2.2/2" {
+		t.Fatalf("Incorrect list produced for multiple values: %#v", list)
+	}
+}
+
+func TestSplitCidrList_Valid(t *testing.T) {
+	list := []string{"0.0.0.0/0", "1.1.1.1/1", "2.2.2.2/2"}
+	cidrs, err := splitCidrList(list)
+	if err != nil || len(cidrs) != 3 {
+		t.Fatalf("Wrong size list produced in cidr splitting: %#v", list)
+	}
+	for i, cidr := range cidrs {
+		if cidr.Prefix != i || cidr.Address != fmt.Sprintf("%[1]d.%[1]d.%[1]d.%[1]d", i) {
+			t.Fatalf("Invalid value set in cidr %d: %#v", i, cidr)
+		}
+	}
+}
+
+func TestSplitCidrList_Invalid(t *testing.T) {
+	list := []string{"abc", "1.1.1.1/1", "2.2.2.2/2"}
+	_, err := splitCidrList(list)
+	if err == nil {
+		t.Error("Error not produced trying to split an invalid cidr")
+	} else if !strings.Contains(err.Error(), "invalid cidr") {
+		t.Errorf("Unexpected error produced trying to split an invalid cidr: %s", err)
+	}
+
+	list = []string{"/24", "1.1.1.1/1", "2.2.2.2/2"}
+	_, err = splitCidrList(list)
+	if err == nil {
+		t.Error("Error not produced trying to split an invalid cidr")
+	} else if !strings.Contains(err.Error(), "parsing cidr") {
+		t.Errorf("Unexpected error produced trying to split an invalid cidr: %s", err)
+	}
+
+	list = []string{"abc/abc", "1.1.1.1/1", "2.2.2.2/2"}
+	_, err = splitCidrList(list)
+	if err == nil {
+		t.Error("Error not produced trying to split an invalid cidr")
+	} else if !strings.Contains(err.Error(), "parsing cidr") {
+		t.Errorf("Unexpected error produced trying to split an invalid cidr: %s", err)
+	}
+
+}
diff --git a/govc/test/namespace.bats b/govc/test/namespace.bats
old mode 100644
new mode 100755
index 33fc1367..219ba59d
--- a/govc/test/namespace.bats
+++ b/govc/test/namespace.bats
@@ -28,6 +28,52 @@ load test_helper
     assert_success WCP-cluster
 }
 
+@test "namespace.cluster.enable" {
+    vcsim_env
+
+    # need to set up some dependencies
+    govc cluster.create Workload-Cluster
+    assert_success
+
+    govc dvs.create "DVPG-Management Network"
+    assert_success
+
+    govc namespace.cluster.enable \
+      --service-cidr 10.96.0.0/23 \
+      --pod-cidrs 10.244.0.0/20 \
+      --cluster "Workload-Cluster" \
+      --control-plane-dns 8.8.8.8 \
+      --worker-dns 8.8.8.8 \
+      --control-plane-dns-search-domains example.com \
+      --control-plane-dns-names wcp.example.com \
+      --control-plane-ntp-servers pool.ntp.org \
+      --network-provider "NSXT_CONTAINER_PLUGIN" \
+      --workload-network.egress-cidrs 10.0.0.128/26 \
+      --workload-network.ingress-cidrs "10.0.0.64/26" \
+      --workload-network.switch VDS \
+      --workload-network.edge-cluster Edge-Cluster-1 \
+      --size TINY   \
+      --mgmt-network.mode STATICRANGE \
+      --mgmt-network.network "DVPG-Management Network" \
+      --mgmt-network.gateway 10.0.0.1 \
+      --mgmt-network.starting-address 10.0.0.45 \
+      --mgmt-network.subnet-mask 255.255.255.0 \
+      --ephemeral-storage-policy "vSAN Default Storage Policy" \
+      --control-plane-storage-policy "vSAN Default Storage Policy" \
+      --image-storage-policy "vSAN Default Storage Policy"
+    assert_success
+}
+
+@test "namespace.cluster.disable" {
+    vcsim_env
+
+    govc cluster.create Workload-Cluster
+    assert_success
+
+    govc namespace.cluster.disable --cluster Workload-Cluster
+    assert_success
+}
+
 @test "namespace.logs" {
   vcsim_env
 
diff --git a/govc/test/vm.bats b/govc/test/vm.bats
index 940fc769..1bfa5f3e 100755
--- a/govc/test/vm.bats
+++ b/govc/test/vm.bats
@@ -683,16 +683,12 @@ load test_helper
 @test "vm.clone" {
   vcsim_env
 
-  vm="DC0_H0_VM0"
+  vm=$(new_empty_vm)
   clone=$(new_id)
 
   run govc vm.clone -vm "$vm" -annotation $$ "$clone"
   assert_success
 
-  backing=$(govc device.info -json -vm "$clone" disk-* | jq .Devices[].Backing)
-  assert_equal false "$(jq .EagerlyScrub <<<"$backing")"
-  assert_equal true "$(jq .ThinProvisioned <<<"$backing")"
-
   run govc object.collect -s "/$GOVC_DATACENTER/vm/$clone" config.annotation
   assert_success $$
 
@@ -718,22 +714,6 @@ load test_helper
 
   run govc vm.clone -force -vm "$vm" "$clone"
   assert_success # clone vm with the same name
-
-  vm=$(new_empty_vm)
-  run govc vm.disk.create -vm "$vm" -thick -eager -size 10M -name "$vm/data.vmdk"
-  assert_success
-
-  backing=$(govc device.info -json -vm "$vm" disk-* | jq .Devices[].Backing)
-  assert_equal true "$(jq .EagerlyScrub <<<"$backing")"
-  assert_equal false "$(jq .ThinProvisioned <<<"$backing")"
-
-  clone=$(new_id)
-  run govc vm.clone -vm "$vm" "$clone"
-  assert_success
-
-  backing=$(govc device.info -json -vm "$clone" disk-* | jq .Devices[].Backing)
-  assert_equal true "$(jq .EagerlyScrub <<<"$backing")"
-  assert_equal false "$(jq .ThinProvisioned <<<"$backing")"
 }
 
 @test "vm.clone change resources" {
diff --git a/vapi/rest/resource_test.go b/vapi/rest/resource_test.go
new file mode 100644
index 00000000..d1f21a53
--- /dev/null
+++ b/vapi/rest/resource_test.go
@@ -0,0 +1,46 @@
+/*
+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 rest_test
+
+import (
+	"context"
+	"strings"
+	"testing"
+
+	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/vapi/rest"
+	"github.com/vmware/govmomi/vim25"
+)
+
+func TestResource_WithParam(t *testing.T) {
+	simulator.Test(func(ctx context.Context, vc *vim25.Client) {
+		c := rest.NewClient(vc)
+
+		url := c.Resource("api/some/resource").
+			WithParam("key1", "value1")
+		expectedPath := "api/some/resource?key1=value1"
+		if !strings.Contains(url.String(), expectedPath) {
+			t.Errorf("Param incorrectly added to resource, URL %q, expected path %q", url.String(), expectedPath)
+		}
+
+		url = url.WithParam("key2", "value2")
+		expectedPath = "api/some/resource?key1=value1&key2=value2"
+		if !strings.Contains(url.String(), expectedPath) {
+			t.Errorf("Param incorrectly updated on resource, URL %q, expected path %q", url.String(), expectedPath)
+		}
+	})
+}
diff --git a/view/example_test.go b/view/example_test.go
index a66193d0..c51c1bbb 100644
--- a/view/example_test.go
+++ b/view/example_test.go
@@ -67,39 +67,6 @@ func ExampleContainerView_Retrieve() {
 	// Output: [DC0_C0_H0 DC0_C0_H1 DC0_C0_H2 DC1_C0_H0 DC1_C0_H1 DC1_C0_H2]
 }
 
-func ExampleContainerView_retrieveClusters() {
-	model := simulator.VPX()
-	model.Cluster = 3
-
-	simulator.Run(func(ctx context.Context, c *vim25.Client) error {
-		m := view.NewManager(c)
-		kind := []string{"ClusterComputeResource"}
-
-		v, err := m.CreateContainerView(ctx, c.ServiceContent.RootFolder, kind, true)
-		if err != nil {
-			log.Fatal(err)
-		}
-
-		var clusters []mo.ClusterComputeResource
-		var names []string
-
-		err = v.Retrieve(ctx, kind, []string{"name"}, &clusters)
-		if err != nil {
-			return err
-		}
-
-		for _, cluster := range clusters {
-			names = append(names, cluster.Name)
-		}
-
-		sort.Strings(names)
-		fmt.Println(names)
-
-		return v.Destroy(ctx)
-	}, model)
-	// Output: [DC0_C0 DC0_C1 DC0_C2]
-}
-
 // Create a view of all VMs in the inventory, printing VM names that end with "_VM1".
 func ExampleContainerView_RetrieveWithFilter() {
 	simulator.Run(func(ctx context.Context, c *vim25.Client) error {