-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathmain.go
More file actions
108 lines (95 loc) · 3.37 KB
/
Copy pathmain.go
File metadata and controls
108 lines (95 loc) · 3.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// costsweep prices recurring AWS waste (unattached EBS, idle EIPs, stale
// snapshots, CE rightsizing recs) and prints the annual total.
//
// costsweep # default region, table
// costsweep -region eu-west-1
// costsweep -format markdown # PR comment
// costsweep -demo # bundled sample data, no AWS
// costsweep -fail-over 1000 # exit 2 if annual waste >= $1000 (CI gate)
package main
import (
"context"
_ "embed"
"encoding/json"
"flag"
"fmt"
"os"
"time"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/costexplorer"
"github.com/aws/aws-sdk-go-v2/service/ec2"
"github.com/rezmoss/costsweep/internal/finding"
"github.com/rezmoss/costsweep/internal/report"
"github.com/rezmoss/costsweep/internal/scan"
)
//go:embed testdata/demo.json
var demoData []byte
func main() {
region := flag.String("region", envOr("AWS_REGION", "us-east-1"), "AWS region to scan")
profile := flag.String("profile", "", "AWS shared-config profile (optional)")
format := flag.String("format", "table", "output format: table | json | markdown")
demo := flag.Bool("demo", false, "run on bundled sample data, no AWS calls")
snapAgeDays := flag.Int("snapshot-age", 90, "flag snapshots older than this many days")
failOver := flag.Float64("fail-over", -1, "exit code 2 if annual waste >= this many USD (-1 disables)")
flag.Parse()
findings, err := collect(*demo, *region, *profile, *snapAgeDays)
if err != nil {
fmt.Fprintln(os.Stderr, "costsweep:", err)
os.Exit(1)
}
summary := report.Summarize(findings)
switch *format {
case "json":
_ = report.WriteJSON(os.Stdout, summary)
case "markdown":
report.WriteMarkdown(os.Stdout, summary)
default:
report.WriteTable(os.Stdout, summary)
}
// exit 2 (over budget) != exit 1 (tool broke)
if *failOver >= 0 && summary.OverThreshold(*failOver) {
fmt.Fprintf(os.Stderr, "\ncostsweep: annual waste $%.0f >= threshold $%.0f\n",
summary.TotalAnnual, *failOver)
os.Exit(2)
}
}
// collect reads findings from the demo file or a live scan.
func collect(demo bool, region, profile string, snapAgeDays int) ([]finding.Finding, error) {
if demo {
var findings []finding.Finding
if err := json.Unmarshal(demoData, &findings); err != nil {
return nil, fmt.Errorf("decoding demo data: %w", err)
}
return findings, nil
}
ctx := context.Background()
opts := []func(*awsconfig.LoadOptions) error{awsconfig.WithRegion(region)}
if profile != "" {
opts = append(opts, awsconfig.WithSharedConfigProfile(profile))
}
cfg, err := awsconfig.LoadDefaultConfig(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("loading AWS config: %w", err)
}
ec2c := ec2.NewFromConfig(cfg)
// CE is global, only answers in us-east-1
cec := costexplorer.NewFromConfig(cfg, func(o *costexplorer.Options) { o.Region = "us-east-1" })
scanners := []scan.Scanner{
&scan.RightsizingScanner{Client: cec},
&scan.UnattachedVolumeScanner{Client: ec2c, Region: region},
&scan.IdleAddressScanner{Client: ec2c, Region: region},
&scan.StaleSnapshotScanner{Client: ec2c, Region: region, MaxAge: time.Duration(snapAgeDays) * 24 * time.Hour},
}
found, errs := scan.Run(ctx, scanners)
for _, e := range errs {
// one bad scanner mustn't hide the rest
fmt.Fprintln(os.Stderr, "warning:", e)
}
return found, nil
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}