Python Scripting

Choose a script from the contents. Study one script at a time, then use Previous Lesson or Next Lesson to continue.

Before using these cloud scripts, complete the production scripting bridge and EC2 reporting lab. Practice credentials, configuration, timeouts, retries, and tests before moving to live automation.

Auto Stop/Start EC2 Instances Using Schedule Tags with Python

Problem Statement

Your team has 20 dev/staging EC2 instances that run 24/7 but are only used during business hours (8 AM – 8 PM). Each instance costs ~$0.10/hour. Running them overnight wastes $0.10 × 12 hours × 20 instances = $24/day — nearly $730/month in idle compute.

Goal: Write a Python script that:

  • Finds all instances tagged AutoStop=true
  • Stops them at 8 PM every day
  • Starts them at 8 AM every day
  • Logs all actions so you can audit what happened

Prerequisites Setup

Step 1 — Install dependencies

pip install boto3 schedule
PackagePurpose
boto3AWS SDK for Python — talks to EC2, S3, Lambda, etc.
scheduleLightweight job scheduler — runs functions at set times

Step 2 — Configure AWS credentials

# Option A — AWS CLI (recommended for local use)
aws configure
# AWS Access Key ID:     AKIA...
# AWS Secret Access Key: xxxxxxxx
# Default region:        ap-south-1
# Default output format: json

# Option B — Environment variables (for CI/CD or Docker)
export AWS_ACCESS_KEY_ID=AKIA...
export AWS_SECRET_ACCESS_KEY=xxxxxxxx
export AWS_DEFAULT_REGION=ap-south-1

# Option C — IAM Role (best for EC2/Lambda — no credentials needed)
# Attach an IAM role with the right permissions to the instance running this script

Step 3 — Tag your EC2 instances

# Tag an instance via AWS CLI
aws ec2 create-tags \
  --resources i-0abc123def456789 \
  --tags Key=AutoStop,Value=true

# Or tag multiple instances at once
aws ec2 create-tags \
  --resources i-0abc123def456789 i-0def456789abc123 \
  --tags Key=AutoStop,Value=true

# Verify the tag
aws ec2 describe-tags \
  --filters "Name=resource-id,Values=i-0abc123def456789"

Step 4 — Required IAM permissions

Create an IAM policy and attach it to the user or role running this script:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "EC2ScheduleControl",
      "Effect": "Allow",
      "Action": [
        "ec2:DescribeInstances",
        "ec2:DescribeTags",
        "ec2:StartInstances",
        "ec2:StopInstances"
      ],
      "Resource": "*"
    }
  ]
}

The Complete Script

# ec2_scheduler.py
"""
EC2 Auto Stop/Start Scheduler
Stops instances tagged AutoStop=true at 8 PM.
Starts them at 8 AM.
"""

import boto3
import schedule
import time
import logging
from datetime import datetime
from botocore.exceptions import ClientError, NoCredentialsError

# ── Logging setup ────────────────────────────────────────────────
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-8s  %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.StreamHandler(),                    # Print to console
        logging.FileHandler("ec2_scheduler.log"),   # Also write to file
    ],
)
log = logging.getLogger(__name__)

# ── Configuration ─────────────────────────────────────────────────
REGION        = "ap-south-1"   # Change to your AWS region
TAG_KEY       = "AutoStop"     # The tag key we look for
TAG_VALUE     = "true"         # The tag value we look for
STOP_TIME     = "20:00"        # 8 PM — 24-hour format
START_TIME    = "08:00"        # 8 AM — 24-hour format


# ── boto3 client ──────────────────────────────────────────────────
def get_ec2_client():
    """
    Create an EC2 client for the specified region.

    boto3.client() creates a low-level service client.
    - 'ec2'     : the AWS service name
    - region_name: which AWS region to connect to
    Credentials are picked up automatically from:
      1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY)
      2. ~/.aws/credentials file (set by `aws configure`)
      3. IAM role attached to the EC2/Lambda running this script
    """
    return boto3.client("ec2", region_name=REGION)


# ── Helper: find tagged instances ─────────────────────────────────
def get_tagged_instances(ec2, desired_state: str) -> list[dict]:
    """
    Return EC2 instances that have AutoStop=true AND are in the desired_state.

    Parameters
    ----------
    ec2           : boto3 EC2 client
    desired_state : 'running' to find instances to stop,
                    'stopped' to find instances to start

    How describe_instances works
    ----------------------------
    - Filters is a list of dicts with 'Name' and 'Values' keys.
    - 'tag:AutoStop'     matches the tag KEY named AutoStop.
    - 'instance-state-name' filters by the current lifecycle state.
    - The API returns a paginated response — Reservations is the top-level list.
      Each Reservation can contain multiple Instances (from a single launch command).
    """
    try:
        response = ec2.describe_instances(
            Filters=[
                {
                    "Name":   f"tag:{TAG_KEY}",    # Filter by tag key
                    "Values": [TAG_VALUE],          # Tag value must be 'true'
                },
                {
                    "Name":   "instance-state-name",
                    "Values": [desired_state],      # Only running or stopped
                },
            ]
        )
    except NoCredentialsError:
        log.error("AWS credentials not found. Run 'aws configure' or set env vars.")
        return []
    except ClientError as e:
        log.error(f"AWS API error: {e.response['Error']['Message']}")
        return []

    # Flatten the nested Reservations → Instances structure into a flat list
    instances = [
        instance
        for reservation in response["Reservations"]
        for instance in reservation["Instances"]
    ]

    return instances


# ── Helper: get a human-readable instance name ─────────────────────
def get_instance_name(instance: dict) -> str:
    """
    Extract the 'Name' tag value from the instance's Tags list.

    Tags is a list of {'Key': '...', 'Value': '...'} dicts.
    We use next() with a default so it doesn't crash if 'Name' tag is absent.
    """
    tags = instance.get("Tags", [])
    name = next((t["Value"] for t in tags if t["Key"] == "Name"), "Unnamed")
    return name


# ── STOP action ───────────────────────────────────────────────────
def stop_instances():
    """
    Find all running instances tagged AutoStop=true and stop them.
    Called automatically at STOP_TIME (8 PM).
    """
    log.info("=" * 55)
    log.info(f"[STOP JOB] Starting at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")

    ec2 = get_ec2_client()
    instances = get_tagged_instances(ec2, desired_state="running")

    if not instances:
        log.info("[STOP JOB] No running instances with AutoStop=true found.")
        return

    # Extract just the instance IDs — that's what start/stop APIs need
    instance_ids = [inst["InstanceId"] for inst in instances]

    log.info(f"[STOP JOB] Found {len(instance_ids)} instance(s) to stop:")
    for inst in instances:
        log.info(f"  - {inst['InstanceId']}  ({get_instance_name(inst)})")

    try:
        """
        ec2.stop_instances() sends a stop signal to each instance.
        - InstanceIds: list of instance ID strings (e.g. ['i-0abc...', 'i-0def...'])
        - The API call is asynchronous — it returns immediately.
          The instance transitions: running → stopping → stopped.
        - DryRun=True can be used to test without actually stopping.
        - StopInstances does NOT terminate (delete) the instance.
          Data on the EBS root volume is preserved.
        """
        response = ec2.stop_instances(InstanceIds=instance_ids)

        # Log the new state reported by AWS for each instance
        for item in response["StoppingInstances"]:
            prev  = item["PreviousState"]["Name"]
            curr  = item["CurrentState"]["Name"]
            iid   = item["InstanceId"]
            log.info(f"  ✓ {iid}: {prev} → {curr}")

        log.info(f"[STOP JOB] Stop signal sent to {len(instance_ids)} instance(s).")

    except ClientError as e:
        error_code = e.response["Error"]["Code"]
        error_msg  = e.response["Error"]["Message"]
        log.error(f"[STOP JOB] Failed to stop instances. Code: {error_code} — {error_msg}")
        # Common errors:
        # UnsupportedOperation: instance store-backed instances can't be stopped
        # IncorrectInstanceState: instance is already stopping/terminated


# ── START action ──────────────────────────────────────────────────
def start_instances():
    """
    Find all stopped instances tagged AutoStop=true and start them.
    Called automatically at START_TIME (8 AM).
    """
    log.info("=" * 55)
    log.info(f"[START JOB] Starting at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")

    ec2 = get_ec2_client()
    instances = get_tagged_instances(ec2, desired_state="stopped")

    if not instances:
        log.info("[START JOB] No stopped instances with AutoStop=true found.")
        return

    instance_ids = [inst["InstanceId"] for inst in instances]

    log.info(f"[START JOB] Found {len(instance_ids)} instance(s) to start:")
    for inst in instances:
        log.info(f"  - {inst['InstanceId']}  ({get_instance_name(inst)})")

    try:
        """
        ec2.start_instances() boots stopped EBS-backed instances.
        - InstanceIds: list of instance ID strings.
        - The API call is asynchronous.
          The instance transitions: stopped → pending → running.
        - A new public IP is assigned (unless an Elastic IP is attached).
        - Instance store data is NOT preserved across stop/start cycles.
          EBS data IS preserved.
        """
        response = ec2.start_instances(InstanceIds=instance_ids)

        for item in response["StartingInstances"]:
            prev  = item["PreviousState"]["Name"]
            curr  = item["CurrentState"]["Name"]
            iid   = item["InstanceId"]
            log.info(f"  ✓ {iid}: {prev} → {curr}")

        log.info(f"[START JOB] Start signal sent to {len(instance_ids)} instance(s).")

    except ClientError as e:
        error_code = e.response["Error"]["Code"]
        error_msg  = e.response["Error"]["Message"]
        log.error(f"[START JOB] Failed to start instances. Code: {error_code} — {error_msg}")


# ── Scheduler setup ───────────────────────────────────────────────
def setup_schedule():
    """
    Register stop and start jobs with the schedule library.

    schedule.every().day.at("HH:MM") registers a recurring daily job.
    - Times use 24-hour format.
    - The scheduler runs in the LOCAL timezone of the machine.
      If you need UTC or a specific timezone, use pytz:
          import pytz
          tz = pytz.timezone("Asia/Kolkata")
          now = datetime.now(tz)
    """
    schedule.every().day.at(STOP_TIME).do(stop_instances)
    schedule.every().day.at(START_TIME).do(start_instances)

    log.info(f"Scheduler active — Stop: {STOP_TIME} | Start: {START_TIME}")
    log.info(f"Region: {REGION} | Tag filter: {TAG_KEY}={TAG_VALUE}")
    log.info("Waiting for next scheduled job... (Ctrl+C to exit)")


# ── Entry point ───────────────────────────────────────────────────
if __name__ == "__main__":
    setup_schedule()

    """
    schedule.run_pending() checks if any registered job is due and runs it.
    It does NOT block — it returns immediately if no job is due.

    We wrap it in an infinite loop with time.sleep(60) so we check
    every 60 seconds. This is lightweight (almost no CPU usage while sleeping).

    The loop runs forever until:
    - You press Ctrl+C (raises KeyboardInterrupt)
    - The process is killed (systemd, Docker, etc.)
    """
    try:
        while True:
            schedule.run_pending()
            time.sleep(60)   # Check every minute — fine-grained enough for HH:MM scheduling
    except KeyboardInterrupt:
        log.info("Scheduler stopped by user.")

Running the Script

Option A — Run directly (for testing)

# Run and watch the logs
python ec2_scheduler.py

# 2025-01-20 07:59:00  INFO     Scheduler active — Stop: 20:00 | Start: 08:00
# 2025-01-20 07:59:00  INFO     Region: ap-south-1 | Tag filter: AutoStop=true
# 2025-01-20 08:00:00  INFO     ═══════════════════════════════════════════════════════
# 2025-01-20 08:00:00  INFO     [START JOB] Starting at 2025-01-20 08:00:00
# 2025-01-20 08:00:00  INFO     Found 3 instance(s) to start:
# 2025-01-20 08:00:00  INFO       - i-0abc123  (dev-api-server)
# 2025-01-20 08:00:00  INFO       - i-0def456  (staging-db)
# 2025-01-20 08:00:00  INFO       - i-0ghi789  (test-worker)
# 2025-01-20 08:00:01  INFO       ✓ i-0abc123: stopped → pending
# 2025-01-20 08:00:01  INFO       ✓ i-0def456: stopped → pending
# 2025-01-20 08:00:01  INFO       ✓ i-0ghi789: stopped → pending

Option B — Run as a Linux systemd service (production)

# /etc/systemd/system/ec2-scheduler.service
[Unit]
Description=EC2 Auto Stop/Start Scheduler
After=network.target

[Service]
Type=simple
User=ubuntu
WorkingDirectory=/opt/ec2-scheduler
ExecStart=/opt/ec2-scheduler/venv/bin/python ec2_scheduler.py
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable ec2-scheduler
sudo systemctl start ec2-scheduler
sudo systemctl status ec2-scheduler
journalctl -u ec2-scheduler -f   # Follow live logs

Option C — Run as a cron job (alternative)

# Edit crontab
crontab -e

# Add these two lines:
# Stop at 8 PM (20:00) every day
0 20 * * * /usr/bin/python3 /opt/ec2-scheduler/ec2_scheduler_once.py stop >> /var/log/ec2-stop.log 2>&1

# Start at 8 AM (08:00) every day
0 8  * * * /usr/bin/python3 /opt/ec2-scheduler/ec2_scheduler_once.py start >> /var/log/ec2-start.log 2>&1
# ec2_scheduler_once.py — for cron (runs once, stops/starts, then exits)
import sys
from ec2_scheduler import start_instances, stop_instances

if __name__ == "__main__":
    action = sys.argv[1] if len(sys.argv) > 1 else "stop"
    if action == "stop":
        stop_instances()
    elif action == "start":
        start_instances()

Option D — AWS Lambda + EventBridge (serverless, no server needed)

# lambda_handler.py — deploy this as a Lambda function
import boto3
from botocore.exceptions import ClientError

TAG_KEY   = "AutoStop"
TAG_VALUE = "true"
REGION    = "ap-south-1"

def lambda_handler(event, context):
    """
    EventBridge triggers this Lambda on a cron schedule.
    event['action'] is set by the EventBridge rule's Input field.
    """
    action = event.get("action", "stop")
    ec2 = boto3.client("ec2", region_name=REGION)

    state = "running" if action == "stop" else "stopped"
    response = ec2.describe_instances(
        Filters=[
            {"Name": f"tag:{TAG_KEY}", "Values": [TAG_VALUE]},
            {"Name": "instance-state-name", "Values": [state]},
        ]
    )

    instance_ids = [
        inst["InstanceId"]
        for r in response["Reservations"]
        for inst in r["Instances"]
    ]

    if not instance_ids:
        return {"status": "no instances found", "action": action}

    if action == "stop":
        ec2.stop_instances(InstanceIds=instance_ids)
    else:
        ec2.start_instances(InstanceIds=instance_ids)

    return {
        "status": "success",
        "action": action,
        "instances": instance_ids,
        "count": len(instance_ids),
    }
# EventBridge rules (AWS Console or CLI)
# Stop rule — every day at 8 PM UTC
aws events put-rule \
  --name ec2-stop-rule \
  --schedule-expression "cron(0 14 * * ? *)" \
  --state ENABLED

# Start rule — every day at 8 AM UTC
aws events put-rule \
  --name ec2-start-rule \
  --schedule-expression "cron(0 2 * * ? *)" \
  --state ENABLED

Key Commands Explained

CommandWhat it does
boto3.client("ec2", region_name=REGION)Creates an EC2 API client for the specified region
ec2.describe_instances(Filters=[...])Lists instances matching the tag + state filter
response["Reservations"]Top-level grouping returned by EC2 describe — each holds 1+ instances
ec2.stop_instances(InstanceIds=[...])Sends stop signal; instance goes running → stopping → stopped
ec2.start_instances(InstanceIds=[...])Sends start signal; instance goes stopped → pending → running
schedule.every().day.at("20:00").do(fn)Registers fn to run daily at 20:00 in local time
schedule.run_pending()Executes any jobs that are due — called in the event loop
item["PreviousState"]["Name"]Reports what the instance state was before the API call
item["CurrentState"]["Name"]Reports the transitional state immediately after the API call

Cost Savings Estimate

ScenarioInstancesHours saved/dayCost/hrDaily savingMonthly saving
Small team512$0.10$6~$180
Medium team2012$0.10$24~$720
Large team5012$0.10$60~$1,800

The script pays for itself (Lambda cost: ~$0/month on free tier) the first day it runs.


Common Issues & Fixes

NoCredentialsError — AWS credentials not configured. Run aws configure or set AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY environment variables.

UnauthorizedOperation — The IAM user/role doesn’t have ec2:StopInstances or ec2:StartInstances permission. Attach the policy from Step 4.

UnsupportedOperation: You may not stop instance-store instances — Instance-store backed AMIs cannot be stopped, only terminated. Only EBS-backed instances support stop/start.

Instances not found — Double-check the tag spelling: AutoStop (capital A and S) with value true (lowercase). Tags are case-sensitive in AWS.

Wrong timezone — schedule uses the machine’s local time. If your server is in UTC and you want 8 PM IST (UTC+5:30), set STOP_TIME = "14:30" (20:00 − 5:30 = 14:30 UTC).


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used & How It Works
import boto3AWS SDK for Python. Every AWS API call goes through this library. Without it you cannot talk to EC2. Install: pip install boto3
import scheduleLightweight job scheduler. Lets you call a function at “20:00 every day”. Install: pip install schedule
import timePython standard library. We use time.sleep(60) to pause the event loop for 60 seconds between scheduler checks
import loggingStandard library for writing structured log output to both console and file
from datetime import datetimeUsed to format the current timestamp inside log messages (e.g., "2025-01-20 08:00:00")
from botocore.exceptions import ClientError, NoCredentialsErrorAWS SDK error classes. ClientError covers all AWS API errors (wrong permissions, wrong state, etc.). NoCredentialsError fires when no AWS credentials are found anywhere

Configuration Constants

LineWhat It Does & Why
REGION = "ap-south-1"Which AWS region to call. EC2 is regional — instances in ap-south-1 can only be managed through the ap-south-1 endpoint
TAG_KEY = "AutoStop"The EC2 tag key we search for. Stored as a constant so changing the tag policy means changing one line
TAG_VALUE = "true"The expected tag value. Only instances tagged AutoStop=true are touched — anything else is left alone
STOP_TIME = "20:00"8 PM in 24-hour format. The schedule library parses this string directly
START_TIME = "08:00"8 AM in 24-hour format

logging.basicConfig(...)

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s  %(levelname)-8s  %(message)s",
    datefmt="%Y-%m-%d %H:%M:%S",
    handlers=[
        logging.StreamHandler(),
        logging.FileHandler("ec2_scheduler.log"),
    ],
)
LineExplanation
level=logging.INFOSets the minimum severity level to log. INFO logs everything except DEBUG. Set to logging.DEBUG when troubleshooting
format="%(asctime)s %(levelname)-8s %(message)s"Log line template. %(asctime)s = timestamp, %(levelname)-8s = level padded to 8 chars, %(message)s = the log text
datefmt="%Y-%m-%d %H:%M:%S"Timestamp format. Without this, the time includes milliseconds
logging.StreamHandler()Sends log lines to stdout (the terminal). Required to see logs when running interactively or in Docker
logging.FileHandler("ec2_scheduler.log")Also writes log lines to a file on disk. Both handlers run together — every log line goes to both

get_ec2_client()

def get_ec2_client():
    return boto3.client("ec2", region_name=REGION)
LineExplanation
boto3.client("ec2")Creates a low-level EC2 client. “ec2” is the AWS service name. The client exposes individual API methods like describe_instances, stop_instances, etc.
region_name=REGIONEvery boto3 EC2 client is region-scoped. Instances in ap-south-1 will not be visible to a client pointed at us-east-1
(credentials auto-discovered)boto3 finds credentials automatically in this order: environment variables → ~/.aws/credentials file → IAM role attached to the current EC2 instance or Lambda

get_tagged_instances(ec2, desired_state)

response = ec2.describe_instances(
    Filters=[
        {"Name": f"tag:{TAG_KEY}", "Values": [TAG_VALUE]},
        {"Name": "instance-state-name", "Values": [desired_state]},
    ]
)
LineExplanation
ec2.describe_instances(Filters=[...])Calls the EC2 DescribeInstances API. Without filters it returns ALL instances — the Filters list narrows results on the server side (faster, cheaper)
{"Name": f"tag:{TAG_KEY}", "Values": [TAG_VALUE]}tag:AutoStop is a special filter key. The tag: prefix tells EC2 to filter by tag key name. Values: ["true"] — only instances where AutoStop tag equals “true”
{"Name": "instance-state-name", "Values": [desired_state]}Filters by lifecycle state. "running" to find instances to stop; "stopped" to find instances to start. Other states: pending, stopping, terminated
response["Reservations"]EC2 groups instances into Reservations (a Reservation = one launch command). Each Reservation has an "Instances" list
for reservation in response["Reservations"]: for instance in reservation["Instances"]Double loop to flatten: Reservations → Instances → flat list

get_instance_name(instance)

tags = instance.get("Tags", [])
name = next((t["Value"] for t in tags if t["Key"] == "Name"), "Unnamed")
LineExplanation
instance.get("Tags", [])Gets the Tags list. .get(key, default) returns the default if the key is missing — instances with no tags would otherwise raise KeyError
next((...), "Unnamed")next() returns the first item from the generator expression. The second argument is the default if the generator yields nothing (i.e., no “Name” tag found)
t["Key"] == "Name"Tags are stored as a list of {"Key": "...", "Value": "..."} dicts — not as a dict. We must search linearly

stop_instances() — Core Stop Logic

response = ec2.stop_instances(InstanceIds=instance_ids)
for item in response["StoppingInstances"]:
    prev = item["PreviousState"]["Name"]
    curr = item["CurrentState"]["Name"]
LineExplanation
ec2.stop_instances(InstanceIds=instance_ids)Sends a graceful stop signal to all instances in the list. The OS is signalled to shut down cleanly (like pressing the power button). Data on EBS volumes is preserved
InstanceIds=instance_idsMust be a list of instance ID strings: ["i-0abc123", "i-0def456"]. Stops all of them in one API call — more efficient than a loop
response["StoppingInstances"]Each element is a dict with InstanceId, PreviousState, and CurrentState
item["PreviousState"]["Name"]State before the API call. Will be "running"
item["CurrentState"]["Name"]State immediately after. Will be "stopping" — the instance is not yet stopped at this point (stop is async)
except ClientError as e:Catches AWS API errors. e.response["Error"]["Code"] contains the error type string

start_instances() — Core Start Logic

response = ec2.start_instances(InstanceIds=instance_ids)
for item in response["StartingInstances"]:
    prev = item["PreviousState"]["Name"]   # "stopped"
    curr = item["CurrentState"]["Name"]    # "pending"
LineExplanation
ec2.start_instances(InstanceIds=instance_ids)Sends a boot signal to all stopped instances. The instance powers on, the OS boots, and services start
response["StartingInstances"]List of status objects — same structure as StoppingInstances
"pending" (CurrentState)The instance is transitioning to running. It takes ~30–90 seconds to become fully reachable
Instance gets a new public IPUnless an Elastic IP is attached, the public IP changes on every stop/start cycle

Scheduler Setup

schedule.every().day.at(STOP_TIME).do(stop_instances)
schedule.every().day.at(START_TIME).do(start_instances)
LineExplanation
schedule.every()Returns a Job object. The chain of calls builds the schedule rule
.daySpecifies the interval unit: “run every 1 day”
.at("20:00")Sets the exact time within the day. Uses the local machine timezone — important if your server is in UTC but you want a different timezone
.do(stop_instances)Registers stop_instances (without calling it) as the function to execute when the time arrives. Note: no parentheses — we pass the function object, not its return value

Event Loop

while True:
    schedule.run_pending()
    time.sleep(60)
LineExplanation
while True:Infinite loop — keeps the script running forever until you press Ctrl+C or kill the process
schedule.run_pending()Checks if any registered job is due RIGHT NOW and runs it. Returns immediately if nothing is due. Does NOT block
time.sleep(60)Pauses the loop for 60 seconds. Since our schedule uses HH:MM precision, checking every 60 seconds is more than fine. This keeps CPU usage near zero
except KeyboardInterrupt:Catches Ctrl+C gracefully — prints a shutdown message instead of an ugly traceback

EBS Snapshot Manager — Auto Backup & Retention Cleanup

Problem Statement

Your team’s EC2 instances hold critical application data on EBS volumes. Without automated snapshots, a failed deployment or accidental rm -rf could mean permanent data loss. Snapshots stored indefinitely inflate storage costs; a 30-day retention window gives you a full month to recover while keeping the bill reasonable.

Goal: Write a Python script that:

  • Discovers every EBS volume attached to running EC2 instances
  • Creates a tagged snapshot for each volume
  • Deletes any AutoBackup=true snapshot older than 30 days
  • Is safe to run as a daily Lambda or cron job

Required IAM Permissions

{
  "Version": "2012-10-17",
  "Statement": [{
    "Effect": "Allow",
    "Action": [
      "ec2:DescribeInstances",
      "ec2:CreateSnapshot",
      "ec2:DescribeSnapshots",
      "ec2:DeleteSnapshot"
    ],
    "Resource": "*"
  }]
}

Complete Script

import boto3
from datetime import datetime, timezone, timedelta
import logging

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


class EBSSnapshotManager:
    def __init__(self, region="us-east-1", retention_days=30):
        """
        boto3.client("ec2") creates a low-level EC2 service client.
        region_name targets the correct AWS region.
        retention_days controls how long snapshots are kept.
        """
        self.ec2 = boto3.client("ec2", region_name=region)
        self.retention_days = retention_days

    # ── Step 1: Discover volumes ─────────────────────────────────────
    def get_running_instance_volumes(self) -> list[dict]:
        """
        get_paginator("describe_instances") returns a paginator object.
        Paginators automatically handle the NextToken loop so you never
        miss instances when you have more than 1,000 results.

        paginator.paginate() yields one page dict at a time.
        Each page["Reservations"] is a list of Reservation objects.
        Each Reservation["Instances"] is a list of Instance objects.

        BlockDeviceMappings lists every EBS volume attached to the instance.
        Each mapping has "DeviceName" (e.g., /dev/xvda) and "Ebs.VolumeId".
        """
        volumes = []
        paginator = self.ec2.get_paginator("describe_instances")

        for page in paginator.paginate(
            Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
        ):
            for reservation in page["Reservations"]:
                for instance in reservation["Instances"]:
                    instance_id = instance["InstanceId"]

                    # Extract the Name tag — default to instance ID if absent
                    instance_name = next(
                        (tag["Value"] for tag in instance.get("Tags", [])
                         if tag["Key"] == "Name"),
                        instance_id,
                    )

                    for mapping in instance.get("BlockDeviceMappings", []):
                        volumes.append({
                            "volume_id":     mapping["Ebs"]["VolumeId"],
                            "instance_id":   instance_id,
                            "instance_name": instance_name,
                            "device":        mapping["DeviceName"],  # e.g. /dev/xvda
                        })
        return volumes

    # ── Step 2: Create snapshots ─────────────────────────────────────
    def create_snapshots(self, volumes: list[dict]) -> list[str]:
        """
        create_snapshot() initiates an async snapshot.
        The snapshot state starts as "pending" and transitions to "completed".

        TagSpecifications lets you tag the snapshot at creation time
        (atomic — avoids a separate create_tags call).

        ResourceType: "snapshot" tells AWS these tags belong to the snapshot,
        not the source volume.
        """
        created = []
        timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d-%H%M")

        for vol in volumes:
            try:
                response = self.ec2.create_snapshot(
                    VolumeId=vol["volume_id"],
                    Description=(
                        f"Auto-backup {vol['instance_name']} "
                        f"{vol['device']} {timestamp}"
                    ),
                    TagSpecifications=[{
                        "ResourceType": "snapshot",
                        "Tags": [
                            {"Key": "Name",       "Value": f"auto-snap-{vol['instance_name']}-{timestamp}"},
                            {"Key": "AutoBackup", "Value": "true"},   # Used by cleanup filter
                            {"Key": "InstanceId", "Value": vol["instance_id"]},
                            {"Key": "CreatedAt",  "Value": timestamp},
                        ],
                    }],
                )
                snapshot_id = response["SnapshotId"]
                created.append(snapshot_id)
                logger.info(
                    f"Created snapshot {snapshot_id} for volume "
                    f"{vol['volume_id']} ({vol['instance_name']} {vol['device']})"
                )
            except Exception as e:
                logger.error(f"Failed to snapshot {vol['volume_id']}: {e}")

        return created

    # ── Step 3: Delete old snapshots ─────────────────────────────────
    def delete_old_snapshots(self) -> list[str]:
        """
        describe_snapshots with OwnerIds=["self"] only returns YOUR snapshots
        (not public ones — important to avoid accidental deletions).

        The tag filter Filters=[{"Name":"tag:AutoBackup","Values":["true"]}]
        ensures we only touch snapshots this script created, never manual ones.

        snapshot["StartTime"] is timezone-aware (UTC) so we compare against
        datetime.now(timezone.utc) — mixing naive/aware datetimes raises TypeError.
        """
        cutoff = datetime.now(timezone.utc) - timedelta(days=self.retention_days)
        deleted = []
        paginator = self.ec2.get_paginator("describe_snapshots")

        for page in paginator.paginate(
            Filters=[{"Name": "tag:AutoBackup", "Values": ["true"]}],
            OwnerIds=["self"],
        ):
            for snapshot in page["Snapshots"]:
                if snapshot["StartTime"] < cutoff:
                    try:
                        self.ec2.delete_snapshot(SnapshotId=snapshot["SnapshotId"])
                        deleted.append(snapshot["SnapshotId"])
                        age_days = (datetime.now(timezone.utc) - snapshot["StartTime"]).days
                        logger.info(
                            f"Deleted snapshot {snapshot['SnapshotId']} "
                            f"(age: {age_days} days)"
                        )
                    except Exception as e:
                        # Snapshot may be in use by an AMI — skip gracefully
                        logger.error(
                            f"Could not delete {snapshot['SnapshotId']}: {e}"
                        )

        return deleted

    # ── Orchestrator ──────────────────────────────────────────────────
    def run(self) -> dict:
        logger.info("Starting EBS snapshot backup cycle...")
        volumes = self.get_running_instance_volumes()
        logger.info(f"Found {len(volumes)} volumes across running instances")

        created = self.create_snapshots(volumes)
        deleted = self.delete_old_snapshots()

        result = {
            "volumes_found":      len(volumes),
            "snapshots_created":  len(created),
            "snapshots_deleted":  len(deleted),
            "created_ids":        created,
            "deleted_ids":        deleted,
        }
        logger.info(f"Backup cycle complete: {result}")
        return result


# ── Lambda entry point ────────────────────────────────────────────
def lambda_handler(event, context):
    """Deploy as Lambda + EventBridge cron(0 1 * * ? *) to run at 1 AM UTC daily."""
    manager = EBSSnapshotManager(retention_days=30)
    return manager.run()


# ── Local / CLI entry point ───────────────────────────────────────
if __name__ == "__main__":
    manager = EBSSnapshotManager(region="ap-south-1", retention_days=30)
    result = manager.run()
    print(result)

Key Commands Explained

CommandWhat it does
get_paginator("describe_instances")Returns a paginator — handles 1000+ result sets automatically
paginator.paginate(Filters=[...])Iterates pages; each page is a full API response dict
BlockDeviceMappingsList of EBS volumes attached to the instance at boot time
create_snapshot(VolumeId=..., TagSpecifications=[...])Creates async snapshot with tags in one API call
response["SnapshotId"]The new snapshot’s ID (e.g., snap-0abc123)
get_paginator("describe_snapshots")Paginates through all snapshots
OwnerIds=["self"]Only return snapshots owned by THIS AWS account
delete_snapshot(SnapshotId=...)Permanently deletes a snapshot — cannot be undone
snapshot["StartTime"]UTC datetime when the snapshot was initiated

Deployment as a Daily Lambda

# 1. Zip the script
zip ebs_backup.zip ebs_snapshot_manager.py

# 2. Create Lambda function
aws lambda create-function \
  --function-name EBSSnapshotManager \
  --runtime python3.12 \
  --role arn:aws:iam::123456789012:role/LambdaEC2BackupRole \
  --handler ebs_snapshot_manager.lambda_handler \
  --zip-file fileb://ebs_backup.zip \
  --timeout 300

# 3. Add daily EventBridge trigger (1 AM UTC)
aws events put-rule \
  --name DailyEBSBackup \
  --schedule-expression "cron(0 1 * * ? *)" \
  --state ENABLED

Common Issues

SnapshotCreationPerVolumeRateExceeded — AWS allows a limited rate of snapshot creation per volume. Add a time.sleep(1) between create_snapshot calls to stay within limits.

InvalidSnapshot.InUse — The snapshot is registered as an AMI. Deregister the AMI first, then delete the snapshot.

Snapshot stays “pending” — Large volumes take longer. Don’t wait synchronously; the Lambda will complete and the snapshot will finish independently. Use an EventBridge rule to check completion if needed.


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK for Python — needed to call EC2 APIs for snapshots and instance discovery
from datetime import datetime, timezone, timedeltadatetime.now(timezone.utc) gives a timezone-aware UTC timestamp. timedelta(days=30) computes the cutoff date. timezone.utc makes the datetime UTC-aware (required to compare with boto3’s UTC timestamps)
import loggingPython standard library for structured log output with timestamps and severity levels

logging.basicConfig(...)

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
PartExplanation
level=logging.INFOOnly log messages at INFO level and above (INFO, WARNING, ERROR, CRITICAL). DEBUG messages are suppressed
format="%(asctime)s %(levelname)s %(message)s"Each log line shows: timestamp, severity level, and the actual message

EBSSnapshotManager.__init__

def __init__(self, region="us-east-1", retention_days=30):
    self.ec2 = boto3.client("ec2", region_name=region)
    self.retention_days = retention_days
LineExplanation
boto3.client("ec2", region_name=region)Creates an EC2 API client. EBS snapshots and instances are regional — you need the correct region
self.ec2 = ...Stores the client on the instance so all methods share one connection pool
self.retention_days = retention_daysStored for use in delete_old_snapshots(). Default 30 means snapshots older than 30 days are deleted

get_running_instance_volumes()

paginator = self.ec2.get_paginator("describe_instances")
LineExplanation
get_paginator("describe_instances")Returns a Paginator object that automatically handles the NextToken loop. Without this, describe_instances() returns at most 1000 instances and silently drops the rest
for page in paginator.paginate(
    Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
):
LineExplanation
paginator.paginate(Filters=[...])Iterates pages. Each page is one API response dict. The paginator calls the API repeatedly, adding NextToken automatically until no more pages exist
Filters=[{"Name": "instance-state-name", "Values": ["running"]}]Server-side filter — only returns running instances. We don’t want snapshots of stopped or terminated instances
for reservation in page["Reservations"]:
    for instance in reservation["Instances"]:
        instance_id = instance["InstanceId"]
LineExplanation
page["Reservations"]EC2 groups instances into Reservations. A Reservation is one launch request that may have launched multiple instances
reservation["Instances"]Each Reservation contains 1 or more Instance dicts
instance["InstanceId"]The unique ID of this EC2 instance (e.g., i-0abc123def456789)
instance_name = next(
    (tag["Value"] for tag in instance.get("Tags", []) if tag["Key"] == "Name"),
    instance_id,
)
LineExplanation
instance.get("Tags", [])Returns [] if the instance has no tags — avoids KeyError
tag["Key"] == "Name"Tags are stored as a list of dicts. We search for the tag whose Key is “Name”
next(..., instance_id)Returns the first match. Falls back to the InstanceId string if there’s no Name tag
for mapping in instance.get("BlockDeviceMappings", []):
    volumes.append({
        "volume_id": mapping["Ebs"]["VolumeId"],
        "device":    mapping["DeviceName"],
    })
LineExplanation
BlockDeviceMappingsLists all EBS volumes attached to this instance. Each entry has DeviceName (e.g., /dev/xvda) and Ebs.VolumeId
mapping["Ebs"]["VolumeId"]The EBS Volume ID (e.g., vol-0abc123). This is what create_snapshot() needs
mapping["DeviceName"]The device path inside the OS (e.g., /dev/xvda for root, /dev/xvdb for data). Used in the snapshot description for human readability

create_snapshots(volumes)

response = self.ec2.create_snapshot(
    VolumeId=vol["volume_id"],
    Description=f"Auto-backup {vol['instance_name']} {vol['device']} {timestamp}",
    TagSpecifications=[{
        "ResourceType": "snapshot",
        "Tags": [
            {"Key": "AutoBackup", "Value": "true"},
            ...
        ],
    }],
)
LineExplanation
create_snapshot(VolumeId=...)Initiates an EBS snapshot. Snapshots are asynchronous — the API returns immediately with a snap-xxx ID while the actual data copy continues in the background
Description=...A human-readable label stored with the snapshot. Not used programmatically — just for humans browsing the console
TagSpecifications=[{"ResourceType": "snapshot", "Tags": [...]}]Tags the snapshot at creation time in one atomic call. ResourceType: "snapshot" tells EC2 these tags belong to the snapshot, not the source volume
{"Key": "AutoBackup", "Value": "true"}This tag is the filter key used by the cleanup function. Only snapshots with this tag will ever be auto-deleted
response["SnapshotId"]The new snapshot’s ID (e.g., snap-0abc123def456789). Used for logging and returned to the caller

delete_old_snapshots()

cutoff = datetime.now(timezone.utc) - timedelta(days=self.retention_days)
LineExplanation
datetime.now(timezone.utc)Current time as a timezone-aware UTC datetime. This is critical — if you use datetime.utcnow() (naive), subtracting it from snapshot["StartTime"] (timezone-aware) raises TypeError
timedelta(days=self.retention_days)A duration object. Subtracting 30 days from “now” gives the cutoff date. Any snapshot created before this date is expired
paginator = self.ec2.get_paginator("describe_snapshots")
for page in paginator.paginate(
    Filters=[{"Name": "tag:AutoBackup", "Values": ["true"]}],
    OwnerIds=["self"],
):
LineExplanation
get_paginator("describe_snapshots")Paginates through all snapshots. An account can have thousands of snapshots — pagination ensures none are missed
Filters=[{"Name": "tag:AutoBackup", "Values": ["true"]}]Only returns snapshots tagged AutoBackup=true. This is the safety fence — we never accidentally delete manually-created snapshots
OwnerIds=["self"]Only shows snapshots owned by this AWS account. Without this, public AWS snapshots (used for AMIs) could appear and be accidentally deleted
if snapshot["StartTime"] < cutoff:
    self.ec2.delete_snapshot(SnapshotId=snapshot["SnapshotId"])
LineExplanation
snapshot["StartTime"]A timezone-aware datetime object (UTC) representing when the snapshot started. boto3 automatically converts the API’s ISO 8601 string to a Python datetime
< cutoffCompares two timezone-aware datetimes. If the snapshot is older than the cutoff, it’s expired
delete_snapshot(SnapshotId=...)Permanently and irreversibly deletes the snapshot and frees the storage. This cannot be undone

run() — Orchestrator

volumes = self.get_running_instance_volumes()
created = self.create_snapshots(volumes)
deleted = self.delete_old_snapshots()
LineExplanation
Order mattersWe create new snapshots before deleting old ones. This ensures we never have a window with fewer-than-retention snapshots
Returns a dict{"volumes_found": N, "snapshots_created": M, "snapshots_deleted": K, ...} — useful for Lambda return values and CloudWatch metric publishing

Lambda & Local Entry Points

def lambda_handler(event, context):
    manager = EBSSnapshotManager(retention_days=30)
    return manager.run()

if __name__ == "__main__":
    manager = EBSSnapshotManager(region="ap-south-1", retention_days=30)
LineExplanation
lambda_handler(event, context)AWS Lambda calls this function when triggered. event contains EventBridge payload (mostly unused here). context has Lambda metadata (timeout remaining, etc.)
if __name__ == "__main__":Python runs this block only when the script is executed directly (not when imported). This lets the same file work both as a module and as a runnable script

Find Untagged Long-Running EC2 Instances & Alert via SES

Problem Statement

Your organization mandates every EC2 instance must carry a Project tag (for billing allocation). Engineers spin up dev instances and forget them for weeks. Without a governance script, you discover the oversight only on the monthly bill.

Goal: Write a Python script that:

  • Scans all running EC2 instances
  • Identifies any running more than 7 days that are missing the Project tag
  • Sends a formatted compliance alert email via AWS SES

Required IAM Permissions

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["ec2:DescribeInstances"],
      "Resource": "*"
    },
    {
      "Effect": "Allow",
      "Action": ["ses:SendEmail"],
      "Resource": "arn:aws:ses:us-east-1:123456789012:identity/[email protected]"
    }
  ]
}

SES prerequisite: Verify both the sender and recipient email addresses (or the domain) in the SES console before running this script.


Complete Script

import boto3
from datetime import datetime, timezone, timedelta


def find_untagged_long_running_instances(region: str = "us-east-1") -> list[dict]:
    """
    Returns a list of violation dicts for instances that have been
    running > 7 days without a 'Project' tag.
    """
    ec2 = boto3.client("ec2", region_name=region)
    ses = boto3.client("ses", region_name=region)

    # datetime.now(timezone.utc) returns a timezone-aware UTC datetime.
    # EC2 instance["LaunchTime"] is also timezone-aware (UTC).
    # Both must be timezone-aware to subtract them without a TypeError.
    threshold = datetime.now(timezone.utc) - timedelta(days=7)
    violations = []

    # get_paginator ensures we get ALL instances even if there are > 1000
    paginator = ec2.get_paginator("describe_instances")
    for page in paginator.paginate(
        Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
    ):
        for reservation in page["Reservations"]:
            for instance in reservation["Instances"]:
                launch_time = instance["LaunchTime"]   # timezone-aware UTC datetime

                # Convert tags list to a dict for O(1) lookup
                # instance.get("Tags", []) returns [] if no tags — avoids KeyError
                tags = {t["Key"]: t["Value"] for t in instance.get("Tags", [])}

                # Check BOTH conditions: old enough AND missing required tag
                if launch_time < threshold and "Project" not in tags:
                    violations.append({
                        "InstanceId":    instance["InstanceId"],
                        "LaunchTime":    launch_time.strftime("%Y-%m-%d %H:%M UTC"),
                        "InstanceType":  instance["InstanceType"],
                        "Name":          tags.get("Name", "Unnamed"),
                        "Owner":         tags.get("Owner", "Unknown"),
                        "PrivateIp":     instance.get("PrivateIpAddress", "N/A"),
                        "RunningDays":   (datetime.now(timezone.utc) - launch_time).days,
                    })

    if violations:
        send_violation_email(ses, violations)
    else:
        print("All running instances are compliant.")

    return violations


def send_violation_email(ses_client, violations: list[dict]) -> None:
    """
    ses_client.send_email() requires:
    - Source: a verified sender email address or domain
    - Destination.ToAddresses: list of recipient emails
    - Message.Subject.Data: email subject string
    - Message.Body.Text.Data: plain-text body
      (Use Body.Html.Data for HTML-formatted emails)

    SES is a regional service — the client must match the region
    where your email identities are verified.
    """
    # Build a human-readable table for the email body
    rows = "\n".join([
        (f"  [{i+1}] {v['InstanceId']} | {v['Name']} | "
         f"{v['InstanceType']} | Running {v['RunningDays']} days | "
         f"Owner: {v['Owner']} | IP: {v['PrivateIp']}")
        for i, v in enumerate(violations)
    ])

    body = f"""
AWS Compliance Alert: Untagged Long-Running EC2 Instances
==========================================================

The following {len(violations)} EC2 instance(s) have been running for more
than 7 days without a required 'Project' tag:

{rows}

Required Action:
  1. Add the 'Project' tag to identify the billing owner.
  2. Terminate the instance if it is no longer needed.
  3. Instances without a Project tag will be stopped automatically
     after 14 days (policy enforcement pending).

This is an automated message from the Cloud Governance bot.
Run script: governance/find_untagged_instances.py
    """

    ses_client.send_email(
        Source="[email protected]",          # Must be SES-verified
        Destination={
            "ToAddresses": ["[email protected]"],
            "CcAddresses": ["[email protected]"],
        },
        Message={
            "Subject": {
                "Data": f"⚠️ {len(violations)} Untagged EC2 Instance(s) Detected",
                "Charset": "UTF-8",
            },
            "Body": {
                "Text": {
                    "Data": body,
                    "Charset": "UTF-8",
                }
            },
        },
        # Optional: ReplyToAddresses, ReturnPath, Tags for SES tracking
    )
    print(f"Alert email sent for {len(violations)} violation(s).")


# ── Lambda entry point (trigger via EventBridge daily) ────────────
def lambda_handler(event, context):
    violations = find_untagged_long_running_instances(region="us-east-1")
    return {
        "statusCode": 200,
        "violations_found": len(violations),
        "instances": [v["InstanceId"] for v in violations],
    }


# ── Local run ─────────────────────────────────────────────────────
if __name__ == "__main__":
    result = find_untagged_long_running_instances(region="ap-south-1")
    print(f"\nTotal violations: {len(result)}")
    for v in result:
        print(f"  - {v['InstanceId']} ({v['Name']}) running {v['RunningDays']} days")

Enhancement: Auto-Stop After 14 Days

def enforce_stop_policy(violations: list[dict], stop_after_days: int = 14):
    """
    Stop (not terminate) instances that have been running more than
    stop_after_days without a Project tag.
    Termination should require a human decision.
    """
    ec2 = boto3.client("ec2")
    to_stop = [v for v in violations if v["RunningDays"] >= stop_after_days]

    if not to_stop:
        return

    ids = [v["InstanceId"] for v in to_stop]
    ec2.stop_instances(InstanceIds=ids)
    print(f"Auto-stopped {len(ids)} instances: {ids}")

Key Commands Explained

CommandWhat it does
datetime.now(timezone.utc)Current time as UTC-aware datetime (required to compare with LaunchTime)
timedelta(days=7)7-day window — subtract from now to get the threshold
{t["Key"]: t["Value"] for t in tags}Converts list of {"Key":...,"Value":...} dicts to a normal dict
"Project" not in tagsO(1) dict key lookup — checks tag existence
ses.send_email(Source=..., Destination=..., Message=...)Sends an email via Amazon SES
Destination["CcAddresses"]CC recipients — SES supports To, CC, and BCC

Common Issues

MessageRejected: Email address not verified — Both sender and recipient must be verified in SES (or you must be out of sandbox mode). Go to SES console → Verified Identities.

SES sandbox limits — In sandbox, you can only send to verified addresses. Request production access in the SES console to send to anyone.

Empty violations list — Instance LaunchTime is stored in UTC. Ensure your local clock isn’t causing the 7-day comparison to behave unexpectedly. Use datetime.now(timezone.utc) not datetime.utcnow() (which is naive).


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK. Needed for boto3.client("ec2") and boto3.client("ses")
from datetime import datetime, timezone, timedeltadatetime.now(timezone.utc) — timezone-aware now. timedelta(days=7) — 7-day duration. timezone.utc — UTC timezone object (makes datetimes comparable with AWS’s UTC timestamps)

find_untagged_long_running_instances()

ec2 = boto3.client("ec2", region_name=region)
ses = boto3.client("ses", region_name=region)
LineExplanation
boto3.client("ec2", region_name=region)EC2 client for the target region. EC2 instances exist in specific regions — a client for us-east-1 will not see instances in ap-south-1
boto3.client("ses", region_name=region)SES (Simple Email Service) client. SES is regional — email identities must be verified in the same region as the client
threshold = datetime.now(timezone.utc) - timedelta(days=7)
LineExplanation
datetime.now(timezone.utc)Returns the current UTC time as a timezone-aware datetime. The timezone.utc argument is what makes it aware
timedelta(days=7)A duration of 7 days. Subtracting it from “now” gives the exact datetime 7 days ago
thresholdAny instance launched before this datetime has been running more than 7 days
Why not datetime.utcnow()?datetime.utcnow() returns a naive datetime (no timezone info). Comparing a naive datetime with the timezone-aware instance["LaunchTime"] raises a TypeError at runtime
paginator = ec2.get_paginator("describe_instances")
for page in paginator.paginate(
    Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
):
LineExplanation
get_paginator("describe_instances")Creates a paginator that automatically handles NextToken loops. If you have 1,500 instances, describe_instances() alone only returns 1,000
paginator.paginate(Filters=[...])Streams pages. Each call to the API returns up to 1,000 instances. The paginator keeps calling until all pages are exhausted
"instance-state-name": ["running"]Server-side filter — only returns running instances. We care about long-running instances, not stopped/terminated ones
for reservation in page["Reservations"]:
    for instance in reservation["Instances"]:
        launch_time = instance["LaunchTime"]
LineExplanation
page["Reservations"]EC2’s response nests instances inside Reservations (groupings from a single launch command)
reservation["Instances"]The actual list of instance dicts within that reservation
instance["LaunchTime"]A timezone-aware UTC datetime when this instance was started. boto3 parses the API’s ISO 8601 string automatically into a Python datetime object
tags = {t["Key"]: t["Value"] for t in instance.get("Tags", [])}
LineExplanation
instance.get("Tags", [])AWS returns Tags as a list of {"Key": ..., "Value": ...} dicts. .get("Tags", []) returns an empty list if the instance has no tags at all
{t["Key"]: t["Value"] for t in ...}Dict comprehension that converts the list into a regular Python dict: {"Name": "web-server", "Owner": "alice", "Env": "prod"}. This enables O(1) key lookups
if launch_time < threshold and "Project" not in tags:
    violations.append({...})
LineExplanation
launch_time < thresholdBoth are UTC-aware datetimes. If the launch time is earlier than 7 days ago, the instance has been running more than 7 days
"Project" not in tagsDict key lookup (O(1)). Returns True if the Project key is absent from the tags dict
andBoth conditions must be true — the instance is only a violation if it’s old AND missing the tag
violations.append({...})Builds a list of dicts with human-readable info for the email body
"RunningDays": (datetime.now(timezone.utc) - launch_time).days,
LineExplanation
datetime.now(timezone.utc) - launch_timeSubtracts two timezone-aware datetimes to get a timedelta object
.daystimedelta.days extracts the whole-day count (ignoring hours/minutes). A 7.9-day old instance returns 7

send_violation_email(ses_client, violations)

rows = "\n".join([
    f"  [{i+1}] {v['InstanceId']} | {v['Name']} | ..."
    for i, v in enumerate(violations)
])
LineExplanation
enumerate(violations)Yields (index, item) pairs — lets us number each row [1], [2], etc.
"\n".join([...])Joins all formatted row strings with newlines into a single multi-line string
ses_client.send_email(
    Source="[email protected]",
    Destination={
        "ToAddresses": ["[email protected]"],
        "CcAddresses": ["[email protected]"],
    },
    Message={
        "Subject": {"Data": f"⚠️ {len(violations)} Untagged EC2 Instance(s)", "Charset": "UTF-8"},
        "Body": {"Text": {"Data": body, "Charset": "UTF-8"}},
    },
)
LineExplanation
Source=Must be a verified SES email identity. SES rejects mail from unverified senders
Destination.ToAddressesPrimary recipients — a Python list of email strings
Destination.CcAddressesCC recipients — receives a copy but is not in the “To” field
Message.Subject.DataThe email subject line. Charset: "UTF-8" enables emoji and non-ASCII characters
Message.Body.Text.DataPlain-text email body. Use Body.Html.Data for HTML-formatted emails
Charset: "UTF-8"Required when body contains non-ASCII characters (emoji, accented chars)

Lambda Entry Point

def lambda_handler(event, context):
    violations = find_untagged_long_running_instances(region="us-east-1")
    return {
        "statusCode": 200,
        "violations_found": len(violations),
        "instances": [v["InstanceId"] for v in violations],
    }
LineExplanation
lambda_handler(event, context)AWS Lambda’s required function signature. event contains EventBridge schedule payload. context has timeout info
return {"statusCode": 200, ...}Lambda return values are logged to CloudWatch. Returning a structured dict makes the result queryable. statusCode follows the API Gateway convention even for non-HTTP triggers
[v["InstanceId"] for v in violations]List comprehension extracting just the IDs for the return value

Sync Local Directory to S3 with KMS Encryption & Manifest

Problem Statement

Your application generates data files that must be backed up to S3. AWS policy mandates all data be encrypted with a customer-managed KMS key. Running the script multiple times should skip files that haven’t changed (idempotent). Every sync run should produce an audit manifest listing what was uploaded, skipped, or failed.


Required IAM Permissions

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": ["s3:PutObject", "s3:GetObject", "s3:HeadObject"],
      "Resource": "arn:aws:s3:::my-company-data/*"
    },
    {
      "Effect": "Allow",
      "Action": ["kms:GenerateDataKey", "kms:Decrypt"],
      "Resource": "arn:aws:kms:us-east-1:123456789012:key/abc-123"
    }
  ]
}

Complete Script

import boto3
import os
import hashlib
import json
from pathlib import Path
from datetime import datetime
from botocore.exceptions import ClientError


class S3Syncer:
    def __init__(self, bucket: str, prefix: str, kms_key_id: str, region: str = "us-east-1"):
        """
        boto3.client("s3") — low-level S3 client used for fine-grained control.
        We choose client (not resource) here because we need ExtraArgs
        for KMS encryption in upload_file().

        kms_key_id can be:
          - Key ID:  "abc-123-..."
          - Key ARN: "arn:aws:kms:us-east-1:123456789012:key/abc-123"
          - Alias:   "alias/my-data-key"
        """
        self.s3 = boto3.client("s3", region_name=region)
        self.bucket = bucket
        self.prefix = prefix.rstrip("/")
        self.kms_key_id = kms_key_id
        self.manifest: list[dict] = []

    # ── MD5 hash ──────────────────────────────────────────────────
    def get_file_md5(self, filepath: Path) -> str:
        """
        Compute the MD5 hash of a local file in 8 KB chunks.
        Reading in chunks prevents loading multi-GB files into RAM.
        iter(lambda: f.read(8192), b"") calls the lambda repeatedly
        until it returns the sentinel value b"" (empty bytes = EOF).
        """
        md5 = hashlib.md5()
        with open(filepath, "rb") as f:
            for chunk in iter(lambda: f.read(8192), b""):
                md5.update(chunk)
        return md5.hexdigest()

    # ── Deduplication check ───────────────────────────────────────
    def file_exists_in_s3(self, s3_key: str, local_md5: str) -> bool:
        """
        head_object() fetches only the metadata of an S3 object (no body).
        It's cheap (~1 ms) compared to downloading the file for comparison.

        S3 ETag for non-multipart uploads equals the MD5 of the object.
        For multipart uploads the ETag is different — this check works
        for files < 5 GB (single-part) which covers most use cases.

        ClientError with code "404" means the object doesn't exist yet.
        Any other error (403 Forbidden, etc.) is re-raised.
        """
        try:
            response = self.s3.head_object(Bucket=self.bucket, Key=s3_key)
            s3_etag = response.get("ETag", "").strip('"')
            return s3_etag == local_md5
        except ClientError as e:
            if e.response["Error"]["Code"] == "404":
                return False
            raise

    # ── Single file upload ────────────────────────────────────────
    def upload_file(self, local_path: Path, s3_key: str) -> str:
        """
        upload_file() streams the file from disk to S3 using multipart
        upload automatically for files > 8 MB. ExtraArgs are passed
        through to the underlying PutObject / CreateMultipartUpload API.

        ServerSideEncryption: "aws:kms" instructs S3 to encrypt with KMS.
        SSEKMSKeyId:           which KMS key to use (defaults to the S3
                               service key if omitted).
        Metadata:              arbitrary key-value pairs stored alongside
                               the object — useful for integrity checks
                               without downloading the file.
        """
        local_md5 = self.get_file_md5(local_path)

        if self.file_exists_in_s3(s3_key, local_md5):
            print(f"  [SKIP]   {s3_key} (unchanged)")
            return "skipped"

        self.s3.upload_file(
            Filename=str(local_path),
            Bucket=self.bucket,
            Key=s3_key,
            ExtraArgs={
                "ServerSideEncryption": "aws:kms",
                "SSEKMSKeyId":          self.kms_key_id,
                "Metadata":             {"md5checksum": local_md5},
            },
        )
        print(f"  [UPLOAD] {s3_key}  ({local_path.stat().st_size:,} bytes)")
        return "uploaded"

    # ── Directory sync ────────────────────────────────────────────
    def sync_directory(self, local_dir: str) -> dict:
        """
        Path.rglob("*") recursively yields all files and directories.
        is_file() filters out directories.
        relative_to() strips the local base path so we get just the
        relative path for the S3 key.
        .replace("\\\\", "/") normalises Windows backslashes to forward
        slashes for S3 keys.
        """
        local_dir_path = Path(local_dir)
        stats = {"uploaded": 0, "skipped": 0, "failed": 0}

        for file_path in local_dir_path.rglob("*"):
            if not file_path.is_file():
                continue

            relative = file_path.relative_to(local_dir_path)
            s3_key = f"{self.prefix}/{relative}".replace("\\", "/")

            try:
                result = self.upload_file(file_path, s3_key)
                stats[result] += 1
                self.manifest.append({
                    "local_path": str(file_path),
                    "s3_key":     s3_key,
                    "s3_uri":     f"s3://{self.bucket}/{s3_key}",
                    "size_bytes": file_path.stat().st_size,
                    "status":     result,
                    "timestamp":  datetime.utcnow().isoformat() + "Z",
                })
            except Exception as e:
                print(f"  [ERROR]  {file_path}: {e}")
                stats["failed"] += 1
                self.manifest.append({
                    "local_path": str(file_path),
                    "s3_key":     s3_key,
                    "status":     "failed",
                    "error":      str(e),
                    "timestamp":  datetime.utcnow().isoformat() + "Z",
                })

        return stats

    # ── Save manifest ─────────────────────────────────────────────
    def save_manifest(self, local_output: str = "manifest.json") -> None:
        """
        Write the manifest locally and also upload it to S3
        so there's a persistent audit trail of every sync run.
        The timestamp in the key prevents overwriting previous manifests.
        """
        with open(local_output, "w") as f:
            json.dump(self.manifest, f, indent=2)

        manifest_key = (
            f"{self.prefix}/_manifests/"
            f"manifest_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
        )
        self.s3.upload_file(
            Filename=local_output,
            Bucket=self.bucket,
            Key=manifest_key,
            ExtraArgs={
                "ServerSideEncryption": "aws:kms",
                "SSEKMSKeyId":          self.kms_key_id,
            },
        )
        print(f"\nManifest uploaded → s3://{self.bucket}/{manifest_key}")


# ── Entry point ───────────────────────────────────────────────────
if __name__ == "__main__":
    syncer = S3Syncer(
        bucket="my-company-data",
        prefix="backups/app-data",
        kms_key_id="arn:aws:kms:us-east-1:123456789012:key/abc-123",
        region="us-east-1",
    )

    stats = syncer.sync_directory("/opt/app/data")
    syncer.save_manifest()

    print(f"\nSync complete:")
    print(f"  Uploaded: {stats['uploaded']}")
    print(f"  Skipped:  {stats['skipped']}  (unchanged)")
    print(f"  Failed:   {stats['failed']}")

Sample Manifest Output

[
  {
    "local_path": "/opt/app/data/report-2025-01.csv",
    "s3_key": "backups/app-data/report-2025-01.csv",
    "s3_uri": "s3://my-company-data/backups/app-data/report-2025-01.csv",
    "size_bytes": 204800,
    "status": "uploaded",
    "timestamp": "2025-01-20T10:30:00Z"
  },
  {
    "local_path": "/opt/app/data/config.json",
    "s3_key": "backups/app-data/config.json",
    "s3_uri": "s3://my-company-data/backups/app-data/config.json",
    "size_bytes": 1024,
    "status": "skipped",
    "timestamp": "2025-01-20T10:30:01Z"
  }
]

Key Commands Explained

CommandWhat it does
hashlib.md5()Creates an MD5 hash object for content fingerprinting
iter(lambda: f.read(8192), b"")Reads file in 8 KB chunks until EOF — memory efficient
head_object(Bucket, Key)Fetches S3 object metadata without downloading the body
response["ETag"].strip('"')ETag comes with surrounding quotes — strip them for comparison
upload_file(Filename, Bucket, Key, ExtraArgs={...})Streams file to S3; ExtraArgs passes encryption settings
ServerSideEncryption: "aws:kms"Tells S3 to use KMS for at-rest encryption
SSEKMSKeyIdSpecifies the KMS key ARN, ID, or alias to use
Path.rglob("*")Recursively yields every file and directory under the path

Common Issues

AccessDenied on KMS — The IAM role must have kms:GenerateDataKey on the specific key ARN. Check the KMS key policy too — it must allow the role.

ETag mismatch for large files — Files uploaded via multipart (> 8 MB default) get a composite ETag (md5-N), not a plain MD5. For those, compare using Metadata["md5checksum"] instead.

Slow syncs — Use concurrent.futures.ThreadPoolExecutor to upload multiple files in parallel. boto3’s upload_file is thread-safe.


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK for Python — needed for S3 client calls
import osUsed for file path manipulation (os.path, etc.)
import hashlibPython standard library for cryptographic hashes. We use hashlib.md5() to compute file fingerprints for deduplication
import jsonStandard library for serializing the manifest dict to a JSON file
from pathlib import PathModern Python path handling. Path.rglob("*") recursively finds all files. More readable than os.walk()
from datetime import datetimeUsed to timestamp the manifest file name and each manifest entry
from botocore.exceptions import ClientErrorAWS SDK error class. We catch it to distinguish “object not found (404)” from real errors

S3Syncer.__init__

def __init__(self, bucket: str, prefix: str, kms_key_id: str, region: str = "us-east-1"):
    self.s3 = boto3.client("s3", region_name=region)
    self.bucket = bucket
    self.prefix = prefix.rstrip("/")
    self.kms_key_id = kms_key_id
    self.manifest: list[dict] = []
LineExplanation
boto3.client("s3", region_name=region)Creates a low-level S3 client. We use client (not resource) because upload_file() on the client accepts ExtraArgs for KMS settings
self.bucketThe S3 bucket name. Stored once — used in every API call
prefix.rstrip("/")Removes trailing slashes from the S3 key prefix. Prevents double-slash keys like backups//file.txt
self.kms_key_idThe KMS key identifier (ID, ARN, or alias). Passed to every upload_file() call
self.manifest: list[dict] = []Accumulates one record per file processed. Written to JSON at the end of the sync run

get_file_md5(filepath)

md5 = hashlib.md5()
with open(filepath, "rb") as f:
    for chunk in iter(lambda: f.read(8192), b""):
        md5.update(chunk)
return md5.hexdigest()
LineExplanation
hashlib.md5()Creates an MD5 hash object. MD5 produces a 32-character hex string that uniquely identifies file content
open(filepath, "rb")Opens the file in binary read mode. Required for accurate MD5 — text mode can alter line endings on Windows
iter(lambda: f.read(8192), b"")This is a sentinel-based iterator. iter(callable, sentinel) calls the callable repeatedly until it returns the sentinel value. f.read(8192) reads 8 KB at a time. When the file ends, read() returns b"" (empty bytes) which is the sentinel — the loop stops
md5.update(chunk)Feeds each 8 KB chunk into the MD5 computation. Chunk-based hashing means the full file is never loaded into RAM
md5.hexdigest()Returns the final MD5 as a 32-character hex string like "d41d8cd98f00b204e9800998ecf8427e"

file_exists_in_s3(s3_key, local_md5)

response = self.s3.head_object(Bucket=self.bucket, Key=s3_key)
s3_etag = response.get("ETag", "").strip('"')
return s3_etag == local_md5
LineExplanation
head_object(Bucket=..., Key=...)Fetches only the metadata of an S3 object — not its content. This is a cheap HTTP HEAD request (~1 ms, zero data transfer cost). Used to check if the object exists and get its ETag
response.get("ETag", "")ETag is the entity tag (fingerprint) of the S3 object. For single-part uploads (files < 5 GB), the ETag equals the MD5 of the object
.strip('"')AWS returns ETags wrapped in double-quotes: '"d41d8cd9..."'. We strip the quotes before comparing
return s3_etag == local_md5If the ETag matches our local MD5, the file is identical — skip the upload
except ClientError as e:head_object raises ClientError for any error
e.response["Error"]["Code"] == "404"HTTP 404 means the object doesn’t exist yet — return False to trigger an upload. Any other error (403 Forbidden, etc.) is re-raised

upload_file(local_path, s3_key)

self.s3.upload_file(
    Filename=str(local_path),
    Bucket=self.bucket,
    Key=s3_key,
    ExtraArgs={
        "ServerSideEncryption": "aws:kms",
        "SSEKMSKeyId": self.kms_key_id,
        "Metadata": {"md5checksum": local_md5},
    },
)
LineExplanation
upload_file(Filename=..., Bucket=..., Key=...)boto3’s managed upload — automatically uses multipart upload for files > 8 MB, retries on failure, and tracks upload progress
Filename=str(local_path)upload_file expects a string path (not a Path object), so we convert with str()
Key=s3_keyThe full S3 object key (path within the bucket), e.g., backups/app-data/report-2025-01.csv
ExtraArgs={"ServerSideEncryption": "aws:kms"}Passes KMS encryption parameters to the underlying PutObject API call. Without this, the object is uploaded unencrypted
"ServerSideEncryption": "aws:kms"Tells S3 to encrypt this object using a KMS key. Alternatives: "AES256" (S3-managed key) or omit for no encryption
"SSEKMSKeyId": self.kms_key_idSpecifies WHICH KMS key to use. Can be key ID, full ARN, or alias (alias/my-key). If omitted, uses the S3 default KMS key
"Metadata": {"md5checksum": local_md5}Custom metadata stored alongside the object. Can later be retrieved with head_object() to verify integrity without downloading the file

sync_directory(local_dir)

local_dir_path = Path(local_dir)
for file_path in local_dir_path.rglob("*"):
    if not file_path.is_file():
        continue
    relative = file_path.relative_to(local_dir_path)
    s3_key = f"{self.prefix}/{relative}".replace("\\", "/")
LineExplanation
Path(local_dir)Converts the string path to a pathlib.Path object. Enables cross-platform path manipulation
local_dir_path.rglob("*")Recursively yields every file and directory under local_dir. rglob is shorthand for glob("**/*")
file_path.is_file()Filters out directories — we only want actual files
file_path.relative_to(local_dir_path)Strips the base directory prefix. If the file is /opt/app/data/reports/q1.csv and the base is /opt/app/data, this returns reports/q1.csv
.replace("\\", "/")On Windows, Path uses backslashes. S3 keys must use forward slashes. This normalizes the separator
stats = {"uploaded": 0, "skipped": 0, "failed": 0}Tracks counts across all files. Updated after each upload_file() call

save_manifest()

with open(local_output, "w") as f:
    json.dump(self.manifest, f, indent=2)

manifest_key = (
    f"{self.prefix}/_manifests/manifest_{datetime.utcnow().strftime('%Y%m%d_%H%M%S')}.json"
)
self.s3.upload_file(Filename=local_output, Bucket=self.bucket, Key=manifest_key, ...)
LineExplanation
json.dump(self.manifest, f, indent=2)Writes the manifest list to a local JSON file. indent=2 makes it human-readable with 2-space indentation
datetime.utcnow().strftime('%Y%m%d_%H%M%S')Timestamp formatted as 20250120_103000. Used in the key name so each manifest run has a unique key — old manifests are never overwritten
manifest_keyThe S3 key for the manifest file. Stored inside a _manifests/ prefix so it stays separate from data files
Second upload_file(...)Uploads the manifest to S3 with the same KMS encryption. This creates a persistent, searchable audit trail of every sync run

S3 Bucket Security Audit — Public Access, Versioning & Encryption

Problem Statement

A misconfigured S3 bucket was the #1 cause of cloud data breaches in 2023. Common mistakes: public access not blocked, no versioning (can’t recover deleted files), no default encryption, and no access logging. Running a manual audit across 50+ buckets is error-prone. This script checks all 5 controls automatically.


Five Controls Checked

CheckAPI CallFail Condition
Public access blockget_public_access_blockAny of the 4 settings is False
Bucket policyget_bucket_policyStatement has Principal: * + Effect: Allow
Versioningget_bucket_versioningStatus is not "Enabled"
Default encryptionget_bucket_encryptionNo SSE configuration found
Access loggingget_bucket_loggingLoggingEnabled key absent

Complete Script

import boto3
import json
from botocore.exceptions import ClientError


def audit_s3_buckets(region: str = "us-east-1") -> list[dict]:
    """
    s3.list_buckets() returns ALL buckets in the account (globally scoped).
    Unlike most AWS services, S3 buckets don't require a region filter —
    list_buckets() always returns everything regardless of client region.

    We then make per-bucket API calls for each compliance check.
    """
    s3 = boto3.client("s3", region_name=region)
    report = []

    buckets = s3.list_buckets()["Buckets"]
    print(f"Auditing {len(buckets)} S3 buckets...\n")

    for bucket in buckets:
        name = bucket["Name"]
        issues: list[str] = []

        # ── Check 1: Public Access Block ─────────────────────────
        # get_public_access_block() returns four boolean settings:
        # - BlockPublicAcls:       Block new public ACLs
        # - IgnorePublicAcls:      Ignore existing public ACLs
        # - BlockPublicPolicy:     Block new public bucket policies
        # - RestrictPublicBuckets: Restrict access for public-policy buckets
        # ALL four must be True for the bucket to be fully protected.
        try:
            pab = s3.get_public_access_block(Bucket=name)[
                "PublicAccessBlockConfiguration"
            ]
            if not all([
                pab.get("BlockPublicAcls"),
                pab.get("IgnorePublicAcls"),
                pab.get("BlockPublicPolicy"),
                pab.get("RestrictPublicBuckets"),
            ]):
                issues.append("PUBLIC_ACCESS_NOT_FULLY_BLOCKED")
        except ClientError as e:
            if "NoSuchPublicAccessBlockConfiguration" in str(e):
                issues.append("NO_PUBLIC_ACCESS_BLOCK")
            else:
                issues.append(f"PAB_CHECK_ERROR")

        # ── Check 2: Bucket Policy — Public Principal ─────────────
        # get_bucket_policy() returns the policy as a JSON string.
        # We parse it and look for statements where:
        #   Effect = "Allow"  AND  Principal = "*" or {"AWS": "*"}
        # These grant public (anonymous) access to the bucket.
        try:
            raw_policy = s3.get_bucket_policy(Bucket=name)["Policy"]
            policy = json.loads(raw_policy)
            for stmt in policy.get("Statement", []):
                principal = stmt.get("Principal")
                is_public = principal in ("*", {"AWS": "*"})
                if stmt.get("Effect") == "Allow" and is_public:
                    issues.append("PUBLIC_BUCKET_POLICY")
                    break   # One public statement is enough to flag it
        except ClientError as e:
            if "NoSuchBucketPolicy" not in str(e):
                # Real error (e.g., AccessDenied) — note it
                issues.append("POLICY_CHECK_ERROR")
            # NoSuchBucketPolicy means no policy exists — not necessarily bad

        # ── Check 3: Versioning ───────────────────────────────────
        # get_bucket_versioning() can return:
        #   {"Status": "Enabled"}   — versioning on
        #   {"Status": "Suspended"} — was on, now paused
        #   {}                      — never enabled
        # Only "Enabled" is compliant.
        try:
            versioning = s3.get_bucket_versioning(Bucket=name)
            if versioning.get("Status") != "Enabled":
                issues.append("VERSIONING_DISABLED")
        except ClientError:
            issues.append("VERSIONING_CHECK_FAILED")

        # ── Check 4: Default Encryption ───────────────────────────
        # get_bucket_encryption() raises ServerSideEncryptionConfigurationNotFoundError
        # if no default encryption is configured.
        # Valid algorithms: "AES256" (SSE-S3) or "aws:kms" (SSE-KMS).
        try:
            enc = s3.get_bucket_encryption(Bucket=name)
            rules = enc["ServerSideEncryptionConfiguration"]["Rules"]
            algo = rules[0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"]
            if algo not in ("aws:kms", "AES256"):
                issues.append("WEAK_ENCRYPTION")
        except ClientError as e:
            if "ServerSideEncryptionConfigurationNotFoundError" in str(e):
                issues.append("ENCRYPTION_DISABLED")

        # ── Check 5: Access Logging ───────────────────────────────
        # get_bucket_logging() returns {"LoggingEnabled": {...}} if logging
        # is configured, or just {} if disabled.
        # Logging is essential for security investigations and audits.
        try:
            logging_cfg = s3.get_bucket_logging(Bucket=name)
            if "LoggingEnabled" not in logging_cfg:
                issues.append("ACCESS_LOGGING_DISABLED")
        except ClientError:
            issues.append("LOGGING_CHECK_FAILED")

        # ── Record result ─────────────────────────────────────────
        compliant = len(issues) == 0
        report.append({
            "bucket":    name,
            "issues":    issues,
            "compliant": compliant,
        })

        status = "✅" if compliant else "❌"
        issue_str = ", ".join(issues) if issues else "All checks passed"
        print(f"{status} {name}: {issue_str}")

    # ── Summary ───────────────────────────────────────────────────
    non_compliant = [r for r in report if not r["compliant"]]
    print(f"\n{'='*60}")
    print(f"Total Buckets:    {len(report)}")
    print(f"Compliant:        {len(report) - len(non_compliant)}")
    print(f"Non-Compliant:    {len(non_compliant)}")

    if non_compliant:
        print("\nTop Issues:")
        from collections import Counter
        all_issues = [i for r in non_compliant for i in r["issues"]]
        for issue, count in Counter(all_issues).most_common():
            print(f"  {issue}: {count} bucket(s)")

    return report


def save_report(report: list[dict], output: str = "s3_audit_report.json") -> None:
    with open(output, "w") as f:
        json.dump(report, f, indent=2)
    print(f"\nFull report saved to {output}")


def auto_fix_public_access(bucket_name: str, dry_run: bool = True) -> None:
    """
    put_public_access_block() with all four settings = True
    is the AWS-recommended way to make a bucket fully private.
    This is a non-destructive operation — it doesn't delete any objects.
    """
    s3 = boto3.client("s3")
    if dry_run:
        print(f"[DRY-RUN] Would enable Public Access Block on: {bucket_name}")
        return

    s3.put_public_access_block(
        Bucket=bucket_name,
        PublicAccessBlockConfiguration={
            "BlockPublicAcls":       True,
            "IgnorePublicAcls":      True,
            "BlockPublicPolicy":     True,
            "RestrictPublicBuckets": True,
        },
    )
    print(f"✅ Public Access Block enabled on: {bucket_name}")


if __name__ == "__main__":
    report = audit_s3_buckets()
    save_report(report)

    # Auto-fix any bucket with PUBLIC_ACCESS_NOT_FULLY_BLOCKED
    for r in report:
        if "PUBLIC_ACCESS_NOT_FULLY_BLOCKED" in r["issues"]:
            auto_fix_public_access(r["bucket"], dry_run=True)   # Set dry_run=False to apply

Sample Output

Auditing 12 S3 buckets...

✅ my-logs-bucket: All checks passed
❌ old-dev-data: PUBLIC_ACCESS_NOT_FULLY_BLOCKED, VERSIONING_DISABLED
❌ finance-reports: ENCRYPTION_DISABLED, ACCESS_LOGGING_DISABLED
✅ prod-artifacts: All checks passed
❌ test-bucket-2024: NO_PUBLIC_ACCESS_BLOCK, VERSIONING_DISABLED, ENCRYPTION_DISABLED

============================================================
Total Buckets:    12
Compliant:        8
Non-Compliant:    4

Top Issues:
  VERSIONING_DISABLED: 3 bucket(s)
  ENCRYPTION_DISABLED: 2 bucket(s)
  PUBLIC_ACCESS_NOT_FULLY_BLOCKED: 2 bucket(s)
  ACCESS_LOGGING_DISABLED: 2 bucket(s)

Key Commands Explained

CommandWhat it does
s3.list_buckets()["Buckets"]Returns all buckets in the account as a list of dicts
get_public_access_block(Bucket=name)Returns the 4-setting Public Access Block config
get_bucket_policy(Bucket=name)["Policy"]Returns the bucket policy as a JSON string
json.loads(raw_policy)Parses the JSON string into a Python dict
get_bucket_versioning(Bucket=name)Returns versioning status — Enabled, Suspended, or {}
get_bucket_encryption(Bucket=name)Returns SSE config — raises if not configured
get_bucket_logging(Bucket=name)Returns {"LoggingEnabled": {...}} if logging is on
put_public_access_block(...)Enables all four public access restrictions

🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK for Python — needed for S3 API calls
import jsonStandard library. Bucket policies are returned as JSON strings — we use json.loads() to parse them into Python dicts
from botocore.exceptions import ClientErrorAWS SDK error class. Each security check may raise a ClientError (e.g., NoSuchBucketPolicy, ServerSideEncryptionConfigurationNotFoundError)

audit_s3_buckets()

s3 = boto3.client("s3", region_name=region)
buckets = s3.list_buckets()["Buckets"]
LineExplanation
boto3.client("s3", region_name=region)S3 client. Even though S3 is a global service, we specify a region for the API endpoint
s3.list_buckets()Returns all buckets in the account — S3 buckets are global, so this is not region-filtered
["Buckets"]The response dict has a "Buckets" key containing a list of {"Name": "...", "CreationDate": ...} dicts

Check 1 — Public Access Block

pab = s3.get_public_access_block(Bucket=name)["PublicAccessBlockConfiguration"]
if not all([
    pab.get("BlockPublicAcls"),
    pab.get("IgnorePublicAcls"),
    pab.get("BlockPublicPolicy"),
    pab.get("RestrictPublicBuckets"),
]):
    issues.append("PUBLIC_ACCESS_NOT_FULLY_BLOCKED")
LineExplanation
get_public_access_block(Bucket=name)Retrieves the bucket’s Public Access Block configuration
["PublicAccessBlockConfiguration"]The nested key containing the four boolean settings
pab.get("BlockPublicAcls")True = blocks new public ACLs from being set on this bucket
pab.get("IgnorePublicAcls")True = ignores any existing public ACLs (even if someone somehow set one)
pab.get("BlockPublicPolicy")True = blocks bucket policies that grant public access
pab.get("RestrictPublicBuckets")True = restricts access for public-policy buckets — prevents cross-account anonymous access
not all([...])all() returns True only if every item in the list is truthy. not all means “if at least one is False” — the bucket is not fully protected
except ClientError: "NoSuchPublicAccessBlockConfiguration"Raised when no Public Access Block config exists at all — even worse than one with partial settings

Check 2 — Bucket Policy Public Principal

raw_policy = s3.get_bucket_policy(Bucket=name)["Policy"]
policy = json.loads(raw_policy)
for stmt in policy.get("Statement", []):
    principal = stmt.get("Principal")
    is_public = principal in ("*", {"AWS": "*"})
    if stmt.get("Effect") == "Allow" and is_public:
        issues.append("PUBLIC_BUCKET_POLICY")
        break
LineExplanation
get_bucket_policy(Bucket=name)["Policy"]Returns the bucket policy as a raw JSON string (not a dict). Raises NoSuchBucketPolicy if no policy exists
json.loads(raw_policy)Parses the JSON string into a Python dict. This allows us to iterate through Statement elements
policy.get("Statement", [])Gets the list of IAM policy statements. Returns [] if the key is absent
stmt.get("Principal")The entity the statement applies to. "*" or {"AWS": "*"} means ANY anonymous user — a major security risk
principal in ("*", {"AWS": "*"})Checks for both forms of the wildcard principal
stmt.get("Effect") == "Allow"Only Allow statements with a public principal are dangerous. A Deny with * principal actually restricts access
breakOnce one public statement is found, we don’t need to keep looking — the bucket is flagged
"NoSuchBucketPolicy" not in str(e)Missing bucket policy is NOT a violation — many secure buckets have no policy. Only real errors (AccessDenied, etc.) are flagged

Check 3 — Versioning

versioning = s3.get_bucket_versioning(Bucket=name)
if versioning.get("Status") != "Enabled":
    issues.append("VERSIONING_DISABLED")
LineExplanation
get_bucket_versioning(Bucket=name)Returns the versioning state. The response is a dict with an optional Status key
versioning.get("Status")Returns "Enabled", "Suspended", or None (key absent = never enabled). .get() is used because Status is absent from the dict when versioning was never configured
!= "Enabled"Both "Suspended" and absent mean versioning is not protecting the bucket. "Suspended" means it was on but is now paused — new objects are not versioned

Check 4 — Default Encryption

enc = s3.get_bucket_encryption(Bucket=name)
rules = enc["ServerSideEncryptionConfiguration"]["Rules"]
algo = rules[0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"]
if algo not in ("aws:kms", "AES256"):
    issues.append("WEAK_ENCRYPTION")
LineExplanation
get_bucket_encryption(Bucket=name)Returns the default SSE config. Raises ServerSideEncryptionConfigurationNotFoundError if no default encryption is set
["ServerSideEncryptionConfiguration"]["Rules"]Nested path to reach the list of encryption rules
rules[0]["ApplyServerSideEncryptionByDefault"]["SSEAlgorithm"]The algorithm in use: "AES256" (S3-managed key, free) or "aws:kms" (customer-managed key, stronger control)
if algo not in ("aws:kms", "AES256")Both algorithms are acceptable. Any other value (rare) would be flagged

Check 5 — Access Logging

logging_cfg = s3.get_bucket_logging(Bucket=name)
if "LoggingEnabled" not in logging_cfg:
    issues.append("ACCESS_LOGGING_DISABLED")
LineExplanation
get_bucket_logging(Bucket=name)Returns the logging configuration. Unlike other checks, this does NOT raise an exception when logging is off — it returns an empty dict {}
"LoggingEnabled" not in logging_cfgWhen logging is configured, the response contains {"LoggingEnabled": {"TargetBucket": "...", "TargetPrefix": "..."}}. If the key is absent, logging is disabled

Summary & Auto-Fix

non_compliant = [r for r in report if not r["compliant"]]
from collections import Counter
all_issues = [i for r in non_compliant for i in r["issues"]]
for issue, count in Counter(all_issues).most_common():
    print(f"  {issue}: {count} bucket(s)")
LineExplanation
[r for r in report if not r["compliant"]]List comprehension filtering to only non-compliant buckets
[i for r in non_compliant for i in r["issues"]]Nested list comprehension that flattens: list of dicts with issue lists → single flat list of all issue strings
Counter(all_issues)Counts occurrences of each issue string. Counter(["A", "A", "B"]) → {"A": 2, "B": 1}
.most_common()Returns items sorted by count descending — shows the most widespread issues first
def auto_fix_public_access(bucket_name: str, dry_run: bool = True) -> None:
    s3.put_public_access_block(
        Bucket=bucket_name,
        PublicAccessBlockConfiguration={
            "BlockPublicAcls": True, "IgnorePublicAcls": True,
            "BlockPublicPolicy": True, "RestrictPublicBuckets": True,
        },
    )
LineExplanation
dry_run: bool = TrueDefault is dry-run (safe). Caller must explicitly pass dry_run=False to make changes
put_public_access_block(...)Non-destructive remediation — sets all 4 block settings to True. Does not delete objects or policies
All four set to TrueAWS recommends all four enabled for maximum protection. Together they block all forms of public access

IAM Access Key Rotation — Auto-Rotate Keys Older than 90 Days

Problem Statement

CIS AWS Benchmark 1.14 requires IAM access keys to be rotated every 90 days. Long-lived keys are a top attack vector — if leaked, an attacker has months of access. Manual rotation across 30+ users is slow and inconsistent. This script automates discovery, rotation, and notification.


Safe Rotation Pattern

Step 1 → Create new key      (user now has 2 active keys — both work)
Step 2 → Notify user         (give them time to update their config)
Step 3 → Deactivate old key  (wait 7 days — old key stops working)
Step 4 → Delete old key      (run a cleanup pass 7 days later)

Never delete an old key immediately — the user may be using it in a CI/CD pipeline that hasn’t been updated yet.


Complete Script

import boto3
from datetime import datetime, timezone, timedelta
import logging
import json

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


def rotate_old_access_keys(
    dry_run: bool = True,
    max_age_days: int = 90,
    region: str = "us-east-1",
) -> list[dict]:
    """
    Scans all IAM users and rotates access keys older than max_age_days.

    dry_run=True (default) — prints what would happen but makes no changes.
    Always test with dry_run=True before running against production.
    """
    iam = boto3.client("iam")
    ses = boto3.client("ses", region_name=region)
    sm  = boto3.client("secretsmanager", region_name=region)
    report = []

    # ── List all IAM users (paginated) ────────────────────────────
    # list_users() is paginated — get_paginator handles NextToken automatically.
    # IAM is a global service (no region needed) but the client still works.
    users = []
    paginator = iam.get_paginator("list_users")
    for page in paginator.paginate():
        users.extend(page["Users"])
    logger.info(f"Found {len(users)} IAM users to check")

    threshold = datetime.now(timezone.utc) - timedelta(days=max_age_days)

    for user in users:
        username = user["UserName"]

        # ── List access keys for this user ─────────────────────────
        # list_access_keys() returns AccessKeyMetadata — the list of
        # key IDs, statuses, and creation dates. It does NOT return
        # the secret — secrets are shown only at creation time.
        keys = iam.list_access_keys(UserName=username)["AccessKeyMetadata"]

        for key in keys:
            if key["Status"] != "Active":
                continue   # Skip already-inactive keys

            key_age = datetime.now(timezone.utc) - key["CreateDate"]

            if key["CreateDate"] < threshold:
                logger.info(
                    f"Key {key['AccessKeyId']} for {username} is "
                    f"{key_age.days} days old (> {max_age_days}) → rotating"
                )
                action_taken = "DRY_RUN"

                if not dry_run:
                    action_taken = _rotate_key(iam, ses, sm, username, key)

                report.append({
                    "username":   username,
                    "old_key_id": key["AccessKeyId"],
                    "key_age_days": key_age.days,
                    "action":     action_taken,
                })

    # Summary
    logger.info(f"Rotation complete. {len(report)} key(s) processed.")
    return report


def _rotate_key(iam, ses, sm, username: str, old_key: dict) -> str:
    """
    Performs the actual key rotation:
    1. Create new key (AWS allows max 2 active keys per user)
    2. Store new secret in Secrets Manager (NOT in email)
    3. Deactivate old key (don't delete yet)
    4. Notify user via SES
    """
    old_key_id = old_key["AccessKeyId"]

    try:
        # Step 1 — Create new key
        # create_access_key() is the ONLY time the SecretAccessKey is visible.
        # Store it immediately in Secrets Manager — it cannot be retrieved later.
        new_key_response = iam.create_access_key(UserName=username)
        new_key = new_key_response["AccessKey"]
        new_key_id     = new_key["AccessKeyId"]
        new_key_secret = new_key["SecretAccessKey"]

        logger.info(f"  Created new key {new_key_id} for {username}")

        # Step 2 — Store in Secrets Manager (never send raw secret in email)
        secret_name = f"/iam/keys/{username}"
        secret_value = json.dumps({
            "AccessKeyId":     new_key_id,
            "SecretAccessKey": new_key_secret,
            "RotatedAt":       datetime.utcnow().isoformat() + "Z",
            "OldKeyId":        old_key_id,
        })

        try:
            # update_secret if it already exists
            sm.put_secret_value(SecretId=secret_name, SecretString=secret_value)
        except sm.exceptions.ResourceNotFoundException:
            # create_secret on first rotation for this user
            sm.create_secret(Name=secret_name, SecretString=secret_value)

        logger.info(f"  New credentials stored at Secrets Manager: {secret_name}")

        # Step 3 — Deactivate (not delete) the old key
        # update_access_key() with Status="Inactive" disables the key
        # without permanently removing it — giving a grace period to update.
        iam.update_access_key(
            UserName=username,
            AccessKeyId=old_key_id,
            Status="Inactive",
        )
        logger.info(f"  Deactivated old key {old_key_id} for {username}")

        # Step 4 — Notify user via SES
        _notify_user(ses, username, old_key_id, new_key_id, secret_name)

        return "ROTATED"

    except Exception as e:
        logger.error(f"Failed to rotate key for {username}: {e}")
        return f"FAILED: {e}"


def _notify_user(ses, username: str, old_key_id: str, new_key_id: str, secret_name: str) -> None:
    """
    Send the user a notification. We send the SECRET MANAGER PATH,
    not the raw secret, so it never appears in email logs or archives.
    """
    ses.send_email(
        Source="[email protected]",
        Destination={"ToAddresses": [f"{username}@company.com"]},
        Message={
            "Subject": {"Data": "🔑 Your AWS Access Key Has Been Rotated"},
            "Body": {
                "Text": {
                    "Data": f"""
Hi {username},

Your AWS access key {old_key_id} was {90}+ days old and has been rotated
as per our security policy (CIS AWS Benchmark 1.14).

Your new Access Key ID: {new_key_id}
Your new Secret:        Stored in AWS Secrets Manager at {secret_name}

Steps to update your credentials:
  1. aws secretsmanager get-secret-value --secret-id {secret_name}
  2. Update ~/.aws/credentials with the new key
  3. Update any CI/CD pipelines that use the old key
  4. The old key ({old_key_id}) will be DELETED in 7 days

Need help? Contact [email protected]
                    """
                }
            },
        },
    )


def delete_inactive_keys(max_age_days: int = 7, dry_run: bool = True) -> None:
    """
    Second-pass script: run 7 days after rotation to delete the old inactive keys.
    delete_access_key() permanently and irreversibly removes the key.
    """
    iam = boto3.client("iam")
    threshold = datetime.now(timezone.utc) - timedelta(days=max_age_days)

    paginator = iam.get_paginator("list_users")
    for page in paginator.paginate():
        for user in page["Users"]:
            username = user["UserName"]
            keys = iam.list_access_keys(UserName=username)["AccessKeyMetadata"]
            for key in keys:
                if key["Status"] == "Inactive" and key["CreateDate"] < threshold:
                    if dry_run:
                        print(f"[DRY-RUN] Would delete {key['AccessKeyId']} for {username}")
                    else:
                        iam.delete_access_key(
                            UserName=username, AccessKeyId=key["AccessKeyId"]
                        )
                        logger.info(f"Deleted inactive key {key['AccessKeyId']} for {username}")


if __name__ == "__main__":
    # Phase 1 — Preview
    report = rotate_old_access_keys(dry_run=True, max_age_days=90)
    print(f"\nDry-run complete: {len(report)} key(s) would be rotated")

    # Phase 2 — Apply (uncomment when ready)
    # rotate_old_access_keys(dry_run=False, max_age_days=90)

    # Phase 3 — Delete old inactive keys (run 7 days after Phase 2)
    # delete_inactive_keys(max_age_days=7, dry_run=False)

Key Commands Explained

CommandWhat it does
get_paginator("list_users")Paginates through all IAM users (default page size: 100)
list_access_keys(UserName=username)Returns key metadata — NOT the secret
key["CreateDate"]Timezone-aware UTC datetime of key creation
create_access_key(UserName=username)Creates a new key — secret is visible ONLY here
new_key["SecretAccessKey"]The raw secret — store immediately, never log
sm.put_secret_value(SecretId=..., SecretString=...)Stores/updates a secret in Secrets Manager
update_access_key(UserName, AccessKeyId, Status="Inactive")Disables a key without deleting it
delete_access_key(UserName, AccessKeyId)Permanently deletes a key — irreversible

Common Issues

LimitExceeded on CreateAccessKey — Each IAM user can have at most 2 access keys. If the user already has 2 active keys, you must delete one before creating a new one. Check and handle this edge case.

NoSuchEntity on send_email — The email address must be verified in SES. In sandbox mode, both sender and recipient need verification.

Key used in multiple places — Before rotating, consider querying CloudTrail to see which services and IPs last used the old key. This helps identify all places that need updating.


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK — needed for IAM, SES, and Secrets Manager clients
from datetime import datetime, timezone, timedeltaCompute key age by subtracting key["CreateDate"] (UTC-aware) from now
import loggingStructured log output with timestamps
import jsonSerialize the new credentials dict into a JSON string for Secrets Manager

rotate_old_access_keys(dry_run, max_age_days, region)

iam = boto3.client("iam")
ses = boto3.client("ses", region_name=region)
sm  = boto3.client("secretsmanager", region_name=region)
LineExplanation
boto3.client("iam")IAM is a global service — no region_name is needed. The same IAM users and roles exist in all regions
boto3.client("ses", region_name=region)SES is regional. Email identities must be verified in the same region as this client
boto3.client("secretsmanager", region_name=region)Secrets Manager is regional. We store new key credentials here — the script and the IAM user must be in the same region
paginator = iam.get_paginator("list_users")
for page in paginator.paginate():
    users.extend(page["Users"])
LineExplanation
get_paginator("list_users")list_users() returns a max of 100 users per call. The paginator handles IsTruncated / Marker tokens automatically
paginator.paginate()Iterates all pages. Each page["Users"] is a list of user dicts
users.extend(page["Users"])Appends users from this page to the main list. Unlike append, extend adds each user individually (not as a nested list)
threshold = datetime.now(timezone.utc) - timedelta(days=max_age_days)
LineExplanation
datetime.now(timezone.utc)Current UTC time as a timezone-aware datetime — required to compare with key["CreateDate"] which is also UTC-aware
timedelta(days=max_age_days)Subtracted from now to get the cutoff date. A key created before this date is too old
keys = iam.list_access_keys(UserName=username)["AccessKeyMetadata"]
LineExplanation
list_access_keys(UserName=username)Returns metadata about all access keys for this user: ID, status, and creation date. Never returns the secret — AWS only shows the secret once at creation time
["AccessKeyMetadata"]The list of key metadata dicts. Each has AccessKeyId, Status ("Active" or "Inactive"), and CreateDate
if key["Status"] != "Active":
    continue
LineExplanation
key["Status"] != "Active"We skip Inactive keys — they’re either already rotated or being phased out. We only rotate currently-active keys
continueSkips to the next key in the loop without executing the rotation logic
key_age = datetime.now(timezone.utc) - key["CreateDate"]
if key["CreateDate"] < threshold:
LineExplanation
key["CreateDate"]A timezone-aware UTC datetime of when this key was created. boto3 parses the API’s ISO 8601 string automatically
datetime.now(timezone.utc) - key["CreateDate"]Produces a timedelta object. .days gives the key age in whole days
key["CreateDate"] < threshold“Was this key created before 90 days ago?” If yes, the key is expired

_rotate_key(iam, ses, sm, username, old_key) — Step by Step

new_key_response = iam.create_access_key(UserName=username)
new_key = new_key_response["AccessKey"]
new_key_id     = new_key["AccessKeyId"]
new_key_secret = new_key["SecretAccessKey"]
LineExplanation
create_access_key(UserName=username)Creates a new IAM access key pair. Each IAM user can have at most 2 access keys — if the user already has 2 active keys, this raises LimitExceeded
new_key_response["AccessKey"]The response contains the full key including the secret
new_key["AccessKeyId"]The public key ID (e.g., AKIAIOSFODNN7EXAMPLE). This is what goes into ~/.aws/credentials
new_key["SecretAccessKey"]The private secret. This is the ONLY time AWS shows this value. If you don’t save it now, you must create a new key
secret_value = json.dumps({
    "AccessKeyId": new_key_id,
    "SecretAccessKey": new_key_secret,
    "RotatedAt": datetime.utcnow().isoformat() + "Z",
    "OldKeyId": old_key_id,
})
sm.put_secret_value(SecretId=secret_name, SecretString=secret_value)
LineExplanation
json.dumps({...})Secrets Manager stores strings — we serialize the credentials dict to a JSON string
datetime.utcnow().isoformat() + "Z"ISO 8601 timestamp with Z suffix indicating UTC. Used for audit purposes
sm.put_secret_value(SecretId=secret_name, SecretString=secret_value)Creates or updates the secret. If the secret already exists at this path, it creates a new version
except ResourceNotFoundException: sm.create_secret(...)put_secret_value fails if the secret doesn’t exist yet — we catch this and create it on first rotation
iam.update_access_key(
    UserName=username,
    AccessKeyId=old_key_id,
    Status="Inactive",
)
LineExplanation
update_access_key(Status="Inactive")Deactivates the old key — API calls using it will receive InvalidClientTokenId errors. The key record still exists (for 7 days) so the user can retrieve the new key and update their pipelines
Why not delete_access_key immediately?CI/CD pipelines may be using the old key. Deactivating gives a grace period. Deleting is irreversible — if something breaks, you can reactivate the old key during the grace period

delete_inactive_keys(max_age_days, dry_run) — Phase 3

for key in keys:
    if key["Status"] == "Inactive" and key["CreateDate"] < threshold:
        iam.delete_access_key(UserName=username, AccessKeyId=key["AccessKeyId"])
LineExplanation
key["Status"] == "Inactive"Only touches keys that were deactivated (by our Phase 2 script)
key["CreateDate"] < thresholdWith max_age_days=7, this finds keys deactivated more than 7 days ago — old enough that all pipelines should have been updated
delete_access_key(UserName=username, AccessKeyId=...)Permanently removes the key. No recovery possible. API calls using this key immediately get InvalidClientTokenId

Generate Least-Privilege IAM Policy from CloudTrail Logs

Problem Statement

Your application role has PowerUserAccess (5,000+ allowed actions), but in 30 days of production usage it only calls 12 distinct APIs. A compromise of this role exposes your entire account. This script reads CloudTrail and generates a policy with just those 12 actions — reducing blast radius by 99%.


How It Works

CloudTrail logs → filter by role name → extract (service, action) pairs
     → group by service → build IAM Statement per service
     → output minimal policy JSON

Complete Script

import boto3
import json
from collections import defaultdict
from datetime import datetime, timedelta


def generate_least_privilege_policy(
    role_name: str,
    lookback_days: int = 30,
    output_file: str = "least_privilege_policy.json",
) -> dict:
    """
    Analyses CloudTrail events for a specific IAM role and generates
    a least-privilege IAM policy based on actual API usage.

    lookback_days: how far back to look in CloudTrail (max 90 days
    for lookup_events — for longer history use Athena on S3 logs).
    """
    cloudtrail = boto3.client("cloudtrail")

    end_time   = datetime.utcnow()
    start_time = end_time - timedelta(days=lookback_days)

    # actions_by_service: {"s3": {"s3:GetObject", "s3:PutObject"}, ...}
    actions_by_service: dict[str, set] = defaultdict(set)
    # resources_used: {"s3": {"arn:aws:s3:::my-bucket"}, ...}
    resources_used: dict[str, set]     = defaultdict(set)

    print(f"Analysing CloudTrail for role: {role_name}")
    print(f"Period: {start_time.date()} → {end_time.date()}")
    print("Scanning events...\n")

    event_count = 0

    # ── Paginate CloudTrail events ─────────────────────────────────
    # lookup_events() supports LookupAttributes to filter events.
    # AttributeKey "Username" matches the session name for assumed roles,
    # which is the role name when assumed via AWS services.
    # For cross-account role assumption, filter by the full ARN instead.
    #
    # get_paginator handles the NextToken loop automatically.
    # Each page["Events"] contains up to 50 events.
    paginator = cloudtrail.get_paginator("lookup_events")
    for page in paginator.paginate(
        LookupAttributes=[{
            "AttributeKey":   "Username",
            "AttributeValue": role_name,
        }],
        StartTime=start_time,
        EndTime=end_time,
    ):
        for event in page["Events"]:
            event_name   = event.get("EventName", "")
            event_source = event.get("EventSource", "")

            if not (event_name and event_source):
                continue

            # event_source is like "s3.amazonaws.com" → strip domain → "s3"
            service = event_source.replace(".amazonaws.com", "")
            action  = f"{service}:{event_name}"

            actions_by_service[service].add(action)
            event_count += 1

            # ── Try to extract resource ARNs from the raw event ───
            # CloudTrailEvent is a JSON string embedded in the event dict.
            # requestParameters varies by API call — we check for common
            # resource identifiers: bucketName, instanceId, functionName, etc.
            try:
                detail = json.loads(event.get("CloudTrailEvent", "{}"))
                params = detail.get("requestParameters") or {}

                # Map known parameter names to ARN patterns
                arn_extractors = {
                    "bucketName":    lambda p: f"arn:aws:s3:::{p['bucketName']}",
                    "instanceId":    lambda p: f"arn:aws:ec2:*:*:instance/{p['instanceId']}",
                    "functionName":  lambda p: f"arn:aws:lambda:*:*:function:{p['functionName']}",
                    "secretId":      lambda p: f"arn:aws:secretsmanager:*:*:secret:{p['secretId']}",
                    "queueUrl":      lambda p: p["queueUrl"],   # SQS URL works as resource
                    "topicArn":      lambda p: p["topicArn"],
                }

                for key, extractor in arn_extractors.items():
                    if key in params:
                        try:
                            resources_used[service].add(extractor(params))
                        except Exception:
                            pass
            except Exception:
                pass   # Malformed event — skip gracefully

    print(f"Processed {event_count} CloudTrail events")
    print(f"Services accessed: {', '.join(sorted(actions_by_service.keys()))}\n")

    # ── Build IAM Policy ──────────────────────────────────────────
    policy_statements = []

    for service, actions in sorted(actions_by_service.items()):
        # Use specific resource ARNs if we found them; otherwise default to "*"
        resources = sorted(resources_used.get(service, {"*"}))

        statement = {
            "Sid":      f"Allow{service.replace('-', '').title()}Actions",
            "Effect":   "Allow",
            "Action":   sorted(list(actions)),
            "Resource": resources,
        }
        policy_statements.append(statement)

        print(f"  {service}: {len(actions)} action(s), {len(resources)} resource(s)")
        for action in sorted(actions):
            print(f"    + {action}")

    policy = {
        "Version":   "2012-10-17",
        "Statement": policy_statements,
    }

    # ── Save to file ──────────────────────────────────────────────
    with open(output_file, "w") as f:
        json.dump(policy, f, indent=2)
    print(f"\nPolicy saved to: {output_file}")

    total_actions = sum(len(a) for a in actions_by_service.values())
    print(f"Total unique API actions: {total_actions}")

    return policy


def apply_policy_to_role(role_name: str, policy: dict, policy_name: str = None) -> str:
    """
    Create a new IAM managed policy from the generated policy document
    and attach it to the role.

    create_policy() takes a JSON string (not dict) for PolicyDocument.
    The resulting ARN can then be attached to the role.
    """
    iam = boto3.client("iam")
    policy_name = policy_name or f"least-privilege-{role_name}"

    response = iam.create_policy(
        PolicyName=policy_name,
        PolicyDocument=json.dumps(policy),
        Description=(
            f"Auto-generated least-privilege policy for role {role_name} "
            f"based on CloudTrail usage analysis."
        ),
        Tags=[
            {"Key": "GeneratedBy", "Value": "LeastPrivilegeTool"},
            {"Key": "SourceRole",  "Value": role_name},
        ],
    )
    policy_arn = response["Policy"]["Arn"]
    print(f"Created policy: {policy_arn}")

    # Attach to the role
    iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn)
    print(f"Attached to role: {role_name}")

    return policy_arn


if __name__ == "__main__":
    # Step 1: Analyse usage and generate policy
    policy = generate_least_privilege_policy(
        role_name="my-app-role",
        lookback_days=30,
        output_file="least_privilege_policy.json",
    )

    print("\nGenerated Policy Preview:")
    print(json.dumps(policy, indent=2))

    # Step 2: Review the policy file manually, then apply:
    # apply_policy_to_role("my-app-role", policy)

Sample Output

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowS3Actions",
      "Effect": "Allow",
      "Action": [
        "s3:GetObject",
        "s3:ListBucket",
        "s3:PutObject"
      ],
      "Resource": [
        "arn:aws:s3:::my-app-bucket"
      ]
    },
    {
      "Sid": "AllowSecretsmanagerActions",
      "Effect": "Allow",
      "Action": [
        "secretsmanager:GetSecretValue"
      ],
      "Resource": [
        "arn:aws:secretsmanager:*:*:secret:my-app-db-creds"
      ]
    },
    {
      "Sid": "AllowCloudwatchActions",
      "Effect": "Allow",
      "Action": [
        "cloudwatch:PutMetricData"
      ],
      "Resource": ["*"]
    }
  ]
}

Key Commands Explained

CommandWhat it does
get_paginator("lookup_events")Paginates CloudTrail events — max 90-day window
LookupAttributes[AttributeKey="Username"]Filters events by IAM principal / session name
event["EventSource"]The AWS service that received the API call (e.g., s3.amazonaws.com)
event["EventName"]The API action called (e.g., GetObject)
event["CloudTrailEvent"]Full raw event JSON — contains requestParameters, responseElements, etc.
json.loads(event["CloudTrailEvent"])Parses the embedded JSON string into a dict
defaultdict(set)Auto-initialises a set for each new key — no setdefault needed
create_policy(PolicyDocument=json.dumps(policy))Creates a managed IAM policy from JSON string
attach_role_policy(RoleName, PolicyArn)Attaches the managed policy to the role

Limitations & Tips

CloudTrail lookup_events limit: The API returns a maximum of 90 days of data. For longer analysis windows, query CloudTrail S3 logs with Amazon Athena.

Data plane events not logged by default: S3 object-level events (GetObject, PutObject) require enabling CloudTrail Data Events. Check your trail settings.

Always review before applying: The generated policy is a starting point. Some actions may be needed only on rare occasions not captured in the analysis window. Add a buffer period or combine with IAM Access Analyzer.

Use IAM Access Analyzer: AWS’s built-in tool does similar analysis — this script gives you programmatic control and integration into your CI/CD workflow.


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK for CloudTrail and IAM API calls
import jsonUsed to parse CloudTrailEvent (a JSON string inside the event dict) and to serialize the final policy document
from collections import defaultdictCreates dictionaries that auto-initialize missing keys. defaultdict(set) gives an empty set when a key is first accessed — no need for setdefault()
from datetime import datetime, timedeltaCompute the CloudTrail lookback window: datetime.utcnow() - timedelta(days=30)

generate_least_privilege_policy(role_name, lookback_days, output_file)

cloudtrail = boto3.client("cloudtrail")
end_time   = datetime.utcnow()
start_time = end_time - timedelta(days=lookback_days)
LineExplanation
boto3.client("cloudtrail")CloudTrail is a global service — you can query it without specifying a region. Events from all regions are included if you have a multi-region trail
datetime.utcnow()Current UTC time as a naive datetime. CloudTrail’s StartTime/EndTime accept naive UTC datetimes
timedelta(days=lookback_days)Creates a duration. Subtracting 30 days from now gives the start of the analysis window
actions_by_service: dict[str, set] = defaultdict(set)
resources_used: dict[str, set]     = defaultdict(set)
LineExplanation
defaultdict(set)A dict that creates a new empty set for any key not yet seen. Without this, the first actions_by_service["s3"].add(...) would raise KeyError
actions_by_serviceWill hold: {"s3": {"s3:GetObject", "s3:PutObject"}, "ec2": {"ec2:DescribeInstances"}, ...}
resources_usedWill hold resource ARNs per service: {"s3": {"arn:aws:s3:::my-bucket"}, ...} — used for tighter Resource clauses in the policy
paginator = cloudtrail.get_paginator("lookup_events")
for page in paginator.paginate(
    LookupAttributes=[{"AttributeKey": "Username", "AttributeValue": role_name}],
    StartTime=start_time,
    EndTime=end_time,
):
LineExplanation
get_paginator("lookup_events")lookup_events returns up to 50 events per page. A busy role may have thousands of events — pagination is mandatory
LookupAttributes=[{"AttributeKey": "Username", ...}]Filters CloudTrail events to only those made by this specific IAM principal. AttributeKey: "Username" matches the session name for assumed roles
StartTime=start_time, EndTime=end_timeThe time window. CloudTrail lookup_events supports a maximum 90-day lookback window
event_name   = event.get("EventName", "")
event_source = event.get("EventSource", "")
service = event_source.replace(".amazonaws.com", "")
action  = f"{service}:{event_name}"
actions_by_service[service].add(action)
LineExplanation
event.get("EventName", "")API method that was called (e.g., "GetObject", "DescribeInstances"). Empty string means the event has no name — we skip it
event.get("EventSource", "")AWS service endpoint that processed the call (e.g., "s3.amazonaws.com", "ec2.amazonaws.com")
.replace(".amazonaws.com", "")Strips the domain suffix to get the service prefix used in IAM actions: "s3", "ec2", "lambda"
f"{service}:{event_name}"Formats the IAM action string: "s3:GetObject", "ec2:DescribeInstances"
actions_by_service[service].add(action)Adds the action to the set for this service. Sets deduplicate — calling the same API 1000 times still records it once
detail = json.loads(event.get("CloudTrailEvent", "{}"))
params = detail.get("requestParameters") or {}
LineExplanation
event.get("CloudTrailEvent", "{}")The full raw event as a JSON-encoded string. We default to "{}" so json.loads doesn’t fail on events without this field
json.loads(...)Parses the JSON string into a Python dict containing requestParameters, responseElements, userIdentity, etc.
detail.get("requestParameters") or {}requestParameters can be None (for read-only events) or a dict of API input parameters. The or {} handles None — you can’t iterate over None
arn_extractors = {
    "bucketName":   lambda p: f"arn:aws:s3:::{p['bucketName']}",
    "instanceId":   lambda p: f"arn:aws:ec2:*:*:instance/{p['instanceId']}",
    "functionName": lambda p: f"arn:aws:lambda:*:*:function:{p['functionName']}",
}
for key, extractor in arn_extractors.items():
    if key in params:
        resources_used[service].add(extractor(params))
LineExplanation
arn_extractorsA dict mapping known request parameter names to lambda functions that build ARN strings
lambda p: f"arn:aws:s3:::{p['bucketName']}"An inline function. When called with params, it builds the S3 bucket ARN
if key in params: extractor(params)Only runs the ARN builder if the relevant parameter exists in this event
resources_used[service].add(...)Adds the extracted ARN to the set for this service — used later for the Resource field in the IAM policy statement

Building the IAM Policy

for service, actions in sorted(actions_by_service.items()):
    resources = sorted(resources_used.get(service, {"*"}))
    statement = {
        "Sid":      f"Allow{service.replace('-', '').title()}Actions",
        "Effect":   "Allow",
        "Action":   sorted(list(actions)),
        "Resource": resources,
    }
    policy_statements.append(statement)
LineExplanation
sorted(actions_by_service.items())Sort by service name so the output policy is alphabetically ordered — easier for humans to review
resources_used.get(service, {"*"})If we extracted specific resource ARNs, use them. If not (no parameter match), default to "*" (any resource)
sorted(resources_used.get(..., {"*"}))Sort the resource list for deterministic, diff-able output
f"Allow{service.replace('-', '').title()}Actions"Generates a Sid like "AllowS3Actions" or "AllowSecretsmanagerActions". replace('-', '') removes hyphens, .title() capitalizes first letter of each word
"Action": sorted(list(actions))Converts the set to a sorted list. IAM requires Action to be a string or list of strings

apply_policy_to_role(role_name, policy, policy_name)

response = iam.create_policy(
    PolicyName=policy_name,
    PolicyDocument=json.dumps(policy),
    Description="Auto-generated least-privilege policy...",
)
policy_arn = response["Policy"]["Arn"]
iam.attach_role_policy(RoleName=role_name, PolicyArn=policy_arn)
LineExplanation
create_policy(PolicyName=..., PolicyDocument=json.dumps(policy))Creates a managed IAM policy. PolicyDocument must be a JSON string — not a dict. json.dumps() converts our dict
response["Policy"]["Arn"]The ARN of the newly-created policy (e.g., arn:aws:iam::123456789012:policy/least-privilege-my-app-role)
attach_role_policy(RoleName=role_name, PolicyArn=policy_arn)Attaches the managed policy to the role. The role can now only perform the actions observed in CloudTrail

Deploy Lambda Function with Version Publishing & Alias Traffic Shifting

Problem Statement

Your team deploys Lambda functions by hand through the AWS Console — which means no audit trail, no rollback plan, and accidental overwrites of environment variables. This script provides a repeatable deployment pipeline with canary releases.


Lambda Deployment Lifecycle

Zip source code
     ↓
update_function_code()      ← uploads new code (state: InProgress)
     ↓
wait for LastUpdateStatus = "Successful"
     ↓
update_function_configuration()  ← update env vars / memory / timeout
     ↓
publish_version()           ← snapshot the code + config as Version N
     ↓
create_or_update_alias()    ← point "prod" alias to V(N) with weights

Complete Script

import boto3
import zipfile
import io
import hashlib
import time
import logging

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


class LambdaDeployer:
    def __init__(self, region: str = "us-east-1"):
        """
        boto3.client("lambda") is the low-level Lambda client.
        We use it directly (not the resource API) because we need
        fine-grained control over publish_version and create_alias.
        """
        self.lmb = boto3.client("lambda", region_name=region)

    # ── Step 1: Create deployment package ────────────────────────
    def create_deployment_package(self, source_file: str) -> bytes:
        """
        Lambda requires the function code as a ZIP archive.
        zipfile.ZipFile + io.BytesIO creates an in-memory ZIP
        so we don't need to write a temp file to disk.

        ZIP_DEFLATED compresses the archive (required for large files).
        zf.write(source_file, arcname="lambda_function.py") writes the
        file into the archive with a specific internal name — Lambda
        uses this name to find the handler module.
        """
        zip_buffer = io.BytesIO()
        with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
            zf.write(source_file, arcname="lambda_function.py")
        zip_buffer.seek(0)
        return zip_buffer.read()

    # ── Step 2: Deploy or update ──────────────────────────────────
    def deploy_or_update(
        self,
        function_name: str,
        source_file: str,
        role_arn: str,
        env_vars: dict,
        runtime: str  = "python3.12",
        memory: int   = 256,
        timeout: int  = 30,
    ) -> str:
        """
        Returns the published version number (as a string, e.g. "5").

        We use a SHA-256 hash of the zip bytes as a human-readable
        deployment identifier — shows up in logs and Lambda tags.
        """
        zip_bytes = self.create_deployment_package(source_file)
        code_hash = hashlib.sha256(zip_bytes).hexdigest()[:8]
        logger.info(f"Deploying {function_name} | code hash: {code_hash}")

        try:
            # ── UPDATE existing function ──────────────────────────
            self.lmb.get_function(FunctionName=function_name)
            logger.info(f"Function exists — updating")

            # update_function_code() replaces the function code.
            # Publish=False means we upload the code first and publish
            # a new version separately after updating config too.
            self.lmb.update_function_code(
                FunctionName=function_name,
                ZipFile=zip_bytes,
                Publish=False,
            )
            # Lambda updates are asynchronous — must wait before next call
            self._wait_for_update(function_name)

            # update_function_configuration() changes runtime settings.
            # Must be a separate call from update_function_code.
            self.lmb.update_function_configuration(
                FunctionName=function_name,
                Environment={"Variables": env_vars},
                MemorySize=memory,
                Timeout=timeout,
            )
            self._wait_for_update(function_name)

        except self.lmb.exceptions.ResourceNotFoundException:
            # ── CREATE new function ───────────────────────────────
            logger.info(f"Function does not exist — creating")
            self.lmb.create_function(
                FunctionName=function_name,
                Runtime=runtime,
                Role=role_arn,
                # Handler = "module_name.function_name"
                # Lambda imports lambda_function.py and calls lambda_handler()
                Handler="lambda_function.lambda_handler",
                Code={"ZipFile": zip_bytes},
                Environment={"Variables": env_vars},
                MemorySize=memory,
                Timeout=timeout,
                Tags={"DeployedBy": "DeployScript", "CodeHash": code_hash},
            )
            self._wait_for_active(function_name)

        # ── Step 3: Publish version ───────────────────────────────
        # publish_version() takes a snapshot of the current code + config
        # and assigns it an immutable version number.
        # Versions are permanent — they can only be deleted explicitly.
        version_resp = self.lmb.publish_version(
            FunctionName=function_name,
            Description=f"Deploy {code_hash}",
        )
        version = version_resp["Version"]
        logger.info(f"Published version: {version}")
        return version

    # ── Step 4: Create / update alias with traffic shifting ───────
    def create_or_update_alias(
        self,
        function_name: str,
        alias: str,
        version: str,
        canary_weight: float = None,
        previous_version: str = None,
    ) -> None:
        """
        An alias is a named pointer to one (or two) Lambda versions.
        Used so callers reference "prod" instead of a version number.

        RoutingConfig.AdditionalVersionWeights enables canary deployment:
        - {"5": 0.9} means 90% traffic to version 5, 10% to this alias's
          FunctionVersion (the new one). Weights must sum to < 1.0.

        Set canary_weight=None to route 100% to the new version.
        """
        config: dict = {
            "FunctionName": function_name,
            "Name":         alias,
            "FunctionVersion": version,
            "Description":  f"Points to version {version}",
        }

        if canary_weight is not None and previous_version is not None:
            # Send `canary_weight` fraction of traffic to old version
            # Remaining fraction (1 - canary_weight) goes to `version`
            config["RoutingConfig"] = {
                "AdditionalVersionWeights": {
                    previous_version: canary_weight
                }
            }
            logger.info(
                f"Canary: {(1-canary_weight)*100:.0f}% → v{version}, "
                f"{canary_weight*100:.0f}% → v{previous_version}"
            )

        try:
            self.lmb.get_alias(FunctionName=function_name, Name=alias)
            self.lmb.update_alias(**config)
            logger.info(f"Updated alias '{alias}' → version {version}")
        except self.lmb.exceptions.ResourceNotFoundException:
            self.lmb.create_alias(**config)
            logger.info(f"Created alias '{alias}' → version {version}")

    def promote_alias(self, function_name: str, alias: str, version: str) -> None:
        """After canary looks healthy, promote to 100% traffic."""
        self.lmb.update_alias(
            FunctionName=function_name,
            Name=alias,
            FunctionVersion=version,
            RoutingConfig={"AdditionalVersionWeights": {}},  # Clear canary weights
        )
        logger.info(f"Alias '{alias}' promoted to 100% → version {version}")

    # ── Waiters ───────────────────────────────────────────────────
    def _wait_for_update(self, function_name: str, max_wait: int = 60) -> None:
        """
        Lambda updates are asynchronous. get_function_configuration()
        returns LastUpdateStatus which cycles:
          InProgress → Successful (or Failed)
        Polling with 1-second intervals is fine; updates usually complete in 2-5s.
        """
        for _ in range(max_wait):
            resp = self.lmb.get_function_configuration(FunctionName=function_name)
            status = resp["LastUpdateStatus"]
            if status == "Successful":
                return
            if status == "Failed":
                raise RuntimeError(
                    f"Lambda update failed: {resp.get('LastUpdateStatusReasonCode')}"
                )
            time.sleep(1)
        raise TimeoutError(f"Lambda update timed out for {function_name}")

    def _wait_for_active(self, function_name: str, max_wait: int = 60) -> None:
        """
        After create_function(), the State cycles: Pending → Active.
        Must wait before calling publish_version() or the API will error.
        """
        for _ in range(max_wait):
            resp = self.lmb.get_function_configuration(FunctionName=function_name)
            if resp["State"] == "Active":
                return
            time.sleep(1)
        raise TimeoutError(f"Lambda activation timed out for {function_name}")


# ── Entry point ───────────────────────────────────────────────────
if __name__ == "__main__":
    deployer = LambdaDeployer(region="us-east-1")

    env_vars = {
        "ENVIRONMENT": "production",
        "DB_HOST":     "prod-db.cluster.us-east-1.rds.amazonaws.com",
        "LOG_LEVEL":   "INFO",
    }

    # Deploy and get new version number
    new_version = deployer.deploy_or_update(
        function_name="my-app-processor",
        source_file="lambda_function.py",
        role_arn="arn:aws:iam::123456789012:role/LambdaExecRole",
        env_vars=env_vars,
        memory=512,
        timeout=60,
    )

    # Canary: route 10% to new version, 90% stays on previous version "4"
    deployer.create_or_update_alias(
        function_name="my-app-processor",
        alias="prod",
        version=new_version,        # e.g., "5"
        canary_weight=0.9,          # 90% to old version
        previous_version="4",       # Previous stable version
    )

    # After observing metrics for 15 minutes...
    # deployer.promote_alias("my-app-processor", "prod", new_version)

Key Commands Explained

CommandWhat it does
zipfile.ZipFile(io.BytesIO(), "w", ZIP_DEFLATED)Creates in-memory ZIP archive
update_function_code(ZipFile=..., Publish=False)Uploads new code without creating a version
update_function_configuration(Environment=...)Updates env vars / memory / timeout separately
publish_version(Description=...)Snapshots current code + config as an immutable version
version_resp["Version"]Returns the version number as a string (e.g., "5")
create_alias(FunctionVersion=version)Creates a named pointer to a specific version
RoutingConfig.AdditionalVersionWeightsSplits traffic between versions for canary releases
get_function_configuration()["LastUpdateStatus"]Checks if an async update has completed

Common Issues

ResourceConflictException on update — Another update is in progress. Always call _wait_for_update() before making another configuration change.

InvalidParameterValueException on weights — Canary weights must sum to less than 1.0 (not equal to 1.0). The main alias version gets the remainder.

PackageStorageException — The ZIP is too large (250 MB uncompressed limit). Use a Lambda layer for large dependencies and keep the function code small.


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK — provides the Lambda client
import zipfileStandard library for creating ZIP archives. Lambda requires code as a ZIP file
import ioStandard library. io.BytesIO() creates an in-memory bytes buffer — we build the ZIP in RAM, not on disk
import hashlibComputes a SHA-256 hash of the ZIP bytes — used as a deployment identifier in logs and tags
import timetime.sleep(1) in the waiter — pauses between status checks
import loggingStructured log output

create_deployment_package(source_file)

zip_buffer = io.BytesIO()
with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf:
    zf.write(source_file, arcname="lambda_function.py")
zip_buffer.seek(0)
return zip_buffer.read()
LineExplanation
io.BytesIO()Creates an in-memory bytes buffer that behaves like a file. We write the ZIP into this instead of a temp file on disk — faster, no cleanup needed
zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED)Opens the in-memory buffer as a writeable ZIP archive. "w" = write mode. ZIP_DEFLATED = use DEFLATE compression (required for larger files)
zf.write(source_file, arcname="lambda_function.py")Adds the source file to the archive with a fixed internal name lambda_function.py. Lambda imports this module using the Handler field: "lambda_function.lambda_handler" means “import lambda_function, call lambda_handler()”
zip_buffer.seek(0)Resets the buffer’s read position to the beginning. Without this, read() would return empty bytes
return zip_buffer.read()Reads all bytes from the buffer. These bytes are what we pass to the ZipFile parameter of update_function_code()

deploy_or_update(function_name, source_file, ...)

zip_bytes = self.create_deployment_package(source_file)
code_hash = hashlib.sha256(zip_bytes).hexdigest()[:8]
LineExplanation
create_deployment_package(source_file)Returns the ZIP as raw bytes
hashlib.sha256(zip_bytes)Computes SHA-256 of the ZIP bytes — a fingerprint of this exact code version
.hexdigest()[:8]Takes the first 8 hex characters as a short identifier (e.g., "a1b2c3d4"). Used in the version description and Lambda tags for traceability
self.lmb.get_function(FunctionName=function_name)
LineExplanation
get_function(FunctionName=...)Checks if the function exists. If it does, we update it. If it raises ResourceNotFoundException, we create it
Why use try/except instead of listing functions?More efficient — one direct call vs paginating through all functions
self.lmb.update_function_code(
    FunctionName=function_name,
    ZipFile=zip_bytes,
    Publish=False,
)
self._wait_for_update(function_name)
LineExplanation
update_function_code(ZipFile=zip_bytes)Uploads the new ZIP to Lambda. The function now runs the new code
ZipFile=zip_bytesPasses the raw bytes directly. For files > 50 MB, use S3Bucket/S3Key instead
Publish=FalseDoes NOT create a new version yet. We update config next, then publish once after both changes. If we published here, we’d create a version with the new code but old config
self._wait_for_update(function_name)Lambda updates are asynchronous — you must wait for LastUpdateStatus == "Successful" before making another change. Without waiting, the next call raises ResourceConflictException
self.lmb.update_function_configuration(
    FunctionName=function_name,
    Environment={"Variables": env_vars},
    MemorySize=memory,
    Timeout=timeout,
)
LineExplanation
update_function_configuration(...)Updates runtime settings separately from code. AWS requires two separate API calls — code and config cannot be updated atomically
Environment={"Variables": env_vars}Sets environment variables. env_vars is a plain dict like {"DB_HOST": "...", "LOG_LEVEL": "INFO"}
MemorySize=memoryRAM allocated to the Lambda in MB. Also proportionally increases CPU. Valid: 128–10,240 MB in 1 MB increments
Timeout=timeoutMax execution time in seconds. Lambda kills the function if it exceeds this. Max: 900 (15 min)

publish_version()

version_resp = self.lmb.publish_version(
    FunctionName=function_name,
    Description=f"Deploy {code_hash}",
)
version = version_resp["Version"]
LineExplanation
publish_version(FunctionName=...)Takes a snapshot of the current code + config and assigns it an immutable version number (e.g., "5")
Description=f"Deploy {code_hash}"Human-readable label for this version. Shown in the Lambda console version list
version_resp["Version"]The version number as a string (e.g., "5"). Lambda versions always use strings, not integers
Why publish a version?Without versioning, aliases always point to $LATEST (the current unpublished code). With versioning, you can roll back by pointing the alias to a previous version number

create_or_update_alias(function_name, alias, version, canary_weight, previous_version)

config["RoutingConfig"] = {
    "AdditionalVersionWeights": {previous_version: canary_weight}
}
LineExplanation
RoutingConfigControls traffic splitting between two Lambda versions
AdditionalVersionWeights: {"4": 0.9}Sends 90% of traffic to version 4. The remaining 10% goes to the alias’s FunctionVersion (the new version 5). Weights must sum to less than 1.0
Why canary?If version 5 has a bug, only 10% of users are affected. You can monitor error rates and roll back by updating the alias to 100% on version 4
try:
    self.lmb.get_alias(FunctionName=function_name, Name=alias)
    self.lmb.update_alias(**config)
except self.lmb.exceptions.ResourceNotFoundException:
    self.lmb.create_alias(**config)
LineExplanation
get_alias(FunctionName=..., Name=alias)Checks if this alias already exists
update_alias(**config)**config unpacks the dict as keyword arguments. Equivalent to writing each key explicitly
create_alias(**config)Creates the alias on first deploy
Idempotent patternThe same code path handles both first-time and subsequent deploys — safe to run multiple times

_wait_for_update(function_name) — Waiter

for _ in range(max_wait):
    resp = self.lmb.get_function_configuration(FunctionName=function_name)
    status = resp["LastUpdateStatus"]
    if status == "Successful":
        return
    if status == "Failed":
        raise RuntimeError(...)
    time.sleep(1)
raise TimeoutError(...)
LineExplanation
for _ in range(max_wait)Polls up to max_wait times (default 60 times = 60 seconds). _ is convention for “don’t care about this variable”
get_function_configuration()Returns the current function config including LastUpdateStatus
resp["LastUpdateStatus"]One of: "Successful" (ready), "InProgress" (still updating), "Failed" (update crashed)
return on "Successful"Exits the loop — the function is ready for the next operation
raise RuntimeError on "Failed"Lambda failed to apply the update. The previous version is still running
time.sleep(1)Waits 1 second between polls. Lambda updates typically complete in 2–5 seconds
raise TimeoutError after loopIf still updating after 60 seconds, something is wrong

Create CloudWatch Alarms for All EC2 Instances (CPU, Memory, Disk)

Problem Statement

Your team gets paged at 3 AM when an EC2 instance runs out of disk space — but only after the application has already crashed. Without proactive alarms, you’re reactive. This script creates CPU, memory, disk, and status-check alarms for every running instance in under 30 seconds.


Two Metric Namespaces

NamespaceSourceMetrics
AWS/EC2Built-in (no agent needed)CPUUtilization, StatusCheckFailed, NetworkIn/Out
CWAgentRequires CloudWatch Agent on instancemem_used_percent, disk_used_percent

Memory and disk are not natively reported by AWS — you must install the CloudWatch Agent on each instance.


Complete Script

import boto3
import logging

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


def setup_ec2_alarms(sns_topic_arn: str, region: str = "us-east-1") -> int:
    """
    Creates 4 alarms per running EC2 instance:
      1. High CPU (AWS/EC2 namespace — no agent needed)
      2. Status Check Failed (AWS/EC2 — built-in)
      3. High Memory (CWAgent — requires CloudWatch Agent)
      4. Low Disk Space (CWAgent — requires CloudWatch Agent)

    Returns the total number of alarms created.
    """
    ec2 = boto3.client("ec2", region_name=region)
    cw  = boto3.client("cloudwatch", region_name=region)

    # ── Discover all running instances (paginated) ─────────────────
    instances = []
    paginator = ec2.get_paginator("describe_instances")
    for page in paginator.paginate(
        Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
    ):
        for r in page["Reservations"]:
            instances.extend(r["Instances"])

    logger.info(f"Creating alarms for {len(instances)} running instances...")
    alarms_created = 0

    for instance in instances:
        iid = instance["InstanceId"]
        name = next(
            (t["Value"] for t in instance.get("Tags", []) if t["Key"] == "Name"),
            iid,
        )

        # ── Alarm configuration list ───────────────────────────────
        # Each dict maps to one put_metric_alarm() call.
        # AWS/EC2 metrics use only InstanceId dimension.
        # CWAgent metrics additionally need InstanceId for linkage.
        alarm_configs = [

            # ── 1. CPU Utilization ─────────────────────────────────
            # Period=300 (5 min) × EvaluationPeriods=3 = 15-min sustained breach
            # Sustained check avoids false alarms from brief CPU spikes.
            {
                "name":         f"{name}-HighCPU",
                "metric":       "CPUUtilization",
                "namespace":    "AWS/EC2",
                "threshold":    80.0,
                "comparison":   "GreaterThanThreshold",
                "period":       300,
                "eval_periods": 3,
                "description":  f"CPU > 80% for 15 min on {name}",
                "dimensions":   [{"Name": "InstanceId", "Value": iid}],
            },

            # ── 2. Status Check ────────────────────────────────────
            # StatusCheckFailed = 1 if EITHER system or instance check fails.
            # Period=60 (1 min) × EvaluationPeriods=2 = 2-min sustained failure.
            # Short period because a failed status check is serious immediately.
            {
                "name":         f"{name}-StatusCheckFailed",
                "metric":       "StatusCheckFailed",
                "namespace":    "AWS/EC2",
                "threshold":    0,
                "comparison":   "GreaterThanThreshold",
                "period":       60,
                "eval_periods": 2,
                "description":  f"Status check failed on {name}",
                "dimensions":   [{"Name": "InstanceId", "Value": iid}],
            },

            # ── 3. Memory (requires CloudWatch Agent) ─────────────
            # mem_used_percent is published by the CWAgent, not AWS.
            # Namespace = "CWAgent" (not "AWS/EC2").
            # The CWAgent config on the instance must be publishing this metric.
            {
                "name":         f"{name}-HighMemory",
                "metric":       "mem_used_percent",
                "namespace":    "CWAgent",
                "threshold":    85.0,
                "comparison":   "GreaterThanThreshold",
                "period":       300,
                "eval_periods": 3,
                "description":  f"Memory > 85% on {name}",
                "dimensions":   [{"Name": "InstanceId", "Value": iid}],
            },

            # ── 4. Disk Space (requires CloudWatch Agent) ──────────
            # disk_used_percent needs extra dimensions to identify WHICH
            # disk: "path" ("/") and "fstype" ("xfs" or "ext4").
            # These must match exactly what the CWAgent is reporting.
            {
                "name":         f"{name}-LowDiskSpace",
                "metric":       "disk_used_percent",
                "namespace":    "CWAgent",
                "threshold":    85.0,
                "comparison":   "GreaterThanThreshold",
                "period":       300,
                "eval_periods": 2,
                "description":  f"Disk > 85% on {name} /",
                "dimensions":   [
                    {"Name": "InstanceId", "Value": iid},
                    {"Name": "path",       "Value": "/"},
                    {"Name": "fstype",     "Value": "xfs"},   # or "ext4"
                    {"Name": "device",     "Value": "nvme0n1p1"},
                ],
            },
        ]

        # ── Create each alarm ──────────────────────────────────────
        for cfg in alarm_configs:
            try:
                cw.put_metric_alarm(
                    AlarmName=cfg["name"],
                    AlarmDescription=cfg["description"],
                    MetricName=cfg["metric"],
                    Namespace=cfg["namespace"],
                    Statistic="Average",
                    # Dimensions uniquely identify the resource to monitor.
                    # AWS/EC2 only needs InstanceId; CWAgent metrics need more.
                    Dimensions=cfg["dimensions"],
                    # Period: seconds per data point (60, 300, 900...)
                    # Lower period = more granular but more CloudWatch costs.
                    Period=cfg["period"],
                    # EvaluationPeriods: how many consecutive data points must
                    # breach the threshold before the alarm fires.
                    EvaluationPeriods=cfg["eval_periods"],
                    Threshold=cfg["threshold"],
                    ComparisonOperator=cfg["comparison"],
                    # TreatMissingData:
                    # "notBreaching" — missing data doesn't trigger alarm
                    # "breaching"    — missing data triggers alarm (good for uptime)
                    # "ignore"       — keeps previous alarm state
                    TreatMissingData="notBreaching",
                    # AlarmActions: SNS topics (or other targets) to notify
                    # when the alarm goes into ALARM state.
                    AlarmActions=[sns_topic_arn],
                    # OKActions: notify when alarm recovers to OK state.
                    OKActions=[sns_topic_arn],
                )
                alarms_created += 1
                logger.info(f"  Created: {cfg['name']}")

            except Exception as e:
                logger.error(f"  Failed to create alarm {cfg['name']}: {e}")

    logger.info(f"Done. Created {alarms_created} alarms for {len(instances)} instances.")
    return alarms_created


def delete_alarms_for_terminated_instances(region: str = "us-east-1") -> int:
    """
    Clean up stale alarms for instances that no longer exist.
    describe_alarms() returns all alarms; we check if their InstanceId
    dimension matches a running or stopped instance.
    """
    ec2 = boto3.client("ec2", region_name=region)
    cw  = boto3.client("cloudwatch", region_name=region)

    # Get all existing instance IDs (any state)
    existing_ids = set()
    paginator = ec2.get_paginator("describe_instances")
    for page in paginator.paginate():
        for r in page["Reservations"]:
            for inst in r["Instances"]:
                existing_ids.add(inst["InstanceId"])

    # Get all CloudWatch alarms
    alarms_to_delete = []
    cw_paginator = cw.get_paginator("describe_alarms")
    for page in cw_paginator.paginate(AlarmTypes=["MetricAlarm"]):
        for alarm in page["MetricAlarms"]:
            # Extract InstanceId from alarm dimensions
            instance_id = next(
                (d["Value"] for d in alarm.get("Dimensions", [])
                 if d["Name"] == "InstanceId"),
                None,
            )
            if instance_id and instance_id not in existing_ids:
                alarms_to_delete.append(alarm["AlarmName"])

    if alarms_to_delete:
        # delete_alarms() accepts up to 100 names at a time
        for i in range(0, len(alarms_to_delete), 100):
            cw.delete_alarms(AlarmNames=alarms_to_delete[i:i+100])
        logger.info(f"Deleted {len(alarms_to_delete)} stale alarm(s)")

    return len(alarms_to_delete)


if __name__ == "__main__":
    total = setup_ec2_alarms(
        sns_topic_arn="arn:aws:sns:us-east-1:123456789012:ops-alerts",
        region="ap-south-1",
    )
    print(f"\nTotal alarms created: {total}")

    # Run cleanup weekly to remove alarms for terminated instances
    # deleted = delete_alarms_for_terminated_instances(region="ap-south-1")

Key Commands Explained

CommandWhat it does
cw.put_metric_alarm(...)Creates or updates (idempotent) a CloudWatch alarm
Namespace="AWS/EC2"Built-in EC2 metrics — no agent required
Namespace="CWAgent"Custom metrics from the CloudWatch Agent
Period=3005-minute data point interval — standard for EC2 metrics
EvaluationPeriods=3Alarm fires only after 3 consecutive breaches (avoids false alarms)
TreatMissingData="notBreaching"Missing data points don’t trigger the alarm
AlarmActions=[sns_topic_arn]SNS topic to notify when alarm state changes to ALARM
OKActions=[sns_topic_arn]SNS topic to notify when alarm recovers to OK
delete_alarms(AlarmNames=[...])Deletes up to 100 alarms per call

CloudWatch Agent Installation (Quick Reference)

# On Amazon Linux 2 / 2023
sudo yum install -y amazon-cloudwatch-agent

# Minimal config to publish mem + disk
cat > /opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json <<'EOF'
{
  "metrics": {
    "metrics_collected": {
      "mem":  { "measurement": ["mem_used_percent"] },
      "disk": { "measurement": ["disk_used_percent"], "resources": ["/"] }
    }
  }
}
EOF

sudo /opt/aws/amazon-cloudwatch-agent/bin/amazon-cloudwatch-agent-ctl \
  -a fetch-config -m ec2 \
  -c file:/opt/aws/amazon-cloudwatch-agent/etc/amazon-cloudwatch-agent.json -s

---

## 🔍 Line-by-Line Code Walkthrough

### Imports

| Line | Why It's Used |
|------|--------------|
| `import boto3` | AWS SDK — needed for EC2 and CloudWatch clients |
| `import logging` | Structured log output with timestamps |

---

### `setup_ec2_alarms(sns_topic_arn, region)`

```python
ec2 = boto3.client("ec2", region_name=region)
cw  = boto3.client("cloudwatch", region_name=region)
LineExplanation
boto3.client("ec2", region_name=region)EC2 client to list running instances
boto3.client("cloudwatch", region_name=region)CloudWatch client to create alarms. Alarms are regional — they must be in the same region as the EC2 instances they monitor
paginator = ec2.get_paginator("describe_instances")
for page in paginator.paginate(
    Filters=[{"Name": "instance-state-name", "Values": ["running"]}]
):
    for r in page["Reservations"]:
        instances.extend(r["Instances"])
LineExplanation
get_paginator("describe_instances")Handles pagination automatically. Without this, you’d miss instances if there are more than 1000
Filters=[{"Name":"instance-state-name","Values":["running"]}]Only returns running instances. No point creating alarms for stopped or terminated instances
instances.extend(r["Instances"])Flattens Reservations → Instances into one list

Alarm Config Dictionary

{
    "name":         f"{name}-HighCPU",
    "metric":       "CPUUtilization",
    "namespace":    "AWS/EC2",
    "threshold":    80.0,
    "comparison":   "GreaterThanThreshold",
    "period":       300,
    "eval_periods": 3,
    "dimensions":   [{"Name": "InstanceId", "Value": iid}],
}
FieldExplanation
"name"The CloudWatch alarm name. Must be unique per account+region. We prefix with the instance Name tag for readability
"metric": "CPUUtilization"The CloudWatch metric name to monitor. Case-sensitive
"namespace": "AWS/EC2"EC2 built-in metrics live in the AWS/EC2 namespace — no agent required
"namespace": "CWAgent"Memory and disk metrics published by the CloudWatch Agent live in the CWAgent namespace
"threshold": 80.0The breach level. For "GreaterThanThreshold", alarm fires when the metric exceeds 80
"comparison": "GreaterThanThreshold"The comparison operator. Other options: GreaterThanOrEqualToThreshold, LessThanThreshold, LessThanOrEqualToThreshold
"period": 300Data point interval in seconds. 300 = 5-minute data points. CloudWatch stores metrics at 1-min or 5-min resolution depending on detailed monitoring
"eval_periods": 3Number of consecutive data points that must breach the threshold before the alarm fires. 3 periods × 300 seconds = 15 minutes sustained breach before alert
"dimensions"Identifies WHICH resource to monitor. InstanceId is the primary dimension for EC2 metrics

Disk Alarm — Extra Dimensions

"dimensions": [
    {"Name": "InstanceId", "Value": iid},
    {"Name": "path",       "Value": "/"},
    {"Name": "fstype",     "Value": "xfs"},
    {"Name": "device",     "Value": "nvme0n1p1"},
],
FieldExplanation
"path": "/"The mount point of the disk to monitor. Must exactly match what the CWAgent reports (check CloudWatch Metrics → CWAgent for actual values)
"fstype": "xfs"Filesystem type. Amazon Linux 2 uses xfs; Ubuntu uses ext4. Wrong value means the alarm never fires because no metrics match
"device": "nvme0n1p1"The disk device name. NVMe instances use nvme0n1p1; older instances use xvda1. Must match CWAgent output exactly
Why so many dimensions?CWAgent reports metrics per disk mount. Without these dimensions, CloudWatch can’t identify which disk’s disk_used_percent to monitor

cw.put_metric_alarm(...)

cw.put_metric_alarm(
    AlarmName=cfg["name"],
    AlarmDescription=cfg["description"],
    MetricName=cfg["metric"],
    Namespace=cfg["namespace"],
    Statistic="Average",
    Dimensions=cfg["dimensions"],
    Period=cfg["period"],
    EvaluationPeriods=cfg["eval_periods"],
    Threshold=cfg["threshold"],
    ComparisonOperator=cfg["comparison"],
    TreatMissingData="notBreaching",
    AlarmActions=[sns_topic_arn],
    OKActions=[sns_topic_arn],
)
ParameterExplanation
AlarmNameUnique identifier. If an alarm with this name already exists, put_metric_alarm updates it — making it idempotent
Statistic="Average"How to aggregate data points within each period. "Average" is standard for CPU and memory. "Sum" for counting events. "Maximum" for peak-sensitive metrics
TreatMissingData="notBreaching"What to do if no data arrives in a period. "notBreaching" = keep OK state (instance may just not be reporting yet). Use "breaching" for uptime alarms where missing data = instance is down
AlarmActions=[sns_topic_arn]List of ARNs to trigger when alarm state changes to ALARM. SNS topics are the most common target — they can fan out to email, PagerDuty, Slack, Lambda
OKActions=[sns_topic_arn]Triggered when the alarm recovers from ALARM → OK. Sends a “resolved” notification to the same SNS topic

delete_alarms_for_terminated_instances()

cw_paginator = cw.get_paginator("describe_alarms")
for page in cw_paginator.paginate(AlarmTypes=["MetricAlarm"]):
    for alarm in page["MetricAlarms"]:
        instance_id = next(
            (d["Value"] for d in alarm.get("Dimensions", []) if d["Name"] == "InstanceId"),
            None,
        )
        if instance_id and instance_id not in existing_ids:
            alarms_to_delete.append(alarm["AlarmName"])
LineExplanation
AlarmTypes=["MetricAlarm"]Filters to metric alarms only (vs composite alarms). Required parameter for the paginator
alarm.get("Dimensions", [])Gets the alarm’s dimension list. Alarms for non-EC2 metrics may have no dimensions
d["Name"] == "InstanceId"Searches for the InstanceId dimension to identify which EC2 instance this alarm belongs to
instance_id not in existing_idsIf the InstanceId doesn’t match any current EC2 instance (including stopped/terminated), the alarm is stale
for i in range(0, len(alarms_to_delete), 100):
    cw.delete_alarms(AlarmNames=alarms_to_delete[i:i+100])
LineExplanation
range(0, len(alarms_to_delete), 100)Generates indices [0, 100, 200, ...] — batch slicing loop
alarms_to_delete[i:i+100]Slice of 100 alarm names. delete_alarms accepts a maximum of 100 alarm names per call
Why batch?With many instances, you could have thousands of stale alarms. Batching avoids hitting the 100-name limit per API call

RDS Snapshot Before Deployment & Point-in-Time Restore

Problem Statement

Your team deployed a schema migration that introduced a breaking change. The code rollback was instant, but the database was already migrated — no snapshot meant a 4-hour manual recovery. A pre-deployment snapshot takes 5-10 minutes and costs only storage. This script automates it as part of your deployment pipeline.


Deployment Integration Pattern

# In your CI/CD pipeline (GitHub Actions, Jenkins, etc.)
python rds_snapshot.py pre-deploy --db prod-postgres --version v2.4.1

# Run your deployment...

# If deployment fails:
python rds_snapshot.py restore --snapshot pre-deploy-prod-postgres-v2.4.1-20250120-103045

Complete Script

import boto3
import time
import sys
from datetime import datetime, timezone


class RDSSnapshotManager:
    def __init__(self, region: str = "us-east-1"):
        """
        boto3.client("rds") is the RDS service client.
        RDS operations are long-running (snapshot: 5-30 min,
        restore: 10-45 min) — we use boto3 Waiters to poll for completion
        instead of writing manual sleep loops.
        """
        self.rds = boto3.client("rds", region_name=region)

    # ── Pre-deployment snapshot ───────────────────────────────────
    def create_pre_deploy_snapshot(
        self, db_identifier: str, deploy_version: str
    ) -> str:
        """
        create_db_snapshot() creates a manual snapshot of the RDS instance.
        Manual snapshots are retained until you explicitly delete them
        (unlike automated backups which expire based on the retention window).

        DBSnapshotIdentifier must be unique, 1-255 chars, alphanumeric + hyphens.
        We embed the instance name + deploy version + timestamp for traceability.

        TagSpecifications on RDS use a different pattern than EC2 — tags
        are passed directly in the API call.
        """
        timestamp   = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
        snapshot_id = f"pre-deploy-{db_identifier}-{deploy_version}-{timestamp}"

        print(f"Creating pre-deployment snapshot: {snapshot_id}")

        self.rds.create_db_snapshot(
            DBSnapshotIdentifier=snapshot_id,
            DBInstanceIdentifier=db_identifier,
            Tags=[
                {"Key": "Type",          "Value": "pre-deployment"},
                {"Key": "DeployVersion", "Value": deploy_version},
                {"Key": "CreatedAt",     "Value": timestamp},
                {"Key": "AutoDelete",    "Value": "false"},   # Keep until manually removed
            ],
        )

        # ── Wait for snapshot to be available ─────────────────────
        # get_waiter("db_snapshot_available") polls describe_db_snapshots()
        # every 30 seconds (default) until Status = "available".
        # Raises WaiterError if it doesn't complete within max_attempts.
        print("Waiting for snapshot to complete (5-20 min for large databases)...")
        waiter = self.rds.get_waiter("db_snapshot_available")
        waiter.wait(
            DBSnapshotIdentifier=snapshot_id,
            WaiterConfig={
                "Delay":       30,    # Poll every 30 seconds
                "MaxAttempts": 60,    # Give up after 30 min (60 × 30s)
            },
        )
        print(f"✅ Snapshot ready: {snapshot_id}")
        return snapshot_id

    # ── Restore from snapshot ─────────────────────────────────────
    def restore_from_snapshot(
        self,
        snapshot_id: str,
        new_db_identifier: str,
        db_subnet_group: str,
        vpc_sg_ids: list[str],
    ) -> str:
        """
        restore_db_instance_from_db_snapshot() creates a NEW RDS instance
        from the snapshot. It does NOT overwrite the existing database.

        This is intentional — you end up with TWO databases:
          prod-postgres         → current (potentially broken) DB
          prod-postgres-rollback → restored from pre-deploy snapshot

        After verifying the restore, you either:
          a) Swap the app config to point to the restored DB, OR
          b) Use pg_dump/restore to merge specific tables back

        We read DBInstanceClass from the snapshot so the restored instance
        matches the original instance type.
        """
        print(f"Restoring {new_db_identifier} from snapshot: {snapshot_id}")

        # Get the original instance class from the snapshot
        snapshots = self.rds.describe_db_snapshots(
            DBSnapshotIdentifier=snapshot_id
        )["DBSnapshots"]
        if not snapshots:
            raise ValueError(f"Snapshot not found: {snapshot_id}")
        snapshot = snapshots[0]

        self.rds.restore_db_instance_from_db_snapshot(
            DBInstanceIdentifier=new_db_identifier,
            DBSnapshotIdentifier=snapshot_id,
            # Use the same instance class as the original
            DBInstanceClass=snapshot["DBInstanceClass"],
            DBSubnetGroupName=db_subnet_group,
            VpcSecurityGroupIds=vpc_sg_ids,
            MultiAZ=True,
            AutoMinorVersionUpgrade=True,
            DeletionProtection=True,    # Prevent accidental deletion
            Tags=[
                {"Key": "RestoredFrom", "Value": snapshot_id},
                {"Key": "RestoredAt",   "Value": datetime.utcnow().isoformat() + "Z"},
                {"Key": "Temporary",    "Value": "true"},
            ],
        )

        print("Waiting for restored instance to be available (10-45 min)...")
        waiter = self.rds.get_waiter("db_instance_available")
        waiter.wait(
            DBInstanceIdentifier=new_db_identifier,
            WaiterConfig={"Delay": 30, "MaxAttempts": 120},   # Up to 60 min
        )

        endpoint = self.rds.describe_db_instances(
            DBInstanceIdentifier=new_db_identifier
        )["DBInstances"][0]["Endpoint"]["Address"]

        print(f"✅ Restored instance available at: {endpoint}")
        return endpoint

    # ── Point-in-time restore ─────────────────────────────────────
    def point_in_time_restore(
        self,
        source_db: str,
        target_db: str,
        restore_time: datetime,
        db_subnet_group: str,
        vpc_sg_ids: list[str],
    ) -> str:
        """
        restore_db_instance_to_point_in_time() restores to any second
        within the automated backup retention window (default 7 days).

        restore_time must be a timezone-aware datetime in UTC.
        Useful when you know exactly when the problem was introduced
        (e.g., "restore to 2025-01-20 14:29:59 UTC — 1 minute before deploy").

        Unlike snapshot restore, this uses the continuous transaction logs
        stored during automated backups to achieve precise RPO.
        """
        print(
            f"Restoring {source_db} to point-in-time: "
            f"{restore_time.strftime('%Y-%m-%d %H:%M:%S UTC')}"
        )

        self.rds.restore_db_instance_to_point_in_time(
            SourceDBInstanceIdentifier=source_db,
            TargetDBInstanceIdentifier=target_db,
            RestoreTime=restore_time,
            DBSubnetGroupName=db_subnet_group,
            VpcSecurityGroupIds=vpc_sg_ids,
            MultiAZ=True,
            DeletionProtection=True,
        )

        waiter = self.rds.get_waiter("db_instance_available")
        waiter.wait(
            DBInstanceIdentifier=target_db,
            WaiterConfig={"Delay": 30, "MaxAttempts": 120},
        )
        print(f"✅ Point-in-time restore complete: {target_db}")

        endpoint = self.rds.describe_db_instances(
            DBInstanceIdentifier=target_db
        )["DBInstances"][0]["Endpoint"]["Address"]
        return endpoint

    # ── List pre-deploy snapshots ─────────────────────────────────
    def list_pre_deploy_snapshots(self, db_identifier: str) -> list[dict]:
        """List all pre-deployment snapshots for a given DB, newest first."""
        paginator = self.rds.get_paginator("describe_db_snapshots")
        snapshots = []
        for page in paginator.paginate(
            DBInstanceIdentifier=db_identifier,
            SnapshotType="manual",
        ):
            for snap in page["DBSnapshots"]:
                if snap["DBSnapshotIdentifier"].startswith("pre-deploy-"):
                    snapshots.append({
                        "id":      snap["DBSnapshotIdentifier"],
                        "status":  snap["Status"],
                        "created": snap["SnapshotCreateTime"].strftime("%Y-%m-%d %H:%M UTC"),
                        "size_gb": snap.get("AllocatedStorage", 0),
                    })

        return sorted(snapshots, key=lambda x: x["created"], reverse=True)


# ── CLI entry point ───────────────────────────────────────────────
if __name__ == "__main__":
    manager = RDSSnapshotManager(region="ap-south-1")

    # Before deployment — called from CI/CD
    snapshot_id = manager.create_pre_deploy_snapshot(
        db_identifier="prod-postgres",
        deploy_version="v2.4.1",
    )
    print(f"\nSave this snapshot ID for rollback: {snapshot_id}")

    # List recent snapshots
    print("\nRecent pre-deploy snapshots:")
    for snap in manager.list_pre_deploy_snapshots("prod-postgres")[:5]:
        print(f"  {snap['id']}  [{snap['status']}]  {snap['created']}")

    # Rollback (uncomment if deployment fails):
    # manager.restore_from_snapshot(
    #     snapshot_id="pre-deploy-prod-postgres-v2.4.1-20250120-103045",
    #     new_db_identifier="prod-postgres-rollback",
    #     db_subnet_group="prod-db-subnet-group",
    #     vpc_sg_ids=["sg-0abc1234"],
    # )

    # Point-in-time restore (uncomment if needed):
    # from datetime import timezone
    # restore_to = datetime(2025, 1, 20, 14, 29, 59, tzinfo=timezone.utc)
    # manager.point_in_time_restore(
    #     source_db="prod-postgres",
    #     target_db="prod-postgres-pitr",
    #     restore_time=restore_to,
    #     db_subnet_group="prod-db-subnet-group",
    #     vpc_sg_ids=["sg-0abc1234"],
    # )

Key Commands Explained

CommandWhat it does
create_db_snapshot(DBSnapshotIdentifier, DBInstanceIdentifier)Creates a manual RDS snapshot
get_waiter("db_snapshot_available")Polls until snapshot Status = “available”
waiter.wait(WaiterConfig={"Delay": 30, "MaxAttempts": 60})Override poll interval and max attempts
describe_db_snapshots(DBSnapshotIdentifier)["DBSnapshots"][0]Get metadata of a specific snapshot
restore_db_instance_from_db_snapshot(...)Creates a new DB instance from a snapshot
restore_db_instance_to_point_in_time(RestoreTime=...)Restores to a specific UTC second
get_waiter("db_instance_available")Polls until DB instance Status = “available”
describe_db_instances()["DBInstances"][0]["Endpoint"]["Address"]Get the restored DB hostname

Common Issues

SnapshotQuotaExceeded — AWS allows up to 100 manual snapshots per account per region. Clean up old pre-deploy snapshots after a successful deployment.

Restore creates new endpoint — The restored instance has a NEW hostname. You must update your app config / connection string to point to it.

Point-in-time restore outside retention window — If your automated backup retention is 7 days, you can only restore to within the last 7 days. Increase the retention period in RDS settings for a wider recovery window.


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK — provides the RDS client
import timeStandard library — could be used for manual sleep loops (we use Waiters instead)
import sysUsed for sys.argv in CLI mode
from datetime import datetime, timezonedatetime.now(timezone.utc) for UTC-aware timestamps. timezone.utc makes the datetime timezone-aware — required for RestoreTime in point-in-time restore

RDSSnapshotManager.__init__

self.rds = boto3.client("rds", region_name=region)
LineExplanation
boto3.client("rds", region_name=region)Creates an RDS API client. RDS is regional — specify the region where your database lives
Why store as self.rds?Shared across all methods so we don’t create a new client on every call

create_pre_deploy_snapshot(db_identifier, deploy_version)

timestamp   = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
snapshot_id = f"pre-deploy-{db_identifier}-{deploy_version}-{timestamp}"
LineExplanation
datetime.now(timezone.utc)Current UTC time — timezone-aware. strftime converts it to a string
"%Y%m%d-%H%M%S"Format: 20250120-103045. Used in the snapshot ID for uniqueness and easy sorting
f"pre-deploy-{db_identifier}-..."Snapshot IDs must be: 1–255 characters, alphanumeric + hyphens, unique per account+region. Embedding the DB name and version makes it self-documenting
self.rds.create_db_snapshot(
    DBSnapshotIdentifier=snapshot_id,
    DBInstanceIdentifier=db_identifier,
    Tags=[
        {"Key": "Type",          "Value": "pre-deployment"},
        {"Key": "DeployVersion", "Value": deploy_version},
    ],
)
LineExplanation
DBSnapshotIdentifier=snapshot_idThe unique name for this snapshot. Used to reference it later in restore or describe calls
DBInstanceIdentifier=db_identifierThe source RDS instance to snapshot (e.g., prod-postgres)
Tags=[{"Key": "Type", ...}]RDS tags are passed directly in the API call (unlike EC2 which has a separate TagSpecifications key)
"AutoDelete": "false"A custom tag we add — no effect on AWS behavior, but reminds operators this snapshot should not be deleted by automated cleanup scripts
waiter = self.rds.get_waiter("db_snapshot_available")
waiter.wait(
    DBSnapshotIdentifier=snapshot_id,
    WaiterConfig={"Delay": 30, "MaxAttempts": 60},
)
LineExplanation
get_waiter("db_snapshot_available")Returns a pre-built Waiter that polls describe_db_snapshots() until Status == "available"
WaiterConfig.Delay: 30Polls every 30 seconds — boto3’s default is also 30 seconds for this waiter
WaiterConfig.MaxAttempts: 60Tries 60 times × 30 seconds = 30 minutes maximum wait. A large database (2 TB) can take this long
Why use Waiter instead of time.sleep?Waiters know the exact API to poll and the terminal conditions (available, failed). A manual loop would need to replicate this logic

restore_from_snapshot(...)

snapshots = self.rds.describe_db_snapshots(
    DBSnapshotIdentifier=snapshot_id
)["DBSnapshots"]
snapshot = snapshots[0]
LineExplanation
describe_db_snapshots(DBSnapshotIdentifier=snapshot_id)Retrieves metadata about this specific snapshot — including the DBInstanceClass (instance type) of the original database
["DBSnapshots"]The list of matching snapshots. Even with a specific ID, the API returns a list
snapshots[0]Takes the first (and only) result. We check if not snapshots above to handle the not-found case
self.rds.restore_db_instance_from_db_snapshot(
    DBInstanceIdentifier=new_db_identifier,
    DBSnapshotIdentifier=snapshot_id,
    DBInstanceClass=snapshot["DBInstanceClass"],
    DBSubnetGroupName=db_subnet_group,
    VpcSecurityGroupIds=vpc_sg_ids,
    MultiAZ=True,
    DeletionProtection=True,
)
LineExplanation
DBInstanceIdentifier=new_db_identifierThe name of the new instance. Restore always creates a NEW instance — it does NOT overwrite the existing database
DBSnapshotIdentifier=snapshot_idWhich snapshot to restore from
DBInstanceClass=snapshot["DBInstanceClass"]Reuses the same instance type (e.g., db.t3.medium) as the original. Ensures the restored DB has the same capacity
DBSubnetGroupName=db_subnet_groupThe subnet group determines which VPC subnets the DB can use. Must exist in your VPC
VpcSecurityGroupIds=vpc_sg_idsThe security groups that control network access. Must allow connections from your application
MultiAZ=TrueCreates a standby replica in another AZ for high availability
DeletionProtection=TruePrevents accidental deletion via CLI or API. Must be explicitly disabled before deleting
endpoint = self.rds.describe_db_instances(
    DBInstanceIdentifier=new_db_identifier
)["DBInstances"][0]["Endpoint"]["Address"]
LineExplanation
describe_db_instances(DBInstanceIdentifier=...)Fetches the instance details after the waiter confirms it’s available
["DBInstances"][0]Returns a list — we take the first (only) result
["Endpoint"]["Address"]The database hostname (e.g., prod-postgres-rollback.abc123.us-east-1.rds.amazonaws.com). This is what goes in your connection string

point_in_time_restore(source_db, target_db, restore_time, ...)

self.rds.restore_db_instance_to_point_in_time(
    SourceDBInstanceIdentifier=source_db,
    TargetDBInstanceIdentifier=target_db,
    RestoreTime=restore_time,
    ...
)
LineExplanation
SourceDBInstanceIdentifier=source_dbThe existing database to restore FROM (not the snapshot — PITR works from automated backups stored in the automated backup retention window)
TargetDBInstanceIdentifier=target_dbName of the new instance to create
RestoreTime=restore_timeA timezone-aware UTC datetime specifying the exact second to restore to. RDS uses transaction logs to replay changes up to this second. The datetime must be within the automated backup retention window
Why PITR vs snapshot?PITR lets you restore to ANY second within your retention window. A pre-deploy snapshot only covers the moment you explicitly took it

list_pre_deploy_snapshots(db_identifier)

for page in paginator.paginate(
    DBInstanceIdentifier=db_identifier,
    SnapshotType="manual",
):
    for snap in page["DBSnapshots"]:
        if snap["DBSnapshotIdentifier"].startswith("pre-deploy-"):
LineExplanation
SnapshotType="manual"Filters to manually created snapshots only. Excludes automated backups (which have SnapshotType="automated")
.startswith("pre-deploy-")Secondary filter to only return snapshots created by this script (vs other manual snapshots the team might have created)
sorted(..., key=lambda x: x["created"], reverse=True)Returns snapshots newest-first so the most recent rollback option is at index 0

EC2 Cost Analysis & Savings Plan Recommendations with Cost Explorer

Problem Statement

Your AWS bill jumped 40% last month. Engineering lead asks: “What are we spending on EC2, and what can we do about it?” Rather than clicking through the Cost Explorer console, this script pulls the data and recommendations programmatically — ready to be emailed, Slacked, or fed into a dashboard.


Cost Explorer Key Concepts

TermMeaning
On-DemandPay-as-you-go — highest rate, no commitment
Savings Plan (Compute)1 or 3-year hourly spend commitment — up to 66% savings, flexible across instance types
Reserved Instance (RI)1 or 3-year instance commitment — up to 72% savings, specific instance type
Blended CostAverage cost across on-demand and committed spend

Complete Script

import boto3
from datetime import datetime, timedelta
import json


def analyze_and_recommend_savings(lookback_days: int = 30) -> dict:
    """
    Analyses EC2 on-demand costs and surfaces AWS-generated
    Savings Plan and Reserved Instance recommendations.

    Cost Explorer is a GLOBAL service — the client always uses
    region "us-east-1" regardless of where your resources are.

    TimePeriod dates must be ISO 8601 format: "YYYY-MM-DD".
    End date is exclusive (same as Python range() semantics).
    """
    # Cost Explorer is only available in us-east-1
    ce = boto3.client("ce", region_name="us-east-1")

    end   = datetime.utcnow().strftime("%Y-%m-%d")
    start = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")

    print(f"Analysing EC2 costs from {start} to {end}...\n")

    # ── Step 1: Current on-demand EC2 spend by instance type ──────
    # get_cost_and_usage() is the primary Cost Explorer API.
    #
    # Filter: We want only EC2 compute on-demand charges.
    # The "And" operator requires ALL nested conditions to match.
    # SERVICE dimension filters by the AWS service name.
    # PURCHASE_TYPE dimension filters by pricing model.
    #
    # Metrics: "BlendedCost" is the dollar amount.
    #          "UsageQuantity" is hours of usage.
    #
    # GroupBy: Breaks the cost by INSTANCE_TYPE dimension so we can
    # see which instance types are driving the spend.
    cost_response = ce.get_cost_and_usage(
        TimePeriod={"Start": start, "End": end},
        Granularity="MONTHLY",
        Filter={
            "And": [
                {
                    "Dimensions": {
                        "Key":    "SERVICE",
                        "Values": ["Amazon Elastic Compute Cloud - Compute"],
                    }
                },
                {
                    "Dimensions": {
                        "Key":    "PURCHASE_TYPE",
                        "Values": ["On-Demand"],
                    }
                },
            ]
        },
        Metrics=["BlendedCost", "UsageQuantity"],
        GroupBy=[{"Type": "DIMENSION", "Key": "INSTANCE_TYPE"}],
    )

    # Aggregate costs across months (in case lookback spans multiple months)
    instance_costs: dict[str, float] = {}
    for result in cost_response["ResultsByTime"]:
        for group in result["Groups"]:
            instance_type = group["Keys"][0]
            cost = float(group["Metrics"]["BlendedCost"]["Amount"])
            instance_costs[instance_type] = instance_costs.get(instance_type, 0) + cost

    # ── Step 2: Savings Plans recommendation ──────────────────────
    # get_savings_plans_purchase_recommendation() calls AWS's recommendation
    # engine which analyses your historical usage and computes the optimal
    # hourly commitment to maximise savings.
    #
    # SavingsPlansType options:
    #   "COMPUTE_SP" — most flexible, applies to any EC2, Lambda, Fargate
    #   "EC2_INSTANCE_SP" — higher savings, locked to a region/instance family
    #
    # TermInYears: "ONE_YEAR" or "THREE_YEARS"
    # PaymentOption: "NO_UPFRONT", "PARTIAL_UPFRONT", "ALL_UPFRONT"
    sp_response = ce.get_savings_plans_purchase_recommendation(
        SavingsPlansType="COMPUTE_SP",
        TermInYears="ONE_YEAR",
        PaymentOption="NO_UPFRONT",
        LookbackPeriodInDays="THIRTY_DAYS",
    )
    sp_summary = sp_response.get(
        "SavingsPlansPurchaseRecommendation", {}
    ).get("SavingsPlansPurchaseRecommendationSummary", {})

    # ── Step 3: Reserved Instance recommendation ──────────────────
    # get_reservation_purchase_recommendation() gives per-instance-type
    # RI recommendations — more specific than Savings Plans but higher savings.
    ri_response = ce.get_reservation_purchase_recommendation(
        Service="Amazon EC2",
        TermInYears="ONE_YEAR",
        PaymentOption="NO_UPFRONT",
        LookbackPeriodInDays="THIRTY_DAYS",
    )
    ri_recommendations = ri_response.get("Recommendations", [])

    # ── Print report ──────────────────────────────────────────────
    total_cost = sum(instance_costs.values())

    print("=" * 60)
    print("EC2 ON-DEMAND SPEND BY INSTANCE TYPE (Last 30 Days)")
    print("=" * 60)
    for itype, cost in sorted(instance_costs.items(), key=lambda x: -x[1]):
        bar = "█" * min(int(cost / total_cost * 30), 30)
        print(f"  {itype:<20} ${cost:>10.2f}  {bar}")
    print(f"\n  {'TOTAL ON-DEMAND':<20} ${total_cost:>10.2f}")

    print("\n" + "=" * 60)
    print("SAVINGS PLAN RECOMMENDATION (Compute SP, 1-Year, No Upfront)")
    print("=" * 60)
    if sp_summary:
        monthly_savings  = float(sp_summary.get("EstimatedMonthlySavingsAmount", 0))
        savings_pct      = sp_summary.get("EstimatedSavingsPercentage", "N/A")
        hourly_commit    = float(sp_summary.get("HourlyCommitmentToPurchase", 0))
        current_spend    = float(sp_summary.get("CurrentOnDemandSpend", 0))

        print(f"  Current On-Demand Spend:   ${current_spend:>10,.2f}")
        print(f"  Recommended Hourly Commit: ${hourly_commit:>10.4f}/hr")
        print(f"  Estimated Monthly Savings: ${monthly_savings:>10,.2f}")
        print(f"  Savings Percentage:        {savings_pct}%")
        print(f"  Annual Savings Estimate:   ${monthly_savings * 12:>10,.2f}")
    else:
        print("  No Savings Plan recommendation available (need 30+ days of data)")

    print("\n" + "=" * 60)
    print("RESERVED INSTANCE RECOMMENDATIONS (Top 5, 1-Year, No Upfront)")
    print("=" * 60)
    for i, rec in enumerate(ri_recommendations[:5], 1):
        details = rec.get("RecommendationDetails", [{}])[0]
        spec    = details.get("InstanceDetails", {}).get("EC2InstanceDetails", {})

        print(f"\n  [{i}] {spec.get('InstanceType', 'N/A')} in {spec.get('Region', 'N/A')}")
        print(f"      Platform:     {spec.get('Platform', 'Linux')}")
        print(f"      Recommended:  {details.get('RecommendedNumberOfInstancesToPurchase', 'N/A')} instances")
        print(f"      Monthly Save: ${float(details.get('EstimatedMonthlySavings', 0)):,.2f}")
        print(f"      Upfront Cost: ${float(details.get('UpfrontCost', 0)):,.2f}")

    result = {
        "period":              {"start": start, "end": end},
        "total_on_demand_usd": round(total_cost, 2),
        "instance_breakdown":  {k: round(v, 2) for k, v in instance_costs.items()},
        "savings_plan": {
            "monthly_savings_usd": round(float(sp_summary.get("EstimatedMonthlySavingsAmount", 0)), 2),
            "savings_pct":         sp_summary.get("EstimatedSavingsPercentage", "N/A"),
            "hourly_commitment":   round(float(sp_summary.get("HourlyCommitmentToPurchase", 0)), 4),
        } if sp_summary else {},
    }

    # Save JSON report
    with open("cost_report.json", "w") as f:
        json.dump(result, f, indent=2)
    print("\nFull report saved to cost_report.json")

    return result


if __name__ == "__main__":
    analyze_and_recommend_savings(lookback_days=30)

Key Commands Explained

CommandWhat it does
ce.get_cost_and_usage(TimePeriod, Granularity, Filter, Metrics, GroupBy)Returns cost data broken down by any dimension
Granularity="MONTHLY"Aggregates data per calendar month
Filter["And"]Both conditions must match (service = EC2 AND purchase type = on-demand)
GroupBy=[{"Type": "DIMENSION", "Key": "INSTANCE_TYPE"}]Breaks results by instance type
group["Metrics"]["BlendedCost"]["Amount"]Cost as a string — cast to float()
get_savings_plans_purchase_recommendation(SavingsPlansType, TermInYears, PaymentOption)AWS-generated SP recommendation
get_reservation_purchase_recommendation(Service, TermInYears, PaymentOption)AWS-generated RI recommendation
LookbackPeriodInDays="THIRTY_DAYS"How much history the recommendation engine uses

Common Issues

DataUnavailableException — Cost Explorer needs at least 14 days of usage data before it can generate recommendations. Enable Cost Explorer in the billing console and wait.

Empty recommendations — On-demand spend must be significant enough for AWS to recommend commitments. Very small accounts may not get RI recommendations.

Region must be us-east-1 — Cost Explorer API is only available in us-east-1 regardless of where your resources are deployed.


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK — needed for the Cost Explorer client
from datetime import datetime, timedeltadatetime.utcnow() for the current date, timedelta(days=30) to compute the lookback start date
import jsonSaves the final report as a JSON file

Cost Explorer Client

ce = boto3.client("ce", region_name="us-east-1")
LineExplanation
boto3.client("ce", ...)"ce" is the service name for AWS Cost Explorer
region_name="us-east-1"Cost Explorer is a global service but the API endpoint only exists in us-east-1. You MUST hardcode this, regardless of which region your resources are in

Date Setup

end   = datetime.utcnow().strftime("%Y-%m-%d")
start = (datetime.utcnow() - timedelta(days=lookback_days)).strftime("%Y-%m-%d")
LineExplanation
datetime.utcnow()Current UTC date as a naive datetime
.strftime("%Y-%m-%d")Formats as "2025-01-20" — the ISO 8601 format required by Cost Explorer. The API rejects other formats
timedelta(days=lookback_days)Subtracting 30 days from today gives the start of the analysis window
End date is exclusiveLike Python’s range(), the end date is NOT included. To see data through Jan 20, set end to Jan 21

get_cost_and_usage(...)

cost_response = ce.get_cost_and_usage(
    TimePeriod={"Start": start, "End": end},
    Granularity="MONTHLY",
    Filter={
        "And": [
            {"Dimensions": {"Key": "SERVICE",       "Values": ["Amazon Elastic Compute Cloud - Compute"]}},
            {"Dimensions": {"Key": "PURCHASE_TYPE",  "Values": ["On-Demand"]}},
        ]
    },
    Metrics=["BlendedCost", "UsageQuantity"],
    GroupBy=[{"Type": "DIMENSION", "Key": "INSTANCE_TYPE"}],
)
ParameterExplanation
TimePeriodDate range. Both Start and End are in "YYYY-MM-DD" format
Granularity="MONTHLY"Aggregates data by calendar month. Other options: "DAILY" or "HOURLY" (hourly only available for last 14 days)
Filter["And"]Logical AND — all nested conditions must match. Here: service must be EC2 AND purchase type must be On-Demand
"Key": "SERVICE"Filter by AWS service name. "Amazon Elastic Compute Cloud - Compute" is the exact service name for EC2 instances
"Key": "PURCHASE_TYPE"Filter to "On-Demand" only — excludes Savings Plan and Reserved Instance charges
Metrics=["BlendedCost", "UsageQuantity"]Which numbers to return. BlendedCost = dollar amount. UsageQuantity = hours of usage. You can request multiple metrics
GroupBy=[{"Type": "DIMENSION", "Key": "INSTANCE_TYPE"}]Splits the result by instance type (e.g., t3.medium, c5.xlarge). Without this, you’d get one total number
for result in cost_response["ResultsByTime"]:
    for group in result["Groups"]:
        instance_type = group["Keys"][0]
        cost = float(group["Metrics"]["BlendedCost"]["Amount"])
        instance_costs[instance_type] = instance_costs.get(instance_type, 0) + cost
LineExplanation
cost_response["ResultsByTime"]List of one dict per time period (one per month when Granularity=MONTHLY)
result["Groups"]List of groups — one per instance type for this month
group["Keys"][0]The first key in the GroupBy key list. Since we group by INSTANCE_TYPE, Keys[0] is the instance type string (e.g., "t3.medium")
group["Metrics"]["BlendedCost"]["Amount"]The cost as a string (e.g., "123.456789"). Must be cast to float() for arithmetic
instance_costs.get(instance_type, 0) + costAccumulates costs across multiple months. .get(key, 0) returns 0 if the key doesn’t exist yet

Savings Plan Recommendation

sp_response = ce.get_savings_plans_purchase_recommendation(
    SavingsPlansType="COMPUTE_SP",
    TermInYears="ONE_YEAR",
    PaymentOption="NO_UPFRONT",
    LookbackPeriodInDays="THIRTY_DAYS",
)
ParameterExplanation
SavingsPlansType="COMPUTE_SP"Compute Savings Plan — the most flexible type. Applies to EC2 (any instance type, any region), Lambda, and Fargate. Alternative: "EC2_INSTANCE_SP" (higher savings but less flexible)
TermInYears="ONE_YEAR"1-year commitment. "THREE_YEARS" offers higher discounts but more risk
PaymentOption="NO_UPFRONT"Pay monthly with no upfront cost. "ALL_UPFRONT" saves more but requires capital. "PARTIAL_UPFRONT" is the middle ground
LookbackPeriodInDays="THIRTY_DAYS"How much historical usage data the recommendation engine analyses. "SEVEN_DAYS" and "SIXTY_DAYS" are also valid
sp_summary = sp_response.get(
    "SavingsPlansPurchaseRecommendation", {}
).get("SavingsPlansPurchaseRecommendationSummary", {})
LineExplanation
.get("SavingsPlansPurchaseRecommendation", {})Returns an empty dict if no recommendation exists (insufficient data). Using .get() with a default prevents KeyError
.get("SavingsPlansPurchaseRecommendationSummary", {})The nested summary dict containing EstimatedMonthlySavingsAmount, HourlyCommitmentToPurchase, etc.

Report Printing

for itype, cost in sorted(instance_costs.items(), key=lambda x: -x[1]):
    bar = "█" * min(int(cost / total_cost * 30), 30)
    print(f"  {itype:<20} ${cost:>10.2f}  {bar}")
LineExplanation
sorted(..., key=lambda x: -x[1])Sorts by cost descending (highest spend first). x[1] is the cost value; negating it reverses the sort order
cost / total_cost * 30Computes the proportional bar length: 30 characters = 100% of total cost
min(..., 30)Caps the bar at 30 characters to prevent line overflow
int(...)"█" * 12.7 is invalid — we need an integer
{itype:<20}Left-aligned string padded to 20 characters. Makes columns line up
{cost:>10.2f}Right-aligned float with 2 decimal places, padded to 10 chars

Reserved Instance Recommendation

ri_response = ce.get_reservation_purchase_recommendation(
    Service="Amazon EC2",
    TermInYears="ONE_YEAR",
    PaymentOption="NO_UPFRONT",
    LookbackPeriodInDays="THIRTY_DAYS",
)
ParameterExplanation
Service="Amazon EC2"Which service’s RIs to recommend. Other values: "Amazon RDS", "Amazon ElastiCache"
Note the differenceRI recommendations are per specific instance type in a specific region. SP recommendations are for a flexible hourly spend commitment
ri_response["Recommendations"]List of RI purchase recommendations, each with details about instance type, count, and savings

Security Group Audit — Find Unrestricted Inbound Rules (0.0.0.0/0)

Problem Statement

A developer accidentally added 0.0.0.0/0 to a Security Group to “quickly test” SSH access. They forgot to remove it. Three days later, the instance was cryptomining. This script catches that misconfiguration before it becomes a breach.


Risk Levels

SeverityPortsWhy dangerous
CRITICAL22 (SSH), 3389 (RDP), 3306 (MySQL), 5432 (PostgreSQL), 27017 (MongoDB), 6379 (Redis)Direct remote access to servers or databases
HIGH9200 (Elasticsearch), 8080 (HTTP-Alt), 80 (HTTP), 443 (HTTPS)May expose unencrypted or unauthenticated services

Complete Script

import boto3
import json
from botocore.exceptions import ClientError


# Ports that should NEVER be open to 0.0.0.0/0
SENSITIVE_PORTS = {
    22:    "SSH",
    3389:  "RDP",
    3306:  "MySQL",
    5432:  "PostgreSQL",
    27017: "MongoDB",
    6379:  "Redis",
    9200:  "Elasticsearch",
    8080:  "HTTP-Alt",
    443:   "HTTPS",
    80:    "HTTP",
}

# These ports are always CRITICAL — direct admin or database access
HIGH_RISK_PORTS = {22, 3389, 3306, 5432, 27017, 6379}


def audit_security_groups(
    region: str = "us-east-1",
    auto_remediate: bool = False,
) -> list[dict]:
    """
    Iterates all Security Groups in the region and checks each
    inbound rule (IpPermissions) for unrestricted CIDR ranges.

    IpPermissions structure:
    [
      {
        "IpProtocol": "tcp",
        "FromPort": 22,
        "ToPort": 22,
        "IpRanges": [{"CidrIp": "0.0.0.0/0", "Description": ""}],
        "Ipv6Ranges": [{"CidrIpv6": "::/0"}],
        "UserIdGroupPairs": [],   # Cross-account SG references
        "PrefixListIds": []
      },
      ...
    ]

    IpProtocol = "-1" means ALL traffic (any protocol, any port).
    """
    ec2 = boto3.client("ec2", region_name=region)
    findings: list[dict] = []

    paginator = ec2.get_paginator("describe_security_groups")
    for page in paginator.paginate():
        for sg in page["SecurityGroups"]:
            sg_id   = sg["GroupId"]
            sg_name = sg["GroupName"]
            vpc_id  = sg.get("VpcId", "EC2-Classic")

            # ── Check each inbound rule ────────────────────────────
            for rule in sg.get("IpPermissions", []):
                from_port = rule.get("FromPort", 0)
                to_port   = rule.get("ToPort",   65535)
                protocol  = rule.get("IpProtocol", "-1")

                # Collect all unrestricted CIDR sources (IPv4 + IPv6)
                unrestricted_cidrs = []
                for cidr_range in rule.get("IpRanges", []):
                    if cidr_range.get("CidrIp") == "0.0.0.0/0":
                        unrestricted_cidrs.append("0.0.0.0/0")
                for ipv6_range in rule.get("Ipv6Ranges", []):
                    if ipv6_range.get("CidrIpv6") == "::/0":
                        unrestricted_cidrs.append("::/0")

                if not unrestricted_cidrs:
                    continue   # This rule is restricted to specific CIDRs — safe

                # ── Check each sensitive port against this rule ────
                for port, service in SENSITIVE_PORTS.items():
                    # protocol == "-1" means ALL traffic (matches every port)
                    # Otherwise, check if the port falls within [from_port, to_port]
                    port_exposed = (
                        protocol == "-1"
                        or (from_port <= port <= to_port)
                    )

                    if not port_exposed:
                        continue

                    severity = "CRITICAL" if port in HIGH_RISK_PORTS else "HIGH"

                    for cidr in unrestricted_cidrs:
                        finding = {
                            "sg_id":    sg_id,
                            "sg_name":  sg_name,
                            "vpc_id":   vpc_id,
                            "port":     port,
                            "service":  service,
                            "protocol": protocol,
                            "cidr":     cidr,
                            "severity": severity,
                        }
                        findings.append(finding)

                        icon = "🔴" if severity == "CRITICAL" else "🟠"
                        print(
                            f"{icon} [{severity}] {sg_id} ({sg_name}) in {vpc_id} "
                            f"allows {service} port {port} from {cidr}"
                        )

                        # ── Auto-remediate critical findings ───────
                        if auto_remediate and port in HIGH_RISK_PORTS:
                            revoke_rule(ec2, sg_id, rule, cidr)

    # ── Summary ────────────────────────────────────────────────────
    critical = [f for f in findings if f["severity"] == "CRITICAL"]
    high     = [f for f in findings if f["severity"] == "HIGH"]

    print(f"\n{'='*60}")
    print(f"Total findings:  {len(findings)}")
    print(f"Critical:        {len(critical)}")
    print(f"High:            {len(high)}")

    if critical:
        print("\nCRITICAL — Immediate action required:")
        seen = set()
        for f in critical:
            key = f"{f['sg_id']}:{f['port']}"
            if key not in seen:
                seen.add(key)
                print(f"  {f['sg_id']} ({f['sg_name']}) → {f['service']} from {f['cidr']}")

    return findings


def revoke_rule(ec2_client, sg_id: str, rule: dict, cidr: str) -> None:
    """
    revoke_security_group_ingress() removes specific CIDR ranges from a rule.

    We reconstruct the IpPermissions entry from the original rule dict,
    replacing IpRanges with ONLY the CIDR we want to remove.
    Other CIDRs in the same rule are untouched.

    This is non-destructive — if the rule allows both 0.0.0.0/0 and
    10.0.0.0/8, only 0.0.0.0/0 is revoked; the private range stays.
    """
    try:
        ip_perm = {
            "IpProtocol": rule["IpProtocol"],
            "IpRanges":   [{"CidrIp": cidr}] if cidr != "::/0" else [],
            "Ipv6Ranges":  [{"CidrIpv6": cidr}] if cidr == "::/0" else [],
        }
        # Only include port range if the protocol is not "-1" (all traffic)
        if rule.get("IpProtocol") != "-1":
            ip_perm["FromPort"] = rule.get("FromPort", 0)
            ip_perm["ToPort"]   = rule.get("ToPort",   65535)

        ec2_client.revoke_security_group_ingress(
            GroupId=sg_id,
            IpPermissions=[ip_perm],
        )
        print(f"    ✅ Revoked {cidr} from {sg_id}")
    except ClientError as e:
        print(f"    ❌ Failed to revoke {cidr} from {sg_id}: {e}")


def save_report(findings: list[dict], output: str = "sg_audit_report.json") -> None:
    with open(output, "w") as f:
        json.dump(findings, f, indent=2)
    print(f"\nFull report saved to {output}")


if __name__ == "__main__":
    # Audit-only mode first (auto_remediate=False)
    findings = audit_security_groups(region="ap-south-1", auto_remediate=False)
    save_report(findings)

    # To auto-fix critical findings, change to:
    # audit_security_groups(region="ap-south-1", auto_remediate=True)

Sample Output

🔴 [CRITICAL] sg-0abc123 (dev-server-sg) in vpc-0def456 allows SSH port 22 from 0.0.0.0/0
🟠 [HIGH] sg-0abc123 (dev-server-sg) in vpc-0def456 allows HTTP-Alt port 8080 from 0.0.0.0/0
🔴 [CRITICAL] sg-0ghi789 (staging-db-sg) in vpc-0def456 allows MySQL port 3306 from 0.0.0.0/0

============================================================
Total findings:  3
Critical:        2
High:            1

CRITICAL — Immediate action required:
  sg-0abc123 (dev-server-sg) → SSH from 0.0.0.0/0
  sg-0ghi789 (staging-db-sg) → MySQL from 0.0.0.0/0

Key Commands Explained

CommandWhat it does
get_paginator("describe_security_groups")Paginates all SGs — important if you have 100+ SGs
sg["IpPermissions"]List of inbound rules for this Security Group
rule["IpRanges"]List of IPv4 CIDR ranges for this rule
rule.get("IpProtocol") == "-1"Matches rules that allow ALL protocols/ports
from_port <= port <= to_portChecks if a sensitive port falls within the rule’s range
revoke_security_group_ingress(GroupId, IpPermissions)Removes specific CIDR from a rule without deleting other CIDRs

Prevention: Use AWS Config Rules

# Enable the AWS managed Config rule that detects this automatically
config = boto3.client("config")
config.put_config_rule(
    ConfigRule={
        "ConfigRuleName": "restricted-ssh",
        "Source": {
            "Owner": "AWS",
            "SourceIdentifier": "INCOMING_SSH_DISABLED",
        },
    }
)

🔍 Line-by-Line Code Walkthrough

Module-Level Constants

SENSITIVE_PORTS = {22: "SSH", 3389: "RDP", 3306: "MySQL", ...}
HIGH_RISK_PORTS = {22, 3389, 3306, 5432, 27017, 6379}
LineExplanation
SENSITIVE_PORTS = {port: service_name}A dict mapping port numbers to human-readable service names. Used to identify which service is exposed when checking rules
HIGH_RISK_PORTS = {22, 3389, ...}A Python set (fast O(1) membership tests) of ports that indicate direct administrative or database access — always classified CRITICAL
Why separate them?SENSITIVE_PORTS is for detection (all ports to check). HIGH_RISK_PORTS is for severity classification (CRITICAL vs HIGH)

audit_security_groups(region, auto_remediate)

ec2 = boto3.client("ec2", region_name=region)
paginator = ec2.get_paginator("describe_security_groups")
for page in paginator.paginate():
    for sg in page["SecurityGroups"]:
LineExplanation
get_paginator("describe_security_groups")Handles pagination. Large accounts can have hundreds of Security Groups
paginate() with no FiltersReturns ALL security groups. We check every one
sg["GroupId"]The SG identifier (e.g., sg-0abc123)
sg["GroupName"]Human-readable name (e.g., "dev-server-sg")
sg.get("VpcId", "EC2-Classic")The VPC this SG belongs to. .get() with default handles the rare case of EC2-Classic (no VpcId)

Parsing IpPermissions

for rule in sg.get("IpPermissions", []):
    from_port = rule.get("FromPort", 0)
    to_port   = rule.get("ToPort",   65535)
    protocol  = rule.get("IpProtocol", "-1")
LineExplanation
sg.get("IpPermissions", [])The list of inbound rules. Empty list if no inbound rules
rule.get("FromPort", 0)Start of the port range. .get() with default 0 because when IpProtocol="-1" (all traffic), AWS does not include FromPort/ToPort in the response
rule.get("ToPort", 65535)End of the port range. Default 65535 (max port) means we’ll catch all ports when protocol is "-1"
rule.get("IpProtocol", "-1")The protocol: "tcp", "udp", "icmp", or "-1" (all protocols)

Finding Unrestricted CIDRs

unrestricted_cidrs = []
for cidr_range in rule.get("IpRanges", []):
    if cidr_range.get("CidrIp") == "0.0.0.0/0":
        unrestricted_cidrs.append("0.0.0.0/0")
for ipv6_range in rule.get("Ipv6Ranges", []):
    if ipv6_range.get("CidrIpv6") == "::/0":
        unrestricted_cidrs.append("::/0")
LineExplanation
rule.get("IpRanges", [])IPv4 CIDR ranges for this rule. Each is a dict with "CidrIp" key
cidr_range.get("CidrIp") == "0.0.0.0/0"0.0.0.0/0 means “any IPv4 address” — the entire internet
rule.get("Ipv6Ranges", [])IPv6 ranges. Each has "CidrIpv6" key
"::/0"IPv6 equivalent of 0.0.0.0/0 — any IPv6 address. Don’t forget IPv6! Many audits miss this
if not unrestricted_cidrs: continueIf the rule doesn’t have any unrestricted CIDRs, it’s fine — skip to the next rule

Port Exposure Check

for port, service in SENSITIVE_PORTS.items():
    port_exposed = (
        protocol == "-1"
        or (from_port <= port <= to_port)
    )
    if not port_exposed:
        continue
    severity = "CRITICAL" if port in HIGH_RISK_PORTS else "HIGH"
LineExplanation
protocol == "-1"Matches rules that allow ALL traffic (e.g., a rule with protocol -1 exposes every port)
from_port <= port <= to_portPython chained comparison. Checks if our sensitive port falls within the rule’s range (e.g., port 22 within range 0-65535)
"CRITICAL" if port in HIGH_RISK_PORTS else "HIGH"in HIGH_RISK_PORTS is O(1) set lookup. CRITICAL for admin ports, HIGH for others

revoke_rule(ec2_client, sg_id, rule, cidr)

ip_perm = {
    "IpProtocol": rule["IpProtocol"],
    "IpRanges":   [{"CidrIp": cidr}] if cidr != "::/0" else [],
    "Ipv6Ranges":  [{"CidrIpv6": cidr}] if cidr == "::/0" else [],
}
if rule.get("IpProtocol") != "-1":
    ip_perm["FromPort"] = rule.get("FromPort", 0)
    ip_perm["ToPort"]   = rule.get("ToPort",   65535)

ec2_client.revoke_security_group_ingress(
    GroupId=sg_id,
    IpPermissions=[ip_perm],
)
LineExplanation
"IpRanges": [{"CidrIp": cidr}] if cidr != "::/0" else []Conditionally populates IPv4 ranges. "::/0" is an IPv6 address — not valid in IpRanges
"Ipv6Ranges": [{"CidrIpv6": cidr}] if cidr == "::/0" else []Conditionally populates IPv6 ranges only for the "::/0" CIDR
if rule.get("IpProtocol") != "-1": add FromPort/ToPortWhen protocol is "-1" (all traffic), AWS doesn’t use port ranges and rejects them if included. We only add ports for tcp/udp rules
revoke_security_group_ingress(GroupId, IpPermissions)Removes the specific CIDR from the rule. Only the specified CIDR is revoked — other CIDRs in the same rule are untouched
Why reconstruct ip_perm?The revoke API requires a precise match of the rule to remove. We build the minimum dict that matches just the CIDR we want to revoke

ECS Rolling Deployment with Automatic Rollback on Health Check Failure

Problem Statement

Your ECS deployment script just updates the service and walks away. When the new container fails to start (bad image, wrong env var, OOM), tasks stay in FAILED state until someone notices the monitoring dashboard 20 minutes later. This script monitors the deployment and rolls back automatically within seconds of detecting failure.


ECS Deployment Flow

Describe current task definition
         ↓
Register new task definition (with updated image)
         ↓
update_service() with new task definition + rolling config
         ↓
Poll service deployments every 30 seconds
         ↓
         ├── running == desired → SUCCESS
         ├── failedTasks > 0   → ROLLBACK to previous task definition
         └── timeout           → ROLLBACK

Complete Script

import boto3
import time
import sys
import logging

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


class ECSRollingDeployer:
    def __init__(self, cluster: str, service: str, region: str = "us-east-1"):
        """
        cluster: ECS cluster name or ARN (e.g. "prod-cluster")
        service: ECS service name (e.g. "payment-service")
        """
        self.ecs     = boto3.client("ecs", region_name=region)
        self.cluster = cluster
        self.service = service

    # ── Step 1: Get current task definition ──────────────────────
    def get_current_task_definition(self) -> str:
        """
        describe_services() returns the full service configuration.
        services[0]["taskDefinition"] is the ARN of the currently
        deployed task definition, e.g.:
          arn:aws:ecs:us-east-1:123456789012:task-definition/payment:8
        """
        response = self.ecs.describe_services(
            cluster=self.cluster,
            services=[self.service],
        )
        if not response["services"]:
            raise ValueError(f"Service not found: {self.service} in {self.cluster}")
        return response["services"][0]["taskDefinition"]

    # ── Step 2: Register new task definition ─────────────────────
    def create_new_task_definition(self, current_td_arn: str, new_image: str) -> str:
        """
        ECS task definitions are immutable — you can't edit them.
        To deploy a new image, you must register a NEW task definition revision
        with the updated container image.

        describe_task_definition() returns the full spec of an existing revision.
        We then strip the read-only fields AWS adds (taskDefinitionArn, revision,
        status, etc.) and register a new revision with the updated image.

        containerDefinitions[0]["image"] is the Docker image URI.
        We update only the first container — adjust the index if your task
        has multiple containers and you want to update a specific one.
        """
        td = self.ecs.describe_task_definition(
            taskDefinition=current_td_arn
        )["taskDefinition"]

        # Update the image in container 0 (main application container)
        containers = td["containerDefinitions"]
        old_image = containers[0]["image"]
        containers[0]["image"] = new_image
        logger.info(f"Image: {old_image} → {new_image}")

        # Strip fields that are set by AWS and cannot be provided on register
        readonly_fields = [
            "taskDefinitionArn", "revision", "status",
            "requiresAttributes", "compatibilities",
            "registeredAt", "registeredBy",
        ]
        for field in readonly_fields:
            td.pop(field, None)

        response = self.ecs.register_task_definition(**td)
        new_td_arn = response["taskDefinition"]["taskDefinitionArn"]
        logger.info(f"Registered new task definition: {new_td_arn}")
        return new_td_arn

    # ── Step 3: Update service ────────────────────────────────────
    def trigger_deployment(self, new_td_arn: str) -> None:
        """
        update_service() tells ECS to replace running tasks with new ones
        using the specified task definition.

        deploymentConfiguration controls the rolling update strategy:
        - minimumHealthyPercent=100: Never go below 100% capacity
          (ensures zero downtime — requires extra capacity)
        - maximumPercent=200: Allow up to 2× the desired count during deploy
          (starts new tasks before stopping old ones — blue/green-like)

        forceNewDeployment=True is needed if the task definition ARN
        hasn't changed (same tag like "latest") — forces ECS to pull
        the latest image even if the ARN is the same.
        """
        self.ecs.update_service(
            cluster=self.cluster,
            service=self.service,
            taskDefinition=new_td_arn,
            deploymentConfiguration={
                "minimumHealthyPercent": 100,
                "maximumPercent":        200,
            },
            forceNewDeployment=True,
        )

    # ── Step 4: Poll deployment health ───────────────────────────
    def get_deployment_status(self) -> str:
        """
        describe_services() returns a list of deployments for the service.
        There is always a PRIMARY deployment (the latest) and optionally
        ACTIVE deployments (older tasks being drained).

        Primary deployment fields:
        - desiredCount:  how many tasks ECS wants to run
        - runningCount:  how many are actually running
        - failedTasks:   how many task launches have failed

        Once runningCount == desiredCount and failedTasks == 0,
        the deployment is complete.
        """
        response = self.ecs.describe_services(
            cluster=self.cluster,
            services=[self.service],
        )
        deployments = response["services"][0]["deployments"]

        # Find the PRIMARY (most recent) deployment
        primary = next(
            (d for d in deployments if d["status"] == "PRIMARY"),
            None,
        )
        if not primary:
            return "UNKNOWN"

        desired = primary["desiredCount"]
        running = primary["runningCount"]
        failed  = primary["failedTasks"]

        if failed > 0:
            logger.error(f"Deployment failure: {failed} failed task(s)")
            return "FAILED"

        if running == desired and desired > 0:
            return "HEALTHY"

        return f"IN_PROGRESS ({running}/{desired} tasks running)"

    # ── Step 5: Roll back ─────────────────────────────────────────
    def rollback(self, previous_td_arn: str) -> None:
        """
        Rollback by calling update_service() with the PREVIOUS task definition.
        ECS will drain the failing new tasks and start tasks from the old definition.
        This is the same API call as deployment — ECS handles the swap gracefully.
        """
        logger.warning(f"Rolling back to: {previous_td_arn}")
        self.ecs.update_service(
            cluster=self.cluster,
            service=self.service,
            taskDefinition=previous_td_arn,
        )
        logger.info("Rollback triggered. Previous version will restore.")

    # ── Orchestrator ──────────────────────────────────────────────
    def deploy(
        self,
        new_image: str,
        health_check_retries: int = 15,
        health_check_delay: int   = 30,
    ) -> bool:
        """
        Full deployment lifecycle:
        1. Capture current state for rollback
        2. Register new task definition
        3. Trigger rolling update
        4. Poll health status
        5. Rollback if failed or timed out

        Returns True if deployment succeeded, False if rolled back.
        """
        logger.info(f"Starting deployment of: {new_image}")

        # Save the current (stable) task definition for rollback
        current_td = self.get_current_task_definition()
        logger.info(f"Current task definition (rollback target): {current_td}")

        # Create and deploy new task definition
        new_td = self.create_new_task_definition(current_td, new_image)
        self.trigger_deployment(new_td)
        logger.info(f"Deployment triggered. Monitoring health ({health_check_retries} checks × {health_check_delay}s)...")

        # Monitor deployment
        for attempt in range(1, health_check_retries + 1):
            time.sleep(health_check_delay)
            status = self.get_deployment_status()
            logger.info(f"[{attempt}/{health_check_retries}] Status: {status}")

            if status == "HEALTHY":
                logger.info(f"✅ Deployment succeeded!")
                return True

            if status == "FAILED":
                logger.error("❌ Deployment failed! Rolling back...")
                self.rollback(current_td)
                return False

        # Timeout — roll back
        logger.error("⏰ Health check timed out. Rolling back...")
        self.rollback(current_td)
        return False


# ── Entry point ───────────────────────────────────────────────────
if __name__ == "__main__":
    deployer = ECSRollingDeployer(
        cluster="prod-cluster",
        service="payment-service",
        region="ap-south-1",
    )

    success = deployer.deploy(
        new_image="123456789012.dkr.ecr.ap-south-1.amazonaws.com/payment:v2.4.1",
        health_check_retries=15,   # Wait up to 7.5 min (15 × 30s)
        health_check_delay=30,
    )

    # Exit with non-zero code on failure — triggers CI/CD pipeline failure
    sys.exit(0 if success else 1)

Key Commands Explained

CommandWhat it does
describe_services(cluster, services)Returns full service state including all deployments
describe_task_definition(taskDefinition=arn)Returns the full spec of a task definition revision
register_task_definition(**td)Creates a new immutable revision with the updated spec
update_service(cluster, service, taskDefinition, deploymentConfiguration)Triggers a rolling update to the new task definition
minimumHealthyPercent=100Never drop below 100% capacity during deployment
maximumPercent=200Allow up to 200% tasks during deployment (old + new)
forceNewDeployment=TrueForces a re-pull even if the task definition ARN didn’t change
deployment["failedTasks"]Number of task launch failures — non-zero means rollback
deployment["runningCount"] == deployment["desiredCount"]All desired tasks are healthy

Common Issues

Deployment stuck in IN_PROGRESS — The new task is failing to start. Check ecs describe-tasks for stopped tasks, then aws logs get-log-events for the container log output.

SERVICE_NOT_ACTIVE error — The ECS service itself is in a bad state (e.g., being deleted). Check the ECS console for service events.

Rolling update causes downtime — If minimumHealthyPercent=50, ECS stops 50% of tasks before starting new ones. Use minimumHealthyPercent=100 + maximumPercent=200 for zero-downtime deployments (requires capacity headroom).


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK — needed for ECS client calls
import timetime.sleep(30) — pauses between health status polls
import syssys.exit(0 or 1) — returns appropriate exit code for CI/CD pipelines
import loggingStructured log output

ECSRollingDeployer.__init__

self.ecs     = boto3.client("ecs", region_name=region)
self.cluster = cluster
self.service = service
LineExplanation
boto3.client("ecs", region_name=region)ECS client for the target region. ECS clusters are regional
self.clusterThe ECS cluster name or ARN. All API calls require this to identify which cluster
self.serviceThe ECS service name (e.g., "payment-service"). A cluster can have many services

get_current_task_definition()

response = self.ecs.describe_services(
    cluster=self.cluster,
    services=[self.service],
)
return response["services"][0]["taskDefinition"]
LineExplanation
describe_services(cluster=..., services=[...])Returns the full service configuration. services is a list — you can request up to 10 services at once
response["services"][0]The first (and only) service dict in the response
["taskDefinition"]The ARN of the currently deployed task definition, e.g., arn:aws:ecs:...:task-definition/payment:8. The :8 is the revision number
if not response["services"]: raise ValueErrorGuard for the case where the service doesn’t exist — gives a clear error message

create_new_task_definition(current_td_arn, new_image)

td = self.ecs.describe_task_definition(
    taskDefinition=current_td_arn
)["taskDefinition"]
LineExplanation
describe_task_definition(taskDefinition=current_td_arn)Fetches the full spec of an existing revision. This spec is the template for our new revision
["taskDefinition"]The task definition dict containing containerDefinitions, cpu, memory, networkMode, executionRoleArn, etc.
containers = td["containerDefinitions"]
old_image = containers[0]["image"]
containers[0]["image"] = new_image
LineExplanation
td["containerDefinitions"]List of container specs — one dict per container in the task
containers[0]The first container (main application container). If your task has a sidecar, you’d target a different index
containers[0]["image"] = new_imageReplaces the Docker image URI. E.g., changes payment:v2.4.0 to payment:v2.4.1
readonly_fields = [
    "taskDefinitionArn", "revision", "status",
    "requiresAttributes", "compatibilities",
    "registeredAt", "registeredBy",
]
for field in readonly_fields:
    td.pop(field, None)
LineExplanation
readonly_fieldsFields that AWS populates automatically. If you include them in register_task_definition(), the API raises ClientException: Unknown parameter
td.pop(field, None)Removes the field from the dict if it exists. .pop(key, None) returns None instead of raising KeyError if the key is absent
response = self.ecs.register_task_definition(**td)
new_td_arn = response["taskDefinition"]["taskDefinitionArn"]
LineExplanation
register_task_definition(**td)**td unpacks the dict as keyword arguments. This passes all remaining fields (containerDefinitions, cpu, memory, etc.) as named parameters
response["taskDefinition"]["taskDefinitionArn"]The ARN of the newly created revision (e.g., ...:task-definition/payment:9)

trigger_deployment(new_td_arn)

self.ecs.update_service(
    cluster=self.cluster,
    service=self.service,
    taskDefinition=new_td_arn,
    deploymentConfiguration={
        "minimumHealthyPercent": 100,
        "maximumPercent":        200,
    },
    forceNewDeployment=True,
)
ParameterExplanation
taskDefinition=new_td_arnTells ECS to run tasks using this new task definition revision
minimumHealthyPercent=100ECS must keep at least 100% of the desired count running at all times. New tasks start BEFORE old ones stop (requires enough cluster capacity for 2×)
maximumPercent=200ECS may run up to 200% of the desired count (old + new tasks) during the transition
forceNewDeployment=TrueRequired when the task definition ARN is the same (e.g., using the latest tag). Forces ECS to pull the newest image even if the ARN hasn’t changed

get_deployment_status()

deployments = response["services"][0]["deployments"]
primary = next(
    (d for d in deployments if d["status"] == "PRIMARY"),
    None,
)
LineExplanation
response["services"][0]["deployments"]List of deployment objects. During a rolling update, there are multiple: PRIMARY (new) and ACTIVE (old tasks being drained)
next((d for d in ... if d["status"] == "PRIMARY"), None)Finds the PRIMARY deployment (the most recent one). next() returns the first match, or None if not found
desired = primary["desiredCount"]
running = primary["runningCount"]
failed  = primary["failedTasks"]

if failed > 0:   return "FAILED"
if running == desired and desired > 0:   return "HEALTHY"
return f"IN_PROGRESS ({running}/{desired} tasks running)"
LineExplanation
primary["desiredCount"]Target number of tasks ECS wants running (from your service configuration)
primary["runningCount"]Currently running tasks in HEALTHY state
primary["failedTasks"]Count of task launches that failed since the deployment started. Non-zero = something is broken
running == desired and desired > 0Deployment is complete. desired > 0 handles the edge case of a service scaling to 0

rollback(previous_td_arn)

self.ecs.update_service(
    cluster=self.cluster,
    service=self.service,
    taskDefinition=previous_td_arn,
)
LineExplanation
update_service(taskDefinition=previous_td_arn)Rollback uses the exact same API call as deployment — just with the old task definition ARN instead of the new one
Why no deploymentConfiguration?During rollback, we accept the default behavior — get healthy as fast as possible. The default minimumHealthyPercent=100 still applies

deploy() — Orchestrator

current_td = self.get_current_task_definition()   # Save for rollback
new_td = self.create_new_task_definition(current_td, new_image)
self.trigger_deployment(new_td)

for attempt in range(1, health_check_retries + 1):
    time.sleep(health_check_delay)
    status = self.get_deployment_status()
    if status == "HEALTHY":   return True
    if status == "FAILED":
        self.rollback(current_td)
        return False

self.rollback(current_td)   # Timeout
return False
LineExplanation
current_td = self.get_current_task_definition()Captures the old task definition ARN BEFORE deploying. This is the rollback target
time.sleep(health_check_delay)Waits 30 seconds between polls. ECS typically takes 30–120 seconds to launch new tasks
self.rollback(current_td)Called on both "FAILED" status AND timeout. The old task definition is the safe state
sys.exit(0 if success else 1)Exit code 0 = CI/CD pipeline continues. Exit code 1 = pipeline marks the step as failed

CloudTrail Root Account Monitor — Alert on Root API Usage via Lambda

Problem Statement

The AWS root account bypasses all IAM policies and has unrestricted access to everything in the account — including billing, support plans, and account closure. Any API call made by root should be treated as a potential security incident (unless it’s a known, scheduled administrative task).


Architecture

CloudTrail (all API calls)
         ↓
EventBridge rule:
  event.detail.userIdentity.type = "Root"
         ↓
Lambda (this script)
         ↓
SNS → PagerDuty / Slack / Email

EventBridge Rule Setup

# Create EventBridge rule to detect root account usage
aws events put-rule \
  --name detect-root-account-usage \
  --event-pattern '{
    "source": ["aws.cloudtrail"],
    "detail-type": ["AWS API Call via CloudTrail"],
    "detail": {
      "userIdentity": {
        "type": ["Root"]
      }
    }
  }' \
  --state ENABLED

# Add Lambda as the target
aws events put-targets \
  --rule detect-root-account-usage \
  --targets "Id=RootMonitorLambda,Arn=arn:aws:lambda:us-east-1:123456789012:function:RootAccountMonitor"

Complete Lambda Script

import boto3
import json
import logging

logger = logging.getLogger()
logger.setLevel(logging.INFO)

# Known root account actions that are acceptable (e.g., automated billing scripts)
# Add your authorized IPs or event names here
AUTHORIZED_ROOT_EVENTS = {
    "GetBillingData",         # Billing automation
    "ListBillingReports",
}
AUTHORIZED_SOURCE_IPS = {
    # Add your corporate IPs here
    # "203.0.113.10",
}

SNS_TOPIC_ARN = "arn:aws:sns:us-east-1:123456789012:security-critical-alerts"


def lambda_handler(event: dict, context) -> dict:
    """
    EventBridge delivers events in this structure when triggered
    by a CloudTrail API call:

    event = {
      "version": "0",
      "id": "...",
      "source": "aws.cloudtrail",
      "detail-type": "AWS API Call via CloudTrail",
      "detail": {
        "eventVersion": "1.08",
        "userIdentity": {
          "type": "Root",
          "principalId": "123456789012",
          "arn": "arn:aws:iam::123456789012:root",
          "accountId": "123456789012"
        },
        "eventTime": "2025-01-20T14:30:00Z",
        "eventName": "ConsoleLogin",
        "eventSource": "signin.amazonaws.com",
        "sourceIPAddress": "203.0.113.5",
        "userAgent": "Mozilla/5.0...",
        "awsRegion": "us-east-1",
        "requestParameters": null,
        "responseElements": {"ConsoleLogin": "Success"}
      }
    }

    event["detail"] contains the raw CloudTrail event fields.
    """
    sns   = boto3.client("sns")
    detail = event.get("detail", {})

    # ── Extract key fields ─────────────────────────────────────────
    event_name   = detail.get("eventName",      "Unknown")
    event_source = detail.get("eventSource",    "Unknown")
    source_ip    = detail.get("sourceIPAddress","Unknown")
    user_agent   = detail.get("userAgent",      "Unknown")
    event_time   = detail.get("eventTime",      "Unknown")
    aws_region   = detail.get("awsRegion",      "Unknown")
    account_id   = detail.get("userIdentity", {}).get("accountId", "Unknown")

    # ── Filter: skip authorized root actions ──────────────────────
    if event_name in AUTHORIZED_ROOT_EVENTS:
        logger.info(f"Root API call {event_name} is in the authorized list — skipping alert")
        return {"statusCode": 200, "message": "Authorized root event — skipped"}

    if source_ip in AUTHORIZED_SOURCE_IPS:
        logger.info(f"Root API call from authorized IP {source_ip} — skipping alert")
        return {"statusCode": 200, "message": "Authorized IP — skipped"}

    # ── Log the incident ──────────────────────────────────────────
    # Using CRITICAL level ensures this appears in CloudWatch Logs
    # with severity that can trigger a separate metric filter alarm
    logger.critical(
        f"ROOT_ACCOUNT_USAGE | {event_name} | {source_ip} | {event_time} | {aws_region}"
    )

    # ── Build alert message ───────────────────────────────────────
    alert_message = f"""
🚨 ROOT ACCOUNT ACTIVITY DETECTED 🚨
======================================
Account ID:   {account_id}
Event:        {event_name}
Service:      {event_source}
Region:       {aws_region}
Time:         {event_time}
Source IP:    {source_ip}
User Agent:   {user_agent}

IMMEDIATE ACTIONS REQUIRED:
1. Verify if this action was authorized
2. If unauthorized — rotate root credentials NOW:
   a. Change root password
   b. Regenerate root MFA device
   c. Check for new IAM users/roles created by root
3. Review CloudTrail for all root activity in the last 24h:
   aws cloudtrail lookup-events \\
     --lookup-attributes AttributeKey=Username,AttributeValue=root \\
     --start-time $(date -d '24 hours ago' --utc +%Y-%m-%dT%H:%M:%SZ)
4. Escalate to Security team immediately

Full CloudTrail Event:
{json.dumps(detail, indent=2, default=str)}
    """

    # ── Publish to SNS ────────────────────────────────────────────
    # publish() sends the message to all SNS subscribers.
    # MessageAttributes let subscribers filter on severity.
    # Subject must be ≤ 100 characters for email delivery.
    #
    # MessageAttributes with DataType="String" allow SNS filter policies:
    # subscribers can opt to receive only CRITICAL messages.
    sns.publish(
        TopicArn=SNS_TOPIC_ARN,
        Subject=f"🚨 CRITICAL: Root Account Activity in {aws_region} — {event_name}",
        Message=alert_message,
        MessageAttributes={
            "severity": {
                "DataType":    "String",
                "StringValue": "CRITICAL",
            },
            "service": {
                "DataType":    "String",
                "StringValue": "root-monitor",
            },
            "event_name": {
                "DataType":    "String",
                "StringValue": event_name,
            },
        },
    )

    logger.info(f"Alert published to SNS for root event: {event_name} from {source_ip}")

    return {
        "statusCode": 200,
        "message":    "Root account alert published",
        "event_name": event_name,
        "source_ip":  source_ip,
    }

Automated Incident Response (Optional Extension)

def create_incident_snapshot(detail: dict) -> None:
    """
    When root activity is detected, automatically capture a snapshot of
    current IAM state for forensic analysis.
    """
    iam = boto3.client("iam")
    s3  = boto3.client("s3")

    # Get current IAM account summary
    summary = iam.get_account_summary()["SummaryMap"]

    # List recently created IAM users (last 30 minutes)
    from datetime import datetime, timezone, timedelta
    users = []
    paginator = iam.get_paginator("list_users")
    threshold = datetime.now(timezone.utc) - timedelta(minutes=30)
    for page in paginator.paginate():
        for user in page["Users"]:
            if user["CreateDate"] > threshold:
                users.append(user["UserName"])

    incident_data = {
        "timestamp":         detail.get("eventTime"),
        "event":             detail.get("eventName"),
        "source_ip":         detail.get("sourceIPAddress"),
        "recent_new_users":  users,
        "iam_summary":       {k: v for k, v in summary.items() if "Users" in k or "Roles" in k},
    }

    # Store forensic data in S3 for later analysis
    s3.put_object(
        Bucket="your-security-audit-bucket",
        Key=f"incidents/root-activity/{detail.get('eventTime', 'unknown')}.json",
        Body=json.dumps(incident_data, indent=2),
        ServerSideEncryption="aws:kms",
    )

Key Commands Explained

CommandWhat it does
event["detail"]The raw CloudTrail event delivered by EventBridge
detail["userIdentity"]["type"] == "Root"Confirms the API call was made by the root account
detail["eventName"]The specific API action performed (e.g., ConsoleLogin, CreateUser)
detail["sourceIPAddress"]IP that made the root API call
sns.publish(TopicArn, Subject, Message, MessageAttributes)Sends alert to all SNS subscribers
MessageAttributesKey-value metadata — allows SNS filter policies for routing
logger.critical(...)High-severity CloudWatch log — can trigger separate metric alarms

Testing the Monitor

# Simulate a CloudTrail root event in EventBridge (test mode)
aws lambda invoke \
  --function-name RootAccountMonitor \
  --payload '{
    "detail": {
      "eventName": "ConsoleLogin",
      "eventSource": "signin.amazonaws.com",
      "sourceIPAddress": "1.2.3.4",
      "userAgent": "Mozilla/5.0",
      "eventTime": "2025-01-20T14:30:00Z",
      "awsRegion": "us-east-1",
      "userIdentity": {"type": "Root", "accountId": "123456789012"}
    }
  }' \
  response.json

cat response.json

🔍 Line-by-Line Code Walkthrough

Module-Level Constants

AUTHORIZED_ROOT_EVENTS = {"GetBillingData", "ListBillingReports"}
AUTHORIZED_SOURCE_IPS  = set()  # e.g., {"203.0.113.10"}
SNS_TOPIC_ARN = "arn:aws:sns:us-east-1:123456789012:security-critical-alerts"
LineExplanation
AUTHORIZED_ROOT_EVENTSPython set for O(1) membership tests. Events in this set are known-safe root actions (e.g., automated billing scripts). We skip alerting for these
AUTHORIZED_SOURCE_IPSIPs of trusted corporate networks. Root activity from these IPs (e.g., your office) is expected
SNS_TOPIC_ARNThe SNS topic that routes to PagerDuty, Slack, and email. Stored at module level so it’s easy to find and change

lambda_handler(event, context) — Entry Point

sns    = boto3.client("sns")
detail = event.get("detail", {})
LineExplanation
boto3.client("sns")Lambda automatically uses the execution role’s credentials. No region_name needed — it defaults to the Lambda function’s region
event.get("detail", {})EventBridge wraps the CloudTrail event inside event["detail"]. Using .get() with {} default prevents KeyError if the event structure is unexpected

Extracting CloudTrail Event Fields

event_name   = detail.get("eventName",      "Unknown")
event_source = detail.get("eventSource",    "Unknown")
source_ip    = detail.get("sourceIPAddress","Unknown")
user_agent   = detail.get("userAgent",      "Unknown")
event_time   = detail.get("eventTime",      "Unknown")
aws_region   = detail.get("awsRegion",      "Unknown")
account_id   = detail.get("userIdentity", {}).get("accountId", "Unknown")
LineExplanation
detail.get("eventName", "Unknown")The API action called (e.g., "ConsoleLogin", "CreateUser", "PutRolePolicy"). Default "Unknown" prevents crashes on malformed events
detail.get("eventSource", "Unknown")Which AWS service received the call (e.g., "signin.amazonaws.com", "iam.amazonaws.com")
detail.get("sourceIPAddress", "Unknown")IP address of whoever made the root API call. "AWS Internal" appears for service-initiated actions
detail.get("userAgent", "Unknown")Browser or SDK info. "Mozilla/5.0" = console login. "aws-cli" = CLI. Helps identify the tool used
detail.get("eventTime", "Unknown")ISO 8601 timestamp when the API call happened
detail.get("userIdentity", {}).get("accountId", "Unknown")Chained .get() — first gets the userIdentity dict (or {} if absent), then gets accountId from it

Allowlist Filtering

if event_name in AUTHORIZED_ROOT_EVENTS:
    logger.info(f"Root API call {event_name} is in the authorized list — skipping alert")
    return {"statusCode": 200, "message": "Authorized root event — skipped"}

if source_ip in AUTHORIZED_SOURCE_IPS:
    logger.info(f"Root API call from authorized IP {source_ip} — skipping alert")
    return {"statusCode": 200, "message": "Authorized IP — skipped"}
LineExplanation
event_name in AUTHORIZED_ROOT_EVENTSSet membership test — O(1). Returns True if this event name is in the approved list
return {"statusCode": 200, ...}Early return — stops Lambda execution. No SNS publish happens. Lambda still returns a success status so EventBridge doesn’t retry
source_ip in AUTHORIZED_SOURCE_IPSChecks if the source IP is a trusted corporate IP. Prevents false alerts from expected admin tasks

Logging the Incident

logger.critical(
    f"ROOT_ACCOUNT_USAGE | {event_name} | {source_ip} | {event_time} | {aws_region}"
)
LineExplanation
logger.critical(...)CloudWatch Logs records this at CRITICAL severity. You can create a CloudWatch Metric Filter on the pattern ROOT_ACCOUNT_USAGE and trigger a separate alarm — this creates a second independent alerting path
Pipe-separated formatMakes the log line queryable with CloudWatch Logs Insights: `filter @message like “ROOT_ACCOUNT_USAGE”

sns.publish(...) — The Alert

sns.publish(
    TopicArn=SNS_TOPIC_ARN,
    Subject=f"🚨 CRITICAL: Root Account Activity in {aws_region} — {event_name}",
    Message=alert_message,
    MessageAttributes={
        "severity":   {"DataType": "String", "StringValue": "CRITICAL"},
        "service":    {"DataType": "String", "StringValue": "root-monitor"},
        "event_name": {"DataType": "String", "StringValue": event_name},
    },
)
ParameterExplanation
TopicArn=SNS_TOPIC_ARNThe SNS topic to publish to. All subscribers receive the message
Subject=...Email subject line (for email subscriptions). Maximum 100 characters. SNS truncates longer subjects
Message=alert_messageThe full alert body. For email subscribers, this is the email body
MessageAttributes={"severity": ...}Key-value metadata attached to the message. SNS subscribers can create filter policies to only receive messages where severity = "CRITICAL" — useful when multiple scripts publish to the same topic
"DataType": "String"The attribute type. Must be "String", "Number", or "Binary"
"StringValue": "CRITICAL"The attribute value

Return Value

return {
    "statusCode": 200,
    "message":    "Root account alert published",
    "event_name": event_name,
    "source_ip":  source_ip,
}
LineExplanation
return {...}Lambda return values are logged in CloudWatch and can be inspected. Returning structured data makes it easy to verify the alert was sent
"statusCode": 200Convention borrowed from HTTP. Tells EventBridge the invocation succeeded — it won’t retry

EventBridge Rule Pattern (Explained)

{
  "source": ["aws.cloudtrail"],
  "detail-type": ["AWS API Call via CloudTrail"],
  "detail": {
    "userIdentity": {
      "type": ["Root"]
    }
  }
}
FieldExplanation
"source": ["aws.cloudtrail"]Only CloudTrail events trigger this rule
"detail-type": ["AWS API Call via CloudTrail"]The event type for API calls. Console logins use "AWS Console Sign In via CloudTrail"
"detail.userIdentity.type": ["Root"]EventBridge filters the event payload. Only events where userIdentity.type equals "Root" match — Lambda is NOT invoked for regular IAM user/role actions

Trigger CodePipeline & Send Slack Notifications at Each Stage

Problem Statement

Your team deploys every day. Developers have to keep the CodePipeline console tab open to watch the deployment. When something fails at the “Deploy to Production” stage, they find out 30 minutes later by checking manually. This script sends a Slack message at every stage transition — instant visibility without console tab watching.


CodePipeline Stage Status Values

StatusMeaning
InProgressStage is currently running
SucceededStage completed successfully
FailedStage failed — pipeline stops here
StoppedManually stopped
SkippedStage was skipped

Complete Script

import boto3
import time
import json
import urllib.request
import urllib.error
import logging
from datetime import datetime

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


class CodePipelineMonitor:
    def __init__(
        self,
        pipeline_name: str,
        slack_webhook_url: str,
        region: str = "us-east-1",
    ):
        """
        pipeline_name:     Name of the CodePipeline to trigger and monitor.
        slack_webhook_url: Slack Incoming Webhook URL from your Slack app.
                           Format: https://hooks.slack.com/services/T.../B.../xxx
        """
        self.cp            = boto3.client("codepipeline", region_name=region)
        self.pipeline_name = pipeline_name
        self.slack_url     = slack_webhook_url
        self.region        = region

    # ── Step 1: Trigger the pipeline ─────────────────────────────
    def trigger_pipeline(self) -> str:
        """
        start_pipeline_execution() queues a new pipeline run.
        Returns a pipelineExecutionId (UUID string) that uniquely
        identifies this run — use it to query status later.

        If a run is already in progress, this starts a new parallel
        execution (CodePipeline supports concurrent executions by default).
        """
        response = self.cp.start_pipeline_execution(name=self.pipeline_name)
        execution_id = response["pipelineExecutionId"]

        logger.info(f"Pipeline triggered: {execution_id}")
        self.send_slack(
            message=(
                f"🚀 *Deployment Started*\n"
                f"Pipeline: `{self.pipeline_name}`\n"
                f"Execution ID: `{execution_id}`\n"
                f"Time: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}"
            ),
            color="#439FE0",   # Blue for "in progress"
        )
        return execution_id

    # ── Step 2: Monitor progress ──────────────────────────────────
    def monitor(
        self,
        execution_id: str,
        poll_interval: int = 15,
        timeout: int       = 1800,
    ) -> bool:
        """
        Polls the pipeline state every poll_interval seconds.
        Returns True if the pipeline succeeded, False otherwise.

        We track stage_statuses to detect changes — we only send
        a Slack notification when a stage's status actually changes,
        not on every poll cycle.

        timeout=1800 (30 min) covers most pipelines; increase for
        pipelines with slow integration tests or large deployments.
        """
        stage_statuses: dict[str, str] = {}
        start_time = time.time()

        logger.info(f"Monitoring execution {execution_id} (timeout: {timeout}s)...")

        while time.time() - start_time < timeout:
            time.sleep(poll_interval)

            try:
                # ── Check overall execution status ─────────────────
                # get_pipeline_execution() returns the high-level status:
                # InProgress / Succeeded / Failed / Stopped / Superseded
                exec_response = self.cp.get_pipeline_execution(
                    pipelineName=self.pipeline_name,
                    pipelineExecutionId=execution_id,
                )
                overall_status = exec_response["pipelineExecution"]["status"]

                # ── Check individual stage statuses ────────────────
                # get_pipeline_state() returns all stages with their
                # latestExecution status — more granular than the overall status.
                state_response = self.cp.get_pipeline_state(
                    name=self.pipeline_name
                )

                for stage in state_response["stageStates"]:
                    stage_name   = stage["stageName"]
                    latest_exec  = stage.get("latestExecution", {})
                    stage_status = latest_exec.get("status", "NotStarted")

                    # Notify only on status change (avoids spam)
                    if stage_statuses.get(stage_name) != stage_status:
                        stage_statuses[stage_name] = stage_status
                        self._notify_stage(stage_name, stage_status)

                # ── Check for terminal state ───────────────────────
                if overall_status in ("Succeeded", "Failed", "Stopped", "Superseded"):
                    self._notify_final(overall_status, execution_id)
                    return overall_status == "Succeeded"

            except self.cp.exceptions.PipelineExecutionNotFoundException:
                logger.error(f"Execution {execution_id} not found")
                return False
            except Exception as e:
                logger.error(f"Error polling pipeline: {e}")

        # Timeout reached
        self.send_slack(
            message=(
                f"⏰ *Pipeline Monitoring Timed Out*\n"
                f"Pipeline: `{self.pipeline_name}`\n"
                f"Execution: `{execution_id}`\n"
                f"Waited {timeout // 60} minutes without completion."
            ),
            color="#FFA500",  # Orange for timeout
        )
        return False

    # ── Slack notifications ───────────────────────────────────────
    def _notify_stage(self, stage_name: str, status: str) -> None:
        """Send a Slack message for each stage status change."""
        emoji_map = {
            "InProgress": "🔄",
            "Succeeded":  "✅",
            "Failed":     "❌",
            "Stopped":    "⛔",
            "Skipped":    "⏭️",
        }
        color_map = {
            "InProgress": "#439FE0",   # Blue
            "Succeeded":  "#36a64f",   # Green
            "Failed":     "#D00000",   # Red
            "Stopped":    "#FFA500",   # Orange
            "Skipped":    "#808080",   # Grey
        }
        emoji = emoji_map.get(status, "•")
        color = color_map.get(status, "#808080")

        self.send_slack(
            message=f"{emoji} Stage *{stage_name}*: `{status}`",
            color=color,
        )

    def _notify_final(self, status: str, execution_id: str) -> None:
        """Send final pipeline result notification."""
        if status == "Succeeded":
            msg   = (
                f"✅ *Deployment Succeeded!*\n"
                f"Pipeline: `{self.pipeline_name}`\n"
                f"Execution: `{execution_id}`"
            )
            color = "#36a64f"
        else:
            console_url = (
                f"https://console.aws.amazon.com/codesuite/codepipeline/pipelines/"
                f"{self.pipeline_name}/executions/{execution_id}/timeline"
            )
            msg = (
                f"❌ *Deployment FAILED!*\n"
                f"Pipeline: `{self.pipeline_name}`\n"
                f"Execution: `{execution_id}`\n"
                f"Status: `{status}`\n"
                f"<{console_url}|View in Console>"
            )
            color = "#D00000"

        self.send_slack(message=msg, color=color)

    def send_slack(self, message: str, color: str = "#439FE0") -> None:
        """
        Send a message to Slack via an Incoming Webhook.

        We use urllib.request (built-in) instead of the requests library
        to keep this script dependency-free — important for Lambda.

        Slack Incoming Webhook payload format:
        {
          "attachments": [{
            "color": "#36a64f",    ← Left-border color (hex or "good"/"warning"/"danger")
            "text": "...",         ← Message body (supports Slack mrkdwn)
            "footer": "...",       ← Small footer text
            "ts": 1234567890       ← Unix timestamp for footer display
          }]
        }

        mrkdwn formatting: *bold*, _italic_, `code`, <url|link text>
        """
        payload = {
            "attachments": [{
                "color":   color,
                "text":    message,
                "footer":  "AWS CodePipeline Monitor",
                "ts":      int(time.time()),
                "mrkdwn_in": ["text"],   # Enable mrkdwn parsing in text field
            }]
        }

        data = json.dumps(payload).encode("utf-8")
        req = urllib.request.Request(
            self.slack_url,
            data=data,
            headers={"Content-Type": "application/json"},
            method="POST",
        )

        try:
            with urllib.request.urlopen(req, timeout=5) as response:
                if response.status != 200:
                    logger.warning(f"Slack returned non-200: {response.status}")
        except urllib.error.URLError as e:
            # Log the error but don't raise — Slack failure shouldn't stop monitoring
            logger.error(f"Slack notification failed: {e}")


# ── Entry point ───────────────────────────────────────────────────
if __name__ == "__main__":
    import sys

    monitor = CodePipelineMonitor(
        pipeline_name="prod-app-pipeline",
        slack_webhook_url="https://hooks.slack.com/services/T00/B00/xxx",
        region="ap-south-1",
    )

    # Trigger and monitor
    execution_id = monitor.trigger_pipeline()
    success = monitor.monitor(
        execution_id,
        poll_interval=15,   # Check every 15 seconds
        timeout=1800,       # Give up after 30 minutes
    )

    # Exit code for CI/CD integration
    sys.exit(0 if success else 1)

Sample Slack Output

🚀 Deployment Started
Pipeline: `prod-app-pipeline`
Execution ID: `abc-123-def`
Time: 2025-01-20 14:30 UTC

🔄 Stage Source: InProgress
✅ Stage Source: Succeeded

🔄 Stage Build: InProgress
✅ Stage Build: Succeeded

🔄 Stage Deploy: InProgress
✅ Stage Deploy: Succeeded

✅ Deployment Succeeded!
Pipeline: `prod-app-pipeline`
Execution ID: `abc-123-def`

Key Commands Explained

CommandWhat it does
start_pipeline_execution(name=pipeline_name)Triggers a new pipeline run, returns pipelineExecutionId
get_pipeline_execution(pipelineName, pipelineExecutionId)Returns overall status: InProgress/Succeeded/Failed
get_pipeline_state(name=pipeline_name)Returns per-stage status with action-level details
stage["latestExecution"]["status"]Current status of the most recent execution of this stage
urllib.request.Request(url, data, headers, method="POST")HTTP POST request using the standard library
json.dumps(payload).encode("utf-8")Serialises dict to JSON bytes for the HTTP body
urlopen(req, timeout=5)Sends the request with a 5-second timeout

🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK — needed for CodePipeline client calls
import timetime.sleep(poll_interval) between status checks. int(time.time()) for Slack message timestamp
import jsonSerialize the Slack payload dict into a JSON string
import urllib.requestStandard library HTTP client. Used to POST to Slack without the requests library dependency (important for Lambda)
import urllib.errorStandard library HTTP error handling for failed Slack calls
import loggingStructured log output
from datetime import datetimeFormats deployment start time for the Slack message

CodePipelineMonitor.__init__

self.cp            = boto3.client("codepipeline", region_name=region)
self.pipeline_name = pipeline_name
self.slack_url     = slack_webhook_url
LineExplanation
boto3.client("codepipeline", region_name=region)CodePipeline client. Pipelines are regional — you must use the correct region
self.slack_urlThe Slack Incoming Webhook URL (e.g., https://hooks.slack.com/services/T.../B.../xxx). Stored as an instance attribute for use by all notification methods

trigger_pipeline()

response = self.cp.start_pipeline_execution(name=self.pipeline_name)
execution_id = response["pipelineExecutionId"]
LineExplanation
start_pipeline_execution(name=self.pipeline_name)Triggers a new execution of the pipeline. If a run is already in progress, this starts a parallel execution
response["pipelineExecutionId"]A UUID string uniquely identifying this particular run. Used to query status with get_pipeline_execution()

monitor(execution_id, poll_interval, timeout)

stage_statuses: dict[str, str] = {}
start_time = time.time()

while time.time() - start_time < timeout:
    time.sleep(poll_interval)
LineExplanation
stage_statuses: dict[str, str] = {}Tracks the last-known status for each stage. We only send Slack notifications when a stage’s status changes (avoids sending duplicate messages on every poll)
time.time()Returns current Unix timestamp (seconds since epoch) as a float
time.time() - start_time < timeoutCompares elapsed time against the timeout. Loop exits after timeout seconds regardless of deployment status
time.sleep(poll_interval)Pauses before the next poll. 15 seconds is fine — CodePipeline stage transitions don’t happen faster than that
exec_response = self.cp.get_pipeline_execution(
    pipelineName=self.pipeline_name,
    pipelineExecutionId=execution_id,
)
overall_status = exec_response["pipelineExecution"]["status"]
LineExplanation
get_pipeline_execution(pipelineName, pipelineExecutionId)Returns the high-level status of this specific execution: InProgress, Succeeded, Failed, Stopped, Superseded
["pipelineExecution"]["status"]The status string. Used to detect terminal states and stop the polling loop
state_response = self.cp.get_pipeline_state(name=self.pipeline_name)
for stage in state_response["stageStates"]:
    stage_name   = stage["stageName"]
    stage_status = stage.get("latestExecution", {}).get("status", "NotStarted")

    if stage_statuses.get(stage_name) != stage_status:
        stage_statuses[stage_name] = stage_status
        self._notify_stage(stage_name, stage_status)
LineExplanation
get_pipeline_state(name=self.pipeline_name)Returns per-stage status. More granular than get_pipeline_execution() — you can see which specific stage is InProgress
state_response["stageStates"]List of stage dicts, one per pipeline stage (Source, Build, Deploy, etc.)
stage["stageName"]The name you gave the stage when creating the pipeline
stage.get("latestExecution", {}).get("status", "NotStarted")Chained .get() — latestExecution is absent if the stage hasn’t run yet. "NotStarted" is our placeholder
if stage_statuses.get(stage_name) != stage_statusOnly notify when status changes. Without this check, every poll would send a Slack message

send_slack(message, color)

payload = {
    "attachments": [{
        "color":     color,
        "text":      message,
        "footer":    "AWS CodePipeline Monitor",
        "ts":        int(time.time()),
        "mrkdwn_in": ["text"],
    }]
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
    self.slack_url,
    data=data,
    headers={"Content-Type": "application/json"},
    method="POST",
)
with urllib.request.urlopen(req, timeout=5) as response:
    ...
LineExplanation
"attachments": [...]Slack’s legacy attachment format. The left-colored sidebar is only available via attachments (not blocks API)
"color": colorThe colored left border: "#36a64f" = green (success), "#D00000" = red (failure), "#439FE0" = blue (info)
"ts": int(time.time())Unix timestamp shown as a human-readable time in the Slack message footer
"mrkdwn_in": ["text"]Tells Slack to render Markdown (*bold*, `code`) in the text field
json.dumps(payload).encode("utf-8")Converts dict → JSON string → bytes. encode("utf-8") is required because HTTP requires bytes, not strings
urllib.request.Request(url, data, headers, method="POST")Creates an HTTP request object. data must be bytes, headers is a dict, method="POST" overrides the default GET
urllib.request.urlopen(req, timeout=5)Sends the request. timeout=5 means: give up if Slack doesn’t respond in 5 seconds
except urllib.error.URLError: logger.error(...)Catches network errors (DNS failure, timeout). We log but don’t raise — Slack failure shouldn’t break the monitoring loop

Exit Code for CI/CD

success = monitor.monitor(execution_id, poll_interval=15, timeout=1800)
sys.exit(0 if success else 1)
LineExplanation
monitor.monitor(...)Returns True if overall_status == "Succeeded", False otherwise
sys.exit(0 if success else 1)CI/CD systems (GitHub Actions, Jenkins) check the process exit code. 0 = the deployment step passed. 1 = the step failed, stop the pipeline

Multi-Account Security Report — Assume Roles Across All Org Accounts

Problem Statement

Your organization has 30 AWS accounts across dev, staging, and production environments. Your security team must verify that GuardDuty, CloudTrail, and AWS Config are enabled in every account — and that root MFA is always on. Doing this manually takes 3 hours. This script runs in under 2 minutes.


Pre-requisite: Deploy the Audit Role to All Accounts

# CloudFormation StackSet to deploy SecurityAuditRole to all accounts
# (run from the Organizations management account)

aws cloudformation create-stack-set \
  --stack-set-name SecurityAuditRole \
  --template-body file://security-audit-role.yaml \
  --capabilities CAPABILITY_NAMED_IAM \
  --permission-model SERVICE_MANAGED \
  --auto-deployment Enabled=true,RetainStacksOnAccountRemoval=false
# security-audit-role.yaml
Resources:
  SecurityAuditRole:
    Type: AWS::IAM::Role
    Properties:
      RoleName: SecurityAuditRole
      AssumeRolePolicyDocument:
        Statement:
          - Effect: Allow
            Principal:
              AWS: "arn:aws:iam::MANAGEMENT_ACCOUNT_ID:root"
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/SecurityAudit
        - arn:aws:iam::aws:policy/ReadOnlyAccess

Complete Script

import boto3
import json
import logging
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")


# ── Step 1: Get all active org accounts ──────────────────────────
def get_all_org_accounts() -> list[dict]:
    """
    list_accounts() returns all accounts in the AWS Organization.
    We must call this from the MANAGEMENT (master) account.
    
    Only include ACTIVE accounts — SUSPENDED accounts cannot have
    roles assumed in them and would cause AssumeRole to fail.
    
    paginator handles the pagination automatically (max 20 per page).
    """
    org = boto3.client("organizations")
    accounts = []
    paginator = org.get_paginator("list_accounts")
    for page in paginator.paginate():
        accounts.extend(
            acct for acct in page["Accounts"] if acct["Status"] == "ACTIVE"
        )
    return accounts


# ── Step 2: Assume role in target account ────────────────────────
def assume_role(account_id: str, role_name: str = "SecurityAuditRole") -> dict | None:
    """
    assume_role() exchanges your current credentials for temporary
    credentials in the target account. The target account must have
    an IAM role that trusts your source account's identity.

    RoleArn format: arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME
    RoleSessionName: appears in CloudTrail logs in the target account.
    DurationSeconds: max 3600s (1 hour) for chained role assumption.

    Returns a dict of credentials or None if assumption fails.
    The credentials dict has four keys:
      AccessKeyId, SecretAccessKey, SessionToken, Expiration
    """
    sts = boto3.client("sts")
    role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"

    try:
        response = sts.assume_role(
            RoleArn=role_arn,
            RoleSessionName="SecurityAuditSession",
            DurationSeconds=3600,
        )
        creds = response["Credentials"]
        return {
            "aws_access_key_id":     creds["AccessKeyId"],
            "aws_secret_access_key": creds["SecretAccessKey"],
            "aws_session_token":     creds["SessionToken"],
        }
    except Exception as e:
        logger.warning(f"Cannot assume role in {account_id}: {e}")
        return None


# ── Step 3: Audit a single account ───────────────────────────────
def audit_account(account: dict) -> dict:
    """
    Run 5 security checks against a single AWS account using
    temporary credentials from assume_role().

    Each check is a separate boto3 client call in the target account.
    We use kwargs unpacking (**creds) to pass the temporary credentials.
    """
    account_id   = account["Id"]
    account_name = account["Name"]
    findings: dict = {
        "account_id":   account_id,
        "account_name": account_name,
        "checks":       {},
        "error":        None,
    }

    creds = assume_role(account_id)
    if creds is None:
        findings["error"] = "Cannot assume SecurityAuditRole"
        return findings

    try:
        # ── Check 1: Root MFA ──────────────────────────────────────
        # get_account_summary() returns aggregate IAM statistics.
        # AccountMFAEnabled=1 means MFA is on for the root account.
        # AccountMFAEnabled=0 means root has NO MFA — critical finding!
        iam = boto3.client("iam", **creds)
        summary = iam.get_account_summary()["SummaryMap"]
        findings["checks"]["root_mfa_enabled"] = bool(
            summary.get("AccountMFAEnabled", 0)
        )

        # ── Check 2: CloudTrail (multi-region) ────────────────────
        # describe_trails(includeShadowTrails=False) returns only trails
        # created in THIS region (not replicated shadow trails).
        # A multi-region trail captures API calls from all regions —
        # required for complete audit coverage.
        ct = boto3.client("cloudtrail", **creds)
        trails = ct.describe_trails(includeShadowTrails=False)["trailList"]
        findings["checks"]["cloudtrail_multi_region"] = any(
            t.get("IsMultiRegionTrail") for t in trails
        )

        # ── Check 3: GuardDuty ────────────────────────────────────
        # list_detectors() returns GuardDuty detector IDs in this region.
        # A detector is the GuardDuty "engine" — you need one per region.
        # Empty list = GuardDuty not enabled in this region.
        gd = boto3.client("guardduty", **creds)
        detectors = gd.list_detectors()["DetectorIds"]
        findings["checks"]["guardduty_enabled"] = len(detectors) > 0

        # ── Check 4: AWS Config ───────────────────────────────────
        # describe_configuration_recorders() returns the Config recorders.
        # You need at least one recording to track resource configurations.
        config = boto3.client("config", **creds)
        recorders = config.describe_configuration_recorders()[
            "ConfigurationRecorders"
        ]
        findings["checks"]["aws_config_enabled"] = len(recorders) > 0

        # ── Check 5: Password Policy ──────────────────────────────
        # get_account_password_policy() raises NoSuchEntityException
        # if no password policy is set — we treat that as non-compliant.
        try:
            policy = iam.get_account_password_policy()["PasswordPolicy"]
            findings["checks"]["password_min_length"] = policy.get(
                "MinimumPasswordLength", 0
            )
            findings["checks"]["password_requires_symbols"] = policy.get(
                "RequireSymbols", False
            )
            findings["checks"]["password_max_age_days"] = policy.get(
                "MaxPasswordAge", 9999
            )
        except iam.exceptions.NoSuchEntityException:
            findings["checks"]["password_policy"] = "NOT_CONFIGURED"

    except Exception as e:
        findings["error"] = str(e)
        logger.error(f"Audit failed for {account_id}: {e}")

    return findings


# ── Step 4: Run all audits in parallel ───────────────────────────
def generate_consolidated_report(
    audit_role: str = "SecurityAuditRole",
    max_workers: int = 10,
    output_file: str = "org_security_report.json",
) -> list[dict]:
    """
    ThreadPoolExecutor runs audit_account() concurrently for all accounts.
    max_workers=10 means up to 10 accounts are audited simultaneously.
    More workers = faster, but watch STS assume_role rate limits.

    as_completed() yields futures as they finish (not in submission order).
    This means the report might not be in account ID order — we sort later.
    """
    accounts = get_all_org_accounts()
    logger.info(f"Auditing {len(accounts)} active accounts with {max_workers} workers")

    results = []
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        futures = {
            executor.submit(audit_account, acct): acct
            for acct in accounts
        }
        for future in as_completed(futures):
            result = future.result()
            results.append(result)
            status = "✅" if not result.get("error") else "❌"
            checks_pass = all(
                bool(v) for v in result.get("checks", {}).values()
                if isinstance(v, bool)
            )
            print(
                f"{status} {result['account_id']} ({result['account_name']}) "
                f"{'✓ All checks passed' if checks_pass else '⚠ Issues found'}"
            )

    # Sort by account name for readability
    results.sort(key=lambda x: x["account_name"])

    # ── Print summary ──────────────────────────────────────────────
    print(f"\n{'='*70}")
    print("CONSOLIDATED SECURITY REPORT")
    print(f"Generated: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}")
    print(f"{'='*70}")

    failed_checks = 0
    for r in results:
        print(f"\n  Account: {r['account_name']} ({r['account_id']})")
        if r.get("error"):
            print(f"    ⚠️  Error: {r['error']}")
            continue
        for check, val in r.get("checks", {}).items():
            if isinstance(val, bool):
                icon = "✅" if val else "❌"
                if not val:
                    failed_checks += 1
            else:
                icon = "ℹ️"
            print(f"    {icon} {check}: {val}")

    print(f"\n{'='*70}")
    print(f"Total accounts:   {len(results)}")
    print(f"Failed checks:    {failed_checks}")

    # Save full report
    with open(output_file, "w") as f:
        json.dump(results, f, indent=2, default=str)
    print(f"Full report saved to {output_file}")

    return results


if __name__ == "__main__":
    generate_consolidated_report(
        audit_role="SecurityAuditRole",
        max_workers=10,
    )

Key Commands Explained

CommandWhat it does
org.get_paginator("list_accounts")Paginates all accounts in the AWS Organization
sts.assume_role(RoleArn, RoleSessionName, DurationSeconds)Returns temporary credentials for the target account
response["Credentials"]Contains AccessKeyId, SecretAccessKey, SessionToken, Expiration
boto3.client("iam", **creds)Creates an IAM client using the assumed role’s credentials
iam.get_account_summary()["SummaryMap"]["AccountMFAEnabled"]1 = root MFA on, 0 = root MFA off
ct.describe_trails(includeShadowTrails=False)Lists CloudTrail trails created in this region
gd.list_detectors()["DetectorIds"]Returns GuardDuty detector IDs — empty = not enabled
ThreadPoolExecutor(max_workers=10)Runs audits on 10 accounts simultaneously
as_completed(futures)Yields results as each thread finishes

🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK — used for Organizations, STS, IAM, CloudTrail, GuardDuty, Config clients
import jsonSerializes the audit report to a JSON file
import loggingStructured log output
from concurrent.futures import ThreadPoolExecutor, as_completedThreadPoolExecutor runs audit functions in parallel threads. as_completed yields results as threads finish
from datetime import datetimeTimestamps for the report header

get_all_org_accounts()

org = boto3.client("organizations")
paginator = org.get_paginator("list_accounts")
for page in paginator.paginate():
    accounts.extend(
        acct for acct in page["Accounts"] if acct["Status"] == "ACTIVE"
    )
LineExplanation
boto3.client("organizations")AWS Organizations client. Must be called from the management (master) account — member accounts cannot call list_accounts()
get_paginator("list_accounts")Returns max 20 accounts per page. Pagination is mandatory for organizations with more than 20 accounts
acct["Status"] == "ACTIVE"Filter to active accounts only. SUSPENDED accounts cannot have roles assumed in them — trying to assume a role there raises AccessDenied

assume_role(account_id, role_name)

sts = boto3.client("sts")
role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"
response = sts.assume_role(
    RoleArn=role_arn,
    RoleSessionName="SecurityAuditSession",
    DurationSeconds=3600,
)
creds = response["Credentials"]
return {
    "aws_access_key_id":     creds["AccessKeyId"],
    "aws_secret_access_key": creds["SecretAccessKey"],
    "aws_session_token":     creds["SessionToken"],
}
LineExplanation
boto3.client("sts")STS (Security Token Service) — exchanges your current credentials for temporary credentials in another account
role_arn = f"arn:aws:iam::{account_id}:role/{role_name}"Builds the full role ARN. Every account’s role ARN follows this pattern with the account’s 12-digit ID
RoleArn=role_arnThe role in the target account to assume. Must have a trust policy allowing your source account’s identity
RoleSessionName="SecurityAuditSession"Appears in CloudTrail logs in the TARGET account as the session name — useful for auditing who (which script) made the calls
DurationSeconds=3600Temporary credentials are valid for 1 hour. Max for chained role assumption is 3600
response["Credentials"]Dict with AccessKeyId, SecretAccessKey, SessionToken, Expiration
Return dict with "aws_access_key_id" etc.These are the exact keyword arguments that boto3.client() accepts — enables boto3.client("iam", **creds) pattern

audit_account(account) — 5 Security Checks

iam = boto3.client("iam", **creds)
summary = iam.get_account_summary()["SummaryMap"]
findings["checks"]["root_mfa_enabled"] = bool(summary.get("AccountMFAEnabled", 0))
LineExplanation
boto3.client("iam", **creds)Creates an IAM client using the assumed role’s temporary credentials. **creds unpacks the dict as keyword args: aws_access_key_id=..., aws_secret_access_key=..., aws_session_token=...
get_account_summary()Returns aggregate IAM statistics for the account. "SummaryMap" is the key containing the counts
summary.get("AccountMFAEnabled", 0)Returns 1 if root MFA is enabled, 0 if not. The key may be absent in some older accounts
bool(...)Converts 1 → True, 0 → False for clean JSON output
ct = boto3.client("cloudtrail", **creds)
trails = ct.describe_trails(includeShadowTrails=False)["trailList"]
findings["checks"]["cloudtrail_multi_region"] = any(t.get("IsMultiRegionTrail") for t in trails)
LineExplanation
boto3.client("cloudtrail", **creds)CloudTrail client using the assumed role’s credentials — operates in the TARGET account
describe_trails(includeShadowTrails=False)Returns trails created in this region. includeShadowTrails=False excludes replicated copies of multi-region trails from other regions (otherwise you’d see the same trail multiple times)
any(t.get("IsMultiRegionTrail") for t in trails)Returns True if at least one trail has IsMultiRegionTrail=True. A multi-region trail captures API calls from all regions
gd = boto3.client("guardduty", **creds)
detectors = gd.list_detectors()["DetectorIds"]
findings["checks"]["guardduty_enabled"] = len(detectors) > 0
LineExplanation
boto3.client("guardduty", **creds)GuardDuty client in the target account
gd.list_detectors()["DetectorIds"]Returns a list of GuardDuty detector IDs in this region. A detector is the GuardDuty monitoring engine — you need one per region
len(detectors) > 0Empty list = GuardDuty not enabled in this region. Returns True/False
config = boto3.client("config", **creds)
recorders = config.describe_configuration_recorders()["ConfigurationRecorders"]
findings["checks"]["aws_config_enabled"] = len(recorders) > 0
LineExplanation
describe_configuration_recorders()Returns the list of AWS Config recorders. A recorder tracks resource configuration changes
len(recorders) > 0Empty list = Config not enabled. At least one recorder needed for compliance tracking
try:
    policy = iam.get_account_password_policy()["PasswordPolicy"]
    findings["checks"]["password_min_length"] = policy.get("MinimumPasswordLength", 0)
except iam.exceptions.NoSuchEntityException:
    findings["checks"]["password_policy"] = "NOT_CONFIGURED"
LineExplanation
get_account_password_policy()Raises NoSuchEntityException if no password policy is configured (the default AWS state). This exception handling is necessary
policy.get("MinimumPasswordLength", 0)CIS Benchmark requires at least 14 characters. Default is 0 when no policy exists

generate_consolidated_report(audit_role, max_workers, output_file)

with ThreadPoolExecutor(max_workers=max_workers) as executor:
    futures = {
        executor.submit(audit_account, acct): acct
        for acct in accounts
    }
    for future in as_completed(futures):
        result = future.result()
        results.append(result)
LineExplanation
ThreadPoolExecutor(max_workers=10)Creates a thread pool with 10 worker threads. Up to 10 audit_account() calls run simultaneously — reduces total time from N × 10s to roughly ⌈N/10⌉ × 10s
executor.submit(audit_account, acct)Submits audit_account(acct) to run in a worker thread. Returns a Future object immediately (non-blocking)
futures = {future: acct for acct in accounts}Dict comprehension creating a mapping from Future → account. Used to identify which account each future belongs to
as_completed(futures)Yields each Future as it completes (not in submission order). Allows processing results as they arrive rather than waiting for all to finish
future.result()Blocks until this specific future completes, then returns the return value of audit_account(). Any exception raised inside the thread is re-raised here
json.dump(results, f, indent=2, default=str)
LineExplanation
json.dump(results, f, indent=2)Writes the results list to the JSON file with 2-space indentation
default=strjson.dump doesn’t know how to serialize datetime objects by default. default=str converts any non-serializable object (datetime, Decimal) to its string representation

Generic boto3 Pagination Utility — Handle All Paginated AWS APIs

Problem Statement

You write ec2.describe_instances() and it works in dev with 5 instances. In production with 1,200 instances, it silently returns only the first 1,000. Your security audit reports “no violations” — but 200 instances were never checked. Pagination is not optional.


Which APIs Are Paginated?

Almost all AWS list/describe APIs are paginated. Common ones:

APIDefault Page SizeResult Key
ec2:DescribeInstances100–1000Reservations
iam:ListUsers100Users
s3:ListObjectsV21000Contents
cloudtrail:LookupEvents50Events
rds:DescribeDBInstances100DBInstances

Complete Utility

import boto3
from typing import Generator, Any, Optional


# ── Method 1: Built-in paginator (preferred) ──────────────────────
def paginate_all(
    client,
    method_name: str,
    result_key: str,
    **kwargs,
) -> Generator[Any, None, None]:
    """
    Generic paginator for any boto3 API that supports pagination.

    client:      a boto3 service client (e.g., boto3.client("ec2"))
    method_name: the API method name as a string (e.g., "describe_instances")
    result_key:  the top-level dict key containing results (e.g., "Reservations")
    **kwargs:    any additional arguments to pass to the API (Filters, etc.)

    How boto3 paginators work:
    - client.get_paginator("method_name") returns a Paginator object.
    - paginator.paginate(**kwargs) returns a PageIterator.
    - Each iteration yields one page (a dict with the same structure as
      a single API call response).
    - boto3 automatically appends NextToken to each subsequent request
      and stops when there are no more pages.

    We use yield from to yield items one at a time — making this a
    lazy generator that never loads all results into memory at once.
    This is critical when you have millions of S3 objects.
    """
    try:
        # get_paginator() raises OperationNotPageable if the method
        # doesn't support pagination — we fall back to manual NextToken.
        paginator = client.get_paginator(method_name)
        for page in paginator.paginate(**kwargs):
            yield from page.get(result_key, [])

    except client.exceptions.ClientError:
        raise
    except Exception:
        # Fallback: manual NextToken loop for non-standard pagination
        yield from _manual_paginate(client, method_name, result_key, **kwargs)


def _manual_paginate(
    client,
    method_name: str,
    result_key: str,
    **kwargs,
) -> Generator[Any, None, None]:
    """
    Manual NextToken pagination for APIs that don't have a built-in paginator.
    Some older APIs use 'Marker' instead of 'NextToken'.
    """
    method = getattr(client, method_name)

    while True:
        response = method(**kwargs)
        yield from response.get(result_key, [])

        # Try NextToken first, then Marker (IAM uses Marker)
        next_token = response.get("NextToken") or response.get("Marker")
        if not next_token:
            break
        # Set the appropriate continuation token for the next call
        if "NextToken" in response:
            kwargs["NextToken"] = next_token
        else:
            kwargs["Marker"] = next_token


# ── Method 2: Collect all results into a list (convenience) ───────
def paginate_all_list(
    client,
    method_name: str,
    result_key: str,
    **kwargs,
) -> list:
    """
    Wrapper that collects all paginated results into a list.
    Use when you need to access results multiple times or check length.
    For very large result sets, prefer the generator version.
    """
    return list(paginate_all(client, method_name, result_key, **kwargs))


# ── Method 3: Paginate with a callback ────────────────────────────
def paginate_with_callback(
    client,
    method_name: str,
    result_key: str,
    callback,
    **kwargs,
) -> int:
    """
    Process each item with a callback function as pages arrive.
    Returns total items processed.
    Useful for writing results to a file/DB without buffering everything.
    """
    count = 0
    for item in paginate_all(client, method_name, result_key, **kwargs):
        callback(item)
        count += 1
    return count


# ── Usage examples ─────────────────────────────────────────────────
if __name__ == "__main__":
    ec2 = boto3.client("ec2", region_name="ap-south-1")
    s3  = boto3.client("s3")
    iam = boto3.client("iam")
    ct  = boto3.client("cloudtrail")

    # ── Example 1: List all running EC2 instances ─────────────────
    # Without pagination you'd call ec2.describe_instances() and risk
    # missing instances if there are more than the default page size.
    print("Running EC2 instances:")
    instance_count = 0
    for reservation in paginate_all(
        ec2,
        "describe_instances",
        "Reservations",
        Filters=[{"Name": "instance-state-name", "Values": ["running"]}],
    ):
        for instance in reservation["Instances"]:
            print(f"  {instance['InstanceId']}")
            instance_count += 1
    print(f"Total: {instance_count} running instances\n")

    # ── Example 2: List all IAM users ─────────────────────────────
    # iam.list_users() returns 100 users per page (max 1000 per call
    # when using PaginationConfig — but paginator handles it).
    all_users = paginate_all_list(iam, "list_users", "Users")
    print(f"Total IAM users: {len(all_users)}\n")

    # ── Example 3: List all S3 objects in a bucket ────────────────
    # S3 can have BILLIONS of objects — never load them all into a list.
    # Use the generator to process one at a time.
    bucket_name = "my-data-bucket"
    total_size = 0
    object_count = 0
    for obj in paginate_all(s3, "list_objects_v2", "Contents", Bucket=bucket_name):
        total_size += obj["Size"]
        object_count += 1
    print(f"Bucket {bucket_name}: {object_count:,} objects, {total_size / 1e9:.2f} GB\n")

    # ── Example 4: CloudTrail events with callback ─────────────────
    from datetime import datetime, timedelta
    login_events = []

    def collect_console_logins(event):
        if event.get("EventName") == "ConsoleLogin":
            login_events.append(event)

    count = paginate_with_callback(
        ct,
        "lookup_events",
        "Events",
        collect_console_logins,
        StartTime=datetime.utcnow() - timedelta(days=7),
        EndTime=datetime.utcnow(),
    )
    print(f"Processed {count} CloudTrail events, {len(login_events)} console logins")

    # ── Example 5: Paginate RDS instances ─────────────────────────
    rds = boto3.client("rds")
    db_instances = paginate_all_list(rds, "describe_db_instances", "DBInstances")
    print(f"\nTotal RDS instances: {len(db_instances)}")
    for db in db_instances:
        print(f"  {db['DBInstanceIdentifier']} ({db['DBInstanceStatus']})")

Why Paginators Beat Manual NextToken

# ❌ WRONG — silently misses resources beyond the first page
response = ec2.describe_instances()   # Returns ONLY the first page!
instances = response["Reservations"]  # May be incomplete

# ❌ FRAGILE — manual but verbose and easy to forget
response = ec2.describe_instances()
all_reservations = response["Reservations"]
while "NextToken" in response:
    response = ec2.describe_instances(NextToken=response["NextToken"])
    all_reservations.extend(response["Reservations"])

# ✅ CORRECT — paginator handles everything
paginator = ec2.get_paginator("describe_instances")
for page in paginator.paginate():
    for reservation in page["Reservations"]:
        process(reservation)

# ✅ BEST — use our generic utility
for reservation in paginate_all(ec2, "describe_instances", "Reservations"):
    process(reservation)

Key Commands Explained

CommandWhat it does
client.get_paginator("method_name")Returns a boto3 Paginator for the given API method
paginator.paginate(**kwargs)Returns a PageIterator — yields one page dict per iteration
page.get(result_key, [])Extracts the result list from each page — defaults to [] if key absent
yield from iterableDelegates iteration to the inner iterable (lazy generator composition)
response.get("NextToken")Returns None if no more pages (loop terminates)
getattr(client, method_name)Gets a method by name string — allows dynamic method dispatch
PaginationConfig={"MaxItems": 500}Limit total results across all pages

PaginationConfig Options

# Limit total results (useful for sampling or testing)
paginator = client.get_paginator("list_objects_v2")
for page in paginator.paginate(
    Bucket="my-bucket",
    PaginationConfig={
        "MaxItems":  100,    # Stop after 100 total items
        "PageSize":  50,     # 50 items per API call
        "StartingToken": None,  # Resume from a specific token
    }
):
    process(page["Contents"])

🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import boto3AWS SDK — needed for creating service clients
from typing import Generator, Any, OptionalType hints. Generator[Any, None, None] declares that a function returns a generator that yields Any type values

paginate_all(client, method_name, result_key, **kwargs)

def paginate_all(client, method_name: str, result_key: str, **kwargs) -> Generator[Any, None, None]:
PartExplanation
clientAny boto3 service client (e.g., boto3.client("ec2"), boto3.client("s3"))
method_name: strThe API method name as a string (e.g., "describe_instances", "list_objects_v2") — allows this function to work with ANY paginated API
result_key: strThe key in each page response that contains the list of results (e.g., "Reservations", "Contents", "Users")
**kwargsAny additional arguments to pass through to the underlying API (e.g., Filters=[...], Bucket="my-bucket")
-> Generator[Any, None, None]Return type hint: this is a generator function. Any = items can be any type. First None = no values are sent into the generator. Second None = no return value
try:
    paginator = client.get_paginator(method_name)
    for page in paginator.paginate(**kwargs):
        yield from page.get(result_key, [])
LineExplanation
client.get_paginator(method_name)Dynamically creates a Paginator for the named method. boto3 knows which response key to use for NextToken automatically
paginator.paginate(**kwargs)Returns a PageIterator. Each iteration yields one full API response dict (one page)
yield from page.get(result_key, [])yield from delegates iteration — yields each item in the list one at a time to the caller. This is the key to making this a lazy generator (memory efficient). page.get(result_key, []) defaults to [] if the key is absent (some pages may have no results)
except Exception:
    yield from _manual_paginate(client, method_name, result_key, **kwargs)
LineExplanation
except ExceptionCatches OperationNotPageable (raised when the method doesn’t have a built-in paginator) and any other error from get_paginator
yield from _manual_paginate(...)Falls back to the manual NextToken implementation. yield from inside a try/except is valid in Python 3.3+

_manual_paginate(client, method_name, result_key, **kwargs)

method = getattr(client, method_name)
while True:
    response = method(**kwargs)
    yield from response.get(result_key, [])
    next_token = response.get("NextToken") or response.get("Marker")
    if not next_token:
        break
    if "NextToken" in response:
        kwargs["NextToken"] = next_token
    else:
        kwargs["Marker"] = next_token
LineExplanation
getattr(client, method_name)Gets a method by name string. getattr(ec2_client, "describe_instances") returns the describe_instances method object. This enables dynamic dispatch
while True:Infinite loop — continues until we break when there are no more pages
response = method(**kwargs)Calls the API. **kwargs passes all accumulated parameters including any pagination tokens
yield from response.get(result_key, [])Yields all items from this page to the caller
response.get("NextToken") or response.get("Marker")Tries NextToken first (modern APIs), then Marker (older APIs like IAM use Marker). The or ensures we get whichever is present
if not next_token: breakNone (key absent) or "" (empty string) both evaluate to falsy — stops the loop
kwargs["NextToken"] = next_tokenInjects the continuation token into kwargs so the next method(**kwargs) call fetches the next page

paginate_all_list(...)

def paginate_all_list(client, method_name, result_key, **kwargs) -> list:
    return list(paginate_all(client, method_name, result_key, **kwargs))
LineExplanation
list(paginate_all(...))Consumes the entire generator and stores all results in a list. Use when you need random access (results[5]), length check (len(results)), or need to iterate multiple times
When to prefer generator vs list?Generator = memory efficient, process as data arrives. List = needed when you must check len(), sort, or iterate multiple times

paginate_with_callback(client, method_name, result_key, callback, **kwargs)

count = 0
for item in paginate_all(client, method_name, result_key, **kwargs):
    callback(item)
    count += 1
return count
LineExplanation
callback(item)Calls the user-provided function with each item. The callback can write to a database, file, or process data without buffering everything
count += 1Tracks total items processed. Returned for logging or reporting
Use caseStreaming processing — e.g., processing 1 million S3 objects without loading all their metadata into RAM first

Usage Example — Why paginate_all Instead of Direct Call

# ❌ WRONG — silently misses resources beyond the first page
response = ec2.describe_instances()
instances = response["Reservations"]   # May be incomplete!

# ✅ CORRECT — never misses a result
for reservation in paginate_all(ec2, "describe_instances", "Reservations"):
    process(reservation)
PointExplanation
The silent failure dangerdescribe_instances() without pagination returns the first page only (up to 1000 instances). In a small test account it looks correct. In production it silently drops instances
No error is raisedAWS doesn’t error when there are more results — it just silently omits them. The response includes "NextToken" but if you don’t check for it, you never know more data exists

boto3 Retry Decorator with Exponential Backoff for ThrottlingException

Problem Statement

Your compliance script calls describe_instances in a loop across 50 regions and 20 accounts. After 30 seconds, you start getting ThrottlingException: Rate exceeded. Without retry logic, your script crashes and you have incomplete data. With exponential backoff, it slows down automatically and completes successfully.


Why Exponential Backoff + Jitter?

Naive retry (bad):     retry immediately → still throttled → fail
Fixed delay (better):  wait 1s → retry → wait 1s → retry
Exponential (good):    wait 1s → 2s → 4s → 8s → 16s (backs off)
Exponential + Jitter (best): 0.8s → 1.7s → 3.9s → 7.2s (avoids thundering herd)

Jitter adds random variation so that 100 concurrent threads don’t all wake up and retry at the same moment — which would just cause another wave of throttling.


Complete Decorator

import boto3
import time
import random
import functools
import logging
from botocore.exceptions import ClientError
from botocore.config import Config

logger = logging.getLogger(__name__)


# ── Decorator: add retry to any function ─────────────────────────
def aws_retry(
    max_retries: int   = 5,
    base_delay: float  = 0.5,
    max_delay: float   = 30.0,
    jitter: bool       = True,
    retryable_errors: set = None,
):
    """
    Decorator that wraps any function with automatic retry logic.

    max_retries:      total number of retry attempts (not counting the first try)
    base_delay:       initial wait before first retry (seconds)
    max_delay:        cap the wait time at this many seconds
    jitter:           add random variation to prevent thundering herd
    retryable_errors: set of AWS error codes to retry on

    Exponential backoff formula:
      delay = min(base_delay × 2^attempt, max_delay)
      with jitter: delay × uniform(0.75, 1.25)

    functools.wraps(func) copies the original function's __name__,
    __doc__, __module__, __qualname__ and __annotations__ to the
    wrapper — essential for debugging and introspection.
    """
    if retryable_errors is None:
        retryable_errors = {
            "ThrottlingException",
            "RequestLimitExceeded",
            "TooManyRequestsException",
            "ServiceUnavailable",
            "InternalServerError",
            "RequestTimeout",
            "ProvisionedThroughputExceededException",
            "LimitExceededException",
            "RequestExpired",
            "Throttling",               # Some services use just "Throttling"
            "SlowDown",                 # S3 uses this for throttling
        }

    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None

            for attempt in range(max_retries + 1):   # +1: attempt 0 is the initial try
                try:
                    return func(*args, **kwargs)

                except ClientError as e:
                    error_code = e.response["Error"]["Code"]
                    error_msg  = e.response["Error"]["Message"]

                    # Non-retryable: re-raise immediately
                    if error_code not in retryable_errors:
                        raise

                    last_exception = e

                    # We've exhausted all retries
                    if attempt == max_retries:
                        logger.error(
                            f"{func.__name__} failed after {max_retries} retries. "
                            f"Last error: {error_code}: {error_msg}"
                        )
                        raise

                    # Calculate wait time
                    delay = min(base_delay * (2 ** attempt), max_delay)
                    if jitter:
                        # Multiply by random value between 0.75 and 1.25
                        # This spreads retries across time instead of bunching them
                        delay *= (0.75 + random.random() * 0.5)

                    logger.warning(
                        f"{func.__name__} attempt {attempt + 1}/{max_retries} failed "
                        f"({error_code}). Retrying in {delay:.2f}s..."
                    )
                    time.sleep(delay)

            raise last_exception   # Should never reach here, but satisfies type checkers

        return wrapper
    return decorator


# ── Usage: function-level decorator ──────────────────────────────
@aws_retry(max_retries=5, base_delay=1.0)
def list_all_instances(region: str) -> list:
    """List all EC2 instances in a region with auto-retry on throttle."""
    ec2 = boto3.client("ec2", region_name=region)
    instances = []
    paginator = ec2.get_paginator("describe_instances")
    for page in paginator.paginate():
        for reservation in page["Reservations"]:
            instances.extend(reservation["Instances"])
    return instances


@aws_retry(max_retries=3, base_delay=0.5)
def get_secret(secret_name: str) -> dict:
    """Retrieve secret from Secrets Manager with retry."""
    import json
    sm = boto3.client("secretsmanager")
    response = sm.get_secret_value(SecretId=secret_name)
    return json.loads(response["SecretString"])


# ── Usage: class-based wrapper (all methods auto-retry) ───────────
class AWSClientWithRetry:
    """
    Wraps a boto3 client so that EVERY method call is automatically
    retried on throttling. Useful when you use a single client extensively.

    __getattr__ is called when Python can't find an attribute on the object.
    We intercept it to wrap any callable (boto3 method) with the retry decorator.
    """

    def __init__(
        self,
        service: str,
        region: str = "us-east-1",
        max_retries: int = 5,
        **boto_kwargs,
    ):
        self._client      = boto3.client(service, region_name=region, **boto_kwargs)
        self._max_retries = max_retries

    def __getattr__(self, name: str):
        """
        Called when accessing any attribute not found on this object.
        Returns the boto3 method wrapped with the retry decorator.
        """
        attr = getattr(self._client, name)
        if callable(attr):
            return aws_retry(max_retries=self._max_retries)(attr)
        return attr


# ── Method 3: botocore built-in retry config (simpler) ───────────
def get_client_with_builtin_retry(service: str, region: str = "us-east-1"):
    """
    botocore has built-in retry logic via Config.
    retry.mode options:
      "legacy"  — default, 3 retries with fixed delays
      "standard" — 3 retries with exponential backoff
      "adaptive" — dynamic retry with token bucket algorithm (best for throttling)

    max_attempts includes the initial attempt + retries.
    So max_attempts=5 means 1 initial + 4 retries.
    """
    config = Config(
        retries={
            "mode":         "adaptive",   # Adaptive token bucket algorithm
            "max_attempts": 10,           # Up to 9 retries
        },
        connect_timeout=5,
        read_timeout=30,
    )
    return boto3.client(service, region_name=region, config=config)


# ── Combining approaches ──────────────────────────────────────────
if __name__ == "__main__":
    # Approach 1: Function decorator (best for specific functions)
    instances = list_all_instances("ap-south-1")
    print(f"Found {len(instances)} instances")

    # Approach 2: Class wrapper (best when reusing a client heavily)
    ec2 = AWSClientWithRetry("ec2", region="ap-south-1", max_retries=5)

    # All these calls will auto-retry on ThrottlingException:
    response = ec2.describe_vpcs()
    sgs = ec2.describe_security_groups()
    subnets = ec2.describe_subnets()
    print(f"VPCs: {len(response['Vpcs'])}")

    # Approach 3: botocore adaptive mode (simplest — built-in)
    s3 = get_client_with_builtin_retry("s3")
    buckets = s3.list_buckets()["Buckets"]
    print(f"S3 buckets: {len(buckets)}")

Retry Timing Comparison

AttemptBase(0.5s)×2^nWith Jitter
1st retry0.5s0.50.38–0.63s
2nd retry1.0s1.00.75–1.25s
3rd retry2.0s2.01.50–2.50s
4th retry4.0s4.03.00–5.00s
5th retry8.0s8.06.00–10.00s
Max cap30.0s—22.5–37.5s

Key Commands Explained

CommandWhat it does
@functools.wraps(func)Copies original function metadata to the wrapper (preserves __name__, __doc__)
e.response["Error"]["Code"]The AWS error type string (e.g., "ThrottlingException")
e.response["Error"]["Message"]Human-readable error description
min(base_delay * (2 ** attempt), max_delay)Exponential backoff capped at max_delay
random.random() * 0.5Random value 0.0–0.5, added to 0.75 to get 0.75–1.25 multiplier
time.sleep(delay)Block the current thread for the calculated delay
Config(retries={"mode": "adaptive"})botocore built-in adaptive retry with token bucket
__getattr__(self, name)Python dunder called for attribute misses — used for transparent method wrapping

Common Issues

Decorator not retrying — Check that the error code in your ClientError matches one in retryable_errors. Print e.response["Error"]["Code"] to see the exact value.

Jitter causing very long delays — With high max_delay and jitter multiplier > 1, delays can exceed max_delay. The formula delay × (0.75 + random × 0.5) keeps jitter between ×0.75 and ×1.25.

Don’t wrap write operations blindly — Retrying create_security_group on a transient error can create duplicate resources. Add idempotency checks (e.g., check if the resource exists before creating).


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
import functoolsProvides functools.wraps — the key tool for writing proper decorators
from botocore.exceptions import ClientErrorThe exception class for all AWS API errors. Has .response["Error"]["Code"] to identify the error type
from botocore.config import ConfigAllows configuring retry behavior, timeouts, and connection pooling at the client level

aws_retry(max_retries, base_delay, max_delay, jitter, retryable_errors) — The Outer Decorator Factory

def aws_retry(
    max_retries: int   = 5,
    base_delay: float  = 0.5,
    max_delay: float   = 30.0,
    jitter: bool       = True,
    retryable_errors: set = None,
):
ParameterExplanation
max_retries=5Total retry attempts AFTER the first try. So 5 retries = 6 total attempts
base_delay=0.5Wait 0.5 seconds before the first retry. Each subsequent retry doubles this
max_delay=30.0Cap the backoff at 30 seconds — prevents waiting 512s on attempt 10
jitter=TrueMultiplies the delay by a random factor (0.75–1.25). Prevents 100 threads all retrying at the same moment (“thundering herd”)
retryable_errors: set = NoneWhich AWS error codes trigger a retry. None means use the built-in set of throttling/transient codes
if retryable_errors is None:
    retryable_errors = {
        "ThrottlingException",
        "RequestLimitExceeded",
        "SlowDown",
        ...
    }
LineExplanation
retryable_errors is NoneWe use None as default (not set()) because mutable default arguments in Python are shared across calls — a subtle bug. None + this check is the safe pattern
{"ThrottlingException", ...}A set for O(1) lookup. When error_code not in retryable_errors is checked on every exception, set lookup is faster than list search

decorator(func) and wrapper(*args, **kwargs) — The Closure

def decorator(func):
    @functools.wraps(func)
    def wrapper(*args, **kwargs):
LineExplanation
def decorator(func):aws_retry(...) returns decorator. This is the two-level pattern required when a decorator takes arguments. @aws_retry(max_retries=5) calls aws_retry() first, then applies the returned decorator to the function
@functools.wraps(func)Copies func.__name__, func.__doc__, func.__module__, func.__qualname__, func.__annotations__ to wrapper. Without this, your wrapped function’s __name__ would be "wrapper" — breaking logging, tracebacks, and introspection
def wrapper(*args, **kwargs):Accepts any arguments the original function takes and passes them through. The retry logic is completely transparent to callers

The Retry Loop

for attempt in range(max_retries + 1):   # +1: attempt 0 is the initial try
    try:
        return func(*args, **kwargs)
    except ClientError as e:
        error_code = e.response["Error"]["Code"]
        error_msg  = e.response["Error"]["Message"]
        if error_code not in retryable_errors:
            raise
LineExplanation
range(max_retries + 1)If max_retries=5, this is range(6) → attempts 0,1,2,3,4,5. Attempt 0 is the initial call, attempts 1-5 are retries
return func(*args, **kwargs)On success, immediately returns the result. The loop stops here — no more retry overhead
e.response["Error"]["Code"]boto3 puts the AWS error code in e.response["Error"]["Code"]. Examples: "ThrottlingException", "AccessDenied", "NoSuchBucket"
e.response["Error"]["Message"]Human-readable description: "Rate exceeded", "Access Denied", etc.
if error_code not in retryable_errors: raiseFor non-retryable errors (e.g., "AccessDeniedException", "NoSuchBucket"), re-raise immediately — retrying would be pointless and wasteful

Exponential Backoff Formula

delay = min(base_delay * (2 ** attempt), max_delay)
if jitter:
    delay *= (0.75 + random.random() * 0.5)
time.sleep(delay)
LineExplanation
base_delay * (2 ** attempt)Exponential growth: attempt 0 → ×1, attempt 1 → ×2, attempt 2 → ×4, attempt 3 → ×8, etc.
min(..., max_delay)Caps the delay. Without this, attempt 10 would wait 0.5 × 2^10 = 512 seconds
random.random()Returns a float in [0.0, 1.0). Multiply by 0.5 gives [0.0, 0.5). Add 0.75 gives [0.75, 1.25)
delay *= (0.75 + random.random() * 0.5)Jitter: random multiplier between 0.75× and 1.25×. Each thread gets a different delay, spreading retries across time
time.sleep(delay)Blocks the current thread. In a Lambda or single-threaded script, this is fine. In async code (asyncio), you’d use await asyncio.sleep(delay) instead

AWSClientWithRetry.__getattr__ — Transparent Method Wrapping

def __getattr__(self, name: str):
    attr = getattr(self._client, name)
    if callable(attr):
        return aws_retry(max_retries=self._max_retries)(attr)
    return attr
LineExplanation
__getattr__(self, name)Python calls __getattr__ only when the attribute is NOT found through normal lookup. Since describe_instances is not defined on AWSClientWithRetry, Python calls this method with name="describe_instances"
getattr(self._client, name)Gets the actual method from the underlying boto3 client
if callable(attr)callable() returns True for functions and methods, False for properties, strings, etc.
return aws_retry(...)(attr)aws_retry(max_retries=5) returns decorator. Calling decorator(attr) returns the wrapped method. We return it without calling it — the caller will call it
return attrFor non-callable attributes (like meta), return them as-is — no wrapping needed

get_client_with_builtin_retry — botocore’s Built-In Retry

config = Config(
    retries={"mode": "adaptive", "max_attempts": 10},
    connect_timeout=5,
    read_timeout=30,
)
FieldExplanation
"mode": "adaptive"Uses a token bucket algorithm. Tokens are consumed with each retry. If the bucket empties, it waits to replenish. This dynamically adapts to the actual throttle rate
"mode": "standard"Fixed exponential backoff — 3 retries. Simpler but less smart
"mode": "legacy"Original boto3 retry (3 retries, fixed delay). The default if you don’t set mode
"max_attempts": 10Total attempts including the initial call. So 10 = 1 initial + 9 retries
connect_timeout=5Give up connecting to the AWS API endpoint after 5 seconds. Catches DNS failures and network partitions quickly
read_timeout=30Give up waiting for the response body after 30 seconds. Some operations (like large S3 copies) need longer

Production-Grade Python Scripts for AWS — Best Practices & Patterns

The 8 Production Best Practices

#PracticeWhy it matters
1Structured JSON loggingCloudWatch Logs Insights can query JSON fields
2botocore Config with timeoutsHangs are worse than errors
3Credentials via IAM role or Secrets ManagerHardcoded keys are a breach waiting to happen
4Idempotent operationsScript should be safe to run twice
5Environment-driven configurationSame code runs in dev/staging/prod
6Dry-run modeDestructive scripts must be testable safely
7Graceful main() with typed exit codesCI/CD pipelines read exit codes
8Input validation at boundariesCatch bad input before making AWS API calls

Complete Reference Script

"""
production_aws_script.py

Template for production-grade AWS automation scripts.
Demonstrates all 8 best practices with annotated examples.
"""

import boto3
import logging
import os
import sys
import json
from botocore.config import Config
from botocore.exceptions import ClientError, EndpointConnectionError, NoCredentialsError
from typing import Optional
from dataclasses import dataclass, field


# ══════════════════════════════════════════════════════════════════
# BEST PRACTICE 1: Structured JSON Logging
# ══════════════════════════════════════════════════════════════════
def setup_logger(name: str) -> logging.Logger:
    """
    Structured JSON logging is essential for CloudWatch Logs Insights
    and log aggregation tools (Datadog, Splunk, ELK).

    CloudWatch Logs Insights query example:
      fields @timestamp, level, message
      | filter level = "ERROR"
      | sort @timestamp desc

    We write to stdout (not stderr) so logs appear in Lambda/ECS
    container logs without mixing with error streams.
    """
    logger = logging.getLogger(name)
    handler = logging.StreamHandler(sys.stdout)

    class JsonFormatter(logging.Formatter):
        def format(self, record: logging.LogRecord) -> str:
            return json.dumps({
                "time":    self.formatTime(record, "%Y-%m-%dT%H:%M:%SZ"),
                "level":   record.levelname,
                "logger":  record.name,
                "message": record.getMessage(),
                # Include exception info if present
                **({"exception": self.formatException(record.exc_info)}
                   if record.exc_info else {}),
            })

    handler.setFormatter(JsonFormatter())
    logger.addHandler(handler)
    # Read log level from env — "INFO" in prod, "DEBUG" in dev
    logger.setLevel(os.environ.get("LOG_LEVEL", "INFO").upper())
    return logger

logger = setup_logger("aws-automation")


# ══════════════════════════════════════════════════════════════════
# BEST PRACTICE 2: botocore Config with Timeouts and Retries
# ══════════════════════════════════════════════════════════════════
BOTO_CONFIG = Config(
    # Adaptive retry uses a token bucket algorithm:
    # - Token bucket starts full; each retry consumes tokens
    # - Tokens regenerate over time
    # - Better than "standard" mode for burst throttling scenarios
    retries={
        "mode":         "adaptive",
        "max_attempts": 10,
    },
    # connect_timeout: seconds to wait for TCP connection to AWS API
    # read_timeout: seconds to wait for the API response body
    # Lower values = faster failure detection for network issues
    connect_timeout=5,
    read_timeout=30,
    # max_pool_connections: max persistent HTTP connections in the pool
    # Increase for high-concurrency scripts that make many parallel calls
    max_pool_connections=10,
)


# ══════════════════════════════════════════════════════════════════
# BEST PRACTICE 3: Credential Management
# ══════════════════════════════════════════════════════════════════
def get_client(
    service: str,
    region: Optional[str] = None,
    role_arn: Optional[str] = None,
) -> boto3.client:
    """
    NEVER hardcode credentials. This function provides a safe pattern
    for getting a boto3 client with proper credential handling.

    Credential resolution order (automatic):
    1. role_arn argument → assume cross-account role
    2. AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY env vars
    3. ~/.aws/credentials file
    4. IAM role attached to the EC2/ECS/Lambda (best for production)

    If role_arn is provided, we use STS to assume that role first.
    This is the standard pattern for cross-account access.
    """
    region = region or os.environ.get("AWS_DEFAULT_REGION", "us-east-1")

    if role_arn:
        sts = boto3.client("sts", config=BOTO_CONFIG)
        assumed = sts.assume_role(
            RoleArn=role_arn,
            RoleSessionName=f"automation-{service}",
        )["Credentials"]
        return boto3.client(
            service,
            region_name=region,
            aws_access_key_id=assumed["AccessKeyId"],
            aws_secret_access_key=assumed["SecretAccessKey"],
            aws_session_token=assumed["SessionToken"],
            config=BOTO_CONFIG,
        )

    return boto3.client(service, region_name=region, config=BOTO_CONFIG)


def get_secret(secret_name: str, region: str = "us-east-1") -> dict:
    """
    Retrieve a JSON-encoded secret from AWS Secrets Manager.

    This replaces hardcoded passwords, API keys, and connection strings.
    Secrets Manager also handles rotation automatically.

    Usage in code:
        db_config = get_secret("prod/postgres/app-user")
        conn = psycopg2.connect(
            host=db_config["host"],
            password=db_config["password"],
        )
    """
    sm = get_client("secretsmanager", region=region)
    try:
        response = sm.get_secret_value(SecretId=secret_name)
        # SecretString is the JSON string of your secret
        return json.loads(response["SecretString"])
    except ClientError as e:
        error_code = e.response["Error"]["Code"]
        if error_code == "ResourceNotFoundException":
            raise ValueError(f"Secret not found: {secret_name}") from e
        if error_code == "AccessDeniedException":
            raise PermissionError(
                f"IAM role lacks secretsmanager:GetSecretValue on {secret_name}"
            ) from e
        raise


# ══════════════════════════════════════════════════════════════════
# BEST PRACTICE 4: Idempotent Operations
# ══════════════════════════════════════════════════════════════════
def create_s3_bucket_idempotent(bucket_name: str, region: str = "us-east-1") -> bool:
    """
    Idempotent: calling this function twice has the same effect as once.
    The script is safe to re-run after a failure without creating duplicates.

    S3 create_bucket raises BucketAlreadyOwnedByYou if the bucket exists
    AND is owned by your account — we treat this as success.

    BucketAlreadyExists means another account owns a bucket with that name —
    you'll need to choose a different name (S3 bucket names are global).
    """
    s3 = get_client("s3", region=region)
    try:
        # us-east-1 does NOT accept CreateBucketConfiguration
        kwargs = {"Bucket": bucket_name}
        if region != "us-east-1":
            kwargs["CreateBucketConfiguration"] = {"LocationConstraint": region}

        s3.create_bucket(**kwargs)
        logger.info(f"Created bucket: {bucket_name}")
        return True

    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code in ("BucketAlreadyOwnedByYou",):
            logger.info(f"Bucket already exists (owned by you): {bucket_name}")
            return True   # Idempotent success
        if code == "BucketAlreadyExists":
            raise ValueError(
                f"Bucket name taken by another account: {bucket_name}"
            ) from e
        raise


# ══════════════════════════════════════════════════════════════════
# BEST PRACTICE 5: Environment-Driven Configuration
# ══════════════════════════════════════════════════════════════════
@dataclass
class AppConfig:
    """
    All configuration comes from environment variables.
    This allows the same code to run in dev/staging/prod by just
    changing environment variables — no code changes needed.

    @dataclass generates __init__, __repr__, __eq__ automatically.
    field(default_factory=...) handles mutable defaults safely.
    """
    # Required (raise if missing)
    sns_topic_arn:  str = field(default_factory=lambda: os.environ["SNS_TOPIC_ARN"])
    s3_bucket:      str = field(default_factory=lambda: os.environ["S3_BUCKET"])

    # Optional with defaults
    environment:   str   = field(default_factory=lambda: os.environ.get("ENVIRONMENT", "dev"))
    region:        str   = field(default_factory=lambda: os.environ.get("AWS_DEFAULT_REGION", "us-east-1"))
    dry_run:       bool  = field(default_factory=lambda: os.environ.get("DRY_RUN", "false").lower() == "true")
    log_level:     str   = field(default_factory=lambda: os.environ.get("LOG_LEVEL", "INFO"))
    role_arn:      Optional[str] = field(default_factory=lambda: os.environ.get("ASSUME_ROLE_ARN"))

    def validate(self) -> None:
        """Validate config on startup rather than failing mid-execution."""
        errors = []
        if not self.sns_topic_arn.startswith("arn:aws:sns:"):
            errors.append(f"Invalid SNS_TOPIC_ARN: {self.sns_topic_arn}")
        if not self.s3_bucket:
            errors.append("S3_BUCKET is empty")
        if self.environment not in ("dev", "staging", "prod"):
            errors.append(f"Unknown ENVIRONMENT: {self.environment}")
        if errors:
            raise EnvironmentError(
                f"Configuration errors:\n" + "\n".join(f"  - {e}" for e in errors)
            )


# ══════════════════════════════════════════════════════════════════
# BEST PRACTICE 6: Dry-Run Mode
# ══════════════════════════════════════════════════════════════════
def delete_old_objects(
    bucket: str,
    prefix: str,
    older_than_days: int,
    dry_run: bool = True,
) -> list[str]:
    """
    dry_run=True (default) → reports what would be deleted without deleting.
    dry_run=False → performs actual deletion.

    Always default to dry_run=True. The caller must explicitly opt in
    to destructive operations.
    """
    from datetime import datetime, timezone, timedelta
    s3 = get_client("s3")
    cutoff = datetime.now(timezone.utc) - timedelta(days=older_than_days)
    would_delete = []

    paginator = s3.get_paginator("list_objects_v2")
    for page in paginator.paginate(Bucket=bucket, Prefix=prefix):
        for obj in page.get("Contents", []):
            if obj["LastModified"] < cutoff:
                would_delete.append(obj["Key"])
                if dry_run:
                    logger.info(f"[DRY-RUN] Would delete: s3://{bucket}/{obj['Key']}")
                else:
                    s3.delete_object(Bucket=bucket, Key=obj["Key"])
                    logger.info(f"Deleted: s3://{bucket}/{obj['Key']}")

    action = "Would delete" if dry_run else "Deleted"
    logger.info(f"{action} {len(would_delete)} object(s) from s3://{bucket}/{prefix}")
    return would_delete


# ══════════════════════════════════════════════════════════════════
# BEST PRACTICE 7: Graceful main() with Exit Codes
# ══════════════════════════════════════════════════════════════════
def main() -> int:
    """
    main() returns an integer exit code — 0 for success, non-zero for failure.
    sys.exit() translates this to the process exit code.
    CI/CD pipelines (GitHub Actions, Jenkins) read the exit code:
      - 0 = step succeeded → continue pipeline
      - Non-zero = step failed → stop pipeline, send alert

    We catch specific exceptions at the top level and map them to
    meaningful exit codes rather than a generic "something failed".
    """
    try:
        config = AppConfig()
        config.validate()

        logger.info(f"Starting in {config.environment} | dry_run={config.dry_run}")

        # Your script logic here
        create_s3_bucket_idempotent(config.s3_bucket, config.region)

        deleted = delete_old_objects(
            bucket=config.s3_bucket,
            prefix="temp/",
            older_than_days=30,
            dry_run=config.dry_run,
        )

        logger.info("Script completed successfully", extra={"objects_processed": len(deleted)})
        return 0   # ← success

    except KeyError as e:
        logger.critical(f"Missing required environment variable: {e}")
        return 1   # ← config/environment error

    except EnvironmentError as e:
        logger.critical(f"Configuration validation failed: {e}")
        return 1

    except NoCredentialsError:
        logger.critical("AWS credentials not found. Run 'aws configure' or attach an IAM role.")
        return 2   # ← credential error

    except EndpointConnectionError as e:
        logger.critical(f"Cannot reach AWS API endpoint: {e}")
        return 3   # ← network error

    except PermissionError as e:
        logger.critical(f"IAM permission denied: {e}")
        return 4   # ← authorization error

    except ClientError as e:
        logger.critical(
            f"AWS API error: {e.response['Error']['Code']} — {e.response['Error']['Message']}"
        )
        return 5   # ← AWS API error

    except Exception as e:
        logger.critical(f"Unexpected error: {e}", exc_info=True)
        return 99  # ← unknown error


# ══════════════════════════════════════════════════════════════════
# BEST PRACTICE 8: Input Validation at System Boundaries
# ══════════════════════════════════════════════════════════════════
def validate_s3_key(key: str) -> str:
    """
    Validate and sanitize user-provided S3 keys before using them in API calls.
    S3 keys can be up to 1,024 bytes. They may not start with '..'.
    """
    if not key:
        raise ValueError("S3 key cannot be empty")
    if len(key.encode("utf-8")) > 1024:
        raise ValueError(f"S3 key exceeds 1,024 bytes: {key[:50]}...")
    if key.startswith(".."):
        raise ValueError(f"S3 key cannot start with '..': {key}")
    # Normalize: remove leading slashes
    return key.lstrip("/")


if __name__ == "__main__":
    sys.exit(main())

Key Commands & Patterns Explained

PatternWhat it does
logging.StreamHandler(sys.stdout)Write logs to stdout (captured by Lambda, ECS, Kubernetes)
JsonFormatterConverts log records to JSON strings for structured logging
Config(retries={"mode": "adaptive"})boto3 adaptive retry with token bucket algorithm
Config(connect_timeout=5, read_timeout=30)Prevents scripts from hanging on network issues
sts.assume_role(RoleArn, RoleSessionName)Gets temporary creds for cross-account or cross-service access
sm.get_secret_value(SecretId=name)Retrieves secret — never hardcode credentials
catch BucketAlreadyOwnedByYou → return TrueIdempotent create — safe to run multiple times
os.environ["KEY"]Raises KeyError if missing — fails fast at startup
os.environ.get("KEY", "default")Optional config with sensible default
dry_run=True as default parameterForces caller to opt-in to destructive operations
sys.exit(main())Translates return value to OS exit code

Environment Variables Reference

# Required
export SNS_TOPIC_ARN="arn:aws:sns:us-east-1:123456789012:alerts"
export S3_BUCKET="my-app-prod-data"

# Optional with defaults
export ENVIRONMENT="prod"
export AWS_DEFAULT_REGION="ap-south-1"
export DRY_RUN="false"
export LOG_LEVEL="INFO"
export ASSUME_ROLE_ARN="arn:aws:iam::999999999999:role/CrossAccountRole"

🔍 Line-by-Line Code Walkthrough

Best Practice 1 — Structured JSON Logging

class JsonFormatter(logging.Formatter):
    def format(self, record: logging.LogRecord) -> str:
        return json.dumps({
            "time":    self.formatTime(record, "%Y-%m-%dT%H:%M:%SZ"),
            "level":   record.levelname,
            "logger":  record.name,
            "message": record.getMessage(),
            **({"exception": self.formatException(record.exc_info)}
               if record.exc_info else {}),
        })
LineExplanation
class JsonFormatter(logging.Formatter)Subclasses the built-in Python formatter. We override only format() — everything else (handler attachment, level filtering) stays the same
def format(self, record: logging.LogRecord)Called by the logging system for every log statement. record contains all metadata: message, level, name, timestamp, exception info
self.formatTime(record, "%Y-%m-%dT%H:%M:%SZ")Converts the log timestamp to ISO 8601 format (e.g., "2025-01-20T14:30:00Z"). CloudWatch Logs Insights recognizes this format and shows a timeline
record.levelnameThe string level: "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"
record.getMessage()Returns the formatted log message. Calling .getMessage() (not .message) ensures %s formatting and f-string substitution is applied
**({"exception": ...} if record.exc_info else {})Dictionary unpacking with a conditional. If exc_info is set (i.e., logged with logger.error("msg", exc_info=True)), adds the "exception" key. If not, adds nothing
handler = logging.StreamHandler(sys.stdout)Writes to stdout (not stderr). Lambda, ECS, and Kubernetes capture stdout for log aggregation
logger.setLevel(os.environ.get("LOG_LEVEL", "INFO").upper())Log level from environment variable. "DEBUG" in dev, "INFO" in prod. .upper() handles "info" or "Info" inputs

Best Practice 2 — botocore Config

BOTO_CONFIG = Config(
    retries={"mode": "adaptive", "max_attempts": 10},
    connect_timeout=5,
    read_timeout=30,
    max_pool_connections=10,
)
FieldExplanation
retries={"mode": "adaptive"}Token bucket algorithm: when throttled, waits for tokens to regenerate before retrying. Smarter than fixed backoff because it adapts to the actual throttle rate
"max_attempts": 10Total call attempts including the first. So 10 = 1 initial + 9 retries
connect_timeout=5Raise EndpointConnectionError if the TCP handshake to the AWS API doesn’t complete in 5 seconds. Catches DNS failures and network partitions
read_timeout=30Raise ReadTimeoutError if the API response doesn’t arrive within 30 seconds. Prevents infinite hangs
max_pool_connections=10Maximum concurrent HTTP connections in the connection pool. If your script makes 20 parallel calls, increase this to avoid connection waiting
Where to apply itPass config=BOTO_CONFIG to every boto3.client() call: boto3.client("ec2", config=BOTO_CONFIG)

Best Practice 3 — Credential Management

region = region or os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
if role_arn:
    sts = boto3.client("sts", config=BOTO_CONFIG)
    assumed = sts.assume_role(
        RoleArn=role_arn,
        RoleSessionName=f"automation-{service}",
    )["Credentials"]
    return boto3.client(
        service,
        aws_access_key_id=assumed["AccessKeyId"],
        aws_secret_access_key=assumed["SecretAccessKey"],
        aws_session_token=assumed["SessionToken"],
        config=BOTO_CONFIG,
    )
LineExplanation
region or os.environ.get(...)If region argument is None (not provided), fall back to the env var, then to "us-east-1". The or short-circuits: truthy region skips the env var lookup
sts.assume_role(RoleArn=..., RoleSessionName=...)Exchanges the current identity for temporary credentials in another role/account. Returns {"Credentials": {"AccessKeyId": ..., "SecretAccessKey": ..., "SessionToken": ...}}
["Credentials"]Immediately indexes into the response to get the credential dict
aws_session_token=assumed["SessionToken"]Assumed role credentials ALWAYS require a session token — omitting it causes InvalidClientTokenId errors
No role_arn pathreturn boto3.client(service, ...) — boto3 uses the automatic credential chain: env vars → ~/.aws/credentials → EC2/Lambda instance metadata

get_secret(secret_name) — Secrets Manager Retrieval

response = sm.get_secret_value(SecretId=secret_name)
return json.loads(response["SecretString"])
LineExplanation
get_secret_value(SecretId=name)SecretId can be the secret’s full ARN or its name. Returns a dict with "SecretString" (for text secrets) or "SecretBinary" (for binary)
response["SecretString"]The raw string value of the secret — typically a JSON string like '{"host":"db.example.com","password":"abc123"}'
json.loads(response["SecretString"])Parses the JSON string into a Python dict so callers can do secret["host"], secret["password"]
if error_code == "ResourceNotFoundException"Raises a descriptive ValueError instead of a raw boto3 error — easier to understand in logs
raise ... from eChains the original exception. The original ClientError is preserved in __cause__ for debugging

Best Practice 4 — create_s3_bucket_idempotent

kwargs = {"Bucket": bucket_name}
if region != "us-east-1":
    kwargs["CreateBucketConfiguration"] = {"LocationConstraint": region}
s3.create_bucket(**kwargs)
LineExplanation
if region != "us-east-1"S3 quirk: us-east-1 is the global default region and does not accept CreateBucketConfiguration. Every other region requires it. Passing it for us-east-1 raises InvalidLocationConstraint
{"LocationConstraint": region}Tells S3 to create the bucket in the specified region. Without it, the bucket would be created in us-east-1 regardless
if code in ("BucketAlreadyOwnedByYou",):Using a tuple for the in test is idiomatic for future extensibility. BucketAlreadyOwnedByYou means your account already has this bucket — treat as success
return True both timesWhether we created it or it already existed, the desired state is achieved. That’s idempotency

Best Practice 5 — AppConfig Dataclass

@dataclass
class AppConfig:
    sns_topic_arn: str = field(default_factory=lambda: os.environ["SNS_TOPIC_ARN"])
    dry_run: bool      = field(default_factory=lambda: os.environ.get("DRY_RUN", "false").lower() == "true")
LineExplanation
@dataclassAuto-generates __init__, __repr__, __eq__. The __init__ calls each default_factory lazily when the object is created
field(default_factory=lambda: ...)default_factory is a callable. The lambda is called at instantiation time — not at class definition time. This means the env var is read when AppConfig() is called, not at import
os.environ["SNS_TOPIC_ARN"]Raises KeyError if the variable is missing — which propagates to main() and gets caught as except KeyError → return 1. This is “fail fast” — don’t let the script run with incomplete config
os.environ.get("DRY_RUN", "false").lower() == "true"Reads the string "true", "True", or "TRUE" and converts to Python bool. .lower() makes it case-insensitive
config.validate()Called after construction. Validates all fields together, so you get one error message listing all problems instead of crashing on the first

Best Practice 6 — Dry-Run Mode

def delete_old_objects(bucket, prefix, older_than_days, dry_run: bool = True) -> list[str]:
    if dry_run:
        logger.info(f"[DRY-RUN] Would delete: s3://{bucket}/{obj['Key']}")
    else:
        s3.delete_object(Bucket=bucket, Key=obj["Key"])
LineExplanation
dry_run: bool = TrueDefault is True — you must explicitly pass dry_run=False to delete. This prevents accidental deletions from missing a flag
[DRY-RUN] prefix in logMakes dry-run output visually distinct in logs. Operators can grep for [DRY-RUN] to review what WOULD happen
Function returns the same list in both modesCallers can inspect what was (or would be) deleted without branching on dry_run themselves

Best Practice 7 — main() with Exit Codes

def main() -> int:
    try:
        ...
        return 0
    except KeyError as e:
        logger.critical(f"Missing required environment variable: {e}")
        return 1
    except NoCredentialsError:
        return 2
    except EndpointConnectionError:
        return 3
    except PermissionError:
        return 4
    except ClientError:
        return 5
    except Exception:
        return 99

if __name__ == "__main__":
    sys.exit(main())
LineExplanation
def main() -> intReturns an integer. This separates the script logic from the OS interface — makes main() testable (just check the return value)
return 0Unix convention for success. CI/CD systems (GitHub Actions, Jenkins) only continue the pipeline if the step exits 0
return 1 (KeyError/EnvironmentError)Config/environment problem — operator needs to set env vars
return 2 (NoCredentialsError)Credentials missing — aws configure or attach IAM role
return 3 (EndpointConnectionError)Network issue — can’t reach AWS API
return 4 (PermissionError)IAM policy missing a permission
return 5 (ClientError)AWS API rejected the call
return 99Unexpected/unknown error — always log with exc_info=True for full traceback
sys.exit(main())sys.exit(0) sets process exit code 0. sys.exit(5) sets exit code 5. Scripts (and CI) read this
if __name__ == "__main__":Prevents main() from running when this module is imported. Only runs when executed directly (python production_aws_script.py)

Clean Up Unused AWS Resources — EBS Volumes, EIPs, Old AMIs with Cost Report

Resource Cost Overview

ResourceApprox. CostWhen it wastes money
EBS gp3 volume$0.08/GB/monthWhen not attached to any instance
Elastic IP$0.005/hour (~$3.60/month)When not associated with a running instance
AMI snapshot$0.05/GB/monthWhen older than N generations (usually keep last 3)

Complete Script

"""
aws_resource_cleanup.py

Find and optionally delete unused AWS resources to reduce costs.
Run with DRY_RUN=true (default) to see what would be deleted first.
"""

import boto3
import json
import logging
import os
import sys
from datetime import datetime, timezone
from dataclasses import dataclass, field
from collections import defaultdict

logger = logging.getLogger(__name__)
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s"
)


@dataclass
class CleanupReport:
    """Accumulates findings and savings estimates across all cleanup operations."""
    # Lists of resources found/deleted
    ebs_volumes:  list = field(default_factory=list)
    eips:         list = field(default_factory=list)
    amis:         list = field(default_factory=list)

    # Cost estimates
    ebs_savings_per_month:  float = 0.0
    eip_savings_per_month:  float = 0.0
    ami_savings_per_month:  float = 0.0

    @property
    def total_savings_per_month(self) -> float:
        return self.ebs_savings_per_month + self.eip_savings_per_month + self.ami_savings_per_month


class AWSResourceCleaner:
    """
    Finds and removes unused AWS resources in a single region.

    All destructive operations are gated by dry_run=True.
    Always run with dry_run=True first to review what will be deleted.
    """

    # Rough pricing — varies by region; adjust if needed
    EBS_PRICE_PER_GB_MONTH = {
        "gp2": 0.10,
        "gp3": 0.08,
        "io1": 0.125,
        "io2": 0.125,
        "st1": 0.045,
        "sc1": 0.015,
        "standard": 0.05,
    }
    EIP_PRICE_PER_HOUR      = 0.005   # When not associated
    SNAPSHOT_PRICE_PER_GB   = 0.05    # Per GB per month

    def __init__(self, region: str = "us-east-1", dry_run: bool = True):
        """
        region:   AWS region to audit (one region at a time).
        dry_run:  When True, reports findings but does NOT delete anything.
                  Set to False only after reviewing the dry-run output.
        """
        self.ec2     = boto3.client("ec2", region_name=region)
        self.region  = region
        self.dry_run = dry_run
        self.report  = CleanupReport()

        if dry_run:
            logger.info("Running in DRY-RUN mode — no resources will be deleted")
        else:
            logger.warning("Running in LIVE mode — resources WILL be deleted!")

    # ══════════════════════════════════════════════════════════════════
    # Part 1: Unattached EBS Volumes
    # ══════════════════════════════════════════════════════════════════
    def clean_unattached_ebs_volumes(self) -> list[dict]:
        """
        Find EBS volumes in 'available' state — these are not attached
        to any instance and accumulating charges.

        Volume lifecycle states:
          creating  → available (not attached) → in-use (attached) → deleting → deleted
          also: error, recovering

        We paginate describe_volumes with Filters so that only
        'available' volumes are returned — no need to filter client-side.

        For each volume we estimate the monthly waste and log it.
        """
        logger.info(f"[EBS] Scanning for unattached volumes in {self.region}")
        found = []

        paginator = self.ec2.get_paginator("describe_volumes")
        for page in paginator.paginate(
            Filters=[{"Name": "status", "Values": ["available"]}]
        ):
            for vol in page["Volumes"]:
                volume_id   = vol["VolumeId"]
                size_gb     = vol["Size"]
                vol_type    = vol["VolumeType"]
                create_time = vol["CreateTime"]
                tags        = {t["Key"]: t["Value"] for t in vol.get("Tags", [])}
                name        = tags.get("Name", "")

                # Calculate days idle (how long since creation with no attachment)
                # Note: real idle time requires checking attachment history, which
                # is not in the EC2 API. We use creation time as a proxy.
                days_old = (datetime.now(timezone.utc) - create_time).days

                # Estimate monthly cost
                price_per_gb = self.EBS_PRICE_PER_GB_MONTH.get(vol_type, 0.08)
                monthly_cost = size_gb * price_per_gb

                entry = {
                    "volume_id":    volume_id,
                    "name":         name,
                    "size_gb":      size_gb,
                    "type":         vol_type,
                    "days_old":     days_old,
                    "monthly_cost": round(monthly_cost, 2),
                    "deleted":      False,
                }

                logger.info(
                    f"[EBS] Found unattached: {volume_id} ({name}) "
                    f"{size_gb} GB {vol_type} — ${monthly_cost:.2f}/mo"
                )

                if not self.dry_run:
                    try:
                        # delete_volume() permanently destroys the EBS volume.
                        # This is IRREVERSIBLE — data is gone.
                        # The volume must be in 'available' state (not attached).
                        self.ec2.delete_volume(VolumeId=volume_id)
                        entry["deleted"] = True
                        logger.info(f"[EBS] Deleted: {volume_id}")
                        self.report.ebs_savings_per_month += monthly_cost
                    except self.ec2.exceptions.ClientError as e:
                        logger.error(f"[EBS] Failed to delete {volume_id}: {e}")
                else:
                    # In dry-run mode, still accumulate savings estimate
                    self.report.ebs_savings_per_month += monthly_cost

                found.append(entry)

        self.report.ebs_volumes = found
        logger.info(
            f"[EBS] Found {len(found)} unattached volumes "
            f"(${self.report.ebs_savings_per_month:.2f}/mo potential savings)"
        )
        return found

    # ══════════════════════════════════════════════════════════════════
    # Part 2: Unassociated Elastic IPs
    # ══════════════════════════════════════════════════════════════════
    def clean_unassociated_eips(self) -> list[dict]:
        """
        Find Elastic IPs not associated with any running instance or
        network interface. AWS charges $0.005/hour for idle EIPs.

        describe_addresses() returns ALL EIPs in the region.
        An EIP is unassociated if it has no AssociationId field.

        Two allocation domains:
          vpc:      EIP allocated for use in a VPC (AllocationId exists)
          standard: Legacy EC2-Classic (almost extinct, treat same way)

        release_address() returns the EIP to the AWS pool.
        You can no longer use that specific IP after this call.
        """
        logger.info(f"[EIP] Scanning for unassociated Elastic IPs in {self.region}")
        found = []

        # describe_addresses() is NOT paginated — returns all at once
        response = self.ec2.describe_addresses()

        for addr in response["Addresses"]:
            # Skip EIPs that are associated (in use)
            if "AssociationId" in addr:
                continue

            allocation_id = addr.get("AllocationId", "")
            public_ip     = addr["PublicIp"]
            tags          = {t["Key"]: t["Value"] for t in addr.get("Tags", [])}
            name          = tags.get("Name", "")

            # Cost: $0.005/hour × 24h × 30.5 days ≈ $3.65/month
            monthly_cost  = self.EIP_PRICE_PER_HOUR * 24 * 30.5

            entry = {
                "allocation_id": allocation_id,
                "public_ip":     public_ip,
                "name":          name,
                "monthly_cost":  round(monthly_cost, 2),
                "released":      False,
            }

            logger.info(
                f"[EIP] Found idle: {public_ip} ({name}) "
                f"— ${monthly_cost:.2f}/mo"
            )

            if not self.dry_run and allocation_id:
                try:
                    # release_address() returns this EIP to the AWS pool.
                    # Use AllocationId for VPC EIPs (not PublicIp).
                    self.ec2.release_address(AllocationId=allocation_id)
                    entry["released"] = True
                    logger.info(f"[EIP] Released: {public_ip} ({allocation_id})")
                    self.report.eip_savings_per_month += monthly_cost
                except self.ec2.exceptions.ClientError as e:
                    logger.error(f"[EIP] Failed to release {public_ip}: {e}")
            else:
                self.report.eip_savings_per_month += monthly_cost

            found.append(entry)

        self.report.eips = found
        logger.info(
            f"[EIP] Found {len(found)} unassociated EIPs "
            f"(${self.report.eip_savings_per_month:.2f}/mo potential savings)"
        )
        return found

    # ══════════════════════════════════════════════════════════════════
    # Part 3: Old AMIs — Keep Only N Most Recent Per Name Prefix
    # ══════════════════════════════════════════════════════════════════
    def clean_old_amis(self, keep_count: int = 3, name_prefix: str = "") -> list[dict]:
        """
        Delete AMIs older than the N most recent for each name prefix group.

        Strategy:
          - Group AMIs by name prefix (e.g., "app-server-*")
          - Sort each group by creation date, newest first
          - Keep the first `keep_count` AMIs
          - Deregister the rest, then delete their backing snapshots

        Two-step deletion:
          1. deregister_image(ImageId)   — removes the AMI registration
             (cannot launch new instances from it, but snapshots still exist)
          2. delete_snapshot(SnapshotId) — deletes the actual EBS snapshot
             (this is what actually frees storage and stops billing)

        Skipping these steps means orphaned snapshots continue to bill you
        even after the AMI is deregistered.

        OwnerIds=["self"] limits results to AMIs owned by THIS account.
        Without this, describe_images() could return AWS marketplace AMIs.
        """
        logger.info(
            f"[AMI] Scanning for old AMIs in {self.region} "
            f"(keep latest {keep_count} per name prefix)"
        )
        found = []

        # Fetch all AMIs owned by this account
        filters = [{"Name": "state", "Values": ["available"]}]
        if name_prefix:
            filters.append({"Name": "name", "Values": [f"{name_prefix}*"]})

        response = self.ec2.describe_images(
            OwnerIds=["self"],
            Filters=filters,
        )
        all_amis = response["Images"]

        # Group AMIs by name prefix (first word before a timestamp/version separator)
        # e.g., "app-server-20250120" → group key "app-server"
        groups: dict[str, list] = defaultdict(list)
        for ami in all_amis:
            ami_name  = ami.get("Name", "")
            # Split on common separators: -, _, space; take first 2 parts as prefix
            parts = ami_name.replace("_", "-").split("-")
            # Use first 2 dash-parts as the grouping key (customise as needed)
            group_key = "-".join(parts[:2]) if len(parts) >= 2 else ami_name
            groups[group_key].append(ami)

        for group_key, amis_in_group in groups.items():
            # Sort newest first by creation date
            # CreationDate format: "2025-01-20T14:30:00.000Z" — lexicographic sort works
            amis_sorted = sorted(
                amis_in_group, key=lambda a: a["CreationDate"], reverse=True
            )
            to_delete = amis_sorted[keep_count:]   # Everything after the N most recent

            for ami in to_delete:
                image_id      = ami["ImageId"]
                ami_name      = ami.get("Name", "")
                creation_date = ami["CreationDate"]

                # Collect snapshot IDs from the AMI's block device mappings
                # Each AMI has one or more EBS snapshots backing its volumes
                snapshot_ids = [
                    bdm["Ebs"]["SnapshotId"]
                    for bdm in ami.get("BlockDeviceMappings", [])
                    if "Ebs" in bdm and "SnapshotId" in bdm["Ebs"]
                ]

                # Estimate snapshot storage size
                total_size_gb = sum(
                    bdm["Ebs"].get("VolumeSize", 0)
                    for bdm in ami.get("BlockDeviceMappings", [])
                    if "Ebs" in bdm
                )
                monthly_cost = total_size_gb * self.SNAPSHOT_PRICE_PER_GB

                entry = {
                    "image_id":     image_id,
                    "name":         ami_name,
                    "group":        group_key,
                    "created":      creation_date,
                    "snapshots":    snapshot_ids,
                    "size_gb":      total_size_gb,
                    "monthly_cost": round(monthly_cost, 2),
                    "deregistered": False,
                    "snapshots_deleted": [],
                }

                logger.info(
                    f"[AMI] Old AMI: {image_id} ({ami_name}) "
                    f"created {creation_date[:10]} "
                    f"— ${monthly_cost:.2f}/mo in snapshots"
                )

                if not self.dry_run:
                    try:
                        # Step 1: Deregister the AMI
                        # After this, you cannot launch new instances from this AMI.
                        # Existing running instances are NOT affected.
                        self.ec2.deregister_image(ImageId=image_id)
                        entry["deregistered"] = True
                        logger.info(f"[AMI] Deregistered: {image_id}")

                        # Step 2: Delete each backing snapshot
                        for snap_id in snapshot_ids:
                            try:
                                self.ec2.delete_snapshot(SnapshotId=snap_id)
                                entry["snapshots_deleted"].append(snap_id)
                                logger.info(f"[AMI] Deleted snapshot: {snap_id}")
                            except self.ec2.exceptions.ClientError as e:
                                logger.warning(
                                    f"[AMI] Could not delete snapshot {snap_id}: {e}"
                                )

                        self.report.ami_savings_per_month += monthly_cost

                    except self.ec2.exceptions.ClientError as e:
                        logger.error(f"[AMI] Failed to deregister {image_id}: {e}")
                else:
                    self.report.ami_savings_per_month += monthly_cost

                found.append(entry)

        self.report.amis = found
        logger.info(
            f"[AMI] Found {len(found)} old AMIs "
            f"(${self.report.ami_savings_per_month:.2f}/mo potential savings)"
        )
        return found

    # ══════════════════════════════════════════════════════════════════
    # Part 4: Generate Cost-Savings Report
    # ══════════════════════════════════════════════════════════════════
    def generate_report(self, output_file: str = "cleanup_report.json") -> dict:
        """
        Write a JSON cost-savings report with all findings.
        The report is machine-readable (can be sent to Slack, stored in S3,
        or imported into a spreadsheet).
        """
        report_data = {
            "generated_at":  datetime.now(timezone.utc).isoformat(),
            "region":        self.region,
            "dry_run":       self.dry_run,
            "summary": {
                "ebs_volumes_found":     len(self.report.ebs_volumes),
                "eips_found":            len(self.report.eips),
                "amis_found":            len(self.report.amis),
                "ebs_savings_per_month": round(self.report.ebs_savings_per_month, 2),
                "eip_savings_per_month": round(self.report.eip_savings_per_month, 2),
                "ami_savings_per_month": round(self.report.ami_savings_per_month, 2),
                "total_savings_per_month": round(self.report.total_savings_per_month, 2),
                "total_savings_per_year":  round(self.report.total_savings_per_month * 12, 2),
            },
            "ebs_volumes": self.report.ebs_volumes,
            "eips":        self.report.eips,
            "amis":        self.report.amis,
        }

        # json.dump with default=str handles datetime objects
        with open(output_file, "w") as f:
            json.dump(report_data, f, indent=2, default=str)

        self._print_summary(report_data["summary"])
        logger.info(f"Full report written to {output_file}")
        return report_data

    def _print_summary(self, summary: dict) -> None:
        """Print a human-readable cost summary to console."""
        mode = "DRY-RUN estimate" if self.dry_run else "Actual savings"
        print(f"\n{'='*60}")
        print(f"AWS RESOURCE CLEANUP REPORT — {self.region.upper()}")
        print(f"Mode: {mode}")
        print(f"{'='*60}")
        print(f"  Unattached EBS volumes: {summary['ebs_volumes_found']:>4}  ${summary['ebs_savings_per_month']:>8.2f}/mo")
        print(f"  Unassociated EIPs:      {summary['eips_found']:>4}  ${summary['eip_savings_per_month']:>8.2f}/mo")
        print(f"  Old AMIs:               {summary['amis_found']:>4}  ${summary['ami_savings_per_month']:>8.2f}/mo")
        print(f"{'─'*60}")
        print(f"  TOTAL POTENTIAL SAVINGS:      ${summary['total_savings_per_month']:>8.2f}/mo")
        print(f"  ANNUALIZED:                   ${summary['total_savings_per_year']:>8.2f}/yr")
        print(f"{'='*60}\n")


# ── Entry point ───────────────────────────────────────────────────
def main() -> int:
    region  = os.environ.get("AWS_DEFAULT_REGION", "us-east-1")
    dry_run = os.environ.get("DRY_RUN", "true").lower() != "false"

    cleaner = AWSResourceCleaner(region=region, dry_run=dry_run)

    # Run all three cleanup operations
    cleaner.clean_unattached_ebs_volumes()
    cleaner.clean_unassociated_eips()
    cleaner.clean_old_amis(
        keep_count=3,
        name_prefix=os.environ.get("AMI_PREFIX", ""),  # e.g., "app-server"
    )

    # Write the consolidated report
    report = cleaner.generate_report("cleanup_report.json")

    # Return non-zero if live run deleted resources (useful for CI audit jobs)
    if not dry_run and report["summary"]["ebs_volumes_found"] > 0:
        return 0   # Deletions happened — success
    return 0


if __name__ == "__main__":
    sys.exit(main())

Safe Execution Workflow

# Step 1: Always dry-run first — see what WOULD be deleted
DRY_RUN=true AWS_DEFAULT_REGION=ap-south-1 python aws_resource_cleanup.py

# Step 2: Review cleanup_report.json
cat cleanup_report.json | python -m json.tool | grep -E '"name"|"size_gb"|"monthly_cost"'

# Step 3: If the report looks correct, run for real
DRY_RUN=false AWS_DEFAULT_REGION=ap-south-1 python aws_resource_cleanup.py

# Limit AMI cleanup to a specific name prefix
AMI_PREFIX=app-server DRY_RUN=true python aws_resource_cleanup.py

Key Commands Explained

CommandWhat it does
describe_volumes(Filters=[{"Name":"status","Values":["available"]}])Lists only unattached EBS volumes — server-side filter, no extra client logic
get_paginator("describe_volumes")Handles pagination for accounts with many volumes
delete_volume(VolumeId=id)Permanently destroys EBS volume — irreversible
describe_addresses()Returns all Elastic IPs (not paginated — returns all at once)
"AssociationId" in addrTrue means EIP is in use; False means it’s idle and billing
release_address(AllocationId=id)Returns EIP to AWS pool — you lose that IP permanently
describe_images(OwnerIds=["self"])Lists only AMIs you own (not public or marketplace AMIs)
deregister_image(ImageId=id)Removes AMI — cannot launch from it, but snapshots still exist
delete_snapshot(SnapshotId=id)Actually frees the storage and stops billing
json.dump(..., default=str)Serialises datetime objects to ISO strings in the JSON output

Common Gotchas

EBS volumes with snapshots — delete_volume() does NOT delete snapshots of that volume. Snapshots outlive the volume and keep billing. Use describe_snapshots(OwnerIds=["self"]) and filter by VolumeId to find them.

AMI deregister before snapshot delete — You must deregister the AMI first before its snapshots can be deleted. Attempting to delete a snapshot that is still registered as an AMI’s root device raises InvalidSnapshot.InUse.

EIP in EC2-Classic domain — Very old accounts may have EIPs with Domain=standard (not vpc). These are released with PublicIp=addr["PublicIp"] instead of AllocationId.


🔍 Line-by-Line Code Walkthrough

Imports

LineWhy It’s Used
from dataclasses import dataclass, field@dataclass auto-generates __init__ and __repr__. field(default_factory=list) safely initializes mutable list fields
from collections import defaultdictdefaultdict(list) auto-creates an empty list when a new key is first accessed — used to group AMIs by name prefix

CleanupReport Dataclass

@dataclass
class CleanupReport:
    ebs_volumes: list = field(default_factory=list)
    eips:        list = field(default_factory=list)
    amis:        list = field(default_factory=list)
    ebs_savings_per_month: float = 0.0

    @property
    def total_savings_per_month(self) -> float:
        return self.ebs_savings_per_month + self.eip_savings_per_month + self.ami_savings_per_month
LineExplanation
field(default_factory=list)Mutable default values (lists, dicts) cannot be written as ebs_volumes: list = [] in a dataclass — that list would be shared across ALL instances. default_factory=list creates a new empty list per instance
ebs_savings_per_month: float = 0.0Immutable default (float) is safe to use directly — no field() wrapper needed
@property total_savings_per_monthComputed on read. Sums the three savings fields. No storage needed — always derived from the three source fields

AWSResourceCleaner.__init__

def __init__(self, region: str = "us-east-1", dry_run: bool = True):
    self.ec2     = boto3.client("ec2", region_name=region)
    self.region  = region
    self.dry_run = dry_run
    self.report  = CleanupReport()
    if dry_run:
        logger.info("Running in DRY-RUN mode — no resources will be deleted")
    else:
        logger.warning("Running in LIVE mode — resources WILL be deleted!")
LineExplanation
dry_run: bool = TrueDefault is True — must explicitly opt-in to deletion. Prevents accidents
boto3.client("ec2", ...)All three cleanup operations (EBS, EIP, AMI) use the EC2 API — one client covers all of them
self.report = CleanupReport()Creates a fresh report object. As cleanup runs, results are accumulated into this object
logger.warning(...) for live modeUses WARNING (not INFO) for live mode — so it’s visually distinct in log output and will appear even if LOG_LEVEL=WARNING is set

Part 1 — clean_unattached_ebs_volumes()

paginator = self.ec2.get_paginator("describe_volumes")
for page in paginator.paginate(
    Filters=[{"Name": "status", "Values": ["available"]}]
):
    for vol in page["Volumes"]:
LineExplanation
get_paginator("describe_volumes")Returns a Paginator that automatically handles NextToken — essential for accounts with hundreds of volumes
Filters=[{"Name": "status", "Values": ["available"]}]Server-side filter — only volumes in "available" state are returned. "available" = not attached to any instance. "in-use" = attached. The filter runs in AWS, saving network transfer and CPU
for vol in page["Volumes"]Each page has a "Volumes" key containing a list of volume dicts
days_old = (datetime.now(timezone.utc) - create_time).days
price_per_gb = self.EBS_PRICE_PER_GB_MONTH.get(vol_type, 0.08)
monthly_cost = size_gb * price_per_gb
LineExplanation
datetime.now(timezone.utc) - create_timeReturns a timedelta. Both sides must be timezone-aware. vol["CreateTime"] is already UTC-aware (boto3 returns aware datetimes)
.daysExtracts only the integer number of days from the timedelta
self.EBS_PRICE_PER_GB_MONTH.get(vol_type, 0.08)Dict lookup with default. If the volume type isn’t in our price table (e.g., a future type), default to gp3 pricing
size_gb * price_per_gbSimple multiplication — not exact (AWS rounds to the month), but close enough for waste estimates
if not self.dry_run:
    self.ec2.delete_volume(VolumeId=volume_id)
    entry["deleted"] = True
    self.report.ebs_savings_per_month += monthly_cost
else:
    self.report.ebs_savings_per_month += monthly_cost
LineExplanation
self.ec2.delete_volume(VolumeId=volume_id)Permanently destroys the EBS volume. The volume must be in "available" state — attached volumes raise VolumeInUse. This is irreversible
entry["deleted"] = TrueMarks the entry so the report distinguishes dry-run (would delete) from actual deletions
Accumulate savings in both branchesWhether we deleted or just found, we accumulate the savings estimate. In dry-run, this is the potential savings; in live mode, this is actual savings

Part 2 — clean_unassociated_eips()

response = self.ec2.describe_addresses()
for addr in response["Addresses"]:
    if "AssociationId" in addr:
        continue
LineExplanation
describe_addresses()Returns all Elastic IPs in the region. Not paginated — always returns all at once (AWS accounts typically have a small limit, like 5 per region by default)
response["Addresses"]The list of EIP objects in the response
if "AssociationId" in addr: continueEIPs associated with an instance or network interface have "AssociationId". If present, the EIP is in use — skip it. If absent, the key doesn’t exist at all in the dict (not None)
monthly_cost = self.EIP_PRICE_PER_HOUR * 24 * 30.5
self.ec2.release_address(AllocationId=allocation_id)
LineExplanation
EIP_PRICE_PER_HOUR * 24 * 30.5$0.005 × 24 hours × 30.5 days ≈ $3.65/month. Uses 30.5 as the average month length
release_address(AllocationId=...)Returns this EIP to the AWS pool. You permanently lose that specific IP address. Use AllocationId for VPC EIPs (modern). Legacy EC2-Classic EIPs use PublicIp
if not self.dry_run and allocation_id:Double check: only release if not dry-run AND the allocation ID exists (EC2-Classic IPs may lack one)

Part 3 — clean_old_amis(keep_count, name_prefix)

response = self.ec2.describe_images(OwnerIds=["self"], Filters=filters)
all_amis = response["Images"]
LineExplanation
OwnerIds=["self"]Critical: restricts results to AMIs owned by your account. Without this, describe_images() returns AWS marketplace AMIs and public AMIs — thousands of results you can’t delete
"self" is a special aliasAWS resolves "self" to your current account ID
response["Images"]The list of AMI objects — not paginated (returns all at once)
groups: dict[str, list] = defaultdict(list)
for ami in all_amis:
    parts = ami_name.replace("_", "-").split("-")
    group_key = "-".join(parts[:2]) if len(parts) >= 2 else ami_name
    groups[group_key].append(ami)
LineExplanation
defaultdict(list)When groups["app-server"] is accessed for the first time, it automatically creates []. Without this, you’d need groups.setdefault(key, []).append(ami)
ami_name.replace("_", "-")Normalizes separators. AMI names like "app_server-20250120" and "app-server-20250120" become the same prefix
.split("-") → parts[:2]Splits "app-server-20250120" into ["app", "server", "20250120"]. Taking [:2] gives ["app", "server"] → joined as "app-server"
groups[group_key].append(ami)Groups all AMIs with the same prefix into the same list for sorting and pruning
amis_sorted = sorted(amis_in_group, key=lambda a: a["CreationDate"], reverse=True)
to_delete = amis_sorted[keep_count:]
LineExplanation
key=lambda a: a["CreationDate"]Sorts by the "CreationDate" string ("2025-01-20T14:30:00.000Z"). ISO 8601 dates sort correctly as strings (lexicographic order = chronological order)
reverse=TrueNewest first. Index 0 is the most recent AMI
amis_sorted[keep_count:]Python slice — skips the first keep_count items (the ones we keep) and returns the rest (the ones to delete)
snapshot_ids = [
    bdm["Ebs"]["SnapshotId"]
    for bdm in ami.get("BlockDeviceMappings", [])
    if "Ebs" in bdm and "SnapshotId" in bdm["Ebs"]
]
self.ec2.deregister_image(ImageId=image_id)
for snap_id in snapshot_ids:
    self.ec2.delete_snapshot(SnapshotId=snap_id)
LineExplanation
ami.get("BlockDeviceMappings", [])List of block device mappings — each volume the AMI includes. [] default prevents errors on AMIs with no mappings
if "Ebs" in bdm and "SnapshotId" in bdm["Ebs"]Some mappings are ephemeral (instance store) not EBS — they have no "Ebs" key. Double-check guards against KeyError
deregister_image(ImageId=...)Step 1: removes the AMI from the catalog. After this, no new instances can be launched from it. Running instances are not affected
delete_snapshot(SnapshotId=...)Step 2: deletes the EBS snapshot. This is what actually frees storage and stops billing. You MUST deregister the AMI first — otherwise delete_snapshot raises InvalidSnapshot.InUse
Why two steps?AMI registration and snapshot storage are separate resources. AWS designed it this way to allow AMI sharing without sharing the underlying snapshot

generate_report(output_file) — JSON Output

report_data = {
    "generated_at": datetime.now(timezone.utc).isoformat(),
    ...
    "total_savings_per_year": round(self.report.total_savings_per_month * 12, 2),
}
with open(output_file, "w") as f:
    json.dump(report_data, f, indent=2, default=str)
LineExplanation
datetime.now(timezone.utc).isoformat()Produces "2025-01-20T14:30:00+00:00" — a standard ISO 8601 string. The report timestamp tells you exactly when this audit ran
round(..., 2)Rounds to 2 decimal places (cents). Avoids floating-point noise like $3.6499999999
total_savings_per_month * 12Annualizes the monthly estimate. Useful for presenting cost savings to management
json.dump(..., default=str)default=str is a fallback serializer. When json.dump encounters a type it can’t serialize (like datetime objects), it calls str() on them. Prevents TypeError: Object of type datetime is not JSON serializable
indent=2Pretty-prints the JSON with 2-space indentation — human-readable for manual review