diff --git a/.darglint b/.darglint deleted file mode 100644 index 761c3ea8..00000000 --- a/.darglint +++ /dev/null @@ -1,4 +0,0 @@ -[darglint] -strictness=long -docstring_style=sphinx -message_template={path}:{line} {msg_id}: {obj} {msg} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ef53b69a..f50c1949 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -13,6 +13,7 @@ repos: rev: v1.13.23 hooks: - id: typos + stages: [push] # vscode & the cli uses these too so they has been installed into the virtualenv - repo: local hooks: diff --git a/docs/ec2.md b/docs/ec2.md index a36c369a..9d2d13be 100644 --- a/docs/ec2.md +++ b/docs/ec2.md @@ -14,6 +14,7 @@ Run `aec ec2 -h` for help: from aec.main import build_parser cog.out(f"```\n{build_parser()._subparsers._actions[1].choices['ec2'].format_help()}```") ]]] --> + ``` usage: aec ec2 [-h] {create-key-pair,describe,launch,logs,modify,start,stop,tag,tags,status,templates,terminate,user-data} ... @@ -36,6 +37,7 @@ subcommands: terminate Terminate EC2 instance. user-data Describe user data for an instance. ``` + Launch an instance named `food baby` from the [ec2 launch template](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-launch-templates.html) named `yummy`: @@ -73,14 +75,16 @@ List all instances in the region: + ``` aec ec2 describe - InstanceId State Name Type DnsName LaunchTime ImageId + InstanceId State Name Type DnsName LaunchTime ImageId ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - i-b5f2c2719ad4a4204 running alice t3.small ec2-54-214-90-201.compute-1.amazonaws.com 2023-03-15 23:25:13+00:00 ami-03cf127a + i-b5f2c2719ad4a4204 running alice t3.small ec2-54-214-90-201.compute-1.amazonaws.com 2023-03-15 23:25:13+00:00 ami-03cf127a i-17227103cbf97cb86 running sam t3.small ec2-54-214-204-133.compute-1.amazonaws.com 2023-03-15 23:25:14+00:00 ami-03cf127a ``` + List instances containing `gaga` in the name: @@ -107,10 +111,10 @@ Show running instances sorted by date started (ie: LaunchTime), oldest first: aec ec2 describe -r -s LaunchTime ``` -Show a custom set of columns +Show a custom set of [columns](#columns) ``` -aec ec2 describe -c SubnetId,Name +aec ec2 describe -c Name,SubnetId,Volumes ``` Show instances and all their tags: @@ -187,3 +191,59 @@ aec ec2 describe -it i-02a840e0ca609c432 -c StateReason ───────────────────────────────────────────────────────────────────────────────────────────── {'Code': 'Client.InternalError', 'Message': 'Client.InternalError: Client error on launch'} ``` + +## Columns + +Columns special to aec: + +- `DnsName` - PublicDnsName if available otherwise PrivateDnsName +- `Name` - Name tag +- `State` - state name +- `Type` - instance type +- `Volumes` - volumes attached to the instance + +Columns returned by the EC2 API: + +``` +AmiLaunchIndex +Architecture +BlockDeviceMappings +CapacityReservationSpecification +ClientToken +CpuOptions +CurrentInstanceBootMode +EbsOptimized +EnaSupport +EnclaveOptions +HibernationOptions +Hypervisor +IamInstanceProfile +ImageId +InstanceId +InstanceType +KeyName +LaunchTime +MaintenanceOptions +MetadataOptions +Monitoring +NetworkInterfaces +Placement +PlatformDetails +PrivateDnsName +PrivateDnsNameOptions +PrivateIpAddress +ProductCodes +PublicDnsName +RootDeviceName +RootDeviceType +SecurityGroups +SourceDestCheck +State +StateTransitionReason +SubnetId +Tags +UsageOperation +UsageOperationUpdateTime +VirtualizationType +VpcId +``` diff --git a/src/aec/command/ec2.py b/src/aec/command/ec2.py index e8dc850b..cba97108 100755 --- a/src/aec/command/ec2.py +++ b/src/aec/command/ec2.py @@ -3,6 +3,7 @@ import base64 import os import os.path +from collections import defaultdict from time import sleep from typing import TYPE_CHECKING, Any, Dict, List, Optional, Sequence, cast @@ -17,6 +18,7 @@ from mypy_boto3_ec2.literals import InstanceTypeType from mypy_boto3_ec2.type_defs import ( BlockDeviceMappingTypeDef, + DescribeVolumesResultTypeDef, FilterTypeDef, InstanceStatusSummaryTypeDef, TagSpecificationTypeDef, @@ -40,6 +42,7 @@ class Instance(TypedDict, total=False): Type: str DnsName: str SubnetId: str + Volumes: List[str] def launch( @@ -222,15 +225,27 @@ def describe( kwargs: Dict[str, Any] = {"MaxResults": 1000, "Filters": filters} - response = ec2_client.describe_instances(**kwargs) - - # import json; print(json.dumps(response)) + response_fut = executor.submit(ec2_client.describe_instances, **kwargs) cols = columns.split(",") # don't sort by cols we aren't showing sort_cols = [sc for sc in sort_by.split(",") if sc in cols] + if "Volumes" in columns: + # fetch volume info + volumes_response: DescribeVolumesResultTypeDef = executor.submit(ec2_client.describe_volumes).result() + volumes: Dict[str, List[str]] = defaultdict(list) + for v in volumes_response["Volumes"]: + for a in v["Attachments"]: + volumes[a["InstanceId"]].append(f'Size={v["Size"]} GiB') + else: + volumes = {} + + response = response_fut.result() + + # import json; print(json.dumps(response)) + instances: List[Instance] = [] while True: for r in response["Reservations"]: @@ -246,9 +261,9 @@ def describe( elif col == "Type": desc[col] = i["InstanceType"] elif col == "DnsName": - desc[col] = ( - i["PublicDnsName"] if i.get("PublicDnsName", None) != "" else i["PrivateDnsName"] - ) + desc[col] = i["PublicDnsName"] or i["PrivateDnsName"] + elif col == "Volumes": + desc[col] = volumes.get(i["InstanceId"], []) else: desc[col] = i.get(col, None) diff --git a/tests/test_display.py b/tests/test_display.py index 37deee47..4bd572d9 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -31,6 +31,16 @@ def test_as_table_with_datetime(): ] +def test_as_table_with_list(): + assert as_table( + [{"a": 1, "b": ["x", "y"]}], + ["a", "b"], + ) == [ + ["a", "b"], + ["1", "['x', 'y']"], + ] + + def test_as_table_with_none(): assert as_table([{"a": 1, "b": None}]) == [["a", "b"], ["1", None]] diff --git a/tests/test_ec2.py b/tests/test_ec2.py index 86ff5986..a5602f13 100644 --- a/tests/test_ec2.py +++ b/tests/test_ec2.py @@ -177,15 +177,6 @@ def test_describe_instance_without_tags(mock_aws_config: Config): assert len(instances) == 1 -def test_tag(mock_aws_config: Config): - launch(mock_aws_config, "alice", ami_id) - - instances = tag(mock_aws_config, ["Project=top secret"], "alice") - - assert len(instances) == 1 - assert instances[0]["Tag: Project"] == "top secret" - - def test_describe_by_name(mock_aws_config: Config): launch(mock_aws_config, "alice", ami_id) launch(mock_aws_config, "alex", ami_id) @@ -260,7 +251,7 @@ def test_describe_columns(mock_aws_config: Config): del mock_aws_config["key_name"] launch(mock_aws_config, "alice", ami_id) - instances = describe(config=mock_aws_config, columns="SubnetId,Name,MissingKey") + instances = describe(config=mock_aws_config, columns="SubnetId,Name,MissingKey,Volumes") print(instances) assert len(instances) == 2 @@ -268,6 +259,8 @@ def test_describe_columns(mock_aws_config: Config): assert instances[1]["Name"] == "sam" assert "subnet" in instances[0]["SubnetId"] assert "subnet" in instances[1]["SubnetId"] + assert instances[0]["Volumes"] == ["Size=15 GiB"] + assert instances[1]["Volumes"] == ["Size=15 GiB"] # MissingKey will appear without values assert instances[0]["MissingKey"] is None # type: ignore @@ -280,6 +273,15 @@ def describe_instance0(region_name: str, instance_id: str): return instances["Reservations"][0]["Instances"][0] +def test_tag(mock_aws_config: Config): + launch(mock_aws_config, "alice", ami_id) + + instances = tag(mock_aws_config, ["Project=top secret"], "alice") + + assert len(instances) == 1 + assert instances[0]["Tag: Project"] == "top secret" + + def test_tags(mock_aws_config: Config): mock_aws_config["additional_tags"] = {"Owner": "alice@testlab.io", "Project": "top secret"} launch(mock_aws_config, "alice", ami_id)