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
823/// Validate a single instance's state transition + stop/termination protection,
824/// returning the exact error AWS returns when it is illegal. Shared by the
825/// pre-flight read check and the re-check under the write lock so both critical
826/// sections enforce identical rules (TOCTOU-safe: a concurrent Terminate/Stop
827/// landing between the read and the write must fail this call, not be silently
828/// clobbered — bug-hunt 2026-07 finding 4.1).
829fn check_transition(inst: &Instance, id: &str, new_code: i64) -> Result<(), AwsServiceError> {
830    if !transition_allowed(inst.state_code, new_code) {
831        return Err(crate::service_helpers::incorrect_instance_state(
832            id,
833            &inst.state_name,
834        ));
835    }
836    // Termination / stop protection.
837    if new_code == 48 && inst.disable_api_termination {
838        return Err(AwsServiceError::aws_error(
839            http::StatusCode::BAD_REQUEST,
840            "OperationNotPermitted",
841            format!(
842                "The instance '{id}' may not be terminated. Modify its 'disableApiTermination' instance attribute and try again."
843            ),
844        ));
845    }
846    if new_code == 80 && inst.disable_api_stop {
847        return Err(AwsServiceError::aws_error(
848            http::StatusCode::BAD_REQUEST,
849            "OperationNotPermitted",
850            format!(
851                "The instance '{id}' may not be stopped. Modify its 'disableApiStop' instance attribute and try again."
852            ),
853        ));
854    }
855    Ok(())
856}
857
858async fn change_state(
859    svc: &Ec2Service,
860    req: &AwsRequest,
861    action: &str,
862    new_code: i64,
863    new_name: &str,
864) -> Result<AwsResponse, AwsServiceError> {
865    let ids = indexed_list(&req.query_params, "InstanceId");
866
867    // Validate existence + legal transitions BEFORE mutating anything: AWS
868    // fails the whole call (no partial application) on a bad id or illegal
869    // transition (bug-hunt 2026-06-15 findings 1.9 / 0.4).
870    {
871        let accounts = svc.state.read();
872        let empty = Ec2State::new(&req.account_id, &req.region);
873        let state = accounts.get(&req.account_id).unwrap_or(&empty);
874        for id in &ids {
875            let inst = state
876                .instances
877                .get(id)
878                .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
879            check_transition(inst, id, new_code)?;
880        }
881    }
882
883    // For StartInstances, AWS returns the instances in `pending` (not yet
884    // `running`); the container comes up in the background. Stop/Terminate
885    // apply their target state immediately (and AWS reports stopping/
886    // shutting-down transitionally, but the durable target is what callers
887    // poll for).
888    let applied_code = if new_code == 16 { 0 } else { new_code };
889    let applied_name = if new_code == 16 { "pending" } else { new_name };
890
891    let mut changes = Vec::new();
892    let mut affected: Vec<String> = Vec::new();
893    {
894        let mut accounts = svc.state.write();
895        let state = accounts.get_or_create(&req.account_id);
896        // Re-run existence + transition/protection checks against the freshly
897        // re-read state INSIDE the write lock before mutating anything. The
898        // read-phase check above ran under a different (dropped) lock, so a
899        // concurrent Terminate/Stop could have landed in between; without this
900        // re-check a StartInstances that passed the read check would overwrite
901        // a terminated instance's state code, resurrecting it past the boot
902        // task's terminal guard (bug-hunt 2026-07 finding 4.1). AWS applies the
903        // whole call atomically, so any id now failing fails the entire call
904        // with nothing mutated (the guard is still held, so no partial writes).
905        for id in &ids {
906            let inst = state
907                .instances
908                .get(id)
909                .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
910            check_transition(inst, id, new_code)?;
911        }
912        for id in &ids {
913            let (prev_code, prev_name) = state
914                .instances
915                .get(id)
916                .map(|i| (i.state_code, i.state_name.clone()))
917                .unwrap_or((16, "running".to_string()));
918            if let Some(inst) = state.instances.get_mut(id) {
919                inst.state_code = applied_code;
920                inst.state_name = applied_name.to_string();
921                if new_code == 80 || new_code == 48 {
922                    inst.public_ip = None;
923                }
924                affected.push(id.clone());
925                // A terminated instance no longer has a backing container.
926                if new_code == 48 {
927                    inst.container_id = None;
928                }
929            }
930            changes.push(format!(
931                "{}{}{}",
932                ec2_elem("instanceId", id),
933                state_xml("currentState", applied_code, applied_name),
934                state_xml("previousState", prev_code, &prev_name),
935            ));
936        }
937    }
938
939    // Drive the backing container's lifecycle in the background so the response
940    // returns immediately (bug-hunt 2026-06-15 findings 0.2 / 0.4). Each op
941    // re-checks the instance's state after its await before persisting.
942    {
943        let svc_state = svc.state.clone();
944        let runtime = svc.runtime.clone();
945        let account_id = req.account_id.clone();
946        tokio::spawn(async move {
947            for id in &affected {
948                match new_code {
949                    16 => {
950                        let running = match &runtime {
951                            Some(rt) => rt.start_instance(id).await,
952                            None => None,
953                        };
954                        reconcile_started(&svc_state, &account_id, id, running);
955                    }
956                    80 => {
957                        if let Some(rt) = &runtime {
958                            rt.stop_instance(id).await;
959                        }
960                    }
961                    48 => {
962                        if let Some(rt) = &runtime {
963                            rt.terminate_instance(id).await;
964                        }
965                    }
966                    _ => {}
967                }
968            }
969            // Lifecycle changes move/remove instances (new IP on start, gone on
970            // terminate): re-apply the security-group firewall (#1745 ph3).
971            if let Some(rt) = &runtime {
972                if rt.network_isolation_enforced() {
973                    super::firewall_model::reconcile(&svc_state, rt).await;
974                }
975            }
976        });
977    }
978
979    Ok(Ec2Service::respond(
980        action,
981        &req.request_id,
982        &ec2_list("instancesSet", &changes),
983    ))
984}
985
986pub(crate) async fn start_instances(
987    svc: &Ec2Service,
988    req: &AwsRequest,
989) -> Result<AwsResponse, AwsServiceError> {
990    change_state(svc, req, "StartInstances", 16, "running").await
991}
992pub(crate) async fn stop_instances(
993    svc: &Ec2Service,
994    req: &AwsRequest,
995) -> Result<AwsResponse, AwsServiceError> {
996    change_state(svc, req, "StopInstances", 80, "stopped").await
997}
998pub(crate) async fn terminate_instances(
999    svc: &Ec2Service,
1000    req: &AwsRequest,
1001) -> Result<AwsResponse, AwsServiceError> {
1002    change_state(svc, req, "TerminateInstances", 48, "terminated").await
1003}
1004
1005pub(crate) async fn reboot_instances(
1006    svc: &Ec2Service,
1007    req: &AwsRequest,
1008) -> Result<AwsResponse, AwsServiceError> {
1009    let ids = indexed_list(&req.query_params, "InstanceId");
1010    // Validate existence + reject rebooting a terminated instance before doing
1011    // any work (findings 1.9). AWS rejects RebootInstances on a bad id.
1012    let backed: Vec<String> = {
1013        let accounts = svc.state.read();
1014        let empty = Ec2State::new(&req.account_id, &req.region);
1015        let state = accounts.get(&req.account_id).unwrap_or(&empty);
1016        let mut backed = Vec::new();
1017        for id in &ids {
1018            let inst = state
1019                .instances
1020                .get(id)
1021                .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
1022            if inst.state_code == 48 {
1023                return Err(crate::service_helpers::incorrect_instance_state(
1024                    id,
1025                    &inst.state_name,
1026                ));
1027            }
1028            if inst.container_id.is_some() {
1029                backed.push(id.clone());
1030            }
1031        }
1032        backed
1033    };
1034    // Reboot the backing containers in the background so the API returns
1035    // immediately (k8s Pod recreate can take up to 90s) — finding 0.2.
1036    {
1037        let svc_state = svc.state.clone();
1038        let runtime = svc.runtime.clone();
1039        let account_id = req.account_id.clone();
1040        tokio::spawn(async move {
1041            let Some(rt) = runtime else {
1042                return;
1043            };
1044            for id in &backed {
1045                // k8s reboot recreates the Pod under a new name/IP; persist them
1046                // so describe/introspection stay accurate (Docker returns None).
1047                if let Some(running) = rt.reboot_instance(id).await {
1048                    let mut accounts = svc_state.write();
1049                    if let Some(state) = accounts.get_mut(&account_id) {
1050                        if let Some(inst) = state.instances.get_mut(id) {
1051                            // Don't clobber an instance a concurrent op
1052                            // terminated mid-reboot.
1053                            if inst.state_code != 48 {
1054                                inst.private_ip = running.private_ip;
1055                                inst.container_id = Some(running.container_id);
1056                            }
1057                        }
1058                    }
1059                }
1060            }
1061            // A reboot can change the instance's IP (k8s Pod recreate), which
1062            // leaves a stale /32 in every peer's security-group rules until an
1063            // unrelated reconcile fires. Re-apply the firewall now (#1745;
1064            // bug-hunt 2026-06-18 finding 4.2). No-op when enforcement is off.
1065            if rt.network_isolation_enforced() {
1066                super::firewall_model::reconcile(&svc_state, &rt).await;
1067            }
1068        });
1069    }
1070    Ok(Ec2Service::respond(
1071        "RebootInstances",
1072        &req.request_id,
1073        &ec2_return(true),
1074    ))
1075}
1076
1077pub(crate) fn monitor_instances(
1078    svc: &Ec2Service,
1079    req: &AwsRequest,
1080) -> Result<AwsResponse, AwsServiceError> {
1081    monitor(svc, req, "MonitorInstances", true)
1082}
1083pub(crate) fn unmonitor_instances(
1084    svc: &Ec2Service,
1085    req: &AwsRequest,
1086) -> Result<AwsResponse, AwsServiceError> {
1087    monitor(svc, req, "UnmonitorInstances", false)
1088}
1089
1090fn monitor(
1091    svc: &Ec2Service,
1092    req: &AwsRequest,
1093    action: &str,
1094    enable: bool,
1095) -> Result<AwsResponse, AwsServiceError> {
1096    let ids = indexed_list(&req.query_params, "InstanceId");
1097    {
1098        let mut accounts = svc.state.write();
1099        let state = accounts.get_or_create(&req.account_id);
1100        // A nonexistent instance id is a hard error, not a silent no-op.
1101        for id in &ids {
1102            if !state.instances.contains_key(id) {
1103                return Err(crate::service_helpers::instance_not_found(id));
1104            }
1105        }
1106        for id in &ids {
1107            if let Some(i) = state.instances.get_mut(id) {
1108                i.monitoring = enable;
1109            }
1110        }
1111    }
1112    let items: Vec<String> = ids
1113        .iter()
1114        .map(|id| {
1115            format!(
1116                "{}<monitoring><state>{}</state></monitoring>",
1117                ec2_elem("instanceId", id),
1118                if enable { "pending" } else { "disabling" }
1119            )
1120        })
1121        .collect();
1122    Ok(Ec2Service::respond(
1123        action,
1124        &req.request_id,
1125        &ec2_list("instancesSet", &items),
1126    ))
1127}
1128
1129pub(crate) fn describe_instances(
1130    svc: &Ec2Service,
1131    req: &AwsRequest,
1132) -> Result<AwsResponse, AwsServiceError> {
1133    crate::service_helpers::validate_max_results(&req.query_params, 5, 1000)?;
1134    let max_results = parse_max_results(&req.query_params);
1135    let next_token = req.query_params.get("NextToken").map(String::as_str);
1136    let filters = parse_filters(&req.query_params);
1137    let wanted = indexed_list(&req.query_params, "InstanceId");
1138    let owner = req.account_id.clone();
1139    let accounts = svc.state.read();
1140    let empty = Ec2State::new(&req.account_id, &req.region);
1141    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1142
1143    // An explicitly-listed InstanceId that does not exist is a hard error on
1144    // AWS (`InvalidInstanceID.NotFound`), not an empty result set.
1145    for id in &wanted {
1146        if !state.instances.contains_key(id) {
1147            return Err(crate::service_helpers::instance_not_found(id));
1148        }
1149    }
1150
1151    // Flatten matching instances into a stable order (by reservation, then id),
1152    // then paginate over the flat instance list — AWS counts instances, not
1153    // reservations, against MaxResults.
1154    let mut matching: Vec<&Instance> = state
1155        .instances
1156        .values()
1157        .filter(|i| wanted.is_empty() || wanted.contains(&i.instance_id))
1158        .filter(|i| {
1159            inst_match(
1160                i,
1161                state.tags_for(&i.instance_id),
1162                &filters,
1163                &arch_for(state, &i.image_id),
1164            )
1165        })
1166        .collect();
1167    matching.sort_by(|a, b| {
1168        a.reservation_id
1169            .cmp(&b.reservation_id)
1170            .then(a.instance_id.cmp(&b.instance_id))
1171    });
1172    let (page, token) = crate::service_helpers::paginate(&matching, next_token, max_results);
1173
1174    // Group the page back into reservations, preserving the sorted order.
1175    let sg_names = sg_name_map(state);
1176    let mut by_res: HashMap<String, Vec<String>> = HashMap::new();
1177    let mut order: Vec<String> = Vec::new();
1178    for i in page {
1179        if !by_res.contains_key(&i.reservation_id) {
1180            order.push(i.reservation_id.clone());
1181        }
1182        by_res
1183            .entry(i.reservation_id.clone())
1184            .or_default()
1185            .push(instance_xml(
1186                i,
1187                state.tags_for(&i.instance_id),
1188                &owner,
1189                &sg_names,
1190                &arch_for(state, &i.image_id),
1191            ));
1192    }
1193    let reservations: Vec<String> = order
1194        .iter()
1195        .map(|rid| {
1196            let insts = by_res.remove(rid).unwrap_or_default();
1197            reservation_xml(rid, &owner, &insts)
1198        })
1199        .collect();
1200    let body = format!(
1201        "{}{}",
1202        ec2_list("reservationSet", &reservations),
1203        token.map(|t| ec2_elem("nextToken", &t)).unwrap_or_default(),
1204    );
1205    Ok(Ec2Service::respond(
1206        "DescribeInstances",
1207        &req.request_id,
1208        &body,
1209    ))
1210}
1211
1212/// Parse `MaxResults` into an optional usize (already range-validated by the
1213/// caller via `validate_max_results`).
1214fn parse_max_results(params: &HashMap<String, String>) -> Option<usize> {
1215    params
1216        .get("MaxResults")
1217        .filter(|v| !v.is_empty())
1218        .and_then(|v| v.parse::<usize>().ok())
1219}
1220
1221fn inst_match(i: &Instance, tags: &[Tag], filters: &[Filter], architecture: &str) -> bool {
1222    use crate::service_helpers::filter_value_matches;
1223    filters.iter().all(|f| {
1224        let candidates: Vec<String> = match f.name.as_str() {
1225            "instance-id" => vec![i.instance_id.clone()],
1226            "instance-type" => vec![i.instance_type.clone()],
1227            "image-id" => vec![i.image_id.clone()],
1228            "instance-state-name" => vec![i.state_name.clone()],
1229            "instance-state-code" => vec![i.state_code.to_string()],
1230            "vpc-id" => i.vpc_id.clone().into_iter().collect(),
1231            "subnet-id" => i.subnet_id.clone().into_iter().collect(),
1232            "availability-zone" => vec![i.az.clone()],
1233            "private-ip-address" => vec![i.private_ip.clone()],
1234            "ip-address" => i.public_ip.clone().into_iter().collect(),
1235            "key-name" => i.key_name.clone().into_iter().collect(),
1236            "architecture" => vec![architecture.to_string()],
1237            "tag-key" => tags.iter().map(|t| t.key.clone()).collect(),
1238            name => {
1239                if let Some(key) = name.strip_prefix("tag:") {
1240                    tags.iter()
1241                        .filter(|t| t.key == key)
1242                        .map(|t| t.value.clone())
1243                        .collect()
1244                } else {
1245                    // Unknown filter name: AWS rejects with InvalidParameterValue.
1246                    // Returning `false` (match nothing) is the safe approximation
1247                    // rather than `return true` (match everything) — finding 1.16.
1248                    return false;
1249                }
1250            }
1251        };
1252        f.values
1253            .iter()
1254            .any(|v| candidates.iter().any(|c| filter_value_matches(v, c)))
1255    })
1256}
1257
1258pub(crate) fn describe_instance_status(
1259    svc: &Ec2Service,
1260    req: &AwsRequest,
1261) -> Result<AwsResponse, AwsServiceError> {
1262    crate::service_helpers::validate_max_results(&req.query_params, 5, 1000)?;
1263    let max_results = parse_max_results(&req.query_params);
1264    let next_token = req.query_params.get("NextToken").map(String::as_str);
1265    let filters = parse_filters(&req.query_params);
1266    let wanted = indexed_list(&req.query_params, "InstanceId");
1267    let include_all = req
1268        .query_params
1269        .get("IncludeAllInstances")
1270        .map(|v| v == "true")
1271        .unwrap_or(false);
1272    let accounts = svc.state.read();
1273    let empty = Ec2State::new(&req.account_id, &req.region);
1274    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1275    let mut matching: Vec<&Instance> = state
1276        .instances
1277        .values()
1278        .filter(|i| wanted.is_empty() || wanted.contains(&i.instance_id))
1279        .filter(|i| include_all || i.state_name == "running")
1280        .filter(|i| {
1281            inst_match(
1282                i,
1283                state.tags_for(&i.instance_id),
1284                &filters,
1285                &arch_for(state, &i.image_id),
1286            )
1287        })
1288        .collect();
1289    matching.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
1290    let (page, token) = crate::service_helpers::paginate(&matching, next_token, max_results);
1291    let items: Vec<String> = page
1292        .iter()
1293        .map(|i| {
1294            format!(
1295                "{}{}{}{}{}{}",
1296                ec2_elem("instanceId", &i.instance_id),
1297                ec2_elem("availabilityZone", &i.az),
1298                state_xml("instanceState", i.state_code, &i.state_name),
1299                "<instanceStatus><status>ok</status></instanceStatus>",
1300                "<systemStatus><status>ok</status></systemStatus>",
1301                ec2_list("eventsSet", &[]),
1302            )
1303        })
1304        .collect();
1305    let body = format!(
1306        "{}{}",
1307        ec2_list("instanceStatusSet", &items),
1308        token.map(|t| ec2_elem("nextToken", &t)).unwrap_or_default(),
1309    );
1310    Ok(Ec2Service::respond(
1311        "DescribeInstanceStatus",
1312        &req.request_id,
1313        &body,
1314    ))
1315}
1316
1317fn instance_type_items(req: &AwsRequest) -> Vec<String> {
1318    let wanted = indexed_list(&req.query_params, "InstanceType");
1319    INSTANCE_TYPES
1320        .iter()
1321        .filter(|t| wanted.is_empty() || wanted.iter().any(|w| w == *t))
1322        .map(|t| {
1323            format!(
1324                "{}<currentGeneration>true</currentGeneration><bareMetal>false</bareMetal>\
1325                 <hypervisor>nitro</hypervisor><instanceStorageSupported>false</instanceStorageSupported>\
1326                 <processorInfo><supportedArchitectures><item>x86_64</item></supportedArchitectures></processorInfo>\
1327                 <vCpuInfo><defaultVCpus>2</defaultVCpus></vCpuInfo>\
1328                 <memoryInfo><sizeInMiB>1024</sizeInMiB></memoryInfo>\
1329                 <supportedVirtualizationTypes><item>hvm</item></supportedVirtualizationTypes>",
1330                ec2_elem("instanceType", t),
1331            )
1332        })
1333        .collect()
1334}
1335
1336pub(crate) fn describe_instance_types(
1337    _svc: &Ec2Service,
1338    req: &AwsRequest,
1339) -> Result<AwsResponse, AwsServiceError> {
1340    crate::service_helpers::validate_max_results(&req.query_params, 5, 100)?;
1341    Ok(Ec2Service::respond(
1342        "DescribeInstanceTypes",
1343        &req.request_id,
1344        &ec2_list("instanceTypeSet", &instance_type_items(req)),
1345    ))
1346}
1347
1348pub(crate) fn get_instance_types_from_requirements(
1349    _svc: &Ec2Service,
1350    req: &AwsRequest,
1351) -> Result<AwsResponse, AwsServiceError> {
1352    require_struct(&req.query_params, "InstanceRequirements")?;
1353    let items: Vec<String> = INSTANCE_TYPES
1354        .iter()
1355        .map(|t| format!("<instanceType>{t}</instanceType>"))
1356        .collect();
1357    Ok(Ec2Service::respond(
1358        "GetInstanceTypesFromInstanceRequirements",
1359        &req.request_id,
1360        &ec2_list("instanceTypeSet", &items),
1361    ))
1362}
1363
1364// ---- attributes ----
1365
1366pub(crate) fn describe_instance_attribute(
1367    svc: &Ec2Service,
1368    req: &AwsRequest,
1369) -> Result<AwsResponse, AwsServiceError> {
1370    let id = require(&req.query_params, "InstanceId")?;
1371    let attribute = require(&req.query_params, "Attribute")?;
1372    validate_enum(&req.query_params, "Attribute", ATTRIBUTE_VALUES)?;
1373    let accounts = svc.state.read();
1374    let acct_state = accounts.get(&req.account_id);
1375    let inst = acct_state
1376        .and_then(|s| s.instances.get(&id))
1377        .ok_or_else(|| crate::service_helpers::instance_not_found(&id))?;
1378    let attr_xml = match attribute.as_str() {
1379        "instanceType" => format!(
1380            "<instanceType><value>{}</value></instanceType>",
1381            inst.instance_type
1382        ),
1383        "disableApiTermination" => format!(
1384            "<disableApiTermination><value>{}</value></disableApiTermination>",
1385            inst.disable_api_termination
1386        ),
1387        "disableApiStop" => format!(
1388            "<disableApiStop><value>{}</value></disableApiStop>",
1389            inst.disable_api_stop
1390        ),
1391        "ebsOptimized" => format!(
1392            "<ebsOptimized><value>{}</value></ebsOptimized>",
1393            inst.ebs_optimized
1394        ),
1395        "sourceDestCheck" => format!(
1396            "<sourceDestCheck><value>{}</value></sourceDestCheck>",
1397            inst.source_dest_check
1398        ),
1399        "instanceInitiatedShutdownBehavior" => format!(
1400            "<instanceInitiatedShutdownBehavior><value>{}</value></instanceInitiatedShutdownBehavior>",
1401            inst.instance_initiated_shutdown_behavior
1402        ),
1403        "userData" => match &inst.user_data {
1404            Some(d) => format!("<userData><value>{d}</value></userData>"),
1405            None => "<userData/>".to_string(),
1406        },
1407        "groupSet" => {
1408            let sg_names = acct_state.map(sg_name_map).unwrap_or_default();
1409            let groups: Vec<String> = inst
1410                .security_group_ids
1411                .iter()
1412                .map(|g| {
1413                    let name = sg_names.get(g).map(String::as_str).unwrap_or(g.as_str());
1414                    format!("{}{}", ec2_elem("groupId", g), ec2_elem("groupName", name))
1415                })
1416                .collect();
1417            ec2_list("groupSet", &groups)
1418        }
1419        _ => String::new(),
1420    };
1421    let body = format!("{}{}", ec2_elem("instanceId", &id), attr_xml);
1422    Ok(Ec2Service::respond(
1423        "DescribeInstanceAttribute",
1424        &req.request_id,
1425        &body,
1426    ))
1427}
1428
1429const ATTRIBUTE_VALUES: &[&str] = &[
1430    "instanceType",
1431    "kernel",
1432    "ramdisk",
1433    "userData",
1434    "disableApiTermination",
1435    "instanceInitiatedShutdownBehavior",
1436    "rootDeviceName",
1437    "blockDeviceMapping",
1438    "productCodes",
1439    "sourceDestCheck",
1440    "groupSet",
1441    "ebsOptimized",
1442    "sriovNetSupport",
1443    "enaSupport",
1444    "enclaveOptions",
1445    "disableApiStop",
1446];
1447
1448/// Read an attribute value from either the flat `<Attr>.Value=` form (e.g.
1449/// `DisableApiTermination.Value=true`) or the bare `<Attr>=` form the CLI
1450/// sends for some attrs (`SourceDestCheck.Value`, `Attribute`+`Value`).
1451fn attr_bool(params: &HashMap<String, String>, key: &str) -> Option<bool> {
1452    params
1453        .get(&format!("{key}.Value"))
1454        .or_else(|| params.get(key))
1455        .map(|v| v == "true")
1456}
1457
1458fn attr_str<'a>(params: &'a HashMap<String, String>, key: &str) -> Option<&'a String> {
1459    params
1460        .get(&format!("{key}.Value"))
1461        .or_else(|| params.get(key))
1462}
1463
1464pub(crate) fn modify_instance_attribute(
1465    svc: &Ec2Service,
1466    req: &AwsRequest,
1467) -> Result<AwsResponse, AwsServiceError> {
1468    let id = require(&req.query_params, "InstanceId")?;
1469    // `Attribute` is only present when called in the generic form; the
1470    // convenience form passes the attribute as its own member (e.g.
1471    // `DisableApiTermination.Value`). Validate it only when present.
1472    validate_enum(&req.query_params, "Attribute", ATTRIBUTE_VALUES)?;
1473    let p = &req.query_params;
1474    let mut accounts = svc.state.write();
1475    let state = accounts.get_or_create(&req.account_id);
1476    let inst = state
1477        .instances
1478        .get_mut(&id)
1479        .ok_or_else(|| crate::service_helpers::instance_not_found(&id))?;
1480
1481    // Generic form: Attribute=<name> Value=<value>.
1482    if let Some(attr) = p.get("Attribute").filter(|v| !v.is_empty()) {
1483        let value = p.get("Value").cloned();
1484        match attr.as_str() {
1485            "instanceType" => {
1486                if let Some(v) = value {
1487                    inst.instance_type = v;
1488                }
1489            }
1490            "userData" => inst.user_data = value.filter(|s| !s.is_empty()),
1491            "disableApiTermination" => {
1492                inst.disable_api_termination = value.as_deref() == Some("true")
1493            }
1494            "disableApiStop" => inst.disable_api_stop = value.as_deref() == Some("true"),
1495            "sourceDestCheck" => inst.source_dest_check = value.as_deref() == Some("true"),
1496            "ebsOptimized" => inst.ebs_optimized = value.as_deref() == Some("true"),
1497            "instanceInitiatedShutdownBehavior" => {
1498                if let Some(v) = value {
1499                    inst.instance_initiated_shutdown_behavior = v;
1500                }
1501            }
1502            _ => {}
1503        }
1504    }
1505    // Convenience form: each modifiable attribute as its own member.
1506    if let Some(v) = attr_bool(p, "DisableApiTermination") {
1507        inst.disable_api_termination = v;
1508    }
1509    if let Some(v) = attr_bool(p, "DisableApiStop") {
1510        inst.disable_api_stop = v;
1511    }
1512    if let Some(v) = attr_bool(p, "SourceDestCheck") {
1513        inst.source_dest_check = v;
1514    }
1515    if let Some(v) = attr_bool(p, "EbsOptimized") {
1516        inst.ebs_optimized = v;
1517    }
1518    if let Some(v) = attr_str(p, "InstanceType") {
1519        inst.instance_type = v.clone();
1520    }
1521    if let Some(v) = attr_str(p, "InstanceInitiatedShutdownBehavior") {
1522        inst.instance_initiated_shutdown_behavior = v.clone();
1523    }
1524    if let Some(v) = attr_str(p, "UserData") {
1525        inst.user_data = Some(v.clone()).filter(|s| !s.is_empty());
1526    }
1527    let new_groups = indexed_list(p, "GroupId");
1528    if !new_groups.is_empty() {
1529        inst.security_group_ids = new_groups;
1530    }
1531
1532    Ok(Ec2Service::respond(
1533        "ModifyInstanceAttribute",
1534        &req.request_id,
1535        &ec2_return(true),
1536    ))
1537}
1538
1539pub(crate) fn reset_instance_attribute(
1540    svc: &Ec2Service,
1541    req: &AwsRequest,
1542) -> Result<AwsResponse, AwsServiceError> {
1543    let id = require(&req.query_params, "InstanceId")?;
1544    let attribute = require(&req.query_params, "Attribute")?;
1545    validate_enum(&req.query_params, "Attribute", ATTRIBUTE_VALUES)?;
1546    let mut accounts = svc.state.write();
1547    let state = accounts.get_or_create(&req.account_id);
1548    let inst = state
1549        .instances
1550        .get_mut(&id)
1551        .ok_or_else(|| crate::service_helpers::instance_not_found(&id))?;
1552    // Reset to AWS defaults. AWS only supports resetting kernel/ramdisk/
1553    // sourceDestCheck, but we reset the corresponding field for any attr.
1554    match attribute.as_str() {
1555        "sourceDestCheck" => inst.source_dest_check = true,
1556        "disableApiTermination" => inst.disable_api_termination = false,
1557        "disableApiStop" => inst.disable_api_stop = false,
1558        "ebsOptimized" => inst.ebs_optimized = false,
1559        "userData" => inst.user_data = None,
1560        "instanceInitiatedShutdownBehavior" => {
1561            inst.instance_initiated_shutdown_behavior = "stop".to_string()
1562        }
1563        _ => {}
1564    }
1565    Ok(Ec2Service::respond(
1566        "ResetInstanceAttribute",
1567        &req.request_id,
1568        &ec2_return(true),
1569    ))
1570}
1571
1572// ---- modify-* and misc ----
1573
1574/// Look up an instance for mutation, erroring with `InvalidInstanceID.NotFound`
1575/// when absent. Returns a write guard so the caller can mutate in place.
1576fn with_instance_mut<R>(
1577    svc: &Ec2Service,
1578    account_id: &str,
1579    id: &str,
1580    f: impl FnOnce(&mut Instance) -> R,
1581) -> Result<R, AwsServiceError> {
1582    let mut accounts = svc.state.write();
1583    let state = accounts.get_or_create(account_id);
1584    let inst = state
1585        .instances
1586        .get_mut(id)
1587        .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
1588    Ok(f(inst))
1589}
1590
1591pub(crate) fn modify_instance_placement(
1592    svc: &Ec2Service,
1593    req: &AwsRequest,
1594) -> Result<AwsResponse, AwsServiceError> {
1595    let id = require(&req.query_params, "InstanceId")?;
1596    validate_enum(
1597        &req.query_params,
1598        "Tenancy",
1599        &["default", "dedicated", "host"],
1600    )?;
1601    validate_enum(&req.query_params, "Affinity", &["default", "host"])?;
1602    let p = req.query_params.clone();
1603    with_instance_mut(svc, &req.account_id, &id, |inst| {
1604        if let Some(t) = p.get("Tenancy").filter(|v| !v.is_empty()) {
1605            inst.placement_tenancy = Some(t.clone());
1606        }
1607        if let Some(a) = p.get("Affinity").filter(|v| !v.is_empty()) {
1608            inst.placement_affinity = Some(a.clone());
1609        }
1610        if let Some(g) = p.get("GroupName") {
1611            inst.placement_group_name = Some(g.clone()).filter(|s| !s.is_empty());
1612        }
1613    })?;
1614    Ok(Ec2Service::respond(
1615        "ModifyInstancePlacement",
1616        &req.request_id,
1617        &ec2_return(true),
1618    ))
1619}
1620
1621pub(crate) fn modify_instance_metadata_options(
1622    svc: &Ec2Service,
1623    req: &AwsRequest,
1624) -> Result<AwsResponse, AwsServiceError> {
1625    let id = require(&req.query_params, "InstanceId")?;
1626    validate_enum(&req.query_params, "HttpTokens", &["optional", "required"])?;
1627    validate_enum(&req.query_params, "HttpEndpoint", &["disabled", "enabled"])?;
1628    validate_enum(
1629        &req.query_params,
1630        "HttpProtocolIpv6",
1631        &["disabled", "enabled"],
1632    )?;
1633    validate_enum(
1634        &req.query_params,
1635        "InstanceMetadataTags",
1636        &["disabled", "enabled"],
1637    )?;
1638    let p = req.query_params.clone();
1639    let opts = with_instance_mut(svc, &req.account_id, &id, |inst| {
1640        let m = &mut inst.metadata_options;
1641        if let Some(v) = p.get("HttpTokens").filter(|v| !v.is_empty()) {
1642            m.http_tokens = v.clone();
1643        }
1644        if let Some(v) = p.get("HttpEndpoint").filter(|v| !v.is_empty()) {
1645            m.http_endpoint = v.clone();
1646        }
1647        if let Some(v) = p.get("HttpProtocolIpv6").filter(|v| !v.is_empty()) {
1648            m.http_protocol_ipv6 = v.clone();
1649        }
1650        if let Some(v) = p.get("InstanceMetadataTags").filter(|v| !v.is_empty()) {
1651            m.instance_metadata_tags = v.clone();
1652        }
1653        if let Some(n) = p
1654            .get("HttpPutResponseHopLimit")
1655            .and_then(|v| v.parse::<i64>().ok())
1656        {
1657            m.http_put_response_hop_limit = n;
1658        }
1659        m.clone()
1660    })?;
1661    let body = format!(
1662        "{}<instanceMetadataOptions><state>applied</state><httpTokens>{}</httpTokens>\
1663         <httpPutResponseHopLimit>{}</httpPutResponseHopLimit><httpEndpoint>{}</httpEndpoint>\
1664         <httpProtocolIpv6>{}</httpProtocolIpv6><instanceMetadataTags>{}</instanceMetadataTags></instanceMetadataOptions>",
1665        ec2_elem("instanceId", &id),
1666        opts.http_tokens,
1667        opts.http_put_response_hop_limit,
1668        opts.http_endpoint,
1669        opts.http_protocol_ipv6,
1670        opts.instance_metadata_tags,
1671    );
1672    Ok(Ec2Service::respond(
1673        "ModifyInstanceMetadataOptions",
1674        &req.request_id,
1675        &body,
1676    ))
1677}
1678
1679pub(crate) fn modify_instance_maintenance_options(
1680    svc: &Ec2Service,
1681    req: &AwsRequest,
1682) -> Result<AwsResponse, AwsServiceError> {
1683    let id = require(&req.query_params, "InstanceId")?;
1684    validate_enum(&req.query_params, "AutoRecovery", &["disabled", "default"])?;
1685    validate_enum(
1686        &req.query_params,
1687        "RebootMigration",
1688        &["disabled", "default"],
1689    )?;
1690    let p = req.query_params.clone();
1691    let opts = with_instance_mut(svc, &req.account_id, &id, |inst| {
1692        let m = &mut inst.maintenance_options;
1693        if let Some(v) = p.get("AutoRecovery").filter(|v| !v.is_empty()) {
1694            m.auto_recovery = v.clone();
1695        }
1696        if let Some(v) = p.get("RebootMigration").filter(|v| !v.is_empty()) {
1697            m.reboot_migration = v.clone();
1698        }
1699        m.clone()
1700    })?;
1701    let body = format!(
1702        "{}<maintenanceOptions><autoRecovery>{}</autoRecovery><rebootMigration>{}</rebootMigration></maintenanceOptions>",
1703        ec2_elem("instanceId", &id),
1704        opts.auto_recovery,
1705        opts.reboot_migration,
1706    );
1707    Ok(Ec2Service::respond(
1708        "ModifyInstanceMaintenanceOptions",
1709        &req.request_id,
1710        &body,
1711    ))
1712}
1713
1714pub(crate) fn modify_instance_cpu_options(
1715    svc: &Ec2Service,
1716    req: &AwsRequest,
1717) -> Result<AwsResponse, AwsServiceError> {
1718    let id = require(&req.query_params, "InstanceId")?;
1719    validate_enum(
1720        &req.query_params,
1721        "NestedVirtualization",
1722        &["disabled", "enabled"],
1723    )?;
1724    let core_count = req
1725        .query_params
1726        .get("CoreCount")
1727        .and_then(|v| v.parse::<i64>().ok())
1728        .unwrap_or(2);
1729    let threads_per_core = req
1730        .query_params
1731        .get("ThreadsPerCore")
1732        .and_then(|v| v.parse::<i64>().ok())
1733        .unwrap_or(1);
1734    with_instance_mut(svc, &req.account_id, &id, |inst| {
1735        inst.cpu_options = Some(crate::state::CpuOptions {
1736            core_count,
1737            threads_per_core,
1738        });
1739    })?;
1740    let body = format!(
1741        "{}<coreCount>{core_count}</coreCount><threadsPerCore>{threads_per_core}</threadsPerCore>",
1742        ec2_elem("instanceId", &id)
1743    );
1744    Ok(Ec2Service::respond(
1745        "ModifyInstanceCpuOptions",
1746        &req.request_id,
1747        &body,
1748    ))
1749}
1750
1751pub(crate) fn modify_instance_network_performance_options(
1752    svc: &Ec2Service,
1753    req: &AwsRequest,
1754) -> Result<AwsResponse, AwsServiceError> {
1755    let id = require(&req.query_params, "InstanceId")?;
1756    let weighting = require(&req.query_params, "BandwidthWeighting")?;
1757    validate_enum(
1758        &req.query_params,
1759        "BandwidthWeighting",
1760        &["default", "vpc-1", "ebs-1"],
1761    )?;
1762    with_instance_mut(svc, &req.account_id, &id, |inst| {
1763        inst.bandwidth_weighting = Some(weighting.clone());
1764    })?;
1765    let body = format!(
1766        "{}<bandwidthWeighting>{weighting}</bandwidthWeighting>",
1767        ec2_elem("instanceId", &id)
1768    );
1769    Ok(Ec2Service::respond(
1770        "ModifyInstanceNetworkPerformanceOptions",
1771        &req.request_id,
1772        &body,
1773    ))
1774}
1775
1776pub(crate) fn modify_instance_event_start_time(
1777    _svc: &Ec2Service,
1778    req: &AwsRequest,
1779) -> Result<AwsResponse, AwsServiceError> {
1780    require(&req.query_params, "InstanceId")?;
1781    let event_id = require(&req.query_params, "InstanceEventId")?;
1782    require(&req.query_params, "NotBefore")?;
1783    let body = format!(
1784        "<event>{}<code>system-reboot</code><description>scheduled</description></event>",
1785        ec2_elem("instanceEventId", &event_id)
1786    );
1787    Ok(Ec2Service::respond(
1788        "ModifyInstanceEventStartTime",
1789        &req.request_id,
1790        &body,
1791    ))
1792}
1793
1794pub(crate) fn describe_instance_credit_specifications(
1795    svc: &Ec2Service,
1796    req: &AwsRequest,
1797) -> Result<AwsResponse, AwsServiceError> {
1798    crate::service_helpers::validate_max_results(&req.query_params, 5, 1000)?;
1799    let wanted = indexed_list(&req.query_params, "InstanceId");
1800    let accounts = svc.state.read();
1801    let empty = Ec2State::new(&req.account_id, &req.region);
1802    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1803    let items: Vec<String> = state
1804        .instances
1805        .values()
1806        .filter(|i| wanted.is_empty() || wanted.contains(&i.instance_id))
1807        .map(|i| {
1808            // Burstable families (t2/t3/t3a/t4g) default to `unlimited` on AWS
1809            // except t2 (`standard`); non-burstable have no credit spec. We only
1810            // track the value once explicitly set, else report `standard`.
1811            let credits = state
1812                .instance_credit_specs
1813                .get(&i.instance_id)
1814                .cloned()
1815                .unwrap_or_else(|| "standard".to_string());
1816            format!(
1817                "{}{}",
1818                ec2_elem("instanceId", &i.instance_id),
1819                ec2_elem("cpuCredits", &credits)
1820            )
1821        })
1822        .collect();
1823    Ok(Ec2Service::respond(
1824        "DescribeInstanceCreditSpecifications",
1825        &req.request_id,
1826        &ec2_list("instanceCreditSpecificationSet", &items),
1827    ))
1828}
1829
1830pub(crate) fn modify_instance_credit_specification(
1831    svc: &Ec2Service,
1832    req: &AwsRequest,
1833) -> Result<AwsResponse, AwsServiceError> {
1834    let p = &req.query_params;
1835    let mut successful = Vec::new();
1836    let mut unsuccessful = Vec::new();
1837    {
1838        let mut accounts = svc.state.write();
1839        let state = accounts.get_or_create(&req.account_id);
1840        let mut n = 1usize;
1841        loop {
1842            let id_key = format!("InstanceCreditSpecification.{n}.InstanceId");
1843            let Some(instance_id) = p.get(&id_key).cloned() else {
1844                break;
1845            };
1846            let credits = p
1847                .get(&format!("InstanceCreditSpecification.{n}.CpuCredits"))
1848                .cloned()
1849                .unwrap_or_else(|| "standard".to_string());
1850            if state.instances.contains_key(&instance_id) {
1851                state
1852                    .instance_credit_specs
1853                    .insert(instance_id.clone(), credits);
1854                successful.push(ec2_elem("instanceId", &instance_id));
1855            } else {
1856                unsuccessful.push(format!(
1857                    "{}<error><code>InvalidInstanceID.NotFound</code><message>The instance ID '{instance_id}' does not exist</message></error>",
1858                    ec2_elem("instanceId", &instance_id)
1859                ));
1860            }
1861            n += 1;
1862        }
1863    }
1864    let body = format!(
1865        "{}{}",
1866        ec2_list("successfulInstanceCreditSpecificationSet", &successful),
1867        ec2_list("unsuccessfulInstanceCreditSpecificationSet", &unsuccessful),
1868    );
1869    Ok(Ec2Service::respond(
1870        "ModifyInstanceCreditSpecification",
1871        &req.request_id,
1872        &body,
1873    ))
1874}
1875
1876pub(crate) fn get_instance_metadata_defaults(
1877    svc: &Ec2Service,
1878    req: &AwsRequest,
1879) -> Result<AwsResponse, AwsServiceError> {
1880    let accounts = svc.state.read();
1881    let d = accounts
1882        .get(&req.account_id)
1883        .and_then(|s| s.instance_metadata_defaults.clone())
1884        .unwrap_or_default();
1885    let mut inner = String::new();
1886    if let Some(v) = &d.http_tokens {
1887        inner.push_str(&ec2_elem("httpTokens", v));
1888    }
1889    if let Some(v) = &d.http_endpoint {
1890        inner.push_str(&ec2_elem("httpEndpoint", v));
1891    }
1892    if let Some(v) = d.http_put_response_hop_limit {
1893        inner.push_str(&ec2_elem("httpPutResponseHopLimit", &v.to_string()));
1894    }
1895    if let Some(v) = &d.instance_metadata_tags {
1896        inner.push_str(&ec2_elem("instanceMetadataTags", v));
1897    }
1898    if let Some(v) = &d.http_tokens_enforced {
1899        inner.push_str(&ec2_elem("httpTokensEnforced", v));
1900    }
1901    Ok(Ec2Service::respond(
1902        "GetInstanceMetadataDefaults",
1903        &req.request_id,
1904        &format!("<accountLevel>{inner}</accountLevel>"),
1905    ))
1906}
1907
1908pub(crate) fn modify_instance_metadata_defaults(
1909    svc: &Ec2Service,
1910    req: &AwsRequest,
1911) -> Result<AwsResponse, AwsServiceError> {
1912    let p = &req.query_params;
1913    validate_enum(p, "HttpTokens", &["optional", "required", "no-preference"])?;
1914    validate_enum(p, "HttpEndpoint", &["disabled", "enabled", "no-preference"])?;
1915    validate_enum(
1916        p,
1917        "InstanceMetadataTags",
1918        &["disabled", "enabled", "no-preference"],
1919    )?;
1920    validate_enum(
1921        p,
1922        "HttpTokensEnforced",
1923        &["disabled", "enabled", "no-preference"],
1924    )?;
1925    // `no-preference` resets that setting to the account default (drop it).
1926    let apply = |cur: &mut Option<String>, key: &str| {
1927        if let Some(v) = p.get(key) {
1928            *cur = if v == "no-preference" {
1929                None
1930            } else {
1931                Some(v.clone())
1932            };
1933        }
1934    };
1935    {
1936        let mut accounts = svc.state.write();
1937        let state = accounts.get_or_create(&req.account_id);
1938        let d = state
1939            .instance_metadata_defaults
1940            .get_or_insert_with(Default::default);
1941        apply(&mut d.http_tokens, "HttpTokens");
1942        apply(&mut d.http_endpoint, "HttpEndpoint");
1943        apply(&mut d.instance_metadata_tags, "InstanceMetadataTags");
1944        apply(&mut d.http_tokens_enforced, "HttpTokensEnforced");
1945        if let Some(v) = p
1946            .get("HttpPutResponseHopLimit")
1947            .and_then(|v| v.parse::<i64>().ok())
1948        {
1949            // -1 clears the account default per the EC2 API.
1950            d.http_put_response_hop_limit = if v < 0 { None } else { Some(v) };
1951        }
1952    }
1953    Ok(Ec2Service::respond(
1954        "ModifyInstanceMetadataDefaults",
1955        &req.request_id,
1956        &ec2_return(true),
1957    ))
1958}
1959
1960pub(crate) fn register_event_notification_attributes(
1961    svc: &Ec2Service,
1962    req: &AwsRequest,
1963) -> Result<AwsResponse, AwsServiceError> {
1964    let keys = indexed_sub_keys(&req.query_params);
1965    let include_all = req
1966        .query_params
1967        .get("InstanceTagAttribute.IncludeAllTagsOfInstance")
1968        .map(|v| v == "true");
1969    let xml = {
1970        let mut accounts = svc.state.write();
1971        let state = accounts.get_or_create(&req.account_id);
1972        for k in keys {
1973            if !state.event_notification_tag_keys.contains(&k) {
1974                state.event_notification_tag_keys.push(k);
1975            }
1976        }
1977        if let Some(v) = include_all {
1978            state.event_notification_include_all_tags = v;
1979        }
1980        event_tag_attribute(state)
1981    };
1982    Ok(Ec2Service::respond(
1983        "RegisterInstanceEventNotificationAttributes",
1984        &req.request_id,
1985        &xml,
1986    ))
1987}
1988
1989pub(crate) fn deregister_event_notification_attributes(
1990    svc: &Ec2Service,
1991    req: &AwsRequest,
1992) -> Result<AwsResponse, AwsServiceError> {
1993    let keys = indexed_sub_keys(&req.query_params);
1994    let include_all = req
1995        .query_params
1996        .get("InstanceTagAttribute.IncludeAllTagsOfInstance")
1997        .map(|v| v == "true");
1998    let xml = {
1999        let mut accounts = svc.state.write();
2000        let state = accounts.get_or_create(&req.account_id);
2001        state
2002            .event_notification_tag_keys
2003            .retain(|k| !keys.contains(k));
2004        // Deregistering with IncludeAllTagsOfInstance=true clears the flag.
2005        if include_all == Some(true) {
2006            state.event_notification_include_all_tags = false;
2007        }
2008        event_tag_attribute(state)
2009    };
2010    Ok(Ec2Service::respond(
2011        "DeregisterInstanceEventNotificationAttributes",
2012        &req.request_id,
2013        &xml,
2014    ))
2015}
2016
2017pub(crate) fn describe_event_notification_attributes(
2018    svc: &Ec2Service,
2019    req: &AwsRequest,
2020) -> Result<AwsResponse, AwsServiceError> {
2021    let accounts = svc.state.read();
2022    let empty = Ec2State::new(&req.account_id, &req.region);
2023    let state = accounts.get(&req.account_id).unwrap_or(&empty);
2024    Ok(Ec2Service::respond(
2025        "DescribeInstanceEventNotificationAttributes",
2026        &req.request_id,
2027        &event_tag_attribute(state),
2028    ))
2029}
2030
2031/// Collect `InstanceTagAttribute.InstanceTagKey.N` values.
2032fn indexed_sub_keys(params: &HashMap<String, String>) -> Vec<String> {
2033    let mut out = Vec::new();
2034    let mut n = 1usize;
2035    while let Some(v) = params.get(&format!("InstanceTagAttribute.InstanceTagKey.{n}")) {
2036        out.push(v.clone());
2037        n += 1;
2038    }
2039    out
2040}
2041
2042fn event_tag_attribute(state: &Ec2State) -> String {
2043    // `ec2_list` already wraps each element in <item>; pass the (escaped) key
2044    // strings directly so the shape is <item>key</item>, not a nested pair.
2045    let keys: Vec<String> = state
2046        .event_notification_tag_keys
2047        .iter()
2048        .map(|k| fakecloud_aws::xml::xml_escape(k))
2049        .collect();
2050    format!(
2051        "<instanceTagAttribute><includeAllTagsOfInstance>{}</includeAllTagsOfInstance>{}</instanceTagAttribute>",
2052        state.event_notification_include_all_tags,
2053        ec2_list("instanceTagKeySet", &keys)
2054    )
2055}
2056
2057pub(crate) fn report_instance_status(
2058    _svc: &Ec2Service,
2059    req: &AwsRequest,
2060) -> Result<AwsResponse, AwsServiceError> {
2061    require(&req.query_params, "Status")?;
2062    validate_enum(&req.query_params, "Status", &["ok", "impaired"])?;
2063    Ok(Ec2Service::respond(
2064        "ReportInstanceStatus",
2065        &req.request_id,
2066        &ec2_return(true),
2067    ))
2068}
2069
2070pub(crate) fn describe_instance_topology(
2071    _svc: &Ec2Service,
2072    req: &AwsRequest,
2073) -> Result<AwsResponse, AwsServiceError> {
2074    crate::service_helpers::validate_max_results(&req.query_params, 1, 100)?;
2075    Ok(Ec2Service::respond(
2076        "DescribeInstanceTopology",
2077        &req.request_id,
2078        &ec2_list("instanceSet", &[]),
2079    ))
2080}
2081
2082#[cfg(test)]
2083mod tests {
2084    use super::subnet_ip_prefix;
2085
2086    #[test]
2087    fn subnet_ip_prefix_uses_subnet_network() {
2088        // The synthesized metadata IP must land inside the subnet (finding 1.7).
2089        assert_eq!(subnet_ip_prefix("172.31.16.0/20"), "172.31.16");
2090        assert_eq!(subnet_ip_prefix("10.0.5.0/24"), "10.0.5");
2091        // bare address (no mask) still works
2092        assert_eq!(subnet_ip_prefix("192.168.1.0"), "192.168.1");
2093    }
2094
2095    #[test]
2096    fn subnet_ip_prefix_falls_back_on_garbage() {
2097        assert_eq!(subnet_ip_prefix(""), "10.0.0");
2098        assert_eq!(subnet_ip_prefix("not-a-cidr"), "10.0.0");
2099        // IPv6 / non-dotted-quad falls back rather than producing nonsense
2100        assert_eq!(subnet_ip_prefix("fd00::/8"), "10.0.0");
2101    }
2102}
2103
2104#[cfg(test)]
2105mod modify_tests {
2106    use super::*;
2107
2108    fn req(action: &str, query: &[(&str, &str)]) -> AwsRequest {
2109        AwsRequest {
2110            service: "ec2".into(),
2111            action: action.into(),
2112            region: "us-east-1".into(),
2113            account_id: "000000000000".into(),
2114            request_id: "rid".into(),
2115            headers: http::HeaderMap::new(),
2116            query_params: query
2117                .iter()
2118                .map(|(k, v)| (k.to_string(), v.to_string()))
2119                .collect(),
2120            body: bytes::Bytes::new(),
2121            body_stream: parking_lot::Mutex::new(None),
2122            path_segments: Vec::new(),
2123            raw_path: "/".into(),
2124            raw_query: String::new(),
2125            method: http::Method::POST,
2126            is_query_protocol: true,
2127            access_key_id: None,
2128            principal: None,
2129        }
2130    }
2131
2132    fn body(resp: AwsResponse) -> String {
2133        String::from_utf8_lossy(resp.body.expect_bytes()).to_string()
2134    }
2135
2136    fn seed_instance(svc: &Ec2Service, id: &str) {
2137        let mut accounts = svc.state.write();
2138        let state = accounts.get_or_create("000000000000");
2139        let inst = Instance {
2140            instance_id: id.into(),
2141            image_id: "ami-1".into(),
2142            instance_type: "t3.micro".into(),
2143            state_code: 16,
2144            state_name: "running".into(),
2145            private_ip: "10.0.0.5".into(),
2146            public_ip: None,
2147            subnet_id: Some("subnet-1".into()),
2148            vpc_id: Some("vpc-1".into()),
2149            key_name: None,
2150            security_group_ids: vec![],
2151            reservation_id: "r-1".into(),
2152            ami_launch_index: 0,
2153            monitoring: false,
2154            az: "us-east-1a".into(),
2155            launch_time: "2024-01-01T00:00:00.000Z".into(),
2156            container_id: None,
2157            disable_api_termination: false,
2158            disable_api_stop: false,
2159            source_dest_check: true,
2160            ebs_optimized: false,
2161            instance_initiated_shutdown_behavior: "stop".into(),
2162            user_data: None,
2163            metadata_options: Default::default(),
2164            cpu_options: None,
2165            bandwidth_weighting: None,
2166            maintenance_options: Default::default(),
2167            placement_tenancy: None,
2168            placement_affinity: None,
2169            placement_group_name: None,
2170            private_dns_hostname_type: None,
2171            enable_resource_name_dns_a_record: false,
2172            enable_resource_name_dns_aaaa_record: false,
2173        };
2174        state.instances.insert(id.to_string(), inst);
2175    }
2176
2177    #[test]
2178    fn modify_instance_credit_specification_round_trips() {
2179        let svc = Ec2Service::new();
2180        seed_instance(&svc, "i-1");
2181        modify_instance_credit_specification(
2182            &svc,
2183            &req(
2184                "ModifyInstanceCreditSpecification",
2185                &[
2186                    ("InstanceCreditSpecification.1.InstanceId", "i-1"),
2187                    ("InstanceCreditSpecification.1.CpuCredits", "unlimited"),
2188                ],
2189            ),
2190        )
2191        .unwrap();
2192        let out = body(
2193            describe_instance_credit_specifications(
2194                &svc,
2195                &req(
2196                    "DescribeInstanceCreditSpecifications",
2197                    &[("InstanceId.1", "i-1")],
2198                ),
2199            )
2200            .unwrap(),
2201        );
2202        assert!(
2203            out.contains("<cpuCredits>unlimited</cpuCredits>"),
2204            "got: {out}"
2205        );
2206    }
2207
2208    #[test]
2209    fn modify_instance_credit_specification_unknown_is_unsuccessful() {
2210        let svc = Ec2Service::new();
2211        let out = body(
2212            modify_instance_credit_specification(
2213                &svc,
2214                &req(
2215                    "ModifyInstanceCreditSpecification",
2216                    &[("InstanceCreditSpecification.1.InstanceId", "i-missing")],
2217                ),
2218            )
2219            .unwrap(),
2220        );
2221        assert!(out.contains("InvalidInstanceID.NotFound"), "got: {out}");
2222    }
2223
2224    #[test]
2225    fn instance_metadata_defaults_round_trip_and_reset() {
2226        let svc = Ec2Service::new();
2227        modify_instance_metadata_defaults(
2228            &svc,
2229            &req(
2230                "ModifyInstanceMetadataDefaults",
2231                &[
2232                    ("HttpTokens", "required"),
2233                    ("HttpEndpoint", "enabled"),
2234                    ("HttpTokensEnforced", "enabled"),
2235                ],
2236            ),
2237        )
2238        .unwrap();
2239        let out = body(
2240            get_instance_metadata_defaults(&svc, &req("GetInstanceMetadataDefaults", &[])).unwrap(),
2241        );
2242        assert!(
2243            out.contains("<httpTokens>required</httpTokens>"),
2244            "got: {out}"
2245        );
2246        assert!(out.contains("<httpEndpoint>enabled</httpEndpoint>"));
2247        // HttpTokensEnforced must persist too (Cubic P2 on #2057).
2248        assert!(out.contains("<httpTokensEnforced>enabled</httpTokensEnforced>"));
2249
2250        // `no-preference` drops the stored default.
2251        modify_instance_metadata_defaults(
2252            &svc,
2253            &req(
2254                "ModifyInstanceMetadataDefaults",
2255                &[("HttpTokens", "no-preference")],
2256            ),
2257        )
2258        .unwrap();
2259        let out = body(
2260            get_instance_metadata_defaults(&svc, &req("GetInstanceMetadataDefaults", &[])).unwrap(),
2261        );
2262        assert!(
2263            !out.contains("<httpTokens>"),
2264            "no-preference should clear it: {out}"
2265        );
2266    }
2267
2268    #[test]
2269    fn event_notification_attributes_persist_keys() {
2270        let svc = Ec2Service::new();
2271        register_event_notification_attributes(
2272            &svc,
2273            &req(
2274                "RegisterInstanceEventNotificationAttributes",
2275                &[
2276                    ("InstanceTagAttribute.InstanceTagKey.1", "Name"),
2277                    ("InstanceTagAttribute.InstanceTagKey.2", "env"),
2278                ],
2279            ),
2280        )
2281        .unwrap();
2282        let out = body(
2283            describe_event_notification_attributes(
2284                &svc,
2285                &req("DescribeInstanceEventNotificationAttributes", &[]),
2286            )
2287            .unwrap(),
2288        );
2289        assert!(out.contains("<item>Name</item>"), "got: {out}");
2290        assert!(out.contains("<item>env</item>"));
2291        // Keys must not be double-wrapped as <item><item>key</item></item>
2292        // (Cubic P2 on #2057).
2293        assert!(!out.contains("<item><item>"), "got: {out}");
2294
2295        deregister_event_notification_attributes(
2296            &svc,
2297            &req(
2298                "DeregisterInstanceEventNotificationAttributes",
2299                &[("InstanceTagAttribute.InstanceTagKey.1", "Name")],
2300            ),
2301        )
2302        .unwrap();
2303        let out = body(
2304            describe_event_notification_attributes(
2305                &svc,
2306                &req("DescribeInstanceEventNotificationAttributes", &[]),
2307            )
2308            .unwrap(),
2309        );
2310        assert!(!out.contains("<item>Name</item>"), "got: {out}");
2311        assert!(out.contains("<item>env</item>"));
2312    }
2313
2314    #[test]
2315    fn describe_instances_explicit_missing_id_errors() {
2316        let svc = Ec2Service::new();
2317        seed_instance(&svc, "i-1");
2318        let err = crate::test_support::err_of(describe_instances(
2319            &svc,
2320            &req("DescribeInstances", &[("InstanceId.1", "i-missing")]),
2321        ));
2322        assert_eq!(err.code(), "InvalidInstanceID.NotFound");
2323    }
2324
2325    #[test]
2326    fn describe_instances_existing_id_ok() {
2327        let svc = Ec2Service::new();
2328        seed_instance(&svc, "i-1");
2329        let out = body(
2330            describe_instances(&svc, &req("DescribeInstances", &[("InstanceId.1", "i-1")]))
2331                .unwrap(),
2332        );
2333        assert!(out.contains("<instanceId>i-1</instanceId>"), "got: {out}");
2334    }
2335
2336    #[tokio::test]
2337    async fn reconcile_pending_metadata_only_flips_to_running() {
2338        let svc = Ec2Service::new();
2339        seed_instance(&svc, "i-1");
2340        // Simulate a persisted mid-boot instance.
2341        {
2342            let mut accounts = svc.state.write();
2343            let inst = accounts
2344                .get_mut("000000000000")
2345                .unwrap()
2346                .instances
2347                .get_mut("i-1")
2348                .unwrap();
2349            inst.state_code = 0;
2350            inst.state_name = "pending".into();
2351        }
2352        svc.reconcile_pending_metadata_only().await;
2353        let accounts = svc.state.read();
2354        let inst = &accounts.get("000000000000").unwrap().instances["i-1"];
2355        assert_eq!(inst.state_code, 16);
2356        assert_eq!(inst.state_name, "running");
2357    }
2358
2359    fn state_of(svc: &Ec2Service, id: &str) -> (i64, Option<String>) {
2360        let accounts = svc.state.read();
2361        let inst = &accounts.get("000000000000").unwrap().instances[id];
2362        (inst.state_code, inst.container_id.clone())
2363    }
2364
2365    #[test]
2366    fn check_transition_enforces_terminal_and_protection() {
2367        // Direct unit test of the shared re-check helper the write-lock TOCTOU
2368        // guard relies on (bug-hunt finding 4.1).
2369        let svc = Ec2Service::new();
2370        seed_instance(&svc, "i-1");
2371        let mut accounts = svc.state.write();
2372        let state = accounts.get_or_create("000000000000");
2373        let inst = state.instances.get_mut("i-1").unwrap();
2374
2375        // Running -> start/stop/terminate all legal.
2376        assert!(check_transition(inst, "i-1", 16).is_ok());
2377        assert!(check_transition(inst, "i-1", 80).is_ok());
2378        assert!(check_transition(inst, "i-1", 48).is_ok());
2379
2380        // Terminated is terminal: no Start (16) allowed, only re-terminate.
2381        inst.state_code = 48;
2382        inst.state_name = "terminated".into();
2383        assert_eq!(
2384            check_transition(inst, "i-1", 16).unwrap_err().code(),
2385            "IncorrectInstanceState"
2386        );
2387        assert!(check_transition(inst, "i-1", 48).is_ok());
2388
2389        // Protection flags map to OperationNotPermitted.
2390        inst.state_code = 16;
2391        inst.state_name = "running".into();
2392        inst.disable_api_termination = true;
2393        assert_eq!(
2394            check_transition(inst, "i-1", 48).unwrap_err().code(),
2395            "OperationNotPermitted"
2396        );
2397        inst.disable_api_termination = false;
2398        inst.disable_api_stop = true;
2399        assert_eq!(
2400            check_transition(inst, "i-1", 80).unwrap_err().code(),
2401            "OperationNotPermitted"
2402        );
2403    }
2404
2405    #[tokio::test]
2406    async fn start_after_terminate_does_not_resurrect() {
2407        // End-to-end: once terminated, a StartInstances must be rejected rather
2408        // than overwriting code 48 with pending (the resurrection the write-lock
2409        // re-check closes). Metadata-only mode has no runtime, so the terminal
2410        // state is fully determined by the control-plane path.
2411        let svc = Ec2Service::new();
2412        seed_instance(&svc, "i-1");
2413
2414        terminate_instances(&svc, &req("TerminateInstances", &[("InstanceId.1", "i-1")]))
2415            .await
2416            .unwrap();
2417        let (code, container) = state_of(&svc, "i-1");
2418        assert_eq!(code, 48, "terminate must set code 48");
2419        assert_eq!(container, None, "terminate must drop the container handle");
2420
2421        let err = start_instances(&svc, &req("StartInstances", &[("InstanceId.1", "i-1")]))
2422            .await
2423            .err()
2424            .expect("starting a terminated instance must fail");
2425        assert_eq!(err.code(), "IncorrectInstanceState");
2426
2427        // State is untouched: still terminated, no partial resurrection.
2428        let (code, _) = state_of(&svc, "i-1");
2429        assert_eq!(code, 48, "instance must stay terminated");
2430    }
2431
2432    #[tokio::test]
2433    async fn change_state_is_atomic_on_mixed_ids() {
2434        // AWS applies the whole call or none: a batch where one id is illegal
2435        // (terminated) must fail entirely and leave the other id untouched.
2436        let svc = Ec2Service::new();
2437        seed_instance(&svc, "i-ok");
2438        seed_instance(&svc, "i-dead");
2439        terminate_instances(
2440            &svc,
2441            &req("TerminateInstances", &[("InstanceId.1", "i-dead")]),
2442        )
2443        .await
2444        .unwrap();
2445
2446        let err = start_instances(
2447            &svc,
2448            &req(
2449                "StartInstances",
2450                &[("InstanceId.1", "i-ok"), ("InstanceId.2", "i-dead")],
2451            ),
2452        )
2453        .await
2454        .err()
2455        .expect("batch with a terminated id must fail");
2456        assert_eq!(err.code(), "IncorrectInstanceState");
2457
2458        // i-ok must NOT have been flipped to pending by a partial write.
2459        let (code, _) = state_of(&svc, "i-ok");
2460        assert_eq!(
2461            code, 16,
2462            "healthy instance must be untouched on atomic fail"
2463        );
2464    }
2465}