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                Some("container-events"),
1548                || async { self.config.connect_docker().await },
1549                |_| true,
1550                &cancel,
1551            )
1552            .await
1553            {
1554                Ok(d) => d,
1555                Err(_) if context.is_cancelled() => {
1556                    tracing::info!("Container events consumer shutting down");
1557                    return Ok(());
1558                }
1559                Err(e) => {
1560                    self.runtime
1561                        .metrics()
1562                        .increment_errors(context.route_id(), "e:container:events-connect");
1563                    // log-policy: outside-contract
1564                    tracing::error!(error = %e, "Container events consumer exhausted reconnect attempts");
1565                    return Err(e);
1566                }
1567            };
1568
1569            let mut event_stream = docker.events(None::<EventsOptions>);
1570
1571            loop {
1572                tokio::select! {
1573                    _ = context.cancelled() => {
1574                        tracing::info!("Container events consumer shutting down");
1575                        return Ok(());
1576                    }
1577
1578                    msg = event_stream.next() => {
1579                        match msg {
1580                            Some(Ok(event)) => {
1581                                let formatted = format_docker_event(&event);
1582                                let message = Message::new(Body::Text(formatted));
1583                                let exchange = Exchange::new(message);
1584
1585                                if let Err(e) = context.send(exchange).await {
1586                                    // log-policy: system-broken
1587                                    tracing::error!("Failed to send exchange: {:?}", e);
1588                                    break;
1589                                }
1590                            }
1591                            Some(Err(e)) => {
1592                                self.runtime.metrics().increment_errors(context.route_id(), "e:container:events-stream");
1593                                // log-policy: outside-contract
1594                                tracing::error!("Docker event stream error: {}. Reconnecting...", e);
1595                                break;
1596                            }
1597                            None => {
1598                                tracing::info!("Docker event stream ended. Reconnecting...");
1599                                break;
1600                            }
1601                        }
1602                    }
1603                }
1604            }
1605
1606            tokio::select! {
1607                _ = context.cancelled() => {
1608                    tracing::info!("Container events consumer shutting down");
1609                    return Ok(());
1610                }
1611                _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1612            }
1613        }
1614    }
1615
1616    async fn start_logs_consumer(&mut self, context: ConsumerContext) -> Result<(), CamelError> {
1617        use futures::StreamExt;
1618
1619        let container_id = self.config.container_id.clone().ok_or_else(|| {
1620            CamelError::EndpointCreationFailed(
1621                "containerId is required for logs consumer. Use container:logs?containerId=xxx"
1622                    .to_string(),
1623            )
1624        })?;
1625
1626        let cancel = context.cancel_token();
1627
1628        // Outer reconnect loop: when the inner log stream breaks, this loop
1629        // reconnects. The connect_docker() retry is now backed by
1630        // retry_async_cancelable (migrated from manual loop in rc-k9c).
1631        loop {
1632            let docker = match retry_async_cancelable(
1633                &self.config.reconnect,
1634                Some("container-logs"),
1635                || async { self.config.connect_docker().await },
1636                |_| true,
1637                &cancel,
1638            )
1639            .await
1640            {
1641                Ok(d) => d,
1642                Err(_) if context.is_cancelled() => {
1643                    tracing::info!("Container logs consumer shutting down");
1644                    return Ok(());
1645                }
1646                Err(e) => {
1647                    self.runtime
1648                        .metrics()
1649                        .increment_errors(context.route_id(), "e:container:logs-connect");
1650                    // log-policy: outside-contract
1651                    tracing::error!(error = %e, "Container logs consumer exhausted reconnect attempts");
1652                    return Err(e);
1653                }
1654            };
1655
1656            let tail = self
1657                .config
1658                .tail
1659                .clone()
1660                .unwrap_or_else(|| "all".to_string());
1661
1662            let options = LogsOptions {
1663                follow: self.config.follow,
1664                stdout: true,
1665                stderr: true,
1666                timestamps: self.config.timestamps,
1667                tail,
1668                ..Default::default()
1669            };
1670
1671            let mut log_stream = docker.logs(&container_id, Some(options));
1672            let container_id_header = container_id.clone();
1673
1674            loop {
1675                tokio::select! {
1676                    _ = context.cancelled() => {
1677                        tracing::info!("Container logs consumer shutting down");
1678                        return Ok(());
1679                    }
1680
1681                    msg = log_stream.next() => {
1682                        match msg {
1683                            Some(Ok(log_output)) => {
1684                                let (stream_type, content) = match log_output {
1685                                    bollard::container::LogOutput::StdOut { message } => {
1686                                        ("stdout", String::from_utf8_lossy(&message).into_owned())
1687                                    }
1688                                    bollard::container::LogOutput::StdErr { message } => {
1689                                        ("stderr", String::from_utf8_lossy(&message).into_owned())
1690                                    }
1691                                    bollard::container::LogOutput::Console { message } => {
1692                                        ("console", String::from_utf8_lossy(&message).into_owned())
1693                                    }
1694                                    bollard::container::LogOutput::StdIn { message } => {
1695                                        ("stdin", String::from_utf8_lossy(&message).into_owned())
1696                                    }
1697                                };
1698
1699                                let content = content.trim_end();
1700                                if content.is_empty() {
1701                                    continue;
1702                                }
1703
1704                                let mut message = Message::new(Body::Text(content.to_string()));
1705                                message.set_header(
1706                                    HEADER_CONTAINER_ID,
1707                                    serde_json::Value::String(container_id_header.clone()),
1708                                );
1709                                message.set_header(
1710                                    HEADER_LOG_STREAM,
1711                                    serde_json::Value::String(stream_type.to_string()),
1712                                );
1713
1714                                if self.config.timestamps
1715                                    && let Some(ts) = extract_timestamp(content) {
1716                                        message.set_header(
1717                                            HEADER_LOG_TIMESTAMP,
1718                                            serde_json::Value::String(ts),
1719                                        );
1720                                    }
1721
1722                                let exchange = Exchange::new(message);
1723
1724                                if let Err(e) = context.send(exchange).await {
1725                                    // log-policy: system-broken
1726                                    tracing::error!("Failed to send log exchange: {:?}", e);
1727                                    break;
1728                                }
1729                            }
1730                            Some(Err(e)) => {
1731                                self.runtime.metrics().increment_errors(context.route_id(), "e:container:logs-stream");
1732                                // log-policy: outside-contract
1733                                tracing::error!("Docker log stream error: {}. Reconnecting...", e);
1734                                break;
1735                            }
1736                            None => {
1737                                if self.config.follow {
1738                                    tracing::info!("Docker log stream ended. Reconnecting...");
1739                                    break;
1740                                } else {
1741                                    tracing::info!("Container logs consumer finished (follow=false)");
1742                                    return Ok(());
1743                                }
1744                            }
1745                        }
1746                    }
1747                }
1748            }
1749
1750            tokio::select! {
1751                _ = context.cancelled() => {
1752                    tracing::info!("Container logs consumer shutting down");
1753                    return Ok(());
1754                }
1755                _ = tokio::time::sleep(std::time::Duration::from_secs(1)) => {}
1756            }
1757        }
1758    }
1759}
1760
1761fn extract_timestamp(log_line: &str) -> Option<String> {
1762    let parts: Vec<&str> = log_line.splitn(2, ' ').collect();
1763    if parts.len() > 1 && parts[0].contains('T') {
1764        Some(parts[0].to_string())
1765    } else {
1766        None
1767    }
1768}
1769
1770/// Component for creating container endpoints.
1771///
1772/// This component handles URIs with the "container" scheme and creates
1773/// appropriate producer and consumer endpoints for Docker operations.
1774///
1775/// Containers created via `run` operation are tracked globally and can be
1776/// cleaned up on shutdown by calling
1777/// `cleanup_tracked_containers(docker_host: Option<&str>)`.
1778pub struct ContainerComponent {
1779    config: Option<ContainerGlobalConfig>,
1780}
1781
1782impl ContainerComponent {
1783    /// Creates a new container component instance without global config.
1784    pub fn new() -> Self {
1785        Self { config: None }
1786    }
1787
1788    /// Creates a container component with the given global config.
1789    pub fn with_config(config: ContainerGlobalConfig) -> Self {
1790        Self {
1791            config: Some(config),
1792        }
1793    }
1794
1795    /// Creates a container component with optional global config.
1796    pub fn with_optional_config(config: Option<ContainerGlobalConfig>) -> Self {
1797        Self { config }
1798    }
1799}
1800
1801impl Default for ContainerComponent {
1802    fn default() -> Self {
1803        Self::new()
1804    }
1805}
1806
1807impl Component for ContainerComponent {
1808    fn scheme(&self) -> &str {
1809        "container"
1810    }
1811
1812    fn metadata(&self) -> ComponentMetadata {
1813        ContainerConfig::metadata()
1814    }
1815
1816    fn create_endpoint(
1817        &self,
1818        uri: &str,
1819        ctx: &dyn camel_component_api::ComponentContext,
1820    ) -> Result<Box<dyn Endpoint>, CamelError> {
1821        let mut config = ContainerConfig::from_uri(uri)?;
1822        // Apply global defaults if present and URI didn't set them
1823        if let Some(ref global) = self.config {
1824            config.apply_global_defaults(global);
1825        }
1826        let health_check = ContainerHealthCheck::new(&config);
1827        ctx.register_current_route_health_check(Arc::new(health_check));
1828        Ok(Box::new(ContainerEndpoint {
1829            uri: uri.to_string(),
1830            config,
1831        }))
1832    }
1833}
1834
1835/// Endpoint for container operations.
1836///
1837/// This endpoint creates producers for executing container operations
1838/// and consumers for receiving container events.
1839// TODO(CON-003): Forward container health status (inspect healthcheck / Health field) to
1840// Camel's health subsystem so the route can react to unhealthy containers.
1841pub struct ContainerEndpoint {
1842    uri: String,
1843    config: ContainerConfig,
1844}
1845
1846impl ContainerEndpoint {
1847    /// Returns the Docker host configured for this endpoint.
1848    /// Returns `None` if not set (for testing purposes).
1849    pub fn docker_host(&self) -> Option<&str> {
1850        self.config.host.as_deref()
1851    }
1852}
1853
1854impl Endpoint for ContainerEndpoint {
1855    fn uri(&self) -> &str {
1856        &self.uri
1857    }
1858
1859    fn create_consumer(
1860        &self,
1861        rt: Arc<dyn camel_component_api::RuntimeObservability>,
1862    ) -> Result<Box<dyn Consumer>, CamelError> {
1863        Ok(Box::new(ContainerConsumer::new(self.config.clone(), rt)))
1864    }
1865
1866    fn create_producer(
1867        &self,
1868        _rt: Arc<dyn camel_component_api::RuntimeObservability>,
1869        _ctx: &ProducerContext,
1870    ) -> Result<BoxProcessor, CamelError> {
1871        let docker = self.config.connect_docker_client()?;
1872        Ok(BoxProcessor::new(ContainerProducer {
1873            config: self.config.clone(),
1874            docker,
1875        }))
1876    }
1877}
1878
1879#[cfg(test)]
1880mod tests {
1881    use camel_component_api::test_support::PanicRuntimeObservability;
1882    fn test_rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1883        std::sync::Arc::new(PanicRuntimeObservability)
1884    }
1885    fn rt() -> std::sync::Arc<dyn camel_component_api::RuntimeObservability> {
1886        std::sync::Arc::new(PanicRuntimeObservability)
1887    }
1888
1889    use super::*;
1890    use camel_api::MetricsCollector;
1891    use camel_component_api::HealthCheckRegistry;
1892    use camel_component_api::NoOpComponentContext;
1893
1894    #[test]
1895    fn test_container_config() {
1896        let config = ContainerConfig::from_uri("container:run?image=alpine").unwrap();
1897        assert_eq!(config.operation, "run");
1898        assert_eq!(config.image.as_deref(), Some("alpine"));
1899        // host is None by default; global config applies it later
1900        assert!(config.host.is_none());
1901    }
1902
1903    #[test]
1904    fn test_global_config_applied_to_endpoint() {
1905        // When global config is set and URI doesn't specify host,
1906        // apply_global_defaults should set host from global config.
1907        let global =
1908            ContainerGlobalConfig::default().with_docker_host("unix:///custom/docker.sock");
1909        let mut config = ContainerConfig::from_uri("container:run?image=alpine").unwrap();
1910        assert!(
1911            config.host.is_none(),
1912            "URI without ?host= should leave host as None"
1913        );
1914        config.apply_global_defaults(&global);
1915        assert_eq!(
1916            config.host.as_deref(),
1917            Some("unix:///custom/docker.sock"),
1918            "global docker_host must be applied when URI did not set host"
1919        );
1920    }
1921
1922    #[test]
1923    fn test_uri_param_wins_over_global_config() {
1924        // When URI explicitly sets host param, apply_global_defaults must NOT override it.
1925        let global =
1926            ContainerGlobalConfig::default().with_docker_host("unix:///custom/docker.sock");
1927        let mut config =
1928            ContainerConfig::from_uri("container:run?image=alpine&host=unix:///override.sock")
1929                .unwrap();
1930        assert_eq!(
1931            config.host.as_deref(),
1932            Some("unix:///override.sock"),
1933            "URI-set host should be parsed correctly"
1934        );
1935        config.apply_global_defaults(&global);
1936        assert_eq!(
1937            config.host.as_deref(),
1938            Some("unix:///override.sock"),
1939            "global config must NOT override a host already set by URI"
1940        );
1941    }
1942
1943    #[test]
1944    fn test_container_config_parses_name() {
1945        let config = ContainerConfig::from_uri("container:run?name=my-container").unwrap();
1946        assert_eq!(config.name.as_deref(), Some("my-container"));
1947    }
1948
1949    #[test]
1950    fn test_parse_producer_operation_known() {
1951        assert_eq!(
1952            parse_producer_operation("list").unwrap(),
1953            ProducerOperation::List
1954        );
1955        assert_eq!(
1956            parse_producer_operation("run").unwrap(),
1957            ProducerOperation::Run
1958        );
1959        assert_eq!(
1960            parse_producer_operation("start").unwrap(),
1961            ProducerOperation::Start
1962        );
1963        assert_eq!(
1964            parse_producer_operation("stop").unwrap(),
1965            ProducerOperation::Stop
1966        );
1967        assert_eq!(
1968            parse_producer_operation("remove").unwrap(),
1969            ProducerOperation::Remove
1970        );
1971    }
1972
1973    #[test]
1974    fn test_parse_producer_operation_unknown() {
1975        let err = parse_producer_operation("destruir_mundo").unwrap_err();
1976        match err {
1977            CamelError::ProcessorError(msg) => {
1978                assert!(
1979                    msg.contains("Unknown container operation"),
1980                    "Unexpected error message: {}",
1981                    msg
1982                );
1983            }
1984            _ => panic!("Expected ProcessorError for unknown operation"),
1985        }
1986    }
1987
1988    #[test]
1989    fn test_parse_producer_operation_new_variants() {
1990        assert_eq!(
1991            parse_producer_operation("exec").unwrap(),
1992            ProducerOperation::Exec
1993        );
1994        assert_eq!(
1995            parse_producer_operation("network-create").unwrap(),
1996            ProducerOperation::NetworkCreate
1997        );
1998        assert_eq!(
1999            parse_producer_operation("network-connect").unwrap(),
2000            ProducerOperation::NetworkConnect
2001        );
2002        assert_eq!(
2003            parse_producer_operation("network-disconnect").unwrap(),
2004            ProducerOperation::NetworkDisconnect
2005        );
2006        assert_eq!(
2007            parse_producer_operation("network-remove").unwrap(),
2008            ProducerOperation::NetworkRemove
2009        );
2010        assert_eq!(
2011            parse_producer_operation("network-list").unwrap(),
2012            ProducerOperation::NetworkList
2013        );
2014    }
2015
2016    #[test]
2017    fn test_resolve_container_name_header_overrides_config() {
2018        let config = ContainerConfig::from_uri("container:run?name=config-name").unwrap();
2019        let mut exchange = Exchange::new(Message::new(""));
2020        exchange.input.set_header(
2021            HEADER_CONTAINER_NAME,
2022            serde_json::Value::String("header-name".to_string()),
2023        );
2024
2025        let resolved = resolve_container_name(&exchange, &config);
2026        assert_eq!(resolved.as_deref(), Some("header-name"));
2027    }
2028
2029    #[test]
2030    fn test_container_config_rejects_tcp_host() {
2031        let config = ContainerConfig::from_uri("container:list?host=tcp://localhost:2375").unwrap();
2032        let err = config.connect_docker_client().unwrap_err();
2033        match err {
2034            CamelError::ProcessorError(msg) => {
2035                assert!(
2036                    msg.to_lowercase().contains("tcp"),
2037                    "Expected TCP scheme error, got: {}",
2038                    msg
2039                );
2040            }
2041            _ => panic!("Expected ProcessorError for unsupported tcp host"),
2042        }
2043    }
2044
2045    #[tokio::test]
2046    async fn test_run_container_with_cleanup_removes_on_start_failure() {
2047        let remove_called = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
2048        let remove_called_clone = remove_called.clone();
2049
2050        let result = run_container_with_cleanup(
2051            || async { Ok("container-123".to_string()) },
2052            |_id| async move {
2053                Err(CamelError::ProcessorError(
2054                    "Failed to start container".to_string(),
2055                ))
2056            },
2057            move |_id| {
2058                let remove_called_inner = remove_called_clone.clone();
2059                async move {
2060                    remove_called_inner.store(true, std::sync::atomic::Ordering::SeqCst);
2061                    Ok(())
2062                }
2063            },
2064        )
2065        .await;
2066
2067        assert!(result.is_err(), "Expected start failure to bubble up");
2068        assert!(
2069            remove_called.load(std::sync::atomic::Ordering::SeqCst),
2070            "Expected cleanup to remove container"
2071        );
2072    }
2073
2074    #[test]
2075    fn test_container_component_creates_endpoint() {
2076        let component = ContainerComponent::new();
2077        assert_eq!(component.scheme(), "container");
2078        let ctx = NoOpComponentContext;
2079        let endpoint = component
2080            .create_endpoint("container:run?image=alpine", &ctx)
2081            .unwrap();
2082        assert_eq!(endpoint.uri(), "container:run?image=alpine");
2083    }
2084
2085    #[test]
2086    fn test_container_config_parses_ports() {
2087        let config =
2088            ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80,8443:443").unwrap();
2089        assert_eq!(config.ports.as_deref(), Some("8080:80,8443:443"));
2090    }
2091
2092    #[test]
2093    fn test_container_config_parses_env() {
2094        let config =
2095            ContainerConfig::from_uri("container:run?image=nginx&env=FOO=bar,BAZ=qux").unwrap();
2096        assert_eq!(config.env.as_deref(), Some("FOO=bar,BAZ=qux"));
2097    }
2098
2099    #[test]
2100    fn test_container_config_parses_logs_options() {
2101        let config = ContainerConfig::from_uri(
2102            "container:logs?containerId=my-app&follow=true&timestamps=true&tail=100",
2103        )
2104        .unwrap();
2105        assert_eq!(config.operation, "logs");
2106        assert_eq!(config.container_id.as_deref(), Some("my-app"));
2107        assert!(config.follow);
2108        assert!(config.timestamps);
2109        assert_eq!(config.tail.as_deref(), Some("100"));
2110    }
2111
2112    #[test]
2113    fn test_container_config_logs_defaults() {
2114        let config = ContainerConfig::from_uri("container:logs?containerId=test").unwrap();
2115        assert!(config.follow); // default: true
2116        assert!(!config.timestamps); // default: false
2117        assert!(config.tail.is_none()); // default: None (all)
2118    }
2119
2120    #[test]
2121    fn test_parse_ports_single() {
2122        let config = ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80").unwrap();
2123        let (exposed, bindings) = config.parse_ports().unwrap();
2124
2125        assert!(exposed.contains(&"80/tcp".to_string()));
2126        assert!(bindings.contains_key("80/tcp"));
2127
2128        let binding = bindings.get("80/tcp").unwrap().as_ref().unwrap();
2129        assert_eq!(binding.len(), 1);
2130        assert_eq!(binding[0].host_port, Some("8080".to_string()));
2131    }
2132
2133    #[test]
2134    fn test_parse_ports_multiple() {
2135        let config =
2136            ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80,8443:443").unwrap();
2137        let (exposed, bindings) = config.parse_ports().unwrap();
2138
2139        assert!(exposed.contains(&"80/tcp".to_string()));
2140        assert!(exposed.contains(&"443/tcp".to_string()));
2141        assert_eq!(bindings.len(), 2);
2142    }
2143
2144    #[test]
2145    fn test_parse_ports_with_protocol() {
2146        let config =
2147            ContainerConfig::from_uri("container:run?image=nginx&ports=8080:80/tcp,5353:53/udp")
2148                .unwrap();
2149        let (exposed, _bindings) = config.parse_ports().unwrap();
2150
2151        assert!(exposed.contains(&"80/tcp".to_string()));
2152        assert!(exposed.contains(&"53/udp".to_string()));
2153    }
2154
2155    #[test]
2156    fn test_parse_ports_none() {
2157        let config = ContainerConfig::from_uri("container:run?image=nginx").unwrap();
2158        let (exposed, bindings) = config.parse_ports().unwrap();
2159        assert!(exposed.is_empty());
2160        assert!(bindings.is_empty());
2161    }
2162
2163    #[test]
2164    fn test_parse_env_single() {
2165        let config = ContainerConfig::from_uri("container:run?image=nginx&env=FOO=bar").unwrap();
2166        let env = config.parse_env().unwrap();
2167
2168        assert_eq!(env.len(), 1);
2169        assert_eq!(env[0], "FOO=bar");
2170    }
2171
2172    #[test]
2173    fn test_parse_env_multiple() {
2174        let config =
2175            ContainerConfig::from_uri("container:run?image=nginx&env=FOO=bar,BAZ=qux,NUM=123")
2176                .unwrap();
2177        let env = config.parse_env().unwrap();
2178
2179        assert_eq!(env.len(), 3);
2180        assert!(env.contains(&"FOO=bar".to_string()));
2181        assert!(env.contains(&"BAZ=qux".to_string()));
2182        assert!(env.contains(&"NUM=123".to_string()));
2183    }
2184
2185    #[test]
2186    fn test_parse_env_none() {
2187        let config = ContainerConfig::from_uri("container:run?image=nginx").unwrap();
2188        assert!(config.parse_env().is_none());
2189    }
2190
2191    use camel_component_api::Message;
2192    use std::sync::Arc;
2193
2194    #[tokio::test]
2195    async fn test_container_producer_connection_error_on_invalid_host() {
2196        // Test that an invalid host (nonexistent socket) results in a connection error
2197        let component = ContainerComponent::new();
2198        let ctx = NoOpComponentContext;
2199        let endpoint = component
2200            .create_endpoint("container:list?host=unix:///nonexistent/docker.sock", &ctx)
2201            .unwrap();
2202
2203        let ctx = ProducerContext::new();
2204        let result = endpoint.create_producer(rt(), &ctx);
2205
2206        // The producer should return an error because it cannot connect to the invalid socket
2207        assert!(
2208            result.is_err(),
2209            "Expected error when connecting to invalid host"
2210        );
2211        let err = result.unwrap_err();
2212        match &err {
2213            CamelError::ProcessorError(msg) => {
2214                assert!(
2215                    msg.to_lowercase().contains("connection")
2216                        || msg.to_lowercase().contains("connect")
2217                        || msg.to_lowercase().contains("socket")
2218                        || msg.contains("docker"),
2219                    "Error message should indicate connection failure, got: {}",
2220                    msg
2221                );
2222            }
2223            _ => panic!("Expected ProcessorError, got: {:?}", err),
2224        }
2225    }
2226
2227    /// Test that consumer returns an error for unsupported operations.
2228    #[tokio::test]
2229    async fn test_container_consumer_unsupported_operation() {
2230        use tokio::sync::mpsc;
2231
2232        let component = ContainerComponent::new();
2233        let ctx = NoOpComponentContext;
2234        let endpoint = component.create_endpoint("container:run", &ctx).unwrap();
2235        let mut consumer = endpoint.create_consumer(rt()).unwrap();
2236
2237        // Create a minimal ConsumerContext
2238        let (tx, _rx) = mpsc::channel(16);
2239        let cancel_token = tokio_util::sync::CancellationToken::new();
2240        let context = ConsumerContext::new(tx, cancel_token, "container-test-route".to_string());
2241
2242        let result = consumer.start(context).await;
2243
2244        // Should return error because "run" is not a supported consumer operation
2245        assert!(
2246            result.is_err(),
2247            "Expected error for unsupported consumer operation"
2248        );
2249        let err = result.unwrap_err();
2250        match &err {
2251            CamelError::EndpointCreationFailed(msg) => {
2252                assert!(
2253                    msg.contains("Consumer only supports 'events' or 'logs'"),
2254                    "Error message should mention events or logs support, got: {}",
2255                    msg
2256                );
2257            }
2258            _ => panic!("Expected EndpointCreationFailed error, got: {:?}", err),
2259        }
2260    }
2261
2262    #[test]
2263    fn test_container_consumer_concurrency_model_is_concurrent() {
2264        let consumer = ContainerConsumer {
2265            config: ContainerConfig::from_uri("container:events").unwrap(),
2266            runtime: test_rt(),
2267        };
2268
2269        assert_eq!(
2270            consumer.concurrency_model(),
2271            camel_component_api::ConcurrencyModel::Concurrent { max: None }
2272        );
2273    }
2274
2275    #[test]
2276    fn test_container_config_parses_volumes() {
2277        let config = ContainerConfig::from_uri(
2278            "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro",
2279        )
2280        .unwrap();
2281        assert_eq!(
2282            config.volumes.as_deref(),
2283            Some("./html:/usr/share/nginx/html:ro")
2284        );
2285    }
2286
2287    #[test]
2288    fn test_container_config_parses_exec_params() {
2289        let config = ContainerConfig::from_uri(
2290            "container:exec?containerId=my-app&cmd=ls /app&user=root&workdir=/tmp&detach=true",
2291        )
2292        .unwrap();
2293        assert_eq!(config.operation, "exec");
2294        assert_eq!(config.container_id.as_deref(), Some("my-app"));
2295        assert_eq!(config.cmd.as_deref(), Some("ls /app"));
2296        assert_eq!(config.user.as_deref(), Some("root"));
2297        assert_eq!(config.workdir.as_deref(), Some("/tmp"));
2298        assert!(config.detach);
2299    }
2300
2301    #[test]
2302    fn test_container_config_parses_network_create_params() {
2303        let config =
2304            ContainerConfig::from_uri("container:network-create?name=my-net&driver=bridge")
2305                .unwrap();
2306        assert_eq!(config.operation, "network-create");
2307        assert_eq!(config.name.as_deref(), Some("my-net"));
2308        assert_eq!(config.driver.as_deref(), Some("bridge"));
2309    }
2310
2311    #[test]
2312    fn test_container_config_defaults_new_fields() {
2313        let config = ContainerConfig::from_uri("container:list").unwrap();
2314        assert!(config.volumes.is_none());
2315        assert!(config.user.is_none());
2316        assert!(config.workdir.is_none());
2317        assert!(!config.detach);
2318        assert!(config.driver.is_none());
2319        assert!(!config.force);
2320    }
2321
2322    #[test]
2323    fn test_parse_volumes_bind_mount() {
2324        let config = ContainerConfig::from_uri(
2325            "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro",
2326        )
2327        .unwrap();
2328        let (binds, anon) = config.parse_volumes().unwrap();
2329        assert_eq!(binds, vec!["./html:/usr/share/nginx/html:ro"]);
2330        assert!(anon.is_empty());
2331    }
2332
2333    #[test]
2334    fn test_parse_volumes_named_volume() {
2335        let config =
2336            ContainerConfig::from_uri("container:run?image=postgres&volumes=data:/var/lib/data")
2337                .unwrap();
2338        let (binds, anon) = config.parse_volumes().unwrap();
2339        assert_eq!(binds, vec!["data:/var/lib/data"]);
2340        assert!(anon.is_empty());
2341    }
2342
2343    #[test]
2344    fn test_parse_volumes_anonymous() {
2345        let config =
2346            ContainerConfig::from_uri("container:run?image=alpine&volumes=/tmp/app-data").unwrap();
2347        let (binds, anon) = config.parse_volumes().unwrap();
2348        assert!(binds.is_empty());
2349        assert!(anon.contains(&"/tmp/app-data".to_string()));
2350    }
2351
2352    #[test]
2353    fn test_parse_volumes_anonymous_with_mode() {
2354        let config =
2355            ContainerConfig::from_uri("container:run?image=alpine&volumes=/tmp/app-data:ro")
2356                .unwrap();
2357        let (binds, anon) = config.parse_volumes().unwrap();
2358        assert!(binds.is_empty());
2359        assert!(anon.contains(&"/tmp/app-data".to_string()));
2360    }
2361
2362    #[test]
2363    fn test_parse_volumes_multiple() {
2364        let config = ContainerConfig::from_uri(
2365            "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro,data:/var/log/app",
2366        )
2367        .unwrap();
2368        let (binds, anon) = config.parse_volumes().unwrap();
2369        assert_eq!(binds.len(), 2);
2370        assert!(binds.contains(&"./html:/usr/share/nginx/html:ro".to_string()));
2371        assert!(binds.contains(&"data:/var/log/app".to_string()));
2372        assert!(anon.is_empty());
2373    }
2374
2375    #[test]
2376    fn test_parse_volumes_mixed() {
2377        let config = ContainerConfig::from_uri(
2378            "container:run?image=nginx&volumes=./html:/usr/share/nginx/html:ro,/tmp/cache",
2379        )
2380        .unwrap();
2381        let (binds, anon) = config.parse_volumes().unwrap();
2382        assert_eq!(binds.len(), 1);
2383        assert!(anon.contains(&"/tmp/cache".to_string()));
2384    }
2385
2386    #[test]
2387    fn test_parse_volumes_none() {
2388        let config = ContainerConfig::from_uri("container:run?image=nginx").unwrap();
2389        assert!(config.parse_volumes().is_none());
2390    }
2391
2392    #[test]
2393    fn test_parse_volumes_empty_entry_skipped() {
2394        let config = ContainerConfig::from_uri("container:run?image=nginx&volumes=,,").unwrap();
2395        assert!(config.parse_volumes().is_none());
2396    }
2397
2398    #[test]
2399    fn test_parse_volumes_rw_mode() {
2400        let config =
2401            ContainerConfig::from_uri("container:run?image=nginx&volumes=./data:/app/data:rw")
2402                .unwrap();
2403        let (binds, _) = config.parse_volumes().unwrap();
2404        assert_eq!(binds, vec!["./data:/app/data:rw"]);
2405    }
2406
2407    #[test]
2408    fn test_container_config_from_uri_parses_false_flags() {
2409        let config = ContainerConfig::from_uri(
2410            "container:logs?containerId=a&follow=false&timestamps=FALSE&autoPull=false&autoRemove=False&detach=TRUE&force=true",
2411        )
2412        .unwrap();
2413        assert!(!config.follow);
2414        assert!(!config.timestamps);
2415        assert!(!config.auto_pull);
2416        assert!(!config.auto_remove);
2417        assert!(config.detach);
2418        assert!(config.force);
2419    }
2420
2421    #[test]
2422    fn test_docker_socket_path_validation() {
2423        let unix_cfg =
2424            ContainerConfig::from_uri("container:list?host=unix:///tmp/docker.sock").unwrap();
2425        assert_eq!(
2426            unix_cfg.docker_socket_path().unwrap(),
2427            "unix:///tmp/docker.sock"
2428        );
2429
2430        let npipe_cfg =
2431            ContainerConfig::from_uri("container:list?host=npipe:////./pipe/docker_engine")
2432                .unwrap();
2433        assert_eq!(
2434            npipe_cfg.docker_socket_path().unwrap(),
2435            "npipe:////./pipe/docker_engine"
2436        );
2437
2438        let plain_cfg =
2439            ContainerConfig::from_uri("container:list?host=/var/run/docker.sock").unwrap();
2440        assert_eq!(
2441            plain_cfg.docker_socket_path().unwrap(),
2442            "/var/run/docker.sock"
2443        );
2444
2445        let bad_cfg =
2446            ContainerConfig::from_uri("container:list?host=http://localhost:2375").unwrap();
2447        assert!(bad_cfg.docker_socket_path().is_err());
2448    }
2449
2450    #[test]
2451    fn test_parse_ports_invalid_and_whitespace_entries() {
2452        // "8080" without colon is malformed — must fail-fast
2453        let cfg = ContainerConfig::from_uri("container:run?ports= , ,8080").unwrap();
2454        let err = cfg.parse_ports().unwrap_err();
2455        assert!(
2456            err.to_string().contains("malformed port mapping"),
2457            "expected malformed port error, got: {}",
2458            err
2459        );
2460
2461        let cfg =
2462            ContainerConfig::from_uri("container:run?ports= 8080:80 ,  5353:53/udp ").unwrap();
2463        let (exposed, bindings) = cfg.parse_ports().unwrap();
2464        assert!(exposed.contains(&"80/tcp".to_string()));
2465        assert!(exposed.contains(&"53/udp".to_string()));
2466        assert_eq!(bindings.len(), 2);
2467    }
2468
2469    #[test]
2470    fn test_parse_ports_fail_fast_on_malformed() {
2471        // First entry valid, second malformed — must fail on the malformed one
2472        let cfg = ContainerConfig::from_uri("container:run?ports=8080:80,badentry").unwrap();
2473        let err = cfg.parse_ports().unwrap_err();
2474        assert!(
2475            err.to_string()
2476                .contains("malformed port mapping 'badentry'"),
2477            "expected fail-fast on 'badentry', got: {}",
2478            err
2479        );
2480    }
2481
2482    #[test]
2483    fn test_parse_env_trims_and_filters_empty_items() {
2484        let cfg = ContainerConfig::from_uri("container:run?env= FOO=bar , ,BAZ=qux, ").unwrap();
2485        let env = cfg.parse_env().unwrap();
2486        assert_eq!(env, vec!["FOO=bar".to_string(), "BAZ=qux".to_string()]);
2487
2488        let cfg = ContainerConfig::from_uri("container:run?env= , , ").unwrap();
2489        assert!(cfg.parse_env().is_none());
2490    }
2491
2492    #[test]
2493    fn test_parse_volume_str_rejects_invalid_mode_and_accepts_mixed() {
2494        assert!(parse_volume_str("/host:/ctr:badmode").is_none());
2495        let (binds, anon) = parse_volume_str("a:/b:rw,/tmp/cache,/tmp/logs:ro").unwrap();
2496        assert!(binds.contains(&"a:/b:rw".to_string()));
2497        assert!(anon.contains(&"/tmp/cache".to_string()));
2498        assert!(anon.contains(&"/tmp/logs".to_string()));
2499    }
2500
2501    #[test]
2502    fn test_format_docker_event_variants_and_timestamp_extraction() {
2503        let mut attrs = std::collections::HashMap::new();
2504        attrs.insert("name".to_string(), "demo".to_string());
2505        attrs.insert("image".to_string(), "alpine:latest".to_string());
2506        attrs.insert("exitCode".to_string(), "137".to_string());
2507        let actor = bollard::models::EventActor {
2508            id: None,
2509            attributes: Some(attrs),
2510        };
2511
2512        let create_event = bollard::models::EventMessage {
2513            action: Some("create".to_string()),
2514            actor: Some(actor.clone()),
2515            ..Default::default()
2516        };
2517        assert_eq!(
2518            format_docker_event(&create_event),
2519            "[CREATE] Container demo (alpine:latest)"
2520        );
2521
2522        let die_event = bollard::models::EventMessage {
2523            action: Some("die".to_string()),
2524            actor: Some(actor),
2525            ..Default::default()
2526        };
2527        assert_eq!(
2528            format_docker_event(&die_event),
2529            "[DIE]    Container demo (exit: 137)"
2530        );
2531
2532        let other_event = bollard::models::EventMessage {
2533            action: Some("oom".to_string()),
2534            actor: None,
2535            ..Default::default()
2536        };
2537        assert_eq!(format_docker_event(&other_event), "[OOM] Container unknown");
2538
2539        assert_eq!(
2540            extract_timestamp("2024-01-01T00:00:00Z hello"),
2541            Some("2024-01-01T00:00:00Z".to_string())
2542        );
2543        assert_eq!(extract_timestamp("hello world"), None);
2544    }
2545
2546    #[tokio::test]
2547    async fn test_run_container_with_cleanup_error_paths() {
2548        let create_fail = run_container_with_cleanup(
2549            || async { Err(CamelError::ProcessorError("create-fail".to_string())) },
2550            |_id| async move { Ok(()) },
2551            |_id| async move { Ok(()) },
2552        )
2553        .await;
2554        assert!(
2555            matches!(create_fail, Err(CamelError::ProcessorError(msg)) if msg == "create-fail")
2556        );
2557
2558        let cleanup_fail = run_container_with_cleanup(
2559            || async { Ok("cid-1".to_string()) },
2560            |_id| async move { Err(CamelError::ProcessorError("start-fail".to_string())) },
2561            |_id| async move { Err(CamelError::ProcessorError("remove-fail".to_string())) },
2562        )
2563        .await;
2564        match cleanup_fail {
2565            Err(CamelError::ProcessorError(msg)) => {
2566                assert!(msg.contains("Failed to start container"));
2567                assert!(msg.contains("Cleanup failed"));
2568            }
2569            other => panic!("unexpected result: {:?}", other),
2570        }
2571    }
2572
2573    #[tokio::test]
2574    async fn test_logs_consumer_requires_container_id() {
2575        use tokio::sync::mpsc;
2576
2577        let mut consumer = ContainerConsumer {
2578            config: ContainerConfig::from_uri("container:logs").unwrap(),
2579            runtime: test_rt(),
2580        };
2581        let (tx, _rx) = mpsc::channel(4);
2582        let context = ConsumerContext::new(
2583            tx,
2584            tokio_util::sync::CancellationToken::new(),
2585            "container-test-route".to_string(),
2586        );
2587
2588        let err = consumer.start(context).await.unwrap_err();
2589        match err {
2590            CamelError::EndpointCreationFailed(msg) => {
2591                assert!(msg.contains("containerId is required for logs consumer"));
2592            }
2593            other => panic!("unexpected error: {:?}", other),
2594        }
2595    }
2596
2597    #[tokio::test]
2598    async fn test_events_consumer_stops_immediately_when_cancelled() {
2599        use tokio::sync::mpsc;
2600
2601        let mut consumer = ContainerConsumer {
2602            config: ContainerConfig::from_uri("container:events").unwrap(),
2603            runtime: test_rt(),
2604        };
2605
2606        let (tx, _rx) = mpsc::channel(4);
2607        let cancel = tokio_util::sync::CancellationToken::new();
2608        cancel.cancel();
2609        let context = ConsumerContext::new(tx, cancel, "container-test-route".to_string());
2610
2611        let result = consumer.start(context).await;
2612        assert!(result.is_ok());
2613    }
2614
2615    #[test]
2616    fn test_global_config_constructors_and_endpoint_docker_host() {
2617        let global = ContainerGlobalConfig::new().with_docker_host("unix:///tmp/docker.sock");
2618        let mut cfg = ContainerConfig::from_uri("container:list").unwrap();
2619        cfg.apply_global_defaults(&global);
2620
2621        let endpoint = ContainerEndpoint {
2622            uri: "container:list".to_string(),
2623            config: cfg,
2624        };
2625
2626        assert_eq!(endpoint.docker_host(), Some("unix:///tmp/docker.sock"));
2627
2628        let component = ContainerComponent::with_config(global);
2629        assert_eq!(component.scheme(), "container");
2630    }
2631
2632    #[test]
2633    fn container_global_config_has_reconnect_policy() {
2634        let cfg = ContainerGlobalConfig::default();
2635        assert_eq!(cfg.reconnect.max_attempts, 0); // unlimited
2636        assert!(cfg.reconnect.enabled);
2637    }
2638
2639    /// Regression: max_attempts=N → exactly N invocations (caught OpenSearch off-by-one 1f5c4c2a).
2640    /// Replicates the exact retry loop from ContainerConsumer::{start_events_consumer,start_logs_consumer}
2641    /// (lib.rs:~1408-1431, ~1503-1525):
2642    ///   attempt starts at 0, incremented on error, !should_retry(attempt), delay_for(attempt-1)
2643    #[tokio::test]
2644    async fn retry_loop_invokes_operation_exactly_max_attempts_times() {
2645        use std::sync::Arc;
2646        use std::sync::atomic::{AtomicU32, Ordering};
2647        use std::time::Duration;
2648
2649        let policy = NetworkRetryPolicy {
2650            max_attempts: 3,
2651            initial_delay: Duration::from_millis(1),
2652            max_delay: Duration::from_millis(1),
2653            multiplier: 1.0,
2654            ..NetworkRetryPolicy::default()
2655        };
2656
2657        let calls = Arc::new(AtomicU32::new(0));
2658        let calls_clone = Arc::clone(&calls);
2659        let mut attempt: u32 = 0;
2660
2661        loop {
2662            calls_clone.fetch_add(1, Ordering::SeqCst);
2663            let result: Result<(), ()> = Err(());
2664            match result {
2665                Ok(_) => {
2666                    break;
2667                }
2668                Err(_) => {
2669                    attempt += 1;
2670                    if !policy.should_retry(attempt) {
2671                        break;
2672                    }
2673                    let delay = policy.delay_for(attempt - 1);
2674                    tokio::time::sleep(delay).await;
2675                    continue;
2676                }
2677            }
2678        }
2679
2680        assert_eq!(
2681            calls.load(Ordering::SeqCst),
2682            3,
2683            "max_attempts=3 must yield exactly 3 invocations"
2684        );
2685    }
2686
2687    // -----------------------------------------------------------------------
2688    // ADR-0012 (e) metric wiring regression tests
2689    // -----------------------------------------------------------------------
2690
2691    /// Regression: events-connect error path calls increment_errors with
2692    /// correct route_id and label. Uses an unsupported tcp:// host to trigger
2693    /// the error path WITHOUT needing a real Docker daemon (connect_docker_from_host
2694    /// returns Err on non-unix/npipe schemes).
2695    #[tokio::test]
2696    async fn events_connect_error_increments_metrics() {
2697        use std::sync::Mutex;
2698        use std::time::Duration;
2699
2700        struct RecordingMetrics(Mutex<Vec<(String, String)>>);
2701
2702        impl MetricsCollector for RecordingMetrics {
2703            fn record_exchange_duration(&self, _: &str, _: Duration) {}
2704            fn increment_errors(&self, route_id: &str, error_type: &str) {
2705                self.0
2706                    .lock()
2707                    .unwrap()
2708                    .push((route_id.to_string(), error_type.to_string()));
2709            }
2710            fn increment_exchanges(&self, _: &str) {}
2711            fn set_queue_depth(&self, _: &str, _: usize) {}
2712            fn record_circuit_breaker_change(&self, _: &str, _: &str, _: &str) {}
2713        }
2714
2715        struct RecordingRuntime {
2716            metrics: Arc<RecordingMetrics>,
2717        }
2718
2719        impl HealthCheckRegistry for RecordingRuntime {
2720            fn force_unhealthy_for_route(&self, _: &str, _: &str, _: &str) {}
2721        }
2722
2723        impl RuntimeObservability for RecordingRuntime {
2724            fn metrics(&self) -> Arc<dyn MetricsCollector> {
2725                self.metrics.clone()
2726            }
2727            fn health(&self) -> Arc<dyn HealthCheckRegistry> {
2728                Arc::new(camel_component_api::NoOpHealthCheckRegistry)
2729            }
2730        }
2731
2732        let recording = Arc::new(RecordingMetrics(Mutex::new(Vec::new())));
2733        let rt: Arc<dyn RuntimeObservability> = Arc::new(RecordingRuntime {
2734            metrics: recording.clone(),
2735        });
2736
2737        // Use unsupported tcp:// host so connect_docker_from_host() fails fast
2738        // without any real I/O. Disable retry so the first failure propagates
2739        // immediately to the Err(e) arm.
2740        let mut config = ContainerConfig::from_uri("container:events").unwrap();
2741        config.host = Some("tcp://192.0.2.1:2375".to_string());
2742        config.reconnect = NetworkRetryPolicy::disabled();
2743
2744        let mut consumer = ContainerConsumer {
2745            config,
2746            runtime: rt,
2747        };
2748
2749        let (tx, _rx) = tokio::sync::mpsc::channel(4);
2750        let cancel = tokio_util::sync::CancellationToken::new();
2751        let context = ConsumerContext::new(tx, cancel, "events-test-route".to_string());
2752
2753        let result = consumer.start(context).await;
2754        assert!(result.is_err(), "expected Docker connection error");
2755
2756        let errors = recording.0.lock().unwrap();
2757        assert_eq!(
2758            errors.len(),
2759            1,
2760            "expected exactly one increment_errors call"
2761        );
2762        assert_eq!(errors[0].0, "events-test-route");
2763        assert_eq!(errors[0].1, "e:container:events-connect");
2764    }
2765
2766    #[test]
2767    fn test_connect_docker_from_host_rejects_bad_scheme() {
2768        let err = connect_docker_from_host(Some("tcp://localhost:2375")).unwrap_err();
2769        match &err {
2770            CamelError::ProcessorError(msg) => {
2771                assert!(msg.contains("tcp"), "expected tcp rejection, got: {}", msg);
2772            }
2773            _ => panic!("expected ProcessorError, got: {:?}", err),
2774        }
2775    }
2776
2777    #[test]
2778    fn test_connect_docker_from_host_accepts_unix_scheme() {
2779        // Validation succeeds; connection attempt fails on nonexistent socket
2780        let result = connect_docker_from_host(Some("unix:///nonexistent/docker.sock"));
2781        match result {
2782            Err(CamelError::ProcessorError(msg)) => {
2783                // bollard error should mention the socket or connection failure
2784                assert!(
2785                    msg.contains("nonexistent")
2786                        || msg.contains("connect")
2787                        || msg.contains("socket"),
2788                    "expected error referencing socket, got: {}",
2789                    msg
2790                );
2791            }
2792            Ok(_) => {} // Docker daemon is running on this path (unlikely in CI)
2793            Err(other) => panic!("unexpected error type: {:?}", other),
2794        }
2795    }
2796
2797    #[test]
2798    fn test_connect_docker_from_host_accepts_schemeless_path() {
2799        // schemeless paths pass through validation
2800        let result = connect_docker_from_host(Some("/nonexistent/docker.sock"));
2801        match result {
2802            Err(CamelError::ProcessorError(msg)) => {
2803                assert!(
2804                    msg.contains("nonexistent")
2805                        || msg.contains("connect")
2806                        || msg.contains("socket"),
2807                    "expected error referencing socket, got: {}",
2808                    msg
2809                );
2810            }
2811            Ok(_) => {} // real daemon on this path (unlikely in CI)
2812            Err(other) => panic!("unexpected error type: {:?}", other),
2813        }
2814    }
2815
2816    #[tokio::test]
2817    async fn test_cleanup_respects_custom_docker_host() {
2818        // Track a dummy container ID so cleanup attempts connection
2819        track_container("test-custom-host-container".to_string());
2820
2821        // Must not panic regardless of whether Docker is available.
2822        // The function logs errors and returns gracefully.
2823        cleanup_tracked_containers(Some("unix:///nonexistent/docker.sock")).await;
2824
2825        // Clean up: remove the dummy ID from the tracker
2826        untrack_container("test-custom-host-container");
2827    }
2828
2829    #[tokio::test]
2830    async fn test_cleanup_none_uses_defaults() {
2831        // Must not panic on empty tracker or connection failure.
2832        cleanup_tracked_containers(None).await;
2833    }
2834
2835    #[test]
2836    fn uri_options_count_parity() {
2837        assert_eq!(
2838            ContainerConfig::uri_options().len(),
2839            19,
2840            "ContainerUriConfig #[uri_param] count drifted from parser"
2841        );
2842    }
2843}