Most AWS automation starts as a bash script with aws CLI commands. It works until it does not. There is no error handling, no types, no way to test individual pieces. We will start with common AWS CLI patterns, then rebuild each one in Go with the AWS SDK v2, hitting real bugs along the way.
By the end you will have a single Go tool that generates a daily infrastructure report covering EC2, S3, costs, and IAM. Every step shows the CLI command first, then the Go code, then an intentional mistake, then the fix.
Prerequisites
Before starting, make sure you have these installed and configured:
- AWS CLI v2 installed and configured (
aws configure) - Go 1.21+ installed
- An AWS account with at least read access to EC2, S3, IAM, and Cost Explorer
- The AWS SDK v2 Go modules:
go mod init infra-report
go get github.com/aws/aws-sdk-go-v2
go get github.com/aws/aws-sdk-go-v2/config
go get github.com/aws/aws-sdk-go-v2/service/ec2
go get github.com/aws/aws-sdk-go-v2/service/s3
go get github.com/aws/aws-sdk-go-v2/service/costexplorer
go get github.com/aws/aws-sdk-go-v2/service/iam
Step 1: List EC2 Instances
AWS CLI
The aws ec2 describe-instances command returns a massive JSON response. Every instance has 200+ lines of metadata. The --query flag uses JMESPath to filter the response down to the fields you actually need.
aws ec2 describe-instances \
--query 'Reservations[*].Instances[*].[InstanceId,State.Name,InstanceType,PrivateIpAddress,Tags[?Key==`Name`].Value|[0]]' \
--output table
This prints a table with instance ID, state, type, private IP, and the Name tag. Without --query, you would need to pipe the output through jq or read through pages of JSON.
Two more useful variations:
# Only running instances
aws ec2 describe-instances \
--filters "Name=instance-state-name,Values=running" \
--output table
# Count instances by state
aws ec2 describe-instances \
--query 'Reservations[*].Instances[*].State.Name' \
--output text | sort | uniq -c
The second command flattens the state names into text, then uses standard Unix tools to count them. You might see output like:
12 running
3 stopped
1 terminated
Go Code
Now let us build the same thing in Go. This program connects to AWS, calls DescribeInstances, and prints a formatted table.
package main
import (
"context"
"fmt"
"os"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/ec2"
)
func main() {
cfg, err := config.LoadDefaultConfig(context.TODO())
if err != nil {
fmt.Fprintf(os.Stderr, "unable to load AWS config: %v\n", err)
os.Exit(1)
}
client := ec2.NewFromConfig(cfg)
output, err := client.DescribeInstances(context.TODO(), &ec2.DescribeInstancesInput{})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to describe instances: %v\n", err)
os.Exit(1)
}
fmt.Printf("%-20s %-12s %-14s %-16s %s\n", "INSTANCE ID", "STATE", "TYPE", "PRIVATE IP", "NAME")
fmt.Println("-------------------- ------------ -------------- ---------------- --------------------")
for _, reservation := range output.Reservations {
for _, instance := range reservation.Instances {
// Get the Name tag
name := ""
for _, tag := range instance.Tags {
if *tag.Key == "Name" {
name = *tag.Value
}
}
privateIP := "none"
if instance.PrivateIpAddress != nil {
privateIP = *instance.PrivateIpAddress
}
fmt.Printf("%-20s %-12s %-14s %-16s %s\n",
*instance.InstanceId,
instance.State.Name,
instance.InstanceType,
privateIP,
name,
)
}
}
}
Expected output:
INSTANCE ID STATE TYPE PRIVATE IP NAME
-------------------- ------------ -------------- ---------------- --------------------
i-0a1b2c3d4e5f6g7h8 running t3.medium 10.0.1.10 api-server-1
i-1b2c3d4e5f6g7h8i9 running t3.medium 10.0.1.11 api-server-2
i-2c3d4e5f6g7h8i9j0 stopped t3.small 10.0.3.30 dev-box
The Bug
This program panics when an instance has no Name tag. Look at this line:
name = *tag.Value
If an instance has no tags at all, the for loop over instance.Tags simply does not execute. That is fine. But name stays as an empty string, which is also fine.
The real problem is different. Some tags have a nil Value. The AWS API returns pointer types. If you have a tag where Key is “Name” but Value is nil, dereferencing *tag.Value causes a nil pointer dereference and the program crashes.
panic: runtime error: invalid memory address or nil pointer dereference
The Fix
Check for nil before dereferencing. Use a default value when the Name tag is missing.
// Safe version
name := "unnamed"
for _, tag := range instance.Tags {
if tag.Key != nil && *tag.Key == "Name" && tag.Value != nil {
name = *tag.Value
}
}
This pattern applies to every optional field in AWS responses. The SDK uses pointer types for optional fields. Always check for nil before dereferencing. This will come up again.
Step 2: Manage S3 Buckets and Objects
AWS CLI
S3 is the most commonly scripted AWS service. Here are the commands you will use daily.
# List all buckets
aws s3 ls
# List objects in a bucket with human-readable sizes and a total
aws s3 ls s3://my-bucket/ --recursive --human-readable --summarize
The --summarize flag adds a total object count and total size at the bottom. Useful for checking how big a bucket has gotten.
# Sync a local directory to S3
aws s3 sync ./backups/ s3://my-bucket/backups/ --delete
--delete flag on aws s3 sync removes S3 objects that do not exist in the local source directory. If you point it at the wrong local directory, or an empty directory, it deletes everything in the S3 prefix. Always double-check the source path before running sync with --delete.# Copy a file with server-side encryption
aws s3 cp backup.tar.gz s3://my-bucket/ --sse AES256
# Generate a presigned URL to share a file temporarily
aws s3 presign s3://my-bucket/report.pdf --expires-in 3600
The presigned URL lets anyone download the file for one hour without needing AWS credentials. Useful for sharing reports or build artifacts with people outside your AWS account.
Go Code
This program lists all S3 buckets, then lists objects in a specific bucket with size and last modified date.
package main
import (
"context"
"fmt"
"os"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/s3"
)
func main() {
cfg, err := config.LoadDefaultConfig(context.TODO())
if err != nil {
fmt.Fprintf(os.Stderr, "unable to load AWS config: %v\n", err)
os.Exit(1)
}
client := s3.NewFromConfig(cfg)
// List all buckets
bucketsOutput, err := client.ListBuckets(context.TODO(), &s3.ListBucketsInput{})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to list buckets: %v\n", err)
os.Exit(1)
}
fmt.Println("S3 Buckets:")
for _, bucket := range bucketsOutput.Buckets {
fmt.Printf(" %s (created %s)\n", *bucket.Name, bucket.CreationDate.Format("2006-01-02"))
}
fmt.Println()
// List objects in a specific bucket
targetBucket := "my-app-logs"
if len(os.Args) > 1 {
targetBucket = os.Args[1]
}
objectsOutput, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: &targetBucket,
})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to list objects in %s: %v\n", targetBucket, err)
os.Exit(1)
}
var totalSize int64
fmt.Printf("Objects in %s:\n", targetBucket)
fmt.Printf(" %-40s %-12s %s\n", "KEY", "SIZE", "LAST MODIFIED")
for _, obj := range objectsOutput.Contents {
totalSize += *obj.Size
fmt.Printf(" %-40s %-12s %s\n",
truncate(*obj.Key, 40),
formatBytes(*obj.Size),
obj.LastModified.Format("2006-01-02 15:04"),
)
}
fmt.Printf("\nTotal objects: %d\n", len(objectsOutput.Contents))
fmt.Printf("Total size: %s\n", formatBytes(totalSize))
}
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
func truncate(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
Expected output:
S3 Buckets:
my-app-assets (created 2024-03-15)
my-app-backups (created 2024-06-01)
my-app-logs (created 2023-11-20)
Objects in my-app-logs:
KEY SIZE LAST MODIFIED
logs/2026/02/app-2026-02-01.log.gz 2.3 MB 2026-02-01 00:15
logs/2026/02/app-2026-02-02.log.gz 1.8 MB 2026-02-02 00:15
...
Total objects: 1000
Total size: 1.2 GB
The Bug
Look at the output. It says “Total objects: 1000.” But the bucket actually has 5000 objects. What happened?
The ListObjectsV2 API returns a maximum of 1000 objects per call by default. That is the MaxKeys default. The response includes a field called IsTruncated that tells you whether there are more objects. Our code ignores it completely.
If you print objectsOutput.IsTruncated, you will see true. There are 4000 more objects we never fetched.
// This is the problem: we only get the first page
fmt.Printf("IsTruncated: %v\n", *objectsOutput.IsTruncated) // true
The Fix
Use a paginator. The AWS SDK v2 provides built-in paginators for every List API. A paginator automatically makes multiple API calls and returns all results.
// Before: one call, maximum 1000 objects
objectsOutput, err := client.ListObjectsV2(context.TODO(), &s3.ListObjectsV2Input{
Bucket: &targetBucket,
})
// After: paginator handles all pages automatically
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: &targetBucket,
})
var totalSize int64
var totalObjects int
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.TODO())
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get page: %v\n", err)
os.Exit(1)
}
for _, obj := range page.Contents {
totalSize += *obj.Size
totalObjects++
}
}
fmt.Printf("Total objects: %d\n", totalObjects) // 5000, not 1000
fmt.Printf("Total size: %s\n", formatBytes(totalSize))
List API is paginated. ListObjectsV2, DescribeInstances, ListUsers, ListFunctions – all of them. If you call them once without a paginator, you only get the first page. Always use paginators.Step 3: Check AWS Costs
AWS CLI
The Cost Explorer API lets you query your AWS spending programmatically.
# Get current month costs grouped by service
aws ce get-cost-and-usage \
--time-period Start=$(date -d "$(date +%Y-%m-01)" +%Y-%m-%d),End=$(date +%Y-%m-%d) \
--granularity MONTHLY \
--metrics BlendedCost \
--group-by Type=DIMENSION,Key=SERVICE \
--output table
This shows a breakdown of costs by AWS service for the current month. The Start date is the first of the current month. The End date is today.
# Get daily costs for the last 7 days
aws ce get-cost-and-usage \
--time-period Start=$(date -d "7 days ago" +%Y-%m-%d),End=$(date +%Y-%m-%d) \
--granularity DAILY \
--metrics BlendedCost
GetCostAndUsage API call costs $0.01. That is cheap for a single call, but if you put it in a loop or call it every minute, it adds up. Do not run Cost Explorer queries in tight loops.Go Code
This program gets the current month’s costs grouped by service and prints them sorted by cost.
package main
import (
"context"
"fmt"
"os"
"sort"
"strconv"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"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/costexplorer/types"
)
type serviceCost struct {
Name string
Cost float64
}
func main() {
cfg, err := config.LoadDefaultConfig(context.TODO())
if err != nil {
fmt.Fprintf(os.Stderr, "unable to load AWS config: %v\n", err)
os.Exit(1)
}
client := costexplorer.NewFromConfig(cfg)
now := time.Now().UTC()
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
output, err := client.GetCostAndUsage(context.TODO(), &costexplorer.GetCostAndUsageInput{
TimePeriod: &types.DateInterval{
Start: aws.String(startOfMonth.Format("2006-01-02")),
End: aws.String(today.Format("2006-01-02")),
},
Granularity: types.GranularityMonthly,
Metrics: []string{"BlendedCost"},
GroupBy: []types.GroupDefinition{
{
Type: types.GroupDefinitionTypeDimension,
Key: aws.String("SERVICE"),
},
},
})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get cost data: %v\n", err)
os.Exit(1)
}
var costs []serviceCost
var total float64
for _, result := range output.ResultsByTime {
for _, group := range result.Groups {
amount, _ := strconv.ParseFloat(*group.Metrics["BlendedCost"].Amount, 64)
if amount > 0.01 {
costs = append(costs, serviceCost{
Name: group.Keys[0],
Cost: amount,
})
total += amount
}
}
}
sort.Slice(costs, func(i, j int) bool {
return costs[i].Cost > costs[j].Cost
})
fmt.Printf("Monthly Costs (%s %d)\n", now.Month(), now.Year())
fmt.Println("------------------------------------------")
for _, c := range costs {
fmt.Printf(" %-30s $%.2f\n", c.Name, c.Cost)
}
fmt.Println("------------------------------------------")
fmt.Printf(" %-30s $%.2f\n", "TOTAL", total)
}
Expected output:
Monthly Costs (February 2026)
------------------------------------------
Amazon EC2 $1234.56
Amazon RDS $456.78
Amazon S3 $89.12
AWS Lambda $12.34
Amazon CloudWatch $8.90
------------------------------------------
TOTAL $1801.70
The Bug
The time period End date in the Cost Explorer API is exclusive. That means if you set End to 2026-02-15, you get costs up to and including February 14, but not February 15.
That is usually fine. The problem comes when someone tries to “fix” this by setting the end date to tomorrow:
// Someone tries to include today's costs
tomorrow := today.AddDate(0, 0, 1)
End: aws.String(tomorrow.Format("2006-01-02")),
If today is the last day of the month and you add one day, you get the first of the next month. That works. But the Cost Explorer API sometimes returns an error for future dates if cost data has not been processed yet. The behavior is inconsistent and depends on when AWS finalizes the billing data for the day.
The Fix
Set End to today’s date. Yes, it is exclusive, so you get data up to yesterday. That is the latest finalized data anyway. Today’s costs are still being calculated and may not be available until the next day.
// Correct: use today as the exclusive end date
// You get all finalized data up to yesterday
End: aws.String(today.Format("2006-01-02")),
If Start and End are the same date (for example, on the first day of the month), the API returns an error because the time period is empty. Handle that case:
if startOfMonth.Equal(today) {
fmt.Println("No cost data available yet for this month.")
return
}
Step 4: IAM Safety: List Users and Access Keys
AWS CLI
IAM access keys are a common security risk. Keys older than 90 days should be rotated. Many compliance frameworks, including CIS AWS Benchmarks, require this.
# List all IAM users
aws iam list-users \
--query 'Users[*].[UserName,CreateDate,PasswordLastUsed]' \
--output table
# List access keys for a specific user
aws iam list-access-keys --user-name deploy-bot
# Check access key age and status
aws iam list-access-keys --user-name deploy-bot \
--query 'AccessKeyMetadata[*].[AccessKeyId,CreateDate,Status]' \
--output table
You can combine these to audit all users at once:
# One-liner: list all users and their access key ages
for user in $(aws iam list-users --query 'Users[*].UserName' --output text); do
echo "=== $user ==="
aws iam list-access-keys --user-name "$user" \
--query 'AccessKeyMetadata[*].[AccessKeyId,CreateDate,Status]' \
--output table
done
This bash loop works, but it makes one API call per user. With 100 users, that is 100 API calls running sequentially. It is slow and can hit rate limits. This is exactly the kind of script that benefits from being rewritten in Go.
Go Code
This program lists all IAM users and their access keys. It flags keys older than 90 days as “ROTATE” and keys older than 180 days as “CRITICAL.”
package main
import (
"context"
"fmt"
"math"
"os"
"time"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/iam"
)
func main() {
cfg, err := config.LoadDefaultConfig(context.TODO())
if err != nil {
fmt.Fprintf(os.Stderr, "unable to load AWS config: %v\n", err)
os.Exit(1)
}
client := iam.NewFromConfig(cfg)
usersOutput, err := client.ListUsers(context.TODO(), &iam.ListUsersInput{})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to list users: %v\n", err)
os.Exit(1)
}
fmt.Printf("%-20s %-24s %-10s %-8s %s\n", "USER", "ACCESS KEY", "STATUS", "AGE", "ACTION")
fmt.Println("-------------------- ------------------------ ---------- -------- ----------")
for _, user := range usersOutput.Users {
keysOutput, err := client.ListAccessKeys(context.TODO(), &iam.ListAccessKeysInput{
UserName: user.UserName,
})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to list keys for %s: %v\n", *user.UserName, err)
continue
}
if len(keysOutput.AccessKeyMetadata) == 0 {
fmt.Printf("%-20s %-24s %-10s %-8s %s\n", *user.UserName, "no keys", "-", "-", "OK")
continue
}
for _, key := range keysOutput.AccessKeyMetadata {
age := time.Since(*key.CreateDate)
days := int(math.Floor(age.Hours() / 24))
action := "OK"
if days > 180 {
action = "CRITICAL"
} else if days > 90 {
action = "ROTATE"
}
fmt.Printf("%-20s %-24s %-10s %-8s %s\n",
*user.UserName,
*key.AccessKeyId,
string(key.Status),
fmt.Sprintf("%dd", days),
action,
)
}
}
}
Expected output:
USER ACCESS KEY STATUS AGE ACTION
-------------------- ------------------------ ---------- -------- ----------
admin-user AKIAIOSFODNN7EXAMPLE Active 45d OK
deploy-bot AKIAI44QH8DHBEXAMPLE Active 142d ROTATE
ci-runner AKIAIOSFODNN7EXAMPLE Active 267d CRITICAL
read-only no keys - - OK
The Bug
The ListUsers API is paginated. By default it returns a maximum of 100 users per page. Our code calls it once and processes the result. If your AWS account has 250 users, you only audit the first 100.
The program happily reports “All keys OK” when there are 150 unaudited users on pages 2 and 3. Some of those users might have access keys that are 300 days old. You would never know.
This is the same pagination bug from Step 2. It is worth repeating because it is the most common AWS SDK mistake.
The Fix
Use a paginator. Same pattern as before.
// Before: only first page of users
usersOutput, err := client.ListUsers(context.TODO(), &iam.ListUsersInput{})
// After: paginator fetches all pages
paginator := iam.NewListUsersPaginator(client, &iam.ListUsersInput{})
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.TODO())
if err != nil {
fmt.Fprintf(os.Stderr, "failed to list users: %v\n", err)
os.Exit(1)
}
for _, user := range page.Users {
// audit each user's access keys
}
}
Two steps in a row, the same bug. That is not a coincidence. The AWS SDK returns partial results by default for every List API. If you are not using a paginator, you are probably missing data.
Step 5: Build an Infrastructure Report Tool
Now we combine everything into a single tool. This program generates a daily infrastructure report covering EC2 instances, S3 buckets, costs, and IAM access key status. It uses colored output to highlight problems.
package main
import (
"context"
"fmt"
"math"
"os"
"sort"
"strconv"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
"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/ec2"
ec2types "github.com/aws/aws-sdk-go-v2/service/ec2/types"
"github.com/aws/aws-sdk-go-v2/service/iam"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/sts"
)
// ANSI color codes
const (
colorRed = "\033[31m"
colorYellow = "\033[33m"
colorGreen = "\033[32m"
colorReset = "\033[0m"
)
func main() {
cfg, err := config.LoadDefaultConfig(context.TODO())
if err != nil {
fmt.Fprintf(os.Stderr, "unable to load AWS config: %v\n", err)
os.Exit(1)
}
// Get account info
stsClient := sts.NewFromConfig(cfg)
identity, err := stsClient.GetCallerIdentity(context.TODO(), &sts.GetCallerIdentityInput{})
if err != nil {
fmt.Fprintf(os.Stderr, "failed to get caller identity: %v\n", err)
os.Exit(1)
}
now := time.Now().UTC()
fmt.Printf("AWS Infrastructure Report — %s\n", now.Format("2006-01-02 15:04:05 MST"))
fmt.Printf("Account: %s\n", *identity.Account)
fmt.Printf("Region: %s\n", cfg.Region)
fmt.Println(strings.Repeat("=", 55))
reportEC2(cfg)
reportS3(cfg)
reportCosts(cfg, now)
reportIAM(cfg)
}
// --- EC2 Section ---
func reportEC2(cfg aws.Config) {
client := ec2.NewFromConfig(cfg)
output, err := client.DescribeInstances(context.TODO(), &ec2.DescribeInstancesInput{})
if err != nil {
fmt.Fprintf(os.Stderr, " EC2 error: %v\n", err)
return
}
counts := map[ec2types.InstanceStateName]int{}
type instanceInfo struct {
Name string
Type string
IP string
State ec2types.InstanceStateName
}
var instances []instanceInfo
for _, res := range output.Reservations {
for _, inst := range res.Instances {
counts[inst.State.Name]++
name := "unnamed"
for _, tag := range inst.Tags {
if tag.Key != nil && *tag.Key == "Name" && tag.Value != nil {
name = *tag.Value
}
}
ip := "none"
if inst.PrivateIpAddress != nil {
ip = *inst.PrivateIpAddress
}
instances = append(instances, instanceInfo{
Name: name,
Type: string(inst.InstanceType),
IP: ip,
State: inst.State.Name,
})
}
}
running := counts[ec2types.InstanceStateNameRunning]
stopped := counts[ec2types.InstanceStateNameStopped]
total := len(instances)
fmt.Println()
fmt.Println("[EC2 Instances]")
fmt.Printf(" Running: %d | Stopped: %d | Total: %d\n", running, stopped, total)
if stopped > 0 {
fmt.Printf(" %sWARNING: %d stopped instances (still incur EBS costs)%s\n",
colorYellow, stopped, colorReset)
}
fmt.Println()
fmt.Printf(" %-20s %-14s %-16s %s\n", "Name", "Type", "IP", "State")
for _, inst := range instances {
stateColor := colorGreen
if inst.State == ec2types.InstanceStateNameStopped {
stateColor = colorYellow
} else if inst.State == ec2types.InstanceStateNameTerminated {
stateColor = colorRed
}
fmt.Printf(" %-20s %-14s %-16s %s%s%s\n",
inst.Name, inst.Type, inst.IP,
stateColor, inst.State, colorReset)
}
}
// --- S3 Section ---
func reportS3(cfg aws.Config) {
client := s3.NewFromConfig(cfg)
bucketsOutput, err := client.ListBuckets(context.TODO(), &s3.ListBucketsInput{})
if err != nil {
fmt.Fprintf(os.Stderr, " S3 error: %v\n", err)
return
}
fmt.Println()
fmt.Println("[S3 Buckets]")
for _, bucket := range bucketsOutput.Buckets {
paginator := s3.NewListObjectsV2Paginator(client, &s3.ListObjectsV2Input{
Bucket: bucket.Name,
})
var objectCount int
var totalSize int64
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.TODO())
if err != nil {
fmt.Fprintf(os.Stderr, " error listing %s: %v\n", *bucket.Name, err)
break
}
for _, obj := range page.Contents {
objectCount++
if obj.Size != nil {
totalSize += *obj.Size
}
}
}
fmt.Printf(" %-25s %10s objects %s\n",
*bucket.Name,
formatNumber(objectCount),
formatBytes(totalSize))
}
}
// --- Cost Section ---
func reportCosts(cfg aws.Config, now time.Time) {
client := costexplorer.NewFromConfig(cfg)
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, time.UTC)
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
fmt.Println()
fmt.Printf("[Monthly Costs] (%s %d)\n", now.Month(), now.Year())
if startOfMonth.Equal(today) {
fmt.Println(" No cost data yet — first day of the month.")
return
}
output, err := client.GetCostAndUsage(context.TODO(), &costexplorer.GetCostAndUsageInput{
TimePeriod: &cetypes.DateInterval{
Start: aws.String(startOfMonth.Format("2006-01-02")),
End: aws.String(today.Format("2006-01-02")),
},
Granularity: cetypes.GranularityMonthly,
Metrics: []string{"BlendedCost"},
GroupBy: []cetypes.GroupDefinition{
{
Type: cetypes.GroupDefinitionTypeDimension,
Key: aws.String("SERVICE"),
},
},
})
if err != nil {
fmt.Fprintf(os.Stderr, " Cost Explorer error: %v\n", err)
return
}
type svcCost struct {
Name string
Cost float64
}
var costs []svcCost
var total float64
for _, result := range output.ResultsByTime {
for _, group := range result.Groups {
amount, _ := strconv.ParseFloat(*group.Metrics["BlendedCost"].Amount, 64)
if amount > 0.01 {
costs = append(costs, svcCost{Name: group.Keys[0], Cost: amount})
total += amount
}
}
}
sort.Slice(costs, func(i, j int) bool {
return costs[i].Cost > costs[j].Cost
})
// Show top 5 services, group the rest as "Other"
var otherCost float64
for i, c := range costs {
if i < 5 {
fmt.Printf(" %-30s $%.2f\n", c.Name, c.Cost)
} else {
otherCost += c.Cost
}
}
if otherCost > 0 {
fmt.Printf(" %-30s $%.2f\n", "Other", otherCost)
}
fmt.Printf(" %-30s $%.2f\n", "TOTAL", total)
}
// --- IAM Section ---
func reportIAM(cfg aws.Config) {
client := iam.NewFromConfig(cfg)
paginator := iam.NewListUsersPaginator(client, &iam.ListUsersInput{})
fmt.Println()
fmt.Println("[IAM Audit]")
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.TODO())
if err != nil {
fmt.Fprintf(os.Stderr, " IAM error: %v\n", err)
return
}
for _, user := range page.Users {
keysOutput, err := client.ListAccessKeys(context.TODO(), &iam.ListAccessKeysInput{
UserName: user.UserName,
})
if err != nil {
fmt.Fprintf(os.Stderr, " error listing keys for %s: %v\n", *user.UserName, err)
continue
}
if len(keysOutput.AccessKeyMetadata) == 0 {
fmt.Printf(" %-20s Keys OK\n", *user.UserName)
continue
}
for _, key := range keysOutput.AccessKeyMetadata {
age := time.Since(*key.CreateDate)
days := int(math.Floor(age.Hours() / 24))
action := colorGreen + "OK" + colorReset
if days > 180 {
action = colorRed + "CRITICAL" + colorReset
} else if days > 90 {
action = colorYellow + "ROTATE" + colorReset
}
fmt.Printf(" %-20s %-24s created %d days ago %s\n",
*user.UserName, *key.AccessKeyId, days, action)
}
}
}
}
// --- Helpers ---
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "KMGTPE"[exp])
}
func formatNumber(n int) string {
s := fmt.Sprintf("%d", n)
if n < 1000 {
return s
}
// Simple thousands separator
result := ""
for i, c := range s {
if i > 0 && (len(s)-i)%3 == 0 {
result += ","
}
result += string(c)
}
return result
}
Note: add "strings" to your import block for the strings.Repeat call.
Run it with:
go run main.go
It uses whatever AWS credentials are configured in your environment (environment variables, ~/.aws/credentials, or an instance profile if running on EC2).
Sample output:
AWS Infrastructure Report — 2026-02-15 22:30:05 UTC
Account: 123456789012
Region: us-east-1
=======================================================
[EC2 Instances]
Running: 12 | Stopped: 3 | Total: 15
WARNING: 3 stopped instances (still incur EBS costs)
Name Type IP State
api-server-1 t3.medium 10.0.1.10 running
api-server-2 t3.medium 10.0.1.11 running
worker-1 c5.xlarge 10.0.2.20 running
dev-box t3.small 10.0.3.30 stopped
[S3 Buckets]
my-app-assets 142,381 objects 48.2 GB
my-app-backups 1,205 objects 12.1 GB
my-app-logs 892,441 objects 203.7 GB
[Monthly Costs] (February 2026)
Amazon EC2 $1,234.56
Amazon S3 $89.12
Amazon RDS $456.78
AWS Lambda $12.34
Other $67.89
TOTAL $1,860.69
[IAM Audit]
deploy-bot AKIAIOSFODNN7EXAMPLE created 142 days ago ROTATE
ci-runner AKIAI44QH8DHBEXAMPLE created 267 days ago CRITICAL
admin-user Keys OK
What We Built
Here is a summary of each step and the bug we hit along the way:
aws ec2 describe-instancesbecame a Go EC2 lister. Bug: nil pointer dereference on missing Name tag. Fix: always check for nil on optional pointer fields.aws s3 lsandaws s3 syncbecame a Go S3 lister. Bug:ListObjectsV2only returns 1000 objects by default. Fix: useNewListObjectsV2Paginatorto fetch all pages.aws ce get-cost-and-usagebecame a Go cost reporter. Bug: theEnddate is exclusive, and setting it to tomorrow can cause errors. Fix: use today as the exclusive end date; handle the first-day-of-month edge case.aws iam list-usersandlist-access-keysbecame a Go IAM auditor. Bug:ListUsersonly returns the first 100 users. Fix: useNewListUsersPaginator.Combined all four into a single infrastructure report tool with colored output.
Two of the four bugs were the same pagination issue. That is not a coincidence. It is the most common AWS SDK mistake.
Cheat Sheet
AWS CLI Patterns
# List running EC2 instances
aws ec2 describe-instances --filters "Name=instance-state-name,Values=running"
# Sync local directory to S3 (careful with --delete)
aws s3 sync ./local/ s3://bucket/prefix/ --delete
# Get monthly costs by service
aws ce get-cost-and-usage --time-period Start=...,End=... --granularity MONTHLY
# List all IAM users
aws iam list-users --query 'Users[*].UserName'
# Generate a temporary download link
aws s3 presign s3://bucket/key --expires-in 3600
Go AWS SDK v2 Patterns
// Always load config from environment or files
cfg, err := config.LoadDefaultConfig(context.TODO())
// Always use paginators for List APIs
paginator := s3.NewListObjectsV2Paginator(client, &input)
for paginator.HasMorePages() {
page, err := paginator.NextPage(context.TODO())
// process page.Contents
}
// Always check for nil before accessing optional fields
if instance.PublicIpAddress != nil {
ip = *instance.PublicIpAddress
}
Rules to Remember
- Every AWS
ListAPI is paginated. Always use paginators. Never assume one page is enough. - Always check for nil on optional fields. AWS SDK responses use pointer types for optional values. Dereferencing a nil pointer crashes your program.
- Cost Explorer API costs $0.01 per call. Do not put it in a loop.
aws s3 sync --deletemirrors source to destination. Point it at the wrong source directory and you delete everything in S3.- Access keys older than 90 days should be rotated. Build this check into your automation and run it daily.
- The Cost Explorer
Enddate is exclusive. Setting it to today gives you data through yesterday, which is the latest finalized data.
References and Further Reading
- AWS. (2024). AWS SDK for Go v2 Developer Guide. Amazon Web Services.
- AWS. (2024). AWS CLI Command Reference. Amazon Web Services.
- AWS. (2024). Cost Explorer API Reference. Amazon Web Services.
- Center for Internet Security. (2024). CIS Amazon Web Services Foundations Benchmark. CIS.
- Wittig, A. & Wittig, M. (2023). Amazon Web Services in Action (3rd ed.). Manning Publications.
Keep Reading
- Terraform From Scratch: Provision AWS Infrastructure Step by Step: provision the EC2 instances, S3 buckets, and VPCs from this article using infrastructure as code.
- AWS Security Audit: From AWS CLI to a Go Security Scanner: use the same AWS SDK patterns to audit IAM, security groups, and S3 policies.
- Build a Go CLI Tool for AWS S3: go deeper on S3 operations with a dedicated Go CLI tool.
- Build and Deploy a Go Lambda Function: run your AWS automation code as a Lambda function.
What's your most used AWS CLI one-liner? The command you've typed so many times you could do it in your sleep?