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
37 changes: 37 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: Test infra-sdk

on:
push:
branches: [ $default-branch ]
pull_request: {}

jobs:
test:
runs-on: ubuntu-latest
permissions:
contents: read
issues: read
checks: write
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.25
cache: true
- name: Set up gotestsum
run: go install gotest.tools/gotestsum@latest
- name: Run tests
run: |
mkdir -p build/test-results
gotestsum \
--format=short-verbose \
--junitfile build/test-results/go-junit.xml \
./...
- name: Publish test results
uses: EnricoMi/publish-unit-test-result-action@v2
with:
files: build/test-results/**/*.xml
check_name: Go Tests
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ go.work
go.work.sum

# env file
.env
*.env

# Editor/IDE
.idea/
Expand Down
84 changes: 84 additions & 0 deletions builtin/aws/aws-account/cost_result_aggregator.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package aws_account

import (
"fmt"
"sort"
"strings"
"time"

cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
infra_sdk "github.com/nullstone-io/infra-sdk"
)

func NewCostResultAggregator() *CostResultAggregator {
return &CostResultAggregator{
CostResult: infra_sdk.NewCostResult(),
}
}

type CostResultAggregator struct {
CostResult *infra_sdk.CostResult
}

func (a *CostResultAggregator) AddResults(resultsByTime []cetypes.ResultByTime, inputGroups infra_sdk.CostGroupIdentifiers) error {
for _, resultByTime := range resultsByTime {
start, end, err := a.parseWindow(resultByTime)
if err != nil {
return fmt.Errorf("error parsing result: %w", err)
}

for _, grp := range resultByTime.Groups {
grpKeys := a.parseResultGroupKeys(inputGroups, grp.Keys)
for metricName, metricValue := range grp.Metrics {
a.CostResult.AddDatapoint(metricName, grpKeys, infra_sdk.CostSeriesDatapoint{
Start: start,
End: end,
Unit: unptr(metricValue.Unit),
Value: unptr(metricValue.Amount),
})
}
}
}
return nil
}

func (a *CostResultAggregator) parseWindow(resultByTime cetypes.ResultByTime) (time.Time, time.Time, error) {
if resultByTime.TimePeriod == nil {
return time.Time{}, time.Time{}, fmt.Errorf("missing time period in results")
}
rawStart, rawEnd := unptr(resultByTime.TimePeriod.Start), unptr(resultByTime.TimePeriod.End)
start, err := time.Parse("2006-01-02", rawStart)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid start time in results %q: %w", rawStart, err)
}
end, err := time.Parse("2006-01-02", rawEnd)
if err != nil {
return time.Time{}, time.Time{}, fmt.Errorf("invalid end time in results %q: %w", rawEnd, err)
}
return start, end, nil
}

func (a *CostResultAggregator) parseResultGroupKeys(inputGroups infra_sdk.CostGroupIdentifiers, keys []string) infra_sdk.CostSeriesGroupKeys {
sort.Strings(keys)

result := make(infra_sdk.CostSeriesGroupKeys, 0)
for i, key := range keys {
tokens := strings.SplitN(key, "$", 2)
if len(tokens) == 2 {
result = append(result, infra_sdk.CostSeriesGroupKey{
TagKey: AwsTag(tokens[0]).ToUniversal(),
Value: tokens[1],
})
} else {
name := fmt.Sprintf("dimension-%d", i)
if i < len(inputGroups) {
name = inputGroups[i].Dimension
}
result = append(result, infra_sdk.CostSeriesGroupKey{
Name: name,
Value: key,
})
}
}
return result
}
119 changes: 119 additions & 0 deletions builtin/aws/aws-account/coster.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
package aws_account

import (
"context"
"fmt"

ce "github.com/aws/aws-sdk-go-v2/service/costexplorer"
cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
infra_sdk "github.com/nullstone-io/infra-sdk"
"github.com/nullstone-io/infra-sdk/access/aws"
"gopkg.in/nullstone-io/go-api-client.v0/types"
)

var (
granularityMappings = map[infra_sdk.CostGranularity]cetypes.Granularity{
infra_sdk.CostGranularityHourly: cetypes.GranularityHourly,
infra_sdk.CostGranularityDaily: cetypes.GranularityDaily,
infra_sdk.CostGranularityMonthly: cetypes.GranularityMonthly,
}
)

type Coster struct {
Assumer aws.Assumer
Provider types.Provider
}

func (c Coster) GetCosts(ctx context.Context, query infra_sdk.CostQuery) (*infra_sdk.CostResult, error) {
// Cost Explorer is global, use us-east-1 as the region to satisfy the aws sdk
providerConfig := types.ProviderConfig{Aws: &types.AwsProviderConfig{Region: "us-east-1"}}
awsConfig, err := aws.ResolveConfig(c.Assumer.AwsConfig(), c.Provider, providerConfig)
if err != nil {
return nil, fmt.Errorf("error resolving aws config: %w", err)
}
client := ce.NewFromConfig(awsConfig)

period := &cetypes.DateInterval{
Start: ptr(query.Start.Format("2006-01-02")),
End: ptr(query.End.Format("2006-01-02")), // end is EXCLUSIVE
}

granularity := granularityMappings[query.Granularity]
if granularity == "" {
granularity = cetypes.GranularityDaily
}

groupBy := query.GroupBy.Unique()
input := &ce.GetCostAndUsageInput{
TimePeriod: period,
Granularity: granularity,
Metrics: []string{"UnblendedCost"},
Filter: costQueryToFilter(query),
GroupBy: costQueryToGroupBy(groupBy),
}

aggregator := NewCostResultAggregator()
var nextToken *string
for {
input.NextPageToken = nextToken
out, err := client.GetCostAndUsage(ctx, input)
if err != nil {
return nil, fmt.Errorf("error querying aws cost explorer: %w", err)
}
if err := aggregator.AddResults(out.ResultsByTime, groupBy); err != nil {
return nil, fmt.Errorf("error aggregating results: %w", err)
}
if out.NextPageToken == nil || *out.NextPageToken == "" {
break
}
nextToken = out.NextPageToken
}

return aggregator.CostResult, nil
}

func costQueryToFilter(query infra_sdk.CostQuery) *cetypes.Expression {
if len(query.FilterTags) < 1 {
return nil
}
if len(query.FilterTags) == 1 {
return &cetypes.Expression{
Tags: &cetypes.TagValues{
Key: ptr(UniversalTag(query.FilterTags[0].Key).ToAws()),
MatchOptions: []cetypes.MatchOption{cetypes.MatchOptionEquals},
Values: query.FilterTags[0].Values,
},
}
}

root := &cetypes.Expression{}
for _, filterTag := range query.FilterTags {
root.And = append(root.And, cetypes.Expression{
Tags: &cetypes.TagValues{
Key: ptr(UniversalTag(filterTag.Key).ToAws()),
MatchOptions: []cetypes.MatchOption{cetypes.MatchOptionEquals},
Values: filterTag.Values,
},
})
}
return root
}

func costQueryToGroupBy(groupBy infra_sdk.CostGroupIdentifiers) []cetypes.GroupDefinition {
var defs []cetypes.GroupDefinition
for _, cur := range groupBy {
if cur.Dimension != "" {
defs = append(defs, cetypes.GroupDefinition{
Key: ptr(UniversalDimension(cur.Dimension).ToAws()),
Type: cetypes.GroupDefinitionTypeDimension,
})
} else if cur.TagKey != "" {
defs = append(defs, cetypes.GroupDefinition{
Key: ptr(UniversalTag(cur.TagKey).ToAws()),
Type: cetypes.GroupDefinitionTypeTag,
})
}

}
return defs
}
13 changes: 13 additions & 0 deletions builtin/aws/aws-account/pointers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package aws_account

func ptr[T any](t T) *T {
return &t
}

func unptr[T any](t *T) T {
var result T
if t != nil {
result = *t
}
return result
}
6 changes: 3 additions & 3 deletions builtin/aws/aws-account/scan_route53.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ package aws_account
import (
"context"
"fmt"
"strings"

"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/route53"
r53types "github.com/aws/aws-sdk-go-v2/service/route53/types"
"github.com/aws/smithy-go/ptr"
infra_sdk "github.com/nullstone-io/infra-sdk"
"golang.org/x/net/publicsuffix"
"gopkg.in/nullstone-io/go-api-client.v0/types"
"strings"
)

// ScanRoute53 scans Route53 hosted zones and returns them as scan resources
Expand Down Expand Up @@ -85,7 +85,7 @@ func getHostedZoneTags(ctx context.Context, client *route53.Client, zoneId strin
}

for _, tag := range output.ResourceTagSet.Tags {
tags[ptr.ToString(tag.Key)] = ptr.ToString(tag.Value)
tags[unptr(tag.Key)] = unptr(tag.Value)
}

return tags, nil
Expand Down
51 changes: 51 additions & 0 deletions builtin/aws/aws-account/universal_names.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package aws_account

import infra_sdk "github.com/nullstone-io/infra-sdk"

type AwsDimension string

func (d AwsDimension) ToUniversal() string {
switch d {
case "LINKED_ACCOUNT":
return infra_sdk.UniversalDimensionAccount
}
return string(d)
}

type UniversalDimension string

func (d UniversalDimension) ToAws() string {
switch d {
case infra_sdk.UniversalDimensionAccount:
return "LINKED_ACCOUNT"
}
return string(d)
}

type AwsTag string

func (t AwsTag) ToUniversal() string {
switch t {
case "Stack":
return infra_sdk.UniversalTagStack
case "Env":
return infra_sdk.UniversalTagEnv
case "Block":
return infra_sdk.UniversalTagBlock
}
return string(t)
}

type UniversalTag string

func (t UniversalTag) ToAws() string {
switch t {
case infra_sdk.UniversalTagStack:
return "Stack"
case infra_sdk.UniversalTagEnv:
return "Env"
case infra_sdk.UniversalTagBlock:
return "Block"
}
return string(t)
}
37 changes: 37 additions & 0 deletions builtin/discover_coster.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package builtin

import (
infra_sdk "github.com/nullstone-io/infra-sdk"
"github.com/nullstone-io/infra-sdk/access/aws"
aws_account "github.com/nullstone-io/infra-sdk/builtin/aws/aws-account"
"gopkg.in/nullstone-io/go-api-client.v0/types"
)

type CosterCreator struct {
AwsAssumer aws.Assumer
}

func (s CosterCreator) NewMultiCoster(providers []types.Provider) (infra_sdk.MultiCoster, error) {
mc := infra_sdk.MultiCoster{Costers: []infra_sdk.Coster{}}
for _, cur := range providers {
coster, err := s.DiscoverCoster(cur)
if err != nil {
return mc, err
}
mc.Costers = append(mc.Costers, coster)
}
return mc, nil
}

func (s CosterCreator) DiscoverCoster(provider types.Provider) (infra_sdk.Coster, error) {
switch provider.ProviderType {
case "aws":
return aws_account.Coster{
Assumer: s.AwsAssumer,
Provider: provider,
}, nil
case "gcp":
// TODO: Implement GCP
}
return nil, nil
}
Loading