Skip to main content
Menu
Home WhoAmI Stack Insights Blog Contact
/user/KayD @ karandeepsingh.ca :~$ cat bash-scripting-meets-aws.md

AWS CLI Automation: From Bash Scripts to Go

Karandeep Singh
• 21 minutes read

Summary

AWS automation from CLI to Go code. Each step shows the AWS CLI command first, then builds it in Go. List EC2 instances, manage S3, check costs, build an infrastructure report tool.

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
# 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))

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

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:

  1. aws ec2 describe-instances became a Go EC2 lister. Bug: nil pointer dereference on missing Name tag. Fix: always check for nil on optional pointer fields.

  2. aws s3 ls and aws s3 sync became a Go S3 lister. Bug: ListObjectsV2 only returns 1000 objects by default. Fix: use NewListObjectsV2Paginator to fetch all pages.

  3. aws ce get-cost-and-usage became a Go cost reporter. Bug: the End date 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.

  4. aws iam list-users and list-access-keys became a Go IAM auditor. Bug: ListUsers only returns the first 100 users. Fix: use NewListUsersPaginator.

  5. 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 List API 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 --delete mirrors 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 End date is exclusive. Setting it to today gives you data through yesterday, which is the latest finalized data.

References and Further Reading

Keep Reading

Question

What's your most used AWS CLI one-liner? The command you've typed so many times you could do it in your sleep?

Contents