Skip to content

Commit e332f3c

Browse files
ec2: enhance fleet creation with LaunchTemplateAndOverrides in responses
1 parent 196ca4c commit e332f3c

4 files changed

Lines changed: 244 additions & 51 deletions

File tree

moto/ec2/models/fleets.py

Lines changed: 96 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ def __init__(
6060

6161
self.launch_specs: list[SpotFleetLaunchSpec] = []
6262

63-
launch_specs_from_config: list[dict[str, Any]] = []
6463
for config in launch_template_configs or []:
6564
launch_spec = config["LaunchTemplateSpecification"]
6665
if "LaunchTemplateId" in launch_spec:
@@ -73,33 +72,49 @@ def __init__(
7372
)
7473
else:
7574
continue
75+
76+
# Resolve $Latest or $Default to actual version number
77+
resolved_launch_spec = launch_spec.copy()
78+
if resolved_launch_spec.get("Version") == "$Latest":
79+
resolved_launch_spec["Version"] = str(
80+
launch_template.latest_version_number
81+
)
82+
elif resolved_launch_spec.get("Version") == "$Default":
83+
resolved_launch_spec["Version"] = str(
84+
launch_template.default_version_number
85+
)
86+
# Always include the template ID in response (AWS does this even when name is used)
87+
resolved_launch_spec["LaunchTemplateId"] = launch_template.id
88+
7689
launch_template_data = launch_template.latest_version().data
77-
new_launch_template = launch_template_data.copy()
78-
if config.get("Overrides"):
79-
for override in config["Overrides"]:
80-
new_launch_template.update(override)
81-
launch_specs_from_config.append(new_launch_template)
82-
83-
for spec in launch_specs_from_config:
84-
tag_spec_set = spec.get("TagSpecifications", [])
85-
tags = convert_tag_spec(tag_spec_set)
86-
tags["instance"] = tags.get("instance", {}) | instance_tags
87-
self.launch_specs.append(
88-
SpotFleetLaunchSpec(
89-
ebs_optimized=spec.get("EbsOptimized"),
90-
group_set=spec.get("GroupSet", []),
91-
iam_instance_profile=spec.get("IamInstanceProfile"),
92-
image_id=spec["ImageId"],
93-
instance_type=spec["InstanceType"],
94-
key_name=spec.get("KeyName"),
95-
monitoring=spec.get("Monitoring"),
96-
spot_price=spec.get("SpotPrice"),
97-
subnet_id=spec.get("SubnetId"),
98-
tag_specifications=tags,
99-
user_data=spec.get("UserData"),
100-
weighted_capacity=spec.get("WeightedCapacity", 1),
90+
overrides_list = config.get("Overrides") or [{}]
91+
for override in overrides_list:
92+
# Merge launch template data with override
93+
spec = launch_template_data.copy()
94+
spec.update(override)
95+
96+
tag_spec_set = spec.get("TagSpecifications", [])
97+
tags = convert_tag_spec(tag_spec_set)
98+
tags["instance"] = tags.get("instance", {}) | instance_tags
99+
100+
self.launch_specs.append(
101+
SpotFleetLaunchSpec(
102+
ebs_optimized=spec.get("EbsOptimized"),
103+
group_set=spec.get("GroupSet", []),
104+
iam_instance_profile=spec.get("IamInstanceProfile"),
105+
image_id=spec["ImageId"],
106+
instance_type=spec["InstanceType"],
107+
key_name=spec.get("KeyName"),
108+
monitoring=spec.get("Monitoring"),
109+
spot_price=spec.get("SpotPrice"),
110+
subnet_id=spec.get("SubnetId"),
111+
tag_specifications=tags,
112+
user_data=spec.get("UserData"),
113+
weighted_capacity=spec.get("WeightedCapacity", 1),
114+
launch_template_spec=resolved_launch_spec,
115+
overrides=override if override else None,
116+
)
101117
)
102-
)
103118

104119
self.spot_requests: list[SpotInstanceRequest] = []
105120
self.on_demand_instances: list[dict[str, Any]] = []
@@ -150,6 +165,59 @@ def valid_until_as_string(self) -> Optional[str]:
150165
def physical_resource_id(self) -> str:
151166
return self.id
152167

168+
@property
169+
def instances(self) -> Optional[list[dict[str, Any]]]:
170+
"""
171+
Return instances for instant fleets, None for other fleet types.
172+
This is part of the CreateFleet response for instant fleets only.
173+
"""
174+
if self.fleet_type != "instant":
175+
return None
176+
177+
instances = []
178+
179+
# Process on-demand instances
180+
for item in self.on_demand_instances:
181+
instance_data = self._build_instance_data(
182+
instance=item["instance"],
183+
lifecycle="on-demand",
184+
launch_spec=item.get("launch_spec"),
185+
)
186+
instances.append(instance_data)
187+
188+
# Process spot instances
189+
for spot_request in self.spot_requests:
190+
instance_data = self._build_instance_data(
191+
instance=spot_request.instance,
192+
lifecycle="spot",
193+
launch_spec=spot_request.launch_spec,
194+
)
195+
instances.append(instance_data)
196+
197+
return instances
198+
199+
@staticmethod
200+
def _build_instance_data(
201+
instance: Any, lifecycle: str, launch_spec: Optional[SpotFleetLaunchSpec]
202+
) -> dict[str, Any]:
203+
instance_data = {
204+
"Lifecycle": lifecycle,
205+
"InstanceIds": [instance.id],
206+
"InstanceType": instance.instance_type,
207+
}
208+
209+
# Add launch template and overrides if available
210+
if launch_spec and launch_spec.launch_template_spec:
211+
launch_template_and_overrides: dict[str, Any] = {
212+
"LaunchTemplateSpecification": launch_spec.launch_template_spec
213+
}
214+
if launch_spec.overrides:
215+
launch_template_and_overrides["Overrides"] = launch_spec.overrides
216+
217+
instance_data["LaunchTemplateAndOverrides"] = launch_template_and_overrides
218+
219+
return instance_data
220+
153221
def create_spot_requests(self, weight_to_add: float) -> list[SpotInstanceRequest]:
154222
weight_map, added_weight = self.get_launch_spec_counts(weight_to_add)
155223
for launch_spec, count in weight_map.items():
@@ -173,6 +241,7 @@ def create_spot_requests(self, weight_to_add: float) -> list[SpotInstanceRequest
173241
subnet_id=launch_spec.subnet_id,
174242
spot_fleet_id=self.id,
175243
tags=launch_spec.tag_specifications,
244+
launch_spec=launch_spec,
176245
)
177246
self.spot_requests.extend(requests)
178247
self.fulfilled_capacity += added_weight
@@ -204,6 +273,7 @@ def create_on_demand_requests(self, weight_to_add: float) -> None:
204273
{
205274
"id": reservation.id,
206275
"instance": instance,
276+
"launch_spec": launch_spec,
207277
}
208278
)
209279
self.fulfilled_capacity += added_weight

moto/ec2/models/spot_requests.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ def __init__(
6767
tags: dict[str, dict[str, str]],
6868
spot_fleet_id: Optional[str],
6969
instance_interruption_behaviour: Optional[str],
70+
launch_spec: Optional["SpotFleetLaunchSpec"] = None,
7071
):
7172
super().__init__()
7273
self.ec2_backend = ec2_backend
@@ -97,6 +98,7 @@ def __init__(
9798
)
9899
self.user_data = user_data # NOT
99100
self.spot_fleet_id = spot_fleet_id
101+
self.launch_spec = launch_spec # Track which launch spec created this request
100102
tag_map = tags.get("spot-instances-request", {})
101103
self.add_tags(tag_map)
102104
self.all_tags = tags
@@ -174,6 +176,8 @@ def __init__(
174176
tag_specifications: dict[str, dict[str, str]],
175177
user_data: Any,
176178
weighted_capacity: float,
179+
launch_template_spec: Optional[dict[str, Any]] = None,
180+
overrides: Optional[dict[str, Any]] = None,
177181
):
178182
self.ebs_optimized = ebs_optimized
179183
self.group_set = group_set
@@ -187,6 +191,8 @@ def __init__(
187191
self.tag_specifications = tag_specifications
188192
self.user_data = user_data
189193
self.weighted_capacity = float(weighted_capacity)
194+
self.launch_template_spec = launch_template_spec
195+
self.overrides = overrides
190196

191197

192198
class SpotFleetRequest(TaggedEC2Resource, CloudFormationModel):
@@ -422,6 +428,7 @@ def request_spot_instances(
422428
tags: Optional[dict[str, dict[str, str]]] = None,
423429
spot_fleet_id: Optional[str] = None,
424430
instance_interruption_behaviour: Optional[str] = None,
431+
launch_spec: Optional[SpotFleetLaunchSpec] = None,
425432
) -> list[SpotInstanceRequest]:
426433
requests = []
427434
tags = tags or {}
@@ -449,6 +456,7 @@ def request_spot_instances(
449456
tags,
450457
spot_fleet_id,
451458
instance_interruption_behaviour,
459+
launch_spec,
452460
)
453461
self.spot_instance_requests[spot_request_id] = request
454462
requests.append(request)

moto/ec2/responses/fleets.py

Lines changed: 4 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ def create_fleet(self) -> ActionResult:
7070

7171
self.error_on_dryrun()
7272

73-
request = self.ec2_backend.create_fleet(
73+
fleet = self.ec2_backend.create_fleet(
7474
on_demand_options=on_demand_options,
7575
spot_options=spot_options,
7676
target_capacity_specification=target_capacity_specification,
@@ -83,26 +83,7 @@ def create_fleet(self) -> ActionResult:
8383
valid_until=valid_until,
8484
tag_specifications=tag_specifications,
8585
)
86-
result = {"FleetId": request.id}
87-
if request.fleet_type == "instant":
88-
# On Demand and Spot Instances are stored as incompatible types on the backend,
89-
# so we have to do some extra work here.
90-
# TODO: This should be addressed on the backend.
91-
on_demand_instances = [
92-
{
93-
"Lifecycle": "on-demand",
94-
"InstanceIds": [instance["instance"].id],
95-
"InstanceType": instance["instance"].instance_type,
96-
}
97-
for instance in request.on_demand_instances
98-
]
99-
spot_requests = [
100-
{
101-
"Lifecycle": "spot",
102-
"InstanceIds": [instance.instance.id],
103-
"InstanceType": instance.instance.instance_type,
104-
}
105-
for instance in request.spot_requests
106-
]
107-
result["Instances"] = on_demand_instances + spot_requests
86+
result = {"FleetId": fleet.id}
87+
if fleet.instances is not None:
88+
result["Instances"] = fleet.instances
10889
return ActionResult(result)

tests/test_ec2/test_fleets.py

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,6 @@ def get_launch_template(conn, instance_type="t2.micro", ami_id=EXAMPLE_AMI_ID):
2727
LaunchTemplateData={
2828
"ImageId": ami_id,
2929
"InstanceType": instance_type,
30-
"KeyName": "test",
31-
"SecurityGroups": ["sg-123456"],
3230
"DisableApiTermination": False,
3331
"TagSpecifications": [
3432
{
@@ -909,3 +907,139 @@ def test_user_data():
909907
Attribute="userData",
910908
)
911909
assert attrs["UserData"]["Value"] == str(user_data)
910+
911+
912+
@pytest.mark.aws_verified
913+
@ec2_aws_verified()
914+
@pytest.mark.parametrize("version_specified", ["$Latest", "$Default"])
915+
def test_version_resolves_to_actual_version_number(version_specified, ec2_client=None):
916+
"""Test that $Latest or $Default in a fleet LaunchTemplateSpecification resolves to the actual version number"""
917+
with launch_template_context() as ctxt:
918+
fleet_response = ctxt.ec2.create_fleet(
919+
LaunchTemplateConfigs=[
920+
{
921+
"LaunchTemplateSpecification": {
922+
"LaunchTemplateId": ctxt.lt_id,
923+
"Version": version_specified,
924+
},
925+
"Overrides": [{"InstanceType": "t3.micro"}],
926+
}
927+
],
928+
TargetCapacitySpecification={
929+
"TotalTargetCapacity": 1,
930+
"OnDemandTargetCapacity": 1,
931+
"SpotTargetCapacity": 0,
932+
"DefaultTargetCapacityType": "on-demand",
933+
},
934+
OnDemandOptions={"AllocationStrategy": "lowest-price"},
935+
Type="instant",
936+
)
937+
fleet_id = fleet_response["FleetId"]
938+
instances = fleet_response["Instances"]
939+
try:
940+
for instance in instances:
941+
lt_spec_response = instance["LaunchTemplateAndOverrides"][
942+
"LaunchTemplateSpecification"
943+
]
944+
assert lt_spec_response["Version"] == "1"
945+
finally:
946+
ctxt.ec2.delete_fleets(FleetIds=[fleet_id], TerminateInstances=True)
947+
948+
949+
@pytest.mark.aws_verified
950+
@ec2_aws_verified()
951+
@pytest.mark.parametrize(
952+
"use_template_name", [False, True], ids=["template-id", "template-name"]
953+
)
954+
@pytest.mark.parametrize(
955+
["on_demand_count", "spot_count"],
956+
[
957+
pytest.param(1, 0, id="on-demand-only"),
958+
pytest.param(0, 2, id="spot-only"),
959+
pytest.param(1, 2, id="mixed-on-demand-spot"),
960+
],
961+
)
962+
@pytest.mark.parametrize(
963+
"overrides",
964+
[
965+
pytest.param(
966+
[{"InstanceType": "t3.micro"}, {"InstanceType": "t3.nano"}],
967+
id="multi-override",
968+
),
969+
pytest.param(
970+
[{"InstanceType": "t3.micro"}],
971+
id="single-override",
972+
),
973+
],
974+
)
975+
def test_create_instant_fleet_with_launch_template_overrides(
976+
use_template_name,
977+
overrides,
978+
on_demand_count,
979+
spot_count,
980+
ec2_client=None,
981+
):
982+
"""Test that LaunchTemplateAndOverrides is included in instant fleet responses"""
983+
expected_instance_types = {o["InstanceType"] for o in overrides}
984+
985+
with launch_template_context(region=ec2_client.meta.region_name) as ctxt:
986+
if use_template_name:
987+
lt_spec = {"LaunchTemplateName": ctxt.lt_name, "Version": "$Latest"}
988+
else:
989+
lt_spec = {"LaunchTemplateId": ctxt.lt_id, "Version": "$Latest"}
990+
991+
total_capacity = on_demand_count + spot_count
992+
fleet_request = {
993+
"LaunchTemplateConfigs": [
994+
{
995+
"LaunchTemplateSpecification": lt_spec,
996+
"Overrides": overrides,
997+
}
998+
],
999+
"TargetCapacitySpecification": {
1000+
"TotalTargetCapacity": total_capacity,
1001+
"OnDemandTargetCapacity": on_demand_count,
1002+
"SpotTargetCapacity": spot_count,
1003+
"DefaultTargetCapacityType": "on-demand"
1004+
if on_demand_count > 0
1005+
else "spot",
1006+
},
1007+
"Type": "instant",
1008+
}
1009+
1010+
if on_demand_count > 0:
1011+
fleet_request["OnDemandOptions"] = {"AllocationStrategy": "lowest-price"}
1012+
if spot_count > 0:
1013+
fleet_request["SpotOptions"] = {"AllocationStrategy": "lowest-price"}
1014+
1015+
fleet_response = ctxt.ec2.create_fleet(**fleet_request)
1016+
1017+
try:
1018+
assert "FleetId" in fleet_response
1019+
fleet_id = fleet_response["FleetId"]
1020+
assert "Instances" in fleet_response
1021+
instances = fleet_response["Instances"]
1022+
total_instance_ids = [i for inst in instances for i in inst["InstanceIds"]]
1023+
1024+
assert len(total_instance_ids) == total_capacity
1025+
1026+
# Verify lifecycles if mixed
1027+
if on_demand_count > 0 and spot_count > 0:
1028+
lifecycles = {instance["Lifecycle"] for instance in instances}
1029+
assert "on-demand" in lifecycles
1030+
assert "spot" in lifecycles
1031+
1032+
for instance in instances:
1033+
assert "LaunchTemplateAndOverrides" in instance
1034+
1035+
lt_and_overrides = instance["LaunchTemplateAndOverrides"]
1036+
assert "LaunchTemplateSpecification" in lt_and_overrides
1037+
1038+
lt_spec_response = lt_and_overrides["LaunchTemplateSpecification"]
1039+
assert lt_spec_response["LaunchTemplateId"] == ctxt.lt_id
1040+
1041+
assert "Overrides" in lt_and_overrides
1042+
overrides_response = lt_and_overrides["Overrides"]
1043+
assert overrides_response["InstanceType"] in expected_instance_types
1044+
finally:
1045+
ctxt.ec2.delete_fleets(FleetIds=[fleet_id], TerminateInstances=True)

0 commit comments

Comments
 (0)