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