From e9ff96dc9273404f5f6d0f86acf413a9e07da02b Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Thu, 26 Mar 2026 22:43:14 +0100 Subject: [PATCH 1/2] Add resource-level cost breakdown via CUR/Athena - New --resources/-r flag on status command for resource-level cost drilldown - New --period flag (day/week/month) to control time window - Athena query functions in internal/aws (RunAthenaQuery, GetTeamResourceCosts) - Graceful degradation when CUR data is unavailable --- cmd/status.go | 24 +++++- go.mod | 15 ++-- go.sum | 19 +++-- internal/aws/aws.go | 182 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 224 insertions(+), 16 deletions(-) diff --git a/cmd/status.go b/cmd/status.go index 79b1266..29a76f9 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -13,6 +13,8 @@ import ( var teamFlag string var serviceFlag string +var resourcesFlag bool +var periodFlag string var statusCmd = &cobra.Command{ Use: "status", @@ -20,13 +22,18 @@ var statusCmd = &cobra.Command{ 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 { @@ -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") diff --git a/go.mod b/go.mod index ce78aad..42b9a29 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index a44b41d..875ad30 100644 --- a/go.sum +++ b/go.sum @@ -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= @@ -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= @@ -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= diff --git a/internal/aws/aws.go b/internal/aws/aws.go index 4504325..2da64be 100644 --- a/internal/aws/aws.go +++ b/internal/aws/aws.go @@ -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)) @@ -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 +} From b80672476ad98e603533270840b13d7bcfb16328 Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Thu, 26 Mar 2026 22:43:14 +0100 Subject: [PATCH 2/2] GitLab activity: javabin-cli-cur-analytics 2026-03-26 e9ff96dc9273404f5f6d0f86acf413a9e07da02b [redacted] --- .../224314_e9ff96dc9273404f5f6d0f86acf413a9e07da02b.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 activity/javabin-cli-cur-analytics/2026-03-26/224314_e9ff96dc9273404f5f6d0f86acf413a9e07da02b.md diff --git a/activity/javabin-cli-cur-analytics/2026-03-26/224314_e9ff96dc9273404f5f6d0f86acf413a9e07da02b.md b/activity/javabin-cli-cur-analytics/2026-03-26/224314_e9ff96dc9273404f5f6d0f86acf413a9e07da02b.md new file mode 100644 index 0000000..9d1a445 --- /dev/null +++ b/activity/javabin-cli-cur-analytics/2026-03-26/224314_e9ff96dc9273404f5f6d0f86acf413a9e07da02b.md @@ -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.