From e51e82ab988fe104e3a69c218a0276849cc4c9f6 Mon Sep 17 00:00:00 2001 From: Angie Pinilla Date: Wed, 6 Oct 2021 21:40:42 -0400 Subject: [PATCH 1/2] feat: add support for deployment targets --- .../service/cloudformation/finder/finder.go | 44 ++++ ...e_aws_cloudformation_stack_set_instance.go | 109 ++++++++-- ..._cloudformation_stack_set_instance_test.go | 189 ++++++++++++++++++ ...formation_stack_set_instance.html.markdown | 21 ++ 4 files changed, 350 insertions(+), 13 deletions(-) diff --git a/aws/internal/service/cloudformation/finder/finder.go b/aws/internal/service/cloudformation/finder/finder.go index 467aac9b3fed..8a3a65528004 100644 --- a/aws/internal/service/cloudformation/finder/finder.go +++ b/aws/internal/service/cloudformation/finder/finder.go @@ -75,6 +75,50 @@ func StackByID(conn *cloudformation.CloudFormation, id string) (*cloudformation. return stack, nil } +func StackInstanceAccountIdByOrgIds(conn *cloudformation.CloudFormation, stackSetName, region string, orgIDs []string) (string, error) { + input := &cloudformation.ListStackInstancesInput{ + StackInstanceRegion: aws.String(region), + StackSetName: aws.String(stackSetName), + } + + var result string + + err := conn.ListStackInstancesPages(input, func(page *cloudformation.ListStackInstancesOutput, lastPage bool) bool { + if page == nil { + return !lastPage + } + + for _, s := range page.Summaries { + if s == nil { + continue + } + + for _, orgID := range orgIDs { + if aws.StringValue(s.OrganizationalUnitId) == orgID { + result = aws.StringValue(s.Account) + return false + } + } + + } + + return !lastPage + }) + + if tfawserr.ErrCodeEquals(err, cloudformation.ErrCodeStackSetNotFoundException) { + return "", &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + + if err != nil { + return "", err + } + + return result, nil +} + func StackInstanceByName(conn *cloudformation.CloudFormation, stackSetName, accountID, region string) (*cloudformation.StackInstance, error) { input := &cloudformation.DescribeStackInstanceInput{ StackInstanceAccount: aws.String(accountID), diff --git a/aws/resource_aws_cloudformation_stack_set_instance.go b/aws/resource_aws_cloudformation_stack_set_instance.go index b094b472953b..94202525ce4e 100644 --- a/aws/resource_aws_cloudformation_stack_set_instance.go +++ b/aws/resource_aws_cloudformation_stack_set_instance.go @@ -3,6 +3,7 @@ package aws import ( "fmt" "log" + "regexp" "strings" "github.com/aws/aws-sdk-go/aws" @@ -37,11 +38,35 @@ func resourceAwsCloudFormationStackSetInstance() *schema.Resource { Schema: map[string]*schema.Schema{ "account_id": { - Type: schema.TypeString, - Optional: true, - Computed: true, - ForceNew: true, - ValidateFunc: validateAwsAccountId, + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + ValidateFunc: validateAwsAccountId, + ConflictsWith: []string{"deployment_targets"}, + }, + "deployment_targets": { + Type: schema.TypeList, + Optional: true, + MaxItems: 1, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "organizational_unit_ids": { + Type: schema.TypeSet, + Optional: true, + MinItems: 1, + Elem: &schema.Schema{ + Type: schema.TypeString, + ValidateFunc: validation.StringMatch(regexp.MustCompile(`^(ou-[a-z0-9]{4,32}-[a-z0-9]{8,32}|r-[a-z0-9]{4,32})$`), ""), + }, + }, + }, + }, + ConflictsWith: []string{"account_id"}, + }, + "organizational_unit_id": { + Type: schema.TypeString, + Computed: true, }, "parameter_overrides": { Type: schema.TypeMap, @@ -76,11 +101,6 @@ func resourceAwsCloudFormationStackSetInstance() *schema.Resource { 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) @@ -88,11 +108,25 @@ func resourceAwsCloudFormationStackSetInstanceCreate(d *schema.ResourceData, met stackSetName := d.Get("stack_set_name").(string) input := &cloudformation.CreateStackInstancesInput{ - Accounts: aws.StringSlice([]string{accountID}), Regions: aws.StringSlice([]string{region}), StackSetName: aws.String(stackSetName), } + accountID := meta.(*AWSClient).accountid + if v, ok := d.GetOk("account_id"); ok { + accountID = v.(string) + } + + if v, ok := d.GetOk("deployment_targets"); ok && len(v.([]interface{})) > 0 && v.([]interface{})[0] != nil { + dt := expandCloudFormationDeploymentTargets(v.([]interface{})) + // temporarily set the accountId to the DeploymentTarget IDs + // to later inform the Read CRUD operation if the true accountID needs to be determined + accountID = strings.Join(aws.StringValueSlice(dt.OrganizationalUnitIds), "/") + input.DeploymentTargets = dt + } else { + input.Accounts = aws.StringSlice([]string{accountID}) + } + if v, ok := d.GetOk("parameter_overrides"); ok { input.ParameterOverrides = expandCloudFormationParameters(v.(map[string]interface{})) } @@ -168,6 +202,20 @@ func resourceAwsCloudFormationStackSetInstanceRead(d *schema.ResourceData, meta return err } + // Determine correct account ID for the Instance if created with deployment targets; + // we only expect the accountID to be the organization root ID or organizational unit (OU) IDs + // separated by a slash after creation. + if regexp.MustCompile(`(ou-[a-z0-9]{4,32}-[a-z0-9]{8,32}|r-[a-z0-9]{4,32})`).MatchString(accountID) { + orgIDs := strings.Split(accountID, "/") + accountID, err = finder.StackInstanceAccountIdByOrgIds(conn, stackSetName, region, orgIDs) + + if err != nil { + return fmt.Errorf("error finding CloudFormation StackSet Instance (%s) Account: %w", d.Id(), err) + } + + d.SetId(tfcloudformation.StackSetInstanceCreateResourceID(stackSetName, accountID, region)) + } + stackInstance, err := finder.StackInstanceByName(conn, stackSetName, accountID, region) if !d.IsNewResource() && tfresource.NotFound(err) { @@ -181,7 +229,7 @@ func resourceAwsCloudFormationStackSetInstanceRead(d *schema.ResourceData, meta } d.Set("account_id", stackInstance.Account) - + d.Set("organizational_unit_id", stackInstance.OrganizationalUnitId) if err := d.Set("parameter_overrides", flattenAllCloudFormationParameters(stackInstance.ParameterOverrides)); err != nil { return fmt.Errorf("error setting parameters: %w", err) } @@ -196,7 +244,7 @@ func resourceAwsCloudFormationStackSetInstanceRead(d *schema.ResourceData, meta func resourceAwsCloudFormationStackSetInstanceUpdate(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).cfconn - if d.HasChange("parameter_overrides") { + if d.HasChanges("deployment_targets", "parameter_overrides") { stackSetName, accountID, region, err := tfcloudformation.StackSetInstanceParseResourceID(d.Id()) if err != nil { @@ -211,6 +259,12 @@ func resourceAwsCloudFormationStackSetInstanceUpdate(d *schema.ResourceData, met StackSetName: aws.String(stackSetName), } + if v, ok := d.GetOk("deployment_targets"); ok && len(v.([]interface{})) > 0 && v.([]interface{})[0] != nil { + // reset input Accounts as the API accepts only 1 of Accounts and DeploymentTargets + input.Accounts = nil + input.DeploymentTargets = expandCloudFormationDeploymentTargets(v.([]interface{})) + } + if v, ok := d.GetOk("parameter_overrides"); ok { input.ParameterOverrides = expandCloudFormationParameters(v.(map[string]interface{})) } @@ -247,6 +301,15 @@ func resourceAwsCloudFormationStackSetInstanceDelete(d *schema.ResourceData, met StackSetName: aws.String(stackSetName), } + if v, ok := d.GetOk("organizational_unit_id"); ok { + // For instances associated with stack sets that use a self-managed permission model, + // the organizational unit must be provided; + input.Accounts = nil + input.DeploymentTargets = &cloudformation.DeploymentTargets{ + OrganizationalUnitIds: aws.StringSlice([]string{v.(string)}), + } + } + log.Printf("[DEBUG] Deleting CloudFormation StackSet Instance: %s", d.Id()) output, err := conn.DeleteStackInstances(input) @@ -264,3 +327,23 @@ func resourceAwsCloudFormationStackSetInstanceDelete(d *schema.ResourceData, met return nil } + +func expandCloudFormationDeploymentTargets(l []interface{}) *cloudformation.DeploymentTargets { + if len(l) == 0 || l[0] == nil { + return nil + } + + tfMap, ok := l[0].(map[string]interface{}) + + if !ok { + return nil + } + + dt := &cloudformation.DeploymentTargets{} + + if v, ok := tfMap["organizational_unit_ids"].(*schema.Set); ok && v.Len() > 0 { + dt.OrganizationalUnitIds = expandStringSet(v) + } + + return dt +} diff --git a/aws/resource_aws_cloudformation_stack_set_instance_test.go b/aws/resource_aws_cloudformation_stack_set_instance_test.go index 3ea06a89392f..8b10ea05b4c6 100644 --- a/aws/resource_aws_cloudformation_stack_set_instance_test.go +++ b/aws/resource_aws_cloudformation_stack_set_instance_test.go @@ -113,6 +113,7 @@ func TestAccAWSCloudFormationStackSetInstance_basic(t *testing.T) { Check: resource.ComposeTestCheckFunc( testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance1), testAccCheckResourceAttrAccountID(resourceName, "account_id"), + resource.TestCheckResourceAttr(resourceName, "deployment_targets.#", "0"), resource.TestCheckResourceAttr(resourceName, "parameter_overrides.%", "0"), resource.TestCheckResourceAttr(resourceName, "region", testAccGetRegion()), resource.TestCheckResourceAttr(resourceName, "retain_stack", "false"), @@ -300,6 +301,50 @@ func TestAccAWSCloudFormationStackSetInstance_RetainStack(t *testing.T) { }) } +func TestAccAWSCloudFormationStackSetInstance_DeploymentTargets(t *testing.T) { + TestAccSkip(t, "API does not support enabling Organizations access (in particular, creating the Stack Sets IAM Service-Linked Role)") + + var stackInstance cloudformation.StackInstance + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_cloudformation_stack_set_instance.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { + testAccPreCheck(t) + testAccPreCheckAWSCloudFormationStackSet(t) + testAccOrganizationsAccountPreCheck(t) + }, + ErrorCheck: testAccErrorCheck(t, cloudformation.EndpointsID, "organizations"), + Providers: testAccProviders, + CheckDestroy: testAccCheckAWSCloudFormationStackSetInstanceDestroy, + Steps: []resource.TestStep{ + { + Config: testAccAWSCloudFormationStackSetInstanceConfigDeploymentTargets(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance), + resource.TestCheckResourceAttr(resourceName, "deployment_targets.#", "1"), + resource.TestCheckResourceAttr(resourceName, "deployment_targets.0.organizational_unit_ids.#", "1"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateVerifyIgnore: []string{ + "retain_stack", + "deployment_targets", + }, + }, + { + Config: testAccAWSCloudFormationStackSetInstanceConfig_ServiceManagedStackSet(rName), + Check: resource.ComposeTestCheckFunc( + testAccCheckCloudFormationStackSetInstanceExists(resourceName, &stackInstance), + ), + }, + }, + }) +} + func testAccCheckCloudFormationStackSetInstanceExists(resourceName string, v *cloudformation.StackInstance) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[resourceName] @@ -562,3 +607,147 @@ resource "aws_cloudformation_stack_set_instance" "test" { } `, retainStack) } + +func testAccAWSCloudFormationStackSetInstanceConfigBase_ServiceManagedStackSet(rName string) string { + return fmt.Sprintf(` +data "aws_partition" "current" {} + +resource "aws_iam_role" "Administration" { + assume_role_policy = < Date: Wed, 6 Oct 2021 21:40:54 -0400 Subject: [PATCH 2/2] Update CHANGELOG for #21193 --- .changelog/21193.txt | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 .changelog/21193.txt diff --git a/.changelog/21193.txt b/.changelog/21193.txt new file mode 100644 index 000000000000..833eabfd9c6f --- /dev/null +++ b/.changelog/21193.txt @@ -0,0 +1,3 @@ +```release-note:enhancement +resource/aws_cloudformation_stack_set_instance: Add `deployment_targets` `organizational_unit_ids` argument +``` \ No newline at end of file