Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions datadog/fwprovider/resource_datadog_cost_budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@ package fwprovider

import (
"context"
"fmt"
"regexp"
"slices"
"sort"
"strings"

"github.com/DataDog/datadog-api-client-go/v2/api/datadogV2"
frameworkPath "github.com/hashicorp/terraform-plugin-framework/path"
Expand All @@ -12,6 +17,11 @@ import (
"github.com/terraform-providers/terraform-provider-datadog/datadog/internal/utils"
)

var (
_ resource.ResourceWithValidateConfig = &costBudgetResource{}
metricQueryRegex = regexp.MustCompile(`by\s*\{(.+)\}`)
)

type costBudgetResource struct {
Api *datadogV2.CloudCostManagementApi
Auth context.Context
Expand Down Expand Up @@ -198,6 +208,177 @@ func (r *costBudgetResource) ImportState(ctx context.Context, req resource.Impor
resource.ImportStatePassthroughID(ctx, frameworkPath.Root("id"), req, resp)
}

// ValidateConfig performs client-side validation during terraform plan
// Mirrors BudgetWithEntries.validate() from dd-source: cost-planning-api/budgets/handler.go
func (r *costBudgetResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
var data costBudgetModel
resp.Diagnostics.Append(req.Config.Get(ctx, &data)...)
if resp.Diagnostics.HasError() || data.MetricsQuery.IsUnknown() || data.StartMonth.IsUnknown() || data.EndMonth.IsUnknown() {
return
}

// Extract tags from metrics_query
tags := extractTagsFromQuery(data.MetricsQuery.ValueString())

// Validate tags length
if len(tags) > 2 {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("metrics_query"),
"Invalid metrics_query",
"tags must have 0, 1 or 2 elements",
)
}

// Validate tags are unique
if len(tags) == 2 && tags[0] == tags[1] {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("metrics_query"),
"Invalid metrics_query",
"tags must be unique",
)
}

startMonth := data.StartMonth.ValueInt64()
endMonth := data.EndMonth.ValueInt64()

// Validate start_month
if startMonth <= 0 {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("start_month"),
"Invalid start_month",
"start_month must be greater than 0 and of the format YYYYMM",
)
}

// Validate end_month
if endMonth <= 0 {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("end_month"),
"Invalid end_month",
"end_month must be greater than 0 and of the format YYYYMM",
)
}

// Validate end_month >= start_month
if startMonth > endMonth {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("end_month"),
"Invalid end_month",
"end_month must be greater than or equal to start_month",
)
}

// Track which months exist for each unique tag combination
// Example: {"ASE\tstaging": {202501: true, 202502: true}} means team=ASE,account=staging has entries for 202501 & 202502
entriesMap := make(map[string]map[int64]bool)

// Validate entries
for i, entry := range data.Entries {
month := entry.Month.ValueInt64()
amount := entry.Amount.ValueFloat64()

// Validate entry month in range
if month < startMonth || month > endMonth {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("entries").AtListIndex(i).AtName("month"),
"Invalid month",
"entry month must be between start_month and end_month",
)
}

// Validate entry amount >= 0
if amount < 0 {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("entries").AtListIndex(i).AtName("amount"),
"Invalid amount",
"entry amount must be greater than or equal to 0",
)
}

// Validate tag_filters count
if len(entry.TagFilters) != len(tags) {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("entries").AtListIndex(i).AtName("tag_filters"),
"Invalid tag_filters",
"entry tag_filters must include all group by tags",
)
continue
}

// Validate tag_key and collect tag values
tagValues := make([]string, len(entry.TagFilters))
for j, tf := range entry.TagFilters {
tagKey := tf.TagKey.ValueString()

if !slices.Contains(tags, tagKey) {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("entries").AtListIndex(i).AtName("tag_filters").AtListIndex(j).AtName("tag_key"),
"Invalid tag_key",
"tag_key must be one of the values inside the tags array",
)
}

tagValues[j] = tf.TagValue.ValueString()
}

// Build unique key for this tag combination (e.g., "ASE\tstaging")
// We sort to ensure same combination regardless of order: {team:ASE,account:staging} = {account:staging,team:ASE}
sort.Strings(tagValues)
tagCombination := strings.Join(tagValues, "\t")
if entriesMap[tagCombination] == nil {
entriesMap[tagCombination] = make(map[int64]bool)
}
entriesMap[tagCombination][month] = true
}

// Validate entries exist
if len(entriesMap) == 0 {
resp.Diagnostics.AddAttributeError(
frameworkPath.Root("entries"),
"Missing entries",
"entries are required",
)
return
}

// Validate all tag combinations have entries for all months
expectedMonthCount := calculateMonthCount(startMonth, endMonth)
for tagCombination, months := range entriesMap {
if len(months) != expectedMonthCount {
resp.Diagnostics.AddError(
"Missing entries for tag combination",
fmt.Sprintf("missing entries for tag value pair: %v", tagCombination),
)
}
}
}

// --- Validation helper functions ---

// extractTagsFromQuery extracts tags from "by {tag1,tag2}" in metrics_query
// Copied from dd-source: domains/cloud_cost_management/libs/costplanningdb/tables.go
func extractTagsFromQuery(query string) []string {
subGroups := metricQueryRegex.FindStringSubmatch(query)
if len(subGroups) != 2 {
return []string{}
}
tags := strings.Split(subGroups[1], ",")
for i, tag := range tags {
tags[i] = strings.TrimSpace(tag)
}
return tags
}

// calculateMonthCount returns the number of months between start and end (inclusive)
// Copied from dd-source: domains/cloud_cost_management/libs/costplanningdb/tables.go (GetBudgetDuration)
func calculateMonthCount(start, end int64) int {
startYear := start / 100
endYear := end / 100
startMonth := start % 100
endMonth := end % 100
return int((endYear-startYear)*12 + endMonth - startMonth + 1)
}

// --- Helper functions to map between model and API types go here ---
func buildBudgetWithEntriesFromModel(plan costBudgetModel) datadogV2.BudgetWithEntries {
// Convert entries
Expand Down
Loading