# AWS CLI Automation: From Bash Scripts to Go

- URL: https://karandeepsingh.ca/posts/bash-scripting-meets-aws/
- Author: Karandeep Singh
- Published: 2026-02-15
- Series: Linux Commands to Go
- Tags: aws, golang, bash, automation
- 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.

![Connected network nodes representing cloud infrastructure automation with bash scripts](https://karandeepsingh.ca/images/posts/bash-scripting-meets-aws.jpg)

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

```bash
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.

```bash
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:

```bash
# Only running instances
aws ec2 describe-instances \
  --filters "Name=instance-state-name,Values=running" \
  --output table
```

```bash
# 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.

```go
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:

```go
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.

```go
// 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.

```bash
# List all buckets
aws s3 ls
```

```bash
# 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.

```bash
# Sync a local directory to S3
aws s3 sync ./backups/ s3://my-bucket/backups/ --delete
```

**warning:**
The `--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`.

```bash
# Copy a file with server-side encryption
aws s3 cp backup.tar.gz s3://my-bucket/ --sse AES256
```

```bash
# 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.

```go
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.

```go
// 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.

```go
// 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))
```

**info:**
Almost every AWS `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](https://docs.aws.amazon.com/cost-management/latest/userguide/ce-api.html) API lets you query your AWS spending programmatically.

```bash
# 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.

```bash
# 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
```

**warning:**
The Cost Explorer API is not free. Each `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.

```go
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:

```go
// 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.

```go
// 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:

```go
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.

```bash
# List all IAM users
aws iam list-users \
  --query 'Users[*].[UserName,CreateDate,PasswordLastUsed]' \
  --output table
```

```bash
# List access keys for a specific user
aws iam list-access-keys --user-name deploy-bot
```

```bash
# 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:

```bash
# 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."

```go
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.

```go
// 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.

```go
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:

```bash
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

```bash
# 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

```go
// 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

- AWS. (2024). [AWS SDK for Go v2 Developer Guide](https://aws.github.io/aws-sdk-go-v2/docs/). Amazon Web Services.
- AWS. (2024). [AWS CLI Command Reference](https://docs.aws.amazon.com/cli/latest/reference/). Amazon Web Services.
- AWS. (2024). [Cost Explorer API Reference](https://docs.aws.amazon.com/cost-management/latest/userguide/ce-api.html). Amazon Web Services.
- Center for Internet Security. (2024). [CIS Amazon Web Services Foundations Benchmark](https://www.cisecurity.org/benchmark/amazon_web_services). CIS.
- Wittig, A. & Wittig, M. (2023). *[Amazon Web Services in Action](https://www.manning.com/books/amazon-web-services-in-action-third-edition)* (3rd ed.). Manning Publications.

## Keep Reading

- [Terraform From Scratch: Provision AWS Infrastructure Step by Step](https://karandeepsingh.ca/posts/terraform-getting-started-aws/): 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](https://karandeepsingh.ca/posts/aws-security-features-guide/): use the same AWS SDK patterns to audit IAM, security groups, and S3 policies.
- [Build a Go CLI Tool for AWS S3](https://karandeepsingh.ca/posts/go-aws-s3-cli-tool/): go deeper on S3 operations with a dedicated Go CLI tool.
- [Build and Deploy a Go Lambda Function](https://karandeepsingh.ca/posts/build-deploy-go-lambda-function/): run your AWS automation code as a Lambda function.

**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?
