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