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