Repository: vmware/govmomi
Pull request #2211 (2020-12-15 13:39:10)

Add batch APIs for multiple tags to object
This PR adds support for the missing multiple-tags-to-object (attach/detach) APIs which are important for efficiency and performance especially in large environments (many tags/objects) and environments with high churn (Kubernetes controllers, event-driven systems).

The following changes were made:

- Add attach-multiple-tags-to-object and detach-multiple-tags-from-object APIs
- Add BatchError(s) types
- Add minimum simulator APIs (see note below)
- Add/update comments to describe error behavior in detail
- Add black-box tests (against simulator)

Example output handled in a `main.go` with the `log` package where some tags could not be assigned to a valid reference:

```bash
2020/12/15 13:47:25 could not attach tags: [error: 0 type: cis.tagging.attach.invalidArgument.cardinality.error reason: Tag urn:vmomi:InventoryServiceTag:e2415c96-b16b-46e2-8c2e-ef8d19fad1b6:GLOBAL is ineligible to attach to urn:vmomi:VirtualMachine:vm-48:5790209b-3064-4fbe-b030-d0301aec0384 because of a cardinality violation.]
```

Tested against vCenter 6.7U3 which has the following behavior:

- Missing/invalid object reference: `403 Forbidden`
- No valid REST session: `401 Unauthorized`
- No tagging permissions: `ServerFaultCode: Permission to perform this operation was denied`
- Tag not found: `200 with individual cis.tagging.objectNotFound.error in response`
- Cardinality violation: `200 with cis.tagging.attach.invalidArgument.cardinality.error`

Note that the existing simulator APIs currently don't support all these failure codes

Fixes: #2208
Related: #2210

Signed-off-by: Michael Gasch <mgasch@vmware.com>

embano1: WIP, need to implement `DetachMultipleTagsFromObject` but waiting for first review on the initial changes.
embano1: Please hold Doug, found a minor glitch in one test which passed but in the wrong way :)

Patch:
diff --git a/govc/cluster/usage.go b/govc/cluster/usage.go
deleted file mode 100644
index 3b3e5bcf..00000000
--- a/govc/cluster/usage.go
+++ /dev/null
@@ -1,183 +0,0 @@
-/*
-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"
-	"io"
-	"text/tabwriter"
-
-	"github.com/vmware/govmomi/govc/cli"
-	"github.com/vmware/govmomi/govc/flags"
-	"github.com/vmware/govmomi/property"
-	"github.com/vmware/govmomi/units"
-	"github.com/vmware/govmomi/vim25/mo"
-)
-
-type usage struct {
-	*flags.DatacenterFlag
-
-	shared bool
-}
-
-func init() {
-	cli.Register("cluster.usage", &usage{})
-}
-
-func (cmd *usage) Register(ctx context.Context, f *flag.FlagSet) {
-	cmd.DatacenterFlag, ctx = flags.NewDatacenterFlag(ctx)
-	cmd.DatacenterFlag.Register(ctx, f)
-
-	f.BoolVar(&cmd.shared, "S", false, "Exclude host local storage")
-}
-
-func (cmd *usage) Usage() string {
-	return "CLUSTER"
-}
-
-func (cmd *usage) Description() string {
-	return `Cluster resource usage summary.
-
-Examples:
-  govc cluster.usage ClusterName
-  govc cluster.usage -S ClusterName # summarize shared storage only
-  govc cluster.usage -json ClusterName | jq -r .CPU.Summary.Usage`
-}
-
-func (cmd *usage) Run(ctx context.Context, f *flag.FlagSet) error {
-	finder, err := cmd.Finder()
-	if err != nil {
-		return err
-	}
-
-	obj, err := finder.ClusterComputeResource(ctx, f.Arg(0))
-	if err != nil {
-		return err
-	}
-
-	var res Usage
-	var cluster mo.ClusterComputeResource
-	var hosts []mo.HostSystem
-	var datastores []mo.Datastore
-
-	pc := property.DefaultCollector(obj.Client())
-
-	err = pc.RetrieveOne(ctx, obj.Reference(), []string{"datastore", "host"}, &cluster)
-	if err != nil {
-		return err
-	}
-
-	err = pc.Retrieve(ctx, cluster.Host, []string{"summary"}, &hosts)
-	if err != nil {
-		return err
-	}
-
-	for _, host := range hosts {
-		res.CPU.Capacity += int64(int32(host.Summary.Hardware.NumCpuCores) * host.Summary.Hardware.CpuMhz)
-		res.CPU.Used += int64(host.Summary.QuickStats.OverallCpuUsage)
-
-		res.Memory.Capacity += host.Summary.Hardware.MemorySize
-		res.Memory.Used += int64(host.Summary.QuickStats.OverallMemoryUsage) << 20
-	}
-
-	err = pc.Retrieve(ctx, cluster.Datastore, []string{"summary"}, &datastores)
-	if err != nil {
-		return err
-	}
-
-	for _, datastore := range datastores {
-		shared := datastore.Summary.MultipleHostAccess
-		if cmd.shared && shared != nil && *shared == false {
-			continue
-		}
-
-		res.Storage.Capacity += datastore.Summary.Capacity
-		res.Storage.Free += datastore.Summary.FreeSpace
-	}
-
-	res.CPU.Free = res.CPU.Capacity - res.CPU.Used
-	res.CPU.summarize(ghz)
-
-	res.Memory.Free = res.Memory.Capacity - res.Memory.Used
-	res.Memory.summarize(size)
-
-	res.Storage.Used = res.Storage.Capacity - res.Storage.Free
-	res.Storage.summarize(size)
-
-	return cmd.WriteResult(&res)
-}
-
-type ResourceUsageSummary struct {
-	Used     string
-	Free     string
-	Capacity string
-	Usage    string
-}
-
-type ResourceUsage struct {
-	Used     int64
-	Free     int64
-	Capacity int64
-	Usage    float64
-	Summary  ResourceUsageSummary
-}
-
-func (r *ResourceUsage) summarize(f func(int64) string) {
-	r.Usage = 100 * float64(r.Used) / float64(r.Capacity)
-
-	r.Summary.Usage = fmt.Sprintf("%.1f", r.Usage)
-	r.Summary.Capacity = f(r.Capacity)
-	r.Summary.Used = f(r.Used)
-	r.Summary.Free = f(r.Used)
-}
-
-func (r *ResourceUsage) write(w io.Writer, label string) {
-	fmt.Fprintf(w, "%s usage:\t%s%%\n", label, r.Summary.Usage)
-	fmt.Fprintf(w, "%s capacity:\t%s\n", label, r.Summary.Capacity)
-	fmt.Fprintf(w, "%s used:\t%s\n", label, r.Summary.Used)
-	fmt.Fprintf(w, "%s free:\t%s\n", label, r.Summary.Free)
-}
-
-func ghz(val int64) string {
-	return fmt.Sprintf("%.1fGHz", float64(val)/1000)
-}
-
-func size(val int64) string {
-	return units.ByteSize(val).String()
-}
-
-type Usage struct {
-	Memory  ResourceUsage
-	CPU     ResourceUsage
-	Storage ResourceUsage
-}
-
-func (r *Usage) Write(w io.Writer) error {
-	tw := tabwriter.NewWriter(w, 2, 0, 2, ' ', 0)
-
-	r.CPU.write(tw, "CPU")
-	fmt.Fprintf(tw, "\t\n")
-
-	r.Memory.write(tw, "Memory")
-	fmt.Fprintf(tw, "\t\n")
-
-	r.Storage.write(tw, "Storage")
-
-	return tw.Flush()
-}
diff --git a/vapi/simulator/simulator.go b/vapi/simulator/simulator.go
index ed6269eb..8b250f7b 100644
--- a/vapi/simulator/simulator.go
+++ b/vapi/simulator/simulator.go
@@ -586,6 +586,7 @@ func (s *handler) tagID(w http.ResponseWriter, r *http.Request) {
 	}
 }
 
+// TODO: support cardinality checks
 func (s *handler) association(w http.ResponseWriter, r *http.Request) {
 	if r.Method != http.MethodPost {
 		w.WriteHeader(http.StatusMethodNotAllowed)
@@ -610,6 +611,7 @@ func (s *handler) association(w http.ResponseWriter, r *http.Request) {
 			}
 		}
 		OK(w, ids)
+
 	case "list-attached-objects-on-tags":
 		var res []tags.AttachedObjects
 		for _, id := range spec.TagIDs {
@@ -620,6 +622,7 @@ func (s *handler) association(w http.ResponseWriter, r *http.Request) {
 			res = append(res, o)
 		}
 		OK(w, res)
+
 	case "list-attached-tags-on-objects":
 		var res []tags.AttachedTags
 		for _, ref := range spec.ObjectIDs {
@@ -632,6 +635,56 @@ func (s *handler) association(w http.ResponseWriter, r *http.Request) {
 			res = append(res, o)
 		}
 		OK(w, res)
+
+	case "attach-multiple-tags-to-object":
+		// TODO: add check if target (moref) exist or return 403 as per API behavior
+
+		res := struct {
+			Success bool             `json:"success"`
+			Errors  tags.BatchErrors `json:"error_messages,omitempty"`
+		}{}
+
+		for _, id := range spec.TagIDs {
+			if _, exists := s.Association[id]; !exists {
+				log.Printf("association tag not found: %s", id)
+				res.Errors = append(res.Errors, tags.BatchError{
+					Type:    "cis.tagging.objectNotFound.error",
+					Message: fmt.Sprintf("Tagging object %s not found", id),
+				})
+			} else {
+				s.Association[id][*spec.ObjectID] = true
+			}
+		}
+
+		if len(res.Errors) == 0 {
+			res.Success = true
+		}
+		OK(w, res)
+
+	case "detach-multiple-tags-from-object":
+		// TODO: add check if target (moref) exist or return 403 as per API behavior
+
+		res := struct {
+			Success bool             `json:"success"`
+			Errors  tags.BatchErrors `json:"error_messages,omitempty"`
+		}{}
+
+		for _, id := range spec.TagIDs {
+			if _, exists := s.Association[id]; !exists {
+				log.Printf("association tag not found: %s", id)
+				res.Errors = append(res.Errors, tags.BatchError{
+					Type:    "cis.tagging.objectNotFound.error",
+					Message: fmt.Sprintf("Tagging object %s not found", id),
+				})
+			} else {
+				s.Association[id][*spec.ObjectID] = false
+			}
+		}
+
+		if len(res.Errors) == 0 {
+			res.Success = true
+		}
+		OK(w, res)
 	}
 }
 
diff --git a/vapi/tags/errors.go b/vapi/tags/errors.go
new file mode 100644
index 00000000..b3f84f84
--- /dev/null
+++ b/vapi/tags/errors.go
@@ -0,0 +1,55 @@
+/*
+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 tags
+
+import (
+	"fmt"
+)
+
+const (
+	errFormat = "[error: %d type: %s reason: %s]"
+	separator = "," // concat multiple error strings
+)
+
+// BatchError is an error returned for a single item which failed in a batch
+// operation
+type BatchError struct {
+	Type    string `json:"id"`
+	Message string `json:"default_message"`
+}
+
+// BatchErrors contains all errors which occurred in a batch operation
+type BatchErrors []BatchError
+
+func (b BatchErrors) Error() string {
+	if len(b) == 0 {
+		return ""
+	}
+
+	var errString string
+	for i := range b {
+		errType := b[i].Type
+		reason := b[i].Message
+		errString += fmt.Sprintf(errFormat, i, errType, reason)
+
+		// no separator after last item
+		if i+1 < len(b) {
+			errString += separator
+		}
+	}
+	return errString
+}
diff --git a/vapi/tags/tag_association.go b/vapi/tags/tag_association.go
index 5abc3238..33b22093 100755
--- a/vapi/tags/tag_association.go
+++ b/vapi/tags/tag_association.go
@@ -60,7 +60,19 @@ func (c *Manager) DetachTag(ctx context.Context, tagID string, ref mo.Reference)
 	return c.Do(ctx, url.Request(http.MethodPost, spec), nil)
 }
 
-// AttachTagToMultipleObjects attaches a tag ID to multiple managed objects
+// batchResponse is the response type used by attach/detach operations which
+// take multiple tagIDs or moRefs as input. On failure Success will be false and
+// Errors contains information about all failed operations
+type batchResponse struct {
+	Success bool        `json:"success"`
+	Errors  BatchErrors `json:"error_messages,omitempty"`
+}
+
+// AttachTagToMultipleObjects attaches a tag ID to multiple managed objects.
+// This operation is idempotent, i.e. if a tag is already attached to the
+// object, then the individual operation is a no-op and no error will be thrown.
+//
+// This operation was added in vSphere API 6.5.
 func (c *Manager) AttachTagToMultipleObjects(ctx context.Context, tagID string, refs []mo.Reference) error {
 	id, err := c.tagID(ctx, tagID)
 	if err != nil {
@@ -80,6 +92,106 @@ func (c *Manager) AttachTagToMultipleObjects(ctx context.Context, tagID string,
 	return c.Do(ctx, url.Request(http.MethodPost, spec), nil)
 }
 
+// AttachMultipleTagsToObject attaches multiple tag IDs to a managed object.
+// This operation is idempotent. If a tag is already attached to the object,
+// then the individual operation is a no-op and no error will be thrown. This
+// operation is not atomic. If the underlying call fails with one or more tags
+// not successfully attached to the managed object reference it might leave the
+// managed object reference in a partially tagged state and needs to be resolved
+// by the caller. In this case BatchErrors is returned and can be used to
+// analyse failure reasons on each failed tag.
+//
+// Specified tagIDs must use URN-notation instead of display names or a generic
+// error will be returned and no tagging operation will be performed. If the
+// managed object reference does not exist a generic 403 Forbidden error will be
+// returned.
+//
+// This operation was added in vSphere API 6.5.
+func (c *Manager) AttachMultipleTagsToObject(ctx context.Context, tagIDs []string, ref mo.Reference) error {
+	for _, id := range tagIDs {
+		// URN enforced to avoid unnecessary round-trips due to invalid tags or display
+		// name lookups
+		if isName(id) {
+			return fmt.Errorf("specified tag is not a URN: %q", id)
+		}
+	}
+
+	obj := internal.AssociatedObject(ref.Reference())
+	spec := struct {
+		ObjectID internal.AssociatedObject `json:"object_id"`
+		TagIDs   []string                  `json:"tag_ids"`
+	}{
+		ObjectID: obj,
+		TagIDs:   tagIDs,
+	}
+
+	var res batchResponse
+	url := c.Resource(internal.AssociationPath).WithAction("attach-multiple-tags-to-object")
+	err := c.Do(ctx, url.Request(http.MethodPost, spec), &res)
+	if err != nil {
+		return err
+	}
+
+	if !res.Success {
+		if len(res.Errors) != 0 {
+			return res.Errors
+		}
+		panic("invalid batch error")
+	}
+
+	return nil
+}
+
+// DetachMultipleTagsFromObject detaches multiple tag IDs from a managed object.
+// This operation is idempotent. If a tag is already detached from the object,
+// then the individual operation is a no-op and no error will be thrown. This
+// operation is not atomic. If the underlying call fails with one or more tags
+// not successfully detached from the managed object reference it might leave
+// the managed object reference in a partially tagged state and needs to be
+// resolved by the caller. In this case BatchErrors is returned and can be used
+// to analyse failure reasons on each failed tag.
+//
+// Specified tagIDs must use URN-notation instead of display names or a generic
+// error will be returned and no tagging operation will be performed. If the
+// managed object reference does not exist a generic 403 Forbidden error will be
+// returned.
+//
+// This operation was added in vSphere API 6.5.
+func (c *Manager) DetachMultipleTagsFromObject(ctx context.Context, tagIDs []string, ref mo.Reference) error {
+	for _, id := range tagIDs {
+		// URN enforced to avoid unnecessary round-trips due to invalid tags or display
+		// name lookups
+		if isName(id) {
+			return fmt.Errorf("specified tag is not a URN: %q", id)
+		}
+	}
+
+	obj := internal.AssociatedObject(ref.Reference())
+	spec := struct {
+		ObjectID internal.AssociatedObject `json:"object_id"`
+		TagIDs   []string                  `json:"tag_ids"`
+	}{
+		ObjectID: obj,
+		TagIDs:   tagIDs,
+	}
+
+	var res batchResponse
+	url := c.Resource(internal.AssociationPath).WithAction("detach-multiple-tags-from-object")
+	err := c.Do(ctx, url.Request(http.MethodPost, spec), &res)
+	if err != nil {
+		return err
+	}
+
+	if !res.Success {
+		if len(res.Errors) != 0 {
+			return res.Errors
+		}
+		panic("invalid batch error")
+	}
+
+	return nil
+}
+
 // ListAttachedTags fetches the array of tag IDs attached to the given object.
 func (c *Manager) ListAttachedTags(ctx context.Context, ref mo.Reference) ([]string, error) {
 	spec := internal.NewAssociation(ref)


Test patch:
diff --git a/govc/test/cluster.bats b/govc/test/cluster.bats
index e84811ec..d15dc437 100755
--- a/govc/test/cluster.bats
+++ b/govc/test/cluster.bats
@@ -245,16 +245,3 @@ _EOF_
     name=$(govc host.info -json -host.ip 10.0.0.42 | jq -r .HostSystems[].Name)
     assert_equal 10.0.0.42 "$name"
 }
-
-@test "cluster.usage" {
-  vcsim_env -host 4
-
-  run govc cluster.usage enoent
-  assert_failure
-
-  run govc cluster.usage DC0_C0
-  assert_success
-
-  memory=$(govc cluster.usage -json DC0_C0 | jq -r .Memory.Summary.Usage)
-  [ "$memory" = "34.3" ]
-}
diff --git a/object/example_test.go b/object/example_test.go
index cfaf187e..ef5800ed 100644
--- a/object/example_test.go
+++ b/object/example_test.go
@@ -271,57 +271,6 @@ func ExampleVirtualMachine_Clone() {
 	// Output: example-clone
 }
 
-func ExampleFolder_CreateVM() {
-	simulator.Run(func(ctx context.Context, c *vim25.Client) error {
-		finder := find.NewFinder(c)
-		dc, err := finder.Datacenter(ctx, "DC0")
-		if err != nil {
-			return err
-		}
-
-		finder.SetDatacenter(dc)
-
-		folders, err := dc.Folders(ctx)
-		if err != nil {
-			return err
-		}
-
-		pool, err := finder.ResourcePool(ctx, "DC0_C0/Resources")
-		if err != nil {
-			return err
-		}
-
-		spec := types.VirtualMachineConfigSpec{
-			Name:    "example-vm",
-			GuestId: string(types.VirtualMachineGuestOsIdentifierOtherGuest),
-			Files: &types.VirtualMachineFileInfo{
-				VmPathName: "[LocalDS_0]",
-			},
-		}
-
-		task, err := folders.VmFolder.CreateVM(ctx, spec, pool, nil)
-		if err != nil {
-			return err
-		}
-
-		info, err := task.WaitForResult(ctx)
-		if err != nil {
-			return err
-		}
-
-		vm := object.NewVirtualMachine(c, info.Result.(types.ManagedObjectReference))
-		name, err := vm.ObjectName(ctx)
-		if err != nil {
-			return err
-		}
-
-		fmt.Println(name)
-
-		return nil
-	})
-	// Output: example-vm
-}
-
 func ExampleVirtualMachine_Reconfigure() {
 	simulator.Run(func(ctx context.Context, c *vim25.Client) error {
 		vm, err := find.NewFinder(c).VirtualMachine(ctx, "DC0_H0_VM0")
diff --git a/vapi/tags/tag_association_test.go b/vapi/tags/tag_association_test.go
new file mode 100644
index 00000000..082d9aed
--- /dev/null
+++ b/vapi/tags/tag_association_test.go
@@ -0,0 +1,378 @@
+/*
+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 tags_test
+
+import (
+	"context"
+	"errors"
+	"reflect"
+	"testing"
+
+	"github.com/vmware/govmomi/simulator"
+	"github.com/vmware/govmomi/vapi/rest"
+	"github.com/vmware/govmomi/vapi/tags"
+	"github.com/vmware/govmomi/view"
+	"github.com/vmware/govmomi/vim25"
+	"github.com/vmware/govmomi/vim25/mo"
+)
+
+func TestManager_AttachMultipleTagsToObject(t *testing.T) {
+	// tags to create in the system before executing tests
+	// returned IDs will be appended to urns in args struct
+	type fields struct {
+		namedTags []string
+	}
+
+	type args struct {
+		// if fields.namedTags are specified this field will be populated with the related URNs
+		urns []string
+	}
+
+	tests := []struct {
+		name     string
+		fields   fields
+		args     args
+		wantTags int // number of expected tags attached to ref
+		wantErr  error
+	}{
+		{
+			name:   "tag not in URN-format",
+			fields: fields{}, // no pre-existing tags required for this test case
+			args: args{
+				urns: []string{"not-urn-tag"},
+			},
+			wantTags: 0,
+			wantErr:  errors.New("specified tag is not a URN: \"not-urn-tag\""),
+		},
+		{
+			name: "two valid tags on one category",
+			fields: fields{
+				namedTags: []string{"valid-tag-1", "valid-tag-2"},
+			},
+			args:     args{}, // will be auto-populated
+			wantTags: 2,
+			wantErr:  nil,
+		},
+		{
+			name: "one valid and one invalid (not-URN) tag on one category",
+			fields: fields{
+				namedTags: []string{"valid-tag"}, // create one valid tag before running test
+			},
+			args: args{
+				urns: []string{"not-urn-tag"}, // force one additional non-URN tag
+			},
+			wantTags: 0,
+			wantErr:  errors.New("specified tag is not a URN: \"not-urn-tag\""),
+		},
+		{
+			name: "one valid and one not existing tag on one category",
+			fields: fields{
+				namedTags: []string{"valid-tag"}, // create one valid tag before running test
+			},
+			args: args{
+				urns: []string{"urn:vmomi:InventoryServiceTag:31e55277-ca60-482a-899b-232184be224c:GLOBAL"}, // does not exist
+			},
+			wantTags: 1,
+			wantErr: tags.BatchErrors{
+				{
+					Type:    "cis.tagging.objectNotFound.error",
+					Message: "Tagging object urn:vmomi:InventoryServiceTag:31e55277-ca60-482a-899b-232184be224c:GLOBAL not found",
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			simulator.Run(func(ctx context.Context, vc *vim25.Client) error {
+				vm, err := getRef(t, ctx, vc)
+				if err != nil {
+					t.Errorf("get virtual machine: %v", err)
+				}
+
+				c := rest.NewClient(vc)
+				_ = c.Login(ctx, simulator.DefaultLogin)
+
+				m := tags.NewManager(c)
+
+				// seed simulator with URNs if required for this test
+				if len(tt.fields.namedTags) > 0 {
+					idMap, err := createTags(t, ctx, m, tt.fields.namedTags)
+					if err != nil {
+						t.Errorf("set up tags: %v", err)
+					}
+
+					for _, id := range idMap {
+						tt.args.urns = append(tt.args.urns, id)
+					}
+				}
+
+				err = m.AttachMultipleTagsToObject(ctx, tt.args.urns, vm)
+				if !reflect.DeepEqual(err, tt.wantErr) {
+					t.Errorf("AttachMultipleTagsToObject() error = %v, wantErr %v", err, tt.wantErr)
+				}
+
+				attached, err := getTags(t, ctx, m, vm)
+				if len(attached) != tt.wantTags {
+					t.Errorf("AttachMultipleTagsToObject() attachedTags = %d, wantTags %d", len(attached), tt.wantTags)
+				}
+
+				return nil
+			})
+		})
+	}
+}
+
+func TestManager_DetachMultipleTagsFromObject(t *testing.T) {
+	// tags which will be optionally created and attached to the target ref
+	// before executing tests - returned IDs for created tags will be appended to
+	// urns in args struct
+	type field struct {
+		name   string
+		create bool
+		attach bool
+	}
+
+	type args struct {
+		// if fields.namedTags && .create are specified this field will be populated
+		// with the related URNs
+		urns []string
+	}
+
+	tests := []struct {
+		name     string
+		fields   []field
+		args     args
+		wantTags int // number of expected tags attached to ref
+		wantErr  error
+	}{
+		{
+			name:   "tag not in URN-format",
+			fields: []field{}, // no pre-existing tags required for this test case
+			args: args{
+				urns: []string{"not-urn-tag"},
+			},
+			wantTags: 0,
+			wantErr:  errors.New("specified tag is not a URN: \"not-urn-tag\""),
+		},
+		{
+			name: "no-op",
+			fields: []field{
+				{
+					name:   "valid-tag",
+					create: true,
+					attach: false, // exists but not attached
+				},
+			},
+			args:     args{}, // will be auto-populated
+			wantTags: 0,
+			wantErr:  nil,
+		},
+		{
+			name: "two valid tags on one category",
+			fields: []field{
+				{
+					name:   "valid-tag-1",
+					create: true,
+					attach: true,
+				},
+				{
+					name:   "valid-tag-2",
+					create: true,
+					attach: true,
+				},
+			},
+			args:     args{}, // will be auto-populated
+			wantTags: 0,
+			wantErr:  nil,
+		},
+		{
+			name: "one valid and one invalid (not-URN) tag on one category",
+			fields: []field{
+				{
+					name:   "valid-tag-1",
+					create: true,
+					attach: true,
+				},
+			},
+			args: args{
+				urns: []string{"not-urn-tag"}, // force one additional non-URN tag
+			},
+			wantTags: 1, // won't be detached
+			wantErr:  errors.New("specified tag is not a URN: \"not-urn-tag\""),
+		},
+		{
+			name: "one valid and one not existing tag on one category",
+			fields: []field{
+				{
+					name:   "valid-tag-1",
+					create: true,
+					attach: true,
+				},
+			},
+			args: args{
+				urns: []string{"urn:vmomi:InventoryServiceTag:31e55277-ca60-482a-899b-232184be224c:GLOBAL"}, // does not exist
+			},
+			wantTags: 0, // existing will be detached
+			wantErr: tags.BatchErrors{
+				{
+					Type:    "cis.tagging.objectNotFound.error",
+					Message: "Tagging object urn:vmomi:InventoryServiceTag:31e55277-ca60-482a-899b-232184be224c:GLOBAL not found",
+				},
+			},
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			simulator.Run(func(ctx context.Context, vc *vim25.Client) error {
+				vm, err := getRef(t, ctx, vc)
+				if err != nil {
+					t.Errorf("get virtual machine: %v", err)
+				}
+
+				c := rest.NewClient(vc)
+				_ = c.Login(ctx, simulator.DefaultLogin)
+
+				m := tags.NewManager(c)
+
+				// seed simulator with URNs and attach to ref if required for this test
+				if len(tt.fields) > 0 {
+					var create []string
+					for _, t := range tt.fields {
+						if t.create {
+							create = append(create, t.name)
+						}
+					}
+
+					idMap, err := createTags(t, ctx, m, create)
+					if err != nil {
+						t.Errorf("create tags: %v", err)
+					}
+
+					// transform name tags to created tag URNs before attaching
+					var attach []string
+					for _, t := range tt.fields {
+						if t.attach {
+							attach = append(attach, idMap[t.name])
+						}
+					}
+
+					err = attachTags(t, ctx, m, vm, attach)
+					if err != nil {
+						t.Errorf("attach tags: %v", err)
+					}
+
+					for _, id := range idMap {
+						tt.args.urns = append(tt.args.urns, id)
+					}
+				}
+
+				err = m.DetachMultipleTagsFromObject(ctx, tt.args.urns, vm)
+				if !reflect.DeepEqual(err, tt.wantErr) {
+					t.Errorf("DetachMultipleTagsFromObject() error = %v, wantErr %v", err, tt.wantErr)
+				}
+
+				attached, err := getTags(t, ctx, m, vm)
+				if len(attached) != tt.wantTags {
+					t.Errorf("DetachMultipleTagsFromObject() attachedTags = %d, wantTags %d", len(attached), tt.wantTags)
+				}
+
+				return nil
+			})
+		})
+	}
+}
+
+// createTags creates the given tag to category mappings and returns a map of
+// names to IDs (URNs) for all created tags
+func createTags(t *testing.T, ctx context.Context, mgr *tags.Manager, tagNames []string) (map[string]string, error) {
+	t.Helper()
+
+	cat := tags.Category{
+		Name:        "test-category-1",
+		Description: "category used for testing against simulator",
+		Cardinality: "MULTIPLE", // simulator currently does not support cardinality validation
+	}
+
+	catID, err := mgr.CreateCategory(ctx, &cat)
+	if err != nil {
+		return nil, err
+	}
+
+	mapping := map[string]string{}
+	for _, name := range tagNames {
+		id, err := mgr.CreateTag(ctx, &tags.Tag{Name: name, CategoryID: catID})
+		if err != nil {
+			return nil, err
+		}
+		mapping[name] = id
+	}
+
+	return mapping, nil
+}
+
+// attachTags attaches the given tags on the given ref
+func attachTags(t *testing.T, ctx context.Context, mgr *tags.Manager, ref mo.Reference, tagIDs []string) error {
+	t.Helper()
+
+	for _, id := range tagIDs {
+		err := mgr.AttachTag(ctx, id, ref)
+		if err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
+
+func getTags(t *testing.T, ctx context.Context, mgr *tags.Manager, ref mo.Reference) ([]tags.Tag, error) {
+	t.Helper()
+
+	attached, err := mgr.GetAttachedTags(ctx, ref)
+	if err != nil {
+		return nil, err
+	}
+
+	return attached, nil
+}
+
+// getRef returns the first virtual machine found in the inventory
+func getRef(t *testing.T, ctx context.Context, client *vim25.Client) (mo.Reference, error) {
+	t.Helper()
+
+	// Create view of VirtualMachine objects
+	m := view.NewManager(client)
+
+	v, err := m.CreateContainerView(ctx, client.ServiceContent.RootFolder, []string{"VirtualMachine"}, true)
+	if err != nil {
+		return nil, err
+	}
+
+	defer v.Destroy(ctx)
+
+	// Retrieve summary property for all machines
+	// Reference: http://pubs.vmware.com/vsphere-60/topic/com.vmware.wssdk.apiref.doc/vim.VirtualMachine.html
+	var vms []mo.VirtualMachine
+	err = v.Retrieve(ctx, []string{"VirtualMachine"}, []string{"summary"}, &vms)
+	if err != nil {
+		return nil, err
+	}
+
+	if len(vms) < 1 {
+		return nil, errors.New("no existing virtual machine found")
+	}
+	return vms[0].Self, nil
+}