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 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 let count = match body.get("count").and_then(|v| v.as_i64()) {
101 Some(n) if (1..=10).contains(&n) => n as usize,
102 Some(n) => {
103 return Err(invalid_parameter(format!(
104 "count should be between 1 and 10, inclusive. count={n}"
105 )))
106 }
107 None => 1,
108 };
109 let group = opt_str(&body, "group").map(String::from);
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 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 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 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 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 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 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 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 let mut accounts = self.state.write();
383 if let Some(state) = accounts.get_mut(&account) {
384 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 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 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 let cluster_name = EcsState::resolve_cluster_name(opt_str(&body, "cluster"));
499
500 let account = request.account_id.clone();
501 let accounts = self.state.read();
502 let Some(state) = accounts.get(&account) else {
503 return Ok(AwsResponse::ok_json(json!({
504 "tasks": [],
505 "failures": refs.iter().map(|r| json!({"arn": r, "reason": "MISSING"})).collect::<Vec<_>>(),
506 })));
507 };
508 let mut found = Vec::new();
509 let mut failures = Vec::new();
510 for input in &refs {
511 let task_id = task_id_from_ref(input);
512 match state.tasks.get(&task_id) {
513 Some(t) if t.cluster_name == cluster_name => {
514 let mut v = task_to_json(t);
515 if include_tags {
516 v.as_object_mut()
517 .unwrap()
518 .insert("tags".into(), tags_json(&t.tags));
519 }
520 found.push(v);
521 }
522 _ => {
523 failures.push(json!({
524 "arn": input,
525 "reason": "MISSING",
526 }));
527 }
528 }
529 }
530 Ok(AwsResponse::ok_json(json!({
531 "tasks": found,
532 "failures": failures,
533 })))
534 }
535
536 pub(super) fn list_tasks(&self, request: &AwsRequest) -> Result<AwsResponse, AwsServiceError> {
537 let body = request.json_body();
538 validate_enum_opt(&body, "desiredStatus", &["RUNNING", "PENDING", "STOPPED"])?;
539 validate_enum_opt(
540 &body,
541 "launchType",
542 &["EC2", "FARGATE", "EXTERNAL", "MANAGED_INSTANCES"],
543 )?;
544 let cluster_ref = opt_str(&body, "cluster");
545 let cluster_name = EcsState::resolve_cluster_name(cluster_ref);
546 let family = opt_str(&body, "family");
547 let status_filter = opt_str(&body, "desiredStatus").or(Some("RUNNING"));
548 let started_by = opt_str(&body, "startedBy");
549 let service_group =
552 opt_str(&body, "serviceName").map(|s| format!("service:{}", service_name_from_ref(s)));
553 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 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 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 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 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 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 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 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 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(®)
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 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 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 "containerDefinitions": [
941 {"name": "main", "image": "alpine"},
942 {"name": "extra", "image": "alpine"}
943 ]
944 }),
945 );
946 svc.register_task_definition(®).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 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 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 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 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 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 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 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 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 {
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 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 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 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 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}