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 max_results = body
550 .get("maxResults")
551 .and_then(|v| v.as_i64())
552 .filter(|n| (1..=100).contains(n))
553 .map(|n| n as usize)
554 .unwrap_or(100);
555 let next_token = opt_str(&body, "nextToken").unwrap_or("");
556
557 let account = request.account_id.clone();
558 let accounts = self.state.read();
559 let mut arns: Vec<String> = match accounts.get(&account) {
560 Some(state) => state
561 .tasks
562 .values()
563 .filter(|t| t.cluster_name == cluster_name)
564 .filter(|t| family.is_none_or(|f| t.family == f))
565 .filter(|t| status_filter.is_none_or(|s| t.desired_status == s))
566 .filter(|t| started_by.is_none_or(|s| t.started_by.as_deref() == Some(s)))
567 .map(|t| t.task_arn.clone())
568 .collect(),
569 None => Vec::new(),
570 };
571 arns.sort();
572 let (page, next) = paginate_arns(arns, next_token, max_results);
573 let mut out = json!({"taskArns": page});
574 if let Some(n) = next {
575 out.as_object_mut()
576 .unwrap()
577 .insert("nextToken".into(), json!(n));
578 }
579 Ok(AwsResponse::ok_json(out))
580 }
581}
582
583#[cfg(test)]
584mod multi_container_tests {
585 use super::*;
586 use crate::EcsService;
587 use bytes::Bytes;
588 use fakecloud_core::multi_account::MultiAccountState;
589 use http::{HeaderMap, Method};
590 use parking_lot::RwLock;
591 use std::collections::HashMap;
592 use std::sync::Arc;
593
594 fn fresh_service() -> EcsService {
595 let accounts: MultiAccountState<EcsState> =
596 MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
597 let state = Arc::new(RwLock::new(accounts));
598 let svc = EcsService::new(state.clone());
599 let mut accounts = state.write();
601 let s = accounts.get_or_create("000000000000");
602 let arn = s.cluster_arn("default");
603 s.clusters
604 .insert("default".into(), Cluster::new("default", arn));
605 drop(accounts);
606 svc
607 }
608
609 fn make_request(action: &str, body: Value) -> AwsRequest {
610 let body_bytes = Bytes::from(serde_json::to_vec(&body).unwrap());
611 AwsRequest {
612 service: "ecs".into(),
613 action: action.into(),
614 region: "us-east-1".into(),
615 account_id: "000000000000".into(),
616 request_id: uuid::Uuid::new_v4().to_string(),
617 headers: HeaderMap::new(),
618 query_params: HashMap::new(),
619 body: body_bytes,
620 body_stream: parking_lot::Mutex::new(None),
621 path_segments: Vec::new(),
622 raw_path: "/".into(),
623 raw_query: String::new(),
624 method: Method::POST,
625 is_query_protocol: false,
626 access_key_id: None,
627 principal: None,
628 }
629 }
630
631 #[test]
632 fn run_task_count_over_ten_is_invalid_parameter() {
633 let svc = fresh_service();
634 let err = match svc.run_task(&make_request(
636 "RunTask",
637 json!({"taskDefinition": "x", "count": 50}),
638 )) {
639 Ok(_) => panic!("expected InvalidParameterException for count > 10"),
640 Err(e) => e,
641 };
642 assert_eq!(err.code(), "InvalidParameterException");
643 }
644
645 #[test]
646 fn create_service_negative_desired_count_is_invalid_parameter() {
647 let svc = fresh_service();
648 let err = match svc.create_service(&make_request(
650 "CreateService",
651 json!({"serviceName": "s", "taskDefinition": "x", "desiredCount": -3}),
652 )) {
653 Ok(_) => panic!("expected InvalidParameterException for negative desiredCount"),
654 Err(e) => e,
655 };
656 assert_eq!(err.code(), "InvalidParameterException");
657 }
658
659 #[test]
660 fn run_task_on_missing_cluster_is_cluster_not_found() {
661 let svc = fresh_service();
662 let err = match svc.run_task(&make_request(
663 "RunTask",
664 json!({"taskDefinition": "x", "cluster": "ghost", "count": 1}),
665 )) {
666 Ok(_) => panic!("expected ClusterNotFoundException for a phantom cluster"),
667 Err(e) => e,
668 };
669 assert_eq!(err.code(), "ClusterNotFoundException");
670 }
671
672 #[test]
673 fn region_from_arn_extracts_region_or_none() {
674 assert_eq!(
675 region_from_arn("arn:aws:ecs:eu-west-1:000000000000:task-definition/web:3"),
676 Some("eu-west-1")
677 );
678 assert_eq!(
679 region_from_arn("arn:aws:ecs:ap-southeast-2:000000000000:cluster/prod"),
680 Some("ap-southeast-2")
681 );
682 assert_eq!(region_from_arn("web:3"), None);
684 assert_eq!(
685 region_from_arn("arn:aws:ecs::000000000000:cluster/prod"),
686 None
687 );
688 }
689
690 fn insert_task(svc: &EcsService, id: &str, cluster: &str) {
691 let state = svc.state.clone();
692 let mut accounts = state.write();
693 let acct = accounts.get_or_create("000000000000");
694 let task = Task {
695 task_arn: format!("arn:aws:ecs:us-east-1:000000000000:task/{cluster}/{id}"),
696 task_id: id.into(),
697 cluster_arn: format!("arn:aws:ecs:us-east-1:000000000000:cluster/{cluster}"),
698 cluster_name: cluster.into(),
699 task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/web:1".into(),
700 family: "web".into(),
701 revision: 1,
702 container_instance_arn: None,
703 capacity_provider_name: None,
704 last_status: "RUNNING".into(),
705 desired_status: "RUNNING".into(),
706 launch_type: "FARGATE".into(),
707 platform_version: None,
708 cpu: None,
709 memory: None,
710 containers: Vec::new(),
711 overrides: serde_json::json!({}),
712 started_by: None,
713 group: None,
714 connectivity: "CONNECTED".into(),
715 stop_code: None,
716 stopped_reason: None,
717 created_at: Utc::now(),
718 started_at: None,
719 stopping_at: None,
720 stopped_at: None,
721 pull_started_at: None,
722 pull_stopped_at: None,
723 connectivity_at: None,
724 started_by_ref_id: None,
725 execution_role_arn: None,
726 task_role_arn: None,
727 tags: Vec::new(),
728 awslogs: None,
729 captured_logs: String::new(),
730 protection: None,
731 enable_execute_command: false,
732 attachments: Vec::new(),
733 volume_configurations: Vec::new(),
734 task_set_arn: None,
735 };
736 acct.tasks.insert(id.into(), task);
737 }
738
739 #[test]
740 fn describe_tasks_scopes_to_requested_cluster() {
741 let svc = fresh_service();
742 insert_task(&svc, "abc", "other");
743 let task_arn = "arn:aws:ecs:us-east-1:000000000000:task/other/abc";
744
745 let resp = svc
747 .describe_tasks(&make_request(
748 "DescribeTasks",
749 json!({"tasks": [task_arn], "cluster": "default"}),
750 ))
751 .unwrap();
752 let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
753 assert_eq!(body["tasks"].as_array().unwrap().len(), 0);
754 assert_eq!(body["failures"][0]["reason"], "MISSING");
755
756 let resp = svc
758 .describe_tasks(&make_request(
759 "DescribeTasks",
760 json!({"tasks": [task_arn], "cluster": "other"}),
761 ))
762 .unwrap();
763 let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
764 assert_eq!(body["tasks"].as_array().unwrap().len(), 1);
765 assert_eq!(body["failures"].as_array().unwrap().len(), 0);
766 }
767
768 #[test]
769 fn describe_service_revisions_returns_stored_revision_or_missing() {
770 let svc = fresh_service();
771 svc.register_task_definition(&make_request(
772 "RegisterTaskDefinition",
773 json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
774 ))
775 .unwrap();
776 let created = svc
777 .create_service(&make_request(
778 "CreateService",
779 json!({"serviceName": "s", "taskDefinition": "web", "desiredCount": 0}),
780 ))
781 .unwrap();
782 let cbody: Value = serde_json::from_slice(created.body.expect_bytes()).unwrap();
783 let service_arn = cbody["service"]["serviceArn"].as_str().unwrap().to_string();
784
785 let rev1 = format!("{service_arn}:1");
787 let d = svc
788 .describe_service_revisions(&make_request(
789 "DescribeServiceRevisions",
790 json!({"serviceRevisionArns": [rev1.clone()]}),
791 ))
792 .unwrap();
793 let dbody: Value = serde_json::from_slice(d.body.expect_bytes()).unwrap();
794 assert_eq!(dbody["serviceRevisions"].as_array().unwrap().len(), 1);
795 assert_eq!(
796 dbody["serviceRevisions"][0]["serviceRevisionArn"],
797 json!(rev1)
798 );
799 assert!(dbody["serviceRevisions"][0]["taskDefinition"]
800 .as_str()
801 .unwrap()
802 .ends_with("web:1"));
803 assert_eq!(dbody["failures"].as_array().unwrap().len(), 0);
804
805 let d2 = svc
807 .describe_service_revisions(&make_request(
808 "DescribeServiceRevisions",
809 json!({"serviceRevisionArns": [format!("{service_arn}:99")]}),
810 ))
811 .unwrap();
812 let d2body: Value = serde_json::from_slice(d2.body.expect_bytes()).unwrap();
813 assert_eq!(d2body["serviceRevisions"].as_array().unwrap().len(), 0);
814 assert_eq!(d2body["failures"][0]["reason"], "MISSING");
815 }
816
817 #[test]
818 fn register_task_def_with_two_containers_then_run_task_starts_both() {
819 let svc = fresh_service();
820 let reg = make_request(
821 "RegisterTaskDefinition",
822 json!({
823 "family": "multi",
824 "containerDefinitions": [
825 {"name": "app", "image": "alpine"},
826 {"name": "sidecar", "image": "alpine"}
827 ]
828 }),
829 );
830 svc.register_task_definition(®)
831 .expect("register should succeed");
832
833 let run = make_request(
834 "RunTask",
835 json!({
836 "cluster": "default",
837 "taskDefinition": "multi",
838 }),
839 );
840 let resp = svc.run_task(&run).expect("run_task should succeed");
841 let body: Value =
842 serde_json::from_slice(resp.body.expect_bytes()).expect("body should be valid JSON");
843 let tasks = body
844 .get("tasks")
845 .and_then(|v| v.as_array())
846 .expect("tasks array");
847 assert_eq!(tasks.len(), 1);
848 let task = &tasks[0];
849 let containers = task
850 .get("containers")
851 .and_then(|v| v.as_array())
852 .expect("containers array on task");
853 assert_eq!(containers.len(), 2, "expected both containers in task");
854 let names: Vec<&str> = containers
855 .iter()
856 .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
857 .collect();
858 assert!(names.contains(&"app"));
859 assert!(names.contains(&"sidecar"));
860
861 let arns: std::collections::HashSet<&str> = containers
864 .iter()
865 .filter_map(|c| c.get("containerArn").and_then(|v| v.as_str()))
866 .collect();
867 assert_eq!(arns.len(), 2);
868 }
869
870 #[test]
871 fn run_task_defaults_group_to_family() {
872 let svc = fresh_service();
876 svc.register_task_definition(&make_request(
877 "RegisterTaskDefinition",
878 json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
879 ))
880 .unwrap();
881
882 let resp = svc
883 .run_task(&make_request(
884 "RunTask",
885 json!({"cluster": "default", "taskDefinition": "web"}),
886 ))
887 .unwrap();
888 let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
889 assert_eq!(body["tasks"][0]["group"], "family:web");
890 }
891
892 #[test]
893 fn run_task_preserves_explicit_group() {
894 let svc = fresh_service();
895 svc.register_task_definition(&make_request(
896 "RegisterTaskDefinition",
897 json!({"family": "web", "containerDefinitions": [{"name": "app", "image": "alpine"}]}),
898 ))
899 .unwrap();
900
901 let resp = svc
902 .run_task(&make_request(
903 "RunTask",
904 json!({"cluster": "default", "taskDefinition": "web", "group": "my-group"}),
905 ))
906 .unwrap();
907 let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
908 assert_eq!(body["tasks"][0]["group"], "my-group");
909 }
910
911 #[test]
912 fn register_task_def_defaults_essential_true() {
913 let svc = fresh_service();
914 let reg = make_request(
915 "RegisterTaskDefinition",
916 json!({
917 "family": "default-essential",
918 "containerDefinitions": [
920 {"name": "main", "image": "alpine"},
921 {"name": "extra", "image": "alpine"}
922 ]
923 }),
924 );
925 svc.register_task_definition(®).unwrap();
926
927 let run = make_request(
928 "RunTask",
929 json!({
930 "cluster": "default",
931 "taskDefinition": "default-essential",
932 }),
933 );
934 let resp = svc.run_task(&run).unwrap();
935 let body: Value = serde_json::from_slice(resp.body.expect_bytes()).unwrap();
936 let containers = body["tasks"][0]["containers"].as_array().unwrap();
937 for c in containers {
942 assert!(
943 c.get("essential").is_none(),
944 "container {:?} must not carry `essential` on the runtime shape",
945 c.get("name")
946 );
947 }
948 }
949
950 #[test]
951 fn task_to_json_emits_full_container_array() {
952 let mut task = Task {
955 task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
956 task_id: "abc".into(),
957 cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
958 cluster_name: "default".into(),
959 task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/multi:1"
960 .into(),
961 family: "multi".into(),
962 revision: 1,
963 container_instance_arn: None,
964 capacity_provider_name: None,
965 last_status: "RUNNING".into(),
966 desired_status: "RUNNING".into(),
967 launch_type: "FARGATE".into(),
968 platform_version: None,
969 cpu: None,
970 memory: None,
971 containers: Vec::new(),
972 overrides: json!({}),
973 started_by: None,
974 group: None,
975 connectivity: "CONNECTED".into(),
976 stop_code: None,
977 stopped_reason: None,
978 created_at: chrono::Utc::now(),
979 started_at: None,
980 stopping_at: None,
981 stopped_at: None,
982 pull_started_at: None,
983 pull_stopped_at: None,
984 connectivity_at: None,
985 started_by_ref_id: None,
986 execution_role_arn: None,
987 task_role_arn: None,
988 tags: Vec::new(),
989 awslogs: None,
990 captured_logs: String::new(),
991 protection: None,
992 enable_execute_command: false,
993 attachments: Vec::new(),
994 volume_configurations: Vec::new(),
995 task_set_arn: None,
996 };
997 for name in ["app", "sidecar"] {
998 task.containers.push(Container {
999 container_arn: format!(
1000 "arn:aws:ecs:us-east-1:000000000000:container/default/abc/{name}"
1001 ),
1002 name: name.into(),
1003 image: "alpine".into(),
1004 task_arn: task.task_arn.clone(),
1005 last_status: "RUNNING".into(),
1006 exit_code: None,
1007 reason: None,
1008 runtime_id: Some(format!("docker-{name}")),
1009 essential: true,
1010 cpu: None,
1011 memory: None,
1012 memory_reservation: None,
1013 network_bindings: Vec::new(),
1014 network_interfaces: Vec::new(),
1015 health_status: None,
1016 managed_agents: None,
1017 image_digest: None,
1018 });
1019 }
1020
1021 let v = task_to_json(&task);
1022 let containers = v
1023 .get("containers")
1024 .and_then(|v| v.as_array())
1025 .expect("containers array");
1026 assert_eq!(containers.len(), 2);
1027 let names: Vec<&str> = containers
1028 .iter()
1029 .filter_map(|c| c.get("name").and_then(|v| v.as_str()))
1030 .collect();
1031 assert_eq!(names, vec!["app", "sidecar"]);
1032 for c in containers {
1033 assert!(c.get("containerArn").is_some());
1034 assert!(c.get("name").is_some());
1035 assert!(c.get("lastStatus").is_some());
1036 assert!(c.get("runtimeId").is_some());
1037 assert!(c.get("essential").is_none());
1038 }
1039 }
1040}
1041
1042#[cfg(test)]
1043mod port_mapping_tests {
1044 use super::*;
1049 use crate::runtime::{
1050 build_run_argv, mark_running_multi, ContainerPlan, PortMapping, RunningContainer,
1051 };
1052 use crate::state::{Container, EcsState};
1053 use crate::SharedEcsState;
1054 use chrono::Utc;
1055 use fakecloud_core::multi_account::MultiAccountState;
1056 use parking_lot::RwLock;
1057 use std::sync::Arc;
1058
1059 fn plan_with_ports(
1060 port_mappings: Vec<PortMapping>,
1061 network_mode: Option<&str>,
1062 ) -> ContainerPlan {
1063 ContainerPlan {
1064 container_name: "app".into(),
1065 image: "alpine:latest".into(),
1066 env: Vec::new(),
1067 entry_point: Vec::new(),
1068 command: Vec::new(),
1069 secrets_refs: Vec::new(),
1070 essential: true,
1071 has_task_role: false,
1072 port_mappings,
1073 network_mode: network_mode.map(String::from),
1074 depends_on: Vec::new(),
1075 health_check: None,
1076 volume_mounts: Vec::new(),
1077 ulimits: Vec::new(),
1078 linux_parameters: None,
1079 stop_timeout: None,
1080 user: None,
1081 working_directory: None,
1082 tty: false,
1083 interactive: false,
1084 readonly_rootfs: false,
1085 }
1086 }
1087
1088 fn argv_string(plan: &ContainerPlan) -> Vec<String> {
1089 build_run_argv(
1090 plan,
1091 &[],
1092 "task-1",
1093 "host.docker.internal",
1094 None,
1095 "alpine:latest",
1096 true,
1097 )
1098 }
1099
1100 fn argv_has_publish(argv: &[String], spec: &str) -> bool {
1103 argv.windows(2).any(|w| w[0] == "--publish" && w[1] == spec)
1104 }
1105
1106 #[test]
1107 fn port_mappings_translate_to_publish_flags() {
1108 let plan = plan_with_ports(
1109 vec![PortMapping {
1110 container_port: 80,
1111 host_port: 8080,
1112 protocol: "tcp".into(),
1113 }],
1114 None,
1115 );
1116 let argv = argv_string(&plan);
1117 assert!(
1118 argv_has_publish(&argv, "80:8080/tcp"),
1119 "expected --publish 80:8080/tcp in argv: {argv:?}"
1120 );
1121 }
1122
1123 #[test]
1124 fn port_mappings_default_host_port_to_container_port() {
1125 let parsed =
1131 crate::runtime::__test_parse_port_mapping(&serde_json::json!({"containerPort": 80}))
1132 .expect("containerPort should parse");
1133 assert_eq!(
1134 parsed.host_port, 80,
1135 "default hostPort should mirror containerPort"
1136 );
1137 let argv = argv_string(&plan_with_ports(vec![parsed], None));
1138 assert!(
1139 argv_has_publish(&argv, "80:80/tcp"),
1140 "expected --publish 80:80/tcp when hostPort omitted: {argv:?}"
1141 );
1142 }
1143
1144 #[test]
1145 fn port_mappings_default_protocol_tcp() {
1146 let parsed = crate::runtime::__test_parse_port_mapping(
1147 &serde_json::json!({"containerPort": 443, "hostPort": 443}),
1148 )
1149 .expect("containerPort should parse");
1150 assert_eq!(parsed.protocol, "tcp");
1151 let argv = argv_string(&plan_with_ports(vec![parsed], None));
1152 assert!(
1153 argv_has_publish(&argv, "443:443/tcp"),
1154 "expected default protocol tcp: {argv:?}"
1155 );
1156 }
1157
1158 #[test]
1159 fn awsvpc_network_mode_skips_publish() {
1160 let plan = plan_with_ports(
1161 vec![PortMapping {
1162 container_port: 80,
1163 host_port: 8080,
1164 protocol: "tcp".into(),
1165 }],
1166 Some("awsvpc"),
1167 );
1168 let argv = argv_string(&plan);
1169 assert!(
1170 !argv.iter().any(|s| s == "--publish"),
1171 "awsvpc must not emit --publish: {argv:?}"
1172 );
1173 }
1174
1175 #[test]
1176 fn awsvpc_network_mode_includes_network_flag() {
1177 let plan = plan_with_ports(Vec::new(), Some("awsvpc"));
1178 let argv = argv_string(&plan);
1179 let network_idx = argv.iter().position(|s| s == "--network");
1180 assert!(
1181 network_idx.is_some(),
1182 "awsvpc must emit --network: {argv:?}"
1183 );
1184 let network_name = argv.get(network_idx.unwrap() + 1);
1185 assert!(
1186 network_name
1187 .map(|n| n.starts_with("fakecloud-ecs-"))
1188 .unwrap_or(false),
1189 "awsvpc must reference fakecloud-ecs network: {argv:?}"
1190 );
1191 }
1192
1193 #[test]
1194 fn network_bindings_populated_on_task() {
1195 let mut accounts: MultiAccountState<EcsState> =
1199 MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
1200 let acct = accounts.get_or_create("000000000000");
1201 let arn = acct.cluster_arn("default");
1202 acct.clusters
1203 .insert("default".into(), Cluster::new("default", arn));
1204 let mut task = Task {
1205 task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
1206 task_id: "abc".into(),
1207 cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
1208 cluster_name: "default".into(),
1209 task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/web:1".into(),
1210 family: "web".into(),
1211 revision: 1,
1212 container_instance_arn: None,
1213 capacity_provider_name: None,
1214 last_status: "PENDING".into(),
1215 desired_status: "RUNNING".into(),
1216 launch_type: "FARGATE".into(),
1217 platform_version: None,
1218 cpu: None,
1219 memory: None,
1220 containers: vec![Container {
1221 container_arn: "arn:aws:ecs:us-east-1:000000000000:container/default/abc/web"
1222 .into(),
1223 name: "web".into(),
1224 image: "alpine".into(),
1225 task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
1226 last_status: "PENDING".into(),
1227 exit_code: None,
1228 reason: None,
1229 runtime_id: None,
1230 essential: true,
1231 cpu: None,
1232 memory: None,
1233 memory_reservation: None,
1234 network_bindings: Vec::new(),
1235 network_interfaces: Vec::new(),
1236 health_status: None,
1237 managed_agents: None,
1238 image_digest: None,
1239 }],
1240 overrides: serde_json::json!({}),
1241 started_by: None,
1242 group: None,
1243 connectivity: "CONNECTING".into(),
1244 stop_code: None,
1245 stopped_reason: None,
1246 created_at: Utc::now(),
1247 started_at: None,
1248 stopping_at: None,
1249 stopped_at: None,
1250 pull_started_at: None,
1251 pull_stopped_at: None,
1252 connectivity_at: None,
1253 started_by_ref_id: None,
1254 execution_role_arn: None,
1255 task_role_arn: None,
1256 tags: Vec::new(),
1257 awslogs: None,
1258 captured_logs: String::new(),
1259 protection: None,
1260 enable_execute_command: false,
1261 attachments: Vec::new(),
1262 volume_configurations: Vec::new(),
1263 task_set_arn: None,
1264 };
1265 task.last_status = "PENDING".into();
1266 acct.tasks.insert("abc".into(), task);
1267 let state: SharedEcsState = Arc::new(RwLock::new(accounts));
1268
1269 let bindings = vec![serde_json::json!({
1270 "bindIP": "0.0.0.0",
1271 "containerPort": 80,
1272 "hostPort": 8080,
1273 "protocol": "tcp",
1274 })];
1275 let started = vec![RunningContainer {
1276 name: "web".into(),
1277 container_id: "docker-id".into(),
1278 essential: true,
1279 exit_code: None,
1280 network_bindings: bindings.clone(),
1281 image_digest: None,
1282 }];
1283 mark_running_multi(&state, "000000000000", "abc", &started);
1284
1285 let accounts = state.read();
1286 let task = accounts
1287 .get("000000000000")
1288 .unwrap()
1289 .tasks
1290 .get("abc")
1291 .unwrap();
1292 let json = task_to_json(task);
1293 let nb = &json["containers"][0]["networkBindings"];
1294 assert_eq!(nb, &serde_json::Value::Array(bindings));
1295 }
1296}