Skip to main content

fakecloud_ec2/service/
instance.rs

1//! EC2 instance control plane (metadata-faithful). A Docker-backed runtime
2//! layers real container execution on top of this in a follow-up; the API
3//! surface and conformance live here.
4
5use std::collections::HashMap;
6
7use fakecloud_aws::ec2query::{ec2_elem, ec2_list, ec2_return};
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
9
10use crate::service::Ec2Service;
11use crate::service_helpers::{
12    gen_id, indexed_list, instance_limit_exceeded, invalid_parameter_value, missing_parameter,
13    parse_filters, require, require_struct, validate_enum, Filter,
14};
15use crate::state::{Ec2State, Instance, Tag};
16
17const LAUNCH_TIME: &str = "2024-01-01T00:00:00.000Z";
18
19const INSTANCE_TYPES: &[&str] = &[
20    "t3.micro",
21    "t3.small",
22    "t3.medium",
23    "t3.large",
24    "m5.large",
25    "m5.xlarge",
26    "c5.large",
27    "r5.large",
28    "t2.micro",
29];
30
31fn state_xml(tag: &str, code: i64, name: &str) -> String {
32    format!("<{tag}><code>{code}</code><name>{name}</name></{tag}>")
33}
34
35/// First three octets of a subnet CIDR's network address, e.g.
36/// `172.31.16.0/20 -> "172.31.16"`, so a synthesized private IP lands inside
37/// the subnet. Falls back to `10.0.0` for a non-IPv4-CIDR input.
38fn subnet_ip_prefix(cidr: &str) -> String {
39    let addr = cidr.split('/').next().unwrap_or(cidr);
40    let octets: Vec<&str> = addr.split('.').collect();
41    if octets.len() == 4 && octets.iter().all(|o| o.parse::<u8>().is_ok()) {
42        format!("{}.{}.{}", octets[0], octets[1], octets[2])
43    } else {
44        "10.0.0".to_string()
45    }
46}
47
48/// Map of `security-group-id -> group-name` for the whole state, so
49/// DescribeInstances can render the real `groupName` (AWS returns the name, not
50/// the id) instead of echoing the id back.
51fn sg_name_map(state: &Ec2State) -> HashMap<String, String> {
52    state
53        .security_groups
54        .values()
55        .map(|g| (g.group_id.clone(), g.group_name.clone()))
56        .collect()
57}
58
59/// Resolve an instance's CPU architecture from its AMI in the seeded/owned
60/// catalogue (arm64 for Graviton images), defaulting to x86_64 when the AMI
61/// isn't known.
62fn arch_for(state: &Ec2State, image_id: &str) -> String {
63    state
64        .images
65        .get(image_id)
66        .map(|img| img.architecture.clone())
67        .unwrap_or_else(|| "x86_64".to_string())
68}
69
70fn instance_xml(
71    i: &Instance,
72    tags: &[Tag],
73    owner: &str,
74    sg_names: &HashMap<String, String>,
75    architecture: &str,
76) -> String {
77    let groups: Vec<String> = i
78        .security_group_ids
79        .iter()
80        .map(|g| {
81            let name = sg_names.get(g).map(String::as_str).unwrap_or(g.as_str());
82            format!("{}{}", ec2_elem("groupId", g), ec2_elem("groupName", name))
83        })
84        .collect();
85    let public = i
86        .public_ip
87        .as_ref()
88        .map(|ip| {
89            format!(
90                "{}{}",
91                ec2_elem("ipAddress", ip),
92                ec2_elem(
93                    "dnsName",
94                    &format!("ec2-{}.compute.amazonaws.com", ip.replace('.', "-"))
95                )
96            )
97        })
98        .unwrap_or_default();
99    let m = &i.metadata_options;
100    let metadata_options = format!(
101        "<metadataOptions><state>applied</state><httpTokens>{}</httpTokens>\
102         <httpPutResponseHopLimit>{}</httpPutResponseHopLimit><httpEndpoint>{}</httpEndpoint>\
103         <httpProtocolIpv6>{}</httpProtocolIpv6><instanceMetadataTags>{}</instanceMetadataTags></metadataOptions>",
104        m.http_tokens,
105        m.http_put_response_hop_limit,
106        m.http_endpoint,
107        m.http_protocol_ipv6,
108        m.instance_metadata_tags,
109    );
110    let cpu_options = i
111        .cpu_options
112        .as_ref()
113        .map(|c| {
114            format!(
115                "<cpuOptions><coreCount>{}</coreCount><threadsPerCore>{}</threadsPerCore></cpuOptions>",
116                c.core_count, c.threads_per_core
117            )
118        })
119        .unwrap_or_default();
120    let private_dns_name_options = format!(
121        "<privateDnsNameOptions><hostnameType>{}</hostnameType>\
122         <enableResourceNameDnsARecord>{}</enableResourceNameDnsARecord>\
123         <enableResourceNameDnsAAAARecord>{}</enableResourceNameDnsAAAARecord></privateDnsNameOptions>",
124        i.private_dns_hostname_type.as_deref().unwrap_or("ip-name"),
125        i.enable_resource_name_dns_a_record,
126        i.enable_resource_name_dns_aaaa_record,
127    );
128    let tenancy = i.placement_tenancy.as_deref().unwrap_or("default");
129    let placement_group = i
130        .placement_group_name
131        .as_ref()
132        .map(|g| ec2_elem("groupName", g))
133        .unwrap_or_default();
134    format!(
135        "{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}{}",
136        ec2_elem("instanceId", &i.instance_id),
137        ec2_elem("imageId", &i.image_id),
138        state_xml("instanceState", i.state_code, &i.state_name),
139        ec2_elem(
140            "privateDnsName",
141            &format!("ip-{}.ec2.internal", i.private_ip.replace('.', "-"))
142        ),
143        ec2_elem("privateIpAddress", &i.private_ip),
144        public,
145        ec2_elem("instanceType", &i.instance_type),
146        ec2_elem("launchTime", &i.launch_time),
147        ec2_elem("amiLaunchIndex", &i.ami_launch_index.to_string()),
148        ec2_elem("architecture", architecture),
149        ec2_elem("rootDeviceType", "ebs"),
150        ec2_elem("rootDeviceName", "/dev/xvda"),
151        ec2_elem("virtualizationType", "hvm"),
152        ec2_elem("hypervisor", "xen"),
153        format_args!(
154            "<ebsOptimized>{}</ebsOptimized><sourceDestCheck>{}</sourceDestCheck>",
155            i.ebs_optimized, i.source_dest_check
156        ),
157        format_args!(
158            "<placement><availabilityZone>{}</availabilityZone>{}<tenancy>{}</tenancy></placement>",
159            i.az, placement_group, tenancy
160        ),
161        format_args!(
162            "<monitoring><state>{}</state></monitoring>",
163            if i.monitoring { "enabled" } else { "disabled" }
164        ),
165        format_args!(
166            "{}{}",
167            i.subnet_id
168                .as_ref()
169                .map(|s| ec2_elem("subnetId", s))
170                .unwrap_or_default(),
171            i.vpc_id
172                .as_ref()
173                .map(|s| ec2_elem("vpcId", s))
174                .unwrap_or_default(),
175        ),
176        i.key_name
177            .as_ref()
178            .map(|k| ec2_elem("keyName", k))
179            .unwrap_or_default(),
180        format_args!(
181            "{}{}",
182            ec2_list("groupSet", &groups),
183            ec2_elem("ownerId", owner)
184        ),
185        metadata_options,
186        cpu_options,
187        super::tags::tag_set_xml(tags),
188        private_dns_name_options,
189    )
190}
191
192fn reservation_xml(reservation_id: &str, owner: &str, instances: &[String]) -> String {
193    format!(
194        "{}{}{}{}",
195        ec2_elem("reservationId", reservation_id),
196        ec2_elem("ownerId", owner),
197        ec2_list("groupSet", &[]),
198        ec2_list("instancesSet", instances),
199    )
200}
201
202pub(crate) async fn run_instances(
203    svc: &Ec2Service,
204    req: &AwsRequest,
205) -> Result<AwsResponse, AwsServiceError> {
206    let min: usize = require(&req.query_params, "MinCount")?
207        .parse()
208        .map_err(|_| invalid_parameter_value("MinCount must be an integer"))?;
209    let max: usize = require(&req.query_params, "MaxCount")?
210        .parse()
211        .map_err(|_| invalid_parameter_value("MaxCount must be an integer"))?;
212    if min == 0 {
213        return Err(invalid_parameter_value("MinCount must be at least 1"));
214    }
215    if min > max {
216        return Err(invalid_parameter_value(format!(
217            "Invalid value '{max}' for parameter maxCount is invalid. The maxCount must be equal to or greater than the minCount."
218        )));
219    }
220    // Reject requests for more instances than this fake will launch. Real AWS
221    // returns `InstanceLimitExceeded` once MaxCount exceeds the per-request /
222    // account ceiling; we apply the same error rather than silently clamping
223    // (which would launch fewer than MinCount and panic for min > ceiling).
224    const MAX_INSTANCES_PER_REQUEST: usize = 64;
225    if min > MAX_INSTANCES_PER_REQUEST {
226        return Err(instance_limit_exceeded(format!(
227            "You have requested more instances ({min}) than your current instance limit of {MAX_INSTANCES_PER_REQUEST} allows for this launch."
228        )));
229    }
230    validate_enum_instance_type(req)?;
231    validate_enum(
232        &req.query_params,
233        "InstanceInitiatedShutdownBehavior",
234        &["stop", "terminate"],
235    )?;
236    // AWS best-effort launches MaxCount instances (>= MinCount). `min` is
237    // already validated to be in 1..=MAX_INSTANCES_PER_REQUEST above, so this
238    // only caps an oversized MaxCount down to the ceiling (never below MinCount,
239    // and never with `lo > hi`, which would panic).
240    let count = max.min(MAX_INSTANCES_PER_REQUEST).max(min);
241    let reservation_id = gen_id("r");
242    // ImageId is required unless a launch template (which can carry the image)
243    // is referenced. AWS returns MissingParameter rather than silently
244    // launching from a placeholder AMI.
245    let uses_launch_template = req.query_params.keys().any(|k| {
246        k.starts_with("LaunchTemplate.LaunchTemplateId")
247            || k.starts_with("LaunchTemplate.LaunchTemplateName")
248    });
249    let image_id = match req.query_params.get("ImageId").filter(|v| !v.is_empty()) {
250        Some(v) => v.clone(),
251        None if uses_launch_template => "ami-00000000000000000".to_string(),
252        None => return Err(missing_parameter("ImageId")),
253    };
254    let instance_type = req
255        .query_params
256        .get("InstanceType")
257        .cloned()
258        .unwrap_or_else(|| "t3.micro".to_string());
259    let key_name = req.query_params.get("KeyName").cloned();
260    let mut subnet_id = req.query_params.get("SubnetId").cloned();
261    let mut sg_ids = indexed_list(&req.query_params, "SecurityGroupId");
262    let user_data = req.query_params.get("UserData").cloned();
263    let owner = req.account_id.clone();
264    let az = format!(
265        "{}a",
266        if req.region.is_empty() {
267            "us-east-1"
268        } else {
269            &req.region
270        }
271    );
272
273    // Reserved `fakecloud-k8s/*` scheduling tags are read from the request's
274    // TagSpecification here, before the backing Pod is built.
275    let instance_tags = crate::service::tags::tag_specifications_for(&req.query_params, "instance");
276
277    // Resolve the VPC from the requested subnet (so the `vpc-id` filter and
278    // describe output reflect reality), and decide whether a public IP is
279    // assigned per AWS rules: explicit `AssociatePublicIpAddress=true`, else
280    // the subnet's `map_public_ip_on_launch` / default-subnet behavior. With
281    // no subnet, an instance launched into the (implicit) default VPC gets a
282    // public IP, matching the EC2-default-VPC contract.
283    let assoc_public = req
284        .query_params
285        .get("NetworkInterface.1.AssociatePublicIpAddress")
286        .or_else(|| req.query_params.get("AssociatePublicIpAddress"))
287        .map(|v| v == "true");
288    let (vpc_id, subnet_auto_public, instance_network, ip_prefix) = {
289        let accounts = svc.state.read();
290        let empty = Ec2State::new(&req.account_id, &req.region);
291        let state = accounts.get(&req.account_id).unwrap_or(&empty);
292        // No explicit subnet: land in the default VPC's default subnet for the
293        // target AZ (falling back to any default subnet), exactly as AWS does.
294        // This fills `subnet_id`/`vpc_id` so DescribeInstances reports a real
295        // subnet/VPC and phase-2 per-subnet networking has something to key on.
296        if subnet_id.is_none() {
297            if let Some(s) = state
298                .subnets
299                .values()
300                .filter(|s| s.default_for_az)
301                .find(|s| s.availability_zone == az)
302                .or_else(|| state.subnets.values().find(|s| s.default_for_az))
303            {
304                subnet_id = Some(s.subnet_id.clone());
305            }
306        }
307        // When the caller named no security group, AWS attaches the VPC's
308        // `default` group. Resolve it from the subnet's VPC (or the default VPC).
309        let resolved_vpc = subnet_id
310            .as_ref()
311            .and_then(|sid| state.subnets.get(sid))
312            .map(|s| s.vpc_id.clone());
313        // Honor `SecurityGroup.N` (group *names*, EC2-Classic/default-VPC form)
314        // by resolving each to its id, alongside the modern `SecurityGroupId.N`.
315        let sg_names = indexed_list(&req.query_params, "SecurityGroup");
316        if !sg_names.is_empty() {
317            let target_vpc = resolved_vpc
318                .clone()
319                .unwrap_or_else(|| crate::defaults::default_vpc_id(&req.account_id));
320            for name in &sg_names {
321                if let Some(sg) = state
322                    .security_groups
323                    .values()
324                    .find(|g| g.vpc_id == target_vpc && &g.group_name == name)
325                {
326                    if !sg_ids.contains(&sg.group_id) {
327                        sg_ids.push(sg.group_id.clone());
328                    }
329                }
330            }
331        }
332        if sg_ids.is_empty() {
333            let vpc = resolved_vpc
334                .clone()
335                .unwrap_or_else(|| crate::defaults::default_vpc_id(&req.account_id));
336            if let Some(sg) = state
337                .security_groups
338                .values()
339                .find(|g| g.vpc_id == vpc && g.group_name == "default")
340            {
341                sg_ids = vec![sg.group_id.clone()];
342            }
343        }
344        // The backing per-subnet network for phase-2 L3 isolation. A subnet
345        // with no `0.0.0.0/0 -> igw` route is private -> `internal` network.
346        let instance_network = subnet_id
347            .as_ref()
348            .map(|sid| crate::runtime::InstanceNetwork {
349                subnet_id: sid.clone(),
350                internal: !crate::defaults::subnet_is_public(state, sid),
351            });
352        let (vpc, auto_public) = match subnet_id.as_ref() {
353            Some(sid) => state
354                .subnets
355                .get(sid)
356                .map(|s| {
357                    (
358                        Some(s.vpc_id.clone()),
359                        s.map_public_ip_on_launch || s.default_for_az,
360                    )
361                })
362                .unwrap_or((None, false)),
363            // No subnet (and no default subnet found): still a default-VPC
364            // launch, which assigns public IPs by default.
365            None => (Some(crate::defaults::default_vpc_id(&req.account_id)), true),
366        };
367        // Metadata-only private IP base, derived from the resolved subnet's
368        // CIDR so DescribeInstances reports an IP inside the subnet (was a
369        // hard-coded 10.0.0.x outside the subnet — bug-hunt finding 1.7). A
370        // real container-backed instance overwrites this with its true bridge
371        // IP once running.
372        let ip_prefix = subnet_id
373            .as_ref()
374            .and_then(|sid| state.subnets.get(sid))
375            .map(|s| subnet_ip_prefix(&s.cidr_block))
376            .unwrap_or_else(|| "10.0.0".to_string());
377        (vpc, auto_public, instance_network, ip_prefix)
378    };
379    let assign_public = assoc_public.unwrap_or(subnet_auto_public);
380
381    // Generate instance ids; insert each instance synchronously in `pending`
382    // state (code 0), then boot the backing container in a background task
383    // that reconciles the instance to `running` (code 16) when it's up, or to
384    // `stopped` (code 80) on failure. RunInstances returns immediately so a
385    // cold image pull / k8s Pod readiness never blocks the client (mirrors
386    // RDS CreateDBInstance). With no runtime configured the instance is
387    // flipped to `running` immediately in a spawned task.
388    let ids: Vec<String> = (0..count).map(|_| gen_id("i")).collect();
389    let mut rendered = Vec::new();
390    {
391        let mut accounts = svc.state.write();
392        let state = accounts.get_or_create(&req.account_id);
393        let sg_names = sg_name_map(state);
394        for (idx, id) in ids.iter().enumerate() {
395            let inst = Instance {
396                instance_id: id.clone(),
397                image_id: image_id.clone(),
398                instance_type: instance_type.clone(),
399                state_code: 0,
400                state_name: "pending".to_string(),
401                private_ip: format!("{ip_prefix}.{}", 10 + idx),
402                public_ip: if assign_public {
403                    Some(format!("52.0.0.{}", 10 + idx))
404                } else {
405                    None
406                },
407                subnet_id: subnet_id.clone(),
408                vpc_id: vpc_id.clone(),
409                key_name: key_name.clone(),
410                security_group_ids: sg_ids.clone(),
411                reservation_id: reservation_id.clone(),
412                ami_launch_index: idx as i64,
413                monitoring: false,
414                az: az.clone(),
415                launch_time: LAUNCH_TIME.to_string(),
416                container_id: None,
417                disable_api_termination: false,
418                disable_api_stop: false,
419                source_dest_check: true,
420                ebs_optimized: false,
421                instance_initiated_shutdown_behavior: req
422                    .query_params
423                    .get("InstanceInitiatedShutdownBehavior")
424                    .cloned()
425                    .unwrap_or_else(|| "stop".to_string()),
426                user_data: user_data.clone().filter(|s| !s.is_empty()),
427                metadata_options: crate::state::MetadataOptions::default(),
428                cpu_options: None,
429                bandwidth_weighting: None,
430                maintenance_options: crate::state::MaintenanceOptions::default(),
431                placement_tenancy: None,
432                placement_affinity: None,
433                placement_group_name: req.query_params.get("Placement.GroupName").cloned(),
434                private_dns_hostname_type: None,
435                enable_resource_name_dns_a_record: false,
436                enable_resource_name_dns_aaaa_record: false,
437            };
438            crate::service::tags::apply_tag_specifications(
439                state,
440                &req.query_params,
441                id,
442                "instance",
443            );
444            let tags = state.tags_for(id).to_vec();
445            let architecture = arch_for(state, &inst.image_id);
446            rendered.push(instance_xml(&inst, &tags, &owner, &sg_names, &architecture));
447            state.instances.insert(id.clone(), inst);
448        }
449    }
450
451    // Background boot: bring up each backing container and reconcile state.
452    {
453        let svc_state = svc.state.clone();
454        let runtime = svc.runtime.clone();
455        let account_id = req.account_id.clone();
456        let ids = ids.clone();
457        let instance_network = instance_network.clone();
458        // Capture the persistence hook so the pending->running flip is written
459        // through to disk; RunInstances' own dispatch-path snapshot ran while
460        // the instance was still `pending` (M12).
461        let snapshot_hook = svc.snapshot_hook();
462        tokio::spawn(async move {
463            for id in &ids {
464                let running = if let Some(rt) = &runtime {
465                    match rt
466                        .run_instance(
467                            &account_id,
468                            id,
469                            user_data.as_deref(),
470                            &instance_tags,
471                            instance_network.as_ref(),
472                        )
473                        .await
474                    {
475                        Ok(r) => Some(r),
476                        Err(e) => {
477                            tracing::warn!(instance_id = %id, error = %e, "EC2 instance container failed to start; serving metadata-only");
478                            None
479                        }
480                    }
481                } else {
482                    None
483                };
484                reconcile_started(&svc_state, &account_id, id, running);
485            }
486            // Persist the reconciled (`running`) state so a restart restores
487            // running instances rather than resurrecting them as pending.
488            if let Some(hook) = &snapshot_hook {
489                hook().await;
490            }
491            // All instances are up with their real IPs: (re)apply the
492            // security-group firewall so the new instances are filtered
493            // (#1745 phase 3). No-op when enforcement is disabled.
494            if let Some(rt) = &runtime {
495                if rt.network_isolation_enforced() {
496                    super::firewall_model::reconcile(&svc_state, rt).await;
497                }
498            }
499        });
500    }
501
502    let body = reservation_xml(&reservation_id, &owner, &rendered);
503    Ok(Ec2Service::respond("RunInstances", &req.request_id, &body))
504}
505
506/// Flip a `pending`/`stopped` instance to `running` after its backing
507/// container is up. Re-acquires the lock and re-checks the instance still
508/// exists and hasn't been terminated by a concurrent op before writing the
509/// container handle / IP (bug-hunt 2026-06-15 finding 0.4).
510fn reconcile_started(
511    state: &crate::state::SharedEc2State,
512    account_id: &str,
513    id: &str,
514    running: Option<crate::runtime::RunningInstance>,
515) {
516    let mut accounts = state.write();
517    let Some(s) = accounts.get_mut(account_id) else {
518        return;
519    };
520    let Some(inst) = s.instances.get_mut(id) else {
521        return;
522    };
523    // A concurrent Terminate (code 48) or Stop wins: don't resurrect it.
524    if inst.state_code == 48 || inst.state_code == 80 {
525        return;
526    }
527    inst.state_code = 16;
528    inst.state_name = "running".to_string();
529    if let Some(r) = running {
530        inst.private_ip = r.private_ip;
531        inst.container_id = Some(r.container_id);
532    }
533}
534
535/// Inputs for a CloudFormation-driven `AWS::EC2::Instance` launch.
536#[derive(Debug, Clone, Default)]
537pub struct CfnInstanceSpec {
538    pub image_id: Option<String>,
539    pub instance_type: Option<String>,
540    pub subnet_id: Option<String>,
541    pub availability_zone: Option<String>,
542    pub security_group_ids: Vec<String>,
543    pub key_name: Option<String>,
544    pub user_data: Option<String>,
545    pub private_ip: Option<String>,
546}
547
548/// The Ref / GetAtt-resolvable attributes of a CFN-launched instance.
549#[derive(Debug, Clone)]
550pub struct CfnInstanceAttrs {
551    pub instance_id: String,
552    pub private_ip: String,
553    pub public_ip: Option<String>,
554    pub availability_zone: String,
555}
556
557/// Synchronously insert a control-plane `AWS::EC2::Instance` record (status
558/// `pending`) and return its Ref/GetAtt attributes. Mirrors the control-plane
559/// half of [`run_instances`] for a single instance so a CFN-provisioned
560/// instance resolves `Ref` to a real `i-...` id and `GetAtt`
561/// PrivateIp/PublicIp/AvailabilityZone immediately. The backing container is
562/// booted afterwards by [`cfn_boot_instance`] (drained off the request path).
563pub(crate) fn cfn_create_instance(
564    svc: &Ec2Service,
565    account_id: &str,
566    region: &str,
567    spec: &CfnInstanceSpec,
568) -> CfnInstanceAttrs {
569    let image_id = spec
570        .image_id
571        .clone()
572        .unwrap_or_else(|| "ami-00000000000000000".to_string());
573    let instance_type = spec
574        .instance_type
575        .clone()
576        .unwrap_or_else(|| "t3.micro".to_string());
577    let region = if region.is_empty() {
578        "us-east-1"
579    } else {
580        region
581    };
582    let mut subnet_id = spec.subnet_id.clone();
583    let mut sg_ids = spec.security_group_ids.clone();
584
585    let (vpc_id, subnet_auto_public, ip_prefix, az) = {
586        let accounts = svc.state.read();
587        let empty = Ec2State::new(account_id, region);
588        let state = accounts.get(account_id).unwrap_or(&empty);
589        // Resolve a default subnet when none is given, preferring the requested
590        // AZ, exactly like `run_instances`.
591        if subnet_id.is_none() {
592            let want_az = spec.availability_zone.clone();
593            if let Some(s) = state
594                .subnets
595                .values()
596                .filter(|s| s.default_for_az)
597                .find(|s| want_az.as_deref().is_none_or(|a| s.availability_zone == a))
598                .or_else(|| state.subnets.values().find(|s| s.default_for_az))
599            {
600                subnet_id = Some(s.subnet_id.clone());
601            }
602        }
603        let resolved_vpc = subnet_id
604            .as_ref()
605            .and_then(|sid| state.subnets.get(sid))
606            .map(|s| s.vpc_id.clone());
607        if sg_ids.is_empty() {
608            let vpc = resolved_vpc
609                .clone()
610                .unwrap_or_else(|| crate::defaults::default_vpc_id(account_id));
611            if let Some(sg) = state
612                .security_groups
613                .values()
614                .find(|g| g.vpc_id == vpc && g.group_name == "default")
615            {
616                sg_ids = vec![sg.group_id.clone()];
617            }
618        }
619        let (vpc, auto_public, az) = match subnet_id.as_ref() {
620            Some(sid) => state
621                .subnets
622                .get(sid)
623                .map(|s| {
624                    (
625                        Some(s.vpc_id.clone()),
626                        s.map_public_ip_on_launch || s.default_for_az,
627                        s.availability_zone.clone(),
628                    )
629                })
630                .unwrap_or((None, false, format!("{region}a"))),
631            None => (
632                Some(crate::defaults::default_vpc_id(account_id)),
633                true,
634                format!("{region}a"),
635            ),
636        };
637        let az = spec.availability_zone.clone().unwrap_or(az);
638        let ip_prefix = subnet_id
639            .as_ref()
640            .and_then(|sid| state.subnets.get(sid))
641            .map(|s| subnet_ip_prefix(&s.cidr_block))
642            .unwrap_or_else(|| "10.0.0".to_string());
643        (vpc, auto_public, ip_prefix, az)
644    };
645
646    let assign_public = subnet_auto_public;
647    let id = gen_id("i");
648    let private_ip = spec
649        .private_ip
650        .clone()
651        .unwrap_or_else(|| format!("{ip_prefix}.10"));
652    let public_ip = if assign_public {
653        Some("52.0.0.10".to_string())
654    } else {
655        None
656    };
657
658    {
659        let mut accounts = svc.state.write();
660        let state = accounts.get_or_create(account_id);
661        let inst = Instance {
662            instance_id: id.clone(),
663            image_id,
664            instance_type,
665            state_code: 0,
666            state_name: "pending".to_string(),
667            private_ip: private_ip.clone(),
668            public_ip: public_ip.clone(),
669            subnet_id: subnet_id.clone(),
670            vpc_id: vpc_id.clone(),
671            key_name: spec.key_name.clone(),
672            security_group_ids: sg_ids,
673            reservation_id: gen_id("r"),
674            ami_launch_index: 0,
675            monitoring: false,
676            az: az.clone(),
677            launch_time: LAUNCH_TIME.to_string(),
678            container_id: None,
679            disable_api_termination: false,
680            disable_api_stop: false,
681            source_dest_check: true,
682            ebs_optimized: false,
683            instance_initiated_shutdown_behavior: "stop".to_string(),
684            user_data: spec.user_data.clone().filter(|s| !s.is_empty()),
685            metadata_options: crate::state::MetadataOptions::default(),
686            cpu_options: None,
687            bandwidth_weighting: None,
688            maintenance_options: crate::state::MaintenanceOptions::default(),
689            placement_tenancy: None,
690            placement_affinity: None,
691            placement_group_name: None,
692            private_dns_hostname_type: None,
693            enable_resource_name_dns_a_record: false,
694            enable_resource_name_dns_aaaa_record: false,
695        };
696        state.instances.insert(id.clone(), inst);
697    }
698
699    CfnInstanceAttrs {
700        instance_id: id,
701        private_ip,
702        public_ip,
703        availability_zone: az,
704    }
705}
706
707/// Boot the backing container for a CFN-created instance and reconcile it to
708/// `running` (or metadata-only `running` when no runtime is wired). Mirrors the
709/// background-boot half of [`run_instances`]. Intended to be `tokio::spawn`ed by
710/// the CloudFormation create drain so stack creation never blocks on a cold
711/// image pull / Pod readiness.
712pub(crate) async fn cfn_boot_instance(svc: &Ec2Service, account_id: &str, id: &str) {
713    let (user_data, instance_network) = {
714        let accounts = svc.state.read();
715        let Some(state) = accounts.get(account_id) else {
716            return;
717        };
718        let Some(inst) = state.instances.get(id) else {
719            return;
720        };
721        let network = inst
722            .subnet_id
723            .as_ref()
724            .map(|sid| crate::runtime::InstanceNetwork {
725                subnet_id: sid.clone(),
726                internal: !crate::defaults::subnet_is_public(state, sid),
727            });
728        (inst.user_data.clone(), network)
729    };
730
731    let empty_tags = std::collections::BTreeMap::new();
732    let running = if let Some(rt) = &svc.runtime {
733        match rt
734            .run_instance(
735                account_id,
736                id,
737                user_data.as_deref(),
738                &empty_tags,
739                instance_network.as_ref(),
740            )
741            .await
742        {
743            Ok(r) => Some(r),
744            Err(e) => {
745                tracing::warn!(instance_id = %id, error = %e, "CFN EC2 instance container failed to start; serving metadata-only");
746                None
747            }
748        }
749    } else {
750        None
751    };
752    reconcile_started(&svc.state, account_id, id, running);
753
754    if let Some(rt) = &svc.runtime {
755        if rt.network_isolation_enforced() {
756            super::firewall_model::reconcile(&svc.state, rt).await;
757        }
758    }
759}
760
761/// Terminate a CFN-created instance (reaping its real backing container) when
762/// its stack is deleted. Routes through the real `TerminateInstances` handler so
763/// the container/Pod is stopped and the firewall re-reconciled, instead of
764/// leaking a running EC2 container. No-op if the instance is already gone.
765pub(crate) async fn cfn_terminate_instance(
766    svc: &Ec2Service,
767    account_id: &str,
768    region: &str,
769    instance_id: &str,
770) {
771    let mut query_params = std::collections::HashMap::new();
772    query_params.insert("InstanceId.1".to_string(), instance_id.to_string());
773    let req = AwsRequest {
774        service: "ec2".to_string(),
775        action: "TerminateInstances".to_string(),
776        region: region.to_string(),
777        account_id: account_id.to_string(),
778        request_id: gen_id("req"),
779        headers: http::HeaderMap::new(),
780        query_params,
781        body: bytes::Bytes::new(),
782        body_stream: parking_lot::Mutex::new(None),
783        path_segments: Vec::new(),
784        raw_path: "/".to_string(),
785        raw_query: String::new(),
786        method: http::Method::POST,
787        is_query_protocol: true,
788        access_key_id: None,
789        principal: None,
790    };
791    let _ = terminate_instances(svc, &req).await;
792}
793
794fn validate_enum_instance_type(req: &AwsRequest) -> Result<(), AwsServiceError> {
795    // InstanceType has ~850 enum members; accept any non-empty value that looks
796    // like a `family.size` token rather than enumerating them all.
797    if let Some(v) = req
798        .query_params
799        .get("InstanceType")
800        .filter(|v| !v.is_empty())
801    {
802        if !v.contains('.') {
803            return Err(invalid_parameter_value(format!(
804                "Invalid instance type '{v}'"
805            )));
806        }
807    }
808    Ok(())
809}
810
811/// Is a transition to `new_code` legal from the instance's `current` state?
812/// Terminated (48) is terminal — no transition out of it is allowed. Stop/Start
813/// from any non-terminal state is accepted (AWS is lenient on no-op
814/// transitions, e.g. stopping an already-stopped instance).
815fn transition_allowed(current: i64, new_code: i64) -> bool {
816    if current == 48 {
817        // A terminated instance can only be (re-)terminated (a no-op).
818        return new_code == 48;
819    }
820    true
821}
822
823async fn change_state(
824    svc: &Ec2Service,
825    req: &AwsRequest,
826    action: &str,
827    new_code: i64,
828    new_name: &str,
829) -> Result<AwsResponse, AwsServiceError> {
830    let ids = indexed_list(&req.query_params, "InstanceId");
831
832    // Validate existence + legal transitions BEFORE mutating anything: AWS
833    // fails the whole call (no partial application) on a bad id or illegal
834    // transition (bug-hunt 2026-06-15 findings 1.9 / 0.4).
835    {
836        let accounts = svc.state.read();
837        let empty = Ec2State::new(&req.account_id, &req.region);
838        let state = accounts.get(&req.account_id).unwrap_or(&empty);
839        for id in &ids {
840            let inst = state
841                .instances
842                .get(id)
843                .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
844            if !transition_allowed(inst.state_code, new_code) {
845                return Err(crate::service_helpers::incorrect_instance_state(
846                    id,
847                    &inst.state_name,
848                ));
849            }
850            // Termination / stop protection.
851            if new_code == 48 && inst.disable_api_termination {
852                return Err(AwsServiceError::aws_error(
853                    http::StatusCode::BAD_REQUEST,
854                    "OperationNotPermitted",
855                    format!(
856                        "The instance '{id}' may not be terminated. Modify its 'disableApiTermination' instance attribute and try again."
857                    ),
858                ));
859            }
860            if new_code == 80 && inst.disable_api_stop {
861                return Err(AwsServiceError::aws_error(
862                    http::StatusCode::BAD_REQUEST,
863                    "OperationNotPermitted",
864                    format!(
865                        "The instance '{id}' may not be stopped. Modify its 'disableApiStop' instance attribute and try again."
866                    ),
867                ));
868            }
869        }
870    }
871
872    // For StartInstances, AWS returns the instances in `pending` (not yet
873    // `running`); the container comes up in the background. Stop/Terminate
874    // apply their target state immediately (and AWS reports stopping/
875    // shutting-down transitionally, but the durable target is what callers
876    // poll for).
877    let applied_code = if new_code == 16 { 0 } else { new_code };
878    let applied_name = if new_code == 16 { "pending" } else { new_name };
879
880    let mut changes = Vec::new();
881    let mut affected: Vec<String> = Vec::new();
882    {
883        let mut accounts = svc.state.write();
884        let state = accounts.get_or_create(&req.account_id);
885        for id in &ids {
886            let (prev_code, prev_name) = state
887                .instances
888                .get(id)
889                .map(|i| (i.state_code, i.state_name.clone()))
890                .unwrap_or((16, "running".to_string()));
891            if let Some(inst) = state.instances.get_mut(id) {
892                inst.state_code = applied_code;
893                inst.state_name = applied_name.to_string();
894                if new_code == 80 || new_code == 48 {
895                    inst.public_ip = None;
896                }
897                affected.push(id.clone());
898                // A terminated instance no longer has a backing container.
899                if new_code == 48 {
900                    inst.container_id = None;
901                }
902            }
903            changes.push(format!(
904                "{}{}{}",
905                ec2_elem("instanceId", id),
906                state_xml("currentState", applied_code, applied_name),
907                state_xml("previousState", prev_code, &prev_name),
908            ));
909        }
910    }
911
912    // Drive the backing container's lifecycle in the background so the response
913    // returns immediately (bug-hunt 2026-06-15 findings 0.2 / 0.4). Each op
914    // re-checks the instance's state after its await before persisting.
915    {
916        let svc_state = svc.state.clone();
917        let runtime = svc.runtime.clone();
918        let account_id = req.account_id.clone();
919        tokio::spawn(async move {
920            for id in &affected {
921                match new_code {
922                    16 => {
923                        let running = match &runtime {
924                            Some(rt) => rt.start_instance(id).await,
925                            None => None,
926                        };
927                        reconcile_started(&svc_state, &account_id, id, running);
928                    }
929                    80 => {
930                        if let Some(rt) = &runtime {
931                            rt.stop_instance(id).await;
932                        }
933                    }
934                    48 => {
935                        if let Some(rt) = &runtime {
936                            rt.terminate_instance(id).await;
937                        }
938                    }
939                    _ => {}
940                }
941            }
942            // Lifecycle changes move/remove instances (new IP on start, gone on
943            // terminate): re-apply the security-group firewall (#1745 ph3).
944            if let Some(rt) = &runtime {
945                if rt.network_isolation_enforced() {
946                    super::firewall_model::reconcile(&svc_state, rt).await;
947                }
948            }
949        });
950    }
951
952    Ok(Ec2Service::respond(
953        action,
954        &req.request_id,
955        &ec2_list("instancesSet", &changes),
956    ))
957}
958
959pub(crate) async fn start_instances(
960    svc: &Ec2Service,
961    req: &AwsRequest,
962) -> Result<AwsResponse, AwsServiceError> {
963    change_state(svc, req, "StartInstances", 16, "running").await
964}
965pub(crate) async fn stop_instances(
966    svc: &Ec2Service,
967    req: &AwsRequest,
968) -> Result<AwsResponse, AwsServiceError> {
969    change_state(svc, req, "StopInstances", 80, "stopped").await
970}
971pub(crate) async fn terminate_instances(
972    svc: &Ec2Service,
973    req: &AwsRequest,
974) -> Result<AwsResponse, AwsServiceError> {
975    change_state(svc, req, "TerminateInstances", 48, "terminated").await
976}
977
978pub(crate) async fn reboot_instances(
979    svc: &Ec2Service,
980    req: &AwsRequest,
981) -> Result<AwsResponse, AwsServiceError> {
982    let ids = indexed_list(&req.query_params, "InstanceId");
983    // Validate existence + reject rebooting a terminated instance before doing
984    // any work (findings 1.9). AWS rejects RebootInstances on a bad id.
985    let backed: Vec<String> = {
986        let accounts = svc.state.read();
987        let empty = Ec2State::new(&req.account_id, &req.region);
988        let state = accounts.get(&req.account_id).unwrap_or(&empty);
989        let mut backed = Vec::new();
990        for id in &ids {
991            let inst = state
992                .instances
993                .get(id)
994                .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
995            if inst.state_code == 48 {
996                return Err(crate::service_helpers::incorrect_instance_state(
997                    id,
998                    &inst.state_name,
999                ));
1000            }
1001            if inst.container_id.is_some() {
1002                backed.push(id.clone());
1003            }
1004        }
1005        backed
1006    };
1007    // Reboot the backing containers in the background so the API returns
1008    // immediately (k8s Pod recreate can take up to 90s) — finding 0.2.
1009    {
1010        let svc_state = svc.state.clone();
1011        let runtime = svc.runtime.clone();
1012        let account_id = req.account_id.clone();
1013        tokio::spawn(async move {
1014            let Some(rt) = runtime else {
1015                return;
1016            };
1017            for id in &backed {
1018                // k8s reboot recreates the Pod under a new name/IP; persist them
1019                // so describe/introspection stay accurate (Docker returns None).
1020                if let Some(running) = rt.reboot_instance(id).await {
1021                    let mut accounts = svc_state.write();
1022                    if let Some(state) = accounts.get_mut(&account_id) {
1023                        if let Some(inst) = state.instances.get_mut(id) {
1024                            // Don't clobber an instance a concurrent op
1025                            // terminated mid-reboot.
1026                            if inst.state_code != 48 {
1027                                inst.private_ip = running.private_ip;
1028                                inst.container_id = Some(running.container_id);
1029                            }
1030                        }
1031                    }
1032                }
1033            }
1034            // A reboot can change the instance's IP (k8s Pod recreate), which
1035            // leaves a stale /32 in every peer's security-group rules until an
1036            // unrelated reconcile fires. Re-apply the firewall now (#1745;
1037            // bug-hunt 2026-06-18 finding 4.2). No-op when enforcement is off.
1038            if rt.network_isolation_enforced() {
1039                super::firewall_model::reconcile(&svc_state, &rt).await;
1040            }
1041        });
1042    }
1043    Ok(Ec2Service::respond(
1044        "RebootInstances",
1045        &req.request_id,
1046        &ec2_return(true),
1047    ))
1048}
1049
1050pub(crate) fn monitor_instances(
1051    svc: &Ec2Service,
1052    req: &AwsRequest,
1053) -> Result<AwsResponse, AwsServiceError> {
1054    monitor(svc, req, "MonitorInstances", true)
1055}
1056pub(crate) fn unmonitor_instances(
1057    svc: &Ec2Service,
1058    req: &AwsRequest,
1059) -> Result<AwsResponse, AwsServiceError> {
1060    monitor(svc, req, "UnmonitorInstances", false)
1061}
1062
1063fn monitor(
1064    svc: &Ec2Service,
1065    req: &AwsRequest,
1066    action: &str,
1067    enable: bool,
1068) -> Result<AwsResponse, AwsServiceError> {
1069    let ids = indexed_list(&req.query_params, "InstanceId");
1070    {
1071        let mut accounts = svc.state.write();
1072        let state = accounts.get_or_create(&req.account_id);
1073        // A nonexistent instance id is a hard error, not a silent no-op.
1074        for id in &ids {
1075            if !state.instances.contains_key(id) {
1076                return Err(crate::service_helpers::instance_not_found(id));
1077            }
1078        }
1079        for id in &ids {
1080            if let Some(i) = state.instances.get_mut(id) {
1081                i.monitoring = enable;
1082            }
1083        }
1084    }
1085    let items: Vec<String> = ids
1086        .iter()
1087        .map(|id| {
1088            format!(
1089                "{}<monitoring><state>{}</state></monitoring>",
1090                ec2_elem("instanceId", id),
1091                if enable { "pending" } else { "disabling" }
1092            )
1093        })
1094        .collect();
1095    Ok(Ec2Service::respond(
1096        action,
1097        &req.request_id,
1098        &ec2_list("instancesSet", &items),
1099    ))
1100}
1101
1102pub(crate) fn describe_instances(
1103    svc: &Ec2Service,
1104    req: &AwsRequest,
1105) -> Result<AwsResponse, AwsServiceError> {
1106    crate::service_helpers::validate_max_results(&req.query_params, 5, 1000)?;
1107    let max_results = parse_max_results(&req.query_params);
1108    let next_token = req.query_params.get("NextToken").map(String::as_str);
1109    let filters = parse_filters(&req.query_params);
1110    let wanted = indexed_list(&req.query_params, "InstanceId");
1111    let owner = req.account_id.clone();
1112    let accounts = svc.state.read();
1113    let empty = Ec2State::new(&req.account_id, &req.region);
1114    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1115
1116    // An explicitly-listed InstanceId that does not exist is a hard error on
1117    // AWS (`InvalidInstanceID.NotFound`), not an empty result set.
1118    for id in &wanted {
1119        if !state.instances.contains_key(id) {
1120            return Err(crate::service_helpers::instance_not_found(id));
1121        }
1122    }
1123
1124    // Flatten matching instances into a stable order (by reservation, then id),
1125    // then paginate over the flat instance list — AWS counts instances, not
1126    // reservations, against MaxResults.
1127    let mut matching: Vec<&Instance> = state
1128        .instances
1129        .values()
1130        .filter(|i| wanted.is_empty() || wanted.contains(&i.instance_id))
1131        .filter(|i| {
1132            inst_match(
1133                i,
1134                state.tags_for(&i.instance_id),
1135                &filters,
1136                &arch_for(state, &i.image_id),
1137            )
1138        })
1139        .collect();
1140    matching.sort_by(|a, b| {
1141        a.reservation_id
1142            .cmp(&b.reservation_id)
1143            .then(a.instance_id.cmp(&b.instance_id))
1144    });
1145    let (page, token) = crate::service_helpers::paginate(&matching, next_token, max_results);
1146
1147    // Group the page back into reservations, preserving the sorted order.
1148    let sg_names = sg_name_map(state);
1149    let mut by_res: HashMap<String, Vec<String>> = HashMap::new();
1150    let mut order: Vec<String> = Vec::new();
1151    for i in page {
1152        if !by_res.contains_key(&i.reservation_id) {
1153            order.push(i.reservation_id.clone());
1154        }
1155        by_res
1156            .entry(i.reservation_id.clone())
1157            .or_default()
1158            .push(instance_xml(
1159                i,
1160                state.tags_for(&i.instance_id),
1161                &owner,
1162                &sg_names,
1163                &arch_for(state, &i.image_id),
1164            ));
1165    }
1166    let reservations: Vec<String> = order
1167        .iter()
1168        .map(|rid| {
1169            let insts = by_res.remove(rid).unwrap_or_default();
1170            reservation_xml(rid, &owner, &insts)
1171        })
1172        .collect();
1173    let body = format!(
1174        "{}{}",
1175        ec2_list("reservationSet", &reservations),
1176        token.map(|t| ec2_elem("nextToken", &t)).unwrap_or_default(),
1177    );
1178    Ok(Ec2Service::respond(
1179        "DescribeInstances",
1180        &req.request_id,
1181        &body,
1182    ))
1183}
1184
1185/// Parse `MaxResults` into an optional usize (already range-validated by the
1186/// caller via `validate_max_results`).
1187fn parse_max_results(params: &HashMap<String, String>) -> Option<usize> {
1188    params
1189        .get("MaxResults")
1190        .filter(|v| !v.is_empty())
1191        .and_then(|v| v.parse::<usize>().ok())
1192}
1193
1194fn inst_match(i: &Instance, tags: &[Tag], filters: &[Filter], architecture: &str) -> bool {
1195    use crate::service_helpers::filter_value_matches;
1196    filters.iter().all(|f| {
1197        let candidates: Vec<String> = match f.name.as_str() {
1198            "instance-id" => vec![i.instance_id.clone()],
1199            "instance-type" => vec![i.instance_type.clone()],
1200            "image-id" => vec![i.image_id.clone()],
1201            "instance-state-name" => vec![i.state_name.clone()],
1202            "instance-state-code" => vec![i.state_code.to_string()],
1203            "vpc-id" => i.vpc_id.clone().into_iter().collect(),
1204            "subnet-id" => i.subnet_id.clone().into_iter().collect(),
1205            "availability-zone" => vec![i.az.clone()],
1206            "private-ip-address" => vec![i.private_ip.clone()],
1207            "ip-address" => i.public_ip.clone().into_iter().collect(),
1208            "key-name" => i.key_name.clone().into_iter().collect(),
1209            "architecture" => vec![architecture.to_string()],
1210            "tag-key" => tags.iter().map(|t| t.key.clone()).collect(),
1211            name => {
1212                if let Some(key) = name.strip_prefix("tag:") {
1213                    tags.iter()
1214                        .filter(|t| t.key == key)
1215                        .map(|t| t.value.clone())
1216                        .collect()
1217                } else {
1218                    // Unknown filter name: AWS rejects with InvalidParameterValue.
1219                    // Returning `false` (match nothing) is the safe approximation
1220                    // rather than `return true` (match everything) — finding 1.16.
1221                    return false;
1222                }
1223            }
1224        };
1225        f.values
1226            .iter()
1227            .any(|v| candidates.iter().any(|c| filter_value_matches(v, c)))
1228    })
1229}
1230
1231pub(crate) fn describe_instance_status(
1232    svc: &Ec2Service,
1233    req: &AwsRequest,
1234) -> Result<AwsResponse, AwsServiceError> {
1235    crate::service_helpers::validate_max_results(&req.query_params, 5, 1000)?;
1236    let max_results = parse_max_results(&req.query_params);
1237    let next_token = req.query_params.get("NextToken").map(String::as_str);
1238    let filters = parse_filters(&req.query_params);
1239    let wanted = indexed_list(&req.query_params, "InstanceId");
1240    let include_all = req
1241        .query_params
1242        .get("IncludeAllInstances")
1243        .map(|v| v == "true")
1244        .unwrap_or(false);
1245    let accounts = svc.state.read();
1246    let empty = Ec2State::new(&req.account_id, &req.region);
1247    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1248    let mut matching: Vec<&Instance> = state
1249        .instances
1250        .values()
1251        .filter(|i| wanted.is_empty() || wanted.contains(&i.instance_id))
1252        .filter(|i| include_all || i.state_name == "running")
1253        .filter(|i| {
1254            inst_match(
1255                i,
1256                state.tags_for(&i.instance_id),
1257                &filters,
1258                &arch_for(state, &i.image_id),
1259            )
1260        })
1261        .collect();
1262    matching.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
1263    let (page, token) = crate::service_helpers::paginate(&matching, next_token, max_results);
1264    let items: Vec<String> = page
1265        .iter()
1266        .map(|i| {
1267            format!(
1268                "{}{}{}{}{}{}",
1269                ec2_elem("instanceId", &i.instance_id),
1270                ec2_elem("availabilityZone", &i.az),
1271                state_xml("instanceState", i.state_code, &i.state_name),
1272                "<instanceStatus><status>ok</status></instanceStatus>",
1273                "<systemStatus><status>ok</status></systemStatus>",
1274                ec2_list("eventsSet", &[]),
1275            )
1276        })
1277        .collect();
1278    let body = format!(
1279        "{}{}",
1280        ec2_list("instanceStatusSet", &items),
1281        token.map(|t| ec2_elem("nextToken", &t)).unwrap_or_default(),
1282    );
1283    Ok(Ec2Service::respond(
1284        "DescribeInstanceStatus",
1285        &req.request_id,
1286        &body,
1287    ))
1288}
1289
1290fn instance_type_items(req: &AwsRequest) -> Vec<String> {
1291    let wanted = indexed_list(&req.query_params, "InstanceType");
1292    INSTANCE_TYPES
1293        .iter()
1294        .filter(|t| wanted.is_empty() || wanted.iter().any(|w| w == *t))
1295        .map(|t| {
1296            format!(
1297                "{}<currentGeneration>true</currentGeneration><bareMetal>false</bareMetal>\
1298                 <hypervisor>nitro</hypervisor><instanceStorageSupported>false</instanceStorageSupported>\
1299                 <processorInfo><supportedArchitectures><item>x86_64</item></supportedArchitectures></processorInfo>\
1300                 <vCpuInfo><defaultVCpus>2</defaultVCpus></vCpuInfo>\
1301                 <memoryInfo><sizeInMiB>1024</sizeInMiB></memoryInfo>\
1302                 <supportedVirtualizationTypes><item>hvm</item></supportedVirtualizationTypes>",
1303                ec2_elem("instanceType", t),
1304            )
1305        })
1306        .collect()
1307}
1308
1309pub(crate) fn describe_instance_types(
1310    _svc: &Ec2Service,
1311    req: &AwsRequest,
1312) -> Result<AwsResponse, AwsServiceError> {
1313    crate::service_helpers::validate_max_results(&req.query_params, 5, 100)?;
1314    Ok(Ec2Service::respond(
1315        "DescribeInstanceTypes",
1316        &req.request_id,
1317        &ec2_list("instanceTypeSet", &instance_type_items(req)),
1318    ))
1319}
1320
1321pub(crate) fn get_instance_types_from_requirements(
1322    _svc: &Ec2Service,
1323    req: &AwsRequest,
1324) -> Result<AwsResponse, AwsServiceError> {
1325    require_struct(&req.query_params, "InstanceRequirements")?;
1326    let items: Vec<String> = INSTANCE_TYPES
1327        .iter()
1328        .map(|t| format!("<instanceType>{t}</instanceType>"))
1329        .collect();
1330    Ok(Ec2Service::respond(
1331        "GetInstanceTypesFromInstanceRequirements",
1332        &req.request_id,
1333        &ec2_list("instanceTypeSet", &items),
1334    ))
1335}
1336
1337// ---- attributes ----
1338
1339pub(crate) fn describe_instance_attribute(
1340    svc: &Ec2Service,
1341    req: &AwsRequest,
1342) -> Result<AwsResponse, AwsServiceError> {
1343    let id = require(&req.query_params, "InstanceId")?;
1344    let attribute = require(&req.query_params, "Attribute")?;
1345    validate_enum(&req.query_params, "Attribute", ATTRIBUTE_VALUES)?;
1346    let accounts = svc.state.read();
1347    let acct_state = accounts.get(&req.account_id);
1348    let inst = acct_state
1349        .and_then(|s| s.instances.get(&id))
1350        .ok_or_else(|| crate::service_helpers::instance_not_found(&id))?;
1351    let attr_xml = match attribute.as_str() {
1352        "instanceType" => format!(
1353            "<instanceType><value>{}</value></instanceType>",
1354            inst.instance_type
1355        ),
1356        "disableApiTermination" => format!(
1357            "<disableApiTermination><value>{}</value></disableApiTermination>",
1358            inst.disable_api_termination
1359        ),
1360        "disableApiStop" => format!(
1361            "<disableApiStop><value>{}</value></disableApiStop>",
1362            inst.disable_api_stop
1363        ),
1364        "ebsOptimized" => format!(
1365            "<ebsOptimized><value>{}</value></ebsOptimized>",
1366            inst.ebs_optimized
1367        ),
1368        "sourceDestCheck" => format!(
1369            "<sourceDestCheck><value>{}</value></sourceDestCheck>",
1370            inst.source_dest_check
1371        ),
1372        "instanceInitiatedShutdownBehavior" => format!(
1373            "<instanceInitiatedShutdownBehavior><value>{}</value></instanceInitiatedShutdownBehavior>",
1374            inst.instance_initiated_shutdown_behavior
1375        ),
1376        "userData" => match &inst.user_data {
1377            Some(d) => format!("<userData><value>{d}</value></userData>"),
1378            None => "<userData/>".to_string(),
1379        },
1380        "groupSet" => {
1381            let sg_names = acct_state.map(sg_name_map).unwrap_or_default();
1382            let groups: Vec<String> = inst
1383                .security_group_ids
1384                .iter()
1385                .map(|g| {
1386                    let name = sg_names.get(g).map(String::as_str).unwrap_or(g.as_str());
1387                    format!("{}{}", ec2_elem("groupId", g), ec2_elem("groupName", name))
1388                })
1389                .collect();
1390            ec2_list("groupSet", &groups)
1391        }
1392        _ => String::new(),
1393    };
1394    let body = format!("{}{}", ec2_elem("instanceId", &id), attr_xml);
1395    Ok(Ec2Service::respond(
1396        "DescribeInstanceAttribute",
1397        &req.request_id,
1398        &body,
1399    ))
1400}
1401
1402const ATTRIBUTE_VALUES: &[&str] = &[
1403    "instanceType",
1404    "kernel",
1405    "ramdisk",
1406    "userData",
1407    "disableApiTermination",
1408    "instanceInitiatedShutdownBehavior",
1409    "rootDeviceName",
1410    "blockDeviceMapping",
1411    "productCodes",
1412    "sourceDestCheck",
1413    "groupSet",
1414    "ebsOptimized",
1415    "sriovNetSupport",
1416    "enaSupport",
1417    "enclaveOptions",
1418    "disableApiStop",
1419];
1420
1421/// Read an attribute value from either the flat `<Attr>.Value=` form (e.g.
1422/// `DisableApiTermination.Value=true`) or the bare `<Attr>=` form the CLI
1423/// sends for some attrs (`SourceDestCheck.Value`, `Attribute`+`Value`).
1424fn attr_bool(params: &HashMap<String, String>, key: &str) -> Option<bool> {
1425    params
1426        .get(&format!("{key}.Value"))
1427        .or_else(|| params.get(key))
1428        .map(|v| v == "true")
1429}
1430
1431fn attr_str<'a>(params: &'a HashMap<String, String>, key: &str) -> Option<&'a String> {
1432    params
1433        .get(&format!("{key}.Value"))
1434        .or_else(|| params.get(key))
1435}
1436
1437pub(crate) fn modify_instance_attribute(
1438    svc: &Ec2Service,
1439    req: &AwsRequest,
1440) -> Result<AwsResponse, AwsServiceError> {
1441    let id = require(&req.query_params, "InstanceId")?;
1442    // `Attribute` is only present when called in the generic form; the
1443    // convenience form passes the attribute as its own member (e.g.
1444    // `DisableApiTermination.Value`). Validate it only when present.
1445    validate_enum(&req.query_params, "Attribute", ATTRIBUTE_VALUES)?;
1446    let p = &req.query_params;
1447    let mut accounts = svc.state.write();
1448    let state = accounts.get_or_create(&req.account_id);
1449    let inst = state
1450        .instances
1451        .get_mut(&id)
1452        .ok_or_else(|| crate::service_helpers::instance_not_found(&id))?;
1453
1454    // Generic form: Attribute=<name> Value=<value>.
1455    if let Some(attr) = p.get("Attribute").filter(|v| !v.is_empty()) {
1456        let value = p.get("Value").cloned();
1457        match attr.as_str() {
1458            "instanceType" => {
1459                if let Some(v) = value {
1460                    inst.instance_type = v;
1461                }
1462            }
1463            "userData" => inst.user_data = value.filter(|s| !s.is_empty()),
1464            "disableApiTermination" => {
1465                inst.disable_api_termination = value.as_deref() == Some("true")
1466            }
1467            "disableApiStop" => inst.disable_api_stop = value.as_deref() == Some("true"),
1468            "sourceDestCheck" => inst.source_dest_check = value.as_deref() == Some("true"),
1469            "ebsOptimized" => inst.ebs_optimized = value.as_deref() == Some("true"),
1470            "instanceInitiatedShutdownBehavior" => {
1471                if let Some(v) = value {
1472                    inst.instance_initiated_shutdown_behavior = v;
1473                }
1474            }
1475            _ => {}
1476        }
1477    }
1478    // Convenience form: each modifiable attribute as its own member.
1479    if let Some(v) = attr_bool(p, "DisableApiTermination") {
1480        inst.disable_api_termination = v;
1481    }
1482    if let Some(v) = attr_bool(p, "DisableApiStop") {
1483        inst.disable_api_stop = v;
1484    }
1485    if let Some(v) = attr_bool(p, "SourceDestCheck") {
1486        inst.source_dest_check = v;
1487    }
1488    if let Some(v) = attr_bool(p, "EbsOptimized") {
1489        inst.ebs_optimized = v;
1490    }
1491    if let Some(v) = attr_str(p, "InstanceType") {
1492        inst.instance_type = v.clone();
1493    }
1494    if let Some(v) = attr_str(p, "InstanceInitiatedShutdownBehavior") {
1495        inst.instance_initiated_shutdown_behavior = v.clone();
1496    }
1497    if let Some(v) = attr_str(p, "UserData") {
1498        inst.user_data = Some(v.clone()).filter(|s| !s.is_empty());
1499    }
1500    let new_groups = indexed_list(p, "GroupId");
1501    if !new_groups.is_empty() {
1502        inst.security_group_ids = new_groups;
1503    }
1504
1505    Ok(Ec2Service::respond(
1506        "ModifyInstanceAttribute",
1507        &req.request_id,
1508        &ec2_return(true),
1509    ))
1510}
1511
1512pub(crate) fn reset_instance_attribute(
1513    svc: &Ec2Service,
1514    req: &AwsRequest,
1515) -> Result<AwsResponse, AwsServiceError> {
1516    let id = require(&req.query_params, "InstanceId")?;
1517    let attribute = require(&req.query_params, "Attribute")?;
1518    validate_enum(&req.query_params, "Attribute", ATTRIBUTE_VALUES)?;
1519    let mut accounts = svc.state.write();
1520    let state = accounts.get_or_create(&req.account_id);
1521    let inst = state
1522        .instances
1523        .get_mut(&id)
1524        .ok_or_else(|| crate::service_helpers::instance_not_found(&id))?;
1525    // Reset to AWS defaults. AWS only supports resetting kernel/ramdisk/
1526    // sourceDestCheck, but we reset the corresponding field for any attr.
1527    match attribute.as_str() {
1528        "sourceDestCheck" => inst.source_dest_check = true,
1529        "disableApiTermination" => inst.disable_api_termination = false,
1530        "disableApiStop" => inst.disable_api_stop = false,
1531        "ebsOptimized" => inst.ebs_optimized = false,
1532        "userData" => inst.user_data = None,
1533        "instanceInitiatedShutdownBehavior" => {
1534            inst.instance_initiated_shutdown_behavior = "stop".to_string()
1535        }
1536        _ => {}
1537    }
1538    Ok(Ec2Service::respond(
1539        "ResetInstanceAttribute",
1540        &req.request_id,
1541        &ec2_return(true),
1542    ))
1543}
1544
1545// ---- modify-* and misc ----
1546
1547/// Look up an instance for mutation, erroring with `InvalidInstanceID.NotFound`
1548/// when absent. Returns a write guard so the caller can mutate in place.
1549fn with_instance_mut<R>(
1550    svc: &Ec2Service,
1551    account_id: &str,
1552    id: &str,
1553    f: impl FnOnce(&mut Instance) -> R,
1554) -> Result<R, AwsServiceError> {
1555    let mut accounts = svc.state.write();
1556    let state = accounts.get_or_create(account_id);
1557    let inst = state
1558        .instances
1559        .get_mut(id)
1560        .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
1561    Ok(f(inst))
1562}
1563
1564pub(crate) fn modify_instance_placement(
1565    svc: &Ec2Service,
1566    req: &AwsRequest,
1567) -> Result<AwsResponse, AwsServiceError> {
1568    let id = require(&req.query_params, "InstanceId")?;
1569    validate_enum(
1570        &req.query_params,
1571        "Tenancy",
1572        &["default", "dedicated", "host"],
1573    )?;
1574    validate_enum(&req.query_params, "Affinity", &["default", "host"])?;
1575    let p = req.query_params.clone();
1576    with_instance_mut(svc, &req.account_id, &id, |inst| {
1577        if let Some(t) = p.get("Tenancy").filter(|v| !v.is_empty()) {
1578            inst.placement_tenancy = Some(t.clone());
1579        }
1580        if let Some(a) = p.get("Affinity").filter(|v| !v.is_empty()) {
1581            inst.placement_affinity = Some(a.clone());
1582        }
1583        if let Some(g) = p.get("GroupName") {
1584            inst.placement_group_name = Some(g.clone()).filter(|s| !s.is_empty());
1585        }
1586    })?;
1587    Ok(Ec2Service::respond(
1588        "ModifyInstancePlacement",
1589        &req.request_id,
1590        &ec2_return(true),
1591    ))
1592}
1593
1594pub(crate) fn modify_instance_metadata_options(
1595    svc: &Ec2Service,
1596    req: &AwsRequest,
1597) -> Result<AwsResponse, AwsServiceError> {
1598    let id = require(&req.query_params, "InstanceId")?;
1599    validate_enum(&req.query_params, "HttpTokens", &["optional", "required"])?;
1600    validate_enum(&req.query_params, "HttpEndpoint", &["disabled", "enabled"])?;
1601    validate_enum(
1602        &req.query_params,
1603        "HttpProtocolIpv6",
1604        &["disabled", "enabled"],
1605    )?;
1606    validate_enum(
1607        &req.query_params,
1608        "InstanceMetadataTags",
1609        &["disabled", "enabled"],
1610    )?;
1611    let p = req.query_params.clone();
1612    let opts = with_instance_mut(svc, &req.account_id, &id, |inst| {
1613        let m = &mut inst.metadata_options;
1614        if let Some(v) = p.get("HttpTokens").filter(|v| !v.is_empty()) {
1615            m.http_tokens = v.clone();
1616        }
1617        if let Some(v) = p.get("HttpEndpoint").filter(|v| !v.is_empty()) {
1618            m.http_endpoint = v.clone();
1619        }
1620        if let Some(v) = p.get("HttpProtocolIpv6").filter(|v| !v.is_empty()) {
1621            m.http_protocol_ipv6 = v.clone();
1622        }
1623        if let Some(v) = p.get("InstanceMetadataTags").filter(|v| !v.is_empty()) {
1624            m.instance_metadata_tags = v.clone();
1625        }
1626        if let Some(n) = p
1627            .get("HttpPutResponseHopLimit")
1628            .and_then(|v| v.parse::<i64>().ok())
1629        {
1630            m.http_put_response_hop_limit = n;
1631        }
1632        m.clone()
1633    })?;
1634    let body = format!(
1635        "{}<instanceMetadataOptions><state>applied</state><httpTokens>{}</httpTokens>\
1636         <httpPutResponseHopLimit>{}</httpPutResponseHopLimit><httpEndpoint>{}</httpEndpoint>\
1637         <httpProtocolIpv6>{}</httpProtocolIpv6><instanceMetadataTags>{}</instanceMetadataTags></instanceMetadataOptions>",
1638        ec2_elem("instanceId", &id),
1639        opts.http_tokens,
1640        opts.http_put_response_hop_limit,
1641        opts.http_endpoint,
1642        opts.http_protocol_ipv6,
1643        opts.instance_metadata_tags,
1644    );
1645    Ok(Ec2Service::respond(
1646        "ModifyInstanceMetadataOptions",
1647        &req.request_id,
1648        &body,
1649    ))
1650}
1651
1652pub(crate) fn modify_instance_maintenance_options(
1653    svc: &Ec2Service,
1654    req: &AwsRequest,
1655) -> Result<AwsResponse, AwsServiceError> {
1656    let id = require(&req.query_params, "InstanceId")?;
1657    validate_enum(&req.query_params, "AutoRecovery", &["disabled", "default"])?;
1658    validate_enum(
1659        &req.query_params,
1660        "RebootMigration",
1661        &["disabled", "default"],
1662    )?;
1663    let p = req.query_params.clone();
1664    let opts = with_instance_mut(svc, &req.account_id, &id, |inst| {
1665        let m = &mut inst.maintenance_options;
1666        if let Some(v) = p.get("AutoRecovery").filter(|v| !v.is_empty()) {
1667            m.auto_recovery = v.clone();
1668        }
1669        if let Some(v) = p.get("RebootMigration").filter(|v| !v.is_empty()) {
1670            m.reboot_migration = v.clone();
1671        }
1672        m.clone()
1673    })?;
1674    let body = format!(
1675        "{}<maintenanceOptions><autoRecovery>{}</autoRecovery><rebootMigration>{}</rebootMigration></maintenanceOptions>",
1676        ec2_elem("instanceId", &id),
1677        opts.auto_recovery,
1678        opts.reboot_migration,
1679    );
1680    Ok(Ec2Service::respond(
1681        "ModifyInstanceMaintenanceOptions",
1682        &req.request_id,
1683        &body,
1684    ))
1685}
1686
1687pub(crate) fn modify_instance_cpu_options(
1688    svc: &Ec2Service,
1689    req: &AwsRequest,
1690) -> Result<AwsResponse, AwsServiceError> {
1691    let id = require(&req.query_params, "InstanceId")?;
1692    validate_enum(
1693        &req.query_params,
1694        "NestedVirtualization",
1695        &["disabled", "enabled"],
1696    )?;
1697    let core_count = req
1698        .query_params
1699        .get("CoreCount")
1700        .and_then(|v| v.parse::<i64>().ok())
1701        .unwrap_or(2);
1702    let threads_per_core = req
1703        .query_params
1704        .get("ThreadsPerCore")
1705        .and_then(|v| v.parse::<i64>().ok())
1706        .unwrap_or(1);
1707    with_instance_mut(svc, &req.account_id, &id, |inst| {
1708        inst.cpu_options = Some(crate::state::CpuOptions {
1709            core_count,
1710            threads_per_core,
1711        });
1712    })?;
1713    let body = format!(
1714        "{}<coreCount>{core_count}</coreCount><threadsPerCore>{threads_per_core}</threadsPerCore>",
1715        ec2_elem("instanceId", &id)
1716    );
1717    Ok(Ec2Service::respond(
1718        "ModifyInstanceCpuOptions",
1719        &req.request_id,
1720        &body,
1721    ))
1722}
1723
1724pub(crate) fn modify_instance_network_performance_options(
1725    svc: &Ec2Service,
1726    req: &AwsRequest,
1727) -> Result<AwsResponse, AwsServiceError> {
1728    let id = require(&req.query_params, "InstanceId")?;
1729    let weighting = require(&req.query_params, "BandwidthWeighting")?;
1730    validate_enum(
1731        &req.query_params,
1732        "BandwidthWeighting",
1733        &["default", "vpc-1", "ebs-1"],
1734    )?;
1735    with_instance_mut(svc, &req.account_id, &id, |inst| {
1736        inst.bandwidth_weighting = Some(weighting.clone());
1737    })?;
1738    let body = format!(
1739        "{}<bandwidthWeighting>{weighting}</bandwidthWeighting>",
1740        ec2_elem("instanceId", &id)
1741    );
1742    Ok(Ec2Service::respond(
1743        "ModifyInstanceNetworkPerformanceOptions",
1744        &req.request_id,
1745        &body,
1746    ))
1747}
1748
1749pub(crate) fn modify_instance_event_start_time(
1750    _svc: &Ec2Service,
1751    req: &AwsRequest,
1752) -> Result<AwsResponse, AwsServiceError> {
1753    require(&req.query_params, "InstanceId")?;
1754    let event_id = require(&req.query_params, "InstanceEventId")?;
1755    require(&req.query_params, "NotBefore")?;
1756    let body = format!(
1757        "<event>{}<code>system-reboot</code><description>scheduled</description></event>",
1758        ec2_elem("instanceEventId", &event_id)
1759    );
1760    Ok(Ec2Service::respond(
1761        "ModifyInstanceEventStartTime",
1762        &req.request_id,
1763        &body,
1764    ))
1765}
1766
1767pub(crate) fn describe_instance_credit_specifications(
1768    svc: &Ec2Service,
1769    req: &AwsRequest,
1770) -> Result<AwsResponse, AwsServiceError> {
1771    crate::service_helpers::validate_max_results(&req.query_params, 5, 1000)?;
1772    let wanted = indexed_list(&req.query_params, "InstanceId");
1773    let accounts = svc.state.read();
1774    let empty = Ec2State::new(&req.account_id, &req.region);
1775    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1776    let items: Vec<String> = state
1777        .instances
1778        .values()
1779        .filter(|i| wanted.is_empty() || wanted.contains(&i.instance_id))
1780        .map(|i| {
1781            // Burstable families (t2/t3/t3a/t4g) default to `unlimited` on AWS
1782            // except t2 (`standard`); non-burstable have no credit spec. We only
1783            // track the value once explicitly set, else report `standard`.
1784            let credits = state
1785                .instance_credit_specs
1786                .get(&i.instance_id)
1787                .cloned()
1788                .unwrap_or_else(|| "standard".to_string());
1789            format!(
1790                "{}{}",
1791                ec2_elem("instanceId", &i.instance_id),
1792                ec2_elem("cpuCredits", &credits)
1793            )
1794        })
1795        .collect();
1796    Ok(Ec2Service::respond(
1797        "DescribeInstanceCreditSpecifications",
1798        &req.request_id,
1799        &ec2_list("instanceCreditSpecificationSet", &items),
1800    ))
1801}
1802
1803pub(crate) fn modify_instance_credit_specification(
1804    svc: &Ec2Service,
1805    req: &AwsRequest,
1806) -> Result<AwsResponse, AwsServiceError> {
1807    let p = &req.query_params;
1808    let mut successful = Vec::new();
1809    let mut unsuccessful = Vec::new();
1810    {
1811        let mut accounts = svc.state.write();
1812        let state = accounts.get_or_create(&req.account_id);
1813        let mut n = 1usize;
1814        loop {
1815            let id_key = format!("InstanceCreditSpecification.{n}.InstanceId");
1816            let Some(instance_id) = p.get(&id_key).cloned() else {
1817                break;
1818            };
1819            let credits = p
1820                .get(&format!("InstanceCreditSpecification.{n}.CpuCredits"))
1821                .cloned()
1822                .unwrap_or_else(|| "standard".to_string());
1823            if state.instances.contains_key(&instance_id) {
1824                state
1825                    .instance_credit_specs
1826                    .insert(instance_id.clone(), credits);
1827                successful.push(ec2_elem("instanceId", &instance_id));
1828            } else {
1829                unsuccessful.push(format!(
1830                    "{}<error><code>InvalidInstanceID.NotFound</code><message>The instance ID '{instance_id}' does not exist</message></error>",
1831                    ec2_elem("instanceId", &instance_id)
1832                ));
1833            }
1834            n += 1;
1835        }
1836    }
1837    let body = format!(
1838        "{}{}",
1839        ec2_list("successfulInstanceCreditSpecificationSet", &successful),
1840        ec2_list("unsuccessfulInstanceCreditSpecificationSet", &unsuccessful),
1841    );
1842    Ok(Ec2Service::respond(
1843        "ModifyInstanceCreditSpecification",
1844        &req.request_id,
1845        &body,
1846    ))
1847}
1848
1849pub(crate) fn get_instance_metadata_defaults(
1850    svc: &Ec2Service,
1851    req: &AwsRequest,
1852) -> Result<AwsResponse, AwsServiceError> {
1853    let accounts = svc.state.read();
1854    let d = accounts
1855        .get(&req.account_id)
1856        .and_then(|s| s.instance_metadata_defaults.clone())
1857        .unwrap_or_default();
1858    let mut inner = String::new();
1859    if let Some(v) = &d.http_tokens {
1860        inner.push_str(&ec2_elem("httpTokens", v));
1861    }
1862    if let Some(v) = &d.http_endpoint {
1863        inner.push_str(&ec2_elem("httpEndpoint", v));
1864    }
1865    if let Some(v) = d.http_put_response_hop_limit {
1866        inner.push_str(&ec2_elem("httpPutResponseHopLimit", &v.to_string()));
1867    }
1868    if let Some(v) = &d.instance_metadata_tags {
1869        inner.push_str(&ec2_elem("instanceMetadataTags", v));
1870    }
1871    if let Some(v) = &d.http_tokens_enforced {
1872        inner.push_str(&ec2_elem("httpTokensEnforced", v));
1873    }
1874    Ok(Ec2Service::respond(
1875        "GetInstanceMetadataDefaults",
1876        &req.request_id,
1877        &format!("<accountLevel>{inner}</accountLevel>"),
1878    ))
1879}
1880
1881pub(crate) fn modify_instance_metadata_defaults(
1882    svc: &Ec2Service,
1883    req: &AwsRequest,
1884) -> Result<AwsResponse, AwsServiceError> {
1885    let p = &req.query_params;
1886    validate_enum(p, "HttpTokens", &["optional", "required", "no-preference"])?;
1887    validate_enum(p, "HttpEndpoint", &["disabled", "enabled", "no-preference"])?;
1888    validate_enum(
1889        p,
1890        "InstanceMetadataTags",
1891        &["disabled", "enabled", "no-preference"],
1892    )?;
1893    validate_enum(
1894        p,
1895        "HttpTokensEnforced",
1896        &["disabled", "enabled", "no-preference"],
1897    )?;
1898    // `no-preference` resets that setting to the account default (drop it).
1899    let apply = |cur: &mut Option<String>, key: &str| {
1900        if let Some(v) = p.get(key) {
1901            *cur = if v == "no-preference" {
1902                None
1903            } else {
1904                Some(v.clone())
1905            };
1906        }
1907    };
1908    {
1909        let mut accounts = svc.state.write();
1910        let state = accounts.get_or_create(&req.account_id);
1911        let d = state
1912            .instance_metadata_defaults
1913            .get_or_insert_with(Default::default);
1914        apply(&mut d.http_tokens, "HttpTokens");
1915        apply(&mut d.http_endpoint, "HttpEndpoint");
1916        apply(&mut d.instance_metadata_tags, "InstanceMetadataTags");
1917        apply(&mut d.http_tokens_enforced, "HttpTokensEnforced");
1918        if let Some(v) = p
1919            .get("HttpPutResponseHopLimit")
1920            .and_then(|v| v.parse::<i64>().ok())
1921        {
1922            // -1 clears the account default per the EC2 API.
1923            d.http_put_response_hop_limit = if v < 0 { None } else { Some(v) };
1924        }
1925    }
1926    Ok(Ec2Service::respond(
1927        "ModifyInstanceMetadataDefaults",
1928        &req.request_id,
1929        &ec2_return(true),
1930    ))
1931}
1932
1933pub(crate) fn register_event_notification_attributes(
1934    svc: &Ec2Service,
1935    req: &AwsRequest,
1936) -> Result<AwsResponse, AwsServiceError> {
1937    let keys = indexed_sub_keys(&req.query_params);
1938    let include_all = req
1939        .query_params
1940        .get("InstanceTagAttribute.IncludeAllTagsOfInstance")
1941        .map(|v| v == "true");
1942    let xml = {
1943        let mut accounts = svc.state.write();
1944        let state = accounts.get_or_create(&req.account_id);
1945        for k in keys {
1946            if !state.event_notification_tag_keys.contains(&k) {
1947                state.event_notification_tag_keys.push(k);
1948            }
1949        }
1950        if let Some(v) = include_all {
1951            state.event_notification_include_all_tags = v;
1952        }
1953        event_tag_attribute(state)
1954    };
1955    Ok(Ec2Service::respond(
1956        "RegisterInstanceEventNotificationAttributes",
1957        &req.request_id,
1958        &xml,
1959    ))
1960}
1961
1962pub(crate) fn deregister_event_notification_attributes(
1963    svc: &Ec2Service,
1964    req: &AwsRequest,
1965) -> Result<AwsResponse, AwsServiceError> {
1966    let keys = indexed_sub_keys(&req.query_params);
1967    let include_all = req
1968        .query_params
1969        .get("InstanceTagAttribute.IncludeAllTagsOfInstance")
1970        .map(|v| v == "true");
1971    let xml = {
1972        let mut accounts = svc.state.write();
1973        let state = accounts.get_or_create(&req.account_id);
1974        state
1975            .event_notification_tag_keys
1976            .retain(|k| !keys.contains(k));
1977        // Deregistering with IncludeAllTagsOfInstance=true clears the flag.
1978        if include_all == Some(true) {
1979            state.event_notification_include_all_tags = false;
1980        }
1981        event_tag_attribute(state)
1982    };
1983    Ok(Ec2Service::respond(
1984        "DeregisterInstanceEventNotificationAttributes",
1985        &req.request_id,
1986        &xml,
1987    ))
1988}
1989
1990pub(crate) fn describe_event_notification_attributes(
1991    svc: &Ec2Service,
1992    req: &AwsRequest,
1993) -> Result<AwsResponse, AwsServiceError> {
1994    let accounts = svc.state.read();
1995    let empty = Ec2State::new(&req.account_id, &req.region);
1996    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1997    Ok(Ec2Service::respond(
1998        "DescribeInstanceEventNotificationAttributes",
1999        &req.request_id,
2000        &event_tag_attribute(state),
2001    ))
2002}
2003
2004/// Collect `InstanceTagAttribute.InstanceTagKey.N` values.
2005fn indexed_sub_keys(params: &HashMap<String, String>) -> Vec<String> {
2006    let mut out = Vec::new();
2007    let mut n = 1usize;
2008    while let Some(v) = params.get(&format!("InstanceTagAttribute.InstanceTagKey.{n}")) {
2009        out.push(v.clone());
2010        n += 1;
2011    }
2012    out
2013}
2014
2015fn event_tag_attribute(state: &Ec2State) -> String {
2016    // `ec2_list` already wraps each element in <item>; pass the (escaped) key
2017    // strings directly so the shape is <item>key</item>, not a nested pair.
2018    let keys: Vec<String> = state
2019        .event_notification_tag_keys
2020        .iter()
2021        .map(|k| fakecloud_aws::xml::xml_escape(k))
2022        .collect();
2023    format!(
2024        "<instanceTagAttribute><includeAllTagsOfInstance>{}</includeAllTagsOfInstance>{}</instanceTagAttribute>",
2025        state.event_notification_include_all_tags,
2026        ec2_list("instanceTagKeySet", &keys)
2027    )
2028}
2029
2030pub(crate) fn report_instance_status(
2031    _svc: &Ec2Service,
2032    req: &AwsRequest,
2033) -> Result<AwsResponse, AwsServiceError> {
2034    require(&req.query_params, "Status")?;
2035    validate_enum(&req.query_params, "Status", &["ok", "impaired"])?;
2036    Ok(Ec2Service::respond(
2037        "ReportInstanceStatus",
2038        &req.request_id,
2039        &ec2_return(true),
2040    ))
2041}
2042
2043pub(crate) fn describe_instance_topology(
2044    _svc: &Ec2Service,
2045    req: &AwsRequest,
2046) -> Result<AwsResponse, AwsServiceError> {
2047    crate::service_helpers::validate_max_results(&req.query_params, 1, 100)?;
2048    Ok(Ec2Service::respond(
2049        "DescribeInstanceTopology",
2050        &req.request_id,
2051        &ec2_list("instanceSet", &[]),
2052    ))
2053}
2054
2055#[cfg(test)]
2056mod tests {
2057    use super::subnet_ip_prefix;
2058
2059    #[test]
2060    fn subnet_ip_prefix_uses_subnet_network() {
2061        // The synthesized metadata IP must land inside the subnet (finding 1.7).
2062        assert_eq!(subnet_ip_prefix("172.31.16.0/20"), "172.31.16");
2063        assert_eq!(subnet_ip_prefix("10.0.5.0/24"), "10.0.5");
2064        // bare address (no mask) still works
2065        assert_eq!(subnet_ip_prefix("192.168.1.0"), "192.168.1");
2066    }
2067
2068    #[test]
2069    fn subnet_ip_prefix_falls_back_on_garbage() {
2070        assert_eq!(subnet_ip_prefix(""), "10.0.0");
2071        assert_eq!(subnet_ip_prefix("not-a-cidr"), "10.0.0");
2072        // IPv6 / non-dotted-quad falls back rather than producing nonsense
2073        assert_eq!(subnet_ip_prefix("fd00::/8"), "10.0.0");
2074    }
2075}
2076
2077#[cfg(test)]
2078mod modify_tests {
2079    use super::*;
2080
2081    fn req(action: &str, query: &[(&str, &str)]) -> AwsRequest {
2082        AwsRequest {
2083            service: "ec2".into(),
2084            action: action.into(),
2085            region: "us-east-1".into(),
2086            account_id: "000000000000".into(),
2087            request_id: "rid".into(),
2088            headers: http::HeaderMap::new(),
2089            query_params: query
2090                .iter()
2091                .map(|(k, v)| (k.to_string(), v.to_string()))
2092                .collect(),
2093            body: bytes::Bytes::new(),
2094            body_stream: parking_lot::Mutex::new(None),
2095            path_segments: Vec::new(),
2096            raw_path: "/".into(),
2097            raw_query: String::new(),
2098            method: http::Method::POST,
2099            is_query_protocol: true,
2100            access_key_id: None,
2101            principal: None,
2102        }
2103    }
2104
2105    fn body(resp: AwsResponse) -> String {
2106        String::from_utf8_lossy(resp.body.expect_bytes()).to_string()
2107    }
2108
2109    fn seed_instance(svc: &Ec2Service, id: &str) {
2110        let mut accounts = svc.state.write();
2111        let state = accounts.get_or_create("000000000000");
2112        let inst = Instance {
2113            instance_id: id.into(),
2114            image_id: "ami-1".into(),
2115            instance_type: "t3.micro".into(),
2116            state_code: 16,
2117            state_name: "running".into(),
2118            private_ip: "10.0.0.5".into(),
2119            public_ip: None,
2120            subnet_id: Some("subnet-1".into()),
2121            vpc_id: Some("vpc-1".into()),
2122            key_name: None,
2123            security_group_ids: vec![],
2124            reservation_id: "r-1".into(),
2125            ami_launch_index: 0,
2126            monitoring: false,
2127            az: "us-east-1a".into(),
2128            launch_time: "2024-01-01T00:00:00.000Z".into(),
2129            container_id: None,
2130            disable_api_termination: false,
2131            disable_api_stop: false,
2132            source_dest_check: true,
2133            ebs_optimized: false,
2134            instance_initiated_shutdown_behavior: "stop".into(),
2135            user_data: None,
2136            metadata_options: Default::default(),
2137            cpu_options: None,
2138            bandwidth_weighting: None,
2139            maintenance_options: Default::default(),
2140            placement_tenancy: None,
2141            placement_affinity: None,
2142            placement_group_name: None,
2143            private_dns_hostname_type: None,
2144            enable_resource_name_dns_a_record: false,
2145            enable_resource_name_dns_aaaa_record: false,
2146        };
2147        state.instances.insert(id.to_string(), inst);
2148    }
2149
2150    #[test]
2151    fn modify_instance_credit_specification_round_trips() {
2152        let svc = Ec2Service::new();
2153        seed_instance(&svc, "i-1");
2154        modify_instance_credit_specification(
2155            &svc,
2156            &req(
2157                "ModifyInstanceCreditSpecification",
2158                &[
2159                    ("InstanceCreditSpecification.1.InstanceId", "i-1"),
2160                    ("InstanceCreditSpecification.1.CpuCredits", "unlimited"),
2161                ],
2162            ),
2163        )
2164        .unwrap();
2165        let out = body(
2166            describe_instance_credit_specifications(
2167                &svc,
2168                &req(
2169                    "DescribeInstanceCreditSpecifications",
2170                    &[("InstanceId.1", "i-1")],
2171                ),
2172            )
2173            .unwrap(),
2174        );
2175        assert!(
2176            out.contains("<cpuCredits>unlimited</cpuCredits>"),
2177            "got: {out}"
2178        );
2179    }
2180
2181    #[test]
2182    fn modify_instance_credit_specification_unknown_is_unsuccessful() {
2183        let svc = Ec2Service::new();
2184        let out = body(
2185            modify_instance_credit_specification(
2186                &svc,
2187                &req(
2188                    "ModifyInstanceCreditSpecification",
2189                    &[("InstanceCreditSpecification.1.InstanceId", "i-missing")],
2190                ),
2191            )
2192            .unwrap(),
2193        );
2194        assert!(out.contains("InvalidInstanceID.NotFound"), "got: {out}");
2195    }
2196
2197    #[test]
2198    fn instance_metadata_defaults_round_trip_and_reset() {
2199        let svc = Ec2Service::new();
2200        modify_instance_metadata_defaults(
2201            &svc,
2202            &req(
2203                "ModifyInstanceMetadataDefaults",
2204                &[
2205                    ("HttpTokens", "required"),
2206                    ("HttpEndpoint", "enabled"),
2207                    ("HttpTokensEnforced", "enabled"),
2208                ],
2209            ),
2210        )
2211        .unwrap();
2212        let out = body(
2213            get_instance_metadata_defaults(&svc, &req("GetInstanceMetadataDefaults", &[])).unwrap(),
2214        );
2215        assert!(
2216            out.contains("<httpTokens>required</httpTokens>"),
2217            "got: {out}"
2218        );
2219        assert!(out.contains("<httpEndpoint>enabled</httpEndpoint>"));
2220        // HttpTokensEnforced must persist too (Cubic P2 on #2057).
2221        assert!(out.contains("<httpTokensEnforced>enabled</httpTokensEnforced>"));
2222
2223        // `no-preference` drops the stored default.
2224        modify_instance_metadata_defaults(
2225            &svc,
2226            &req(
2227                "ModifyInstanceMetadataDefaults",
2228                &[("HttpTokens", "no-preference")],
2229            ),
2230        )
2231        .unwrap();
2232        let out = body(
2233            get_instance_metadata_defaults(&svc, &req("GetInstanceMetadataDefaults", &[])).unwrap(),
2234        );
2235        assert!(
2236            !out.contains("<httpTokens>"),
2237            "no-preference should clear it: {out}"
2238        );
2239    }
2240
2241    #[test]
2242    fn event_notification_attributes_persist_keys() {
2243        let svc = Ec2Service::new();
2244        register_event_notification_attributes(
2245            &svc,
2246            &req(
2247                "RegisterInstanceEventNotificationAttributes",
2248                &[
2249                    ("InstanceTagAttribute.InstanceTagKey.1", "Name"),
2250                    ("InstanceTagAttribute.InstanceTagKey.2", "env"),
2251                ],
2252            ),
2253        )
2254        .unwrap();
2255        let out = body(
2256            describe_event_notification_attributes(
2257                &svc,
2258                &req("DescribeInstanceEventNotificationAttributes", &[]),
2259            )
2260            .unwrap(),
2261        );
2262        assert!(out.contains("<item>Name</item>"), "got: {out}");
2263        assert!(out.contains("<item>env</item>"));
2264        // Keys must not be double-wrapped as <item><item>key</item></item>
2265        // (Cubic P2 on #2057).
2266        assert!(!out.contains("<item><item>"), "got: {out}");
2267
2268        deregister_event_notification_attributes(
2269            &svc,
2270            &req(
2271                "DeregisterInstanceEventNotificationAttributes",
2272                &[("InstanceTagAttribute.InstanceTagKey.1", "Name")],
2273            ),
2274        )
2275        .unwrap();
2276        let out = body(
2277            describe_event_notification_attributes(
2278                &svc,
2279                &req("DescribeInstanceEventNotificationAttributes", &[]),
2280            )
2281            .unwrap(),
2282        );
2283        assert!(!out.contains("<item>Name</item>"), "got: {out}");
2284        assert!(out.contains("<item>env</item>"));
2285    }
2286
2287    #[test]
2288    fn describe_instances_explicit_missing_id_errors() {
2289        let svc = Ec2Service::new();
2290        seed_instance(&svc, "i-1");
2291        let err = crate::test_support::err_of(describe_instances(
2292            &svc,
2293            &req("DescribeInstances", &[("InstanceId.1", "i-missing")]),
2294        ));
2295        assert_eq!(err.code(), "InvalidInstanceID.NotFound");
2296    }
2297
2298    #[test]
2299    fn describe_instances_existing_id_ok() {
2300        let svc = Ec2Service::new();
2301        seed_instance(&svc, "i-1");
2302        let out = body(
2303            describe_instances(&svc, &req("DescribeInstances", &[("InstanceId.1", "i-1")]))
2304                .unwrap(),
2305        );
2306        assert!(out.contains("<instanceId>i-1</instanceId>"), "got: {out}");
2307    }
2308
2309    #[tokio::test]
2310    async fn reconcile_pending_metadata_only_flips_to_running() {
2311        let svc = Ec2Service::new();
2312        seed_instance(&svc, "i-1");
2313        // Simulate a persisted mid-boot instance.
2314        {
2315            let mut accounts = svc.state.write();
2316            let inst = accounts
2317                .get_mut("000000000000")
2318                .unwrap()
2319                .instances
2320                .get_mut("i-1")
2321                .unwrap();
2322            inst.state_code = 0;
2323            inst.state_name = "pending".into();
2324        }
2325        svc.reconcile_pending_metadata_only().await;
2326        let accounts = svc.state.read();
2327        let inst = &accounts.get("000000000000").unwrap().instances["i-1"];
2328        assert_eq!(inst.state_code, 16);
2329        assert_eq!(inst.state_name, "running");
2330    }
2331}