Output: ./2026-02-12.md:    "output": "#!/usr/bin/env python3\nimport boto3\nimport csv\nimport time\nfrom botocore.exceptions import ClientError, ProfileNotFound\nfrom collections import defaultdict\n\n# The original script's helper function to get an AMI's human-readable name from its ID\ndef get_ami_name(ec2_client, ami_id):\n    \"\"\"\n    Fetches the AMI Name from the AMI ID using the EC2 client.\n    Handles potential errors if the AMI is not accessible or deleted.\n    \"\"\"\n    if not ami_id:\n        return \"N/A - No AMI ID\"\n    # Skip AMI lookup if the ID is clearly an error message from a previous step\n    if not ami_id.startswith('ami-'):\n        return f\"Invalid AMI ID format\"\n        \n    try:\n        # Use describe_images to get metadata about the AMI\n        image_info = ec2_client.describe_images(ImageIds=[ami_id])['Images']\n        if image_info:\n            return image_info[0].get('Name', 'Unnamed AMI')\n        return 'AMI not found'\n    except ClientError as e:\n        # Handle cases where the AMI is not found or permissions are denied\n        return f\"AMI not accessible (Error: {e.response['Error']['Code']})\"\n    except Exception as e:\n        return f\"AMI lookup error: {e}\"\n\n\ndef get_asg_details(account_id, account_name, region, asg_client, ec2_client):\n    \"\"\"\n    Lists all Auto Scaling Groups (ASGs) in the region and determines\n    their associated AMI ID, Launch Configuration, or Launch Template details.\n    \"\"\"\n    asg_info_list = []\n    paginator = asg_client.get_paginator('describe_auto_scaling_groups')\n\n    print(f\"  -> Fetching ASGs in {region}...\")\n\n    try:\n        for page in paginator.paginate():\n            for asg in page['AutoScalingGroups']:\n                ami_id = \"N/A\"\n                template_type = \"None\"\n                template_name = \"None\"\n                template_version = \"N/A\"\n\n                # 1. Check for Launch Template\n                lt_data = asg.get('LaunchTemplate') or asg.get('MixedInstancesPolicy', {}).get('LaunchTemplate')\n                \n                if lt_data:\n                    # An ASG can use either a primary LaunchTemplate or a MixedInstancesPolicy\n                    template_type = \"LaunchTemplate\"\n                    template_id = lt_data.get('LaunchTemplateId')\n                    template_name = lt_data.get('LaunchTemplateName')\n                    template_version = lt_data.get('Version') or \"$Default\" # Use $Default if version is missing\n                    \n                    if template_id or template_name:\n                        # Attempt to resolve the AMI from the Launch Template version\n                        try:\n                            # AWS API requires EITHER LaunchTemplateId OR LaunchTemplateName, not both.\n                            lt_lookup_args = {'Versions': [template_version]}\n                            \n                            if template_id:\n                                lt_lookup_args['LaunchTemplateId'] = template_id\n                            elif template_name:\n                                lt_lookup_args['LaunchTemplateName'] = template_name\n                            else:\n                                # This case is unlikely if lt_data was present, but provides robust error handling\n                                raise ValueError(\"Launch Template ID and Name are both missing.\")\n                            \n                            lt_versions = ec2_client.describe_launch_template_versions(**lt_lookup_args)['LaunchTemplateVersions']\n\n                            if lt_versions:\n                                # AMI ID is nested inside LaunchTemplateData\n                                launch_template_data = lt_versions[0]['LaunchTemplateData']\n                                ami_id = launch_template_data.get('ImageId', 'AMI ID not specified in LT')\n                        \n                        except ClientError as e:\n                            # Capture the specific error code for debugging\n                            ami_id = f\"LT lookup error: {e.response['Error']['Code']}\"\n                        except Exception as e:\n                            ami_id = f\"LT processing error: {e}\"\n\n                # 2. Check for Launch Configuration (Legacy)\n                elif asg.get('LaunchConfigurationName'):\n                    lc_name = asg['LaunchConfigurationName']\n                    template_type = \"LaunchConfiguration\"\n                    template_name = lc_name\n                    template_version = \"Latest\" # LCs don't have versions like LTs\n\n                    # Attempt to resolve the AMI from the Launch Configuration\n                    try:\n                        lc_response = asg_client.describe_launch_configurations(\n                            LaunchConfigurationNames=[lc_name]\n                        )['LaunchConfigurations']\n                        \n                        if lc_response:\n                            ami_id = lc_response[0].get('ImageId', 'AMI ID not specified in LC')\n                    \n                    except ClientError as e:\n                        ami_id = f\"LC lookup error: {e.response['Error']['Code']}\"\n                    except Exception as e:\n                        ami_id = f\"LC processing error: {e}\"\n                \n                # Resolve the AMI Name if an ID was found\n                ami_name = get_ami_name(ec2_client, ami_id)\n                \n                asg_info_list.append({\n                    'AccountName': account_name,\n                    'AccountID': account_id,\n                    'Region': region,\n                    'ASG_Name': asg['AutoScalingGroupName'],\n                    'Template_Type': template_type,\n                    'Template_Name': template_name,\n                    'Template_Version': template_version,\n                    'AMI_ID': ami_id,\n                    'AMI_Name': ami_name,\n                })\n        \n    except ClientError as e:\n        print(f\"  -> Error listing ASGs in {region}: {e}\")\n        \n    return asg_info_list\n\n# Initialize AWS Organizations client\n# NOTE: This part is taken directly from your example script's organizational structure.\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs) to check\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w'] \nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nprint(\"1. Fetching active accounts from specified OUs...\")\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\nprint(f\"-> Found {len(active_accounts)} active accounts to check.\")\n\nall_asg_info = []\n\n# Define the regions to check (matches your example script)\nregions_to_check = ['eu-west-1', 'eu-west-2'] \n\n# Open CSV file for writing ASG information\ncsv_filepath = 'autoscaling_group_ami_template_info.csv'\nwith open(csv_filepath, mode='w', newline='') as csvfile:\n    fieldnames = [\n        'AccountName', \n        'AccountID', \n        'Region', \n        'ASG_Name', \n        'Template_Type', \n        'Template_Name', \n        'Template_Version', \n        'AMI_ID', \n        'AMI_Name'\n    ]\n    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n    writer.writeheader()\n\n    # Iterate through active accounts and then through the regions\n    print(\"\\n2. Iterating through accounts and regions to collect ASG data...\")\n    for account in active_accounts:\n        account_id = account['Id']\n        # Sanitize account name for profile use (matching original script's logic)\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin' \n        \n        print(f\"\\n--- Checking Account: {account_name} (ID: {account_id}) ---\")\n        \n        try:\n            # Set up the default session using the AWS SSO profile for each account\n            # NOTE: This relies on your local AWS configuration having these profiles set up.\n            boto3.setup_default_session(profile_name=profile_name)\n            \n            # Loop through each region\n            for region in regions_to_check:\n                try:\n                    # Initialize clients for the specific region\n                    asg_client = boto3.client('autoscaling', region_name=region)\n                    ec2_client = boto3.client('ec2', region_name=region) # Needed to resolve AMI Name and LT details\n                    \n                    # Get ASG information\n                    asg_data = get_asg_details(account_id, account_name, region, asg_client, ec2_client)\n                    \n                    # Write results to CSV and print to console\n                    for asg in asg_data:\n                        all_asg_info.append(asg)\n                        writer.writerow(asg)\n                        print(f\"  [FOUND] ASG: {asg['ASG_Name']}, Template Type: {asg['Template_Type']}, Template Name: {asg['Template_Name']} ({asg['Template_Version']}), AMI ID: {asg['AMI_ID']}, AMI Name: {asg['AMI_Name']}\")\n\n                except (ClientError) as e:\n                    print(f\"  -> Error accessing services in region '{region}': {e}\")\n                \n                # Small delay to respect API limits, especially for org-wide searches\n                time.sleep(0.5)\n            \n        except (ClientError, ProfileNotFound) as e:\n            print(f\"  -> Error setting up session/profile '{profile_name}': {e}\")\n\n\n# Output summary\ntotal_asgs = len(all_asg_info)\nprint(\"\\n========================================================\")\nprint(\"ASG & AMI Collection Summary:\")\nprint(f\"Total Auto Scaling Groups found: {total_asgs}\")\n\nif total_asgs > 0:\n    # Basic summary of AMI usage\n    ami_usage = defaultdict(int)\n    for asg in all_asg_info:\n        ami_usage[asg['AMI_Name']] += 1\n    \n    print(f\"Distinct AMI Names used: {len(ami_usage)}\")\n    print(\"\\nTop 5 AMI Usage Counts:\")\n    for ami_name, count in sorted(ami_usage.items(), key=lambda item: item[1], reverse=True)[:5]:\n        print(f\"  - {ami_name}: {count} ASGs\")\n\nprint(f\"\\nCSV file '{csv_filepath}' generated successfully.\")\nprint(\"========================================================\")\n"
./2026-02-12.md:    "output": "import boto3\nimport csv\nfrom botocore.exceptions import ClientError, ProfileNotFound\n\n# Function to get the VPC name from tags\ndef get_vpc_name(tags):\n    \"\"\"\n    Retrieves the value of the 'Name' tag from a VPC's tags.\n    Returns \"Unnamed VPC\" if the tag is not found.\n    \"\"\"\n    if tags:\n        for tag in tags:\n            if tag['Key'] == 'Name':\n                return tag['Value']\n    return \"Unnamed VPC\"\n\n# Function to list all VPCs with name, ID, and CIDR block\ndef list_vpcs(ec2_client):\n    \"\"\"\n    Lists all VPCs in a given region, retrieves their Name, ID,\n    and CIDR block, and returns the information in a list of dictionaries.\n    \"\"\"\n    vpcs_info = []\n    paginator = ec2_client.get_paginator('describe_vpcs')\n    \n    for page in paginator.paginate():\n        for vpc in page['Vpcs']:\n            vpc_name = get_vpc_name(vpc.get('Tags'))\n            vpc_id = vpc['VpcId']\n            cidr_block = vpc.get('CidrBlock', 'N/A')\n            \n            vpcs_info.append({\n                'VPCName': vpc_name,\n                'VPCID': vpc_id,\n                'CIDRBlock': cidr_block\n            })\n    return vpcs_info\n\n# Initialize AWS Organizations client\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs)\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\n\n# Define the regions to check\nregions_to_check = ['eu-west-1', 'eu-west-2']\n\n# Initialize a counter for the total number of VPCs\ntotal_vpcs_all_accounts = 0\n\n# Open CSV file for writing VPC information\nwith open('aws_vpcs_by_region.csv', mode='w', newline='') as csvfile:\n    # Define the fieldnames for the CSV header\n    fieldnames = ['AccountName', 'AccountID', 'Region', 'VPCName', 'VPCID', 'CIDRBlock']\n    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n    writer.writeheader()\n\n    # Iterate through active accounts and then through the regions\n    for account in active_accounts:\n        account_id = account['Id']\n        \n        # Replace \" - \" with \"-\" and replace spaces with \"-\" in account names\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin'  # Constructing the profile name\n        \n        try:\n            # Set up the default session using the AWS SSO profile for each account\n            boto3.setup_default_session(profile_name=profile_name)\n            \n            for region in regions_to_check:\n                try:\n                    # Initialize EC2 client for the specific region\n                    ec2_client = boto3.client('ec2', region_name=region)\n                    \n                    # List all VPCs for the account and region\n                    vpcs_info = list_vpcs(ec2_client)\n                    \n                    # Add the count for this region to the total\n                    total_vpcs_all_accounts += len(vpcs_info)\n                    \n                    # Write VPC information to CSV\n                    for vpc in vpcs_info:\n                        writer.writerow({\n                            'AccountName': account_name,\n                            'AccountID': account_id,\n                            'Region': region,\n                            'VPCName': vpc['VPCName'],\n                            'VPCID': vpc['VPCID'],\n                            'CIDRBlock': vpc['CIDRBlock']\n                        })\n                    \n                    # Print the results for the current account and region\n                    print(f'\"{account_name}\" (ID: {account_id}, Region: {region}) VPCs:')\n                    for vpc in vpcs_info:\n                        print(f\"VPC Name: {vpc['VPCName']}, VPC ID: {vpc['VPCID']}, CIDR Block: {vpc['CIDRBlock']}\")\n                    print('--------------------------------------------------------')\n\n                except ClientError as e:\n                    print(f\"Error accessing VPCs in region '{region}' for account '{account_name}' (ID: {account_id}): {e}\")\n\n        except (ClientError, ProfileNotFound) as e:\n            print(f\"Error accessing account '{account_name}' (ID: {account_id}): {e}\")\n\nprint(\"\\nSummary of VPCs Across All Accounts and Regions:\")\nprint(f\"Total number of VPCs found: {total_vpcs_all_accounts}\")\nprint(\"\\nCSV file 'aws_vpcs_by_region.csv' generated successfully.\")\n"
./2026-02-12.md:    "output": "import boto3\nfrom botocore.exceptions import ClientError, ProfileNotFound\n\n# Function to list RDS instances\ndef list_rds_instances(rds_client):\n    instance_names = []\n    paginator = rds_client.get_paginator('describe_db_instances')\n    for page in paginator.paginate():\n        for db_instance in page['DBInstances']:\n            instance_names.append(db_instance['DBInstanceIdentifier'])\n    return instance_names\n\n# Function to list RDS clusters\ndef list_rds_clusters(rds_client):\n    cluster_names = []\n    paginator = rds_client.get_paginator('describe_db_clusters')\n    for page in paginator.paginate():\n        for db_cluster in page['DBClusters']:\n            cluster_names.append(db_cluster['DBClusterIdentifier'])\n    return cluster_names\n\n# Initialize AWS Organizations client\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs)\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\n\n# Iterate through active accounts and print RDS names\nfor account in active_accounts:\n    account_id = account['Id']\n    \n    # Replace \" - \" with \"-\" and replace spaces with \"-\" in account names\n    account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n    profile_name = f'{account_name}.admin'  # Constructing the profile name\n    \n    try:\n        # Set up the default session using the AWS SSO profile for each account\n        boto3.setup_default_session(profile_name=profile_name)\n        \n        # Initialize RDS client with the SSO session\n        rds_client = boto3.client('rds')\n        \n        # Get RDS instance names\n        rds_instance_names = list_rds_instances(rds_client)\n        \n        # Get RDS cluster names\n        rds_cluster_names = list_rds_clusters(rds_client)\n        \n        # Print RDS instance names\n        for rds_name in rds_instance_names:\n            print(f'\"{account_name}\":\"{rds_name}\"')\n        \n        # Print RDS cluster names\n        for rds_name in rds_cluster_names:\n            print(f'\"{account_name}\":\"{rds_name}\"')\n        \n        print(f\"Processed account '{account_name}' (ID: {account_id})\")\n\n    except (ClientError, ProfileNotFound) as e:\n        print(f\"Error accessing account '{account_name}' (ID: {account_id}): {e}\")\n\nprint(\"\\nRDS names have been successfully listed for all accounts.\")\n"
./2026-02-12.md:    "content": "import boto3\nimport csv\nfrom botocore.exceptions import ClientError, ProfileNotFound\nfrom aws_utils import setup_org_accounts_session\n\ndef get_vpc_name(tags):\n    if tags:\n        for tag in tags:\n            if tag['Key'] == 'Name':\n                return tag['Value']\n    return \"Unnamed VPC\"\n\ndef list_vpcs(ec2_client):\n    vpcs_info = []\n    paginator = ec2_client.get_paginator('describe_vpcs')\n    for page in paginator.paginate():\n        for vpc in page['Vpcs']:\n            vpcs_info.append({\n                'VPCName': get_vpc_name(vpc.get('Tags')),\n                'VPCID': vpc['VpcId'],\n                'CIDRBlock': vpc.get('CidrBlock', 'N/A')\n            })\n    return vpcs_info\n\ndef main():\n    ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n    regions_to_check = ['eu-west-1', 'eu-west-2']\n    total_vpcs_all_accounts = 0\n\n    with open('aws_vpcs_by_region.csv', mode='w', newline='') as csvfile:\n        fieldnames = ['AccountName', 'AccountID', 'Region', 'VPCName', 'VPCID', 'CIDRBlock']\n        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n        writer.writeheader()\n\n        for account_id, account_name, profile_name in setup_org_accounts_session(ou_ids):\n            try:\n                boto3.setup_default_session(profile_name=profile_name)\n                for region in regions_to_check:\n                    try:\n                        ec2_client = boto3.client('ec2', region_name=region)\n                        vpcs_info = list_vpcs(ec2_client)\n                        total_vpcs_all_accounts += len(vpcs_info)\n                        \n                        for vpc in vpcs_info:\n                            writer.writerow({\n                                'AccountName': account_name,\n                                'AccountID': account_id,\n                                'Region': region,\n                                'VPCName': vpc['VPCName'],\n                                'VPCID': vpc['VPCID'],\n                                'CIDRBlock': vpc['CIDRBlock']\n                            })\n                        \n                        if vpcs_info:\n                            print(f'\"{account_name}\" (ID: {account_id}, Region: {region}) VPCs found: {len(vpcs_info)}')\n                    except ClientError as e:\n                        print(f\"Error in {region} for {account_name}: {e}\")\n            except (ClientError, ProfileNotFound) as e:\n                print(f\"Error accessing account {account_name}: {e}\")\n\n    print(f\"\\nTotal number of VPCs found: {total_vpcs_all_accounts}\")\n    print(\"CSV file 'aws_vpcs_by_region.csv' generated successfully.\")\n\nif __name__ == \"__main__\":\n    main()\n"
./2026-02-12.md:    "content": "import boto3\nfrom botocore.exceptions import ClientError, ProfileNotFound\nfrom aws_utils import setup_org_accounts_session\n\ndef list_rds_instances(rds_client):\n    instance_names = []\n    paginator = rds_client.get_paginator('describe_db_instances')\n    for page in paginator.paginate():\n        for db_instance in page['DBInstances']:\n            instance_names.append(db_instance['DBInstanceIdentifier'])\n    return instance_names\n\ndef list_rds_clusters(rds_client):\n    cluster_names = []\n    paginator = rds_client.get_paginator('describe_db_clusters')\n    for page in paginator.paginate():\n        for db_cluster in page['DBClusters']:\n            cluster_names.append(db_cluster['DBClusterIdentifier'])\n    return cluster_names\n\ndef main():\n    ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n\n    for account_id, account_name, profile_name in setup_org_accounts_session(ou_ids):\n        try:\n            boto3.setup_default_session(profile_name=profile_name)\n            rds_client = boto3.client('rds')\n            \n            names = list_rds_instances(rds_client) + list_rds_clusters(rds_client)\n            for name in names:\n                print(f'\"{account_name}\":\"{name}\"')\n            \n            print(f\"Processed account '{account_name}' (ID: {account_id})\")\n        except (ClientError, ProfileNotFound) as e:\n            print(f\"Error accessing account '{account_name}': {e}\")\n\n    print(\"\\nRDS names have been successfully listed for all accounts.\")\n\nif __name__ == \"__main__\":\n    main()\n"
./2026-02-12.md:    "pattern": "ou-"
./2026-02-12.md:    "output": "Found 7 matches for pattern \"ou-\" in path \".\":\n---\nFile: aws-ec2-ami.py\nL54: ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n---\nFile: aws-vpc.py\nL26: ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n---\nFile: cloudfront.py\nL52: ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n---\nFile: aws-metrics.py\nL72: ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n---\nFile: aws-asg-lt-ami.py\nL134: ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n---\nFile: list_rds_instances.py\nL22: ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n---\nFile: aws-ips.py\nL47: ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n---"
./2026-02-12.md:    "content": "import boto3\nimport json\nimport os\nfrom datetime import datetime, date\nfrom dateutil.relativedelta import relativedelta\n\n# Attempt to load .env file if python-dotenv is installed\ntry:\n    from dotenv import load_dotenv\n    load_dotenv()\nexcept ImportError:\n    pass\n\ndef get_skip_accounts():\n    \"\"\"Returns a list of account IDs to skip from SKIP_ACCOUNTS env var.\"\"\"\n    skip_str = os.getenv('SKIP_ACCOUNTS', '')\n    if not skip_str:\n        return []\n    return [s.strip() for s in skip_str.split(',') if s.strip()]\n\ndef get_ou_ids():\n    \"\"\"Returns a list of OU IDs from OU_IDS env var, or defaults.\"\"\"\n    ou_str = os.getenv('OU_IDS', '')\n    if ou_str:\n        return [o.strip() for o in ou_str.split(',') if o.strip()]\n    # Default OUs if none provided\n    return ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n\ndef get_account_names():\n    \"\"\"Fetches account names from AWS Organizations, excluding skipped accounts.\"\"\"\n    org_client = boto3.client('organizations')\n    skip_accounts = get_skip_accounts()\n    accounts = {}\n    try:\n        paginator = org_client.get_paginator('list_accounts')\n        for page in paginator.paginate():\n            for account in page['Accounts']:\n                if account['Status'] == 'ACTIVE' and account['Id'] not in skip_accounts:\n                    accounts[account['Id']] = account['Name']\n    except Exception as e:\n        print(f\"Error fetching account names: {e}\")\n    return accounts\n\ndef get_previous_month_range():\n    \"\"\"Returns (start_date, end_date) for the previous month in YYYY-MM-DD format.\"\"\"\n    today = date.today()\n    first_day_curr = today.replace(day=1)\n    last_day_prev = first_day_curr - relativedelta(days=1)\n    start_date = last_day_prev.replace(day=1).strftime('%Y-%m-%d')\n    end_date = first_day_curr.strftime('%Y-%m-%d')\n    return start_date, end_date\n\ndef get_last_n_months_ranges(n=3):\n    \"\"\"Returns a list of (start_date, end_date, label) for the last n months.\"\"\"\n    ranges = []\n    current_date = datetime.now().replace(day=1)\n    for i in range(1, n + 1):\n        start_dt = current_date - relativedelta(months=i)\n        end_dt = current_date - relativedelta(months=i-1)\n        ranges.append((\n            start_dt.strftime('%Y-%m-%d'),\n            end_dt.strftime('%Y-%m-%d'),\n            start_dt.strftime('%Y-%m')\n        ))\n    return ranges\n\ndef get_aws_pricing(service_code, filters):\n    \"\"\"Generic helper to fetch on-demand price from AWS Pricing API (us-east-1).\"\"\"\n    pricing_client = boto3.client('pricing', region_name='us-east-1')\n    try:\n        response = pricing_client.get_products(\n            ServiceCode=service_code,\n            Filters=filters\n        )\n        if response['PriceList']:\n            price_item = json.loads(response['PriceList'][0])\n            on_demand = price_item['terms']['OnDemand']\n            term_key = list(on_demand.keys())[0]\n            price_dimensions = on_demand[term_key]['priceDimensions']\n            dim_key = list(price_dimensions.keys())[0]\n            return float(price_dimensions[dim_key]['pricePerUnit']['USD'])\n    except Exception as e:\n        print(f\"Error fetching pricing for {service_code}: {e}\")\n    return None\n\ndef setup_org_accounts_session(ou_ids=None, profile_suffix='.admin'):\n    \"\"\"\n    Yields (account_id, account_name, profile_name) for active accounts in OUs.\n    Handles boto3 session setup for each account.\n    Excludes accounts in SKIP_ACCOUNTS env var.\n    \"\"\"\n    if ou_ids is None:\n        ou_ids = get_ou_ids()\n        \n    skip_accounts = get_skip_accounts()\n    org_client = boto3.client('organizations')\n    for ou_id in ou_ids:\n        paginator = org_client.get_paginator('list_accounts_for_parent')\n        for page in paginator.paginate(ParentId=ou_id):\n            for account in page['Accounts']:\n                if account['Status'] == 'ACTIVE' and account['Id'] not in skip_accounts:\n                    account_id = account['Id']\n                    # Sanitize account name for profile use\n                    account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n                    profile_name = f'{account_name}{profile_suffix}'\n                    yield account_id, account['Name'], profile_name\n",
./2026-02-12.md:    "old_string": "def main():\n    ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n    regions_to_check = ['eu-west-1', 'eu-west-2']\n    total_vpcs_all_accounts = 0\n\n    with open('aws_vpcs_by_region.csv', mode='w', newline='') as csvfile:\n        fieldnames = ['AccountName', 'AccountID', 'Region', 'VPCName', 'VPCID', 'CIDRBlock']\n        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n        writer.writeheader()\n\n        for account_id, account_name, profile_name in setup_org_accounts_session(ou_ids):\n",
./2026-02-12.md:    "old_string": "def main():\n    ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n\n    for account_id, account_name, profile_name in setup_org_accounts_session(ou_ids):\n",
./2026-02-12.md:    "output": "\nIMPORTANT: The file content has been truncated.\nStatus: Showing lines 131-237 of 237 total lines.\nAction: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: 237.\n\n--- FILE CONTENT (truncated) ---\n# NOTE: This part is taken directly from your example script's organizational structure.\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs) to check\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w'] \nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nprint(\"1. Fetching active accounts from specified OUs...\")\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\nprint(f\"-> Found {len(active_accounts)} active accounts to check.\")\n\nall_asg_info = []\n\n# Define the regions to check (matches your example script)\nregions_to_check = ['eu-west-1', 'eu-west-2'] \n\n# Open CSV file for writing ASG information\ncsv_filepath = 'autoscaling_group_ami_template_info.csv'\nwith open(csv_filepath, mode='w', newline='') as csvfile:\n    fieldnames = [\n        'AccountName', \n        'AccountID', \n        'Region', \n        'ASG_Name', \n        'Template_Type', \n        'Template_Name', \n        'Template_Version', \n        'AMI_ID', \n        'AMI_Name'\n    ]\n    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n    writer.writeheader()\n\n    # Iterate through active accounts and then through the regions\n    print(\"\\n2. Iterating through accounts and regions to collect ASG data...\")\n    for account in active_accounts:\n        account_id = account['Id']\n        # Sanitize account name for profile use (matching original script's logic)\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin' \n        \n        print(f\"\\n--- Checking Account: {account_name} (ID: {account_id}) ---\")\n        \n        try:\n            # Set up the default session using the AWS SSO profile for each account\n            # NOTE: This relies on your local AWS configuration having these profiles set up.\n            boto3.setup_default_session(profile_name=profile_name)\n            \n            # Loop through each region\n            for region in regions_to_check:\n                try:\n                    # Initialize clients for the specific region\n                    asg_client = boto3.client('autoscaling', region_name=region)\n                    ec2_client = boto3.client('ec2', region_name=region) # Needed to resolve AMI Name and LT details\n                    \n                    # Get ASG information\n                    asg_data = get_asg_details(account_id, account_name, region, asg_client, ec2_client)\n                    \n                    # Write results to CSV and print to console\n                    for asg in asg_data:\n                        all_asg_info.append(asg)\n                        writer.writerow(asg)\n                        print(f\"  [FOUND] ASG: {asg['ASG_Name']}, Template Type: {asg['Template_Type']}, Template Name: {asg['Template_Name']} ({asg['Template_Version']}), AMI ID: {asg['AMI_ID']}, AMI Name: {asg['AMI_Name']}\")\n\n                except (ClientError) as e:\n                    print(f\"  -> Error accessing services in region '{region}': {e}\")\n                \n                # Small delay to respect API limits, especially for org-wide searches\n                time.sleep(0.5)\n            \n        except (ClientError, ProfileNotFound) as e:\n            print(f\"  -> Error setting up session/profile '{profile_name}': {e}\")\n\n\n# Output summary\ntotal_asgs = len(all_asg_info)\nprint(\"\\n========================================================\")\nprint(\"ASG & AMI Collection Summary:\")\nprint(f\"Total Auto Scaling Groups found: {total_asgs}\")\n\nif total_asgs > 0:\n    # Basic summary of AMI usage\n    ami_usage = defaultdict(int)\n    for asg in all_asg_info:\n        ami_usage[asg['AMI_Name']] += 1\n    \n    print(f\"Distinct AMI Names used: {len(ami_usage)}\")\n    print(\"\\nTop 5 AMI Usage Counts:\")\n    for ami_name, count in sorted(ami_usage.items(), key=lambda item: item[1], reverse=True)[:5]:\n        print(f\"  - {ami_name}: {count} ASGs\")\n\nprint(f\"\\nCSV file '{csv_filepath}' generated successfully.\")\nprint(\"========================================================\")\n"
./2026-02-12.md:    "old_string": "# NOTE: This part is taken directly from your example script's organizational structure.\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs) to check\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w'] \nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nprint(\"1. Fetching active accounts from specified OUs...\")\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('Token')\n        if not next_token:\n            break\nprint(f\"-> Found {len(active_accounts)} active accounts to check.\")\n\nall_asg_info = []\n\n# Define the regions to check (matches your example script)\nregions_to_check = ['eu-west-1', 'eu-west-2'] \n\n# Open CSV file for writing ASG information\ncsv_filepath = 'autoscaling_group_ami_template_info.csv'\nwith open(csv_filepath, mode='w', newline='') as csvfile:\n    fieldnames = [\n        'AccountName', \n        'AccountID', \n        'Region', \n        'ASG_Name', \n        'Template_Type', \n        'Template_Name', \n        'Template_Version', \n        'AMI_ID', \n        'AMI_Name'\n    ]\n    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n    writer.writeheader()\n\n    # Iterate through active accounts and then through the regions\n    print(\"\\n2. Iterating through accounts and regions to collect ASG data...\")\n    for account in active_accounts:\n        account_id = account['Id']\n        # Sanitize account name for profile use (matching original script's logic)\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin' \n        \n        print(f\"\\n--- Checking Account: {account_name} (ID: {account_id}) ---\")\n        \n        try:\n            # Set up the default session using the AWS SSO profile for each account\n            # NOTE: This relies on your local AWS configuration having these profiles set up.\n            boto3.setup_default_session(profile_name=profile_name)\n"
./2026-02-12.md:    "old_string": "# NOTE: This part is taken directly from your example script's organizational structure.\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs) to check\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w'] \nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nprint(\"1. Fetching active accounts from specified OUs...\")\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\nprint(f\"-> Found {len(active_accounts)} active accounts to check.\")\n\nall_asg_info = []\n\n# Define the regions to check (matches your example script)\nregions_to_check = ['eu-west-1', 'eu-west-2'] \n\n# Open CSV file for writing ASG information\ncsv_filepath = 'autoscaling_group_ami_template_info.csv'\nwith open(csv_filepath, mode='w', newline='') as csvfile:\n    fieldnames = [\n        'AccountName', \n        'AccountID', \n        'Region', \n        'ASG_Name', \n        'Template_Type', \n        'Template_Name', \n        'Template_Version', \n        'AMI_ID', \n        'AMI_Name'\n    ]\n    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n    writer.writeheader()\n\n    # Iterate through active accounts and then through the regions\n    print(\"\\n2. Iterating through accounts and regions to collect ASG data...\")\n    for account in active_accounts:\n        account_id = account['Id']\n        # Sanitize account name for profile use (matching original script's logic)\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin' \n        \n        print(f\"\\n--- Checking Account: {account_name} (ID: {account_id}) ---\")\n        \n        try:\n            # Set up the default session using the AWS SSO profile for each account\n            # NOTE: This relies on your local AWS configuration having these profiles set up.\n            boto3.setup_default_session(profile_name=profile_name)\n",
./2026-02-12.md:    "output": "\nIMPORTANT: The file content has been truncated.\nStatus: Showing lines 51-146 of 146 total lines.\nAction: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: 146.\n\n--- FILE CONTENT (truncated) ---\n# Initialize AWS Organizations client\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs)\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\n\n# Initialize a dictionary to store AMI names and their counts\nami_name_counts = defaultdict(int)\nall_instances_info = []\n\n# Define the regions to check\nregions_to_check = ['eu-west-1', 'eu-west-2']\n\n# Open CSV file for writing instance information\nwith open('running_ec2_instances_info.csv', mode='w', newline='') as csvfile:\n    # Add 'Region' to the fieldnames for the CSV header\n    fieldnames = ['AccountName', 'AccountID', 'Region', 'InstanceName', 'InstanceID', 'AMI_ID', 'AMI_Name']\n    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n    writer.writeheader()\n\n    # Iterate through active accounts and then through the regions\n    for account in active_accounts:\n        account_id = account['Id']\n        \n        # Replace \" - \" with \"-\" and replace spaces with \"-\" in account names\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin'  # Constructing the profile name\n        \n        try:\n            # Set up the default session using the AWS SSO profile for each account\n            boto3.setup_default_session(profile_name=profile_name)\n            \n            # Loop through each region\n            for region in regions_to_check:\n                try:\n                    # Initialize EC2 client for the specific region\n                    ec2_client = boto3.client('ec2', region_name=region)\n                    \n                    # List only running EC2 instances with name, AMI ID, and AMI name\n                    instances_info = list_running_ec2_instances_with_ami_info(ec2_client)\n                    \n                    # Collect instance information and count AMI names\n                    for instance in instances_info:\n                        all_instances_info.append(instance)\n                        ami_name_counts[instance['AMI_Name']] += 1  # Count each AMI name\n                        \n                        # Write instance information to CSV, including the region\n                        writer.writerow({\n                            'AccountName': account_name,\n                            'AccountID': account_id,\n                            'Region': region,\n                            'InstanceName': instance['InstanceName'],\n                            'InstanceID': instance['InstanceId'],\n                            'AMI_ID': instance['AMI_ID'],\n                            'AMI_Name': instance['AMI_Name']\n                        })\n                    \n                    # Print the results for the current account and region\n                    print(f'\"{account_name}\" (ID: {account_id}, Region: {region}) Running EC2 Instances:')\n                    for instance in instances_info:\n                        print(f\"Instance Name: {instance['InstanceName']}, Instance ID: {instance['InstanceId']}, AMI ID: {instance['AMI_ID']}, AMI Name: {instance['AMI_Name']}\")\n                    print('--------------------------------------------------------')\n\n                except (ClientError) as e:\n                    print(f\"Error accessing EC2 in region '{region}' for account '{account_name}' (ID: {account_id}): {e}\")\n            \n        except (ClientError, ProfileNotFound) as e:\n            print(f\"Error accessing account '{account_name}' (ID: {account_id}): {e}\")\n\n# Output summary of distinct AMI names with counts and total instance details\nprint(\"\\nSummary of Running EC2 Instances and AMI Usage Across All Accounts:\")\n#output total number of runniing instances\nprint(f\"Total number of running EC2 instances across all accounts: {len(all_instances_info)}\")\nprint(f\"Total number of distinct AMI names across all accounts: {len(ami_name_counts)}\")\nprint(\"Count of each distinct AMI name across all accounts:\")\nfor ami_name, count in sorted(ami_name_counts.items()):\n    print(f\"{ami_name}: {count} instances\")\n\nprint(\"\\nCSV file 'running_ec2_instances_info.csv' generated successfully.\")\n"
./2026-02-12.md:    "old_string": "# Initialize AWS Organizations client\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs)\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\n\n# Initialize a dictionary to store AMI names and their counts\nami_name_counts = defaultdict(int)\nall_instances_info = []\n\n# Define the regions to check\nregions_to_check = ['eu-west-1', 'eu-west-2']\n\n# Open CSV file for writing instance information\nwith open('running_ec2_instances_info.csv', mode='w', newline='') as csvfile:\n    # Add 'Region' to the fieldnames for the CSV header\n    fieldnames = ['AccountName', 'AccountID', 'Region', 'InstanceName', 'InstanceID', 'AMI_ID', 'AMI_Name']\n    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n    writer.writeheader()\n\n    # Iterate through active accounts and then through the regions\n    for account in active_accounts:\n        account_id = account['Id']\n        \n        # Replace \" - \" with \"-\" and replace spaces with \"-\" in account names\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin'  # Constructing the profile name\n        \n        try:\n            # Set up the default session using the AWS SSO profile for each account\n            boto3.setup_default_session(profile_name=profile_name)\n",
./2026-02-12.md:    "output": "\nIMPORTANT: The file content has been truncated.\nStatus: Showing lines 1-100 of 170 total lines.\nAction: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: 100.\n\n--- FILE CONTENT (truncated) ---\nimport boto3\nimport csv\nfrom botocore.exceptions import ClientError, ProfileNotFound\n\n# Function to list public IPs, Private IPs, and Instance IDs\ndef list_ec2_instance_info(ec2_client):\n    \"\"\"\n    Lists the public IP, private IP, and Instance ID of EC2 instances.\n    Returns a list of dictionaries.\n    \"\"\"\n    instance_info = []\n    paginator = ec2_client.get_paginator('describe_instances')\n    for page in paginator.paginate():\n        for reservation in page['Reservations']:\n            for instance in reservation['Instances']:\n                # Only process if it has a Public IP\n                if 'PublicIpAddress' in instance:\n                    instance_info.append({\n                        'InstanceId': instance['InstanceId'],\n                        'PublicIp': instance['PublicIpAddress'],\n                        'PrivateIp': instance.get('PrivateIpAddress', 'N/A')\n                    })\n    return instance_info\n\n# Function to list Elastic IPs (EIPs)\ndef list_elastic_ips(ec2_client):\n    elastic_ips = []\n    response = ec2_client.describe_addresses()\n    for address in response['Addresses']:\n        if 'PublicIp' in address:\n            elastic_ips.append(address['PublicIp'])\n    return elastic_ips\n\n# Function to list the DNS names of internet-facing ALBs\ndef list_alb_dns_names(elbv2_client):\n    alb_dns_names = []\n    response = elbv2_client.describe_load_balancers()\n    for lb in response['LoadBalancers']:\n        if lb['Scheme'] == 'internet-facing':\n            alb_dns_names.append(lb['DNSName'])\n    return alb_dns_names\n\n# Initialize AWS Organizations client\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs)\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\n\n# Initialize a counter for the total number of external resources\ntotal_external_resources_count = 0\n\n# Define the regions to check\nregions_to_check = ['eu-west-1', 'eu-west-2']\n\n# Open CSV file for writing\nwith open('aws_external_ips_by_region.csv', mode='w', newline='') as csvfile:\n    # Updated Headers\n    fieldnames = [\n        'AccountName', \n        'AccountID', \n        'Region', \n        'Type', \n        'Instance ID', \n        'Public IP/DNS Name', \n        'Private IP', \n        'Private Hostname'\n    ]\n    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n    writer.writeheader()\n\n    # Iterate through active accounts and then through the regions\n    for account in active_accounts:\n        account_id = account['Id']\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin'\n        \n        try:\n            boto3.setup_default_session(profile_name=profile_name)\n            \n            for region in regions_to_check:\n                try:\n                    ec2_client = boto3.client('ec2', region_name=region)\n                    elbv2_client = boto3.client('elbv2', region_name=region)\n                    "
./2026-02-12.md:    "old_string": "# Initialize AWS Organizations client\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs)\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\n\n# Initialize a counter for the total number of external resources\ntotal_external_resources_count = 0\n\n# Define the regions to check\nregions_to_check = ['eu-west-1', 'eu-west-2']\n\n# Open CSV file for writing\nwith open('aws_external_ips_by_region.csv', mode='w', newline='') as csvfile:\n    # Updated Headers\n    fieldnames = [\n        'AccountName', \n        'AccountID', \n        'Region', \n        'Type', \n        'Instance ID', \n        'Public IP/DNS Name', \n        'Private IP', \n        'Private Hostname'\n    ]\n    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)\n    writer.writeheader()\n\n    # Iterate through active accounts and then through the regions\n    for account in active_accounts:\n        account_id = account['Id']\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin'\n        \n        try:\n            boto3.setup_default_session(profile_name=profile_name)\n",
./2026-02-12.md:    "output": "\nIMPORTANT: The file content has been truncated.\nStatus: Showing lines 1-100 of 168 total lines.\nAction: To read more of the file, you can use the 'offset' and 'limit' parameters in a subsequent 'read_file' call. For example, to read the next section of the file, use offset: 100.\n\n--- FILE CONTENT (truncated) ---\nimport boto3\nimport csv\nfrom datetime import datetime, timedelta, timezone\nfrom botocore.exceptions import ClientError, ProfileNotFound\n\n# Function to get CloudFront log size for the last month\ndef get_cloudfront_log_size(cloudfront_client, s3_client, distribution_id):\n    try:\n        # Get the distribution configuration\n        distribution_config_response = cloudfront_client.get_distribution_config(Id=distribution_id)\n        distribution_config = distribution_config_response['DistributionConfig']\n\n        # Check if logging is enabled\n        logging_config = distribution_config.get('Logging', {})\n        if not logging_config.get('Enabled', False):\n            return 0\n\n        # Get the S3 bucket and prefix for logs\n        s3_bucket_name = logging_config['Bucket'].split('.')[0]\n        s3_bucket_prefix = logging_config.get('Prefix', '')\n\n        # Set time range for the last month\n        end_date = datetime.now(timezone.utc)  # Current UTC time, timezone-aware\n        start_date = end_date - timedelta(days=30)\n\n        total_size = 0\n        paginator = s3_client.get_paginator('list_objects_v2')\n        for page in paginator.paginate(Bucket=s3_bucket_name, Prefix=s3_bucket_prefix):\n            for obj in page.get('Contents', []):\n                # Make sure 'LastModified' is compared as a timezone-aware datetime\n                if start_date <= obj['LastModified'].replace(tzinfo=timezone.utc) <= end_date:\n                    total_size += obj['Size']\n\n        return total_size\n    except KeyError as e:\n        print(f\"Error retrieving CloudFront logs for distribution {distribution_id}: Missing key {e}\")\n        return 0\n    except ClientError as e:\n        print(f\"Error accessing CloudFront or S3 for distribution {distribution_id}: {e}\")\n        return 0\n\n# Function to count EC2 instances\ndef count_ec2_instances(ec2_client):\n    instances = ec2_client.describe_instances()\n    instance_count = sum([len(reservation['Instances']) for reservation in instances['Reservations']])\n    return instance_count\n\n# Function to count running ECS containers\ndef count_ecs_containers(ecs_client):\n    cluster_arns = ecs_client.list_clusters()['clusterArns']\n    running_tasks = 0\n    for cluster_arn in cluster_arns:\n        response = ecs_client.describe_clusters(clusters=[cluster_arn])\n        if response['clusters']:\n            running_tasks += response['clusters'][0]['runningTasksCount']\n    return running_tasks\n\n# Function to count RDS instances\ndef count_rds_instances(rds_client):\n    instances = rds_client.describe_db_instances()\n    return len(instances['DBInstances'])\n\n# Function to count RDS clusters\ndef count_rds_clusters(rds_client):\n    clusters = rds_client.describe_db_clusters()\n    return len(clusters['DBClusters'])\n\n# Initialize AWS Organizations client\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs)\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\n\n# Initialize total counters\ntotal_accounts = 0\ntotal_ec2_instances = 0\ntotal_ecs_containers = 0\ntotal_rds_instances = 0\ntotal_rds_clusters = 0\ntotal_cloudfront_log_size_bytes = 0\n\n# Prepare CSV file\nwith open('aws_account_metrics.csv', mode='w', newline='') as file:\n    writer = csv.writer(file)"
./2026-02-12.md:    "old_string": "# Initialize AWS Organizations client\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs)\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\n\n# Initialize total counters\ntotal_accounts = 0\ntotal_ec2_instances = 0\ntotal_ecs_containers = 0\ntotal_rds_instances = 0\ntotal_rds_clusters = 0\ntotal_cloudfront_log_size_bytes = 0\n\n# Prepare CSV file\nwith open('aws_account_metrics.csv', mode='w', newline='') as file:\n    writer = csv.writer(file)\n    writer.writerow(['AccountName', 'AccountID', 'EC2Instances', 'ECSContainers', 'RDSInstances', 'RDSClusters', 'CloudFrontLogSizeGB'])\n\n    # Iterate through each active account\n    for account in active_accounts:\n        account_id = account['Id']\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin'\n        \n        try:\n            # Set up session using the constructed profile name\n            boto3.setup_default_session(profile_name=profile_name)\n",
./2026-02-12.md:    "output": "import boto3\nimport json\nimport os\nfrom datetime import datetime, date\nfrom dateutil.relativedelta import relativedelta\n\n# Attempt to load .env file if python-dotenv is installed\ntry:\n    from dotenv import load_dotenv\n    load_dotenv()\nexcept ImportError:\n    pass\n\ndef get_skip_accounts():\n    \"\"\"Returns a list of account IDs to skip from SKIP_ACCOUNTS env var.\"\"\"\n    skip_str = os.getenv('SKIP_ACCOUNTS', '')\n    if not skip_str:\n        return []\n    return [s.strip() for s in skip_str.split(',') if s.strip()]\n\ndef get_ou_ids():\n    \"\"\"Returns a list of OU IDs from OU_IDS env var, or defaults.\"\"\"\n    ou_str = os.getenv('OU_IDS', '')\n    if ou_str:\n        return [o.strip() for o in ou_str.split(',') if o.strip()]\n    # Default OUs if none provided\n    return ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n\ndef get_account_names():\n    \"\"\"Fetches account names from AWS Organizations, excluding skipped accounts.\"\"\"\n    org_client = boto3.client('organizations')\n    skip_accounts = get_skip_accounts()\n    accounts = {}\n    try:\n        paginator = org_client.get_paginator('list_accounts')\n        for page in paginator.paginate():\n            for account in page['Accounts']:\n                if account['Status'] == 'ACTIVE' and account['Id'] not in skip_accounts:\n                    accounts[account['Id']] = account['Name']\n    except Exception as e:\n        print(f\"Error fetching account names: {e}\")\n    return accounts\n\ndef get_previous_month_range():\n    \"\"\"Returns (start_date, end_date) for the previous month in YYYY-MM-DD format.\"\"\"\n    today = date.today()\n    first_day_curr = today.replace(day=1)\n    last_day_prev = first_day_curr - relativedelta(days=1)\n    start_date = last_day_prev.replace(day=1).strftime('%Y-%m-%d')\n    end_date = first_day_curr.strftime('%Y-%m-%d')\n    return start_date, end_date\n\ndef get_last_n_months_ranges(n=3):\n    \"\"\"Returns a list of (start_date, end_date, label) for the last n months.\"\"\"\n    ranges = []\n    current_date = datetime.now().replace(day=1)\n    for i in range(1, n + 1):\n        start_dt = current_date - relativedelta(months=i)\n        end_dt = current_date - relativedelta(months=i-1)\n        ranges.append((\n            start_dt.strftime('%Y-%m-%d'),\n            end_dt.strftime('%Y-%m-%d'),\n            start_dt.strftime('%Y-%m')\n        ))\n    return ranges\n\ndef get_aws_pricing(service_code, filters):\n    \"\"\"Generic helper to fetch on-demand price from AWS Pricing API (us-east-1).\"\"\"\n    pricing_client = boto3.client('pricing', region_name='us-east-1')\n    try:\n        response = pricing_client.get_products(\n            ServiceCode=service_code,\n            Filters=filters\n        )\n        if response['PriceList']:\n            price_item = json.loads(response['PriceList'][0])\n            on_demand = price_item['terms']['OnDemand']\n            term_key = list(on_demand.keys())[0]\n            price_dimensions = on_demand[term_key]['priceDimensions']\n            dim_key = list(price_dimensions.keys())[0]\n            return float(price_dimensions[dim_key]['pricePerUnit']['USD'])\n    except Exception as e:\n        print(f\"Error fetching pricing for {service_code}: {e}\")\n    return None\n\ndef setup_org_accounts_session(ou_ids=None, profile_suffix='.admin'):\n    \"\"\"\n    Yields (account_id, account_name, profile_name) for active accounts in OUs.\n    Handles boto3 session setup for each account.\n    Excludes accounts in SKIP_ACCOUNTS env var.\n    \"\"\"\n    if ou_ids is None:\n        ou_ids = get_ou_ids()\n        \n    skip_accounts = get_skip_accounts()\n    org_client = boto3.client('organizations')\n    for ou_id in ou_ids:\n        paginator = org_client.get_paginator('list_accounts_for_parent')\n        for page in paginator.paginate(ParentId=ou_id):\n            for account in page['Accounts']:\n                if account['Status'] == 'ACTIVE' and account['Id'] not in skip_accounts:\n                    account_id = account['Id']\n                    # Sanitize account name for profile use\n                    account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n                    profile_name = f'{account_name}{profile_suffix}'\n                    yield account_id, account['Name'], profile_name\n"
./2026-02-12.md:    "old_string": "# Initialize AWS Organizations client\norg_client = boto3.client('organizations')\n\n# List of specific Organizational Units (OUs)\nou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nactive_accounts = []\n\n# Get all active accounts for the specified OUs, handling pagination\nfor ou_id in ou_ids:\n    next_token = None\n    while True:\n        if next_token:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)\n        else:\n            response = org_client.list_accounts_for_parent(ParentId=ou_id)\n        \n        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])\n        \n        next_token = response.get('NextToken')\n        if not next_token:\n            break\n\n# Initialize total counters\ntotal_accounts = 0\ntotal_ec2_instances = 0\ntotal_ecs_containers = 0\ntotal_rds_instances = 0\ntotal_rds_clusters = 0\ntotal_cloudfront_log_size_bytes = 0\n\n# Prepare CSV file\nwith open('aws_account_metrics.csv', mode='w', newline='') as file:\n    writer = csv.writer(file)\n    writer.writerow(['Account ID', 'Account Email', 'EC2 Instance Count', 'ECS Running Containers', 'RDS Instances', 'RDS Clusters', 'CloudFront Log Size (GB)'])\n\n    # Iterate through active accounts\n    for account in active_accounts:\n        account_id = account['Id']\n        \n        # Replace \" - \" with \"-\" and replace spaces with \"-\" in account names\n        account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n        profile_name = f'{account_name}.admin'  # Constructing the profile name\n        account_email = account['Email']\n        \n        try:\n            # Set up the default session using the AWS SSO profile for each account\n            boto3.setup_default_session(profile_name=profile_name)\n",
./2026-02-12.md:    "old_string": "def get_ou_ids():\n    \"\"\"Returns a list of OU IDs from OU_IDS env var, or defaults.\"\"\"\n    ou_str = os.getenv('OU_IDS', '')\n    if ou_str:\n        return [o.strip() for o in o.strip().split(',') if o.strip()]\n    # Default OUs if none provided\n    return ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n",
./2026-02-12.md:    "output": "import boto3\nimport json\nimport os\nfrom datetime import datetime, date\nfrom dateutil.relativedelta import relativedelta\n\n# Attempt to load .env file if python-dotenv is installed\ntry:\n    from dotenv import load_dotenv\n    load_dotenv()\nexcept ImportError:\n    pass\n\ndef get_skip_accounts():\n    \"\"\"Returns a list of account IDs to skip from SKIP_ACCOUNTS env var.\"\"\"\n    skip_str = os.getenv('SKIP_ACCOUNTS', '')\n    if not skip_str:\n        return []\n    return [s.strip() for s in skip_str.split(',') if s.strip()]\n\ndef get_ou_ids():\n    \"\"\"Returns a list of OU IDs from OU_IDS env var, or defaults.\"\"\"\n    ou_str = os.getenv('OU_IDS', '')\n    if ou_str:\n        return [o.strip() for o in ou_str.split(',') if o.strip()]\n    # Default OUs if none provided\n    return ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n\ndef get_account_names():\n    \"\"\"Fetches account names from AWS Organizations, excluding skipped accounts.\"\"\"\n    org_client = boto3.client('organizations')\n    skip_accounts = get_skip_accounts()\n    accounts = {}\n    try:\n        paginator = org_client.get_paginator('list_accounts')\n        for page in paginator.paginate():\n            for account in page['Accounts']:\n                if account['Status'] == 'ACTIVE' and account['Id'] not in skip_accounts:\n                    accounts[account['Id']] = account['Name']\n    except Exception as e:\n        print(f\"Error fetching account names: {e}\")\n    return accounts\n\ndef get_previous_month_range():\n    \"\"\"Returns (start_date, end_date) for the previous month in YYYY-MM-DD format.\"\"\"\n    today = date.today()\n    first_day_curr = today.replace(day=1)\n    last_day_prev = first_day_curr - relativedelta(days=1)\n    start_date = last_day_prev.replace(day=1).strftime('%Y-%m-%d')\n    end_date = first_day_curr.strftime('%Y-%m-%d')\n    return start_date, end_date\n\ndef get_last_n_months_ranges(n=3):\n    \"\"\"Returns a list of (start_date, end_date, label) for the last n months.\"\"\"\n    ranges = []\n    current_date = datetime.now().replace(day=1)\n    for i in range(1, n + 1):\n        start_dt = current_date - relativedelta(months=i)\n        end_dt = current_date - relativedelta(months=i-1)\n        ranges.append((\n            start_dt.strftime('%Y-%m-%d'),\n            end_dt.strftime('%Y-%m-%d'),\n            start_dt.strftime('%Y-%m')\n        ))\n    return ranges\n\ndef get_aws_pricing(service_code, filters):\n    \"\"\"Generic helper to fetch on-demand price from AWS Pricing API (us-east-1).\"\"\"\n    pricing_client = boto3.client('pricing', region_name='us-east-1')\n    try:\n        response = pricing_client.get_products(\n            ServiceCode=service_code,\n            Filters=filters\n        )\n        if response['PriceList']:\n            price_item = json.loads(response['PriceList'][0])\n            on_demand = price_item['terms']['OnDemand']\n            term_key = list(on_demand.keys())[0]\n            price_dimensions = on_demand[term_key]['priceDimensions']\n            dim_key = list(price_dimensions.keys())[0]\n            return float(price_dimensions[dim_key]['pricePerUnit']['USD'])\n    except Exception as e:\n        print(f\"Error fetching pricing for {service_code}: {e}\")\n    return None\n\ndef setup_org_accounts_session(ou_ids=None, profile_suffix='.admin'):\n    \"\"\"\n    Yields (account_dict, profile_name) for active accounts in OUs.\n    Handles boto3 session setup for each account.\n    Excludes accounts in SKIP_ACCOUNTS env var.\n    \"\"\"\n    if ou_ids is None:\n        ou_ids = get_ou_ids()\n        \n    skip_accounts = get_skip_accounts()\n    org_client = boto3.client('organizations')\n    for ou_id in ou_ids:\n        paginator = org_client.get_paginator('list_accounts_for_parent')\n        for page in paginator.paginate(ParentId=ou_id):\n            for account in page['Accounts']:\n                if account['Status'] == 'ACTIVE' and account['Id'] not in skip_accounts:\n                    # Sanitize account name for profile use\n                    account_name = account['Name'].replace(\" - \", \"-\").replace(\" \", \"-\")\n                    profile_name = f'{account_name}{profile_suffix}'\n                    yield account, profile_name\n"
./2026-02-12.md:    "old_string": "def get_ou_ids():\n    \"\"\"Returns a list of OU IDs from OU_IDS env var, or defaults.\"\"\"\n    ou_str = os.getenv('OU_IDS', '')\n    if ou_str:\n        return [o.strip() for o in ou_str.split(',') if o.strip()]\n    # Default OUs if none provided\n    return ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\n",
./2026-02-12.md:    "content": "# AWS Organization Unit IDs to scan (comma-separated)\n# OU_IDS=ou-h0vo-0waugyoz,ou-h0vo-32wgvd6w\n\n# AWS Account IDs to skip (comma-separated)\n# SKIP_ACCOUNTS=123456789012,987654321098\n",
./2026-02-12.md:I will sanitize the codebase by replacing specific identifiers like `dxw.admin` and `ou-h0vo-...` with generic placeholders in `aws_utils.py`, `setup.sh`, and `.env.example`.
./2026-02-12.md:    "content": "# Parent AWS profile for Organizations and SSO login\n# AWS_PARENT_PROFILE=my-parent-profile\n\n# AWS Organization Unit IDs to scan (comma-separated)\n# OU_IDS=ou-1234-abcd5678,ou-9012-efgh3456\n\n# AWS Account IDs to skip (comma-separated)\n# SKIP_ACCOUNTS=123456789012,987654321098\n",
./2026-02-12.md:    "pattern": "ou-h0vo|dxw\\.admin"
./2026-02-12.md:    "output": "Found 3 matches for pattern \"ou-h0vo|dxw\\.admin\" in path \".\":\n---\nFile: cloudfront.py\nL52: ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']\nL74: # Set up the default session for the billing account ('dxw.admin') to access cost data\nL75: boto3.setup_default_session(profile_name='dxw.admin')\n---"
./2026-02-12.md:    "pattern": "ou-h0vo|dxw\\.admin"
./2026-02-12.md:    "output": "No matches found for pattern \"ou-h0vo|dxw\\.admin\" in path \".\"."
./2026-02-12.md:I'll check for any remaining hardcoded OU strings by searching for the "ou-" prefix.**Tool Command**:
./2026-02-12.md:    "pattern": "ou-([a-z0-9]+)-([a-z0-9]+)"
./2026-02-12.md:    "output": "No matches found for pattern \"ou-([a-z0-9]+)-([a-z0-9]+)\" in path \".\"."
./2026-02-12.md:    "output": "Output: # Parent AWS profile for Organizations and SSO login\nAWS_PARENT_PROFILE=\"dxw.admin\"\n\n# AWS Organization Unit IDs to scan (comma-separated)\n# OU_IDS=ou-1234-abcd5678,ou-9012-efgh3456\n\n# AWS Account IDs to skip (comma-separated)\n# SKIP_ACCOUNTS=123456789012,987654321098\nProcess Group PGID: 38238"
./2026-02-12.md:    "content": "# Gemini CLI Project Context - AWS Organization Audit Scripts\n\n## Project Overview\nThis repository contains a collection of Python scripts for auditing AWS Organizations. It handles multi-account discovery, SSO authentication, and generates reports on costs, bandwidth, and resource inventory.\n\n## Core Guidelines\n\n- **Shared Logic:** All AWS interaction MUST go through `aws_utils.py`. Never initialize `boto3.client` directly without first ensuring a session via `aws_utils`.\n- **Authentication:** Use `ensure_sso_login()` to handle AWS SSO sessions. This triggers `aws sso login` via the CLI if the session is expired.\n- **Session Management:** \n    - Use `get_boto_session()` for parent/management account tasks (Organizations, Pricing API).\n    - Use `setup_org_accounts_session()` to iterate through accounts in specific OUs or the entire organization. This handles profile switching for multi-account scans.\n- **Configuration:** \n    - Honor environment variables: `AWS_PARENT_PROFILE`, `OU_IDS`, and `SKIP_ACCOUNTS`.\n    - Support `.env` files via `python-dotenv` (handled in `aws_utils.py`).\n- **Reporting:** \n    - For large datasets, prefer CSV output using the `csv` module.\n    - For console summaries, use formatted tables with `print` or `pandas`.\n\n## Key Patterns\n\n### Account Iteration\nAlways use the generator pattern for cross-account audits:\n```python\nfrom aws_utils import setup_org_accounts_session\nfor account, profile_name in setup_org_accounts_session():\n    boto3.setup_default_session(profile_name=profile_name)\n    # ... account-specific logic ...\n```\n\n### Date Handling\nStandardize billing periods using `aws_utils.py` helpers:\n- `get_previous_month_range()`: Returns `(start, end)` for the last full month.\n- `get_last_n_months_ranges(n)`: Returns list of ranges for historical analysis.\n\n## Tooling & Dependencies\n- **Primary:** `boto3`, `pandas`, `python-dotenv`.\n- **Secondary:** `gspread` (for Google Sheets reports).\n- **Environment:** Always assume execution within a virtual environment (`.venv`).\n\n## Security & Privacy\n- **Identifiers:** Never hardcode Account IDs, OU IDs, or Profile Names. Use the configuration patterns in `aws_utils`.\n- **Sanitization:** When creating examples or documentation, use generic placeholders like `ou-1234-abcd5678` or `123456789012`.\n"
./2026-02-12.md:    "content": "import unittest\nfrom unittest.mock import patch, MagicMock\nimport os\nimport sys\nfrom datetime import date\n\n# Add the project root to sys.path so we can import aws_utils\nsys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))\n\nimport aws_utils\n\nclass TestAwsUtils(unittest.TestCase):\n\n    def test_get_previous_month_range(self):\n        start, end = aws_utils.get_previous_month_range()\n        self.assertTrue(len(start) == 10) # YYYY-MM-DD\n        self.assertTrue(len(end) == 10)\n        self.assertTrue(start.endswith(\"-01\"))\n        self.assertTrue(end.endswith(\"-01\"))\n\n    def test_get_last_n_months_ranges(self):\n        ranges = aws_utils.get_last_n_months_ranges(3)\n        self.assertEqual(len(ranges), 3)\n        for start, end, label in ranges:\n            self.assertEqual(len(start), 10)\n            self.assertEqual(len(end), 10)\n            self.assertEqual(len(label), 7) # YYYY-MM\n\n    @patch.dict(os.environ, {'SKIP_ACCOUNTS': '123, 456 '})\n    def test_get_skip_accounts(self):\n        skipped = aws_utils.get_skip_accounts()\n        self.assertEqual(skipped, ['123', '456'])\n\n    @patch.dict(os.environ, {'SKIP_ACCOUNTS': ''})\n    def test_get_skip_accounts_empty(self):\n        skipped = aws_utils.get_skip_accounts()\n        self.assertEqual(skipped, [])\n\n    @patch.dict(os.environ, {'OU_IDS': 'ou-1, ou-2'})\n    def test_get_ou_ids(self):\n        ous = aws_utils.get_ou_ids()\n        self.assertEqual(ous, ['ou-1', 'ou-2'])\n\n    @patch.dict(os.environ, {'OU_IDS': ''})\n    def test_get_ou_ids_empty(self):\n        ous = aws_utils.get_ou_ids()\n        self.assertEqual(ous, [])\n\n    @patch('aws_utils.get_boto_session')\n    def test_get_account_names(self, mock_get_session):\n        # Mock session and client\n        mock_session = MagicMock()\n        mock_client = MagicMock()\n        mock_get_session.return_value = mock_session\n        mock_session.client.return_value = mock_client\n\n        # Mock pagination\n        mock_paginator = MagicMock()\n        mock_client.get_paginator.return_value = mock_paginator\n        mock_paginator.paginate.return_value = [\n            {\n                'Accounts': [\n                    {'Id': '111', 'Name': 'Acc1', 'Status': 'ACTIVE'},\n                    {'Id': '222', 'Name': 'Acc2', 'Status': 'SUSPENDED'},\n                    {'Id': '333', 'Name': 'Acc3', 'Status': 'ACTIVE'}\n                ]\n            }\n        ]\n\n        # Mock skip accounts to be empty for this test\n        with patch('aws_utils.get_skip_accounts', return_value=[]):\n            accounts = aws_utils.get_account_names()\n        \n        self.assertEqual(len(accounts), 2)\n        self.assertEqual(accounts['111'], 'Acc1')\n        self.assertEqual(accounts['333'], 'Acc3')\n        # Ensure SUSPENDED account is ignored\n        self.assertNotIn('222', accounts)\n\n    @patch('aws_utils.get_boto_session')\n    def test_get_account_names_with_skip(self, mock_get_session):\n        mock_session = MagicMock()\n        mock_client = MagicMock()\n        mock_get_session.return_value = mock_session\n        mock_session.client.return_value = mock_client\n\n        mock_paginator = MagicMock()\n        mock_client.get_paginator.return_value = mock_paginator\n        mock_paginator.paginate.return_value = [\n            {\n                'Accounts': [\n                    {'Id': '111', 'Name': 'Acc1', 'Status': 'ACTIVE'},\n                    {'Id': '222', 'Name': 'Acc2', 'Status': 'ACTIVE'}\n                ]\n            }\n        ]\n\n        with patch('aws_utils.get_skip_accounts', return_value=['222']):\n            accounts = aws_utils.get_account_names()\n        \n        self.assertEqual(len(accounts), 1)\n        self.assertEqual(accounts['111'], 'Acc1')\n        self.assertNotIn('222', accounts)\n\n    @patch('aws_utils.get_boto_session')\n    def test_setup_org_accounts_session_explicit_ou(self, mock_get_session):\n        mock_session = MagicMock()\n        mock_org_client = MagicMock()\n        mock_get_session.return_value = mock_session\n        mock_session.client.return_value = mock_org_client\n\n        mock_paginator = MagicMock()\n        mock_org_client.get_paginator.return_value = mock_paginator\n        mock_paginator.paginate.return_value = [\n            {\n                'Accounts': [\n                    {'Id': '111', 'Name': 'Acc 1', 'Status': 'ACTIVE'}\n                ]\n            }\n        ]\n\n        with patch('aws_utils.get_skip_accounts', return_value=[]):\n            # Iterate through generator\n            results = list(aws_utils.setup_org_accounts_session(ou_ids=['ou-test']))\n        \n        self.assertEqual(len(results), 1)\n        account, profile = results[0]\n        self.assertEqual(account['Id'], '111')\n        self.assertEqual(profile, 'Acc-1.admin') # Checks sanitization of name\n\n    @patch('aws_utils.get_boto_session')\n    def test_setup_org_accounts_session_fallback(self, mock_get_session):\n        mock_session = MagicMock()\n        mock_org_client = MagicMock()\n        mock_get_session.return_value = mock_session\n        mock_session.client.return_value = mock_org_client\n\n        mock_paginator = MagicMock()\n        mock_org_client.get_paginator.return_value = mock_paginator\n        mock_paginator.paginate.return_value = [\n            {\n                'Accounts': [\n                    {'Id': '999', 'Name': 'FallbackAcc', 'Status': 'ACTIVE'}\n                ]\n            }\n        ]\n\n        with patch('aws_utils.get_ou_ids', return_value=[]), \\\n             patch('aws_utils.get_skip_accounts', return_value=[]):\n            \n            results = list(aws_utils.setup_org_accounts_session())\n        \n        self.assertEqual(len(results), 1)\n        account, profile = results[0]\n        self.assertEqual(account['Id'], '999')\n        # Check that it called list_accounts (fallback) not list_accounts_for_parent\n        mock_org_client.get_paginator.assert_called_with('list_accounts')\n\nif __name__ == '__main__':\n    unittest.main()\n",
./2026-02-12.md:    "output": "# Gemini CLI Project Context - AWS Organization Audit Scripts\n\n## Project Overview\nThis repository contains a collection of Python scripts for auditing AWS Organizations. It handles multi-account discovery, SSO authentication, and generates reports on costs, bandwidth, and resource inventory.\n\n## Core Guidelines\n\n- **Shared Logic:** All AWS interaction MUST go through `aws_utils.py`. Never initialize `boto3.client` directly without first ensuring a session via `aws_utils`.\n- **Authentication:** Use `ensure_sso_login()` to handle AWS SSO sessions. This triggers `aws sso login` via the CLI if the session is expired.\n- **Session Management:** \n    - Use `get_boto_session()` for parent/management account tasks (Organizations, Pricing API).\n    - Use `setup_org_accounts_session()` to iterate through accounts in specific OUs or the entire organization. This handles profile switching for multi-account scans.\n- **Configuration:** \n    - Honor environment variables: `AWS_PARENT_PROFILE`, `OU_IDS`, and `SKIP_ACCOUNTS`.\n    - Support `.env` files via `python-dotenv` (handled in `aws_utils.py`).\n- **Reporting:** \n    - For large datasets, prefer CSV output using the `csv` module.\n    - For console summaries, use formatted tables with `print` or `pandas`.\n\n## Key Patterns\n\n### Account Iteration\nAlways use the generator pattern for cross-account audits:\n```python\nfrom aws_utils import setup_org_accounts_session\nfor account, profile_name in setup_org_accounts_session():\n    boto3.setup_default_session(profile_name=profile_name)\n    # ... account-specific logic ...\n```\n\n### Date Handling\nStandardize billing periods using `aws_utils.py` helpers:\n- `get_previous_month_range()`: Returns `(start, end)` for the last full month.\n- `get_last_n_months_ranges(n)`: Returns list of ranges for historical analysis.\n\n## Tooling & Dependencies\n- **Primary:** `boto3`, `pandas`, `python-dotenv`.\n- **Secondary:** `gspread` (for Google Sheets reports).\n- **Environment:** Always assume execution within a virtual environment (`.venv`).\n\n## Security & Privacy\n- **Identifiers:** Never hardcode Account IDs, OU IDs, or Profile Names. Use the configuration patterns in `aws_utils`.\n- **Sanitization:** When creating examples or documentation, use generic placeholders like `ou-1234-abcd5678` or `123456789012`.\n"
./2026-02-12.md:    "content": "# Gemini CLI Project Context - AWS Organization Audit Scripts\n\n## Project Overview\nThis repository contains a collection of Python scripts for auditing AWS Organizations. It handles multi-account discovery, SSO authentication, and generates reports on costs, bandwidth, and resource inventory.\n\n## Core Guidelines\n\n- **Shared Logic:** All AWS interaction MUST go through `aws_utils.py`. Never initialize `boto3.client` directly without first ensuring a session via `aws_utils`.\n- **Authentication:** Use `ensure_sso_login()` to handle AWS SSO sessions. This triggers `aws sso login` via the CLI if the session is expired.\n- **Session Management:** \n    - Use `get_boto_session()` for parent/management account tasks (Organizations, Pricing API).\n    - Use `setup_org_accounts_session()` to iterate through accounts in specific OUs or the entire organization. This handles profile switching for multi-account scans and unsets conflicting IAM env vars.\n- **Configuration:** \n    - Honor environment variables: `AWS_PARENT_PROFILE`, `OU_IDS`, `SKIP_ACCOUNTS`, `GOOGLE_CLIENT_SECRET_FILE`, and `GOOGLE_AUTHORIZED_USER_FILE`.\n    - Support `.env` files via `python-dotenv` (handled in `aws_utils.py`).\n- **Reporting:** \n    - For large datasets, prefer CSV output using the `csv` module.\n    - **Google Sheets:** Use `export_to_sheets(title, headers, rows)` from `aws_utils.py` to automatically export CSV data to Google Sheets.\n    - For console summaries, use formatted tables with `print` or `pandas`.\n\n## Key Patterns\n\n### Account Iteration\nAlways use the generator pattern for cross-account audits:\n```python\nfrom aws_utils import setup_org_accounts_session\nfor account, profile_name in setup_org_accounts_session():\n    boto3.setup_default_session(profile_name=profile_name)\n    # ... account-specific logic ...\n```\n\n### Date Handling\nStandardize billing periods using `aws_utils.py` helpers:\n- `get_previous_month_range()`: Returns `(start, end)` for the last full month.\n- `get_last_n_months_ranges(n)`: Returns list of ranges for historical analysis.\n\n### Testing\n- Place tests in the `tests/` directory.\n- Use `unittest` and `unittest.mock`.\n- **Mocks:** Always mock `aws_utils.get_boto_session` and AWS clients to ensure tests run without credentials or network access.\n\n## Tooling & Dependencies\n- **Primary:** `boto3`, `pandas`, `python-dotenv`.\n- **Secondary:** `gspread`, `google-auth-oauthlib` (for Google Sheets reports).\n- **Environment:** Always execute within the project's virtual environment (`.venv`).\n\n## Security & Privacy\n- **Identifiers:** Never hardcode Account IDs, OU IDs, or Profile Names. Use the configuration patterns in `aws_utils`.\n- **Sanitization:** When creating examples or documentation, use generic placeholders like `ou-1234-abcd5678` or `123456789012`.\n- **Environment:** Do not commit `.env` files or Google authentication JSON files (`client_secret.json`, `authorized_user.json`).\n",
Binary file ./tests/__pycache__/test_aws_utils.cpython-312.pyc matches
./tests/test_aws_utils.py:    @patch.dict(os.environ, {'OU_IDS': 'ou-1, ou-2'})
./tests/test_aws_utils.py:        self.assertEqual(ous, ['ou-1', 'ou-2'])
./tests/test_aws_utils.py:            results = list(aws_utils.setup_org_accounts_session(ou_ids=['ou-test']))
./.env:OU_IDS=ou-h0vo-32wgvd6w,ou-h0vo-0waugyoz
./.venv/lib/python3.12/site-packages/pyparsing/core.py:        # ref: https://stackoverflow.com/questions/267399/how-do-you-match-only-valid-roman-numerals-with-a-regular-expression
Binary file ./.venv/lib/python3.12/site-packages/pyparsing/__pycache__/core.cpython-312.pyc matches
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:          "TargetId": "ou-examplerootid111-exampleouid111"
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:            "Arn": "arn:aws:organizations::111111111111:ou/o-exampleorgid/ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:            "Id": "ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:          "OrganizationalUnitId": "ou-examplerootid111-exampleouid111"
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:          "OrganizationalUnitId": "ou-examplerootid111-exampleouid111"
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:            "Arn": "arn:aws:organizations::111111111111:ou/o-exampleorgid/ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:            "Id": "ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:          "TargetId": "ou-examplerootid111-exampleouid111"
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:        "id": "to-detach-a-policy-from-a-root-ou-or-account",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:          "ParentId": "ou-examplerootid111-exampleouid111"
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:        "id": "to-retrieve-a-list-of-all-of-the-accounts-in-a-root-or-ou-1472509590974",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:          "ParentId": "ou-examplerootid111-exampleouid111"
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:              "Id": "ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:              "Id": "ou-examplerootid111-exampleouid222",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:              "Arn": "arn:aws:organizations::111111111111:ou/o-exampleorgid/ou-examlerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:              "Id": "ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:              "Arn": "arn:aws:organizations::111111111111:ou/o-exampleorgid/ou-examlerootid111-exampleouid222",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:              "Id": "ou-examplerootid111-exampleouid222",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:              "Id": "ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:        "id": "to-retrieve-a-list-of-all-of-the-parents-of-a-child-ou-or-account",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:        "id": "to-retrieve-a-list-of-policies-attached-to-a-root-ou-or-account",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:              "Arn": "arn:aws:organizations::111111111111:ou/o-exampleorgid/ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:              "TargetId": "ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:          "DestinationParentId": "ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:        "id": "to-move-an-ou-or-account-to-another-ou-or-the-root",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:          "OrganizationalUnitId": "ou-examplerootid111-exampleouid111"
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:            "Arn": "arn:aws:organizations::111111111111:ou/o-exampleorgid/ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/organizations/2016-11-28/examples-1.json:            "Id": "ou-examplerootid111-exampleouid111",
./.venv/lib/python3.12/site-packages/botocore/data/glacier/2012-06-01/examples-1.json:        "id": "to-get-information-about-a-job-you-previously-initiated-1481840928592",
./.venv/lib/python3.12/site-packages/botocore/data/iam/2010-05-08/examples-1.json:          "EntityPath": "o-a1b2c3d4e5/r-f6g7h8i9j0example/ou-1a2b3c-k9l8m7n6o5example"
./.venv/lib/python3.12/site-packages/botocore/data/iam/2010-05-08/examples-1.json:        "description": "The following operation generates a report for the organizational unit ou-rge0-awexample",
./.venv/lib/python3.12/site-packages/botocore/data/iam/2010-05-08/examples-1.json:              "EntityPath": "o-a1b2c3d4e5/r-f6g7h8i9j0example/ou-1a2b3c-k9l8m7n6o5example/111122223333",
./.venv/lib/python3.12/site-packages/botocore/data/iam/2010-05-08/examples-1.json:              "EntityPath": "o-a1b2c3d4e5/r-f6g7h8i9j0example/ou-1a2b3c-k9l8m7n6o5example/123456789012",
./.venv/lib/python3.12/site-packages/botocore/data/snowball/2016-06-30/examples-1.json:        "id": "to-see-your-snowball-service-limit-and-the-number-of-snowballs-you-have-in-use-1482863394588",
./.venv/lib/python3.12/site-packages/pandas/core/strings/accessor.py:        0    caribou---
./.venv/lib/python3.12/site-packages/pandas/core/strings/accessor.py:        0    -caribou--
Binary file ./.venv/lib/python3.12/site-packages/pandas/core/strings/__pycache__/accessor.cpython-312.pyc matches
./.venv/lib/python3.12/site-packages/pandas/io/sql.py:    # See https://stackoverflow.com/questions/6514274/how-do-you-escape-strings\
./GEMINI.md:- **Sanitization:** When creating examples or documentation, use generic placeholders like `ou-1234-abcd5678` or `123456789012`.
./.env.example:# OU_IDS=ou-1234-abcd5678,ou-9012-efgh3456
Binary file ./.git/objects/8e/30703583b0929b35edb22357a78a7962583823 matches
commit ec16e288a4c1519ff236d7f3cec6f0a8b04f08a8
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 18:13:01 2026 +0000

    add test suite for aws_utils and script smoke tests

diff --git a/tests/test_aws_utils.py b/tests/test_aws_utils.py
new file mode 100644
index 0000000..38220ef
--- /dev/null
+++ b/tests/test_aws_utils.py
@@ -0,0 +1,160 @@
+import unittest
+from unittest.mock import patch, MagicMock
+import os
+import sys
+from datetime import date
+
+# Add the project root to sys.path so we can import aws_utils
+sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+
+import aws_utils
+
+class TestAwsUtils(unittest.TestCase):
+
+    def test_get_previous_month_range(self):
+        start, end = aws_utils.get_previous_month_range()
+        self.assertTrue(len(start) == 10) # YYYY-MM-DD
+        self.assertTrue(len(end) == 10)
+        self.assertTrue(start.endswith("-01"))
+        self.assertTrue(end.endswith("-01"))
+
+    def test_get_last_n_months_ranges(self):
+        ranges = aws_utils.get_last_n_months_ranges(3)
+        self.assertEqual(len(ranges), 3)
+        for start, end, label in ranges:
+            self.assertEqual(len(start), 10)
+            self.assertEqual(len(end), 10)
+            self.assertEqual(len(label), 7) # YYYY-MM
+
+    @patch.dict(os.environ, {'SKIP_ACCOUNTS': '123, 456 '})
+    def test_get_skip_accounts(self):
+        skipped = aws_utils.get_skip_accounts()
+        self.assertEqual(skipped, ['123', '456'])
+
+    @patch.dict(os.environ, {'SKIP_ACCOUNTS': ''})
+    def test_get_skip_accounts_empty(self):
+        skipped = aws_utils.get_skip_accounts()
+        self.assertEqual(skipped, [])
+
+    @patch.dict(os.environ, {'OU_IDS': 'ou-1, ou-2'})
+    def test_get_ou_ids(self):
+        ous = aws_utils.get_ou_ids()
+        self.assertEqual(ous, ['ou-1', 'ou-2'])
+
+    @patch.dict(os.environ, {'OU_IDS': ''})
+    def test_get_ou_ids_empty(self):
+        ous = aws_utils.get_ou_ids()
+        self.assertEqual(ous, [])
+
+    @patch('aws_utils.get_boto_session')
+    def test_get_account_names(self, mock_get_session):
+        # Mock session and client
+        mock_session = MagicMock()
+        mock_client = MagicMock()
+        mock_get_session.return_value = mock_session
+        mock_session.client.return_value = mock_client
+
+        # Mock pagination
+        mock_paginator = MagicMock()
+        mock_client.get_paginator.return_value = mock_paginator
+        mock_paginator.paginate.return_value = [
+            {
+                'Accounts': [
+                    {'Id': '111', 'Name': 'Acc1', 'Status': 'ACTIVE'},
+                    {'Id': '222', 'Name': 'Acc2', 'Status': 'SUSPENDED'},
+                    {'Id': '333', 'Name': 'Acc3', 'Status': 'ACTIVE'}
+                ]
+            }
+        ]
+
+        # Mock skip accounts to be empty for this test
+        with patch('aws_utils.get_skip_accounts', return_value=[]):
+            accounts = aws_utils.get_account_names()
+        
+        self.assertEqual(len(accounts), 2)
+        self.assertEqual(accounts['111'], 'Acc1')
+        self.assertEqual(accounts['333'], 'Acc3')
+        # Ensure SUSPENDED account is ignored
+        self.assertNotIn('222', accounts)
+
+    @patch('aws_utils.get_boto_session')
+    def test_get_account_names_with_skip(self, mock_get_session):
+        mock_session = MagicMock()
+        mock_client = MagicMock()
+        mock_get_session.return_value = mock_session
+        mock_session.client.return_value = mock_client
+
+        mock_paginator = MagicMock()
+        mock_client.get_paginator.return_value = mock_paginator
+        mock_paginator.paginate.return_value = [
+            {
+                'Accounts': [
+                    {'Id': '111', 'Name': 'Acc1', 'Status': 'ACTIVE'},
+                    {'Id': '222', 'Name': 'Acc2', 'Status': 'ACTIVE'}
+                ]
+            }
+        ]
+
+        with patch('aws_utils.get_skip_accounts', return_value=['222']):
+            accounts = aws_utils.get_account_names()
+        
+        self.assertEqual(len(accounts), 1)
+        self.assertEqual(accounts['111'], 'Acc1')
+        self.assertNotIn('222', accounts)
+
+    @patch('aws_utils.get_boto_session')
+    def test_setup_org_accounts_session_explicit_ou(self, mock_get_session):
+        mock_session = MagicMock()
+        mock_org_client = MagicMock()
+        mock_get_session.return_value = mock_session
+        mock_session.client.return_value = mock_org_client
+
+        mock_paginator = MagicMock()
+        mock_org_client.get_paginator.return_value = mock_paginator
+        mock_paginator.paginate.return_value = [
+            {
+                'Accounts': [
+                    {'Id': '111', 'Name': 'Acc 1', 'Status': 'ACTIVE'}
+                ]
+            }
+        ]
+
+        with patch('aws_utils.get_skip_accounts', return_value=[]):
+            # Iterate through generator
+            results = list(aws_utils.setup_org_accounts_session(ou_ids=['ou-test']))
+        
+        self.assertEqual(len(results), 1)
+        account, profile = results[0]
+        self.assertEqual(account['Id'], '111')
+        self.assertEqual(profile, 'Acc-1.admin') # Checks sanitization of name
+
+    @patch('aws_utils.get_boto_session')
+    def test_setup_org_accounts_session_fallback(self, mock_get_session):
+        mock_session = MagicMock()
+        mock_org_client = MagicMock()
+        mock_get_session.return_value = mock_session
+        mock_session.client.return_value = mock_org_client
+
+        mock_paginator = MagicMock()
+        mock_org_client.get_paginator.return_value = mock_paginator
+        mock_paginator.paginate.return_value = [
+            {
+                'Accounts': [
+                    {'Id': '999', 'Name': 'FallbackAcc', 'Status': 'ACTIVE'}
+                ]
+            }
+        ]
+
+        with patch('aws_utils.get_ou_ids', return_value=[]), \
+             patch('aws_utils.get_skip_accounts', return_value=[]):
+            
+            results = list(aws_utils.setup_org_accounts_session())
+        
+        self.assertEqual(len(results), 1)
+        account, profile = results[0]
+        self.assertEqual(account['Id'], '999')
+        # Check that it called list_accounts (fallback) not list_accounts_for_parent
+        mock_org_client.get_paginator.assert_called_with('list_accounts')
+
+if __name__ == '__main__':
+    unittest.main()

commit 66846dbfe0bc655b0d7d2bc987fa684907971ee4
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 17:57:43 2026 +0000

    add GEMINI.md for project context and AI agent guidance

diff --git a/GEMINI.md b/GEMINI.md
new file mode 100644
index 0000000..a9e1816
--- /dev/null
+++ b/GEMINI.md
@@ -0,0 +1,43 @@
+# Gemini CLI Project Context - AWS Organization Audit Scripts
+
+## Project Overview
+This repository contains a collection of Python scripts for auditing AWS Organizations. It handles multi-account discovery, SSO authentication, and generates reports on costs, bandwidth, and resource inventory.
+
+## Core Guidelines
+
+- **Shared Logic:** All AWS interaction MUST go through `aws_utils.py`. Never initialize `boto3.client` directly without first ensuring a session via `aws_utils`.
+- **Authentication:** Use `ensure_sso_login()` to handle AWS SSO sessions. This triggers `aws sso login` via the CLI if the session is expired.
+- **Session Management:** 
+    - Use `get_boto_session()` for parent/management account tasks (Organizations, Pricing API).
+    - Use `setup_org_accounts_session()` to iterate through accounts in specific OUs or the entire organization. This handles profile switching for multi-account scans.
+- **Configuration:** 
+    - Honor environment variables: `AWS_PARENT_PROFILE`, `OU_IDS`, and `SKIP_ACCOUNTS`.
+    - Support `.env` files via `python-dotenv` (handled in `aws_utils.py`).
+- **Reporting:** 
+    - For large datasets, prefer CSV output using the `csv` module.
+    - For console summaries, use formatted tables with `print` or `pandas`.
+
+## Key Patterns
+
+### Account Iteration
+Always use the generator pattern for cross-account audits:
+```python
+from aws_utils import setup_org_accounts_session
+for account, profile_name in setup_org_accounts_session():
+    boto3.setup_default_session(profile_name=profile_name)
+    # ... account-specific logic ...
+```
+
+### Date Handling
+Standardize billing periods using `aws_utils.py` helpers:
+- `get_previous_month_range()`: Returns `(start, end)` for the last full month.
+- `get_last_n_months_ranges(n)`: Returns list of ranges for historical analysis.
+
+## Tooling & Dependencies
+- **Primary:** `boto3`, `pandas`, `python-dotenv`.
+- **Secondary:** `gspread` (for Google Sheets reports).
+- **Environment:** Always assume execution within a virtual environment (`.venv`).
+
+## Security & Privacy
+- **Identifiers:** Never hardcode Account IDs, OU IDs, or Profile Names. Use the configuration patterns in `aws_utils`.
+- **Sanitization:** When creating examples or documentation, use generic placeholders like `ou-1234-abcd5678` or `123456789012`.

commit ae687ab1691e832df419baaef684c001fbc5eb0b
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 17:00:57 2026 +0000

    fix: use get_boto_session to ensure clean environment and correct profile usage

diff --git a/.env b/.env
new file mode 100644
index 0000000..5fd702e
--- /dev/null
+++ b/.env
@@ -0,0 +1,8 @@
+# Parent AWS profile for Organizations and SSO login
+AWS_PARENT_PROFILE="dxw.admin"
+
+# AWS Organization Unit IDs to scan (comma-separated)
+# OU_IDS=ou-1234-abcd5678,ou-9012-efgh3456
+
+# AWS Account IDs to skip (comma-separated)
+# SKIP_ACCOUNTS=123456789012,987654321098

commit add381d68cd8bfa00de88e422a2afb734ada1a8b
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 16:44:15 2026 +0000

    sanitize codebase by removing specific AWS identifiers and using generic placeholders

diff --git a/cloudfront.py b/cloudfront.py
index 50167a1..3c5adc4 100755
--- a/cloudfront.py
+++ b/cloudfront.py
@@ -1,113 +1,64 @@
 import boto3
-from datetime import datetime, timedelta
+import csv
 from botocore.exceptions import ClientError, ProfileNotFound
+from aws_utils import setup_org_accounts_session, ensure_sso_login
 
-# Function to list CloudFront distributions and associated domains
-def list_cloudfront_distributions(cloudfront_client):
-    distributions_info = []
-    paginator = cloudfront_client.get_paginator('list_distributions')
-    for page in paginator.paginate():
-        for distribution in page['DistributionList'].get('Items', []):
-            distribution_id = distribution['Id']
-            distribution_arn = f"arn:aws:cloudfront::{boto3.client('sts').get_caller_identity()['Account']}:distribution/{distribution_id}"
-            distribution_domains = distribution.get('Aliases', {}).get('Items', [])
-            distributions_info.append({
-                'DistributionId': distribution_id,
-                'DistributionArn': distribution_arn,
-                'Domains': distribution_domains
-            })
-    return distributions_info
-
-# Function to get bandwidth cost and usage for a given distribution ARN
-def get_bandwidth_cost_and_usage(cost_explorer_client, distribution_arn, start_date, end_date):
+def get_cloudfront_details(account_id, account_name, cloudfront_client):
+    """
+    Lists all CloudFront distributions for the account and determines
+    their associated details.
+    """
+    dist_info_list = []
     try:
-        response = cost_explorer_client.get_cost_and_usage(
-            TimePeriod={
-                'Start': start_date,
-                'End': end_date
-            },
-            Granularity='DAILY',
-            Metrics=['UnblendedCost', 'UsageQuantity'],
-            Filter={
-                'And': [
-                    {'Dimensions': {'Key': 'SERVICE', 'Values': ['Amazon CloudFront']}},
-                    {'Dimensions': {'Key': 'LINKED_ACCOUNT', 'Values': [boto3.client('sts').get_caller_identity()['Account']]}},
-                    {'Dimensions': {'Key': 'RESOURCE_ARN', 'Values': [distribution_arn]}}
-                ]
-            }
-        )
-
-        cost = response['ResultsByTime'][0]['Total']['UnblendedCost']['Amount']
-        usage = response['ResultsByTime'][0]['Total']['UsageQuantity']['Amount']
-        return cost, usage
-
-    except Exception as e:
-        print(f"Error retrieving cost and usage for distribution {distribution_arn}: {e}")
-        return 'N/A', 'N/A'
-
-# Initialize AWS Organizations client
-org_client = boto3.client('organizations')
-
-# List of specific Organizational Units (OUs)
-ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
-active_accounts = []
-
-# Get all active accounts for the specified OUs, handling pagination
-for ou_id in ou_ids:
-    next_token = None
-    while True:
-        if next_token:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
-        else:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id)
-        
-        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
-        
-        next_token = response.get('NextToken')
-        if not next_token:
-            break
+        paginator = cloudfront_client.get_paginator('list_distributions')
+        for page in paginator.paginate():
+            if 'DistributionList' in page and 'Items' in page['DistributionList']:
+                for dist in page['DistributionList']['Items']:
+                    dist_info_list.append({
+                        'AccountName': account_name,
+                        'AccountID': account_id,
+                        'ID': dist['Id'],
+                        'DomainName': dist['DomainName'],
+                        'Status': dist['Status'],
+                        'Enabled': dist['Enabled'],
+                        'Comment': dist.get('Comment', 'N/A')
+                    })
+    except ClientError as e:
+        print(f"  -> Error listing distributions: {e}")
+    return dist_info_list
 
-# Calculate the date range for the last 7 days
-end_date = datetime.now().strftime('%Y-%m-%d')
-start_date = (datetime.now() - timedelta(days=7)).strftime('%Y-%m-%d')
+def main():
+    if not ensure_sso_login():
+        return
 
-# Set up the default session for the billing account ('dxw.admin') to access cost data
-boto3.setup_default_session(profile_name='dxw.admin')
-cost_explorer_client = boto3.client('ce')
-
-# Iterate through active accounts and list CloudFront distributions
-for account in active_accounts:
-    account_id = account['Id']
-    
-    # Replace " - " with "-" and replace spaces with "-" in account names
-    account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
-    profile_name = f'{account_name}.admin'  # Constructing the profile name
+    all_dist_info = []
+    csv_filepath = 'cloudfront_distributions_info.csv'
     
-    try:
-        # Set up the default session using the AWS SSO profile for each account
-        boto3.setup_default_session(profile_name=profile_name)
-        
-        # Initialize CloudFront client with the SSO session
-        cloudfront_client = boto3.client('cloudfront')
-        
-        # Get CloudFront distribution info
-        distributions_info = list_cloudfront_distributions(cloudfront_client)
-        
-        # Get bandwidth cost/usage for each distribution using the ARN from the billing account
-        for dist in distributions_info:
-            distribution_id = dist['DistributionId']
-            distribution_arn = dist['DistributionArn']
-            domains = dist['Domains'] if dist['Domains'] else ['No custom domains']
-            
-            # Retrieve the bandwidth cost and usage from the billing account session
-            cost, usage = get_bandwidth_cost_and_usage(cost_explorer_client, distribution_arn, start_date, end_date)
+    with open(csv_filepath, mode='w', newline='') as csvfile:
+        fieldnames = ['AccountName', 'AccountID', 'ID', 'DomainName', 'Status', 'Enabled', 'Comment']
+        writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
+        writer.writeheader()
+
+        print("\nIterating through accounts to collect CloudFront data...")
+        for account, profile_name in setup_org_accounts_session():
+            account_id = account['Id']
+            account_name = account['Name']
+            print(f"\n--- Checking Account: {account_name} (ID: {account_id}) ---")
             
-            for domain in domains:
-                print(f'"{account_name}":"{distribution_id}":"{domain}":"Cost={cost}":"Usage={usage} GB"')
-        
-        print(f"Processed account '{account_name}' (ID: {account_id})")
+            try:
+                boto3.setup_default_session(profile_name=profile_name)
+                cloudfront_client = boto3.client('cloudfront')
+                
+                dist_data = get_cloudfront_details(account_id, account_name, cloudfront_client)
+                for dist in dist_data:
+                    all_dist_info.append(dist)
+                    writer.writerow(dist)
+                    print(f"  [FOUND] ID: {dist['ID']}, Domain: {dist['DomainName']}, Status: {dist['Status']}")
+            except (ClientError, ProfileNotFound) as e:
+                print(f"  -> Error setting up session/profile '{profile_name}': {e}")
 
-    except (ClientError, ProfileNotFound) as e:
-        print(f"Error accessing account '{account_name}' (ID: {account_id}): {e}")
+    print(f"\nTotal distributions found: {len(all_dist_info)}")
+    print(f"CSV file '{csv_filepath}' generated successfully.")
 
-print("\nCloudFront distributions, associated domains, and bandwidth cost/usage have been successfully listed for the last 7 days.")
+if __name__ == "__main__":
+    main()

commit e34ae042da6edccd2539f05d458df599d3fcdb1a
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 16:34:17 2026 +0000

    add .env.example with configuration placeholders

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..c7b9c36
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+# AWS Organization Unit IDs to scan (comma-separated)
+# OU_IDS=ou-h0vo-0waugyoz,ou-h0vo-32wgvd6w
+
+# AWS Account IDs to skip (comma-separated)
+# SKIP_ACCOUNTS=123456789012,987654321098

commit 6ecf45a5989137cf1633bd3aff59440917e11d57
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 16:33:02 2026 +0000

    remove default OU IDs from aws_utils.py

diff --git a/aws_utils.py b/aws_utils.py
index 6878d30..ac60359 100644
--- a/aws_utils.py
+++ b/aws_utils.py
@@ -19,12 +19,11 @@ def get_skip_accounts():
     return [s.strip() for s in skip_str.split(',') if s.strip()]
 
 def get_ou_ids():
-    """Returns a list of OU IDs from OU_IDS env var, or defaults."""
+    """Returns a list of OU IDs from OU_IDS env var."""
     ou_str = os.getenv('OU_IDS', '')
     if ou_str:
         return [o.strip() for o in ou_str.split(',') if o.strip()]
-    # Default OUs if none provided
-    return ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
+    return []
 
 def get_account_names():
     """Fetches account names from AWS Organizations, excluding skipped accounts."""

commit b6322a103ee83d9f8d10c31e446bdd9949132949
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 16:30:25 2026 +0000

    make OUs and skipped accounts configurable via env vars

diff --git a/aws-asg-lt-ami.py b/aws-asg-lt-ami.py
index 0936d9c..d36140c 100755
--- a/aws-asg-lt-ami.py
+++ b/aws-asg-lt-ami.py
@@ -4,6 +4,7 @@ import csv
 import time
 from botocore.exceptions import ClientError, ProfileNotFound
 from collections import defaultdict
+from aws_utils import setup_org_accounts_session
 
 # The original script's helper function to get an AMI's human-readable name from its ID
 def get_ami_name(ec2_client, ami_id):
@@ -127,30 +128,6 @@ def get_asg_details(account_id, account_name, region, asg_client, ec2_client):
     return asg_info_list
 
 # Initialize AWS Organizations client
-# NOTE: This part is taken directly from your example script's organizational structure.
-org_client = boto3.client('organizations')
-
-# List of specific Organizational Units (OUs) to check
-ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w'] 
-active_accounts = []
-
-# Get all active accounts for the specified OUs, handling pagination
-print("1. Fetching active accounts from specified OUs...")
-for ou_id in ou_ids:
-    next_token = None
-    while True:
-        if next_token:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
-        else:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id)
-        
-        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
-        
-        next_token = response.get('NextToken')
-        if not next_token:
-            break
-print(f"-> Found {len(active_accounts)} active accounts to check.")
-
 all_asg_info = []
 
 # Define the regions to check (matches your example script)
@@ -175,12 +152,9 @@ with open(csv_filepath, mode='w', newline='') as csvfile:
 
     # Iterate through active accounts and then through the regions
     print("\n2. Iterating through accounts and regions to collect ASG data...")
-    for account in active_accounts:
+    for account, profile_name in setup_org_accounts_session():
         account_id = account['Id']
-        # Sanitize account name for profile use (matching original script's logic)
-        account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
-        profile_name = f'{account_name}.admin' 
-        
+        account_name = account['Name']
         print(f"\n--- Checking Account: {account_name} (ID: {account_id}) ---")
         
         try:
diff --git a/aws-ec2-ami.py b/aws-ec2-ami.py
index cbdf8cd..81e4f86 100755
--- a/aws-ec2-ami.py
+++ b/aws-ec2-ami.py
@@ -3,6 +3,7 @@ import boto3
 import csv
 from botocore.exceptions import ClientError, ProfileNotFound
 from collections import defaultdict
+from aws_utils import setup_org_accounts_session
 
 # Function to get the instance name from tags
 def get_instance_name(tags):
@@ -47,28 +48,6 @@ def list_running_ec2_instances_with_ami_info(ec2_client):
                 })
     return instances_info
 
-# Initialize AWS Organizations client
-org_client = boto3.client('organizations')
-
-# List of specific Organizational Units (OUs)
-ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
-active_accounts = []
-
-# Get all active accounts for the specified OUs, handling pagination
-for ou_id in ou_ids:
-    next_token = None
-    while True:
-        if next_token:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
-        else:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id)
-        
-        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
-        
-        next_token = response.get('NextToken')
-        if not next_token:
-            break
-
 # Initialize a dictionary to store AMI names and their counts
 ami_name_counts = defaultdict(int)
 all_instances_info = []
@@ -84,13 +63,9 @@ with open('running_ec2_instances_info.csv', mode='w', newline='') as csvfile:
     writer.writeheader()
 
     # Iterate through active accounts and then through the regions
-    for account in active_accounts:
+    for account, profile_name in setup_org_accounts_session():
         account_id = account['Id']
-        
-        # Replace " - " with "-" and replace spaces with "-" in account names
-        account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
-        profile_name = f'{account_name}.admin'  # Constructing the profile name
-        
+        account_name = account['Name']
         try:
             # Set up the default session using the AWS SSO profile for each account
             boto3.setup_default_session(profile_name=profile_name)
diff --git a/aws-ips.py b/aws-ips.py
index 5a686a0..a7b2f5a 100644
--- a/aws-ips.py
+++ b/aws-ips.py
@@ -1,6 +1,7 @@
 import boto3
 import csv
 from botocore.exceptions import ClientError, ProfileNotFound
+from aws_utils import setup_org_accounts_session
 
 # Function to list public IPs, Private IPs, and Instance IDs
 def list_ec2_instance_info(ec2_client):
@@ -40,28 +41,6 @@ def list_alb_dns_names(elbv2_client):
             alb_dns_names.append(lb['DNSName'])
     return alb_dns_names
 
-# Initialize AWS Organizations client
-org_client = boto3.client('organizations')
-
-# List of specific Organizational Units (OUs)
-ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
-active_accounts = []
-
-# Get all active accounts for the specified OUs, handling pagination
-for ou_id in ou_ids:
-    next_token = None
-    while True:
-        if next_token:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
-        else:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id)
-        
-        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
-        
-        next_token = response.get('NextToken')
-        if not next_token:
-            break
-
 # Initialize a counter for the total number of external resources
 total_external_resources_count = 0
 
@@ -85,11 +64,9 @@ with open('aws_external_ips_by_region.csv', mode='w', newline='') as csvfile:
     writer.writeheader()
 
     # Iterate through active accounts and then through the regions
-    for account in active_accounts:
+    for account, profile_name in setup_org_accounts_session():
         account_id = account['Id']
-        account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
-        profile_name = f'{account_name}.admin'
-        
+        account_name = account['Name']
         try:
             boto3.setup_default_session(profile_name=profile_name)
             
diff --git a/aws-metrics.py b/aws-metrics.py
index db72036..93174c2 100644
--- a/aws-metrics.py
+++ b/aws-metrics.py
@@ -2,6 +2,7 @@ import boto3
 import csv
 from datetime import datetime, timedelta, timezone
 from botocore.exceptions import ClientError, ProfileNotFound
+from aws_utils import setup_org_accounts_session
 
 # Function to get CloudFront log size for the last month
 def get_cloudfront_log_size(cloudfront_client, s3_client, distribution_id):
@@ -65,48 +66,15 @@ def count_rds_clusters(rds_client):
     clusters = rds_client.describe_db_clusters()
     return len(clusters['DBClusters'])
 
-# Initialize AWS Organizations client
-org_client = boto3.client('organizations')
-
-# List of specific Organizational Units (OUs)
-ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
-active_accounts = []
-
-# Get all active accounts for the specified OUs, handling pagination
-for ou_id in ou_ids:
-    next_token = None
-    while True:
-        if next_token:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
-        else:
-            response = org_client.list_accounts_for_parent(ParentId=ou_id)
-        
-        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
-        
-        next_token = response.get('NextToken')
-        if not next_token:
-            break
-
-# Initialize total counters
-total_accounts = 0
-total_ec2_instances = 0
-total_ecs_containers = 0
-total_rds_instances = 0
-total_rds_clusters = 0
-total_cloudfront_log_size_bytes = 0
-
 # Prepare CSV file
 with open('aws_account_metrics.csv', mode='w', newline='') as file:
     writer = csv.writer(file)
     writer.writerow(['Account ID', 'Account Email', 'EC2 Instance Count', 'ECS Running Containers', 'RDS Instances', 'RDS Clusters', 'CloudFront Log Size (GB)'])
 
     # Iterate through active accounts
-    for account in active_accounts:
+    for account, profile_name in setup_org_accounts_session():
         account_id = account['Id']
-        
-        # Replace " - " with "-" and replace spaces with "-" in account names
-        account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
-        profile_name = f'{account_name}.admin'  # Constructing the profile name
+        account_name = account['Name']
         account_email = account['Email']
         
         try:
diff --git a/aws-vpc.py b/aws-vpc.py
index f0b386e..c563722 100755
--- a/aws-vpc.py
+++ b/aws-vpc.py
@@ -23,7 +23,6 @@ def list_vpcs(ec2_client):
     return vpcs_info
 
 def main():
-    ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
     regions_to_check = ['eu-west-1', 'eu-west-2']
     total_vpcs_all_accounts = 0
 
@@ -32,7 +31,9 @@ def main():
         writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
         writer.writeheader()
 
-        for account_id, account_name, profile_name in setup_org_accounts_session(ou_ids):
+        for account, profile_name in setup_org_accounts_session():
+            account_id = account['Id']
+            account_name = account['Name']
             try:
                 boto3.setup_default_session(profile_name=profile_name)
                 for region in regions_to_check:
diff --git a/aws_utils.py b/aws_utils.py
index e129cb0..6878d30 100644
--- a/aws_utils.py
+++ b/aws_utils.py
@@ -1,17 +1,41 @@
 import boto3
 import json
+import os
 from datetime import datetime, date
 from dateutil.relativedelta import relativedelta
 
+# Attempt to load .env file if python-dotenv is installed
+try:
+    from dotenv import load_dotenv
+    load_dotenv()
+except ImportError:
+    pass
+
+def get_skip_accounts():
+    """Returns a list of account IDs to skip from SKIP_ACCOUNTS env var."""
+    skip_str = os.getenv('SKIP_ACCOUNTS', '')
+    if not skip_str:
+        return []
+    return [s.strip() for s in skip_str.split(',') if s.strip()]
+
+def get_ou_ids():
+    """Returns a list of OU IDs from OU_IDS env var, or defaults."""
+    ou_str = os.getenv('OU_IDS', '')
+    if ou_str:
+        return [o.strip() for o in ou_str.split(',') if o.strip()]
+    # Default OUs if none provided
+    return ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
+
 def get_account_names():
-    """Fetches account names from AWS Organizations."""
+    """Fetches account names from AWS Organizations, excluding skipped accounts."""
     org_client = boto3.client('organizations')
+    skip_accounts = get_skip_accounts()
     accounts = {}
     try:
         paginator = org_client.get_paginator('list_accounts')
         for page in paginator.paginate():
             for account in page['Accounts']:
-                if account['Status'] == 'ACTIVE':
+                if account['Status'] == 'ACTIVE' and account['Id'] not in skip_accounts:
                     accounts[account['Id']] = account['Name']
     except Exception as e:
         print(f"Error fetching account names: {e}")
@@ -59,19 +83,23 @@ def get_aws_pricing(service_code, filters):
         print(f"Error fetching pricing for {service_code}: {e}")
     return None
 
-def setup_org_accounts_session(ou_ids, profile_suffix='.admin'):
+def setup_org_accounts_session(ou_ids=None, profile_suffix='.admin'):
     """
-    Yields (account_id, account_name, profile_name) for active accounts in OUs.
+    Yields (account_dict, profile_name) for active accounts in OUs.
     Handles boto3 session setup for each account.
+    Excludes accounts in SKIP_ACCOUNTS env var.
     """
+    if ou_ids is None:
+        ou_ids = get_ou_ids()
+        
+    skip_accounts = get_skip_accounts()
     org_client = boto3.client('organizations')
     for ou_id in ou_ids:
         paginator = org_client.get_paginator('list_accounts_for_parent')
         for page in paginator.paginate(ParentId=ou_id):
             for account in page['Accounts']:
-                if account['Status'] == 'ACTIVE':
-                    account_id = account['Id']
+                if account['Status'] == 'ACTIVE' and account['Id'] not in skip_accounts:
                     # Sanitize account name for profile use
                     account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
                     profile_name = f'{account_name}{profile_suffix}'
-                    yield account_id, account['Name'], profile_name
+                    yield account, profile_name
diff --git a/list_rds_instances.py b/list_rds_instances.py
index 2e14f74..2d56934 100644
--- a/list_rds_instances.py
+++ b/list_rds_instances.py
@@ -19,9 +19,9 @@ def list_rds_clusters(rds_client):
     return cluster_names
 
 def main():
-    ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
-
-    for account_id, account_name, profile_name in setup_org_accounts_session(ou_ids):
+    for account, profile_name in setup_org_accounts_session():
+        account_id = account['Id']
+        account_name = account['Name']
         try:
             boto3.setup_default_session(profile_name=profile_name)
             rds_client = boto3.client('rds')

commit dc9e82b6ee010d4cc252953faa930655e3be55fe
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 16:19:08 2026 +0000

    add script to list RDS instances and clusters across accounts

diff --git a/list_rds_instances.py b/list_rds_instances.py
new file mode 100644
index 0000000..0db91c3
--- /dev/null
+++ b/list_rds_instances.py
@@ -0,0 +1,78 @@
+import boto3
+from botocore.exceptions import ClientError, ProfileNotFound
+
+# Function to list RDS instances
+def list_rds_instances(rds_client):
+    instance_names = []
+    paginator = rds_client.get_paginator('describe_db_instances')
+    for page in paginator.paginate():
+        for db_instance in page['DBInstances']:
+            instance_names.append(db_instance['DBInstanceIdentifier'])
+    return instance_names
+
+# Function to list RDS clusters
+def list_rds_clusters(rds_client):
+    cluster_names = []
+    paginator = rds_client.get_paginator('describe_db_clusters')
+    for page in paginator.paginate():
+        for db_cluster in page['DBClusters']:
+            cluster_names.append(db_cluster['DBClusterIdentifier'])
+    return cluster_names
+
+# Initialize AWS Organizations client
+org_client = boto3.client('organizations')
+
+# List of specific Organizational Units (OUs)
+ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
+active_accounts = []
+
+# Get all active accounts for the specified OUs, handling pagination
+for ou_id in ou_ids:
+    next_token = None
+    while True:
+        if next_token:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
+        else:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id)
+        
+        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
+        
+        next_token = response.get('NextToken')
+        if not next_token:
+            break
+
+# Iterate through active accounts and print RDS names
+for account in active_accounts:
+    account_id = account['Id']
+    
+    # Replace " - " with "-" and replace spaces with "-" in account names
+    account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
+    profile_name = f'{account_name}.admin'  # Constructing the profile name
+    
+    try:
+        # Set up the default session using the AWS SSO profile for each account
+        boto3.setup_default_session(profile_name=profile_name)
+        
+        # Initialize RDS client with the SSO session
+        rds_client = boto3.client('rds')
+        
+        # Get RDS instance names
+        rds_instance_names = list_rds_instances(rds_client)
+        
+        # Get RDS cluster names
+        rds_cluster_names = list_rds_clusters(rds_client)
+        
+        # Print RDS instance names
+        for rds_name in rds_instance_names:
+            print(f'"{account_name}":"{rds_name}"')
+        
+        # Print RDS cluster names
+        for rds_name in rds_cluster_names:
+            print(f'"{account_name}":"{rds_name}"')
+        
+        print(f"Processed account '{account_name}' (ID: {account_id})")
+
+    except (ClientError, ProfileNotFound) as e:
+        print(f"Error accessing account '{account_name}' (ID: {account_id}): {e}")
+
+print("\nRDS names have been successfully listed for all accounts.")

commit ffecb6b4b28850b96ad9f5718458fc104d892409
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 16:18:20 2026 +0000

    add script to list VPCs and their CIDR blocks across accounts

diff --git a/aws-vpc.py b/aws-vpc.py
new file mode 100755
index 0000000..f771eb1
--- /dev/null
+++ b/aws-vpc.py
@@ -0,0 +1,122 @@
+import boto3
+import csv
+from botocore.exceptions import ClientError, ProfileNotFound
+
+# Function to get the VPC name from tags
+def get_vpc_name(tags):
+    """
+    Retrieves the value of the 'Name' tag from a VPC's tags.
+    Returns "Unnamed VPC" if the tag is not found.
+    """
+    if tags:
+        for tag in tags:
+            if tag['Key'] == 'Name':
+                return tag['Value']
+    return "Unnamed VPC"
+
+# Function to list all VPCs with name, ID, and CIDR block
+def list_vpcs(ec2_client):
+    """
+    Lists all VPCs in a given region, retrieves their Name, ID,
+    and CIDR block, and returns the information in a list of dictionaries.
+    """
+    vpcs_info = []
+    paginator = ec2_client.get_paginator('describe_vpcs')
+    
+    for page in paginator.paginate():
+        for vpc in page['Vpcs']:
+            vpc_name = get_vpc_name(vpc.get('Tags'))
+            vpc_id = vpc['VpcId']
+            cidr_block = vpc.get('CidrBlock', 'N/A')
+            
+            vpcs_info.append({
+                'VPCName': vpc_name,
+                'VPCID': vpc_id,
+                'CIDRBlock': cidr_block
+            })
+    return vpcs_info
+
+# Initialize AWS Organizations client
+org_client = boto3.client('organizations')
+
+# List of specific Organizational Units (OUs)
+ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
+active_accounts = []
+
+# Get all active accounts for the specified OUs, handling pagination
+for ou_id in ou_ids:
+    next_token = None
+    while True:
+        if next_token:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
+        else:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id)
+        
+        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
+        
+        next_token = response.get('NextToken')
+        if not next_token:
+            break
+
+# Define the regions to check
+regions_to_check = ['eu-west-1', 'eu-west-2']
+
+# Initialize a counter for the total number of VPCs
+total_vpcs_all_accounts = 0
+
+# Open CSV file for writing VPC information
+with open('aws_vpcs_by_region.csv', mode='w', newline='') as csvfile:
+    # Define the fieldnames for the CSV header
+    fieldnames = ['AccountName', 'AccountID', 'Region', 'VPCName', 'VPCID', 'CIDRBlock']
+    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
+    writer.writeheader()
+
+    # Iterate through active accounts and then through the regions
+    for account in active_accounts:
+        account_id = account['Id']
+        
+        # Replace " - " with "-" and replace spaces with "-" in account names
+        account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
+        profile_name = f'{account_name}.admin'  # Constructing the profile name
+        
+        try:
+            # Set up the default session using the AWS SSO profile for each account
+            boto3.setup_default_session(profile_name=profile_name)
+            
+            for region in regions_to_check:
+                try:
+                    # Initialize EC2 client for the specific region
+                    ec2_client = boto3.client('ec2', region_name=region)
+                    
+                    # List all VPCs for the account and region
+                    vpcs_info = list_vpcs(ec2_client)
+                    
+                    # Add the count for this region to the total
+                    total_vpcs_all_accounts += len(vpcs_info)
+                    
+                    # Write VPC information to CSV
+                    for vpc in vpcs_info:
+                        writer.writerow({
+                            'AccountName': account_name,
+                            'AccountID': account_id,
+                            'Region': region,
+                            'VPCName': vpc['VPCName'],
+                            'VPCID': vpc['VPCID'],
+                            'CIDRBlock': vpc['CIDRBlock']
+                        })
+                    
+                    # Print the results for the current account and region
+                    print(f'"{account_name}" (ID: {account_id}, Region: {region}) VPCs:')
+                    for vpc in vpcs_info:
+                        print(f"VPC Name: {vpc['VPCName']}, VPC ID: {vpc['VPCID']}, CIDR Block: {vpc['CIDRBlock']}")
+                    print('--------------------------------------------------------')
+
+                except ClientError as e:
+                    print(f"Error accessing VPCs in region '{region}' for account '{account_name}' (ID: {account_id}): {e}")
+
+        except (ClientError, ProfileNotFound) as e:
+            print(f"Error accessing account '{account_name}' (ID: {account_id}): {e}")
+
+print("\nSummary of VPCs Across All Accounts and Regions:")
+print(f"Total number of VPCs found: {total_vpcs_all_accounts}")
+print("\nCSV file 'aws_vpcs_by_region.csv' generated successfully.")

commit 1e3cbc227fba888f464c58a151f32d9b6e329b37
Author: bob walker <bob@dxw.com>
Date:   Thu Feb 12 16:18:08 2026 +0000

    add script to audit Auto Scaling Groups and their AMIs across accounts

diff --git a/aws-asg-lt-ami.py b/aws-asg-lt-ami.py
new file mode 100755
index 0000000..0936d9c
--- /dev/null
+++ b/aws-asg-lt-ami.py
@@ -0,0 +1,235 @@
+#!/usr/bin/env python3
+import boto3
+import csv
+import time
+from botocore.exceptions import ClientError, ProfileNotFound
+from collections import defaultdict
+
+# The original script's helper function to get an AMI's human-readable name from its ID
+def get_ami_name(ec2_client, ami_id):
+    """
+    Fetches the AMI Name from the AMI ID using the EC2 client.
+    Handles potential errors if the AMI is not accessible or deleted.
+    """
+    if not ami_id:
+        return "N/A - No AMI ID"
+    # Skip AMI lookup if the ID is clearly an error message from a previous step
+    if not ami_id.startswith('ami-'):
+        return f"Invalid AMI ID format"
+        
+    try:
+        # Use describe_images to get metadata about the AMI
+        image_info = ec2_client.describe_images(ImageIds=[ami_id])['Images']
+        if image_info:
+            return image_info[0].get('Name', 'Unnamed AMI')
+        return 'AMI not found'
+    except ClientError as e:
+        # Handle cases where the AMI is not found or permissions are denied
+        return f"AMI not accessible (Error: {e.response['Error']['Code']})"
+    except Exception as e:
+        return f"AMI lookup error: {e}"
+
+
+def get_asg_details(account_id, account_name, region, asg_client, ec2_client):
+    """
+    Lists all Auto Scaling Groups (ASGs) in the region and determines
+    their associated AMI ID, Launch Configuration, or Launch Template details.
+    """
+    asg_info_list = []
+    paginator = asg_client.get_paginator('describe_auto_scaling_groups')
+
+    print(f"  -> Fetching ASGs in {region}...")
+
+    try:
+        for page in paginator.paginate():
+            for asg in page['AutoScalingGroups']:
+                ami_id = "N/A"
+                template_type = "None"
+                template_name = "None"
+                template_version = "N/A"
+
+                # 1. Check for Launch Template
+                lt_data = asg.get('LaunchTemplate') or asg.get('MixedInstancesPolicy', {}).get('LaunchTemplate')
+                
+                if lt_data:
+                    # An ASG can use either a primary LaunchTemplate or a MixedInstancesPolicy
+                    template_type = "LaunchTemplate"
+                    template_id = lt_data.get('LaunchTemplateId')
+                    template_name = lt_data.get('LaunchTemplateName')
+                    template_version = lt_data.get('Version') or "$Default" # Use $Default if version is missing
+                    
+                    if template_id or template_name:
+                        # Attempt to resolve the AMI from the Launch Template version
+                        try:
+                            # AWS API requires EITHER LaunchTemplateId OR LaunchTemplateName, not both.
+                            lt_lookup_args = {'Versions': [template_version]}
+                            
+                            if template_id:
+                                lt_lookup_args['LaunchTemplateId'] = template_id
+                            elif template_name:
+                                lt_lookup_args['LaunchTemplateName'] = template_name
+                            else:
+                                # This case is unlikely if lt_data was present, but provides robust error handling
+                                raise ValueError("Launch Template ID and Name are both missing.")
+                            
+                            lt_versions = ec2_client.describe_launch_template_versions(**lt_lookup_args)['LaunchTemplateVersions']
+
+                            if lt_versions:
+                                # AMI ID is nested inside LaunchTemplateData
+                                launch_template_data = lt_versions[0]['LaunchTemplateData']
+                                ami_id = launch_template_data.get('ImageId', 'AMI ID not specified in LT')
+                        
+                        except ClientError as e:
+                            # Capture the specific error code for debugging
+                            ami_id = f"LT lookup error: {e.response['Error']['Code']}"
+                        except Exception as e:
+                            ami_id = f"LT processing error: {e}"
+
+                # 2. Check for Launch Configuration (Legacy)
+                elif asg.get('LaunchConfigurationName'):
+                    lc_name = asg['LaunchConfigurationName']
+                    template_type = "LaunchConfiguration"
+                    template_name = lc_name
+                    template_version = "Latest" # LCs don't have versions like LTs
+
+                    # Attempt to resolve the AMI from the Launch Configuration
+                    try:
+                        lc_response = asg_client.describe_launch_configurations(
+                            LaunchConfigurationNames=[lc_name]
+                        )['LaunchConfigurations']
+                        
+                        if lc_response:
+                            ami_id = lc_response[0].get('ImageId', 'AMI ID not specified in LC')
+                    
+                    except ClientError as e:
+                        ami_id = f"LC lookup error: {e.response['Error']['Code']}"
+                    except Exception as e:
+                        ami_id = f"LC processing error: {e}"
+                
+                # Resolve the AMI Name if an ID was found
+                ami_name = get_ami_name(ec2_client, ami_id)
+                
+                asg_info_list.append({
+                    'AccountName': account_name,
+                    'AccountID': account_id,
+                    'Region': region,
+                    'ASG_Name': asg['AutoScalingGroupName'],
+                    'Template_Type': template_type,
+                    'Template_Name': template_name,
+                    'Template_Version': template_version,
+                    'AMI_ID': ami_id,
+                    'AMI_Name': ami_name,
+                })
+        
+    except ClientError as e:
+        print(f"  -> Error listing ASGs in {region}: {e}")
+        
+    return asg_info_list
+
+# Initialize AWS Organizations client
+# NOTE: This part is taken directly from your example script's organizational structure.
+org_client = boto3.client('organizations')
+
+# List of specific Organizational Units (OUs) to check
+ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w'] 
+active_accounts = []
+
+# Get all active accounts for the specified OUs, handling pagination
+print("1. Fetching active accounts from specified OUs...")
+for ou_id in ou_ids:
+    next_token = None
+    while True:
+        if next_token:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
+        else:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id)
+        
+        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
+        
+        next_token = response.get('NextToken')
+        if not next_token:
+            break
+print(f"-> Found {len(active_accounts)} active accounts to check.")
+
+all_asg_info = []
+
+# Define the regions to check (matches your example script)
+regions_to_check = ['eu-west-1', 'eu-west-2'] 
+
+# Open CSV file for writing ASG information
+csv_filepath = 'autoscaling_group_ami_template_info.csv'
+with open(csv_filepath, mode='w', newline='') as csvfile:
+    fieldnames = [
+        'AccountName', 
+        'AccountID', 
+        'Region', 
+        'ASG_Name', 
+        'Template_Type', 
+        'Template_Name', 
+        'Template_Version', 
+        'AMI_ID', 
+        'AMI_Name'
+    ]
+    writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
+    writer.writeheader()
+
+    # Iterate through active accounts and then through the regions
+    print("\n2. Iterating through accounts and regions to collect ASG data...")
+    for account in active_accounts:
+        account_id = account['Id']
+        # Sanitize account name for profile use (matching original script's logic)
+        account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
+        profile_name = f'{account_name}.admin' 
+        
+        print(f"\n--- Checking Account: {account_name} (ID: {account_id}) ---")
+        
+        try:
+            # Set up the default session using the AWS SSO profile for each account
+            # NOTE: This relies on your local AWS configuration having these profiles set up.
+            boto3.setup_default_session(profile_name=profile_name)
+            
+            # Loop through each region
+            for region in regions_to_check:
+                try:
+                    # Initialize clients for the specific region
+                    asg_client = boto3.client('autoscaling', region_name=region)
+                    ec2_client = boto3.client('ec2', region_name=region) # Needed to resolve AMI Name and LT details
+                    
+                    # Get ASG information
+                    asg_data = get_asg_details(account_id, account_name, region, asg_client, ec2_client)
+                    
+                    # Write results to CSV and print to console
+                    for asg in asg_data:
+                        all_asg_info.append(asg)
+                        writer.writerow(asg)
+                        print(f"  [FOUND] ASG: {asg['ASG_Name']}, Template Type: {asg['Template_Type']}, Template Name: {asg['Template_Name']} ({asg['Template_Version']}), AMI ID: {asg['AMI_ID']}, AMI Name: {asg['AMI_Name']}")
+
+                except (ClientError) as e:
+                    print(f"  -> Error accessing services in region '{region}': {e}")
+                
+                # Small delay to respect API limits, especially for org-wide searches
+                time.sleep(0.5)
+            
+        except (ClientError, ProfileNotFound) as e:
+            print(f"  -> Error setting up session/profile '{profile_name}': {e}")
+
+
+# Output summary
+total_asgs = len(all_asg_info)
+print("\n========================================================")
+print("ASG & AMI Collection Summary:")
+print(f"Total Auto Scaling Groups found: {total_asgs}")
+
+if total_asgs > 0:
+    # Basic summary of AMI usage
+    ami_usage = defaultdict(int)
+    for asg in all_asg_info:
+        ami_usage[asg['AMI_Name']] += 1
+    
+    print(f"Distinct AMI Names used: {len(ami_usage)}")
+    print("\nTop 5 AMI Usage Counts:")
+    for ami_name, count in sorted(ami_usage.items(), key=lambda item: item[1], reverse=True)[:5]:
+        print(f"  - {ami_name}: {count} ASGs")
+
+print(f"\nCSV file '{csv_filepath}' generated successfully.")
+print("========================================================")

commit d26c4f2730c4d04a5f015b592681edc6f04d879e
Author: bob walker <bob@randomness.org.uk>
Date:   Tue Nov 5 15:24:46 2024 +0000

    Script to list ami names

diff --git a/aws-ec2-ami.py b/aws-ec2-ami.py
new file mode 100644
index 0000000..392a856
--- /dev/null
+++ b/aws-ec2-ami.py
@@ -0,0 +1,100 @@
+import boto3
+from botocore.exceptions import ClientError, ProfileNotFound
+
+# Function to get the instance name from tags
+def get_instance_name(tags):
+    if tags:
+        for tag in tags:
+            if tag['Key'] == 'Name':
+                return tag['Value']
+    return "Unnamed"
+
+# Function to list EC2 instances with name, AMI ID, and AMI name
+def list_ec2_instances_with_ami_info(ec2_client):
+    instances_info = []
+    paginator = ec2_client.get_paginator('describe_instances')
+    for page in paginator.paginate():
+        for reservation in page['Reservations']:
+            for instance in reservation['Instances']:
+                instance_name = get_instance_name(instance.get('Tags'))
+                ami_id = instance['ImageId']
+                
+                # Fetch the AMI name using describe_images
+                try:
+                    image_info = ec2_client.describe_images(ImageIds=[ami_id])['Images'][0]
+                    ami_name = image_info.get('Name', 'Unnamed AMI')
+                except ClientError as e:
+                    ami_name = 'AMI not accessible'  # In case the AMI is private or not accessible
+                
+                instances_info.append({
+                    'InstanceName': instance_name,
+                    'InstanceId': instance['InstanceId'],
+                    'AMI_ID': ami_id,
+                    'AMI_Name': ami_name
+                })
+    return instances_info
+
+# Initialize AWS Organizations client
+org_client = boto3.client('organizations')
+
+# List of specific Organizational Units (OUs)
+ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
+active_accounts = []
+
+# Get all active accounts for the specified OUs, handling pagination
+for ou_id in ou_ids:
+    next_token = None
+    while True:
+        if next_token:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
+        else:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id)
+        
+        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
+        
+        next_token = response.get('NextToken')
+        if not next_token:
+            break
+
+# Initialize a dictionary to store all AMI names and instance information across all accounts
+all_ami_names = set()
+all_instances_info = []
+
+# Iterate through active accounts and collect instance details
+for account in active_accounts:
+    account_id = account['Id']
+    
+    # Replace " - " with "-" and replace spaces with "-" in account names
+    account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
+    profile_name = f'{account_name}.admin'  # Constructing the profile name
+    
+    try:
+        # Set up the default session using the AWS SSO profile for each account
+        boto3.setup_default_session(profile_name=profile_name)
+        
+        # Initialize EC2 client with the SSO session
+        ec2_client = boto3.client('ec2')
+        
+        # List EC2 instances with name, AMI ID, and AMI name
+        instances_info = list_ec2_instances_with_ami_info(ec2_client)
+        
+        # Collect instance information and AMI names
+        for instance in instances_info:
+            all_instances_info.append(instance)
+            all_ami_names.add(instance['AMI_Name'])  # Use a set to keep only unique AMI names
+        
+        # Print the results for the current account
+        print(f'"{account_name}" (ID: {account_id}) EC2 Instances:')
+        for instance in instances_info:
+            print(f"Instance Name: {instance['InstanceName']}, Instance ID: {instance['InstanceId']}, AMI ID: {instance['AMI_ID']}, AMI Name: {instance['AMI_Name']}")
+        print('--------------------------------------------------------')
+
+    except (ClientError, ProfileNotFound) as e:
+        print(f"Error accessing account '{account_name}' (ID: {account_id}): {e}")
+
+# Output summary of distinct AMI names and total instance details
+print("\nSummary of EC2 Instances Across All Accounts:")
+print(f"Total number of distinct AMI names across all accounts: {len(all_ami_names)}")
+print("List of distinct AMI names across all accounts:")
+for ami_name in sorted(all_ami_names):
+    print(f"- {ami_name}")

commit 8c3a6358c68d5f2bdddfb4702f152234e6a4cc8a
Author: bob walker <bob@randomness.org.uk>
Date:   Tue Oct 15 17:22:56 2024 +0100

    Add aws-ips.py script to list external IPs of EC2 instances and Elastic IPs

diff --git a/aws-ips.py b/aws-ips.py
new file mode 100644
index 0000000..0bb35fb
--- /dev/null
+++ b/aws-ips.py
@@ -0,0 +1,94 @@
+import boto3
+from botocore.exceptions import ClientError, ProfileNotFound
+
+# Function to list public IPs of EC2 instances
+def list_ec2_public_ips(ec2_client):
+    public_ips = []
+    paginator = ec2_client.get_paginator('describe_instances')
+    for page in paginator.paginate():
+        for reservation in page['Reservations']:
+            for instance in reservation['Instances']:
+                if 'PublicIpAddress' in instance:
+                    public_ips.append(instance['PublicIpAddress'])
+    return public_ips
+
+# Function to list Elastic IPs (EIPs) without pagination
+def list_elastic_ips(ec2_client):
+    elastic_ips = []
+    response = ec2_client.describe_addresses()
+    for address in response['Addresses']:
+        if 'PublicIp' in address:
+            elastic_ips.append(address['PublicIp'])
+    return elastic_ips
+
+# Initialize AWS Organizations client
+org_client = boto3.client('organizations')
+
+# List of specific Organizational Units (OUs)
+ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
+active_accounts = []
+
+# Get all active accounts for the specified OUs, handling pagination
+for ou_id in ou_ids:
+    next_token = None
+    while True:
+        if next_token:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
+        else:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id)
+        
+        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
+        
+        next_token = response.get('NextToken')
+        if not next_token:
+            break
+
+# Initialize a list to store all external IPs across all accounts
+all_external_ips = []
+
+# Initialize a counter for the total number of external IPs across all accounts
+total_external_ips_all_accounts = 0
+
+# Iterate through active accounts and count external IPs
+for account in active_accounts:
+    account_id = account['Id']
+    
+    # Replace " - " with "-" and replace spaces with "-" in account names
+    account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
+    profile_name = f'{account_name}.admin'  # Constructing the profile name
+    
+    try:
+        # Set up the default session using the AWS SSO profile for each account
+        boto3.setup_default_session(profile_name=profile_name)
+        
+        # Initialize EC2 client with the SSO session
+        ec2_client = boto3.client('ec2')
+        
+        # Get public IPs of EC2 instances
+        ec2_public_ips = list_ec2_public_ips(ec2_client)
+        
+        # Get Elastic IPs (EIPs) without pagination
+        elastic_ips = list_elastic_ips(ec2_client)
+        
+        # Combine EC2 public IPs and Elastic IPs into one list for the account
+        account_ips = ec2_public_ips + elastic_ips
+        
+        # Add account's IPs to the global list
+        all_external_ips.extend(account_ips)
+        
+        # Update the total count of external IPs for all accounts
+        total_external_ips_all_accounts += len(account_ips)
+        
+        # Print the results for the current account
+        print(f'"{account_name}" (ID: {account_id}) has {len(account_ips)} external IPs')
+        print(f'EC2 Public IPs: {ec2_public_ips}')
+        print(f'Elastic IPs: {elastic_ips}')
+        print('--------------------------------------------------------')
+
+    except (ClientError, ProfileNotFound) as e:
+        print(f"Error accessing account '{account_name}' (ID: {account_id}): {e}")
+
+# Output the total number of external IPs for all accounts and the list of all external IPs
+print("\nSummary of External IPs Across All Accounts:")
+print(f"Total number of external IPs across all accounts: {total_external_ips_all_accounts}")
+print(f"List of all external IPs across all accounts: {all_external_ips}")

commit 79b83835e88a9b087f28ec11c41d63592e1dc1fe
Author: bob walker <bob@randomness.org.uk>
Date:   Fri Sep 13 11:34:23 2024 +0100

    Add a script to list CloudFront distributions and associated domains for all
    accounts

diff --git a/cloudfront.py b/cloudfront.py
new file mode 100755
index 0000000..8e30703
--- /dev/null
+++ b/cloudfront.py
@@ -0,0 +1,70 @@
+import boto3
+from botocore.exceptions import ClientError, ProfileNotFound
+
+# Function to list CloudFront distributions and associated domains
+def list_cloudfront_distributions(cloudfront_client):
+    distributions_info = []
+    paginator = cloudfront_client.get_paginator('list_distributions')
+    for page in paginator.paginate():
+        for distribution in page['DistributionList'].get('Items', []):
+            distribution_id = distribution['Id']
+            distribution_domains = distribution.get('Aliases', {}).get('Items', [])
+            distributions_info.append({
+                'DistributionId': distribution_id,
+                'Domains': distribution_domains
+            })
+    return distributions_info
+
+# Initialize AWS Organizations client
+org_client = boto3.client('organizations')
+
+# List of specific Organizational Units (OUs)
+ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
+active_accounts = []
+
+# Get all active accounts for the specified OUs, handling pagination
+for ou_id in ou_ids:
+    next_token = None
+    while True:
+        if next_token:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
+        else:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id)
+        
+        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
+        
+        next_token = response.get('NextToken')
+        if not next_token:
+            break
+
+# Iterate through active accounts and list CloudFront distributions
+for account in active_accounts:
+    account_id = account['Id']
+    
+    # Replace " - " with "-" and replace spaces with "-" in account names
+    account_name = account['Name'].replace(" - ", "-").replace(" ", "-")
+    profile_name = f'{account_name}.admin'  # Constructing the profile name
+    
+    try:
+        # Set up the default session using the AWS SSO profile for each account
+        boto3.setup_default_session(profile_name=profile_name)
+        
+        # Initialize CloudFront client with the SSO session
+        cloudfront_client = boto3.client('cloudfront')
+        
+        # Get CloudFront distribution info
+        distributions_info = list_cloudfront_distributions(cloudfront_client)
+        
+        # Print CloudFront distribution IDs and associated domains
+        for dist in distributions_info:
+            distribution_id = dist['DistributionId']
+            domains = dist['Domains'] if dist['Domains'] else ['No custom domains']
+            for domain in domains:
+                print(f'"{account_name}":"{distribution_id}":"{domain}"')
+        
+        print(f"Processed account '{account_name}' (ID: {account_id})")
+
+    except (ClientError, ProfileNotFound) as e:
+        print(f"Error accessing account '{account_name}' (ID: {account_id}): {e}")
+
+print("\nCloudFront distributions and associated domains have been successfully listed for all accounts.")

commit c1f7fa213b48fd71c2bf82af8dc71d5cd6eac20a
Author: bob walker <bob@randomness.org.uk>
Date:   Thu Aug 29 17:01:13 2024 +0100

    only do live accounts

diff --git a/aws-metrics.py b/aws-metrics.py
index f7b0a30..431fb6f 100644
--- a/aws-metrics.py
+++ b/aws-metrics.py
@@ -12,7 +12,7 @@ def get_cloudfront_log_size(cloudfront_client, s3_client, distribution_id):
         return 0
 
     s3_bucket_name = logging_config['Bucket'].split('.')[0]
-    s3_bucket_prefix = logging_config['Prefix']
+    s3_bucket_prefix = logging_config['DistributionConfig'].get('Logging', {}).get('Prefix', '')
     
     # Set time range for the last month
     end_date = datetime.now(timezone.utc)  # Current UTC time, timezone-aware
@@ -47,21 +47,24 @@ def count_ecs_containers(ecs_client):
 # Initialize AWS Organizations client
 org_client = boto3.client('organizations')
 
-# Get all active accounts, handling pagination
+# List of specific Organizational Units (OUs)
+ou_ids = ['ou-h0vo-0waugyoz', 'ou-h0vo-32wgvd6w']
 active_accounts = []
-next_token = None
 
-while True:
-    if next_token:
-        response = org_client.list_accounts(NextToken=next_token)
-    else:
-        response = org_client.list_accounts()
-    
-    active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
-    
-    next_token = response.get('NextToken')
-    if not next_token:
-        break
+# Get all active accounts for the specified OUs, handling pagination
+for ou_id in ou_ids:
+    next_token = None
+    while True:
+        if next_token:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id, NextToken=next_token)
+        else:
+            response = org_client.list_accounts_for_parent(ParentId=ou_id)
+        
+        active_accounts.extend([acc for acc in response['Accounts'] if acc['Status'] == 'ACTIVE'])
+        
+        next_token = response.get('NextToken')
+        if not next_token:
+            break
 
 # Prepare CSV file
 with open('aws_account_metrics.csv', mode='w', newline='') as file:
@@ -96,9 +99,9 @@ with open('aws_account_metrics.csv', mode='w', newline='') as file:
             # Get CloudFront log size
             cloudfront_size = 0
             distributions = cloudfront_client.list_distributions()
-#            for dist in distributions.get('DistributionList', {}).get('Items', []):
-#                cloudfront_size += get_cloudfront_log_size(cloudfront_client, s3_client, dist['Id'])
-#            
+            for dist in distributions.get('DistributionList', {}).get('Items', []):
+                cloudfront_size += get_cloudfront_log_size(cloudfront_client, s3_client, dist['Id'])
+            
             # Write to CSV
             writer.writerow([account_id, account_email, ec2_count, ecs_count, cloudfront_size])
         
Process Group PGID: 75770