diff --git a/.changelog/9092.txt b/.changelog/9092.txt new file mode 100644 index 00000000000..679397328fd --- /dev/null +++ b/.changelog/9092.txt @@ -0,0 +1,19 @@ +```release-note:enhancement +resource/aws_budgets_budget: Add the `cost_filter` argument which allows multiple `values` to be specified per filter. This new argument will eventually replace the `cost_filters` argument +``` + +```release-note:enhancement +resource/aws_budgets_budget: Change `time_period_start` to an optional argument. If you don't specify a start date, AWS defaults to the start of your chosen time period +``` + +```release-note:bug +resource/aws_budgets_budget: Change the service name in the `arn` attribute from `budgetservice` to `budgets` +``` + +```release-note:bug +resource/aws_budgets_budget: Suppress plan differences with trailing zeroes for `limit_amount` +``` + +```release-note:bug +resource/aws_budgets_budget_action: Change the service name in the `arn` attribute from `budgetservice` to `budgets` +``` \ No newline at end of file diff --git a/aws/internal/service/budgets/finder/finder.go b/aws/internal/service/budgets/finder/finder.go index 2c399fd8e10..d2f19931344 100644 --- a/aws/internal/service/budgets/finder/finder.go +++ b/aws/internal/service/budgets/finder/finder.go @@ -3,25 +3,154 @@ package finder import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/budgets" - tfbudgets "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" ) -func ActionById(conn *budgets.Budgets, id string) (*budgets.DescribeBudgetActionOutput, error) { - accountID, actionID, budgetName, err := tfbudgets.DecodeBudgetsBudgetActionID(id) +func ActionByAccountIDActionIDAndBudgetName(conn *budgets.Budgets, accountID, actionID, budgetName string) (*budgets.Action, error) { + input := &budgets.DescribeBudgetActionInput{ + AccountId: aws.String(accountID), + ActionId: aws.String(actionID), + BudgetName: aws.String(budgetName), + } + + output, err := conn.DescribeBudgetAction(input) + + if tfawserr.ErrCodeEquals(err, budgets.ErrCodeNotFoundException) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + if err != nil { return nil, err } - input := &budgets.DescribeBudgetActionInput{ + if output == nil || output.Action == nil { + return nil, &resource.NotFoundError{ + Message: "Empty result", + LastRequest: input, + } + } + + return output.Action, nil +} + +func BudgetByAccountIDAndBudgetName(conn *budgets.Budgets, accountID, budgetName string) (*budgets.Budget, error) { + input := &budgets.DescribeBudgetInput{ + AccountId: aws.String(accountID), BudgetName: aws.String(budgetName), + } + + output, err := conn.DescribeBudget(input) + + if tfawserr.ErrCodeEquals(err, budgets.ErrCodeNotFoundException) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + + if err != nil { + return nil, err + } + + if output == nil || output.Budget == nil { + return nil, &resource.NotFoundError{ + Message: "Empty result", + LastRequest: input, + } + } + + return output.Budget, nil +} + +func NotificationsByAccountIDAndBudgetName(conn *budgets.Budgets, accountID, budgetName string) ([]*budgets.Notification, error) { + input := &budgets.DescribeNotificationsForBudgetInput{ AccountId: aws.String(accountID), - ActionId: aws.String(actionID), + BudgetName: aws.String(budgetName), + } + var output []*budgets.Notification + + err := conn.DescribeNotificationsForBudgetPages(input, func(page *budgets.DescribeNotificationsForBudgetOutput, lastPage bool) bool { + if page == nil { + return !lastPage + } + + for _, notification := range page.Notifications { + if notification == nil { + continue + } + + output = append(output, notification) + } + + return !lastPage + }) + + if tfawserr.ErrCodeEquals(err, budgets.ErrCodeNotFoundException) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } } - out, err := conn.DescribeBudgetAction(input) if err != nil { return nil, err } - return out, nil + if len(output) == 0 { + return nil, &resource.NotFoundError{ + Message: "Empty result", + LastRequest: input, + } + } + + return output, nil +} + +func SubscribersByAccountIDBudgetNameAndNotification(conn *budgets.Budgets, accountID, budgetName string, notification *budgets.Notification) ([]*budgets.Subscriber, error) { + input := &budgets.DescribeSubscribersForNotificationInput{ + AccountId: aws.String(accountID), + BudgetName: aws.String(budgetName), + Notification: notification, + } + var output []*budgets.Subscriber + + err := conn.DescribeSubscribersForNotificationPages(input, func(page *budgets.DescribeSubscribersForNotificationOutput, lastPage bool) bool { + if page == nil { + return !lastPage + } + + for _, subscriber := range page.Subscribers { + if subscriber == nil { + continue + } + + output = append(output, subscriber) + } + + return !lastPage + }) + + if tfawserr.ErrCodeEquals(err, budgets.ErrCodeNotFoundException) { + return nil, &resource.NotFoundError{ + LastError: err, + LastRequest: input, + } + } + + if err != nil { + return nil, err + } + + if len(output) == 0 { + return nil, &resource.NotFoundError{ + Message: "Empty result", + LastRequest: input, + } + } + + return output, nil } diff --git a/aws/internal/service/budgets/id.go b/aws/internal/service/budgets/id.go index 63d140c7fae..f74bd5665e9 100644 --- a/aws/internal/service/budgets/id.go +++ b/aws/internal/service/budgets/id.go @@ -1,14 +1,44 @@ -package glue +package budgets import ( "fmt" "strings" ) -func DecodeBudgetsBudgetActionID(id string) (string, string, string, error) { - parts := strings.Split(id, ":") - if len(parts) != 3 { - return "", "", "", fmt.Errorf("Unexpected format of ID (%q), expected AccountID:ActionID:BudgetName", id) +const budgetActionResourceIDSeparator = ":" + +func BudgetActionCreateResourceID(accountID, actionID, budgetName string) string { + parts := []string{accountID, actionID, budgetName} + id := strings.Join(parts, budgetActionResourceIDSeparator) + + return id +} + +func BudgetActionParseResourceID(id string) (string, string, string, error) { + parts := strings.Split(id, budgetActionResourceIDSeparator) + + if len(parts) == 3 && parts[0] != "" && parts[1] != "" && parts[2] != "" { + return parts[0], parts[1], parts[2], nil } - return parts[0], parts[1], parts[2], nil + + return "", "", "", fmt.Errorf("unexpected format for ID (%[1]s), expected AccountID%[2]sActionID%[2]sBudgetName", id, budgetActionResourceIDSeparator) +} + +const budgetResourceIDSeparator = ":" + +func BudgetCreateResourceID(accountID, budgetName string) string { + parts := []string{accountID, budgetName} + id := strings.Join(parts, budgetResourceIDSeparator) + + return id +} + +func BudgetParseResourceID(id string) (string, string, error) { + parts := strings.Split(id, budgetResourceIDSeparator) + + if len(parts) == 2 && parts[0] != "" && parts[1] != "" { + return parts[0], parts[1], nil + } + + return "", "", fmt.Errorf("unexpected format for ID (%[1]s), expected AccountID%[2]sBudgetName", id, budgetActionResourceIDSeparator) } diff --git a/aws/internal/service/budgets/time.go b/aws/internal/service/budgets/time.go new file mode 100644 index 00000000000..ab2d4acac24 --- /dev/null +++ b/aws/internal/service/budgets/time.go @@ -0,0 +1,44 @@ +package budgets + +import ( + "fmt" + "time" + + "github.com/aws/aws-sdk-go/aws" +) + +const ( + timePeriodLayout = "2006-01-02_15:04" +) + +func TimePeriodTimestampFromString(s string) (*time.Time, error) { + if s == "" { + return nil, nil + } + + ts, err := time.Parse(timePeriodLayout, s) + + if err != nil { + return nil, err + } + + return aws.Time(ts), nil +} + +func TimePeriodTimestampToString(ts *time.Time) string { + if ts == nil { + return "" + } + + return aws.TimeValue(ts).Format(timePeriodLayout) +} + +func ValidateTimePeriodTimestamp(v interface{}, k string) (ws []string, errors []error) { + _, err := time.Parse(timePeriodLayout, v.(string)) + + if err != nil { + errors = append(errors, fmt.Errorf("%q cannot be parsed as %q: %w", k, timePeriodLayout, err)) + } + + return +} diff --git a/aws/internal/service/budgets/waiter/status.go b/aws/internal/service/budgets/waiter/status.go index 46f79acfb23..22f5f2f3aa4 100644 --- a/aws/internal/service/budgets/waiter/status.go +++ b/aws/internal/service/budgets/waiter/status.go @@ -3,22 +3,23 @@ package waiter import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/budgets" - "github.com/hashicorp/aws-sdk-go-base/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets/finder" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" ) -func ActionStatus(conn *budgets.Budgets, id string) resource.StateRefreshFunc { +func ActionStatus(conn *budgets.Budgets, accountID, actionID, budgetName string) resource.StateRefreshFunc { return func() (interface{}, string, error) { - out, err := finder.ActionById(conn, id) + output, err := finder.ActionByAccountIDActionIDAndBudgetName(conn, accountID, actionID, budgetName) + + if tfresource.NotFound(err) { + return nil, "", nil + } + if err != nil { - if tfawserr.ErrCodeEquals(err, budgets.ErrCodeNotFoundException) { - return nil, "", nil - } return nil, "", err } - action := out.Action - return action, aws.StringValue(action.Status), err + return output, aws.StringValue(output.Status), nil } } diff --git a/aws/internal/service/budgets/waiter/waiter.go b/aws/internal/service/budgets/waiter/waiter.go index 7aa9e7bc63b..208f81336b2 100644 --- a/aws/internal/service/budgets/waiter/waiter.go +++ b/aws/internal/service/budgets/waiter/waiter.go @@ -11,7 +11,7 @@ const ( ActionAvailableTimeout = 2 * time.Minute ) -func ActionAvailable(conn *budgets.Budgets, id string) (*budgets.Action, error) { +func ActionAvailable(conn *budgets.Budgets, accountID, actionID, budgetName string) (*budgets.Action, error) { stateConf := &resource.StateChangeConf{ Pending: []string{ budgets.ActionStatusExecutionInProgress, @@ -22,7 +22,7 @@ func ActionAvailable(conn *budgets.Budgets, id string) (*budgets.Action, error) budgets.ActionStatusExecutionFailure, budgets.ActionStatusPending, }, - Refresh: ActionStatus(conn, id), + Refresh: ActionStatus(conn, accountID, actionID, budgetName), Timeout: ActionAvailableTimeout, } diff --git a/aws/resource_aws_budgets_budget.go b/aws/resource_aws_budgets_budget.go index 7979bae02c1..95698350dcf 100644 --- a/aws/resource_aws_budgets_budget.go +++ b/aws/resource_aws_budgets_budget.go @@ -4,23 +4,32 @@ import ( "fmt" "log" "strings" - "time" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/arn" "github.com/aws/aws-sdk-go/service/budgets" - "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" + "github.com/shopspring/decimal" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/naming" + tfbudgets "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets/finder" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" ) func resourceAwsBudgetsBudget() *schema.Resource { return &schema.Resource{ + Create: resourceAwsBudgetsBudgetCreate, + Read: resourceAwsBudgetsBudgetRead, + Update: resourceAwsBudgetsBudgetUpdate, + Delete: resourceAwsBudgetsBudgetDelete, + + Importer: &schema.ResourceImporter{ + State: schema.ImportStatePassthrough, + }, + Schema: map[string]*schema.Schema{ - "arn": { - Type: schema.TypeString, - Computed: true, - }, "account_id": { Type: schema.TypeString, Computed: true, @@ -28,31 +37,42 @@ func resourceAwsBudgetsBudget() *schema.Resource { ForceNew: true, ValidateFunc: validateAwsAccountId, }, - "name": { - Type: schema.TypeString, - Computed: true, - Optional: true, - ForceNew: true, - ConflictsWith: []string{"name_prefix"}, - }, - "name_prefix": { + "arn": { Type: schema.TypeString, Computed: true, - Optional: true, - ForceNew: true, }, "budget_type": { Type: schema.TypeString, Required: true, ValidateFunc: validation.StringInSlice(budgets.BudgetType_Values(), false), }, - "limit_amount": { - Type: schema.TypeString, - Required: true, + "cost_filters": { + Type: schema.TypeMap, + Optional: true, + Computed: true, + Elem: &schema.Schema{Type: schema.TypeString}, + ConflictsWith: []string{"cost_filter"}, }, - "limit_unit": { - Type: schema.TypeString, - Required: true, + "cost_filter": { + Type: schema.TypeSet, + Optional: true, + Computed: true, + Elem: &schema.Resource{ + Schema: map[string]*schema.Schema{ + "name": { + Type: schema.TypeString, + Required: true, + }, + "values": { + Type: schema.TypeList, + Required: true, + Elem: &schema.Schema{ + Type: schema.TypeString, + }, + }, + }, + }, + ConflictsWith: []string{"cost_filters"}, }, "cost_types": { Type: schema.TypeList, @@ -119,25 +139,28 @@ func resourceAwsBudgetsBudget() *schema.Resource { }, }, }, - "time_period_start": { - Type: schema.TypeString, - Required: true, + "limit_amount": { + Type: schema.TypeString, + Required: true, + DiffSuppressFunc: suppressEquivalentBudgetLimitAmount, }, - "time_period_end": { + "limit_unit": { Type: schema.TypeString, - Optional: true, - Default: "2087-06-15_00:00", + Required: true, }, - "time_unit": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice(budgets.TimeUnit_Values(), false), + "name": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + ConflictsWith: []string{"name_prefix"}, }, - "cost_filters": { - Type: schema.TypeMap, - Optional: true, - Computed: true, - Elem: &schema.Schema{Type: schema.TypeString}, + "name_prefix": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ForceNew: true, + ConflictsWith: []string{"name"}, }, "notification": { Type: schema.TypeSet, @@ -149,15 +172,6 @@ func resourceAwsBudgetsBudget() *schema.Resource { Required: true, ValidateFunc: validation.StringInSlice(budgets.ComparisonOperator_Values(), false), }, - "threshold": { - Type: schema.TypeFloat, - Required: true, - }, - "threshold_type": { - Type: schema.TypeString, - Required: true, - ValidateFunc: validation.StringInSlice(budgets.ThresholdType_Values(), false), - }, "notification_type": { Type: schema.TypeString, Required: true, @@ -176,41 +190,52 @@ func resourceAwsBudgetsBudget() *schema.Resource { ValidateFunc: validateArn, }, }, + "threshold": { + Type: schema.TypeFloat, + Required: true, + }, + "threshold_type": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice(budgets.ThresholdType_Values(), false), + }, }, }, }, - }, - Create: resourceAwsBudgetsBudgetCreate, - Read: resourceAwsBudgetsBudgetRead, - Update: resourceAwsBudgetsBudgetUpdate, - Delete: resourceAwsBudgetsBudgetDelete, - Importer: &schema.ResourceImporter{ - State: schema.ImportStatePassthrough, + "time_period_end": { + Type: schema.TypeString, + Optional: true, + Default: "2087-06-15_00:00", + ValidateFunc: tfbudgets.ValidateTimePeriodTimestamp, + }, + "time_period_start": { + Type: schema.TypeString, + Optional: true, + Computed: true, + ValidateFunc: tfbudgets.ValidateTimePeriodTimestamp, + }, + "time_unit": { + Type: schema.TypeString, + Required: true, + ValidateFunc: validation.StringInSlice(budgets.TimeUnit_Values(), false), + }, }, } } func resourceAwsBudgetsBudgetCreate(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).budgetconn + budget, err := expandBudgetsBudgetUnmarshal(d) if err != nil { return fmt.Errorf("failed unmarshalling budget: %v", err) } - if v, ok := d.GetOk("name"); ok { - budget.BudgetName = aws.String(v.(string)) + name := naming.Generate(d.Get("name").(string), d.Get("name_prefix").(string)) + budget.BudgetName = aws.String(name) - } else if v, ok := d.GetOk("name_prefix"); ok { - budget.BudgetName = aws.String(resource.PrefixedUniqueId(v.(string))) - - } else { - budget.BudgetName = aws.String(resource.UniqueId()) - } - - conn := meta.(*AWSClient).budgetconn - var accountID string - if v, ok := d.GetOk("account_id"); ok { - accountID = v.(string) - } else { + accountID := d.Get("account_id").(string) + if accountID == "" { accountID = meta.(*AWSClient).accountid } @@ -218,8 +243,9 @@ func resourceAwsBudgetsBudgetCreate(d *schema.ResourceData, meta interface{}) er AccountId: aws.String(accountID), Budget: budget, }) + if err != nil { - return fmt.Errorf("create budget failed: %v", err) + return fmt.Errorf("error creating Budget (%s): %w", name, err) } d.SetId(fmt.Sprintf("%s:%s", accountID, aws.StringValue(budget.BudgetName))) @@ -236,104 +262,47 @@ func resourceAwsBudgetsBudgetCreate(d *schema.ResourceData, meta interface{}) er return resourceAwsBudgetsBudgetRead(d, meta) } -func resourceAwsBudgetsBudgetNotificationsCreate(notifications []*budgets.Notification, subscribers [][]*budgets.Subscriber, budgetName string, accountID string, meta interface{}) error { +func resourceAwsBudgetsBudgetRead(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).budgetconn - for i, notification := range notifications { - subscribers := subscribers[i] - if len(subscribers) == 0 { - return fmt.Errorf("Notification must have at least one subscriber!") - } - _, err := conn.CreateNotification(&budgets.CreateNotificationInput{ - BudgetName: aws.String(budgetName), - AccountId: aws.String(accountID), - Notification: notification, - Subscribers: subscribers, - }) - - if err != nil { - return err - } - } - - return nil -} - -func expandBudgetNotificationsUnmarshal(notificationsRaw []interface{}) ([]*budgets.Notification, [][]*budgets.Subscriber) { - - notifications := make([]*budgets.Notification, len(notificationsRaw)) - subscribersForNotifications := make([][]*budgets.Subscriber, len(notificationsRaw)) - for i, notificationRaw := range notificationsRaw { - notificationRaw := notificationRaw.(map[string]interface{}) - comparisonOperator := notificationRaw["comparison_operator"].(string) - threshold := notificationRaw["threshold"].(float64) - thresholdType := notificationRaw["threshold_type"].(string) - notificationType := notificationRaw["notification_type"].(string) - - notifications[i] = &budgets.Notification{ - ComparisonOperator: aws.String(comparisonOperator), - Threshold: aws.Float64(threshold), - ThresholdType: aws.String(thresholdType), - NotificationType: aws.String(notificationType), - } - - emailSubscribers := expandBudgetSubscribers(notificationRaw["subscriber_email_addresses"], budgets.SubscriptionTypeEmail) - snsSubscribers := expandBudgetSubscribers(notificationRaw["subscriber_sns_topic_arns"], budgets.SubscriptionTypeSns) + accountID, budgetName, err := tfbudgets.BudgetParseResourceID(d.Id()) - subscribersForNotifications[i] = append(emailSubscribers, snsSubscribers...) - } - return notifications, subscribersForNotifications -} - -func expandBudgetSubscribers(rawList interface{}, subscriptionType string) []*budgets.Subscriber { - result := make([]*budgets.Subscriber, 0) - addrs := expandStringSet(rawList.(*schema.Set)) - for _, addr := range addrs { - result = append(result, &budgets.Subscriber{ - SubscriptionType: aws.String(subscriptionType), - Address: addr, - }) - } - return result -} - -func resourceAwsBudgetsBudgetRead(d *schema.ResourceData, meta interface{}) error { - accountID, budgetName, err := decodeBudgetsBudgetID(d.Id()) if err != nil { return err } - conn := meta.(*AWSClient).budgetconn - describeBudgetOutput, err := conn.DescribeBudget(&budgets.DescribeBudgetInput{ - BudgetName: aws.String(budgetName), - AccountId: aws.String(accountID), - }) - if isAWSErr(err, budgets.ErrCodeNotFoundException, "") { - log.Printf("[WARN] Budget %s not found, removing from state", d.Id()) + budget, err := finder.BudgetByAccountIDAndBudgetName(conn, accountID, budgetName) + + if !d.IsNewResource() && tfresource.NotFound(err) { + log.Printf("[WARN] Budget (%s) not found, removing from state", d.Id()) d.SetId("") return nil } if err != nil { - return fmt.Errorf("describe budget failed: %v", err) - } - - budget := describeBudgetOutput.Budget - if budget == nil { - log.Printf("[WARN] Budget %s not found, removing from state", d.Id()) - d.SetId("") - return nil + return fmt.Errorf("error reading Budget (%s): %w", d.Id(), err) } d.Set("account_id", accountID) + arn := arn.ARN{ + Partition: meta.(*AWSClient).partition, + Service: "budgets", + AccountID: accountID, + Resource: fmt.Sprintf("budget/%s", budgetName), + } + d.Set("arn", arn.String()) d.Set("budget_type", budget.BudgetType) + // `cost_filters` should be removed in future releases + if err := d.Set("cost_filter", convertCostFiltersToMap(budget.CostFilters)); err != nil { + return fmt.Errorf("error setting cost_filter: %w", err) + } if err := d.Set("cost_filters", convertCostFiltersToStringMap(budget.CostFilters)); err != nil { - return fmt.Errorf("error setting cost_filters: %s", err) + return fmt.Errorf("error setting cost_filters: %w", err) } if err := d.Set("cost_types", flattenBudgetsCostTypes(budget.CostTypes)); err != nil { - return fmt.Errorf("error setting cost_types: %s %s", err, budget.CostTypes) + return fmt.Errorf("error setting cost_types: %w", err) } if budget.BudgetLimit != nil { @@ -342,103 +311,86 @@ func resourceAwsBudgetsBudgetRead(d *schema.ResourceData, meta interface{}) erro } d.Set("name", budget.BudgetName) + d.Set("name_prefix", naming.NamePrefixFromName(aws.StringValue(budget.BudgetName))) if budget.TimePeriod != nil { - d.Set("time_period_end", aws.TimeValue(budget.TimePeriod.End).Format("2006-01-02_15:04")) - d.Set("time_period_start", aws.TimeValue(budget.TimePeriod.Start).Format("2006-01-02_15:04")) + d.Set("time_period_end", tfbudgets.TimePeriodTimestampToString(budget.TimePeriod.End)) + d.Set("time_period_start", tfbudgets.TimePeriodTimestampToString(budget.TimePeriod.Start)) } d.Set("time_unit", budget.TimeUnit) - arn := arn.ARN{ - Partition: meta.(*AWSClient).partition, - Service: "budgetservice", - AccountID: meta.(*AWSClient).accountid, - Resource: fmt.Sprintf("budget/%s", aws.StringValue(budget.BudgetName)), - } - d.Set("arn", arn.String()) - - return resourceAwsBudgetsBudgetNotificationRead(d, meta) -} - -func resourceAwsBudgetsBudgetNotificationRead(d *schema.ResourceData, meta interface{}) error { - conn := meta.(*AWSClient).budgetconn + notifications, err := finder.NotificationsByAccountIDAndBudgetName(conn, accountID, budgetName) - accountID, budgetName, err := decodeBudgetsBudgetID(d.Id()) - - if err != nil { - return fmt.Errorf("error decoding Budget (%s) ID: %s", d.Id(), err) + if tfresource.NotFound(err) { + return nil } - describeNotificationsForBudgetOutput, err := conn.DescribeNotificationsForBudget(&budgets.DescribeNotificationsForBudgetInput{ - BudgetName: aws.String(budgetName), - AccountId: aws.String(accountID), - }) - if err != nil { - return fmt.Errorf("error describing Budget (%s) Notifications: %s", d.Id(), err) + return fmt.Errorf("error reading Budget (%s) notifications: %w", d.Id(), err) } - notifications := make([]map[string]interface{}, 0) + var tfList []interface{} - for _, notificationOutput := range describeNotificationsForBudgetOutput.Notifications { - notification := make(map[string]interface{}) + for _, notification := range notifications { + tfMap := make(map[string]interface{}) - notification["comparison_operator"] = aws.StringValue(notificationOutput.ComparisonOperator) - notification["threshold"] = aws.Float64Value(notificationOutput.Threshold) - notification["notification_type"] = aws.StringValue(notificationOutput.NotificationType) + tfMap["comparison_operator"] = aws.StringValue(notification.ComparisonOperator) + tfMap["threshold"] = aws.Float64Value(notification.Threshold) + tfMap["notification_type"] = aws.StringValue(notification.NotificationType) - if notificationOutput.ThresholdType == nil { + if notification.ThresholdType == nil { // The AWS API doesn't seem to return a ThresholdType if it's set to PERCENTAGE // Set it manually to make behavior more predictable - notification["threshold_type"] = budgets.ThresholdTypePercentage + tfMap["threshold_type"] = budgets.ThresholdTypePercentage } else { - notification["threshold_type"] = aws.StringValue(notificationOutput.ThresholdType) + tfMap["threshold_type"] = aws.StringValue(notification.ThresholdType) } - subscribersOutput, err := conn.DescribeSubscribersForNotification(&budgets.DescribeSubscribersForNotificationInput{ - BudgetName: aws.String(budgetName), - AccountId: aws.String(accountID), - Notification: notificationOutput, - }) + subscribers, err := finder.SubscribersByAccountIDBudgetNameAndNotification(conn, accountID, budgetName, notification) + + if tfresource.NotFound(err) { + tfList = append(tfList, tfMap) + continue + } if err != nil { - return fmt.Errorf("error describing Budget (%s) Notification Subscribers: %s", d.Id(), err) + return fmt.Errorf("error reading Budget (%s) subscribers: %w", d.Id(), err) } - snsSubscribers := make([]interface{}, 0) - emailSubscribers := make([]interface{}, 0) + var emailSubscribers []string + var snsSubscribers []string - for _, subscriberOutput := range subscribersOutput.Subscribers { - if aws.StringValue(subscriberOutput.SubscriptionType) == budgets.SubscriptionTypeSns { - snsSubscribers = append(snsSubscribers, *subscriberOutput.Address) - } else if aws.StringValue(subscriberOutput.SubscriptionType) == budgets.SubscriptionTypeEmail { - emailSubscribers = append(emailSubscribers, *subscriberOutput.Address) + for _, subscriber := range subscribers { + if aws.StringValue(subscriber.SubscriptionType) == budgets.SubscriptionTypeSns { + snsSubscribers = append(snsSubscribers, aws.StringValue(subscriber.Address)) + } else if aws.StringValue(subscriber.SubscriptionType) == budgets.SubscriptionTypeEmail { + emailSubscribers = append(emailSubscribers, aws.StringValue(subscriber.Address)) } } - if len(snsSubscribers) > 0 { - notification["subscriber_sns_topic_arns"] = schema.NewSet(schema.HashString, snsSubscribers) - } - if len(emailSubscribers) > 0 { - notification["subscriber_email_addresses"] = schema.NewSet(schema.HashString, emailSubscribers) - } - notifications = append(notifications, notification) + + tfMap["subscriber_email_addresses"] = emailSubscribers + tfMap["subscriber_sns_topic_arns"] = snsSubscribers + + tfList = append(tfList, tfMap) } - if err := d.Set("notification", notifications); err != nil { - return fmt.Errorf("error setting notification: %s %s", err, describeNotificationsForBudgetOutput.Notifications) + if err := d.Set("notification", tfList); err != nil { + return fmt.Errorf("error setting notification: %w", err) } return nil } func resourceAwsBudgetsBudgetUpdate(d *schema.ResourceData, meta interface{}) error { - accountID, _, err := decodeBudgetsBudgetID(d.Id()) + conn := meta.(*AWSClient).budgetconn + + accountID, _, err := tfbudgets.BudgetParseResourceID(d.Id()) + if err != nil { return err } - conn := meta.(*AWSClient).budgetconn budget, err := expandBudgetsBudgetUnmarshal(d) if err != nil { return fmt.Errorf("could not create budget: %v", err) @@ -460,9 +412,60 @@ func resourceAwsBudgetsBudgetUpdate(d *schema.ResourceData, meta interface{}) er return resourceAwsBudgetsBudgetRead(d, meta) } + +func resourceAwsBudgetsBudgetDelete(d *schema.ResourceData, meta interface{}) error { + conn := meta.(*AWSClient).budgetconn + + accountID, budgetName, err := tfbudgets.BudgetParseResourceID(d.Id()) + + if err != nil { + return err + } + + log.Printf("[DEBUG] Deleting Budget: %s", d.Id()) + _, err = conn.DeleteBudget(&budgets.DeleteBudgetInput{ + AccountId: aws.String(accountID), + BudgetName: aws.String(budgetName), + }) + + if tfawserr.ErrCodeEquals(err, budgets.ErrCodeNotFoundException) { + return nil + } + + if err != nil { + return fmt.Errorf("error deleting Budget (%s): %w", d.Id(), err) + } + + return nil +} + +func resourceAwsBudgetsBudgetNotificationsCreate(notifications []*budgets.Notification, subscribers [][]*budgets.Subscriber, budgetName string, accountID string, meta interface{}) error { + conn := meta.(*AWSClient).budgetconn + + for i, notification := range notifications { + subscribers := subscribers[i] + if len(subscribers) == 0 { + return fmt.Errorf("Notification must have at least one subscriber!") + } + _, err := conn.CreateNotification(&budgets.CreateNotificationInput{ + BudgetName: aws.String(budgetName), + AccountId: aws.String(accountID), + Notification: notification, + Subscribers: subscribers, + }) + + if err != nil { + return err + } + } + + return nil +} + func resourceAwsBudgetsBudgetNotificationsUpdate(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).budgetconn - accountID, budgetName, err := decodeBudgetsBudgetID(d.Id()) + + accountID, budgetName, err := tfbudgets.BudgetParseResourceID(d.Id()) if err != nil { return err @@ -500,29 +503,6 @@ func resourceAwsBudgetsBudgetNotificationsUpdate(d *schema.ResourceData, meta in return nil } -func resourceAwsBudgetsBudgetDelete(d *schema.ResourceData, meta interface{}) error { - accountID, budgetName, err := decodeBudgetsBudgetID(d.Id()) - if err != nil { - return err - } - - conn := meta.(*AWSClient).budgetconn - _, err = conn.DeleteBudget(&budgets.DeleteBudgetInput{ - BudgetName: aws.String(budgetName), - AccountId: aws.String(accountID), - }) - if err != nil { - if isAWSErr(err, budgets.ErrCodeNotFoundException, "") { - log.Printf("[INFO] budget %s could not be found. skipping delete.", d.Id()) - return nil - } - - return fmt.Errorf("delete budget failed: %v", err) - } - - return nil -} - func flattenBudgetsCostTypes(costTypes *budgets.CostTypes) []map[string]interface{} { if costTypes == nil { return []map[string]interface{}{} @@ -544,6 +524,22 @@ func flattenBudgetsCostTypes(costTypes *budgets.CostTypes) []map[string]interfac return []map[string]interface{}{m} } +func convertCostFiltersToMap(costFilters map[string][]*string) []map[string]interface{} { + convertedCostFilters := make([]map[string]interface{}, 0) + for k, v := range costFilters { + convertedCostFilter := make(map[string]interface{}) + filterValues := make([]string, 0) + for _, singleFilterValue := range v { + filterValues = append(filterValues, *singleFilterValue) + } + convertedCostFilter["values"] = filterValues + convertedCostFilter["name"] = k + convertedCostFilters = append(convertedCostFilters, convertedCostFilter) + } + + return convertedCostFilters +} + func convertCostFiltersToStringMap(costFilters map[string][]*string) map[string]string { convertedCostFilters := make(map[string]string) for k, v := range costFilters { @@ -563,22 +559,34 @@ func expandBudgetsBudgetUnmarshal(d *schema.ResourceData) (*budgets.Budget, erro budgetType := d.Get("budget_type").(string) budgetLimitAmount := d.Get("limit_amount").(string) budgetLimitUnit := d.Get("limit_unit").(string) - costTypes := expandBudgetsCostTypesUnmarshal(d.Get("cost_types").([]interface{})) budgetTimeUnit := d.Get("time_unit").(string) budgetCostFilters := make(map[string][]*string) - for k, v := range d.Get("cost_filters").(map[string]interface{}) { - filterValue := v.(string) - budgetCostFilters[k] = append(budgetCostFilters[k], aws.String(filterValue)) + + if costFilter, ok := d.GetOk("cost_filter"); ok { + for _, v := range costFilter.(*schema.Set).List() { + element := v.(map[string]interface{}) + key := element["name"].(string) + for _, filterValue := range element["values"].([]interface{}) { + budgetCostFilters[key] = append(budgetCostFilters[key], aws.String(filterValue.(string))) + } + } + } else if costFilters, ok := d.GetOk("cost_filters"); ok { + for k, v := range costFilters.(map[string]interface{}) { + filterValue := v.(string) + budgetCostFilters[k] = append(budgetCostFilters[k], aws.String(filterValue)) + } } - budgetTimePeriodStart, err := time.Parse("2006-01-02_15:04", d.Get("time_period_start").(string)) + budgetTimePeriodStart, err := tfbudgets.TimePeriodTimestampFromString(d.Get("time_period_start").(string)) + if err != nil { - return nil, fmt.Errorf("failure parsing time: %v", err) + return nil, err } - budgetTimePeriodEnd, err := time.Parse("2006-01-02_15:04", d.Get("time_period_end").(string)) + budgetTimePeriodEnd, err := tfbudgets.TimePeriodTimestampFromString(d.Get("time_period_end").(string)) + if err != nil { - return nil, fmt.Errorf("failure parsing time: %v", err) + return nil, err } budget := &budgets.Budget{ @@ -588,59 +596,115 @@ func expandBudgetsBudgetUnmarshal(d *schema.ResourceData) (*budgets.Budget, erro Amount: aws.String(budgetLimitAmount), Unit: aws.String(budgetLimitUnit), }, - CostTypes: costTypes, TimePeriod: &budgets.TimePeriod{ - End: &budgetTimePeriodEnd, - Start: &budgetTimePeriodStart, + End: budgetTimePeriodEnd, + Start: budgetTimePeriodStart, }, TimeUnit: aws.String(budgetTimeUnit), CostFilters: budgetCostFilters, } + + if v, ok := d.GetOk("cost_types"); ok && len(v.([]interface{})) > 0 && v.([]interface{})[0] != nil { + budget.CostTypes = expandBudgetsCostTypes(v.([]interface{})[0].(map[string]interface{})) + } + return budget, nil } -func decodeBudgetsBudgetID(id string) (string, string, error) { - parts := strings.Split(id, ":") - if len(parts) != 2 { - return "", "", fmt.Errorf("Unexpected format of ID (%q), expected AccountID:BudgetName", id) +func expandBudgetsCostTypes(tfMap map[string]interface{}) *budgets.CostTypes { + if tfMap == nil { + return nil + } + + apiObject := &budgets.CostTypes{} + + if v, ok := tfMap["include_credit"].(bool); ok { + apiObject.IncludeCredit = aws.Bool(v) + } + if v, ok := tfMap["include_discount"].(bool); ok { + apiObject.IncludeDiscount = aws.Bool(v) + } + if v, ok := tfMap["include_other_subscription"].(bool); ok { + apiObject.IncludeOtherSubscription = aws.Bool(v) + } + if v, ok := tfMap["include_recurring"].(bool); ok { + apiObject.IncludeRecurring = aws.Bool(v) + } + if v, ok := tfMap["include_refund"].(bool); ok { + apiObject.IncludeRefund = aws.Bool(v) + } + if v, ok := tfMap["include_subscription"].(bool); ok { + apiObject.IncludeSubscription = aws.Bool(v) + } + if v, ok := tfMap["include_support"].(bool); ok { + apiObject.IncludeSupport = aws.Bool(v) + } + if v, ok := tfMap["include_tax"].(bool); ok { + apiObject.IncludeTax = aws.Bool(v) + } + if v, ok := tfMap["include_upfront"].(bool); ok { + apiObject.IncludeUpfront = aws.Bool(v) + } + if v, ok := tfMap["use_amortized"].(bool); ok { + apiObject.UseAmortized = aws.Bool(v) + } + if v, ok := tfMap["use_blended"].(bool); ok { + apiObject.UseBlended = aws.Bool(v) } - return parts[0], parts[1], nil + + return apiObject } -func expandBudgetsCostTypesUnmarshal(budgetCostTypes []interface{}) *budgets.CostTypes { - costTypes := &budgets.CostTypes{ - IncludeCredit: aws.Bool(true), - IncludeDiscount: aws.Bool(true), - IncludeOtherSubscription: aws.Bool(true), - IncludeRecurring: aws.Bool(true), - IncludeRefund: aws.Bool(true), - IncludeSubscription: aws.Bool(true), - IncludeSupport: aws.Bool(true), - IncludeTax: aws.Bool(true), - IncludeUpfront: aws.Bool(true), - UseAmortized: aws.Bool(false), - UseBlended: aws.Bool(false), - } - if len(budgetCostTypes) == 1 { - costTypesMap := budgetCostTypes[0].(map[string]interface{}) - for k, v := range map[string]*bool{ - "include_credit": costTypes.IncludeCredit, - "include_discount": costTypes.IncludeDiscount, - "include_other_subscription": costTypes.IncludeOtherSubscription, - "include_recurring": costTypes.IncludeRecurring, - "include_refund": costTypes.IncludeRefund, - "include_subscription": costTypes.IncludeSubscription, - "include_support": costTypes.IncludeSupport, - "include_tax": costTypes.IncludeTax, - "include_upfront": costTypes.IncludeUpfront, - "use_amortized": costTypes.UseAmortized, - "use_blended": costTypes.UseBlended, - } { - if val, ok := costTypesMap[k]; ok { - *v = val.(bool) - } +func expandBudgetNotificationsUnmarshal(notificationsRaw []interface{}) ([]*budgets.Notification, [][]*budgets.Subscriber) { + + notifications := make([]*budgets.Notification, len(notificationsRaw)) + subscribersForNotifications := make([][]*budgets.Subscriber, len(notificationsRaw)) + for i, notificationRaw := range notificationsRaw { + notificationRaw := notificationRaw.(map[string]interface{}) + comparisonOperator := notificationRaw["comparison_operator"].(string) + threshold := notificationRaw["threshold"].(float64) + thresholdType := notificationRaw["threshold_type"].(string) + notificationType := notificationRaw["notification_type"].(string) + + notifications[i] = &budgets.Notification{ + ComparisonOperator: aws.String(comparisonOperator), + Threshold: aws.Float64(threshold), + ThresholdType: aws.String(thresholdType), + NotificationType: aws.String(notificationType), } + + emailSubscribers := expandBudgetSubscribers(notificationRaw["subscriber_email_addresses"], budgets.SubscriptionTypeEmail) + snsSubscribers := expandBudgetSubscribers(notificationRaw["subscriber_sns_topic_arns"], budgets.SubscriptionTypeSns) + + subscribersForNotifications[i] = append(emailSubscribers, snsSubscribers...) + } + return notifications, subscribersForNotifications +} + +func expandBudgetSubscribers(rawList interface{}, subscriptionType string) []*budgets.Subscriber { + result := make([]*budgets.Subscriber, 0) + addrs := expandStringSet(rawList.(*schema.Set)) + for _, addr := range addrs { + result = append(result, &budgets.Subscriber{ + SubscriptionType: aws.String(subscriptionType), + Address: addr, + }) + } + return result +} + +func suppressEquivalentBudgetLimitAmount(k, old, new string, d *schema.ResourceData) bool { + d1, err := decimal.NewFromString(old) + + if err != nil { + return false + } + + d2, err := decimal.NewFromString(new) + + if err != nil { + return false } - return costTypes + return d1.Equal(d2) } diff --git a/aws/resource_aws_budgets_budget_action.go b/aws/resource_aws_budgets_budget_action.go index 2cceb30a7ae..40dc9acb66c 100644 --- a/aws/resource_aws_budgets_budget_action.go +++ b/aws/resource_aws_budgets_budget_action.go @@ -8,11 +8,14 @@ import ( "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws/arn" "github.com/aws/aws-sdk-go/service/budgets" + "github.com/hashicorp/aws-sdk-go-base/tfawserr" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" tfbudgets "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets" "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets/finder" "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets/waiter" + iamwaiter "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/iam/waiter" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" ) func resourceAwsBudgetsBudgetAction() *schema.Resource { @@ -21,9 +24,11 @@ func resourceAwsBudgetsBudgetAction() *schema.Resource { Read: resourceAwsBudgetsBudgetActionRead, Update: resourceAwsBudgetsBudgetActionUpdate, Delete: resourceAwsBudgetsBudgetActionDelete, + Importer: &schema.ResourceImporter{ State: schema.ImportStatePassthrough, }, + Schema: map[string]*schema.Schema{ "arn": { Type: schema.TypeString, @@ -205,39 +210,40 @@ func resourceAwsBudgetsBudgetAction() *schema.Resource { func resourceAwsBudgetsBudgetActionCreate(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).budgetconn - var accountID string - if v, ok := d.GetOk("account_id"); ok { - accountID = v.(string) - } else { + accountID := d.Get("account_id").(string) + if accountID == "" { accountID = meta.(*AWSClient).accountid } input := &budgets.CreateBudgetActionInput{ AccountId: aws.String(accountID), - BudgetName: aws.String(d.Get("budget_name").(string)), + ActionThreshold: expandAwsBudgetsBudgetActionActionThreshold(d.Get("action_threshold").([]interface{})), ActionType: aws.String(d.Get("action_type").(string)), ApprovalModel: aws.String(d.Get("approval_model").(string)), + BudgetName: aws.String(d.Get("budget_name").(string)), + Definition: expandAwsBudgetsBudgetActionActionDefinition(d.Get("definition").([]interface{})), ExecutionRoleArn: aws.String(d.Get("execution_role_arn").(string)), NotificationType: aws.String(d.Get("notification_type").(string)), - ActionThreshold: expandAwsBudgetsBudgetActionActionThreshold(d.Get("action_threshold").([]interface{})), Subscribers: expandAwsBudgetsBudgetActionSubscriber(d.Get("subscriber").(*schema.Set)), - Definition: expandAwsBudgetsBudgetActionActionDefinition(d.Get("definition").([]interface{})), } - var output *budgets.CreateBudgetActionOutput - _, err := retryOnAwsCode(budgets.ErrCodeAccessDeniedException, func() (interface{}, error) { - var err error - output, err = conn.CreateBudgetAction(input) - return output, err - }) + log.Printf("[DEBUG] Creating Budget Action: %s", input) + outputRaw, err := tfresource.RetryWhenAwsErrCodeEquals(iamwaiter.PropagationTimeout, func() (interface{}, error) { + return conn.CreateBudgetAction(input) + }, budgets.ErrCodeAccessDeniedException) + if err != nil { - return fmt.Errorf("create Budget Action failed: %v", err) + return fmt.Errorf("error creating Budget Action: %w", err) } - d.SetId(fmt.Sprintf("%s:%s:%s", aws.StringValue(output.AccountId), aws.StringValue(output.ActionId), aws.StringValue(output.BudgetName))) + output := outputRaw.(*budgets.CreateBudgetActionOutput) + actionID := aws.StringValue(output.ActionId) + budgetName := aws.StringValue(output.BudgetName) - if _, err := waiter.ActionAvailable(conn, d.Id()); err != nil { - return fmt.Errorf("error waiting for Budget Action (%s) creation: %w", d.Id(), err) + d.SetId(tfbudgets.BudgetActionCreateResourceID(accountID, actionID, budgetName)) + + if _, err := waiter.ActionAvailable(conn, accountID, actionID, budgetName); err != nil { + return fmt.Errorf("error waiting for Budget Action (%s) to create: %w", d.Id(), err) } return resourceAwsBudgetsBudgetActionRead(d, meta) @@ -245,52 +251,53 @@ func resourceAwsBudgetsBudgetActionCreate(d *schema.ResourceData, meta interface func resourceAwsBudgetsBudgetActionRead(d *schema.ResourceData, meta interface{}) error { conn := meta.(*AWSClient).budgetconn - out, err := finder.ActionById(conn, d.Id()) - if isAWSErr(err, budgets.ErrCodeNotFoundException, "") { - log.Printf("[WARN] Budget Action %s not found, removing from state", d.Id()) + + accountID, actionID, budgetName, err := tfbudgets.BudgetActionParseResourceID(d.Id()) + + if err != nil { + return err + } + + output, err := finder.ActionByAccountIDActionIDAndBudgetName(conn, accountID, actionID, budgetName) + + if !d.IsNewResource() && tfresource.NotFound(err) { + log.Printf("[WARN] Budget Action (%s) not found, removing from state", d.Id()) d.SetId("") return nil } if err != nil { - return fmt.Errorf("describe Budget Action failed: %w", err) + return fmt.Errorf("error reading Budget Action (%s): %w", d.Id(), err) } - action := out.Action - if action == nil { - log.Printf("[WARN] Budget Action %s not found, removing from state", d.Id()) - d.SetId("") - return nil + d.Set("account_id", accountID) + d.Set("action_id", actionID) + + if err := d.Set("action_threshold", flattenAwsBudgetsBudgetActionActionThreshold(output.ActionThreshold)); err != nil { + return fmt.Errorf("error setting action_threshold: %w", err) } - budgetName := aws.StringValue(out.BudgetName) - actId := aws.StringValue(action.ActionId) - d.Set("account_id", out.AccountId) + d.Set("action_type", output.ActionType) + d.Set("approval_model", output.ApprovalModel) d.Set("budget_name", budgetName) - d.Set("action_id", actId) - d.Set("action_type", action.ActionType) - d.Set("approval_model", action.ApprovalModel) - d.Set("execution_role_arn", action.ExecutionRoleArn) - d.Set("notification_type", action.NotificationType) - d.Set("status", action.Status) - - if err := d.Set("subscriber", flattenAwsBudgetsBudgetActionSubscriber(action.Subscribers)); err != nil { - return fmt.Errorf("error setting subscriber: %w", err) - } - if err := d.Set("definition", flattenAwsBudgetsBudgetActionDefinition(action.Definition)); err != nil { + if err := d.Set("definition", flattenAwsBudgetsBudgetActionDefinition(output.Definition)); err != nil { return fmt.Errorf("error setting definition: %w", err) } - if err := d.Set("action_threshold", flattenAwsBudgetsBudgetActionActionThreshold(action.ActionThreshold)); err != nil { - return fmt.Errorf("error setting action_threshold: %w", err) + d.Set("execution_role_arn", output.ExecutionRoleArn) + d.Set("notification_type", output.NotificationType) + d.Set("status", output.Status) + + if err := d.Set("subscriber", flattenAwsBudgetsBudgetActionSubscriber(output.Subscribers)); err != nil { + return fmt.Errorf("error setting subscriber: %w", err) } arn := arn.ARN{ Partition: meta.(*AWSClient).partition, - Service: "budgetservice", + Service: "budgets", AccountID: meta.(*AWSClient).accountid, - Resource: fmt.Sprintf("budget/%s/action/%s", budgetName, actId), + Resource: fmt.Sprintf("budget/%s/action/%s", budgetName, actionID), } d.Set("arn", arn.String()) @@ -298,22 +305,32 @@ func resourceAwsBudgetsBudgetActionRead(d *schema.ResourceData, meta interface{} } func resourceAwsBudgetsBudgetActionUpdate(d *schema.ResourceData, meta interface{}) error { - accountID, actionID, budgetName, err := tfbudgets.DecodeBudgetsBudgetActionID(d.Id()) + conn := meta.(*AWSClient).budgetconn + + accountID, actionID, budgetName, err := tfbudgets.BudgetActionParseResourceID(d.Id()) + if err != nil { return err } - conn := meta.(*AWSClient).budgetconn input := &budgets.UpdateBudgetActionInput{ - BudgetName: aws.String(budgetName), AccountId: aws.String(accountID), ActionId: aws.String(actionID), + BudgetName: aws.String(budgetName), + } + + if d.HasChange("action_threshold") { + input.ActionThreshold = expandAwsBudgetsBudgetActionActionThreshold(d.Get("action_threshold").([]interface{})) } if d.HasChange("approval_model") { input.ApprovalModel = aws.String(d.Get("approval_model").(string)) } + if d.HasChange("definition") { + input.Definition = expandAwsBudgetsBudgetActionActionDefinition(d.Get("definition").([]interface{})) + } + if d.HasChange("execution_role_arn") { input.ExecutionRoleArn = aws.String(d.Get("execution_role_arn").(string)) } @@ -322,49 +339,46 @@ func resourceAwsBudgetsBudgetActionUpdate(d *schema.ResourceData, meta interface input.NotificationType = aws.String(d.Get("notification_type").(string)) } - if d.HasChange("action_threshold") { - input.ActionThreshold = expandAwsBudgetsBudgetActionActionThreshold(d.Get("action_threshold").([]interface{})) - } - if d.HasChange("subscriber") { input.Subscribers = expandAwsBudgetsBudgetActionSubscriber(d.Get("subscriber").(*schema.Set)) } - if d.HasChange("definition") { - input.Definition = expandAwsBudgetsBudgetActionActionDefinition(d.Get("definition").([]interface{})) - } - + log.Printf("[DEBUG] Updating Budget Action: %s", input) _, err = conn.UpdateBudgetAction(input) + if err != nil { - return fmt.Errorf("Updating Budget Action failed: %w", err) + return fmt.Errorf("error updating Budget Action (%s): %w", d.Id(), err) } - if _, err := waiter.ActionAvailable(conn, d.Id()); err != nil { - return fmt.Errorf("error waiting for Budget Action (%s) update: %w", d.Id(), err) + if _, err := waiter.ActionAvailable(conn, accountID, actionID, budgetName); err != nil { + return fmt.Errorf("error waiting for Budget Action (%s) to update: %w", d.Id(), err) } return resourceAwsBudgetsBudgetActionRead(d, meta) } func resourceAwsBudgetsBudgetActionDelete(d *schema.ResourceData, meta interface{}) error { - accountID, actionID, budgetName, err := tfbudgets.DecodeBudgetsBudgetActionID(d.Id()) + conn := meta.(*AWSClient).budgetconn + + accountID, actionID, budgetName, err := tfbudgets.BudgetActionParseResourceID(d.Id()) + if err != nil { return err } - conn := meta.(*AWSClient).budgetconn + log.Printf("[DEBUG] Deleting Budget Action: %s", d.Id()) _, err = conn.DeleteBudgetAction(&budgets.DeleteBudgetActionInput{ - BudgetName: aws.String(budgetName), AccountId: aws.String(accountID), ActionId: aws.String(actionID), + BudgetName: aws.String(budgetName), }) - if err != nil { - if isAWSErr(err, budgets.ErrCodeNotFoundException, "") { - log.Printf("[INFO] Budget Action %s could not be found. skipping delete.", d.Id()) - return nil - } - return fmt.Errorf("Deleting Budget Action failed: %w", err) + if tfawserr.ErrCodeEquals(err, budgets.ErrCodeNotFoundException) { + return nil + } + + if err != nil { + return fmt.Errorf("error deleting Budget Action (%s): %w", d.Id(), err) } return nil diff --git a/aws/resource_aws_budgets_budget_action_test.go b/aws/resource_aws_budgets_budget_action_test.go index e8ee74e0949..907c1205c71 100644 --- a/aws/resource_aws_budgets_budget_action_test.go +++ b/aws/resource_aws_budgets_budget_action_test.go @@ -12,7 +12,9 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + tfbudgets "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets" "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets/finder" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" ) func init() { @@ -87,7 +89,7 @@ func TestAccAWSBudgetsBudgetAction_basic(t *testing.T) { Config: testAccAWSBudgetsBudgetActionConfigBasic(rName), Check: resource.ComposeTestCheckFunc( testAccAWSBudgetsBudgetActionExists(resourceName, &conf), - testAccMatchResourceAttrGlobalARN(resourceName, "arn", "budgetservice", regexp.MustCompile(fmt.Sprintf(`budget/%s/action/.+`, rName))), + testAccMatchResourceAttrGlobalARN(resourceName, "arn", "budgets", regexp.MustCompile(fmt.Sprintf(`budget/%s/action/.+`, rName))), resource.TestCheckResourceAttrPair(resourceName, "budget_name", "aws_budgets_budget.test", "name"), resource.TestCheckResourceAttrPair(resourceName, "execution_role_arn", "aws_iam_role.test", "arn"), resource.TestCheckResourceAttr(resourceName, "action_type", "APPLY_IAM_POLICY"), @@ -142,35 +144,55 @@ func testAccAWSBudgetsBudgetActionExists(resourceName string, config *budgets.Ac return fmt.Errorf("Not found: %s", resourceName) } + if rs.Primary.ID == "" { + return fmt.Errorf("No Budget Action ID is set") + } + conn := testAccProvider.Meta().(*AWSClient).budgetconn - out, err := finder.ActionById(conn, rs.Primary.ID) + + accountID, actionID, budgetName, err := tfbudgets.BudgetActionParseResourceID(rs.Primary.ID) if err != nil { - return fmt.Errorf("Describe budget action error: %v", err) + return err } - if out.Action == nil { - return fmt.Errorf("No budget Action returned %v in %v", out.Action, out) + output, err := finder.ActionByAccountIDActionIDAndBudgetName(conn, accountID, actionID, budgetName) + + if err != nil { + return err } - *out.Action = *config + *config = *output return nil } } func testAccAWSBudgetsBudgetActionDestroy(s *terraform.State) error { - meta := testAccProvider.Meta() - conn := meta.(*AWSClient).budgetconn + conn := testAccProvider.Meta().(*AWSClient).budgetconn + for _, rs := range s.RootModule().Resources { if rs.Type != "aws_budgets_budget_action" { continue } - _, err := finder.ActionById(conn, rs.Primary.ID) - if !isAWSErr(err, budgets.ErrCodeNotFoundException, "") { - return fmt.Errorf("Budget Action '%s' was not deleted properly", rs.Primary.ID) + accountID, actionID, budgetName, err := tfbudgets.BudgetActionParseResourceID(rs.Primary.ID) + + if err != nil { + return err + } + + _, err = finder.ActionByAccountIDActionIDAndBudgetName(conn, accountID, actionID, budgetName) + + if tfresource.NotFound(err) { + continue + } + + if err != nil { + return err } + + return fmt.Errorf("Budget Action %s still exists", rs.Primary.ID) } return nil diff --git a/aws/resource_aws_budgets_budget_test.go b/aws/resource_aws_budgets_budget_test.go index f207c6d4a20..db482bae7a7 100644 --- a/aws/resource_aws_budgets_budget_test.go +++ b/aws/resource_aws_budgets_budget_test.go @@ -3,9 +3,6 @@ package aws import ( "fmt" "log" - "reflect" - "regexp" - "strconv" "strings" "testing" "time" @@ -16,6 +13,10 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/naming" + tfbudgets "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/service/budgets/finder" + "github.com/terraform-providers/terraform-provider-aws/aws/internal/tfresource" ) func init() { @@ -77,11 +78,8 @@ func testSweepBudgetsBudgets(region string) error { } func TestAccAWSBudgetsBudget_basic(t *testing.T) { - costFilterKey := "AZ" + var budget budgets.Budget rName := acctest.RandomWithPrefix("tf-acc-test") - configBasicDefaults := testAccAWSBudgetsBudgetConfigDefaults(rName) - accountID := "012345678910" - configBasicUpdate := testAccAWSBudgetsBudgetConfigUpdate(rName) resourceName := "aws_budgets_budget.test" resource.ParallelTest(t, resource.TestCase{ @@ -91,52 +89,82 @@ func TestAccAWSBudgetsBudget_basic(t *testing.T) { CheckDestroy: testAccAWSBudgetsBudgetDestroy, Steps: []resource.TestStep{ { - Config: testAccAWSBudgetsBudgetConfig_BasicDefaults(configBasicDefaults, costFilterKey), + Config: testAccAWSBudgetsBudgetConfig(rName), Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), - testAccCheckResourceAttrGlobalARN(resourceName, "arn", "budgetservice", fmt.Sprintf(`budget/%s`, rName)), - resource.TestMatchResourceAttr(resourceName, "name", regexp.MustCompile(*configBasicDefaults.BudgetName)), - resource.TestCheckResourceAttr(resourceName, "budget_type", *configBasicDefaults.BudgetType), - resource.TestCheckResourceAttr(resourceName, "limit_amount", *configBasicDefaults.BudgetLimit.Amount), - resource.TestCheckResourceAttr(resourceName, "limit_unit", *configBasicDefaults.BudgetLimit.Unit), - resource.TestCheckResourceAttr(resourceName, "time_period_start", configBasicDefaults.TimePeriod.Start.Format("2006-01-02_15:04")), - resource.TestCheckResourceAttr(resourceName, "time_period_end", configBasicDefaults.TimePeriod.End.Format("2006-01-02_15:04")), - resource.TestCheckResourceAttr(resourceName, "time_unit", *configBasicDefaults.TimeUnit), + testAccAWSBudgetsBudgetExists(resourceName, &budget), + testAccCheckResourceAttrAccountID(resourceName, "account_id"), + testAccCheckResourceAttrGlobalARN(resourceName, "arn", "budgets", fmt.Sprintf(`budget/%s`, rName)), + resource.TestCheckResourceAttr(resourceName, "budget_type", "RI_UTILIZATION"), + resource.TestCheckResourceAttr(resourceName, "cost_filter.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cost_filter.*", map[string]string{ + "name": "Service", + "values.#": "1", + "values.0": "Amazon Elasticsearch Service", + }), + resource.TestCheckResourceAttr(resourceName, "cost_filters.%", "1"), + resource.TestCheckResourceAttr(resourceName, "cost_filters.Service", "Amazon Elasticsearch Service"), + resource.TestCheckResourceAttr(resourceName, "limit_amount", "100.0"), + resource.TestCheckResourceAttr(resourceName, "limit_unit", "PERCENTAGE"), + resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttr(resourceName, "notification.#", "0"), + resource.TestCheckResourceAttrSet(resourceName, "time_period_end"), + resource.TestCheckResourceAttrSet(resourceName, "time_period_start"), + resource.TestCheckResourceAttr(resourceName, "time_unit", "QUARTERLY"), ), }, { - PlanOnly: true, - Config: testAccAWSBudgetsBudgetConfig_WithAccountID(configBasicDefaults, accountID, costFilterKey), - ExpectError: regexp.MustCompile("account_id.*" + accountID), + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, }, + }, + }) +} + +func TestAccAWSBudgetsBudget_Name_Generated(t *testing.T) { + var budget budgets.Budget + resourceName := "aws_budgets_budget.test" + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t); testAccPartitionHasServicePreCheck(budgets.EndpointsID, t) }, + ErrorCheck: testAccErrorCheck(t, budgets.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccAWSBudgetsBudgetDestroy, + Steps: []resource.TestStep{ { - Config: testAccAWSBudgetsBudgetConfig_Basic(configBasicUpdate, costFilterKey), + Config: testAccAWSBudgetsBudgetConfigNameGenerated(), Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicUpdate), - resource.TestMatchResourceAttr(resourceName, "name", regexp.MustCompile(*configBasicUpdate.BudgetName)), - resource.TestCheckResourceAttr(resourceName, "budget_type", *configBasicUpdate.BudgetType), - resource.TestCheckResourceAttr(resourceName, "limit_amount", *configBasicUpdate.BudgetLimit.Amount), - resource.TestCheckResourceAttr(resourceName, "limit_unit", *configBasicUpdate.BudgetLimit.Unit), - resource.TestCheckResourceAttr(resourceName, "time_period_start", configBasicUpdate.TimePeriod.Start.Format("2006-01-02_15:04")), - resource.TestCheckResourceAttr(resourceName, "time_period_end", configBasicUpdate.TimePeriod.End.Format("2006-01-02_15:04")), - resource.TestCheckResourceAttr(resourceName, "time_unit", *configBasicUpdate.TimeUnit), + testAccAWSBudgetsBudgetExists(resourceName, &budget), + resource.TestCheckResourceAttr(resourceName, "budget_type", "RI_COVERAGE"), + resource.TestCheckResourceAttr(resourceName, "cost_filter.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cost_filter.*", map[string]string{ + "name": "Service", + "values.#": "1", + "values.0": "Amazon Redshift", + }), + resource.TestCheckResourceAttr(resourceName, "cost_filters.%", "1"), + resource.TestCheckResourceAttr(resourceName, "cost_filters.Service", "Amazon Redshift"), + resource.TestCheckResourceAttr(resourceName, "limit_amount", "100.0"), + resource.TestCheckResourceAttr(resourceName, "limit_unit", "PERCENTAGE"), + naming.TestCheckResourceAttrNameGenerated(resourceName, "name"), + resource.TestCheckResourceAttr(resourceName, "name_prefix", "terraform-"), + resource.TestCheckResourceAttr(resourceName, "notification.#", "0"), + resource.TestCheckResourceAttrSet(resourceName, "time_period_end"), + resource.TestCheckResourceAttrSet(resourceName, "time_period_start"), + resource.TestCheckResourceAttr(resourceName, "time_unit", "ANNUALLY"), ), }, { - ResourceName: resourceName, - ImportState: true, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"name_prefix"}, + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, }, }, }) } -func TestAccAWSBudgetsBudget_prefix(t *testing.T) { - costFilterKey := "AZ" - rName := acctest.RandomWithPrefix("tf-acc-test") - configBasicDefaults := testAccAWSBudgetsBudgetConfigDefaults(rName) - configBasicUpdate := testAccAWSBudgetsBudgetConfigUpdate(rName) +func TestAccAWSBudgetsBudget_NamePrefix(t *testing.T) { + var budget budgets.Budget resourceName := "aws_budgets_budget.test" resource.ParallelTest(t, resource.TestCase{ @@ -146,150 +174,164 @@ func TestAccAWSBudgetsBudget_prefix(t *testing.T) { CheckDestroy: testAccAWSBudgetsBudgetDestroy, Steps: []resource.TestStep{ { - Config: testAccAWSBudgetsBudgetConfig_PrefixDefaults(configBasicDefaults, costFilterKey), + Config: testAccAWSBudgetsBudgetConfigNamePrefix("tf-acc-test-prefix-"), Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), - resource.TestMatchResourceAttr(resourceName, "name_prefix", regexp.MustCompile(*configBasicDefaults.BudgetName)), - resource.TestCheckResourceAttr(resourceName, "budget_type", *configBasicDefaults.BudgetType), - resource.TestCheckResourceAttr(resourceName, "limit_amount", *configBasicDefaults.BudgetLimit.Amount), - resource.TestCheckResourceAttr(resourceName, "limit_unit", *configBasicDefaults.BudgetLimit.Unit), - resource.TestCheckResourceAttr(resourceName, "time_period_start", configBasicDefaults.TimePeriod.Start.Format("2006-01-02_15:04")), - resource.TestCheckResourceAttr(resourceName, "time_period_end", configBasicDefaults.TimePeriod.End.Format("2006-01-02_15:04")), - resource.TestCheckResourceAttr(resourceName, "time_unit", *configBasicDefaults.TimeUnit), + testAccAWSBudgetsBudgetExists(resourceName, &budget), + resource.TestCheckResourceAttr(resourceName, "budget_type", "SAVINGS_PLANS_UTILIZATION"), + resource.TestCheckResourceAttr(resourceName, "cost_filter.#", "0"), + resource.TestCheckResourceAttr(resourceName, "cost_filters.%", "0"), + resource.TestCheckResourceAttr(resourceName, "limit_amount", "100.0"), + resource.TestCheckResourceAttr(resourceName, "limit_unit", "PERCENTAGE"), + naming.TestCheckResourceAttrNameFromPrefix(resourceName, "name", "tf-acc-test-prefix-"), + resource.TestCheckResourceAttr(resourceName, "name_prefix", "tf-acc-test-prefix-"), + resource.TestCheckResourceAttr(resourceName, "notification.#", "0"), + resource.TestCheckResourceAttrSet(resourceName, "time_period_end"), + resource.TestCheckResourceAttrSet(resourceName, "time_period_start"), + resource.TestCheckResourceAttr(resourceName, "time_unit", "MONTHLY"), ), }, - { - Config: testAccAWSBudgetsBudgetConfig_Prefix(configBasicUpdate, costFilterKey), - Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicUpdate), - resource.TestMatchResourceAttr(resourceName, "name_prefix", regexp.MustCompile(*configBasicUpdate.BudgetName)), - resource.TestCheckResourceAttr(resourceName, "budget_type", *configBasicUpdate.BudgetType), - resource.TestCheckResourceAttr(resourceName, "limit_amount", *configBasicUpdate.BudgetLimit.Amount), - resource.TestCheckResourceAttr(resourceName, "limit_unit", *configBasicUpdate.BudgetLimit.Unit), - resource.TestCheckResourceAttr(resourceName, "time_period_start", configBasicUpdate.TimePeriod.Start.Format("2006-01-02_15:04")), - resource.TestCheckResourceAttr(resourceName, "time_period_end", configBasicUpdate.TimePeriod.End.Format("2006-01-02_15:04")), - resource.TestCheckResourceAttr(resourceName, "time_unit", *configBasicUpdate.TimeUnit), - ), - }, - - { - ResourceName: resourceName, - ImportState: true, - ImportStateVerify: true, - ImportStateVerifyIgnore: []string{"name_prefix"}, + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, }, }, }) } -func TestAccAWSBudgetsBudget_notification(t *testing.T) { +func TestAccAWSBudgetsBudget_disappears(t *testing.T) { + var budget budgets.Budget rName := acctest.RandomWithPrefix("tf-acc-test") - configBasicDefaults := testAccAWSBudgetsBudgetConfigDefaults(rName) - configBasicDefaults.CostFilters = map[string][]*string{} resourceName := "aws_budgets_budget.test" - notificationConfigDefaults := []budgets.Notification{testAccAWSBudgetsBudgetNotificationConfigDefaults()} - notificationConfigUpdated := []budgets.Notification{testAccAWSBudgetsBudgetNotificationConfigUpdate()} - twoNotificationConfigs := []budgets.Notification{ - testAccAWSBudgetsBudgetNotificationConfigUpdate(), - testAccAWSBudgetsBudgetNotificationConfigDefaults(), - } - - domain := testAccRandomDomainName() - address1 := testAccRandomEmailAddress(domain) - address2 := testAccRandomEmailAddress(domain) - address3 := testAccRandomEmailAddress(domain) - - noEmails := []string{} - oneEmail := []string{address1} - oneOtherEmail := []string{address2} - twoEmails := []string{address2, address3} - noTopics := []string{} - oneTopic := []string{"${aws_sns_topic.budget_notifications.arn}"} - resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t); testAccPartitionHasServicePreCheck(budgets.EndpointsID, t) }, ErrorCheck: testAccErrorCheck(t, budgets.EndpointsID), Providers: testAccProviders, CheckDestroy: testAccAWSBudgetsBudgetDestroy, Steps: []resource.TestStep{ - // Can't create without at least one subscriber - { - Config: testAccAWSBudgetsBudgetConfigWithNotification_Basic(configBasicDefaults, notificationConfigDefaults, noEmails, noTopics), - ExpectError: regexp.MustCompile(`Notification must have at least one subscriber`), - Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), - ), - }, - // Basic Notification with only email - { - Config: testAccAWSBudgetsBudgetConfigWithNotification_Basic(configBasicDefaults, notificationConfigDefaults, oneEmail, noTopics), - Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), - ), - }, - // Change only subscriber to a different e-mail - { - Config: testAccAWSBudgetsBudgetConfigWithNotification_Basic(configBasicDefaults, notificationConfigDefaults, oneOtherEmail, noTopics), - Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), - ), - }, - // Add a second e-mail and a topic - { - Config: testAccAWSBudgetsBudgetConfigWithNotification_Basic(configBasicDefaults, notificationConfigDefaults, twoEmails, oneTopic), - Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), - ), - }, - // Delete both E-Mails - { - Config: testAccAWSBudgetsBudgetConfigWithNotification_Basic(configBasicDefaults, notificationConfigDefaults, noEmails, oneTopic), - Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), - ), - }, - // Swap one Topic fo one E-Mail { - Config: testAccAWSBudgetsBudgetConfigWithNotification_Basic(configBasicDefaults, notificationConfigDefaults, oneEmail, noTopics), + Config: testAccAWSBudgetsBudgetConfig(rName), Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), + testAccAWSBudgetsBudgetExists(resourceName, &budget), + testAccCheckResourceDisappears(testAccProvider, resourceAwsBudgetsBudget(), resourceName), ), + ExpectNonEmptyPlan: true, }, - // Can't update without at least one subscriber + }, + }) +} + +func TestAccAWSBudgetsBudget_CostTypes(t *testing.T) { + var budget budgets.Budget + rName := acctest.RandomWithPrefix("tf-acc-test") + resourceName := "aws_budgets_budget.test" + + now := time.Now().UTC() + ts1 := now.AddDate(0, 0, -14) + ts2 := time.Date(2050, 1, 1, 00, 0, 0, 0, time.UTC) + ts3 := now.AddDate(0, 0, -28) + ts4 := time.Date(2060, 7, 1, 00, 0, 0, 0, time.UTC) + startDate1 := tfbudgets.TimePeriodTimestampToString(&ts1) + endDate1 := tfbudgets.TimePeriodTimestampToString(&ts2) + startDate2 := tfbudgets.TimePeriodTimestampToString(&ts3) + endDate2 := tfbudgets.TimePeriodTimestampToString(&ts4) + + resource.ParallelTest(t, resource.TestCase{ + PreCheck: func() { testAccPreCheck(t); testAccPartitionHasServicePreCheck(budgets.EndpointsID, t) }, + ErrorCheck: testAccErrorCheck(t, budgets.EndpointsID), + Providers: testAccProviders, + CheckDestroy: testAccAWSBudgetsBudgetDestroy, + Steps: []resource.TestStep{ { - Config: testAccAWSBudgetsBudgetConfigWithNotification_Basic(configBasicDefaults, notificationConfigDefaults, noEmails, noTopics), - ExpectError: regexp.MustCompile(`Notification must have at least one subscriber`), + Config: testAccAWSBudgetsBudgetConfigCostTypes(rName, startDate1, endDate1), Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), + testAccAWSBudgetsBudgetExists(resourceName, &budget), + resource.TestCheckResourceAttr(resourceName, "budget_type", "COST"), + resource.TestCheckResourceAttr(resourceName, "cost_filter.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cost_filter.*", map[string]string{ + "name": "AZ", + "values.#": "2", + "values.0": testAccGetRegion(), + "values.1": testAccGetAlternateRegion(), + }), + resource.TestCheckResourceAttr(resourceName, "cost_filters.%", "1"), + resource.TestCheckResourceAttr(resourceName, "cost_filters.AZ", strings.Join([]string{testAccGetRegion(), testAccGetAlternateRegion()}, ",")), + resource.TestCheckResourceAttr(resourceName, "cost_types.#", "1"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_credit", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_discount", "false"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_other_subscription", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_recurring", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_refund", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_subscription", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_support", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_tax", "false"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_upfront", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.use_amortized", "false"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.use_blended", "true"), + resource.TestCheckResourceAttr(resourceName, "limit_amount", "456.78"), + resource.TestCheckResourceAttr(resourceName, "limit_unit", "USD"), + resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttr(resourceName, "notification.#", "0"), + resource.TestCheckResourceAttr(resourceName, "time_period_end", endDate1), + resource.TestCheckResourceAttr(resourceName, "time_period_start", startDate1), + resource.TestCheckResourceAttr(resourceName, "time_unit", "DAILY"), ), }, - // Update all non-subscription parameters { - Config: testAccAWSBudgetsBudgetConfigWithNotification_Basic(configBasicDefaults, notificationConfigUpdated, noEmails, noTopics), - ExpectError: regexp.MustCompile(`Notification must have at least one subscriber`), - Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), - ), + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, }, - // Add a second subscription { - Config: testAccAWSBudgetsBudgetConfigWithNotification_Basic(configBasicDefaults, twoNotificationConfigs, noEmails, noTopics), - ExpectError: regexp.MustCompile(`Notification must have at least one subscriber`), + Config: testAccAWSBudgetsBudgetConfigCostTypesUpdated(rName, startDate2, endDate2), Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), + testAccAWSBudgetsBudgetExists(resourceName, &budget), + resource.TestCheckResourceAttr(resourceName, "budget_type", "COST"), + resource.TestCheckResourceAttr(resourceName, "cost_filter.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "cost_filter.*", map[string]string{ + "name": "AZ", + "values.#": "2", + "values.0": testAccGetAlternateRegion(), + "values.1": testAccGetThirdRegion(), + }), + resource.TestCheckResourceAttr(resourceName, "cost_filters.%", "1"), + resource.TestCheckResourceAttr(resourceName, "cost_filters.AZ", strings.Join([]string{testAccGetAlternateRegion(), testAccGetThirdRegion()}, ",")), + resource.TestCheckResourceAttr(resourceName, "cost_types.#", "1"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_credit", "false"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_discount", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_other_subscription", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_recurring", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_refund", "false"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_subscription", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_support", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_tax", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.include_upfront", "true"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.use_amortized", "false"), + resource.TestCheckResourceAttr(resourceName, "cost_types.0.use_blended", "false"), + resource.TestCheckResourceAttr(resourceName, "limit_amount", "567.89"), + resource.TestCheckResourceAttr(resourceName, "limit_unit", "USD"), + resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttr(resourceName, "notification.#", "0"), + resource.TestCheckResourceAttr(resourceName, "time_period_end", endDate2), + resource.TestCheckResourceAttr(resourceName, "time_period_start", startDate2), + resource.TestCheckResourceAttr(resourceName, "time_unit", "DAILY"), ), }, }, }) } -func TestAccAWSBudgetsBudget_disappears(t *testing.T) { - costFilterKey := "AZ" +func TestAccAWSBudgetsBudget_Notifications(t *testing.T) { + var budget budgets.Budget rName := acctest.RandomWithPrefix("tf-acc-test") - configBasicDefaults := testAccAWSBudgetsBudgetConfigDefaults(rName) resourceName := "aws_budgets_budget.test" + snsTopicResourceName := "aws_sns_topic.test" + + domain := testAccRandomDomainName() + emailAddress1 := testAccRandomEmailAddress(domain) + emailAddress2 := testAccRandomEmailAddress(domain) + emailAddress3 := testAccRandomEmailAddress(domain) resource.ParallelTest(t, resource.TestCase{ PreCheck: func() { testAccPreCheck(t); testAccPartitionHasServicePreCheck(budgets.EndpointsID, t) }, @@ -298,415 +340,279 @@ func TestAccAWSBudgetsBudget_disappears(t *testing.T) { CheckDestroy: testAccAWSBudgetsBudgetDestroy, Steps: []resource.TestStep{ { - Config: testAccAWSBudgetsBudgetConfig_BasicDefaults(configBasicDefaults, costFilterKey), + Config: testAccAWSBudgetsBudgetConfigNotifications(rName, emailAddress1, emailAddress2), Check: resource.ComposeTestCheckFunc( - testAccAWSBudgetsBudgetExists(resourceName, configBasicDefaults), - testAccCheckResourceDisappears(testAccProvider, resourceAwsBudgetsBudget(), resourceName), + testAccAWSBudgetsBudgetExists(resourceName, &budget), + resource.TestCheckResourceAttr(resourceName, "budget_type", "USAGE"), + resource.TestCheckResourceAttr(resourceName, "cost_filter.#", "0"), + resource.TestCheckResourceAttr(resourceName, "cost_filters.%", "0"), + resource.TestCheckResourceAttr(resourceName, "limit_amount", "432.1"), + resource.TestCheckResourceAttr(resourceName, "limit_unit", "GBP"), + resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttr(resourceName, "notification.#", "2"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "notification.*", map[string]string{ + "comparison_operator": "GREATER_THAN", + "notification_type": "ACTUAL", + "subscriber_email_addresses.#": "0", + "subscriber_sns_topic_arns.#": "1", + "threshold": "150", + "threshold_type": "PERCENTAGE", + }), + resource.TestCheckTypeSetElemAttrPair(resourceName, "notification.*.subscriber_sns_topic_arns.*", snsTopicResourceName, "arn"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "notification.*", map[string]string{ + "comparison_operator": "EQUAL_TO", + "notification_type": "FORECASTED", + "subscriber_email_addresses.#": "2", + "subscriber_sns_topic_arns.#": "0", + "threshold": "200.1", + "threshold_type": "ABSOLUTE_VALUE", + }), + resource.TestCheckTypeSetElemAttr(resourceName, "notification.*.subscriber_email_addresses.*", emailAddress1), + resource.TestCheckTypeSetElemAttr(resourceName, "notification.*.subscriber_email_addresses.*", emailAddress2), + resource.TestCheckResourceAttr(resourceName, "time_unit", "ANNUALLY"), + ), + }, + { + ResourceName: resourceName, + ImportState: true, + ImportStateVerify: true, + }, + { + Config: testAccAWSBudgetsBudgetConfigNotificationsUpdated(rName, emailAddress3), + Check: resource.ComposeTestCheckFunc( + testAccAWSBudgetsBudgetExists(resourceName, &budget), + resource.TestCheckResourceAttr(resourceName, "budget_type", "USAGE"), + resource.TestCheckResourceAttr(resourceName, "cost_filter.#", "0"), + resource.TestCheckResourceAttr(resourceName, "cost_filters.%", "0"), + resource.TestCheckResourceAttr(resourceName, "limit_amount", "432.1"), + resource.TestCheckResourceAttr(resourceName, "limit_unit", "GBP"), + resource.TestCheckResourceAttr(resourceName, "name", rName), + resource.TestCheckResourceAttr(resourceName, "notification.#", "1"), + resource.TestCheckTypeSetElemNestedAttrs(resourceName, "notification.*", map[string]string{ + "comparison_operator": "LESS_THAN", + "notification_type": "ACTUAL", + "subscriber_email_addresses.#": "1", + "subscriber_sns_topic_arns.#": "0", + "threshold": "123.45", + "threshold_type": "ABSOLUTE_VALUE", + }), + resource.TestCheckTypeSetElemAttr(resourceName, "notification.*.subscriber_email_addresses.*", emailAddress3), + resource.TestCheckResourceAttr(resourceName, "time_unit", "ANNUALLY"), ), - ExpectNonEmptyPlan: true, }, }, }) } -func testAccAWSBudgetsBudgetExists(resourceName string, config budgets.Budget) resource.TestCheckFunc { +func testAccAWSBudgetsBudgetExists(resourceName string, v *budgets.Budget) resource.TestCheckFunc { return func(s *terraform.State) error { rs, ok := s.RootModule().Resources[resourceName] if !ok { return fmt.Errorf("Not found: %s", resourceName) } - accountID, budgetName, err := decodeBudgetsBudgetID(rs.Primary.ID) - if err != nil { - return fmt.Errorf("failed decoding ID: %v", err) - } - - client := testAccProvider.Meta().(*AWSClient).budgetconn - b, err := client.DescribeBudget(&budgets.DescribeBudgetInput{ - BudgetName: &budgetName, - AccountId: &accountID, - }) - - if err != nil { - return fmt.Errorf("Describebudget error: %v", err) + if rs.Primary.ID == "" { + return fmt.Errorf("No Budget ID is set") } - if b.Budget == nil { - return fmt.Errorf("No budget returned %v in %v", b.Budget, b) - } + conn := testAccProvider.Meta().(*AWSClient).budgetconn - if aws.StringValue(b.Budget.BudgetLimit.Amount) != aws.StringValue(config.BudgetLimit.Amount) { - return fmt.Errorf("budget limit incorrectly set %v != %v", aws.StringValue(config.BudgetLimit.Amount), - aws.StringValue(b.Budget.BudgetLimit.Amount)) - } + accountID, budgetName, err := tfbudgets.BudgetParseResourceID(rs.Primary.ID) - if err := testAccAWSBudgetsBudgetCheckCostTypes(config, *b.Budget.CostTypes); err != nil { + if err != nil { return err } - if err := testAccAWSBudgetsBudgetCheckTimePeriod(*config.TimePeriod, *b.Budget.TimePeriod); err != nil { + output, err := finder.BudgetByAccountIDAndBudgetName(conn, accountID, budgetName) + + if err != nil { return err } - if !reflect.DeepEqual(b.Budget.CostFilters, config.CostFilters) { - return fmt.Errorf("cost filter not set properly: %v != %v", b.Budget.CostFilters, config.CostFilters) - } + *v = *output return nil } } -func testAccAWSBudgetsBudgetCheckTimePeriod(configTimePeriod, timePeriod budgets.TimePeriod) error { - if configTimePeriod.End.Format("2006-01-02_15:04") != timePeriod.End.Format("2006-01-02_15:04") { - return fmt.Errorf("TimePeriodEnd not set properly '%v' should be '%v'", *timePeriod.End, *configTimePeriod.End) - } - - if configTimePeriod.Start.Format("2006-01-02_15:04") != timePeriod.Start.Format("2006-01-02_15:04") { - return fmt.Errorf("TimePeriodStart not set properly '%v' should be '%v'", *timePeriod.Start, *configTimePeriod.Start) - } - - return nil -} - -func testAccAWSBudgetsBudgetCheckCostTypes(config budgets.Budget, costTypes budgets.CostTypes) error { - if *costTypes.IncludeCredit != *config.CostTypes.IncludeCredit { - return fmt.Errorf("IncludeCredit not set properly '%v' should be '%v'", *costTypes.IncludeCredit, *config.CostTypes.IncludeCredit) - } - - if *costTypes.IncludeOtherSubscription != *config.CostTypes.IncludeOtherSubscription { - return fmt.Errorf("IncludeOtherSubscription not set properly '%v' should be '%v'", *costTypes.IncludeOtherSubscription, *config.CostTypes.IncludeOtherSubscription) - } - - if *costTypes.IncludeRecurring != *config.CostTypes.IncludeRecurring { - return fmt.Errorf("IncludeRecurring not set properly '%v' should be '%v'", *costTypes.IncludeRecurring, *config.CostTypes.IncludeRecurring) - } - - if *costTypes.IncludeRefund != *config.CostTypes.IncludeRefund { - return fmt.Errorf("IncludeRefund not set properly '%v' should be '%v'", *costTypes.IncludeRefund, *config.CostTypes.IncludeRefund) - } - - if *costTypes.IncludeSubscription != *config.CostTypes.IncludeSubscription { - return fmt.Errorf("IncludeSubscription not set properly '%v' should be '%v'", *costTypes.IncludeSubscription, *config.CostTypes.IncludeSubscription) - } - - if *costTypes.IncludeSupport != *config.CostTypes.IncludeSupport { - return fmt.Errorf("IncludeSupport not set properly '%v' should be '%v'", *costTypes.IncludeSupport, *config.CostTypes.IncludeSupport) - } - - if *costTypes.IncludeTax != *config.CostTypes.IncludeTax { - return fmt.Errorf("IncludeTax not set properly '%v' should be '%v'", *costTypes.IncludeTax, *config.CostTypes.IncludeTax) - } - - if *costTypes.IncludeUpfront != *config.CostTypes.IncludeUpfront { - return fmt.Errorf("IncludeUpfront not set properly '%v' should be '%v'", *costTypes.IncludeUpfront, *config.CostTypes.IncludeUpfront) - } - - if *costTypes.UseBlended != *config.CostTypes.UseBlended { - return fmt.Errorf("UseBlended not set properly '%v' should be '%v'", *costTypes.UseBlended, *config.CostTypes.UseBlended) - } - - return nil -} - func testAccAWSBudgetsBudgetDestroy(s *terraform.State) error { - meta := testAccProvider.Meta() - client := meta.(*AWSClient).budgetconn + conn := testAccProvider.Meta().(*AWSClient).budgetconn + for _, rs := range s.RootModule().Resources { if rs.Type != "aws_budgets_budget" { continue } - accountID, budgetName, err := decodeBudgetsBudgetID(rs.Primary.ID) - if err != nil { - return fmt.Errorf("Budget '%s': id could not be decoded and could not be deleted properly", rs.Primary.ID) - } + accountID, budgetName, err := tfbudgets.BudgetParseResourceID(rs.Primary.ID) - _, err = client.DescribeBudget(&budgets.DescribeBudgetInput{ - BudgetName: aws.String(budgetName), - AccountId: aws.String(accountID), - }) - if !isAWSErr(err, budgets.ErrCodeNotFoundException, "") { - return fmt.Errorf("Budget '%s' was not deleted properly", rs.Primary.ID) + if err != nil { + return err } - } - return nil -} + _, err = finder.BudgetByAccountIDAndBudgetName(conn, accountID, budgetName) -func testAccAWSBudgetsBudgetConfigUpdate(name string) budgets.Budget { - dateNow := time.Now().UTC() - futureDate := dateNow.AddDate(0, 0, 14) - startDate := dateNow.AddDate(0, 0, -14) - return budgets.Budget{ - BudgetName: aws.String(name), - BudgetType: aws.String("COST"), - BudgetLimit: &budgets.Spend{ - Amount: aws.String("500.0"), - Unit: aws.String("USD"), - }, - CostFilters: map[string][]*string{ - "AZ": { - aws.String(testAccGetAlternateRegion()), - }, - }, - CostTypes: &budgets.CostTypes{ - IncludeCredit: aws.Bool(true), - IncludeOtherSubscription: aws.Bool(true), - IncludeRecurring: aws.Bool(true), - IncludeRefund: aws.Bool(true), - IncludeSubscription: aws.Bool(false), - IncludeSupport: aws.Bool(true), - IncludeTax: aws.Bool(false), - IncludeUpfront: aws.Bool(true), - UseBlended: aws.Bool(false), - }, - TimeUnit: aws.String("MONTHLY"), - TimePeriod: &budgets.TimePeriod{ - End: &futureDate, - Start: &startDate, - }, - } -} + if tfresource.NotFound(err) { + continue + } -func testAccAWSBudgetsBudgetConfigDefaults(name string) budgets.Budget { - dateNow := time.Now().UTC() - futureDate := time.Date(2087, 6, 15, 00, 0, 0, 0, time.UTC) - startDate := dateNow.AddDate(0, 0, -14) - return budgets.Budget{ - BudgetName: aws.String(name), - BudgetType: aws.String("COST"), - BudgetLimit: &budgets.Spend{ - Amount: aws.String("100.0"), - Unit: aws.String("USD"), - }, - CostFilters: map[string][]*string{ - "AZ": { - aws.String(testAccGetRegion()), - }, - }, - CostTypes: &budgets.CostTypes{ - IncludeCredit: aws.Bool(true), - IncludeOtherSubscription: aws.Bool(true), - IncludeRecurring: aws.Bool(true), - IncludeRefund: aws.Bool(true), - IncludeSubscription: aws.Bool(true), - IncludeSupport: aws.Bool(true), - IncludeTax: aws.Bool(true), - IncludeUpfront: aws.Bool(true), - UseBlended: aws.Bool(false), - }, - TimeUnit: aws.String("MONTHLY"), - TimePeriod: &budgets.TimePeriod{ - End: &futureDate, - Start: &startDate, - }, - } -} + if err != nil { + return err + } -func testAccAWSBudgetsBudgetNotificationConfigDefaults() budgets.Notification { - return budgets.Notification{ - NotificationType: aws.String(budgets.NotificationTypeActual), - ThresholdType: aws.String(budgets.ThresholdTypeAbsoluteValue), - Threshold: aws.Float64(100.0), - ComparisonOperator: aws.String(budgets.ComparisonOperatorGreaterThan), + return fmt.Errorf("Budget Action %s still exists", rs.Primary.ID) } -} -func testAccAWSBudgetsBudgetNotificationConfigUpdate() budgets.Notification { - return budgets.Notification{ - NotificationType: aws.String(budgets.NotificationTypeForecasted), - ThresholdType: aws.String(budgets.ThresholdTypePercentage), - Threshold: aws.Float64(200.0), - ComparisonOperator: aws.String(budgets.ComparisonOperatorLessThan), - } + return nil } -func testAccAWSBudgetsBudgetConfig_WithAccountID(budgetConfig budgets.Budget, accountID, costFilterKey string) string { - timePeriodStart := budgetConfig.TimePeriod.Start.Format("2006-01-02_15:04") - costFilterValue := aws.StringValue(budgetConfig.CostFilters[costFilterKey][0]) - +func testAccAWSBudgetsBudgetConfig(rName string) string { return fmt.Sprintf(` resource "aws_budgets_budget" "test" { - account_id = "%s" - name_prefix = "%s" - budget_type = "%s" - limit_amount = "%s" - limit_unit = "%s" - time_period_start = "%s" - time_unit = "%s" + name = %[1]q + budget_type = "RI_UTILIZATION" + limit_amount = "100.0" + limit_unit = "PERCENTAGE" + time_unit = "QUARTERLY" cost_filters = { - "%s" = "%s" + Service = "Amazon Elasticsearch Service" } } -`, accountID, aws.StringValue(budgetConfig.BudgetName), aws.StringValue(budgetConfig.BudgetType), aws.StringValue(budgetConfig.BudgetLimit.Amount), aws.StringValue(budgetConfig.BudgetLimit.Unit), timePeriodStart, aws.StringValue(budgetConfig.TimeUnit), costFilterKey, costFilterValue) +`, rName) } -func testAccAWSBudgetsBudgetConfig_PrefixDefaults(budgetConfig budgets.Budget, costFilterKey string) string { - timePeriodStart := budgetConfig.TimePeriod.Start.Format("2006-01-02_15:04") - costFilterValue := aws.StringValue(budgetConfig.CostFilters[costFilterKey][0]) - - return fmt.Sprintf(` +func testAccAWSBudgetsBudgetConfigNameGenerated() string { + return ` resource "aws_budgets_budget" "test" { - name_prefix = "%s" - budget_type = "%s" - limit_amount = "%s" - limit_unit = "%s" - time_period_start = "%s" - time_unit = "%s" - - cost_filters = { - "%s" = "%s" + budget_type = "RI_COVERAGE" + limit_amount = "100.00" + limit_unit = "PERCENTAGE" + time_unit = "ANNUALLY" + + cost_filter { + name = "Service" + values = ["Amazon Redshift"] } } -`, aws.StringValue(budgetConfig.BudgetName), aws.StringValue(budgetConfig.BudgetType), aws.StringValue(budgetConfig.BudgetLimit.Amount), aws.StringValue(budgetConfig.BudgetLimit.Unit), timePeriodStart, aws.StringValue(budgetConfig.TimeUnit), costFilterKey, costFilterValue) +` } -func testAccAWSBudgetsBudgetConfig_Prefix(budgetConfig budgets.Budget, costFilterKey string) string { - timePeriodStart := budgetConfig.TimePeriod.Start.Format("2006-01-02_15:04") - timePeriodEnd := budgetConfig.TimePeriod.End.Format("2006-01-02_15:04") - costFilterValue := aws.StringValue(budgetConfig.CostFilters[costFilterKey][0]) - +func testAccAWSBudgetsBudgetConfigNamePrefix(namePrefix string) string { return fmt.Sprintf(` resource "aws_budgets_budget" "test" { - name_prefix = "%s" - budget_type = "%s" - limit_amount = "%s" - limit_unit = "%s" - - cost_types { - include_tax = "%t" - include_subscription = "%t" - use_blended = "%t" - } - - time_period_start = "%s" - time_period_end = "%s" - time_unit = "%s" - - cost_filters = { - "%s" = "%s" - } + name_prefix = %[1]q + budget_type = "SAVINGS_PLANS_UTILIZATION" + limit_amount = "100" + limit_unit = "PERCENTAGE" + time_unit = "MONTHLY" } -`, aws.StringValue(budgetConfig.BudgetName), aws.StringValue(budgetConfig.BudgetType), aws.StringValue(budgetConfig.BudgetLimit.Amount), aws.StringValue(budgetConfig.BudgetLimit.Unit), aws.BoolValue(budgetConfig.CostTypes.IncludeTax), aws.BoolValue(budgetConfig.CostTypes.IncludeSubscription), aws.BoolValue(budgetConfig.CostTypes.UseBlended), timePeriodStart, timePeriodEnd, aws.StringValue(budgetConfig.TimeUnit), costFilterKey, costFilterValue) +`, namePrefix) } -func testAccAWSBudgetsBudgetConfig_BasicDefaults(budgetConfig budgets.Budget, costFilterKey string) string { - timePeriodStart := budgetConfig.TimePeriod.Start.Format("2006-01-02_15:04") - costFilterValue := aws.StringValue(budgetConfig.CostFilters[costFilterKey][0]) - +func testAccAWSBudgetsBudgetConfigCostTypes(rName, startDate, endDate string) string { return fmt.Sprintf(` resource "aws_budgets_budget" "test" { - name = "%s" - budget_type = "%s" - limit_amount = "%s" - limit_unit = "%s" - time_period_start = "%s" - time_unit = "%s" + name = %[1]q + budget_type = "COST" + limit_amount = "456.78" + limit_unit = "USD" + + time_period_start = %[2]q + time_period_end = %[3]q + time_unit = "DAILY" + + cost_filter { + name = "AZ" + values = [%[4]q, %[5]q] + } - cost_filters = { - "%s" = "%s" + cost_types { + include_discount = false + include_subscription = true + include_tax = false + use_blended = true } } -`, aws.StringValue(budgetConfig.BudgetName), aws.StringValue(budgetConfig.BudgetType), aws.StringValue(budgetConfig.BudgetLimit.Amount), aws.StringValue(budgetConfig.BudgetLimit.Unit), timePeriodStart, aws.StringValue(budgetConfig.TimeUnit), costFilterKey, costFilterValue) +`, rName, startDate, endDate, testAccGetRegion(), testAccGetAlternateRegion()) } -func testAccAWSBudgetsBudgetConfig_Basic(budgetConfig budgets.Budget, costFilterKey string) string { - timePeriodStart := budgetConfig.TimePeriod.Start.Format("2006-01-02_15:04") - timePeriodEnd := budgetConfig.TimePeriod.End.Format("2006-01-02_15:04") - costFilterValue := aws.StringValue(budgetConfig.CostFilters[costFilterKey][0]) - +func testAccAWSBudgetsBudgetConfigCostTypesUpdated(rName, startDate, endDate string) string { return fmt.Sprintf(` resource "aws_budgets_budget" "test" { - name = "%s" - budget_type = "%s" - limit_amount = "%s" - limit_unit = "%s" - - cost_types { - include_tax = "%t" - include_subscription = "%t" - use_blended = "%t" + name = %[1]q + budget_type = "COST" + limit_amount = "567.89" + limit_unit = "USD" + + time_period_start = %[2]q + time_period_end = %[3]q + time_unit = "DAILY" + + cost_filter { + name = "AZ" + values = [%[4]q, %[5]q] } - time_period_start = "%s" - time_period_end = "%s" - time_unit = "%s" - - cost_filters = { - "%s" = "%s" + cost_types { + include_credit = false + include_discount = true + include_refund = false + include_subscription = true + include_tax = true + use_blended = false } } -`, aws.StringValue(budgetConfig.BudgetName), aws.StringValue(budgetConfig.BudgetType), aws.StringValue(budgetConfig.BudgetLimit.Amount), aws.StringValue(budgetConfig.BudgetLimit.Unit), aws.BoolValue(budgetConfig.CostTypes.IncludeTax), aws.BoolValue(budgetConfig.CostTypes.IncludeSubscription), aws.BoolValue(budgetConfig.CostTypes.UseBlended), timePeriodStart, timePeriodEnd, aws.StringValue(budgetConfig.TimeUnit), costFilterKey, costFilterValue) +`, rName, startDate, endDate, testAccGetAlternateRegion(), testAccGetThirdRegion()) } -func testAccAWSBudgetsBudgetConfigWithNotification_Basic(budgetConfig budgets.Budget, notifications []budgets.Notification, emails []string, topics []string) string { - timePeriodStart := budgetConfig.TimePeriod.Start.Format("2006-01-02_15:04") - timePeriodEnd := budgetConfig.TimePeriod.End.Format("2006-01-02_15:04") - notificationStrings := make([]string, len(notifications)) - - for i, notification := range notifications { - notificationStrings[i] = testAccAWSBudgetsBudgetConfigNotificationSnippet(notification, emails, topics) - } - +func testAccAWSBudgetsBudgetConfigNotifications(rName, emailAddress1, emailAddress2 string) string { return fmt.Sprintf(` -resource "aws_sns_topic" "budget_notifications" { - name_prefix = "user-updates-topic" +resource "aws_sns_topic" "test" { + name = %[1]q } resource "aws_budgets_budget" "test" { - name = "%s" - budget_type = "%s" - limit_amount = "%s" - limit_unit = "%s" - cost_types { - include_tax = "%t" - include_subscription = "%t" - use_blended = "%t" + name = %[1]q + budget_type = "USAGE" + limit_amount = "432.10" + limit_unit = "GBP" + time_unit = "ANNUALLY" + + notification { + comparison_operator = "GREATER_THAN" + notification_type = "ACTUAL" + threshold = 150 + threshold_type = "PERCENTAGE" + subscriber_sns_topic_arns = [aws_sns_topic.test.arn] } - time_period_start = "%s" - time_period_end = "%s" - time_unit = "%s" - %s + notification { + comparison_operator = "EQUAL_TO" + notification_type = "FORECASTED" + threshold = 200.10 + threshold_type = "ABSOLUTE_VALUE" + subscriber_email_addresses = [%[2]q, %[3]q] + } } -`, aws.StringValue(budgetConfig.BudgetName), - aws.StringValue(budgetConfig.BudgetType), - aws.StringValue(budgetConfig.BudgetLimit.Amount), - aws.StringValue(budgetConfig.BudgetLimit.Unit), - aws.BoolValue(budgetConfig.CostTypes.IncludeTax), - aws.BoolValue(budgetConfig.CostTypes.IncludeSubscription), - aws.BoolValue(budgetConfig.CostTypes.UseBlended), - timePeriodStart, - timePeriodEnd, - aws.StringValue(budgetConfig.TimeUnit), - strings.Join(notificationStrings, "\n")) +`, rName, emailAddress1, emailAddress2) } -func testAccAWSBudgetsBudgetConfigNotificationSnippet(notification budgets.Notification, emails []string, topics []string) string { - quotedEMails := make([]string, len(emails)) - for i, email := range emails { - quotedEMails[i] = strconv.Quote(email) - } - - quotedTopics := make([]string, len(topics)) - for i, topic := range topics { - quotedTopics[i] = strconv.Quote(topic) - } - +func testAccAWSBudgetsBudgetConfigNotificationsUpdated(rName, emailAddress1 string) string { return fmt.Sprintf(` -notification { - threshold = %f - threshold_type = "%s" - notification_type = "%s" - subscriber_email_addresses = [%s] - subscriber_sns_topic_arns = [%s] - comparison_operator = "%s" +resource "aws_budgets_budget" "test" { + name = %[1]q + budget_type = "USAGE" + limit_amount = "432.10" + limit_unit = "GBP" + time_unit = "ANNUALLY" + + notification { + comparison_operator = "LESS_THAN" + notification_type = "ACTUAL" + threshold = 123.45 + threshold_type = "ABSOLUTE_VALUE" + subscriber_email_addresses = [%[2]q] + } } -`, aws.Float64Value(notification.Threshold), - aws.StringValue(notification.ThresholdType), - aws.StringValue(notification.NotificationType), - strings.Join(quotedEMails, ","), - strings.Join(quotedTopics, ","), - aws.StringValue(notification.ComparisonOperator)) +`, rName, emailAddress1) } diff --git a/go.mod b/go.mod index 033701463f4..7f2a0a5362e 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/mitchellh/go-testing-interface v1.14.1 github.com/pquerna/otp v1.3.0 + github.com/shopspring/decimal v1.2.0 golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b gopkg.in/yaml.v2 v2.4.0 ) diff --git a/go.sum b/go.sum index 57ed609af56..6ef4614ab90 100644 --- a/go.sum +++ b/go.sum @@ -307,6 +307,8 @@ github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAm github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= +github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o= github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q= github.com/spf13/pflag v1.0.2/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= diff --git a/website/docs/r/budgets_budget.html.markdown b/website/docs/r/budgets_budget.html.markdown index ad44bf5ee06..21730bc3462 100644 --- a/website/docs/r/budgets_budget.html.markdown +++ b/website/docs/r/budgets_budget.html.markdown @@ -22,8 +22,11 @@ resource "aws_budgets_budget" "ec2" { time_period_start = "2017-07-01_00:00" time_unit = "MONTHLY" - cost_filters = { - Service = "Amazon Elastic Compute Cloud - Compute" + cost_filter { + name = "Service" + values = [ + "Amazon Elastic Compute Cloud - Compute", + ] } notification { @@ -114,6 +117,8 @@ resource "aws_budgets_budget" "ri_utilization" { ## Argument Reference +~> **NOTE:** The `cost_filters` attribute will be deprecated and eventually removed in future releases, please use `cost_filter` instead. + For more detailed documentation about each argument, refer to the [AWS official documentation](http://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/data-type-budget.html). @@ -123,12 +128,13 @@ The following arguments are supported: * `name` - (Optional) The name of a budget. Unique within accounts. * `name_prefix` - (Optional) The prefix of the name of a budget. Unique within accounts. * `budget_type` - (Required) Whether this budget tracks monetary cost or usage. -* `cost_filters` - (Optional) Map of [Cost Filters](#Cost-Filters) key/value pairs to apply to the budget. -* `cost_types` - (Optional) Object containing [Cost Types](#Cost-Types) The types of cost included in a budget, such as tax and subscriptions.. +* `cost_filter` - (Optional) A list of [CostFilter](#Cost-Filter) name/values pair to apply to budget. +* `cost_filters` - (Optional) Map of [CostFilters](#Cost-Filters) key/value pairs to apply to the budget. +* `cost_types` - (Optional) Object containing [CostTypes](#Cost-Types) The types of cost included in a budget, such as tax and subscriptions. * `limit_amount` - (Required) The amount of cost or usage being measured for a budget. * `limit_unit` - (Required) The unit of measurement used for the budget forecast, actual spend, or budget threshold, such as dollars or GB. See [Spend](http://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/data-type-spend.html) documentation. * `time_period_end` - (Optional) The end of the time period covered by the budget. There are no restrictions on the end date. Format: `2017-01-01_12:00`. -* `time_period_start` - (Required) The start of the time period covered by the budget. The start date must come before the end date. Format: `2017-01-01_12:00`. +* `time_period_start` - (Optional) The start of the time period covered by the budget. If you don't specify a start date, AWS defaults to the start of your chosen time period. The start date must come before the end date. Format: `2017-01-01_12:00`. * `time_unit` - (Required) The length of time until a budget resets the actual and forecasted spend. Valid values: `MONTHLY`, `QUARTERLY`, `ANNUALLY`, and `DAILY`. * `notification` - (Optional) Object containing [Budget Notifications](#Budget-Notification). Can be used multiple times to define more than one budget notification @@ -139,7 +145,6 @@ In addition to all arguments above, the following attributes are exported: * `id` - id of resource. * `arn` - The ARN of the budget. - ### Cost Types Valid keys for `cost_types` parameter. @@ -158,9 +163,9 @@ Valid keys for `cost_types` parameter. Refer to [AWS CostTypes documentation](https://docs.aws.amazon.com/aws-cost-management/latest/APIReference/API_budgets_CostTypes.html) for further detail. -### Cost Filters +### Cost Filter -Valid keys for `cost_filters` parameter vary depending on the `budget_type` value. +Valid name for `cost_filter` parameter vary depending on the `budget_type` value. * `cost` * `AZ` @@ -179,6 +184,10 @@ Valid keys for `cost_filters` parameter vary depending on the `budget_type` valu Refer to [AWS CostFilter documentation](http://docs.aws.amazon.com/awsaccountbilling/latest/aboutv2/data-type-filter.html) for further detail. +### Cost Filters + +Valid key for `cost_filters` is same as `cost_filter`. Please refer to [Cost Filter](#Cost-Filter). + ### Budget Notification Valid keys for `notification` parameter. @@ -190,7 +199,6 @@ Valid keys for `notification` parameter. * `subscriber_email_addresses` - (Optional) E-Mail addresses to notify. Either this or `subscriber_sns_topic_arns` is required. * `subscriber_sns_topic_arns` - (Optional) SNS topics to notify. Either this or `subscriber_email_addresses` is required. - ## Import Budgets can be imported using `AccountID:BudgetName`, e.g.