From mboxrd@z Thu Jan 1 00:00:00 1970 Received: from bombadil.infradead.org (bombadil.infradead.org [198.137.202.133]) (using TLSv1.2 with cipher ECDHE-RSA-AES256-GCM-SHA384 (256/256 bits)) (No client certificate requested) by smtp.subspace.kernel.org (Postfix) with ESMTPS id A4862136358 for ; Wed, 17 Sep 2025 00:34:52 +0000 (UTC) Authentication-Results: smtp.subspace.kernel.org; arc=none smtp.client-ip=198.137.202.133 ARC-Seal:i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1758069296; cv=none; b=Tc/5enPxDIIJUN2oJHZTocCuk0iripznEJh3nESbspXybsiRdkdtdQJoQdOsJIN/zM6qvhkBhg+sDNuwovzmzPOGZqTK6dl3BdRiUQfhnqRaISFMdTsb9WayL+RAFRTeK5HrWPnHlN2nnvw/rwP3f0XCEcMzdsyf12pc1R3pASM= ARC-Message-Signature:i=1; a=rsa-sha256; d=subspace.kernel.org; s=arc-20240116; t=1758069296; c=relaxed/simple; bh=qCyx6qIzqQkSMIZjVgweSZZ97t2UYQd9JPx9DEzAr9w=; h=From:To:Cc:Subject:Date:Message-ID:In-Reply-To:References: MIME-Version; b=CBGBCfChHE8eVmKtuqx2Rcn6cTU8ArYfNlXd9X3dUZ6giFAKUO6T0Wo1bH6L3dSUYYGCC2iDj4snLMzzExLfqYDe9y9gqIpBIQa6vjghmbqjU7MK4uOGs6Qo247qLhWpWlHMWThz5KSnZR354XhO01J+J+WC2e3V3Nbvx2CnBfQ= ARC-Authentication-Results:i=1; smtp.subspace.kernel.org; dmarc=fail (p=quarantine dis=none) header.from=kernel.org; spf=none smtp.mailfrom=infradead.org; dkim=pass (2048-bit key) header.d=infradead.org header.i=@infradead.org header.b=z3F2vNfL; arc=none smtp.client-ip=198.137.202.133 Authentication-Results: smtp.subspace.kernel.org; dmarc=fail (p=quarantine dis=none) header.from=kernel.org Authentication-Results: smtp.subspace.kernel.org; spf=none smtp.mailfrom=infradead.org Authentication-Results: smtp.subspace.kernel.org; dkim=pass (2048-bit key) header.d=infradead.org header.i=@infradead.org header.b="z3F2vNfL" DKIM-Signature: v=1; a=rsa-sha256; q=dns/txt; c=relaxed/relaxed; d=infradead.org; s=bombadil.20210309; h=Sender:Content-Transfer-Encoding: MIME-Version:References:In-Reply-To:Message-ID:Date:Subject:Cc:To:From: Reply-To:Content-Type:Content-ID:Content-Description; bh=vUcoMkY740JMCpMCD1T659l+AHqpY+rzOGgD68SY2/Y=; b=z3F2vNfLfrcfRQVJnngOgpw9uj Xg2u5aTj3FPlZPVG/6cyp+VtyTzv5LuqPR/FgfncN8urchU2eW5DYdshCp7h1a4kMsFCmUch3RdGo RctbDaLbMAGOrps/sdXukE7A9OKJAJsM04FaqEDUBBTAbd9mSHGGac96e7huKvyDyAhY04dPGGAGd HSQb2JYbhb2IiTsfKdfbQk6N9CqqeEifxdC6z5tmPKg5tirEDEEn+sSUNQ9lhprNVQIFMBDoCdV0T 1NmuWCO+OBysoFSVXlXxa2mvS0ulr38QiO0hwR83MLJu5vc+M1MbFULPXRKB0COSmuhvMDv4Fy2nT +aCq1tCg==; Received: from mcgrof by bombadil.infradead.org with local (Exim 4.98.2 #2 (Red Hat Linux)) id 1uyg8J-00000009j5I-40rR; Wed, 17 Sep 2025 00:34:51 +0000 From: Luis Chamberlain To: Chuck Lever , Daniel Gomez , kdevops@lists.linux.dev Cc: Chuck Lever , Luis Chamberlain Subject: [PATCH v4 2/8] terraform/aws: Add scripts to gather provider resource information Date: Tue, 16 Sep 2025 17:34:43 -0700 Message-ID: <20250917003451.2318229-3-mcgrof@kernel.org> X-Mailer: git-send-email 2.51.0 In-Reply-To: <20250917003451.2318229-1-mcgrof@kernel.org> References: <20250917003451.2318229-1-mcgrof@kernel.org> Precedence: bulk X-Mailing-List: kdevops@lists.linux.dev List-Id: List-Subscribe: List-Unsubscribe: MIME-Version: 1.0 Content-Transfer-Encoding: 8bit Sender: Luis Chamberlain From: Chuck Lever Luis suggested I try my hand... so here goes. I prompted Claude for a few ideas about how to grab information about AMIs, instance types, and regions/AVs. The script output is either JSON, a human-readable table, or CSV. Additional scripts (not provided here) would then capture this output and convert it to Kconfig menus. This is not at all complete. Just another way to approach these tasks, for comparison. I learned why the region information is needed for querying instance types, and that it looks like we won't get clear and reliable pricing information from AWS, for various reasons. I would say that this code works and has a pleasant UX but is still more complex than we might want to carry in kdevops in the long run. I'm interested in seeing some human or AI effort to simplifying these scripts further. And note that the instance type information is always based on what's available in the queried region and what the credentialed user has permission to see. Therefore: - out of the shrink-wrap, kdevops might provide some sensible generic default menu selections - a power user might need to run this before selecting the cloud resources they want to use So it better be damn simple and damn reliable. :-) And we probably need to be very careful before changing the in-tree menus that are committed to the kdevops repo... They might need to continue to be hand-rolled. Or we just have some fixed JSON source that generates the sensible default menus. Posting this for thoughts and opinions. Generated-by: Claude Sonnet 4 Signed-off-by: Chuck Lever Signed-off-by: Luis Chamberlain --- terraform/aws/scripts/aws_ami_info.py | 771 +++++++++++++++++++++ terraform/aws/scripts/aws_regions_info.py | 371 ++++++++++ terraform/aws/scripts/ec2_instance_info.py | 540 +++++++++++++++ 3 files changed, 1682 insertions(+) create mode 100755 terraform/aws/scripts/aws_ami_info.py create mode 100755 terraform/aws/scripts/aws_regions_info.py create mode 100755 terraform/aws/scripts/ec2_instance_info.py diff --git a/terraform/aws/scripts/aws_ami_info.py b/terraform/aws/scripts/aws_ami_info.py new file mode 100755 index 00000000..d9ea8bea --- /dev/null +++ b/terraform/aws/scripts/aws_ami_info.py @@ -0,0 +1,771 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT + +import boto3 +import json +import sys +import argparse +import os +import re +from collections import defaultdict +from configparser import ConfigParser +from botocore.exceptions import ClientError, NoCredentialsError + + +def get_aws_default_region(): + """ + Get the default AWS region from ~/.aws/config file. + + Returns: + str: Default region or 'us-east-1' if not found + """ + config_path = os.path.expanduser("~/.aws/config") + + if os.path.exists(config_path): + try: + config = ConfigParser() + config.read(config_path) + + # Check for default profile region + if "default" in config: + return config["default"].get("region", "us-east-1") + + # Check for profile default section + if "profile default" in config: + return config["profile default"].get("region", "us-east-1") + + except Exception as e: + print(f"Warning: Error reading AWS config file: {e}", file=sys.stderr) + + return "us-east-1" + + +def get_known_ami_owners(): + """ + Get dictionary of well-known AMI owners that provide Linux images. + + Returns: + dict: Dictionary of owner information + """ + return { + "amazon": { + "owner_id": "137112412989", + "owner_name": "Amazon", + "description": "Amazon Linux AMIs", + "search_patterns": [ + r"al2023-ami-.*", # Amazon Linux 2023 + r"amzn2-ami-.*", # Amazon Linux 2 + r"amzn-ami-.*", # Amazon Linux 1 + ], + }, + "ubuntu": { + "owner_id": "099720109477", + "owner_name": "Canonical", + "description": "Ubuntu AMIs", + "search_patterns": [ + r"ubuntu/images/.*ubuntu-.*", # All Ubuntu images + ], + }, + "redhat": { + "owner_id": "309956199498", + "owner_name": "Red Hat", + "description": "Red Hat Enterprise Linux AMIs", + "search_patterns": [ + r"RHEL-.*", # All RHEL versions + ], + }, + "suse": { + "owner_id": "013907871322", + "owner_name": "SUSE", + "description": "SUSE Linux Enterprise AMIs", + "search_patterns": [ + r"suse-sles-.*", + r"suse-sle-.*", + ], + }, + "debian": { + "owner_id": "136693071363", + "owner_name": "Debian", + "description": "Debian GNU/Linux AMIs", + "search_patterns": [ + r"debian-.*", + ], + }, + "centos": { + "owner_id": "125523088429", + "owner_name": "CentOS", + "description": "CentOS Linux AMIs (Legacy)", + "search_patterns": [ + r"CentOS.*", + ], + }, + "rocky": { + "owner_id": "792107900819", + "owner_name": "Rocky Linux", + "description": "Rocky Linux AMIs", + "search_patterns": [ + r"Rocky-.*", + ], + }, + "almalinux": { + "owner_id": "764336703387", + "owner_name": "AlmaLinux", + "description": "AlmaLinux AMIs", + "search_patterns": [ + r"AlmaLinux.*", + ], + }, + "fedora": { + "owner_id": "125523088429", + "owner_name": "Fedora Project", + "description": "Fedora Linux Cloud AMIs", + "search_patterns": [ + r"Fedora-Cloud-.*", + r"Fedora-.*", + ], + }, + "oracle": { + "owner_id": "131827586825", + "owner_name": "Oracle", + "description": "Oracle Linux AMIs", + "search_patterns": [ + r"OL.*-.*", + ], + }, + } + + +def discover_ami_patterns( + owner_id, + owner_name, + search_patterns, + region="us-east-1", + quiet=False, + max_results=1000, +): + """ + Dynamically discover AMI patterns by scanning available AMIs for an owner. + + Args: + owner_id (str): AWS owner account ID + owner_name (str): Human readable owner name + search_patterns (list): Regex patterns to filter AMI names + region (str): AWS region to query + quiet (bool): Suppress debug messages + max_results (int): Maximum AMIs to scan + + Returns: + dict: Dictionary of discovered AMI patterns and examples + """ + try: + if not quiet: + print( + f"Discovering AMI patterns for {owner_name} in {region}...", + file=sys.stderr, + ) + + ec2_client = boto3.client("ec2", region_name=region) + + # Get all AMIs from this owner + all_amis = [] + paginator = ec2_client.get_paginator("describe_images") + + for page in paginator.paginate( + Owners=[owner_id], + Filters=[ + {"Name": "state", "Values": ["available"]}, + {"Name": "image-type", "Values": ["machine"]}, + ], + ): + all_amis.extend(page["Images"]) + if len(all_amis) >= max_results: + all_amis = all_amis[:max_results] + break + + if not quiet: + print(f"Found {len(all_amis)} total AMIs for {owner_name}", file=sys.stderr) + + # Filter AMIs by search patterns + matching_amis = [] + for ami in all_amis: + ami_name = ami.get("Name", "") + for pattern in search_patterns: + if re.match(pattern, ami_name, re.IGNORECASE): + matching_amis.append(ami) + break + + if not quiet: + print( + f"Found {len(matching_amis)} matching AMIs after pattern filtering", + file=sys.stderr, + ) + + # Group AMIs by detected patterns + pattern_groups = defaultdict(list) + + for ami in matching_amis: + ami_name = ami.get("Name", "") + group_key = classify_ami_name(ami_name, owner_name) + + ami_info = { + "ami_id": ami["ImageId"], + "name": ami_name, + "description": ami.get("Description", ""), + "creation_date": ami["CreationDate"], + "architecture": ami.get("Architecture", "Unknown"), + "virtualization_type": ami.get("VirtualizationType", "Unknown"), + "root_device_type": ami.get("RootDeviceType", "Unknown"), + "platform_details": ami.get("PlatformDetails", "Unknown"), + } + + pattern_groups[group_key].append(ami_info) + + # Sort each group by creation date (newest first) and generate patterns + discovered_patterns = {} + for group_key, amis in pattern_groups.items(): + # Sort by creation date, newest first + sorted_amis = sorted(amis, key=lambda x: x["creation_date"], reverse=True) + + # Generate Terraform-compatible filter pattern + terraform_pattern = generate_terraform_pattern( + group_key, sorted_amis[:5] + ) # Use top 5 for pattern analysis + + discovered_patterns[group_key] = { + "display_name": group_key, + "ami_count": len(sorted_amis), + "latest_ami": sorted_amis[0] if sorted_amis else None, + "sample_amis": sorted_amis[:3], # Show 3 most recent + "terraform_filter": terraform_pattern, + "terraform_example": generate_terraform_example( + group_key, terraform_pattern, owner_id + ), + } + + return discovered_patterns + + except NoCredentialsError: + print( + "Error: AWS credentials not found. Please configure your credentials.", + file=sys.stderr, + ) + return {} + except ClientError as e: + print(f"AWS API Error: {e}", file=sys.stderr) + return {} + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + return {} + + +def classify_ami_name(ami_name, owner_name): + """ + Classify an AMI name into a logical group for pattern generation. + + Args: + ami_name (str): AMI name + owner_name (str): Owner name for context + + Returns: + str: Classification key + """ + ami_lower = ami_name.lower() + + # Amazon Linux patterns + if "al2023" in ami_lower: + return "Amazon Linux 2023" + elif "amzn2" in ami_lower: + return "Amazon Linux 2" + elif "amzn-ami" in ami_lower: + return "Amazon Linux 1" + + # Ubuntu patterns + elif "ubuntu" in ami_lower: + if "noble" in ami_lower or "24.04" in ami_lower: + return "Ubuntu 24.04 LTS (Noble)" + elif "jammy" in ami_lower or "22.04" in ami_lower: + return "Ubuntu 22.04 LTS (Jammy)" + elif "focal" in ami_lower or "20.04" in ami_lower: + return "Ubuntu 20.04 LTS (Focal)" + elif "bionic" in ami_lower or "18.04" in ami_lower: + return "Ubuntu 18.04 LTS (Bionic)" + else: + # Extract version number if available + version_match = re.search(r"(\d+\.\d+)", ami_name) + if version_match: + return f"Ubuntu {version_match.group(1)}" + return "Ubuntu (Other)" + + # RHEL patterns + elif "rhel" in ami_lower: + if re.search(r"rhel-?10", ami_lower): + return "RHEL 10" + elif re.search(r"rhel-?9", ami_lower): + return "RHEL 9" + elif re.search(r"rhel-?8", ami_lower): + return "RHEL 8" + elif re.search(r"rhel-?7", ami_lower): + return "RHEL 7" + else: + version_match = re.search(r"rhel-?(\d+)", ami_lower) + if version_match: + return f"RHEL {version_match.group(1)}" + return "RHEL (Other)" + + # Rocky Linux patterns + elif "rocky" in ami_lower: + version_match = re.search(r"rocky-(\d+)", ami_lower) + if version_match: + return f"Rocky Linux {version_match.group(1)}" + return "Rocky Linux" + + # AlmaLinux patterns + elif "almalinux" in ami_lower: + version_match = re.search(r"(\d+)", ami_name) + if version_match: + return f"AlmaLinux {version_match.group(1)}" + return "AlmaLinux" + + # Debian patterns + elif "debian" in ami_lower: + if re.search(r"debian-?12", ami_lower) or "bookworm" in ami_lower: + return "Debian 12 (Bookworm)" + elif re.search(r"debian-?11", ami_lower) or "bullseye" in ami_lower: + return "Debian 11 (Bullseye)" + elif re.search(r"debian-?10", ami_lower) or "buster" in ami_lower: + return "Debian 10 (Buster)" + else: + version_match = re.search(r"debian-?(\d+)", ami_lower) + if version_match: + return f"Debian {version_match.group(1)}" + return "Debian (Other)" + + # SUSE patterns + elif "suse" in ami_lower or "sles" in ami_lower: + version_match = re.search(r"(\d+)", ami_name) + if version_match: + return f"SUSE Linux Enterprise {version_match.group(1)}" + return "SUSE Linux Enterprise" + + # CentOS patterns + elif "centos" in ami_lower: + version_match = re.search(r"(\d+)", ami_name) + if version_match: + return f"CentOS {version_match.group(1)}" + return "CentOS" + + # Fedora patterns + elif "fedora" in ami_lower: + version_match = re.search(r"fedora-.*?(\d+)", ami_lower) + if version_match: + return f"Fedora {version_match.group(1)}" + return "Fedora" + + # Oracle Linux patterns + elif ami_lower.startswith("ol"): + version_match = re.search(r"ol(\d+)", ami_lower) + if version_match: + return f"Oracle Linux {version_match.group(1)}" + return "Oracle Linux" + + # Default: use the owner name + return f"{owner_name} (Other)" + + +def generate_terraform_pattern(group_key, sample_amis): + """ + Generate a Terraform-compatible filter pattern from sample AMIs. + + Args: + group_key (str): Classification key + sample_amis (list): List of sample AMI info + + Returns: + str: Terraform filter pattern + """ + if not sample_amis: + return "" + + # Analyze common patterns in AMI names + names = [ami["name"] for ami in sample_amis] + + # Find the longest common prefix and suffix patterns + if len(names) == 1: + # Single AMI - create a pattern that matches similar names + name = names[0] + # Replace specific dates/versions with wildcards + pattern = re.sub(r"\d{4}-\d{2}-\d{2}", "*", name) # Replace dates + pattern = re.sub(r"-\d+\.\d+\.\d+", "-*", pattern) # Replace version numbers + pattern = re.sub( + r"_\d+\.\d+\.\d+", "_*", pattern + ) # Replace version numbers with underscores + return pattern + + # Multiple AMIs - find common pattern + common_parts = [] + min_len = min(len(name) for name in names) + + # Find common prefix + prefix_len = 0 + for i in range(min_len): + chars = set(name[i] for name in names) + if len(chars) == 1: + prefix_len = i + 1 + else: + break + + if prefix_len > 0: + prefix = names[0][:prefix_len] + return f"{prefix}*" + + # If no common prefix, try to extract the base pattern + first_name = names[0] + # Replace numbers and dates with wildcards + pattern = re.sub(r"\d{8}", "*", first_name) # Replace 8-digit dates + pattern = re.sub(r"\d{4}-\d{2}-\d{2}", "*", pattern) # Replace ISO dates + pattern = re.sub(r"-\d+\.\d+\.\d+", "-*", pattern) # Replace version numbers + pattern = re.sub( + r"_\d+\.\d+\.\d+", "_*", pattern + ) # Replace version numbers with underscores + + return pattern + + +def generate_terraform_example(group_key, filter_pattern, owner_id): + """ + Generate a complete Terraform example. + + Args: + group_key (str): Classification key + filter_pattern (str): Filter pattern + owner_id (str): AWS owner account ID + + Returns: + str: Complete Terraform data source example + """ + # Create a safe resource name + resource_name = re.sub(r"[^a-zA-Z0-9_]", "_", group_key.lower()) + resource_name = re.sub(r"_+", "_", resource_name) # Remove multiple underscores + resource_name = resource_name.strip("_") # Remove leading/trailing underscores + + if not filter_pattern: + filter_pattern = "*" + + terraform_code = f"""data "aws_ami" "{resource_name}" {{ + most_recent = true + owners = ["{owner_id}"] + filter {{ + name = "name" + values = ["{filter_pattern}"] + }} + filter {{ + name = "architecture" + values = ["x86_64"] + }} + filter {{ + name = "virtualization-type" + values = ["hvm"] + }} + filter {{ + name = "state" + values = ["available"] + }} +}}""" + + return terraform_code + + +def get_owner_ami_info(owner_key, region="us-east-1", quiet=False): + """ + Get comprehensive AMI information for a specific owner. + + Args: + owner_key (str): Owner key (e.g., 'amazon', 'ubuntu') + region (str): AWS region to query + quiet (bool): Suppress debug messages + + Returns: + dict: Owner information with discovered AMI patterns + """ + known_owners = get_known_ami_owners() + + if owner_key not in known_owners: + return None + + owner_info = known_owners[owner_key].copy() + + # Discover actual AMI patterns + discovered_patterns = discover_ami_patterns( + owner_info["owner_id"], + owner_info["owner_name"], + owner_info["search_patterns"], + region, + quiet, + ) + + owner_info["discovered_patterns"] = discovered_patterns + owner_info["total_pattern_count"] = len(discovered_patterns) + + return owner_info + + +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Get AWS AMI owner information and Terraform filter examples", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python %(prog)s --owners + python %(prog)s amazon + python %(prog)s ubuntu --format json + python %(prog)s --owners --format csv + python %(prog)s redhat --region eu-west-1 + """, + ) + + parser.add_argument( + "owner_key", + nargs="?", # Make owner_key optional when using --owners + help="AMI owner key (e.g., amazon, ubuntu, redhat, debian, suse, centos, rocky, almalinux)", + ) + + parser.add_argument( + "--region", "-r", help="AWS region (default: from ~/.aws/config or us-east-1)" + ) + + parser.add_argument( + "--format", + "-f", + choices=["table", "json", "csv", "terraform"], + default="table", + help="Output format (default: table)", + ) + + parser.add_argument( + "--quiet", "-q", action="store_true", help="Suppress informational messages" + ) + + parser.add_argument( + "--debug", "-d", action="store_true", help="Enable debug output" + ) + + parser.add_argument( + "--owners", action="store_true", help="List all known AMI owners" + ) + + parser.add_argument( + "--max-results", + type=int, + default=1000, + help="Maximum number of AMIs to scan per owner (default: 1000)", + ) + + return parser.parse_args() + + +def output_owners_table(owners, quiet=False): + """Output AMI owners in table format.""" + if not quiet: + print(f"Known Linux AMI owners ({len(owners)}):\n") + + # Print header + print(f"{'Owner Key':<12} {'Owner Name':<15} {'Owner ID':<15} {'Description':<30}") + print("-" * 75) + + # Sort owners by key + for owner_key in sorted(owners.keys()): + owner = owners[owner_key] + print( + f"{owner_key:<12} " + f"{owner['owner_name']:<15} " + f"{owner['owner_id']:<15} " + f"{owner['description']:<30}" + ) + + +def output_owners_json(owners): + """Output owners in JSON format.""" + print(json.dumps(owners, indent=2)) + + +def output_owners_csv(owners): + """Output owners in CSV format.""" + print("owner_key,owner_name,owner_id,description") + + for owner_key in sorted(owners.keys()): + owner = owners[owner_key] + description = owner["description"].replace(",", ";") # Avoid CSV issues + print(f"{owner_key},{owner['owner_name']},{owner['owner_id']},{description}") + + +def output_owner_table(owner_info, quiet=False): + """Output owner AMI information in table format.""" + if not quiet: + print( + f"AMI Information for {owner_info['owner_name']} (Owner ID: {owner_info['owner_id']})" + ) + print(f"Description: {owner_info['description']}") + print(f"Found {owner_info['total_pattern_count']} AMI pattern groups\n") + + if not owner_info["discovered_patterns"]: + print("No AMI patterns discovered for this owner in the specified region.") + return + + for pattern_name, pattern_info in sorted(owner_info["discovered_patterns"].items()): + print(f"Pattern: {pattern_name}") + print(f" AMI Count: {pattern_info['ami_count']}") + print(f" Filter Pattern: {pattern_info['terraform_filter']}") + + # Show latest AMI + if pattern_info["latest_ami"]: + latest = pattern_info["latest_ami"] + print(f" Latest AMI: {latest['ami_id']} ({latest['creation_date'][:10]})") + print(f" Architecture: {latest['architecture']}") + + # Show sample AMIs + if pattern_info["sample_amis"]: + print(f" Sample AMIs:") + for ami in pattern_info["sample_amis"]: + print( + f" {ami['ami_id']} - {ami['name'][:60]}{'...' if len(ami['name']) > 60 else ''}" + ) + print( + f" Created: {ami['creation_date'][:10]} | Arch: {ami['architecture']} | Virt: {ami['virtualization_type']}" + ) + + print() # Empty line between patterns + + +def output_owner_json(owner_info): + """Output owner information in JSON format.""" + + # Convert datetime objects to strings for JSON serialization + def json_serializer(obj): + if hasattr(obj, "isoformat"): + return obj.isoformat() + return str(obj) + + print(json.dumps(owner_info, indent=2, default=json_serializer)) + + +def output_owner_csv(owner_info): + """Output owner information in CSV format.""" + print( + "pattern_name,ami_count,filter_pattern,latest_ami_id,latest_ami_name,creation_date,architecture" + ) + + for pattern_name, pattern_info in sorted(owner_info["discovered_patterns"].items()): + latest = pattern_info.get("latest_ami", {}) + ami_id = latest.get("ami_id", "") + ami_name = latest.get("name", "").replace(",", ";") # Avoid CSV issues + creation_date = ( + latest.get("creation_date", "")[:10] if latest.get("creation_date") else "" + ) + architecture = latest.get("architecture", "") + + print( + f"{pattern_name},{pattern_info['ami_count']},{pattern_info['terraform_filter']},{ami_id},{ami_name},{creation_date},{architecture}" + ) + + +def output_owner_terraform(owner_info): + """Output owner information as Terraform examples.""" + print(f"# Terraform aws_ami data source examples for {owner_info['owner_name']}") + print(f"# Owner ID: {owner_info['owner_id']}") + print(f"# {owner_info['description']}") + print(f"# Found {owner_info['total_pattern_count']} AMI pattern groups") + print() + + for pattern_name, pattern_info in sorted(owner_info["discovered_patterns"].items()): + print(f"# {pattern_name} ({pattern_info['ami_count']} AMIs available)") + if pattern_info["latest_ami"]: + print( + f"# Latest: {pattern_info['latest_ami']['ami_id']} ({pattern_info['latest_ami']['creation_date'][:10]})" + ) + print(pattern_info["terraform_example"]) + print() + + +def main(): + """Main function to run the program.""" + args = parse_arguments() + + # Determine region + if args.region: + region = args.region + else: + region = get_aws_default_region() + + # Handle --owners option + if args.owners: + owners = get_known_ami_owners() + if args.format == "json": + output_owners_json(owners) + elif args.format == "csv": + output_owners_csv(owners) + else: # table format (terraform not applicable for owners list) + output_owners_table(owners, args.quiet) + return + + # Require owner_key if not using --owners + if not args.owner_key: + print( + "Error: owner_key is required unless using --owners option", file=sys.stderr + ) + print("Use --owners to list all available AMI owners", file=sys.stderr) + sys.exit(1) + + # Validate owner key + known_owners = get_known_ami_owners() + if args.owner_key not in known_owners: + print(f"Error: Unknown owner key '{args.owner_key}'", file=sys.stderr) + print( + f"Available owners: {', '.join(sorted(known_owners.keys()))}", + file=sys.stderr, + ) + sys.exit(1) + + if not args.quiet: + print( + f"Discovering AMI patterns for {args.owner_key} in {region}...", + file=sys.stderr, + ) + print(f"This may take a moment as we scan available AMIs...", file=sys.stderr) + + # Get owner information with dynamic discovery + owner_info = get_owner_ami_info( + args.owner_key, region, args.quiet or not args.debug + ) + + if not owner_info: + print( + f"Could not retrieve AMI information for owner '{args.owner_key}'.", + file=sys.stderr, + ) + sys.exit(1) + + if not owner_info.get("discovered_patterns"): + print( + f"No AMI patterns discovered for owner '{args.owner_key}' in region {region}.", + file=sys.stderr, + ) + print( + "This may be because the owner has no AMIs in this region or the search patterns need adjustment.", + file=sys.stderr, + ) + sys.exit(1) + + # Output results in specified format + if args.format == "json": + output_owner_json(owner_info) + elif args.format == "csv": + output_owner_csv(owner_info) + elif args.format == "terraform": + output_owner_terraform(owner_info) + else: # table format + output_owner_table(owner_info, args.quiet) + + +if __name__ == "__main__": + main() diff --git a/terraform/aws/scripts/aws_regions_info.py b/terraform/aws/scripts/aws_regions_info.py new file mode 100755 index 00000000..00b88e0a --- /dev/null +++ b/terraform/aws/scripts/aws_regions_info.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT + +import boto3 +import json +import sys +import argparse +import os +from configparser import ConfigParser +from botocore.exceptions import ClientError, NoCredentialsError + + +def get_aws_default_region(): + """ + Get the default AWS region from ~/.aws/config file. + + Returns: + str: Default region or 'us-east-1' if not found + """ + config_path = os.path.expanduser("~/.aws/config") + + if os.path.exists(config_path): + try: + config = ConfigParser() + config.read(config_path) + + # Check for default profile region + if "default" in config: + return config["default"].get("region", "us-east-1") + + # Check for profile default section + if "profile default" in config: + return config["profile default"].get("region", "us-east-1") + + except Exception as e: + print(f"Warning: Error reading AWS config file: {e}", file=sys.stderr) + + return "us-east-1" + + +def get_all_regions(): + """ + Get all available AWS regions with their descriptions. + + Returns: + dict: Dictionary of region information + """ + try: + # Use a default region to get the list of all regions + ec2_client = boto3.client("ec2", region_name="us-east-1") + response = ec2_client.describe_regions(AllRegions=True) + + regions = {} + for region in response["Regions"]: + region_name = region["RegionName"] + regions[region_name] = { + "region_name": region_name, + "region_description": region.get("RegionName", region_name), + "opt_in_status": region.get("OptInStatus", "Unknown"), + "availability_zones": [], + } + + return regions + + except Exception as e: + print(f"Error retrieving AWS regions: {e}", file=sys.stderr) + return {} + + +def get_region_info(region_name, quiet=False): + """ + Get detailed information about a specific region including availability zones. + + Args: + region_name (str): AWS region name (e.g., 'us-east-1', 'eu-west-1') + quiet (bool): Suppress debug messages + + Returns: + dict: Dictionary containing region information and availability zones + """ + try: + if not quiet: + print(f"Querying information for region {region_name}...", file=sys.stderr) + + # Initialize EC2 client for the specific region + ec2_client = boto3.client("ec2", region_name=region_name) + + # Get region information + regions_response = ec2_client.describe_regions( + Filters=[{"Name": "region-name", "Values": [region_name]}] + ) + + if not regions_response["Regions"]: + if not quiet: + print(f"Region {region_name} not found", file=sys.stderr) + return None + + region_info = regions_response["Regions"][0] + + # Get availability zones for the region + az_response = ec2_client.describe_availability_zones() + + availability_zones = [] + for az in az_response["AvailabilityZones"]: + zone_info = { + "zone_id": az["ZoneId"], + "zone_name": az["ZoneName"], + "zone_type": az.get("ZoneType", "availability-zone"), + "parent_zone_id": az.get("ParentZoneId", ""), + "parent_zone_name": az.get("ParentZoneName", ""), + "state": az["State"], + "messages": [], + } + + # Add any messages about the zone + if "Messages" in az: + zone_info["messages"] = [ + msg.get("Message", "") for msg in az["Messages"] + ] + + availability_zones.append(zone_info) + + # Get network border group information if available + try: + zone_details = {} + for az in az_response["AvailabilityZones"]: + if "NetworkBorderGroup" in az: + zone_details[az["ZoneName"]] = az["NetworkBorderGroup"] + except: + zone_details = {} + + result = { + "region_name": region_info["RegionName"], + "endpoint": region_info.get("Endpoint", f"ec2.{region_name}.amazonaws.com"), + "opt_in_status": region_info.get("OptInStatus", "opt-in-not-required"), + "availability_zone_count": len(availability_zones), + "availability_zones": sorted( + availability_zones, key=lambda x: x["zone_name"] + ), + } + + if not quiet: + print( + f"Found {len(availability_zones)} availability zones in {region_name}", + file=sys.stderr, + ) + + return result + + except NoCredentialsError: + print( + "Error: AWS credentials not found. Please configure your credentials.", + file=sys.stderr, + ) + return None + except ClientError as e: + error_code = e.response.get("Error", {}).get("Code", "Unknown") + if error_code in ["UnauthorizedOperation", "InvalidRegion"]: + print( + f"Error: Cannot access region {region_name}. Check region name and permissions.", + file=sys.stderr, + ) + else: + print(f"AWS API Error: {e}", file=sys.stderr) + return None + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + return None + + +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Get AWS region and availability zone information", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python %(prog)s --regions + python %(prog)s us-east-1 + python %(prog)s eu-west-1 --format json + python %(prog)s --regions --format csv + python %(prog)s ap-southeast-1 --quiet + """, + ) + + parser.add_argument( + "region_name", + nargs="?", # Make region_name optional when using --regions + help="AWS region name (e.g., us-east-1, eu-west-1, ap-southeast-1)", + ) + + parser.add_argument( + "--format", + "-f", + choices=["table", "json", "csv"], + default="table", + help="Output format (default: table)", + ) + + parser.add_argument( + "--quiet", "-q", action="store_true", help="Suppress informational messages" + ) + + parser.add_argument( + "--debug", "-d", action="store_true", help="Enable debug output" + ) + + parser.add_argument( + "--regions", action="store_true", help="List all available AWS regions" + ) + + return parser.parse_args() + + +def output_regions_table(regions, quiet=False): + """Output available regions in table format.""" + if not quiet: + print(f"Available AWS regions ({len(regions)}):\n") + + # Print header + print(f"{'Region Name':<20} {'Opt-in Status':<20}") + print("-" * 42) + + # Sort regions by name + sorted_regions = sorted(regions.values(), key=lambda x: x["region_name"]) + + for region in sorted_regions: + opt_in_status = region.get("opt_in_status", "Unknown") + print(f"{region['region_name']:<20} {opt_in_status:<20}") + + +def output_regions_json(regions): + """Output regions in JSON format.""" + # Convert to list for JSON output + regions_list = sorted(regions.values(), key=lambda x: x["region_name"]) + print(json.dumps(regions_list, indent=2)) + + +def output_regions_csv(regions): + """Output regions in CSV format.""" + if regions: + # Print header + print("region_name,opt_in_status") + + # Sort regions by name + sorted_regions = sorted(regions.values(), key=lambda x: x["region_name"]) + + # Print data + for region in sorted_regions: + opt_in_status = region.get("opt_in_status", "Unknown") + print(f"{region['region_name']},{opt_in_status}") + + +def output_region_table(region_info, quiet=False): + """Output region information in table format.""" + if not quiet: + print(f"Region: {region_info['region_name']}\n") + print(f"Endpoint: {region_info['endpoint']}") + print(f"Opt-in Status: {region_info['opt_in_status']}") + print(f"Availability Zones: {region_info['availability_zone_count']}\n") + + # Print availability zones table + print( + f"{'Zone Name':<15} {'Zone ID':<15} {'Zone Type':<18} {'State':<12} {'Parent Zone':<15}" + ) + print("-" * 80) + + for az in region_info["availability_zones"]: + parent_zone = az.get("parent_zone_name", "") or az.get("parent_zone_id", "") + zone_type = az.get("zone_type", "availability-zone") + + print( + f"{az['zone_name']:<15} " + f"{az['zone_id']:<15} " + f"{zone_type:<18} " + f"{az['state']:<12} " + f"{parent_zone:<15}" + ) + + # Show messages if any zones have them + zones_with_messages = [ + az for az in region_info["availability_zones"] if az.get("messages") + ] + if zones_with_messages and not quiet: + print("\nZone Messages:") + for az in zones_with_messages: + for message in az["messages"]: + print(f" {az['zone_name']}: {message}") + + +def output_region_json(region_info): + """Output region information in JSON format.""" + print(json.dumps(region_info, indent=2)) + + +def output_region_csv(region_info): + """Output region information in CSV format.""" + # First output region info + print("type,region_name,endpoint,opt_in_status,availability_zone_count") + print( + f"region,{region_info['region_name']},{region_info['endpoint']},{region_info['opt_in_status']},{region_info['availability_zone_count']}" + ) + + # Then output availability zones + print("\ntype,zone_name,zone_id,zone_type,state,parent_zone_name,parent_zone_id") + for az in region_info["availability_zones"]: + parent_zone_name = az.get("parent_zone_name", "") + parent_zone_id = az.get("parent_zone_id", "") + zone_type = az.get("zone_type", "availability-zone") + + print( + f"availability_zone,{az['zone_name']},{az['zone_id']},{zone_type},{az['state']},{parent_zone_name},{parent_zone_id}" + ) + + +def main(): + """Main function to run the program.""" + args = parse_arguments() + + # Handle --regions option + if args.regions: + if not args.quiet: + print("Fetching list of all AWS regions...", file=sys.stderr) + + regions = get_all_regions() + if regions: + if args.format == "json": + output_regions_json(regions) + elif args.format == "csv": + output_regions_csv(regions) + else: # table format + output_regions_table(regions, args.quiet) + else: + print("Could not retrieve AWS regions.", file=sys.stderr) + sys.exit(1) + return + + # Require region_name if not using --regions + if not args.region_name: + print( + "Error: region_name is required unless using --regions option", + file=sys.stderr, + ) + print("Use --regions to list all available regions", file=sys.stderr) + sys.exit(1) + + if not args.quiet: + print(f"Fetching information for region {args.region_name}...", file=sys.stderr) + + # Get region information + region_info = get_region_info(args.region_name, args.quiet or not args.debug) + + if not region_info: + print( + f"Could not retrieve information for region '{args.region_name}'.", + file=sys.stderr, + ) + print(f"Try running with --regions to see available regions.", file=sys.stderr) + sys.exit(1) + + # Output results in specified format + if args.format == "json": + output_region_json(region_info) + elif args.format == "csv": + output_region_csv(region_info) + else: # table format + output_region_table(region_info, args.quiet) + + +if __name__ == "__main__": + main() diff --git a/terraform/aws/scripts/ec2_instance_info.py b/terraform/aws/scripts/ec2_instance_info.py new file mode 100755 index 00000000..4dcbc6c9 --- /dev/null +++ b/terraform/aws/scripts/ec2_instance_info.py @@ -0,0 +1,540 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: MIT + +import boto3 +import json +import sys +import argparse +import os +from configparser import ConfigParser +from botocore.exceptions import ClientError, NoCredentialsError + + +def get_aws_default_region(): + """ + Get the default AWS region from ~/.aws/config file. + + Returns: + str: Default region or 'us-east-1' if not found + """ + config_path = os.path.expanduser("~/.aws/config") + + if os.path.exists(config_path): + try: + config = ConfigParser() + config.read(config_path) + + # Check for default profile region + if "default" in config: + return config["default"].get("region", "us-east-1") + + # Check for profile default section + if "profile default" in config: + return config["profile default"].get("region", "us-east-1") + + except Exception as e: + print(f"Warning: Error reading AWS config file: {e}", file=sys.stderr) + + return "us-east-1" + + +def get_available_families(region="us-east-1"): + """ + Get all available instance families in the specified region. + + Args: + region (str): AWS region to query + + Returns: + dict: Dictionary with family info including count of instances per family + """ + try: + ec2_client = boto3.client("ec2", region_name=region) + response = ec2_client.describe_instance_types() + + families = {} + for instance_type in response["InstanceTypes"]: + instance_name = instance_type["InstanceType"] + family = instance_name.split(".")[0] + + if family not in families: + families[family] = { + "family_name": family, + "instance_count": 0, + "has_gpu": False, + "architectures": set(), + } + + families[family]["instance_count"] += 1 + + # Check for GPU + if "GpuInfo" in instance_type: + families[family]["has_gpu"] = True + + # Get architectures + cpu_architectures = instance_type.get("ProcessorInfo", {}).get( + "SupportedArchitectures", [] + ) + families[family]["architectures"].update(cpu_architectures) + + # Convert architecture sets to sorted lists for JSON serialization + for family in families.values(): + family["architectures"] = sorted(list(family["architectures"])) + + return families + + except Exception as e: + print(f"Error retrieving instance families: {e}", file=sys.stderr) + return {} + + +def get_gpu_info(instance_type): + """ + Extract GPU information from instance type data. + + Args: + instance_type (dict): Instance type data from AWS API + + Returns: + str: Formatted GPU information string + """ + if "GpuInfo" not in instance_type: + return "None" + + gpu_info = instance_type["GpuInfo"] + total_gpu_memory = gpu_info.get("TotalGpuMemoryInMiB", 0) + gpus = gpu_info.get("Gpus", []) + + if not gpus: + return "GPU present (details unavailable)" + + gpu_details = [] + for gpu in gpus: + gpu_name = gpu.get("Name", "Unknown GPU") + gpu_count = gpu.get("Count", 1) + gpu_memory = gpu.get("MemoryInfo", {}).get("SizeInMiB", 0) + + if gpu_count > 1: + detail = f"{gpu_count}x {gpu_name}" + else: + detail = gpu_name + + if gpu_memory > 0: + detail += f" ({gpu_memory // 1024}GB)" + + gpu_details.append(detail) + + return ", ".join(gpu_details) + + +def get_instance_family_info(family_name, region="us-east-1", quiet=False): + """ + Get instance types, pricing, and hardware info for an AWS instance family. + + Args: + family_name (str): Instance family name (e.g., 'm5', 't3', 'c5') + region (str): AWS region to query (default: us-east-1) + quiet (bool): Suppress debug messages + + Returns: + list: List of dictionaries containing instance information + """ + try: + # Initialize AWS clients + ec2_client = boto3.client("ec2", region_name=region) + pricing_client = boto3.client( + "pricing", region_name="us-east-1" + ) # Pricing API only in us-east-1 + + if not quiet: + print( + f"Querying EC2 API for instances starting with '{family_name}'...", + file=sys.stderr, + ) + + # Get ALL instance types first, then filter + response = ec2_client.describe_instance_types() + + # Filter instances that belong to the specified family + family_instances = [] + for instance_type in response["InstanceTypes"]: + instance_name = instance_type["InstanceType"] + # More flexible matching - check if instance name starts with family name + if instance_name.startswith(family_name + ".") or instance_name.startswith( + family_name + ): + family_instances.append(instance_type) + + if not family_instances: + if not quiet: + print( + f"No instances found starting with '{family_name}'. Trying broader search...", + file=sys.stderr, + ) + + # Try a broader search - maybe the family name is part of the instance type + family_instances = [] + for instance_type in response["InstanceTypes"]: + instance_name = instance_type["InstanceType"] + if family_name.lower() in instance_name.lower(): + family_instances.append(instance_type) + + if not family_instances: + if not quiet: + # Show available families to help debug + families = get_available_families(region) + family_names = sorted([f["family_name"] for f in families.values()]) + print(f"Available instance families: {family_names}", file=sys.stderr) + return [] + + if not quiet: + print( + f"Found {len(family_instances)} instances in family '{family_name}'", + file=sys.stderr, + ) + + instance_info = [] + + for instance_type in family_instances: + instance_name = instance_type["InstanceType"] + + if not quiet: + print(f"Processing {instance_name}...", file=sys.stderr) + + # Extract CPU architecture information + cpu_architectures = instance_type.get("ProcessorInfo", {}).get( + "SupportedArchitectures", ["Unknown"] + ) + cpu_isa = ", ".join(cpu_architectures) if cpu_architectures else "Unknown" + + # Extract GPU information + gpu_info = get_gpu_info(instance_type) + + # Extract hardware specifications + hardware_info = { + "instance_type": instance_name, + "vcpus": instance_type["VCpuInfo"]["DefaultVCpus"], + "memory_gb": instance_type["MemoryInfo"]["SizeInMiB"] / 1024, + "cpu_isa": cpu_isa, + "gpu": gpu_info, + "network_performance": instance_type.get("NetworkInfo", {}).get( + "NetworkPerformance", "Not specified" + ), + "storage": "EBS-only", + } + + # Check for instance storage + if "InstanceStorageInfo" in instance_type: + storage_info = instance_type["InstanceStorageInfo"] + total_storage = storage_info.get("TotalSizeInGB", 0) + storage_type = storage_info.get("Disks", [{}])[0].get("Type", "Unknown") + hardware_info["storage"] = f"{total_storage} GB {storage_type}" + + # Get pricing information (note: this often fails due to AWS Pricing API limitations) + try: + pricing_response = pricing_client.get_products( + ServiceCode="AmazonEC2", + Filters=[ + { + "Type": "TERM_MATCH", + "Field": "instanceType", + "Value": instance_name, + }, + { + "Type": "TERM_MATCH", + "Field": "location", + "Value": get_location_name(region), + }, + {"Type": "TERM_MATCH", "Field": "tenancy", "Value": "Shared"}, + { + "Type": "TERM_MATCH", + "Field": "operating-system", + "Value": "Linux", + }, + { + "Type": "TERM_MATCH", + "Field": "preInstalledSw", + "Value": "NA", + }, + { + "Type": "TERM_MATCH", + "Field": "capacitystatus", + "Value": "Used", + }, + ], + ) + + if pricing_response["PriceList"]: + price_data = json.loads(pricing_response["PriceList"][0]) + terms = price_data["terms"]["OnDemand"] + + # Extract the hourly price + for term_key in terms: + price_dimensions = terms[term_key]["priceDimensions"] + for price_key in price_dimensions: + price_per_hour = price_dimensions[price_key][ + "pricePerUnit" + ]["USD"] + hardware_info["price_per_hour_usd"] = f"${price_per_hour}" + break + break + else: + hardware_info["price_per_hour_usd"] = "Not available" + + except Exception as e: + if not quiet: + print( + f"Warning: Could not fetch pricing for {instance_name}: {str(e)}", + file=sys.stderr, + ) + hardware_info["price_per_hour_usd"] = "Not available" + + instance_info.append(hardware_info) + + return sorted(instance_info, key=lambda x: x["instance_type"]) + + except NoCredentialsError: + print( + "Error: AWS credentials not found. Please configure your credentials.", + file=sys.stderr, + ) + return [] + except ClientError as e: + print(f"AWS API Error: {e}", file=sys.stderr) + return [] + except Exception as e: + print(f"Unexpected error: {e}", file=sys.stderr) + return [] + + +def get_location_name(region): + """Convert AWS region to location name for pricing API.""" + region_mapping = { + "us-east-1": "US East (N. Virginia)", + "us-east-2": "US East (Ohio)", + "us-west-1": "US West (N. California)", + "us-west-2": "US West (Oregon)", + "us-west-2-lax-1": "US West (Los Angeles)", + "ca-central-1": "Canada (Central)", + "eu-west-1": "Europe (Ireland)", + "eu-west-2": "Europe (London)", + "eu-west-3": "Europe (Paris)", + "eu-central-1": "Europe (Frankfurt)", + "eu-north-1": "Europe (Stockholm)", + "ap-southeast-1": "Asia Pacific (Singapore)", + "ap-southeast-2": "Asia Pacific (Sydney)", + "ap-northeast-1": "Asia Pacific (Tokyo)", + "ap-northeast-2": "Asia Pacific (Seoul)", + "ap-south-1": "Asia Pacific (Mumbai)", + "sa-east-1": "South America (Sao Paulo)", + } + return region_mapping.get(region, "US East (N. Virginia)") + + +def parse_arguments(): + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + description="Get AWS EC2 instance family information including pricing and hardware specs", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + python %(prog)s m5 + python %(prog)s t3 --region us-west-2 + python %(prog)s c5 --format json + python %(prog)s r5 --quiet + python %(prog)s --families + python %(prog)s --families --format json + """, + ) + + parser.add_argument( + "family_name", + nargs="?", # Make family_name optional when using --families + help="Instance family name (e.g., m5, t3, c5, r5)", + ) + + parser.add_argument( + "--region", "-r", help="AWS region (default: from ~/.aws/config or us-east-1)" + ) + + parser.add_argument( + "--format", + "-f", + choices=["table", "json", "csv"], + default="table", + help="Output format (default: table)", + ) + + parser.add_argument( + "--quiet", "-q", action="store_true", help="Suppress informational messages" + ) + + parser.add_argument( + "--debug", "-d", action="store_true", help="Enable debug output" + ) + + parser.add_argument( + "--families", + action="store_true", + help="List all available instance families in the region", + ) + + return parser.parse_args() + + +def output_families_table(families, region, quiet=False): + """Output available instance families in table format.""" + if not quiet: + print(f"Available instance families in {region}:\n") + + # Print header + print(f"{'Family':<10} {'Count':<6} {'GPU':<5} {'Architectures':<20}") + print("-" * 45) + + # Sort families by name + sorted_families = sorted(families.values(), key=lambda x: x["family_name"]) + + for family in sorted_families: + gpu_indicator = "Yes" if family["has_gpu"] else "No" + architectures = ", ".join(family["architectures"]) + + print( + f"{family['family_name']:<10} " + f"{family['instance_count']:<6} " + f"{gpu_indicator:<5} " + f"{architectures:<20}" + ) + + +def output_families_json(families): + """Output families in JSON format.""" + # Convert to list for JSON output + families_list = sorted(families.values(), key=lambda x: x["family_name"]) + print(json.dumps(families_list, indent=2)) + + +def output_families_csv(families): + """Output families in CSV format.""" + if families: + # Print header + print("family_name,instance_count,has_gpu,architectures") + + # Sort families by name + sorted_families = sorted(families.values(), key=lambda x: x["family_name"]) + + # Print data + for family in sorted_families: + architectures = ";".join( + family["architectures"] + ) # Use semicolon to avoid CSV issues + print( + f"{family['family_name']},{family['instance_count']},{family['has_gpu']},{architectures}" + ) + + +def output_table(instances, quiet=False): + """Output results in table format.""" + if not quiet: + print(f"Found {len(instances)} instance types:\n") + + # Print header - adjusted for GPU column + print( + f"{'Instance Type':<15} {'vCPUs':<6} {'Memory (GB)':<12} {'CPU ISA':<10} {'GPU':<25} {'Storage':<20} {'Network':<15} {'Price/Hour':<12}" + ) + print("-" * 130) + + # Print instance details + for instance in instances: + print( + f"{instance['instance_type']:<15} " + f"{instance['vcpus']:<6} " + f"{instance['memory_gb']:<12.1f} " + f"{instance['cpu_isa']:<10} " + f"{instance['gpu']:<25} " + f"{instance['storage']:<20} " + f"{instance['network_performance']:<15} " + f"{instance['price_per_hour_usd']:<12}" + ) + + +def output_json(instances): + """Output results in JSON format.""" + print(json.dumps(instances, indent=2)) + + +def output_csv(instances): + """Output results in CSV format.""" + if instances: + # Print header + headers = instances[0].keys() + print(",".join(headers)) + + # Print data + for instance in instances: + values = [str(instance[header]).replace(",", ";") for header in headers] + print(",".join(values)) + + +def main(): + """Main function to run the program.""" + args = parse_arguments() + + # Determine region + if args.region: + region = args.region + else: + region = get_aws_default_region() + + # Handle --families option + if args.families: + families = get_available_families(region) + if families: + if args.format == "json": + output_families_json(families) + elif args.format == "csv": + output_families_csv(families) + else: # table format + output_families_table(families, region, args.quiet) + else: + print("Could not retrieve instance families.", file=sys.stderr) + sys.exit(1) + return + + # Require family_name if not using --families + if not args.family_name: + print( + "Error: family_name is required unless using --families option", + file=sys.stderr, + ) + sys.exit(1) + + if not args.quiet: + print( + f"Fetching information for {args.family_name} family in {region}...", + file=sys.stderr, + ) + + # Get instance information + instances = get_instance_family_info( + args.family_name, region, args.quiet or not args.debug + ) + + if not instances: + print(f"No instances found for family '{args.family_name}'.", file=sys.stderr) + print( + f"Try running with --families to see available instance families.", + file=sys.stderr, + ) + sys.exit(1) + + # Output results in specified format + if args.format == "json": + output_json(instances) + elif args.format == "csv": + output_csv(instances) + else: # table format + output_table(instances, args.quiet) + + +if __name__ == "__main__": + main() -- 2.51.0