1use std::path::PathBuf;
11use std::sync::Arc;
12use std::time::Duration;
13
14use base64::Engine;
15use chrono::Utc;
16use fakecloud_core::delivery::DeliveryBus;
17use fakecloud_logs::ingest::{append_events, IngestEvent};
18use fakecloud_logs::SharedLogsState;
19use fakecloud_secretsmanager::SharedSecretsManagerState;
20use fakecloud_ssm::SharedSsmState;
21use parking_lot::RwLock;
22use tempfile::TempDir;
23use tokio::process::Command;
24
25use crate::state::{LifecycleEvent, SharedEcsState};
26
27#[derive(Debug, thiserror::Error)]
28pub enum RuntimeError {
29 #[error("container CLI not found (tried docker, podman)")]
30 NoCli,
31 #[error("image pull failed: {0}")]
32 ImagePull(String),
33 #[error("container start failed: {0}")]
34 ContainerStart(String),
35 #[error("docker wait failed: {0}")]
36 Wait(String),
37}
38
39pub struct EcsRuntime {
41 cli: String,
42 net: fakecloud_core::container_net::HostNetworking,
47 server_port: u16,
52 docker_config: Option<Arc<TempDir>>,
57 containers: RwLock<std::collections::HashMap<String, Vec<(String, String)>>>,
62 delivery_bus: Option<Arc<DeliveryBus>>,
66 logs_state: Option<SharedLogsState>,
70 secretsmanager_state: Option<SharedSecretsManagerState>,
73 ssm_state: Option<SharedSsmState>,
76 k8s: Option<k8s::K8sTaskBackend>,
80 snapshot_hook: std::sync::OnceLock<fakecloud_persistence::SnapshotHook>,
91}
92
93mod config;
94mod k8s;
95mod lb;
96mod monitoring;
97mod secrets;
98mod task_lifecycle;
99
100impl EcsRuntime {
101 pub fn new(server_port: u16) -> Option<Self> {
106 let cli = fakecloud_core::container_net::detect_container_cli()?;
107 let net = fakecloud_core::container_net::HostNetworking::detect(&cli);
108 let docker_config = build_local_registry_docker_config(server_port).map(Arc::new);
109 Some(Self {
110 cli,
111 net,
112 server_port,
113 docker_config,
114 containers: RwLock::new(std::collections::HashMap::new()),
115 delivery_bus: None,
116 logs_state: None,
117 secretsmanager_state: None,
118 ssm_state: None,
119 k8s: None,
120 snapshot_hook: std::sync::OnceLock::new(),
121 })
122 }
123
124 pub async fn new_k8s(server_port: u16) -> Result<Self, k8s::BackendInitError> {
128 let backend = k8s::K8sTaskBackend::from_env(server_port).await?;
129 let net = fakecloud_core::container_net::HostNetworking {
132 host_alias: String::new(),
133 add_host_arg: None,
134 sibling_host: String::new(),
135 };
136 Ok(Self {
137 cli: String::new(),
138 net,
139 server_port,
140 docker_config: None,
141 containers: RwLock::new(std::collections::HashMap::new()),
142 delivery_bus: None,
143 logs_state: None,
144 secretsmanager_state: None,
145 ssm_state: None,
146 k8s: Some(backend),
147 snapshot_hook: std::sync::OnceLock::new(),
148 })
149 }
150
151 pub fn cli_name(&self) -> &str {
153 if self.k8s.is_some() {
154 "kubernetes"
155 } else {
156 &self.cli
157 }
158 }
159
160 pub async fn reap_stale(&self) {
163 if let Some(k) = &self.k8s {
164 k.reap_stale().await;
165 }
166 }
167
168 pub fn with_delivery_bus(mut self, bus: Arc<DeliveryBus>) -> Self {
171 self.delivery_bus = Some(bus);
172 self
173 }
174
175 pub fn with_logs(mut self, logs: SharedLogsState) -> Self {
178 self.logs_state = Some(logs);
179 self
180 }
181
182 pub fn set_snapshot_hook(&self, hook: fakecloud_persistence::SnapshotHook) {
189 let _ = self.snapshot_hook.set(hook);
190 }
191
192 pub(crate) async fn persist_snapshot(&self) {
196 if let Some(hook) = self.snapshot_hook.get() {
197 hook().await;
198 }
199 }
200}
201
202#[derive(Clone, Debug)]
204pub(crate) struct ContainerPlan {
205 pub(crate) container_name: String,
206 pub(crate) image: String,
207 pub(crate) env: Vec<(String, String)>,
208 pub(crate) entry_point: Vec<String>,
209 pub(crate) command: Vec<String>,
210 pub(crate) secrets_refs: Vec<(String, String)>,
211 pub(crate) essential: bool,
212 pub(crate) has_task_role: bool,
213 pub(crate) port_mappings: Vec<PortMapping>,
218 pub(crate) network_mode: Option<String>,
223 pub(crate) depends_on: Vec<DependsOn>,
231 pub(crate) health_check: Option<HealthCheckSpec>,
238 pub(crate) volume_mounts: Vec<VolumeMount>,
243 pub(crate) ulimits: Vec<Ulimit>,
246 pub(crate) linux_parameters: Option<LinuxParameters>,
250 pub(crate) stop_timeout: Option<u32>,
252 pub(crate) user: Option<String>,
254 pub(crate) working_directory: Option<String>,
256 pub(crate) tty: bool,
258 pub(crate) interactive: bool,
260 pub(crate) readonly_rootfs: bool,
262}
263
264#[derive(Clone, Debug, PartialEq, Eq)]
270pub(crate) struct DependsOn {
271 pub container_name: String,
272 pub condition: DependsOnCondition,
273}
274
275#[derive(Clone, Copy, Debug, PartialEq, Eq)]
279pub(crate) enum DependsOnCondition {
280 Start,
283 Complete,
285 Success,
287 Healthy,
291}
292
293impl DependsOnCondition {
294 pub fn parse(raw: &str) -> Option<Self> {
298 match raw {
299 "START" => Some(Self::Start),
300 "COMPLETE" => Some(Self::Complete),
301 "SUCCESS" => Some(Self::Success),
302 "HEALTHY" => Some(Self::Healthy),
303 _ => None,
304 }
305 }
306
307 pub fn as_aws_str(self) -> &'static str {
311 match self {
312 Self::Start => "START",
313 Self::Complete => "COMPLETE",
314 Self::Success => "SUCCESS",
315 Self::Healthy => "HEALTHY",
316 }
317 }
318}
319
320#[derive(Clone, Debug, PartialEq, Eq)]
326pub(crate) struct HealthCheckSpec {
327 pub command: Vec<String>,
333 pub interval_seconds: u32,
334 pub timeout_seconds: u32,
335 pub retries: u32,
336 pub start_period_seconds: u32,
337}
338
339#[derive(Clone, Debug, PartialEq, Eq)]
343pub(crate) struct PortMapping {
344 pub container_port: u16,
345 pub host_port: u16,
348 pub protocol: String,
350}
351
352#[derive(Clone, Debug, PartialEq, Eq)]
374pub(crate) struct VolumeMount {
375 pub source: String,
379 pub container_path: String,
382 pub read_only: bool,
386 pub cleanup_on_stop: bool,
392}
393
394#[derive(Clone, Debug, PartialEq, Eq)]
396pub(crate) struct Ulimit {
397 pub name: String,
398 pub soft_limit: i32,
399 pub hard_limit: i32,
400}
401
402#[derive(Clone, Debug, PartialEq, Eq)]
404pub(crate) struct Device {
405 pub host_path: String,
406 pub container_path: String,
407 pub permissions: String,
408}
409
410#[derive(Clone, Debug, PartialEq, Eq)]
412pub(crate) struct Sysctl {
413 pub name: String,
414 pub value: String,
415}
416
417#[derive(Clone, Debug, PartialEq, Eq, Default)]
419pub(crate) struct LinuxParameters {
420 pub capabilities_add: Vec<String>,
421 pub capabilities_drop: Vec<String>,
422 pub devices: Vec<Device>,
423 pub init_process_enabled: bool,
424 pub shared_memory_size: Option<i32>,
425 pub sysctls: Vec<Sysctl>,
426 pub tmpfs: Vec<Tmpfs>,
427 pub privileged: bool,
428}
429
430#[derive(Clone, Debug, PartialEq, Eq)]
432pub(crate) struct Tmpfs {
433 pub container_path: String,
434 pub size: i32,
435 pub mount_options: Vec<String>,
436}
437
438#[derive(Clone, Debug)]
439struct ResolvedContainerPlan {
440 plan: ContainerPlan,
441 env: Vec<(String, String)>,
442}
443
444#[derive(Clone, Debug)]
446struct TaskExitOutcome {
447 exited_index: Option<usize>,
451 exit_code: i64,
452 stop_code: &'static str,
453}
454
455#[derive(Clone, Debug)]
458pub(crate) struct RunningContainer {
459 pub(crate) name: String,
460 pub(crate) container_id: String,
461 pub(crate) essential: bool,
462 pub(crate) exit_code: Option<i64>,
463 pub(crate) network_bindings: Vec<serde_json::Value>,
467 pub(crate) image_digest: Option<String>,
472}
473
474pub(crate) fn task_should_stop(containers: &[RunningContainer]) -> bool {
479 if containers.is_empty() {
480 return true;
481 }
482 let any_essential_exited = containers
483 .iter()
484 .any(|c| c.essential && c.exit_code.is_some());
485 if any_essential_exited {
486 return true;
487 }
488 containers.iter().all(|c| c.exit_code.is_some())
489}
490
491pub(crate) fn task_desired_stopped(
496 state: &SharedEcsState,
497 account_id: &str,
498 task_id: &str,
499) -> bool {
500 let accounts = state.read();
501 match accounts.get(account_id).and_then(|s| s.tasks.get(task_id)) {
502 Some(task) => task.desired_status == "STOPPED",
503 None => true,
504 }
505}
506
507fn build_container_plans(
508 state: &SharedEcsState,
509 account_id: &str,
510 task_id: &str,
511 _server_port: u16,
512) -> Result<Vec<ContainerPlan>, RuntimeError> {
513 let accounts = state.read();
514 let s = accounts
515 .get(account_id)
516 .ok_or_else(|| RuntimeError::ContainerStart("account missing".into()))?;
517 let task = s
518 .tasks
519 .get(task_id)
520 .ok_or_else(|| RuntimeError::ContainerStart("task missing".into()))?;
521 if task.containers.is_empty() {
522 return Err(RuntimeError::ContainerStart(
523 "task has no containers".into(),
524 ));
525 }
526 let has_task_role = task.task_role_arn.is_some();
527 let task_def = s
528 .task_definitions
529 .get(&task.family)
530 .and_then(|revs| revs.get(&task.revision));
531 let network_mode = task_def.and_then(|td| td.network_mode.clone());
532 let volumes_by_name: std::collections::HashMap<String, &serde_json::Value> = task_def
537 .map(|td| {
538 td.volumes
539 .iter()
540 .filter_map(|v| {
541 let name = v.get("name").and_then(|n| n.as_str())?;
542 Some((name.to_string(), v))
543 })
544 .collect()
545 })
546 .unwrap_or_default();
547 let mut plans = Vec::with_capacity(task.containers.len());
548 for container in &task.containers {
549 let def = find_container_definition(s, &task.family, task.revision, &container.name);
550 let secrets_refs = def
551 .as_ref()
552 .and_then(|d| d.get("secrets").and_then(|v| v.as_array()).cloned())
553 .map(|arr| {
554 arr.iter()
555 .filter_map(|e| {
556 let name = e.get("name").and_then(|v| v.as_str())?.to_string();
557 let value_from = e.get("valueFrom").and_then(|v| v.as_str())?.to_string();
558 Some((name, value_from))
559 })
560 .collect::<Vec<_>>()
561 })
562 .unwrap_or_default();
563 let str_array = |key: &str| -> Vec<String> {
564 def.as_ref()
565 .and_then(|d| d.get(key).and_then(|v| v.as_array()).cloned())
566 .map(|arr| {
567 arr.iter()
568 .filter_map(|v| v.as_str().map(String::from))
569 .collect::<Vec<_>>()
570 })
571 .unwrap_or_default()
572 };
573 let env = def
574 .as_ref()
575 .and_then(|d| d.get("environment").and_then(|v| v.as_array()).cloned())
576 .map(|arr| {
577 arr.iter()
578 .filter_map(|e| {
579 let k = e.get("name").and_then(|v| v.as_str())?;
580 let v = e.get("value").and_then(|v| v.as_str()).unwrap_or("");
581 Some((k.to_string(), v.to_string()))
582 })
583 .collect::<Vec<_>>()
584 })
585 .unwrap_or_default();
586 let port_mappings = def
587 .as_ref()
588 .and_then(|d| d.get("portMappings").and_then(|v| v.as_array()).cloned())
589 .map(|arr| {
590 arr.iter()
591 .filter_map(parse_port_mapping)
592 .collect::<Vec<_>>()
593 })
594 .unwrap_or_default();
595 let depends_on = def
596 .as_ref()
597 .and_then(|d| d.get("dependsOn").and_then(|v| v.as_array()).cloned())
598 .map(|arr| {
599 arr.iter()
600 .filter_map(parse_depends_on_entry)
601 .collect::<Vec<_>>()
602 })
603 .unwrap_or_default();
604 let health_check = def
605 .as_ref()
606 .and_then(|d| d.get("healthCheck"))
607 .and_then(parse_health_check);
608 let volume_mounts = def
609 .as_ref()
610 .and_then(|d| d.get("mountPoints").and_then(|v| v.as_array()).cloned())
611 .map(|arr| {
612 arr.iter()
613 .filter_map(|mp| resolve_mount_point(mp, &volumes_by_name))
614 .collect::<Vec<_>>()
615 })
616 .unwrap_or_default();
617 let ulimits = def
618 .as_ref()
619 .and_then(|d| d.get("ulimits").and_then(|v| v.as_array()).cloned())
620 .map(|arr| arr.iter().filter_map(parse_ulimit).collect::<Vec<_>>())
621 .unwrap_or_default();
622 let linux_parameters = def
623 .as_ref()
624 .and_then(|d| d.get("linuxParameters"))
625 .and_then(parse_linux_parameters);
626 let stop_timeout = def.as_ref().and_then(|d| {
627 d.get("stopTimeout")
628 .and_then(|v| v.as_u64())
629 .map(|n| n as u32)
630 });
631 let user = def
632 .as_ref()
633 .and_then(|d| d.get("user").and_then(|v| v.as_str()).map(String::from));
634 let working_directory = def.as_ref().and_then(|d| {
635 d.get("workingDirectory")
636 .and_then(|v| v.as_str())
637 .map(String::from)
638 });
639 let tty = def
640 .as_ref()
641 .and_then(|d| d.get("tty").and_then(|v| v.as_bool()))
642 .unwrap_or(false);
643 let interactive = def
644 .as_ref()
645 .and_then(|d| d.get("interactive").and_then(|v| v.as_bool()))
646 .unwrap_or(false);
647 let readonly_rootfs = def
648 .as_ref()
649 .and_then(|d| d.get("readonlyRootFilesystem").and_then(|v| v.as_bool()))
650 .unwrap_or(false);
651 plans.push(ContainerPlan {
652 container_name: container.name.clone(),
653 image: container.image.clone(),
654 env,
655 entry_point: str_array("entryPoint"),
656 command: str_array("command"),
657 secrets_refs,
658 essential: container.essential,
659 has_task_role,
660 port_mappings,
661 network_mode: network_mode.clone(),
662 depends_on,
663 health_check,
664 volume_mounts,
665 ulimits,
666 linux_parameters,
667 stop_timeout,
668 user,
669 working_directory,
670 tty,
671 interactive,
672 readonly_rootfs,
673 });
674 }
675 let plans = topo_sort_plans(plans);
676 Ok(plans)
677}
678
679fn resolve_mount_point(
687 mount_point: &serde_json::Value,
688 volumes_by_name: &std::collections::HashMap<String, &serde_json::Value>,
689) -> Option<VolumeMount> {
690 let container_path = mount_point
691 .get("containerPath")
692 .and_then(|v| v.as_str())?
693 .to_string();
694 let source_volume = mount_point.get("sourceVolume").and_then(|v| v.as_str())?;
695 let read_only = mount_point
696 .get("readOnly")
697 .and_then(|v| v.as_bool())
698 .unwrap_or(false);
699 let volume = volumes_by_name.get(source_volume)?;
700 let source = resolve_volume_source(source_volume, volume)?;
701 let cleanup_on_stop = volume_is_task_scoped(volume);
702 Some(VolumeMount {
703 source,
704 container_path,
705 read_only,
706 cleanup_on_stop,
707 })
708}
709
710fn volume_is_task_scoped(volume: &serde_json::Value) -> bool {
719 if let Some(host) = volume.get("host") {
720 if let Some(path) = host.get("sourcePath").and_then(|v| v.as_str()) {
721 if !path.is_empty() {
722 return false;
723 }
724 }
725 }
726 if volume.get("efsVolumeConfiguration").is_some()
727 || volume
728 .get("fsxWindowsFileServerVolumeConfiguration")
729 .is_some()
730 {
731 return false;
732 }
733 if let Some(docker) = volume.get("dockerVolumeConfiguration") {
734 let scope = docker
737 .get("scope")
738 .and_then(|v| v.as_str())
739 .unwrap_or("task");
740 return scope != "shared";
741 }
742 true
744}
745
746fn resolve_volume_source(name: &str, volume: &serde_json::Value) -> Option<String> {
765 if let Some(host) = volume.get("host") {
766 if let Some(path) = host.get("sourcePath").and_then(|v| v.as_str()) {
767 if !path.is_empty() {
770 ensure_dir_exists(path);
771 return Some(path.to_string());
772 }
773 }
774 }
775 if let Some(efs) = volume.get("efsVolumeConfiguration") {
776 let fs_id = efs.get("fileSystemId").and_then(|v| v.as_str())?;
777 let root = efs
778 .get("rootDirectory")
779 .and_then(|v| v.as_str())
780 .unwrap_or("/");
781 return Some(shared_volume_name("efs", fs_id, root));
782 }
783 if let Some(fsx) = volume.get("fsxWindowsFileServerVolumeConfiguration") {
784 let fs_id = fsx.get("fileSystemId").and_then(|v| v.as_str())?;
785 let root = fsx
786 .get("rootDirectory")
787 .and_then(|v| v.as_str())
788 .unwrap_or("/");
789 return Some(shared_volume_name("fsx", fs_id, root));
790 }
791 if volume.get("dockerVolumeConfiguration").is_some() {
792 return Some(name.to_string());
795 }
796 Some(name.to_string())
798}
799
800fn shared_volume_name(kind: &str, fs_id: &str, root: &str) -> String {
811 let trimmed = root.trim_start_matches('/').trim_end_matches('/');
812 let fs_id = sanitize_volume_segment(fs_id);
813 if trimmed.is_empty() {
814 format!("fakecloud-{kind}-{fs_id}")
815 } else {
816 format!(
817 "fakecloud-{kind}-{fs_id}-{}",
818 sanitize_volume_segment(trimmed)
819 )
820 }
821}
822
823fn sanitize_volume_segment(s: &str) -> String {
826 s.chars()
827 .map(|c| {
828 if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') {
829 c
830 } else {
831 '-'
832 }
833 })
834 .collect()
835}
836
837fn ensure_dir_exists(path: &str) {
842 let _ = std::fs::create_dir_all(path);
843}
844
845fn parse_depends_on_entry(value: &serde_json::Value) -> Option<DependsOn> {
850 let container_name = value
851 .get("containerName")
852 .and_then(|v| v.as_str())?
853 .to_string();
854 let raw_condition = value.get("condition").and_then(|v| v.as_str())?;
855 let condition = DependsOnCondition::parse(raw_condition)?;
856 Some(DependsOn {
857 container_name,
858 condition,
859 })
860}
861
862fn topo_sort_plans(plans: Vec<ContainerPlan>) -> Vec<ContainerPlan> {
873 use std::collections::{HashMap, HashSet};
874 let names: HashSet<String> = plans.iter().map(|p| p.container_name.clone()).collect();
875 let index: HashMap<String, usize> = plans
876 .iter()
877 .enumerate()
878 .map(|(i, p)| (p.container_name.clone(), i))
879 .collect();
880 let mut in_degree: Vec<usize> = plans
885 .iter()
886 .map(|p| {
887 p.depends_on
888 .iter()
889 .filter(|d| names.contains(&d.container_name))
890 .count()
891 })
892 .collect();
893 let mut dependants: Vec<Vec<usize>> = vec![Vec::new(); plans.len()];
895 for (i, p) in plans.iter().enumerate() {
896 for d in &p.depends_on {
897 if let Some(&di) = index.get(&d.container_name) {
898 dependants[di].push(i);
899 }
900 }
901 }
902 let mut ordered: Vec<ContainerPlan> = Vec::with_capacity(plans.len());
903 let mut emitted: Vec<bool> = vec![false; plans.len()];
904 loop {
905 let next = (0..plans.len()).find(|&i| !emitted[i] && in_degree[i] == 0);
908 match next {
909 Some(i) => {
910 emitted[i] = true;
911 ordered.push(plans[i].clone());
912 for &di in &dependants[i] {
913 if in_degree[di] > 0 {
914 in_degree[di] -= 1;
915 }
916 }
917 }
918 None => break,
919 }
920 }
921 for (i, p) in plans.into_iter().enumerate() {
923 if !emitted[i] {
924 ordered.push(p);
925 }
926 }
927 ordered
928}
929
930pub(crate) fn find_depends_on_cycle(
939 container_definitions: &[serde_json::Value],
940) -> Option<(String, String)> {
941 use std::collections::HashMap;
942
943 let names: Vec<String> = container_definitions
944 .iter()
945 .filter_map(|c| c.get("name").and_then(|n| n.as_str()).map(String::from))
946 .collect();
947 let index: HashMap<&str, usize> = names
948 .iter()
949 .enumerate()
950 .map(|(i, n)| (n.as_str(), i))
951 .collect();
952
953 let mut adj: Vec<Vec<usize>> = vec![Vec::new(); names.len()];
954 for (i, cd) in container_definitions.iter().enumerate() {
955 if i >= names.len() {
956 continue;
957 }
958 let Some(deps) = cd.get("dependsOn").and_then(|v| v.as_array()) else {
959 continue;
960 };
961 for d in deps {
962 let Some(target) = d.get("containerName").and_then(|v| v.as_str()) else {
963 continue;
964 };
965 if let Some(&j) = index.get(target) {
966 adj[i].push(j);
968 }
969 }
970 }
971
972 let mut state = vec![0u8; names.len()];
976 let mut stack: Vec<(usize, usize)> = Vec::new();
977 for start in 0..names.len() {
978 if state[start] != 0 {
979 continue;
980 }
981 stack.clear();
982 stack.push((start, 0));
983 state[start] = 1;
984 while let Some(&(node, next_edge)) = stack.last() {
985 if next_edge < adj[node].len() {
986 let nb = adj[node][next_edge];
987 stack.last_mut().unwrap().1 += 1;
988 match state[nb] {
989 0 => {
990 state[nb] = 1;
991 stack.push((nb, 0));
992 }
993 1 => {
994 return Some((names[node].clone(), names[nb].clone()));
995 }
996 _ => {}
997 }
998 } else {
999 state[node] = 2;
1000 stack.pop();
1001 }
1002 }
1003 }
1004 None
1005}
1006
1007#[derive(Debug, Clone)]
1011struct InspectedState {
1012 started: bool,
1013 exited: bool,
1014 exit_code: i64,
1015 health: Option<String>,
1016}
1017
1018async fn inspect_container_state(cli: &str, container_id: &str) -> Option<InspectedState> {
1022 let format =
1025 "{{.State.Status}}|{{.State.Running}}|{{.State.ExitCode}}|{{if .State.Health}}{{.State.Health.Status}}{{else}}<none>{{end}}";
1026 let out = Command::new(cli)
1027 .args(["inspect", "-f", format, container_id])
1028 .output()
1029 .await
1030 .ok()?;
1031 if !out.status.success() {
1032 return None;
1033 }
1034 let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
1035 let parts: Vec<&str> = raw.split('|').collect();
1036 if parts.len() < 4 {
1037 return None;
1038 }
1039 let status = parts[0];
1040 let running = parts[1] == "true";
1041 let exit_code: i64 = parts[2].parse().unwrap_or(-1);
1042 let health = match parts[3] {
1043 "<none>" | "" => None,
1044 other => Some(other.to_string()),
1045 };
1046 let started = running || status == "exited" || status == "running" || status == "dead";
1050 let exited = status == "exited" || status == "dead";
1051 Some(InspectedState {
1052 started,
1053 exited,
1054 exit_code,
1055 health,
1056 })
1057}
1058
1059fn condition_is_met(condition: DependsOnCondition, state: &InspectedState) -> bool {
1063 match condition {
1064 DependsOnCondition::Start => state.started,
1065 DependsOnCondition::Complete => state.exited,
1066 DependsOnCondition::Success => state.exited && state.exit_code == 0,
1067 DependsOnCondition::Healthy => state.health.as_deref() == Some("healthy"),
1068 }
1069}
1070
1071#[cfg(test)]
1075pub(crate) fn __test_parse_port_mapping(value: &serde_json::Value) -> Option<PortMapping> {
1076 parse_port_mapping(value)
1077}
1078
1079fn parse_health_check(value: &serde_json::Value) -> Option<HealthCheckSpec> {
1085 let cmd_arr = value.get("command")?.as_array()?;
1086 let command: Vec<String> = cmd_arr
1087 .iter()
1088 .filter_map(|v| v.as_str().map(String::from))
1089 .collect();
1090 if command.is_empty() {
1091 return None;
1092 }
1093 if command.first().map(|s| s.as_str()) == Some("NONE") {
1094 return None;
1095 }
1096 let read_u32 = |key: &str, default: u32| -> u32 {
1097 value
1098 .get(key)
1099 .and_then(|v| v.as_i64())
1100 .filter(|n| (0..=u32::MAX as i64).contains(n))
1101 .map(|n| n as u32)
1102 .unwrap_or(default)
1103 };
1104 Some(HealthCheckSpec {
1105 command,
1106 interval_seconds: read_u32("interval", 30),
1107 timeout_seconds: read_u32("timeout", 5),
1108 retries: read_u32("retries", 3),
1109 start_period_seconds: read_u32("startPeriod", 0),
1110 })
1111}
1112
1113fn parse_ulimit(value: &serde_json::Value) -> Option<Ulimit> {
1115 let name = value.get("name").and_then(|v| v.as_str())?;
1116 let soft = value
1117 .get("softLimit")
1118 .and_then(|v| v.as_i64())
1119 .filter(|n| *n >= 0)? as i32;
1120 let hard = value
1121 .get("hardLimit")
1122 .and_then(|v| v.as_i64())
1123 .filter(|n| *n >= 0)? as i32;
1124 Some(Ulimit {
1125 name: name.to_string(),
1126 soft_limit: soft,
1127 hard_limit: hard,
1128 })
1129}
1130
1131fn parse_linux_parameters(value: &serde_json::Value) -> Option<LinuxParameters> {
1133 let mut lp = LinuxParameters::default();
1134 if let Some(arr) = value
1135 .get("capabilities")
1136 .and_then(|v| v.get("add"))
1137 .and_then(|v| v.as_array())
1138 {
1139 lp.capabilities_add = arr
1140 .iter()
1141 .filter_map(|v| v.as_str().map(String::from))
1142 .collect();
1143 }
1144 if let Some(arr) = value
1145 .get("capabilities")
1146 .and_then(|v| v.get("drop"))
1147 .and_then(|v| v.as_array())
1148 {
1149 lp.capabilities_drop = arr
1150 .iter()
1151 .filter_map(|v| v.as_str().map(String::from))
1152 .collect();
1153 }
1154 if let Some(arr) = value.get("devices").and_then(|v| v.as_array()) {
1155 lp.devices = arr.iter().filter_map(parse_device).collect();
1156 }
1157 lp.init_process_enabled = value
1158 .get("initProcessEnabled")
1159 .and_then(|v| v.as_bool())
1160 .unwrap_or(false);
1161 lp.shared_memory_size = value
1162 .get("sharedMemorySize")
1163 .and_then(|v| v.as_i64())
1164 .map(|n| n as i32);
1165 if let Some(arr) = value.get("sysctl").and_then(|v| v.as_array()) {
1166 lp.sysctls = arr.iter().filter_map(parse_sysctl).collect();
1167 }
1168 if let Some(arr) = value.get("tmpfs").and_then(|v| v.as_array()) {
1169 lp.tmpfs = arr.iter().filter_map(parse_tmpfs).collect();
1170 }
1171 lp.privileged = value
1172 .get("privileged")
1173 .and_then(|v| v.as_bool())
1174 .unwrap_or(false);
1175 Some(lp)
1176}
1177
1178fn parse_device(value: &serde_json::Value) -> Option<Device> {
1179 let host_path = value.get("hostPath").and_then(|v| v.as_str())?.to_string();
1180 let container_path = value
1181 .get("containerPath")
1182 .and_then(|v| v.as_str())?
1183 .to_string();
1184 let permissions = value
1185 .get("permissions")
1186 .and_then(|v| v.as_str())
1187 .unwrap_or("rwm")
1188 .to_string();
1189 Some(Device {
1190 host_path,
1191 container_path,
1192 permissions,
1193 })
1194}
1195
1196fn parse_sysctl(value: &serde_json::Value) -> Option<Sysctl> {
1197 let name = value.get("name").and_then(|v| v.as_str())?.to_string();
1198 let value_str = value.get("value").and_then(|v| v.as_str())?.to_string();
1199 Some(Sysctl {
1200 name,
1201 value: value_str,
1202 })
1203}
1204
1205fn parse_tmpfs(value: &serde_json::Value) -> Option<Tmpfs> {
1206 let container_path = value
1207 .get("containerPath")
1208 .and_then(|v| v.as_str())?
1209 .to_string();
1210 let size = value
1211 .get("size")
1212 .and_then(|v| v.as_i64())
1213 .filter(|n| *n > 0)? as i32;
1214 let mount_options = value
1215 .get("mountOptions")
1216 .and_then(|v| v.as_array())
1217 .map(|arr| {
1218 arr.iter()
1219 .filter_map(|v| v.as_str().map(String::from))
1220 .collect()
1221 })
1222 .unwrap_or_default();
1223 Some(Tmpfs {
1224 container_path,
1225 size,
1226 mount_options,
1227 })
1228}
1229
1230pub(crate) fn render_health_flags(hc: &HealthCheckSpec) -> Vec<String> {
1237 if hc.command.len() < 2 {
1238 return Vec::new();
1239 }
1240 let cmd_kind = hc.command[0].as_str();
1241 if cmd_kind != "CMD" && cmd_kind != "CMD-SHELL" {
1242 return Vec::new();
1243 }
1244 let cmd_string = hc.command[1..].join(" ");
1245 vec![
1246 "--health-cmd".into(),
1247 cmd_string,
1248 format!("--health-interval={}s", hc.interval_seconds),
1249 format!("--health-timeout={}s", hc.timeout_seconds),
1250 format!("--health-retries={}", hc.retries),
1251 format!("--health-start-period={}s", hc.start_period_seconds),
1252 ]
1253}
1254
1255#[cfg(test)]
1259pub(crate) fn __test_parse_health_check(value: &serde_json::Value) -> Option<HealthCheckSpec> {
1260 parse_health_check(value)
1261}
1262
1263pub(crate) fn docker_health_to_ecs(raw: &str) -> &'static str {
1269 match raw.trim().to_ascii_lowercase().as_str() {
1270 "healthy" => "HEALTHY",
1271 "unhealthy" => "UNHEALTHY",
1272 _ => "UNKNOWN",
1273 }
1274}
1275
1276fn parse_port_mapping(value: &serde_json::Value) -> Option<PortMapping> {
1280 let container_port = value
1281 .get("containerPort")
1282 .and_then(|v| v.as_i64())
1283 .filter(|n| (0..=u16::MAX as i64).contains(n))? as u16;
1284 let host_port_raw = value
1285 .get("hostPort")
1286 .and_then(|v| v.as_i64())
1287 .filter(|n| (0..=u16::MAX as i64).contains(n))
1288 .map(|n| n as u16)
1289 .unwrap_or(0);
1290 let host_port = if host_port_raw == 0 {
1291 container_port
1292 } else {
1293 host_port_raw
1294 };
1295 let protocol = value
1296 .get("protocol")
1297 .and_then(|v| v.as_str())
1298 .map(|s| s.to_ascii_lowercase())
1299 .unwrap_or_else(|| "tcp".to_string());
1300 Some(PortMapping {
1301 container_port,
1302 host_port,
1303 protocol,
1304 })
1305}
1306
1307pub(crate) fn fakecloud_instance_label() -> String {
1313 format!("fakecloud-instance=fakecloud-{}", std::process::id())
1314}
1315
1316pub(crate) fn build_run_argv(
1322 plan: &ContainerPlan,
1323 env: &[(String, String)],
1324 task_id: &str,
1325 host_alias: &str,
1326 add_host_arg: Option<&str>,
1327 run_image: &str,
1328 awsvpc_network_ready: bool,
1329) -> Vec<String> {
1330 let mut argv: Vec<String> = Vec::new();
1331 argv.push("run".into());
1332 argv.push("-d".into());
1333 argv.push("--name".into());
1334 argv.push(format!("{}-{}", task_id, plan.container_name));
1335 argv.push("--label".into());
1336 argv.push(format!("fakecloud-ecs-task={}", task_id));
1337 argv.push("--label".into());
1338 argv.push(format!("fakecloud-ecs-container={}", plan.container_name));
1339 argv.push("--label".into());
1346 argv.push(fakecloud_instance_label());
1347 if let Some(arg) = add_host_arg {
1351 argv.push("--add-host".into());
1352 argv.push(arg.to_string());
1353 }
1354 let use_awsvpc_network = plan.network_mode.as_deref() == Some("awsvpc") && awsvpc_network_ready;
1355 if use_awsvpc_network {
1356 argv.push("--network".into());
1357 argv.push(format!("fakecloud-ecs-{}", task_id));
1358 }
1359 let publish_ports = !use_awsvpc_network;
1365 if publish_ports {
1366 for pm in &plan.port_mappings {
1367 argv.push("--publish".into());
1368 argv.push(format!(
1369 "{}:{}/{}",
1370 pm.container_port, pm.host_port, pm.protocol
1371 ));
1372 }
1373 }
1374 if let Some(ref hc) = plan.health_check {
1375 argv.extend(render_health_flags(hc));
1376 }
1377 let http_alias_prefix = format!("http://{host_alias}:");
1378 let https_alias_prefix = format!("https://{host_alias}:");
1379 for (k, v) in env {
1380 let transformed = v
1381 .replace("http://127.0.0.1:", http_alias_prefix.as_str())
1382 .replace("https://127.0.0.1:", https_alias_prefix.as_str())
1383 .replace("http://localhost:", http_alias_prefix.as_str())
1384 .replace("https://localhost:", https_alias_prefix.as_str());
1385 argv.push("-e".into());
1386 argv.push(format!("{}={}", k, transformed));
1387 }
1388 for vm in &plan.volume_mounts {
1393 argv.push("-v".into());
1394 let suffix = if vm.read_only { ":ro" } else { "" };
1395 argv.push(format!("{}:{}{}", vm.source, vm.container_path, suffix));
1396 }
1397 for ul in &plan.ulimits {
1398 argv.push("--ulimit".into());
1399 argv.push(format!("{}={}:{}", ul.name, ul.soft_limit, ul.hard_limit));
1400 }
1401 if let Some(ref lp) = plan.linux_parameters {
1402 for cap in &lp.capabilities_add {
1403 argv.push("--cap-add".into());
1404 argv.push(cap.clone());
1405 }
1406 for cap in &lp.capabilities_drop {
1407 argv.push("--cap-drop".into());
1408 argv.push(cap.clone());
1409 }
1410 for dev in &lp.devices {
1411 argv.push("--device".into());
1412 argv.push(format!(
1413 "{}:{}{}",
1414 dev.host_path, dev.container_path, dev.permissions
1415 ));
1416 }
1417 if lp.init_process_enabled {
1418 argv.push("--init".into());
1419 }
1420 if let Some(size) = lp.shared_memory_size {
1421 argv.push("--shm-size".into());
1422 argv.push(format!("{}m", size));
1423 }
1424 for sys in &lp.sysctls {
1425 argv.push("--sysctl".into());
1426 argv.push(format!("{}={}", sys.name, sys.value));
1427 }
1428 for tmp in &lp.tmpfs {
1429 let mut opts = tmp.mount_options.join(",");
1430 if !opts.is_empty() {
1431 opts = format!(",{}", opts);
1432 }
1433 argv.push("--tmpfs".into());
1434 argv.push(format!("{}:size={}M{}", tmp.container_path, tmp.size, opts));
1435 }
1436 if lp.privileged {
1437 argv.push("--privileged".into());
1438 }
1439 }
1440 if let Some(timeout) = plan.stop_timeout {
1441 argv.push("--stop-timeout".into());
1442 argv.push(format!("{}", timeout));
1443 }
1444 if let Some(ref user) = plan.user {
1445 argv.push("--user".into());
1446 argv.push(user.clone());
1447 }
1448 if let Some(ref wd) = plan.working_directory {
1449 argv.push("--workdir".into());
1450 argv.push(wd.clone());
1451 }
1452 if plan.tty {
1453 argv.push("--tty".into());
1454 }
1455 if plan.interactive {
1456 argv.push("--interactive".into());
1457 }
1458 if plan.readonly_rootfs {
1459 argv.push("--read-only".into());
1460 }
1461 if let Some(first) = plan.entry_point.first() {
1462 argv.push("--entrypoint".into());
1463 argv.push(first.clone());
1464 }
1465 argv.push(run_image.to_string());
1466 for arg in plan.entry_point.iter().skip(1) {
1467 argv.push(arg.clone());
1468 }
1469 for arg in &plan.command {
1470 argv.push(arg.clone());
1471 }
1472 argv
1473}
1474
1475pub(crate) fn network_bindings_for(plan: &ContainerPlan) -> Vec<serde_json::Value> {
1479 if plan.network_mode.as_deref() == Some("awsvpc") {
1480 return Vec::new();
1481 }
1482 plan.port_mappings
1483 .iter()
1484 .map(|pm| {
1485 serde_json::json!({
1486 "bindIP": "0.0.0.0",
1487 "containerPort": pm.container_port,
1488 "hostPort": pm.host_port,
1489 "protocol": pm.protocol,
1490 })
1491 })
1492 .collect()
1493}
1494
1495#[allow(clippy::type_complexity)]
1499pub(crate) fn compute_elbv2_targets(
1500 ecs_state: &crate::state::EcsState,
1501 task: &crate::state::Task,
1502) -> Vec<(String, Vec<(String, Option<i64>)>)> {
1503 let mut result = Vec::new();
1504 let Some(group) = task.group.as_deref() else {
1505 return result;
1506 };
1507 let service_name = group.strip_prefix("service:").unwrap_or(group);
1508 let key = crate::state::EcsState::service_key(&task.cluster_name, service_name);
1509 let Some(service) = ecs_state.services.get(&key) else {
1510 return result;
1511 };
1512
1513 let network_mode = ecs_state
1514 .task_definitions
1515 .get(&task.family)
1516 .and_then(|revs| revs.get(&task.revision))
1517 .and_then(|td| td.network_mode.as_deref());
1518
1519 for lb in &service.load_balancers {
1520 let tg_arn = lb.get("targetGroupArn").and_then(|v| v.as_str());
1521 let container_name = lb.get("containerName").and_then(|v| v.as_str());
1522 let container_port = lb.get("containerPort").and_then(|v| v.as_i64());
1523 let Some(tg_arn) = tg_arn else { continue };
1524 let Some(container_name) = container_name else {
1525 continue;
1526 };
1527
1528 let target_id = if network_mode == Some("awsvpc") {
1529 task.attachments
1530 .iter()
1531 .find(|a| a.attachment_type == "eni")
1532 .and_then(|eni| {
1533 eni.details
1534 .iter()
1535 .find(|d| d.name == "privateIPv4Address")
1536 .map(|d| d.value.clone())
1537 })
1538 } else {
1539 Some("127.0.0.1".to_string())
1540 };
1541
1542 let port = if network_mode == Some("awsvpc") {
1543 container_port
1544 } else {
1545 task.containers
1546 .iter()
1547 .find(|c| c.name == container_name)
1548 .and_then(|c| {
1549 c.network_bindings
1550 .iter()
1551 .find(|nb| {
1552 nb.get("containerPort").and_then(|v| v.as_i64()) == container_port
1553 })
1554 .and_then(|nb| nb.get("hostPort").and_then(|v| v.as_i64()))
1555 })
1556 };
1557
1558 if let Some(id) = target_id {
1559 if let Some(entry) = result.iter_mut().find(|(arn, _)| arn == tg_arn) {
1560 entry.1.push((id, port));
1561 } else {
1562 result.push((tg_arn.to_string(), vec![(id, port)]));
1563 }
1564 }
1565 }
1566 result
1567}
1568
1569struct TaskSnapshot {
1570 task_arn: String,
1571 cluster_arn: String,
1572 launch_type: String,
1573 group: Option<String>,
1574 task_definition_arn: String,
1575 containers: serde_json::Value,
1576}
1577
1578fn snapshot_task(state: &SharedEcsState, account_id: &str, task_id: &str) -> Option<TaskSnapshot> {
1579 let accounts = state.read();
1580 let s = accounts.get(account_id)?;
1581 let task = s.tasks.get(task_id)?;
1582 Some(TaskSnapshot {
1583 task_arn: task.task_arn.clone(),
1584 cluster_arn: task.cluster_arn.clone(),
1585 launch_type: task.launch_type.clone(),
1586 group: task.group.clone(),
1587 task_definition_arn: task.task_definition_arn.clone(),
1588 containers: serde_json::Value::Array(
1589 task.containers
1590 .iter()
1591 .map(|c| {
1592 serde_json::json!({
1593 "containerArn": c.container_arn,
1594 "name": c.name,
1595 "image": c.image,
1596 "lastStatus": c.last_status,
1597 "exitCode": c.exit_code,
1598 "reason": c.reason,
1599 })
1600 })
1601 .collect(),
1602 ),
1603 })
1604}
1605
1606fn build_local_registry_docker_config(server_port: u16) -> Option<TempDir> {
1616 let dir = TempDir::new().ok()?;
1617 let auth = base64::engine::general_purpose::STANDARD.encode("AWS:fakecloud-ecs-runtime");
1618 let auths: serde_json::Map<String, serde_json::Value> =
1619 fakecloud_core::container_net::registry_auth_hosts(server_port)
1620 .into_iter()
1621 .map(|host| (host, serde_json::json!({ "auth": auth })))
1622 .collect();
1623 let config = serde_json::json!({ "auths": auths });
1624 std::fs::write(dir.path().join("config.json"), config.to_string()).ok()?;
1625 Some(dir)
1626}
1627
1628fn find_container_definition(
1629 state: &crate::state::EcsState,
1630 family: &str,
1631 revision: i32,
1632 name: &str,
1633) -> Option<serde_json::Value> {
1634 state
1635 .task_definitions
1636 .get(family)?
1637 .get(&revision)?
1638 .container_definitions
1639 .iter()
1640 .find(|c| c.get("name").and_then(|v| v.as_str()) == Some(name))
1641 .cloned()
1642}
1643
1644fn mark_pull_started(state: &SharedEcsState, account_id: &str, task_id: &str) {
1645 let mut accounts = state.write();
1646 let Some(s) = accounts.get_mut(account_id) else {
1647 return;
1648 };
1649 let task_arn_cluster = s
1650 .tasks
1651 .get(task_id)
1652 .map(|t| (t.task_arn.clone(), t.cluster_arn.clone()));
1653 if let Some(task) = s.tasks.get_mut(task_id) {
1654 task.pull_started_at = Some(Utc::now());
1655 }
1656 if let Some((arn, cluster_arn)) = task_arn_cluster {
1657 s.push_event(LifecycleEvent {
1658 at: Utc::now(),
1659 event_type: "PullStarted".into(),
1660 task_arn: Some(arn),
1661 cluster_arn: Some(cluster_arn),
1662 last_status: Some("PENDING".into()),
1663 detail: serde_json::json!({}),
1664 });
1665 }
1666}
1667
1668fn mark_pull_stopped(state: &SharedEcsState, account_id: &str, task_id: &str) {
1669 let mut accounts = state.write();
1670 let Some(s) = accounts.get_mut(account_id) else {
1671 return;
1672 };
1673 if let Some(task) = s.tasks.get_mut(task_id) {
1674 task.pull_stopped_at = Some(Utc::now());
1675 }
1676}
1677
1678pub(crate) fn mark_running_multi(
1679 state: &SharedEcsState,
1680 account_id: &str,
1681 task_id: &str,
1682 started: &[RunningContainer],
1683) {
1684 let mut accounts = state.write();
1685 let Some(s) = accounts.get_mut(account_id) else {
1686 return;
1687 };
1688 let (arn, cluster_arn) = {
1689 let Some(task) = s.tasks.get_mut(task_id) else {
1690 return;
1691 };
1692 task.last_status = "RUNNING".into();
1693 task.connectivity = "CONNECTED".into();
1694 task.connectivity_at = Some(Utc::now());
1695 task.started_at = Some(Utc::now());
1696 for rc in started {
1697 if let Some(c) = task.containers.iter_mut().find(|c| c.name == rc.name) {
1698 c.runtime_id = Some(rc.container_id.clone());
1699 c.last_status = "RUNNING".into();
1700 c.network_bindings = rc.network_bindings.clone();
1701 if rc.image_digest.is_some() {
1702 c.image_digest = rc.image_digest.clone();
1703 }
1704 }
1705 }
1706 if let Some(cluster) = s.clusters.get_mut(&task.cluster_name) {
1707 cluster.running_tasks_count += 1;
1708 if cluster.pending_tasks_count > 0 {
1709 cluster.pending_tasks_count -= 1;
1710 }
1711 }
1712 if let Some(ref ci_arn) = task.container_instance_arn {
1713 if let Some(ci) = s
1714 .container_instances
1715 .values_mut()
1716 .find(|ci| ci.container_instance_arn == *ci_arn)
1717 {
1718 ci.running_tasks_count += 1;
1719 if ci.pending_tasks_count > 0 {
1720 ci.pending_tasks_count -= 1;
1721 }
1722 }
1723 }
1724 (task.task_arn.clone(), task.cluster_arn.clone())
1725 };
1726 s.push_event(LifecycleEvent {
1727 at: Utc::now(),
1728 event_type: "TaskStateChange".into(),
1729 task_arn: Some(arn),
1730 cluster_arn: Some(cluster_arn),
1731 last_status: Some("RUNNING".into()),
1732 detail: serde_json::json!({}),
1733 });
1734}
1735
1736#[allow(clippy::too_many_arguments)]
1737fn finalize_stopped_multi(
1738 state: &SharedEcsState,
1739 account_id: &str,
1740 task_id: &str,
1741 final_containers: &[RunningContainer],
1742 primary_exit_code: i64,
1743 captured: &str,
1744 stop_code: &str,
1745 stopped_reason: Option<String>,
1746) {
1747 let mut accounts = state.write();
1748 let Some(s) = accounts.get_mut(account_id) else {
1749 return;
1750 };
1751 let (arn, cluster_arn) = {
1752 let Some(task) = s.tasks.get_mut(task_id) else {
1753 return;
1754 };
1755 task.last_status = "STOPPED".into();
1756 task.desired_status = "STOPPED".into();
1757 task.stopping_at = task.stopping_at.or(Some(Utc::now()));
1758 task.stopped_at = Some(Utc::now());
1759 task.stop_code = Some(stop_code.into());
1760 task.stopped_reason = stopped_reason.or(Some(format!("Exit code {}", primary_exit_code)));
1761 task.captured_logs = captured.to_string();
1762 for c in task.containers.iter_mut() {
1763 c.last_status = "STOPPED".into();
1764 if c.exit_code.is_none() {
1765 let mapped = final_containers
1766 .iter()
1767 .find(|r| r.name == c.name)
1768 .and_then(|r| r.exit_code);
1769 c.exit_code = mapped.or(Some(primary_exit_code));
1770 }
1771 }
1772 if let Some(cluster) = s.clusters.get_mut(&task.cluster_name) {
1773 if cluster.running_tasks_count > 0 {
1774 cluster.running_tasks_count -= 1;
1775 }
1776 }
1777 if let Some(ref ci_arn) = task.container_instance_arn {
1778 if let Some(ci) = s
1779 .container_instances
1780 .values_mut()
1781 .find(|ci| ci.container_instance_arn == *ci_arn)
1782 {
1783 if ci.running_tasks_count > 0 {
1784 ci.running_tasks_count -= 1;
1785 }
1786 }
1787 }
1788 (task.task_arn.clone(), task.cluster_arn.clone())
1789 };
1790 s.push_event(LifecycleEvent {
1791 at: Utc::now(),
1792 event_type: "TaskStateChange".into(),
1793 task_arn: Some(arn),
1794 cluster_arn: Some(cluster_arn),
1795 last_status: Some("STOPPED".into()),
1796 detail: serde_json::json!({
1797 "exitCode": primary_exit_code,
1798 "stopCode": stop_code,
1799 }),
1800 });
1801}
1802
1803fn finalize_failure(state: &SharedEcsState, account_id: &str, task_id: &str, reason: &str) {
1804 let mut accounts = state.write();
1805 let Some(s) = accounts.get_mut(account_id) else {
1806 return;
1807 };
1808 let (arn, cluster_arn) = {
1809 let Some(task) = s.tasks.get_mut(task_id) else {
1810 return;
1811 };
1812 let was_running = task.last_status == "RUNNING";
1818 task.last_status = "STOPPED".into();
1819 task.desired_status = "STOPPED".into();
1820 task.stopped_at = Some(Utc::now());
1821 task.stop_code = Some("TaskFailedToStart".into());
1822 task.stopped_reason = Some(reason.to_string());
1823 task.captured_logs = format!("[task failed to start]: {reason}");
1827 for c in task.containers.iter_mut() {
1828 c.last_status = "STOPPED".into();
1829 c.reason = Some(reason.to_string());
1830 }
1831 if let Some(cluster) = s.clusters.get_mut(&task.cluster_name) {
1832 if was_running {
1833 if cluster.running_tasks_count > 0 {
1834 cluster.running_tasks_count -= 1;
1835 }
1836 } else if cluster.pending_tasks_count > 0 {
1837 cluster.pending_tasks_count -= 1;
1838 }
1839 }
1840 if let Some(ref ci_arn) = task.container_instance_arn {
1841 if let Some(ci) = s
1842 .container_instances
1843 .values_mut()
1844 .find(|ci| ci.container_instance_arn == *ci_arn)
1845 {
1846 if was_running {
1847 if ci.running_tasks_count > 0 {
1848 ci.running_tasks_count -= 1;
1849 }
1850 } else if ci.pending_tasks_count > 0 {
1851 ci.pending_tasks_count -= 1;
1852 }
1853 }
1854 }
1855 (task.task_arn.clone(), task.cluster_arn.clone())
1856 };
1857 s.push_event(LifecycleEvent {
1858 at: Utc::now(),
1859 event_type: "TaskFailedToStart".into(),
1860 task_arn: Some(arn),
1861 cluster_arn: Some(cluster_arn),
1862 last_status: Some("STOPPED".into()),
1863 detail: serde_json::json!({ "reason": reason }),
1864 });
1865}
1866
1867pub async fn sleep(duration: Duration) {
1871 tokio::time::sleep(duration).await;
1872}
1873
1874#[cfg(test)]
1875mod tests {
1876 use super::*;
1877 use crate::state::{EcsState, Task};
1878 use fakecloud_aws::arn::Arn;
1879 use fakecloud_core::multi_account::MultiAccountState;
1880 use parking_lot::RwLock;
1881 use std::sync::Arc;
1882
1883 #[test]
1884 fn cli_available_for_known_missing_binary_is_false() {
1885 assert!(!fakecloud_core::container_net::cli_available(
1886 "definitely-not-a-real-cli-binary-xyz"
1887 ));
1888 }
1889
1890 #[test]
1891 fn aws_ecr_uris_translate_for_local_pull() {
1892 assert_eq!(
1893 fakecloud_core::ecr_uri::translate_to_local(
1894 "123456789012.dkr.ecr.us-east-1.amazonaws.com/app:latest",
1895 4566
1896 )
1897 .as_deref(),
1898 Some("127.0.0.1:4566/app:latest")
1899 );
1900 }
1901
1902 fn make_task(task_id: &str) -> Task {
1903 Task {
1904 task_arn: Arn::new(
1905 "ecs",
1906 "us-east-1",
1907 "000000000000",
1908 &format!("task/default/{task_id}"),
1909 )
1910 .to_string(),
1911 task_id: task_id.into(),
1912 cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
1913 cluster_name: "default".into(),
1914 task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
1915 family: "app".into(),
1916 revision: 1,
1917 container_instance_arn: None,
1918 capacity_provider_name: None,
1919 last_status: "PENDING".into(),
1920 desired_status: "RUNNING".into(),
1921 launch_type: "FARGATE".into(),
1922 platform_version: None,
1923 cpu: None,
1924 memory: None,
1925 containers: Vec::new(),
1926 overrides: serde_json::json!({}),
1927 started_by: None,
1928 group: None,
1929 connectivity: "CONNECTING".into(),
1930 stop_code: None,
1931 stopped_reason: None,
1932 created_at: Utc::now(),
1933 started_at: None,
1934 stopping_at: None,
1935 stopped_at: None,
1936 pull_started_at: None,
1937 pull_stopped_at: None,
1938 connectivity_at: None,
1939 started_by_ref_id: None,
1940 execution_role_arn: None,
1941 task_role_arn: None,
1942 tags: Vec::new(),
1943 awslogs: None,
1944 captured_logs: String::new(),
1945 protection: None,
1946 enable_execute_command: false,
1947 attachments: Vec::new(),
1948 volume_configurations: Vec::new(),
1949 task_set_arn: None,
1950 }
1951 }
1952
1953 #[test]
1954 fn finalize_failure_writes_reason_into_captured_logs() {
1955 let mut accounts: MultiAccountState<EcsState> =
1956 MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
1957 let acct = accounts.get_or_create("000000000000");
1958 acct.tasks.insert("t1".into(), make_task("t1"));
1959 let state: SharedEcsState = Arc::new(RwLock::new(accounts));
1960
1961 finalize_failure(
1962 &state,
1963 "000000000000",
1964 "t1",
1965 "failed to resolve secret DB_PASSWORD",
1966 );
1967
1968 let accounts = state.read();
1969 let task = accounts
1970 .get("000000000000")
1971 .unwrap()
1972 .tasks
1973 .get("t1")
1974 .unwrap();
1975 assert_eq!(task.last_status, "STOPPED");
1976 assert_eq!(task.stop_code.as_deref(), Some("TaskFailedToStart"));
1977 assert!(
1978 task.captured_logs
1979 .contains("failed to resolve secret DB_PASSWORD"),
1980 "captured_logs missing reason: {:?}",
1981 task.captured_logs
1982 );
1983 assert!(
1984 task.captured_logs.starts_with("[task failed to start]:"),
1985 "captured_logs missing prefix: {:?}",
1986 task.captured_logs
1987 );
1988 }
1989
1990 struct RecordingStore(Arc<std::sync::Mutex<Option<Vec<u8>>>>);
1993 impl fakecloud_persistence::SnapshotStore for RecordingStore {
1994 fn load(&self) -> std::io::Result<Option<Vec<u8>>> {
1995 Ok(None)
1996 }
1997 fn save(&self, bytes: &[u8]) -> std::io::Result<()> {
1998 *self.0.lock().unwrap() = Some(bytes.to_vec());
1999 Ok(())
2000 }
2001 }
2002
2003 fn bare_runtime() -> EcsRuntime {
2004 EcsRuntime {
2005 cli: String::new(),
2006 net: fakecloud_core::container_net::HostNetworking {
2007 host_alias: String::new(),
2008 add_host_arg: None,
2009 sibling_host: String::new(),
2010 },
2011 server_port: 0,
2012 docker_config: None,
2013 containers: RwLock::new(std::collections::HashMap::new()),
2014 delivery_bus: None,
2015 logs_state: None,
2016 secretsmanager_state: None,
2017 ssm_state: None,
2018 k8s: None,
2019 snapshot_hook: std::sync::OnceLock::new(),
2020 }
2021 }
2022
2023 #[tokio::test]
2028 async fn running_transition_persists_snapshot() {
2029 let mut accounts: MultiAccountState<EcsState> =
2030 MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
2031 let acct = accounts.get_or_create("000000000000");
2032 acct.tasks.insert("t1".into(), make_task("t1"));
2033 let state: SharedEcsState = Arc::new(RwLock::new(accounts));
2034
2035 let recorded = Arc::new(std::sync::Mutex::new(None));
2037 let store: Arc<dyn fakecloud_persistence::SnapshotStore> =
2038 Arc::new(RecordingStore(recorded.clone()));
2039 let hook = crate::service::EcsService::new(state.clone())
2040 .with_snapshot_store(store)
2041 .snapshot_hook()
2042 .expect("hook present when a store is wired");
2043
2044 let rt = bare_runtime();
2045 rt.set_snapshot_hook(hook);
2046
2047 assert!(recorded.lock().unwrap().is_none());
2049
2050 mark_running_multi(&state, "000000000000", "t1", &[]);
2053 rt.persist_snapshot().await;
2054
2055 let bytes = recorded
2056 .lock()
2057 .unwrap()
2058 .clone()
2059 .expect("running transition should have flushed a snapshot");
2060 let snap: crate::EcsSnapshot = serde_json::from_slice(&bytes).unwrap();
2061 let persisted = snap.accounts.expect("accounts present");
2062 let task = persisted
2063 .get("000000000000")
2064 .unwrap()
2065 .tasks
2066 .get("t1")
2067 .unwrap();
2068 assert_eq!(
2069 task.last_status, "RUNNING",
2070 "persisted snapshot must reflect the RUNNING transition"
2071 );
2072 }
2073
2074 #[test]
2079 fn task_desired_stopped_detects_stop_during_launch() {
2080 let mut accounts: MultiAccountState<EcsState> =
2081 MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
2082 let acct = accounts.get_or_create("000000000000");
2083 acct.tasks.insert("running".into(), make_task("running"));
2084 let mut stopping = make_task("stopping");
2085 stopping.desired_status = "STOPPED".into();
2086 acct.tasks.insert("stopping".into(), stopping);
2087 let state: SharedEcsState = Arc::new(RwLock::new(accounts));
2088
2089 assert!(
2090 !task_desired_stopped(&state, "000000000000", "running"),
2091 "a RUNNING task must not be treated as stopped",
2092 );
2093 assert!(
2094 task_desired_stopped(&state, "000000000000", "stopping"),
2095 "a task whose desired_status is STOPPED must be treated as stopped",
2096 );
2097 assert!(
2098 task_desired_stopped(&state, "000000000000", "deleted-mid-launch"),
2099 "a task removed from state mid-launch must be treated as stopped",
2100 );
2101 }
2102
2103 fn make_container(name: &str, essential: bool) -> crate::state::Container {
2104 crate::state::Container {
2105 container_arn: format!(
2106 "arn:aws:ecs:us-east-1:000000000000:container/default/abc/{name}"
2107 ),
2108 name: name.into(),
2109 image: "alpine".into(),
2110 task_arn: "arn:aws:ecs:us-east-1:000000000000:task/default/abc".into(),
2111 last_status: "RUNNING".into(),
2112 exit_code: None,
2113 reason: None,
2114 runtime_id: Some(format!("dockerid-{name}")),
2115 essential,
2116 cpu: None,
2117 memory: None,
2118 memory_reservation: None,
2119 network_bindings: Vec::new(),
2120 network_interfaces: Vec::new(),
2121 health_status: None,
2122 managed_agents: None,
2123 image_digest: None,
2124 }
2125 }
2126
2127 #[test]
2128 fn task_should_stop_when_essential_exits() {
2129 let containers = vec![
2130 RunningContainer {
2131 name: "app".into(),
2132 container_id: "id-app".into(),
2133 essential: true,
2134 exit_code: Some(0),
2135 network_bindings: Vec::new(),
2136 image_digest: None,
2137 },
2138 RunningContainer {
2139 name: "sidecar".into(),
2140 container_id: "id-sc".into(),
2141 essential: false,
2142 exit_code: None,
2143 network_bindings: Vec::new(),
2144 image_digest: None,
2145 },
2146 ];
2147 assert!(task_should_stop(&containers));
2148 }
2149
2150 #[test]
2151 fn task_keeps_running_when_only_non_essential_exits() {
2152 let containers = vec![
2153 RunningContainer {
2154 name: "app".into(),
2155 container_id: "id-app".into(),
2156 essential: true,
2157 exit_code: None,
2158 network_bindings: Vec::new(),
2159 image_digest: None,
2160 },
2161 RunningContainer {
2162 name: "sidecar".into(),
2163 container_id: "id-sc".into(),
2164 essential: false,
2165 exit_code: Some(0),
2166 network_bindings: Vec::new(),
2167 image_digest: None,
2168 },
2169 ];
2170 assert!(!task_should_stop(&containers));
2171 }
2172
2173 #[test]
2174 fn task_stops_when_all_non_essentials_exit() {
2175 let containers = vec![
2176 RunningContainer {
2177 name: "a".into(),
2178 container_id: "id-a".into(),
2179 essential: false,
2180 exit_code: Some(0),
2181 network_bindings: Vec::new(),
2182 image_digest: None,
2183 },
2184 RunningContainer {
2185 name: "b".into(),
2186 container_id: "id-b".into(),
2187 essential: false,
2188 exit_code: Some(1),
2189 network_bindings: Vec::new(),
2190 image_digest: None,
2191 },
2192 ];
2193 assert!(task_should_stop(&containers));
2194 }
2195
2196 #[test]
2197 fn finalize_stopped_multi_assigns_per_container_exit_codes() {
2198 let mut accounts: MultiAccountState<EcsState> =
2199 MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
2200 let acct = accounts.get_or_create("000000000000");
2201 let mut t = make_task("t1");
2202 t.containers = vec![
2203 make_container("app", true),
2204 make_container("sidecar", false),
2205 ];
2206 acct.tasks.insert("t1".into(), t);
2207 let state: SharedEcsState = Arc::new(RwLock::new(accounts));
2208
2209 let final_containers = vec![
2210 RunningContainer {
2211 name: "app".into(),
2212 container_id: "id-app".into(),
2213 essential: true,
2214 exit_code: Some(0),
2215 network_bindings: Vec::new(),
2216 image_digest: None,
2217 },
2218 RunningContainer {
2219 name: "sidecar".into(),
2220 container_id: "id-sc".into(),
2221 essential: false,
2222 exit_code: Some(137),
2223 network_bindings: Vec::new(),
2224 image_digest: None,
2225 },
2226 ];
2227 finalize_stopped_multi(
2228 &state,
2229 "000000000000",
2230 "t1",
2231 &final_containers,
2232 0,
2233 "captured",
2234 "EssentialContainerExited",
2235 None,
2236 );
2237
2238 let accounts = state.read();
2239 let task = accounts
2240 .get("000000000000")
2241 .unwrap()
2242 .tasks
2243 .get("t1")
2244 .unwrap();
2245 assert_eq!(task.last_status, "STOPPED");
2246 assert_eq!(task.stop_code.as_deref(), Some("EssentialContainerExited"));
2247 let app = task.containers.iter().find(|c| c.name == "app").unwrap();
2248 let sc = task
2249 .containers
2250 .iter()
2251 .find(|c| c.name == "sidecar")
2252 .unwrap();
2253 assert_eq!(app.exit_code, Some(0));
2254 assert_eq!(sc.exit_code, Some(137));
2255 assert_eq!(app.last_status, "STOPPED");
2256 assert_eq!(sc.last_status, "STOPPED");
2257 }
2258
2259 fn plan(name: &str, deps: &[&str]) -> ContainerPlan {
2260 ContainerPlan {
2261 container_name: name.into(),
2262 image: "alpine".into(),
2263 env: Vec::new(),
2264 entry_point: Vec::new(),
2265 command: Vec::new(),
2266 secrets_refs: Vec::new(),
2267 essential: true,
2268 has_task_role: false,
2269 port_mappings: Vec::new(),
2270 network_mode: None,
2271 depends_on: deps
2272 .iter()
2273 .map(|s| DependsOn {
2274 container_name: (*s).to_string(),
2275 condition: DependsOnCondition::Start,
2276 })
2277 .collect(),
2278 health_check: None,
2279 volume_mounts: Vec::new(),
2280 ulimits: Vec::new(),
2281 linux_parameters: None,
2282 stop_timeout: None,
2283 user: None,
2284 working_directory: None,
2285 tty: false,
2286 interactive: false,
2287 readonly_rootfs: false,
2288 }
2289 }
2290
2291 #[test]
2292 fn topo_sort_orders_by_depends_on() {
2293 let plans = vec![plan("sidecar", &["app"]), plan("app", &[])];
2296 let ordered = topo_sort_plans(plans);
2297 assert_eq!(ordered[0].container_name, "app");
2298 assert_eq!(ordered[1].container_name, "sidecar");
2299 }
2300
2301 #[test]
2302 fn topo_sort_preserves_declaration_order_when_no_deps() {
2303 let plans = vec![plan("first", &[]), plan("second", &[]), plan("third", &[])];
2304 let ordered = topo_sort_plans(plans);
2305 let names: Vec<&str> = ordered.iter().map(|p| p.container_name.as_str()).collect();
2306 assert_eq!(names, vec!["first", "second", "third"]);
2307 }
2308
2309 #[test]
2310 fn topo_sort_handles_chain() {
2311 let plans = vec![plan("c", &["b"]), plan("b", &["a"]), plan("a", &[])];
2314 let ordered = topo_sort_plans(plans);
2315 let names: Vec<&str> = ordered.iter().map(|p| p.container_name.as_str()).collect();
2316 assert_eq!(names, vec!["a", "b", "c"]);
2317 }
2318
2319 #[test]
2320 fn topo_sort_ignores_unknown_dependency() {
2321 let plans = vec![plan("only", &["does-not-exist"])];
2325 let ordered = topo_sort_plans(plans);
2326 assert_eq!(ordered.len(), 1);
2327 assert_eq!(ordered[0].container_name, "only");
2328 }
2329
2330 #[test]
2331 fn topo_sort_recovers_from_cycle() {
2332 let plans = vec![plan("a", &["b"]), plan("b", &["a"])];
2335 let ordered = topo_sort_plans(plans);
2336 assert_eq!(ordered.len(), 2);
2337 }
2338
2339 #[test]
2340 fn parse_health_check_fills_aws_defaults() {
2341 let v = serde_json::json!({
2342 "command": ["CMD-SHELL", "curl -f http://localhost/ || exit 1"],
2343 });
2344 let hc = __test_parse_health_check(&v).expect("parsed");
2345 assert_eq!(hc.command[0], "CMD-SHELL");
2346 assert_eq!(hc.interval_seconds, 30);
2347 assert_eq!(hc.timeout_seconds, 5);
2348 assert_eq!(hc.retries, 3);
2349 assert_eq!(hc.start_period_seconds, 0);
2350 }
2351
2352 #[test]
2353 fn parse_health_check_overrides_explicit_values() {
2354 let v = serde_json::json!({
2355 "command": ["CMD", "/probe"],
2356 "interval": 7,
2357 "timeout": 2,
2358 "retries": 9,
2359 "startPeriod": 12,
2360 });
2361 let hc = __test_parse_health_check(&v).expect("parsed");
2362 assert_eq!(hc.interval_seconds, 7);
2363 assert_eq!(hc.timeout_seconds, 2);
2364 assert_eq!(hc.retries, 9);
2365 assert_eq!(hc.start_period_seconds, 12);
2366 }
2367
2368 #[test]
2369 fn parse_health_check_returns_none_for_none_sentinel() {
2370 let v = serde_json::json!({ "command": ["NONE"] });
2373 assert!(__test_parse_health_check(&v).is_none());
2374 }
2375
2376 #[test]
2377 fn parse_health_check_returns_none_for_missing_command() {
2378 let v = serde_json::json!({ "interval": 30 });
2379 assert!(__test_parse_health_check(&v).is_none());
2380 }
2381
2382 #[test]
2383 fn render_health_flags_emits_full_set_for_cmd_shell() {
2384 let hc = HealthCheckSpec {
2385 command: vec!["CMD-SHELL".into(), "curl -f http://localhost/".into()],
2386 interval_seconds: 15,
2387 timeout_seconds: 3,
2388 retries: 4,
2389 start_period_seconds: 10,
2390 };
2391 let flags = render_health_flags(&hc);
2392 assert_eq!(flags[0], "--health-cmd");
2393 assert_eq!(flags[1], "curl -f http://localhost/");
2394 assert!(flags.contains(&"--health-interval=15s".to_string()));
2395 assert!(flags.contains(&"--health-timeout=3s".to_string()));
2396 assert!(flags.contains(&"--health-retries=4".to_string()));
2397 assert!(flags.contains(&"--health-start-period=10s".to_string()));
2398 }
2399
2400 #[test]
2401 fn render_health_flags_joins_cmd_argv_with_spaces() {
2402 let hc = HealthCheckSpec {
2405 command: vec![
2406 "CMD".into(),
2407 "/bin/probe".into(),
2408 "--port".into(),
2409 "8080".into(),
2410 ],
2411 interval_seconds: 30,
2412 timeout_seconds: 5,
2413 retries: 3,
2414 start_period_seconds: 0,
2415 };
2416 let flags = render_health_flags(&hc);
2417 assert_eq!(flags[1], "/bin/probe --port 8080");
2418 }
2419
2420 #[test]
2421 fn build_run_argv_emits_health_flags_when_present() {
2422 let plan = ContainerPlan {
2423 container_name: "app".into(),
2424 image: "alpine".into(),
2425 env: Vec::new(),
2426 entry_point: Vec::new(),
2427 command: Vec::new(),
2428 secrets_refs: Vec::new(),
2429 essential: true,
2430 has_task_role: false,
2431 port_mappings: Vec::new(),
2432 network_mode: None,
2433 depends_on: Vec::new(),
2434 health_check: Some(HealthCheckSpec {
2435 command: vec!["CMD-SHELL".into(), "true".into()],
2436 interval_seconds: 5,
2437 timeout_seconds: 2,
2438 retries: 1,
2439 start_period_seconds: 1,
2440 }),
2441 volume_mounts: Vec::new(),
2442 ulimits: Vec::new(),
2443 linux_parameters: None,
2444 stop_timeout: None,
2445 user: None,
2446 working_directory: None,
2447 tty: false,
2448 interactive: false,
2449 readonly_rootfs: false,
2450 };
2451 let argv = build_run_argv(
2452 &plan,
2453 &[],
2454 "task-1",
2455 "host.docker.internal",
2456 None,
2457 "alpine",
2458 true,
2459 );
2460 let joined = argv.join(" ");
2461 assert!(joined.contains("--health-cmd true"), "argv: {joined}");
2462 assert!(joined.contains("--health-interval=5s"), "argv: {joined}");
2463 assert!(joined.contains("--health-timeout=2s"), "argv: {joined}");
2464 assert!(joined.contains("--health-retries=1"), "argv: {joined}");
2465 assert!(
2466 joined.contains("--health-start-period=1s"),
2467 "argv: {joined}"
2468 );
2469 }
2470
2471 #[test]
2472 fn build_run_argv_emits_no_health_flags_when_absent() {
2473 let plan = ContainerPlan {
2474 container_name: "app".into(),
2475 image: "alpine".into(),
2476 env: Vec::new(),
2477 entry_point: Vec::new(),
2478 command: Vec::new(),
2479 secrets_refs: Vec::new(),
2480 essential: true,
2481 has_task_role: false,
2482 port_mappings: Vec::new(),
2483 network_mode: None,
2484 depends_on: Vec::new(),
2485 health_check: None,
2486 volume_mounts: Vec::new(),
2487 ulimits: Vec::new(),
2488 linux_parameters: None,
2489 stop_timeout: None,
2490 user: None,
2491 working_directory: None,
2492 tty: false,
2493 interactive: false,
2494 readonly_rootfs: false,
2495 };
2496 let argv = build_run_argv(
2497 &plan,
2498 &[],
2499 "task-1",
2500 "host.docker.internal",
2501 None,
2502 "alpine",
2503 true,
2504 );
2505 assert!(!argv.iter().any(|s| s.starts_with("--health")));
2506 }
2507
2508 #[test]
2509 fn docker_health_to_ecs_maps_known_states() {
2510 assert_eq!(docker_health_to_ecs("healthy"), "HEALTHY");
2511 assert_eq!(docker_health_to_ecs("HEALTHY"), "HEALTHY");
2512 assert_eq!(docker_health_to_ecs("unhealthy"), "UNHEALTHY");
2513 assert_eq!(docker_health_to_ecs("starting"), "UNKNOWN");
2514 assert_eq!(docker_health_to_ecs("none"), "UNKNOWN");
2515 assert_eq!(docker_health_to_ecs(""), "UNKNOWN");
2516 }
2517
2518 #[test]
2521 fn resolve_host_bind_volume_uses_source_path() {
2522 let mut volumes = std::collections::HashMap::new();
2523 let v = serde_json::json!({
2524 "name": "data",
2525 "host": { "sourcePath": "/var/lib/myapp" }
2526 });
2527 volumes.insert("data".to_string(), &v);
2528 let mp = serde_json::json!({
2529 "sourceVolume": "data",
2530 "containerPath": "/app/data",
2531 "readOnly": false
2532 });
2533 let resolved = resolve_mount_point(&mp, &volumes).expect("resolved");
2534 assert_eq!(resolved.source, "/var/lib/myapp");
2535 assert_eq!(resolved.container_path, "/app/data");
2536 assert!(!resolved.read_only);
2537 }
2538
2539 #[test]
2542 fn read_only_mount_renders_ro_suffix() {
2543 let plan = ContainerPlan {
2544 container_name: "app".into(),
2545 image: "alpine".into(),
2546 env: Vec::new(),
2547 entry_point: Vec::new(),
2548 command: Vec::new(),
2549 secrets_refs: Vec::new(),
2550 essential: true,
2551 has_task_role: false,
2552 port_mappings: Vec::new(),
2553 network_mode: None,
2554 depends_on: Vec::new(),
2555 health_check: None,
2556 volume_mounts: vec![VolumeMount {
2557 source: "/host/path".into(),
2558 container_path: "/in/container".into(),
2559 read_only: true,
2560 cleanup_on_stop: false,
2561 }],
2562 ulimits: Vec::new(),
2563 linux_parameters: None,
2564 stop_timeout: None,
2565 user: None,
2566 working_directory: None,
2567 tty: false,
2568 interactive: false,
2569 readonly_rootfs: false,
2570 };
2571 let argv = build_run_argv(
2572 &plan,
2573 &[],
2574 "task-1",
2575 "host.docker.internal",
2576 None,
2577 "alpine",
2578 true,
2579 );
2580 let pair = argv
2581 .windows(2)
2582 .find(|w| w[0] == "-v")
2583 .expect("expected -v flag");
2584 assert_eq!(pair[1], "/host/path:/in/container:ro");
2585 }
2586
2587 #[test]
2592 fn resolve_efs_volume_uses_stub_dir() {
2593 let mut volumes = std::collections::HashMap::new();
2594 let v = serde_json::json!({
2595 "name": "efs-vol",
2596 "efsVolumeConfiguration": {
2597 "fileSystemId": "fs-12345678",
2598 "rootDirectory": "/exports/app"
2599 }
2600 });
2601 volumes.insert("efs-vol".to_string(), &v);
2602 let mp = serde_json::json!({
2603 "sourceVolume": "efs-vol",
2604 "containerPath": "/mnt/efs"
2605 });
2606 let resolved = resolve_mount_point(&mp, &volumes).expect("resolved");
2607 assert_eq!(resolved.source, "fakecloud-efs-fs-12345678-exports-app");
2610 assert_eq!(resolved.container_path, "/mnt/efs");
2611 }
2612
2613 #[test]
2617 fn efs_without_root_directory_uses_filesystem_root() {
2618 assert_eq!(
2621 shared_volume_name("efs", "fs-abc", "/"),
2622 "fakecloud-efs-fs-abc"
2623 );
2624 assert_eq!(
2625 shared_volume_name("efs", "fs-abc", ""),
2626 "fakecloud-efs-fs-abc"
2627 );
2628 }
2629
2630 #[test]
2634 fn resolve_docker_named_volume_uses_volume_name() {
2635 let mut volumes = std::collections::HashMap::new();
2636 let v = serde_json::json!({
2637 "name": "named-vol",
2638 "dockerVolumeConfiguration": {
2639 "scope": "task",
2640 "driver": "local"
2641 }
2642 });
2643 volumes.insert("named-vol".to_string(), &v);
2644 let mp = serde_json::json!({
2645 "sourceVolume": "named-vol",
2646 "containerPath": "/data"
2647 });
2648 let resolved = resolve_mount_point(&mp, &volumes).expect("resolved");
2649 assert_eq!(resolved.source, "named-vol");
2650 assert_eq!(resolved.container_path, "/data");
2651 }
2652
2653 #[test]
2656 fn cleanup_on_stop_matches_aws_volume_scope() {
2657 let cases = [
2658 (
2660 serde_json::json!({ "name": "v", "host": { "sourcePath": "/data" } }),
2661 false,
2662 ),
2663 (
2664 serde_json::json!({ "name": "v", "efsVolumeConfiguration": { "fileSystemId": "fs-a" } }),
2665 false,
2666 ),
2667 (
2668 serde_json::json!({ "name": "v", "fsxWindowsFileServerVolumeConfiguration": { "fileSystemId": "fs-b" } }),
2669 false,
2670 ),
2671 (
2672 serde_json::json!({ "name": "v", "dockerVolumeConfiguration": { "scope": "shared" } }),
2673 false,
2674 ),
2675 (
2676 serde_json::json!({ "name": "v", "dockerVolumeConfiguration": { "scope": "task" } }),
2677 true,
2678 ),
2679 (
2681 serde_json::json!({ "name": "v", "dockerVolumeConfiguration": {} }),
2682 true,
2683 ),
2684 (serde_json::json!({ "name": "v" }), true),
2686 (
2687 serde_json::json!({ "name": "v", "host": { "sourcePath": "" } }),
2688 true,
2689 ),
2690 ];
2691 for (vol, expected) in cases {
2692 assert_eq!(
2693 volume_is_task_scoped(&vol),
2694 expected,
2695 "unexpected cleanup_on_stop for {vol}"
2696 );
2697 }
2698 }
2699
2700 #[test]
2703 fn resolve_fsx_volume_uses_stub_dir() {
2704 let mut volumes = std::collections::HashMap::new();
2705 let v = serde_json::json!({
2706 "name": "fsx-vol",
2707 "fsxWindowsFileServerVolumeConfiguration": {
2708 "fileSystemId": "fs-xyz",
2709 "rootDirectory": "share"
2710 }
2711 });
2712 volumes.insert("fsx-vol".to_string(), &v);
2713 let mp = serde_json::json!({
2714 "sourceVolume": "fsx-vol",
2715 "containerPath": "C:\\data"
2716 });
2717 let resolved = resolve_mount_point(&mp, &volumes).expect("resolved");
2718 assert_eq!(resolved.source, "fakecloud-fsx-fs-xyz-share");
2720 }
2721
2722 #[test]
2726 fn unknown_source_volume_returns_none() {
2727 let volumes = std::collections::HashMap::new();
2728 let mp = serde_json::json!({
2729 "sourceVolume": "missing",
2730 "containerPath": "/x"
2731 });
2732 assert!(resolve_mount_point(&mp, &volumes).is_none());
2733 }
2734
2735 #[test]
2739 fn find_depends_on_cycle_detects_two_node_cycle() {
2740 let cds = vec![
2741 serde_json::json!({
2742 "name": "a",
2743 "image": "alpine",
2744 "dependsOn": [{"containerName": "b", "condition": "START"}],
2745 }),
2746 serde_json::json!({
2747 "name": "b",
2748 "image": "alpine",
2749 "dependsOn": [{"containerName": "a", "condition": "START"}],
2750 }),
2751 ];
2752 let cycle = find_depends_on_cycle(&cds);
2753 assert!(cycle.is_some(), "expected cycle to be detected");
2754 }
2755
2756 #[test]
2760 fn find_depends_on_cycle_accepts_chain() {
2761 let cds = vec![
2762 serde_json::json!({
2763 "name": "a",
2764 "image": "alpine",
2765 "dependsOn": [{"containerName": "b", "condition": "START"}],
2766 }),
2767 serde_json::json!({
2768 "name": "b",
2769 "image": "alpine",
2770 "dependsOn": [{"containerName": "c", "condition": "START"}],
2771 }),
2772 serde_json::json!({
2773 "name": "c",
2774 "image": "alpine",
2775 }),
2776 ];
2777 assert!(find_depends_on_cycle(&cds).is_none());
2778 }
2779
2780 #[test]
2784 fn find_depends_on_cycle_ignores_unknown_target() {
2785 let cds = vec![serde_json::json!({
2786 "name": "only",
2787 "image": "alpine",
2788 "dependsOn": [{"containerName": "ghost", "condition": "START"}],
2789 })];
2790 assert!(find_depends_on_cycle(&cds).is_none());
2791 }
2792
2793 #[test]
2797 fn condition_is_met_matches_aws_semantics() {
2798 let running = InspectedState {
2799 started: true,
2800 exited: false,
2801 exit_code: 0,
2802 health: None,
2803 };
2804 let exited_ok = InspectedState {
2805 started: true,
2806 exited: true,
2807 exit_code: 0,
2808 health: None,
2809 };
2810 let exited_fail = InspectedState {
2811 started: true,
2812 exited: true,
2813 exit_code: 1,
2814 health: None,
2815 };
2816 let healthy = InspectedState {
2817 started: true,
2818 exited: false,
2819 exit_code: 0,
2820 health: Some("healthy".into()),
2821 };
2822
2823 assert!(condition_is_met(DependsOnCondition::Start, &running));
2826 assert!(condition_is_met(DependsOnCondition::Start, &exited_ok));
2827
2828 assert!(!condition_is_met(DependsOnCondition::Complete, &running));
2830 assert!(condition_is_met(DependsOnCondition::Complete, &exited_ok));
2831 assert!(condition_is_met(DependsOnCondition::Complete, &exited_fail));
2832
2833 assert!(!condition_is_met(DependsOnCondition::Success, &running));
2835 assert!(condition_is_met(DependsOnCondition::Success, &exited_ok));
2836 assert!(!condition_is_met(DependsOnCondition::Success, &exited_fail));
2837
2838 assert!(!condition_is_met(DependsOnCondition::Healthy, &running));
2840 assert!(condition_is_met(DependsOnCondition::Healthy, &healthy));
2841 }
2842
2843 #[test]
2847 fn depends_on_condition_parse_round_trips() {
2848 assert_eq!(
2849 DependsOnCondition::parse("START"),
2850 Some(DependsOnCondition::Start)
2851 );
2852 assert_eq!(
2853 DependsOnCondition::parse("COMPLETE"),
2854 Some(DependsOnCondition::Complete)
2855 );
2856 assert_eq!(
2857 DependsOnCondition::parse("SUCCESS"),
2858 Some(DependsOnCondition::Success)
2859 );
2860 assert_eq!(
2861 DependsOnCondition::parse("HEALTHY"),
2862 Some(DependsOnCondition::Healthy)
2863 );
2864 assert_eq!(DependsOnCondition::parse("start"), None);
2865 assert_eq!(DependsOnCondition::parse("ANY"), None);
2866 }
2867
2868 #[test]
2871 fn build_run_argv_emits_ulimits() {
2872 let plan = ContainerPlan {
2873 container_name: "app".into(),
2874 image: "alpine".into(),
2875 env: Vec::new(),
2876 entry_point: Vec::new(),
2877 command: Vec::new(),
2878 secrets_refs: Vec::new(),
2879 essential: true,
2880 has_task_role: false,
2881 port_mappings: Vec::new(),
2882 network_mode: None,
2883 depends_on: Vec::new(),
2884 health_check: None,
2885 volume_mounts: Vec::new(),
2886 ulimits: vec![Ulimit {
2887 name: "nofile".into(),
2888 soft_limit: 1024,
2889 hard_limit: 2048,
2890 }],
2891 linux_parameters: None,
2892 stop_timeout: None,
2893 user: None,
2894 working_directory: None,
2895 tty: false,
2896 interactive: false,
2897 readonly_rootfs: false,
2898 };
2899 let argv = build_run_argv(&plan, &[], "t", "host.docker.internal", None, "img", true);
2900 assert!(argv.contains(&"--ulimit".to_string()));
2901 assert!(argv.contains(&"nofile=1024:2048".to_string()));
2902 }
2903
2904 #[test]
2905 fn build_run_argv_emits_linux_parameters() {
2906 let plan = ContainerPlan {
2907 container_name: "app".into(),
2908 image: "alpine".into(),
2909 env: Vec::new(),
2910 entry_point: Vec::new(),
2911 command: Vec::new(),
2912 secrets_refs: Vec::new(),
2913 essential: true,
2914 has_task_role: false,
2915 port_mappings: Vec::new(),
2916 network_mode: None,
2917 depends_on: Vec::new(),
2918 health_check: None,
2919 volume_mounts: Vec::new(),
2920 ulimits: Vec::new(),
2921 linux_parameters: Some(LinuxParameters {
2922 capabilities_add: vec!["NET_ADMIN".into()],
2923 capabilities_drop: vec!["ALL".into()],
2924 devices: vec![Device {
2925 host_path: "/dev/zero".into(),
2926 container_path: "/dev/zero".into(),
2927 permissions: "rwm".into(),
2928 }],
2929 init_process_enabled: true,
2930 shared_memory_size: Some(256),
2931 sysctls: vec![Sysctl {
2932 name: "net.ipv4.ip_forward".into(),
2933 value: "1".into(),
2934 }],
2935 tmpfs: vec![Tmpfs {
2936 container_path: "/tmp".into(),
2937 size: 128,
2938 mount_options: vec!["noexec".into()],
2939 }],
2940 privileged: true,
2941 }),
2942 stop_timeout: Some(30),
2943 user: Some("1000:1000".into()),
2944 working_directory: Some("/app".into()),
2945 tty: true,
2946 interactive: true,
2947 readonly_rootfs: true,
2948 };
2949 let argv = build_run_argv(&plan, &[], "t", "host.docker.internal", None, "img", true);
2950 assert!(argv.contains(&"--cap-add".to_string()));
2951 assert!(argv.contains(&"NET_ADMIN".to_string()));
2952 assert!(argv.contains(&"--cap-drop".to_string()));
2953 assert!(argv.contains(&"ALL".to_string()));
2954 assert!(argv.contains(&"--device".to_string()));
2955 assert!(argv.contains(&"/dev/zero:/dev/zerorwm".to_string()));
2956 assert!(argv.contains(&"--init".to_string()));
2957 assert!(argv.contains(&"--shm-size".to_string()));
2958 assert!(argv.contains(&"256m".to_string()));
2959 assert!(argv.contains(&"--sysctl".to_string()));
2960 assert!(argv.contains(&"net.ipv4.ip_forward=1".to_string()));
2961 assert!(argv.contains(&"--tmpfs".to_string()));
2962 assert!(argv.contains(&"--privileged".to_string()));
2963 assert!(argv.contains(&"--stop-timeout".to_string()));
2964 assert!(argv.contains(&"30".to_string()));
2965 assert!(argv.contains(&"--user".to_string()));
2966 assert!(argv.contains(&"1000:1000".to_string()));
2967 assert!(argv.contains(&"--workdir".to_string()));
2968 assert!(argv.contains(&"/app".to_string()));
2969 assert!(argv.contains(&"--tty".to_string()));
2970 assert!(argv.contains(&"--interactive".to_string()));
2971 assert!(argv.contains(&"--read-only".to_string()));
2972 }
2973
2974 #[test]
2975 fn parse_linux_parameters_fills_defaults() {
2976 let raw = serde_json::json!({"initProcessEnabled": true});
2977 let lp = parse_linux_parameters(&raw).expect("parses");
2978 assert!(lp.init_process_enabled);
2979 assert!(!lp.privileged);
2980 assert!(lp.capabilities_add.is_empty());
2981 }
2982
2983 #[test]
2984 fn parse_device_uses_default_permissions() {
2985 let raw = serde_json::json!({"hostPath": "/dev/null", "containerPath": "/dev/null"});
2986 let dev = parse_device(&raw).expect("parses");
2987 assert_eq!(dev.permissions, "rwm");
2988 }
2989
2990 #[test]
2991 fn compute_elbv2_targets_empty_when_no_group() {
2992 let mut accounts: MultiAccountState<EcsState> =
2993 MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
2994 let acct = accounts.get_or_create("000000000000");
2995 let mut task = make_task("t1");
2996 task.group = None;
2997 acct.tasks.insert("t1".into(), task);
2998 let state = acct.clone();
2999 let targets = compute_elbv2_targets(&state, state.tasks.get("t1").unwrap());
3000 assert!(targets.is_empty());
3001 }
3002
3003 #[test]
3004 fn compute_elbv2_targets_bridge_mode_uses_localhost_and_host_port() {
3005 let mut accounts: MultiAccountState<EcsState> =
3006 MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
3007 let acct = accounts.get_or_create("000000000000");
3008
3009 let td = crate::state::TaskDefinition {
3010 family: "app".into(),
3011 revision: 1,
3012 task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
3013 container_definitions: Vec::new(),
3014 network_mode: Some("bridge".into()),
3015 status: "ACTIVE".into(),
3016 task_role_arn: None,
3017 execution_role_arn: None,
3018 requires_compatibilities: Vec::new(),
3019 compatibilities: Vec::new(),
3020 cpu: None,
3021 memory: None,
3022 pid_mode: None,
3023 ipc_mode: None,
3024 volumes: Vec::new(),
3025 placement_constraints: Vec::new(),
3026 proxy_configuration: None,
3027 inference_accelerators: Vec::new(),
3028 ephemeral_storage: None,
3029 runtime_platform: None,
3030 requires_attributes: Vec::new(),
3031 registered_at: Utc::now(),
3032 registered_by: None,
3033 deregistered_at: None,
3034 tags: Vec::new(),
3035 enable_fault_injection: None,
3036 };
3037 acct.task_definitions.insert("app".into(), {
3038 let mut m = std::collections::BTreeMap::new();
3039 m.insert(1, td);
3040 m
3041 });
3042
3043 let service = crate::state::Service {
3044 service_name: "svc".into(),
3045 service_arn: "arn:aws:ecs:us-east-1:000000000000:service/default/svc".into(),
3046 cluster_name: "default".into(),
3047 cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
3048 task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
3049 family: "app".into(),
3050 revision: 1,
3051 desired_count: 1,
3052 running_count: 0,
3053 pending_count: 0,
3054 launch_type: "FARGATE".into(),
3055 status: "ACTIVE".into(),
3056 scheduling_strategy: "REPLICA".into(),
3057 deployment_controller: "ECS".into(),
3058 minimum_healthy_percent: Some(0),
3059 maximum_percent: Some(200),
3060 circuit_breaker: None,
3061 deployments: Vec::new(),
3062 load_balancers: vec![serde_json::json!({
3063 "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/tg/abc",
3064 "containerName": "app",
3065 "containerPort": 80,
3066 })],
3067 service_registries: Vec::new(),
3068 placement_constraints: Vec::new(),
3069 placement_strategy: Vec::new(),
3070 network_configuration: None,
3071 volume_configurations: vec![],
3072 service_connect_configuration: None,
3073 tags: Vec::new(),
3074 created_at: Utc::now(),
3075 created_by: None,
3076 role_arn: None,
3077 platform_version: None,
3078 health_check_grace_period_seconds: None,
3079 enable_execute_command: false,
3080 enable_ecs_managed_tags: false,
3081 propagate_tags: None,
3082 capacity_provider_strategy: Vec::new(),
3083 availability_zone_rebalancing: None,
3084 };
3085 acct.services.insert(
3086 crate::state::EcsState::service_key("default", "svc"),
3087 service,
3088 );
3089
3090 let mut task = make_task("t1");
3091 task.group = Some("service:svc".into());
3092 task.containers = vec![crate::state::Container {
3093 container_arn: "arn:aws:ecs:us-east-1:000000000000:container/default/abc/app".into(),
3094 name: "app".into(),
3095 image: "alpine".into(),
3096 task_arn: task.task_arn.clone(),
3097 last_status: "RUNNING".into(),
3098 exit_code: None,
3099 reason: None,
3100 runtime_id: Some("dockerid-app".into()),
3101 essential: true,
3102 cpu: None,
3103 memory: None,
3104 memory_reservation: None,
3105 network_bindings: vec![serde_json::json!({
3106 "bindIP": "0.0.0.0",
3107 "containerPort": 80,
3108 "hostPort": 32768,
3109 "protocol": "tcp",
3110 })],
3111 network_interfaces: Vec::new(),
3112 health_status: None,
3113 managed_agents: None,
3114 image_digest: None,
3115 }];
3116 acct.tasks.insert("t1".into(), task);
3117
3118 let state = acct.clone();
3119 let targets = compute_elbv2_targets(&state, state.tasks.get("t1").unwrap());
3120 assert_eq!(targets.len(), 1);
3121 let (arn, tg_targets) = &targets[0];
3122 assert_eq!(
3123 arn,
3124 "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/tg/abc"
3125 );
3126 assert_eq!(tg_targets.len(), 1);
3127 assert_eq!(tg_targets[0].0, "127.0.0.1");
3128 assert_eq!(tg_targets[0].1, Some(32768));
3129 }
3130
3131 #[test]
3132 fn compute_elbv2_targets_awsvpc_uses_eni_ip() {
3133 let mut accounts: MultiAccountState<EcsState> =
3134 MultiAccountState::new("000000000000", "us-east-1", "http://localhost:4566");
3135 let acct = accounts.get_or_create("000000000000");
3136
3137 let td = crate::state::TaskDefinition {
3138 family: "app".into(),
3139 revision: 1,
3140 task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
3141 container_definitions: Vec::new(),
3142 network_mode: Some("awsvpc".into()),
3143 status: "ACTIVE".into(),
3144 task_role_arn: None,
3145 execution_role_arn: None,
3146 requires_compatibilities: Vec::new(),
3147 compatibilities: Vec::new(),
3148 cpu: None,
3149 memory: None,
3150 pid_mode: None,
3151 ipc_mode: None,
3152 volumes: Vec::new(),
3153 placement_constraints: Vec::new(),
3154 proxy_configuration: None,
3155 inference_accelerators: Vec::new(),
3156 ephemeral_storage: None,
3157 runtime_platform: None,
3158 requires_attributes: Vec::new(),
3159 registered_at: Utc::now(),
3160 registered_by: None,
3161 deregistered_at: None,
3162 tags: Vec::new(),
3163 enable_fault_injection: None,
3164 };
3165 acct.task_definitions.insert("app".into(), {
3166 let mut m = std::collections::BTreeMap::new();
3167 m.insert(1, td);
3168 m
3169 });
3170
3171 let service = crate::state::Service {
3172 service_name: "svc".into(),
3173 service_arn: "arn:aws:ecs:us-east-1:000000000000:service/default/svc".into(),
3174 cluster_name: "default".into(),
3175 cluster_arn: "arn:aws:ecs:us-east-1:000000000000:cluster/default".into(),
3176 task_definition_arn: "arn:aws:ecs:us-east-1:000000000000:task-definition/app:1".into(),
3177 family: "app".into(),
3178 revision: 1,
3179 desired_count: 1,
3180 running_count: 0,
3181 pending_count: 0,
3182 launch_type: "FARGATE".into(),
3183 status: "ACTIVE".into(),
3184 scheduling_strategy: "REPLICA".into(),
3185 deployment_controller: "ECS".into(),
3186 minimum_healthy_percent: Some(0),
3187 maximum_percent: Some(200),
3188 circuit_breaker: None,
3189 deployments: Vec::new(),
3190 load_balancers: vec![serde_json::json!({
3191 "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/tg/abc",
3192 "containerName": "app",
3193 "containerPort": 80,
3194 })],
3195 service_registries: Vec::new(),
3196 placement_constraints: Vec::new(),
3197 placement_strategy: Vec::new(),
3198 network_configuration: None,
3199 volume_configurations: vec![],
3200 service_connect_configuration: None,
3201 tags: Vec::new(),
3202 created_at: Utc::now(),
3203 created_by: None,
3204 role_arn: None,
3205 platform_version: None,
3206 health_check_grace_period_seconds: None,
3207 enable_execute_command: false,
3208 enable_ecs_managed_tags: false,
3209 propagate_tags: None,
3210 capacity_provider_strategy: Vec::new(),
3211 availability_zone_rebalancing: None,
3212 };
3213 acct.services.insert(
3214 crate::state::EcsState::service_key("default", "svc"),
3215 service,
3216 );
3217
3218 let mut task = make_task("t1");
3219 task.group = Some("service:svc".into());
3220 task.attachments = vec![crate::state::TaskAttachment {
3221 id: "eni-123".into(),
3222 attachment_type: "eni".into(),
3223 status: "ATTACHED".into(),
3224 details: vec![
3225 crate::state::AttachmentDetail {
3226 name: "privateIPv4Address".into(),
3227 value: "172.18.0.2".into(),
3228 },
3229 crate::state::AttachmentDetail {
3230 name: "macAddress".into(),
3231 value: "02:42:ac:12:00:02".into(),
3232 },
3233 ],
3234 }];
3235 acct.tasks.insert("t1".into(), task);
3236
3237 let state = acct.clone();
3238 let targets = compute_elbv2_targets(&state, state.tasks.get("t1").unwrap());
3239 assert_eq!(targets.len(), 1);
3240 let (arn, tg_targets) = &targets[0];
3241 assert_eq!(
3242 arn,
3243 "arn:aws:elasticloadbalancing:us-east-1:000000000000:targetgroup/tg/abc"
3244 );
3245 assert_eq!(tg_targets.len(), 1);
3246 assert_eq!(tg_targets[0].0, "172.18.0.2");
3247 assert_eq!(tg_targets[0].1, Some(80));
3248 }
3249
3250 fn minimal_plan() -> ContainerPlan {
3251 ContainerPlan {
3252 container_name: "app".into(),
3253 image: "alpine".into(),
3254 env: Vec::new(),
3255 entry_point: Vec::new(),
3256 command: Vec::new(),
3257 secrets_refs: Vec::new(),
3258 essential: true,
3259 has_task_role: false,
3260 port_mappings: Vec::new(),
3261 network_mode: None,
3262 depends_on: Vec::new(),
3263 health_check: None,
3264 volume_mounts: Vec::new(),
3265 ulimits: Vec::new(),
3266 linux_parameters: None,
3267 stop_timeout: None,
3268 user: None,
3269 working_directory: None,
3270 tty: false,
3271 interactive: false,
3272 readonly_rootfs: false,
3273 }
3274 }
3275
3276 #[test]
3280 fn build_run_argv_emits_fakecloud_instance_label() {
3281 let plan = minimal_plan();
3282 let argv = build_run_argv(
3283 &plan,
3284 &[],
3285 "task-1",
3286 "host.docker.internal",
3287 None,
3288 "alpine",
3289 true,
3290 );
3291 let expected = fakecloud_instance_label();
3292 assert!(
3293 argv.windows(2)
3294 .any(|w| w[0] == "--label" && w[1] == expected),
3295 "argv must contain `--label {expected}`: {argv:?}",
3296 );
3297 }
3298
3299 #[test]
3304 fn fakecloud_instance_label_matches_reaper_format() {
3305 let label = fakecloud_instance_label();
3306 let (key, value) = label.split_once('=').expect("label is key=value");
3307 assert_eq!(key, "fakecloud-instance");
3308 let pid_str = value
3309 .strip_prefix("fakecloud-")
3310 .expect("value starts with fakecloud-");
3311 assert_eq!(
3312 pid_str.parse::<u32>().ok(),
3313 Some(std::process::id()),
3314 "reaper must be able to parse the owning pid out of {label}",
3315 );
3316 }
3317}