Repository: vmware/govmomi
Pull request #3451 (2024-05-28 19:57:21)

vcsim: add PropertyCollector index support
PropertyCollector supports use of index (unique key) to collect a single array element. Further, a property path may also be applied to the given element.



Patch:
diff --git a/gen/Gemfile.lock b/gen/Gemfile.lock
index 2e812e45..245d5b14 100644
--- a/gen/Gemfile.lock
+++ b/gen/Gemfile.lock
@@ -1,8 +1,8 @@
 GEM
   remote: https://rubygems.org/
   specs:
-    mini_portile2 (2.8.6)
-    nokogiri (1.16.5)
+    mini_portile2 (2.8.5)
+    nokogiri (1.16.2)
       mini_portile2 (~> 2.8.2)
       racc (~> 1.4)
     power_assert (0.4.1)
diff --git a/govc/object/collect.go b/govc/object/collect.go
index 0689d3da..972f1f33 100644
--- a/govc/object/collect.go
+++ b/govc/object/collect.go
@@ -1,5 +1,5 @@
 /*
-Copyright (c) 2017-2023 VMware, Inc. All Rights Reserved.
+Copyright (c) 2017-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.
@@ -416,7 +416,7 @@ func (cmd *collect) Run(ctx context.Context, f *flag.FlagSet) error {
 		}
 		res, err := p.RetrieveProperties(ctx, req)
 		if err != nil {
-			return nil
+			return err
 		}
 		content := res.Returnval
 		if len(content) != 1 {
diff --git a/simulator/extension_manager.go b/simulator/extension_manager.go
index 6b72ae0f..84b7c6b0 100644
--- a/simulator/extension_manager.go
+++ b/simulator/extension_manager.go
@@ -97,6 +97,12 @@ func (m *ExtensionManager) RegisterExtension(ctx *Context, req *types.RegisterEx
 	body.Res = new(types.RegisterExtensionResponse)
 	m.ExtensionList = append(m.ExtensionList, req.Extension)
 
+	f := mo.Field{Path: "extensionList", Key: req.Extension.Key}
+	ctx.Map.Update(m, []types.PropertyChange{
+		{Name: f.Path, Val: m.ExtensionList},
+		{Name: f.String(), Val: req.Extension, Op: types.PropertyChangeOpAdd},
+	})
+
 	return body
 }
 
@@ -107,6 +113,12 @@ func (m *ExtensionManager) UnregisterExtension(ctx *Context, req *types.Unregist
 		if x.Key == req.ExtensionKey {
 			m.ExtensionList = append(m.ExtensionList[:i], m.ExtensionList[i+1:]...)
 
+			f := mo.Field{Path: "extensionList", Key: req.ExtensionKey}
+			ctx.Map.Update(m, []types.PropertyChange{
+				{Name: f.Path, Val: m.ExtensionList},
+				{Name: f.String(), Op: types.PropertyChangeOpRemove},
+			})
+
 			body.Res = new(types.UnregisterExtensionResponse)
 			return body
 		}
@@ -124,6 +136,12 @@ func (m *ExtensionManager) UpdateExtension(ctx *Context, req *types.UpdateExtens
 		if x.Key == req.Extension.Key {
 			m.ExtensionList[i] = req.Extension
 
+			f := mo.Field{Path: "extensionList", Key: req.Extension.Key}
+			ctx.Map.Update(m, []types.PropertyChange{
+				{Name: f.Path, Val: m.ExtensionList},
+				{Name: f.String(), Val: req.Extension},
+			})
+
 			body.Res = new(types.UpdateExtensionResponse)
 			return body
 		}
diff --git a/simulator/property_collector.go b/simulator/property_collector.go
index c7c60895..b50fc9a9 100644
--- a/simulator/property_collector.go
+++ b/simulator/property_collector.go
@@ -157,15 +157,19 @@ func wrapValue(rval reflect.Value, rtype reflect.Type) interface{} {
 	return pval
 }
 
-func fieldValueInterface(f reflect.StructField, rval reflect.Value) interface{} {
+func fieldValueInterface(f reflect.StructField, rval reflect.Value, keyed ...bool) interface{} {
 	if rval.Kind() == reflect.Ptr {
 		rval = rval.Elem()
 	}
 
+	if len(keyed) == 1 && keyed[0] {
+		return rval.Interface() // don't wrap keyed fields in ArrayOf* type
+	}
+
 	return wrapValue(rval, f.Type)
 }
 
-func fieldValue(rval reflect.Value, p string) (interface{}, error) {
+func fieldValue(rval reflect.Value, p string, keyed ...bool) (interface{}, error) {
 	var value interface{}
 	fields := strings.Split(p, ".")
 
@@ -204,7 +208,7 @@ func fieldValue(rval reflect.Value, p string) (interface{}, error) {
 
 		if i == len(fields)-1 {
 			ftype, _ := rval.Type().FieldByName(x)
-			value = fieldValueInterface(ftype, val)
+			value = fieldValueInterface(ftype, val, keyed...)
 			break
 		}
 
@@ -214,6 +218,64 @@ func fieldValue(rval reflect.Value, p string) (interface{}, error) {
 	return value, nil
 }
 
+func fieldValueKey(rval reflect.Value, p mo.Field) (interface{}, error) {
+	if rval.Kind() != reflect.Slice {
+		return nil, errInvalidField
+	}
+
+	zero := reflect.Value{}
+
+	for i := 0; i < rval.Len(); i++ {
+		item := rval.Index(i)
+		if item.Kind() == reflect.Interface {
+			item = item.Elem()
+		}
+		if item.Kind() == reflect.Ptr {
+			item = item.Elem()
+		}
+		if item.Kind() != reflect.Struct {
+			return reflect.Value{}, errInvalidField
+		}
+
+		field := item.FieldByName("Key")
+		if field == zero {
+			return nil, errInvalidField
+		}
+
+		switch key := field.Interface().(type) {
+		case string:
+			s, ok := p.Key.(string)
+			if !ok {
+				return nil, errInvalidField
+			}
+			if s == key {
+				return item.Interface(), nil
+			}
+		case int32:
+			s, ok := p.Key.(int32)
+			if !ok {
+				return nil, errInvalidField
+			}
+			if s == key {
+				return item.Interface(), nil
+			}
+		default:
+			return nil, errInvalidField
+		}
+	}
+
+	return nil, nil
+}
+
+func fieldValueIndex(rval reflect.Value, p mo.Field) (interface{}, error) {
+	val, err := fieldValueKey(rval, p)
+	if err != nil || val == nil || p.Item == "" {
+		return val, err
+	}
+
+	return fieldValue(reflect.ValueOf(val), p.Item)
+}
+
 func fieldRefs(f interface{}) []types.ManagedObjectReference {
 	switch fv := f.(type) {
 	case types.ManagedObjectReference:
@@ -329,7 +391,19 @@ func (rr *retrieveResult) collectFields(ctx *Context, rval reflect.Value, fields
 		}
 		seen[name] = true
 
-		val, err := fieldValue(rval, name)
+		var val interface{}
+		var err error
+		var field mo.Field
+		if field.FromString(name) {
+			keyed := field.Key != nil
+
+			val, err = fieldValue(rval, field.Path, keyed)
+			if err == nil && keyed {
+				val, err = fieldValueIndex(reflect.ValueOf(val), field)
+			}
+		} else {
+			err = errInvalidField
+		}
 
 		switch err {
 		case nil, errEmptyField:
diff --git a/simulator/property_filter.go b/simulator/property_filter.go
index b84c85e7..8871b179 100644
--- a/simulator/property_filter.go
+++ b/simulator/property_filter.go
@@ -1,11 +1,11 @@
 /*
-Copyright (c) 2017 VMware, Inc. All Rights Reserved.
+Copyright (c) 2017-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
+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,
@@ -70,6 +70,22 @@ func (f *PropertyFilter) matches(ctx *Context, ref types.ManagedObjectReference,
 				return true
 			}
 
+			var field mo.Field
+			if field.FromString(name) && field.Item != "" {
+				// "field[key].item" -> "field[key]"
+				item := field.Item
+				field.Item = ""
+				if field.String() == change.Name {
+					change.Name = name
+					change.Val, _ = fieldValue(reflect.ValueOf(change.Val), item)
+					return true
+				}
+			}
+
+			if field.FromString(change.Name) && field.Key != nil {
+				continue // case below does not apply to property index
+			}
+
 			// strings.HasPrefix("runtime.powerState", "runtime") == parent field matches
 			if strings.HasPrefix(change.Name, name) {
 				if obj := ctx.Map.Get(ref); obj != nil { // object may have since been deleted
diff --git a/simulator/virtual_machine.go b/simulator/virtual_machine.go
index 6fd4cc85..1a05352e 100644
--- a/simulator/virtual_machine.go
+++ b/simulator/virtual_machine.go
@@ -429,12 +429,19 @@ func extraConfigKey(key string) string {
 }
 
 func (vm *VirtualMachine) applyExtraConfig(ctx *Context, spec *types.VirtualMachineConfigSpec) types.BaseMethodFault {
+	if len(spec.ExtraConfig) == 0 {
+		return nil
+	}
 	var removedContainerBacking bool
 	var changes []types.PropertyChange
+	field := mo.Field{Path: "config.extraConfig"}
+
 	for _, c := range spec.ExtraConfig {
 		val := c.GetOptionValue()
 		key := strings.TrimPrefix(extraConfigKey(val.Key), "SET.")
 		if key == val.Key {
+			field.Key = key
+			op := types.PropertyChangeOpAssign
 			keyIndex := -1
 			for i := range vm.Config.ExtraConfig {
 				bov := vm.Config.ExtraConfig[i]
@@ -451,23 +458,26 @@ func (vm *VirtualMachine) applyExtraConfig(ctx *Context, spec *types.VirtualMach
 				}
 			}
 			if keyIndex < 0 {
+				op = types.PropertyChangeOpAdd
 				vm.Config.ExtraConfig = append(vm.Config.ExtraConfig, c)
 			} else {
 				if s, ok := val.Value.(string); ok && s == "" {
+					op = types.PropertyChangeOpRemove
 					if key == ContainerBackingOptionKey {
 						removedContainerBacking = true
 					}
 					// Remove existing element
-					l := len(vm.Config.ExtraConfig)
-					vm.Config.ExtraConfig[keyIndex] = vm.Config.ExtraConfig[l-1]
-					vm.Config.ExtraConfig[l-1] = nil
-					vm.Config.ExtraConfig = vm.Config.ExtraConfig[:l-1]
+					vm.Config.ExtraConfig = append(
+						vm.Config.ExtraConfig[:keyIndex],
+						vm.Config.ExtraConfig[keyIndex+1:]...)
+					val = nil
 				} else {
 					// Update existing element
-					vm.Config.ExtraConfig[keyIndex].GetOptionValue().Value = val.Value
+					vm.Config.ExtraConfig[keyIndex] = val
 				}
 			}
 
+			changes = append(changes, types.PropertyChange{Name: field.String(), Val: val, Op: op})
 			continue
 		}
 		changes = append(changes, types.PropertyChange{Name: key, Val: val.Value})
@@ -529,9 +539,8 @@ func (vm *VirtualMachine) applyExtraConfig(ctx *Context, spec *types.VirtualMach
 		}
 	}
 
-	if len(changes) != 0 {
-		Map.Update(vm, changes)
-	}
+	change := types.PropertyChange{Name: field.Path, Val: vm.Config.ExtraConfig}
+	ctx.Map.Update(vm, append(changes, change))
 
 	return fault
 }
@@ -1586,6 +1595,8 @@ func (vm *VirtualMachine) genVmdkPath(p object.DatastorePath) (string, types.Bas
 }
 
 func (vm *VirtualMachine) configureDevices(ctx *Context, spec *types.VirtualMachineConfigSpec) types.BaseMethodFault {
+	var changes []types.PropertyChange
+	field := mo.Field{Path: "config.hardware.device"}
 	devices := object.VirtualDeviceList(vm.Config.Hardware.Device)
 
 	var err types.BaseMethodFault
@@ -1593,6 +1604,7 @@ func (vm *VirtualMachine) configureDevices(ctx *Context, spec *types.VirtualMach
 		dspec := change.GetVirtualDeviceConfigSpec()
 		device := dspec.Device.GetVirtualDevice()
 		invalid := &types.InvalidDeviceSpec{DeviceIndex: int32(i)}
+		change := types.PropertyChange{}
 
 		switch dspec.FileOperation {
 		case types.VirtualDeviceConfigSpecFileOperationCreate:
@@ -1606,6 +1618,8 @@ func (vm *VirtualMachine) configureDevices(ctx *Context, spec *types.VirtualMach
 
 		switch dspec.Operation {
 		case types.VirtualDeviceConfigSpecOperationAdd:
+			change.Op = types.PropertyChangeOpAdd
+
 			if devices.FindByKey(device.Key) != nil && device.ControllerKey == 0 {
 				// Note: real ESX does not allow adding base controllers (ControllerKey = 0)
 				// after VM is created (returns success but device is not added).
@@ -1631,6 +1645,7 @@ func (vm *VirtualMachine) configureDevices(ctx *Context, spec *types.VirtualMach
 			}
 
 			devices = append(devices, dspec.Device)
+			change.Val = dspec.Device
 			if key != device.Key {
 				// Update ControllerKey refs
 				for i := range spec.DeviceChange {
@@ -1658,14 +1673,22 @@ func (vm *VirtualMachine) configureDevices(ctx *Context, spec *types.VirtualMach
 			}
 
 			devices = append(devices, dspec.Device)
+			change.Val = dspec.Device
 		case types.VirtualDeviceConfigSpecOperationRemove:
+			change.Op = types.PropertyChangeOpRemove
+
 			devices = vm.removeDevice(ctx, devices, dspec)
 		}
+
+		field.Key = device.Key
+		change.Name = field.String()
+		changes = append(changes, change)
 	}
 
-	ctx.Map.Update(vm, []types.PropertyChange{
-		{Name: "config.hardware.device", Val: []types.BaseVirtualDevice(devices)},
-	})
+	if len(changes) != 0 {
+		change := types.PropertyChange{Name: field.Path, Val: []types.BaseVirtualDevice(devices)}
+		ctx.Map.Update(vm, append(changes, change))
+	}
 
 	err = vm.updateDiskLayouts()
 	if err != nil {
diff --git a/vim25/mo/retrieve.go b/vim25/mo/retrieve.go
index 9f2b3248..66a8a978 100644
--- a/vim25/mo/retrieve.go
+++ b/vim25/mo/retrieve.go
@@ -1,11 +1,11 @@
 /*
-Copyright (c) 2014-2015 VMware, Inc. All Rights Reserved.
+Copyright (c) 2014-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
+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,
@@ -75,16 +75,19 @@ func ApplyPropertyChange(obj Reference, changes []types.PropertyChange) {
 	v := reflect.ValueOf(obj)
 
 	for _, p := range changes {
-		rv, ok := t.props[p.Name]
+		var field Field
+		if !field.FromString(p.Name) {
+			panic(p.Name + ": invalid property path")
+		}
+
+		rv, ok := t.props[field.Path]
 		if !ok {
-			// For now, skip unknown properties allowing PC updates to be triggered
-			// for partial updates (e.g. extensionList["my.extension"]).
-			// Ultimately we should support partial updates by assigning the value
-			// reflectively in assignValue.
-			continue
+			panic(field.Path + ": property not found")
 		}
 
-		assignValue(v, rv, reflect.ValueOf(p.Val))
+		if field.Key == nil { // Key is only used for notifications
+			assignValue(v, rv, reflect.ValueOf(p.Val))
+		}
 	}
 }
 
diff --git a/vim25/mo/type_info.go b/vim25/mo/type_info.go
index 3b1ccce2..21f59291 100644
--- a/vim25/mo/type_info.go
+++ b/vim25/mo/type_info.go
@@ -1,11 +1,11 @@
 /*
-Copyright (c) 2014 VMware, Inc. All Rights Reserved.
+Copyright (c) 2014-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
+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,
@@ -20,6 +20,7 @@ import (
 	"fmt"
 	"reflect"
 	"regexp"
+	"strconv"
 	"strings"
 	"sync"
 
@@ -34,6 +35,9 @@ type typeInfo struct {
 
 	// Map property names to field indices.
 	props map[string][]int
+
+	// Use base type for interface indices.
+	base bool
 }
 
 var typeInfoLock sync.RWMutex
@@ -62,12 +66,22 @@ func typeInfoForType(tname string) *typeInfo {
 	return ti
 }
 
-func newTypeInfo(typ reflect.Type) *typeInfo {
+func baseType(ftyp reflect.Type) reflect.Type {
+	base := strings.TrimPrefix(ftyp.Name(), "Base")
+	if kind, ok := types.TypeFunc()(base); ok {
+		return kind
+	}
+	return ftyp
+}
+
+func newTypeInfo(typ reflect.Type, base ...bool) *typeInfo {
 	t := typeInfo{
 		typ:   typ,
 		props: make(map[string][]int),
 	}
-
+	if len(base) == 1 {
+		t.base = base[0]
+	}
 	t.build(typ, "", []int{})
 
 	return &t
@@ -155,6 +169,15 @@ func (t *typeInfo) build(typ reflect.Type, fn string, fi []int) {
 		if ftyp.Kind() == reflect.Struct {
 			t.build(ftyp, fnc, fic)
 		}
+
+		// Indexed property path may traverse into array element fields.
+		// When interface, use the base type to index fields.
+		// For example, BaseVirtualDevice:
+		//   config.hardware.device[4000].deviceInfo.label
+		if t.base && ftyp.Kind() == reflect.Interface {
+			base := baseType(ftyp)
+			t.build(base, fnc, fic)
+		}
 	}
 }
 
@@ -164,7 +187,14 @@ var nilValue reflect.Value
 // slice of field indices. It recurses into the struct until it finds the field
 // specified by the indices. It creates new values for pointer types where
 // needed.
-func assignValue(val reflect.Value, fi []int, pv reflect.Value) {
+func assignValue(val reflect.Value, fi []int, pv reflect.Value, field ...string) {
+	// Indexed property path can only use base types
+	if val.Kind() == reflect.Interface {
+		base := baseType(val.Type())
+		val.Set(reflect.New(base))
+		val = val.Elem()
+	}
+
 	// Create new value if necessary.
 	if val.Kind() == reflect.Ptr {
 		if val.IsNil() {
@@ -230,6 +260,43 @@ func assignValue(val reflect.Value, fi []int, pv reflect.Value) {
 			rv.Set(pv)
 		} else if rt.ConvertibleTo(pt) {
 			rv.Set(pv.Convert(rt))
+		} else if rt.Kind() == reflect.Slice {
+			// Indexed array value
+			path := field[0]
+			isInterface := rt.Elem().Kind() == reflect.Interface
+
+			if len(path) == 0 {
+				// Append item (pv) directly to the array, converting to pointer if interface
+				if isInterface {
+					npv := reflect.New(pt)
+					npv.Elem().Set(pv)
+					pv = npv
+					pt = pv.Type()
+				}
+			} else {
+				// Construct item to be appended to the array, setting field within to value of pv
+				var item reflect.Value
+				if isInterface {
+					base := baseType(rt.Elem())
+					item = reflect.New(base)
+				} else {
+					item = reflect.New(rt.Elem())
+				}
+
+				field := newTypeInfo(item.Type(), true)
+				if ix, ok := field.props[path]; ok {
+					assignValue(item, ix, pv)
+				}
+
+				if rt.Elem().Kind() == reflect.Struct {
+					pv = item.Elem()
+				} else {
+					pv = item
+				}
+				pt = pv.Type()
+			}
+
+			rv.Set(reflect.Append(rv, pv))
 		} else {
 			panic(fmt.Sprintf("cannot assign %q (%s) to %q (%s)", rt.Name(), rt.Kind(), pt.Name(), pt.Kind()))
 		}
@@ -237,7 +304,7 @@ func assignValue(val reflect.Value, fi []int, pv reflect.Value) {
 		return
 	}
 
-	assignValue(rv, fi, pv)
+	assignValue(rv, fi, pv, field...)
 }
 
 var arrayOfRegexp = regexp.MustCompile("ArrayOf(.*)$")
@@ -250,11 +317,14 @@ func (t *typeInfo) LoadFromObjectContent(o types.ObjectContent) (reflect.Value,
 	assignValue(v, t.self, reflect.ValueOf(o.Obj))
 
 	for _, p := range o.PropSet {
-		rv, ok := t.props[p.Name]
+		var field Field
+		field.FromString(p.Name)
+
+		rv, ok := t.props[field.Path]
 		if !ok {
 			continue
 		}
-		assignValue(v, rv, reflect.ValueOf(p.Val))
+		assignValue(v, rv, reflect.ValueOf(p.Val), field.Item)
 	}
 
 	return v, nil
@@ -264,3 +334,70 @@ func IsManagedObjectType(kind string) bool {
 	_, ok := t[kind]
 	return ok
 }
+
+// Field of a ManagedObject in string form.
+type Field struct {
+	Path string
+	Key  any
+	Item string
+}
+
+func (f *Field) String() string {
+	if f.Key == nil {
+		return f.Path
+	}
+
+	var key, item string
+
+	switch f.Key.(type) {
+	case string:
+		key = fmt.Sprintf("%q", f.Key)
+	default:
+		key = fmt.Sprintf("%d", f.Key)
+	}
+
+	if f.Item != "" {
+		item = "." + f.Item
+	}
+
+	return fmt.Sprintf("%s[%s]%s", f.Path, key, item)
+}
+
+func (f *Field) FromString(spec string) bool {
+	s := strings.SplitN(spec, "[", 2)
+	f.Path = s[0]
+	f.Key = nil
+	f.Item = ""
+	if len(s) == 1 {
+		return true
+	}
+
+	parts := strings.SplitN(s[1], "]", 2)
+
+	if len(parts) != 2 {
+		return false
+	}
+
+	ix := strings.Trim(parts[0], `"`)
+
+	if ix == parts[0] {
+		v, err := strconv.ParseInt(ix, 0, 32)
+		if err != nil {
+			return false
+		}
+		f.Key = int32(v)
+	} else {
+		f.Key = ix
+	}
+
+	if parts[1] == "" {
+		return true
+	}
+
+	if parts[1][0] != '.' {
+		return false
+	}
+	f.Item = parts[1][1:]
+
+	return true
+}


Test patch:
diff --git a/govc/test/object.bats b/govc/test/object.bats
index bf523207..2da45061 100755
--- a/govc/test/object.bats
+++ b/govc/test/object.bats
@@ -411,6 +411,66 @@ EOF
   govc object.collect -O -json | jq .
 }
 
+@test "object.collect index" {
+  vcsim_env
+
+  export GOVC_VM=/DC0/vm/DC0_H0_VM0
+
+  # NOTE: '-o' flag uses RetrievePropertiesEx() and mo.ObjectContentToType()
+  # By default, WaitForUpdatesEx() is used with raw types.ObjectContent
+
+  run govc object.collect -o $GOVC_VM 'config.hardware[4000]'
+  assert_failure
+
+  run govc object.collect -o $GOVC_VM 'config.hardware.device[4000'
+  assert_failure
+
+  run govc object.collect -o $GOVC_VM 'config.hardware.device["4000"]'
+  assert_failure # Key is int, not string
+
+  run govc object.collect -o -json $GOVC_VM 'config.hardware.device[4000]'
+  assert_success
+
+  run jq -r .config.hardware.device[].deviceInfo.label <<<"$output"
+  assert_success ethernet-0
+
+  run govc object.collect -o $GOVC_VM 'config.hardware.device[4000].enoent'
+  assert_failure # InvalidProperty
+
+  run govc object.collect -o -json $GOVC_VM 'config.hardware.device[4000].deviceInfo.label'
+  assert_success
+
+  run govc object.collect -s $GOVC_VM 'config.hardware.device[4000].deviceInfo.label'
+  assert_success ethernet-0
+
+  run govc object.collect -o $GOVC_VM 'config.extraConfig[guestinfo.a]'
+  assert_failure # string Key requires quotes
+
+  run govc object.collect -o $GOVC_VM 'config["guestinfo.a"]'
+  assert_failure
+
+  run govc object.collect -o $GOVC_VM 'config.extraConfig["guestinfo.a"]'
+  assert_success # Key does not exist, not an error
+
+  run govc vm.change -e "guestinfo.a=1" -e "guestinfo.b=2"
+  assert_success
+
+  run govc object.collect -json $GOVC_VM 'config.extraConfig["guestinfo.b"]'
+  assert_success
+
+  run jq -r .[].val.value <<<"$output"
+  assert_success 2
+
+  run govc object.collect -o -json $GOVC_VM 'config.extraConfig["guestinfo.b"]'
+  assert_success
+
+  run jq -r .config.extraConfig[].value <<<"$output"
+  assert_success 2
+
+  run govc object.collect -s $GOVC_VM 'config.extraConfig["guestinfo.b"].value'
+  assert_success 2
+}
+
 @test "object.find" {
   vcsim_env -ds 2
 
diff --git a/object/extension_manager_test.go b/object/extension_manager_test.go
new file mode 100644
index 00000000..639f8ce9
--- /dev/null
+++ b/object/extension_manager_test.go
@@ -0,0 +1,121 @@
+/*
+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 object_test
+
+import (
+	"context"
+	"reflect"
+	"sync"
+	"testing"
+
+	"github.com/vmware/govmomi/object"
+	"github.com/vmware/govmomi/property"
+	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/vim25"
+	"github.com/vmware/govmomi/vim25/mo"
+	"github.com/vmware/govmomi/vim25/types"
+)
+
+func TestExtensionMangerUpdates(t *testing.T) {
+	extension := types.Extension{
+		Description: &types.Description{
+			Label:   "govmomi-test",
+			Summary: "Extension Manager test",
+		},
+		Key:                    t.Name(),
+		Version:                "0.0.1",
+		ShownInSolutionManager: types.NewBool(false),
+	}
+
+	description := extension.Description.GetDescription()
+
+	f := func(item string) string {
+		return (&mo.Field{Path: "extensionList", Key: extension.Key, Item: item}).String()
+	}
+
+	tests := []types.PropertyChange{
+		{Name: f(""), Val: extension, Op: types.PropertyChangeOpAdd},
+		{Name: f(""), Val: extension, Op: types.PropertyChangeOpAssign},
+		{Name: f("description"), Val: *description, Op: types.PropertyChangeOpAssign},
+		{Name: f("description.label"), Val: description.Label, Op: types.PropertyChangeOpAssign},
+		{Name: f(""), Val: nil, Op: types.PropertyChangeOpRemove},
+	}
+
+	simulator.Test(func(ctx context.Context, c *vim25.Client) {
+		m := object.NewExtensionManager(c)
+		pc := property.DefaultCollector(c)
+
+		for _, test := range tests {
+			t.Logf("%s: %s", test.Op, test.Name)
+			update := make(chan bool)
+			parked := sync.OnceFunc(func() { update <- true })
+
+			var change *types.PropertyChange
+			cb := func(p []types.PropertyChange) bool {
+				parked()
+				change = &p[0]
+				if change.Op != test.Op {
+					t.Logf("ignore: change Op=%s, test Op=%s", change.Op, test.Op)
+					return false
+				}
+				return true
+			}
+
+			go func() {
+				werr := property.Wait(ctx, pc, m.Reference(), []string{test.Name}, cb)
+				if werr != nil {
+					t.Log(werr)
+				}
+				update <- true
+			}()
+			<-update // wait until above go func is parked in WaitForUpdatesEx()
+
+			switch test.Op {
+			case types.PropertyChangeOpAdd:
+				if err := m.Register(ctx, extension); err != nil {
+					t.Fatal(err)
+				}
+			case types.PropertyChangeOpAssign:
+				if err := m.Update(ctx, extension); err != nil {
+					t.Fatal(err)
+				}
+			case types.PropertyChangeOpRemove:
+				if err := m.Unregister(ctx, extension.Key); err != nil {
+					t.Fatal(err)
+				}
+			}
+			<-update // wait until update is received (cb returns true)
+
+			if change == nil {
+				t.Fatal("no change")
+			}
+
+			if change.Name != test.Name {
+				t.Errorf("Name: %s", change.Name)
+			}
+
+			if change.Op != test.Op {
+				t.Errorf("Op: %s", change.Op)
+			}
+
+			if !reflect.DeepEqual(change.Val, test.Val) {
+				t.Errorf("change.Val: %#v", change.Val)
+				t.Errorf("test.Val:   %#v", test.Val)
+			}
+		}
+	})
+}
diff --git a/object/virtual_machine_property_test.go b/object/virtual_machine_property_test.go
new file mode 100644
index 00000000..e3fce10e
--- /dev/null
+++ b/object/virtual_machine_property_test.go
@@ -0,0 +1,283 @@
+/*
+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 object_test
+
+import (
+	"context"
+	"reflect"
+	"sync"
+	"testing"
+
+	"github.com/vmware/govmomi/object"
+	"github.com/vmware/govmomi/property"
+	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/view"
+	"github.com/vmware/govmomi/vim25"
+	"github.com/vmware/govmomi/vim25/mo"
+	"github.com/vmware/govmomi/vim25/types"
+)
+
+func TestVirtualMachinePropertyExtraConfig(t *testing.T) {
+	key := "guestinfo.test"
+	val := func(v types.AnyType) types.OptionValue {
+		return types.OptionValue{Key: key, Value: v}
+	}
+	f := func(item string) string {
+		return (&mo.Field{Path: "config.extraConfig", Key: key, Item: item}).String()
+	}
+
+	tests := []types.PropertyChange{
+		{Name: f(""), Val: val("111"), Op: types.PropertyChangeOpAdd},
+		{Name: f(""), Val: val("222"), Op: types.PropertyChangeOpAssign},
+		{Name: f("key"), Val: val("333"), Op: types.PropertyChangeOpAssign},
+		{Name: f("value"), Val: val("444"), Op: types.PropertyChangeOpAssign},
+		{Name: f(""), Val: val(""), Op: types.PropertyChangeOpRemove},
+	}
+
+	expect := map[string]string{
+		f("key"):   key,
+		f("value"): "444",
+	}
+
+	simulator.Test(func(ctx context.Context, c *vim25.Client) {
+		kind := []string{"VirtualMachine"}
+
+		m := view.NewManager(c)
+		v, err := m.CreateContainerView(ctx, c.ServiceContent.RootFolder, kind, true)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer v.Destroy(ctx)
+
+		refs, err := v.Find(ctx, kind, property.Match{})
+		if err != nil {
+			t.Fatal(err)
+		}
+		vm := object.NewVirtualMachine(c, refs[0])
+
+		pc := property.DefaultCollector(c)
+
+		for _, test := range tests {
+			t.Logf("%s: %s", test.Op, test.Name)
+			update := make(chan bool)
+			parked := sync.OnceFunc(func() { update <- true })
+
+			var change *types.PropertyChange
+			cb := func(updates []types.ObjectUpdate) bool {
+				parked()
+				update := updates[0]
+				if update.Kind != types.ObjectUpdateKindModify {
+					return false
+				}
+				change = &update.ChangeSet[0]
+				if change.Op != test.Op {
+					t.Logf("ignore: change Op=%s, test Op=%s", change.Op, test.Op)
+					return false
+				}
+				return true
+			}
+
+			filter := new(property.WaitFilter)
+			filter.Add(v.Reference(), kind[0], []string{test.Name}, v.TraversalSpec())
+			go func() {
+				werr := property.WaitForUpdates(ctx, pc, filter, cb)
+				if werr != nil {
+					t.Log(werr)
+				}
+				update <- true
+			}()
+			<-update // wait until above go func is parked in WaitForUpdatesEx()
+
+			opt := test.Val.(types.OptionValue)
+			spec := types.VirtualMachineConfigSpec{
+				ExtraConfig: []types.BaseOptionValue{&opt},
+			}
+			task, err := vm.Reconfigure(ctx, spec)
+			if err != nil {
+				t.Fatal(err)
+			}
+			if err := task.Wait(ctx); err != nil {
+				t.Fatal(err)
+			}
+			<-update // wait until update is received (cb returns true)
+
+			if change == nil {
+				t.Fatal("no change")
+			}
+
+			if change.Name != test.Name {
+				t.Errorf("Name: %s", change.Name)
+			}
+
+			if change.Op != test.Op {
+				t.Errorf("Op: %s", change.Op)
+			}
+
+			if change.Op == types.PropertyChangeOpRemove {
+				if change.Val != nil {
+					t.Errorf("Val: %#v", change.Val)
+				}
+				continue
+			}
+
+			switch change.Val.(type) {
+			case types.OptionValue:
+				if !reflect.DeepEqual(change.Val, test.Val) {
+					t.Errorf("change.Val: %#v", change.Val)
+					t.Errorf("test.Val:   %#v", test.Val)
+				}
+			case string:
+				if expect[change.Name] != change.Val {
+					t.Errorf("Val: %s", change.Val)
+				}
+			default:
+				t.Errorf("unexpected type: %T", change.Val)
+			}
+		}
+	})
+}
+
+func TestVirtualMachinePropertyDevice(t *testing.T) {
+	key := int32(3000)
+
+	f := func(item string) string {
+		return (&mo.Field{Path: "config.hardware.device", Key: key, Item: item}).String()
+	}
+
+	info := types.Description{Label: "cdrom-3000", Summary: "cdrom-3000"}
+
+	tests := []types.PropertyChange{
+		{Name: f(""), Val: nil, Op: types.PropertyChangeOpAdd},
+		{Name: f(""), Val: nil, Op: types.PropertyChangeOpAssign},
+		{Name: f("deviceInfo"), Val: info, Op: types.PropertyChangeOpAssign},
+		{Name: f("deviceInfo.label"), Val: info.Label, Op: types.PropertyChangeOpAssign},
+		{Name: f(""), Val: nil, Op: types.PropertyChangeOpRemove},
+	}
+
+	simulator.Test(func(ctx context.Context, c *vim25.Client) {
+		kind := []string{"VirtualMachine"}
+
+		m := view.NewManager(c)
+		v, err := m.CreateContainerView(ctx, c.ServiceContent.RootFolder, kind, true)
+		if err != nil {
+			t.Fatal(err)
+		}
+		defer v.Destroy(ctx)
+
+		refs, err := v.Find(ctx, kind, property.Match{})
+		if err != nil {
+			t.Fatal(err)
+		}
+		vm := object.NewVirtualMachine(c, refs[0])
+
+		pc := property.DefaultCollector(c)
+
+		for _, test := range tests {
+			t.Logf("%s: %s", test.Op, test.Name)
+			update := make(chan bool)
+			parked := sync.OnceFunc(func() { update <- true })
+
+			var change *types.PropertyChange
+			cb := func(updates []types.ObjectUpdate) bool {
+				parked()
+				update := updates[0]
+				if update.Kind != types.ObjectUpdateKindModify {
+					return false
+				}
+				change = &update.ChangeSet[0]
+				if change.Op != test.Op {
+					t.Logf("ignore: change Op=%s, test Op=%s", change.Op, test.Op)
+					return false
+				}
+				return true
+			}
+
+			filter := new(property.WaitFilter)
+			filter.Add(v.Reference(), kind[0], []string{test.Name}, v.TraversalSpec())
+			go func() {
+				werr := property.WaitForUpdates(ctx, pc, filter, cb)
+				if werr != nil {
+					t.Log(werr)
+				}
+				update <- true
+			}()
+			<-update // wait until above go func is parked in WaitForUpdatesEx()
+
+			device, err := vm.Device(ctx)
+			if err != nil {
+				t.Fatal(err)
+			}
+
+			switch test.Op {
+			case types.PropertyChangeOpAdd:
+				ide, err := device.FindIDEController("")
+				if err != nil {
+					t.Fatal(err)
+				}
+				cdrom, err := device.CreateCdrom(ide)
+				if err != nil {
+					t.Fatal(err)
+				}
+				cdrom.GetVirtualDevice().Key = key
+				if err = vm.AddDevice(ctx, cdrom); err != nil {
+					t.Fatal(err)
+				}
+			case types.PropertyChangeOpAssign:
+				cdrom := device.FindByKey(key)
+				if err = vm.EditDevice(ctx, cdrom); err != nil {
+					t.Fatal(err)
+				}
+			case types.PropertyChangeOpRemove:
+				cdrom := device.FindByKey(key)
+				if err = vm.RemoveDevice(ctx, false, cdrom); err != nil {
+					t.Fatal(err)
+				}
+			}
+			<-update // wait until update is received (cb returns true)
+
+			if change == nil {
+				t.Fatal("no change")
+			}
+
+			if change.Name != test.Name {
+				t.Errorf("Name: %s", change.Name)
+			}
+
+			if change.Op != test.Op {
+				t.Errorf("Op: %s", change.Op)
+			}
+
+			if change.Op == types.PropertyChangeOpRemove {
+				if change.Val != nil {
+					t.Errorf("Val: %#v", change.Val)
+				}
+				continue
+			}
+
+			if test.Val != nil {
+				if !reflect.DeepEqual(change.Val, test.Val) {
+					t.Errorf("change.Val: %#v", change.Val)
+					t.Errorf("test.Val:   %#v", test.Val)
+				}
+			} else {
+				if _, ok := change.Val.(types.VirtualCdrom); !ok {
+					t.Errorf("unexpected type: %T", change.Val)
+				}
+			}
+		}
+	})
+}
diff --git a/vim25/mo/type_info_test.go b/vim25/mo/type_info_test.go
index 51fc7c48..53116822 100644
--- a/vim25/mo/type_info_test.go
+++ b/vim25/mo/type_info_test.go
@@ -1,11 +1,11 @@
 /*
-Copyright (c) 2014 VMware, Inc. All Rights Reserved.
+Copyright (c) 2014-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
+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,
@@ -49,3 +49,31 @@ func BenchmarkLoadVirtualMachine(b *testing.B) {
 		newTypeInfo(vmtyp)
 	}
 }
+
+func TestPropertyPathFromString(t *testing.T) {
+	tests := []struct {
+		path   string
+		expect *Field
+	}{
+		{`foo.bar`, &Field{Path: "foo.bar"}},
+		{`foo.bar["biz"]`, &Field{Path: "foo.bar", Key: "biz"}},
+		{`foo.bar["biz"].baz`, &Field{Path: "foo.bar", Key: "biz", Item: "baz"}},
+		{`foo.bar[0]`, &Field{Path: "foo.bar", Key: int32(0)}},
+		{`foo.bar[1].baz`, &Field{Path: "foo.bar", Key: int32(1), Item: "baz"}},
+		{`foo.bar[1].baz.buz`, &Field{Path: "foo.bar", Key: int32(1), Item: "baz.buz"}},
+	}
+
+	for i, tp := range tests {
+		var field Field
+		if field.FromString(tp.path) {
+			if field.String() != tp.expect.String() {
+				t.Errorf("%d: %s != %s", i, field, tp.expect)
+			}
+			if field != *tp.expect {
+				t.Errorf("%d: %#v != %#v", i, field, *tp.expect)
+			}
+		} else {
+			t.Error(tp.path)
+		}
+	}
+}