diff --git a/pkg/analyser/analyzer.go b/pkg/analyser/analyzer.go index 79c71b1d..15c37df2 100644 --- a/pkg/analyser/analyzer.go +++ b/pkg/analyser/analyzer.go @@ -29,7 +29,7 @@ func NewComputedDiffAlert() *ComputedDiffAlert { } func (c *ComputedDiffAlert) Message() string { - return "You have diffs on computed fields, check the documentation for potential false positive drifts" + return "You have diffs on computed fields, check the documentation for potential false positive drifts: https://docs.driftctl.com/limitations" } func (c *ComputedDiffAlert) ShouldIgnoreResource() bool { diff --git a/pkg/cmd/scan.go b/pkg/cmd/scan.go index 2125f88b..8e49137f 100644 --- a/pkg/cmd/scan.go +++ b/pkg/cmd/scan.go @@ -157,6 +157,11 @@ func NewScanCmd() *cobra.Command { false, "Includes cloud provider service-linked roles (disabled by default)", ) + fl.StringVar(&opts.DriftignorePath, + "driftignore", + ".driftignore", + "Path to the driftignore file", + ) configDir, err := homedir.Dir() if err != nil { diff --git a/pkg/cmd/scan/output/testdata/output_computed_fields.json b/pkg/cmd/scan/output/testdata/output_computed_fields.json index 8f8f88f2..27c04921 100644 --- a/pkg/cmd/scan/output/testdata/output_computed_fields.json +++ b/pkg/cmd/scan/output/testdata/output_computed_fields.json @@ -80,7 +80,7 @@ "alerts": { "": [ { - "message": "You have diffs on computed fields, check the documentation for potential false positive drifts" + "message": "You have diffs on computed fields, check the documentation for potential false positive drifts: https://docs.driftctl.com/limitations" } ] } diff --git a/pkg/cmd/scan/output/testdata/output_computed_fields.txt b/pkg/cmd/scan/output/testdata/output_computed_fields.txt index 458b8e4e..700c48bd 100644 --- a/pkg/cmd/scan/output/testdata/output_computed_fields.txt +++ b/pkg/cmd/scan/output/testdata/output_computed_fields.txt @@ -11,4 +11,4 @@ Found 1 resource(s) - 0 not covered by IaC - 0 missing on cloud provider - 1/1 changed outside of IaC -You have diffs on computed fields, check the documentation for potential false positive drifts +You have diffs on computed fields, check the documentation for potential false positive drifts: https://docs.driftctl.com/limitations diff --git a/pkg/cmd/scan_test.go b/pkg/cmd/scan_test.go index f21935f2..591f2a85 100644 --- a/pkg/cmd/scan_test.go +++ b/pkg/cmd/scan_test.go @@ -45,6 +45,8 @@ func TestScanCmd_Valid(t *testing.T) { {args: []string{"scan", "--strict"}}, {args: []string{"scan", "--tf-provider-version", "1.2.3"}}, {args: []string{"scan", "--tf-provider-version", "3.30.2"}}, + {args: []string{"scan", "--driftignore", "./path/to/driftignore.s3"}}, + {args: []string{"scan", "--driftignore", ".driftignore"}}, } for _, tt := range cases { @@ -85,6 +87,7 @@ func TestScanCmd_Invalid(t *testing.T) { {args: []string{"scan", "--filter", "Type='test'", "--filter", "Type='test2'"}, expected: "Filter flag should be specified only once"}, {args: []string{"scan", "--tf-provider-version", ".30.2"}, expected: "Invalid version argument .30.2, expected a valid semver string (e.g. 2.13.4)"}, {args: []string{"scan", "--tf-provider-version", "foo"}, expected: "Invalid version argument foo, expected a valid semver string (e.g. 2.13.4)"}, + {args: []string{"scan", "--driftignore"}, expected: "flag needs an argument: --driftignore"}, } for _, tt := range cases { diff --git a/pkg/driftctl.go b/pkg/driftctl.go index a45f2e9f..7c688c20 100644 --- a/pkg/driftctl.go +++ b/pkg/driftctl.go @@ -31,6 +31,7 @@ type ScanOptions struct { DisableTelemetry bool ProviderVersion string ConfigDir string + DriftignorePath string } type DriftCTL struct { @@ -38,12 +39,11 @@ type DriftCTL struct { iacSupplier resource.Supplier alerter alerter.AlerterInterface analyzer analyser.Analyzer - filter *jmespath.JMESPath resourceFactory resource.ResourceFactory - strictMode bool scanProgress globaloutput.Progress iacProgress globaloutput.Progress resourceSchemaRepository resource.SchemaRepositoryInterface + opts *ScanOptions } func NewDriftCTL(remoteSupplier resource.Supplier, @@ -59,12 +59,11 @@ func NewDriftCTL(remoteSupplier resource.Supplier, iacSupplier, alerter, analyser.NewAnalyzer(alerter), - opts.Filter, resFactory, - opts.StrictMode, scanProgress, iacProgress, resourceSchemaRepository, + opts, } } @@ -98,9 +97,10 @@ func (d DriftCTL) Run() (*analyser.Analysis, error) { middlewares.NewAwsDefaultSqsQueuePolicy(), middlewares.NewAwsSNSTopicPolicyExpander(d.resourceFactory, d.resourceSchemaRepository), middlewares.NewAwsRoleManagedPolicyExpander(d.resourceFactory), + middlewares.NewTagsAllManager(), ) - if !d.strictMode { + if !d.opts.StrictMode { middleware = append(middleware, middlewares.NewAwsDefaults(), ) @@ -112,8 +112,8 @@ func (d DriftCTL) Run() (*analyser.Analysis, error) { return nil, err } - if d.filter != nil { - engine := filter.NewFilterEngine(d.filter) + if d.opts.Filter != nil { + engine := filter.NewFilterEngine(d.opts.Filter) remoteResources, err = engine.Run(remoteResources) if err != nil { return nil, err @@ -125,7 +125,7 @@ func (d DriftCTL) Run() (*analyser.Analysis, error) { } logrus.Debug("Checking for driftignore") - driftIgnore := filter.NewDriftIgnore() + driftIgnore := filter.NewDriftIgnore(d.opts.DriftignorePath) analysis, err := d.analyzer.Analyze(remoteResources, resourcesFromState, driftIgnore) if err != nil { diff --git a/pkg/driftctl_test.go b/pkg/driftctl_test.go index a2509550..2c8f1b95 100644 --- a/pkg/driftctl_test.go +++ b/pkg/driftctl_test.go @@ -1559,6 +1559,14 @@ func TestDriftctlRun_Middlewares(t *testing.T) { }, }, }, + &resource.AbstractResource{ + Id: "role_with_managed_policy_attr-arn2", + Type: aws.AwsIamPolicyAttachmentResourceType, + Attrs: &resource.Attributes{ + "policy_arn": "arn2", + "roles": []interface{}{"role_with_managed_policy_attr"}, + }, + }, &resource.AbstractResource{ Id: "role_with_empty_managed_policy_attribute", Type: aws.AwsIamRoleResourceType, diff --git a/pkg/filter/driftignore.go b/pkg/filter/driftignore.go index a0f3e28f..4a969f92 100644 --- a/pkg/filter/driftignore.go +++ b/pkg/filter/driftignore.go @@ -15,13 +15,15 @@ type DriftIgnore struct { resExclusionList map[string]struct{} // map[type.id] exists to ignore resExclusionWildcardList map[string]struct{} // map[type.id] exists with wildcard to ignore driftExclusionList map[string][]string // map[type.id] contains path for drift to ignore + driftignorePath string } -func NewDriftIgnore() *DriftIgnore { +func NewDriftIgnore(path string) *DriftIgnore { d := DriftIgnore{ resExclusionList: map[string]struct{}{}, resExclusionWildcardList: map[string]struct{}{}, driftExclusionList: map[string][]string{}, + driftignorePath: path, } err := d.readIgnoreFile() if err != nil { @@ -31,7 +33,7 @@ func NewDriftIgnore() *DriftIgnore { } func (r *DriftIgnore) readIgnoreFile() error { - file, err := os.Open(".driftignore") + file, err := os.Open(r.driftignorePath) if err != nil { return err } diff --git a/pkg/filter/driftignore_test.go b/pkg/filter/driftignore_test.go index 9af64710..28b0e4fd 100644 --- a/pkg/filter/driftignore_test.go +++ b/pkg/filter/driftignore_test.go @@ -2,7 +2,6 @@ package filter import ( "os" - "path" "reflect" "strings" "testing" @@ -19,6 +18,7 @@ func TestDriftIgnore_IsResourceIgnored(t *testing.T) { name string resources []resource.Resource want []bool + path string }{ { name: "drift_ignore_no_file", @@ -28,10 +28,10 @@ func TestDriftIgnore_IsResourceIgnored(t *testing.T) { Id: "id1", }, }, - want: []bool{ false, }, + path: "testdata/drift_ignore_no_file/.driftignore", }, { name: "drift_ignore_empty", @@ -44,6 +44,7 @@ func TestDriftIgnore_IsResourceIgnored(t *testing.T) { want: []bool{ false, }, + path: "testdata/drift_ignore_empty/.driftignore", }, { name: "drift_ignore_invalid_lines", @@ -61,6 +62,7 @@ func TestDriftIgnore_IsResourceIgnored(t *testing.T) { false, true, }, + path: "testdata/drift_ignore_invalid_lines/.driftignore", }, { name: "drift_ignore_valid", @@ -108,6 +110,7 @@ func TestDriftIgnore_IsResourceIgnored(t *testing.T) { true, true, }, + path: "testdata/drift_ignore_valid/.driftignore", }, { name: "drift_ignore_wildcard", @@ -150,16 +153,15 @@ func TestDriftIgnore_IsResourceIgnored(t *testing.T) { false, true, }, + path: "testdata/drift_ignore_wildcard/.driftignore", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cwd, _ := os.Getwd() defer func() { _ = os.Chdir(cwd) }() - if err := os.Chdir(path.Join("testdata", tt.name)); err != nil { - t.Fatal(err) - } - r := NewDriftIgnore() + + r := NewDriftIgnore(tt.path) got := make([]bool, 0, len(tt.want)) for _, res := range tt.resources { got = append(got, r.IsResourceIgnored(res)) @@ -180,6 +182,7 @@ func TestDriftIgnore_IsFieldIgnored(t *testing.T) { tests := []struct { name string args []Args + path string }{ { name: "drift_ignore_no_file", @@ -196,6 +199,7 @@ func TestDriftIgnore_IsFieldIgnored(t *testing.T) { Want: false, }, }, + path: "testdata/drift_ignore_no_file/.driftignore", }, { name: "drift_ignore_empty", @@ -211,6 +215,7 @@ func TestDriftIgnore_IsFieldIgnored(t *testing.T) { Want: false, }, }, + path: "testdata/drift_ignore_empty/.driftignore", }, { name: "drift_ignore_fields", @@ -281,16 +286,15 @@ func TestDriftIgnore_IsFieldIgnored(t *testing.T) { Want: true, }, }, + path: "testdata/drift_ignore_fields/.driftignore", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cwd, _ := os.Getwd() defer func() { _ = os.Chdir(cwd) }() - if err := os.Chdir(path.Join("testdata", tt.name)); err != nil { - t.Fatal(err) - } - r := NewDriftIgnore() + + r := NewDriftIgnore(tt.path) for _, arg := range tt.args { got := r.IsFieldIgnored(arg.Res, arg.Path) if arg.Want != got { diff --git a/pkg/middlewares/aws_instance_block_device.go b/pkg/middlewares/aws_instance_block_device.go index a0006dbd..4eed3889 100644 --- a/pkg/middlewares/aws_instance_block_device.go +++ b/pkg/middlewares/aws_instance_block_device.go @@ -47,7 +47,10 @@ func (a AwsInstanceBlockDeviceResourceMapper) Execute(remoteResources, resources "size": rootBlock["volume_size"], "type": rootBlock["volume_type"], "multi_attach_enabled": false, - "tags": (*instance.Attrs)["volume_tags"], + "tags": a.volumeTags(instance, rootBlock), + } + if throughput, exist := rootBlock["throughput"]; exist { + data["throughput"] = throughput } newRes := a.resourceFactory.CreateAbstractResource("aws_ebs_volume", rootBlock["volume_id"].(string), data) newStateResources = append(newStateResources, newRes) @@ -72,7 +75,10 @@ func (a AwsInstanceBlockDeviceResourceMapper) Execute(remoteResources, resources "size": blockDevice["volume_size"], "type": blockDevice["volume_type"], "multi_attach_enabled": false, - "tags": (*instance.Attrs)["volume_tags"], + "tags": a.volumeTags(instance, blockDevice), + } + if throughput, exist := blockDevice["throughput"]; exist { + data["throughput"] = throughput } newRes := a.resourceFactory.CreateAbstractResource("aws_ebs_volume", blockDevice["volume_id"].(string), data) newStateResources = append(newStateResources, newRes) @@ -101,3 +107,10 @@ func (a AwsInstanceBlockDeviceResourceMapper) Execute(remoteResources, resources return nil } + +func (a AwsInstanceBlockDeviceResourceMapper) volumeTags(instance *resource.AbstractResource, blockDevice map[string]interface{}) interface{} { + if tags, exist := instance.Attrs.Get("volume_tags"); exist { + return tags + } + return blockDevice["tags"] +} diff --git a/pkg/middlewares/aws_instance_block_device_test.go b/pkg/middlewares/aws_instance_block_device_test.go index 8e947fdc..5c167520 100644 --- a/pkg/middlewares/aws_instance_block_device_test.go +++ b/pkg/middlewares/aws_instance_block_device_test.go @@ -48,6 +48,7 @@ func TestAwsInstanceBlockDeviceResourceMapper_Execute(t *testing.T) { "kms_key_id": "kms", "size": 8, "type": "gp2", + "throughput": 125, "tags": map[string]interface{}{ "Name": "rootVol", }, @@ -63,6 +64,7 @@ func TestAwsInstanceBlockDeviceResourceMapper_Execute(t *testing.T) { "availability_zone": "eu-west-3", "size": 23, "type": "gp2", + "throughput": 125, "tags": map[string]interface{}{ "Name": "rootVol", }, @@ -96,6 +98,7 @@ func TestAwsInstanceBlockDeviceResourceMapper_Execute(t *testing.T) { "encrypted": true, "kms_key_id": "kms", "volume_size": 8, + "throughput": 125, "iops": 1234, }, }, @@ -107,6 +110,7 @@ func TestAwsInstanceBlockDeviceResourceMapper_Execute(t *testing.T) { "encrypted": true, "delete_on_termination": true, "volume_size": 23, + "throughput": 125, }, }, }, @@ -126,6 +130,7 @@ func TestAwsInstanceBlockDeviceResourceMapper_Execute(t *testing.T) { "kms_key_id": "kms", "size": 8, "type": "gp2", + "throughput": 125, "tags": map[string]interface{}{ "Name": "rootVol", }, @@ -145,6 +150,7 @@ func TestAwsInstanceBlockDeviceResourceMapper_Execute(t *testing.T) { "availability_zone": "eu-west-3", "size": 23, "type": "gp2", + "throughput": 125, "tags": map[string]interface{}{ "Name": "rootVol", }, @@ -156,6 +162,139 @@ func TestAwsInstanceBlockDeviceResourceMapper_Execute(t *testing.T) { }, false, }, + { + "Test with tags inside root/ebs block device", + struct { + expectedResource *[]resource.Resource + resourcesFromState *[]resource.Resource + }{ + expectedResource: &[]resource.Resource{ + &resource.AbstractResource{ + Id: "dummy-instance", + Type: "aws_instance", + Attrs: &resource.Attributes{ + "availability_zone": "eu-west-3", + }, + }, + &resource.AbstractResource{ + Id: "vol-02862d9b39045a3a4", + Type: "aws_ebs_volume", + Attrs: &resource.Attributes{ + "id": "vol-02862d9b39045a3a4", + "encrypted": true, + "multi_attach_enabled": false, + "availability_zone": "eu-west-3", + "iops": 1234, + "kms_key_id": "kms", + "size": 8, + "type": "gp2", + "throughput": 125, + "tags": map[string]interface{}{ + "Name": "rootVol", + }, + }, + }, + &resource.AbstractResource{ + Id: "vol-018c5ae89895aca4c", + Type: "aws_ebs_volume", + Attrs: &resource.Attributes{ + "id": "vol-018c5ae89895aca4c", + "encrypted": true, + "multi_attach_enabled": false, + "availability_zone": "eu-west-3", + "size": 23, + "type": "gp2", + "throughput": 125, + "tags": map[string]interface{}{ + "Name": "ebsVol", + }, + }, + }, + }, + resourcesFromState: &[]resource.Resource{ + &resource.AbstractResource{ + Id: "dummy-instance", + Type: "aws_instance", + Attrs: &resource.Attributes{ + "availability_zone": "eu-west-3", + "root_block_device": []interface{}{ + map[string]interface{}{ + "volume_id": "vol-02862d9b39045a3a4", + "volume_type": "gp2", + "device_name": "/dev/sda1", + "encrypted": true, + "kms_key_id": "kms", + "volume_size": 8, + "throughput": 125, + "iops": 1234, + "tags": map[string]interface{}{ + "Name": "rootVol", + }, + }, + }, + "ebs_block_device": []interface{}{ + map[string]interface{}{ + "volume_id": "vol-018c5ae89895aca4c", + "volume_type": "gp2", + "device_name": "/dev/sdb", + "encrypted": true, + "delete_on_termination": true, + "volume_size": 23, + "throughput": 125, + "tags": map[string]interface{}{ + "Name": "ebsVol", + }, + }, + }, + }, + }, + }, + }, + func(factory *terraform.MockResourceFactory) { + foo := resource.AbstractResource{ + Id: "vol-02862d9b39045a3a4", + Type: "aws_ebs_volume", + Attrs: &resource.Attributes{ + "id": "vol-02862d9b39045a3a4", + "encrypted": true, + "multi_attach_enabled": false, + "availability_zone": "eu-west-3", + "iops": 1234, + "kms_key_id": "kms", + "size": 8, + "type": "gp2", + "throughput": 125, + "tags": map[string]interface{}{ + "Name": "rootVol", + }, + }, + } + factory.On("CreateAbstractResource", "aws_ebs_volume", mock.Anything, mock.MatchedBy(func(input map[string]interface{}) bool { + return input["id"] == "vol-02862d9b39045a3a4" && len(input["tags"].(map[string]interface{})) == 1 + })).Times(1).Return(&foo, nil) + + bar := resource.AbstractResource{ + Id: "vol-018c5ae89895aca4c", + Type: "aws_ebs_volume", + Attrs: &resource.Attributes{ + "id": "vol-018c5ae89895aca4c", + "encrypted": true, + "multi_attach_enabled": false, + "availability_zone": "eu-west-3", + "size": 23, + "type": "gp2", + "throughput": 125, + "tags": map[string]interface{}{ + "Name": "ebsVol", + }, + }, + } + factory.On("CreateAbstractResource", "aws_ebs_volume", mock.Anything, mock.MatchedBy(func(input map[string]interface{}) bool { + return input["id"] == "vol-018c5ae89895aca4c" && len(input["tags"].(map[string]interface{})) == 1 + })).Times(1).Return(&bar, nil) + }, + false, + }, } for _, c := range tests { t.Run(c.name, func(tt *testing.T) { diff --git a/pkg/middlewares/aws_role_managed_policy_expander.go b/pkg/middlewares/aws_role_managed_policy_expander.go index d63f7017..d763c469 100644 --- a/pkg/middlewares/aws_role_managed_policy_expander.go +++ b/pkg/middlewares/aws_role_managed_policy_expander.go @@ -61,6 +61,8 @@ func (a AwsRoleManagedPolicyExpander) Execute(remoteResources, resourcesFromStat for _, arn := range managedPolicyArns { arn := arn.(string) + id := fmt.Sprintf("%s-%s", *roleName, arn) + policyAttachmentData := resource.Attributes{ "policy_arn": arn, "users": []interface{}{}, @@ -73,7 +75,19 @@ func (a AwsRoleManagedPolicyExpander) Execute(remoteResources, resourcesFromStat "policy_arn": arn, }).Debug("Expanded managed_policy_arns from role") - newList = append(newList, a.resourceFactory.CreateAbstractResource(aws.AwsIamPolicyAttachmentResourceType, fmt.Sprintf("%s-%s", *roleName, arn), policyAttachmentData)) + newRes := a.resourceFactory.CreateAbstractResource(aws.AwsIamPolicyAttachmentResourceType, id, policyAttachmentData) + + alreadyExist := false + for _, resInState := range *resourcesFromState { + if resource.IsSameResource(resInState, newRes) { + alreadyExist = true + break + } + } + + if !alreadyExist { + newList = append(newList, newRes) + } } res.Attributes().SafeDelete([]string{"managed_policy_arns"}) diff --git a/pkg/middlewares/tags_all_manager.go b/pkg/middlewares/tags_all_manager.go new file mode 100644 index 00000000..d29bd378 --- /dev/null +++ b/pkg/middlewares/tags_all_manager.go @@ -0,0 +1,31 @@ +package middlewares + +import ( + "github.com/cloudskiff/driftctl/pkg/resource" +) + +// Manage tags_all attribute on each compatible resources +type TagsAllManager struct{} + +func NewTagsAllManager() TagsAllManager { + return TagsAllManager{} +} + +func (a TagsAllManager) Execute(remoteResources, resourcesFromState *[]resource.Resource) error { + for _, remoteRes := range *remoteResources { + if res, ok := remoteRes.(*resource.AbstractResource); ok { + if _, exist := res.Attrs.Get("tags_all"); exist { + res.Attrs.SafeDelete([]string{"tags_all"}) + } + } + } + for _, stateRes := range *resourcesFromState { + if res, ok := stateRes.(*resource.AbstractResource); ok { + if allTags, exist := res.Attrs.Get("tags_all"); exist { + _ = res.Attrs.SafeSet([]string{"tags"}, allTags) + res.Attrs.SafeDelete([]string{"tags_all"}) + } + } + } + return nil +} diff --git a/pkg/middlewares/tags_all_manager_test.go b/pkg/middlewares/tags_all_manager_test.go new file mode 100644 index 00000000..3233cfc3 --- /dev/null +++ b/pkg/middlewares/tags_all_manager_test.go @@ -0,0 +1,98 @@ +package middlewares + +import ( + "strings" + "testing" + + "github.com/aws/aws-sdk-go/aws/awsutil" + "github.com/cloudskiff/driftctl/pkg/resource" + "github.com/r3labs/diff/v2" +) + +func TestTagsAllManager_Execute(t *testing.T) { + tests := []struct { + name string + remoteResources *[]resource.Resource + resourcesFromState *[]resource.Resource + wantErr bool + }{ + { + name: "With multiple resources that are tags_all compatible", + remoteResources: &[]resource.Resource{ + &resource.AbstractResource{ + Id: "dummy-instance", + Type: "aws_instance", + Attrs: &resource.Attributes{ + "tags": map[string]interface{}{ + "Name": "toto", + "Terraform": "true", + }, + "tags_all": map[string]interface{}{ + "Name": "toto", + "Terraform": "true", + }, + }, + }, + &resource.AbstractResource{ + Id: "dummy-ebs-volume", + Type: "aws_ebs_volume", + Attrs: &resource.Attributes{ + "tags": map[string]interface{}{ + "Name": "tata", + "Terraform": "true", + }, + "tags_all": map[string]interface{}{ + "Name": "tata", + "Terraform": "true", + }, + }, + }, + }, + resourcesFromState: &[]resource.Resource{ + &resource.AbstractResource{ + Id: "dummy-instance", + Type: "aws_instance", + Attrs: &resource.Attributes{ + "tags": map[string]interface{}{ + "Name": "toto", + }, + "tags_all": map[string]interface{}{ + "Name": "toto", + "Terraform": "true", + }, + }, + }, + &resource.AbstractResource{ + Id: "dummy-ebs-volume", + Type: "aws_ebs_volume", + Attrs: &resource.Attributes{ + "tags": map[string]interface{}{ + "Name": "tata", + }, + "tags_all": map[string]interface{}{ + "Name": "tata", + "Terraform": "true", + }, + }, + }, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + a := NewTagsAllManager() + if err := a.Execute(tt.remoteResources, tt.resourcesFromState); (err != nil) != tt.wantErr { + t.Errorf("Execute() error = %v, wantErr %v", err, tt.wantErr) + } + changelog, err := diff.Diff(tt.resourcesFromState, tt.remoteResources) + if err != nil { + t.Error(err) + } + if len(changelog) > 0 { + for _, change := range changelog { + t.Errorf("%s got = %v, want %v", strings.Join(change.Path, "."), awsutil.Prettify(change.From), awsutil.Prettify(change.To)) + } + } + }) + } +}