Skip to content
Merged
Show file tree
Hide file tree
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
36 changes: 36 additions & 0 deletions builtin/gcp/gcp-project/cost_result_aggregator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,39 @@ func TestCostResultAggregator_AddRow_MultipleRows(t *testing.T) {
// Two env values x four measures
assert.Len(t, agg.CostResult.Series, 8)
}

func TestCostResultAggregator_AddRow_ResourceGroupBy(t *testing.T) {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
groupBy := infra_sdk.CostGroupIdentifiers{
{Dimension: infra_sdk.UniversalDimensionResource},
}

t.Run("full resource name is the key value", func(t *testing.T) {
agg := NewCostResultAggregator()
row := metricRow(start, end, map[string]bigquery.Value{
"dim_0": "//compute.googleapis.com/projects/p/zones/us-central1-a/instances/api-1",
})
require.NoError(t, agg.AddRow(row, groupBy))

require.NotEmpty(t, agg.CostResult.Series)
for _, series := range agg.CostResult.Series {
require.Len(t, series.GroupKeys, 1)
assert.Equal(t, infra_sdk.UniversalDimensionResource, series.GroupKeys[0].Name)
assert.Equal(t, "//compute.googleapis.com/projects/p/zones/us-central1-a/instances/api-1", series.GroupKeys[0].Value)
}
})

t.Run("rows without a resource group under an empty id", func(t *testing.T) {
agg := NewCostResultAggregator()
row := metricRow(start, end, map[string]bigquery.Value{"dim_0": nil})
require.NoError(t, agg.AddRow(row, groupBy))

require.NotEmpty(t, agg.CostResult.Series)
for _, series := range agg.CostResult.Series {
require.Len(t, series.GroupKeys, 1)
assert.Equal(t, infra_sdk.UniversalDimensionResource, series.GroupKeys[0].Name)
assert.Equal(t, "", series.GroupKeys[0].Value)
}
})
}
6 changes: 3 additions & 3 deletions builtin/gcp/gcp-project/coster.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,10 @@ type Coster struct {

func (c Coster) ProviderType() string { return "gcp" }

// Capabilities describes the BigQuery billing export: any grouping in one query, every measure,
// charge category carried; no resource grouping yet.
// Capabilities describes the resource-level BigQuery billing export: any grouping in one query,
// every measure, charge category carried, and the billed resource (see GcpResourceColumn).
func Capabilities() infra_sdk.CostCapabilities {
return infra_sdk.CostCapabilities{MaxGroupBy: 0, Metrics: SupportedMetrics(), HasChargeCategory: true}
return infra_sdk.CostCapabilities{MaxGroupBy: 0, Metrics: SupportedMetrics(), HasChargeCategory: true, HasResource: true}
}

// MonthSource is always the billing export: GCP has a single source.
Expand Down
16 changes: 16 additions & 0 deletions builtin/gcp/gcp-project/coster_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package gcp_project

import (
"testing"

infra_sdk "github.com/nullstone-io/infra-sdk"
"github.com/stretchr/testify/assert"
)

func TestCapabilities(t *testing.T) {
caps := Capabilities()
assert.Equal(t, 0, caps.MaxGroupBy, "the billing export accepts any number of groupings in one query")
assert.Equal(t, infra_sdk.AllCostMetrics(), caps.Metrics)
assert.True(t, caps.HasChargeCategory)
assert.True(t, caps.HasResource, "the resource-level export carries the billed resource")
}
19 changes: 19 additions & 0 deletions builtin/gcp/gcp-project/query_builder_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gcp_project

import (
"strings"
"testing"
"time"

Expand Down Expand Up @@ -255,3 +256,21 @@ func TestQueryBuilder_Build_WithGroupByChargeCategory(t *testing.T) {
assert.Contains(t, result.SQL, "cost_type AS dim_0")
assert.Contains(t, result.SQL, "GROUP BY period_start, period_end, dim_0, currency")
}

func TestQueryBuilder_Build_WithGroupByResource(t *testing.T) {
builder := &QueryBuilder{Table: "ds.table"}
result := builder.Build(infra_sdk.CostQuery{
Start: time.Now(),
End: time.Now(),
Granularity: infra_sdk.CostGranularityDaily,
GroupBy: infra_sdk.CostGroupIdentifiers{
{Dimension: infra_sdk.UniversalDimensionService},
{Dimension: infra_sdk.UniversalDimensionResource},
},
})

// the resource is grouped through its alias, so the IFNULL expression appears once in SELECT
assert.Contains(t, result.SQL, "IFNULL(resource.global_name, resource.name) AS dim_1")
assert.Contains(t, result.SQL, "GROUP BY period_start, period_end, dim_0, dim_1, currency")
assert.Equal(t, 1, strings.Count(result.SQL, "resource.global_name"))
}
11 changes: 11 additions & 0 deletions builtin/gcp/gcp-project/universal_names.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ func (t UniversalTag) ToGcp() string {
return string(t)
}

// GcpResourceColumn is the expression that identifies the billed resource in the resource-level
// billing export (gcp_billing_export_resource_v1_*, the only table the integration accepts).
// resource.global_name is the full resource name (//compute.googleapis.com/projects/.../instances/x)
// and plays the FOCUS ResourceId role; some services only populate the short resource.name, and
// rows with no resource at all (fees, some network SKUs) leave both NULL, which groups as "".
const GcpResourceColumn = "IFNULL(resource.global_name, resource.name)"

type UniversalDimension string

func (d UniversalDimension) ToGcpColumn() string {
Expand All @@ -40,6 +47,8 @@ func (d UniversalDimension) ToGcpColumn() string {
return "service.description"
case infra_sdk.UniversalDimensionChargeCategory:
return "cost_type"
case infra_sdk.UniversalDimensionResource:
return GcpResourceColumn
}
return string(d)
}
Expand All @@ -54,6 +63,8 @@ func (d GcpDimension) ToUniversal() string {
return infra_sdk.UniversalDimensionService
case "cost_type":
return infra_sdk.UniversalDimensionChargeCategory
case GcpResourceColumn:
return infra_sdk.UniversalDimensionResource
}
return string(d)
}
Expand Down
11 changes: 10 additions & 1 deletion builtin/gcp/gcp-project/universal_names_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,26 @@ func TestUniversalTag_ToGcp(t *testing.T) {
func TestUniversalDimension_ToGcpColumn(t *testing.T) {
assert.Equal(t, "project.id", UniversalDimension(infra_sdk.UniversalDimensionAccount).ToGcpColumn())
assert.Equal(t, "service.description", UniversalDimension(infra_sdk.UniversalDimensionService).ToGcpColumn())
assert.Equal(t, "cost_type", UniversalDimension(infra_sdk.UniversalDimensionChargeCategory).ToGcpColumn())
assert.Equal(t, "IFNULL(resource.global_name, resource.name)", UniversalDimension(infra_sdk.UniversalDimensionResource).ToGcpColumn())
assert.Equal(t, "custom-dim", UniversalDimension("custom-dim").ToGcpColumn())
}

func TestGcpDimension_ToUniversal(t *testing.T) {
assert.Equal(t, infra_sdk.UniversalDimensionAccount, GcpDimension("project.id").ToUniversal())
assert.Equal(t, infra_sdk.UniversalDimensionService, GcpDimension("service.description").ToUniversal())
assert.Equal(t, infra_sdk.UniversalDimensionChargeCategory, GcpDimension("cost_type").ToUniversal())
assert.Equal(t, infra_sdk.UniversalDimensionResource, GcpDimension(GcpResourceColumn).ToUniversal())
assert.Equal(t, "custom-dim", GcpDimension("custom-dim").ToUniversal())
}

func TestRoundTrip_Dimensions(t *testing.T) {
dims := []string{infra_sdk.UniversalDimensionAccount, infra_sdk.UniversalDimensionService}
dims := []string{
infra_sdk.UniversalDimensionAccount,
infra_sdk.UniversalDimensionService,
infra_sdk.UniversalDimensionChargeCategory,
infra_sdk.UniversalDimensionResource,
}
for _, dim := range dims {
gcpColumn := UniversalDimension(dim).ToGcpColumn()
roundTripped := GcpDimension(gcpColumn).ToUniversal()
Expand Down
Loading