Repository: vmware/govmomi
Pull request #3435 (2024-05-08 14:11:28)

feat: Bindings for vSphere Zone APIs
## Description

Adding bindings for the [consumption domains API](https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/consumption_domains/) (a.k.a. vSphere Zones)

Related: https://github.com/hashicorp/terraform-provider-vsphere/issues/2185

## 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?

Tested against a live VC.
Created a simulator for the new endpoints and added a unit test that verifies the contract.

## Checklist:

- [X] My code follows the `CONTRIBUTION` [guidelines] of this project
- [X] I have commented my code, particularly in hard-to-understand areas
- [ ] 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

[guidelines]: https://github.com/vmware/govmomi/blob/main/CONTRIBUTING.md



Patch:
diff --git a/simulator/esx/host_vnic_manager.go b/simulator/esx/host_vnic_manager.go
deleted file mode 100644
index 6d6479af..00000000
--- a/simulator/esx/host_vnic_manager.go
+++ /dev/null
@@ -1,47 +0,0 @@
-/*
-Copyright (c) 2024-2024 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 esx
-
-import "github.com/vmware/govmomi/vim25/types"
-
-var VirtualNicManagerNetConfig = []types.VirtualNicManagerNetConfig{
-	{
-		NicType:            "management",
-		MultiSelectAllowed: true,
-		CandidateVnic: []types.HostVirtualNic{
-			{
-				Device:    "vmk0",
-				Key:       "management.key-vim.host.VirtualNic-vmk0",
-				Portgroup: "Management Network",
-				Spec: types.HostVirtualNicSpec{
-					Ip: &types.HostIpConfig{
-						Dhcp:       true,
-						IpAddress:  "127.0.0.1",
-						SubnetMask: "255.0.0.0",
-					},
-					Mac:                 "00:0c:29:81:d8:a0",
-					Portgroup:           "Management Network",
-					Mtu:                 1500,
-					TsoEnabled:          types.NewBool(true),
-					NetStackInstanceKey: "defaultTcpipStack",
-					SystemOwned:         types.NewBool(false),
-				},
-			},
-		},
-		SelectedVnic: []string{"management.key-vim.host.VirtualNic-vmk0"},
-	},
-}
diff --git a/simulator/host_system.go b/simulator/host_system.go
index 6c7705e5..6dcab0d9 100644
--- a/simulator/host_system.go
+++ b/simulator/host_system.go
@@ -1,11 +1,11 @@
 /*
-Copyright (c) 2017-2024 VMware, Inc. All Rights Reserved.
+Copyright (c) 2017 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
+    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,
@@ -96,7 +96,6 @@ func NewHostSystem(host mo.HostSystem) *HostSystem {
 	}{
 		{&hs.ConfigManager.DatastoreSystem, &HostDatastoreSystem{Host: &hs.HostSystem}},
 		{&hs.ConfigManager.NetworkSystem, NewHostNetworkSystem(&hs.HostSystem)},
-		{&hs.ConfigManager.VirtualNicManager, NewHostVirtualNicManager(&hs.HostSystem)},
 		{&hs.ConfigManager.AdvancedOption, NewOptionManager(nil, nil, &hs.Config.Option)},
 		{&hs.ConfigManager.FirewallSystem, NewHostFirewallSystem(&hs.HostSystem)},
 		{&hs.ConfigManager.StorageSystem, NewHostStorageSystem(&hs.HostSystem)},
diff --git a/simulator/host_vnic_manager.go b/simulator/host_vnic_manager.go
deleted file mode 100644
index 06915218..00000000
--- a/simulator/host_vnic_manager.go
+++ /dev/null
@@ -1,59 +0,0 @@
-/*
-Copyright (c) 2024-2024 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 (
-	"github.com/vmware/govmomi/simulator/esx"
-	"github.com/vmware/govmomi/vim25/methods"
-	"github.com/vmware/govmomi/vim25/mo"
-	"github.com/vmware/govmomi/vim25/soap"
-	"github.com/vmware/govmomi/vim25/types"
-)
-
-type HostVirtualNicManager struct {
-	mo.HostVirtualNicManager
-
-	Host *mo.HostSystem
-}
-
-func NewHostVirtualNicManager(host *mo.HostSystem) *HostVirtualNicManager {
-	return &HostVirtualNicManager{
-		Host: host,
-		HostVirtualNicManager: mo.HostVirtualNicManager{
-			Info: types.HostVirtualNicManagerInfo{
-				NetConfig: esx.VirtualNicManagerNetConfig,
-			},
-		},
-	}
-}
-
-func (m *HostVirtualNicManager) QueryNetConfig(req *types.QueryNetConfig) soap.HasFault {
-	body := new(methods.QueryNetConfigBody)
-
-	for _, c := range m.Info.NetConfig {
-		if c.NicType == req.NicType {
-			body.Res = &types.QueryNetConfigResponse{
-				Returnval: &c,
-			}
-			return body
-		}
-	}
-
-	body.Fault_ = Fault("", &types.InvalidArgument{InvalidProperty: req.NicType})
-
-	return body
-}
diff --git a/vapi/vcenter/consumptiondomains/associations/associations.go b/vapi/vcenter/consumptiondomains/associations/associations.go
new file mode 100644
index 00000000..14804f8d
--- /dev/null
+++ b/vapi/vcenter/consumptiondomains/associations/associations.go
@@ -0,0 +1,86 @@
+/*
+Copyright (c) 2024-2024 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 associations
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"net/http"
+
+	"github.com/vmware/govmomi/vapi/rest"
+)
+
+const (
+	basePath         = "/api/vcenter/consumption-domains/zones/cluster"
+	associationsPath = basePath + "/%s/associations"
+)
+
+// Manager extends rest.Client, adding vSphere Zone association 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,
+	}
+}
+
+// Status
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/data-structures/ConsumptionDomains_Zones_Cluster_Associations_Status
+type Status struct {
+	Success bool `json:"success"`
+}
+
+// AddAssociations
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/api/vcenter/consumption-domains/zones/cluster/zone/associations__action=add/post
+func (c *Manager) AddAssociations(zone string, cluster string) error {
+	path := c.Resource(fmt.Sprintf(associationsPath, zone)).WithParam("action", "add")
+	req := path.Request(http.MethodPost, []string{cluster})
+	var res Status
+	if err := c.Do(context.Background(), req, &res); err != nil {
+		return err
+	}
+
+	// This endpoint does not always return and error upon failure.
+	// In such cases we need to parse the response to figure out the actual status.
+
+	if !res.Success {
+		return errors.New("unable to add associations")
+	}
+
+	return nil
+}
+
+// RemoveAssociations
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/api/vcenter/consumption-domains/zones/cluster/zone/associations__action=remove/post/
+func (c *Manager) RemoveAssociations(zone string, cluster string) error {
+	path := c.Resource(fmt.Sprintf(associationsPath, zone)).WithParam("action", "remove")
+	req := path.Request(http.MethodPost, []string{cluster})
+	return c.Do(context.Background(), req, nil)
+}
+
+// GetAssociations
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/api/vcenter/consumption-domains/zones/cluster/zone/associations/get/
+func (c *Manager) GetAssociations(zone string) ([]string, error) {
+	path := c.Resource(fmt.Sprintf(associationsPath, zone))
+	req := path.Request(http.MethodGet)
+	var res []string
+	return res, c.Do(context.Background(), req, &res)
+}
diff --git a/vapi/vcenter/consumptiondomains/simulator/simulator.go b/vapi/vcenter/consumptiondomains/simulator/simulator.go
new file mode 100644
index 00000000..3e4cde4c
--- /dev/null
+++ b/vapi/vcenter/consumptiondomains/simulator/simulator.go
@@ -0,0 +1,191 @@
+/*
+Copyright (c) 2024-2024 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 (
+	"net/http"
+	"net/url"
+	"strings"
+
+	"github.com/vmware/govmomi/simulator"
+	vapi "github.com/vmware/govmomi/vapi/simulator"
+	"github.com/vmware/govmomi/vapi/vcenter/consumptiondomains/zones"
+)
+
+const (
+	zonesPath        = "/api/vcenter/consumption-domains/zones"
+	associationsPath = "/api/vcenter/consumption-domains/zones/cluster"
+)
+
+func init() {
+	simulator.RegisterEndpoint(func(s *simulator.Service, r *simulator.Registry) {
+		New(s.Listen).Register(s, r)
+	})
+}
+
+// ZoneData Helper type to store simulated entries
+type ZoneData struct {
+	Name         string
+	Id           string
+	Description  string
+	Associations []string
+}
+
+// Handler implements the Cluster Modules API simulator
+type Handler struct {
+	URL  *url.URL
+	data map[string]*ZoneData
+}
+
+// New creates a Handler instance
+func New(u *url.URL) *Handler {
+	return &Handler{
+		URL:  u,
+		data: make(map[string]*ZoneData),
+	}
+}
+
+// Register Consumption Domains API paths with the vapi simulator's http.ServeMux
+func (h *Handler) Register(s *simulator.Service, r *simulator.Registry) {
+	if r.IsVPX() {
+		s.HandleFunc(zonesPath, h.zones)
+		s.HandleFunc(zonesPath+"/", h.zones)
+		s.HandleFunc(associationsPath, h.associations)
+		s.HandleFunc(associationsPath+"/", h.associations)
+	}
+}
+
+func (h *Handler) zones(w http.ResponseWriter, r *http.Request) {
+	subpath := r.URL.Path[len(zonesPath):]
+	zoneId := strings.Replace(subpath, "/", "", -1)
+
+	switch r.Method {
+	case http.MethodGet:
+		if len(subpath) > 0 {
+			if d, ok := h.data[zoneId]; ok {
+				vapi.StatusOK(w, zones.ZoneInfo{Description: d.Description})
+				return
+			}
+			vapi.ApiErrorNotFound(w)
+		} else {
+			items := make([]zones.ListItem, len(h.data))
+			i := 0
+			for _, d := range h.data {
+				item := zones.ListItem{
+					Zone: d.Name,
+					Info: zones.ZoneInfo{
+						Description: d.Description,
+					},
+				}
+				items[i] = item
+				i++
+			}
+
+			result := zones.ListResult{Items: items}
+			vapi.StatusOK(w, result)
+		}
+	case http.MethodPost:
+		var spec zones.CreateSpec
+		if !vapi.Decode(r, w, &spec) {
+			vapi.ApiErrorGeneral(w)
+			return
+		}
+
+		newZone := ZoneData{
+			Name:         spec.Zone,
+			Description:  spec.Description,
+			Id:           spec.Zone,
+			Associations: make([]string, 0),
+		}
+		h.data[newZone.Id] = &newZone
+
+		vapi.StatusOK(w, newZone.Id)
+	case http.MethodDelete:
+		if _, ok := h.data[zoneId]; ok {
+			delete(h.data, zoneId)
+			vapi.StatusOK(w)
+			return
+		}
+		vapi.ApiErrorNotFound(w)
+	}
+}
+
+func (h *Handler) associations(w http.ResponseWriter, r *http.Request) {
+	subpath := r.URL.Path[len(associationsPath)+1:]
+	pathParts := strings.Split(subpath, "/")
+
+	if len(pathParts) != 2 || pathParts[1] != "associations" {
+		vapi.ApiErrorNotFound(w)
+		return
+	}
+
+	zoneId := pathParts[0]
+
+	switch r.Method {
+	case http.MethodGet:
+		if d, ok := h.data[zoneId]; ok {
+			vapi.StatusOK(w, d.Associations)
+			return
+		}
+	case http.MethodPost:
+		action := r.URL.Query().Get("action")
+
+		var clusterIds []string
+		if !vapi.Decode(r, w, &clusterIds) {
+			vapi.ApiErrorGeneral(w)
+			return
+		}
+
+		switch action {
+		case "add":
+			if d, ok := h.data[zoneId]; ok {
+				associations := append(d.Associations, clusterIds...)
+				d.Associations = associations
+				res := make(map[string]interface{})
+				res["success"] = true
+				vapi.StatusOK(w, res)
+				return
+			}
+			vapi.ApiErrorNotFound(w)
+		case "remove":
+			if d, ok := h.data[zoneId]; ok {
+				associations := make([]string, 0)
+
+				for _, a := range d.Associations {
+					found := false
+					for _, id := range clusterIds {
+						if a == id {
+							found = true
+						}
+					}
+
+					if !found {
+						associations = append(associations, a)
+					}
+				}
+
+				d.Associations = associations
+
+				vapi.StatusOK(w, nil)
+				return
+			}
+			vapi.ApiErrorNotFound(w)
+		default:
+			vapi.ApiErrorGeneral(w)
+		}
+	}
+}
diff --git a/vapi/vcenter/consumptiondomains/zones/zones.go b/vapi/vcenter/consumptiondomains/zones/zones.go
new file mode 100644
index 00000000..c6272a72
--- /dev/null
+++ b/vapi/vcenter/consumptiondomains/zones/zones.go
@@ -0,0 +1,106 @@
+/*
+Copyright (c) 2024-2024 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 zones
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/vmware/govmomi/vapi/rest"
+)
+
+const (
+	basePath = "/api/vcenter/consumption-domains/zones"
+)
+
+// Manager extends rest.Client, adding vSphere Zone 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,
+	}
+}
+
+// CreateSpec
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/data-structures/ConsumptionDomains_Zones_CreateSpec
+type CreateSpec struct {
+	Zone        string `json:"zone"`
+	Description string `json:"description"`
+}
+
+// ZoneInfo
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/data-structures/ConsumptionDomains_Zones_Info
+type ZoneInfo struct {
+	Description string `json:"description"`
+}
+
+// ListItem
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/data-structures/ConsumptionDomains_Zones_ListItem
+type ListItem struct {
+	Zone string   `json:"zone"`
+	Info ZoneInfo `json:"info"`
+}
+
+// ListResult
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/data-structures/ConsumptionDomains_Zones_ListResult
+type ListResult struct {
+	Items []ListItem `json:"items"`
+}
+
+// CreateZone creates a vSphere Zone. Returns the identifier of the new zone and an error if the operation fails
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/api/vcenter/consumption-domains/zones/post
+func (c *Manager) CreateZone(spec CreateSpec) (string, error) {
+	path := c.Resource(basePath)
+	req := path.Request(http.MethodPost, spec)
+	var res string
+	return res, c.Do(context.Background(), req, &res)
+}
+
+// DeleteZone deletes a vSphere Zone
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/api/vcenter/consumption-domains/zones/zone/delete
+func (c *Manager) DeleteZone(zone string) error {
+	path := c.Resource(basePath).WithSubpath(zone)
+	req := path.Request(http.MethodDelete)
+	return c.Do(context.Background(), req, nil)
+}
+
+// GetZone returns the details of a vSphere Zone
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/api/vcenter/consumption-domains/zones/zone/get
+func (c *Manager) GetZone(zone string) (ZoneInfo, error) {
+	path := c.Resource(basePath).WithSubpath(zone)
+	req := path.Request(http.MethodGet)
+	var res ZoneInfo
+	return res, c.Do(context.Background(), req, &res)
+}
+
+// ListZones returns the details of all vSphere Zones
+// https://developer.broadcom.com/xapis/vsphere-automation-api/latest/vcenter/api/vcenter/consumption-domains/zones/get
+func (c *Manager) ListZones() ([]ListItem, error) {
+	path := c.Resource(basePath)
+	req := path.Request(http.MethodGet)
+	var res ListResult
+
+	if err := c.Do(context.Background(), req, &res); err != nil {
+		return nil, err
+	}
+
+	return res.Items, nil
+}
diff --git a/vcsim/main.go b/vcsim/main.go
index bf7faef2..f4379b15 100644
--- a/vcsim/main.go
+++ b/vcsim/main.go
@@ -52,6 +52,7 @@ import (
 	_ "github.com/vmware/govmomi/vapi/esx/settings/simulator"
 	_ "github.com/vmware/govmomi/vapi/namespace/simulator"
 	_ "github.com/vmware/govmomi/vapi/simulator"
+	_ "github.com/vmware/govmomi/vapi/vcenter/consumptiondomains/simulator"
 	_ "github.com/vmware/govmomi/vapi/vm/simulator"
 	_ "github.com/vmware/govmomi/vsan/simulator"
 )
diff --git a/vim25/types/helpers.go b/vim25/types/helpers.go
index cbcdb637..c7739b58 100644
--- a/vim25/types/helpers.go
+++ b/vim25/types/helpers.go
@@ -128,7 +128,6 @@ func (ci VirtualMachineConfigInfo) ToConfigSpec() VirtualMachineConfigSpec {
 		Flags:                        &ci.Flags,
 		ConsolePreferences:           ci.ConsolePreferences,
 		PowerOpInfo:                  &ci.DefaultPowerOps,
-		RebootPowerOff:               ci.RebootPowerOff,
 		NumCPUs:                      ci.Hardware.NumCPU,
 		VcpuConfig:                   ci.VcpuConfig,
 		NumCoresPerSocket:            ci.Hardware.NumCoresPerSocket,
@@ -138,14 +137,14 @@ func (ci VirtualMachineConfigInfo) ToConfigSpec() VirtualMachineConfigSpec {
 		CpuHotRemoveEnabled:          ci.CpuHotRemoveEnabled,
 		VirtualICH7MPresent:          ci.Hardware.VirtualICH7MPresent,
 		VirtualSMCPresent:            ci.Hardware.VirtualSMCPresent,
-		DeviceChange:                 nil, // See below
+		DeviceChange:                 make([]BaseVirtualDeviceConfigSpec, len(ci.Hardware.Device)),
 		CpuAllocation:                ci.CpuAllocation,
 		MemoryAllocation:             ci.MemoryAllocation,
 		LatencySensitivity:           ci.LatencySensitivity,
 		CpuAffinity:                  ci.CpuAffinity,
 		MemoryAffinity:               ci.MemoryAffinity,
 		NetworkShaper:                ci.NetworkShaper,
-		CpuFeatureMask:               nil, // See below
+		CpuFeatureMask:               make([]VirtualMachineCpuIdInfoSpec, len(ci.CpuFeatureMask)),
 		ExtraConfig:                  ci.ExtraConfig,
 		SwapPlacement:                ci.SwapPlacement,
 		BootOptions:                  ci.BootOptions,
@@ -164,21 +163,20 @@ func (ci VirtualMachineConfigInfo) ToConfigSpec() VirtualMachineConfigSpec {
 		MigrateEncryption:            ci.MigrateEncryption,
 		FtEncryptionMode:             ci.FtEncryptionMode,
 		SevEnabled:                   ci.SevEnabled,
-		MotherboardLayout:            ci.Hardware.MotherboardLayout,
+		PmemFailoverEnabled:          ci.PmemFailoverEnabled,
+		Pmem:                         ci.Pmem,
+		NpivWorldWideNameOp:          ci.NpivWorldWideNameType,
+		RebootPowerOff:               ci.RebootPowerOff,
 		ScheduledHardwareUpgradeInfo: ci.ScheduledHardwareUpgradeInfo,
 		SgxInfo:                      ci.SgxInfo,
 		GuestMonitoringModeInfo:      ci.GuestMonitoringModeInfo,
-		PmemFailoverEnabled:          ci.PmemFailoverEnabled,
 		VmxStatsCollectionEnabled:    ci.VmxStatsCollectionEnabled,
 		VmOpNotificationToAppEnabled: ci.VmOpNotificationToAppEnabled,
 		VmOpNotificationTimeout:      ci.VmOpNotificationTimeout,
 		DeviceSwap:                   ci.DeviceSwap,
 		SimultaneousThreads:          ci.Hardware.SimultaneousThreads,
-		Pmem:                         ci.Pmem,
 		DeviceGroups:                 ci.DeviceGroups,
-		FixedPassthruHotPlugEnabled:  ci.FixedPassthruHotPlugEnabled,
-		MetroFtEnabled:               ci.MetroFtEnabled,
-		MetroFtHostGroup:             ci.MetroFtHostGroup,
+		MotherboardLayout:            ci.Hardware.MotherboardLayout,
 	}
 
 	// Unassign the Files field if all of its fields are empty.
@@ -220,36 +218,39 @@ func (ci VirtualMachineConfigInfo) ToConfigSpec() VirtualMachineConfigSpec {
 		cs.PowerOpInfo = nil
 	}
 
-	if l := len(ci.CpuFeatureMask); l > 0 {
-		cs.CpuFeatureMask = make([]VirtualMachineCpuIdInfoSpec, l)
-		for i := 0; i < l; i++ {
-			cs.CpuFeatureMask[i] = VirtualMachineCpuIdInfoSpec{
-				ArrayUpdateSpec: ArrayUpdateSpec{
-					Operation: ArrayUpdateOperationAdd,
-				},
-				Info: &HostCpuIdInfo{
-					Level:  ci.CpuFeatureMask[i].Level,
-					Vendor: ci.CpuFeatureMask[i].Vendor,
-					Eax:    ci.CpuFeatureMask[i].Eax,
-					Ebx:    ci.CpuFeatureMask[i].Ebx,
-					Ecx:    ci.CpuFeatureMask[i].Ecx,
-					Edx:    ci.CpuFeatureMask[i].Edx,
-				},
-			}
+	for i := 0; i < len(cs.CpuFeatureMask); i++ {
+		cs.CpuFeatureMask[i] = VirtualMachineCpuIdInfoSpec{
+			ArrayUpdateSpec: ArrayUpdateSpec{
+				Operation: ArrayUpdateOperationAdd,
+			},
+			Info: &HostCpuIdInfo{
+				// TODO: Does DynamicData need to be copied?
+				//       It is an empty struct...
+				Level:  ci.CpuFeatureMask[i].Level,
+				Vendor: ci.CpuFeatureMask[i].Vendor,
+				Eax:    ci.CpuFeatureMask[i].Eax,
+				Ebx:    ci.CpuFeatureMask[i].Ebx,
+				Ecx:    ci.CpuFeatureMask[i].Ecx,
+				Edx:    ci.CpuFeatureMask[i].Edx,
+			},
 		}
 	}
 
-	if l := len(ci.Hardware.Device); l > 0 {
-		cs.DeviceChange = make([]BaseVirtualDeviceConfigSpec, l)
-		for i := 0; i < l; i++ {
-			cs.DeviceChange[i] = &VirtualDeviceConfigSpec{
-				Operation:     VirtualDeviceConfigSpecOperationAdd,
-				FileOperation: VirtualDeviceConfigSpecFileOperationCreate,
-				Device:        ci.Hardware.Device[i],
-				Profile:       nil,
-				Backing:       nil,
-				FilterSpec:    nil,
-			}
+	for i := 0; i < len(cs.DeviceChange); i++ {
+		cs.DeviceChange[i] = &VirtualDeviceConfigSpec{
+			// TODO: Does DynamicData need to be copied?
+			//       It is an empty struct...
+			Operation:     VirtualDeviceConfigSpecOperationAdd,
+			FileOperation: VirtualDeviceConfigSpecFileOperationCreate,
+			Device:        ci.Hardware.Device[i],
+			// TODO: It is unclear how the profiles associated with the VM or
+			//       its hardware can be reintroduced/persisted in the
+			//       ConfigSpec.
+			Profile: nil,
+			// The backing will come from the device.
+			Backing: nil,
+			// TODO: Investigate futher.
+			FilterSpec: nil,
 		}
 	}
 


Test patch:
diff --git a/govc/test/host.bats b/govc/test/host.bats
index 9364dc65..1aac4712 100755
--- a/govc/test/host.bats
+++ b/govc/test/host.bats
@@ -106,7 +106,7 @@ load test_helper
 }
 
 @test "host.vnic.info" {
-  vcsim_env
+  esx_env
 
   run govc host.vnic.info
   assert_success
diff --git a/vapi/vcenter/consumptiondomains/consumptiondomains_test.go b/vapi/vcenter/consumptiondomains/consumptiondomains_test.go
new file mode 100644
index 00000000..db3d6d25
--- /dev/null
+++ b/vapi/vcenter/consumptiondomains/consumptiondomains_test.go
@@ -0,0 +1,133 @@
+/*
+Copyright (c) 2024-2024 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 consumptiondomains_test
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/vapi/rest"
+	"github.com/vmware/govmomi/vapi/vcenter/consumptiondomains/associations"
+	"github.com/vmware/govmomi/vapi/vcenter/consumptiondomains/zones"
+	"github.com/vmware/govmomi/vim25"
+
+	_ "github.com/vmware/govmomi/vapi/simulator"
+	_ "github.com/vmware/govmomi/vapi/vcenter/consumptiondomains/simulator"
+)
+
+func TestConsumptionDomains(t *testing.T) {
+	simulator.Test(func(ctx context.Context, vc *vim25.Client) {
+		rc := rest.NewClient(vc)
+
+		err := rc.Login(ctx, simulator.DefaultLogin)
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		zm := zones.NewManager(rc)
+
+		// Create a zone
+		zoneId, err := zm.CreateZone(zones.CreateSpec{
+			Zone:        "test-zone-1",
+			Description: "placeholder description",
+		})
+
+		if err != nil {
+			t.Error(err)
+		}
+
+		// List all zones and find the one created earlier
+		zonesList, err := zm.ListZones()
+
+		if err != nil {
+			t.Error(err)
+		}
+
+		assert.Equal(t, 1, len(zonesList))
+		assert.Equal(t, "test-zone-1", zonesList[0].Zone)
+		assert.Equal(t, "placeholder description", zonesList[0].Info.Description)
+
+		// Query zone by ID
+		zone, err := zm.GetZone(zoneId)
+
+		if err != nil {
+			t.Error(err)
+		}
+
+		assert.Equal(t, "placeholder description", zone.Description)
+
+		am := associations.NewManager(rc)
+
+		// Create a cluster association
+		err = am.AddAssociations(zoneId, "domain-c9")
+
+		if err != nil {
+			t.Error(err)
+		}
+
+		// Query the associations for the test zone
+		assc, err := am.GetAssociations(zoneId)
+
+		if err != nil {
+			t.Error(err)
+		}
+
+		assert.Equal(t, 1, len(assc))
+		assert.Equal(t, "domain-c9", assc[0])
+
+		// Delete the cluster association
+		err = am.RemoveAssociations(zoneId, "domain-c9")
+
+		if err != nil {
+			t.Error(err)
+		}
+
+		// Verify that the association is removed
+		assc, err = am.GetAssociations(zoneId)
+
+		if err != nil {
+			t.Error(err)
+		}
+
+		assert.Equal(t, 0, len(assc))
+
+		// Delete the zone
+		err = zm.DeleteZone(zoneId)
+
+		if err != nil {
+			t.Error(err)
+		}
+
+		// Verify the zone is removed
+		zone, err = zm.GetZone(zoneId)
+
+		if err == nil {
+			t.Error(err)
+		}
+
+		zonesList, err = zm.ListZones()
+
+		if err != nil {
+			t.Error(err)
+		}
+
+		assert.Equal(t, 0, len(zonesList))
+	})
+}