diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 8062fc56b..d000b2826 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -1745,7 +1745,7 @@ - [X] accept_vpc_peering_connection - [ ] advertise_byoip_cidr - [X] allocate_address -- [ ] allocate_hosts +- [X] allocate_hosts - [ ] allocate_ipam_pool_cidr - [ ] apply_security_groups_to_client_vpn_target_network - [X] assign_ipv6_addresses @@ -1982,7 +1982,7 @@ - [ ] describe_fpga_images - [ ] describe_host_reservation_offerings - [ ] describe_host_reservations -- [ ] describe_hosts +- [X] describe_hosts - [X] describe_iam_instance_profile_associations - [ ] describe_id_format - [ ] describe_identity_id_format @@ -2193,7 +2193,7 @@ - [ ] modify_ebs_default_kms_key_id - [ ] modify_fleet - [ ] modify_fpga_image_attribute -- [ ] modify_hosts +- [X] modify_hosts - [ ] modify_id_format - [ ] modify_identity_id_format - [ ] modify_image_attribute @@ -2267,7 +2267,7 @@ - [ ] reject_vpc_endpoint_connections - [X] reject_vpc_peering_connection - [X] release_address -- [ ] release_hosts +- [X] release_hosts - [ ] release_ipam_pool_allocation - [X] replace_iam_instance_profile_association - [X] replace_network_acl_association diff --git a/docs/docs/services/ec2.rst b/docs/docs/services/ec2.rst index 2f9b1f1d4..5cad2534d 100644 --- a/docs/docs/services/ec2.rst +++ b/docs/docs/services/ec2.rst @@ -36,7 +36,7 @@ ec2 - [X] accept_vpc_peering_connection - [ ] advertise_byoip_cidr - [X] allocate_address -- [ ] allocate_hosts +- [X] allocate_hosts - [ ] allocate_ipam_pool_cidr - [ ] apply_security_groups_to_client_vpn_target_network - [X] assign_ipv6_addresses @@ -281,7 +281,11 @@ ec2 - [ ] describe_fpga_images - [ ] describe_host_reservation_offerings - [ ] describe_host_reservations -- [ ] describe_hosts +- [X] describe_hosts + + Pagination is not yet implemented + + - [X] describe_iam_instance_profile_associations - [ ] describe_id_format - [ ] describe_identity_id_format @@ -511,7 +515,7 @@ ec2 - [ ] modify_ebs_default_kms_key_id - [ ] modify_fleet - [ ] modify_fpga_image_attribute -- [ ] modify_hosts +- [X] modify_hosts - [ ] modify_id_format - [ ] modify_identity_id_format - [ ] modify_image_attribute @@ -589,7 +593,7 @@ ec2 - [ ] reject_vpc_endpoint_connections - [X] reject_vpc_peering_connection - [X] release_address -- [ ] release_hosts +- [X] release_hosts - [ ] release_ipam_pool_allocation - [X] replace_iam_instance_profile_association - [X] replace_network_acl_association diff --git a/moto/ec2/models/__init__.py b/moto/ec2/models/__init__.py index 05c64453a..29a39a74f 100644 --- a/moto/ec2/models/__init__.py +++ b/moto/ec2/models/__init__.py @@ -12,6 +12,7 @@ from .dhcp_options import DHCPOptionsSetBackend from .elastic_block_store import EBSBackend from .elastic_ip_addresses import ElasticAddressBackend from .elastic_network_interfaces import NetworkInterfaceBackend +from .hosts import HostsBackend from .fleets import FleetsBackend from .flow_logs import FlowLogsBackend from .key_pairs import KeyPairBackend @@ -124,6 +125,7 @@ class EC2Backend( CarrierGatewayBackend, FleetsBackend, WindowsBackend, + HostsBackend, ): """ moto includes a limited set of AMIs in `moto/ec2/resources/amis.json`. diff --git a/moto/ec2/models/hosts.py b/moto/ec2/models/hosts.py new file mode 100644 index 000000000..32aacfeb9 --- /dev/null +++ b/moto/ec2/models/hosts.py @@ -0,0 +1,102 @@ +from .core import TaggedEC2Resource +from ..utils import generic_filter, random_dedicated_host_id +from typing import Any, Dict, List + + +class Host(TaggedEC2Resource): + def __init__( + self, + host_recovery: str, + zone: str, + instance_type: str, + instance_family: str, + auto_placement: str, + backend: Any, + ): + self.id = random_dedicated_host_id() + self.state = "available" + self.host_recovery = host_recovery or "off" + self.zone = zone + self.instance_type = instance_type + self.instance_family = instance_family + self.auto_placement = auto_placement or "on" + self.ec2_backend = backend + + def release(self) -> None: + self.state = "released" + + def get_filter_value(self, key): + if key == "availability-zone": + return self.zone + if key == "state": + return self.state + if key == "tag-key": + return [t["key"] for t in self.get_tags()] + if key == "instance-type": + return self.instance_type + return None + + +class HostsBackend: + def __init__(self): + self.hosts = {} + + def allocate_hosts( + self, + quantity: int, + host_recovery: str, + zone: str, + instance_type: str, + instance_family: str, + auto_placement: str, + tags: Dict[str, str], + ) -> List[str]: + hosts = [ + Host( + host_recovery, + zone, + instance_type, + instance_family, + auto_placement, + self, + ) + for _ in range(quantity) + ] + for host in hosts: + self.hosts[host.id] = host + if tags: + host.add_tags(tags) + return [host.id for host in hosts] + + def describe_hosts( + self, host_ids: List[str], filters: Dict[str, Any] + ) -> List[Host]: + """ + Pagination is not yet implemented + """ + results = self.hosts.values() + if host_ids: + results = [r for r in results if r.id in host_ids] + if filters: + results = generic_filter(filters, results) + return results + + def modify_hosts( + self, host_ids, auto_placement, host_recovery, instance_type, instance_family + ): + for _id in host_ids: + host = self.hosts[_id] + if auto_placement is not None: + host.auto_placement = auto_placement + if host_recovery is not None: + host.host_recovery = host_recovery + if instance_type is not None: + host.instance_type = instance_type + host.instance_family = None + if instance_family is not None: + host.instance_family = instance_family + host.instance_type = None + + def release_hosts(self, host_ids: List[str]) -> None: + for host_id in host_ids: + self.hosts[host_id].release() diff --git a/moto/ec2/responses/__init__.py b/moto/ec2/responses/__init__.py index bdee5ce03..fcbb99ba6 100644 --- a/moto/ec2/responses/__init__.py +++ b/moto/ec2/responses/__init__.py @@ -9,6 +9,7 @@ from .elastic_ip_addresses import ElasticIPAddresses from .elastic_network_interfaces import ElasticNetworkInterfaces from .fleets import Fleets from .general import General +from .hosts import HostsResponse from .instances import InstanceResponse from .internet_gateways import InternetGateways from .egress_only_internet_gateways import EgressOnlyInternetGateway @@ -55,6 +56,7 @@ class EC2Response( ElasticNetworkInterfaces, Fleets, General, + HostsResponse, InstanceResponse, InternetGateways, EgressOnlyInternetGateway, diff --git a/moto/ec2/responses/hosts.py b/moto/ec2/responses/hosts.py new file mode 100644 index 000000000..daf0d7c29 --- /dev/null +++ b/moto/ec2/responses/hosts.py @@ -0,0 +1,118 @@ +from ._base_response import EC2BaseResponse + + +class HostsResponse(EC2BaseResponse): + def allocate_hosts(self): + params = self._get_params() + quantity = int(params.get("Quantity")) + host_recovery = params.get("HostRecovery") + zone = params.get("AvailabilityZone") + instance_type = params.get("InstanceType") + instance_family = params.get("InstanceFamily") + auto_placement = params.get("AutoPlacement") + tags = self._parse_tag_specification() + host_tags = tags.get("dedicated-host", {}) + host_ids = self.ec2_backend.allocate_hosts( + quantity, + host_recovery, + zone, + instance_type, + instance_family, + auto_placement, + host_tags, + ) + template = self.response_template(EC2_ALLOCATE_HOSTS) + return template.render(host_ids=host_ids) + + def describe_hosts(self): + host_ids = list(self._get_params().get("HostId", {}).values()) + filters = self._filters_from_querystring() + hosts = self.ec2_backend.describe_hosts(host_ids, filters) + template = self.response_template(EC2_DESCRIBE_HOSTS) + return template.render(account_id=self.current_account, hosts=hosts) + + def modify_hosts(self): + params = self._get_params() + host_ids = list(self._get_params().get("HostId", {}).values()) + auto_placement = params.get("AutoPlacement") + host_recovery = params.get("HostRecovery") + instance_type = params.get("InstanceType") + instance_family = params.get("InstanceFamily") + self.ec2_backend.modify_hosts( + host_ids, auto_placement, host_recovery, instance_type, instance_family + ) + template = self.response_template(EC2_MODIFY_HOSTS) + return template.render(host_ids=host_ids) + + def release_hosts(self): + host_ids = list(self._get_params().get("HostId", {}).values()) + self.ec2_backend.release_hosts(host_ids) + template = self.response_template(EC2_RELEASE_HOSTS) + return template.render(host_ids=host_ids) + + +EC2_ALLOCATE_HOSTS = """ +fdcdcab1-ae5c-489e-9c33-4637c5dda355 + + {% for host_id in host_ids %} + {{ host_id }} + {% endfor %} + +""" + + +EC2_DESCRIBE_HOSTS = """ +fdcdcab1-ae5c-489e-9c33-4637c5dda355 + + {% for host in hosts %} + + {{ host.auto_placement }} + {{ host.zone }} + + {{ host.id }} + {{ host.state }} + + {% if host.instance_type %} + {{ host.instance_type }} + {% endif %} + {% if host.instance_family %} + {{ host.instance_family }} + {% endif %} + + reserv_id + + + {{ account_id }} + {{ host.host_recovery }} + + {% for tag in host.get_tags() %} + + {{ tag.key }} + {{ tag.value }} + + {% endfor %} + + + {% endfor %} + +""" + + +EC2_MODIFY_HOSTS = """ +fdcdcab1-ae5c-489e-9c33-4637c5dda355 + + {% for host_id in host_ids %} + {{ host_id }} + {% endfor %} + +""" + + +EC2_RELEASE_HOSTS = """ +fdcdcab1-ae5c-489e-9c33-4637c5dda355 + + {% for host_id in host_ids %} + {{ host_id }} + {% endfor %} + +""" diff --git a/moto/ec2/utils.py b/moto/ec2/utils.py index 610a3c248..e081708a1 100644 --- a/moto/ec2/utils.py +++ b/moto/ec2/utils.py @@ -17,6 +17,7 @@ EC2_RESOURCE_TO_PREFIX = { "transit-gateway": "tgw", "transit-gateway-route-table": "tgw-rtb", "transit-gateway-attachment": "tgw-attach", + "dedicated_host": "h", "dhcp-options": "dopt", "fleet": "fleet", "flow-logs": "fl", @@ -238,6 +239,10 @@ def random_public_ip(): return f"54.214.{random.choice(range(255))}.{random.choice(range(255))}" +def random_dedicated_host_id(): + return random_id(prefix=EC2_RESOURCE_TO_PREFIX["dedicated_host"], size=17) + + def random_private_ip(cidr=None, ipv6=False): # prefix - ula.prefixlen : get number of remaing length for the IP. # prefix will be 32 for IPv4 and 128 for IPv6. diff --git a/tests/terraformtests/terraform-tests.success.txt b/tests/terraformtests/terraform-tests.success.txt index 85145b503..28e16c2ce 100644 --- a/tests/terraformtests/terraform-tests.success.txt +++ b/tests/terraformtests/terraform-tests.success.txt @@ -118,6 +118,11 @@ dynamodb: ec2: - TestAccEC2AvailabilityZonesDataSource_ - TestAccEC2CarrierGateway_ + - TestAccEC2HostDataSource_ + - TestAccEC2Host_basic + - TestAccEC2Host_disappears + - TestAccEC2Host_instanceFamily + - TestAccEC2Host_tags - TestAccEC2InstanceTypeOfferingDataSource_ - TestAccEC2InstanceTypeOfferingsDataSource_ - TestAccEC2RouteTableAssociation_ diff --git a/tests/test_ec2/test_hosts.py b/tests/test_ec2/test_hosts.py new file mode 100644 index 000000000..bc8237060 --- /dev/null +++ b/tests/test_ec2/test_hosts.py @@ -0,0 +1,168 @@ +import boto3 + +from moto import mock_ec2 +from uuid import uuid4 + + +@mock_ec2 +def test_allocate_hosts(): + client = boto3.client("ec2", "us-west-1") + resp = client.allocate_hosts( + AvailabilityZone="us-west-1a", + InstanceType="a1.small", + HostRecovery="off", + AutoPlacement="on", + Quantity=3, + ) + resp["HostIds"].should.have.length_of(3) + + +@mock_ec2 +def test_describe_hosts_with_instancefamily(): + client = boto3.client("ec2", "us-west-1") + host_ids = client.allocate_hosts( + AvailabilityZone="us-west-1a", InstanceFamily="c5", Quantity=1 + )["HostIds"] + + host = client.describe_hosts(HostIds=host_ids)["Hosts"][0] + + host.should.have.key("HostProperties").should.have.key("InstanceFamily").equals( + "c5" + ) + + +@mock_ec2 +def test_describe_hosts(): + client = boto3.client("ec2", "us-west-1") + host_ids = client.allocate_hosts( + AvailabilityZone="us-west-1c", + InstanceType="a1.large", + HostRecovery="on", + AutoPlacement="off", + Quantity=2, + )["HostIds"] + + hosts = client.describe_hosts(HostIds=host_ids)["Hosts"] + hosts.should.have.length_of(2) + + hosts[0].should.have.key("State").equals("available") + hosts[0].should.have.key("AvailabilityZone").equals("us-west-1c") + hosts[0].should.have.key("HostRecovery").equals("on") + hosts[0].should.have.key("HostProperties").should.have.key("InstanceType").equals( + "a1.large" + ) + hosts[0].should.have.key("AutoPlacement").equals("off") + + +@mock_ec2 +def test_describe_hosts_with_tags(): + client = boto3.client("ec2", "us-west-1") + tagkey = str(uuid4()) + host_ids = client.allocate_hosts( + AvailabilityZone="us-west-1b", + InstanceType="b1.large", + Quantity=1, + TagSpecifications=[ + {"ResourceType": "dedicated-host", "Tags": [{"Key": tagkey, "Value": "v1"}]} + ], + )["HostIds"] + + host = client.describe_hosts(HostIds=host_ids)["Hosts"][0] + host.should.have.key("Tags").equals([{"Key": tagkey, "Value": "v1"}]) + + client.allocate_hosts( + AvailabilityZone="us-west-1a", InstanceType="b1.large", Quantity=1 + ) + hosts = client.describe_hosts(Filters=[{"Name": "tag-key", "Values": [tagkey]}])[ + "Hosts" + ] + hosts.should.have.length_of(1) + + +@mock_ec2 +def test_describe_hosts_using_filters(): + client = boto3.client("ec2", "us-west-1") + host_id1 = client.allocate_hosts( + AvailabilityZone="us-west-1a", InstanceType="b1.large", Quantity=1 + )["HostIds"][0] + host_id2 = client.allocate_hosts( + AvailabilityZone="us-west-1b", InstanceType="b1.large", Quantity=1 + )["HostIds"][0] + + hosts = client.describe_hosts( + Filters=[{"Name": "availability-zone", "Values": ["us-west-1b"]}] + )["Hosts"] + [h["HostId"] for h in hosts].should.contain(host_id2) + + hosts = client.describe_hosts( + Filters=[{"Name": "availability-zone", "Values": ["us-west-1d"]}] + )["Hosts"] + hosts.should.have.length_of(0) + + client.release_hosts(HostIds=[host_id1]) + hosts = client.describe_hosts(Filters=[{"Name": "state", "Values": ["released"]}])[ + "Hosts" + ] + [h["HostId"] for h in hosts].should.contain(host_id1) + + hosts = client.describe_hosts( + Filters=[{"Name": "state", "Values": ["under-assessment"]}] + )["Hosts"] + hosts.should.have.length_of(0) + + +@mock_ec2 +def test_modify_hosts(): + client = boto3.client("ec2", "us-west-1") + host_ids = client.allocate_hosts( + AvailabilityZone="us-west-1a", InstanceFamily="c5", Quantity=1 + )["HostIds"] + + client.modify_hosts( + HostIds=host_ids, + AutoPlacement="off", + HostRecovery="on", + InstanceType="c5.medium", + ) + + host = client.describe_hosts(HostIds=host_ids)["Hosts"][0] + + host.should.have.key("AutoPlacement").equals("off") + host.should.have.key("HostRecovery").equals("on") + host.should.have.key("HostProperties").shouldnt.have.key("InstanceFamily") + host.should.have.key("HostProperties").should.have.key("InstanceType").equals( + "c5.medium" + ) + + +@mock_ec2 +def test_release_hosts(): + client = boto3.client("ec2", "us-west-1") + host_ids = client.allocate_hosts( + AvailabilityZone="us-west-1a", + InstanceType="a1.small", + HostRecovery="off", + AutoPlacement="on", + Quantity=2, + )["HostIds"] + + resp = client.release_hosts(HostIds=[host_ids[0]]) + resp.should.have.key("Successful").equals([host_ids[0]]) + + host = client.describe_hosts(HostIds=[host_ids[0]])["Hosts"][0] + + host.should.have.key("State").equals("released") + + +@mock_ec2 +def test_add_tags_to_dedicated_hosts(): + client = boto3.client("ec2", "us-west-1") + resp = client.allocate_hosts( + AvailabilityZone="us-west-1a", InstanceType="a1.small", Quantity=1 + ) + host_id = resp["HostIds"][0] + + client.create_tags(Resources=[host_id], Tags=[{"Key": "k1", "Value": "v1"}]) + + host = client.describe_hosts(HostIds=[host_id])["Hosts"][0] + host.should.have.key("Tags").equals([{"Key": "k1", "Value": "v1"}])