1pub mod bundle;
7pub mod health;
8
9pub use bundle::ContainerBundle;
10pub use health::ContainerHealthCheck;
11
12use std::collections::{HashMap, HashSet};
13use std::future::Future;
14use std::pin::Pin;
15use std::sync::{Arc, Mutex};
16use std::task::{Context, Poll};
17
18use async_trait::async_trait;
19use bollard::Docker;
20use bollard::models::{
21 ContainerCreateBody, NetworkConnectRequest, NetworkCreateRequest, NetworkDisconnectRequest,
22};
23use bollard::query_parameters::{
24 CreateContainerOptions, CreateImageOptions, EventsOptions, ListContainersOptions,
25 ListImagesOptions, ListNetworksOptions, LogsOptions, RemoveContainerOptions,
26 StartContainerOptions,
27};
28use bollard::service::{HostConfig, PortBinding};
29use camel_component_api::parse_uri;
30use camel_component_api::retry_async_cancelable;
31use camel_component_api::{
32 Body, BoxProcessor, CamelError, ComponentMetadata, Exchange, Message, NetworkRetryPolicy,
33 UriOption,
34};
35use camel_component_api::{
36 Component, Consumer, ConsumerContext, Endpoint, ProducerContext, RuntimeObservability,
37 UriConfig,
38};
39use tower::Service;
40
41static CONTAINER_TRACKER: once_cell::sync::Lazy<Arc<Mutex<HashSet<String>>>> =
44 once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(HashSet::new())));
45
46fn track_container(id: String) {
48 if let Ok(mut tracker) = CONTAINER_TRACKER.lock() {
49 tracker.insert(id);
50 }
51}
52
53fn untrack_container(id: &str) {
55 if let Ok(mut tracker) = CONTAINER_TRACKER.lock() {
56 tracker.remove(id);
57 }
58}
59
60fn validate_docker_host(host: &str) -> Result<&str, CamelError> {
63 if host.starts_with("unix://") || host.starts_with("npipe://") {
64 return Ok(host);
65 }
66 if host.contains("://") {
67 return Err(CamelError::ProcessorError(format!(
68 "Unsupported Docker host scheme: {} (only unix:// and npipe:// are supported)",
69 host
70 )));
71 }
72 Ok(host)
73}
74
75fn connect_docker_from_host(docker_host: Option<&str>) -> Result<Docker, CamelError> {
81 match docker_host {
82 Some(host) => {
83 validate_docker_host(host)?;
84 Docker::connect_with_socket(
85 host,
86 DOCKER_CONNECT_TIMEOUT_SECS,
87 bollard::API_DEFAULT_VERSION,
88 )
89 .map_err(|e| {
90 CamelError::ProcessorError(format!("Failed to connect to docker daemon: {}", e))
91 })
92 }
93 None => Docker::connect_with_local_defaults().map_err(|e| {
94 CamelError::ProcessorError(format!("Failed to connect to docker daemon: {}", e))
95 }),
96 }
97}
98
99pub async fn cleanup_tracked_containers(docker_host: Option<&str>) {
105 let ids: Vec<String> = {
106 match CONTAINER_TRACKER.lock() {
107 Ok(tracker) => tracker.iter().cloned().collect(),
108 Err(_) => return,
109 }
110 };
111
112 if ids.is_empty() {
113 return;
114 }
115
116 tracing::info!("Cleaning up {} tracked container(s)", ids.len());
117
118 let docker = match connect_docker_from_host(docker_host) {
119 Ok(d) => d,
120 Err(e) => {
121 tracing::error!("Failed to connect to Docker for cleanup: {}", e);
123 return;
124 }
125 };
126
127 for id in ids {
128 match docker
129 .remove_container(
130 &id,
131 Some(RemoveContainerOptions {
132 force: true,
133 ..Default::default()
134 }),
135 )
136 .await
137 {
138 Ok(_) => {
139 tracing::debug!("Cleaned up container {}", id);
140 untrack_container(&id);
141 }
142 Err(e) => {
143 tracing::warn!("Failed to cleanup container {}: {}", id, e);
144 }
145 }
146 }
147}
148
149const DOCKER_CONNECT_TIMEOUT_SECS: u64 = 120;
153
154pub const HEADER_ACTION: &str = "CamelContainerAction";
156
157pub const HEADER_IMAGE: &str = "CamelContainerImage";
159
160pub const HEADER_CONTAINER_ID: &str = "CamelContainerId";
162
163pub const HEADER_LOG_STREAM: &str = "CamelContainerLogStream";
165
166pub const HEADER_LOG_TIMESTAMP: &str = "CamelContainerLogTimestamp";
168
169pub const HEADER_CONTAINER_NAME: &str = "CamelContainerName";
171
172pub const HEADER_ACTION_RESULT: &str = "CamelContainerActionResult";
174
175pub const HEADER_CMD: &str = "CamelContainerCmd";
177
178pub const HEADER_NETWORK: &str = "CamelContainerNetwork";
180
181pub const HEADER_EXIT_CODE: &str = "CamelContainerExitCode";
183
184pub const HEADER_VOLUMES: &str = "CamelContainerVolumes";
186
187pub const HEADER_EXEC_ID: &str = "CamelContainerExecId";
189
190fn container_reconnect_default() -> NetworkRetryPolicy {
198 NetworkRetryPolicy {
199 enabled: true,
200 max_attempts: 0, initial_delay: std::time::Duration::from_secs(5),
202 multiplier: 1.0, max_delay: std::time::Duration::from_secs(5),
204 jitter_factor: 0.0,
205 max_attempts_absolute: None,
206 }
207}
208
209#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
213#[serde(default)]
214pub struct ContainerGlobalConfig {
215 pub docker_host: String,
217 #[serde(default = "container_reconnect_default")]
219 pub reconnect: NetworkRetryPolicy,
220}
221
222impl Default for ContainerGlobalConfig {
223 fn default() -> Self {
224 Self {
225 docker_host: "unix:///var/run/docker.sock".to_string(),
226 reconnect: container_reconnect_default(),
227 }
228 }
229}
230
231impl ContainerGlobalConfig {
232 pub fn new() -> Self {
233 Self::default()
234 }
235
236 pub fn with_docker_host(mut self, v: impl Into<String>) -> Self {
237 self.docker_host = v.into();
238 self
239 }
240}
241
242#[derive(Debug, Clone)]
251pub struct ContainerConfig {
252 pub operation: String,
254 pub image: Option<String>,
256 pub name: Option<String>,
258 pub host: Option<String>,
260 pub cmd: Option<String>,
262 pub ports: Option<String>,
264 pub env: Option<String>,
266 pub network: Option<String>,
268 pub container_id: Option<String>,
270 pub follow: bool,
272 pub timestamps: bool,
274 pub tail: Option<String>,
276 pub auto_pull: bool,
278 pub auto_remove: bool,
280 pub volumes: Option<String>,
282 pub user: Option<String>,
284 pub workdir: Option<String>,
286 pub detach: bool,
288 pub driver: Option<String>,
290 pub force: bool,
292 pub reconnect: NetworkRetryPolicy,
294}
295
296#[derive(Debug, Clone, UriConfig)]
302#[allow(dead_code)]
303#[uri_scheme = "container"]
304#[uri_config(
305 skip_impl,
306 metadata(
307 scheme = "container",
308 description = "Container lifecycle management endpoint",
309 producer,
310 consumer
311 ),
312 crate = "camel_component_api"
313)]
314struct ContainerUriConfig {
315 pub operation: String,
317 #[uri_param(desc = "Container image to use for run operations")]
319 pub image: Option<String>,
320 #[uri_param(desc = "Container name for run operations")]
322 pub name: Option<String>,
323 #[uri_param(desc = "Docker host URL")]
325 pub host: Option<String>,
326 #[uri_param(desc = "Command to run in the container")]
328 pub cmd: Option<String>,
329 #[uri_param(desc = "Port mappings in hostPort:containerPort format")]
331 pub ports: Option<String>,
332 #[uri_param(desc = "Environment variables in KEY=value format")]
334 pub env: Option<String>,
335 #[uri_param(default = "bridge", desc = "Network mode (bridge, host, none)")]
337 pub network: Option<String>,
338 #[uri_param(name = "containerId", desc = "Container ID or name for logs consumer")]
340 pub container_id: Option<String>,
341 #[uri_param(default = "true", desc = "Follow log output")]
343 pub follow: bool,
344 #[uri_param(default = "false", desc = "Include timestamps in logs")]
346 pub timestamps: bool,
347 #[uri_param(desc = "Number of lines to show from end of logs")]
349 pub tail: Option<String>,
350 #[uri_param(
352 name = "autoPull",
353 default = "true",
354 desc = "Automatically pull the image if not present"
355 )]
356 pub auto_pull: bool,
357 #[uri_param(
359 name = "autoRemove",
360 default = "true",
361 desc = "Automatically remove container when it exits"
362 )]
363 pub auto_remove: bool,
364 #[uri_param(desc = "Volume mounts in host:container:ro format")]
366 pub volumes: Option<String>,
367 #[uri_param(desc = "User to run the container or exec command as")]
369 pub user: Option<String>,
370 #[uri_param(desc = "Working directory inside the container")]
372 pub workdir: Option<String>,
373 #[uri_param(default = "false", desc = "Whether to detach from the exec process")]
375 pub detach: bool,
376 #[uri_param(desc = "Network driver for network-create")]
378 pub driver: Option<String>,
379 #[uri_param(default = "false", desc = "Whether to force the operation")]
381 pub force: bool,
382}
383
384impl ContainerConfig {
385 pub fn from_uri(uri: &str) -> Result<Self, CamelError> {
393 let parts = parse_uri(uri)?;
394 if parts.scheme != "container" {
395 return Err(CamelError::InvalidUri(format!(
396 "expected scheme 'container', got '{}'",
397 parts.scheme
398 )));
399 }
400
401 let image = parts.params.get("image").cloned();
402 let name = parts.params.get("name").cloned();
403 let cmd = parts.params.get("cmd").cloned();
404 let ports = parts.params.get("ports").cloned();
405 let env = parts.params.get("env").cloned();
406 let network = parts.params.get("network").cloned();
407 let container_id = parts.params.get("containerId").cloned();
408 let follow = parts
409 .params
410 .get("follow")
411 .map(|v| v.eq_ignore_ascii_case("true"))
412 .unwrap_or(true);
413 let timestamps = parts
414 .params
415 .get("timestamps")
416 .map(|v| v.eq_ignore_ascii_case("true"))
417 .unwrap_or(false);
418 let tail = parts.params.get("tail").cloned();
419 let auto_pull = parts
420 .params
421 .get("autoPull")
422 .map(|v| v.eq_ignore_ascii_case("true"))
423 .unwrap_or(true);
424 let auto_remove = parts
425 .params
426 .get("autoRemove")
427 .map(|v| v.eq_ignore_ascii_case("true"))
428 .unwrap_or(true);
429 let host = parts.params.get("host").cloned();
431 let volumes = parts.params.get("volumes").cloned();
432 let user = parts.params.get("user").cloned();
433 let workdir = parts.params.get("workdir").cloned();
434 let detach = parts
435 .params
436 .get("detach")
437 .map(|v| v.eq_ignore_ascii_case("true"))
438 .unwrap_or(false);
439 let driver = parts.params.get("driver").cloned();
440 let force = parts
441 .params
442 .get("force")
443 .map(|v| v.eq_ignore_ascii_case("true"))
444 .unwrap_or(false);
445
446 Ok(Self {
447 operation: parts.path,
448 image,
449 name,
450 host,
451 cmd,
452 ports,
453 env,
454 network,
455 container_id,
456 follow,
457 timestamps,
458 tail,
459 auto_pull,
460 auto_remove,
461 volumes,
462 user,
463 workdir,
464 detach,
465 driver,
466 force,
467 reconnect: NetworkRetryPolicy::default(),
468 })
469 }
470
471 fn apply_global_defaults(&mut self, global: &ContainerGlobalConfig) {
474 if self.host.is_none() {
475 self.host = Some(global.docker_host.clone());
476 }
477 self.reconnect = global.reconnect.clone();
478 }
479
480 #[cfg(test)]
481 fn docker_socket_path(&self) -> Result<&str, CamelError> {
482 let host = self.host.as_deref().unwrap_or(if cfg!(windows) {
483 "npipe:////./pipe/docker_engine"
484 } else {
485 "unix:///var/run/docker.sock"
486 });
487
488 validate_docker_host(host)
489 }
490
491 pub fn connect_docker_client(&self) -> Result<Docker, CamelError> {
492 connect_docker_from_host(self.host.as_deref())
493 }
494
495 pub async fn connect_docker(&self) -> Result<Docker, CamelError> {
503 let docker = self.connect_docker_client()?;
504 docker
505 .ping()
506 .await
507 .map_err(|e| CamelError::ProcessorError(format!("Docker ping failed: {}", e)))?;
508 Ok(docker)
509 }
510
511 #[allow(clippy::type_complexity)]
512 fn parse_ports(
513 &self,
514 ) -> Result<(Vec<String>, HashMap<String, Option<Vec<PortBinding>>>), CamelError> {
515 let ports_str = match self.ports.as_ref() {
516 Some(s) => s,
517 None => return Ok((Vec::new(), HashMap::new())),
518 };
519
520 let mut exposed_ports: Vec<String> = Vec::new();
521 let mut port_bindings: HashMap<String, Option<Vec<PortBinding>>> = HashMap::new();
522
523 for mapping in ports_str.split(',') {
524 let mapping = mapping.trim();
525 if mapping.is_empty() {
526 continue;
527 }
528
529 let (host_port, container_spec) = mapping.split_once(':').ok_or_else(|| {
530 CamelError::ProcessorError(format!(
531 "malformed port mapping '{}': expected hostPort:containerPort",
532 mapping
533 ))
534 })?;
535
536 let (container_port, protocol) = if container_spec.contains('/') {
537 let parts: Vec<&str> = container_spec.split('/').collect();
538 (parts[0], parts[1])
539 } else {
540 (container_spec, "tcp")
541 };
542
543 let container_key = format!("{}/{}", container_port, protocol);
544
545 exposed_ports.push(container_key.clone());
546
547 port_bindings.insert(
548 container_key,
549 Some(vec![PortBinding {
550 host_ip: None,
551 host_port: Some(host_port.to_string()),
552 }]),
553 );
554 }
555
556 Ok((exposed_ports, port_bindings))
557 }
558
559 fn parse_env(&self) -> Option<Vec<String>> {
560 let env_str = self.env.as_ref()?;
561
562 let env_vars: Vec<String> = env_str
563 .split(',')
564 .map(|s| s.trim().to_string())
565 .filter(|s| !s.is_empty())
566 .collect();
567
568 if env_vars.is_empty() {
569 None
570 } else {
571 Some(env_vars)
572 }
573 }
574
575 #[cfg(test)]
576 #[allow(clippy::type_complexity)]
577 fn parse_volumes(&self) -> Option<(Vec<String>, Vec<String>)> {
578 self.volumes.as_deref().and_then(parse_volume_str)
579 }
580
581 pub fn metadata() -> ComponentMetadata {
584 ContainerUriConfig::metadata()
585 }
586
587 pub fn uri_options() -> Vec<UriOption> {
590 ContainerUriConfig::uri_options()
591 }
592}
593
594#[allow(clippy::type_complexity)]
599fn parse_volume_str(volumes_str: &str) -> Option<(Vec<String>, Vec<String>)> {
600 let mut binds: Vec<String> = Vec::new();
601 let mut anonymous_volumes: Vec<String> = Vec::new();
602
603 for entry in volumes_str.split(',') {
604 let entry = entry.trim();
605 if entry.is_empty() {
606 continue;
607 }
608
609 let segments: Vec<&str> = entry.split(':').collect();
610
611 match segments.len() {
612 3 => {
613 let source = segments[0];
614 let target = segments[1];
615 let mode = segments[2];
616 if mode != "ro" && mode != "rw" {
617 continue;
618 }
619 binds.push(format!("{}:{}:{}", source, target, mode));
620 }
621 2 => {
622 let a = segments[0];
623 let b = segments[1];
624 if b == "ro" || b == "rw" {
625 anonymous_volumes.push(a.to_string());
626 } else {
627 binds.push(format!("{}:{}", a, b));
628 }
629 }
630 1 => {
631 anonymous_volumes.push(segments[0].to_string());
632 }
633 _ => continue,
634 }
635 }
636
637 if binds.is_empty() && anonymous_volumes.is_empty() {
638 None
639 } else {
640 Some((binds, anonymous_volumes))
641 }
642}
643
644#[derive(Debug, Clone, Copy, PartialEq, Eq)]
645enum ProducerOperation {
646 List,
647 Run,
648 Start,
649 Stop,
650 Remove,
651 Exec,
652 NetworkCreate,
653 NetworkConnect,
654 NetworkDisconnect,
655 NetworkRemove,
656 NetworkList,
657}
658
659fn parse_producer_operation(operation: &str) -> Result<ProducerOperation, CamelError> {
660 match operation {
661 "list" => Ok(ProducerOperation::List),
662 "run" => Ok(ProducerOperation::Run),
663 "start" => Ok(ProducerOperation::Start),
664 "stop" => Ok(ProducerOperation::Stop),
665 "remove" => Ok(ProducerOperation::Remove),
666 "exec" => Ok(ProducerOperation::Exec),
667 "network-create" => Ok(ProducerOperation::NetworkCreate),
668 "network-connect" => Ok(ProducerOperation::NetworkConnect),
669 "network-disconnect" => Ok(ProducerOperation::NetworkDisconnect),
670 "network-remove" => Ok(ProducerOperation::NetworkRemove),
671 "network-list" => Ok(ProducerOperation::NetworkList),
672 _ => Err(CamelError::ProcessorError(format!(
673 "Unknown container operation: {}",
674 operation
675 ))),
676 }
677}
678
679fn resolve_container_name(exchange: &Exchange, config: &ContainerConfig) -> Option<String> {
680 exchange
681 .input
682 .header(HEADER_CONTAINER_NAME)
683 .and_then(|v| v.as_str().map(|s| s.to_string()))
684 .or_else(|| config.name.clone())
685}
686
687async fn image_exists_locally(docker: &Docker, image: &str) -> Result<bool, CamelError> {
688 let images = docker
689 .list_images(None::<ListImagesOptions>)
690 .await
691 .map_err(|e| CamelError::ProcessorError(format!("Failed to list images: {}", e)))?;
692
693 Ok(images.iter().any(|img| {
694 img.repo_tags
695 .iter()
696 .any(|tag| tag == image || tag.starts_with(&format!("{}:", image)))
697 }))
698}
699
700async fn pull_image_with_progress(
701 docker: &Docker,
702 image: &str,
703 timeout_secs: u64,
704) -> Result<(), CamelError> {
705 use futures::StreamExt;
706
707 tracing::info!("Pulling image: {}", image);
708
709 let mut stream = docker.create_image(
710 Some(CreateImageOptions {
711 from_image: Some(image.to_string()),
712 ..Default::default()
713 }),
714 None,
715 None,
716 );
717
718 let start = std::time::Instant::now();
719 let mut last_progress = std::time::Instant::now();
720
721 while let Some(item) = stream.next().await {
722 if start.elapsed().as_secs() > timeout_secs {
723 return Err(CamelError::ProcessorError(format!(
724 "Image pull timeout after {}s. Try manually: docker pull {}",
725 timeout_secs, image
726 )));
727 }
728
729 match item {
730 Ok(update) => {
731 if last_progress.elapsed().as_secs() >= 2 {
733 if let Some(status) = update.status {
734 tracing::debug!("Pull progress: {}", status);
735 }
736 last_progress = std::time::Instant::now();
737 }
738 }
739 Err(e) => {
740 let err_str = e.to_string().to_lowercase();
741 if err_str.contains("unauthorized") || err_str.contains("401") {
742 return Err(CamelError::ProcessorError(format!(
743 "Authentication required for image '{}'. Configure Docker credentials: docker login",
744 image
745 )));
746 }
747 if err_str.contains("not found") || err_str.contains("404") {
748 return Err(CamelError::ProcessorError(format!(
749 "Image '{}' not found in registry. Check the image name and tag",
750 image
751 )));
752 }
753 return Err(CamelError::ProcessorError(format!(
754 "Failed to pull image '{}': {}",
755 image, e
756 )));
757 }
758 }
759 }
760
761 tracing::info!("Successfully pulled image: {}", image);
762 Ok(())
763}
764
765async fn ensure_image_available(
766 docker: &Docker,
767 image: &str,
768 auto_pull: bool,
769 timeout_secs: u64,
770) -> Result<(), CamelError> {
771 if image_exists_locally(docker, image).await? {
772 tracing::debug!("Image '{}' already available locally", image);
773 return Ok(());
774 }
775
776 if !auto_pull {
777 return Err(CamelError::ProcessorError(format!(
778 "Image '{}' not found locally. Set autoPull=true to pull automatically, or run: docker pull {}",
779 image, image
780 )));
781 }
782
783 pull_image_with_progress(docker, image, timeout_secs).await
784}
785
786fn format_docker_event(event: &bollard::models::EventMessage) -> String {
787 let action = event.action.as_deref().unwrap_or("unknown");
788 let actor = event.actor.as_ref();
789
790 let container_name = actor
791 .and_then(|a| a.attributes.as_ref())
792 .and_then(|attrs| attrs.get("name"))
793 .map(|s| s.as_str())
794 .unwrap_or("unknown");
795
796 let image = actor
797 .and_then(|a| a.attributes.as_ref())
798 .and_then(|attrs| attrs.get("image"))
799 .map(|s| s.as_str())
800 .unwrap_or("");
801
802 let exit_code = actor
803 .and_then(|a| a.attributes.as_ref())
804 .and_then(|attrs| attrs.get("exitCode"))
805 .map(|s| s.as_str());
806
807 match action {
808 "create" => {
809 if image.is_empty() {
810 format!("[CREATE] Container {}", container_name)
811 } else {
812 format!("[CREATE] Container {} ({})", container_name, image)
813 }
814 }
815 "start" => format!("[START] Container {}", container_name),
816 "die" => {
817 if let Some(code) = exit_code {
818 format!("[DIE] Container {} (exit: {})", container_name, code)
819 } else {
820 format!("[DIE] Container {}", container_name)
821 }
822 }
823 "destroy" => format!("[DESTROY] Container {}", container_name),
824 "stop" => format!("[STOP] Container {}", container_name),
825 "pause" => format!("[PAUSE] Container {}", container_name),
826 "unpause" => format!("[UNPAUSE] Container {}", container_name),
827 "restart" => format!("[RESTART] Container {}", container_name),
828 _ => format!("[{}] Container {}", action.to_uppercase(), container_name),
829 }
830}
831
832async fn run_container_with_cleanup<CreateFn, CreateFut, StartFn, StartFut, RemoveFn, RemoveFut>(
833 create: CreateFn,
834 start: StartFn,
835 remove: RemoveFn,
836) -> Result<String, CamelError>
837where
838 CreateFn: FnOnce() -> CreateFut,
839 CreateFut: Future<Output = Result<String, CamelError>>,
840 StartFn: FnOnce(String) -> StartFut,
841 StartFut: Future<Output = Result<(), CamelError>>,
842 RemoveFn: FnOnce(String) -> RemoveFut,
843 RemoveFut: Future<Output = Result<(), CamelError>>,
844{
845 let container_id = create().await?;
846 if let Err(start_err) = start(container_id.clone()).await {
847 if let Err(remove_err) = remove(container_id.clone()).await {
848 return Err(CamelError::ProcessorError(format!(
849 "Failed to start container: {}. Cleanup failed: {}",
850 start_err, remove_err
851 )));
852 }
853 return Err(start_err);
854 }
855
856 Ok(container_id)
857}
858
859async fn handle_list(
860 docker: Docker,
861 _config: ContainerConfig,
862 exchange: &mut Exchange,
863) -> Result<(), CamelError> {
864 let containers = docker
865 .list_containers(None::<ListContainersOptions>)
866 .await
867 .map_err(|e| CamelError::ProcessorError(format!("Failed to list containers: {}", e)))?;
868
869 let json_value = serde_json::to_value(&containers).map_err(|e| {
870 CamelError::ProcessorError(format!("Failed to serialize containers: {}", e))
871 })?;
872
873 exchange.input.body = Body::Json(json_value);
874 exchange.input.set_header(
875 HEADER_ACTION_RESULT,
876 serde_json::Value::String("success".to_string()),
877 );
878 Ok(())
879}
880
881async fn handle_run(
882 docker: Docker,
883 config: ContainerConfig,
884 exchange: &mut Exchange,
885) -> Result<(), CamelError> {
886 let image = exchange
887 .input
888 .header(HEADER_IMAGE)
889 .and_then(|v| v.as_str().map(|s| s.to_string()))
890 .or(config.image.clone())
891 .ok_or_else(|| {
892 CamelError::ProcessorError(
893 "missing image for run operation. Specify in URI (image=alpine) or header (CamelContainerImage)".to_string(),
894 )
895 })?;
896
897 let image = if !image.contains(':') && !image.contains('@') {
898 format!("{}:latest", image)
899 } else {
900 image
901 };
902
903 let pull_timeout = 300;
904 ensure_image_available(&docker, &image, config.auto_pull, pull_timeout)
905 .await
906 .map_err(|e| {
907 CamelError::ProcessorError(format!("Image '{}' not available: {}", image, e))
908 })?;
909
910 let container_name = resolve_container_name(exchange, &config);
911 let container_name_ref = container_name.as_deref().unwrap_or("");
912 let cmd_parts: Option<Vec<String>> = config
913 .cmd
914 .as_ref()
915 .map(|c| c.split_whitespace().map(|s| s.to_string()).collect());
916 let auto_remove = config.auto_remove;
917 let (exposed_ports, port_bindings) = config.parse_ports()?;
918 let env_vars = config.parse_env();
919 let network_mode = config.network.clone();
920
921 let volumes_str = exchange
922 .input
923 .header(HEADER_VOLUMES)
924 .and_then(|v| v.as_str().map(|s| s.to_string()))
925 .or(config.volumes.clone());
926 let (binds, anon_volumes) = volumes_str
927 .as_deref()
928 .and_then(parse_volume_str)
929 .unwrap_or_default();
930
931 let docker_create = docker.clone();
932 let docker_start = docker.clone();
933 let docker_remove = docker.clone();
934
935 let container_id = run_container_with_cleanup(
936 move || async move {
937 let create_options = CreateContainerOptions {
938 name: Some(container_name_ref.to_string()),
939 ..Default::default()
940 };
941 let container_config = ContainerCreateBody {
942 image: Some(image.clone()),
943 cmd: cmd_parts,
944 env: env_vars,
945 exposed_ports: if exposed_ports.is_empty() { None } else { Some(exposed_ports) },
946 volumes: if anon_volumes.is_empty() { None } else { Some(anon_volumes) },
947 host_config: Some(HostConfig {
948 auto_remove: Some(auto_remove),
949 port_bindings: if port_bindings.is_empty() { None } else { Some(port_bindings) },
950 network_mode,
951 binds: if binds.is_empty() { None } else { Some(binds) },
952 ..Default::default()
953 }),
954 ..Default::default()
955 };
956
957 let create_response = docker_create
958 .create_container(Some(create_options), container_config)
959 .await
960 .map_err(|e| {
961 let err_str = e.to_string().to_lowercase();
962 if err_str.contains("409") || err_str.contains("conflict") {
963 CamelError::ProcessorError(format!(
964 "Container name '{}' already exists. Use a unique name or remove the existing container first",
965 container_name_ref
966 ))
967 } else {
968 CamelError::ProcessorError(format!(
969 "Failed to create container: {}",
970 e
971 ))
972 }
973 })?;
974
975 Ok(create_response.id)
976 },
977 move |container_id| async move {
978 docker_start
979 .start_container(&container_id, None::<StartContainerOptions>)
980 .await
981 .map_err(|e| {
982 CamelError::ProcessorError(format!(
983 "Failed to start container: {}",
984 e
985 ))
986 })
987 },
988 move |container_id| async move {
989 docker_remove
990 .remove_container(&container_id, None)
991 .await
992 .map_err(|e| {
993 CamelError::ProcessorError(format!(
994 "Failed to remove container after start failure: {}",
995 e
996 ))
997 })
998 },
999 )
1000 .await?;
1001
1002 track_container(container_id.clone());
1003
1004 exchange
1005 .input
1006 .set_header(HEADER_CONTAINER_ID, serde_json::Value::String(container_id));
1007 exchange.input.set_header(
1008 HEADER_ACTION_RESULT,
1009 serde_json::Value::String("success".to_string()),
1010 );
1011 Ok(())
1012}
1013
1014async fn handle_lifecycle(
1015 docker: Docker,
1016 _config: ContainerConfig,
1017 exchange: &mut Exchange,
1018 operation: ProducerOperation,
1019 operation_name: &str,
1020) -> Result<(), CamelError> {
1021 let container_id = exchange
1022 .input
1023 .header(HEADER_CONTAINER_ID)
1024 .and_then(|v| v.as_str().map(|s| s.to_string()))
1025 .ok_or_else(|| {
1026 CamelError::ProcessorError(format!(
1027 "{} header is required for {} operation",
1028 HEADER_CONTAINER_ID, operation_name
1029 ))
1030 })?;
1031
1032 match operation {
1033 ProducerOperation::Start => {
1034 docker
1035 .start_container(&container_id, None::<StartContainerOptions>)
1036 .await
1037 .map_err(|e| {
1038 CamelError::ProcessorError(format!("Failed to start container: {}", e))
1039 })?;
1040 }
1041 ProducerOperation::Stop => {
1042 docker
1043 .stop_container(&container_id, None)
1044 .await
1045 .map_err(|e| {
1046 CamelError::ProcessorError(format!("Failed to stop container: {}", e))
1047 })?;
1048 }
1049 ProducerOperation::Remove => {
1050 docker
1051 .remove_container(&container_id, None)
1052 .await
1053 .map_err(|e| {
1054 CamelError::ProcessorError(format!("Failed to remove container: {}", e))
1055 })?;
1056 untrack_container(&container_id);
1057 }
1058 _ => {}
1059 }
1060
1061 exchange.input.set_header(
1062 HEADER_ACTION_RESULT,
1063 serde_json::Value::String("success".to_string()),
1064 );
1065 Ok(())
1066}
1067
1068async fn handle_exec(
1069 docker: Docker,
1070 config: ContainerConfig,
1071 exchange: &mut Exchange,
1072) -> Result<(), CamelError> {
1073 let container_id = exchange
1074 .input
1075 .header(HEADER_CONTAINER_ID)
1076 .and_then(|v| v.as_str().map(|s| s.to_string()))
1077 .or(config.container_id.clone())
1078 .ok_or_else(|| {
1079 CamelError::ProcessorError(format!(
1080 "{} header or containerId param is required for exec operation",
1081 HEADER_CONTAINER_ID
1082 ))
1083 })?;
1084
1085 let cmd = exchange
1086 .input
1087 .header(HEADER_CMD)
1088 .and_then(|v| v.as_str().map(|s| s.to_string()))
1089 .or(config.cmd.clone())
1090 .ok_or_else(|| {
1091 CamelError::ProcessorError(
1092 "CamelContainerCmd header or cmd param is required for exec operation".to_string(),
1093 )
1094 })?;
1095
1096 let cmd_parts: Vec<String> = cmd.split_whitespace().map(|s| s.to_string()).collect();
1097 let env_vars = config.parse_env();
1098
1099 let exec_config = bollard::exec::CreateExecOptions {
1100 cmd: Some(cmd_parts),
1101 env: env_vars,
1102 user: config.user.clone(),
1103 working_dir: config.workdir.clone(),
1104 attach_stdout: Some(true),
1105 attach_stderr: Some(true),
1106 ..Default::default()
1107 };
1108
1109 let create_result = docker
1110 .create_exec(&container_id, exec_config)
1111 .await
1112 .map_err(|e| {
1113 let err_str = e.to_string().to_lowercase();
1114 if err_str.contains("404") || err_str.contains("no such") {
1115 CamelError::ProcessorError(format!(
1116 "Container '{}' not found for exec",
1117 container_id
1118 ))
1119 } else {
1120 CamelError::ProcessorError(format!("Failed to create exec: {}", e))
1121 }
1122 })?;
1123
1124 let exec_id = create_result.id;
1125
1126 if config.detach {
1127 docker
1128 .start_exec(
1129 &exec_id,
1130 Some(bollard::exec::StartExecOptions {
1131 detach: true,
1132 ..Default::default()
1133 }),
1134 )
1135 .await
1136 .map_err(|e| {
1137 CamelError::ProcessorError(format!("Failed to start exec (detached): {}", e))
1138 })?;
1139
1140 exchange
1141 .input
1142 .set_header(HEADER_EXEC_ID, serde_json::Value::String(exec_id));
1143 exchange
1144 .input
1145 .set_header(HEADER_CONTAINER_ID, serde_json::Value::String(container_id));
1146 } else {
1147 let start_result = docker
1148 .start_exec(&exec_id, None)
1149 .await
1150 .map_err(|e| CamelError::ProcessorError(format!("Failed to start exec: {}", e)))?;
1151
1152 let mut output = String::new();
1153
1154 match start_result {
1155 bollard::exec::StartExecResults::Attached {
1156 output: mut stream, ..
1157 } => {
1158 use futures::StreamExt;
1159 while let Some(msg) = stream.next().await {
1160 match msg {
1161 Ok(bollard::container::LogOutput::StdOut { message }) => {
1162 output.push_str(&String::from_utf8_lossy(&message));
1163 }
1164 Ok(bollard::container::LogOutput::StdErr { message }) => {
1165 output.push_str(&String::from_utf8_lossy(&message));
1166 }
1167 Ok(_) => {}
1168 Err(e) => {
1169 output.push_str(&format!("[error reading stream: {}]", e));
1170 }
1171 }
1172 }
1173 }
1174 bollard::exec::StartExecResults::Detached => {}
1175 }
1176
1177 let inspect = docker
1178 .inspect_exec(&exec_id)
1179 .await
1180 .map_err(|e| CamelError::ProcessorError(format!("Failed to inspect exec: {}", e)))?;
1181
1182 let exit_code: i64 = inspect.exit_code.ok_or_else(|| {
1183 CamelError::ProcessorError("container exec returned no exit code".into())
1184 })?;
1185
1186 let output = output.trim_end().to_string();
1187 exchange.input.body = Body::Text(output);
1188 exchange.input.set_header(
1189 HEADER_EXIT_CODE,
1190 serde_json::Value::Number(exit_code.into()),
1191 );
1192 exchange
1193 .input
1194 .set_header(HEADER_CONTAINER_ID, serde_json::Value::String(container_id));
1195 }
1196
1197 exchange.input.set_header(
1198 HEADER_ACTION_RESULT,
1199 serde_json::Value::String("success".to_string()),
1200 );
1201 Ok(())
1202}
1203
1204async fn handle_network_create(
1205 docker: Docker,
1206 config: ContainerConfig,
1207 exchange: &mut Exchange,
1208) -> Result<(), CamelError> {
1209 let network_name = exchange
1210 .input
1211 .header(HEADER_CONTAINER_NAME)
1212 .and_then(|v| v.as_str().map(|s| s.to_string()))
1213 .or(config.name.clone())
1214 .ok_or_else(|| {
1215 CamelError::ProcessorError(
1216 "CamelContainerName header or name param is required for network-create"
1217 .to_string(),
1218 )
1219 })?;
1220
1221 let driver = config.driver.as_deref().unwrap_or("bridge");
1222
1223 let options = NetworkCreateRequest {
1224 name: network_name.clone(),
1225 driver: Some(driver.to_string()),
1226 ..Default::default()
1227 };
1228
1229 let result = docker.create_network(options).await.map_err(|e| {
1230 let err_str = e.to_string().to_lowercase();
1231 if err_str.contains("409") || err_str.contains("already exists") {
1232 CamelError::ProcessorError(format!("Network '{}' already exists", network_name))
1233 } else {
1234 CamelError::ProcessorError(format!("Failed to create network: {}", e))
1235 }
1236 })?;
1237
1238 let network_id = result.id.clone();
1239 let json_value = serde_json::to_value(&result).map_err(|e| {
1240 CamelError::ProcessorError(format!("Failed to serialize network response: {}", e))
1241 })?;
1242
1243 exchange.input.body = Body::Json(json_value);
1244 exchange
1245 .input
1246 .set_header(HEADER_NETWORK, serde_json::Value::String(network_id));
1247 exchange.input.set_header(
1248 HEADER_ACTION_RESULT,
1249 serde_json::Value::String("success".to_string()),
1250 );
1251 Ok(())
1252}
1253
1254async fn handle_network_connect(
1255 docker: Docker,
1256 config: ContainerConfig,
1257 exchange: &mut Exchange,
1258) -> Result<(), CamelError> {
1259 let network = exchange
1260 .input
1261 .header(HEADER_NETWORK)
1262 .and_then(|v| v.as_str().map(|s| s.to_string()))
1263 .or(config.network.clone())
1264 .ok_or_else(|| {
1265 CamelError::ProcessorError(
1266 "CamelContainerNetwork header or network param is required for network-connect"
1267 .to_string(),
1268 )
1269 })?;
1270
1271 let container = exchange
1272 .input
1273 .header(HEADER_CONTAINER_ID)
1274 .and_then(|v| v.as_str().map(|s| s.to_string()))
1275 .or(config.container_id.clone())
1276 .ok_or_else(|| {
1277 CamelError::ProcessorError(
1278 "CamelContainerId header or container param is required for network-connect"
1279 .to_string(),
1280 )
1281 })?;
1282
1283 docker
1284 .connect_network(
1285 &network,
1286 NetworkConnectRequest {
1287 container,
1288 ..Default::default()
1289 },
1290 )
1291 .await
1292 .map_err(|e| {
1293 let err_str = e.to_string().to_lowercase();
1294 if err_str.contains("404") || err_str.contains("not found") {
1295 CamelError::ProcessorError(format!("Network '{}' or container not found", network))
1296 } else {
1297 CamelError::ProcessorError(format!("Failed to connect to network: {}", e))
1298 }
1299 })?;
1300
1301 exchange.input.set_header(
1302 HEADER_ACTION_RESULT,
1303 serde_json::Value::String("success".to_string()),
1304 );
1305 Ok(())
1306}
1307
1308async fn handle_network_disconnect(
1309 docker: Docker,
1310 config: ContainerConfig,
1311 exchange: &mut Exchange,
1312) -> Result<(), CamelError> {
1313 let network = exchange
1314 .input
1315 .header(HEADER_NETWORK)
1316 .and_then(|v| v.as_str().map(|s| s.to_string()))
1317 .or(config.network.clone())
1318 .ok_or_else(|| {
1319 CamelError::ProcessorError(
1320 "CamelContainerNetwork header or network param is required for network-disconnect"
1321 .to_string(),
1322 )
1323 })?;
1324
1325 let container = exchange
1326 .input
1327 .header(HEADER_CONTAINER_ID)
1328 .and_then(|v| v.as_str().map(|s| s.to_string()))
1329 .or(config.container_id.clone())
1330 .ok_or_else(|| {
1331 CamelError::ProcessorError(
1332 "CamelContainerId header or container param is required for network-disconnect"
1333 .to_string(),
1334 )
1335 })?;
1336
1337 docker
1338 .disconnect_network(
1339 &network,
1340 NetworkDisconnectRequest {
1341 container,
1342 force: Some(config.force),
1343 },
1344 )
1345 .await
1346 .map_err(|e| {
1347 let err_str = e.to_string().to_lowercase();
1348 if err_str.contains("404") || err_str.contains("not found") {
1349 CamelError::ProcessorError(format!("Network '{}' or container not found", network))
1350 } else {
1351 CamelError::ProcessorError(format!("Failed to disconnect from network: {}", e))
1352 }
1353 })?;
1354
1355 exchange.input.set_header(
1356 HEADER_ACTION_RESULT,
1357 serde_json::Value::String("success".to_string()),
1358 );
1359 Ok(())
1360}
1361
1362async fn handle_network_remove(
1363 docker: Docker,
1364 config: ContainerConfig,
1365 exchange: &mut Exchange,
1366) -> Result<(), CamelError> {
1367 let network = exchange
1368 .input
1369 .header(HEADER_NETWORK)
1370 .and_then(|v| v.as_str().map(|s| s.to_string()))
1371 .or(config.network.clone())
1372 .ok_or_else(|| {
1373 CamelError::ProcessorError(
1374 "CamelContainerNetwork header or network param is required for network-remove"
1375 .to_string(),
1376 )
1377 })?;
1378
1379 docker.remove_network(&network).await.map_err(|e| {
1380 let err_str = e.to_string().to_lowercase();
1381 if err_str.contains("404") || err_str.contains("not found") {
1382 CamelError::ProcessorError(format!("Network '{}' not found", network))
1383 } else if err_str.contains("409") || err_str.contains("in use") {
1384 CamelError::ProcessorError(format!(
1385 "Network '{}' is in use and cannot be removed",
1386 network
1387 ))
1388 } else {
1389 CamelError::ProcessorError(format!("Failed to remove network: {}", e))
1390 }
1391 })?;
1392
1393 exchange.input.set_header(
1394 HEADER_ACTION_RESULT,
1395 serde_json::Value::String("success".to_string()),
1396 );
1397 Ok(())
1398}
1399
1400async fn handle_network_list(
1401 docker: Docker,
1402 _config: ContainerConfig,
1403 exchange: &mut Exchange,
1404) -> Result<(), CamelError> {
1405 let networks = docker
1406 .list_networks(None::<ListNetworksOptions>)
1407 .await
1408 .map_err(|e| CamelError::ProcessorError(format!("Failed to list networks: {}", e)))?;
1409
1410 let json_value = serde_json::to_value(&networks)
1411 .map_err(|e| CamelError::ProcessorError(format!("Failed to serialize networks: {}", e)))?;
1412
1413 exchange.input.body = Body::Json(json_value);
1414 exchange.input.set_header(
1415 HEADER_ACTION_RESULT,
1416 serde_json::Value::String("success".to_string()),
1417 );
1418 Ok(())
1419}
1420
1421#[derive(Clone)]
1426pub struct ContainerProducer {
1427 config: ContainerConfig,
1428 docker: Docker,
1429}
1430
1431impl Service<Exchange> for ContainerProducer {
1432 type Response = Exchange;
1433 type Error = CamelError;
1434 type Future = Pin<Box<dyn Future<Output = Result<Exchange, CamelError>> + Send>>;
1435
1436 fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
1437 Poll::Ready(Ok(()))
1438 }
1439
1440 fn call(&mut self, mut exchange: Exchange) -> Self::Future {
1441 let config = self.config.clone();
1442 let docker = self.docker.clone();
1443 Box::pin(async move {
1444 let operation_name = exchange
1445 .input
1446 .header(HEADER_ACTION)
1447 .and_then(|v| v.as_str().map(|s| s.to_string()))
1448 .unwrap_or_else(|| config.operation.clone());
1449
1450 let operation = parse_producer_operation(&operation_name)?;
1451
1452 match operation {
1453 ProducerOperation::List => {
1454 handle_list(docker, config, &mut exchange).await?;
1455 }
1456 ProducerOperation::Run => {
1457 handle_run(docker, config, &mut exchange).await?;
1458 }
1459 ProducerOperation::Start => {
1460 handle_lifecycle(docker, config, &mut exchange, operation, &operation_name)
1461 .await?;
1462 }
1463 ProducerOperation::Stop => {
1464 handle_lifecycle(docker, config, &mut exchange, operation, &operation_name)
1465 .await?;
1466 }
1467 ProducerOperation::Remove => {
1468 handle_lifecycle(docker, config, &mut exchange, operation, &operation_name)
1469 .await?;
1470 }
1471 ProducerOperation::Exec => {
1472 handle_exec(docker, config, &mut exchange).await?;
1473 }
1474 ProducerOperation::NetworkCreate => {
1475 handle_network_create(docker, config, &mut exchange).await?;
1476 }
1477 ProducerOperation::NetworkConnect => {
1478 handle_network_connect(docker, config, &mut exchange).await?;
1479 }
1480 ProducerOperation::NetworkDisconnect => {
1481 handle_network_disconnect(docker, config, &mut exchange).await?;
1482 }
1483 ProducerOperation::NetworkRemove => {
1484 handle_network_remove(docker, config, &mut exchange).await?;
1485 }
1486 ProducerOperation::NetworkList => {
1487 handle_network_list(docker, config, &mut exchange).await?;
1488 }
1489 }
1490
1491 Ok(exchange)
1492 })
1493 }
1494}
1495
1496pub struct ContainerConsumer {
1501 config: ContainerConfig,
1502 runtime: Arc<dyn RuntimeObservability>,
1505}
1506
1507impl ContainerConsumer {
1508 pub fn new(config: ContainerConfig, runtime: Arc<dyn RuntimeObservability>) -> Self {
1509 Self { config, runtime }
1510 }
1511}
1512
1513#[async_trait]
1514impl Consumer for ContainerConsumer {
1515 async fn start(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
1516 match self.config.operation.as_str() {
1517 "events" => self.start_events_consumer(context).await,
1518 "logs" => self.start_logs_consumer(context).await,
1519 _ => Err(CamelError::EndpointCreationFailed(format!(
1520 "Consumer only supports 'events' or 'logs' operations, got '{}'",
1521 self.config.operation
1522 ))),
1523 }
1524 }
1525
1526 async fn stop(&mut self) -> Result<(), CamelError> {
1527 Ok(())
1528 }
1529
1530 fn concurrency_model(&self) -> camel_component_api::ConcurrencyModel {
1531 camel_component_api::ConcurrencyModel::Concurrent { max: None }
1532 }
1533}
1534
1535impl ContainerConsumer {
1536 async fn start_events_consumer(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
1537 use futures::StreamExt;
1538
1539 let cancel = context.cancel_token();
1540
1541 loop {
1545 let docker = match retry_async_cancelable(
1546 &self.config.reconnect,
1547 Some("container-events"),
1548 || async { self.config.connect_docker().await },
1549 |_| true,
1550 &cancel,
1551 )
1552 .await
1553 {
1554 Ok(d) => d,
1555 Err(_) if context.is_cancelled() => {
1556 tracing::info!("Container events consumer shutting down");
1557 return Ok(());
1558 }
1559 Err(e) => {
1560 self.runtime
1561 .metrics()
1562 .increment_errors(context.route_id(), "e:container:events-connect");
1563 tracing::error!(error = %e, "Container events consumer exhausted reconnect attempts");
1565 return Err(e);
1566 }
1567 };
1568
1569 let mut event_stream = docker.events(None::<EventsOptions>);
1570
1571 loop {
1572 tokio::select! {
1573 _ = context.cancelled() => {
1574 tracing::info!("Container events consumer shutting down");
1575 return Ok(());
1576 }
1577
1578 msg = event_stream.next() => {
1579 match msg {
1580 Some(Ok(event)) => {
1581 let formatted = format_docker_event(&event);
1582 let message = Message::new(Body::Text(formatted));
1583 let exchange = Exchange::new(message);
1584
1585 if let Err(e) = context.send(exchange).await {
1586 tracing::error!("Failed to send exchange: {:?}", e);
1588 break;
1589 }
1590 }
1591 Some(Err(e)) => {
1592 self.runtime.metrics().increment_errors(context.route_id(), "e:container:events-stream");
1593 tracing::error!("Docker event stream error: {}. Reconnecting...", e);
1595 break;
1596 }
1597 None => {
1598 tracing::info!("Docker event stream ended. Reconnecting...");
1599 break;
1600 }
1601 }
1602 }
1603 }
1604 }
1605
1606 tokio::select! {
1607 _ = context.cancelled() => {
1608 tracing::info!("Container events consumer shutting down");
1609 return Ok(());
1610 }
1611 _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1612 }
1613 }
1614 }
1615
1616 async fn start_logs_consumer(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
1617 use futures::StreamExt;
1618
1619 let container_id = self.config.container_id.clone().ok_or_else(|| {
1620 CamelError::EndpointCreationFailed(
1621 "containerId is required for logs consumer. Use container:logs?containerId=xxx"
1622 .to_string(),
1623 )
1624 })?;
1625
1626 let cancel = context.cancel_token();
1627
1628 loop {
1632 let docker = match retry_async_cancelable(
1633 &self.config.reconnect,
1634 Some("container-logs"),
1635 || async { self.config.connect_docker().await },
1636 |_| true,
1637 &cancel,
1638 )
1639 .await
1640 {
1641 Ok(d) => d,
1642 Err(_) if context.is_cancelled() => {
1643 tracing::info!("Container logs consumer shutting down");
1644 return Ok(());
1645 }
1646 Err(e) => {
1647 self.runtime
1648 .metrics()
1649 .increment_errors(context.route_id(), "e:container:logs-connect");
1650 tracing::error!(error = %e, "Container logs consumer exhausted reconnect attempts");
1652 return Err(e);
1653 }
1654 };
1655
1656 let tail = self
1657 .config
1658 .tail
1659 .clone()
1660 .unwrap_or_else(|| "all".to_string());
1661
1662 let options = LogsOptions {
1663 follow: self.config.follow,
1664 stdout: true,
1665 stderr: true,
1666 timestamps: self.config.timestamps,
1667 tail,
1668 ..Default::default()
1669 };
1670
1671 let mut log_stream = docker.logs(&container_id, Some(options));
1672 let container_id_header = container_id.clone();
1673
1674 loop {
1675 tokio::select! {
1676 _ = context.cancelled() => {
1677 tracing::info!("Container logs consumer shutting down");
1678 return Ok(());
1679 }
1680
1681 msg = log_stream.next() => {
1682 match msg {
1683 Some(Ok(log_output)) => {
1684 let (stream_type, content) = match log_output {
1685 bollard::container::LogOutput::StdOut { message } => {
1686 ("stdout", String::from_utf8_lossy(&message).into_owned())
1687 }
1688 bollard::container::LogOutput::StdErr { message } => {
1689 ("stderr", String::from_utf8_lossy(&message).into_owned())
1690 }
1691 bollard::container::LogOutput::Console { message } => {
1692 ("console", String::from_utf8_lossy(&message).into_owned())
1693 }
1694 bollard::container::LogOutput::StdIn { message } => {
1695 ("stdin", String::from_utf8_lossy(&message).into_owned())
1696 }
1697 };
1698
1699 let content = content.trim_end();
1700 if content.is_empty() {
1701 continue;
1702 }
1703
1704 let mut message = Message::new(Body::Text(content.to_string()));
1705 message.set_header(
1706 HEADER_CONTAINER_ID,
1707 serde_json::Value::String(container_id_header.clone()),
1708 );
1709 message.set_header(
1710 HEADER_LOG_STREAM,
1711 serde_json::Value::String(stream_type.to_string()),
1712 );
1713
1714 if self.config.timestamps
1715 && let Some(ts) = extract_timestamp(content) {
1716 message.set_header(
1717 HEADER_LOG_TIMESTAMP,
1718 serde_json::Value::String(ts),
1719 );
1720 }
1721
1722 let exchange = Exchange::new(message);
1723
1724 if let Err(e) = context.send(exchange).await {
1725 tracing::error!("Failed to send log exchange: {:?}", e);
1727 break;
1728 }
1729 }
1730 Some(Err(e)) => {
1731 self.runtime.metrics().increment_errors(context.route_id(), "e:container:logs-stream");
1732 tracing::error!("Docker log stream error: {}. Reconnecting...", e);
1734 break;
1735 }
1736 None => {
1737 if self.config.follow {
1738 tracing::info!("Docker log stream ended. Reconnecting...");
1739 break;
1740 } else {
1741 tracing::info!("Container logs consumer finished (follow=false)");
1742 return Ok(());
1743 }
1744 }
1745 }
1746 }
1747 }
1748 }
1749
1750 tokio::select! {
1751 _ = context.cancelled() => {
1752 tracing::info!("Container logs consumer shutting down");
1753 return Ok(());
1754 }
1755 _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1756 }
1757 }
1758 }
1759}
1760
1761fn extract_timestamp(log_line: &str) -> Option<String> {
1762 let parts: Vec<&str> = log_line.splitn(2, ' ').collect();
1763 if parts.len() > 1 && parts[0].contains('T') {
1764 Some(parts[0].to_string())
1765 } else {
1766 None
1767 }
1768}
1769
1770pub struct ContainerComponent {
1779 config: Option<ContainerGlobalConfig>,
1780}
1781
1782impl ContainerComponent {
1783 pub fn new() -> Self {
1785 Self { config: None }
1786 }
1787
1788 pub fn with_config(config: ContainerGlobalConfig) -> Self {
1790 Self {
1791 config: Some(config),
1792 }
1793 }
1794
1795 pub fn with_optional_config(config: Option<ContainerGlobalConfig>) -> Self {
1797 Self { config }
1798 }
1799}
1800
1801impl Default for ContainerComponent {
1802 fn default() -> Self {
1803 Self::new()
1804 }
1805}
1806
1807impl Component for ContainerComponent {
1808 fn scheme(&self) -> &str {
1809 "container"
1810 }
1811
1812 fn metadata(&self) -> ComponentMetadata {
1813 ContainerConfig::metadata()
1814 }
1815
1816 fn create_endpoint(
1817 &self,
1818 uri: &str,
1819 ctx: &dyn camel_component_api::ComponentContext,
1820 ) -> Result<Box<dyn Endpoint>, CamelError> {
1821 let mut config = ContainerConfig::from_uri(uri)?;
1822 if let Some(ref global) = self.config {
1824 config.apply_global_defaults(global);
1825 }
1826 let health_check = ContainerHealthCheck::new(&config);
1827 ctx.register_current_route_health_check(Arc::new(health_check));
1828 Ok(Box::new(ContainerEndpoint {
1829 uri: uri.to_string(),
1830 config,
1831 }))
1832 }
1833}
1834
1835pub struct ContainerEndpoint {
1842 uri: String,
1843 config: ContainerConfig,
1844}
1845
1846impl ContainerEndpoint {
1847 pub fn docker_host(&self) -> Option<&str> {
1850 self.config.host.as_deref()
1851 }
1852}
1853
1854impl Endpoint for ContainerEndpoint {
1855 fn uri(&self) -> &str {
1856 &self.uri
1857 }
1858
1859 fn create_consumer(
1860 &self,
1861 rt: Arc<dyn camel_component_api::RuntimeObservability>,
1862 ) -> Result<Box<dyn Consumer>, CamelError> {
1863 Ok(Box::new(ContainerConsumer::new(self.config.clone(), rt)))
1864 }
1865
1866 fn create_producer(
1867 &self,
1868 _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1869 _ctx: &ProducerContext,
1870 ) -> Result<BoxProcessor, CamelError> {
1871 let docker = self.config.connect_docker_client()?;
1872 Ok(BoxProcessor::new(ContainerProducer {
1873 config: self.config.clone(),
1874 docker,
1875 }))
1876 }
1877}
1878
1879#[cfg(test)]
1880mod tests {
1881 use camel_component_api::test_support::PanicRuntimeObservability;
1882 fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1883 std::sync::Arc::new(PanicRuntimeObservability)
1884 }
1885 fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1886 std::sync::Arc::new(PanicRuntimeObservability)
1887 }
1888
1889 use super::*;
1890 use camel_api::MetricsCollector;
1891 use camel_component_api::HealthCheckRegistry;
1892 use camel_component_api::NoOpComponentContext;
1893
1894 #[test]
1895 fn test_container_config() {
1896 let config = ContainerConfig::from_uri("container:run?image=alpine").unwrap();
1897 assert_eq!(config.operation, "run");
1898 assert_eq!(config.image.as_deref(), Some("alpine"));
1899 assert!(config.host.is_none());
1901 }
1902
1903 #[test]
1904 fn test_global_config_applied_to_endpoint() {
1905 let global =
1908 ContainerGlobalConfig::default().with_docker_host("unix:///custom/docker.sock");
1909 let mut config = ContainerConfig::from_uri("container:run?image=alpine").unwrap();
1910 assert!(
1911 config.host.is_none(),
1912 "URI without ?host= should leave host as None"
1913 );
1914 config.apply_global_defaults(&global);
1915 assert_eq!(
1916 config.host.as_deref(),
1917 Some("unix:///custom/docker.sock"),
1918 "global docker_host must be applied when URI did not set host"
1919 );
1920 }
1921
1922 #[test]
1923 fn test_uri_param_wins_over_global_config() {
1924 let global =
1926 ContainerGlobalConfig::default().with_docker_host("unix:///custom/docker.sock");
1927 let mut config =
1928 ContainerConfig::from_uri("container:run?image=alpine&host=unix:///override.sock")
1929 .unwrap();
1930 assert_eq!(
1931 config.host.as_deref(),
1932 Some("unix:///override.sock"),
1933 "URI-set host should be parsed correctly"
1934 );
1935 config.apply_global_defaults(&global);
1936 assert_eq!(
1937 config.host.as_deref(),
1938 Some("unix:///override.sock"),
1939 "global config must NOT override a host already set by URI"
1940 );
1941 }
1942
1943 #[test]
1944 fn test_container_config_parses_name() {
1945 let config = ContainerConfig::from_uri("container:run?name=my-container").unwrap();
1946 assert_eq!(config.name.as_deref(), Some("my-container"));
1947 }
1948
1949 #[test]
1950 fn test_parse_producer_operation_known() {
1951 assert_eq!(
1952 parse_producer_operation("list").unwrap(),
1953 ProducerOperation::List
1954 );
1955 assert_eq!(
1956 parse_producer_operation("run").unwrap(),
1957 ProducerOperation::Run
1958 );
1959 assert_eq!(
1960 parse_producer_operation("start").unwrap(),
1961 ProducerOperation::Start
1962 );
1963 assert_eq!(
1964 parse_producer_operation("stop").unwrap(),
1965 ProducerOperation::Stop
1966 );
1967 assert_eq!(
1968 parse_producer_operation("remove").unwrap(),
1969 ProducerOperation::Remove
1970 );
1971 }
1972
1973 #[test]
1974 fn test_parse_producer_operation_unknown() {
1975 let err = parse_producer_operation("destruir_mundo").unwrap_err();
1976 match err {
1977 CamelError::ProcessorError(msg) => {
1978 assert!(
1979 msg.contains("Unknown container operation"),
1980 "Unexpected error message: {}",
1981 msg
1982 );
1983 }
1984 _ => panic!("Expected ProcessorError for unknown operation"),
1985 }
1986 }
1987
1988 #[test]
1989 fn test_parse_producer_operation_new_variants() {
1990 assert_eq!(
1991 parse_producer_operation("exec").unwrap(),
1992 ProducerOperation::Exec
1993 );
1994 assert_eq!(
1995 parse_producer_operation("network-create").unwrap(),
1996 ProducerOperation::NetworkCreate
1997 );
1998 assert_eq!(
1999 parse_producer_operation("network-connect").unwrap(),
2000 ProducerOperation::NetworkConnect
2001 );
2002 assert_eq!(
2003 parse_producer_operation("network-disconnect").unwrap(),
2004 ProducerOperation::NetworkDisconnect
2005 );
2006 assert_eq!(
2007 parse_producer_operation("network-remove").unwrap(),
2008 ProducerOperation::NetworkRemove
2009 );
2010 assert_eq!(
2011 parse_producer_operation("network-list").unwrap(),
2012 ProducerOperation::NetworkList
2013 );
2014 }
2015
2016 #[test]
2017 fn test_resolve_container_name_header_overrides_config() {
2018 let config = ContainerConfig::from_uri("container:run?name=config-name").unwrap();
2019 let mut exchange = Exchange::new(Message::new(""));
2020 exchange.input.set_header(
2021 HEADER_CONTAINER_NAME,
2022 serde_json::Value::String("header-name".to_string()),
2023 );
2024
2025 let resolved = resolve_container_name(&exchange, &config);
2026 assert_eq!(resolved.as_deref(), Some("header-name"));
2027 }
2028
2029 #[test]
2030 fn test_container_config_rejects_tcp_host() {
2031 let config = ContainerConfig::from_uri("container:list?host=tcp://localhost:2375").unwrap();
2032 let err = config.connect_docker_client().unwrap_err();
2033 match err {
2034 CamelError::ProcessorError(msg) => {
2035 assert!(
2036 msg.to_lowercase().contains("tcp"),
2037 "Expected TCP scheme error, got: {}",
2038 msg
2039 );
2040 }
2041 _ => panic!("Expected ProcessorError for unsupported tcp host"),
2042 }
2043 }
2044
2045 #[tokio::test]
2046 async fn test_run_container_with_cleanup_removes_on_start_failure() {
2047 let remove_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2048 let remove_called_clone = remove_called.clone();
2049
2050 let result = run_container_with_cleanup(
2051 || async { Ok("container-123".to_string()) },
2052 |_id| async move {
2053 Err(CamelError::ProcessorError(
2054 "Failed to start container".to_string(),
2055 ))
2056 },
2057 move |_id| {
2058 let remove_called_inner = remove_called_clone.clone();
2059 async move {
2060 remove_called_inner.store(true, std::sync::atomic::Ordering::SeqCst);
2061 Ok(())
2062 }
2063 },
2064 )
2065 .await;
2066
2067 assert!(result.is_err(), "Expected start failure to bubble up");
2068 assert!(
2069 remove_called.load(std::sync::atomic::Ordering::SeqCst),
2070 "Expected cleanup to remove container"
2071 );
2072 }
2073
2074 #[test]
2075 fn test_container_component_creates_endpoint() {
2076 let component = ContainerComponent::new();
2077 assert_eq!(component.scheme(), "container");
2078 let ctx = NoOpComponentContext;
2079 let endpoint = component
2080 .create_endpoint("container:run?image=alpine", &ctx)
2081 .unwrap();
2082 assert_eq!(endpoint.uri(), "container:run?image=alpine");
2083 }
2084
2085 #[test]
2086 fn test_container_config_parses_ports() {
2087 let config =
2088 ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80,8443:443").unwrap();
2089 assert_eq!(config.ports.as_deref(), Some("8080:80,8443:443"));
2090 }
2091
2092 #[test]
2093 fn test_container_config_parses_env() {
2094 let config =
2095 ContainerConfig::from_uri("container:run?image=nginx&env=FOO=bar,BAZ=qux").unwrap();
2096 assert_eq!(config.env.as_deref(), Some("FOO=bar,BAZ=qux"));
2097 }
2098
2099 #[test]
2100 fn test_container_config_parses_logs_options() {
2101 let config = ContainerConfig::from_uri(
2102 "container:logs?containerId=my-app&follow=true×tamps=true&tail=100",
2103 )
2104 .unwrap();
2105 assert_eq!(config.operation, "logs");
2106 assert_eq!(config.container_id.as_deref(), Some("my-app"));
2107 assert!(config.follow);
2108 assert!(config.timestamps);
2109 assert_eq!(config.tail.as_deref(), Some("100"));
2110 }
2111
2112 #[test]
2113 fn test_container_config_logs_defaults() {
2114 let config = ContainerConfig::from_uri("container:logs?containerId=test").unwrap();
2115 assert!(config.follow); assert!(!config.timestamps); assert!(config.tail.is_none()); }
2119
2120 #[test]
2121 fn test_parse_ports_single() {
2122 let config = ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80").unwrap();
2123 let (exposed, bindings) = config.parse_ports().unwrap();
2124
2125 assert!(exposed.contains(&"80/tcp".to_string()));
2126 assert!(bindings.contains_key("80/tcp"));
2127
2128 let binding = bindings.get("80/tcp").unwrap().as_ref().unwrap();
2129 assert_eq!(binding.len(), 1);
2130 assert_eq!(binding[0].host_port, Some("8080".to_string()));
2131 }
2132
2133 #[test]
2134 fn test_parse_ports_multiple() {
2135 let config =
2136 ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80,8443:443").unwrap();
2137 let (exposed, bindings) = config.parse_ports().unwrap();
2138
2139 assert!(exposed.contains(&"80/tcp".to_string()));
2140 assert!(exposed.contains(&"443/tcp".to_string()));
2141 assert_eq!(bindings.len(), 2);
2142 }
2143
2144 #[test]
2145 fn test_parse_ports_with_protocol() {
2146 let config =
2147 ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80/tcp,5353:53/udp")
2148 .unwrap();
2149 let (exposed, _bindings) = config.parse_ports().unwrap();
2150
2151 assert!(exposed.contains(&"80/tcp".to_string()));
2152 assert!(exposed.contains(&"53/udp".to_string()));
2153 }
2154
2155 #[test]
2156 fn test_parse_ports_none() {
2157 let config = ContainerConfig::from_uri("container:run?image=nginx").unwrap();
2158 let (exposed, bindings) = config.parse_ports().unwrap();
2159 assert!(exposed.is_empty());
2160 assert!(bindings.is_empty());
2161 }
2162
2163 #[test]
2164 fn test_parse_env_single() {
2165 let config = ContainerConfig::from_uri("container:run?image=nginx&env=FOO=bar").unwrap();
2166 let env = config.parse_env().unwrap();
2167
2168 assert_eq!(env.len(), 1);
2169 assert_eq!(env[0], "FOO=bar");
2170 }
2171
2172 #[test]
2173 fn test_parse_env_multiple() {
2174 let config =
2175 ContainerConfig::from_uri("container:run?image=nginx&env=FOO=bar,BAZ=qux,NUM=123")
2176 .unwrap();
2177 let env = config.parse_env().unwrap();
2178
2179 assert_eq!(env.len(), 3);
2180 assert!(env.contains(&"FOO=bar".to_string()));
2181 assert!(env.contains(&"BAZ=qux".to_string()));
2182 assert!(env.contains(&"NUM=123".to_string()));
2183 }
2184
2185 #[test]
2186 fn test_parse_env_none() {
2187 let config = ContainerConfig::from_uri("container:run?image=nginx").unwrap();
2188 assert!(config.parse_env().is_none());
2189 }
2190
2191 use camel_component_api::Message;
2192 use std::sync::Arc;
2193
2194 #[tokio::test]
2195 async fn test_container_producer_connection_error_on_invalid_host() {
2196 let component = ContainerComponent::new();
2198 let ctx = NoOpComponentContext;
2199 let endpoint = component
2200 .create_endpoint("container:list?host=unix:///nonexistent/docker.sock", &ctx)
2201 .unwrap();
2202
2203 let ctx = ProducerContext::new();
2204 let result = endpoint.create_producer(rt(), &ctx);
2205
2206 assert!(
2208 result.is_err(),
2209 "Expected error when connecting to invalid host"
2210 );
2211 let err = result.unwrap_err();
2212 match &err {
2213 CamelError::ProcessorError(msg) => {
2214 assert!(
2215 msg.to_lowercase().contains("connection")
2216 || msg.to_lowercase().contains("connect")
2217 || msg.to_lowercase().contains("socket")
2218 || msg.contains("docker"),
2219 "Error message should indicate connection failure, got: {}",
2220 msg
2221 );
2222 }
2223 _ => panic!("Expected ProcessorError, got: {:?}", err),
2224 }
2225 }
2226
2227 #[tokio::test]
2229 async fn test_container_consumer_unsupported_operation() {
2230 use tokio::sync::mpsc;
2231
2232 let component = ContainerComponent::new();
2233 let ctx = NoOpComponentContext;
2234 let endpoint = component.create_endpoint("container:run", &ctx).unwrap();
2235 let mut consumer = endpoint.create_consumer(rt()).unwrap();
2236
2237 let (tx, _rx) = mpsc::channel(16);
2239 let cancel_token = tokio_util::sync::CancellationToken::new();
2240 let context = ConsumerContext::new(tx, cancel_token, "container-test-route".to_string());
2241
2242 let result = consumer.start(context).await;
2243
2244 assert!(
2246 result.is_err(),
2247 "Expected error for unsupported consumer operation"
2248 );
2249 let err = result.unwrap_err();
2250 match &err {
2251 CamelError::EndpointCreationFailed(msg) => {
2252 assert!(
2253 msg.contains("Consumer only supports 'events' or 'logs'"),
2254 "Error message should mention events or logs support, got: {}",
2255 msg
2256 );
2257 }
2258 _ => panic!("Expected EndpointCreationFailed error, got: {:?}", err),
2259 }
2260 }
2261
2262 #[test]
2263 fn test_container_consumer_concurrency_model_is_concurrent() {
2264 let consumer = ContainerConsumer {
2265 config: ContainerConfig::from_uri("container:events").unwrap(),
2266 runtime: test_rt(),
2267 };
2268
2269 assert_eq!(
2270 consumer.concurrency_model(),
2271 camel_component_api::ConcurrencyModel::Concurrent { max: None }
2272 );
2273 }
2274
2275 #[test]
2276 fn test_container_config_parses_volumes() {
2277 let config = ContainerConfig::from_uri(
2278 "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro",
2279 )
2280 .unwrap();
2281 assert_eq!(
2282 config.volumes.as_deref(),
2283 Some("./html:/usr/share/nginx/html:ro")
2284 );
2285 }
2286
2287 #[test]
2288 fn test_container_config_parses_exec_params() {
2289 let config = ContainerConfig::from_uri(
2290 "container:exec?containerId=my-app&cmd=ls /app&user=root&workdir=/tmp&detach=true",
2291 )
2292 .unwrap();
2293 assert_eq!(config.operation, "exec");
2294 assert_eq!(config.container_id.as_deref(), Some("my-app"));
2295 assert_eq!(config.cmd.as_deref(), Some("ls /app"));
2296 assert_eq!(config.user.as_deref(), Some("root"));
2297 assert_eq!(config.workdir.as_deref(), Some("/tmp"));
2298 assert!(config.detach);
2299 }
2300
2301 #[test]
2302 fn test_container_config_parses_network_create_params() {
2303 let config =
2304 ContainerConfig::from_uri("container:network-create?name=my-net&driver=bridge")
2305 .unwrap();
2306 assert_eq!(config.operation, "network-create");
2307 assert_eq!(config.name.as_deref(), Some("my-net"));
2308 assert_eq!(config.driver.as_deref(), Some("bridge"));
2309 }
2310
2311 #[test]
2312 fn test_container_config_defaults_new_fields() {
2313 let config = ContainerConfig::from_uri("container:list").unwrap();
2314 assert!(config.volumes.is_none());
2315 assert!(config.user.is_none());
2316 assert!(config.workdir.is_none());
2317 assert!(!config.detach);
2318 assert!(config.driver.is_none());
2319 assert!(!config.force);
2320 }
2321
2322 #[test]
2323 fn test_parse_volumes_bind_mount() {
2324 let config = ContainerConfig::from_uri(
2325 "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro",
2326 )
2327 .unwrap();
2328 let (binds, anon) = config.parse_volumes().unwrap();
2329 assert_eq!(binds, vec!["./html:/usr/share/nginx/html:ro"]);
2330 assert!(anon.is_empty());
2331 }
2332
2333 #[test]
2334 fn test_parse_volumes_named_volume() {
2335 let config =
2336 ContainerConfig::from_uri("container:run?image=postgres&volumes=data:/var/lib/data")
2337 .unwrap();
2338 let (binds, anon) = config.parse_volumes().unwrap();
2339 assert_eq!(binds, vec!["data:/var/lib/data"]);
2340 assert!(anon.is_empty());
2341 }
2342
2343 #[test]
2344 fn test_parse_volumes_anonymous() {
2345 let config =
2346 ContainerConfig::from_uri("container:run?image=alpine&volumes=/tmp/app-data").unwrap();
2347 let (binds, anon) = config.parse_volumes().unwrap();
2348 assert!(binds.is_empty());
2349 assert!(anon.contains(&"/tmp/app-data".to_string()));
2350 }
2351
2352 #[test]
2353 fn test_parse_volumes_anonymous_with_mode() {
2354 let config =
2355 ContainerConfig::from_uri("container:run?image=alpine&volumes=/tmp/app-data:ro")
2356 .unwrap();
2357 let (binds, anon) = config.parse_volumes().unwrap();
2358 assert!(binds.is_empty());
2359 assert!(anon.contains(&"/tmp/app-data".to_string()));
2360 }
2361
2362 #[test]
2363 fn test_parse_volumes_multiple() {
2364 let config = ContainerConfig::from_uri(
2365 "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro,data:/var/log/app",
2366 )
2367 .unwrap();
2368 let (binds, anon) = config.parse_volumes().unwrap();
2369 assert_eq!(binds.len(), 2);
2370 assert!(binds.contains(&"./html:/usr/share/nginx/html:ro".to_string()));
2371 assert!(binds.contains(&"data:/var/log/app".to_string()));
2372 assert!(anon.is_empty());
2373 }
2374
2375 #[test]
2376 fn test_parse_volumes_mixed() {
2377 let config = ContainerConfig::from_uri(
2378 "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro,/tmp/cache",
2379 )
2380 .unwrap();
2381 let (binds, anon) = config.parse_volumes().unwrap();
2382 assert_eq!(binds.len(), 1);
2383 assert!(anon.contains(&"/tmp/cache".to_string()));
2384 }
2385
2386 #[test]
2387 fn test_parse_volumes_none() {
2388 let config = ContainerConfig::from_uri("container:run?image=nginx").unwrap();
2389 assert!(config.parse_volumes().is_none());
2390 }
2391
2392 #[test]
2393 fn test_parse_volumes_empty_entry_skipped() {
2394 let config = ContainerConfig::from_uri("container:run?image=nginx&volumes=,,").unwrap();
2395 assert!(config.parse_volumes().is_none());
2396 }
2397
2398 #[test]
2399 fn test_parse_volumes_rw_mode() {
2400 let config =
2401 ContainerConfig::from_uri("container:run?image=nginx&volumes=./data:/app/data:rw")
2402 .unwrap();
2403 let (binds, _) = config.parse_volumes().unwrap();
2404 assert_eq!(binds, vec!["./data:/app/data:rw"]);
2405 }
2406
2407 #[test]
2408 fn test_container_config_from_uri_parses_false_flags() {
2409 let config = ContainerConfig::from_uri(
2410 "container:logs?containerId=a&follow=false×tamps=FALSE&autoPull=false&autoRemove=False&detach=TRUE&force=true",
2411 )
2412 .unwrap();
2413 assert!(!config.follow);
2414 assert!(!config.timestamps);
2415 assert!(!config.auto_pull);
2416 assert!(!config.auto_remove);
2417 assert!(config.detach);
2418 assert!(config.force);
2419 }
2420
2421 #[test]
2422 fn test_docker_socket_path_validation() {
2423 let unix_cfg =
2424 ContainerConfig::from_uri("container:list?host=unix:///tmp/docker.sock").unwrap();
2425 assert_eq!(
2426 unix_cfg.docker_socket_path().unwrap(),
2427 "unix:///tmp/docker.sock"
2428 );
2429
2430 let npipe_cfg =
2431 ContainerConfig::from_uri("container:list?host=npipe:////./pipe/docker_engine")
2432 .unwrap();
2433 assert_eq!(
2434 npipe_cfg.docker_socket_path().unwrap(),
2435 "npipe:////./pipe/docker_engine"
2436 );
2437
2438 let plain_cfg =
2439 ContainerConfig::from_uri("container:list?host=/var/run/docker.sock").unwrap();
2440 assert_eq!(
2441 plain_cfg.docker_socket_path().unwrap(),
2442 "/var/run/docker.sock"
2443 );
2444
2445 let bad_cfg =
2446 ContainerConfig::from_uri("container:list?host=http://localhost:2375").unwrap();
2447 assert!(bad_cfg.docker_socket_path().is_err());
2448 }
2449
2450 #[test]
2451 fn test_parse_ports_invalid_and_whitespace_entries() {
2452 let cfg = ContainerConfig::from_uri("container:run?ports= , ,8080").unwrap();
2454 let err = cfg.parse_ports().unwrap_err();
2455 assert!(
2456 err.to_string().contains("malformed port mapping"),
2457 "expected malformed port error, got: {}",
2458 err
2459 );
2460
2461 let cfg =
2462 ContainerConfig::from_uri("container:run?ports= 8080:80 , 5353:53/udp ").unwrap();
2463 let (exposed, bindings) = cfg.parse_ports().unwrap();
2464 assert!(exposed.contains(&"80/tcp".to_string()));
2465 assert!(exposed.contains(&"53/udp".to_string()));
2466 assert_eq!(bindings.len(), 2);
2467 }
2468
2469 #[test]
2470 fn test_parse_ports_fail_fast_on_malformed() {
2471 let cfg = ContainerConfig::from_uri("container:run?ports=8080:80,badentry").unwrap();
2473 let err = cfg.parse_ports().unwrap_err();
2474 assert!(
2475 err.to_string()
2476 .contains("malformed port mapping 'badentry'"),
2477 "expected fail-fast on 'badentry', got: {}",
2478 err
2479 );
2480 }
2481
2482 #[test]
2483 fn test_parse_env_trims_and_filters_empty_items() {
2484 let cfg = ContainerConfig::from_uri("container:run?env= FOO=bar , ,BAZ=qux, ").unwrap();
2485 let env = cfg.parse_env().unwrap();
2486 assert_eq!(env, vec!["FOO=bar".to_string(), "BAZ=qux".to_string()]);
2487
2488 let cfg = ContainerConfig::from_uri("container:run?env= , , ").unwrap();
2489 assert!(cfg.parse_env().is_none());
2490 }
2491
2492 #[test]
2493 fn test_parse_volume_str_rejects_invalid_mode_and_accepts_mixed() {
2494 assert!(parse_volume_str("/host:/ctr:badmode").is_none());
2495 let (binds, anon) = parse_volume_str("a:/b:rw,/tmp/cache,/tmp/logs:ro").unwrap();
2496 assert!(binds.contains(&"a:/b:rw".to_string()));
2497 assert!(anon.contains(&"/tmp/cache".to_string()));
2498 assert!(anon.contains(&"/tmp/logs".to_string()));
2499 }
2500
2501 #[test]
2502 fn test_format_docker_event_variants_and_timestamp_extraction() {
2503 let mut attrs = std::collections::HashMap::new();
2504 attrs.insert("name".to_string(), "demo".to_string());
2505 attrs.insert("image".to_string(), "alpine:latest".to_string());
2506 attrs.insert("exitCode".to_string(), "137".to_string());
2507 let actor = bollard::models::EventActor {
2508 id: None,
2509 attributes: Some(attrs),
2510 };
2511
2512 let create_event = bollard::models::EventMessage {
2513 action: Some("create".to_string()),
2514 actor: Some(actor.clone()),
2515 ..Default::default()
2516 };
2517 assert_eq!(
2518 format_docker_event(&create_event),
2519 "[CREATE] Container demo (alpine:latest)"
2520 );
2521
2522 let die_event = bollard::models::EventMessage {
2523 action: Some("die".to_string()),
2524 actor: Some(actor),
2525 ..Default::default()
2526 };
2527 assert_eq!(
2528 format_docker_event(&die_event),
2529 "[DIE] Container demo (exit: 137)"
2530 );
2531
2532 let other_event = bollard::models::EventMessage {
2533 action: Some("oom".to_string()),
2534 actor: None,
2535 ..Default::default()
2536 };
2537 assert_eq!(format_docker_event(&other_event), "[OOM] Container unknown");
2538
2539 assert_eq!(
2540 extract_timestamp("2024-01-01T00:00:00Z hello"),
2541 Some("2024-01-01T00:00:00Z".to_string())
2542 );
2543 assert_eq!(extract_timestamp("hello world"), None);
2544 }
2545
2546 #[tokio::test]
2547 async fn test_run_container_with_cleanup_error_paths() {
2548 let create_fail = run_container_with_cleanup(
2549 || async { Err(CamelError::ProcessorError("create-fail".to_string())) },
2550 |_id| async move { Ok(()) },
2551 |_id| async move { Ok(()) },
2552 )
2553 .await;
2554 assert!(
2555 matches!(create_fail, Err(CamelError::ProcessorError(msg)) if msg == "create-fail")
2556 );
2557
2558 let cleanup_fail = run_container_with_cleanup(
2559 || async { Ok("cid-1".to_string()) },
2560 |_id| async move { Err(CamelError::ProcessorError("start-fail".to_string())) },
2561 |_id| async move { Err(CamelError::ProcessorError("remove-fail".to_string())) },
2562 )
2563 .await;
2564 match cleanup_fail {
2565 Err(CamelError::ProcessorError(msg)) => {
2566 assert!(msg.contains("Failed to start container"));
2567 assert!(msg.contains("Cleanup failed"));
2568 }
2569 other => panic!("unexpected result: {:?}", other),
2570 }
2571 }
2572
2573 #[tokio::test]
2574 async fn test_logs_consumer_requires_container_id() {
2575 use tokio::sync::mpsc;
2576
2577 let mut consumer = ContainerConsumer {
2578 config: ContainerConfig::from_uri("container:logs").unwrap(),
2579 runtime: test_rt(),
2580 };
2581 let (tx, _rx) = mpsc::channel(4);
2582 let context = ConsumerContext::new(
2583 tx,
2584 tokio_util::sync::CancellationToken::new(),
2585 "container-test-route".to_string(),
2586 );
2587
2588 let err = consumer.start(context).await.unwrap_err();
2589 match err {
2590 CamelError::EndpointCreationFailed(msg) => {
2591 assert!(msg.contains("containerId is required for logs consumer"));
2592 }
2593 other => panic!("unexpected error: {:?}", other),
2594 }
2595 }
2596
2597 #[tokio::test]
2598 async fn test_events_consumer_stops_immediately_when_cancelled() {
2599 use tokio::sync::mpsc;
2600
2601 let mut consumer = ContainerConsumer {
2602 config: ContainerConfig::from_uri("container:events").unwrap(),
2603 runtime: test_rt(),
2604 };
2605
2606 let (tx, _rx) = mpsc::channel(4);
2607 let cancel = tokio_util::sync::CancellationToken::new();
2608 cancel.cancel();
2609 let context = ConsumerContext::new(tx, cancel, "container-test-route".to_string());
2610
2611 let result = consumer.start(context).await;
2612 assert!(result.is_ok());
2613 }
2614
2615 #[test]
2616 fn test_global_config_constructors_and_endpoint_docker_host() {
2617 let global = ContainerGlobalConfig::new().with_docker_host("unix:///tmp/docker.sock");
2618 let mut cfg = ContainerConfig::from_uri("container:list").unwrap();
2619 cfg.apply_global_defaults(&global);
2620
2621 let endpoint = ContainerEndpoint {
2622 uri: "container:list".to_string(),
2623 config: cfg,
2624 };
2625
2626 assert_eq!(endpoint.docker_host(), Some("unix:///tmp/docker.sock"));
2627
2628 let component = ContainerComponent::with_config(global);
2629 assert_eq!(component.scheme(), "container");
2630 }
2631
2632 #[test]
2633 fn container_global_config_has_reconnect_policy() {
2634 let cfg = ContainerGlobalConfig::default();
2635 assert_eq!(cfg.reconnect.max_attempts, 0); assert!(cfg.reconnect.enabled);
2637 }
2638
2639 #[tokio::test]
2644 async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
2645 use std::sync::Arc;
2646 use std::sync::atomic::{AtomicU32, Ordering};
2647 use std::time::Duration;
2648
2649 let policy = NetworkRetryPolicy {
2650 max_attempts: 3,
2651 initial_delay: Duration::from_millis(1),
2652 max_delay: Duration::from_millis(1),
2653 multiplier: 1.0,
2654 ..NetworkRetryPolicy::default()
2655 };
2656
2657 let calls = Arc::new(AtomicU32::new(0));
2658 let calls_clone = Arc::clone(&calls);
2659 let mut attempt: u32 = 0;
2660
2661 loop {
2662 calls_clone.fetch_add(1, Ordering::SeqCst);
2663 let result: Result<(), ()> = Err(());
2664 match result {
2665 Ok(_) => {
2666 break;
2667 }
2668 Err(_) => {
2669 attempt += 1;
2670 if !policy.should_retry(attempt) {
2671 break;
2672 }
2673 let delay = policy.delay_for(attempt - 1);
2674 tokio::time::sleep(delay).await;
2675 continue;
2676 }
2677 }
2678 }
2679
2680 assert_eq!(
2681 calls.load(Ordering::SeqCst),
2682 3,
2683 "max_attempts=3 must yield exactly 3 invocations"
2684 );
2685 }
2686
2687 #[tokio::test]
2696 async fn events_connect_error_increments_metrics() {
2697 use std::sync::Mutex;
2698 use std::time::Duration;
2699
2700 struct RecordingMetrics(Mutex<Vec<(String, String)>>);
2701
2702 impl MetricsCollector for RecordingMetrics {
2703 fn record_exchange_duration(&self, _: &str, _: Duration) {}
2704 fn increment_errors(&self, route_id: &str, error_type: &str) {
2705 self.0
2706 .lock()
2707 .unwrap()
2708 .push((route_id.to_string(), error_type.to_string()));
2709 }
2710 fn increment_exchanges(&self, _: &str) {}
2711 fn set_queue_depth(&self, _: &str, _: usize) {}
2712 fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
2713 }
2714
2715 struct RecordingRuntime {
2716 metrics: Arc<RecordingMetrics>,
2717 }
2718
2719 impl HealthCheckRegistry for RecordingRuntime {
2720 fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
2721 }
2722
2723 impl RuntimeObservability for RecordingRuntime {
2724 fn metrics(&self) -> Arc<dyn MetricsCollector> {
2725 self.metrics.clone()
2726 }
2727 fn health(&self) -> Arc<dyn HealthCheckRegistry> {
2728 Arc::new(camel_component_api::NoOpHealthCheckRegistry)
2729 }
2730 }
2731
2732 let recording = Arc::new(RecordingMetrics(Mutex::new(Vec::new())));
2733 let rt: Arc<dyn RuntimeObservability> = Arc::new(RecordingRuntime {
2734 metrics: recording.clone(),
2735 });
2736
2737 let mut config = ContainerConfig::from_uri("container:events").unwrap();
2741 config.host = Some("tcp://192.0.2.1:2375".to_string());
2742 config.reconnect = NetworkRetryPolicy::disabled();
2743
2744 let mut consumer = ContainerConsumer {
2745 config,
2746 runtime: rt,
2747 };
2748
2749 let (tx, _rx) = tokio::sync::mpsc::channel(4);
2750 let cancel = tokio_util::sync::CancellationToken::new();
2751 let context = ConsumerContext::new(tx, cancel, "events-test-route".to_string());
2752
2753 let result = consumer.start(context).await;
2754 assert!(result.is_err(), "expected Docker connection error");
2755
2756 let errors = recording.0.lock().unwrap();
2757 assert_eq!(
2758 errors.len(),
2759 1,
2760 "expected exactly one increment_errors call"
2761 );
2762 assert_eq!(errors[0].0, "events-test-route");
2763 assert_eq!(errors[0].1, "e:container:events-connect");
2764 }
2765
2766 #[test]
2767 fn test_connect_docker_from_host_rejects_bad_scheme() {
2768 let err = connect_docker_from_host(Some("tcp://localhost:2375")).unwrap_err();
2769 match &err {
2770 CamelError::ProcessorError(msg) => {
2771 assert!(msg.contains("tcp"), "expected tcp rejection, got: {}", msg);
2772 }
2773 _ => panic!("expected ProcessorError, got: {:?}", err),
2774 }
2775 }
2776
2777 #[test]
2778 fn test_connect_docker_from_host_accepts_unix_scheme() {
2779 let result = connect_docker_from_host(Some("unix:///nonexistent/docker.sock"));
2781 match result {
2782 Err(CamelError::ProcessorError(msg)) => {
2783 assert!(
2785 msg.contains("nonexistent")
2786 || msg.contains("connect")
2787 || msg.contains("socket"),
2788 "expected error referencing socket, got: {}",
2789 msg
2790 );
2791 }
2792 Ok(_) => {} Err(other) => panic!("unexpected error type: {:?}", other),
2794 }
2795 }
2796
2797 #[test]
2798 fn test_connect_docker_from_host_accepts_schemeless_path() {
2799 let result = connect_docker_from_host(Some("/nonexistent/docker.sock"));
2801 match result {
2802 Err(CamelError::ProcessorError(msg)) => {
2803 assert!(
2804 msg.contains("nonexistent")
2805 || msg.contains("connect")
2806 || msg.contains("socket"),
2807 "expected error referencing socket, got: {}",
2808 msg
2809 );
2810 }
2811 Ok(_) => {} Err(other) => panic!("unexpected error type: {:?}", other),
2813 }
2814 }
2815
2816 #[tokio::test]
2817 async fn test_cleanup_respects_custom_docker_host() {
2818 track_container("test-custom-host-container".to_string());
2820
2821 cleanup_tracked_containers(Some("unix:///nonexistent/docker.sock")).await;
2824
2825 untrack_container("test-custom-host-container");
2827 }
2828
2829 #[tokio::test]
2830 async fn test_cleanup_none_uses_defaults() {
2831 cleanup_tracked_containers(None).await;
2833 }
2834
2835 #[test]
2836 fn uri_options_count_parity() {
2837 assert_eq!(
2838 ContainerConfig::uri_options().len(),
2839 19,
2840 "ContainerUriConfig #[uri_param] count drifted from parser"
2841 );
2842 }
2843}