diff --git a/README.md b/README.md index ea0effa..afa09c2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# function-tags-manager +# function-tag-manager `function-tag-manager` is a [Crossplane](https://crossplane.io) function that allows Platform Operators to manage Cloud tags on managed resources. @@ -10,6 +10,7 @@ There several use cases for this Function: - Allowing external systems to set tags on Crossplane Managed Resources without conflict. - Adding Common Tags to Resources without having to update every resource in a Composition. - Allowing users the ability to add their own tags when Requesting new resources. +- Removing tags that have been set earlier in the pipeline by other functions. ## Installing the Function @@ -128,6 +129,20 @@ Tag keys to ignore can be defined in `FromValue` or set in the Composite/Claim u Another option for allowing external systems to manage tags is to use the [`initProvider`](https://docs.crossplane.io/latest/concepts/managed-resources/#initprovider) field of a Managed Resource. +### RemoveTags + +The function can remove tags defined in the desired state by specifying +`removeTags` and providing an array of keys to delete. + +```yaml + removeTags: + - type: FromValue + keys: + - fromValue2 + - type: FromCompositeFieldPath + fromFieldPath: spec.parameters.removeTags +``` + ## Tag Policies When Merging tags, a `Policy` can be set: diff --git a/examples/configuration-aws-network/composition.yaml b/examples/configuration-aws-network/composition.yaml index 9b9a5ce..fb0517c 100644 --- a/examples/configuration-aws-network/composition.yaml +++ b/examples/configuration-aws-network/composition.yaml @@ -351,6 +351,13 @@ spec: policy: Retain keys: - ignoreRetain1 + removeTags: + - type: FromValue + keys: + - fromValue2 # delete tag defined earlier + - type: FromCompositeFieldPath + fromFieldPath: spec.parameters.removeTags + - step: automatically-detect-ready-composed-resources functionRef: name: crossplane-contrib-function-auto-ready diff --git a/fn.go b/fn.go index cb9c20c..9ee411c 100644 --- a/fn.go +++ b/fn.go @@ -92,6 +92,14 @@ func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) } } } + removeTags := f.ResolveRemoveTags(in.RemoveTags, oxr) + // Remove tags + if len(removeTags) > 0 { + err := RemoveTags(desired, removeTags) + if err != nil { + f.log.Debug("error removing tags", string(name), err.Error()) + } + } } if err := response.SetDesiredComposedResources(rsp, desiredComposed); err != nil { diff --git a/input/v1beta1/input.go b/input/v1beta1/input.go index 8c3ca0f..b378625 100644 --- a/input/v1beta1/input.go +++ b/input/v1beta1/input.go @@ -26,10 +26,14 @@ type ManagedTags struct { // +optional AddTags []AddTag `json:"addTags,omitempty"` - // IgnoreTags is a map of tag keys to ignore if set on the + // IgnoreTags is a list of tag keys to ignore if set on the // resource outside of Crossplane // +optional IgnoreTags IgnoreTags `json:"ignoreTags,omitempty"` + + // IgnoreTags is a list of tag keys to remove from the resource + // +optional + RemoveTags RemoveTags `json:"removeTags,omitempty"` } type Tags map[string]string @@ -78,6 +82,7 @@ type AddTag struct { Policy TagManagerPolicy `json:"policy,omitempty"` } +// IgnoreTag is a tag that is "ignored" by setting the desired value to the observed value. type IgnoreTag struct { // Type determines where tag keys are sourced from. FromValue are inline // to the composition. FromCompositeFieldPath fetches keys from a field in @@ -98,8 +103,29 @@ type IgnoreTag struct { // +optional Policy TagManagerPolicy `json:"policy,omitempty"` } + type IgnoreTags []IgnoreTag +// RemoveTag is a tag that removed from the desired state. +type RemoveTag struct { + // Type determines where tag keys are sourced from. FromValue are inline + // to the composition. FromCompositeFieldPath fetches keys from a field in + // the composite resource + // +kubebuilder:validation:Enum=FromCompositeFieldPath;FromValue + Type TagManagerType `json:"type"` + + // FromFieldPath if type is FromCompositeFieldPath, get keys to remove + // from the field in the Composite (like spec.parameters.removeTags) + // +optional + FromFieldPath *string `json:"fromFieldPath,omitempty"` + + // Keys are tag keys to ignore for the FromValue type + // +optional + Keys []string `json:"keys,omitempty"` +} + +type RemoveTags []RemoveTag + func (a *AddTag) GetType() TagManagerType { if a == nil || a.Type == "" { return FromValue @@ -141,3 +167,10 @@ func GetKeys(i []IgnoreTag) []string { } return keys } + +func (a *RemoveTag) GetType() TagManagerType { + if a == nil || a.Type == "" { + return FromValue + } + return a.Type +} diff --git a/input/v1beta1/zz_generated.deepcopy.go b/input/v1beta1/zz_generated.deepcopy.go index 69098eb..a211864 100644 --- a/input/v1beta1/zz_generated.deepcopy.go +++ b/input/v1beta1/zz_generated.deepcopy.go @@ -100,6 +100,13 @@ func (in *ManagedTags) DeepCopyInto(out *ManagedTags) { (*in)[i].DeepCopyInto(&(*out)[i]) } } + if in.RemoveTags != nil { + in, out := &in.RemoveTags, &out.RemoveTags + *out = make(RemoveTags, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ManagedTags. @@ -120,6 +127,52 @@ func (in *ManagedTags) DeepCopyObject() runtime.Object { return nil } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *RemoveTag) DeepCopyInto(out *RemoveTag) { + *out = *in + if in.FromFieldPath != nil { + in, out := &in.FromFieldPath, &out.FromFieldPath + *out = new(string) + **out = **in + } + if in.Keys != nil { + in, out := &in.Keys, &out.Keys + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoveTag. +func (in *RemoveTag) DeepCopy() *RemoveTag { + if in == nil { + return nil + } + out := new(RemoveTag) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in RemoveTags) DeepCopyInto(out *RemoveTags) { + { + in := &in + *out = make(RemoveTags, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new RemoveTags. +func (in RemoveTags) DeepCopy() RemoveTags { + if in == nil { + return nil + } + out := new(RemoveTags) + in.DeepCopyInto(out) + return *out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in Tags) DeepCopyInto(out *Tags) { { diff --git a/package/input/tag-manager.fn.crossplane.io_managedtags.yaml b/package/input/tag-manager.fn.crossplane.io_managedtags.yaml index 5d2ec3e..ea43967 100644 --- a/package/input/tag-manager.fn.crossplane.io_managedtags.yaml +++ b/package/input/tag-manager.fn.crossplane.io_managedtags.yaml @@ -65,9 +65,11 @@ spec: type: string ignoreTags: description: |- - IgnoreTags is a map of tag keys to ignore if set on the + IgnoreTags is a list of tag keys to ignore if set on the resource outside of Crossplane items: + description: IgnoreTag is a tag that is "ignored" by setting the desired + value to the observed value. properties: fromFieldPath: description: |- @@ -88,7 +90,7 @@ spec: type: string type: description: |- - Type determines where tag keysare sourced from. FromValue are inline + Type determines where tag keys are sourced from. FromValue are inline to the composition. FromCompositeFieldPath fetches keys from a field in the composite resource enum: @@ -109,6 +111,34 @@ spec: type: string metadata: type: object + removeTags: + description: IgnoreTags is a list of tag keys to remove from the resource + items: + description: RemoveTag is a tag that removed from the desired state. + properties: + fromFieldPath: + description: |- + FromFieldPath if type is FromCompositeFieldPath, get keys to remove + from the field in the Composite (like spec.parameters.removeTags) + type: string + keys: + description: Keys are tag keys to ignore for the FromValue type + items: + type: string + type: array + type: + description: |- + Type determines where tag keys are sourced from. FromValue are inline + to the composition. FromCompositeFieldPath fetches keys from a field in + the composite resource + enum: + - FromCompositeFieldPath + - FromValue + type: string + required: + - type + type: object + type: array type: object served: true storage: true diff --git a/tags.go b/tags.go index 82ab4ab..153bfa5 100644 --- a/tags.go +++ b/tags.go @@ -107,3 +107,40 @@ func (f *Function) ResolveIgnoreTags(in []v1beta1.IgnoreTag, oxr *resource.Compo } return tu } + +// ResolveRemoveTag resolves the list of tag keys that will be removed +func (f *Function) ResolveRemoveTags(in []v1beta1.RemoveTag, oxr *resource.Composite) []string { + tagKeys := make([]string, 0) + for _, at := range in { + switch t := at.GetType(); t { + case v1beta1.FromValue: + tagKeys = append(tagKeys, at.Keys...) + case v1beta1.FromCompositeFieldPath: // resolve fields + tags := make([]string, 0) + err := fieldpath.Pave(oxr.Resource.Object).GetValueInto(*at.FromFieldPath, &tags) + if err != nil { + f.log.Debug("Unable to read tags from Composite field: ", *at.FromFieldPath, err) + } + tagKeys = append(tagKeys, tags...) + } + } + return tagKeys +} + +// RemoveTags removes tags from a desired composed resource based +// on matching keys +func RemoveTags(desired *resource.DesiredComposed, keys []string) error { + if len(keys) == 0 { + return nil + } + var desiredTags v1beta1.Tags + _ = fieldpath.Pave(desired.Resource.Object).GetValueInto("spec.forProvider.tags", &desiredTags) + numTags := len(desiredTags) + for _, key := range keys { + delete(desiredTags, key) + } + if numTags > 0 { + return desired.Resource.SetValue("spec.forProvider.tags", desiredTags) + } + return nil +} diff --git a/tags_test.go b/tags_test.go index db4d9ee..c88e445 100644 --- a/tags_test.go +++ b/tags_test.go @@ -358,3 +358,278 @@ func TestResolveIgnoreTags(t *testing.T) { }) } } + +func TestResolveRemoveTags(t *testing.T) { + fieldPath := "spec.removeTags" + type args struct { + in []v1beta1.RemoveTag + oxr *resource.Composite + } + type want struct { + keys []string + } + cases := map[string]struct { + reason string + args args + want want + }{ + "EmptyInput": { + reason: "With no input should return an empty TagUpdater", + args: args{ + in: []v1beta1.RemoveTag{}, + }, + want: want{ + keys: []string{}, + }, + }, + "SimpleFromValue": { + reason: "Keys should be populated correctly from simple values", + args: args{ + in: []v1beta1.RemoveTag{ + {Type: v1beta1.FromValue, + Keys: []string{ + "key1", + "key2", + }, + }, + {Type: v1beta1.FromValue, + Keys: []string{ + "key3", + "key4", + }, + }, + }, + }, + want: want{ + keys: []string{"key1", "key2", "key3", "key4"}, + }, + }, + "ValuesFromComposite": { + reason: "Test getting keys from XR field Path", + args: args{ + in: []v1beta1.RemoveTag{ + {Type: v1beta1.FromCompositeFieldPath, + FromFieldPath: &fieldPath, + }, + {Type: v1beta1.FromValue, + Keys: []string{ + "key1", + "key2", + }, + }, + }, + oxr: &resource.Composite{ + Resource: &composite.Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.crossplane.io/v1", + "kind": "XR", + "metadata": map[string]any{ + "name": "test-resource", + "labels": map[string]any{ + IgnoreResourceLabel: "False", + }, + }, + "spec": map[string]any{ + "removeTags": []string{ + "fromXR1", + "fromXR2", + }, + }, + }}}, + }, + }, + want: want{ + keys: []string{"fromXR1", "fromXR2", "key1", "key2"}, + }, + }, + } + f := &Function{log: logging.NewNopLogger()} + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := f.ResolveRemoveTags(tc.args.in, tc.args.oxr) + + if diff := cmp.Diff(tc.want.keys, got); diff != "" { + t.Errorf("%s\nfResolveRemoveTags(): -want err, +got err:\n%s", tc.reason, diff) + } + }) + } +} + +func TestRemoveTags(t *testing.T) { + type args struct { + desired *resource.DesiredComposed + keys []string + } + type want struct { + desired *resource.DesiredComposed + err error + } + cases := map[string]struct { + reason string + args args + want want + }{ + "ResourceNoTags": { + reason: "A resource with no tags should be a no-op", + args: args{ + desired: &resource.DesiredComposed{ + Resource: &composed.Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.crossplane.io/v1", + "kind": "TagManager", + "metadata": map[string]any{ + "name": "test-resource", + "labels": map[string]any{ + IgnoreResourceLabel: "False", + }, + }, + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "eu-south", + }, + }, + }, + }, + }, + }, + keys: []string{"key1", "key2"}, + }, + want: want{ + desired: &resource.DesiredComposed{ + Resource: &composed.Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.crossplane.io/v1", + "kind": "TagManager", + "metadata": map[string]any{ + "name": "test-resource", + "labels": map[string]any{ + IgnoreResourceLabel: "False", + }, + }, + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "eu-south", + }, + }, + }, + }}, + }, + err: nil, + }, + }, + "RemoveAllTags": { + reason: "Remove all tags correctly", + args: args{ + desired: &resource.DesiredComposed{ + Resource: &composed.Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.crossplane.io/v1", + "kind": "TagManager", + "metadata": map[string]any{ + "name": "test-resource", + "labels": map[string]any{ + IgnoreResourceLabel: "False", + }, + }, + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "eu-south", + "tags": map[string]any{ + "key1": "value1", + "key2": "value2", + }, + }, + }, + }, + }, + }, + }, + keys: []string{"key1", "key2"}, + }, + want: want{ + desired: &resource.DesiredComposed{ + Resource: &composed.Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.crossplane.io/v1", + "kind": "TagManager", + "metadata": map[string]any{ + "name": "test-resource", + "labels": map[string]any{ + IgnoreResourceLabel: "False", + }, + }, + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "eu-south", + "tags": map[string]any{}, + }, + }, + }, + }}, + }, + err: nil, + }, + }, + "RemoveSomeTags": { + reason: "Remove all tags correctly", + args: args{ + desired: &resource.DesiredComposed{ + Resource: &composed.Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.crossplane.io/v1", + "kind": "TagManager", + "metadata": map[string]any{ + "name": "test-resource", + "labels": map[string]any{ + IgnoreResourceLabel: "False", + }, + }, + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "eu-south", + "tags": map[string]any{ + "key1": "value1", + "key2": "value2", + "key3": "keep", + }, + }, + }, + }, + }, + }, + }, + keys: []string{"key1", "key2"}, + }, + want: want{ + desired: &resource.DesiredComposed{ + Resource: &composed.Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.crossplane.io/v1", + "kind": "TagManager", + "metadata": map[string]any{ + "name": "test-resource", + "labels": map[string]any{ + IgnoreResourceLabel: "False", + }, + }, + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "eu-south", + "tags": map[string]any{ + "key3": "keep", + }, + }, + }, + }, + }}, + }, + err: nil, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := RemoveTags(tc.args.desired, tc.args.keys) + + if diff := cmp.Diff(tc.want.desired, tc.args.desired); diff != "" { + t.Errorf("%s\nfAddTags(): -want err, +got err:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) + } + }) + } +}