Skip to main content

fakecloud_ecs/
service_tasks.rs

1// Auto-extracted from service.rs as part of carryover service.rs split.
2
3#![allow(clippy::too_many_arguments)]
4
5use chrono::Utc;
6use serde_json::{json, Value};
7
8use fakecloud_core::service::{AwsRequest, AwsResponse, AwsServiceError};
9
10use super::*;
11
12/// Extract the region field (index 3) from a standard ARN
13/// (`arn:partition:service:region:account:resource`). Returns `None` when
14/// the string isn't an ARN or the region segment is empty.
15fn region_from_arn(arn: &str) -> Option<&str> {
16    let parts: Vec<&str> = arn.splitn(6, ':').collect();
17    if parts.len() >= 4 && parts[0] == "arn" && !parts[3].is_empty() {
18        Some(parts[3])
19    } else {
20        None
21    }
22}
23
24impl EcsService {
25    /// Spawn a task from a cross-service caller (EventBridge Scheduler /
26    /// EventBridge Rules) without going through the AwsRequest dispatch
27    /// path. Builds the JSON body and reuses [`Self::run_task`] so all
28    /// the existing validation / runtime spawn logic runs identically.
29    /// Returns Err with a human-readable message on validation failures —
30    /// the caller decides whether to surface the failure (e.g. DLQ).
31    pub fn run_task_external(
32        &self,
33        account_id: &str,
34        cluster: &str,
35        task_definition: &str,
36        launch_type: Option<&str>,
37        count: usize,
38    ) -> Result<(), String> {
39        use bytes::Bytes;
40        use http::{HeaderMap, Method};
41        use std::collections::HashMap;
42        let body = serde_json::json!({
43            "cluster": cluster,
44            "taskDefinition": task_definition,
45            "launchType": launch_type.unwrap_or("FARGATE"),
46            "count": count.max(1) as i64,
47        });
48        let body_bytes =
49            Bytes::from(serde_json::to_vec(&body).map_err(|e| format!("encode body: {e}"))?);
50        // Derive the region from the task-definition or cluster ARN (EventBridge
51        // targets pass full ARNs) so the spawned task's ARNs carry the caller's
52        // region instead of a hardcoded us-east-1.
53        let region = region_from_arn(task_definition)
54            .or_else(|| region_from_arn(cluster))
55            .unwrap_or("us-east-1")
56            .to_string();
57        let req = AwsRequest {
58            service: "ecs".into(),
59            action: "RunTask".into(),
60            region,
61            account_id: account_id.to_string(),
62            request_id: uuid::Uuid::new_v4().to_string(),
63            headers: HeaderMap::new(),
64            query_params: HashMap::new(),
65            body: body_bytes,
66            body_stream: parking_lot::Mutex::new(None),
67            path_segments: Vec::new(),
68            raw_path: "/".into(),
69            raw_query: String::new(),
70            method: Method::POST,
71            is_query_protocol: false,
72            access_key_id: None,
73            principal: None,
74        };
75        self.run_task(&req)
76            .map(|_| ())
77            .map_err(|e| format!("{e:?}"))
78    }
79
80    pub fn run_task(&self, request: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
81        self.run_task_impl(request, Vec::new())
82    }
83
84    /// Shared RunTask/StartTask core. `explicit_instances` is empty for RunTask
85    /// (placement is chosen by strategy) and holds the caller-specified
86    /// container-instance references for StartTask (one task placed per entry).
87    fn run_task_impl(
88        &self,
89        request: &AwsRequest,
90        explicit_instances: Vec<String>,
91    ) -> Result<AwsResponse, AwsServiceError> {
92        let body = request.json_body();
93        let start_task = !explicit_instances.is_empty();
94        let td_ref = req_str(&body, "taskDefinition")?;
95        let cluster_ref = opt_str(&body, "cluster");
96        let cluster_name = EcsState::resolve_cluster_name(cluster_ref);
97        // StartTask places tasks on registered EC2/EXTERNAL container instances,
98        // so it is never FARGATE; RunTask defaults to FARGATE.
99        let launch_type = opt_str(&body, "launchType")
100            .unwrap_or(if start_task { "EC2" } else { "FARGATE" })
101            .to_string();
102        let placement_constraints: Vec<Value> = body
103            .get("placementConstraints")
104            .and_then(|v| v.as_array())
105            .cloned()
106            .unwrap_or_default();
107        let placement_strategy: Vec<Value> = body
108            .get("placementStrategy")
109            .and_then(|v| v.as_array())
110            .cloned()
111            .unwrap_or_default();
112        // AWS caps RunTask at 1..=10 tasks per call and rejects anything else
113        // with InvalidParameterException — it does NOT silently clamp to 1.
114        // StartTask instead launches exactly one task per specified container
115        // instance.
116        let count = if start_task {
117            explicit_instances.len()
118        } else {
119            match body.get("count").and_then(|v| v.as_i64()) {
120                Some(n) if (1..=10).contains(&n) => n as usize,
121                Some(n) => {
122                    return Err(invalid_parameter(format!(
123                        "count should be between 1 and 10, inclusive. count={n}"
124                    )))
125                }
126                None => 1,
127            }
128        };
129        // Defaulted to `family:<taskDefinitionFamily>` below once the task
130        // definition is resolved, mirroring how real AWS labels a bare RunTask.
131        let group = opt_str(&body, "group").map(String::from);
132        let started_by = opt_str(&body, "startedBy").map(String::from);
133        let enable_execute_command = body
134            .get("enableExecuteCommand")
135            .and_then(|v| v.as_bool())
136            .unwrap_or(false);
137        let propagate_tags = opt_str(&body, "propagateTags").map(String::from);
138        let _enable_ecs_managed_tags = body
139            .get("enableECSManagedTags")
140            .and_then(|v| v.as_bool())
141            .unwrap_or(false);
142        let _capacity_provider_strategy: Vec<Value> = body
143            .get("capacityProviderStrategy")
144            .and_then(|v| v.as_array())
145            .cloned()
146            .unwrap_or_default();
147        let volume_configurations: Vec<Value> = body
148            .get("volumeConfigurations")
149            .and_then(|v| v.as_array())
150            .cloned()
151            .unwrap_or_default();
152        let _availability_zone_rebalancing =
153            opt_str(&body, "availabilityZoneRebalancing").map(String::from);
154        let mut tags = parse_tags(&body);
155
156        // PassRole trust check on any role overrides supplied via the
157        // overrides.taskRoleArn / overrides.executionRoleArn fields.
158        // The base task definition was already checked at Register time,
159        // but RunTask can override either role and AWS re-validates the
160        // trust policy on every call.
161        if let Some(overrides) = body.get("overrides") {
162            if let Some(role_arn) = opt_str(overrides, "taskRoleArn") {
163                self.check_pass_role(&request.account_id, role_arn)?;
164            }
165            if let Some(role_arn) = opt_str(overrides, "executionRoleArn") {
166                self.check_pass_role(&request.account_id, role_arn)?;
167            }
168        }
169
170        let account = request.account_id.clone();
171        let runtime = self.runtime.clone();
172        let mut accounts = self.state.write();
173        let state = accounts.get_or_create(&account);
174        // AWS rejects RunTask against a cluster that does not exist rather than
175        // synthesizing one; a phantom cluster returns ClusterNotFoundException.
176        let cluster_arn = state
177            .clusters
178            .get(&cluster_name)
179            .map(|c| c.cluster_arn.clone())
180            .ok_or_else(|| cluster_not_found(&cluster_name))?;
181        let (_, family, rev) = resolve_task_definition_ref(td_ref)?;
182        let revisions = state
183            .task_definitions
184            .get(&family)
185            .ok_or_else(|| task_definition_not_found(td_ref))?;
186        let td = match rev {
187            Some(n) => revisions
188                .get(&n)
189                .ok_or_else(|| task_definition_not_found(td_ref))?,
190            None => latest_active_revision(revisions)
191                .ok_or_else(|| task_definition_not_found(td_ref))?,
192        };
193        if td.status != "ACTIVE" {
194            return Err(client_exception(format!(
195                "Task definition {} is not ACTIVE",
196                td.task_definition_arn
197            )));
198        }
199        let td_arn = td.task_definition_arn.clone();
200        let td_family = td.family.clone();
201        // Real AWS assigns a bare RunTask (no explicit `group`) the default
202        // group `family:<taskDefinitionFamily>`. Service/daemon tasks set their
203        // own group upstream, so only default it when the caller omitted one.
204        let group = group.or_else(|| Some(format!("family:{td_family}")));
205        let td_revision = td.revision;
206        let td_cpu = td.cpu.clone();
207        let td_memory = td.memory.clone();
208        let td_task_role = td.task_role_arn.clone();
209        let td_exec_role = td.execution_role_arn.clone();
210        let td_containers = td.container_definitions.clone();
211        // RunTask supports propagateTags=TASK_DEFINITION to copy the
212        // TaskDefinition's tags onto each spawned task, in addition to
213        // any tags supplied directly in the request body. Real AWS
214        // unions the two sets; explicit tags win on key conflicts.
215        if propagate_tags.as_deref() == Some("TASK_DEFINITION") {
216            let mut td_tags = td.tags.clone();
217            td_tags.retain(|t| !tags.iter().any(|x| x.key == t.key));
218            tags.extend(td_tags);
219        }
220
221        let mut spawned_tasks: Vec<String> = Vec::new();
222        let mut task_jsons: Vec<Value> = Vec::new();
223        // `task_index` maps a StartTask task to its explicit container instance;
224        // for RunTask (count from `count`) it is unused, so a plain range loop
225        // is clearer than restructuring around the two shapes.
226        #[allow(clippy::needless_range_loop)]
227        for task_index in 0..count {
228            let task_id = uuid::Uuid::new_v4().to_string().replace('-', "");
229            let task_arn = state.task_arn(&cluster_name, &task_id);
230            let containers: Vec<Container> = td_containers
231                .iter()
232                .map(|def| Container {
233                    container_arn: format!(
234                        "arn:aws:ecs:{}:{}:container/{}/{}/{}",
235                        state.region,
236                        state.account_id,
237                        cluster_name,
238                        task_id,
239                        def.get("name").and_then(|v| v.as_str()).unwrap_or("app")
240                    ),
241                    name: def
242                        .get("name")
243                        .and_then(|v| v.as_str())
244                        .unwrap_or("app")
245                        .to_string(),
246                    image: def
247                        .get("image")
248                        .and_then(|v| v.as_str())
249                        .unwrap_or("")
250                        .to_string(),
251                    task_arn: task_arn.clone(),
252                    last_status: "PENDING".into(),
253                    exit_code: None,
254                    reason: None,
255                    runtime_id: None,
256                    essential: def
257                        .get("essential")
258                        .and_then(|v| v.as_bool())
259                        .unwrap_or(true),
260                    cpu: def
261                        .get("cpu")
262                        .and_then(|v| v.as_i64())
263                        .map(|n| n.to_string()),
264                    memory: def
265                        .get("memory")
266                        .and_then(|v| v.as_i64())
267                        .map(|n| n.to_string()),
268                    memory_reservation: def
269                        .get("memoryReservation")
270                        .and_then(|v| v.as_i64())
271                        .map(|n| n.to_string()),
272                    network_bindings: Vec::new(),
273                    network_interfaces: Vec::new(),
274                    health_status: Some("UNKNOWN".to_string()),
275                    managed_agents: None,
276                    image_digest: None,
277                })
278                .collect();
279            let awslogs = td_containers.iter().find_map(|def| {
280                let name = def.get("name").and_then(|v| v.as_str())?.to_string();
281                let log_cfg = def.get("logConfiguration")?;
282                if log_cfg.get("logDriver").and_then(|v| v.as_str()) != Some("awslogs") {
283                    return None;
284                }
285                let opts = log_cfg.get("options").and_then(|v| v.as_object())?;
286                Some(AwsLogsConfig {
287                    group: opts.get("awslogs-group").and_then(|v| v.as_str())?.into(),
288                    stream_prefix: opts
289                        .get("awslogs-stream-prefix")
290                        .and_then(|v| v.as_str())
291                        .map(String::from),
292                    region: opts
293                        .get("awslogs-region")
294                        .and_then(|v| v.as_str())
295                        .unwrap_or(&state.region)
296                        .to_string(),
297                    container_name: name,
298                })
299            });
300            let capacity_provider_name = body
301                .get("capacityProviderStrategy")
302                .and_then(|v| v.as_array())
303                .and_then(|arr| arr.first())
304                .and_then(|item| item.get("capacityProvider"))
305                .and_then(|v| v.as_str())
306                .map(String::from);
307            let mut task = Task {
308                task_arn: task_arn.clone(),
309                task_id: task_id.clone(),
310                cluster_arn: cluster_arn.clone(),
311                cluster_name: cluster_name.clone(),
312                task_definition_arn: td_arn.clone(),
313                family: td_family.clone(),
314                revision: td_revision,
315                container_instance_arn: None,
316                capacity_provider_name,
317                last_status: "PROVISIONING".into(),
318                desired_status: "RUNNING".into(),
319                launch_type: launch_type.clone(),
320                platform_version: Some("1.4.0".into()),
321                cpu: body
322                    .get("overrides")
323                    .and_then(|v| v.get("cpu"))
324                    .and_then(|v| v.as_str())
325                    .map(String::from)
326                    .or_else(|| td_cpu.clone()),
327                memory: body
328                    .get("overrides")
329                    .and_then(|v| v.get("memory"))
330                    .and_then(|v| v.as_str())
331                    .map(String::from)
332                    .or_else(|| td_memory.clone()),
333                containers,
334                overrides: body.get("overrides").cloned().unwrap_or_else(|| json!({})),
335                started_by: started_by.clone(),
336                group: group.clone(),
337                connectivity: "CONNECTING".into(),
338                stop_code: None,
339                stopped_reason: None,
340                created_at: Utc::now(),
341                started_at: None,
342                stopping_at: None,
343                stopped_at: None,
344                pull_started_at: None,
345                pull_stopped_at: None,
346                connectivity_at: None,
347                started_by_ref_id: None,
348                execution_role_arn: td_exec_role.clone(),
349                task_role_arn: td_task_role.clone(),
350                tags: tags.clone(),
351                awslogs,
352                captured_logs: String::new(),
353                protection: None,
354                enable_execute_command,
355                attachments: Vec::new(),
356                volume_configurations: volume_configurations.clone(),
357                task_set_arn: None,
358            };
359            // Placement. StartTask targets the caller's explicit container
360            // instances (one per task); RunTask picks by strategy. Either way we
361            // resolve to a real registered instance and bump its pending count.
362            let placement_arn = if start_task {
363                let reference = &explicit_instances[task_index];
364                let tail = reference
365                    .rsplit_once("container-instance/")
366                    .map(|(_, t)| t)
367                    .unwrap_or(reference);
368                match super::helpers::resolve_container_instance_key(state, tail) {
369                    Some(key) => state
370                        .container_instances
371                        .get(&key)
372                        .map(|ci| ci.container_instance_arn.clone()),
373                    None => {
374                        return Err(invalid_parameter(format!(
375                            "Container instance {reference} is not registered in cluster {cluster_name}"
376                        )));
377                    }
378                }
379            } else if launch_type != "FARGATE" {
380                crate::placement::select_container_instance(
381                    state,
382                    &cluster_name,
383                    &placement_constraints,
384                    &placement_strategy,
385                    task.group.as_deref(),
386                    &td_arn,
387                    &launch_type,
388                )
389            } else {
390                None
391            };
392            if let Some(arn) = placement_arn {
393                task.container_instance_arn = Some(arn.clone());
394                if let Some(ci) = state
395                    .container_instances
396                    .values_mut()
397                    .find(|ci| ci.container_instance_arn == arn)
398                {
399                    ci.pending_tasks_count += 1;
400                }
401            }
402            state.tasks.insert(task_id.clone(), task.clone());
403            if let Some(cluster) = state.clusters.get_mut(&cluster_name) {
404                cluster.pending_tasks_count += 1;
405            }
406            // Snapshot-in-progress: transition to PENDING synchronously so
407            // callers that immediately DescribeTasks see movement. RUNNING /
408            // STOPPED come later from the background runtime task.
409            if let Some(t) = state.tasks.get_mut(&task_id) {
410                t.last_status = "PENDING".into();
411            }
412            task_jsons.push(task_to_json(&task));
413            spawned_tasks.push(task_id.clone());
414        }
415        drop(accounts);
416
417        // Launch container execution outside the state lock.
418        if let Some(rt) = runtime {
419            for id in &spawned_tasks {
420                rt.clone()
421                    .run_task(self.state.clone(), id.clone(), account.clone());
422            }
423        } else {
424            // No runtime available — fail fast so the task doesn't stay
425            // PENDING forever. We incremented pending_tasks_count above;
426            // decrement it here so the cluster counter doesn't drift and
427            // block later DeleteCluster calls.
428            let mut accounts = self.state.write();
429            if let Some(state) = accounts.get_mut(&account) {
430                // (cluster_name, container_instance_arn) for each failed task so
431                // BOTH pending counters that RunTask incremented get drained —
432                // previously only the cluster counter was decremented, leaking
433                // the container-instance pendingTasksCount for EC2/EXTERNAL tasks.
434                let mut drains: Vec<(String, Option<String>)> = Vec::new();
435                for id in &spawned_tasks {
436                    if let Some(t) = state.tasks.get_mut(id) {
437                        t.last_status = "STOPPED".into();
438                        // desired_status stays RUNNING: the task was requested
439                        // to run but failed to start. AWS keeps desired_status
440                        // as RUNNING for failed standalone RunTask until the
441                        // caller explicitly stops it. This also ensures
442                        // list_tasks (default filter=RUNNING) finds it.
443                        t.stop_code = Some("TaskFailedToStart".into());
444                        t.stopped_reason = Some(format!(
445                            "No container runtime available (docker/podman not installed). {}",
446                            fakecloud_core::container_net::CONTAINER_RUNTIME_HINT
447                        ));
448                        t.stopped_at = Some(Utc::now());
449                        for c in t.containers.iter_mut() {
450                            c.last_status = "STOPPED".into();
451                        }
452                        drains.push((t.cluster_name.clone(), t.container_instance_arn.clone()));
453                    }
454                }
455                for (name, ci_arn) in drains {
456                    if let Some(cluster) = state.clusters.get_mut(&name) {
457                        if cluster.pending_tasks_count > 0 {
458                            cluster.pending_tasks_count -= 1;
459                        }
460                    }
461                    if let Some(arn) = ci_arn {
462                        if let Some(ci) = state
463                            .container_instances
464                            .values_mut()
465                            .find(|ci| ci.container_instance_arn == arn)
466                        {
467                            if ci.pending_tasks_count > 0 {
468                                ci.pending_tasks_count -= 1;
469                            }
470                        }
471                    }
472                }
473            }
474        }
475
476        Ok(AwsResponse::ok_json(json!({
477            "tasks": task_jsons,
478            "failures": [],
479        })))
480    }
481
482    pub(super) fn start_task(&self, request: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
483        // StartTask places one task on each caller-specified container instance
484        // (as opposed to RunTask, which picks placement by strategy).
485        let body = request.json_body();
486        let instances: Vec<String> = body
487            .get("containerInstances")
488            .and_then(|v| v.as_array())
489            .map(|arr| {
490                arr.iter()
491                    .filter_map(|v| v.as_str().map(String::from))
492                    .collect()
493            })
494            .unwrap_or_default();
495        if instances.is_empty() {
496            return Err(invalid_parameter(
497                "containerInstances is required and must not be empty for StartTask",
498            ));
499        }
500        self.run_task_impl(request, instances)
501    }
502
503    pub(super) async fn stop_task(
504        &self,
505        request: &AwsRequest,
506    ) -> Result<AwsResponse, AwsServiceError> {
507        let body = request.json_body();
508        let task_ref = req_str(&body, "task")?;
509        let reason = opt_str(&body, "reason")
510            .unwrap_or("UserInitiated")
511            .to_string();
512        let cluster_ref = opt_str(&body, "cluster");
513        let _cluster_name = EcsState::resolve_cluster_name(cluster_ref);
514
515        let (task_id, account, task_snapshot) = {
516            let account = request.account_id.clone();
517            let mut accounts = self.state.write();
518            let state = accounts
519                .get_mut(&account)
520                .ok_or_else(|| task_not_found(task_ref))?;
521            let task_id = resolve_task_id(state, task_ref)?;
522            let task = state
523                .tasks
524                .get_mut(&task_id)
525                .ok_or_else(|| task_not_found(task_ref))?;
526            task.desired_status = "STOPPED".into();
527            task.stopping_at = Some(Utc::now());
528            task.stopped_reason = Some(reason.clone());
529            task.stop_code = Some("UserInitiated".into());
530            (task_id, account, task.clone())
531        };
532        if let Some(rt) = &self.runtime {
533            rt.stop_task(&task_id, &reason).await;
534        }
535        let _ = account;
536        Ok(AwsResponse::ok_json(json!({
537            "task": task_to_json(&task_snapshot),
538        })))
539    }
540
541    pub(super) fn describe_tasks(
542        &self,
543        request: &AwsRequest,
544    ) -> Result<AwsResponse, AwsServiceError> {
545        let body = request.json_body();
546        let refs: Vec<String> = req_array(&body, "tasks")?
547            .iter()
548            .filter_map(|v| v.as_str().map(String::from))
549            .collect();
550        let include_tags = body
551            .get("include")
552            .and_then(|v| v.as_array())
553            .map(|arr| arr.iter().any(|v| v.as_str() == Some("TAGS")))
554            .unwrap_or(false);
555        // AWS scopes DescribeTasks to the given cluster (default "default"):
556        // a task that exists in another cluster is reported MISSING, not found.
557        let cluster_name = EcsState::resolve_cluster_name(opt_str(&body, "cluster"));
558
559        let account = request.account_id.clone();
560        let accounts = self.state.read();
561        let Some(state) = accounts.get(&account) else {
562            return Ok(AwsResponse::ok_json(json!({
563                "tasks": [],
564                "failures": refs.iter().map(|r| json!({"arn": r, "reason": "MISSING"})).collect::<Vec<_>>(),
565            })));
566        };
567        let mut found = Vec::new();
568        let mut failures = Vec::new();
569        for input in &refs {
570            let task_id = task_id_from_ref(input);
571            match state.tasks.get(&task_id) {
572                Some(t) if t.cluster_name == cluster_name => {
573                    let mut v = task_to_json(t);
574                    if include_tags {
575                        v.as_object_mut()
576                            .unwrap()
577                            .insert("tags".into(), tags_json(&t.tags));
578                    }
579                    found.push(v);
580                }
581                _ => {
582                    failures.push(json!({
583                        "arn": input,
584                        "reason": "MISSING",
585                    }));
586                }
587            }
588        }
589        Ok(AwsResponse::ok_json(json!({
590            "tasks": found,
591            "failures": failures,
592        })))
593    }
594
595    pub(super) fn list_tasks(&self, request: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
596        let body = request.json_body();
597        validate_enum_opt(&body, "desiredStatus", &["RUNNING", "PENDING", "STOPPED"])?;
598        validate_enum_opt(
599            &body,
600            "launchType",
601            &["EC2", "FARGATE", "EXTERNAL", "MANAGED_INSTANCES"],
602        )?;
603        let cluster_ref = opt_str(&body, "cluster");
604        let cluster_name = EcsState::resolve_cluster_name(cluster_ref);
605        let family = opt_str(&body, "family");
606        let status_filter = opt_str(&body, "desiredStatus").or(Some("RUNNING"));
607        let started_by = opt_str(&body, "startedBy");
608        // A service's tasks carry group `service:<name>`. The serviceName
609        // filter may arrive as a bare name or a full service ARN.
610        let service_group =
611            opt_str(&body, "serviceName").map(|s| format!("service:{}", service_name_from_ref(s)));
612        // containerInstance may be a full ARN or a bare instance ID; compare on
613        // the trailing ID segment so both forms match.
614        let container_instance = opt_str(&body, "containerInstance").map(id_from_ref);
615        let max_results = body
616            .get("maxResults")
617            .and_then(|v| v.as_i64())
618            .filter(|n| (1..=100).contains(n))
619            .map(|n| n as usize)
620            .unwrap_or(100);
621        let next_token = opt_str(&body, "nextToken").unwrap_or("");
622
623        let account = request.account_id.clone();
624        let accounts = self.state.read();
625        let mut arns: Vec<String> = match accounts.get(&account) {
626            Some(state) => state
627                .tasks
628                .values()
629                .filter(|t| t.cluster_name == cluster_name)
630                .filter(|t| family.is_none_or(|f| t.family == f))
631                .filter(|t| status_filter.is_none_or(|s| t.desired_status == s))
632                .filter(|t| started_by.is_none_or(|s| t.started_by.as_deref() == Some(s)))
633                .filter(|t| {
634                    service_group
635                        .as_deref()
636                        .is_none_or(|g| t.group.as_deref() == Some(g))
637                })
638                .filter(|t| {
639                    container_instance.as_deref().is_none_or(|ci| {
640                        t.container_instance_arn
641                            .as_deref()
642                            .map(id_from_ref)
643                            .as_deref()
644                            == Some(ci)
645                    })
646                })
647                .map(|t| t.task_arn.clone())
648                .collect(),
649            None => Vec::new(),
650        };
651        arns.sort();
652        let (page, next) = paginate_arns(arns, next_token, max_results);
653        let mut out = json!({"taskArns": page});
654        if let Some(n) = next {
655            out.as_object_mut()
656                .unwrap()
657                .insert("nextToken".into(), json!(n));
658        }
659        Ok(AwsResponse::ok_json(out))
660    }
661}
662
663#[cfg(test)]
664mod multi_container_tests {
665    use super::*;
666    use crate::EcsService;
667    use bytes::Bytes;
668    use fakecloud_core::multi_account::MultiAccountState;
669    use http::{HeaderMap, Method};
670    use parking_lot::RwLock;
671    use std::collections::HashMap;
672    use std::sync::Arc;
673
674    pub(super) fn fresh_service() -> EcsService {
675        let accounts: MultiAccountState<EcsState> =
676            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
677        let state = Arc::new(RwLock::new(accounts));
678        let svc = EcsService::new(state.clone());
679        // Pre-create the cluster so RunTask doesn't trip on a missing one.
680        let mut accounts = state.write();
681        let s = accounts.get_or_create("000000000000");
682        let arn = s.cluster_arn("default");
683        s.clusters
684            .insert("default".into(), Cluster::new("default", arn));
685        drop(accounts);
686        svc
687    }
688
689    pub(super) fn make_request(action: &str, body: Value) -> AwsRequest {
690        let body_bytes = Bytes::from(serde_json::to_vec(&body).unwrap());
691        AwsRequest {
692            service: "ecs".into(),
693            action: action.into(),
694            region: "us-east-1".into(),
695            account_id: "000000000000".into(),
696            request_id: uuid::Uuid::new_v4().to_string(),
697            headers: HeaderMap::new(),
698            query_params: HashMap::new(),
699            body: body_bytes,
700            body_stream: parking_lot::Mutex::new(None),
701            path_segments: Vec::new(),
702            raw_path: "/".into(),
703            raw_query: String::new(),
704            method: Method::POST,
705            is_query_protocol: false,
706            access_key_id: None,
707            principal: None,
708        }
709    }
710
711    #[test]
712    fn run_task_count_over_ten_is_invalid_parameter() {
713        let svc = fresh_service();
714        // count is validated before the cluster/task-def are dereferenced.
715        let err = match svc.run_task(&make_request(
716            "RunTask",
717            json!({"taskDefinition": "x", "count": 50}),
718        )) {
719            Ok(_) => panic!("expected InvalidParameterException for count > 10"),
720            Err(e) => e,
721        };
722        assert_eq!(err.code(), "InvalidParameterException");
723    }
724
725    #[test]
726    fn create_service_negative_desired_count_is_invalid_parameter() {
727        let svc = fresh_service();
728        // desiredCount is validated before the cluster/task-def are resolved.
729        let err = match svc.create_service(&make_request(
730            "CreateService",
731            json!({"serviceName": "s", "taskDefinition": "x", "desiredCount": -3}),
732        )) {
733            Ok(_) => panic!("expected InvalidParameterException for negative desiredCount"),
734            Err(e) => e,
735        };
736        assert_eq!(err.code(), "InvalidParameterException");
737    }
738
739    #[test]
740    fn run_task_on_missing_cluster_is_cluster_not_found() {
741        let svc = fresh_service();
742        let err = match svc.run_task(&make_request(
743            "RunTask",
744            json!({"taskDefinition": "x", "cluster": "ghost", "count": 1}),
745        )) {
746            Ok(_) => panic!("expected ClusterNotFoundException for a phantom cluster"),
747            Err(e) => e,
748        };
749        assert_eq!(err.code(), "ClusterNotFoundException");
750    }
751
752    #[test]
753    fn region_from_arn_extracts_region_or_none() {
754        assert_eq!(
755            region_from_arn("arn:aws:ecs:eu-west-1:000000000000:task-definition/web:3"),
756            Some("eu-west-1")
757        );
758        assert_eq!(
759            region_from_arn("arn:aws:ecs:ap-southeast-2:000000000000:cluster/prod"),
760            Some("ap-southeast-2")
761        );
762        // Bare names (not ARNs) and empty-region ARNs yield None.
763        assert_eq!(region_from_arn("web:3"), None);
764        assert_eq!(
765            region_from_arn("arn:aws:ecs::000000000000:cluster/prod"),
766            None
767        );
768    }
769
770    pub(super) fn insert_task(svc: &EcsService, id: &str, cluster: &str) {
771        let state = svc.state.clone();
772        let mut accounts = state.write();
773        let acct = accounts.get_or_create("000000000000");
774        let task = Task {
775            task_arn: format!("arn:aws:ecs:us-east-1:000000000000:task/{cluster}/{id}"),
776            task_id: id.into(),
777            cluster_arn: format!("arn:aws:ecs:us-east-1:000000000000:cluster/{cluster}"),
778            cluster_name: cluster.into(),
779            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/web:1".into(),
780            family: "web".into(),
781            revision: 1,
782            container_instance_arn: None,
783            capacity_provider_name: None,
784            last_status: "RUNNING".into(),
785            desired_status: "RUNNING".into(),
786            launch_type: "FARGATE".into(),
787            platform_version: None,
788            cpu: None,
789            memory: None,
790            containers: Vec::new(),
791            overrides: serde_json::json!({}),
792            started_by: None,
793            group: None,
794            connectivity: "CONNECTED".into(),
795            stop_code: None,
796            stopped_reason: None,
797            created_at: Utc::now(),
798            started_at: None,
799            stopping_at: None,
800            stopped_at: None,
801            pull_started_at: None,
802            pull_stopped_at: None,
803            connectivity_at: None,
804            started_by_ref_id: None,
805            execution_role_arn: None,
806            task_role_arn: None,
807            tags: Vec::new(),
808            awslogs: None,
809            captured_logs: String::new(),
810            protection: None,
811            enable_execute_command: false,
812            attachments: Vec::new(),
813            volume_configurations: Vec::new(),
814            task_set_arn: None,
815        };
816        acct.tasks.insert(id.into(), task);
817    }
818
819    #[test]
820    fn describe_tasks_scopes_to_requested_cluster() {
821        let svc = fresh_service();
822        insert_task(&svc, "abc", "other");
823        let task_arn = "arn:aws:ecs:us-east-1:000000000000:task/other/abc";
824
825        // Wrong cluster (default) -> MISSING, not found.
826        let resp = svc
827            .describe_tasks(&make_request(
828                "DescribeTasks",
829                json!({"tasks": [task_arn], "cluster": "default"}),
830            ))
831            .unwrap();
832        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
833        assert_eq!(body["tasks"].as_array().unwrap().len(), 0);
834        assert_eq!(body["failures"][0]["reason"], "MISSING");
835
836        // Correct cluster -> found.
837        let resp = svc
838            .describe_tasks(&make_request(
839                "DescribeTasks",
840                json!({"tasks": [task_arn], "cluster": "other"}),
841            ))
842            .unwrap();
843        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
844        assert_eq!(body["tasks"].as_array().unwrap().len(), 1);
845        assert_eq!(body["failures"].as_array().unwrap().len(), 0);
846    }
847
848    #[test]
849    fn describe_service_revisions_returns_stored_revision_or_missing() {
850        let svc = fresh_service();
851        svc.register_task_definition(&make_request(
852            "RegisterTaskDefinition",
853            json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
854        ))
855        .unwrap();
856        let created = svc
857            .create_service(&make_request(
858                "CreateService",
859                json!({"serviceName": "s", "taskDefinition": "web", "desiredCount": 0}),
860            ))
861            .unwrap();
862        let cbody: Value = serde_json::from_slice(created.body.expect_bytes()).unwrap();
863        let service_arn = cbody["service"]["serviceArn"].as_str().unwrap().to_string();
864
865        // CreateService minted revision 1.
866        let rev1 = format!("{service_arn}:1");
867        let d = svc
868            .describe_service_revisions(&make_request(
869                "DescribeServiceRevisions",
870                json!({"serviceRevisionArns": [rev1.clone()]}),
871            ))
872            .unwrap();
873        let dbody: Value = serde_json::from_slice(d.body.expect_bytes()).unwrap();
874        assert_eq!(dbody["serviceRevisions"].as_array().unwrap().len(), 1);
875        assert_eq!(
876            dbody["serviceRevisions"][0]["serviceRevisionArn"],
877            json!(rev1)
878        );
879        assert!(dbody["serviceRevisions"][0]["taskDefinition"]
880            .as_str()
881            .unwrap()
882            .ends_with("web:1"));
883        assert_eq!(dbody["failures"].as_array().unwrap().len(), 0);
884
885        // Unknown revision -> MISSING.
886        let d2 = svc
887            .describe_service_revisions(&make_request(
888                "DescribeServiceRevisions",
889                json!({"serviceRevisionArns": [format!("{service_arn}:99")]}),
890            ))
891            .unwrap();
892        let d2body: Value = serde_json::from_slice(d2.body.expect_bytes()).unwrap();
893        assert_eq!(d2body["serviceRevisions"].as_array().unwrap().len(), 0);
894        assert_eq!(d2body["failures"][0]["reason"], "MISSING");
895    }
896
897    #[test]
898    fn register_task_def_with_two_containers_then_run_task_starts_both() {
899        let svc = fresh_service();
900        let reg = make_request(
901            "RegisterTaskDefinition",
902            json!({
903                "family": "multi",
904                "containerDefinitions": [
905                    {"name": "app", "image": "alpine"},
906                    {"name": "sidecar", "image": "alpine"}
907                ]
908            }),
909        );
910        svc.register_task_definition(&reg)
911            .expect("register should succeed");
912
913        let run = make_request(
914            "RunTask",
915            json!({
916                "cluster": "default",
917                "taskDefinition": "multi",
918            }),
919        );
920        let resp = svc.run_task(&run).expect("run_task should succeed");
921        let body: Value =
922            serde_json::from_slice(resp.body.expect_bytes()).expect("body should be valid JSON");
923        let tasks = body
924            .get("tasks")
925            .and_then(|v| v.as_array())
926            .expect("tasks array");
927        assert_eq!(tasks.len(), 1);
928        let task = &tasks[0];
929        let containers = task
930            .get("containers")
931            .and_then(|v| v.as_array())
932            .expect("containers array on task");
933        assert_eq!(containers.len(), 2, "expected both containers in task");
934        let names: Vec<&str> = containers
935            .iter()
936            .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
937            .collect();
938        assert!(names.contains(&"app"));
939        assert!(names.contains(&"sidecar"));
940
941        // Per-container ARNs must be distinct so DescribeTasks can address
942        // each container independently.
943        let arns: std::collections::HashSet<&str> = containers
944            .iter()
945            .filter_map(|c| c.get("containerArn").and_then(|v| v.as_str()))
946            .collect();
947        assert_eq!(arns.len(), 2);
948    }
949
950    #[test]
951    fn run_task_defaults_group_to_family() {
952        // A bare RunTask (no `group`) gets `family:<taskDefinitionFamily>`,
953        // matching real AWS. eventing/consumers filter tasks by group, so a
954        // null group hides RunTask-spawned tasks from them.
955        let svc = fresh_service();
956        svc.register_task_definition(&make_request(
957            "RegisterTaskDefinition",
958            json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
959        ))
960        .unwrap();
961
962        let resp = svc
963            .run_task(&make_request(
964                "RunTask",
965                json!({"cluster": "default", "taskDefinition": "web"}),
966            ))
967            .unwrap();
968        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
969        assert_eq!(body["tasks"][0]["group"], "family:web");
970    }
971
972    #[test]
973    fn run_task_preserves_explicit_group() {
974        let svc = fresh_service();
975        svc.register_task_definition(&make_request(
976            "RegisterTaskDefinition",
977            json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
978        ))
979        .unwrap();
980
981        let resp = svc
982            .run_task(&make_request(
983                "RunTask",
984                json!({"cluster": "default", "taskDefinition": "web", "group": "my-group"}),
985            ))
986            .unwrap();
987        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
988        assert_eq!(body["tasks"][0]["group"], "my-group");
989    }
990
991    #[test]
992    fn register_task_def_defaults_essential_true() {
993        let svc = fresh_service();
994        let reg = make_request(
995            "RegisterTaskDefinition",
996            json!({
997                "family": "default-essential",
998                // No `essential` declared on either container.
999                "containerDefinitions": [
1000                    {"name": "main", "image": "alpine"},
1001                    {"name": "extra", "image": "alpine"}
1002                ]
1003            }),
1004        );
1005        svc.register_task_definition(&reg).unwrap();
1006
1007        let run = make_request(
1008            "RunTask",
1009            json!({
1010                "cluster": "default",
1011                "taskDefinition": "default-essential",
1012            }),
1013        );
1014        let resp = svc.run_task(&run).unwrap();
1015        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1016        let containers = body["tasks"][0]["containers"].as_array().unwrap();
1017        // The `essential` flag is a TaskDefinition.ContainerDefinition
1018        // field, not part of the runtime `Container` response shape.
1019        // Real ECS doesn't echo it back on RunTask, and the conformance
1020        // probe rejects responses that do.
1021        for c in containers {
1022            assert!(
1023                c.get("essential").is_none(),
1024                "container {:?} must not carry `essential` on the runtime shape",
1025                c.get("name")
1026            );
1027        }
1028    }
1029
1030    #[test]
1031    fn task_to_json_emits_full_container_array() {
1032        // Build a Task with two containers directly and confirm helper
1033        // emits both entries in the response shape.
1034        let mut task = Task {
1035            task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
1036            task_id: "abc".into(),
1037            cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
1038            cluster_name: "default".into(),
1039            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/multi:1"
1040                .into(),
1041            family: "multi".into(),
1042            revision: 1,
1043            container_instance_arn: None,
1044            capacity_provider_name: None,
1045            last_status: "RUNNING".into(),
1046            desired_status: "RUNNING".into(),
1047            launch_type: "FARGATE".into(),
1048            platform_version: None,
1049            cpu: None,
1050            memory: None,
1051            containers: Vec::new(),
1052            overrides: json!({}),
1053            started_by: None,
1054            group: None,
1055            connectivity: "CONNECTED".into(),
1056            stop_code: None,
1057            stopped_reason: None,
1058            created_at: chrono::Utc::now(),
1059            started_at: None,
1060            stopping_at: None,
1061            stopped_at: None,
1062            pull_started_at: None,
1063            pull_stopped_at: None,
1064            connectivity_at: None,
1065            started_by_ref_id: None,
1066            execution_role_arn: None,
1067            task_role_arn: None,
1068            tags: Vec::new(),
1069            awslogs: None,
1070            captured_logs: String::new(),
1071            protection: None,
1072            enable_execute_command: false,
1073            attachments: Vec::new(),
1074            volume_configurations: Vec::new(),
1075            task_set_arn: None,
1076        };
1077        for name in ["app", "sidecar"] {
1078            task.containers.push(Container {
1079                container_arn: format!(
1080                    "arn:aws:ecs:us-east-1:000000000000:container/default/abc/{name}"
1081                ),
1082                name: name.into(),
1083                image: "alpine".into(),
1084                task_arn: task.task_arn.clone(),
1085                last_status: "RUNNING".into(),
1086                exit_code: None,
1087                reason: None,
1088                runtime_id: Some(format!("docker-{name}")),
1089                essential: true,
1090                cpu: None,
1091                memory: None,
1092                memory_reservation: None,
1093                network_bindings: Vec::new(),
1094                network_interfaces: Vec::new(),
1095                health_status: None,
1096                managed_agents: None,
1097                image_digest: None,
1098            });
1099        }
1100
1101        let v = task_to_json(&task);
1102        let containers = v
1103            .get("containers")
1104            .and_then(|v| v.as_array())
1105            .expect("containers array");
1106        assert_eq!(containers.len(), 2);
1107        let names: Vec<&str> = containers
1108            .iter()
1109            .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
1110            .collect();
1111        assert_eq!(names, vec!["app", "sidecar"]);
1112        for c in containers {
1113            assert!(c.get("containerArn").is_some());
1114            assert!(c.get("name").is_some());
1115            assert!(c.get("lastStatus").is_some());
1116            assert!(c.get("runtimeId").is_some());
1117            assert!(c.get("essential").is_none());
1118        }
1119    }
1120}
1121
1122#[cfg(test)]
1123mod port_mapping_tests {
1124    //! Cover the `portMappings` -> `docker run --publish` translation
1125    //! plus the per-container `networkBindings` projected onto the task.
1126    //! The argv builder is exercised directly so we don't need a real
1127    //! container CLI on the test host.
1128    use super::*;
1129    use crate::runtime::{
1130        build_run_argv, mark_running_multi, ContainerPlan, PortMapping, RunningContainer,
1131    };
1132    use crate::state::{Container, EcsState};
1133    use crate::SharedEcsState;
1134    use chrono::Utc;
1135    use fakecloud_core::multi_account::MultiAccountState;
1136    use parking_lot::RwLock;
1137    use std::sync::Arc;
1138
1139    fn plan_with_ports(
1140        port_mappings: Vec<PortMapping>,
1141        network_mode: Option<&str>,
1142    ) -> ContainerPlan {
1143        ContainerPlan {
1144            container_name: "app".into(),
1145            image: "alpine:latest".into(),
1146            env: Vec::new(),
1147            entry_point: Vec::new(),
1148            command: Vec::new(),
1149            secrets_refs: Vec::new(),
1150            essential: true,
1151            has_task_role: false,
1152            port_mappings,
1153            network_mode: network_mode.map(String::from),
1154            depends_on: Vec::new(),
1155            health_check: None,
1156            volume_mounts: Vec::new(),
1157            ulimits: Vec::new(),
1158            linux_parameters: None,
1159            stop_timeout: None,
1160            user: None,
1161            working_directory: None,
1162            tty: false,
1163            interactive: false,
1164            readonly_rootfs: false,
1165        }
1166    }
1167
1168    fn argv_string(plan: &ContainerPlan) -> Vec<String> {
1169        build_run_argv(
1170            plan,
1171            &[],
1172            "task-1",
1173            "host.docker.internal",
1174            None,
1175            "alpine:latest",
1176            true,
1177        )
1178    }
1179
1180    /// Helper for asserting a `--publish <spec>` pair is present in argv.
1181    /// Returns true when the flag/value pair appears as adjacent entries.
1182    fn argv_has_publish(argv: &[String], spec: &str) -> bool {
1183        argv.windows(2).any(|w| w[0] == "--publish" && w[1] == spec)
1184    }
1185
1186    #[test]
1187    fn port_mappings_translate_to_publish_flags() {
1188        let plan = plan_with_ports(
1189            vec![PortMapping {
1190                container_port: 80,
1191                host_port: 8080,
1192                protocol: "tcp".into(),
1193            }],
1194            None,
1195        );
1196        let argv = argv_string(&plan);
1197        assert!(
1198            argv_has_publish(&argv, "80:8080/tcp"),
1199            "expected --publish 80:8080/tcp in argv: {argv:?}"
1200        );
1201    }
1202
1203    #[test]
1204    fn port_mappings_default_host_port_to_container_port() {
1205        // host_port=0 in the parsed mapping means "AWS host-mode default";
1206        // parse_port_mapping rewrites that to containerPort, so by the time
1207        // we reach build_run_argv the host_port should already equal 80.
1208        // Drive the same path through the JSON parser to lock in the
1209        // default behaviour end to end.
1210        let parsed =
1211            crate::runtime::__test_parse_port_mapping(&serde_json::json!({"containerPort": 80}))
1212                .expect("containerPort should parse");
1213        assert_eq!(
1214            parsed.host_port, 80,
1215            "default hostPort should mirror containerPort"
1216        );
1217        let argv = argv_string(&plan_with_ports(vec![parsed], None));
1218        assert!(
1219            argv_has_publish(&argv, "80:80/tcp"),
1220            "expected --publish 80:80/tcp when hostPort omitted: {argv:?}"
1221        );
1222    }
1223
1224    #[test]
1225    fn port_mappings_default_protocol_tcp() {
1226        let parsed = crate::runtime::__test_parse_port_mapping(
1227            &serde_json::json!({"containerPort": 443, "hostPort": 443}),
1228        )
1229        .expect("containerPort should parse");
1230        assert_eq!(parsed.protocol, "tcp");
1231        let argv = argv_string(&plan_with_ports(vec![parsed], None));
1232        assert!(
1233            argv_has_publish(&argv, "443:443/tcp"),
1234            "expected default protocol tcp: {argv:?}"
1235        );
1236    }
1237
1238    #[test]
1239    fn awsvpc_network_mode_skips_publish() {
1240        let plan = plan_with_ports(
1241            vec![PortMapping {
1242                container_port: 80,
1243                host_port: 8080,
1244                protocol: "tcp".into(),
1245            }],
1246            Some("awsvpc"),
1247        );
1248        let argv = argv_string(&plan);
1249        assert!(
1250            !argv.iter().any(|s| s == "--publish"),
1251            "awsvpc must not emit --publish: {argv:?}"
1252        );
1253    }
1254
1255    #[test]
1256    fn awsvpc_network_mode_includes_network_flag() {
1257        let plan = plan_with_ports(Vec::new(), Some("awsvpc"));
1258        let argv = argv_string(&plan);
1259        let network_idx = argv.iter().position(|s| s == "--network");
1260        assert!(
1261            network_idx.is_some(),
1262            "awsvpc must emit --network: {argv:?}"
1263        );
1264        let network_name = argv.get(network_idx.unwrap() + 1);
1265        assert!(
1266            network_name
1267                .map(|n| n.starts_with("fakecloud-ecs-"))
1268                .unwrap_or(false),
1269            "awsvpc must reference fakecloud-ecs network: {argv:?}"
1270        );
1271    }
1272
1273    #[test]
1274    fn network_bindings_populated_on_task() {
1275        // Build a task in state, run mark_running_multi with a started
1276        // container that has network_bindings populated, and verify
1277        // task_to_json emits them under containers[0].networkBindings.
1278        let mut accounts: MultiAccountState<EcsState> =
1279            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
1280        let acct = accounts.get_or_create("000000000000");
1281        let arn = acct.cluster_arn("default");
1282        acct.clusters
1283            .insert("default".into(), Cluster::new("default", arn));
1284        let mut task = Task {
1285            task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
1286            task_id: "abc".into(),
1287            cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
1288            cluster_name: "default".into(),
1289            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/web:1".into(),
1290            family: "web".into(),
1291            revision: 1,
1292            container_instance_arn: None,
1293            capacity_provider_name: None,
1294            last_status: "PENDING".into(),
1295            desired_status: "RUNNING".into(),
1296            launch_type: "FARGATE".into(),
1297            platform_version: None,
1298            cpu: None,
1299            memory: None,
1300            containers: vec![Container {
1301                container_arn: "arn:aws:ecs:us-east-1:000000000000:container/default/abc/web"
1302                    .into(),
1303                name: "web".into(),
1304                image: "alpine".into(),
1305                task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
1306                last_status: "PENDING".into(),
1307                exit_code: None,
1308                reason: None,
1309                runtime_id: None,
1310                essential: true,
1311                cpu: None,
1312                memory: None,
1313                memory_reservation: None,
1314                network_bindings: Vec::new(),
1315                network_interfaces: Vec::new(),
1316                health_status: None,
1317                managed_agents: None,
1318                image_digest: None,
1319            }],
1320            overrides: serde_json::json!({}),
1321            started_by: None,
1322            group: None,
1323            connectivity: "CONNECTING".into(),
1324            stop_code: None,
1325            stopped_reason: None,
1326            created_at: Utc::now(),
1327            started_at: None,
1328            stopping_at: None,
1329            stopped_at: None,
1330            pull_started_at: None,
1331            pull_stopped_at: None,
1332            connectivity_at: None,
1333            started_by_ref_id: None,
1334            execution_role_arn: None,
1335            task_role_arn: None,
1336            tags: Vec::new(),
1337            awslogs: None,
1338            captured_logs: String::new(),
1339            protection: None,
1340            enable_execute_command: false,
1341            attachments: Vec::new(),
1342            volume_configurations: Vec::new(),
1343            task_set_arn: None,
1344        };
1345        task.last_status = "PENDING".into();
1346        acct.tasks.insert("abc".into(), task);
1347        let state: SharedEcsState = Arc::new(RwLock::new(accounts));
1348
1349        let bindings = vec![serde_json::json!({
1350            "bindIP": "0.0.0.0",
1351            "containerPort": 80,
1352            "hostPort": 8080,
1353            "protocol": "tcp",
1354        })];
1355        let started = vec![RunningContainer {
1356            name: "web".into(),
1357            container_id: "docker-id".into(),
1358            essential: true,
1359            exit_code: None,
1360            network_bindings: bindings.clone(),
1361            image_digest: None,
1362        }];
1363        mark_running_multi(&state, "000000000000", "abc", &started);
1364
1365        let accounts = state.read();
1366        let task = accounts
1367            .get("000000000000")
1368            .unwrap()
1369            .tasks
1370            .get("abc")
1371            .unwrap();
1372        let json = task_to_json(task);
1373        let nb = &json["containers"][0]["networkBindings"];
1374        assert_eq!(nb, &serde_json::Value::Array(bindings));
1375    }
1376}
1377
1378#[cfg(test)]
1379mod service_validation_tests {
1380    use super::multi_container_tests::*;
1381    use super::*;
1382    use serde_json::{json, Value};
1383
1384    /// Register a task def + create an ACTIVE service named `web` with
1385    /// desiredCount 0, so the update/create validation tests have a target.
1386    fn service_ready(svc: &EcsService) {
1387        svc.register_task_definition(&make_request(
1388            "RegisterTaskDefinition",
1389            json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
1390        ))
1391        .unwrap();
1392        svc.create_service(&make_request(
1393            "CreateService",
1394            json!({"serviceName": "web", "taskDefinition": "web", "desiredCount": 0}),
1395        ))
1396        .unwrap();
1397    }
1398
1399    #[test]
1400    fn update_service_negative_desired_count_is_invalid_parameter() {
1401        let svc = fresh_service();
1402        service_ready(&svc);
1403        // A negative desiredCount previously clamped to 0 and silently scaled
1404        // the service down. AWS rejects it instead.
1405        let err = match svc.update_service(&make_request(
1406            "UpdateService",
1407            json!({"service": "web", "desiredCount": -1}),
1408        )) {
1409            Ok(_) => panic!("expected InvalidParameterException for negative desiredCount"),
1410            Err(e) => e,
1411        };
1412        assert_eq!(err.code(), "InvalidParameterException");
1413    }
1414
1415    #[test]
1416    fn update_service_on_inactive_service_is_service_not_active() {
1417        let svc = fresh_service();
1418        service_ready(&svc);
1419        // Simulate a deleted service left at INACTIVE (still describable).
1420        {
1421            let mut accounts = svc.state.write();
1422            let state = accounts.get_mut("000000000000").unwrap();
1423            let key = EcsState::service_key("default", "web");
1424            state.services.get_mut(&key).unwrap().status = "INACTIVE".into();
1425        }
1426        let err = match svc.update_service(&make_request(
1427            "UpdateService",
1428            json!({"service": "web", "desiredCount": 2}),
1429        )) {
1430            Ok(_) => panic!("expected ServiceNotActiveException for a non-ACTIVE service"),
1431            Err(e) => e,
1432        };
1433        assert_eq!(err.code(), "ServiceNotActiveException");
1434    }
1435
1436    #[test]
1437    fn create_service_duplicate_name_is_invalid_parameter_not_idempotent() {
1438        let svc = fresh_service();
1439        service_ready(&svc);
1440        let err = match svc.create_service(&make_request(
1441            "CreateService",
1442            json!({"serviceName": "web", "taskDefinition": "web", "desiredCount": 0}),
1443        )) {
1444            Ok(_) => panic!("expected InvalidParameterException for a duplicate service name"),
1445            Err(e) => e,
1446        };
1447        assert_eq!(err.code(), "InvalidParameterException");
1448        assert_eq!(err.message(), "Creation of service was not idempotent.");
1449    }
1450
1451    #[test]
1452    fn list_tasks_filters_by_service_name_and_container_instance() {
1453        let svc = fresh_service();
1454        // Two service-owned tasks (group service:web) on distinct container
1455        // instances, plus one unrelated task (group service:api).
1456        insert_task(&svc, "web1", "default");
1457        insert_task(&svc, "web2", "default");
1458        insert_task(&svc, "api1", "default");
1459        {
1460            let mut accounts = svc.state.write();
1461            let state = accounts.get_mut("000000000000").unwrap();
1462            let ci_a = "arn:aws:ecs:us-east-1:000000000000:container-instance/default/aaaaaaaa"
1463                .to_string();
1464            let ci_b = "arn:aws:ecs:us-east-1:000000000000:container-instance/default/bbbbbbbb"
1465                .to_string();
1466            let t = state.tasks.get_mut("web1").unwrap();
1467            t.group = Some("service:web".into());
1468            t.container_instance_arn = Some(ci_a);
1469            let t = state.tasks.get_mut("web2").unwrap();
1470            t.group = Some("service:web".into());
1471            t.container_instance_arn = Some(ci_b);
1472            let t = state.tasks.get_mut("api1").unwrap();
1473            t.group = Some("service:api".into());
1474        }
1475
1476        // serviceName filter returns only the two web tasks.
1477        let resp = svc
1478            .list_tasks(&make_request("ListTasks", json!({"serviceName": "web"})))
1479            .unwrap();
1480        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1481        let arns = body["taskArns"].as_array().unwrap();
1482        assert_eq!(arns.len(), 2);
1483        assert!(arns.iter().all(|a| a.as_str().unwrap().contains("/web")));
1484
1485        // containerInstance filter (bare ID) narrows to the single task on it.
1486        let resp = svc
1487            .list_tasks(&make_request(
1488                "ListTasks",
1489                json!({"containerInstance": "aaaaaaaa"}),
1490            ))
1491            .unwrap();
1492        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1493        let arns = body["taskArns"].as_array().unwrap();
1494        assert_eq!(arns.len(), 1);
1495        assert!(arns[0].as_str().unwrap().ends_with("/web1"));
1496
1497        // containerInstance filter also accepts a full ARN.
1498        let resp = svc
1499            .list_tasks(&make_request(
1500                "ListTasks",
1501                json!({"containerInstance": "arn:aws:ecs:us-east-1:000000000000:container-instance/default/bbbbbbbb"}),
1502            ))
1503            .unwrap();
1504        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1505        let arns = body["taskArns"].as_array().unwrap();
1506        assert_eq!(arns.len(), 1);
1507        assert!(arns[0].as_str().unwrap().ends_with("/web2"));
1508    }
1509}