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        // A service's tasks carry group `service:<name>`. The serviceName
550        // filter may arrive as a bare name or a full service ARN.
551        let service_group =
552            opt_str(&body, "serviceName").map(|s| format!("service:{}", service_name_from_ref(s)));
553        // containerInstance may be a full ARN or a bare instance ID; compare on
554        // the trailing ID segment so both forms match.
555        let container_instance = opt_str(&body, "containerInstance").map(id_from_ref);
556        let max_results = body
557            .get("maxResults")
558            .and_then(|v| v.as_i64())
559            .filter(|n| (1..=100).contains(n))
560            .map(|n| n as usize)
561            .unwrap_or(100);
562        let next_token = opt_str(&body, "nextToken").unwrap_or("");
563
564        let account = request.account_id.clone();
565        let accounts = self.state.read();
566        let mut arns: Vec<String> = match accounts.get(&account) {
567            Some(state) => state
568                .tasks
569                .values()
570                .filter(|t| t.cluster_name == cluster_name)
571                .filter(|t| family.is_none_or(|f| t.family == f))
572                .filter(|t| status_filter.is_none_or(|s| t.desired_status == s))
573                .filter(|t| started_by.is_none_or(|s| t.started_by.as_deref() == Some(s)))
574                .filter(|t| {
575                    service_group
576                        .as_deref()
577                        .is_none_or(|g| t.group.as_deref() == Some(g))
578                })
579                .filter(|t| {
580                    container_instance.as_deref().is_none_or(|ci| {
581                        t.container_instance_arn
582                            .as_deref()
583                            .map(id_from_ref)
584                            .as_deref()
585                            == Some(ci)
586                    })
587                })
588                .map(|t| t.task_arn.clone())
589                .collect(),
590            None => Vec::new(),
591        };
592        arns.sort();
593        let (page, next) = paginate_arns(arns, next_token, max_results);
594        let mut out = json!({"taskArns": page});
595        if let Some(n) = next {
596            out.as_object_mut()
597                .unwrap()
598                .insert("nextToken".into(), json!(n));
599        }
600        Ok(AwsResponse::ok_json(out))
601    }
602}
603
604#[cfg(test)]
605mod multi_container_tests {
606    use super::*;
607    use crate::EcsService;
608    use bytes::Bytes;
609    use fakecloud_core::multi_account::MultiAccountState;
610    use http::{HeaderMap, Method};
611    use parking_lot::RwLock;
612    use std::collections::HashMap;
613    use std::sync::Arc;
614
615    pub(super) fn fresh_service() -> EcsService {
616        let accounts: MultiAccountState<EcsState> =
617            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
618        let state = Arc::new(RwLock::new(accounts));
619        let svc = EcsService::new(state.clone());
620        // Pre-create the cluster so RunTask doesn't trip on a missing one.
621        let mut accounts = state.write();
622        let s = accounts.get_or_create("000000000000");
623        let arn = s.cluster_arn("default");
624        s.clusters
625            .insert("default".into(), Cluster::new("default", arn));
626        drop(accounts);
627        svc
628    }
629
630    pub(super) fn make_request(action: &str, body: Value) -> AwsRequest {
631        let body_bytes = Bytes::from(serde_json::to_vec(&body).unwrap());
632        AwsRequest {
633            service: "ecs".into(),
634            action: action.into(),
635            region: "us-east-1".into(),
636            account_id: "000000000000".into(),
637            request_id: uuid::Uuid::new_v4().to_string(),
638            headers: HeaderMap::new(),
639            query_params: HashMap::new(),
640            body: body_bytes,
641            body_stream: parking_lot::Mutex::new(None),
642            path_segments: Vec::new(),
643            raw_path: "/".into(),
644            raw_query: String::new(),
645            method: Method::POST,
646            is_query_protocol: false,
647            access_key_id: None,
648            principal: None,
649        }
650    }
651
652    #[test]
653    fn run_task_count_over_ten_is_invalid_parameter() {
654        let svc = fresh_service();
655        // count is validated before the cluster/task-def are dereferenced.
656        let err = match svc.run_task(&make_request(
657            "RunTask",
658            json!({"taskDefinition": "x", "count": 50}),
659        )) {
660            Ok(_) => panic!("expected InvalidParameterException for count > 10"),
661            Err(e) => e,
662        };
663        assert_eq!(err.code(), "InvalidParameterException");
664    }
665
666    #[test]
667    fn create_service_negative_desired_count_is_invalid_parameter() {
668        let svc = fresh_service();
669        // desiredCount is validated before the cluster/task-def are resolved.
670        let err = match svc.create_service(&make_request(
671            "CreateService",
672            json!({"serviceName": "s", "taskDefinition": "x", "desiredCount": -3}),
673        )) {
674            Ok(_) => panic!("expected InvalidParameterException for negative desiredCount"),
675            Err(e) => e,
676        };
677        assert_eq!(err.code(), "InvalidParameterException");
678    }
679
680    #[test]
681    fn run_task_on_missing_cluster_is_cluster_not_found() {
682        let svc = fresh_service();
683        let err = match svc.run_task(&make_request(
684            "RunTask",
685            json!({"taskDefinition": "x", "cluster": "ghost", "count": 1}),
686        )) {
687            Ok(_) => panic!("expected ClusterNotFoundException for a phantom cluster"),
688            Err(e) => e,
689        };
690        assert_eq!(err.code(), "ClusterNotFoundException");
691    }
692
693    #[test]
694    fn region_from_arn_extracts_region_or_none() {
695        assert_eq!(
696            region_from_arn("arn:aws:ecs:eu-west-1:000000000000:task-definition/web:3"),
697            Some("eu-west-1")
698        );
699        assert_eq!(
700            region_from_arn("arn:aws:ecs:ap-southeast-2:000000000000:cluster/prod"),
701            Some("ap-southeast-2")
702        );
703        // Bare names (not ARNs) and empty-region ARNs yield None.
704        assert_eq!(region_from_arn("web:3"), None);
705        assert_eq!(
706            region_from_arn("arn:aws:ecs::000000000000:cluster/prod"),
707            None
708        );
709    }
710
711    pub(super) fn insert_task(svc: &EcsService, id: &str, cluster: &str) {
712        let state = svc.state.clone();
713        let mut accounts = state.write();
714        let acct = accounts.get_or_create("000000000000");
715        let task = Task {
716            task_arn: format!("arn:aws:ecs:us-east-1:000000000000:task/{cluster}/{id}"),
717            task_id: id.into(),
718            cluster_arn: format!("arn:aws:ecs:us-east-1:000000000000:cluster/{cluster}"),
719            cluster_name: cluster.into(),
720            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/web:1".into(),
721            family: "web".into(),
722            revision: 1,
723            container_instance_arn: None,
724            capacity_provider_name: None,
725            last_status: "RUNNING".into(),
726            desired_status: "RUNNING".into(),
727            launch_type: "FARGATE".into(),
728            platform_version: None,
729            cpu: None,
730            memory: None,
731            containers: Vec::new(),
732            overrides: serde_json::json!({}),
733            started_by: None,
734            group: None,
735            connectivity: "CONNECTED".into(),
736            stop_code: None,
737            stopped_reason: None,
738            created_at: Utc::now(),
739            started_at: None,
740            stopping_at: None,
741            stopped_at: None,
742            pull_started_at: None,
743            pull_stopped_at: None,
744            connectivity_at: None,
745            started_by_ref_id: None,
746            execution_role_arn: None,
747            task_role_arn: None,
748            tags: Vec::new(),
749            awslogs: None,
750            captured_logs: String::new(),
751            protection: None,
752            enable_execute_command: false,
753            attachments: Vec::new(),
754            volume_configurations: Vec::new(),
755            task_set_arn: None,
756        };
757        acct.tasks.insert(id.into(), task);
758    }
759
760    #[test]
761    fn describe_tasks_scopes_to_requested_cluster() {
762        let svc = fresh_service();
763        insert_task(&svc, "abc", "other");
764        let task_arn = "arn:aws:ecs:us-east-1:000000000000:task/other/abc";
765
766        // Wrong cluster (default) -> MISSING, not found.
767        let resp = svc
768            .describe_tasks(&make_request(
769                "DescribeTasks",
770                json!({"tasks": [task_arn], "cluster": "default"}),
771            ))
772            .unwrap();
773        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
774        assert_eq!(body["tasks"].as_array().unwrap().len(), 0);
775        assert_eq!(body["failures"][0]["reason"], "MISSING");
776
777        // Correct cluster -> found.
778        let resp = svc
779            .describe_tasks(&make_request(
780                "DescribeTasks",
781                json!({"tasks": [task_arn], "cluster": "other"}),
782            ))
783            .unwrap();
784        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
785        assert_eq!(body["tasks"].as_array().unwrap().len(), 1);
786        assert_eq!(body["failures"].as_array().unwrap().len(), 0);
787    }
788
789    #[test]
790    fn describe_service_revisions_returns_stored_revision_or_missing() {
791        let svc = fresh_service();
792        svc.register_task_definition(&make_request(
793            "RegisterTaskDefinition",
794            json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
795        ))
796        .unwrap();
797        let created = svc
798            .create_service(&make_request(
799                "CreateService",
800                json!({"serviceName": "s", "taskDefinition": "web", "desiredCount": 0}),
801            ))
802            .unwrap();
803        let cbody: Value = serde_json::from_slice(created.body.expect_bytes()).unwrap();
804        let service_arn = cbody["service"]["serviceArn"].as_str().unwrap().to_string();
805
806        // CreateService minted revision 1.
807        let rev1 = format!("{service_arn}:1");
808        let d = svc
809            .describe_service_revisions(&make_request(
810                "DescribeServiceRevisions",
811                json!({"serviceRevisionArns": [rev1.clone()]}),
812            ))
813            .unwrap();
814        let dbody: Value = serde_json::from_slice(d.body.expect_bytes()).unwrap();
815        assert_eq!(dbody["serviceRevisions"].as_array().unwrap().len(), 1);
816        assert_eq!(
817            dbody["serviceRevisions"][0]["serviceRevisionArn"],
818            json!(rev1)
819        );
820        assert!(dbody["serviceRevisions"][0]["taskDefinition"]
821            .as_str()
822            .unwrap()
823            .ends_with("web:1"));
824        assert_eq!(dbody["failures"].as_array().unwrap().len(), 0);
825
826        // Unknown revision -> MISSING.
827        let d2 = svc
828            .describe_service_revisions(&make_request(
829                "DescribeServiceRevisions",
830                json!({"serviceRevisionArns": [format!("{service_arn}:99")]}),
831            ))
832            .unwrap();
833        let d2body: Value = serde_json::from_slice(d2.body.expect_bytes()).unwrap();
834        assert_eq!(d2body["serviceRevisions"].as_array().unwrap().len(), 0);
835        assert_eq!(d2body["failures"][0]["reason"], "MISSING");
836    }
837
838    #[test]
839    fn register_task_def_with_two_containers_then_run_task_starts_both() {
840        let svc = fresh_service();
841        let reg = make_request(
842            "RegisterTaskDefinition",
843            json!({
844                "family": "multi",
845                "containerDefinitions": [
846                    {"name": "app", "image": "alpine"},
847                    {"name": "sidecar", "image": "alpine"}
848                ]
849            }),
850        );
851        svc.register_task_definition(&reg)
852            .expect("register should succeed");
853
854        let run = make_request(
855            "RunTask",
856            json!({
857                "cluster": "default",
858                "taskDefinition": "multi",
859            }),
860        );
861        let resp = svc.run_task(&run).expect("run_task should succeed");
862        let body: Value =
863            serde_json::from_slice(resp.body.expect_bytes()).expect("body should be valid JSON");
864        let tasks = body
865            .get("tasks")
866            .and_then(|v| v.as_array())
867            .expect("tasks array");
868        assert_eq!(tasks.len(), 1);
869        let task = &tasks[0];
870        let containers = task
871            .get("containers")
872            .and_then(|v| v.as_array())
873            .expect("containers array on task");
874        assert_eq!(containers.len(), 2, "expected both containers in task");
875        let names: Vec<&str> = containers
876            .iter()
877            .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
878            .collect();
879        assert!(names.contains(&"app"));
880        assert!(names.contains(&"sidecar"));
881
882        // Per-container ARNs must be distinct so DescribeTasks can address
883        // each container independently.
884        let arns: std::collections::HashSet<&str> = containers
885            .iter()
886            .filter_map(|c| c.get("containerArn").and_then(|v| v.as_str()))
887            .collect();
888        assert_eq!(arns.len(), 2);
889    }
890
891    #[test]
892    fn run_task_defaults_group_to_family() {
893        // A bare RunTask (no `group`) gets `family:<taskDefinitionFamily>`,
894        // matching real AWS. eventing/consumers filter tasks by group, so a
895        // null group hides RunTask-spawned tasks from them.
896        let svc = fresh_service();
897        svc.register_task_definition(&make_request(
898            "RegisterTaskDefinition",
899            json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
900        ))
901        .unwrap();
902
903        let resp = svc
904            .run_task(&make_request(
905                "RunTask",
906                json!({"cluster": "default", "taskDefinition": "web"}),
907            ))
908            .unwrap();
909        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
910        assert_eq!(body["tasks"][0]["group"], "family:web");
911    }
912
913    #[test]
914    fn run_task_preserves_explicit_group() {
915        let svc = fresh_service();
916        svc.register_task_definition(&make_request(
917            "RegisterTaskDefinition",
918            json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
919        ))
920        .unwrap();
921
922        let resp = svc
923            .run_task(&make_request(
924                "RunTask",
925                json!({"cluster": "default", "taskDefinition": "web", "group": "my-group"}),
926            ))
927            .unwrap();
928        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
929        assert_eq!(body["tasks"][0]["group"], "my-group");
930    }
931
932    #[test]
933    fn register_task_def_defaults_essential_true() {
934        let svc = fresh_service();
935        let reg = make_request(
936            "RegisterTaskDefinition",
937            json!({
938                "family": "default-essential",
939                // No `essential` declared on either container.
940                "containerDefinitions": [
941                    {"name": "main", "image": "alpine"},
942                    {"name": "extra", "image": "alpine"}
943                ]
944            }),
945        );
946        svc.register_task_definition(&reg).unwrap();
947
948        let run = make_request(
949            "RunTask",
950            json!({
951                "cluster": "default",
952                "taskDefinition": "default-essential",
953            }),
954        );
955        let resp = svc.run_task(&run).unwrap();
956        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
957        let containers = body["tasks"][0]["containers"].as_array().unwrap();
958        // The `essential` flag is a TaskDefinition.ContainerDefinition
959        // field, not part of the runtime `Container` response shape.
960        // Real ECS doesn't echo it back on RunTask, and the conformance
961        // probe rejects responses that do.
962        for c in containers {
963            assert!(
964                c.get("essential").is_none(),
965                "container {:?} must not carry `essential` on the runtime shape",
966                c.get("name")
967            );
968        }
969    }
970
971    #[test]
972    fn task_to_json_emits_full_container_array() {
973        // Build a Task with two containers directly and confirm helper
974        // emits both entries in the response shape.
975        let mut task = Task {
976            task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
977            task_id: "abc".into(),
978            cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
979            cluster_name: "default".into(),
980            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/multi:1"
981                .into(),
982            family: "multi".into(),
983            revision: 1,
984            container_instance_arn: None,
985            capacity_provider_name: None,
986            last_status: "RUNNING".into(),
987            desired_status: "RUNNING".into(),
988            launch_type: "FARGATE".into(),
989            platform_version: None,
990            cpu: None,
991            memory: None,
992            containers: Vec::new(),
993            overrides: json!({}),
994            started_by: None,
995            group: None,
996            connectivity: "CONNECTED".into(),
997            stop_code: None,
998            stopped_reason: None,
999            created_at: chrono::Utc::now(),
1000            started_at: None,
1001            stopping_at: None,
1002            stopped_at: None,
1003            pull_started_at: None,
1004            pull_stopped_at: None,
1005            connectivity_at: None,
1006            started_by_ref_id: None,
1007            execution_role_arn: None,
1008            task_role_arn: None,
1009            tags: Vec::new(),
1010            awslogs: None,
1011            captured_logs: String::new(),
1012            protection: None,
1013            enable_execute_command: false,
1014            attachments: Vec::new(),
1015            volume_configurations: Vec::new(),
1016            task_set_arn: None,
1017        };
1018        for name in ["app", "sidecar"] {
1019            task.containers.push(Container {
1020                container_arn: format!(
1021                    "arn:aws:ecs:us-east-1:000000000000:container/default/abc/{name}"
1022                ),
1023                name: name.into(),
1024                image: "alpine".into(),
1025                task_arn: task.task_arn.clone(),
1026                last_status: "RUNNING".into(),
1027                exit_code: None,
1028                reason: None,
1029                runtime_id: Some(format!("docker-{name}")),
1030                essential: true,
1031                cpu: None,
1032                memory: None,
1033                memory_reservation: None,
1034                network_bindings: Vec::new(),
1035                network_interfaces: Vec::new(),
1036                health_status: None,
1037                managed_agents: None,
1038                image_digest: None,
1039            });
1040        }
1041
1042        let v = task_to_json(&task);
1043        let containers = v
1044            .get("containers")
1045            .and_then(|v| v.as_array())
1046            .expect("containers array");
1047        assert_eq!(containers.len(), 2);
1048        let names: Vec<&str> = containers
1049            .iter()
1050            .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
1051            .collect();
1052        assert_eq!(names, vec!["app", "sidecar"]);
1053        for c in containers {
1054            assert!(c.get("containerArn").is_some());
1055            assert!(c.get("name").is_some());
1056            assert!(c.get("lastStatus").is_some());
1057            assert!(c.get("runtimeId").is_some());
1058            assert!(c.get("essential").is_none());
1059        }
1060    }
1061}
1062
1063#[cfg(test)]
1064mod port_mapping_tests {
1065    //! Cover the `portMappings` -> `docker run --publish` translation
1066    //! plus the per-container `networkBindings` projected onto the task.
1067    //! The argv builder is exercised directly so we don't need a real
1068    //! container CLI on the test host.
1069    use super::*;
1070    use crate::runtime::{
1071        build_run_argv, mark_running_multi, ContainerPlan, PortMapping, RunningContainer,
1072    };
1073    use crate::state::{Container, EcsState};
1074    use crate::SharedEcsState;
1075    use chrono::Utc;
1076    use fakecloud_core::multi_account::MultiAccountState;
1077    use parking_lot::RwLock;
1078    use std::sync::Arc;
1079
1080    fn plan_with_ports(
1081        port_mappings: Vec<PortMapping>,
1082        network_mode: Option<&str>,
1083    ) -> ContainerPlan {
1084        ContainerPlan {
1085            container_name: "app".into(),
1086            image: "alpine:latest".into(),
1087            env: Vec::new(),
1088            entry_point: Vec::new(),
1089            command: Vec::new(),
1090            secrets_refs: Vec::new(),
1091            essential: true,
1092            has_task_role: false,
1093            port_mappings,
1094            network_mode: network_mode.map(String::from),
1095            depends_on: Vec::new(),
1096            health_check: None,
1097            volume_mounts: Vec::new(),
1098            ulimits: Vec::new(),
1099            linux_parameters: None,
1100            stop_timeout: None,
1101            user: None,
1102            working_directory: None,
1103            tty: false,
1104            interactive: false,
1105            readonly_rootfs: false,
1106        }
1107    }
1108
1109    fn argv_string(plan: &ContainerPlan) -> Vec<String> {
1110        build_run_argv(
1111            plan,
1112            &[],
1113            "task-1",
1114            "host.docker.internal",
1115            None,
1116            "alpine:latest",
1117            true,
1118        )
1119    }
1120
1121    /// Helper for asserting a `--publish <spec>` pair is present in argv.
1122    /// Returns true when the flag/value pair appears as adjacent entries.
1123    fn argv_has_publish(argv: &[String], spec: &str) -> bool {
1124        argv.windows(2).any(|w| w[0] == "--publish" && w[1] == spec)
1125    }
1126
1127    #[test]
1128    fn port_mappings_translate_to_publish_flags() {
1129        let plan = plan_with_ports(
1130            vec![PortMapping {
1131                container_port: 80,
1132                host_port: 8080,
1133                protocol: "tcp".into(),
1134            }],
1135            None,
1136        );
1137        let argv = argv_string(&plan);
1138        assert!(
1139            argv_has_publish(&argv, "80:8080/tcp"),
1140            "expected --publish 80:8080/tcp in argv: {argv:?}"
1141        );
1142    }
1143
1144    #[test]
1145    fn port_mappings_default_host_port_to_container_port() {
1146        // host_port=0 in the parsed mapping means "AWS host-mode default";
1147        // parse_port_mapping rewrites that to containerPort, so by the time
1148        // we reach build_run_argv the host_port should already equal 80.
1149        // Drive the same path through the JSON parser to lock in the
1150        // default behaviour end to end.
1151        let parsed =
1152            crate::runtime::__test_parse_port_mapping(&serde_json::json!({"containerPort": 80}))
1153                .expect("containerPort should parse");
1154        assert_eq!(
1155            parsed.host_port, 80,
1156            "default hostPort should mirror containerPort"
1157        );
1158        let argv = argv_string(&plan_with_ports(vec![parsed], None));
1159        assert!(
1160            argv_has_publish(&argv, "80:80/tcp"),
1161            "expected --publish 80:80/tcp when hostPort omitted: {argv:?}"
1162        );
1163    }
1164
1165    #[test]
1166    fn port_mappings_default_protocol_tcp() {
1167        let parsed = crate::runtime::__test_parse_port_mapping(
1168            &serde_json::json!({"containerPort": 443, "hostPort": 443}),
1169        )
1170        .expect("containerPort should parse");
1171        assert_eq!(parsed.protocol, "tcp");
1172        let argv = argv_string(&plan_with_ports(vec![parsed], None));
1173        assert!(
1174            argv_has_publish(&argv, "443:443/tcp"),
1175            "expected default protocol tcp: {argv:?}"
1176        );
1177    }
1178
1179    #[test]
1180    fn awsvpc_network_mode_skips_publish() {
1181        let plan = plan_with_ports(
1182            vec![PortMapping {
1183                container_port: 80,
1184                host_port: 8080,
1185                protocol: "tcp".into(),
1186            }],
1187            Some("awsvpc"),
1188        );
1189        let argv = argv_string(&plan);
1190        assert!(
1191            !argv.iter().any(|s| s == "--publish"),
1192            "awsvpc must not emit --publish: {argv:?}"
1193        );
1194    }
1195
1196    #[test]
1197    fn awsvpc_network_mode_includes_network_flag() {
1198        let plan = plan_with_ports(Vec::new(), Some("awsvpc"));
1199        let argv = argv_string(&plan);
1200        let network_idx = argv.iter().position(|s| s == "--network");
1201        assert!(
1202            network_idx.is_some(),
1203            "awsvpc must emit --network: {argv:?}"
1204        );
1205        let network_name = argv.get(network_idx.unwrap() + 1);
1206        assert!(
1207            network_name
1208                .map(|n| n.starts_with("fakecloud-ecs-"))
1209                .unwrap_or(false),
1210            "awsvpc must reference fakecloud-ecs network: {argv:?}"
1211        );
1212    }
1213
1214    #[test]
1215    fn network_bindings_populated_on_task() {
1216        // Build a task in state, run mark_running_multi with a started
1217        // container that has network_bindings populated, and verify
1218        // task_to_json emits them under containers[0].networkBindings.
1219        let mut accounts: MultiAccountState<EcsState> =
1220            MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
1221        let acct = accounts.get_or_create("000000000000");
1222        let arn = acct.cluster_arn("default");
1223        acct.clusters
1224            .insert("default".into(), Cluster::new("default", arn));
1225        let mut task = Task {
1226            task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
1227            task_id: "abc".into(),
1228            cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
1229            cluster_name: "default".into(),
1230            task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/web:1".into(),
1231            family: "web".into(),
1232            revision: 1,
1233            container_instance_arn: None,
1234            capacity_provider_name: None,
1235            last_status: "PENDING".into(),
1236            desired_status: "RUNNING".into(),
1237            launch_type: "FARGATE".into(),
1238            platform_version: None,
1239            cpu: None,
1240            memory: None,
1241            containers: vec![Container {
1242                container_arn: "arn:aws:ecs:us-east-1:000000000000:container/default/abc/web"
1243                    .into(),
1244                name: "web".into(),
1245                image: "alpine".into(),
1246                task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
1247                last_status: "PENDING".into(),
1248                exit_code: None,
1249                reason: None,
1250                runtime_id: None,
1251                essential: true,
1252                cpu: None,
1253                memory: None,
1254                memory_reservation: None,
1255                network_bindings: Vec::new(),
1256                network_interfaces: Vec::new(),
1257                health_status: None,
1258                managed_agents: None,
1259                image_digest: None,
1260            }],
1261            overrides: serde_json::json!({}),
1262            started_by: None,
1263            group: None,
1264            connectivity: "CONNECTING".into(),
1265            stop_code: None,
1266            stopped_reason: None,
1267            created_at: Utc::now(),
1268            started_at: None,
1269            stopping_at: None,
1270            stopped_at: None,
1271            pull_started_at: None,
1272            pull_stopped_at: None,
1273            connectivity_at: None,
1274            started_by_ref_id: None,
1275            execution_role_arn: None,
1276            task_role_arn: None,
1277            tags: Vec::new(),
1278            awslogs: None,
1279            captured_logs: String::new(),
1280            protection: None,
1281            enable_execute_command: false,
1282            attachments: Vec::new(),
1283            volume_configurations: Vec::new(),
1284            task_set_arn: None,
1285        };
1286        task.last_status = "PENDING".into();
1287        acct.tasks.insert("abc".into(), task);
1288        let state: SharedEcsState = Arc::new(RwLock::new(accounts));
1289
1290        let bindings = vec![serde_json::json!({
1291            "bindIP": "0.0.0.0",
1292            "containerPort": 80,
1293            "hostPort": 8080,
1294            "protocol": "tcp",
1295        })];
1296        let started = vec![RunningContainer {
1297            name: "web".into(),
1298            container_id: "docker-id".into(),
1299            essential: true,
1300            exit_code: None,
1301            network_bindings: bindings.clone(),
1302            image_digest: None,
1303        }];
1304        mark_running_multi(&state, "000000000000", "abc", &started);
1305
1306        let accounts = state.read();
1307        let task = accounts
1308            .get("000000000000")
1309            .unwrap()
1310            .tasks
1311            .get("abc")
1312            .unwrap();
1313        let json = task_to_json(task);
1314        let nb = &json["containers"][0]["networkBindings"];
1315        assert_eq!(nb, &serde_json::Value::Array(bindings));
1316    }
1317}
1318
1319#[cfg(test)]
1320mod service_validation_tests {
1321    use super::multi_container_tests::*;
1322    use super::*;
1323    use serde_json::{json, Value};
1324
1325    /// Register a task def + create an ACTIVE service named `web` with
1326    /// desiredCount 0, so the update/create validation tests have a target.
1327    fn service_ready(svc: &EcsService) {
1328        svc.register_task_definition(&make_request(
1329            "RegisterTaskDefinition",
1330            json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
1331        ))
1332        .unwrap();
1333        svc.create_service(&make_request(
1334            "CreateService",
1335            json!({"serviceName": "web", "taskDefinition": "web", "desiredCount": 0}),
1336        ))
1337        .unwrap();
1338    }
1339
1340    #[test]
1341    fn update_service_negative_desired_count_is_invalid_parameter() {
1342        let svc = fresh_service();
1343        service_ready(&svc);
1344        // A negative desiredCount previously clamped to 0 and silently scaled
1345        // the service down. AWS rejects it instead.
1346        let err = match svc.update_service(&make_request(
1347            "UpdateService",
1348            json!({"service": "web", "desiredCount": -1}),
1349        )) {
1350            Ok(_) => panic!("expected InvalidParameterException for negative desiredCount"),
1351            Err(e) => e,
1352        };
1353        assert_eq!(err.code(), "InvalidParameterException");
1354    }
1355
1356    #[test]
1357    fn update_service_on_inactive_service_is_service_not_active() {
1358        let svc = fresh_service();
1359        service_ready(&svc);
1360        // Simulate a deleted service left at INACTIVE (still describable).
1361        {
1362            let mut accounts = svc.state.write();
1363            let state = accounts.get_mut("000000000000").unwrap();
1364            let key = EcsState::service_key("default", "web");
1365            state.services.get_mut(&key).unwrap().status = "INACTIVE".into();
1366        }
1367        let err = match svc.update_service(&make_request(
1368            "UpdateService",
1369            json!({"service": "web", "desiredCount": 2}),
1370        )) {
1371            Ok(_) => panic!("expected ServiceNotActiveException for a non-ACTIVE service"),
1372            Err(e) => e,
1373        };
1374        assert_eq!(err.code(), "ServiceNotActiveException");
1375    }
1376
1377    #[test]
1378    fn create_service_duplicate_name_is_invalid_parameter_not_idempotent() {
1379        let svc = fresh_service();
1380        service_ready(&svc);
1381        let err = match svc.create_service(&make_request(
1382            "CreateService",
1383            json!({"serviceName": "web", "taskDefinition": "web", "desiredCount": 0}),
1384        )) {
1385            Ok(_) => panic!("expected InvalidParameterException for a duplicate service name"),
1386            Err(e) => e,
1387        };
1388        assert_eq!(err.code(), "InvalidParameterException");
1389        assert_eq!(err.message(), "Creation of service was not idempotent.");
1390    }
1391
1392    #[test]
1393    fn list_tasks_filters_by_service_name_and_container_instance() {
1394        let svc = fresh_service();
1395        // Two service-owned tasks (group service:web) on distinct container
1396        // instances, plus one unrelated task (group service:api).
1397        insert_task(&svc, "web1", "default");
1398        insert_task(&svc, "web2", "default");
1399        insert_task(&svc, "api1", "default");
1400        {
1401            let mut accounts = svc.state.write();
1402            let state = accounts.get_mut("000000000000").unwrap();
1403            let ci_a = "arn:aws:ecs:us-east-1:000000000000:container-instance/default/aaaaaaaa"
1404                .to_string();
1405            let ci_b = "arn:aws:ecs:us-east-1:000000000000:container-instance/default/bbbbbbbb"
1406                .to_string();
1407            let t = state.tasks.get_mut("web1").unwrap();
1408            t.group = Some("service:web".into());
1409            t.container_instance_arn = Some(ci_a);
1410            let t = state.tasks.get_mut("web2").unwrap();
1411            t.group = Some("service:web".into());
1412            t.container_instance_arn = Some(ci_b);
1413            let t = state.tasks.get_mut("api1").unwrap();
1414            t.group = Some("service:api".into());
1415        }
1416
1417        // serviceName filter returns only the two web tasks.
1418        let resp = svc
1419            .list_tasks(&make_request("ListTasks", json!({"serviceName": "web"})))
1420            .unwrap();
1421        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1422        let arns = body["taskArns"].as_array().unwrap();
1423        assert_eq!(arns.len(), 2);
1424        assert!(arns.iter().all(|a| a.as_str().unwrap().contains("/web")));
1425
1426        // containerInstance filter (bare ID) narrows to the single task on it.
1427        let resp = svc
1428            .list_tasks(&make_request(
1429                "ListTasks",
1430                json!({"containerInstance": "aaaaaaaa"}),
1431            ))
1432            .unwrap();
1433        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1434        let arns = body["taskArns"].as_array().unwrap();
1435        assert_eq!(arns.len(), 1);
1436        assert!(arns[0].as_str().unwrap().ends_with("/web1"));
1437
1438        // containerInstance filter also accepts a full ARN.
1439        let resp = svc
1440            .list_tasks(&make_request(
1441                "ListTasks",
1442                json!({"containerInstance": "arn:aws:ecs:us-east-1:000000000000:container-instance/default/bbbbbbbb"}),
1443            ))
1444            .unwrap();
1445        let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
1446        let arns = body["taskArns"].as_array().unwrap();
1447        assert_eq!(arns.len(), 1);
1448        assert!(arns[0].as_str().unwrap().ends_with("/web2"));
1449    }
1450}