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        let body = request.json_body();
82        let td_ref = req_str(&body, "taskDefinition")?;
83        let cluster_ref = opt_str(&body, "cluster");
84        let cluster_name = EcsState::resolve_cluster_name(cluster_ref);
85        let launch_type = opt_str(&body, "launchType")
86            .unwrap_or("FARGATE")
87            .to_string();
88        let placement_constraints: Vec<Value> = body
89            .get("placementConstraints")
90            .and_then(|v| v.as_array())
91            .cloned()
92            .unwrap_or_default();
93        let placement_strategy: Vec<Value> = body
94            .get("placementStrategy")
95            .and_then(|v| v.as_array())
96            .cloned()
97            .unwrap_or_default();
98        // AWS caps RunTask at 1..=10 tasks per call and rejects anything else
99        // with InvalidParameterException — it does NOT silently clamp to 1.
100        let count = match body.get("count").and_then(|v| v.as_i64()) {
101            Some(n) if (1..=10).contains(&n) => n as usize,
102            Some(n) => {
103                return Err(invalid_parameter(format!(
104                    "count should be between 1 and 10, inclusive. count={n}"
105                )))
106            }
107            None => 1,
108        };
109        let group = opt_str(&body, "group").map(String::from);
110        let started_by = opt_str(&body, "startedBy").map(String::from);
111        let enable_execute_command = body
112            .get("enableExecuteCommand")
113            .and_then(|v| v.as_bool())
114            .unwrap_or(false);
115        let propagate_tags = opt_str(&body, "propagateTags").map(String::from);
116        let _enable_ecs_managed_tags = body
117            .get("enableECSManagedTags")
118            .and_then(|v| v.as_bool())
119            .unwrap_or(false);
120        let _capacity_provider_strategy: Vec<Value> = body
121            .get("capacityProviderStrategy")
122            .and_then(|v| v.as_array())
123            .cloned()
124            .unwrap_or_default();
125        let volume_configurations: Vec<Value> = body
126            .get("volumeConfigurations")
127            .and_then(|v| v.as_array())
128            .cloned()
129            .unwrap_or_default();
130        let _availability_zone_rebalancing =
131            opt_str(&body, "availabilityZoneRebalancing").map(String::from);
132        let mut tags = parse_tags(&body);
133
134        // PassRole trust check on any role overrides supplied via the
135        // overrides.taskRoleArn / overrides.executionRoleArn fields.
136        // The base task definition was already checked at Register time,
137        // but RunTask can override either role and AWS re-validates the
138        // trust policy on every call.
139        if let Some(overrides) = body.get("overrides") {
140            if let Some(role_arn) = opt_str(overrides, "taskRoleArn") {
141                self.check_pass_role(&request.account_id, role_arn)?;
142            }
143            if let Some(role_arn) = opt_str(overrides, "executionRoleArn") {
144                self.check_pass_role(&request.account_id, role_arn)?;
145            }
146        }
147
148        let account = request.account_id.clone();
149        let runtime = self.runtime.clone();
150        let mut accounts = self.state.write();
151        let state = accounts.get_or_create(&account);
152        // AWS rejects RunTask against a cluster that does not exist rather than
153        // synthesizing one; a phantom cluster returns ClusterNotFoundException.
154        let cluster_arn = state
155            .clusters
156            .get(&cluster_name)
157            .map(|c| c.cluster_arn.clone())
158            .ok_or_else(|| cluster_not_found(&cluster_name))?;
159        let (_, family, rev) = resolve_task_definition_ref(td_ref)?;
160        let revisions = state
161            .task_definitions
162            .get(&family)
163            .ok_or_else(|| task_definition_not_found(td_ref))?;
164        let td = match rev {
165            Some(n) => revisions
166                .get(&n)
167                .ok_or_else(|| task_definition_not_found(td_ref))?,
168            None => latest_active_revision(revisions)
169                .ok_or_else(|| task_definition_not_found(td_ref))?,
170        };
171        if td.status != "ACTIVE" {
172            return Err(client_exception(format!(
173                "Task definition {} is not ACTIVE",
174                td.task_definition_arn
175            )));
176        }
177        let td_arn = td.task_definition_arn.clone();
178        let td_family = td.family.clone();
179        let td_revision = td.revision;
180        let td_cpu = td.cpu.clone();
181        let td_memory = td.memory.clone();
182        let td_task_role = td.task_role_arn.clone();
183        let td_exec_role = td.execution_role_arn.clone();
184        let td_containers = td.container_definitions.clone();
185        // RunTask supports propagateTags=TASK_DEFINITION to copy the
186        // TaskDefinition's tags onto each spawned task, in addition to
187        // any tags supplied directly in the request body. Real AWS
188        // unions the two sets; explicit tags win on key conflicts.
189        if propagate_tags.as_deref() == Some("TASK_DEFINITION") {
190            let mut td_tags = td.tags.clone();
191            td_tags.retain(|t| !tags.iter().any(|x| x.key == t.key));
192            tags.extend(td_tags);
193        }
194
195        let mut spawned_tasks: Vec<String> = Vec::new();
196        let mut task_jsons: Vec<Value> = Vec::new();
197        for _ in 0..count {
198            let task_id = uuid::Uuid::new_v4().to_string().replace('-', "");
199            let task_arn = state.task_arn(&cluster_name, &task_id);
200            let containers: Vec<Container> = td_containers
201                .iter()
202                .map(|def| Container {
203                    container_arn: format!(
204                        "arn:aws:ecs:{}:{}:container/{}/{}/{}",
205                        state.region,
206                        state.account_id,
207                        cluster_name,
208                        task_id,
209                        def.get("name").and_then(|v| v.as_str()).unwrap_or("app")
210                    ),
211                    name: def
212                        .get("name")
213                        .and_then(|v| v.as_str())
214                        .unwrap_or("app")
215                        .to_string(),
216                    image: def
217                        .get("image")
218                        .and_then(|v| v.as_str())
219                        .unwrap_or("")
220                        .to_string(),
221                    task_arn: task_arn.clone(),
222                    last_status: "PENDING".into(),
223                    exit_code: None,
224                    reason: None,
225                    runtime_id: None,
226                    essential: def
227                        .get("essential")
228                        .and_then(|v| v.as_bool())
229                        .unwrap_or(true),
230                    cpu: def
231                        .get("cpu")
232                        .and_then(|v| v.as_i64())
233                        .map(|n| n.to_string()),
234                    memory: def
235                        .get("memory")
236                        .and_then(|v| v.as_i64())
237                        .map(|n| n.to_string()),
238                    memory_reservation: def
239                        .get("memoryReservation")
240                        .and_then(|v| v.as_i64())
241                        .map(|n| n.to_string()),
242                    network_bindings: Vec::new(),
243                    network_interfaces: Vec::new(),
244                    health_status: Some("UNKNOWN".to_string()),
245                    managed_agents: None,
246                    image_digest: None,
247                })
248                .collect();
249            let awslogs = td_containers.iter().find_map(|def| {
250                let name = def.get("name").and_then(|v| v.as_str())?.to_string();
251                let log_cfg = def.get("logConfiguration")?;
252                if log_cfg.get("logDriver").and_then(|v| v.as_str()) != Some("awslogs") {
253                    return None;
254                }
255                let opts = log_cfg.get("options").and_then(|v| v.as_object())?;
256                Some(AwsLogsConfig {
257                    group: opts.get("awslogs-group").and_then(|v| v.as_str())?.into(),
258                    stream_prefix: opts
259                        .get("awslogs-stream-prefix")
260                        .and_then(|v| v.as_str())
261                        .map(String::from),
262                    region: opts
263                        .get("awslogs-region")
264                        .and_then(|v| v.as_str())
265                        .unwrap_or(&state.region)
266                        .to_string(),
267                    container_name: name,
268                })
269            });
270            let capacity_provider_name = body
271                .get("capacityProviderStrategy")
272                .and_then(|v| v.as_array())
273                .and_then(|arr| arr.first())
274                .and_then(|item| item.get("capacityProvider"))
275                .and_then(|v| v.as_str())
276                .map(String::from);
277            let mut task = Task {
278                task_arn: task_arn.clone(),
279                task_id: task_id.clone(),
280                cluster_arn: cluster_arn.clone(),
281                cluster_name: cluster_name.clone(),
282                task_definition_arn: td_arn.clone(),
283                family: td_family.clone(),
284                revision: td_revision,
285                container_instance_arn: None,
286                capacity_provider_name,
287                last_status: "PROVISIONING".into(),
288                desired_status: "RUNNING".into(),
289                launch_type: launch_type.clone(),
290                platform_version: Some("1.4.0".into()),
291                cpu: body
292                    .get("overrides")
293                    .and_then(|v| v.get("cpu"))
294                    .and_then(|v| v.as_str())
295                    .map(String::from)
296                    .or_else(|| td_cpu.clone()),
297                memory: body
298                    .get("overrides")
299                    .and_then(|v| v.get("memory"))
300                    .and_then(|v| v.as_str())
301                    .map(String::from)
302                    .or_else(|| td_memory.clone()),
303                containers,
304                overrides: body.get("overrides").cloned().unwrap_or_else(|| json!({})),
305                started_by: started_by.clone(),
306                group: group.clone(),
307                connectivity: "CONNECTING".into(),
308                stop_code: None,
309                stopped_reason: None,
310                created_at: Utc::now(),
311                started_at: None,
312                stopping_at: None,
313                stopped_at: None,
314                pull_started_at: None,
315                pull_stopped_at: None,
316                connectivity_at: None,
317                started_by_ref_id: None,
318                execution_role_arn: td_exec_role.clone(),
319                task_role_arn: td_task_role.clone(),
320                tags: tags.clone(),
321                awslogs,
322                captured_logs: String::new(),
323                protection: None,
324                enable_execute_command,
325                attachments: Vec::new(),
326                volume_configurations: volume_configurations.clone(),
327                task_set_arn: None,
328            };
329            // Best-effort placement for EC2 / EXTERNAL launch types.
330            if launch_type != "FARGATE" {
331                if let Some(arn) = crate::placement::select_container_instance(
332                    state,
333                    &cluster_name,
334                    &placement_constraints,
335                    &placement_strategy,
336                    task.group.as_deref(),
337                    &td_arn,
338                    &launch_type,
339                ) {
340                    task.container_instance_arn = Some(arn.clone());
341                    if let Some(ci) = state
342                        .container_instances
343                        .values_mut()
344                        .find(|ci| ci.container_instance_arn == arn)
345                    {
346                        ci.pending_tasks_count += 1;
347                    }
348                }
349            }
350            state.tasks.insert(task_id.clone(), task.clone());
351            if let Some(cluster) = state.clusters.get_mut(&cluster_name) {
352                cluster.pending_tasks_count += 1;
353            }
354            // Snapshot-in-progress: transition to PENDING synchronously so
355            // callers that immediately DescribeTasks see movement. RUNNING /
356            // STOPPED come later from the background runtime task.
357            if let Some(t) = state.tasks.get_mut(&task_id) {
358                t.last_status = "PENDING".into();
359            }
360            task_jsons.push(task_to_json(&task));
361            spawned_tasks.push(task_id.clone());
362        }
363        drop(accounts);
364
365        // Launch container execution outside the state lock.
366        if let Some(rt) = runtime {
367            for id in &spawned_tasks {
368                rt.clone()
369                    .run_task(self.state.clone(), id.clone(), account.clone());
370            }
371        } else {
372            // No runtime available — fail fast so the task doesn't stay
373            // PENDING forever. We incremented pending_tasks_count above;
374            // decrement it here so the cluster counter doesn't drift and
375            // block later DeleteCluster calls.
376            let mut accounts = self.state.write();
377            if let Some(state) = accounts.get_mut(&account) {
378                // (cluster_name, container_instance_arn) for each failed task so
379                // BOTH pending counters that RunTask incremented get drained —
380                // previously only the cluster counter was decremented, leaking
381                // the container-instance pendingTasksCount for EC2/EXTERNAL tasks.
382                let mut drains: Vec<(String, Option<String>)> = Vec::new();
383                for id in &spawned_tasks {
384                    if let Some(t) = state.tasks.get_mut(id) {
385                        t.last_status = "STOPPED".into();
386                        // desired_status stays RUNNING: the task was requested
387                        // to run but failed to start. AWS keeps desired_status
388                        // as RUNNING for failed standalone RunTask until the
389                        // caller explicitly stops it. This also ensures
390                        // list_tasks (default filter=RUNNING) finds it.
391                        t.stop_code = Some("TaskFailedToStart".into());
392                        t.stopped_reason = Some(format!(
393                            "No container runtime available (docker/podman not installed). {}",
394                            fakecloud_core::container_net::CONTAINER_RUNTIME_HINT
395                        ));
396                        t.stopped_at = Some(Utc::now());
397                        for c in t.containers.iter_mut() {
398                            c.last_status = "STOPPED".into();
399                        }
400                        drains.push((t.cluster_name.clone(), t.container_instance_arn.clone()));
401                    }
402                }
403                for (name, ci_arn) in drains {
404                    if let Some(cluster) = state.clusters.get_mut(&name) {
405                        if cluster.pending_tasks_count > 0 {
406                            cluster.pending_tasks_count -= 1;
407                        }
408                    }
409                    if let Some(arn) = ci_arn {
410                        if let Some(ci) = state
411                            .container_instances
412                            .values_mut()
413                            .find(|ci| ci.container_instance_arn == arn)
414                        {
415                            if ci.pending_tasks_count > 0 {
416                                ci.pending_tasks_count -= 1;
417                            }
418                        }
419                    }
420                }
421            }
422        }
423
424        Ok(AwsResponse::ok_json(json!({
425            "tasks": task_jsons,
426            "failures": [],
427        })))
428    }
429
430    pub(super) fn start_task(&self, request: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
431        // StartTask targets explicit container instances. Our ECS emulator
432        // has no concept of registered container instances yet (Batch 4);
433        // fall through to the same semantics as RunTask so the API is
434        // usable while the container-instance surface is pending.
435        self.run_task(request)
436    }
437
438    pub(super) async fn stop_task(
439        &self,
440        request: &AwsRequest,
441    ) -> Result<AwsResponse, AwsServiceError> {
442        let body = request.json_body();
443        let task_ref = req_str(&body, "task")?;
444        let reason = opt_str(&body, "reason")
445            .unwrap_or("UserInitiated")
446            .to_string();
447        let cluster_ref = opt_str(&body, "cluster");
448        let _cluster_name = EcsState::resolve_cluster_name(cluster_ref);
449
450        let (task_id, account, task_snapshot) = {
451            let account = request.account_id.clone();
452            let mut accounts = self.state.write();
453            let state = accounts
454                .get_mut(&account)
455                .ok_or_else(|| task_not_found(task_ref))?;
456            let task_id = resolve_task_id(state, task_ref)?;
457            let task = state
458                .tasks
459                .get_mut(&task_id)
460                .ok_or_else(|| task_not_found(task_ref))?;
461            task.desired_status = "STOPPED".into();
462            task.stopping_at = Some(Utc::now());
463            task.stopped_reason = Some(reason.clone());
464            task.stop_code = Some("UserInitiated".into());
465            (task_id, account, task.clone())
466        };
467        if let Some(rt) = &self.runtime {
468            rt.stop_task(&task_id, &reason).await;
469        }
470        let _ = account;
471        Ok(AwsResponse::ok_json(json!({
472            "task": task_to_json(&task_snapshot),
473        })))
474    }
475
476    pub(super) fn describe_tasks(
477        &self,
478        request: &AwsRequest,
479    ) -> Result<AwsResponse, AwsServiceError> {
480        let body = request.json_body();
481        let refs: Vec<String> = req_array(&body, "tasks")?
482            .iter()
483            .filter_map(|v| v.as_str().map(String::from))
484            .collect();
485        let include_tags = body
486            .get("include")
487            .and_then(|v| v.as_array())
488            .map(|arr| arr.iter().any(|v| v.as_str() == Some("TAGS")))
489            .unwrap_or(false);
490        // AWS scopes DescribeTasks to the given cluster (default "default"):
491        // a task that exists in another cluster is reported MISSING, not found.
492        let cluster_name = EcsState::resolve_cluster_name(opt_str(&body, "cluster"));
493
494        let account = request.account_id.clone();
495        let accounts = self.state.read();
496        let Some(state) = accounts.get(&account) else {
497            return Ok(AwsResponse::ok_json(json!({
498                "tasks": [],
499                "failures": refs.iter().map(|r| json!({"arn": r, "reason": "MISSING"})).collect::<Vec<_>>(),
500            })));
501        };
502        let mut found = Vec::new();
503        let mut failures = Vec::new();
504        for input in &refs {
505            let task_id = task_id_from_ref(input);
506            match state.tasks.get(&task_id) {
507                Some(t) if t.cluster_name == cluster_name => {
508                    let mut v = task_to_json(t);
509                    if include_tags {
510                        v.as_object_mut()
511                            .unwrap()
512                            .insert("tags".into(), tags_json(&t.tags));
513                    }
514                    found.push(v);
515                }
516                _ => {
517                    failures.push(json!({
518                        "arn": input,
519                        "reason": "MISSING",
520                    }));
521                }
522            }
523        }
524        Ok(AwsResponse::ok_json(json!({
525            "tasks": found,
526            "failures": failures,
527        })))
528    }
529
530    pub(super) fn list_tasks(&self, request: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
531        let body = request.json_body();
532        validate_enum_opt(&body, "desiredStatus", &["RUNNING", "PENDING", "STOPPED"])?;
533        validate_enum_opt(
534            &body,
535            "launchType",
536            &["EC2", "FARGATE", "EXTERNAL", "MANAGED_INSTANCES"],
537        )?;
538        let cluster_ref = opt_str(&body, "cluster");
539        let cluster_name = EcsState::resolve_cluster_name(cluster_ref);
540        let family = opt_str(&body, "family");
541        let status_filter = opt_str(&body, "desiredStatus").or(Some("RUNNING"));
542        let started_by = opt_str(&body, "startedBy");
543        let max_results = body
544            .get("maxResults")
545            .and_then(|v| v.as_i64())
546            .filter(|n| (1..=100).contains(n))
547            .map(|n| n as usize)
548            .unwrap_or(100);
549        let next_token = opt_str(&body, "nextToken").unwrap_or("");
550
551        let account = request.account_id.clone();
552        let accounts = self.state.read();
553        let mut arns: Vec<String> = match accounts.get(&account) {
554            Some(state) => state
555                .tasks
556                .values()
557                .filter(|t| t.cluster_name == cluster_name)
558                .filter(|t| family.is_none_or(|f| t.family == f))
559                .filter(|t| status_filter.is_none_or(|s| t.desired_status == s))
560                .filter(|t| started_by.is_none_or(|s| t.started_by.as_deref() == Some(s)))
561                .map(|t| t.task_arn.clone())
562                .collect(),
563            None => Vec::new(),
564        };
565        arns.sort();
566        let (page, next) = paginate_arns(arns, next_token, max_results);
567        let mut out = json!({"taskArns": page});
568        if let Some(n) = next {
569            out.as_object_mut()
570                .unwrap()
571                .insert("nextToken".into(), json!(n));
572        }
573        Ok(AwsResponse::ok_json(out))
574    }
575}
576
577#[cfg(test)]
578mod multi_container_tests {
579    use super::*;
580    use crate::EcsService;
581    use bytes::Bytes;
582    use fakecloud_core::multi_account::MultiAccountState;
583    use http::{HeaderMap, Method};
584    use parking_lot::RwLock;
585    use std::collections::HashMap;
586    use std::sync::Arc;
587
588    fn fresh_service() -> EcsService {
589        let accounts: MultiAccountState<EcsState> =
590            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
591        let state = Arc::new(RwLock::new(accounts));
592        let svc = EcsService::new(state.clone());
593        // Pre-create the cluster so RunTask doesn't trip on a missing one.
594        let mut accounts = state.write();
595        let s = accounts.get_or_create("000000000000");
596        let arn = s.cluster_arn("default");
597        s.clusters
598            .insert("default".into(), Cluster::new("default", arn));
599        drop(accounts);
600        svc
601    }
602
603    fn make_request(action: &str, body: Value) -> AwsRequest {
604        let body_bytes = Bytes::from(serde_json::to_vec(&body).unwrap());
605        AwsRequest {
606            service: "ecs".into(),
607            action: action.into(),
608            region: "us-east-1".into(),
609            account_id: "000000000000".into(),
610            request_id: uuid::Uuid::new_v4().to_string(),
611            headers: HeaderMap::new(),
612            query_params: HashMap::new(),
613            body: body_bytes,
614            body_stream: parking_lot::Mutex::new(None),
615            path_segments: Vec::new(),
616            raw_path: "/".into(),
617            raw_query: String::new(),
618            method: Method::POST,
619            is_query_protocol: false,
620            access_key_id: None,
621            principal: None,
622        }
623    }
624
625    #[test]
626    fn run_task_count_over_ten_is_invalid_parameter() {
627        let svc = fresh_service();
628        // count is validated before the cluster/task-def are dereferenced.
629        let err = match svc.run_task(&make_request(
630            "RunTask",
631            json!({"taskDefinition": "x", "count": 50}),
632        )) {
633            Ok(_) => panic!("expected InvalidParameterException for count > 10"),
634            Err(e) => e,
635        };
636        assert_eq!(err.code(), "InvalidParameterException");
637    }
638
639    #[test]
640    fn create_service_negative_desired_count_is_invalid_parameter() {
641        let svc = fresh_service();
642        // desiredCount is validated before the cluster/task-def are resolved.
643        let err = match svc.create_service(&make_request(
644            "CreateService",
645            json!({"serviceName": "s", "taskDefinition": "x", "desiredCount": -3}),
646        )) {
647            Ok(_) => panic!("expected InvalidParameterException for negative desiredCount"),
648            Err(e) => e,
649        };
650        assert_eq!(err.code(), "InvalidParameterException");
651    }
652
653    #[test]
654    fn run_task_on_missing_cluster_is_cluster_not_found() {
655        let svc = fresh_service();
656        let err = match svc.run_task(&make_request(
657            "RunTask",
658            json!({"taskDefinition": "x", "cluster": "ghost", "count": 1}),
659        )) {
660            Ok(_) => panic!("expected ClusterNotFoundException for a phantom cluster"),
661            Err(e) => e,
662        };
663        assert_eq!(err.code(), "ClusterNotFoundException");
664    }
665
666    #[test]
667    fn region_from_arn_extracts_region_or_none() {
668        assert_eq!(
669            region_from_arn("arn:aws:ecs:eu-west-1:000000000000:task-definition/web:3"),
670            Some("eu-west-1")
671        );
672        assert_eq!(
673            region_from_arn("arn:aws:ecs:ap-southeast-2:000000000000:cluster/prod"),
674            Some("ap-southeast-2")
675        );
676        // Bare names (not ARNs) and empty-region ARNs yield None.
677        assert_eq!(region_from_arn("web:3"), None);
678        assert_eq!(
679            region_from_arn("arn:aws:ecs::000000000000:cluster/prod"),
680            None
681        );
682    }
683
684    fn insert_task(svc: &EcsService, id: &str, cluster: &str) {
685        let state = svc.state.clone();
686        let mut accounts = state.write();
687        let acct = accounts.get_or_create("000000000000");
688        let task = Task {
689            task_arn: format!("arn:aws:ecs:us-east-1:000000000000:task/{cluster}/{id}"),
690            task_id: id.into(),
691            cluster_arn: format!("arn:aws:ecs:us-east-1:000000000000:cluster/{cluster}"),
692            cluster_name: cluster.into(),
693            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/web:1".into(),
694            family: "web".into(),
695            revision: 1,
696            container_instance_arn: None,
697            capacity_provider_name: None,
698            last_status: "RUNNING".into(),
699            desired_status: "RUNNING".into(),
700            launch_type: "FARGATE".into(),
701            platform_version: None,
702            cpu: None,
703            memory: None,
704            containers: Vec::new(),
705            overrides: serde_json::json!({}),
706            started_by: None,
707            group: None,
708            connectivity: "CONNECTED".into(),
709            stop_code: None,
710            stopped_reason: None,
711            created_at: Utc::now(),
712            started_at: None,
713            stopping_at: None,
714            stopped_at: None,
715            pull_started_at: None,
716            pull_stopped_at: None,
717            connectivity_at: None,
718            started_by_ref_id: None,
719            execution_role_arn: None,
720            task_role_arn: None,
721            tags: Vec::new(),
722            awslogs: None,
723            captured_logs: String::new(),
724            protection: None,
725            enable_execute_command: false,
726            attachments: Vec::new(),
727            volume_configurations: Vec::new(),
728            task_set_arn: None,
729        };
730        acct.tasks.insert(id.into(), task);
731    }
732
733    #[test]
734    fn describe_tasks_scopes_to_requested_cluster() {
735        let svc = fresh_service();
736        insert_task(&svc, "abc", "other");
737        let task_arn = "arn:aws:ecs:us-east-1:000000000000:task/other/abc";
738
739        // Wrong cluster (default) -> MISSING, not found.
740        let resp = svc
741            .describe_tasks(&make_request(
742                "DescribeTasks",
743                json!({"tasks": [task_arn], "cluster": "default"}),
744            ))
745            .unwrap();
746        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
747        assert_eq!(body["tasks"].as_array().unwrap().len(), 0);
748        assert_eq!(body["failures"][0]["reason"], "MISSING");
749
750        // Correct cluster -> found.
751        let resp = svc
752            .describe_tasks(&make_request(
753                "DescribeTasks",
754                json!({"tasks": [task_arn], "cluster": "other"}),
755            ))
756            .unwrap();
757        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
758        assert_eq!(body["tasks"].as_array().unwrap().len(), 1);
759        assert_eq!(body["failures"].as_array().unwrap().len(), 0);
760    }
761
762    #[test]
763    fn describe_service_revisions_returns_stored_revision_or_missing() {
764        let svc = fresh_service();
765        svc.register_task_definition(&make_request(
766            "RegisterTaskDefinition",
767            json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
768        ))
769        .unwrap();
770        let created = svc
771            .create_service(&make_request(
772                "CreateService",
773                json!({"serviceName": "s", "taskDefinition": "web", "desiredCount": 0}),
774            ))
775            .unwrap();
776        let cbody: Value = serde_json::from_slice(created.body.expect_bytes()).unwrap();
777        let service_arn = cbody["service"]["serviceArn"].as_str().unwrap().to_string();
778
779        // CreateService minted revision 1.
780        let rev1 = format!("{service_arn}:1");
781        let d = svc
782            .describe_service_revisions(&make_request(
783                "DescribeServiceRevisions",
784                json!({"serviceRevisionArns": [rev1.clone()]}),
785            ))
786            .unwrap();
787        let dbody: Value = serde_json::from_slice(d.body.expect_bytes()).unwrap();
788        assert_eq!(dbody["serviceRevisions"].as_array().unwrap().len(), 1);
789        assert_eq!(
790            dbody["serviceRevisions"][0]["serviceRevisionArn"],
791            json!(rev1)
792        );
793        assert!(dbody["serviceRevisions"][0]["taskDefinition"]
794            .as_str()
795            .unwrap()
796            .ends_with("web:1"));
797        assert_eq!(dbody["failures"].as_array().unwrap().len(), 0);
798
799        // Unknown revision -> MISSING.
800        let d2 = svc
801            .describe_service_revisions(&make_request(
802                "DescribeServiceRevisions",
803                json!({"serviceRevisionArns": [format!("{service_arn}:99")]}),
804            ))
805            .unwrap();
806        let d2body: Value = serde_json::from_slice(d2.body.expect_bytes()).unwrap();
807        assert_eq!(d2body["serviceRevisions"].as_array().unwrap().len(), 0);
808        assert_eq!(d2body["failures"][0]["reason"], "MISSING");
809    }
810
811    #[test]
812    fn register_task_def_with_two_containers_then_run_task_starts_both() {
813        let svc = fresh_service();
814        let reg = make_request(
815            "RegisterTaskDefinition",
816            json!({
817                "family": "multi",
818                "containerDefinitions": [
819                    {"name": "app", "image": "alpine"},
820                    {"name": "sidecar", "image": "alpine"}
821                ]
822            }),
823        );
824        svc.register_task_definition(&reg)
825            .expect("register should succeed");
826
827        let run = make_request(
828            "RunTask",
829            json!({
830                "cluster": "default",
831                "taskDefinition": "multi",
832            }),
833        );
834        let resp = svc.run_task(&run).expect("run_task should succeed");
835        let body: Value =
836            serde_json::from_slice(resp.body.expect_bytes()).expect("body should be valid JSON");
837        let tasks = body
838            .get("tasks")
839            .and_then(|v| v.as_array())
840            .expect("tasks array");
841        assert_eq!(tasks.len(), 1);
842        let task = &tasks[0];
843        let containers = task
844            .get("containers")
845            .and_then(|v| v.as_array())
846            .expect("containers array on task");
847        assert_eq!(containers.len(), 2, "expected both containers in task");
848        let names: Vec<&str> = containers
849            .iter()
850            .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
851            .collect();
852        assert!(names.contains(&"app"));
853        assert!(names.contains(&"sidecar"));
854
855        // Per-container ARNs must be distinct so DescribeTasks can address
856        // each container independently.
857        let arns: std::collections::HashSet<&str> = containers
858            .iter()
859            .filter_map(|c| c.get("containerArn").and_then(|v| v.as_str()))
860            .collect();
861        assert_eq!(arns.len(), 2);
862    }
863
864    #[test]
865    fn register_task_def_defaults_essential_true() {
866        let svc = fresh_service();
867        let reg = make_request(
868            "RegisterTaskDefinition",
869            json!({
870                "family": "default-essential",
871                // No `essential` declared on either container.
872                "containerDefinitions": [
873                    {"name": "main", "image": "alpine"},
874                    {"name": "extra", "image": "alpine"}
875                ]
876            }),
877        );
878        svc.register_task_definition(&reg).unwrap();
879
880        let run = make_request(
881            "RunTask",
882            json!({
883                "cluster": "default",
884                "taskDefinition": "default-essential",
885            }),
886        );
887        let resp = svc.run_task(&run).unwrap();
888        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
889        let containers = body["tasks"][0]["containers"].as_array().unwrap();
890        // The `essential` flag is a TaskDefinition.ContainerDefinition
891        // field, not part of the runtime `Container` response shape.
892        // Real ECS doesn't echo it back on RunTask, and the conformance
893        // probe rejects responses that do.
894        for c in containers {
895            assert!(
896                c.get("essential").is_none(),
897                "container {:?} must not carry `essential` on the runtime shape",
898                c.get("name")
899            );
900        }
901    }
902
903    #[test]
904    fn task_to_json_emits_full_container_array() {
905        // Build a Task with two containers directly and confirm helper
906        // emits both entries in the response shape.
907        let mut task = Task {
908            task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
909            task_id: "abc".into(),
910            cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
911            cluster_name: "default".into(),
912            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/multi:1"
913                .into(),
914            family: "multi".into(),
915            revision: 1,
916            container_instance_arn: None,
917            capacity_provider_name: None,
918            last_status: "RUNNING".into(),
919            desired_status: "RUNNING".into(),
920            launch_type: "FARGATE".into(),
921            platform_version: None,
922            cpu: None,
923            memory: None,
924            containers: Vec::new(),
925            overrides: json!({}),
926            started_by: None,
927            group: None,
928            connectivity: "CONNECTED".into(),
929            stop_code: None,
930            stopped_reason: None,
931            created_at: chrono::Utc::now(),
932            started_at: None,
933            stopping_at: None,
934            stopped_at: None,
935            pull_started_at: None,
936            pull_stopped_at: None,
937            connectivity_at: None,
938            started_by_ref_id: None,
939            execution_role_arn: None,
940            task_role_arn: None,
941            tags: Vec::new(),
942            awslogs: None,
943            captured_logs: String::new(),
944            protection: None,
945            enable_execute_command: false,
946            attachments: Vec::new(),
947            volume_configurations: Vec::new(),
948            task_set_arn: None,
949        };
950        for name in ["app", "sidecar"] {
951            task.containers.push(Container {
952                container_arn: format!(
953                    "arn:aws:ecs:us-east-1:000000000000:container/default/abc/{name}"
954                ),
955                name: name.into(),
956                image: "alpine".into(),
957                task_arn: task.task_arn.clone(),
958                last_status: "RUNNING".into(),
959                exit_code: None,
960                reason: None,
961                runtime_id: Some(format!("docker-{name}")),
962                essential: true,
963                cpu: None,
964                memory: None,
965                memory_reservation: None,
966                network_bindings: Vec::new(),
967                network_interfaces: Vec::new(),
968                health_status: None,
969                managed_agents: None,
970                image_digest: None,
971            });
972        }
973
974        let v = task_to_json(&task);
975        let containers = v
976            .get("containers")
977            .and_then(|v| v.as_array())
978            .expect("containers array");
979        assert_eq!(containers.len(), 2);
980        let names: Vec<&str> = containers
981            .iter()
982            .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
983            .collect();
984        assert_eq!(names, vec!["app", "sidecar"]);
985        for c in containers {
986            assert!(c.get("containerArn").is_some());
987            assert!(c.get("name").is_some());
988            assert!(c.get("lastStatus").is_some());
989            assert!(c.get("runtimeId").is_some());
990            assert!(c.get("essential").is_none());
991        }
992    }
993}
994
995#[cfg(test)]
996mod port_mapping_tests {
997    //! Cover the `portMappings` -> `docker run --publish` translation
998    //! plus the per-container `networkBindings` projected onto the task.
999    //! The argv builder is exercised directly so we don't need a real
1000    //! container CLI on the test host.
1001    use super::*;
1002    use crate::runtime::{
1003        build_run_argv, mark_running_multi, ContainerPlan, PortMapping, RunningContainer,
1004    };
1005    use crate::state::{Container, EcsState};
1006    use crate::SharedEcsState;
1007    use chrono::Utc;
1008    use fakecloud_core::multi_account::MultiAccountState;
1009    use parking_lot::RwLock;
1010    use std::sync::Arc;
1011
1012    fn plan_with_ports(
1013        port_mappings: Vec<PortMapping>,
1014        network_mode: Option<&str>,
1015    ) -> ContainerPlan {
1016        ContainerPlan {
1017            container_name: "app".into(),
1018            image: "alpine:latest".into(),
1019            env: Vec::new(),
1020            entry_point: Vec::new(),
1021            command: Vec::new(),
1022            secrets_refs: Vec::new(),
1023            essential: true,
1024            has_task_role: false,
1025            port_mappings,
1026            network_mode: network_mode.map(String::from),
1027            depends_on: Vec::new(),
1028            health_check: None,
1029            volume_mounts: Vec::new(),
1030            ulimits: Vec::new(),
1031            linux_parameters: None,
1032            stop_timeout: None,
1033            user: None,
1034            working_directory: None,
1035            tty: false,
1036            interactive: false,
1037            readonly_rootfs: false,
1038        }
1039    }
1040
1041    fn argv_string(plan: &ContainerPlan) -> Vec<String> {
1042        build_run_argv(
1043            plan,
1044            &[],
1045            "task-1",
1046            "host.docker.internal",
1047            None,
1048            "alpine:latest",
1049            true,
1050        )
1051    }
1052
1053    /// Helper for asserting a `--publish <spec>` pair is present in argv.
1054    /// Returns true when the flag/value pair appears as adjacent entries.
1055    fn argv_has_publish(argv: &[String], spec: &str) -> bool {
1056        argv.windows(2).any(|w| w[0] == "--publish" && w[1] == spec)
1057    }
1058
1059    #[test]
1060    fn port_mappings_translate_to_publish_flags() {
1061        let plan = plan_with_ports(
1062            vec![PortMapping {
1063                container_port: 80,
1064                host_port: 8080,
1065                protocol: "tcp".into(),
1066            }],
1067            None,
1068        );
1069        let argv = argv_string(&plan);
1070        assert!(
1071            argv_has_publish(&argv, "80:8080/tcp"),
1072            "expected --publish 80:8080/tcp in argv: {argv:?}"
1073        );
1074    }
1075
1076    #[test]
1077    fn port_mappings_default_host_port_to_container_port() {
1078        // host_port=0 in the parsed mapping means "AWS host-mode default";
1079        // parse_port_mapping rewrites that to containerPort, so by the time
1080        // we reach build_run_argv the host_port should already equal 80.
1081        // Drive the same path through the JSON parser to lock in the
1082        // default behaviour end to end.
1083        let parsed =
1084            crate::runtime::__test_parse_port_mapping(&serde_json::json!({"containerPort": 80}))
1085                .expect("containerPort should parse");
1086        assert_eq!(
1087            parsed.host_port, 80,
1088            "default hostPort should mirror containerPort"
1089        );
1090        let argv = argv_string(&plan_with_ports(vec![parsed], None));
1091        assert!(
1092            argv_has_publish(&argv, "80:80/tcp"),
1093            "expected --publish 80:80/tcp when hostPort omitted: {argv:?}"
1094        );
1095    }
1096
1097    #[test]
1098    fn port_mappings_default_protocol_tcp() {
1099        let parsed = crate::runtime::__test_parse_port_mapping(
1100            &serde_json::json!({"containerPort": 443, "hostPort": 443}),
1101        )
1102        .expect("containerPort should parse");
1103        assert_eq!(parsed.protocol, "tcp");
1104        let argv = argv_string(&plan_with_ports(vec![parsed], None));
1105        assert!(
1106            argv_has_publish(&argv, "443:443/tcp"),
1107            "expected default protocol tcp: {argv:?}"
1108        );
1109    }
1110
1111    #[test]
1112    fn awsvpc_network_mode_skips_publish() {
1113        let plan = plan_with_ports(
1114            vec![PortMapping {
1115                container_port: 80,
1116                host_port: 8080,
1117                protocol: "tcp".into(),
1118            }],
1119            Some("awsvpc"),
1120        );
1121        let argv = argv_string(&plan);
1122        assert!(
1123            !argv.iter().any(|s| s == "--publish"),
1124            "awsvpc must not emit --publish: {argv:?}"
1125        );
1126    }
1127
1128    #[test]
1129    fn awsvpc_network_mode_includes_network_flag() {
1130        let plan = plan_with_ports(Vec::new(), Some("awsvpc"));
1131        let argv = argv_string(&plan);
1132        let network_idx = argv.iter().position(|s| s == "--network");
1133        assert!(
1134            network_idx.is_some(),
1135            "awsvpc must emit --network: {argv:?}"
1136        );
1137        let network_name = argv.get(network_idx.unwrap() + 1);
1138        assert!(
1139            network_name
1140                .map(|n| n.starts_with("fakecloud-ecs-"))
1141                .unwrap_or(false),
1142            "awsvpc must reference fakecloud-ecs network: {argv:?}"
1143        );
1144    }
1145
1146    #[test]
1147    fn network_bindings_populated_on_task() {
1148        // Build a task in state, run mark_running_multi with a started
1149        // container that has network_bindings populated, and verify
1150        // task_to_json emits them under containers[0].networkBindings.
1151        let mut accounts: MultiAccountState<EcsState> =
1152            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
1153        let acct = accounts.get_or_create("000000000000");
1154        let arn = acct.cluster_arn("default");
1155        acct.clusters
1156            .insert("default".into(), Cluster::new("default", arn));
1157        let mut task = Task {
1158            task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
1159            task_id: "abc".into(),
1160            cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
1161            cluster_name: "default".into(),
1162            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/web:1".into(),
1163            family: "web".into(),
1164            revision: 1,
1165            container_instance_arn: None,
1166            capacity_provider_name: None,
1167            last_status: "PENDING".into(),
1168            desired_status: "RUNNING".into(),
1169            launch_type: "FARGATE".into(),
1170            platform_version: None,
1171            cpu: None,
1172            memory: None,
1173            containers: vec![Container {
1174                container_arn: "arn:aws:ecs:us-east-1:000000000000:container/default/abc/web"
1175                    .into(),
1176                name: "web".into(),
1177                image: "alpine".into(),
1178                task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
1179                last_status: "PENDING".into(),
1180                exit_code: None,
1181                reason: None,
1182                runtime_id: None,
1183                essential: true,
1184                cpu: None,
1185                memory: None,
1186                memory_reservation: None,
1187                network_bindings: Vec::new(),
1188                network_interfaces: Vec::new(),
1189                health_status: None,
1190                managed_agents: None,
1191                image_digest: None,
1192            }],
1193            overrides: serde_json::json!({}),
1194            started_by: None,
1195            group: None,
1196            connectivity: "CONNECTING".into(),
1197            stop_code: None,
1198            stopped_reason: None,
1199            created_at: Utc::now(),
1200            started_at: None,
1201            stopping_at: None,
1202            stopped_at: None,
1203            pull_started_at: None,
1204            pull_stopped_at: None,
1205            connectivity_at: None,
1206            started_by_ref_id: None,
1207            execution_role_arn: None,
1208            task_role_arn: None,
1209            tags: Vec::new(),
1210            awslogs: None,
1211            captured_logs: String::new(),
1212            protection: None,
1213            enable_execute_command: false,
1214            attachments: Vec::new(),
1215            volume_configurations: Vec::new(),
1216            task_set_arn: None,
1217        };
1218        task.last_status = "PENDING".into();
1219        acct.tasks.insert("abc".into(), task);
1220        let state: SharedEcsState = Arc::new(RwLock::new(accounts));
1221
1222        let bindings = vec![serde_json::json!({
1223            "bindIP": "0.0.0.0",
1224            "containerPort": 80,
1225            "hostPort": 8080,
1226            "protocol": "tcp",
1227        })];
1228        let started = vec![RunningContainer {
1229            name: "web".into(),
1230            container_id: "docker-id".into(),
1231            essential: true,
1232            exit_code: None,
1233            network_bindings: bindings.clone(),
1234            image_digest: None,
1235        }];
1236        mark_running_multi(&state, "000000000000", "abc", &started);
1237
1238        let accounts = state.read();
1239        let task = accounts
1240            .get("000000000000")
1241            .unwrap()
1242            .tasks
1243            .get("abc")
1244            .unwrap();
1245        let json = task_to_json(task);
1246        let nb = &json["containers"][0]["networkBindings"];
1247        assert_eq!(nb, &serde_json::Value::Array(bindings));
1248    }
1249}