Skip to content

Commit

Permalink
Merge pull request #24523 from sbutler/b-aws_cloudformation_stack_set…
Browse files Browse the repository at this point in the history
…_instance-empty-ou-refactor

r/aws_cloudformation_stack_set_instance: Empty OU refactor
  • Loading branch information
jar-b authored Jul 20, 2023
2 parents de664b5 + 20059ef commit e8f5acf
Show file tree
Hide file tree
Showing 5 changed files with 291 additions and 84 deletions.
11 changes: 11 additions & 0 deletions .changelog/24523.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
```release-note:bug
resource/aws_cloudformation_stack_set_instance: Fix error when deploying to organizational units with no accounts.
```

```release-note:enhancement
resource/aws_cloudformation_stack_set_instance: Changes to `deployment_targets` now force a new resource.
```

```release-note:enhancement
resource/aws_cloudformation_stack_set_instance: Added the `stack_instance_summaries` attribute to track all account and stack IDs for deployments to organizational units.
```
11 changes: 5 additions & 6 deletions internal/service/cloudformation/find.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func FindStackByID(ctx context.Context, conn *cloudformation.CloudFormation, id
return stack, nil
}

func FindStackInstanceAccountIdByOrgIDs(ctx context.Context, conn *cloudformation.CloudFormation, stackSetName, region, callAs string, orgIDs []string) (string, error) {
func FindStackInstanceSummariesByOrgIDs(ctx context.Context, conn *cloudformation.CloudFormation, stackSetName, region, callAs string, orgIDs []string) ([]*cloudformation.StackInstanceSummary, error) {
input := &cloudformation.ListStackInstancesInput{
StackInstanceRegion: aws.String(region),
StackSetName: aws.String(stackSetName),
Expand All @@ -87,7 +87,7 @@ func FindStackInstanceAccountIdByOrgIDs(ctx context.Context, conn *cloudformatio
input.CallAs = aws.String(callAs)
}

var result string
var result []*cloudformation.StackInstanceSummary

err := conn.ListStackInstancesPagesWithContext(ctx, input, func(page *cloudformation.ListStackInstancesOutput, lastPage bool) bool {
if page == nil {
Expand All @@ -101,8 +101,7 @@ func FindStackInstanceAccountIdByOrgIDs(ctx context.Context, conn *cloudformatio

for _, orgID := range orgIDs {
if aws.StringValue(s.OrganizationalUnitId) == orgID {
result = aws.StringValue(s.Account)
return false
result = append(result, s)
}
}
}
Expand All @@ -111,14 +110,14 @@ func FindStackInstanceAccountIdByOrgIDs(ctx context.Context, conn *cloudformatio
})

if tfawserr.ErrCodeEquals(err, cloudformation.ErrCodeStackSetNotFoundException) {
return "", &retry.NotFoundError{
return nil, &retry.NotFoundError{
LastError: err,
LastRequest: input,
}
}

if err != nil {
return "", err
return nil, err
}

return result, nil
Expand Down
178 changes: 123 additions & 55 deletions internal/service/cloudformation/stack_set_instance.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func ResourceStackSetInstance() *schema.Resource {
"deployment_targets": {
Type: schema.TypeList,
Optional: true,
ForceNew: true,
MaxItems: 1,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
Expand Down Expand Up @@ -147,6 +148,28 @@ func ResourceStackSetInstance() *schema.Resource {
Type: schema.TypeString,
Computed: true,
},
"stack_instance_summaries": {
Type: schema.TypeList,
Computed: true,
Description: "List of stack instances created from an organizational unit deployment target. " +
"This will only be populated when `deployment_targets` is set.",
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"account_id": {
Type: schema.TypeString,
Computed: true,
},
"organizational_unit_id": {
Type: schema.TypeString,
Computed: true,
},
"stack_id": {
Type: schema.TypeString,
Computed: true,
},
},
},
},
"stack_set_name": {
Type: schema.TypeString,
Required: true,
Expand All @@ -157,6 +180,10 @@ func ResourceStackSetInstance() *schema.Resource {
}
}

var (
accountIDRegexp = regexp.MustCompile(`^\d{12}$`)
)

func resourceStackSetInstanceCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics {
var diags diag.Diagnostics
conn := meta.(*conns.AWSClient).CloudFormationConn(ctx)
Expand All @@ -176,22 +203,25 @@ func resourceStackSetInstanceCreate(ctx context.Context, d *schema.ResourceData,
if v, ok := d.GetOk("account_id"); ok {
accountID = v.(string)
}

callAs := d.Get("call_as").(string)
if v, ok := d.GetOk("call_as"); ok {
input.CallAs = aws.String(v.(string))
}
// accountOrOrgID will either be account_id or a slash-delimited list of
// organizational_unit_id's from the deployment_targets argument. This
// is composed with stack_set_name and region to form the resources ID.
accountOrOrgID := accountID

if v, ok := d.GetOk("deployment_targets"); ok && len(v.([]interface{})) > 0 && v.([]interface{})[0] != nil {
dt := expandDeploymentTargets(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), "/")
accountOrOrgID = strings.Join(aws.StringValueSlice(dt.OrganizationalUnitIds), "/")
input.DeploymentTargets = dt
} else {
d.Set("account_id", accountID)
input.Accounts = aws.StringSlice([]string{accountID})
}

callAs := d.Get("call_as").(string)
if v, ok := d.GetOk("call_as"); ok {
input.CallAs = aws.String(v.(string))
}

if v, ok := d.GetOk("parameter_overrides"); ok {
input.ParameterOverrides = expandParameters(v.(map[string]interface{}))
}
Expand All @@ -211,7 +241,7 @@ func resourceStackSetInstanceCreate(ctx context.Context, d *schema.ResourceData,
return nil, err
}

d.SetId(StackSetInstanceCreateResourceID(stackSetName, accountID, region))
d.SetId(StackSetInstanceCreateResourceID(stackSetName, accountOrOrgID, region))

operation, err := WaitStackSetOperationSucceeded(ctx, conn, stackSetName, aws.StringValue(output.OperationId), callAs, d.Timeout(schema.TimeoutCreate))
if err != nil {
Expand Down Expand Up @@ -269,50 +299,56 @@ func resourceStackSetInstanceRead(ctx context.Context, d *schema.ResourceData, m
var diags diag.Diagnostics
conn := meta.(*conns.AWSClient).CloudFormationConn(ctx)

stackSetName, accountID, region, err := StackSetInstanceParseResourceID(d.Id())

callAs := d.Get("call_as").(string)

stackSetName, accountOrOrgID, region, err := StackSetInstanceParseResourceID(d.Id())
if err != nil {
return sdkdiag.AppendErrorf(diags, "reading CloudFormation StackSet Instance (%s): %s", d.Id(), err)
}
if accountOrOrgID == "" {
return sdkdiag.AppendErrorf(diags, "reading CloudFormation StackSet Instance (%s): account_id or organizational_unit_id section empty", d.Id())
}
d.Set("region", region)
d.Set("stack_set_name", stackSetName)

// 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 = FindStackInstanceAccountIdByOrgIDs(ctx, conn, stackSetName, region, callAs, orgIDs)
callAs := d.Get("call_as").(string)

if accountIDRegexp.MatchString(accountOrOrgID) {
// Stack instances deployed by account ID
stackInstance, err := FindStackInstanceByName(ctx, conn, stackSetName, accountOrOrgID, region, callAs)
if !d.IsNewResource() && tfresource.NotFound(err) {
log.Printf("[WARN] CloudFormation StackSet Instance (%s) not found, removing from state", d.Id())
d.SetId("")
return diags
}
if err != nil {
return sdkdiag.AppendErrorf(diags, "finding CloudFormation StackSet Instance (%s) Account: %s", d.Id(), err)
return sdkdiag.AppendErrorf(diags, "reading CloudFormation StackSet Instance (%s): %s", d.Id(), err)
}

d.SetId(StackSetInstanceCreateResourceID(stackSetName, accountID, region))
}

stackInstance, err := FindStackInstanceByName(ctx, conn, stackSetName, accountID, region, callAs)

if !d.IsNewResource() && tfresource.NotFound(err) {
log.Printf("[WARN] CloudFormation StackSet Instance (%s) not found, removing from state", d.Id())
d.SetId("")
return diags
}
d.Set("account_id", stackInstance.Account)
d.Set("organizational_unit_id", stackInstance.OrganizationalUnitId)
if err := d.Set("parameter_overrides", flattenAllParameters(stackInstance.ParameterOverrides)); err != nil {
return sdkdiag.AppendErrorf(diags, "setting parameters: %s", err)
}

if err != nil {
return sdkdiag.AppendErrorf(diags, "reading CloudFormation StackSet Instance (%s): %s", d.Id(), err)
}
d.Set("stack_id", stackInstance.StackId)
d.Set("stack_instance_summaries", nil)
} else {
// Stack instances deployed by organizational unit ID
orgIDs := strings.Split(accountOrOrgID, "/")

summaries, err := FindStackInstanceSummariesByOrgIDs(ctx, conn, stackSetName, region, callAs, orgIDs)
if !d.IsNewResource() && tfresource.NotFound(err) {
log.Printf("[WARN] CloudFormation StackSet Instance (%s) not found, removing from state", d.Id())
d.SetId("")
return diags
}
if err != nil {
return sdkdiag.AppendErrorf(diags, "finding CloudFormation StackSet Instance (%s) Account: %s", d.Id(), err)
}

d.Set("account_id", stackInstance.Account)
d.Set("organizational_unit_id", stackInstance.OrganizationalUnitId)
if err := d.Set("parameter_overrides", flattenAllParameters(stackInstance.ParameterOverrides)); err != nil {
return sdkdiag.AppendErrorf(diags, "setting parameters: %s", err)
d.Set("deployment_targets", flattenDeploymentTargetsFromSlice(orgIDs))
d.Set("stack_instance_summaries", flattenStackInstanceSummaries(summaries))
}

d.Set("region", stackInstance.Region)
d.Set("stack_id", stackInstance.StackId)
d.Set("stack_set_name", stackSetName)

return diags
}

Expand All @@ -321,14 +357,14 @@ func resourceStackSetInstanceUpdate(ctx context.Context, d *schema.ResourceData,
conn := meta.(*conns.AWSClient).CloudFormationConn(ctx)

if d.HasChanges("deployment_targets", "parameter_overrides", "operation_preferences") {
stackSetName, accountID, region, err := StackSetInstanceParseResourceID(d.Id())
stackSetName, accountOrOrgID, region, err := StackSetInstanceParseResourceID(d.Id())

if err != nil {
return sdkdiag.AppendErrorf(diags, "updating CloudFormation StackSet Instance (%s): %s", d.Id(), err)
}

input := &cloudformation.UpdateStackInstancesInput{
Accounts: aws.StringSlice([]string{accountID}),
Accounts: aws.StringSlice([]string{accountOrOrgID}),
OperationId: aws.String(id.UniqueId()),
ParameterOverrides: []*cloudformation.Parameter{},
Regions: aws.StringSlice([]string{region}),
Expand All @@ -341,9 +377,10 @@ func resourceStackSetInstanceUpdate(ctx context.Context, d *schema.ResourceData,
}

if v, ok := d.GetOk("deployment_targets"); ok && len(v.([]interface{})) > 0 && v.([]interface{})[0] != nil {
dt := expandDeploymentTargets(v.([]interface{}))
// reset input Accounts as the API accepts only 1 of Accounts and DeploymentTargets
input.Accounts = nil
input.DeploymentTargets = expandDeploymentTargets(v.([]interface{}))
input.DeploymentTargets = dt
}

if v, ok := d.GetOk("parameter_overrides"); ok {
Expand Down Expand Up @@ -373,14 +410,14 @@ func resourceStackSetInstanceDelete(ctx context.Context, d *schema.ResourceData,
var diags diag.Diagnostics
conn := meta.(*conns.AWSClient).CloudFormationConn(ctx)

stackSetName, accountID, region, err := StackSetInstanceParseResourceID(d.Id())
stackSetName, accountOrOrgID, region, err := StackSetInstanceParseResourceID(d.Id())

if err != nil {
return sdkdiag.AppendErrorf(diags, "deleting CloudFormation StackSet Instance (%s): %s", d.Id(), err)
}

input := &cloudformation.DeleteStackInstancesInput{
Accounts: aws.StringSlice([]string{accountID}),
Accounts: aws.StringSlice([]string{accountOrOrgID}),
OperationId: aws.String(id.UniqueId()),
Regions: aws.StringSlice([]string{region}),
RetainStacks: aws.Bool(d.Get("retain_stack").(bool)),
Expand All @@ -392,13 +429,12 @@ func resourceStackSetInstanceDelete(ctx context.Context, d *schema.ResourceData,
input.CallAs = aws.String(v.(string))
}

if v, ok := d.GetOk("organizational_unit_id"); ok {
if v, ok := d.GetOk("deployment_targets"); ok && len(v.([]interface{})) > 0 && v.([]interface{})[0] != nil {
dt := expandDeploymentTargets(v.([]interface{}))
// 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)}),
}
input.DeploymentTargets = dt
}

log.Printf("[DEBUG] Deleting CloudFormation StackSet Instance: %s", d.Id())
Expand All @@ -419,22 +455,54 @@ func resourceStackSetInstanceDelete(ctx context.Context, d *schema.ResourceData,
return diags
}

func expandDeploymentTargets(l []interface{}) *cloudformation.DeploymentTargets {
if len(l) == 0 || l[0] == nil {
func expandDeploymentTargets(tfList []interface{}) *cloudformation.DeploymentTargets {
if len(tfList) == 0 || tfList[0] == nil {
return nil
}

tfMap, ok := l[0].(map[string]interface{})

tfMap, ok := tfList[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 = flex.ExpandStringSet(v)
}

return dt
}

// flattenDeployment targets converts a list of organizational units (typically
// parsed from the resource ID) into the Terraform representation of the
// deployment_targets attribute.
func flattenDeploymentTargetsFromSlice(orgIDs []string) []interface{} {
tfList := []interface{}{}
for _, ou := range orgIDs {
tfList = append(tfList, ou)
}

m := map[string]interface{}{
"organizational_unit_ids": tfList,
}

return []interface{}{m}
}

func flattenStackInstanceSummaries(apiObject []*cloudformation.StackInstanceSummary) []interface{} {
if len(apiObject) == 0 {
return nil
}

tfList := []interface{}{}
for _, obj := range apiObject {
m := map[string]interface{}{
"account_id": obj.Account,
"organizational_unit_id": obj.OrganizationalUnitId,
"stack_id": obj.StackId,
}
tfList = append(tfList, m)
}

return tfList
}
Loading

0 comments on commit e8f5acf

Please sign in to comment.