def describe( config: Config, ami: Optional[str] = None, owner: Optional[str] = None, name_match: Optional[str] = None, show_snapshot_id: bool = False, ) -> List[Image]: """List AMIs.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) if ami: response = ec2_client.describe_images(ImageIds=[ami]) else: if owner: owners_filter = [owner] else: describe_images_owners = config.get("describe_images_owners", None) if not describe_images_owners: owners_filter = ["self"] elif isinstance(describe_images_owners, str): owners_filter = [describe_images_owners] else: owners_filter: List[str] = describe_images_owners if name_match is None: name_match = config.get("describe_images_name_match", None) filters: List[FilterTypeDef] = [] if name_match is None else [{"Name": "name", "Values": [f"*{name_match}*"]}] print(f'Describing images owned by {owners_filter} with name matching {name_match or "*"}') response = ec2_client.describe_images(Owners=owners_filter, Filters=filters) images = [] for i in response["Images"]: image: Image = { "Name": i.get("Name", None), "ImageId": i["ImageId"], "CreationDate": i["CreationDate"], "RootDeviceName": i["RootDeviceName"] if "RootDeviceName" in i else None, "Size": i["BlockDeviceMappings"][0]["Ebs"]["VolumeSize"] if i["BlockDeviceMappings"] else None, } if show_snapshot_id: image["SnapshotId"] = i["BlockDeviceMappings"][0]["Ebs"]["SnapshotId"] images.append(image) return sorted(images, key=lambda i: i["CreationDate"], reverse=True)
def tag( config: Config, tags: List[str], name: Optional[str] = None, name_match: Optional[str] = None, ) -> List[Dict[str, Any]]: """Tag EC2 instance(s).""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) tagdefs: List[TagTypeDef] = [] for t in tags: parts = t.split("=") tagdefs.append({"Key": parts[0], "Value": parts[1]}) instances = describe(config, name, name_match) ids = [i["InstanceId"] for i in instances] if not ids: raise NoInstancesError(name=name, name_match=name_match) ec2_client.create_tags(Resources=ids, Tags=tagdefs) return describe_tags(config, name, name_match, keys=[d["Key"] for d in tagdefs])
def instance_tags(config: Config, name: Optional[str] = None, name_match: Optional[str] = None, keys: List[str] = []) -> List[Dict[str, Any]]: """List EC2 instances with their tags.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) response = ec2_client.describe_instances(Filters=filters(name, name_match)) instances: List[Dict[str, Any]] = [] for r in response["Reservations"]: for i in r["Instances"]: if i["State"]["Name"] != "terminated": inst = { "InstanceId": i["InstanceId"], "Name": util_tags.get_value(i, "Name") } if not keys: inst["Tags"] = ", ".join(f"{tag['Key']}={tag['Value']}" for tag in i.get("Tags", [])) else: for key in keys: inst[f"Tag: {key}"] = util_tags.get_value(i, key) instances.append(inst) return sorted(instances, key=lambda i: str(i["Name"]))
def volume_tags(config: Config, name: Optional[str] = None, name_match: Optional[str] = None, keys: List[str] = []) -> List[Dict[str, Any]]: """List EC2 volumes with their tags.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) response = ec2_client.describe_volumes(Filters=filters(name, name_match)) volumes: List[Dict[str, Any]] = [] for v in response["Volumes"]: vol = { "VolumeId": v["VolumeId"], "Name": util_tags.get_value(v, "Name") } if not keys: vol["Tags"] = ", ".join(f"{tag['Key']}={tag['Value']}" for tag in v.get("Tags", [])) else: for key in keys: vol[f"Tag: {key}"] = util_tags.get_value(v, key) volumes.append(vol) return sorted(volumes, key=lambda i: str(i["Name"]))
def fetch_instance_ids(config: Config, ids_or_names: List[str]) -> List[str]: if ids_or_names == ["all"]: return [i["ID"] for i in describe(config)] ids: List[str] = [] names: List[str] = [] for i in ids_or_names: if i.startswith("i-"): ids.append(i) else: names.append(i) if names: ec2_client = boto3.client("ec2", region_name=config.get("region", None)) response = ec2_client.describe_instances(Filters=[{"Name": "tag:Name", "Values": names}]) try: for r in response["Reservations"]: for i in r["Instances"]: ids.append(i["InstanceId"]) except IndexError: raise ValueError(f"No instances with names {','.join(names)}") return ids
def describe(config: Config) -> Iterator[Agent]: """List running instances with the SSM agent.""" instances_names = describe_instances_names(config) kwargs = {"MaxResults": 50} client = boto3.client("ssm", region_name=config.get("region", None)) while True: response = client.describe_instance_information(**kwargs) for i in response["InstanceInformationList"]: a: Agent = { "ID": i["InstanceId"], "Name": instances_names.get(i["InstanceId"], None), "PingStatus": i["PingStatus"], "Platform": f'{i["PlatformName"]} {i["PlatformVersion"]}', "AgentVersion": i["AgentVersion"], } yield a next_token = response.get("NextToken", None) if next_token: kwargs = {"NextToken": next_token} else: break
def over_provisioned(config: Config) -> List[Dict[str, Any]]: """Show recommendations for over-provisioned EC2 instances.""" def util(metric: UtilizationMetricTypeDef) -> str: return f'{metric["name"]} {metric["statistic"][:3]} {metric["value"]}' instances_uptime = describe_instances_uptime(config) client = boto3.client("compute-optimizer", region_name=config.get("region", None)) response = client.get_ec2_instance_recommendations( filters=[{ "name": "Finding", "values": ["Overprovisioned"] }]) recs = [{ "ID": i["instanceArn"].split("/")[1], "Name": i.get("instanceName", None), "Instance Type": i["currentInstanceType"], "Recommendation": i["recommendationOptions"][0]["instanceType"], "Utilization": util(i["utilizationMetrics"][0]), "Uptime": instances_uptime[i["instanceArn"].split("/")[1]], } for i in response["instanceRecommendations"]] return recs
def describe_instances_names(config: Config) -> Dict[str, Optional[str]]: """List EC2 instance names in the region.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) response = ec2_client.describe_instances() return {i["InstanceId"]: util_tags.get_value(i, "Name") for r in response["Reservations"] for i in r["Instances"]}
def delete(config: Config, ami: str) -> None: """Deregister an AMI and delete its snapshot.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) response = describe(config, ami, show_snapshot_id=True) ec2_client.deregister_image(ImageId=ami) ec2_client.delete_snapshot(SnapshotId=response[0]["SnapshotId"])
def fetch_instance_id(config: Config, name: str) -> str: if name.startswith("i-"): return name ec2_client = boto3.client("ec2", region_name=config.get("region", None)) response = ec2_client.describe_instances(Filters=[{"Name": "tag:Name", "Values": [name]}]) try: return response["Reservations"][0]["Instances"][0]["InstanceId"] except IndexError: raise ValueError(f"No instance named {name}")
def templates(config: Config) -> List[Dict[str, Any]]: """Describe launch templates.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) response = ec2_client.describe_launch_templates() return [{ "Name": t["LaunchTemplateName"], "Default Version": t["DefaultVersionNumber"] } for t in response["LaunchTemplates"]]
def create_key_pair(config: Config, key_name: str, file_path: str) -> str: """Create a key pair.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) path = os.path.expanduser(file_path) with open(path, "x") as file: key = ec2_client.create_key_pair(KeyName=key_name) file.write(key["KeyMaterial"]) os.chmod(path, 0o600) return f"Created key pair {key_name} and saved private key to {path}"
def describe( config: Config, name: Optional[str] = None, name_match: Optional[str] = None, include_terminated: bool = False, show_running_only: bool = False, sort_by: str = "State,Name", columns: Optional[str] = None, ) -> List[Instance]: """List EC2 instances in the region.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) response = ec2_client.describe_instances(Filters=filters(name, name_match)) # print(response["Reservations"][0]["Instances"][0]) cols = columns.split(",") if columns else [ "InstanceId", "State", "Name", "Type", "DnsName" ] # don't sort by cols we aren't showing sort_cols = [sc for sc in sort_by.split(",") if sc in cols] instances: List[Instance] = [] for r in response["Reservations"]: for i in r["Instances"]: if (include_terminated or i["State"]["Name"] != "terminated") and ( not show_running_only or i["State"]["Name"] in ["pending", "running"]): desc: Instance = {} for col in cols: if col == "State": desc[col] = i["State"]["Name"] elif col == "Name": desc[col] = util_tags.get_value(i, "Name") elif col == "Type": desc[col] = i["InstanceType"] elif col == "DnsName": desc[col] = i["PublicDnsName"] if i.get( "PublicDnsName", None) != "" else i["PrivateDnsName"] else: desc[col] = i[col] instances.append(desc) return sorted( instances, key=lambda i: "".join(str(i[field]) for field in sort_cols), )
def share(config: Config, ami: str, account: str) -> None: """Share an AMI with another account.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) ec2_client.modify_image_attribute( ImageId=ami, LaunchPermission={"Add": [{"UserId": account}]}, OperationType="add", UserIds=[account], Value="string", DryRun=False, )
def describe_instances_uptime(config: Config) -> Dict[str, str]: """List EC2 instance uptimes in the region.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) response = ec2_client.describe_instances() instances = { i["InstanceId"]: difference_in_words(datetime.now(pytz.utc), i["LaunchTime"]) for r in response["Reservations"] for i in r["Instances"] } return instances
def logs(config: Config, name: str) -> str: """Show the system logs.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) instances = describe(config, name) if not instances: raise NoInstancesError(name=name) instance_id = instances[0]["InstanceId"] response = ec2_client.get_console_output(InstanceId=instance_id) return response.get("Output", "No logs yet 😔")
def output(config: Config, command_id: str, instance_id: str, stderr: bool) -> None: """Fetch output of a command from S3.""" ssm_client = boto3.client("ssm", region_name=config.get("region", None)) command = ssm_client.list_commands(CommandId=command_id)["Commands"][0] if not command.get("OutputS3BucketName", None): raise ValueError("No OutputS3BucketName") bucket = command["OutputS3BucketName"] try: doc_path = DOC_PATHS[command["DocumentName"]] except KeyError: raise NotImplementedError( f"for {command['DocumentName']}. Run aws s3 ls {command['OutputS3KeyPrefix']}/{command_id}/{instance_id}/awsrunShellScript/" ) std = "stderr" if stderr else "stdout" key = f"{command['OutputS3KeyPrefix']}/{command_id}/{instance_id}/awsrunShellScript/{doc_path}/{std}" s3_client = boto3.client("s3", region_name=config.get("region", None)) try: response = s3_client.get_object(Bucket=bucket, Key=key) except ClientError as e: if e.response["Error"]["Code"] == "NoSuchKey": raise KeyError(f"s3://{bucket}/{key} does not exist") else: raise e # converts body bytes to string lines streaming_body = cast(IO[bytes], response["Body"]) for line in codecs.getreader("utf-8")(streaming_body): print(line, end="") return None
def terminate(config: Config, name: str) -> List[Dict[str, Any]]: """Terminate EC2 instance.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) instances = describe(config, name) if not instances: raise NoInstancesError(name=name) response = ec2_client.terminate_instances( InstanceIds=[instance["InstanceId"] for instance in instances]) return [{ "State": i["CurrentState"]["Name"], "InstanceId": i["InstanceId"] } for i in response["TerminatingInstances"]]
def modify(config: Config, name: str, type: str) -> List[Instance]: """Change an instance's type.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) instances = describe(config, name) if not instances: raise NoInstancesError(name=name) instance_id = instances[0]["InstanceId"] ec2_client.modify_instance_attribute(InstanceId=instance_id, InstanceType={"Value": type}) ec2_client.modify_instance_attribute( InstanceId=instance_id, EbsOptimized={"Value": is_ebs_optimizable(type)}) return describe(config, name)
def start(config: Config, name: str) -> List[Instance]: """Start EC2 instance.""" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) print(f"Starting instances with the name {name} ... ") instances = describe(config, name) if not instances: raise NoInstancesError(name=name) instance_ids = [instance["InstanceId"] for instance in instances] ec2_client.start_instances(InstanceIds=instance_ids) waiter = ec2_client.get_waiter("instance_running") waiter.wait(InstanceIds=instance_ids) return describe(config, name)
def patch( config: Config, operation: Literal["scan", "install"], names: List[str], no_reboot: bool ) -> List[Dict[str, Optional[str]]]: """Scan or install AWS patch baseline.""" instance_ids = fetch_instance_ids(config, names) client = boto3.client("ssm", region_name=config.get("region", None)) kwargs: Dict[str, Any] = { "DocumentName": "AWS-RunPatchBaseline", "InstanceIds": instance_ids, } if operation == "scan": kwargs["Parameters"] = {"Operation": ["Scan"], "SnapshotId": [str(uuid.uuid4())]} else: kwargs["Parameters"] = { "Operation": ["Install"], "RebootOption": ["NoReboot" if no_reboot else "RebootIfNeeded"], "SnapshotId": [str(uuid.uuid4())], } try: kwargs["OutputS3BucketName"] = config["ssm"]["s3bucket"] kwargs["OutputS3KeyPrefix"] = config["ssm"]["s3prefix"] except KeyError: pass response = client.send_command(**kwargs) return [ { "CommandId": response["Command"]["CommandId"], "InstanceId": i, "Status": response["Command"]["Status"], "Document": response["Command"]["DocumentName"], "Output": f"s3://{response['Command']['OutputS3BucketName']}/{response['Command']['OutputS3KeyPrefix']}" if response["Command"].get("OutputS3BucketName", None) else None, } for i in response["Command"]["InstanceIds"] ]
def compliance_summary(config: Config) -> List[Dict[str, Any]]: """Compliance summary for running instances that have run the patch baseline.""" instances_names = describe_instances_names(config) client = boto3.client("ssm", region_name=config.get("region", None)) response = client.list_resource_compliance_summaries( Filters=[{"Key": "ComplianceType", "Values": ["Patch"], "Type": "EQUAL"}] ) return [ { "InstanceId": i["ResourceId"], "Name": instances_names.get(i["ResourceId"], None), "Status": i["Status"], "NonCompliantCount": i["NonCompliantSummary"]["NonCompliantCount"], "Last operation time": i["ExecutionSummary"]["ExecutionTime"], } for i in response["ResourceComplianceSummaryItems"] ]
def run(config: Config, names: List[str]) -> List[Dict[str, Optional[str]]]: """ Run a shell script on instance(s). Script is read from stdin. """ instance_ids = fetch_instance_ids(config, names) client = boto3.client("ssm", region_name=config.get("region", None)) script = sys.stdin.readlines() kwargs: Dict[str, Any] = { "DocumentName": "AWS-RunShellScript", "InstanceIds": instance_ids, "Parameters": {"commands": script}, } try: kwargs["OutputS3BucketName"] = config["ssm"]["s3bucket"] kwargs["OutputS3KeyPrefix"] = config["ssm"]["s3prefix"] except KeyError: pass response = client.send_command(**kwargs) return [ { "CommandId": response["Command"]["CommandId"], "InstanceId": i, "Status": response["Command"]["Status"], "Document": response["Command"]["DocumentName"], "Output": f"s3://{response['Command']['OutputS3BucketName']}/{response['Command']['OutputS3KeyPrefix']}" if response["Command"].get("OutputS3BucketName", None) else None, } for i in response["Command"]["InstanceIds"] ]
def invocations(config: Config, command_id: str) -> List[Dict[str, Any]]: """List invocations of a command across instances.""" client = boto3.client("ssm", region_name=config.get("region", None)) command = client.list_commands(CommandId=command_id)["Commands"][0] invocations = client.list_command_invocations(CommandId=command_id) instances_names = describe_instances_names(config) return [ { "RequestedDateTime": i["RequestedDateTime"].strftime("%Y-%m-%d %H:%M"), "InstanceId": i["InstanceId"], "Name": instances_names.get(i["InstanceId"], None), "Status": i["Status"], "DocumentName": i["DocumentName"], "Parameters": json.dumps(command["Parameters"]), "Output": f"s3://{command['OutputS3BucketName']}/{command['OutputS3KeyPrefix']}" if command.get("OutputS3BucketName", None) else None, } for i in invocations["CommandInvocations"] ]
def patch_summary(config: Config) -> Iterator[Dict[str, Any]]: """Patch summary for all instances that have run the patch baseline.""" instances_names = describe_instances_names(config) instance_ids = list(instances_names.keys()) client = boto3.client("ssm", region_name=config.get("region", None)) max_at_a_time = 50 for i in range(0, len(instance_ids), max_at_a_time): chunk = instance_ids[i : i + max_at_a_time] response = client.describe_instance_patch_states(InstanceIds=chunk) for i in response["InstancePatchStates"]: yield { "InstanceId": i["InstanceId"], "Name": instances_names.get(i["InstanceId"], None), "Needed": i["MissingCount"], "Pending Reboot": i["InstalledPendingRebootCount"], "Errored": i["FailedCount"], "Rejected": i["InstalledRejectedCount"], "Last operation time": i["OperationEndTime"], "Last operation": i["Operation"], }
def commands(config: Config, name: Optional[str]) -> List[Dict[str, Optional[str]]]: """List commands by instance.""" client = boto3.client("ssm", region_name=config.get("region", None)) instances_names = describe_instances_names(config) if name: instance_id = fetch_instance_id(config, name) response = client.list_commands(InstanceId=instance_id) else: response = client.list_commands() return [ { "RequestedDateTime": c["RequestedDateTime"].strftime("%Y-%m-%d %H:%M"), "CommandId": c["CommandId"], "InstanceIds": ",".join(c["InstanceIds"]), "Names": ",".join([instances_names.get(i, None) or "" for i in c["InstanceIds"]]), "Status": c["Status"], "DocumentName": c["DocumentName"], "Operation": first(c["Parameters"].get("Operation", None)), } for c in response["Commands"] ]
def launch( config: Config, name: str, ami: Optional[str] = None, template: Optional[str] = None, volume_size: Optional[int] = None, encrypted: bool = True, instance_type: Optional[str] = None, key_name: Optional[str] = None, userdata: Optional[str] = None, ) -> List[Instance]: """Launch a tagged EC2 instance with an EBS volume.""" if not (template or ami): raise ValueError("Please specify either an ami or a launch template") if not template and not instance_type: # if no instance type is provided set one instance_type = "t3.small" ec2_client = boto3.client("ec2", region_name=config.get("region", None)) runargs: RunArgs = { "MaxCount": 1, "MinCount": 1, } desc = "" if template: runargs["LaunchTemplate"] = {"LaunchTemplateName": template} desc = f"template {template} " if ami: image = ami_cmd.fetch(config, ami) if not image["RootDeviceName"]: raise ValueError(f"{image['ImageId']} is missing RootDeviceName") volume_size = volume_size or config.get("volume_size", None) or image["Size"] if not volume_size: raise ValueError("No volume size") device: BlockDeviceMappingTypeDef = { "DeviceName": image["RootDeviceName"], "Ebs": { "VolumeSize": volume_size, "DeleteOnTermination": True, "VolumeType": "gp3", "Encrypted": encrypted, }, } kms_key_id = config.get("kms_key_id", None) if kms_key_id: device["Ebs"]["KmsKeyId"] = kms_key_id runargs["ImageId"] = image["ImageId"] runargs["BlockDeviceMappings"] = [device] desc = desc + image["Name"] if image[ "Name"] else desc + image["ImageId"] key = key_name or config.get("key_name", None) if key: runargs["KeyName"] = key elif not template: raise HandledError("Please provide a key name") if instance_type: runargs["InstanceType"] = cast("InstanceTypeType", instance_type) runargs["EbsOptimized"] = is_ebs_optimizable(instance_type) tags: List[TagTypeDef] = [{"Key": "Name", "Value": name}] additional_tags = config.get("additional_tags", {}) if additional_tags: tags.extend([{ "Key": k, "Value": v } for k, v in additional_tags.items()]) runargs["TagSpecifications"] = [ { "ResourceType": "instance", "Tags": tags }, { "ResourceType": "volume", "Tags": tags }, ] if config.get("vpc", None): # TODO: support multiple subnets security_group = config["vpc"]["security_group"] runargs["NetworkInterfaces"] = [{ "DeviceIndex": 0, "Description": "Primary network interface", "DeleteOnTermination": True, "SubnetId": config["vpc"]["subnet"], "Ipv6AddressCount": 0, "Groups": security_group if isinstance(security_group, list) else [security_group], }] associate_public_ip_address = config["vpc"].get( "associate_public_ip_address", None) if associate_public_ip_address is not None: runargs["NetworkInterfaces"][0][ "AssociatePublicIpAddress"] = associate_public_ip_address vpc_name = f" vpc {config['vpc']['name']}" else: vpc_name = "" iam_instance_profile_arn = config.get("iam_instance_profile_arn", None) if iam_instance_profile_arn: runargs["IamInstanceProfile"] = {"Arn": iam_instance_profile_arn} if userdata: runargs["UserData"] = read_file(userdata) # use IMDSv2 to prevent SSRF runargs["MetadataOptions"] = {"HttpTokens": "required"} region_name = ec2_client.meta.region_name print( f"Launching a {instance_type} in {region_name}{vpc_name} named {name} using {desc} ... " ) response = ec2_client.run_instances(**runargs) instance = response["Instances"][0] waiter = ec2_client.get_waiter("instance_running") waiter.wait(InstanceIds=[instance["InstanceId"]]) # TODO: wait until instance checks passed (as they do in the console) # the response from run_instances above always contains an empty string # for PublicDnsName, so we call describe to get it return describe(config=config, name=name)