diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 6bfe877b1..06a73bc4f 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -1597,7 +1597,7 @@ - [X] create_default_vpc - [X] create_dhcp_options - [X] create_egress_only_internet_gateway -- [ ] create_fleet +- [X] create_fleet - [X] create_flow_logs - [ ] create_fpga_image - [X] create_image @@ -1665,7 +1665,7 @@ - [X] delete_customer_gateway - [ ] delete_dhcp_options - [X] delete_egress_only_internet_gateway -- [ ] delete_fleets +- [X] delete_fleets - [X] delete_flow_logs - [ ] delete_fpga_image - [ ] delete_instance_event_window @@ -1757,8 +1757,8 @@ - [ ] describe_fast_launch_images - [ ] describe_fast_snapshot_restores - [ ] describe_fleet_history -- [ ] describe_fleet_instances -- [ ] describe_fleets +- [X] describe_fleet_instances +- [X] describe_fleets - [X] describe_flow_logs - [ ] describe_fpga_image_attribute - [ ] describe_fpga_images diff --git a/docs/docs/services/ec2.rst b/docs/docs/services/ec2.rst index 89052cfc7..65c5a6412 100644 --- a/docs/docs/services/ec2.rst +++ b/docs/docs/services/ec2.rst @@ -85,7 +85,7 @@ ec2 - [X] create_default_vpc - [X] create_dhcp_options - [X] create_egress_only_internet_gateway -- [ ] create_fleet +- [X] create_fleet - [X] create_flow_logs - [ ] create_fpga_image - [X] create_image @@ -157,7 +157,7 @@ ec2 - [X] delete_customer_gateway - [ ] delete_dhcp_options - [X] delete_egress_only_internet_gateway -- [ ] delete_fleets +- [X] delete_fleets - [X] delete_flow_logs - [ ] delete_fpga_image - [ ] delete_instance_event_window @@ -253,8 +253,8 @@ ec2 - [ ] describe_fast_launch_images - [ ] describe_fast_snapshot_restores - [ ] describe_fleet_history -- [ ] describe_fleet_instances -- [ ] describe_fleets +- [X] describe_fleet_instances +- [X] describe_fleets - [X] describe_flow_logs - [ ] describe_fpga_image_attribute - [ ] describe_fpga_images diff --git a/moto/ec2/models/__init__.py b/moto/ec2/models/__init__.py index bca0dc1b5..37e480ed9 100644 --- a/moto/ec2/models/__init__.py +++ b/moto/ec2/models/__init__.py @@ -14,6 +14,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 .fleets import FleetsBackend from .flow_logs import FlowLogsBackend from .key_pairs import KeyPairBackend from .launch_templates import LaunchTemplateBackend @@ -124,6 +125,7 @@ class EC2Backend( LaunchTemplateBackend, IamInstanceProfileAssociationBackend, CarrierGatewayBackend, + FleetsBackend, ): """ Implementation of the AWS EC2 endpoint. diff --git a/moto/ec2/models/fleets.py b/moto/ec2/models/fleets.py new file mode 100644 index 000000000..656506f7e --- /dev/null +++ b/moto/ec2/models/fleets.py @@ -0,0 +1,313 @@ +from collections import defaultdict + +from moto.ec2.models.spot_requests import SpotFleetLaunchSpec + +from .core import TaggedEC2Resource +from ..utils import ( + random_fleet_id, + convert_tag_spec, +) + + +class Fleet(TaggedEC2Resource): + def __init__( + self, + ec2_backend, + fleet_id, + on_demand_options, + spot_options, + target_capacity_specification, + launch_template_configs, + excess_capacity_termination_policy, + replace_unhealthy_instances, + terminate_instances_with_expiration, + fleet_type, + valid_from, + valid_until, + tag_specifications, + ): + + self.ec2_backend = ec2_backend + self.id = fleet_id + self.spot_options = spot_options + self.on_demand_options = on_demand_options + self.target_capacity_specification = target_capacity_specification + self.launch_template_configs = launch_template_configs + self.excess_capacity_termination_policy = ( + excess_capacity_termination_policy or "termination" + ) + self.replace_unhealthy_instances = replace_unhealthy_instances + self.terminate_instances_with_expiration = terminate_instances_with_expiration + self.fleet_type = fleet_type + self.valid_from = valid_from + self.valid_until = valid_until + tag_map = convert_tag_spec(tag_specifications).get("fleet", {}) + self.add_tags(tag_map) + self.tags = self.get_tags() + + self.state = "active" + self.fulfilled_capacity = 0.0 + self.fulfilled_on_demand_capacity = 0.0 + self.fulfilled_spot_capacity = 0.0 + + self.launch_specs = [] + + launch_specs_from_config = [] + for config in launch_template_configs or []: + spec = config["LaunchTemplateSpecification"] + if "LaunchTemplateId" in spec: + launch_template = self.ec2_backend.get_launch_template( + template_id=spec["LaunchTemplateId"] + ) + elif "LaunchTemplateName" in spec: + launch_template = self.ec2_backend.get_launch_template_by_name( + name=spec["LaunchTemplateName"] + ) + else: + continue + launch_template_data = launch_template.latest_version().data + new_launch_template = launch_template_data.copy() + if config.get("Overrides"): + for override in config["Overrides"]: + new_launch_template.update(override) + launch_specs_from_config.append(new_launch_template) + + for spec in launch_specs_from_config: + tag_spec_set = spec.get("TagSpecification", []) + tags = convert_tag_spec(tag_spec_set) + self.launch_specs.append( + SpotFleetLaunchSpec( + ebs_optimized=spec.get("EbsOptimized"), + group_set=spec.get("GroupSet", []), + iam_instance_profile=spec.get("IamInstanceProfile"), + image_id=spec["ImageId"], + instance_type=spec["InstanceType"], + key_name=spec.get("KeyName"), + monitoring=spec.get("Monitoring"), + spot_price=spec.get("SpotPrice"), + subnet_id=spec.get("SubnetId"), + tag_specifications=tags, + user_data=spec.get("UserData"), + weighted_capacity=spec.get("WeightedCapacity", 1), + ) + ) + + self.spot_requests = [] + self.on_demand_instances = [] + default_capacity = ( + target_capacity_specification.get("DefaultTargetCapacityType") + or "on-demand" + ) + self.target_capacity = int( + target_capacity_specification.get("TotalTargetCapacity") + ) + self.spot_target_capacity = int( + target_capacity_specification.get("SpotTargetCapacity") + ) + if self.spot_target_capacity > 0: + self.create_spot_requests(self.spot_target_capacity) + self.on_demand_target_capacity = int( + target_capacity_specification.get("OnDemandTargetCapacity") + ) + if self.on_demand_target_capacity > 0: + self.create_on_demand_requests(self.on_demand_target_capacity) + + remaining_capacity = self.target_capacity - self.fulfilled_capacity + if remaining_capacity > 0: + if default_capacity == "on-demand": + self.create_on_demand_requests(remaining_capacity) + elif default_capacity == "spot": + self.create_spot_requests(remaining_capacity) + + @property + def physical_resource_id(self): + return self.id + + def create_spot_requests(self, weight_to_add): + weight_map, added_weight = self.get_launch_spec_counts(weight_to_add) + for launch_spec, count in weight_map.items(): + requests = self.ec2_backend.request_spot_instances( + price=launch_spec.spot_price, + image_id=launch_spec.image_id, + count=count, + spot_instance_type="persistent", + valid_from=None, + valid_until=None, + launch_group=None, + availability_zone_group=None, + key_name=launch_spec.key_name, + security_groups=launch_spec.group_set, + user_data=launch_spec.user_data, + instance_type=launch_spec.instance_type, + placement=None, + kernel_id=None, + ramdisk_id=None, + monitoring_enabled=launch_spec.monitoring, + subnet_id=launch_spec.subnet_id, + spot_fleet_id=self.id, + tags=launch_spec.tag_specifications, + ) + self.spot_requests.extend(requests) + self.fulfilled_capacity += added_weight + return self.spot_requests + + def create_on_demand_requests(self, weight_to_add): + weight_map, added_weight = self.get_launch_spec_counts(weight_to_add) + for launch_spec, count in weight_map.items(): + reservation = self.ec2_backend.add_instances( + image_id=launch_spec.image_id, + count=count, + instance_type=launch_spec.instance_type, + is_instance_type_default=False, + key_name=launch_spec.key_name, + security_group_names=launch_spec.group_set, + user_data=launch_spec.user_data, + placement=None, + kernel_id=None, + ramdisk_id=None, + monitoring_enabled=launch_spec.monitoring, + subnet_id=launch_spec.subnet_id, + fleet_id=self.id, + tags=launch_spec.tag_specifications, + ) + + # get the instance from the reservation + instance = reservation.instances[0] + self.on_demand_instances.append( + { + "id": reservation.id, + "instance": instance, + } + ) + self.fulfilled_capacity += added_weight + return self.on_demand_instances + + def get_launch_spec_counts(self, weight_to_add): + weight_map = defaultdict(int) + + weight_so_far = 0 + if ( + self.spot_options + and self.spot_options["AllocationStrategy"] == "diversified" + ): + launch_spec_index = 0 + while True: + launch_spec = self.launch_specs[ + launch_spec_index % len(self.launch_specs) + ] + weight_map[launch_spec] += 1 + weight_so_far += launch_spec.weighted_capacity + if weight_so_far >= weight_to_add: + break + launch_spec_index += 1 + else: # lowestPrice + cheapest_spec = sorted( + # FIXME: change `+inf` to the on demand price scaled to weighted capacity when it's not present + self.launch_specs, + key=lambda spec: float(spec.spot_price or "+inf"), + )[0] + weight_so_far = weight_to_add + ( + weight_to_add % cheapest_spec.weighted_capacity + ) + weight_map[cheapest_spec] = int( + weight_so_far // cheapest_spec.weighted_capacity + ) + + return weight_map, weight_so_far + + def terminate_instances(self): + instance_ids = [] + new_fulfilled_capacity = self.fulfilled_capacity + for req in self.spot_requests + self.on_demand_instances: + instance = None + try: + instance = req.instance + except AttributeError: + instance = req["instance"] + + if instance.state == "terminated": + continue + + # stop when we hit the target capacity + if new_fulfilled_capacity <= self.target_capacity: + break + + instance_ids.append(instance.id) + new_fulfilled_capacity -= 1 + + self.spot_requests = [ + req for req in self.spot_requests if req.instance.id not in instance_ids + ] + self.on_demand_instances = [ + req + for req in self.on_demand_instances + if req["instance"].id not in instance_ids + ] + self.ec2_backend.terminate_instances(instance_ids) + + +class FleetsBackend: + def __init__(self): + self.fleets = {} + + def create_fleet( + self, + on_demand_options, + spot_options, + target_capacity_specification, + launch_template_configs, + excess_capacity_termination_policy, + replace_unhealthy_instances, + terminate_instances_with_expiration, + fleet_type, + valid_from, + valid_until, + tag_specifications, + ): + + fleet_id = random_fleet_id() + fleet = Fleet( + self, + fleet_id, + on_demand_options, + spot_options, + target_capacity_specification, + launch_template_configs, + excess_capacity_termination_policy, + replace_unhealthy_instances, + terminate_instances_with_expiration, + fleet_type, + valid_from, + valid_until, + tag_specifications, + ) + self.fleets[fleet_id] = fleet + return fleet + + def get_fleet(self, fleet_id): + return self.fleets.get(fleet_id) + + def describe_fleet_instances(self, fleet_id): + fleet = self.get_fleet(fleet_id) + if not fleet: + return [] + return fleet.spot_requests + fleet.on_demand_instances + + def describe_fleets(self, fleet_ids): + fleets = self.fleets.values() + + if fleet_ids: + fleets = [fleet for fleet in fleets if fleet.id in fleet_ids] + + return fleets + + def delete_fleets(self, fleet_ids, terminate_instances): + fleets = [] + for fleet_id in fleet_ids: + fleet = self.fleets[fleet_id] + if terminate_instances: + fleet.target_capacity = 0 + fleet.terminate_instances() + fleets.append(fleet) + fleet.state = "deleted" + return fleets diff --git a/moto/ec2/models/instances.py b/moto/ec2/models/instances.py index 9424699b2..c66fec62a 100644 --- a/moto/ec2/models/instances.py +++ b/moto/ec2/models/instances.py @@ -7,6 +7,7 @@ from moto import settings from moto.core import get_account_id from moto.core import CloudFormationModel from moto.core.utils import camelcase_to_underscores +from moto.ec2.models.fleets import Fleet from moto.ec2.models.instance_types import ( INSTANCE_TYPE_OFFERINGS, InstanceTypeOfferingBackend, @@ -114,6 +115,7 @@ class Instance(TaggedEC2Resource, BotoInstance, CloudFormationModel): ) self.sriov_net_support = "simple" self._spot_fleet_id = kwargs.get("spot_fleet_id", None) + self._fleet_id = kwargs.get("fleet_id", None) self.associate_public_ip = kwargs.get("associate_public_ip", False) if in_ec2_classic: # If we are in EC2-Classic, autoassign a public IP @@ -367,18 +369,28 @@ class Instance(TaggedEC2Resource, BotoInstance, CloudFormationModel): self.teardown_defaults() - if self._spot_fleet_id: - spot_fleet = self.ec2_backend.get_spot_fleet_request(self._spot_fleet_id) - for spec in spot_fleet.launch_specs: + if self._spot_fleet_id or self._fleet_id: + fleet = self.ec2_backend.get_spot_fleet_request(self._spot_fleet_id) + if not fleet: + fleet = self.ec2_backend.get_fleet( + self._spot_fleet_id + ) or self.ec2_backend.get_fleet(self._fleet_id) + for spec in fleet.launch_specs: if ( spec.instance_type == self.instance_type and spec.subnet_id == self.subnet_id ): break - spot_fleet.fulfilled_capacity -= spec.weighted_capacity - spot_fleet.spot_requests = [ - req for req in spot_fleet.spot_requests if req.instance != self + fleet.fulfilled_capacity -= spec.weighted_capacity + fleet.spot_requests = [ + req for req in fleet.spot_requests if req.instance != self ] + if isinstance(fleet, Fleet): + fleet.on_demand_instances = [ + inst + for inst in fleet.on_demand_instances + if inst["instance"] != self + ] self._state.name = "terminated" self._state.code = 48 diff --git a/moto/ec2/responses/__init__.py b/moto/ec2/responses/__init__.py index 24be4b8f8..548d97188 100644 --- a/moto/ec2/responses/__init__.py +++ b/moto/ec2/responses/__init__.py @@ -7,6 +7,7 @@ from .dhcp_options import DHCPOptions from .elastic_block_store import ElasticBlockStore from .elastic_ip_addresses import ElasticIPAddresses from .elastic_network_interfaces import ElasticNetworkInterfaces +from .fleets import Fleets from .general import General from .instances import InstanceResponse from .internet_gateways import InternetGateways @@ -52,6 +53,7 @@ class EC2Response( ElasticBlockStore, ElasticIPAddresses, ElasticNetworkInterfaces, + Fleets, General, InstanceResponse, InternetGateways, diff --git a/moto/ec2/responses/fleets.py b/moto/ec2/responses/fleets.py new file mode 100644 index 000000000..dc8b31f56 --- /dev/null +++ b/moto/ec2/responses/fleets.py @@ -0,0 +1,417 @@ +from moto.core.responses import BaseResponse + + +class Fleets(BaseResponse): + def delete_fleets(self): + fleet_ids = self._get_multi_param("FleetId.") + terminate_instances = self._get_param("TerminateInstances") + fleets = self.ec2_backend.delete_fleets(fleet_ids, terminate_instances) + template = self.response_template(DELETE_FLEETS_TEMPLATE) + return template.render(fleets=fleets) + + def describe_fleet_instances(self): + fleet_id = self._get_param("FleetId") + + instances = self.ec2_backend.describe_fleet_instances(fleet_id) + template = self.response_template(DESCRIBE_FLEET_INSTANCES_TEMPLATE) + return template.render(fleet_id=fleet_id, instances=instances) + + def describe_fleets(self): + fleet_ids = self._get_multi_param("FleetId.") + + requests = self.ec2_backend.describe_fleets(fleet_ids) + template = self.response_template(DESCRIBE_FLEETS_TEMPLATE) + rend = template.render(requests=requests) + return rend + + def create_fleet(self): + on_demand_options = self._get_multi_param_dict("OnDemandOptions") + spot_options = self._get_multi_param_dict("SpotOptions") + target_capacity_specification = self._get_multi_param_dict( + "TargetCapacitySpecification" + ) + launch_template_configs = self._get_multi_param("LaunchTemplateConfigs") + + excess_capacity_termination_policy = self._get_param( + "ExcessCapacityTerminationPolicy" + ) + replace_unhealthy_instances = self._get_param("ReplaceUnhealthyInstances") + terminate_instances_with_expiration = self._get_param( + "TerminateInstancesWithExpiration", if_none=True + ) + fleet_type = self._get_param("Type", if_none="maintain") + valid_from = self._get_param("ValidFrom") + valid_until = self._get_param("ValidUntil") + + tag_specifications = self._get_multi_param("TagSpecification") + + request = self.ec2_backend.create_fleet( + on_demand_options=on_demand_options, + spot_options=spot_options, + target_capacity_specification=target_capacity_specification, + launch_template_configs=launch_template_configs, + excess_capacity_termination_policy=excess_capacity_termination_policy, + replace_unhealthy_instances=replace_unhealthy_instances, + terminate_instances_with_expiration=terminate_instances_with_expiration, + fleet_type=fleet_type, + valid_from=valid_from, + valid_until=valid_until, + tag_specifications=tag_specifications, + ) + + template = self.response_template(CREATE_FLEET_TEMPLATE) + return template.render(request=request) + + +CREATE_FLEET_TEMPLATE = """ + 60262cc5-2bd4-4c8d-98ed-example + {{ request.id }} + {% if request.fleet_type == "instant" %} + + {% for instance in request.on_demand_instances %} + + {{ instance["instance"].instance_type }} + on-demand + + {{ instance["instance"].id }} + + + {% endfor %} + {% for instance in request.spot_requests %} + + {{ instance.instance.instance_type }} + spot + + {{ instance.instance.id }} + + + {% endfor %} + + {% endif %} +""" + +DESCRIBE_FLEETS_TEMPLATE = """ + 4d68a6cc-8f2e-4be1-b425-example + + {% for request in requests %} + + {{ request.id }} + {{ request.state }} + {{ request.excess_capacity_termination_policy }} + {{ request.fulfilled_capacity }} + {{ request.fulfilled_on_demand_capacity }} + + {% for config in request.launch_template_configs %} + + + {{ config.LaunchTemplateSpecification.LaunchTemplateId }} + {{ config.LaunchTemplateSpecification.Version }} + + {% if config.Overrides %} + + {% for override in config.Overrides %} + + {% if override.AvailabilityZone %} + {{ override.AvailabilityZone }} + {% endif %} + {% if override.InstanceType %} + {{ override.InstanceType }} + {% endif %} + {% if override.InstanceRequirements %} + + {% if override.InstanceRequirements.AcceleratorCount %} + + {% if override.InstanceRequirements.AcceleratorCount.Max %} + {{ override.InstanceRequirements.AcceleratorCount.Max }} + {% endif %} + {% if override.InstanceRequirements.AcceleratorCount.Min %} + {{ override.InstanceRequirements.AcceleratorCount.Min }} + {% endif %} + + {% endif %} + {% if override.InstanceRequirements.AcceleratorManufacturer %} + + {% for manufacturer in override.InstanceRequirements.AcceleratorManufacturer %} + {{ manufacturer }} + {% endfor %} + + {% endif %} + {% if override.InstanceRequirements.AcceleratorName %} + + {% for name in override.InstanceRequirements.AcceleratorName %} + {{ name }} + {% endfor %} + + {% endif %} + {% if override.InstanceRequirements.AcceleratorTotalMemoryMiB %} + + {% if override.InstanceRequirements.AcceleratorTotalMemoryMiB.Max %} + {{ override.InstanceRequirements.AcceleratorTotalMemoryMiB.Max }} + {% endif %} + {% if override.InstanceRequirements.AcceleratorTotalMemoryMiB.Min %} + {{ override.InstanceRequirements.AcceleratorTotalMemoryMiB.Min }} + {% endif %} + + {% endif %} + {% if override.InstanceRequirements.AcceleratorType %} + + {% for type in override.InstanceRequirements.AcceleratorType %} + {{ type }} + {% endfor %} + + {% endif %} + {% if override.InstanceRequirements.BareMetal %} + {{ override.InstanceRequirements.BareMetal }} + {% endif %} + {% if override.InstanceRequirements.BaselineEbsBandwidthMbps %} + + {% if override.InstanceRequirements.BaselineEbsBandwidthMbps.Min %} + {{ override.InstanceRequirements.BaselineEbsBandwidthMbps.Min }} + {% endif %} + {% if override.InstanceRequirements.BaselineEbsBandwidthMbps.Max %} + {{ override.InstanceRequirements.BaselineEbsBandwidthMbps.Max }} + {% endif %} + + {% endif %} + {% if override.InstanceRequirements.BurstablePerformance %} + {{ override.InstanceRequirements.BurstablePerformance }} + {% endif %} + {% if override.InstanceRequirements.CpuManufacturer %} + + {% for manufacturer in override.InstanceRequirements.CpuManufacturer %} + {{ manufacturer }} + {% endfor %} + + {% endif %} + {% if override.InstanceRequirements.ExcludedInstanceType %} + + {% for type in override.InstanceRequirements.ExcludedInstanceType %} + {{ type }} + {% endfor %} + + {% endif %} + {% if override.InstanceRequirements.InstanceGeneration %} + + {% for generation in override.InstanceRequirements.InstanceGeneration %} + {{ generation }} + {% endfor %} + + {% endif %} + {% if override.InstanceRequirements.LocalStorage %} + {{ override.InstanceRequirements.LocalStorage }} + {% endif %} + {% if override.InstanceRequirements.LocalStorageType %} + + {% for type in override.InstanceRequirements.LocalStorageType %} + {{ type }} + {% endfor %} + + {% endif %} + {% if override.InstanceRequirements.MemoryGiBPerVCpu %} + + {{ override.InstanceRequirements.MemoryGiBPerVCpu.Min }} + {{ override.InstanceRequirements.MemoryGiBPerVCpu.Max }} + + {% endif %} + {% if override.InstanceRequirements.MemoryMiB %} + + {% if override.InstanceRequirements.MemoryMiB.Min %} + {{ override.InstanceRequirements.MemoryMiB.Min }} + {% endif %} + {% if override.InstanceRequirements.MemoryMiB.Max %} + {{ override.InstanceRequirements.MemoryMiB.Max }} + {% endif %} + + {% endif %} + {% if override.InstanceRequirements.NetworkInterfaceCount %} + + {% if override.InstanceRequirements.NetworkInterfaceCount.Max %} + {{ override.InstanceRequirements.NetworkInterfaceCount.Max }} + {% endif %} + {% if override.InstanceRequirements.NetworkInterfaceCount.Min %} + {{ override.InstanceRequirements.NetworkInterfaceCount.Min }} + {% endif %} + + {% endif %} + {% if override.InstanceRequirements.OnDemandMaxPricePercentageOverLowestPrice %} + {{ override.InstanceRequirements.OnDemandMaxPricePercentageOverLowestPrice }} + {% endif %} + {% if override.InstanceRequirements.RequireHibernateSupport %} + {{ override.InstanceRequirements.RequireHibernateSupport }} + {% endif %} + {% if override.InstanceRequirements.SpotMaxPricePercentageOverLowestPrice %} + {{ override.InstanceRequirements.SpotMaxPricePercentageOverLowestPrice }} + {% endif %} + {% if override.InstanceRequirements.TotalLocalStorageGB %} + + {% if override.InstanceRequirements.TotalLocalStorageGB.Min %} + {{ override.InstanceRequirements.TotalLocalStorageGB.Min }} + {% endif %} + {% if override.InstanceRequirements.TotalLocalStorageGB.Max %} + {{ override.InstanceRequirements.TotalLocalStorageGB.Max }} + {% endif %} + + {% endif %} + {% if override.InstanceRequirements.VCpuCount %} + + {% if override.InstanceRequirements.VCpuCount.Min %} + {{ override.InstanceRequirements.VCpuCount.Min }} + {% endif %} + {% if override.InstanceRequirements.VCpuCount.Max %} + {{ override.InstanceRequirements.VCpuCount.Max }} + {% endif %} + + {% endif %} + + {% endif %} + {% if override.MaxPrice %} + {{ override.MaxPrice }} + {% endif %} + {% if override.Placement %} + + {% if override.Placement.GroupName %} + {{ override.Placement.GroupName }} + {% endif %} + + {% endif %} + {% if override.Priority %} + {{ override.Priority }} + {% endif %} + {% if override.SubnetId %} + {{ override.SubnetId }} + {% endif %} + {% if override.WeightedCapacity %} + {{ override.WeightedCapacity }} + {% endif %} + + {% endfor %} + + {% endif %} + + {% endfor %} + + + {{ request.target_capacity }} + {% if request.on_demand_target_capacity %} + {{ request.on_demand_target_capacity }} + {% endif %} + {% if request.spot_target_capacity %} + {{ request.spot_target_capacity }} + {% endif %} + {{ request.target_capacity_specification.DefaultTargetCapacityType }} + + {% if request.spot_options %} + + {% if request.spot_options.AllocationStrategy %} + {{ request.spot_options.AllocationStrategy }} + {% endif %} + {% if request.spot_options.InstanceInterruptionBehavior %} + {{ request.spot_options.InstanceInterruptionBehavior }} + {% endif %} + {% if request.spot_options.InstancePoolsToUseCount %} + {{ request.spot_options.InstancePoolsToUseCount }} + {% endif %} + {% if request.spot_options.MaintenanceStrategies %} + + {% if request.spot_options.MaintenanceStrategies.CapacityRebalance %} + + {% if request.spot_options.MaintenanceStrategies.CapacityRebalance.ReplacementStrategy %} + {{ request.spot_options.MaintenanceStrategies.CapacityRebalance.ReplacementStrategy }} + {% endif %} + {% if request.spot_options.MaintenanceStrategies.CapacityRebalance.TerminationDelay %} + {{ request.spot_options.MaintenanceStrategies.CapacityRebalance.TerminationDelay }} + {% endif %} + + {% endif %} + + {% endif %} + {% if request.spot_options.MaxTotalPrice %} + {{ request.spot_options.MaxTotalPrice }} + {% endif %} + {% if request.spot_options.MinTargetCapacity %} + {{ request.spot_options.MinTargetCapacity }} + {% endif %} + {% if request.spot_options.SingleAvailabilityZone %} + {{ request.spot_options.SingleAvailabilityZone }} + {% endif %} + {% if request.spot_options.SingleInstanceType %} + {{ request.spot_options.SingleInstanceType }} + {% endif %} + + {% endif %} + + {% if request.on_demand_options %} + + {% if request.on_demand_options.AllocationStrategy %} + {{ request.on_demand_options.AllocationStrategy }} + {% endif %} + {% if request.on_demand_options.MaxTotalPrice %} + {{ request.on_demand_options.MaxTotalPrice }} + {% endif %} + {% if request.on_demand_options.MinTargetCapacity %} + {{ request.on_demand_options.MinTargetCapacity }} + {% endif %} + {% if request.on_demand_options.SingleAvailabilityZone %} + {{ request.on_demand_options.SingleAvailabilityZone }} + {% endif %} + {% if request.on_demand_options.SingleInstanceType %} + {{ request.on_demand_options.SingleInstanceType }} + {% endif %} + {% if request.on_demand_options.CapacityReservationOptions %} + + {% if request.on_demand_options.CapacityReservationOptions.UsageStrategy %} + {{ request.on_demand_options.CapacityReservationOptions.UsageStrategy }} + {% endif %} + + {% endif %} + + {% endif %} + {{ request.terminate_instances_with_expiration }} + {{ request.fleet_type }} + {{ request.valid_from }} + {{ request.valid_until }} + {{ request.replace_unhealthy_instances }} + + {% for tag in request.tags %} + + {{ tag.key }} + {{ tag.value }} + + {% endfor %} + + + {% endfor %} + +""" + +DESCRIBE_FLEET_INSTANCES_TEMPLATE = """ + cfb09950-45e2-472d-a6a9-example + {{ fleet_id }} + + {% for i in instances %} + + {{ i.instance.id }} + {% if i.id %} + {{ i.id }} + {% endif %} + {{ i.instance.instance_type }} + healthy + + {% endfor %} + + +""" + +DELETE_FLEETS_TEMPLATE = """ + e12d2fe5-6503-4b4b-911c-example + + + {% for fleet in fleets %} + + {{ fleet.id }} + {{ fleet.state }} + active + + {% endfor %} + +""" diff --git a/moto/ec2/utils.py b/moto/ec2/utils.py index 1a992586c..367c183dd 100644 --- a/moto/ec2/utils.py +++ b/moto/ec2/utils.py @@ -19,6 +19,7 @@ EC2_RESOURCE_TO_PREFIX = { "transit-gateway-route-table": "tgw-rtb", "transit-gateway-attachment": "tgw-attach", "dhcp-options": "dopt", + "fleet": "fleet", "flow-logs": "fl", "image": "ami", "instance": "i", @@ -90,6 +91,10 @@ def random_security_group_rule_id(): return random_id(prefix=EC2_RESOURCE_TO_PREFIX["security-group-rule"], size=17) +def random_fleet_id(): + return f"fleet-{random_resource_id(size=8)}-{random_resource_id(size=4)}-{random_resource_id(size=4)}-{random_resource_id(size=4)}-{random_resource_id(size=12)}" + + def random_flow_log_id(): return random_id(prefix=EC2_RESOURCE_TO_PREFIX["flow-logs"]) diff --git a/tests/test_ec2/test_fleets.py b/tests/test_ec2/test_fleets.py new file mode 100644 index 000000000..c97dffb65 --- /dev/null +++ b/tests/test_ec2/test_fleets.py @@ -0,0 +1,663 @@ +import boto3 +import sure # noqa # pylint: disable=unused-import +import pytest + +from moto import mock_ec2 +from tests import EXAMPLE_AMI_ID +from uuid import uuid4 + + +def get_subnet_id(conn): + vpc = conn.create_vpc(CidrBlock="10.0.0.0/16")["Vpc"] + subnet = conn.create_subnet( + VpcId=vpc["VpcId"], CidrBlock="10.0.0.0/16", AvailabilityZone="us-east-1a" + )["Subnet"] + subnet_id = subnet["SubnetId"] + return subnet_id + + +def get_launch_template(conn, instance_type="t2.micro"): + launch_template = conn.create_launch_template( + LaunchTemplateName="test" + str(uuid4()), + LaunchTemplateData={ + "ImageId": EXAMPLE_AMI_ID, + "InstanceType": instance_type, + "KeyName": "test", + "SecurityGroups": ["sg-123456"], + "DisableApiTermination": False, + "TagSpecifications": [ + { + "ResourceType": "instance", + "Tags": [ + {"Key": "test", "Value": "value"}, + {"Key": "Name", "Value": "test"}, + ], + } + ], + }, + )["LaunchTemplate"] + launch_template_id = launch_template["LaunchTemplateId"] + launch_template_name = launch_template["LaunchTemplateName"] + return launch_template_id, launch_template_name + + +@mock_ec2 +def test_create_spot_fleet_with_lowest_price(): + conn = boto3.client("ec2", region_name="us-west-2") + launch_template_id, _ = get_launch_template(conn) + + fleet_res = conn.create_fleet( + ExcessCapacityTerminationPolicy="terminate", + LaunchTemplateConfigs=[ + { + "LaunchTemplateSpecification": { + "LaunchTemplateId": launch_template_id, + "Version": "1", + }, + }, + ], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "spot", + "OnDemandTargetCapacity": 0, + "SpotTargetCapacity": 1, + "TotalTargetCapacity": 1, + }, + SpotOptions={ + "AllocationStrategy": "lowest-price", + }, + Type="maintain", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + ) + + fleet_id = fleet_res["FleetId"] + fleet_id.should.be.a(str) + fleet_id.should.have.length_of(42) + + fleets_res = conn.describe_fleets(FleetIds=[fleet_id]) + fleets_res.should.have.key("Fleets") + fleets = fleets_res["Fleets"] + + fleet = fleets[0] + fleet["FleetState"].should.equal("active") + fleet_config = fleet["LaunchTemplateConfigs"][0] + + launch_template_spec = fleet_config["LaunchTemplateSpecification"] + + launch_template_spec["LaunchTemplateId"].should.equal(launch_template_id) + launch_template_spec["Version"].should.equal("1") + + instance_res = conn.describe_fleet_instances(FleetId=fleet_id) + instances = instance_res["ActiveInstances"] + len(instances).should.equal(1) + + +@mock_ec2 +def test_create_on_demand_fleet(): + conn = boto3.client("ec2", region_name="us-west-2") + launch_template_id, _ = get_launch_template(conn) + + fleet_res = conn.create_fleet( + ExcessCapacityTerminationPolicy="terminate", + LaunchTemplateConfigs=[ + { + "LaunchTemplateSpecification": { + "LaunchTemplateId": launch_template_id, + "Version": "1", + }, + }, + ], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "on-demand", + "OnDemandTargetCapacity": 1, + "SpotTargetCapacity": 0, + "TotalTargetCapacity": 1, + }, + OnDemandOptions={ + "AllocationStrategy": "lowestPrice", + }, + Type="maintain", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + ) + + fleet_id = fleet_res["FleetId"] + fleet_id.should.be.a(str) + fleet_id.should.have.length_of(42) + + fleets_res = conn.describe_fleets(FleetIds=[fleet_id]) + fleets_res.should.have.key("Fleets") + fleets = fleets_res["Fleets"] + + fleet = fleets[0] + fleet["FleetState"].should.equal("active") + fleet_config = fleet["LaunchTemplateConfigs"][0] + + launch_template_spec = fleet_config["LaunchTemplateSpecification"] + + launch_template_spec["LaunchTemplateId"].should.equal(launch_template_id) + launch_template_spec["Version"].should.equal("1") + + instance_res = conn.describe_fleet_instances(FleetId=fleet_id) + instances = instance_res["ActiveInstances"] + len(instances).should.equal(1) + + +@mock_ec2 +def test_create_diversified_spot_fleet(): + conn = boto3.client("ec2", region_name="us-west-2") + launch_template_id_1, _ = get_launch_template(conn, instance_type="t2.small") + launch_template_id_2, _ = get_launch_template(conn, instance_type="t2.large") + + fleet_res = conn.create_fleet( + ExcessCapacityTerminationPolicy="terminate", + LaunchTemplateConfigs=[ + { + "LaunchTemplateSpecification": { + "LaunchTemplateId": launch_template_id_1, + "Version": "1", + }, + }, + { + "LaunchTemplateSpecification": { + "LaunchTemplateId": launch_template_id_2, + "Version": "1", + }, + }, + ], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "spot", + "OnDemandTargetCapacity": 0, + "SpotTargetCapacity": 2, + "TotalTargetCapacity": 2, + }, + SpotOptions={ + "AllocationStrategy": "diversified", + }, + Type="maintain", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + ) + fleet_id = fleet_res["FleetId"] + + instance_res = conn.describe_fleet_instances(FleetId=fleet_id) + instances = instance_res["ActiveInstances"] + len(instances).should.equal(2) + instance_types = set([instance["InstanceType"] for instance in instances]) + instance_types.should.equal(set(["t2.small", "t2.large"])) + instances[0]["InstanceId"].should.contain("i-") + + +@mock_ec2 +@pytest.mark.parametrize( + "spot_allocation_strategy", + [ + "diversified", + "lowest-price", + "capacity-optimized", + "capacity-optimized-prioritized", + ], +) +@pytest.mark.parametrize( + "on_demand_allocation_strategy", + ["lowestPrice", "prioritized"], +) +def test_request_fleet_using_launch_template_config__name( + spot_allocation_strategy, on_demand_allocation_strategy +): + + conn = boto3.client("ec2", region_name="us-east-2") + + _, launch_template_name = get_launch_template(conn, instance_type="t2.medium") + + fleet_res = conn.create_fleet( + LaunchTemplateConfigs=[ + { + "LaunchTemplateSpecification": { + "LaunchTemplateName": launch_template_name, + "Version": "1", + }, + }, + ], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "spot", + "OnDemandTargetCapacity": 1, + "SpotTargetCapacity": 2, + "TotalTargetCapacity": 3, + }, + SpotOptions={ + "AllocationStrategy": spot_allocation_strategy, + }, + OnDemandOptions={ + "AllocationStrategy": on_demand_allocation_strategy, + }, + Type="maintain", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + ) + + fleet_id = fleet_res["FleetId"] + + instance_res = conn.describe_fleet_instances(FleetId=fleet_id) + instances = instance_res["ActiveInstances"] + len(instances).should.equal(3) + instance_types = set([instance["InstanceType"] for instance in instances]) + instance_types.should.equal(set(["t2.medium"])) + instances[0]["InstanceId"].should.contain("i-") + + +@mock_ec2 +def test_create_fleet_request_with_tags(): + conn = boto3.client("ec2", region_name="us-west-2") + + launch_template_id, _ = get_launch_template(conn) + tags = [ + {"Key": "Name", "Value": "test-fleet"}, + {"Key": "Another", "Value": "tag"}, + ] + tags_instance = [ + {"Key": "test", "Value": "value"}, + {"Key": "Name", "Value": "test"}, + ] + fleet_res = conn.create_fleet( + DryRun=False, + SpotOptions={ + "AllocationStrategy": "lowestPrice", + "InstanceInterruptionBehavior": "terminate", + }, + LaunchTemplateConfigs=[ + { + "LaunchTemplateSpecification": { + "LaunchTemplateId": launch_template_id, + "Version": "1", + }, + }, + ], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "spot", + "OnDemandTargetCapacity": 1, + "SpotTargetCapacity": 2, + "TotalTargetCapacity": 3, + }, + Type="request", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + TagSpecifications=[ + { + "ResourceType": "fleet", + "Tags": tags, + }, + ], + ) + fleet_id = fleet_res["FleetId"] + fleets = conn.describe_fleets(FleetIds=[fleet_id])["Fleets"] + + fleets[0]["Tags"].should.equal(tags) + + instance_res = conn.describe_fleet_instances(FleetId=fleet_id) + instances = conn.describe_instances( + InstanceIds=[i["InstanceId"] for i in instance_res["ActiveInstances"]] + ) + for instance in instances["Reservations"][0]["Instances"]: + for tag in tags_instance: + instance["Tags"].should.contain(tag) + + +@mock_ec2 +def test_create_fleet_using_launch_template_config__overrides(): + + conn = boto3.client("ec2", region_name="us-east-2") + subnet_id = get_subnet_id(conn) + + template_id, _ = get_launch_template(conn, instance_type="t2.medium") + + template_config_overrides = [ + { + "InstanceType": "t2.nano", + "SubnetId": subnet_id, + "AvailabilityZone": "us-west-1", + "WeightedCapacity": 2, + } + ] + + fleet_res = conn.create_fleet( + LaunchTemplateConfigs=[ + { + "LaunchTemplateSpecification": { + "LaunchTemplateId": template_id, + "Version": "1", + }, + "Overrides": template_config_overrides, + }, + ], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "spot", + "OnDemandTargetCapacity": 1, + "SpotTargetCapacity": 0, + "TotalTargetCapacity": 1, + }, + SpotOptions={ + "AllocationStrategy": "lowest-price", + "InstanceInterruptionBehavior": "terminate", + }, + Type="maintain", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + ) + fleet_id = fleet_res["FleetId"] + + instance_res = conn.describe_fleet_instances(FleetId=fleet_id) + instances = instance_res["ActiveInstances"] + instances.should.have.length_of(1) + instances[0].should.have.key("InstanceType").equals("t2.nano") + + instance = conn.describe_instances( + InstanceIds=[i["InstanceId"] for i in instances] + )["Reservations"][0]["Instances"][0] + instance.should.have.key("SubnetId").equals(subnet_id) + + +@mock_ec2 +def test_delete_fleet(): + conn = boto3.client("ec2", region_name="us-west-2") + + launch_template_id, _ = get_launch_template(conn) + + fleet_res = conn.create_fleet( + LaunchTemplateConfigs=[ + { + "LaunchTemplateSpecification": { + "LaunchTemplateId": launch_template_id, + "Version": "1", + }, + }, + ], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "spot", + "OnDemandTargetCapacity": 1, + "SpotTargetCapacity": 2, + "TotalTargetCapacity": 3, + }, + SpotOptions={ + "AllocationStrategy": "lowestPrice", + "InstanceInterruptionBehavior": "terminate", + }, + Type="maintain", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + ) + fleet_id = fleet_res["FleetId"] + + delete_fleet_out = conn.delete_fleets(FleetIds=[fleet_id], TerminateInstances=True) + + delete_fleet_out["SuccessfulFleetDeletions"].should.have.length_of(1) + delete_fleet_out["SuccessfulFleetDeletions"][0]["FleetId"].should.equal(fleet_id) + delete_fleet_out["SuccessfulFleetDeletions"][0]["CurrentFleetState"].should.equal( + "deleted" + ) + + fleets = conn.describe_fleets(FleetIds=[fleet_id])["Fleets"] + len(fleets).should.equal(1) + + target_capacity_specification = fleets[0]["TargetCapacitySpecification"] + target_capacity_specification.should.have.key("TotalTargetCapacity").equals(0) + fleets[0]["FleetState"].should.equal("deleted") + + # Instances should be terminated + instance_res = conn.describe_fleet_instances(FleetId=fleet_id) + instances = instance_res["ActiveInstances"] + len(instances).should.equal(0) + + +@mock_ec2 +def test_describe_fleet_instences_api(): + conn = boto3.client("ec2", region_name="us-west-1") + + launch_template_id, _ = get_launch_template(conn) + + fleet_res = conn.create_fleet( + LaunchTemplateConfigs=[ + { + "LaunchTemplateSpecification": { + "LaunchTemplateId": launch_template_id, + "Version": "1", + }, + }, + ], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "spot", + "OnDemandTargetCapacity": 1, + "SpotTargetCapacity": 2, + "TotalTargetCapacity": 3, + }, + SpotOptions={ + "AllocationStrategy": "lowestPrice", + "InstanceInterruptionBehavior": "terminate", + }, + Type="maintain", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + ) + + fleet_id = fleet_res["FleetId"] + fleet_res = conn.describe_fleet_instances(FleetId=fleet_id) + + fleet_res["FleetId"].should.equal(fleet_id) + fleet_res["ActiveInstances"].should.have.length_of(3) + + instance_ids = [i["InstanceId"] for i in fleet_res["ActiveInstances"]] + for instance_id in instance_ids: + instance_id.startswith("i-").should.be.true + + instance_types = [i["InstanceType"] for i in fleet_res["ActiveInstances"]] + instance_types.should.equal(["t2.micro", "t2.micro", "t2.micro"]) + + instance_healths = [i["InstanceHealth"] for i in fleet_res["ActiveInstances"]] + instance_healths.should.equal(["healthy", "healthy", "healthy"]) + + +@mock_ec2 +def test_create_fleet_api(): + conn = boto3.client("ec2", region_name="us-west-1") + + launch_template_id, _ = get_launch_template(conn) + + fleet_res = conn.create_fleet( + LaunchTemplateConfigs=[ + { + "LaunchTemplateSpecification": { + "LaunchTemplateId": launch_template_id, + "Version": "1", + }, + }, + ], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "spot", + "OnDemandTargetCapacity": 1, + "SpotTargetCapacity": 2, + "TotalTargetCapacity": 3, + }, + SpotOptions={ + "AllocationStrategy": "lowestPrice", + "InstanceInterruptionBehavior": "terminate", + }, + Type="instant", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + ) + + fleet_res.should.have.key("FleetId") + fleet_res["FleetId"].startswith("fleet-").should.be.true + + fleet_res.should.have.key("Instances") + fleet_res["Instances"].should.have.length_of(3) + + instance_ids = [i["InstanceIds"] for i in fleet_res["Instances"]] + for instance_id in instance_ids: + instance_id[0].startswith("i-").should.be.true + + instance_types = [i["InstanceType"] for i in fleet_res["Instances"]] + instance_types.should.equal(["t2.micro", "t2.micro", "t2.micro"]) + + lifecycle = [i["Lifecycle"] for i in fleet_res["Instances"]] + lifecycle.should.contain("spot") + lifecycle.should.contain("on-demand") + + +@mock_ec2 +def test_create_fleet_api_response(): + conn = boto3.client("ec2", region_name="us-west-2") + subnet_id = get_subnet_id(conn) + + launch_template_id, _ = get_launch_template(conn) + + lt_config = { + "LaunchTemplateSpecification": { + "LaunchTemplateId": launch_template_id, + "Version": "1", + }, + "Overrides": [ + { + "AvailabilityZone": "us-west-1a", + "InstanceRequirements": { + "AcceleratorCount": { + "Max": 10, + "Min": 1, + }, + "AcceleratorManufacturers": ["nvidia"], + "AcceleratorNames": ["t4"], + "AcceleratorTotalMemoryMiB": { + "Max": 20972, + "Min": 1, + }, + "AcceleratorTypes": ["gpu"], + "BareMetal": "included", + "BaselineEbsBandwidthMbps": { + "Max": 10000, + "Min": 125, + }, + "BurstablePerformance": "included", + "CpuManufacturers": ["amd", "intel"], + "ExcludedInstanceTypes": ["m5.8xlarge"], + "InstanceGenerations": ["current"], + "LocalStorage": "included", + "LocalStorageTypes": ["ssd"], + "MemoryGiBPerVCpu": { + "Min": 1, + "Max": 160, + }, + "MemoryMiB": { + "Min": 2048, + "Max": 40960, + }, + "NetworkInterfaceCount": { + "Max": 1, + "Min": 1, + }, + "OnDemandMaxPricePercentageOverLowestPrice": 99999, + "RequireHibernateSupport": True, + "SpotMaxPricePercentageOverLowestPrice": 99999, + "TotalLocalStorageGB": { + "Min": 100, + "Max": 10000, + }, + "VCpuCount": { + "Min": 2, + "Max": 160, + }, + }, + "MaxPrice": "0.5", + "Priority": 2, + "SubnetId": subnet_id, + "WeightedCapacity": 1, + }, + ], + } + + fleet_res = conn.create_fleet( + ExcessCapacityTerminationPolicy="no-termination", + LaunchTemplateConfigs=[lt_config], + TargetCapacitySpecification={ + "DefaultTargetCapacityType": "on-demand", + "OnDemandTargetCapacity": 10, + "SpotTargetCapacity": 10, + "TotalTargetCapacity": 30, + }, + SpotOptions={ + "AllocationStrategy": "lowest-price", + "InstanceInterruptionBehavior": "terminate", + "InstancePoolsToUseCount": 1, + "MaintenanceStrategies": { + "CapacityRebalance": { + "ReplacementStrategy": "launch-before-terminate", + "TerminationDelay": 120, + }, + }, + "MaxTotalPrice": "50", + "MinTargetCapacity": 1, + "SingleAvailabilityZone": True, + "SingleInstanceType": True, + }, + OnDemandOptions={ + "AllocationStrategy": "lowest-price", + "MaxTotalPrice": "50", + "MinTargetCapacity": 1, + "SingleAvailabilityZone": True, + "SingleInstanceType": True, + }, + ReplaceUnhealthyInstances=True, + TerminateInstancesWithExpiration=True, + Type="maintain", + ValidFrom="2020-01-01T00:00:00Z", + ValidUntil="2020-12-31T00:00:00Z", + ) + fleet_id = fleet_res["FleetId"] + + fleet_res = conn.describe_fleets(FleetIds=[fleet_id])["Fleets"] + fleet_res.should.have.length_of(1) + fleet_res[0].should.have.key("FleetId").equals(fleet_id) + fleet_res[0].should.have.key("ExcessCapacityTerminationPolicy").equals( + "no-termination" + ) + fleet_res[0].should.have.key("LaunchTemplateConfigs").equals([lt_config]) + fleet_res[0].should.have.key("TargetCapacitySpecification").equals( + { + "DefaultTargetCapacityType": "on-demand", + "OnDemandTargetCapacity": 10, + "SpotTargetCapacity": 10, + "TotalTargetCapacity": 30, + } + ) + fleet_res[0].should.have.key("SpotOptions").equals( + { + "AllocationStrategy": "lowest-price", + "InstanceInterruptionBehavior": "terminate", + "InstancePoolsToUseCount": 1, + "MaintenanceStrategies": { + "CapacityRebalance": { + "ReplacementStrategy": "launch-before-terminate", + "TerminationDelay": 120, + }, + }, + "MaxTotalPrice": "50", + "MinTargetCapacity": 1, + "SingleAvailabilityZone": True, + "SingleInstanceType": True, + } + ) + fleet_res[0].should.have.key("OnDemandOptions").equals( + { + "AllocationStrategy": "lowest-price", + "MaxTotalPrice": "50", + "MinTargetCapacity": 1, + "SingleAvailabilityZone": True, + "SingleInstanceType": True, + } + ) + fleet_res[0].should.have.key("ReplaceUnhealthyInstances").equals(True) + fleet_res[0].should.have.key("TerminateInstancesWithExpiration").equals(True) + fleet_res[0].should.have.key("Type").equals("maintain") + fleet_res[0].should.have.key("ValidFrom") + fleet_res[0]["ValidFrom"].isoformat().should.equal("2020-01-01T00:00:00+00:00") + fleet_res[0].should.have.key("ValidUntil") + fleet_res[0]["ValidUntil"].isoformat().should.equal("2020-12-31T00:00:00+00:00")