Skip to main content

camel_component_container/
lib.rs

1//! Camel Container Component
2//!
3//! This component provides integration with Docker containers, allowing Camel routes
4//! to manage container lifecycle (create, start, stop, remove) and consume container events.
5
6pub 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
41/// Global tracker for containers created by this component.
42/// Used for cleanup on shutdown (especially important for hot-reload scenarios).
43static CONTAINER_TRACKER: once_cell::sync::Lazy<Arc<Mutex<HashSet<String>>>> =
44    once_cell::sync::Lazy::new(|| Arc::new(Mutex::new(HashSet::new())));
45
46/// Registers a container ID for tracking (will be cleaned up on shutdown).
47fn track_container(id: String) {
48    if let Ok(mut tracker) = CONTAINER_TRACKER.lock() {
49        tracker.insert(id);
50    }
51}
52
53/// Removes a container ID from tracking (when it's been removed naturally).
54fn untrack_container(id: &str) {
55    if let Ok(mut tracker) = CONTAINER_TRACKER.lock() {
56        tracker.remove(id);
57    }
58}
59
60/// Validates a Docker host string, accepting `unix://`, `npipe://`, or
61/// schemeless paths. Returns the host as-is on success.
62fn 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
75/// Creates a Docker client connection, optionally using a custom host.
76///
77/// When `docker_host` is `Some`, validates the scheme (accepts `unix://`,
78/// `npipe://`, or schemeless paths) and connects via the given socket.  When
79/// `None`, connects using Docker's local-defaults resolution.
80fn 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
99/// Cleans up all tracked containers. Call this on application shutdown.
100///
101/// `docker_host` allows cleanup to use a non-default Docker socket (e.g. for
102/// hot-reload scenarios where the host was configured).  Pass `None` to use
103/// Docker's local defaults.
104pub 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            // log-policy: system-broken
122            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
149// Header constants for container operations
150
151/// Timeout (seconds) for connecting to the Docker daemon.
152const DOCKER_CONNECT_TIMEOUT_SECS: u64 = 120;
153
154/// Header key for specifying the container action (e.g., "list", "run", "start", "stop", "remove").
155pub const HEADER_ACTION: &str = "CamelContainerAction";
156
157/// Header key for specifying the container image to use for "run" operations.
158pub const HEADER_IMAGE: &str = "CamelContainerImage";
159
160/// Header key for specifying or receiving the container ID.
161pub const HEADER_CONTAINER_ID: &str = "CamelContainerId";
162
163/// Header key for the log stream type (stdout or stderr).
164pub const HEADER_LOG_STREAM: &str = "CamelContainerLogStream";
165
166/// Header key for the log timestamp.
167pub const HEADER_LOG_TIMESTAMP: &str = "CamelContainerLogTimestamp";
168
169/// Header key for specifying the container name for "run" operations.
170pub const HEADER_CONTAINER_NAME: &str = "CamelContainerName";
171
172/// Header key for the result status of a container operation (e.g., "success").
173pub const HEADER_ACTION_RESULT: &str = "CamelContainerActionResult";
174
175/// Header key for specifying the command to execute in a container.
176pub const HEADER_CMD: &str = "CamelContainerCmd";
177
178/// Header key for specifying the network name for network operations.
179pub const HEADER_NETWORK: &str = "CamelContainerNetwork";
180
181/// Header key for the exit code of an exec operation.
182pub const HEADER_EXIT_CODE: &str = "CamelContainerExitCode";
183
184/// Header key for specifying volume mounts.
185pub const HEADER_VOLUMES: &str = "CamelContainerVolumes";
186
187/// Header key for the exec instance ID.
188pub const HEADER_EXEC_ID: &str = "CamelContainerExecId";
189
190// ---------------------------------------------------------------------------
191// ContainerGlobalConfig
192// ---------------------------------------------------------------------------
193
194/// Per-component reconnect default: unlimited retries (max_attempts=0)
195/// with a fixed 5s delay, preserving the old infinite-reconnect behavior.
196/// Operators can opt into bounded retry via TOML `[reconnect]`.
197fn container_reconnect_default() -> NetworkRetryPolicy {
198    NetworkRetryPolicy {
199        enabled: true,
200        max_attempts: 0, // unlimited
201        initial_delay: std::time::Duration::from_secs(5),
202        multiplier: 1.0, // fixed delay (old behavior was no backoff)
203        max_delay: std::time::Duration::from_secs(5),
204        jitter_factor: 0.0,
205        max_attempts_absolute: None,
206    }
207}
208
209/// Global configuration for Container component.
210/// Supports serde deserialization with defaults and builder methods.
211/// These are the fallback defaults when URI params are not set.
212#[derive(Debug, Clone, PartialEq, serde::Deserialize)]
213#[serde(default)]
214pub struct ContainerGlobalConfig {
215    /// The Docker host URL (default: "unix:///var/run/docker.sock").
216    pub docker_host: String,
217    /// Reconnection policy for events/logs consumers (default: unlimited, 5s fixed delay).
218    #[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// ---------------------------------------------------------------------------
243// ContainerConfig (endpoint configuration)
244// ---------------------------------------------------------------------------
245
246/// Configuration for the container component endpoint.
247///
248/// This struct holds the parsed URI configuration including the operation type,
249/// optional container image, and Docker host connection details.
250#[derive(Debug, Clone)]
251pub struct ContainerConfig {
252    /// The operation to perform (e.g., "list", "run", "start", "stop", "remove", "events").
253    pub operation: String,
254    /// The container image to use for "run" operations (can be overridden via header).
255    pub image: Option<String>,
256    /// The container name to use for "run" operations (can be overridden via header).
257    pub name: Option<String>,
258    /// The Docker host URL (defaults to "unix:///var/run/docker.sock").
259    pub host: Option<String>,
260    /// Command to run in the container (e.g., "sleep 30").
261    pub cmd: Option<String>,
262    /// Port mappings in format "hostPort:containerPort" (e.g., "8080:80,8443:443").
263    pub ports: Option<String>,
264    /// Environment variables in format "KEY=value,KEY2=value2".
265    pub env: Option<String>,
266    /// Network mode (e.g., "bridge", "host", "none"). Default: "bridge".
267    pub network: Option<String>,
268    /// Container ID or name for logs consumer.
269    pub container_id: Option<String>,
270    /// Follow log output (default: true for consumer).
271    pub follow: bool,
272    /// Include timestamps in logs (default: false).
273    pub timestamps: bool,
274    /// Number of lines to show from the end of logs (default: all).
275    pub tail: Option<String>,
276    /// Automatically pull the image if not present (default: true).
277    pub auto_pull: bool,
278    /// Automatically remove the container when it exits (default: true).
279    pub auto_remove: bool,
280    /// Volume mounts in format "host:container:ro" (e.g., "./html:/usr/share/nginx/html:ro").
281    pub volumes: Option<String>,
282    /// User to run the container or exec command as (e.g., "root").
283    pub user: Option<String>,
284    /// Working directory inside the container.
285    pub workdir: Option<String>,
286    /// Whether to detach from the exec process (default: false).
287    pub detach: bool,
288    /// Network driver for network-create (e.g., "bridge", "overlay").
289    pub driver: Option<String>,
290    /// Whether to force the operation (default: false).
291    pub force: bool,
292    /// Reconnection policy for events/logs consumers (applied from global config).
293    pub reconnect: NetworkRetryPolicy,
294}
295
296/// Private container for macro-derived `uri_options()` and `metadata()`.
297///
298/// Mirrors `ContainerConfig`'s URI-parsed fields exactly. `ContainerConfig`
299/// holds the public struct with non-URI fields (`reconnect`) appended;
300/// metadata delegation targets this inner type.
301#[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    /// The operation to perform (e.g., "list", "run", "start", "stop", "remove", "events").
316    pub operation: String,
317    /// The container image to use for "run" operations (can be overridden via header).
318    #[uri_param(desc = "Container image to use for run operations")]
319    pub image: Option<String>,
320    /// The container name to use for "run" operations (can be overridden via header).
321    #[uri_param(desc = "Container name for run operations")]
322    pub name: Option<String>,
323    /// The Docker host URL (defaults to "unix:///var/run/docker.sock").
324    #[uri_param(desc = "Docker host URL")]
325    pub host: Option<String>,
326    /// Command to run in the container (e.g., "sleep 30").
327    #[uri_param(desc = "Command to run in the container")]
328    pub cmd: Option<String>,
329    /// Port mappings in format "hostPort:containerPort" (e.g., "8080:80,8443:443").
330    #[uri_param(desc = "Port mappings in hostPort:containerPort format")]
331    pub ports: Option<String>,
332    /// Environment variables in format "KEY=value,KEY2=value2".
333    #[uri_param(desc = "Environment variables in KEY=value format")]
334    pub env: Option<String>,
335    /// Network mode (e.g., "bridge", "host", "none"). Default: "bridge".
336    #[uri_param(default = "bridge", desc = "Network mode (bridge, host, none)")]
337    pub network: Option<String>,
338    /// Container ID or name for logs consumer.
339    #[uri_param(name = "containerId", desc = "Container ID or name for logs consumer")]
340    pub container_id: Option<String>,
341    /// Follow log output (default: true for consumer).
342    #[uri_param(default = "true", desc = "Follow log output")]
343    pub follow: bool,
344    /// Include timestamps in logs (default: false).
345    #[uri_param(default = "false", desc = "Include timestamps in logs")]
346    pub timestamps: bool,
347    /// Number of lines to show from the end of logs (default: all).
348    #[uri_param(desc = "Number of lines to show from end of logs")]
349    pub tail: Option<String>,
350    /// Automatically pull the image if not present (default: true).
351    #[uri_param(
352        name = "autoPull",
353        default = "true",
354        desc = "Automatically pull the image if not present"
355    )]
356    pub auto_pull: bool,
357    /// Automatically remove the container when it exits (default: true).
358    #[uri_param(
359        name = "autoRemove",
360        default = "true",
361        desc = "Automatically remove container when it exits"
362    )]
363    pub auto_remove: bool,
364    /// Volume mounts in format "host:container:ro" (e.g., "./html:/usr/share/nginx/html:ro").
365    #[uri_param(desc = "Volume mounts in host:container:ro format")]
366    pub volumes: Option<String>,
367    /// User to run the container or exec command as (e.g., "root").
368    #[uri_param(desc = "User to run the container or exec command as")]
369    pub user: Option<String>,
370    /// Working directory inside the container.
371    #[uri_param(desc = "Working directory inside the container")]
372    pub workdir: Option<String>,
373    /// Whether to detach from the exec process (default: false).
374    #[uri_param(default = "false", desc = "Whether to detach from the exec process")]
375    pub detach: bool,
376    /// Network driver for network-create (e.g., "bridge", "overlay").
377    #[uri_param(desc = "Network driver for network-create")]
378    pub driver: Option<String>,
379    /// Whether to force the operation (default: false).
380    #[uri_param(default = "false", desc = "Whether to force the operation")]
381    pub force: bool,
382}
383
384impl ContainerConfig {
385    /// Parses a container URI into a `ContainerConfig`.
386    ///
387    /// # Arguments
388    /// * `uri` - The URI to parse (e.g., "container:run?image=alpine")
389    ///
390    /// # Errors
391    /// Returns an error if the URI scheme is not "container".
392    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        // host is only set from URI param; global config defaults are applied later
430        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    /// Apply global config defaults to this endpoint config.
472    /// Only sets values that are currently `None`.
473    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    /// Connects to the Docker daemon using the configured host.
496    ///
497    /// This method establishes a Unix socket connection to Docker and verifies
498    /// the connection by sending a ping request.
499    ///
500    /// # Errors
501    /// Returns an error if the connection fails or the ping request fails.
502    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    /// Component metadata for the container scheme, generated by the
582    /// `#[derive(UriConfig)]` macro on the private `ContainerUriConfig`.
583    pub fn metadata() -> ComponentMetadata {
584        ContainerUriConfig::metadata()
585    }
586
587    /// Generated URI option definitions for the container scheme, derived
588    /// from `#[uri_param]` annotations on `ContainerUriConfig`.
589    pub fn uri_options() -> Vec<UriOption> {
590        ContainerUriConfig::uri_options()
591    }
592}
593
594/// Parses a volume specification string into bind mounts and anonymous volumes.
595///
596/// Format: `host:container:ro|rw` for bind mounts, `path` for anonymous volumes,
597/// `path:ro|rw` for anonymous volumes with mode, or `name:container` for named volumes.
598#[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                // Log progress every 2 seconds
732                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/// Producer for executing container operations.
1422///
1423/// This producer handles synchronous container operations like listing,
1424/// creating, starting, stopping, and removing containers.
1425#[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
1496/// Consumer for receiving Docker container events or logs.
1497///
1498/// This consumer subscribes to Docker events or container logs and forwards them
1499/// to the route as exchanges. It implements automatic reconnection on connection failures.
1500pub struct ContainerConsumer {
1501    config: ContainerConfig,
1502    /// ADR-0012 observability handle: `rt.metrics().increment_errors(...)` and
1503    /// `rt.health().force_unhealthy_for_route(...)` calls.
1504    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        // Outer reconnect loop: when the inner event stream breaks, this loop
1542        // reconnects. The connect_docker() retry is now backed by
1543        // retry_async_cancelable (migrated from manual loop in rc-k9c).
1544        loop {
1545            let docker = match retry_async_cancelable(
1546                &self.config.reconnect,
1547                "container",
1548                "events-connect",
1549                || async { self.config.connect_docker().await },
1550                |_| true,
1551                &cancel,
1552                Some(self.runtime.metrics().as_ref()),
1553            )
1554            .await
1555            {
1556                Ok(d) => d,
1557                Err(_) if context.is_cancelled() => {
1558                    tracing::info!("Container events consumer shutting down");
1559                    return Ok(());
1560                }
1561                Err(e) => {
1562                    // Retry-exhaustion error accounting is owned by
1563                    // retry_async_cancelable (e:container:events-connect).
1564                    // log-policy: outside-contract
1565                    tracing::error!(error = %e, "Container events consumer exhausted reconnect attempts");
1566                    return Err(e);
1567                }
1568            };
1569
1570            let mut event_stream = docker.events(None::<EventsOptions>);
1571
1572            loop {
1573                tokio::select! {
1574                    _ = context.cancelled() => {
1575                        tracing::info!("Container events consumer shutting down");
1576                        return Ok(());
1577                    }
1578
1579                    msg = event_stream.next() => {
1580                        match msg {
1581                            Some(Ok(event)) => {
1582                                let formatted = format_docker_event(&event);
1583                                let message = Message::new(Body::Text(formatted));
1584                                let exchange = Exchange::new(message);
1585
1586                                if let Err(e) = context.send(exchange).await {
1587                                    // log-policy: system-broken
1588                                    tracing::error!("Failed to send exchange: {:?}", e);
1589                                    break;
1590                                }
1591                            }
1592                            Some(Err(e)) => {
1593                                self.runtime.metrics().increment_errors(context.route_id(), "e:container:events-stream");
1594                                // log-policy: outside-contract
1595                                tracing::error!("Docker event stream error: {}. Reconnecting...", e);
1596                                break;
1597                            }
1598                            None => {
1599                                tracing::info!("Docker event stream ended. Reconnecting...");
1600                                break;
1601                            }
1602                        }
1603                    }
1604                }
1605            }
1606
1607            tokio::select! {
1608                _ = context.cancelled() => {
1609                    tracing::info!("Container events consumer shutting down");
1610                    return Ok(());
1611                }
1612                _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1613            }
1614        }
1615    }
1616
1617    async fn start_logs_consumer(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
1618        use futures::StreamExt;
1619
1620        let container_id = self.config.container_id.clone().ok_or_else(|| {
1621            CamelError::EndpointCreationFailed(
1622                "containerId is required for logs consumer. Use container:logs?containerId=xxx"
1623                    .to_string(),
1624            )
1625        })?;
1626
1627        let cancel = context.cancel_token();
1628
1629        // Outer reconnect loop: when the inner log stream breaks, this loop
1630        // reconnects. The connect_docker() retry is now backed by
1631        // retry_async_cancelable (migrated from manual loop in rc-k9c).
1632        loop {
1633            let docker = match retry_async_cancelable(
1634                &self.config.reconnect,
1635                "container",
1636                "logs-connect",
1637                || async { self.config.connect_docker().await },
1638                |_| true,
1639                &cancel,
1640                Some(self.runtime.metrics().as_ref()),
1641            )
1642            .await
1643            {
1644                Ok(d) => d,
1645                Err(_) if context.is_cancelled() => {
1646                    tracing::info!("Container logs consumer shutting down");
1647                    return Ok(());
1648                }
1649                Err(e) => {
1650                    // Retry-exhaustion error accounting is owned by
1651                    // retry_async_cancelable (e:container:logs-connect).
1652                    // log-policy: outside-contract
1653                    tracing::error!(error = %e, "Container logs consumer exhausted reconnect attempts");
1654                    return Err(e);
1655                }
1656            };
1657
1658            let tail = self
1659                .config
1660                .tail
1661                .clone()
1662                .unwrap_or_else(|| "all".to_string());
1663
1664            let options = LogsOptions {
1665                follow: self.config.follow,
1666                stdout: true,
1667                stderr: true,
1668                timestamps: self.config.timestamps,
1669                tail,
1670                ..Default::default()
1671            };
1672
1673            let mut log_stream = docker.logs(&container_id, Some(options));
1674            let container_id_header = container_id.clone();
1675
1676            loop {
1677                tokio::select! {
1678                    _ = context.cancelled() => {
1679                        tracing::info!("Container logs consumer shutting down");
1680                        return Ok(());
1681                    }
1682
1683                    msg = log_stream.next() => {
1684                        match msg {
1685                            Some(Ok(log_output)) => {
1686                                let (stream_type, content) = match log_output {
1687                                    bollard::container::LogOutput::StdOut { message } => {
1688                                        ("stdout", String::from_utf8_lossy(&message).into_owned())
1689                                    }
1690                                    bollard::container::LogOutput::StdErr { message } => {
1691                                        ("stderr", String::from_utf8_lossy(&message).into_owned())
1692                                    }
1693                                    bollard::container::LogOutput::Console { message } => {
1694                                        ("console", String::from_utf8_lossy(&message).into_owned())
1695                                    }
1696                                    bollard::container::LogOutput::StdIn { message } => {
1697                                        ("stdin", String::from_utf8_lossy(&message).into_owned())
1698                                    }
1699                                };
1700
1701                                let content = content.trim_end();
1702                                if content.is_empty() {
1703                                    continue;
1704                                }
1705
1706                                let mut message = Message::new(Body::Text(content.to_string()));
1707                                message.set_header(
1708                                    HEADER_CONTAINER_ID,
1709                                    serde_json::Value::String(container_id_header.clone()),
1710                                );
1711                                message.set_header(
1712                                    HEADER_LOG_STREAM,
1713                                    serde_json::Value::String(stream_type.to_string()),
1714                                );
1715
1716                                if self.config.timestamps
1717                                    && let Some(ts) = extract_timestamp(content) {
1718                                        message.set_header(
1719                                            HEADER_LOG_TIMESTAMP,
1720                                            serde_json::Value::String(ts),
1721                                        );
1722                                    }
1723
1724                                let exchange = Exchange::new(message);
1725
1726                                if let Err(e) = context.send(exchange).await {
1727                                    // log-policy: system-broken
1728                                    tracing::error!("Failed to send log exchange: {:?}", e);
1729                                    break;
1730                                }
1731                            }
1732                            Some(Err(e)) => {
1733                                self.runtime.metrics().increment_errors(context.route_id(), "e:container:logs-stream");
1734                                // log-policy: outside-contract
1735                                tracing::error!("Docker log stream error: {}. Reconnecting...", e);
1736                                break;
1737                            }
1738                            None => {
1739                                if self.config.follow {
1740                                    tracing::info!("Docker log stream ended. Reconnecting...");
1741                                    break;
1742                                } else {
1743                                    tracing::info!("Container logs consumer finished (follow=false)");
1744                                    return Ok(());
1745                                }
1746                            }
1747                        }
1748                    }
1749                }
1750            }
1751
1752            tokio::select! {
1753                _ = context.cancelled() => {
1754                    tracing::info!("Container logs consumer shutting down");
1755                    return Ok(());
1756                }
1757                _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1758            }
1759        }
1760    }
1761}
1762
1763fn extract_timestamp(log_line: &str) -> Option<String> {
1764    let parts: Vec<&str> = log_line.splitn(2, ' ').collect();
1765    if parts.len() > 1 && parts[0].contains('T') {
1766        Some(parts[0].to_string())
1767    } else {
1768        None
1769    }
1770}
1771
1772/// Component for creating container endpoints.
1773///
1774/// This component handles URIs with the "container" scheme and creates
1775/// appropriate producer and consumer endpoints for Docker operations.
1776///
1777/// Containers created via `run` operation are tracked globally and can be
1778/// cleaned up on shutdown by calling
1779/// `cleanup_tracked_containers(docker_host: Option<&str>)`.
1780pub struct ContainerComponent {
1781    config: Option<ContainerGlobalConfig>,
1782}
1783
1784impl ContainerComponent {
1785    /// Creates a new container component instance without global config.
1786    pub fn new() -> Self {
1787        Self { config: None }
1788    }
1789
1790    /// Creates a container component with the given global config.
1791    pub fn with_config(config: ContainerGlobalConfig) -> Self {
1792        Self {
1793            config: Some(config),
1794        }
1795    }
1796
1797    /// Creates a container component with optional global config.
1798    pub fn with_optional_config(config: Option<ContainerGlobalConfig>) -> Self {
1799        Self { config }
1800    }
1801}
1802
1803impl Default for ContainerComponent {
1804    fn default() -> Self {
1805        Self::new()
1806    }
1807}
1808
1809impl Component for ContainerComponent {
1810    fn scheme(&self) -> &str {
1811        "container"
1812    }
1813
1814    fn metadata(&self) -> ComponentMetadata {
1815        ContainerConfig::metadata()
1816    }
1817
1818    fn create_endpoint(
1819        &self,
1820        uri: &str,
1821        ctx: &dyn camel_component_api::ComponentContext,
1822    ) -> Result<Box<dyn Endpoint>, CamelError> {
1823        let mut config = ContainerConfig::from_uri(uri)?;
1824        // Apply global defaults if present and URI didn't set them
1825        if let Some(ref global) = self.config {
1826            config.apply_global_defaults(global);
1827        }
1828        let health_check = ContainerHealthCheck::new(&config);
1829        ctx.register_current_route_health_check(Arc::new(health_check));
1830        Ok(Box::new(ContainerEndpoint {
1831            uri: uri.to_string(),
1832            config,
1833        }))
1834    }
1835}
1836
1837/// Endpoint for container operations.
1838///
1839/// This endpoint creates producers for executing container operations
1840/// and consumers for receiving container events.
1841// TODO(CON-003): Forward container health status (inspect healthcheck / Health field) to
1842// Camel's health subsystem so the route can react to unhealthy containers.
1843pub struct ContainerEndpoint {
1844    uri: String,
1845    config: ContainerConfig,
1846}
1847
1848impl ContainerEndpoint {
1849    /// Returns the Docker host configured for this endpoint.
1850    /// Returns `None` if not set (for testing purposes).
1851    pub fn docker_host(&self) -> Option<&str> {
1852        self.config.host.as_deref()
1853    }
1854}
1855
1856impl Endpoint for ContainerEndpoint {
1857    fn uri(&self) -> &str {
1858        &self.uri
1859    }
1860
1861    fn create_consumer(
1862        &self,
1863        rt: Arc<dyn camel_component_api::RuntimeObservability>,
1864    ) -> Result<Box<dyn Consumer>, CamelError> {
1865        Ok(Box::new(ContainerConsumer::new(self.config.clone(), rt)))
1866    }
1867
1868    fn create_producer(
1869        &self,
1870        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1871        _ctx: &ProducerContext,
1872    ) -> Result<BoxProcessor, CamelError> {
1873        let docker = self.config.connect_docker_client()?;
1874        Ok(BoxProcessor::new(ContainerProducer {
1875            config: self.config.clone(),
1876            docker,
1877        }))
1878    }
1879}
1880
1881#[cfg(test)]
1882mod tests {
1883    use camel_component_api::test_support::PanicRuntimeObservability;
1884    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1885        std::sync::Arc::new(PanicRuntimeObservability)
1886    }
1887    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1888        std::sync::Arc::new(PanicRuntimeObservability)
1889    }
1890
1891    use super::*;
1892    use camel_api::MetricsCollector;
1893    use camel_component_api::HealthCheckRegistry;
1894    use camel_component_api::NoOpComponentContext;
1895
1896    #[test]
1897    fn test_container_config() {
1898        let config = ContainerConfig::from_uri("container:run?image=alpine").unwrap();
1899        assert_eq!(config.operation, "run");
1900        assert_eq!(config.image.as_deref(), Some("alpine"));
1901        // host is None by default; global config applies it later
1902        assert!(config.host.is_none());
1903    }
1904
1905    #[test]
1906    fn test_global_config_applied_to_endpoint() {
1907        // When global config is set and URI doesn't specify host,
1908        // apply_global_defaults should set host from global config.
1909        let global =
1910            ContainerGlobalConfig::default().with_docker_host("unix:///custom/docker.sock");
1911        let mut config = ContainerConfig::from_uri("container:run?image=alpine").unwrap();
1912        assert!(
1913            config.host.is_none(),
1914            "URI without ?host= should leave host as None"
1915        );
1916        config.apply_global_defaults(&global);
1917        assert_eq!(
1918            config.host.as_deref(),
1919            Some("unix:///custom/docker.sock"),
1920            "global docker_host must be applied when URI did not set host"
1921        );
1922    }
1923
1924    #[test]
1925    fn test_uri_param_wins_over_global_config() {
1926        // When URI explicitly sets host param, apply_global_defaults must NOT override it.
1927        let global =
1928            ContainerGlobalConfig::default().with_docker_host("unix:///custom/docker.sock");
1929        let mut config =
1930            ContainerConfig::from_uri("container:run?image=alpine&host=unix:///override.sock")
1931                .unwrap();
1932        assert_eq!(
1933            config.host.as_deref(),
1934            Some("unix:///override.sock"),
1935            "URI-set host should be parsed correctly"
1936        );
1937        config.apply_global_defaults(&global);
1938        assert_eq!(
1939            config.host.as_deref(),
1940            Some("unix:///override.sock"),
1941            "global config must NOT override a host already set by URI"
1942        );
1943    }
1944
1945    #[test]
1946    fn test_container_config_parses_name() {
1947        let config = ContainerConfig::from_uri("container:run?name=my-container").unwrap();
1948        assert_eq!(config.name.as_deref(), Some("my-container"));
1949    }
1950
1951    #[test]
1952    fn test_parse_producer_operation_known() {
1953        assert_eq!(
1954            parse_producer_operation("list").unwrap(),
1955            ProducerOperation::List
1956        );
1957        assert_eq!(
1958            parse_producer_operation("run").unwrap(),
1959            ProducerOperation::Run
1960        );
1961        assert_eq!(
1962            parse_producer_operation("start").unwrap(),
1963            ProducerOperation::Start
1964        );
1965        assert_eq!(
1966            parse_producer_operation("stop").unwrap(),
1967            ProducerOperation::Stop
1968        );
1969        assert_eq!(
1970            parse_producer_operation("remove").unwrap(),
1971            ProducerOperation::Remove
1972        );
1973    }
1974
1975    #[test]
1976    fn test_parse_producer_operation_unknown() {
1977        let err = parse_producer_operation("destruir_mundo").unwrap_err();
1978        match err {
1979            CamelError::ProcessorError(msg) => {
1980                assert!(
1981                    msg.contains("Unknown container operation"),
1982                    "Unexpected error message: {}",
1983                    msg
1984                );
1985            }
1986            _ => panic!("Expected ProcessorError for unknown operation"),
1987        }
1988    }
1989
1990    #[test]
1991    fn test_parse_producer_operation_new_variants() {
1992        assert_eq!(
1993            parse_producer_operation("exec").unwrap(),
1994            ProducerOperation::Exec
1995        );
1996        assert_eq!(
1997            parse_producer_operation("network-create").unwrap(),
1998            ProducerOperation::NetworkCreate
1999        );
2000        assert_eq!(
2001            parse_producer_operation("network-connect").unwrap(),
2002            ProducerOperation::NetworkConnect
2003        );
2004        assert_eq!(
2005            parse_producer_operation("network-disconnect").unwrap(),
2006            ProducerOperation::NetworkDisconnect
2007        );
2008        assert_eq!(
2009            parse_producer_operation("network-remove").unwrap(),
2010            ProducerOperation::NetworkRemove
2011        );
2012        assert_eq!(
2013            parse_producer_operation("network-list").unwrap(),
2014            ProducerOperation::NetworkList
2015        );
2016    }
2017
2018    #[test]
2019    fn test_resolve_container_name_header_overrides_config() {
2020        let config = ContainerConfig::from_uri("container:run?name=config-name").unwrap();
2021        let mut exchange = Exchange::new(Message::new(""));
2022        exchange.input.set_header(
2023            HEADER_CONTAINER_NAME,
2024            serde_json::Value::String("header-name".to_string()),
2025        );
2026
2027        let resolved = resolve_container_name(&exchange, &config);
2028        assert_eq!(resolved.as_deref(), Some("header-name"));
2029    }
2030
2031    #[test]
2032    fn test_container_config_rejects_tcp_host() {
2033        let config = ContainerConfig::from_uri("container:list?host=tcp://localhost:2375").unwrap();
2034        let err = config.connect_docker_client().unwrap_err();
2035        match err {
2036            CamelError::ProcessorError(msg) => {
2037                assert!(
2038                    msg.to_lowercase().contains("tcp"),
2039                    "Expected TCP scheme error, got: {}",
2040                    msg
2041                );
2042            }
2043            _ => panic!("Expected ProcessorError for unsupported tcp host"),
2044        }
2045    }
2046
2047    #[tokio::test]
2048    async fn test_run_container_with_cleanup_removes_on_start_failure() {
2049        let remove_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2050        let remove_called_clone = remove_called.clone();
2051
2052        let result = run_container_with_cleanup(
2053            || async { Ok("container-123".to_string()) },
2054            |_id| async move {
2055                Err(CamelError::ProcessorError(
2056                    "Failed to start container".to_string(),
2057                ))
2058            },
2059            move |_id| {
2060                let remove_called_inner = remove_called_clone.clone();
2061                async move {
2062                    remove_called_inner.store(true, std::sync::atomic::Ordering::SeqCst);
2063                    Ok(())
2064                }
2065            },
2066        )
2067        .await;
2068
2069        assert!(result.is_err(), "Expected start failure to bubble up");
2070        assert!(
2071            remove_called.load(std::sync::atomic::Ordering::SeqCst),
2072            "Expected cleanup to remove container"
2073        );
2074    }
2075
2076    #[test]
2077    fn test_container_component_creates_endpoint() {
2078        let component = ContainerComponent::new();
2079        assert_eq!(component.scheme(), "container");
2080        let ctx = NoOpComponentContext;
2081        let endpoint = component
2082            .create_endpoint("container:run?image=alpine", &ctx)
2083            .unwrap();
2084        assert_eq!(endpoint.uri(), "container:run?image=alpine");
2085    }
2086
2087    #[test]
2088    fn test_container_config_parses_ports() {
2089        let config =
2090            ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80,8443:443").unwrap();
2091        assert_eq!(config.ports.as_deref(), Some("8080:80,8443:443"));
2092    }
2093
2094    #[test]
2095    fn test_container_config_parses_env() {
2096        let config =
2097            ContainerConfig::from_uri("container:run?image=nginx&env=FOO=bar,BAZ=qux").unwrap();
2098        assert_eq!(config.env.as_deref(), Some("FOO=bar,BAZ=qux"));
2099    }
2100
2101    #[test]
2102    fn test_container_config_parses_logs_options() {
2103        let config = ContainerConfig::from_uri(
2104            "container:logs?containerId=my-app&follow=true&timestamps=true&tail=100",
2105        )
2106        .unwrap();
2107        assert_eq!(config.operation, "logs");
2108        assert_eq!(config.container_id.as_deref(), Some("my-app"));
2109        assert!(config.follow);
2110        assert!(config.timestamps);
2111        assert_eq!(config.tail.as_deref(), Some("100"));
2112    }
2113
2114    #[test]
2115    fn test_container_config_logs_defaults() {
2116        let config = ContainerConfig::from_uri("container:logs?containerId=test").unwrap();
2117        assert!(config.follow); // default: true
2118        assert!(!config.timestamps); // default: false
2119        assert!(config.tail.is_none()); // default: None (all)
2120    }
2121
2122    #[test]
2123    fn test_parse_ports_single() {
2124        let config = ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80").unwrap();
2125        let (exposed, bindings) = config.parse_ports().unwrap();
2126
2127        assert!(exposed.contains(&"80/tcp".to_string()));
2128        assert!(bindings.contains_key("80/tcp"));
2129
2130        let binding = bindings.get("80/tcp").unwrap().as_ref().unwrap();
2131        assert_eq!(binding.len(), 1);
2132        assert_eq!(binding[0].host_port, Some("8080".to_string()));
2133    }
2134
2135    #[test]
2136    fn test_parse_ports_multiple() {
2137        let config =
2138            ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80,8443:443").unwrap();
2139        let (exposed, bindings) = config.parse_ports().unwrap();
2140
2141        assert!(exposed.contains(&"80/tcp".to_string()));
2142        assert!(exposed.contains(&"443/tcp".to_string()));
2143        assert_eq!(bindings.len(), 2);
2144    }
2145
2146    #[test]
2147    fn test_parse_ports_with_protocol() {
2148        let config =
2149            ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80/tcp,5353:53/udp")
2150                .unwrap();
2151        let (exposed, _bindings) = config.parse_ports().unwrap();
2152
2153        assert!(exposed.contains(&"80/tcp".to_string()));
2154        assert!(exposed.contains(&"53/udp".to_string()));
2155    }
2156
2157    #[test]
2158    fn test_parse_ports_none() {
2159        let config = ContainerConfig::from_uri("container:run?image=nginx").unwrap();
2160        let (exposed, bindings) = config.parse_ports().unwrap();
2161        assert!(exposed.is_empty());
2162        assert!(bindings.is_empty());
2163    }
2164
2165    #[test]
2166    fn test_parse_env_single() {
2167        let config = ContainerConfig::from_uri("container:run?image=nginx&env=FOO=bar").unwrap();
2168        let env = config.parse_env().unwrap();
2169
2170        assert_eq!(env.len(), 1);
2171        assert_eq!(env[0], "FOO=bar");
2172    }
2173
2174    #[test]
2175    fn test_parse_env_multiple() {
2176        let config =
2177            ContainerConfig::from_uri("container:run?image=nginx&env=FOO=bar,BAZ=qux,NUM=123")
2178                .unwrap();
2179        let env = config.parse_env().unwrap();
2180
2181        assert_eq!(env.len(), 3);
2182        assert!(env.contains(&"FOO=bar".to_string()));
2183        assert!(env.contains(&"BAZ=qux".to_string()));
2184        assert!(env.contains(&"NUM=123".to_string()));
2185    }
2186
2187    #[test]
2188    fn test_parse_env_none() {
2189        let config = ContainerConfig::from_uri("container:run?image=nginx").unwrap();
2190        assert!(config.parse_env().is_none());
2191    }
2192
2193    use camel_component_api::Message;
2194    use std::sync::Arc;
2195
2196    #[tokio::test]
2197    async fn test_container_producer_connection_error_on_invalid_host() {
2198        // Test that an invalid host (nonexistent socket) results in a connection error
2199        let component = ContainerComponent::new();
2200        let ctx = NoOpComponentContext;
2201        let endpoint = component
2202            .create_endpoint("container:list?host=unix:///nonexistent/docker.sock", &ctx)
2203            .unwrap();
2204
2205        let ctx = ProducerContext::new();
2206        let result = endpoint.create_producer(rt(), &ctx);
2207
2208        // The producer should return an error because it cannot connect to the invalid socket
2209        assert!(
2210            result.is_err(),
2211            "Expected error when connecting to invalid host"
2212        );
2213        let err = result.unwrap_err();
2214        match &err {
2215            CamelError::ProcessorError(msg) => {
2216                assert!(
2217                    msg.to_lowercase().contains("connection")
2218                        || msg.to_lowercase().contains("connect")
2219                        || msg.to_lowercase().contains("socket")
2220                        || msg.contains("docker"),
2221                    "Error message should indicate connection failure, got: {}",
2222                    msg
2223                );
2224            }
2225            _ => panic!("Expected ProcessorError, got: {:?}", err),
2226        }
2227    }
2228
2229    /// Test that consumer returns an error for unsupported operations.
2230    #[tokio::test]
2231    async fn test_container_consumer_unsupported_operation() {
2232        use tokio::sync::mpsc;
2233
2234        let component = ContainerComponent::new();
2235        let ctx = NoOpComponentContext;
2236        let endpoint = component.create_endpoint("container:run", &ctx).unwrap();
2237        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2238
2239        // Create a minimal ConsumerContext
2240        let (tx, _rx) = mpsc::channel(16);
2241        let cancel_token = tokio_util::sync::CancellationToken::new();
2242        let context = ConsumerContext::new(tx, cancel_token, "container-test-route".to_string());
2243
2244        let result = consumer.start(context).await;
2245
2246        // Should return error because "run" is not a supported consumer operation
2247        assert!(
2248            result.is_err(),
2249            "Expected error for unsupported consumer operation"
2250        );
2251        let err = result.unwrap_err();
2252        match &err {
2253            CamelError::EndpointCreationFailed(msg) => {
2254                assert!(
2255                    msg.contains("Consumer only supports 'events' or 'logs'"),
2256                    "Error message should mention events or logs support, got: {}",
2257                    msg
2258                );
2259            }
2260            _ => panic!("Expected EndpointCreationFailed error, got: {:?}", err),
2261        }
2262    }
2263
2264    #[test]
2265    fn test_container_consumer_concurrency_model_is_concurrent() {
2266        let consumer = ContainerConsumer {
2267            config: ContainerConfig::from_uri("container:events").unwrap(),
2268            runtime: test_rt(),
2269        };
2270
2271        assert_eq!(
2272            consumer.concurrency_model(),
2273            camel_component_api::ConcurrencyModel::Concurrent { max: None }
2274        );
2275    }
2276
2277    #[test]
2278    fn test_container_config_parses_volumes() {
2279        let config = ContainerConfig::from_uri(
2280            "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro",
2281        )
2282        .unwrap();
2283        assert_eq!(
2284            config.volumes.as_deref(),
2285            Some("./html:/usr/share/nginx/html:ro")
2286        );
2287    }
2288
2289    #[test]
2290    fn test_container_config_parses_exec_params() {
2291        let config = ContainerConfig::from_uri(
2292            "container:exec?containerId=my-app&cmd=ls /app&user=root&workdir=/tmp&detach=true",
2293        )
2294        .unwrap();
2295        assert_eq!(config.operation, "exec");
2296        assert_eq!(config.container_id.as_deref(), Some("my-app"));
2297        assert_eq!(config.cmd.as_deref(), Some("ls /app"));
2298        assert_eq!(config.user.as_deref(), Some("root"));
2299        assert_eq!(config.workdir.as_deref(), Some("/tmp"));
2300        assert!(config.detach);
2301    }
2302
2303    #[test]
2304    fn test_container_config_parses_network_create_params() {
2305        let config =
2306            ContainerConfig::from_uri("container:network-create?name=my-net&driver=bridge")
2307                .unwrap();
2308        assert_eq!(config.operation, "network-create");
2309        assert_eq!(config.name.as_deref(), Some("my-net"));
2310        assert_eq!(config.driver.as_deref(), Some("bridge"));
2311    }
2312
2313    #[test]
2314    fn test_container_config_defaults_new_fields() {
2315        let config = ContainerConfig::from_uri("container:list").unwrap();
2316        assert!(config.volumes.is_none());
2317        assert!(config.user.is_none());
2318        assert!(config.workdir.is_none());
2319        assert!(!config.detach);
2320        assert!(config.driver.is_none());
2321        assert!(!config.force);
2322    }
2323
2324    #[test]
2325    fn test_parse_volumes_bind_mount() {
2326        let config = ContainerConfig::from_uri(
2327            "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro",
2328        )
2329        .unwrap();
2330        let (binds, anon) = config.parse_volumes().unwrap();
2331        assert_eq!(binds, vec!["./html:/usr/share/nginx/html:ro"]);
2332        assert!(anon.is_empty());
2333    }
2334
2335    #[test]
2336    fn test_parse_volumes_named_volume() {
2337        let config =
2338            ContainerConfig::from_uri("container:run?image=postgres&volumes=data:/var/lib/data")
2339                .unwrap();
2340        let (binds, anon) = config.parse_volumes().unwrap();
2341        assert_eq!(binds, vec!["data:/var/lib/data"]);
2342        assert!(anon.is_empty());
2343    }
2344
2345    #[test]
2346    fn test_parse_volumes_anonymous() {
2347        let config =
2348            ContainerConfig::from_uri("container:run?image=alpine&volumes=/tmp/app-data").unwrap();
2349        let (binds, anon) = config.parse_volumes().unwrap();
2350        assert!(binds.is_empty());
2351        assert!(anon.contains(&"/tmp/app-data".to_string()));
2352    }
2353
2354    #[test]
2355    fn test_parse_volumes_anonymous_with_mode() {
2356        let config =
2357            ContainerConfig::from_uri("container:run?image=alpine&volumes=/tmp/app-data:ro")
2358                .unwrap();
2359        let (binds, anon) = config.parse_volumes().unwrap();
2360        assert!(binds.is_empty());
2361        assert!(anon.contains(&"/tmp/app-data".to_string()));
2362    }
2363
2364    #[test]
2365    fn test_parse_volumes_multiple() {
2366        let config = ContainerConfig::from_uri(
2367            "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro,data:/var/log/app",
2368        )
2369        .unwrap();
2370        let (binds, anon) = config.parse_volumes().unwrap();
2371        assert_eq!(binds.len(), 2);
2372        assert!(binds.contains(&"./html:/usr/share/nginx/html:ro".to_string()));
2373        assert!(binds.contains(&"data:/var/log/app".to_string()));
2374        assert!(anon.is_empty());
2375    }
2376
2377    #[test]
2378    fn test_parse_volumes_mixed() {
2379        let config = ContainerConfig::from_uri(
2380            "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro,/tmp/cache",
2381        )
2382        .unwrap();
2383        let (binds, anon) = config.parse_volumes().unwrap();
2384        assert_eq!(binds.len(), 1);
2385        assert!(anon.contains(&"/tmp/cache".to_string()));
2386    }
2387
2388    #[test]
2389    fn test_parse_volumes_none() {
2390        let config = ContainerConfig::from_uri("container:run?image=nginx").unwrap();
2391        assert!(config.parse_volumes().is_none());
2392    }
2393
2394    #[test]
2395    fn test_parse_volumes_empty_entry_skipped() {
2396        let config = ContainerConfig::from_uri("container:run?image=nginx&volumes=,,").unwrap();
2397        assert!(config.parse_volumes().is_none());
2398    }
2399
2400    #[test]
2401    fn test_parse_volumes_rw_mode() {
2402        let config =
2403            ContainerConfig::from_uri("container:run?image=nginx&volumes=./data:/app/data:rw")
2404                .unwrap();
2405        let (binds, _) = config.parse_volumes().unwrap();
2406        assert_eq!(binds, vec!["./data:/app/data:rw"]);
2407    }
2408
2409    #[test]
2410    fn test_container_config_from_uri_parses_false_flags() {
2411        let config = ContainerConfig::from_uri(
2412            "container:logs?containerId=a&follow=false&timestamps=FALSE&autoPull=false&autoRemove=False&detach=TRUE&force=true",
2413        )
2414        .unwrap();
2415        assert!(!config.follow);
2416        assert!(!config.timestamps);
2417        assert!(!config.auto_pull);
2418        assert!(!config.auto_remove);
2419        assert!(config.detach);
2420        assert!(config.force);
2421    }
2422
2423    #[test]
2424    fn test_docker_socket_path_validation() {
2425        let unix_cfg =
2426            ContainerConfig::from_uri("container:list?host=unix:///tmp/docker.sock").unwrap();
2427        assert_eq!(
2428            unix_cfg.docker_socket_path().unwrap(),
2429            "unix:///tmp/docker.sock"
2430        );
2431
2432        let npipe_cfg =
2433            ContainerConfig::from_uri("container:list?host=npipe:////./pipe/docker_engine")
2434                .unwrap();
2435        assert_eq!(
2436            npipe_cfg.docker_socket_path().unwrap(),
2437            "npipe:////./pipe/docker_engine"
2438        );
2439
2440        let plain_cfg =
2441            ContainerConfig::from_uri("container:list?host=/var/run/docker.sock").unwrap();
2442        assert_eq!(
2443            plain_cfg.docker_socket_path().unwrap(),
2444            "/var/run/docker.sock"
2445        );
2446
2447        let bad_cfg =
2448            ContainerConfig::from_uri("container:list?host=http://localhost:2375").unwrap();
2449        assert!(bad_cfg.docker_socket_path().is_err());
2450    }
2451
2452    #[test]
2453    fn test_parse_ports_invalid_and_whitespace_entries() {
2454        // "8080" without colon is malformed — must fail-fast
2455        let cfg = ContainerConfig::from_uri("container:run?ports= , ,8080").unwrap();
2456        let err = cfg.parse_ports().unwrap_err();
2457        assert!(
2458            err.to_string().contains("malformed port mapping"),
2459            "expected malformed port error, got: {}",
2460            err
2461        );
2462
2463        let cfg =
2464            ContainerConfig::from_uri("container:run?ports= 8080:80 ,  5353:53/udp ").unwrap();
2465        let (exposed, bindings) = cfg.parse_ports().unwrap();
2466        assert!(exposed.contains(&"80/tcp".to_string()));
2467        assert!(exposed.contains(&"53/udp".to_string()));
2468        assert_eq!(bindings.len(), 2);
2469    }
2470
2471    #[test]
2472    fn test_parse_ports_fail_fast_on_malformed() {
2473        // First entry valid, second malformed — must fail on the malformed one
2474        let cfg = ContainerConfig::from_uri("container:run?ports=8080:80,badentry").unwrap();
2475        let err = cfg.parse_ports().unwrap_err();
2476        assert!(
2477            err.to_string()
2478                .contains("malformed port mapping 'badentry'"),
2479            "expected fail-fast on 'badentry', got: {}",
2480            err
2481        );
2482    }
2483
2484    #[test]
2485    fn test_parse_env_trims_and_filters_empty_items() {
2486        let cfg = ContainerConfig::from_uri("container:run?env= FOO=bar , ,BAZ=qux, ").unwrap();
2487        let env = cfg.parse_env().unwrap();
2488        assert_eq!(env, vec!["FOO=bar".to_string(), "BAZ=qux".to_string()]);
2489
2490        let cfg = ContainerConfig::from_uri("container:run?env= , , ").unwrap();
2491        assert!(cfg.parse_env().is_none());
2492    }
2493
2494    #[test]
2495    fn test_parse_volume_str_rejects_invalid_mode_and_accepts_mixed() {
2496        assert!(parse_volume_str("/host:/ctr:badmode").is_none());
2497        let (binds, anon) = parse_volume_str("a:/b:rw,/tmp/cache,/tmp/logs:ro").unwrap();
2498        assert!(binds.contains(&"a:/b:rw".to_string()));
2499        assert!(anon.contains(&"/tmp/cache".to_string()));
2500        assert!(anon.contains(&"/tmp/logs".to_string()));
2501    }
2502
2503    #[test]
2504    fn test_format_docker_event_variants_and_timestamp_extraction() {
2505        let mut attrs = std::collections::HashMap::new();
2506        attrs.insert("name".to_string(), "demo".to_string());
2507        attrs.insert("image".to_string(), "alpine:latest".to_string());
2508        attrs.insert("exitCode".to_string(), "137".to_string());
2509        let actor = bollard::models::EventActor {
2510            id: None,
2511            attributes: Some(attrs),
2512        };
2513
2514        let create_event = bollard::models::EventMessage {
2515            action: Some("create".to_string()),
2516            actor: Some(actor.clone()),
2517            ..Default::default()
2518        };
2519        assert_eq!(
2520            format_docker_event(&create_event),
2521            "[CREATE] Container demo (alpine:latest)"
2522        );
2523
2524        let die_event = bollard::models::EventMessage {
2525            action: Some("die".to_string()),
2526            actor: Some(actor),
2527            ..Default::default()
2528        };
2529        assert_eq!(
2530            format_docker_event(&die_event),
2531            "[DIE]    Container demo (exit: 137)"
2532        );
2533
2534        let other_event = bollard::models::EventMessage {
2535            action: Some("oom".to_string()),
2536            actor: None,
2537            ..Default::default()
2538        };
2539        assert_eq!(format_docker_event(&other_event), "[OOM] Container unknown");
2540
2541        assert_eq!(
2542            extract_timestamp("2024-01-01T00:00:00Z hello"),
2543            Some("2024-01-01T00:00:00Z".to_string())
2544        );
2545        assert_eq!(extract_timestamp("hello world"), None);
2546    }
2547
2548    #[tokio::test]
2549    async fn test_run_container_with_cleanup_error_paths() {
2550        let create_fail = run_container_with_cleanup(
2551            || async { Err(CamelError::ProcessorError("create-fail".to_string())) },
2552            |_id| async move { Ok(()) },
2553            |_id| async move { Ok(()) },
2554        )
2555        .await;
2556        assert!(
2557            matches!(create_fail, Err(CamelError::ProcessorError(msg)) if msg == "create-fail")
2558        );
2559
2560        let cleanup_fail = run_container_with_cleanup(
2561            || async { Ok("cid-1".to_string()) },
2562            |_id| async move { Err(CamelError::ProcessorError("start-fail".to_string())) },
2563            |_id| async move { Err(CamelError::ProcessorError("remove-fail".to_string())) },
2564        )
2565        .await;
2566        match cleanup_fail {
2567            Err(CamelError::ProcessorError(msg)) => {
2568                assert!(msg.contains("Failed to start container"));
2569                assert!(msg.contains("Cleanup failed"));
2570            }
2571            other => panic!("unexpected result: {:?}", other),
2572        }
2573    }
2574
2575    #[tokio::test]
2576    async fn test_logs_consumer_requires_container_id() {
2577        use tokio::sync::mpsc;
2578
2579        let mut consumer = ContainerConsumer {
2580            config: ContainerConfig::from_uri("container:logs").unwrap(),
2581            runtime: test_rt(),
2582        };
2583        let (tx, _rx) = mpsc::channel(4);
2584        let context = ConsumerContext::new(
2585            tx,
2586            tokio_util::sync::CancellationToken::new(),
2587            "container-test-route".to_string(),
2588        );
2589
2590        let err = consumer.start(context).await.unwrap_err();
2591        match err {
2592            CamelError::EndpointCreationFailed(msg) => {
2593                assert!(msg.contains("containerId is required for logs consumer"));
2594            }
2595            other => panic!("unexpected error: {:?}", other),
2596        }
2597    }
2598
2599    #[tokio::test]
2600    async fn test_events_consumer_stops_immediately_when_cancelled() {
2601        use tokio::sync::mpsc;
2602
2603        // Noop runtime (not PanicRuntimeObservability): the retry helper now
2604        // records per-attempt telemetry before observing cancellation, and a
2605        // cancelled start is still a clean shutdown.
2606        let mut consumer = ContainerConsumer {
2607            config: ContainerConfig::from_uri("container:events").unwrap(),
2608            runtime: std::sync::Arc::new(
2609                camel_component_api::test_support::NoopRuntimeObservability,
2610            ),
2611        };
2612
2613        let (tx, _rx) = mpsc::channel(4);
2614        let cancel = tokio_util::sync::CancellationToken::new();
2615        cancel.cancel();
2616        let context = ConsumerContext::new(tx, cancel, "container-test-route".to_string());
2617
2618        let result = consumer.start(context).await;
2619        assert!(result.is_ok());
2620    }
2621
2622    #[test]
2623    fn test_global_config_constructors_and_endpoint_docker_host() {
2624        let global = ContainerGlobalConfig::new().with_docker_host("unix:///tmp/docker.sock");
2625        let mut cfg = ContainerConfig::from_uri("container:list").unwrap();
2626        cfg.apply_global_defaults(&global);
2627
2628        let endpoint = ContainerEndpoint {
2629            uri: "container:list".to_string(),
2630            config: cfg,
2631        };
2632
2633        assert_eq!(endpoint.docker_host(), Some("unix:///tmp/docker.sock"));
2634
2635        let component = ContainerComponent::with_config(global);
2636        assert_eq!(component.scheme(), "container");
2637    }
2638
2639    #[test]
2640    fn container_global_config_has_reconnect_policy() {
2641        let cfg = ContainerGlobalConfig::default();
2642        assert_eq!(cfg.reconnect.max_attempts, 0); // unlimited
2643        assert!(cfg.reconnect.enabled);
2644    }
2645
2646    /// Regression: max_attempts=N → exactly N invocations (caught OpenSearch off-by-one 1f5c4c2a).
2647    /// Replicates the exact retry loop from ContainerConsumer::{start_events_consumer,start_logs_consumer}
2648    /// (lib.rs:~1408-1431, ~1503-1525):
2649    ///   attempt starts at 0, incremented on error, !should_retry(attempt), delay_for(attempt-1)
2650    #[tokio::test]
2651    async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
2652        use std::sync::Arc;
2653        use std::sync::atomic::{AtomicU32, Ordering};
2654        use std::time::Duration;
2655
2656        let policy = NetworkRetryPolicy {
2657            max_attempts: 3,
2658            initial_delay: Duration::from_millis(1),
2659            max_delay: Duration::from_millis(1),
2660            multiplier: 1.0,
2661            ..NetworkRetryPolicy::default()
2662        };
2663
2664        let calls = Arc::new(AtomicU32::new(0));
2665        let calls_clone = Arc::clone(&calls);
2666        let mut attempt: u32 = 0;
2667
2668        loop {
2669            calls_clone.fetch_add(1, Ordering::SeqCst);
2670            let result: Result<(), ()> = Err(());
2671            match result {
2672                Ok(_) => {
2673                    break;
2674                }
2675                Err(_) => {
2676                    attempt += 1;
2677                    if !policy.should_retry(attempt) {
2678                        break;
2679                    }
2680                    let delay = policy.delay_for(attempt - 1);
2681                    tokio::time::sleep(delay).await;
2682                    continue;
2683                }
2684            }
2685        }
2686
2687        assert_eq!(
2688            calls.load(Ordering::SeqCst),
2689            3,
2690            "max_attempts=3 must yield exactly 3 invocations"
2691        );
2692    }
2693
2694    // -----------------------------------------------------------------------
2695    // ADR-0012 (e) metric wiring regression tests
2696    // -----------------------------------------------------------------------
2697
2698    /// Regression: events-connect exhaustion records exactly one error via
2699    /// the retry helper, with the `e:container:events-connect` label
2700    /// (operation label first — the helper has no route scope). Uses an
2701    /// unsupported tcp:// host to trigger the error path WITHOUT needing a
2702    /// real Docker daemon (connect_docker_from_host returns Err on
2703    /// non-unix/npipe schemes).
2704    #[tokio::test]
2705    async fn events_connect_error_increments_metrics() {
2706        use std::sync::Mutex;
2707        use std::time::Duration;
2708
2709        struct RecordingMetrics(Mutex<Vec<(String, String)>>);
2710
2711        impl MetricsCollector for RecordingMetrics {
2712            fn record_exchange_duration(&self, _: &str, _: Duration) {}
2713            fn increment_errors(&self, route_id: &str, error_type: &str) {
2714                self.0
2715                    .lock()
2716                    .unwrap()
2717                    .push((route_id.to_string(), error_type.to_string()));
2718            }
2719            fn increment_exchanges(&self, _: &str) {}
2720            fn set_queue_depth(&self, _: &str, _: usize) {}
2721            fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
2722        }
2723
2724        struct RecordingRuntime {
2725            metrics: Arc<RecordingMetrics>,
2726        }
2727
2728        impl HealthCheckRegistry for RecordingRuntime {
2729            fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
2730        }
2731
2732        impl RuntimeObservability for RecordingRuntime {
2733            fn metrics(&self) -> Arc<dyn MetricsCollector> {
2734                self.metrics.clone()
2735            }
2736            fn health(&self) -> Arc<dyn HealthCheckRegistry> {
2737                Arc::new(camel_component_api::NoOpHealthCheckRegistry)
2738            }
2739        }
2740
2741        let recording = Arc::new(RecordingMetrics(Mutex::new(Vec::new())));
2742        let rt: Arc<dyn RuntimeObservability> = Arc::new(RecordingRuntime {
2743            metrics: recording.clone(),
2744        });
2745
2746        // Use unsupported tcp:// host so connect_docker_from_host() fails fast
2747        // without any real I/O. Disable retry so the first failure propagates
2748        // immediately to the Err(e) arm.
2749        let mut config = ContainerConfig::from_uri("container:events").unwrap();
2750        config.host = Some("tcp://192.0.2.1:2375".to_string());
2751        config.reconnect = NetworkRetryPolicy::disabled();
2752
2753        let mut consumer = ContainerConsumer {
2754            config,
2755            runtime: rt,
2756        };
2757
2758        let (tx, _rx) = tokio::sync::mpsc::channel(4);
2759        let cancel = tokio_util::sync::CancellationToken::new();
2760        let context = ConsumerContext::new(tx, cancel, "events-test-route".to_string());
2761
2762        let result = consumer.start(context).await;
2763        assert!(result.is_err(), "expected Docker connection error");
2764
2765        let errors = recording.0.lock().unwrap();
2766        assert_eq!(
2767            errors.len(),
2768            1,
2769            "expected exactly one increment_errors call"
2770        );
2771        assert_eq!(errors[0].0, "events-connect");
2772        assert_eq!(errors[0].1, "e:container:events-connect");
2773    }
2774
2775    #[test]
2776    fn test_connect_docker_from_host_rejects_bad_scheme() {
2777        let err = connect_docker_from_host(Some("tcp://localhost:2375")).unwrap_err();
2778        match &err {
2779            CamelError::ProcessorError(msg) => {
2780                assert!(msg.contains("tcp"), "expected tcp rejection, got: {}", msg);
2781            }
2782            _ => panic!("expected ProcessorError, got: {:?}", err),
2783        }
2784    }
2785
2786    #[test]
2787    fn test_connect_docker_from_host_accepts_unix_scheme() {
2788        // Validation succeeds; connection attempt fails on nonexistent socket
2789        let result = connect_docker_from_host(Some("unix:///nonexistent/docker.sock"));
2790        match result {
2791            Err(CamelError::ProcessorError(msg)) => {
2792                // bollard error should mention the socket or connection failure
2793                assert!(
2794                    msg.contains("nonexistent")
2795                        || msg.contains("connect")
2796                        || msg.contains("socket"),
2797                    "expected error referencing socket, got: {}",
2798                    msg
2799                );
2800            }
2801            Ok(_) => {} // Docker daemon is running on this path (unlikely in CI)
2802            Err(other) => panic!("unexpected error type: {:?}", other),
2803        }
2804    }
2805
2806    #[test]
2807    fn test_connect_docker_from_host_accepts_schemeless_path() {
2808        // schemeless paths pass through validation
2809        let result = connect_docker_from_host(Some("/nonexistent/docker.sock"));
2810        match result {
2811            Err(CamelError::ProcessorError(msg)) => {
2812                assert!(
2813                    msg.contains("nonexistent")
2814                        || msg.contains("connect")
2815                        || msg.contains("socket"),
2816                    "expected error referencing socket, got: {}",
2817                    msg
2818                );
2819            }
2820            Ok(_) => {} // real daemon on this path (unlikely in CI)
2821            Err(other) => panic!("unexpected error type: {:?}", other),
2822        }
2823    }
2824
2825    #[tokio::test]
2826    async fn test_cleanup_respects_custom_docker_host() {
2827        // Track a dummy container ID so cleanup attempts connection
2828        track_container("test-custom-host-container".to_string());
2829
2830        // Must not panic regardless of whether Docker is available.
2831        // The function logs errors and returns gracefully.
2832        cleanup_tracked_containers(Some("unix:///nonexistent/docker.sock")).await;
2833
2834        // Clean up: remove the dummy ID from the tracker
2835        untrack_container("test-custom-host-container");
2836    }
2837
2838    #[tokio::test]
2839    async fn test_cleanup_none_uses_defaults() {
2840        // Must not panic on empty tracker or connection failure.
2841        cleanup_tracked_containers(None).await;
2842    }
2843
2844    #[test]
2845    fn uri_options_count_parity() {
2846        assert_eq!(
2847            ContainerConfig::uri_options().len(),
2848            19,
2849            "ContainerUriConfig #[uri_param] count drifted from parser"
2850        );
2851    }
2852}