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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# GitLab Activity Bridge

**Repository:** javabin-cli-cur-analytics
**Date:** 2026-03-26
**Commit:** e9ff96dc9273404f5f6d0f86acf413a9e07da02b
**Author:** Alexander Amiri
**Message:** [redacted for privacy]

This represents development activity on a private GitLab repository.
24 changes: 23 additions & 1 deletion cmd/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,27 @@ import (

var teamFlag string
var serviceFlag string
var resourcesFlag bool
var periodFlag string

var statusCmd = &cobra.Command{
Use: "status",
Short: "Show team costs and ECS service status",
Long: `Show month-to-date AWS costs for a team and ECS service status.

Flags --team and --service override auto-detection. If run from a directory
with an app.yaml, team and service name are read from it automatically.`,
with an app.yaml, team and service name are read from it automatically.

Use --resources to show resource-level cost breakdown from CUR data via Athena.
Use --period to control the time window (day, week, month).`,
RunE: runStatus,
}

func init() {
statusCmd.Flags().StringVar(&teamFlag, "team", "", "Team name (reads from app.yaml if not set)")
statusCmd.Flags().StringVar(&serviceFlag, "service", "", "Service name (reads from app.yaml if not set)")
statusCmd.Flags().BoolVarP(&resourcesFlag, "resources", "r", false, "Show resource-level cost breakdown (requires CUR)")
statusCmd.Flags().StringVar(&periodFlag, "period", "month", "Time period for resource costs: day, week, month")
}

type appYaml struct {
Expand Down Expand Up @@ -86,6 +93,21 @@ func runStatus(cmd *cobra.Command, args []string) error {
fmt.Printf(" Team spend: $%.2f\n", cost)
}

// Resource-level breakdown from CUR
if resourcesFlag {
fmt.Printf("\n--- Top Resources (%s) ---\n", periodFlag)
resources, err := aws.GetTeamResourceCosts(ctx, cfg, team, periodFlag)
if err != nil {
fmt.Printf(" Could not fetch resources: %v\n", err)
} else if len(resources) == 0 {
fmt.Println(" No CUR data available yet")
} else {
for _, r := range resources {
fmt.Printf(" %-40s %-20s $%.2f\n", r.FriendlyName(), r.Service, r.Cost)
}
}
}

// ECS services
fmt.Println("\n--- ECS Services ---")
services, err := aws.ListServices(ctx, cfg, "javabin-platform")
Expand Down
15 changes: 9 additions & 6 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,29 +1,32 @@
module github.com/javaBin/javabin-cli

go 1.22
go 1.24

toolchain go1.24.6

require (
github.com/aws/aws-sdk-go-v2 v1.32.7
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.28.7
github.com/aws/aws-sdk-go-v2/service/athena v1.57.4
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.45.1
github.com/aws/aws-sdk-go-v2/service/ecs v1.52.1
github.com/aws/aws-sdk-go-v2/service/sts v1.33.3
github.com/spf13/cobra v1.8.1
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/aws/aws-sdk-go-v2/credentials v1.17.48 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.12.1 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.12.7 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.24.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 // indirect
github.com/aws/smithy-go v1.22.1 // indirect
github.com/aws/smithy-go v1.24.2 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
19 changes: 11 additions & 8 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
github.com/aws/aws-sdk-go-v2 v1.32.7 h1:ky5o35oENWi0JYWUZkB7WYvVPP+bcRF5/Iq7JWSb5Rw=
github.com/aws/aws-sdk-go-v2 v1.32.7/go.mod h1:P5WJBrYqqbWVaOxgH0X/FYYD47/nooaPOZPlQdmiN2U=
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
github.com/aws/aws-sdk-go-v2/config v1.28.7 h1:GduUnoTXlhkgnxTD93g1nv4tVPILbdNQOzav+Wpg7AE=
github.com/aws/aws-sdk-go-v2/config v1.28.7/go.mod h1:vZGX6GVkIE8uECSUHB6MWAUsd4ZcG2Yq/dMa4refR3M=
github.com/aws/aws-sdk-go-v2/credentials v1.17.48 h1:IYdLD1qTJ0zanRavulofmqut4afs45mOWEI+MzZtTfQ=
github.com/aws/aws-sdk-go-v2/credentials v1.17.48/go.mod h1:tOscxHN3CGmuX9idQ3+qbkzrjVIx32lqDSU1/0d/qXs=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22 h1:kqOrpojG71DxJm/KDPO+Z/y1phm1JlC8/iT+5XRmAn8=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.22/go.mod h1:NtSFajXVVL8TA2QNngagVZmUtXciyrHOt7xgz4faS/M=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26 h1:I/5wmGMffY4happ8NOCuIUEWGUvvFp5NSeQcXl9RHcI=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.26/go.mod h1:FR8f4turZtNy6baO0KJ5FJUmXH/cSkI9fOngs0yl6mA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26 h1:zXFLuEuMMUOvEARXFUVJdfqZ4bvvSgdGRq/ATcrQxzM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.26/go.mod h1:3o2Wpy0bogG1kyOPrgkXA8pgIfEEv0+m19O9D5+W8y8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1 h1:VaRN3TlFdd6KxX1x3ILT5ynH6HvKgqdiXoTxAF4HQcQ=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.1/go.mod h1:FbtygfRFze9usAadmnGJNc8KsP346kEe+y2/oyhGAGc=
github.com/aws/aws-sdk-go-v2/service/athena v1.57.4 h1:NVPYbXd3/opIA7aYbrVYdpiAT4X9v0gFdm3pIT/Gv38=
github.com/aws/aws-sdk-go-v2/service/athena v1.57.4/go.mod h1:bAt78R/Er51uSM3xY44wP9ptfXHFvAQ8w7c4ZNtv+Ik=
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.45.1 h1:2aaEZa6CBfsEebfn3jxwnIDGbSAwZnqIsEC5KF89X2w=
github.com/aws/aws-sdk-go-v2/service/costexplorer v1.45.1/go.mod h1:RboWadEsqV6Hw/OOyyu8IP+kdz0DASutt3H4ezBxSIk=
github.com/aws/aws-sdk-go-v2/service/ecs v1.52.1 h1:85SGI/Db9I8PT2rvDLIRGxXdSzuyC4ZKDJwfzuv7WqQ=
Expand All @@ -26,8 +28,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7 h1:F2rBfNAL5UyswqoeWv9zs74N
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.7/go.mod h1:JfyQ0g2JG8+Krq0EuZNnRwX0mU0HrwY/tG6JNfcqh4k=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.3 h1:Xgv/hyNgvLda/M9l9qxXc4UFSgppnRczLxlMs5Ae/QY=
github.com/aws/aws-sdk-go-v2/service/sts v1.33.3/go.mod h1:5Gn+d+VaaRgsjewpMvGazt0WfcFO+Md4wLOuBfGR9Bc=
github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro=
github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg=
github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng=
github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand All @@ -45,6 +47,7 @@ github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3k
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
Expand Down
182 changes: 181 additions & 1 deletion internal/aws/aws.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,25 @@ package aws
import (
"context"
"fmt"
"strings"
"time"

"github.com/aws/aws-sdk-go-v2/aws"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/athena"
athenatypes "github.com/aws/aws-sdk-go-v2/service/athena/types"
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
cetypes "github.com/aws/aws-sdk-go-v2/service/costexplorer/types"
"github.com/aws/aws-sdk-go-v2/service/ecs"
"github.com/aws/aws-sdk-go-v2/service/sts"
)

const defaultRegion = "eu-central-1"
const (
defaultRegion = "eu-central-1"
CURDatabase = "javabin_cur"
CURTable = "javabin_cur"
AthenaWorkgroup = "javabin-cost-analytics"
)

func LoadConfig(ctx context.Context) (aws.Config, error) {
return awsconfig.LoadDefaultConfig(ctx, awsconfig.WithRegion(defaultRegion))
Expand Down Expand Up @@ -116,3 +124,175 @@ func ListServices(ctx context.Context, cfg aws.Config, cluster string) ([]Servic
}
return services, nil
}

// ResourceCost holds a CUR resource-level cost entry.
type ResourceCost struct {
ResourceID string
Service string
Team string
Cost float64
UsageType string
}

// FriendlyName returns a shortened version of the resource ARN.
func (r ResourceCost) FriendlyName() string {
id := r.ResourceID
if id == "" {
return "(no resource ID)"
}
if strings.Contains(id, ":::") {
parts := strings.SplitN(id, ":::", 2)
if len(parts) == 2 {
return parts[1]
}
}
if strings.Contains(id, "/") {
parts := strings.Split(id, "/")
if len(parts) <= 3 {
return parts[len(parts)-1]
}
return strings.Join(parts[len(parts)-2:], "/")
}
if strings.Contains(id, ":") {
parts := strings.Split(id, ":")
return parts[len(parts)-1]
}
return id
}

// GetTeamResourceCosts queries CUR via Athena for top resources by cost for a team.
func GetTeamResourceCosts(ctx context.Context, cfg aws.Config, team, period string) ([]ResourceCost, error) {
now := time.Now().UTC()
year := now.Format("2006")
month := now.Format("01")

var dateFilter string
switch period {
case "day":
yesterday := now.AddDate(0, 0, -1).Format("2006-01-02")
today := now.Format("2006-01-02")
dateFilter = fmt.Sprintf(
"AND line_item_usage_start_date >= TIMESTAMP '%s' AND line_item_usage_start_date < TIMESTAMP '%s'",
yesterday, today,
)
case "week":
weekAgo := now.AddDate(0, 0, -7).Format("2006-01-02")
today := now.Format("2006-01-02")
dateFilter = fmt.Sprintf(
"AND line_item_usage_start_date >= TIMESTAMP '%s' AND line_item_usage_start_date < TIMESTAMP '%s'",
weekAgo, today,
)
default: // month
dateFilter = "" // year/month partition filter is sufficient
}

query := fmt.Sprintf(`
SELECT line_item_resource_id,
line_item_product_code,
line_item_usage_type,
COALESCE(resource_tags_user_team, '') as team,
SUM(CAST(line_item_unblended_cost AS double)) as total_cost
FROM "%s"."%s"
WHERE year = '%s' AND month = '%s'
AND resource_tags_user_team = '%s'
AND line_item_resource_id != ''
AND line_item_line_item_type = 'Usage'
%s
GROUP BY line_item_resource_id, line_item_product_code,
line_item_usage_type, COALESCE(resource_tags_user_team, '')
HAVING SUM(CAST(line_item_unblended_cost AS double)) >= 0.01
ORDER BY total_cost DESC
LIMIT 10
`, CURDatabase, CURTable, year, month, team, dateFilter)

rows, err := RunAthenaQuery(ctx, cfg, query, CURDatabase, AthenaWorkgroup)
if err != nil {
return nil, err
}

var results []ResourceCost
for _, row := range rows {
var cost float64
fmt.Sscanf(row["total_cost"], "%f", &cost)
results = append(results, ResourceCost{
ResourceID: row["line_item_resource_id"],
Service: row["line_item_product_code"],
Team: row["team"],
Cost: cost,
UsageType: row["line_item_usage_type"],
})
}
return results, nil
}

// RunAthenaQuery executes a query and returns results as a slice of maps.
func RunAthenaQuery(ctx context.Context, cfg aws.Config, query, database, workgroup string) ([]map[string]string, error) {
client := athena.NewFromConfig(cfg)

startOut, err := client.StartQueryExecution(ctx, &athena.StartQueryExecutionInput{
QueryString: aws.String(query),
QueryExecutionContext: &athenatypes.QueryExecutionContext{
Database: aws.String(database),
},
WorkGroup: aws.String(workgroup),
})
if err != nil {
return nil, fmt.Errorf("start query: %w", err)
}

execID := startOut.QueryExecutionId

// Poll for completion (30s timeout)
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
statusOut, err := client.GetQueryExecution(ctx, &athena.GetQueryExecutionInput{
QueryExecutionId: execID,
})
if err != nil {
return nil, fmt.Errorf("get query status: %w", err)
}

state := statusOut.QueryExecution.Status.State
switch state {
case athenatypes.QueryExecutionStateSucceeded:
goto fetchResults
case athenatypes.QueryExecutionStateFailed, athenatypes.QueryExecutionStateCancelled:
reason := aws.ToString(statusOut.QueryExecution.Status.StateChangeReason)
return nil, fmt.Errorf("query %s: %s", state, reason)
}

time.Sleep(1 * time.Second)
}
return nil, fmt.Errorf("query timed out")

fetchResults:
resultsOut, err := client.GetQueryResults(ctx, &athena.GetQueryResultsInput{
QueryExecutionId: execID,
})
if err != nil {
return nil, fmt.Errorf("get results: %w", err)
}

resultSet := resultsOut.ResultSet
if len(resultSet.Rows) < 2 {
return nil, nil // header only, no data
}

// First row is the header
var columns []string
for _, col := range resultSet.Rows[0].Data {
columns = append(columns, aws.ToString(col.VarCharValue))
}

var rows []map[string]string
for _, row := range resultSet.Rows[1:] {
m := make(map[string]string)
for i, d := range row.Data {
if i < len(columns) {
m[columns[i]] = aws.ToString(d.VarCharValue)
}
}
rows = append(rows, m)
}
return rows, nil
}