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