diff --git a/aws/diff_suppress_funcs.go b/aws/diff_suppress_funcs.go index 17462a93a1e..0e340e4351e 100644 --- a/aws/diff_suppress_funcs.go +++ b/aws/diff_suppress_funcs.go @@ -107,6 +107,24 @@ func suppressAutoscalingGroupAvailabilityZoneDiffs(k, old, new string, d *schema return false } +func suppressCloudFormationTemplateBodyDiffs(k, old, new string, d *schema.ResourceData) bool { + normalizedOld, err := normalizeCloudFormationTemplate(old) + + if err != nil { + log.Printf("[WARN] Unable to normalize Terraform state CloudFormation template body: %s", err) + return false + } + + normalizedNew, err := normalizeCloudFormationTemplate(new) + + if err != nil { + log.Printf("[WARN] Unable to normalize Terraform configuration CloudFormation template body: %s", err) + return false + } + + return normalizedOld == normalizedNew +} + func suppressRoute53ZoneNameWithTrailingDot(k, old, new string, d *schema.ResourceData) bool { // "." is different from "". if old == "." || new == "." { diff --git a/aws/diff_suppress_funcs_test.go b/aws/diff_suppress_funcs_test.go index de92e4b3360..efe0b27f7f7 100644 --- a/aws/diff_suppress_funcs_test.go +++ b/aws/diff_suppress_funcs_test.go @@ -71,6 +71,200 @@ func TestSuppressEquivalentTypeStringBoolean(t *testing.T) { } } +func TestSuppressCloudFormationTemplateBodyDiffs(t *testing.T) { + testCases := []struct { + description string + equivalent bool + old string + new string + }{ + { + description: `JSON no change`, + equivalent: true, + old: ` +{ + "Resources": { + "TestVpc": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16" + } + } + }, + "Outputs": { + "TestVpcID": { + "Value": { "Ref" : "TestVpc" } + } + } +} +`, + new: ` +{ + "Resources": { + "TestVpc": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16" + } + } + }, + "Outputs": { + "TestVpcID": { + "Value": { "Ref" : "TestVpc" } + } + } +} +`, + }, + { + description: `JSON whitespace`, + equivalent: true, + old: `{"Resources":{"TestVpc":{"Type":"AWS::EC2::VPC","Properties":{"CidrBlock":"10.0.0.0/16"}}},"Outputs":{"TestVpcID":{"Value":{"Ref":"TestVpc"}}}}`, + new: ` +{ + "Resources": { + "TestVpc": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16" + } + } + }, + "Outputs": { + "TestVpcID": { + "Value": { "Ref" : "TestVpc" } + } + } +} +`, + }, + { + description: `JSON change`, + equivalent: false, + old: ` +{ + "Resources": { + "TestVpc": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16" + } + } + }, + "Outputs": { + "TestVpcID": { + "Value": { "Ref" : "TestVpc" } + } + } +} +`, + new: ` +{ + "Resources": { + "TestVpc": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "172.16.0.0/16" + } + } + }, + "Outputs": { + "TestVpcID": { + "Value": { "Ref" : "TestVpc" } + } + } +} +`, + }, + { + description: `YAML no change`, + equivalent: true, + old: ` +Resources: + TestVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 +Outputs: + TestVpcID: + Value: !Ref TestVpc +`, + new: ` +Resources: + TestVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 +Outputs: + TestVpcID: + Value: !Ref TestVpc +`, + }, + { + description: `YAML whitespace`, + equivalent: false, + old: ` +Resources: + TestVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 + +Outputs: + TestVpcID: + Value: !Ref TestVpc + +`, + new: ` +Resources: + TestVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 +Outputs: + TestVpcID: + Value: !Ref TestVpc +`, + }, + { + description: `YAML change`, + equivalent: false, + old: ` +Resources: + TestVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 172.16.0.0/16 +Outputs: + TestVpcID: + Value: !Ref TestVpc +`, + new: ` +Resources: + TestVpc: + Type: AWS::EC2::VPC + Properties: + CidrBlock: 10.0.0.0/16 +Outputs: + TestVpcID: + Value: !Ref TestVpc +`, + }, + } + + for _, tc := range testCases { + value := suppressCloudFormationTemplateBodyDiffs("test_property", tc.old, tc.new, nil) + + if tc.equivalent && !value { + t.Fatalf("expected test case (%s) to be equivalent", tc.description) + } + + if !tc.equivalent && value { + t.Fatalf("expected test case (%s) to not be equivalent", tc.description) + } + } +} + func TestSuppressRoute53ZoneNameWithTrailingDot(t *testing.T) { testCases := []struct { old string diff --git a/aws/provider.go b/aws/provider.go index b9790f743f6..e82e1def091 100644 --- a/aws/provider.go +++ b/aws/provider.go @@ -330,6 +330,8 @@ func Provider() terraform.ResourceProvider { "aws_budgets_budget": resourceAwsBudgetsBudget(), "aws_cloud9_environment_ec2": resourceAwsCloud9EnvironmentEc2(), "aws_cloudformation_stack": resourceAwsCloudFormationStack(), + "aws_cloudformation_stack_set": resourceAwsCloudFormationStackSet(), + "aws_cloudformation_stack_set_instance": resourceAwsCloudFormationStackSetInstance(), "aws_cloudfront_distribution": resourceAwsCloudFrontDistribution(), "aws_cloudfront_origin_access_identity": resourceAwsCloudFrontOriginAccessIdentity(), "aws_cloudfront_public_key": resourceAwsCloudFrontPublicKey(), diff --git a/aws/resource_aws_cloudformation_stack_set.go b/aws/resource_aws_cloudformation_stack_set.go new file mode 100644 index 00000000000..5954853f796 --- /dev/null +++ b/aws/resource_aws_cloudformation_stack_set.go @@ -0,0 +1,262 @@ +package aws + +import ( + "fmt" + "log" + "regexp" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudformation" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/helper/validation" +) + +func resourceAwsCloudFormationStackSet() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsCloudFormationStackSetCreate, + Read: resourceAwsCloudFormationStackSetRead, + Update: resourceAwsCloudFormationStackSetUpdate, + Delete: resourceAwsCloudFormationStackSetDelete, + + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Schema: map[string]*schema.Schema{ + "administration_role_arn": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validateArn, + }, + "arn": { + Type: schema.TypeString, + Computed: true, + }, + "capabilities": { + Type: schema.TypeSet, + Optional: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringInSlice([]string{ + cloudformation.CapabilityCapabilityAutoExpand, + cloudformation.CapabilityCapabilityIam, + cloudformation.CapabilityCapabilityNamedIam, + }, false), + }, + }, + "description": { + Type: schema.TypeString, + Optional: true, + ValidateFunc: validation.StringLenBetween(0, 1024), + }, + "execution_role_name": { + Type: schema.TypeString, + Optional: true, + Default: "AWSCloudFormationStackSetExecutionRole", + }, + "name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.All( + validation.StringLenBetween(1, 128), + validation.StringMatch(regexp.MustCompile(`^[a-zA-Z]`), "must begin with alphabetic character"), + validation.StringMatch(regexp.MustCompile(`^[a-zA-Z0-9-]+$`), "must contain only alphanumeric and hyphen characters"), + ), + }, + "parameters": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "stack_set_id": { + Type: schema.TypeString, + Computed: true, + }, + "tags": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "template_body": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ConflictsWith: []string{"template_url"}, + DiffSuppressFunc: suppressCloudFormationTemplateBodyDiffs, + ValidateFunc: validateCloudFormationTemplate, + }, + "template_url": { + Type: schema.TypeString, + Optional: true, + ConflictsWith: []string{"template_body"}, + }, + }, + } +} + +func resourceAwsCloudFormationStackSetCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + name := d.Get("name").(string) + + input := &cloudformation.CreateStackSetInput{ + AdministrationRoleARN: aws.String(d.Get("administration_role_arn").(string)), + ClientRequestToken: aws.String(resource.UniqueId()), + ExecutionRoleName: aws.String(d.Get("execution_role_name").(string)), + StackSetName: aws.String(name), + } + + if v, ok := d.GetOk("capabilities"); ok { + input.Capabilities = expandStringSet(v.(*schema.Set)) + } + + if v, ok := d.GetOk("description"); ok { + input.Description = aws.String(v.(string)) + } + + if v, ok := d.GetOk("parameters"); ok { + input.Parameters = expandCloudFormationParameters(v.(map[string]interface{})) + } + + if v, ok := d.GetOk("tags"); ok { + input.Tags = expandCloudFormationTags(v.(map[string]interface{})) + } + + if v, ok := d.GetOk("template_body"); ok { + input.TemplateBody = aws.String(v.(string)) + } + + if v, ok := d.GetOk("template_url"); ok { + input.TemplateURL = aws.String(v.(string)) + } + + log.Printf("[DEBUG] Creating CloudFormation Stack Set: %s", input) + _, err := conn.CreateStackSet(input) + + if err != nil { + return fmt.Errorf("error creating CloudFormation Stack Set: %s", err) + } + + d.SetId(name) + + return resourceAwsCloudFormationStackSetRead(d, meta) +} + +func resourceAwsCloudFormationStackSetRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + input := &cloudformation.DescribeStackSetInput{ + StackSetName: aws.String(d.Id()), + } + + log.Printf("[DEBUG] Reading CloudFormation Stack Set: %s", d.Id()) + output, err := conn.DescribeStackSet(input) + + if isAWSErr(err, cloudformation.ErrCodeStackSetNotFoundException, "") { + log.Printf("[WARN] CloudFormation Stack Set (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return fmt.Errorf("error reading CloudFormation Stack Set (%s): %s", d.Id(), err) + } + + if output == nil || output.StackSet == nil { + return fmt.Errorf("error reading CloudFormation Stack Set (%s): empty response", d.Id()) + } + + stackSet := output.StackSet + + d.Set("administration_role_arn", stackSet.AdministrationRoleARN) + d.Set("arn", stackSet.StackSetARN) + + if err := d.Set("capabilities", aws.StringValueSlice(stackSet.Capabilities)); err != nil { + return fmt.Errorf("error setting capabilities: %s", err) + } + + d.Set("description", stackSet.Description) + d.Set("execution_role_name", stackSet.ExecutionRoleName) + d.Set("name", stackSet.StackSetName) + + if err := d.Set("parameters", flattenAllCloudFormationParameters(stackSet.Parameters)); err != nil { + return fmt.Errorf("error setting parameters: %s", err) + } + + d.Set("stack_set_id", stackSet.StackSetId) + + if err := d.Set("tags", flattenCloudFormationTags(stackSet.Tags)); err != nil { + return fmt.Errorf("error setting tags: %s", err) + } + + d.Set("template_body", stackSet.TemplateBody) + + return nil +} + +func resourceAwsCloudFormationStackSetUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + input := &cloudformation.UpdateStackSetInput{ + AdministrationRoleARN: aws.String(d.Get("administration_role_arn").(string)), + ExecutionRoleName: aws.String(d.Get("execution_role_name").(string)), + OperationId: aws.String(resource.UniqueId()), + StackSetName: aws.String(d.Id()), + Tags: []*cloudformation.Tag{}, + TemplateBody: aws.String(d.Get("template_body").(string)), + } + + if v, ok := d.GetOk("capabilities"); ok { + input.Capabilities = expandStringSet(v.(*schema.Set)) + } + + if v, ok := d.GetOk("description"); ok { + input.Description = aws.String(v.(string)) + } + + if v, ok := d.GetOk("parameters"); ok { + input.Parameters = expandCloudFormationParameters(v.(map[string]interface{})) + } + + if v, ok := d.GetOk("tags"); ok { + input.Tags = expandCloudFormationTags(v.(map[string]interface{})) + } + + if v, ok := d.GetOk("template_url"); ok { + // ValidationError: Exactly one of TemplateBody or TemplateUrl must be specified + // TemplateBody is always present when TemplateUrl is used so remove TemplateBody if TemplateUrl is set + input.TemplateBody = nil + input.TemplateURL = aws.String(v.(string)) + } + + log.Printf("[DEBUG] Updating CloudFormation Stack Set: %s", input) + _, err := conn.UpdateStackSet(input) + + if err != nil { + return fmt.Errorf("error updating CloudFormation Stack Set (%s): %s", d.Id(), err) + } + + return resourceAwsCloudFormationStackSetRead(d, meta) +} + +func resourceAwsCloudFormationStackSetDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + input := &cloudformation.DeleteStackSetInput{ + StackSetName: aws.String(d.Id()), + } + + log.Printf("[DEBUG] Deleting CloudFormation Stack Set: %s", d.Id()) + _, err := conn.DeleteStackSet(input) + + if isAWSErr(err, cloudformation.ErrCodeStackSetNotFoundException, "") { + return nil + } + + if err != nil { + return fmt.Errorf("error deleting CloudFormation Stack Set (%s): %s", d.Id(), err) + } + + return nil +} diff --git a/aws/resource_aws_cloudformation_stack_set_instance.go b/aws/resource_aws_cloudformation_stack_set_instance.go new file mode 100644 index 00000000000..2406f008927 --- /dev/null +++ b/aws/resource_aws_cloudformation_stack_set_instance.go @@ -0,0 +1,323 @@ +package aws + +import ( + "fmt" + "log" + "strings" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudformation" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/helper/schema" + "github.com/hashicorp/terraform/helper/validation" +) + +func resourceAwsCloudFormationStackSetInstance() *schema.Resource { + return &schema.Resource{ + Create: resourceAwsCloudFormationStackSetInstanceCreate, + Read: resourceAwsCloudFormationStackSetInstanceRead, + Update: resourceAwsCloudFormationStackSetInstanceUpdate, + Delete: resourceAwsCloudFormationStackSetInstanceDelete, + + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(30 * time.Minute), + Update: schema.DefaultTimeout(30 * time.Minute), + Delete: schema.DefaultTimeout(30 * time.Minute), + }, + + Schema: map[string]*schema.Schema{ + "account_id": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + ValidateFunc: validateAwsAccountId, + }, + "parameter_overrides": { + Type: schema.TypeMap, + Optional: true, + Elem: &schema.Schema{Type: schema.TypeString}, + }, + "retain_stack": { + Type: schema.TypeBool, + Optional: true, + Default: false, + }, + "region": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + }, + "stack_id": { + Type: schema.TypeString, + Computed: true, + }, + "stack_set_name": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + ValidateFunc: validation.NoZeroValues, + }, + }, + } +} + +func resourceAwsCloudFormationStackSetInstanceCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + accountID := meta.(*AWSClient).accountid + if v, ok := d.GetOk("account_id"); ok { + accountID = v.(string) + } + + region := meta.(*AWSClient).region + if v, ok := d.GetOk("region"); ok { + region = v.(string) + } + + stackSetName := d.Get("stack_set_name").(string) + + input := &cloudformation.CreateStackInstancesInput{ + Accounts: aws.StringSlice([]string{accountID}), + OperationId: aws.String(resource.UniqueId()), + Regions: aws.StringSlice([]string{region}), + StackSetName: aws.String(stackSetName), + } + + if v, ok := d.GetOk("parameter_overrides"); ok { + input.ParameterOverrides = expandCloudFormationParameters(v.(map[string]interface{})) + } + + log.Printf("[DEBUG] Creating CloudFormation Stack Set Instance: %s", input) + output, err := conn.CreateStackInstances(input) + + if err != nil { + return fmt.Errorf("error creating CloudFormation Stack Set Instance: %s", err) + } + + d.SetId(fmt.Sprintf("%s,%s,%s", stackSetName, accountID, region)) + + if err := waitForCloudFormationStackSetOperation(conn, stackSetName, aws.StringValue(output.OperationId), d.Timeout(schema.TimeoutCreate)); err != nil { + return fmt.Errorf("error waiting for CloudFormation Stack Set Instance (%s) creation: %s", d.Id(), err) + } + + return resourceAwsCloudFormationStackSetInstanceRead(d, meta) +} + +func resourceAwsCloudFormationStackSetInstanceRead(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + stackSetName, accountID, region, err := resourceAwsCloudFormationStackSetInstanceParseId(d.Id()) + + if err != nil { + return err + } + + input := &cloudformation.DescribeStackInstanceInput{ + StackInstanceAccount: aws.String(accountID), + StackInstanceRegion: aws.String(region), + StackSetName: aws.String(stackSetName), + } + + log.Printf("[DEBUG] Reading CloudFormation Stack Set Instance: %s", d.Id()) + output, err := conn.DescribeStackInstance(input) + + if isAWSErr(err, cloudformation.ErrCodeStackInstanceNotFoundException, "") { + log.Printf("[WARN] CloudFormation Stack Set Instance (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if isAWSErr(err, cloudformation.ErrCodeStackSetNotFoundException, "") { + log.Printf("[WARN] CloudFormation Stack Set (%s) not found, removing from state", d.Id()) + d.SetId("") + return nil + } + + if err != nil { + return fmt.Errorf("error reading CloudFormation Stack Set Instance (%s): %s", d.Id(), err) + } + + if output == nil || output.StackInstance == nil { + return fmt.Errorf("error reading CloudFormation Stack Set Instance (%s): empty response", d.Id()) + } + + stackInstance := output.StackInstance + + d.Set("account_id", stackInstance.Account) + + if err := d.Set("parameter_overrides", flattenAllCloudFormationParameters(stackInstance.ParameterOverrides)); err != nil { + return fmt.Errorf("error setting parameters: %s", err) + } + + d.Set("region", stackInstance.Region) + d.Set("stack_id", stackInstance.StackId) + d.Set("stack_set_name", stackSetName) + + return nil +} + +func resourceAwsCloudFormationStackSetInstanceUpdate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + if d.HasChange("parameter_overrides") { + stackSetName, accountID, region, err := resourceAwsCloudFormationStackSetInstanceParseId(d.Id()) + + if err != nil { + return err + } + + input := &cloudformation.UpdateStackInstancesInput{ + Accounts: aws.StringSlice([]string{accountID}), + OperationId: aws.String(resource.UniqueId()), + ParameterOverrides: []*cloudformation.Parameter{}, + Regions: aws.StringSlice([]string{region}), + StackSetName: aws.String(stackSetName), + } + + if v, ok := d.GetOk("parameter_overrides"); ok { + input.ParameterOverrides = expandCloudFormationParameters(v.(map[string]interface{})) + } + + log.Printf("[DEBUG] Updating CloudFormation Stack Set Instance: %s", input) + output, err := conn.UpdateStackInstances(input) + + if err != nil { + return fmt.Errorf("error updating CloudFormation Stack Set Instance (%s): %s", d.Id(), err) + } + + if err := waitForCloudFormationStackSetOperation(conn, stackSetName, aws.StringValue(output.OperationId), d.Timeout(schema.TimeoutUpdate)); err != nil { + return fmt.Errorf("error waiting for CloudFormation Stack Set Instance (%s) update: %s", d.Id(), err) + } + } + + return resourceAwsCloudFormationStackSetInstanceRead(d, meta) +} + +func resourceAwsCloudFormationStackSetInstanceDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).cfconn + + stackSetName, accountID, region, err := resourceAwsCloudFormationStackSetInstanceParseId(d.Id()) + + if err != nil { + return err + } + + input := &cloudformation.DeleteStackInstancesInput{ + Accounts: aws.StringSlice([]string{accountID}), + OperationId: aws.String(resource.UniqueId()), + Regions: aws.StringSlice([]string{region}), + RetainStacks: aws.Bool(d.Get("retain_stack").(bool)), + StackSetName: aws.String(stackSetName), + } + + log.Printf("[DEBUG] Deleting CloudFormation Stack Set Instance: %s", d.Id()) + output, err := conn.DeleteStackInstances(input) + + if isAWSErr(err, cloudformation.ErrCodeStackInstanceNotFoundException, "") { + return nil + } + + if isAWSErr(err, cloudformation.ErrCodeStackSetNotFoundException, "") { + return nil + } + + if err != nil { + return fmt.Errorf("error deleting CloudFormation Stack Set Instance (%s): %s", d.Id(), err) + } + + if err := waitForCloudFormationStackSetOperation(conn, stackSetName, aws.StringValue(output.OperationId), d.Timeout(schema.TimeoutDelete)); err != nil { + return fmt.Errorf("error waiting for CloudFormation Stack Set Instance (%s) deletion: %s", d.Id(), err) + } + + return nil +} + +func resourceAwsCloudFormationStackSetInstanceParseId(id string) (string, string, string, error) { + idFormatErr := fmt.Errorf("unexpected format of ID (%s), expected NAME,ACCOUNT,REGION", id) + + parts := strings.SplitN(id, ",", 3) + if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" { + return "", "", "", idFormatErr + } + + return parts[0], parts[1], parts[2], nil +} + +func refreshCloudformationStackSetOperation(conn *cloudformation.CloudFormation, stackSetName, operationID string) resource.StateRefreshFunc { + return func() (interface{}, string, error) { + input := &cloudformation.DescribeStackSetOperationInput{ + OperationId: aws.String(operationID), + StackSetName: aws.String(stackSetName), + } + + output, err := conn.DescribeStackSetOperation(input) + + if isAWSErr(err, cloudformation.ErrCodeOperationNotFoundException, "") { + return nil, cloudformation.StackSetOperationStatusRunning, nil + } + + if err != nil { + return nil, cloudformation.StackSetOperationStatusFailed, err + } + + if output == nil || output.StackSetOperation == nil { + return nil, cloudformation.StackSetOperationStatusRunning, nil + } + + if aws.StringValue(output.StackSetOperation.Status) == cloudformation.StackSetOperationStatusFailed { + allResults := make([]string, 0) + listOperationResultsInput := &cloudformation.ListStackSetOperationResultsInput{ + OperationId: aws.String(operationID), + StackSetName: aws.String(stackSetName), + } + + for { + listOperationResultsOutput, err := conn.ListStackSetOperationResults(listOperationResultsInput) + + if err != nil { + return output.StackSetOperation, cloudformation.StackSetOperationStatusFailed, fmt.Errorf("error listing Operation (%s) errors: %s", operationID, err) + } + + if listOperationResultsOutput == nil { + continue + } + + for _, summary := range listOperationResultsOutput.Summaries { + allResults = append(allResults, fmt.Sprintf("Account (%s) Region (%s) Status (%s) Status Reason: %s", aws.StringValue(summary.Account), aws.StringValue(summary.Region), aws.StringValue(summary.Status), aws.StringValue(summary.StatusReason))) + } + + if aws.StringValue(listOperationResultsOutput.NextToken) == "" { + break + } + + listOperationResultsInput.NextToken = listOperationResultsOutput.NextToken + } + + return output.StackSetOperation, cloudformation.StackSetOperationStatusFailed, fmt.Errorf("Operation (%s) Results:\n%s", operationID, strings.Join(allResults, "\n")) + } + + return output.StackSetOperation, aws.StringValue(output.StackSetOperation.Status), nil + } +} + +func waitForCloudFormationStackSetOperation(conn *cloudformation.CloudFormation, stackSetName, operationID string, timeout time.Duration) error { + stateConf := &resource.StateChangeConf{ + Pending: []string{cloudformation.StackSetOperationStatusRunning}, + Target: []string{cloudformation.StackSetOperationStatusSucceeded}, + Refresh: refreshCloudformationStackSetOperation(conn, stackSetName, operationID), + Timeout: timeout, + Delay: 5 * time.Second, + } + + log.Printf("[DEBUG] Waiting for CloudFormation Stack Set (%s) operation: %s", stackSetName, operationID) + _, err := stateConf.WaitForState() + + return err +} diff --git a/aws/resource_aws_cloudformation_stack_set_instance_test.go b/aws/resource_aws_cloudformation_stack_set_instance_test.go new file mode 100644 index 00000000000..459b3851c97 --- /dev/null +++ b/aws/resource_aws_cloudformation_stack_set_instance_test.go @@ -0,0 +1,456 @@ +package aws + +import ( + "fmt" + "testing" + "time" + + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/service/cloudformation" + "github.com/hashicorp/terraform/helper/acctest" + "github.com/hashicorp/terraform/helper/resource" + "github.com/hashicorp/terraform/terraform" +) + +func TestAccAWSCloudFormationStackSetInstance_basic(t *testing.T) { + var stackInstance1 cloudformation.StackInstance + rName := acctest.RandomWithPrefix("tf-acc-test") + cloudformationStackSetResourceName := "aws_cloudformation_stack_set.test" + resourceName := "aws_cloudformation_stack_set_instance.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSCloudFormationStackSetInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSCloudFormationStackSetInstanceConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance1), + testAccCheckResourceAttrAccountID(resourceName, "account_id"), + resource.TestCheckResourceAttr(resourceName, "parameter_overrides.%", "0"), + resource.TestCheckResourceAttr(resourceName, "region", testAccGetRegion()), + resource.TestCheckResourceAttr(resourceName, "retain_stack", "false"), + resource.TestCheckResourceAttrSet(resourceName, "stack_id"), + resource.TestCheckResourceAttrPair(resourceName, "stack_set_name", cloudformationStackSetResourceName, "name"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "retain_stack", + }, + }, + }, + }) +} + +func TestAccAWSCloudFormationStackSetInstance_disappears(t *testing.T) { + var stackInstance1 cloudformation.StackInstance + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_cloudformation_stack_set_instance.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSCloudFormationStackSetInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSCloudFormationStackSetInstanceConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance1), + testAccCheckCloudFormationStackSetInstanceDisappears(&stackInstance1), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccAWSCloudFormationStackSetInstance_disappears_StackSet(t *testing.T) { + var stackInstance1 cloudformation.StackInstance + var stackSet1 cloudformation.StackSet + rName := acctest.RandomWithPrefix("tf-acc-test") + cloudformationStackSetResourceName := "aws_cloudformation_stack_set.test" + resourceName := "aws_cloudformation_stack_set_instance.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSCloudFormationStackSetInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSCloudFormationStackSetInstanceConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetExists(cloudformationStackSetResourceName, &stackSet1), + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance1), + testAccCheckCloudFormationStackSetInstanceDisappears(&stackInstance1), + testAccCheckCloudFormationStackSetDisappears(&stackSet1), + ), + ExpectNonEmptyPlan: true, + }, + }, + }) +} + +func TestAccAWSCloudFormationStackSetInstance_ParameterOverrides(t *testing.T) { + var stackInstance1, stackInstance2, stackInstance3, stackInstance4 cloudformation.StackInstance + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_cloudformation_stack_set_instance.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSCloudFormationStackSetInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSCloudFormationStackSetInstanceConfigParameterOverrides1(rName, "overridevalue1"), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance1), + resource.TestCheckResourceAttr(resourceName, "parameter_overrides.%", "1"), + resource.TestCheckResourceAttr(resourceName, "parameter_overrides.Parameter1", "overridevalue1"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "retain_stack", + }, + }, + { + Config: testAccAWSCloudFormationStackSetInstanceConfigParameterOverrides2(rName, "overridevalue1updated", "overridevalue2"), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance2), + testAccCheckCloudFormationStackSetInstanceNotRecreated(&stackInstance1, &stackInstance2), + resource.TestCheckResourceAttr(resourceName, "parameter_overrides.%", "2"), + resource.TestCheckResourceAttr(resourceName, "parameter_overrides.Parameter1", "overridevalue1updated"), + resource.TestCheckResourceAttr(resourceName, "parameter_overrides.Parameter2", "overridevalue2"), + ), + }, + { + Config: testAccAWSCloudFormationStackSetInstanceConfigParameterOverrides1(rName, "overridevalue1updated"), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance3), + testAccCheckCloudFormationStackSetInstanceNotRecreated(&stackInstance2, &stackInstance3), + resource.TestCheckResourceAttr(resourceName, "parameter_overrides.%", "1"), + resource.TestCheckResourceAttr(resourceName, "parameter_overrides.Parameter1", "overridevalue1updated"), + ), + }, + { + Config: testAccAWSCloudFormationStackSetInstanceConfig(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance4), + testAccCheckCloudFormationStackSetInstanceNotRecreated(&stackInstance3, &stackInstance4), + resource.TestCheckResourceAttr(resourceName, "parameter_overrides.%", "0"), + ), + }, + }, + }) +} + +// TestAccAWSCloudFrontDistribution_RetainStack verifies retain_stack = true +// This acceptance test performs the following steps: +// * Trigger a Terraform destroy of the resource, which should only remove the instance from the stack set +// * Check it still exists outside Terraform +// * Destroy for real outside Terraform +func TestAccAWSCloudFormationStackSetInstance_RetainStack(t *testing.T) { + var stack1 cloudformation.Stack + var stackInstance1, stackInstance2, stackInstance3 cloudformation.StackInstance + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_cloudformation_stack_set_instance.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t) }, + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSCloudFormationStackSetInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSCloudFormationStackSetInstanceConfigRetainStack(rName, true), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance1), + resource.TestCheckResourceAttr(resourceName, "retain_stack", "true"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "retain_stack", + }, + }, + { + Config: testAccAWSCloudFormationStackSetInstanceConfigRetainStack(rName, false), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance2), + testAccCheckCloudFormationStackSetInstanceNotRecreated(&stackInstance1, &stackInstance2), + resource.TestCheckResourceAttr(resourceName, "retain_stack", "false"), + ), + }, + { + Config: testAccAWSCloudFormationStackSetInstanceConfigRetainStack(rName, true), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance3), + testAccCheckCloudFormationStackSetInstanceNotRecreated(&stackInstance2, &stackInstance3), + resource.TestCheckResourceAttr(resourceName, "retain_stack", "true"), + ), + }, + { + Config: testAccAWSCloudFormationStackSetInstanceConfigRetainStack(rName, true), + Destroy: true, + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceStackExists(&stackInstance3, &stack1), + testAccCheckCloudFormationStackDisappears(&stack1), + ), + }, + }, + }) +} + +func testAccCheckCloudFormationStackSetInstanceExists(resourceName string, stackInstance *cloudformation.StackInstance) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[resourceName] + + if !ok { + return fmt.Errorf("Not found: %s", resourceName) + } + + conn := testAccProvider.Meta().(*AWSClient).cfconn + + stackSetName, accountID, region, err := resourceAwsCloudFormationStackSetInstanceParseId(rs.Primary.ID) + + if err != nil { + return err + } + + input := &cloudformation.DescribeStackInstanceInput{ + StackInstanceAccount: aws.String(accountID), + StackInstanceRegion: aws.String(region), + StackSetName: aws.String(stackSetName), + } + + output, err := conn.DescribeStackInstance(input) + + if err != nil { + return err + } + + if output == nil || output.StackInstance == nil { + return fmt.Errorf("CloudFormation Stack Set Instance (%s) not found", rs.Primary.ID) + } + + *stackInstance = *output.StackInstance + + return nil + } +} + +func testAccCheckCloudFormationStackSetInstanceStackExists(stackInstance *cloudformation.StackInstance, stack *cloudformation.Stack) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).cfconn + + input := &cloudformation.DescribeStacksInput{ + StackName: stackInstance.StackId, + } + + output, err := conn.DescribeStacks(input) + + if err != nil { + return err + } + + if len(output.Stacks) == 0 || output.Stacks[0] == nil { + return fmt.Errorf("CloudFormation Stack (%s) not found", aws.StringValue(stackInstance.StackId)) + } + + *stack = *output.Stacks[0] + + return nil + } +} + +func testAccCheckAWSCloudFormationStackSetInstanceDestroy(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).cfconn + + for _, rs := range s.RootModule().Resources { + if rs.Type != "aws_cloudformation_stack_set_instance" { + continue + } + + stackSetName, accountID, region, err := resourceAwsCloudFormationStackSetInstanceParseId(rs.Primary.ID) + + if err != nil { + return err + } + + input := &cloudformation.DescribeStackInstanceInput{ + StackInstanceAccount: aws.String(accountID), + StackInstanceRegion: aws.String(region), + StackSetName: aws.String(stackSetName), + } + + output, err := conn.DescribeStackInstance(input) + + if isAWSErr(err, cloudformation.ErrCodeStackInstanceNotFoundException, "") { + return nil + } + + if isAWSErr(err, cloudformation.ErrCodeStackSetNotFoundException, "") { + return nil + } + + if err != nil { + return err + } + + if output != nil && output.StackInstance != nil { + return fmt.Errorf("CloudFormation Stack Set Instance (%s) still exists", rs.Primary.ID) + } + } + + return nil +} + +func testAccCheckCloudFormationStackSetInstanceDisappears(stackInstance *cloudformation.StackInstance) resource.TestCheckFunc { + return func(s *terraform.State) error { + conn := testAccProvider.Meta().(*AWSClient).cfconn + + input := &cloudformation.DeleteStackInstancesInput{ + Accounts: []*string{stackInstance.Account}, + Regions: []*string{stackInstance.Region}, + RetainStacks: aws.Bool(false), + StackSetName: stackInstance.StackSetId, + } + + output, err := conn.DeleteStackInstances(input) + + if err != nil { + return err + } + + return waitForCloudFormationStackSetOperation(conn, aws.StringValue(stackInstance.StackSetId), aws.StringValue(output.OperationId), 10*time.Minute) + } +} + +func testAccCheckCloudFormationStackSetInstanceNotRecreated(i, j *cloudformation.StackInstance) resource.TestCheckFunc { + return func(s *terraform.State) error { + if aws.StringValue(i.StackId) != aws.StringValue(j.StackId) { + return fmt.Errorf("CloudFormation Stack Set Instance (%s,%s,%s) recreated", aws.StringValue(i.StackSetId), aws.StringValue(i.Account), aws.StringValue(i.Region)) + } + + return nil + } +} + +func testAccAWSCloudFormationStackSetInstanceConfigBase(rName string) string { + return fmt.Sprintf(` +resource "aws_iam_role" "Administration" { + assume_role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"Service\":[\"cloudformation.amazonaws.com\"]},\"Action\":[\"sts:AssumeRole\"]}]}" + name = "%[1]s-Administration" +} + +resource "aws_iam_role_policy" "Administration" { + policy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Resource\":[\"*\"],\"Action\":[\"sts:AssumeRole\"]}]}" + role = "${aws_iam_role.Administration.name}" +} + +resource "aws_iam_role" "Execution" { + assume_role_policy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Principal\":{\"AWS\":[\"${aws_iam_role.Administration.arn}\"]},\"Action\":[\"sts:AssumeRole\"]}]}" + name = "%[1]s-Execution" +} + +resource "aws_iam_role_policy" "Execution" { + policy = "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Effect\":\"Allow\",\"Resource\":[\"*\"],\"Action\":[\"*\"]}]}" + role = "${aws_iam_role.Execution.name}" +} + +resource "aws_cloudformation_stack_set" "test" { + depends_on = ["aws_iam_role_policy.Execution"] + + administration_role_arn = "${aws_iam_role.Administration.arn}" + execution_role_name = "${aws_iam_role.Execution.name}" + name = %[1]q + parameters = { + Parameter1 = "stacksetvalue1" + Parameter2 = "stacksetvalue2" + } + template_body = <