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