diff --git a/aws/internal/service/autoscaling/resourcetags/resource_tags.go b/aws/internal/service/autoscaling/resourcetags/resource_tags.go new file mode 100644 index 00000000000..cf9882beb3d --- /dev/null +++ b/aws/internal/service/autoscaling/resourcetags/resource_tags.go @@ -0,0 +1,273 @@ +package resourcetags + +import ( + "fmt" + "strconv" + "strings" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/autoscaling" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/keyvaluetags" +) + +type TagValue struct { + PropagateAtLaunch bool + Value string +} + +// ResourceTags is a standard implementation for AWS Auto Scaling resource tags. +type ResourceTags map[string]TagValue + +// AutoscalingTags returns autoscaling service tags. +func (tags ResourceTags) AutoscalingTags(identifier string) []*autoscaling.Tag { + result := make([]*autoscaling.Tag, 0, len(tags)) + + for k, v := range tags { + tag := &autoscaling.Tag{ + Key: aws.String(k), + PropagateAtLaunch: aws.Bool(v.PropagateAtLaunch), + ResourceId: aws.String(identifier), + ResourceType: aws.String("auto-scaling-group"), + Value: aws.String(v.Value), + } + + result = append(result, tag) + } + + return result +} + +// AutoscalingResourceTags creates ResourceTags from autoscaling service tags. +func AutoscalingResourceTags(tags []*autoscaling.TagDescription) (ResourceTags, error) { + m := make(map[string]TagValue, len(tags)) + + for _, tag := range tags { + m[aws.StringValue(tag.Key)] = TagValue{ + PropagateAtLaunch: aws.BoolValue(tag.PropagateAtLaunch), + Value: aws.StringValue(tag.Value), + } + } + + return New(m) +} + +// AutoscalingUpdateTags updates autoscaling service tags. +func AutoscalingUpdateTags(conn *autoscaling.AutoScaling, identifier string, oldTagsMap interface{}, newTagsMap interface{}) error { + oldTags, err := New(oldTagsMap) + if err != nil { + return err + } + newTags, err := New(newTagsMap) + if err != nil { + return err + } + + if removedTags := oldTags.Removed(newTags); len(removedTags) > 0 { + input := &autoscaling.DeleteTagsInput{ + Tags: removedTags.IgnoreAws().AutoscalingTags(identifier), + } + + _, err := conn.DeleteTags(input) + + if err != nil { + return fmt.Errorf("error untagging resource (%s): %w", identifier, err) + } + } + + if updatedTags := oldTags.Updated(newTags); len(updatedTags) > 0 { + input := &autoscaling.CreateOrUpdateTagsInput{ + Tags: updatedTags.IgnoreAws().AutoscalingTags(identifier), + } + + _, err := conn.CreateOrUpdateTags(input) + + if err != nil { + return fmt.Errorf("error tagging resource (%s): %w", identifier, err) + } + } + + return nil +} + +// IgnoreAws returns non-AWS tag keys. +func (tags ResourceTags) IgnoreAws() ResourceTags { + result := make(ResourceTags, len(tags)) + + for k, v := range tags { + if !strings.HasPrefix(k, keyvaluetags.AwsTagKeyPrefix) { + result[k] = v + } + } + + return result +} + +// Keys returns tag keys. +func (tags ResourceTags) Keys() []string { + result := make([]string, 0, len(tags)) + + for k := range tags { + result = append(result, k) + } + + return result +} + +// List returns tags as described by ListSchema(). +func (tags ResourceTags) List() []interface{} { + result := make([]interface{}, 0, len(tags)) + + for k, v := range tags { + result = append(result, map[string]interface{}{ + "key": k, + "value": v.Value, + // For the list representation, all map values are strings. + "propagate_at_launch": strconv.FormatBool(v.PropagateAtLaunch), + }) + } + + return result +} + +// Set returns tags as described by SetSchema(). +func (tags ResourceTags) Set() []interface{} { + result := make([]interface{}, 0, len(tags)) + + for k, v := range tags { + result = append(result, map[string]interface{}{ + "key": k, + "value": v.Value, + "propagate_at_launch": v.PropagateAtLaunch, + }) + } + + return result +} + +// Removed returns tags removed. +func (tags ResourceTags) Removed(newTags ResourceTags) ResourceTags { + result := make(ResourceTags) + + for k, v := range tags { + if _, ok := newTags[k]; !ok { + result[k] = v + } + } + + return result +} + +// Updated returns tags added and updated. +func (tags ResourceTags) Updated(newTags ResourceTags) ResourceTags { + result := make(ResourceTags) + + for k, newV := range newTags { + if oldV, ok := tags[k]; !ok || oldV.PropagateAtLaunch != newV.PropagateAtLaunch || oldV.Value != newV.Value { + result[k] = newV + } + } + + return result +} + +// New creates ResourceTags from common Terraform Provider SDK types. +func New(i interface{}) (ResourceTags, error) { + switch values := i.(type) { + case map[string]TagValue: + return ResourceTags(values), nil + case []interface{}: + // The list of tags described by ListSchema(). + tags := make(ResourceTags, len(values)) + + for _, value := range values { + m := value.(map[string]interface{}) + + key, ok := m["key"].(string) + if !ok || key == "" { + return nil, fmt.Errorf("missing Auto Scaling tag key") + } + + if _, ok := tags[key]; ok { + // https://github.com/terraform-providers/terraform-provider-aws/issues/6375. + return nil, fmt.Errorf("duplicate Auto Scaling tag key (%s)", key) + } + + value, ok := m["value"].(string) + if !ok { + return nil, fmt.Errorf("invalid tag value for Auto Scaling tag key (%s)", key) + } + + v, ok := m["propagate_at_launch"] + if !ok { + return nil, fmt.Errorf("missing propagate_at_launch value for Auto Scaling tag key (%s)", key) + } + + var propagateAtLaunch bool + var err error + + switch v := v.(type) { + case bool: + propagateAtLaunch = v + case string: + if propagateAtLaunch, err = strconv.ParseBool(v); err != nil { + return nil, fmt.Errorf("invalid propagate_at_launch value for Auto Scaling tag key (%s): %w", key, err) + } + default: + return nil, fmt.Errorf("invalid propagate_at_launch type (%T) for Auto Scaling tag key (%s)", v, key) + } + + tags[key] = TagValue{ + PropagateAtLaunch: propagateAtLaunch, + Value: value, + } + } + + return tags, nil + case *schema.Set: + // The set of tags described by SetSchema(). + return New(values.List()) + default: + return nil, fmt.Errorf("invalid Auto Scaling tags type: %T", values) + } +} + +// ListSchema returns a *schema.Schema that represents a list of Auto Scaling resource tags. +// It is conventional for an attribute of this type to be included as a top-level attribute called "tags". +func ListSchema() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeList, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeMap, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + } +} + +// SetSchema returns a *schema.Schema that represents a set of Auto Scaling resource tags. +// It is conventional for an attribute of this type to be included as a top-level attribute called "tag". +func SetSchema() *schema.Schema { + return &schema.Schema{ + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "key": { + Type: schema.TypeString, + Required: true, + }, + + "value": { + Type: schema.TypeString, + Required: true, + }, + + "propagate_at_launch": { + Type: schema.TypeBool, + Required: true, + }, + }, + }, + } +} diff --git a/aws/internal/service/autoscaling/resourcetags/resource_tags_test.go b/aws/internal/service/autoscaling/resourcetags/resource_tags_test.go new file mode 100644 index 00000000000..bed0a63ea42 --- /dev/null +++ b/aws/internal/service/autoscaling/resourcetags/resource_tags_test.go @@ -0,0 +1,642 @@ +package resourcetags + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/hashcode" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" +) + +func TestResourceTagsNew(t *testing.T) { + testCases := []struct { + name string + data interface{} + fail bool + want []string + }{ + { + name: "empty_list", + data: []interface{}{}, + want: []string{}, + }, + { + name: "empty_set", + data: schema.NewSet(testResourceTagsHashSet, []interface{}{}), + want: []string{}, + }, + { + name: "invalid_type", + data: 42, + fail: true, + }, + { + name: "list_with_empty_map", + data: []interface{}{map[string]interface{}{}}, + fail: true, + }, + { + name: "set_with_empty_map", + data: schema.NewSet(testResourceTagsHashSet, []interface{}{map[string]interface{}{}}), + fail: true, + }, + { + name: "single_list", + data: []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + }, + want: []string{"key1"}, + }, + { + name: "single_set", + data: schema.NewSet(testResourceTagsHashSet, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": "true", + }, + }), + want: []string{"key1"}, + }, + { + name: "multi_list", + data: []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }, + want: []string{"key1", "key2", "key3"}, + }, + { + name: "multi_set", + data: schema.NewSet(testResourceTagsHashSet, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": "true", + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": "false", + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": "true", + }, + }), + want: []string{"key1", "key2", "key3"}, + }, + { + name: "missing_key", + data: []interface{}{ + map[string]interface{}{ + "value": "value1", + "propagate_at_launch": true, + }, + }, + fail: true, + }, + { + name: "missing_value", + data: []interface{}{ + map[string]interface{}{ + "key": "key1", + "propagate_at_launch": true, + }, + }, + fail: true, + }, + { + name: "missing_propagate_at_launch", + data: []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + }, + }, + fail: true, + }, + { + name: "empty_key", + data: []interface{}{ + map[string]interface{}{ + "key": "", + "value": "value1", + "propagate_at_launch": true, + }, + }, + fail: true, + }, + { + name: "empty_value", + data: []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "", + "propagate_at_launch": true, + }, + }, + want: []string{"key1"}, + }, + { + name: "invalid_key_type", + data: []interface{}{ + map[string]interface{}{ + "key": 42, + "value": "value1", + "propagate_at_launch": true, + }, + }, + fail: true, + }, + { + name: "invalid_value_type", + data: []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": 42, + "propagate_at_launch": true, + }, + }, + fail: true, + }, + { + name: "invalid_propagate_at_launch_type", + data: []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": 42, + }, + }, + fail: true, + }, + { + name: "invalid_propagate_at_launch_boolean", + data: []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": "nein", + }, + }, + fail: true, + }, + { + name: "duplicate_key", + data: []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1a", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key1", + "value": "value1b", + "propagate_at_launch": true, + }, + }, + fail: true, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + got, err := New(testCase.data) + if err != nil { + if !testCase.fail { + t.Errorf("unexpected failure: %s", err) + } + return + } + if testCase.fail { + t.Errorf("unexpected success") + } + + testResourceTagsVerifyKeys(t, got.Keys(), testCase.want) + }) + } +} + +func TestResourceTagsIgnoreAws(t *testing.T) { + testCases := []struct { + name string + tags ResourceTags + want []string + }{ + { + name: "empty", + tags: testResourceTagsNew(t, []interface{}{}), + want: []string{}, + }, + { + name: "all", + tags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "aws:cloudformation:key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "aws:cloudformation:key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "aws:cloudformation:key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + want: []string{}, + }, + { + name: "mixed", + tags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "aws:cloudformation:key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + want: []string{"key2", "key3"}, + }, + { + name: "all", + tags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + want: []string{"key1", "key2", "key3"}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + got := testCase.tags.IgnoreAws() + + testResourceTagsVerifyKeys(t, got.Keys(), testCase.want) + }) + } +} + +func TestResourceTagsRemoved(t *testing.T) { + testCases := []struct { + name string + oldTags ResourceTags + newTags ResourceTags + want []string + }{ + { + name: "empty", + oldTags: testResourceTagsNew(t, []interface{}{}), + newTags: testResourceTagsNew(t, []interface{}{}), + want: []string{}, + }, + { + name: "all_new", + oldTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + newTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key4", + "value": "value4", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key5", + "value": "value5", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key6", + "value": "value6", + "propagate_at_launch": true, + }, + }), + want: []string{"key1", "key2", "key3"}, + }, + { + name: "mixed", + oldTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + newTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + }), + want: []string{"key2", "key3"}, + }, + { + name: "no_changes", + oldTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + newTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + want: []string{}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + got := testCase.oldTags.Removed(testCase.newTags) + + testResourceTagsVerifyKeys(t, got.Keys(), testCase.want) + }) + } +} + +func TestResourceTagsUpdated(t *testing.T) { + testCases := []struct { + name string + oldTags ResourceTags + newTags ResourceTags + want []string + }{ + { + name: "empty", + oldTags: testResourceTagsNew(t, []interface{}{}), + newTags: testResourceTagsNew(t, []interface{}{}), + want: []string{}, + }, + { + name: "all_new", + oldTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + newTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key4", + "value": "value4", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key5", + "value": "value5", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key6", + "value": "value6", + "propagate_at_launch": true, + }, + }), + want: []string{"key4", "key5", "key6"}, + }, + { + name: "mixed", + oldTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + newTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1updated", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key4", + "value": "value4", + "propagate_at_launch": false, + }, + }), + want: []string{"key1", "key3", "key4"}, + }, + { + name: "no_changes", + oldTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + newTags: testResourceTagsNew(t, []interface{}{ + map[string]interface{}{ + "key": "key1", + "value": "value1", + "propagate_at_launch": true, + }, + map[string]interface{}{ + "key": "key2", + "value": "value2", + "propagate_at_launch": false, + }, + map[string]interface{}{ + "key": "key3", + "value": "value3", + "propagate_at_launch": true, + }, + }), + want: []string{}, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + got := testCase.oldTags.Updated(testCase.newTags) + + testResourceTagsVerifyKeys(t, got.Keys(), testCase.want) + }) + } +} + +func testResourceTagsNew(t *testing.T, i interface{}) ResourceTags { + tags, err := New(i) + if err != nil { + t.Errorf("%w", err) + } + + return tags +} + +func testResourceTagsVerifyKeys(t *testing.T, got []string, want []string) { + for _, g := range got { + found := false + + for _, w := range want { + if w == g { + found = true + break + } + } + + if !found { + t.Errorf("got extra key: %s", g) + } + } + + for _, w := range want { + found := false + + for _, g := range got { + if g == w { + found = true + break + } + } + + if !found { + t.Errorf("want missing key: %s", w) + } + } +} + +func testResourceTagsHashSet(v interface{}) int { + m := v.(map[string]interface{}) + key, ok := m["key"].(string) + if !ok { + return 0 + } + return hashcode.String(key) +}