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, parse_filters, require,
13    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    let image_id = req
243        .query_params
244        .get("ImageId")
245        .cloned()
246        .unwrap_or_else(|| "ami-00000000000000000".to_string());
247    let instance_type = req
248        .query_params
249        .get("InstanceType")
250        .cloned()
251        .unwrap_or_else(|| "t3.micro".to_string());
252    let key_name = req.query_params.get("KeyName").cloned();
253    let mut subnet_id = req.query_params.get("SubnetId").cloned();
254    let mut sg_ids = indexed_list(&req.query_params, "SecurityGroupId");
255    let user_data = req.query_params.get("UserData").cloned();
256    let owner = req.account_id.clone();
257    let az = format!(
258        "{}a",
259        if req.region.is_empty() {
260            "us-east-1"
261        } else {
262            &req.region
263        }
264    );
265
266    // Reserved `fakecloud-k8s/*` scheduling tags are read from the request's
267    // TagSpecification here, before the backing Pod is built.
268    let instance_tags = crate::service::tags::tag_specifications_for(&req.query_params, "instance");
269
270    // Resolve the VPC from the requested subnet (so the `vpc-id` filter and
271    // describe output reflect reality), and decide whether a public IP is
272    // assigned per AWS rules: explicit `AssociatePublicIpAddress=true`, else
273    // the subnet's `map_public_ip_on_launch` / default-subnet behavior. With
274    // no subnet, an instance launched into the (implicit) default VPC gets a
275    // public IP, matching the EC2-default-VPC contract.
276    let assoc_public = req
277        .query_params
278        .get("NetworkInterface.1.AssociatePublicIpAddress")
279        .or_else(|| req.query_params.get("AssociatePublicIpAddress"))
280        .map(|v| v == "true");
281    let (vpc_id, subnet_auto_public, instance_network, ip_prefix) = {
282        let accounts = svc.state.read();
283        let empty = Ec2State::new(&req.account_id, &req.region);
284        let state = accounts.get(&req.account_id).unwrap_or(&empty);
285        // No explicit subnet: land in the default VPC's default subnet for the
286        // target AZ (falling back to any default subnet), exactly as AWS does.
287        // This fills `subnet_id`/`vpc_id` so DescribeInstances reports a real
288        // subnet/VPC and phase-2 per-subnet networking has something to key on.
289        if subnet_id.is_none() {
290            if let Some(s) = state
291                .subnets
292                .values()
293                .filter(|s| s.default_for_az)
294                .find(|s| s.availability_zone == az)
295                .or_else(|| state.subnets.values().find(|s| s.default_for_az))
296            {
297                subnet_id = Some(s.subnet_id.clone());
298            }
299        }
300        // When the caller named no security group, AWS attaches the VPC's
301        // `default` group. Resolve it from the subnet's VPC (or the default VPC).
302        let resolved_vpc = subnet_id
303            .as_ref()
304            .and_then(|sid| state.subnets.get(sid))
305            .map(|s| s.vpc_id.clone());
306        if sg_ids.is_empty() {
307            let vpc = resolved_vpc
308                .clone()
309                .unwrap_or_else(|| crate::defaults::default_vpc_id(&req.account_id));
310            if let Some(sg) = state
311                .security_groups
312                .values()
313                .find(|g| g.vpc_id == vpc && g.group_name == "default")
314            {
315                sg_ids = vec![sg.group_id.clone()];
316            }
317        }
318        // The backing per-subnet network for phase-2 L3 isolation. A subnet
319        // with no `0.0.0.0/0 -> igw` route is private -> `internal` network.
320        let instance_network = subnet_id
321            .as_ref()
322            .map(|sid| crate::runtime::InstanceNetwork {
323                subnet_id: sid.clone(),
324                internal: !crate::defaults::subnet_is_public(state, sid),
325            });
326        let (vpc, auto_public) = match subnet_id.as_ref() {
327            Some(sid) => state
328                .subnets
329                .get(sid)
330                .map(|s| {
331                    (
332                        Some(s.vpc_id.clone()),
333                        s.map_public_ip_on_launch || s.default_for_az,
334                    )
335                })
336                .unwrap_or((None, false)),
337            // No subnet (and no default subnet found): still a default-VPC
338            // launch, which assigns public IPs by default.
339            None => (Some(crate::defaults::default_vpc_id(&req.account_id)), true),
340        };
341        // Metadata-only private IP base, derived from the resolved subnet's
342        // CIDR so DescribeInstances reports an IP inside the subnet (was a
343        // hard-coded 10.0.0.x outside the subnet — bug-hunt finding 1.7). A
344        // real container-backed instance overwrites this with its true bridge
345        // IP once running.
346        let ip_prefix = subnet_id
347            .as_ref()
348            .and_then(|sid| state.subnets.get(sid))
349            .map(|s| subnet_ip_prefix(&s.cidr_block))
350            .unwrap_or_else(|| "10.0.0".to_string());
351        (vpc, auto_public, instance_network, ip_prefix)
352    };
353    let assign_public = assoc_public.unwrap_or(subnet_auto_public);
354
355    // Generate instance ids; insert each instance synchronously in `pending`
356    // state (code 0), then boot the backing container in a background task
357    // that reconciles the instance to `running` (code 16) when it's up, or to
358    // `stopped` (code 80) on failure. RunInstances returns immediately so a
359    // cold image pull / k8s Pod readiness never blocks the client (mirrors
360    // RDS CreateDBInstance). With no runtime configured the instance is
361    // flipped to `running` immediately in a spawned task.
362    let ids: Vec<String> = (0..count).map(|_| gen_id("i")).collect();
363    let mut rendered = Vec::new();
364    {
365        let mut accounts = svc.state.write();
366        let state = accounts.get_or_create(&req.account_id);
367        let sg_names = sg_name_map(state);
368        for (idx, id) in ids.iter().enumerate() {
369            let inst = Instance {
370                instance_id: id.clone(),
371                image_id: image_id.clone(),
372                instance_type: instance_type.clone(),
373                state_code: 0,
374                state_name: "pending".to_string(),
375                private_ip: format!("{ip_prefix}.{}", 10 + idx),
376                public_ip: if assign_public {
377                    Some(format!("52.0.0.{}", 10 + idx))
378                } else {
379                    None
380                },
381                subnet_id: subnet_id.clone(),
382                vpc_id: vpc_id.clone(),
383                key_name: key_name.clone(),
384                security_group_ids: sg_ids.clone(),
385                reservation_id: reservation_id.clone(),
386                ami_launch_index: idx as i64,
387                monitoring: false,
388                az: az.clone(),
389                launch_time: LAUNCH_TIME.to_string(),
390                container_id: None,
391                disable_api_termination: false,
392                disable_api_stop: false,
393                source_dest_check: true,
394                ebs_optimized: false,
395                instance_initiated_shutdown_behavior: req
396                    .query_params
397                    .get("InstanceInitiatedShutdownBehavior")
398                    .cloned()
399                    .unwrap_or_else(|| "stop".to_string()),
400                user_data: user_data.clone().filter(|s| !s.is_empty()),
401                metadata_options: crate::state::MetadataOptions::default(),
402                cpu_options: None,
403                bandwidth_weighting: None,
404                maintenance_options: crate::state::MaintenanceOptions::default(),
405                placement_tenancy: None,
406                placement_affinity: None,
407                placement_group_name: req.query_params.get("Placement.GroupName").cloned(),
408                private_dns_hostname_type: None,
409                enable_resource_name_dns_a_record: false,
410                enable_resource_name_dns_aaaa_record: false,
411            };
412            crate::service::tags::apply_tag_specifications(
413                state,
414                &req.query_params,
415                id,
416                "instance",
417            );
418            let tags = state.tags_for(id).to_vec();
419            let architecture = arch_for(state, &inst.image_id);
420            rendered.push(instance_xml(&inst, &tags, &owner, &sg_names, &architecture));
421            state.instances.insert(id.clone(), inst);
422        }
423    }
424
425    // Background boot: bring up each backing container and reconcile state.
426    {
427        let svc_state = svc.state.clone();
428        let runtime = svc.runtime.clone();
429        let account_id = req.account_id.clone();
430        let ids = ids.clone();
431        let instance_network = instance_network.clone();
432        tokio::spawn(async move {
433            for id in &ids {
434                let running = if let Some(rt) = &runtime {
435                    match rt
436                        .run_instance(
437                            &account_id,
438                            id,
439                            user_data.as_deref(),
440                            &instance_tags,
441                            instance_network.as_ref(),
442                        )
443                        .await
444                    {
445                        Ok(r) => Some(r),
446                        Err(e) => {
447                            tracing::warn!(instance_id = %id, error = %e, "EC2 instance container failed to start; serving metadata-only");
448                            None
449                        }
450                    }
451                } else {
452                    None
453                };
454                reconcile_started(&svc_state, &account_id, id, running);
455            }
456            // All instances are up with their real IPs: (re)apply the
457            // security-group firewall so the new instances are filtered
458            // (#1745 phase 3). No-op when enforcement is disabled.
459            if let Some(rt) = &runtime {
460                if rt.network_isolation_enforced() {
461                    super::firewall_model::reconcile(&svc_state, rt).await;
462                }
463            }
464        });
465    }
466
467    let body = reservation_xml(&reservation_id, &owner, &rendered);
468    Ok(Ec2Service::respond("RunInstances", &req.request_id, &body))
469}
470
471/// Flip a `pending`/`stopped` instance to `running` after its backing
472/// container is up. Re-acquires the lock and re-checks the instance still
473/// exists and hasn't been terminated by a concurrent op before writing the
474/// container handle / IP (bug-hunt 2026-06-15 finding 0.4).
475fn reconcile_started(
476    state: &crate::state::SharedEc2State,
477    account_id: &str,
478    id: &str,
479    running: Option<crate::runtime::RunningInstance>,
480) {
481    let mut accounts = state.write();
482    let Some(s) = accounts.get_mut(account_id) else {
483        return;
484    };
485    let Some(inst) = s.instances.get_mut(id) else {
486        return;
487    };
488    // A concurrent Terminate (code 48) or Stop wins: don't resurrect it.
489    if inst.state_code == 48 || inst.state_code == 80 {
490        return;
491    }
492    inst.state_code = 16;
493    inst.state_name = "running".to_string();
494    if let Some(r) = running {
495        inst.private_ip = r.private_ip;
496        inst.container_id = Some(r.container_id);
497    }
498}
499
500/// Inputs for a CloudFormation-driven `AWS::EC2::Instance` launch.
501#[derive(Debug, Clone, Default)]
502pub struct CfnInstanceSpec {
503    pub image_id: Option<String>,
504    pub instance_type: Option<String>,
505    pub subnet_id: Option<String>,
506    pub availability_zone: Option<String>,
507    pub security_group_ids: Vec<String>,
508    pub key_name: Option<String>,
509    pub user_data: Option<String>,
510    pub private_ip: Option<String>,
511}
512
513/// The Ref / GetAtt-resolvable attributes of a CFN-launched instance.
514#[derive(Debug, Clone)]
515pub struct CfnInstanceAttrs {
516    pub instance_id: String,
517    pub private_ip: String,
518    pub public_ip: Option<String>,
519    pub availability_zone: String,
520}
521
522/// Synchronously insert a control-plane `AWS::EC2::Instance` record (status
523/// `pending`) and return its Ref/GetAtt attributes. Mirrors the control-plane
524/// half of [`run_instances`] for a single instance so a CFN-provisioned
525/// instance resolves `Ref` to a real `i-...` id and `GetAtt`
526/// PrivateIp/PublicIp/AvailabilityZone immediately. The backing container is
527/// booted afterwards by [`cfn_boot_instance`] (drained off the request path).
528pub(crate) fn cfn_create_instance(
529    svc: &Ec2Service,
530    account_id: &str,
531    region: &str,
532    spec: &CfnInstanceSpec,
533) -> CfnInstanceAttrs {
534    let image_id = spec
535        .image_id
536        .clone()
537        .unwrap_or_else(|| "ami-00000000000000000".to_string());
538    let instance_type = spec
539        .instance_type
540        .clone()
541        .unwrap_or_else(|| "t3.micro".to_string());
542    let region = if region.is_empty() {
543        "us-east-1"
544    } else {
545        region
546    };
547    let mut subnet_id = spec.subnet_id.clone();
548    let mut sg_ids = spec.security_group_ids.clone();
549
550    let (vpc_id, subnet_auto_public, ip_prefix, az) = {
551        let accounts = svc.state.read();
552        let empty = Ec2State::new(account_id, region);
553        let state = accounts.get(account_id).unwrap_or(&empty);
554        // Resolve a default subnet when none is given, preferring the requested
555        // AZ, exactly like `run_instances`.
556        if subnet_id.is_none() {
557            let want_az = spec.availability_zone.clone();
558            if let Some(s) = state
559                .subnets
560                .values()
561                .filter(|s| s.default_for_az)
562                .find(|s| want_az.as_deref().is_none_or(|a| s.availability_zone == a))
563                .or_else(|| state.subnets.values().find(|s| s.default_for_az))
564            {
565                subnet_id = Some(s.subnet_id.clone());
566            }
567        }
568        let resolved_vpc = subnet_id
569            .as_ref()
570            .and_then(|sid| state.subnets.get(sid))
571            .map(|s| s.vpc_id.clone());
572        if sg_ids.is_empty() {
573            let vpc = resolved_vpc
574                .clone()
575                .unwrap_or_else(|| crate::defaults::default_vpc_id(account_id));
576            if let Some(sg) = state
577                .security_groups
578                .values()
579                .find(|g| g.vpc_id == vpc && g.group_name == "default")
580            {
581                sg_ids = vec![sg.group_id.clone()];
582            }
583        }
584        let (vpc, auto_public, az) = match subnet_id.as_ref() {
585            Some(sid) => state
586                .subnets
587                .get(sid)
588                .map(|s| {
589                    (
590                        Some(s.vpc_id.clone()),
591                        s.map_public_ip_on_launch || s.default_for_az,
592                        s.availability_zone.clone(),
593                    )
594                })
595                .unwrap_or((None, false, format!("{region}a"))),
596            None => (
597                Some(crate::defaults::default_vpc_id(account_id)),
598                true,
599                format!("{region}a"),
600            ),
601        };
602        let az = spec.availability_zone.clone().unwrap_or(az);
603        let ip_prefix = subnet_id
604            .as_ref()
605            .and_then(|sid| state.subnets.get(sid))
606            .map(|s| subnet_ip_prefix(&s.cidr_block))
607            .unwrap_or_else(|| "10.0.0".to_string());
608        (vpc, auto_public, ip_prefix, az)
609    };
610
611    let assign_public = subnet_auto_public;
612    let id = gen_id("i");
613    let private_ip = spec
614        .private_ip
615        .clone()
616        .unwrap_or_else(|| format!("{ip_prefix}.10"));
617    let public_ip = if assign_public {
618        Some("52.0.0.10".to_string())
619    } else {
620        None
621    };
622
623    {
624        let mut accounts = svc.state.write();
625        let state = accounts.get_or_create(account_id);
626        let inst = Instance {
627            instance_id: id.clone(),
628            image_id,
629            instance_type,
630            state_code: 0,
631            state_name: "pending".to_string(),
632            private_ip: private_ip.clone(),
633            public_ip: public_ip.clone(),
634            subnet_id: subnet_id.clone(),
635            vpc_id: vpc_id.clone(),
636            key_name: spec.key_name.clone(),
637            security_group_ids: sg_ids,
638            reservation_id: gen_id("r"),
639            ami_launch_index: 0,
640            monitoring: false,
641            az: az.clone(),
642            launch_time: LAUNCH_TIME.to_string(),
643            container_id: None,
644            disable_api_termination: false,
645            disable_api_stop: false,
646            source_dest_check: true,
647            ebs_optimized: false,
648            instance_initiated_shutdown_behavior: "stop".to_string(),
649            user_data: spec.user_data.clone().filter(|s| !s.is_empty()),
650            metadata_options: crate::state::MetadataOptions::default(),
651            cpu_options: None,
652            bandwidth_weighting: None,
653            maintenance_options: crate::state::MaintenanceOptions::default(),
654            placement_tenancy: None,
655            placement_affinity: None,
656            placement_group_name: None,
657            private_dns_hostname_type: None,
658            enable_resource_name_dns_a_record: false,
659            enable_resource_name_dns_aaaa_record: false,
660        };
661        state.instances.insert(id.clone(), inst);
662    }
663
664    CfnInstanceAttrs {
665        instance_id: id,
666        private_ip,
667        public_ip,
668        availability_zone: az,
669    }
670}
671
672/// Boot the backing container for a CFN-created instance and reconcile it to
673/// `running` (or metadata-only `running` when no runtime is wired). Mirrors the
674/// background-boot half of [`run_instances`]. Intended to be `tokio::spawn`ed by
675/// the CloudFormation create drain so stack creation never blocks on a cold
676/// image pull / Pod readiness.
677pub(crate) async fn cfn_boot_instance(svc: &Ec2Service, account_id: &str, id: &str) {
678    let (user_data, instance_network) = {
679        let accounts = svc.state.read();
680        let Some(state) = accounts.get(account_id) else {
681            return;
682        };
683        let Some(inst) = state.instances.get(id) else {
684            return;
685        };
686        let network = inst
687            .subnet_id
688            .as_ref()
689            .map(|sid| crate::runtime::InstanceNetwork {
690                subnet_id: sid.clone(),
691                internal: !crate::defaults::subnet_is_public(state, sid),
692            });
693        (inst.user_data.clone(), network)
694    };
695
696    let empty_tags = std::collections::BTreeMap::new();
697    let running = if let Some(rt) = &svc.runtime {
698        match rt
699            .run_instance(
700                account_id,
701                id,
702                user_data.as_deref(),
703                &empty_tags,
704                instance_network.as_ref(),
705            )
706            .await
707        {
708            Ok(r) => Some(r),
709            Err(e) => {
710                tracing::warn!(instance_id = %id, error = %e, "CFN EC2 instance container failed to start; serving metadata-only");
711                None
712            }
713        }
714    } else {
715        None
716    };
717    reconcile_started(&svc.state, account_id, id, running);
718
719    if let Some(rt) = &svc.runtime {
720        if rt.network_isolation_enforced() {
721            super::firewall_model::reconcile(&svc.state, rt).await;
722        }
723    }
724}
725
726/// Terminate a CFN-created instance (reaping its real backing container) when
727/// its stack is deleted. Routes through the real `TerminateInstances` handler so
728/// the container/Pod is stopped and the firewall re-reconciled, instead of
729/// leaking a running EC2 container. No-op if the instance is already gone.
730pub(crate) async fn cfn_terminate_instance(
731    svc: &Ec2Service,
732    account_id: &str,
733    region: &str,
734    instance_id: &str,
735) {
736    let mut query_params = std::collections::HashMap::new();
737    query_params.insert("InstanceId.1".to_string(), instance_id.to_string());
738    let req = AwsRequest {
739        service: "ec2".to_string(),
740        action: "TerminateInstances".to_string(),
741        region: region.to_string(),
742        account_id: account_id.to_string(),
743        request_id: gen_id("req"),
744        headers: http::HeaderMap::new(),
745        query_params,
746        body: bytes::Bytes::new(),
747        body_stream: parking_lot::Mutex::new(None),
748        path_segments: Vec::new(),
749        raw_path: "/".to_string(),
750        raw_query: String::new(),
751        method: http::Method::POST,
752        is_query_protocol: true,
753        access_key_id: None,
754        principal: None,
755    };
756    let _ = terminate_instances(svc, &req).await;
757}
758
759fn validate_enum_instance_type(req: &AwsRequest) -> Result<(), AwsServiceError> {
760    // InstanceType has ~850 enum members; accept any non-empty value that looks
761    // like a `family.size` token rather than enumerating them all.
762    if let Some(v) = req
763        .query_params
764        .get("InstanceType")
765        .filter(|v| !v.is_empty())
766    {
767        if !v.contains('.') {
768            return Err(invalid_parameter_value(format!(
769                "Invalid instance type '{v}'"
770            )));
771        }
772    }
773    Ok(())
774}
775
776/// Is a transition to `new_code` legal from the instance's `current` state?
777/// Terminated (48) is terminal — no transition out of it is allowed. Stop/Start
778/// from any non-terminal state is accepted (AWS is lenient on no-op
779/// transitions, e.g. stopping an already-stopped instance).
780fn transition_allowed(current: i64, new_code: i64) -> bool {
781    if current == 48 {
782        // A terminated instance can only be (re-)terminated (a no-op).
783        return new_code == 48;
784    }
785    true
786}
787
788async fn change_state(
789    svc: &Ec2Service,
790    req: &AwsRequest,
791    action: &str,
792    new_code: i64,
793    new_name: &str,
794) -> Result<AwsResponse, AwsServiceError> {
795    let ids = indexed_list(&req.query_params, "InstanceId");
796
797    // Validate existence + legal transitions BEFORE mutating anything: AWS
798    // fails the whole call (no partial application) on a bad id or illegal
799    // transition (bug-hunt 2026-06-15 findings 1.9 / 0.4).
800    {
801        let accounts = svc.state.read();
802        let empty = Ec2State::new(&req.account_id, &req.region);
803        let state = accounts.get(&req.account_id).unwrap_or(&empty);
804        for id in &ids {
805            let inst = state
806                .instances
807                .get(id)
808                .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
809            if !transition_allowed(inst.state_code, new_code) {
810                return Err(crate::service_helpers::incorrect_instance_state(
811                    id,
812                    &inst.state_name,
813                ));
814            }
815            // Termination / stop protection.
816            if new_code == 48 && inst.disable_api_termination {
817                return Err(AwsServiceError::aws_error(
818                    http::StatusCode::BAD_REQUEST,
819                    "OperationNotPermitted",
820                    format!(
821                        "The instance '{id}' may not be terminated. Modify its 'disableApiTermination' instance attribute and try again."
822                    ),
823                ));
824            }
825            if new_code == 80 && inst.disable_api_stop {
826                return Err(AwsServiceError::aws_error(
827                    http::StatusCode::BAD_REQUEST,
828                    "OperationNotPermitted",
829                    format!(
830                        "The instance '{id}' may not be stopped. Modify its 'disableApiStop' instance attribute and try again."
831                    ),
832                ));
833            }
834        }
835    }
836
837    // For StartInstances, AWS returns the instances in `pending` (not yet
838    // `running`); the container comes up in the background. Stop/Terminate
839    // apply their target state immediately (and AWS reports stopping/
840    // shutting-down transitionally, but the durable target is what callers
841    // poll for).
842    let applied_code = if new_code == 16 { 0 } else { new_code };
843    let applied_name = if new_code == 16 { "pending" } else { new_name };
844
845    let mut changes = Vec::new();
846    let mut affected: Vec<String> = Vec::new();
847    {
848        let mut accounts = svc.state.write();
849        let state = accounts.get_or_create(&req.account_id);
850        for id in &ids {
851            let (prev_code, prev_name) = state
852                .instances
853                .get(id)
854                .map(|i| (i.state_code, i.state_name.clone()))
855                .unwrap_or((16, "running".to_string()));
856            if let Some(inst) = state.instances.get_mut(id) {
857                inst.state_code = applied_code;
858                inst.state_name = applied_name.to_string();
859                if new_code == 80 || new_code == 48 {
860                    inst.public_ip = None;
861                }
862                affected.push(id.clone());
863                // A terminated instance no longer has a backing container.
864                if new_code == 48 {
865                    inst.container_id = None;
866                }
867            }
868            changes.push(format!(
869                "{}{}{}",
870                ec2_elem("instanceId", id),
871                state_xml("currentState", applied_code, applied_name),
872                state_xml("previousState", prev_code, &prev_name),
873            ));
874        }
875    }
876
877    // Drive the backing container's lifecycle in the background so the response
878    // returns immediately (bug-hunt 2026-06-15 findings 0.2 / 0.4). Each op
879    // re-checks the instance's state after its await before persisting.
880    {
881        let svc_state = svc.state.clone();
882        let runtime = svc.runtime.clone();
883        let account_id = req.account_id.clone();
884        tokio::spawn(async move {
885            for id in &affected {
886                match new_code {
887                    16 => {
888                        let running = match &runtime {
889                            Some(rt) => rt.start_instance(id).await,
890                            None => None,
891                        };
892                        reconcile_started(&svc_state, &account_id, id, running);
893                    }
894                    80 => {
895                        if let Some(rt) = &runtime {
896                            rt.stop_instance(id).await;
897                        }
898                    }
899                    48 => {
900                        if let Some(rt) = &runtime {
901                            rt.terminate_instance(id).await;
902                        }
903                    }
904                    _ => {}
905                }
906            }
907            // Lifecycle changes move/remove instances (new IP on start, gone on
908            // terminate): re-apply the security-group firewall (#1745 ph3).
909            if let Some(rt) = &runtime {
910                if rt.network_isolation_enforced() {
911                    super::firewall_model::reconcile(&svc_state, rt).await;
912                }
913            }
914        });
915    }
916
917    Ok(Ec2Service::respond(
918        action,
919        &req.request_id,
920        &ec2_list("instancesSet", &changes),
921    ))
922}
923
924pub(crate) async fn start_instances(
925    svc: &Ec2Service,
926    req: &AwsRequest,
927) -> Result<AwsResponse, AwsServiceError> {
928    change_state(svc, req, "StartInstances", 16, "running").await
929}
930pub(crate) async fn stop_instances(
931    svc: &Ec2Service,
932    req: &AwsRequest,
933) -> Result<AwsResponse, AwsServiceError> {
934    change_state(svc, req, "StopInstances", 80, "stopped").await
935}
936pub(crate) async fn terminate_instances(
937    svc: &Ec2Service,
938    req: &AwsRequest,
939) -> Result<AwsResponse, AwsServiceError> {
940    change_state(svc, req, "TerminateInstances", 48, "terminated").await
941}
942
943pub(crate) async fn reboot_instances(
944    svc: &Ec2Service,
945    req: &AwsRequest,
946) -> Result<AwsResponse, AwsServiceError> {
947    let ids = indexed_list(&req.query_params, "InstanceId");
948    // Validate existence + reject rebooting a terminated instance before doing
949    // any work (findings 1.9). AWS rejects RebootInstances on a bad id.
950    let backed: Vec<String> = {
951        let accounts = svc.state.read();
952        let empty = Ec2State::new(&req.account_id, &req.region);
953        let state = accounts.get(&req.account_id).unwrap_or(&empty);
954        let mut backed = Vec::new();
955        for id in &ids {
956            let inst = state
957                .instances
958                .get(id)
959                .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
960            if inst.state_code == 48 {
961                return Err(crate::service_helpers::incorrect_instance_state(
962                    id,
963                    &inst.state_name,
964                ));
965            }
966            if inst.container_id.is_some() {
967                backed.push(id.clone());
968            }
969        }
970        backed
971    };
972    // Reboot the backing containers in the background so the API returns
973    // immediately (k8s Pod recreate can take up to 90s) — finding 0.2.
974    {
975        let svc_state = svc.state.clone();
976        let runtime = svc.runtime.clone();
977        let account_id = req.account_id.clone();
978        tokio::spawn(async move {
979            let Some(rt) = runtime else {
980                return;
981            };
982            for id in &backed {
983                // k8s reboot recreates the Pod under a new name/IP; persist them
984                // so describe/introspection stay accurate (Docker returns None).
985                if let Some(running) = rt.reboot_instance(id).await {
986                    let mut accounts = svc_state.write();
987                    if let Some(state) = accounts.get_mut(&account_id) {
988                        if let Some(inst) = state.instances.get_mut(id) {
989                            // Don't clobber an instance a concurrent op
990                            // terminated mid-reboot.
991                            if inst.state_code != 48 {
992                                inst.private_ip = running.private_ip;
993                                inst.container_id = Some(running.container_id);
994                            }
995                        }
996                    }
997                }
998            }
999            // A reboot can change the instance's IP (k8s Pod recreate), which
1000            // leaves a stale /32 in every peer's security-group rules until an
1001            // unrelated reconcile fires. Re-apply the firewall now (#1745;
1002            // bug-hunt 2026-06-18 finding 4.2). No-op when enforcement is off.
1003            if rt.network_isolation_enforced() {
1004                super::firewall_model::reconcile(&svc_state, &rt).await;
1005            }
1006        });
1007    }
1008    Ok(Ec2Service::respond(
1009        "RebootInstances",
1010        &req.request_id,
1011        &ec2_return(true),
1012    ))
1013}
1014
1015pub(crate) fn monitor_instances(
1016    svc: &Ec2Service,
1017    req: &AwsRequest,
1018) -> Result<AwsResponse, AwsServiceError> {
1019    monitor(svc, req, "MonitorInstances", true)
1020}
1021pub(crate) fn unmonitor_instances(
1022    svc: &Ec2Service,
1023    req: &AwsRequest,
1024) -> Result<AwsResponse, AwsServiceError> {
1025    monitor(svc, req, "UnmonitorInstances", false)
1026}
1027
1028fn monitor(
1029    svc: &Ec2Service,
1030    req: &AwsRequest,
1031    action: &str,
1032    enable: bool,
1033) -> Result<AwsResponse, AwsServiceError> {
1034    let ids = indexed_list(&req.query_params, "InstanceId");
1035    {
1036        let mut accounts = svc.state.write();
1037        let state = accounts.get_or_create(&req.account_id);
1038        for id in &ids {
1039            if let Some(i) = state.instances.get_mut(id) {
1040                i.monitoring = enable;
1041            }
1042        }
1043    }
1044    let items: Vec<String> = ids
1045        .iter()
1046        .map(|id| {
1047            format!(
1048                "{}<monitoring><state>{}</state></monitoring>",
1049                ec2_elem("instanceId", id),
1050                if enable { "pending" } else { "disabling" }
1051            )
1052        })
1053        .collect();
1054    Ok(Ec2Service::respond(
1055        action,
1056        &req.request_id,
1057        &ec2_list("instancesSet", &items),
1058    ))
1059}
1060
1061pub(crate) fn describe_instances(
1062    svc: &Ec2Service,
1063    req: &AwsRequest,
1064) -> Result<AwsResponse, AwsServiceError> {
1065    crate::service_helpers::validate_max_results(&req.query_params, 5, 1000)?;
1066    let max_results = parse_max_results(&req.query_params);
1067    let next_token = req.query_params.get("NextToken").map(String::as_str);
1068    let filters = parse_filters(&req.query_params);
1069    let wanted = indexed_list(&req.query_params, "InstanceId");
1070    let owner = req.account_id.clone();
1071    let accounts = svc.state.read();
1072    let empty = Ec2State::new(&req.account_id, &req.region);
1073    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1074
1075    // Flatten matching instances into a stable order (by reservation, then id),
1076    // then paginate over the flat instance list — AWS counts instances, not
1077    // reservations, against MaxResults.
1078    let mut matching: Vec<&Instance> = state
1079        .instances
1080        .values()
1081        .filter(|i| wanted.is_empty() || wanted.contains(&i.instance_id))
1082        .filter(|i| {
1083            inst_match(
1084                i,
1085                state.tags_for(&i.instance_id),
1086                &filters,
1087                &arch_for(state, &i.image_id),
1088            )
1089        })
1090        .collect();
1091    matching.sort_by(|a, b| {
1092        a.reservation_id
1093            .cmp(&b.reservation_id)
1094            .then(a.instance_id.cmp(&b.instance_id))
1095    });
1096    let (page, token) = crate::service_helpers::paginate(&matching, next_token, max_results);
1097
1098    // Group the page back into reservations, preserving the sorted order.
1099    let sg_names = sg_name_map(state);
1100    let mut by_res: HashMap<String, Vec<String>> = HashMap::new();
1101    let mut order: Vec<String> = Vec::new();
1102    for i in page {
1103        if !by_res.contains_key(&i.reservation_id) {
1104            order.push(i.reservation_id.clone());
1105        }
1106        by_res
1107            .entry(i.reservation_id.clone())
1108            .or_default()
1109            .push(instance_xml(
1110                i,
1111                state.tags_for(&i.instance_id),
1112                &owner,
1113                &sg_names,
1114                &arch_for(state, &i.image_id),
1115            ));
1116    }
1117    let reservations: Vec<String> = order
1118        .iter()
1119        .map(|rid| {
1120            let insts = by_res.remove(rid).unwrap_or_default();
1121            reservation_xml(rid, &owner, &insts)
1122        })
1123        .collect();
1124    let body = format!(
1125        "{}{}",
1126        ec2_list("reservationSet", &reservations),
1127        token.map(|t| ec2_elem("nextToken", &t)).unwrap_or_default(),
1128    );
1129    Ok(Ec2Service::respond(
1130        "DescribeInstances",
1131        &req.request_id,
1132        &body,
1133    ))
1134}
1135
1136/// Parse `MaxResults` into an optional usize (already range-validated by the
1137/// caller via `validate_max_results`).
1138fn parse_max_results(params: &HashMap<String, String>) -> Option<usize> {
1139    params
1140        .get("MaxResults")
1141        .filter(|v| !v.is_empty())
1142        .and_then(|v| v.parse::<usize>().ok())
1143}
1144
1145fn inst_match(i: &Instance, tags: &[Tag], filters: &[Filter], architecture: &str) -> bool {
1146    use crate::service_helpers::filter_value_matches;
1147    filters.iter().all(|f| {
1148        let candidates: Vec<String> = match f.name.as_str() {
1149            "instance-id" => vec![i.instance_id.clone()],
1150            "instance-type" => vec![i.instance_type.clone()],
1151            "image-id" => vec![i.image_id.clone()],
1152            "instance-state-name" => vec![i.state_name.clone()],
1153            "instance-state-code" => vec![i.state_code.to_string()],
1154            "vpc-id" => i.vpc_id.clone().into_iter().collect(),
1155            "subnet-id" => i.subnet_id.clone().into_iter().collect(),
1156            "availability-zone" => vec![i.az.clone()],
1157            "private-ip-address" => vec![i.private_ip.clone()],
1158            "ip-address" => i.public_ip.clone().into_iter().collect(),
1159            "key-name" => i.key_name.clone().into_iter().collect(),
1160            "architecture" => vec![architecture.to_string()],
1161            "tag-key" => tags.iter().map(|t| t.key.clone()).collect(),
1162            name => {
1163                if let Some(key) = name.strip_prefix("tag:") {
1164                    tags.iter()
1165                        .filter(|t| t.key == key)
1166                        .map(|t| t.value.clone())
1167                        .collect()
1168                } else {
1169                    // Unknown filter name: AWS rejects with InvalidParameterValue.
1170                    // Returning `false` (match nothing) is the safe approximation
1171                    // rather than `return true` (match everything) — finding 1.16.
1172                    return false;
1173                }
1174            }
1175        };
1176        f.values
1177            .iter()
1178            .any(|v| candidates.iter().any(|c| filter_value_matches(v, c)))
1179    })
1180}
1181
1182pub(crate) fn describe_instance_status(
1183    svc: &Ec2Service,
1184    req: &AwsRequest,
1185) -> Result<AwsResponse, AwsServiceError> {
1186    crate::service_helpers::validate_max_results(&req.query_params, 5, 1000)?;
1187    let max_results = parse_max_results(&req.query_params);
1188    let next_token = req.query_params.get("NextToken").map(String::as_str);
1189    let filters = parse_filters(&req.query_params);
1190    let wanted = indexed_list(&req.query_params, "InstanceId");
1191    let include_all = req
1192        .query_params
1193        .get("IncludeAllInstances")
1194        .map(|v| v == "true")
1195        .unwrap_or(false);
1196    let accounts = svc.state.read();
1197    let empty = Ec2State::new(&req.account_id, &req.region);
1198    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1199    let mut matching: Vec<&Instance> = state
1200        .instances
1201        .values()
1202        .filter(|i| wanted.is_empty() || wanted.contains(&i.instance_id))
1203        .filter(|i| include_all || i.state_name == "running")
1204        .filter(|i| {
1205            inst_match(
1206                i,
1207                state.tags_for(&i.instance_id),
1208                &filters,
1209                &arch_for(state, &i.image_id),
1210            )
1211        })
1212        .collect();
1213    matching.sort_by(|a, b| a.instance_id.cmp(&b.instance_id));
1214    let (page, token) = crate::service_helpers::paginate(&matching, next_token, max_results);
1215    let items: Vec<String> = page
1216        .iter()
1217        .map(|i| {
1218            format!(
1219                "{}{}{}{}{}{}",
1220                ec2_elem("instanceId", &i.instance_id),
1221                ec2_elem("availabilityZone", &i.az),
1222                state_xml("instanceState", i.state_code, &i.state_name),
1223                "<instanceStatus><status>ok</status></instanceStatus>",
1224                "<systemStatus><status>ok</status></systemStatus>",
1225                ec2_list("eventsSet", &[]),
1226            )
1227        })
1228        .collect();
1229    let body = format!(
1230        "{}{}",
1231        ec2_list("instanceStatusSet", &items),
1232        token.map(|t| ec2_elem("nextToken", &t)).unwrap_or_default(),
1233    );
1234    Ok(Ec2Service::respond(
1235        "DescribeInstanceStatus",
1236        &req.request_id,
1237        &body,
1238    ))
1239}
1240
1241fn instance_type_items(req: &AwsRequest) -> Vec<String> {
1242    let wanted = indexed_list(&req.query_params, "InstanceType");
1243    INSTANCE_TYPES
1244        .iter()
1245        .filter(|t| wanted.is_empty() || wanted.iter().any(|w| w == *t))
1246        .map(|t| {
1247            format!(
1248                "{}<currentGeneration>true</currentGeneration><bareMetal>false</bareMetal>\
1249                 <hypervisor>nitro</hypervisor><instanceStorageSupported>false</instanceStorageSupported>\
1250                 <processorInfo><supportedArchitectures><item>x86_64</item></supportedArchitectures></processorInfo>\
1251                 <vCpuInfo><defaultVCpus>2</defaultVCpus></vCpuInfo>\
1252                 <memoryInfo><sizeInMiB>1024</sizeInMiB></memoryInfo>\
1253                 <supportedVirtualizationTypes><item>hvm</item></supportedVirtualizationTypes>",
1254                ec2_elem("instanceType", t),
1255            )
1256        })
1257        .collect()
1258}
1259
1260pub(crate) fn describe_instance_types(
1261    _svc: &Ec2Service,
1262    req: &AwsRequest,
1263) -> Result<AwsResponse, AwsServiceError> {
1264    crate::service_helpers::validate_max_results(&req.query_params, 5, 100)?;
1265    Ok(Ec2Service::respond(
1266        "DescribeInstanceTypes",
1267        &req.request_id,
1268        &ec2_list("instanceTypeSet", &instance_type_items(req)),
1269    ))
1270}
1271
1272pub(crate) fn get_instance_types_from_requirements(
1273    _svc: &Ec2Service,
1274    req: &AwsRequest,
1275) -> Result<AwsResponse, AwsServiceError> {
1276    require_struct(&req.query_params, "InstanceRequirements")?;
1277    let items: Vec<String> = INSTANCE_TYPES
1278        .iter()
1279        .map(|t| format!("<instanceType>{t}</instanceType>"))
1280        .collect();
1281    Ok(Ec2Service::respond(
1282        "GetInstanceTypesFromInstanceRequirements",
1283        &req.request_id,
1284        &ec2_list("instanceTypeSet", &items),
1285    ))
1286}
1287
1288// ---- attributes ----
1289
1290pub(crate) fn describe_instance_attribute(
1291    svc: &Ec2Service,
1292    req: &AwsRequest,
1293) -> Result<AwsResponse, AwsServiceError> {
1294    let id = require(&req.query_params, "InstanceId")?;
1295    let attribute = require(&req.query_params, "Attribute")?;
1296    validate_enum(&req.query_params, "Attribute", ATTRIBUTE_VALUES)?;
1297    let accounts = svc.state.read();
1298    let acct_state = accounts.get(&req.account_id);
1299    let inst = acct_state
1300        .and_then(|s| s.instances.get(&id))
1301        .ok_or_else(|| crate::service_helpers::instance_not_found(&id))?;
1302    let attr_xml = match attribute.as_str() {
1303        "instanceType" => format!(
1304            "<instanceType><value>{}</value></instanceType>",
1305            inst.instance_type
1306        ),
1307        "disableApiTermination" => format!(
1308            "<disableApiTermination><value>{}</value></disableApiTermination>",
1309            inst.disable_api_termination
1310        ),
1311        "disableApiStop" => format!(
1312            "<disableApiStop><value>{}</value></disableApiStop>",
1313            inst.disable_api_stop
1314        ),
1315        "ebsOptimized" => format!(
1316            "<ebsOptimized><value>{}</value></ebsOptimized>",
1317            inst.ebs_optimized
1318        ),
1319        "sourceDestCheck" => format!(
1320            "<sourceDestCheck><value>{}</value></sourceDestCheck>",
1321            inst.source_dest_check
1322        ),
1323        "instanceInitiatedShutdownBehavior" => format!(
1324            "<instanceInitiatedShutdownBehavior><value>{}</value></instanceInitiatedShutdownBehavior>",
1325            inst.instance_initiated_shutdown_behavior
1326        ),
1327        "userData" => match &inst.user_data {
1328            Some(d) => format!("<userData><value>{d}</value></userData>"),
1329            None => "<userData/>".to_string(),
1330        },
1331        "groupSet" => {
1332            let sg_names = acct_state.map(sg_name_map).unwrap_or_default();
1333            let groups: Vec<String> = inst
1334                .security_group_ids
1335                .iter()
1336                .map(|g| {
1337                    let name = sg_names.get(g).map(String::as_str).unwrap_or(g.as_str());
1338                    format!("{}{}", ec2_elem("groupId", g), ec2_elem("groupName", name))
1339                })
1340                .collect();
1341            ec2_list("groupSet", &groups)
1342        }
1343        _ => String::new(),
1344    };
1345    let body = format!("{}{}", ec2_elem("instanceId", &id), attr_xml);
1346    Ok(Ec2Service::respond(
1347        "DescribeInstanceAttribute",
1348        &req.request_id,
1349        &body,
1350    ))
1351}
1352
1353const ATTRIBUTE_VALUES: &[&str] = &[
1354    "instanceType",
1355    "kernel",
1356    "ramdisk",
1357    "userData",
1358    "disableApiTermination",
1359    "instanceInitiatedShutdownBehavior",
1360    "rootDeviceName",
1361    "blockDeviceMapping",
1362    "productCodes",
1363    "sourceDestCheck",
1364    "groupSet",
1365    "ebsOptimized",
1366    "sriovNetSupport",
1367    "enaSupport",
1368    "enclaveOptions",
1369    "disableApiStop",
1370];
1371
1372/// Read an attribute value from either the flat `<Attr>.Value=` form (e.g.
1373/// `DisableApiTermination.Value=true`) or the bare `<Attr>=` form the CLI
1374/// sends for some attrs (`SourceDestCheck.Value`, `Attribute`+`Value`).
1375fn attr_bool(params: &HashMap<String, String>, key: &str) -> Option<bool> {
1376    params
1377        .get(&format!("{key}.Value"))
1378        .or_else(|| params.get(key))
1379        .map(|v| v == "true")
1380}
1381
1382fn attr_str<'a>(params: &'a HashMap<String, String>, key: &str) -> Option<&'a String> {
1383    params
1384        .get(&format!("{key}.Value"))
1385        .or_else(|| params.get(key))
1386}
1387
1388pub(crate) fn modify_instance_attribute(
1389    svc: &Ec2Service,
1390    req: &AwsRequest,
1391) -> Result<AwsResponse, AwsServiceError> {
1392    let id = require(&req.query_params, "InstanceId")?;
1393    // `Attribute` is only present when called in the generic form; the
1394    // convenience form passes the attribute as its own member (e.g.
1395    // `DisableApiTermination.Value`). Validate it only when present.
1396    validate_enum(&req.query_params, "Attribute", ATTRIBUTE_VALUES)?;
1397    let p = &req.query_params;
1398    let mut accounts = svc.state.write();
1399    let state = accounts.get_or_create(&req.account_id);
1400    let inst = state
1401        .instances
1402        .get_mut(&id)
1403        .ok_or_else(|| crate::service_helpers::instance_not_found(&id))?;
1404
1405    // Generic form: Attribute=<name> Value=<value>.
1406    if let Some(attr) = p.get("Attribute").filter(|v| !v.is_empty()) {
1407        let value = p.get("Value").cloned();
1408        match attr.as_str() {
1409            "instanceType" => {
1410                if let Some(v) = value {
1411                    inst.instance_type = v;
1412                }
1413            }
1414            "userData" => inst.user_data = value.filter(|s| !s.is_empty()),
1415            "disableApiTermination" => {
1416                inst.disable_api_termination = value.as_deref() == Some("true")
1417            }
1418            "disableApiStop" => inst.disable_api_stop = value.as_deref() == Some("true"),
1419            "sourceDestCheck" => inst.source_dest_check = value.as_deref() == Some("true"),
1420            "ebsOptimized" => inst.ebs_optimized = value.as_deref() == Some("true"),
1421            "instanceInitiatedShutdownBehavior" => {
1422                if let Some(v) = value {
1423                    inst.instance_initiated_shutdown_behavior = v;
1424                }
1425            }
1426            _ => {}
1427        }
1428    }
1429    // Convenience form: each modifiable attribute as its own member.
1430    if let Some(v) = attr_bool(p, "DisableApiTermination") {
1431        inst.disable_api_termination = v;
1432    }
1433    if let Some(v) = attr_bool(p, "DisableApiStop") {
1434        inst.disable_api_stop = v;
1435    }
1436    if let Some(v) = attr_bool(p, "SourceDestCheck") {
1437        inst.source_dest_check = v;
1438    }
1439    if let Some(v) = attr_bool(p, "EbsOptimized") {
1440        inst.ebs_optimized = v;
1441    }
1442    if let Some(v) = attr_str(p, "InstanceType") {
1443        inst.instance_type = v.clone();
1444    }
1445    if let Some(v) = attr_str(p, "InstanceInitiatedShutdownBehavior") {
1446        inst.instance_initiated_shutdown_behavior = v.clone();
1447    }
1448    if let Some(v) = attr_str(p, "UserData") {
1449        inst.user_data = Some(v.clone()).filter(|s| !s.is_empty());
1450    }
1451    let new_groups = indexed_list(p, "GroupId");
1452    if !new_groups.is_empty() {
1453        inst.security_group_ids = new_groups;
1454    }
1455
1456    Ok(Ec2Service::respond(
1457        "ModifyInstanceAttribute",
1458        &req.request_id,
1459        &ec2_return(true),
1460    ))
1461}
1462
1463pub(crate) fn reset_instance_attribute(
1464    svc: &Ec2Service,
1465    req: &AwsRequest,
1466) -> Result<AwsResponse, AwsServiceError> {
1467    let id = require(&req.query_params, "InstanceId")?;
1468    let attribute = require(&req.query_params, "Attribute")?;
1469    validate_enum(&req.query_params, "Attribute", ATTRIBUTE_VALUES)?;
1470    let mut accounts = svc.state.write();
1471    let state = accounts.get_or_create(&req.account_id);
1472    let inst = state
1473        .instances
1474        .get_mut(&id)
1475        .ok_or_else(|| crate::service_helpers::instance_not_found(&id))?;
1476    // Reset to AWS defaults. AWS only supports resetting kernel/ramdisk/
1477    // sourceDestCheck, but we reset the corresponding field for any attr.
1478    match attribute.as_str() {
1479        "sourceDestCheck" => inst.source_dest_check = true,
1480        "disableApiTermination" => inst.disable_api_termination = false,
1481        "disableApiStop" => inst.disable_api_stop = false,
1482        "ebsOptimized" => inst.ebs_optimized = false,
1483        "userData" => inst.user_data = None,
1484        "instanceInitiatedShutdownBehavior" => {
1485            inst.instance_initiated_shutdown_behavior = "stop".to_string()
1486        }
1487        _ => {}
1488    }
1489    Ok(Ec2Service::respond(
1490        "ResetInstanceAttribute",
1491        &req.request_id,
1492        &ec2_return(true),
1493    ))
1494}
1495
1496// ---- modify-* and misc ----
1497
1498/// Look up an instance for mutation, erroring with `InvalidInstanceID.NotFound`
1499/// when absent. Returns a write guard so the caller can mutate in place.
1500fn with_instance_mut<R>(
1501    svc: &Ec2Service,
1502    account_id: &str,
1503    id: &str,
1504    f: impl FnOnce(&mut Instance) -> R,
1505) -> Result<R, AwsServiceError> {
1506    let mut accounts = svc.state.write();
1507    let state = accounts.get_or_create(account_id);
1508    let inst = state
1509        .instances
1510        .get_mut(id)
1511        .ok_or_else(|| crate::service_helpers::instance_not_found(id))?;
1512    Ok(f(inst))
1513}
1514
1515pub(crate) fn modify_instance_placement(
1516    svc: &Ec2Service,
1517    req: &AwsRequest,
1518) -> Result<AwsResponse, AwsServiceError> {
1519    let id = require(&req.query_params, "InstanceId")?;
1520    validate_enum(
1521        &req.query_params,
1522        "Tenancy",
1523        &["default", "dedicated", "host"],
1524    )?;
1525    validate_enum(&req.query_params, "Affinity", &["default", "host"])?;
1526    let p = req.query_params.clone();
1527    with_instance_mut(svc, &req.account_id, &id, |inst| {
1528        if let Some(t) = p.get("Tenancy").filter(|v| !v.is_empty()) {
1529            inst.placement_tenancy = Some(t.clone());
1530        }
1531        if let Some(a) = p.get("Affinity").filter(|v| !v.is_empty()) {
1532            inst.placement_affinity = Some(a.clone());
1533        }
1534        if let Some(g) = p.get("GroupName") {
1535            inst.placement_group_name = Some(g.clone()).filter(|s| !s.is_empty());
1536        }
1537    })?;
1538    Ok(Ec2Service::respond(
1539        "ModifyInstancePlacement",
1540        &req.request_id,
1541        &ec2_return(true),
1542    ))
1543}
1544
1545pub(crate) fn modify_instance_metadata_options(
1546    svc: &Ec2Service,
1547    req: &AwsRequest,
1548) -> Result<AwsResponse, AwsServiceError> {
1549    let id = require(&req.query_params, "InstanceId")?;
1550    validate_enum(&req.query_params, "HttpTokens", &["optional", "required"])?;
1551    validate_enum(&req.query_params, "HttpEndpoint", &["disabled", "enabled"])?;
1552    validate_enum(
1553        &req.query_params,
1554        "HttpProtocolIpv6",
1555        &["disabled", "enabled"],
1556    )?;
1557    validate_enum(
1558        &req.query_params,
1559        "InstanceMetadataTags",
1560        &["disabled", "enabled"],
1561    )?;
1562    let p = req.query_params.clone();
1563    let opts = with_instance_mut(svc, &req.account_id, &id, |inst| {
1564        let m = &mut inst.metadata_options;
1565        if let Some(v) = p.get("HttpTokens").filter(|v| !v.is_empty()) {
1566            m.http_tokens = v.clone();
1567        }
1568        if let Some(v) = p.get("HttpEndpoint").filter(|v| !v.is_empty()) {
1569            m.http_endpoint = v.clone();
1570        }
1571        if let Some(v) = p.get("HttpProtocolIpv6").filter(|v| !v.is_empty()) {
1572            m.http_protocol_ipv6 = v.clone();
1573        }
1574        if let Some(v) = p.get("InstanceMetadataTags").filter(|v| !v.is_empty()) {
1575            m.instance_metadata_tags = v.clone();
1576        }
1577        if let Some(n) = p
1578            .get("HttpPutResponseHopLimit")
1579            .and_then(|v| v.parse::<i64>().ok())
1580        {
1581            m.http_put_response_hop_limit = n;
1582        }
1583        m.clone()
1584    })?;
1585    let body = format!(
1586        "{}<instanceMetadataOptions><state>applied</state><httpTokens>{}</httpTokens>\
1587         <httpPutResponseHopLimit>{}</httpPutResponseHopLimit><httpEndpoint>{}</httpEndpoint>\
1588         <httpProtocolIpv6>{}</httpProtocolIpv6><instanceMetadataTags>{}</instanceMetadataTags></instanceMetadataOptions>",
1589        ec2_elem("instanceId", &id),
1590        opts.http_tokens,
1591        opts.http_put_response_hop_limit,
1592        opts.http_endpoint,
1593        opts.http_protocol_ipv6,
1594        opts.instance_metadata_tags,
1595    );
1596    Ok(Ec2Service::respond(
1597        "ModifyInstanceMetadataOptions",
1598        &req.request_id,
1599        &body,
1600    ))
1601}
1602
1603pub(crate) fn modify_instance_maintenance_options(
1604    svc: &Ec2Service,
1605    req: &AwsRequest,
1606) -> Result<AwsResponse, AwsServiceError> {
1607    let id = require(&req.query_params, "InstanceId")?;
1608    validate_enum(&req.query_params, "AutoRecovery", &["disabled", "default"])?;
1609    validate_enum(
1610        &req.query_params,
1611        "RebootMigration",
1612        &["disabled", "default"],
1613    )?;
1614    let p = req.query_params.clone();
1615    let opts = with_instance_mut(svc, &req.account_id, &id, |inst| {
1616        let m = &mut inst.maintenance_options;
1617        if let Some(v) = p.get("AutoRecovery").filter(|v| !v.is_empty()) {
1618            m.auto_recovery = v.clone();
1619        }
1620        if let Some(v) = p.get("RebootMigration").filter(|v| !v.is_empty()) {
1621            m.reboot_migration = v.clone();
1622        }
1623        m.clone()
1624    })?;
1625    let body = format!(
1626        "{}<maintenanceOptions><autoRecovery>{}</autoRecovery><rebootMigration>{}</rebootMigration></maintenanceOptions>",
1627        ec2_elem("instanceId", &id),
1628        opts.auto_recovery,
1629        opts.reboot_migration,
1630    );
1631    Ok(Ec2Service::respond(
1632        "ModifyInstanceMaintenanceOptions",
1633        &req.request_id,
1634        &body,
1635    ))
1636}
1637
1638pub(crate) fn modify_instance_cpu_options(
1639    svc: &Ec2Service,
1640    req: &AwsRequest,
1641) -> Result<AwsResponse, AwsServiceError> {
1642    let id = require(&req.query_params, "InstanceId")?;
1643    validate_enum(
1644        &req.query_params,
1645        "NestedVirtualization",
1646        &["disabled", "enabled"],
1647    )?;
1648    let core_count = req
1649        .query_params
1650        .get("CoreCount")
1651        .and_then(|v| v.parse::<i64>().ok())
1652        .unwrap_or(2);
1653    let threads_per_core = req
1654        .query_params
1655        .get("ThreadsPerCore")
1656        .and_then(|v| v.parse::<i64>().ok())
1657        .unwrap_or(1);
1658    with_instance_mut(svc, &req.account_id, &id, |inst| {
1659        inst.cpu_options = Some(crate::state::CpuOptions {
1660            core_count,
1661            threads_per_core,
1662        });
1663    })?;
1664    let body = format!(
1665        "{}<coreCount>{core_count}</coreCount><threadsPerCore>{threads_per_core}</threadsPerCore>",
1666        ec2_elem("instanceId", &id)
1667    );
1668    Ok(Ec2Service::respond(
1669        "ModifyInstanceCpuOptions",
1670        &req.request_id,
1671        &body,
1672    ))
1673}
1674
1675pub(crate) fn modify_instance_network_performance_options(
1676    svc: &Ec2Service,
1677    req: &AwsRequest,
1678) -> Result<AwsResponse, AwsServiceError> {
1679    let id = require(&req.query_params, "InstanceId")?;
1680    let weighting = require(&req.query_params, "BandwidthWeighting")?;
1681    validate_enum(
1682        &req.query_params,
1683        "BandwidthWeighting",
1684        &["default", "vpc-1", "ebs-1"],
1685    )?;
1686    with_instance_mut(svc, &req.account_id, &id, |inst| {
1687        inst.bandwidth_weighting = Some(weighting.clone());
1688    })?;
1689    let body = format!(
1690        "{}<bandwidthWeighting>{weighting}</bandwidthWeighting>",
1691        ec2_elem("instanceId", &id)
1692    );
1693    Ok(Ec2Service::respond(
1694        "ModifyInstanceNetworkPerformanceOptions",
1695        &req.request_id,
1696        &body,
1697    ))
1698}
1699
1700pub(crate) fn modify_instance_event_start_time(
1701    _svc: &Ec2Service,
1702    req: &AwsRequest,
1703) -> Result<AwsResponse, AwsServiceError> {
1704    require(&req.query_params, "InstanceId")?;
1705    let event_id = require(&req.query_params, "InstanceEventId")?;
1706    require(&req.query_params, "NotBefore")?;
1707    let body = format!(
1708        "<event>{}<code>system-reboot</code><description>scheduled</description></event>",
1709        ec2_elem("instanceEventId", &event_id)
1710    );
1711    Ok(Ec2Service::respond(
1712        "ModifyInstanceEventStartTime",
1713        &req.request_id,
1714        &body,
1715    ))
1716}
1717
1718pub(crate) fn describe_instance_credit_specifications(
1719    svc: &Ec2Service,
1720    req: &AwsRequest,
1721) -> Result<AwsResponse, AwsServiceError> {
1722    crate::service_helpers::validate_max_results(&req.query_params, 5, 1000)?;
1723    let wanted = indexed_list(&req.query_params, "InstanceId");
1724    let accounts = svc.state.read();
1725    let empty = Ec2State::new(&req.account_id, &req.region);
1726    let state = accounts.get(&req.account_id).unwrap_or(&empty);
1727    let items: Vec<String> = state
1728        .instances
1729        .values()
1730        .filter(|i| wanted.is_empty() || wanted.contains(&i.instance_id))
1731        .map(|i| {
1732            format!(
1733                "{}<cpuCredits>standard</cpuCredits>",
1734                ec2_elem("instanceId", &i.instance_id)
1735            )
1736        })
1737        .collect();
1738    Ok(Ec2Service::respond(
1739        "DescribeInstanceCreditSpecifications",
1740        &req.request_id,
1741        &ec2_list("instanceCreditSpecificationSet", &items),
1742    ))
1743}
1744
1745pub(crate) fn modify_instance_credit_specification(
1746    _svc: &Ec2Service,
1747    req: &AwsRequest,
1748) -> Result<AwsResponse, AwsServiceError> {
1749    let body = format!(
1750        "{}{}",
1751        ec2_list("successfulInstanceCreditSpecificationSet", &[]),
1752        ec2_list("unsuccessfulInstanceCreditSpecificationSet", &[]),
1753    );
1754    Ok(Ec2Service::respond(
1755        "ModifyInstanceCreditSpecification",
1756        &req.request_id,
1757        &body,
1758    ))
1759}
1760
1761pub(crate) fn get_instance_metadata_defaults(
1762    _svc: &Ec2Service,
1763    req: &AwsRequest,
1764) -> Result<AwsResponse, AwsServiceError> {
1765    Ok(Ec2Service::respond(
1766        "GetInstanceMetadataDefaults",
1767        &req.request_id,
1768        "<accountLevel><httpTokens>optional</httpTokens><httpEndpoint>enabled</httpEndpoint></accountLevel>",
1769    ))
1770}
1771
1772pub(crate) fn modify_instance_metadata_defaults(
1773    _svc: &Ec2Service,
1774    req: &AwsRequest,
1775) -> Result<AwsResponse, AwsServiceError> {
1776    validate_enum(
1777        &req.query_params,
1778        "HttpTokens",
1779        &["optional", "required", "no-preference"],
1780    )?;
1781    validate_enum(
1782        &req.query_params,
1783        "HttpEndpoint",
1784        &["disabled", "enabled", "no-preference"],
1785    )?;
1786    validate_enum(
1787        &req.query_params,
1788        "InstanceMetadataTags",
1789        &["disabled", "enabled", "no-preference"],
1790    )?;
1791    validate_enum(
1792        &req.query_params,
1793        "HttpTokensEnforced",
1794        &["disabled", "enabled", "no-preference"],
1795    )?;
1796    Ok(Ec2Service::respond(
1797        "ModifyInstanceMetadataDefaults",
1798        &req.request_id,
1799        &ec2_return(true),
1800    ))
1801}
1802
1803pub(crate) fn register_event_notification_attributes(
1804    _svc: &Ec2Service,
1805    req: &AwsRequest,
1806) -> Result<AwsResponse, AwsServiceError> {
1807    // InstanceTagAttribute is a required struct with no required members, so an
1808    // empty one is wire-invisible (== omission) — not validated here.
1809    Ok(Ec2Service::respond(
1810        "RegisterInstanceEventNotificationAttributes",
1811        &req.request_id,
1812        &event_tag_attribute(),
1813    ))
1814}
1815
1816pub(crate) fn deregister_event_notification_attributes(
1817    _svc: &Ec2Service,
1818    req: &AwsRequest,
1819) -> Result<AwsResponse, AwsServiceError> {
1820    Ok(Ec2Service::respond(
1821        "DeregisterInstanceEventNotificationAttributes",
1822        &req.request_id,
1823        &event_tag_attribute(),
1824    ))
1825}
1826
1827pub(crate) fn describe_event_notification_attributes(
1828    _svc: &Ec2Service,
1829    req: &AwsRequest,
1830) -> Result<AwsResponse, AwsServiceError> {
1831    Ok(Ec2Service::respond(
1832        "DescribeInstanceEventNotificationAttributes",
1833        &req.request_id,
1834        &event_tag_attribute(),
1835    ))
1836}
1837
1838fn event_tag_attribute() -> String {
1839    format!(
1840        "<instanceTagAttribute><includeAllTagsOfInstance>false</includeAllTagsOfInstance>{}</instanceTagAttribute>",
1841        ec2_list("instanceTagKeySet", &[])
1842    )
1843}
1844
1845pub(crate) fn report_instance_status(
1846    _svc: &Ec2Service,
1847    req: &AwsRequest,
1848) -> Result<AwsResponse, AwsServiceError> {
1849    require(&req.query_params, "Status")?;
1850    validate_enum(&req.query_params, "Status", &["ok", "impaired"])?;
1851    Ok(Ec2Service::respond(
1852        "ReportInstanceStatus",
1853        &req.request_id,
1854        &ec2_return(true),
1855    ))
1856}
1857
1858pub(crate) fn describe_instance_topology(
1859    _svc: &Ec2Service,
1860    req: &AwsRequest,
1861) -> Result<AwsResponse, AwsServiceError> {
1862    crate::service_helpers::validate_max_results(&req.query_params, 1, 100)?;
1863    Ok(Ec2Service::respond(
1864        "DescribeInstanceTopology",
1865        &req.request_id,
1866        &ec2_list("instanceSet", &[]),
1867    ))
1868}
1869
1870#[cfg(test)]
1871mod tests {
1872    use super::subnet_ip_prefix;
1873
1874    #[test]
1875    fn subnet_ip_prefix_uses_subnet_network() {
1876        // The synthesized metadata IP must land inside the subnet (finding 1.7).
1877        assert_eq!(subnet_ip_prefix("172.31.16.0/20"), "172.31.16");
1878        assert_eq!(subnet_ip_prefix("10.0.5.0/24"), "10.0.5");
1879        // bare address (no mask) still works
1880        assert_eq!(subnet_ip_prefix("192.168.1.0"), "192.168.1");
1881    }
1882
1883    #[test]
1884    fn subnet_ip_prefix_falls_back_on_garbage() {
1885        assert_eq!(subnet_ip_prefix(""), "10.0.0");
1886        assert_eq!(subnet_ip_prefix("not-a-cidr"), "10.0.0");
1887        // IPv6 / non-dotted-quad falls back rather than producing nonsense
1888        assert_eq!(subnet_ip_prefix("fd00::/8"), "10.0.0");
1889    }
1890}