Skip to main content

cameleer_main/
lib.rs

1//! # cameleer-main
2//!
3//! Standalone runner for Cameleer applications (Camel Main equivalent):
4//! loads `application.properties`/`application.yaml` (with profiles, relaxed binding,
5//! environment variables, `camel.springboot.*` aliases, `camel.component.*` options and
6//! `camel.beans.*` beans, see [`config`]), configures the context, discovers route files
7//! (YAML/XML DSL, Kamelets, Pipes and Camel K `Integration` resources, see
8//! [`integration`]), starts the context and keeps running until stopped (signal, stop file,
9//! max duration, max messages or idle timeout). With `--dev` (`camel.main.dev-reload`) route
10//! files are reloaded when they change; [`local`] publishes the status files used by the
11//! `cameleer ps/get/cmd/log/trace/stop` commands.
12
13pub mod cli;
14pub mod testing;
15pub mod config;
16pub mod console;
17pub mod features;
18pub mod integration;
19pub mod lambda;
20pub mod local;
21pub mod properties;
22#[cfg(feature = "validate")]
23pub mod schema;
24pub mod signal;
25pub mod tools;
26#[cfg(feature = "validate")]
27pub mod validate;
28pub mod yaml_rest;
29
30use std::collections::BTreeMap;
31use std::path::{Path, PathBuf};
32use std::sync::atomic::{AtomicU64, Ordering};
33use std::sync::Arc;
34use std::time::{Duration, Instant};
35
36use cameleer_core::context::CamelEvent;
37use cameleer_core::prelude::*;
38use cameleer_core::spi::EventNotifier;
39
40/// Loads routes from a file's content into a context (registered per file extension).
41pub type RouteLoader = Arc<dyn Fn(&CamelContext, &str, &Path) -> Result<()> + Send + Sync>;
42
43/// Configuration options (from `camel.main.*` properties or code).
44#[derive(Clone, Debug, Default)]
45pub struct MainConfig {
46    pub name: Option<String>,
47    /// Glob patterns for route files, comma separated (e.g. `routes/*.yaml`).
48    pub routes_include_pattern: Vec<String>,
49    pub duration_max_seconds: Option<u64>,
50    pub duration_max_messages: Option<u64>,
51    pub duration_max_idle_seconds: Option<u64>,
52    pub shutdown_timeout: Option<Duration>,
53    pub log_level: Option<String>,
54    /// Reload route files when they change (`cameleer run --dev`).
55    pub dev_reload: bool,
56}
57
58/// The main runner.
59pub struct Main {
60    ctx: CamelContext,
61    config: MainConfig,
62    properties: BTreeMap<String, String>,
63    loaders: BTreeMap<String, RouteLoader>,
64    route_files: Vec<PathBuf>,
65    configurers: Vec<Box<dyn FnOnce(&CamelContext) -> Result<()>>>,
66    console: Option<console::DevConsoleServer>,
67    /// The MCP server's HTTP endpoint (`camel.server.mcp-enabled`), stopped when dropped.
68    mcp: std::sync::Mutex<Option<Box<dyn std::any::Any + Send>>>,
69    /// Properties set in code / on the command line (highest precedence).
70    explicit: BTreeMap<String, String>,
71    /// Defaults set in code / on the command line (lowest precedence).
72    defaults: BTreeMap<String, String>,
73    /// Per-profile YAML documents of the loaded configuration files.
74    profile_docs: Vec<(String, BTreeMap<String, String>)>,
75    /// Directories of the loaded configuration files (for `application-<profile>.*`).
76    config_dirs: Vec<PathBuf>,
77    profile: Option<String>,
78    env: Option<BTreeMap<String, String>>,
79    /// Route files loaded at startup with their modification time (dev reload).
80    loaded_files: Vec<(PathBuf, Option<std::time::SystemTime>)>,
81    local: Option<local::LocalStatus>,
82    local_dir: Option<PathBuf>,
83    /// In-memory route sources: (file name, content).
84    route_sources: Vec<(String, String)>,
85    /// Ids of the routes created from route files (replaced on reload).
86    file_route_ids: Vec<String>,
87    /// `host:port` of the AWS Lambda Runtime API (default: `AWS_LAMBDA_RUNTIME_API`).
88    lambda_api: Option<String>,
89}
90
91struct CompletedCounter {
92    completed: Arc<AtomicU64>,
93    last_activity: Arc<std::sync::Mutex<Instant>>,
94    /// `camel.main.duration-max-messages` (known once the context started): reaching it
95    /// wakes the run loop at once.
96    max_messages: Arc<std::sync::OnceLock<Option<u64>>>,
97    wake: Arc<Wake>,
98}
99
100/// Wakes [`Main::run`]'s loop before its next periodic check.
101#[derive(Default)]
102struct Wake {
103    flag: std::sync::Mutex<bool>,
104    cv: std::sync::Condvar,
105}
106
107impl Wake {
108    fn notify(&self) {
109        *self.flag.lock().unwrap() = true;
110        self.cv.notify_all();
111    }
112
113    /// Wait until notified or `timeout` passes.
114    fn wait(&self, timeout: Duration) {
115        let guard = self.flag.lock().unwrap();
116        let (mut guard, _) = self.cv.wait_timeout_while(guard, timeout, |woken| !*woken).unwrap();
117        *guard = false;
118    }
119}
120
121impl EventNotifier for CompletedCounter {
122    fn notify(&self, event: &CamelEvent) {
123        match event {
124            CamelEvent::ExchangeCompleted { .. } | CamelEvent::ExchangeFailed { .. } => {
125                let n = self.completed.fetch_add(1, Ordering::SeqCst) + 1;
126                *self.last_activity.lock().unwrap() = Instant::now();
127                if self.max_messages.get().copied().flatten() == Some(n) {
128                    self.wake.notify();
129                }
130            }
131            CamelEvent::ExchangeCreated { .. } => {
132                *self.last_activity.lock().unwrap() = Instant::now();
133            }
134            _ => {}
135        }
136    }
137}
138
139impl Default for Main {
140    fn default() -> Self {
141        Self::new()
142    }
143}
144
145impl Main {
146    pub fn new() -> Self {
147        Main {
148            ctx: CamelContext::new(),
149            config: MainConfig::default(),
150            properties: BTreeMap::new(),
151            loaders: BTreeMap::new(),
152            route_files: Vec::new(),
153            configurers: Vec::new(),
154            console: None,
155            mcp: std::sync::Mutex::new(None),
156            defaults: BTreeMap::new(),
157            explicit: BTreeMap::new(),
158            profile_docs: Vec::new(),
159            config_dirs: Vec::new(),
160            profile: None,
161            env: None,
162            loaded_files: Vec::new(),
163            local: None,
164            local_dir: None,
165            route_sources: Vec::new(),
166            file_route_ids: Vec::new(),
167            lambda_api: lambda::runtime_api(),
168        }
169    }
170
171    /// Serve AWS Lambda invocations from the Runtime API at `host:port` in [`Self::run`]
172    /// (Lambda sets `AWS_LAMBDA_RUNTIME_API`, which is the default); `None` turns it off.
173    pub fn set_lambda_runtime_api(&mut self, api: Option<String>) {
174        self.lambda_api = api;
175    }
176
177    /// Whether [`Self::run`] serves AWS Lambda invocations (see [`lambda`]).
178    pub fn is_lambda(&self) -> bool {
179        self.lambda_api.is_some()
180    }
181
182    /// Publish status files in `dir` and accept commands from the `cameleer` CLI (see
183    /// [`local`]).
184    pub fn enable_local_status(&mut self, dir: impl Into<PathBuf>) {
185        self.local_dir = Some(dir.into());
186    }
187
188    /// The local status publisher, when running with [`Self::enable_local_status`].
189    pub fn local_status(&self) -> Option<&local::LocalStatus> {
190        self.local.as_ref()
191    }
192
193    /// Replace the routes loaded from route files by reloading the files (dev mode).
194    /// Errors are logged; the previous routes are removed either way.
195    pub fn reload_route_files(&mut self) -> Result<()> {
196        for id in std::mem::take(&mut self.file_route_ids) {
197            let _ = self.ctx.stop_route(&id);
198            let _ = self.ctx.remove_route(&id);
199        }
200        let before: Vec<String> = self.ctx.route_ids();
201        let files: Vec<PathBuf> = self.loaded_files.iter().map(|(f, _)| f.clone()).collect();
202        let mut result = Ok(());
203        self.loaded_files.clear();
204        for f in files {
205            let mtime = std::fs::metadata(&f).and_then(|m| m.modified()).ok();
206            if let Err(e) = self.load_route_file(&f) {
207                cameleer_core::camel_error!("Main", "Failed to reload {}: {}", f.display(), e);
208                result = Err(e);
209            } else {
210                cameleer_core::camel_info!("Main", "Reloaded routes from: {}", f.display());
211            }
212            self.loaded_files.push((f, mtime));
213        }
214        self.file_route_ids = self.ctx.route_ids().into_iter().filter(|id| !before.contains(id)).collect();
215        result
216    }
217
218    /// Whether a loaded route file changed since it was (re)loaded.
219    fn route_files_changed(&self) -> bool {
220        self.loaded_files.iter().any(|(f, t)| std::fs::metadata(f).and_then(|m| m.modified()).ok() != *t)
221    }
222
223    /// Use an existing context.
224    pub fn with_context(ctx: CamelContext) -> Self {
225        Main { ctx, ..Self::new() }
226    }
227
228    pub fn context(&self) -> &CamelContext {
229        &self.ctx
230    }
231
232    pub fn config_mut(&mut self) -> &mut MainConfig {
233        &mut self.config
234    }
235
236    /// Register a route loader for a file extension (`yaml`, `yml`, `xml`, ...).
237    pub fn add_route_loader(&mut self, extension: &str, loader: impl Fn(&CamelContext, &str, &Path) -> Result<()> + Send + Sync + 'static) {
238        self.loaders.insert(extension.to_ascii_lowercase(), Arc::new(loader));
239    }
240
241    pub fn supported_extensions(&self) -> Vec<String> {
242        self.loaders.keys().cloned().collect()
243    }
244
245    /// Add a route file to load at startup.
246    pub fn add_route_file(&mut self, path: impl Into<PathBuf>) {
247        self.route_files.push(path.into());
248    }
249
250    /// Add an in-memory route source (e.g. embedded with `include_str!` by `cameleer export`);
251    /// the loader is chosen by the extension of `name`.
252    pub fn add_route_source(&mut self, name: impl Into<String>, content: impl Into<String>) {
253        self.route_sources.push((name.into(), content.into()));
254    }
255
256    /// Load configuration text (`yaml = true` for application.yaml content).
257    pub fn load_properties_text(&mut self, text: &str, yaml: bool) -> Result<()> {
258        if yaml {
259            let (base, docs) = config::parse_yaml_config(text)?;
260            self.properties.extend(base);
261            self.profile_docs.extend(docs);
262        } else {
263            self.properties.extend(properties::parse_properties(text));
264        }
265        Ok(())
266    }
267
268    /// Add routes with the Rust DSL.
269    pub fn configure(&mut self, f: impl FnOnce(&CamelContext) -> Result<()> + 'static) {
270        self.configurers.push(Box::new(f));
271    }
272
273    /// Set a property (overrides configuration files and environment variables, like a
274    /// command line argument in Spring Boot).
275    pub fn set_property(&mut self, key: impl Into<String>, value: impl Into<String>) {
276        self.explicit.insert(key.into(), value.into());
277    }
278
279    /// Set a property only if no file, profile, environment variable or explicit property
280    /// sets it.
281    pub fn set_default_property(&mut self, key: impl Into<String>, value: impl Into<String>) {
282        self.defaults.insert(key.into(), value.into());
283    }
284
285    /// The effective properties (after `start`: files, profile, environment and explicit
286    /// properties merged).
287    pub fn properties(&self) -> &BTreeMap<String, String> {
288        &self.properties
289    }
290
291    /// Activate a profile (`application-<profile>.*`, `%<profile>.` keys).
292    pub fn set_profile(&mut self, profile: impl Into<String>) {
293        self.profile = Some(profile.into());
294    }
295
296    /// Use these environment variables instead of the process environment.
297    pub fn set_environment(&mut self, env: BTreeMap<String, String>) {
298        self.env = Some(env);
299    }
300
301    /// Load a configuration file: `.properties`, or `.yaml`/`.yml` (flattened; documents
302    /// for other profiles are kept for [`Self::set_profile`]).
303    pub fn load_properties_file(&mut self, path: impl AsRef<Path>) -> Result<()> {
304        let path = path.as_ref();
305        let (base, docs) = config::load_file(path)?;
306        self.properties.extend(base);
307        self.profile_docs.extend(docs);
308        let dir = path.parent().map(Path::to_path_buf).unwrap_or_default();
309        if !self.config_dirs.contains(&dir) {
310            self.config_dirs.push(dir);
311        }
312        Ok(())
313    }
314
315    /// Load `application.properties`, `application.yaml` and `application.yml` from `dir`
316    /// (those that exist). Returns how many files were loaded.
317    pub fn load_application_config(&mut self, dir: impl AsRef<Path>) -> Result<usize> {
318        let files = config::application_files(dir.as_ref());
319        for f in &files {
320            self.load_properties_file(f)?;
321        }
322        if !self.config_dirs.contains(&dir.as_ref().to_path_buf()) {
323            self.config_dirs.push(dir.as_ref().to_path_buf());
324        }
325        Ok(files.len())
326    }
327
328    /// Merge files, profile, environment variables, aliases and explicit properties.
329    fn merge_configuration(&mut self) -> Result<()> {
330        let env: BTreeMap<String, String> = self.env.clone().unwrap_or_else(|| std::env::vars().collect());
331        let mut props = self.properties.clone();
332        let mut probe = props.clone();
333        probe.extend(self.explicit.clone());
334        let profile = self.profile.clone().or_else(|| config::active_profile(&probe, &env));
335        if let Some(p) = &profile {
336            for name in p.split(',').map(str::trim) {
337                for dir in self.config_dirs.clone() {
338                    for ext in ["properties", "yaml", "yml"] {
339                        let f = dir.join(format!("application-{}.{}", name, ext));
340                        if f.is_file() {
341                            let (base, docs) = config::load_file(&f)?;
342                            props.extend(base);
343                            self.profile_docs.extend(docs);
344                        }
345                    }
346                }
347            }
348            cameleer_core::camel_info!("Main", "The following profiles are active: {}", p);
349        }
350        config::apply_profile(&mut props, &self.profile_docs, profile.as_deref());
351        let schemes = self.ctx.component_names();
352        for (k, v) in &env {
353            if let Some(key) = config::env_to_property(k, &schemes) {
354                props.insert(key, v.clone());
355            }
356        }
357        props.extend(self.explicit.clone());
358        for (k, v) in &self.defaults {
359            if config::relaxed_get(&props, k).is_none() {
360                props.insert(k.clone(), v.clone());
361            }
362        }
363        config::apply_aliases(&mut props);
364        if let Some(p) = profile {
365            props.entry("camel.main.profile".into()).or_insert(p);
366        }
367        resolve_runtime_placeholders(&mut props);
368        self.properties = props;
369        Ok(())
370    }
371
372    /// Apply `camel.main.*` and other well-known properties to the configuration.
373    fn apply_properties(&mut self) -> Result<()> {
374        self.merge_configuration()?;
375        let get = |k: &str| -> Option<String> { config::relaxed_get(&self.properties, k).cloned() };
376        if let Some(v) = get("camel.main.name") {
377            self.config.name = Some(v);
378        }
379        if let Some(v) = get("camel.main.routes-include-pattern") {
380            self.config.routes_include_pattern.extend(v.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()));
381        }
382        let num = |v: Option<String>, k: &str| -> Result<Option<u64>> {
383            match v {
384                None => Ok(None),
385                Some(s) => s.trim().parse::<i64>().map(|n| if n > 0 { Some(n as u64) } else { None }).map_err(|_| CamelError::illegal_argument(format!("Invalid number for {}: {}", k, s))),
386            }
387        };
388        if let Some(v) = num(get("camel.main.duration-max-seconds"), "duration-max-seconds")? {
389            self.config.duration_max_seconds = Some(v);
390        }
391        if let Some(v) = num(get("camel.main.duration-max-messages"), "duration-max-messages")? {
392            self.config.duration_max_messages = Some(v);
393        }
394        if let Some(v) = num(get("camel.main.duration-max-idle-seconds"), "duration-max-idle-seconds")? {
395            self.config.duration_max_idle_seconds = Some(v);
396        }
397        if let Some(v) = get("camel.main.shutdown-timeout") {
398            let secs: u64 = v.trim().parse().map_err(|_| CamelError::illegal_argument(format!("Invalid shutdown-timeout: {}", v)))?;
399            self.config.shutdown_timeout = Some(Duration::from_secs(secs));
400        }
401        if let Some(v) = get("camel.main.log-level").or_else(|| get("logging.level.root")) {
402            self.config.log_level = Some(v);
403        }
404        if let Some(v) = get("camel.main.dev-reload") {
405            self.config.dev_reload = v.trim().eq_ignore_ascii_case("true");
406        }
407        // all properties become placeholders
408        self.ctx.properties().set_all(self.properties.clone());
409        config::create_beans(&self.ctx, &self.properties)?;
410        Ok(())
411    }
412
413    /// Load the Kamelets of `camel.main.kamelet-locations` (alias
414    /// `camel.component.kamelet.location`; comma separated directories, `file:` and
415    /// `classpath:` prefixes accepted — the latter relative to the working directory; default
416    /// `classpath:kamelets`). Remote (`github:`) locations are skipped with a warning.
417    fn load_kamelet_locations(&self) -> Result<()> {
418        let explicit = config::relaxed_get(&self.properties, "camel.main.kamelet-locations")
419            .or_else(|| config::relaxed_get(&self.properties, "camel.component.kamelet.location"))
420            .cloned();
421        let locations = explicit.clone().unwrap_or_else(|| "classpath:kamelets".into());
422        for loc in locations.split(',').map(str::trim).filter(|l| !l.is_empty()) {
423            if loc.starts_with("github:") || loc.starts_with("http:") || loc.starts_with("https:") {
424                cameleer_core::camel_warn!("Main", "Remote Kamelet location {} is not supported (no network access); skipped", loc);
425                continue;
426            }
427            let dir = loc.strip_prefix("file:").or_else(|| loc.strip_prefix("classpath:")).unwrap_or(loc);
428            let dir = Path::new(dir);
429            if dir.is_dir() {
430                let names = cameleer_yaml::load_kamelets_from_dir(&self.ctx, dir)?;
431                cameleer_core::camel_info!("Main", "Loaded {} Kamelets from {}: {}", names.len(), dir.display(), names.join(", "));
432            } else if explicit.is_some() {
433                cameleer_core::camel_warn!("Main", "Kamelet location {} does not exist", dir.display());
434            }
435        }
436        Ok(())
437    }
438
439    /// Camel K Integration files among the explicit route files contribute properties
440    /// before the configuration is applied.
441    fn prescan_integrations(&mut self) -> Result<()> {
442        for f in self.route_files.clone() {
443            let is_yaml = matches!(f.extension().and_then(|e| e.to_str()), Some("yaml" | "yml"));
444            if !is_yaml {
445                continue;
446            }
447            if let Ok(text) = std::fs::read_to_string(&f) {
448                if let Some(it) = integration::parse_integration(&text)? {
449                    for (k, v) in it.properties {
450                        self.properties.entry(k).or_insert(v);
451                    }
452                }
453            }
454        }
455        Ok(())
456    }
457
458    /// Resolve route include patterns to files.
459    fn discover_route_files(&self) -> Result<Vec<PathBuf>> {
460        let mut files = self.route_files.clone();
461        for pattern in &self.config.routes_include_pattern {
462            let pattern = pattern.strip_prefix("file:").unwrap_or(pattern);
463            let pattern = pattern.strip_prefix("classpath:").unwrap_or(pattern);
464            for f in glob_files(pattern)? {
465                if !files.contains(&f) {
466                    files.push(f);
467                }
468            }
469        }
470        Ok(files)
471    }
472
473    /// Load a single route file using the loader for its extension.
474    pub fn load_route_file(&self, path: &Path) -> Result<()> {
475        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
476        // support `.camel.yaml` etc.
477        let loader = self
478            .loaders
479            .get(&ext)
480            .ok_or_else(|| CamelError::illegal_argument(format!("No route loader for file {} (supported: {:?})", path.display(), self.supported_extensions())))?;
481        let text = std::fs::read_to_string(path).map_err(|e| CamelError::io(format!("Cannot read route file {}: {}", path.display(), e)))?;
482        loader(&self.ctx, &text, path).map_err(|e| CamelError::new(e.kind.clone(), format!("Error loading routes from {}: {}", path.display(), e.message)).with_cause(e))
483    }
484
485    /// Initialize and start the context (does not block).
486    pub fn start(&mut self) -> Result<()> {
487        cameleer_core::startup::step("main", "configure", || -> Result<()> {
488            self.prescan_integrations()?;
489            self.apply_properties()
490        })?;
491        if let Some(l) = &self.config.log_level {
492            if let Some(level) = cameleer_core::log::Level::parse(l) {
493                cameleer_core::log::set_level(level);
494            }
495        }
496        if let Some(t) = self.config.shutdown_timeout {
497            self.ctx.set_shutdown_timeout(t);
498        }
499        self.apply_management_properties()?;
500        for c in std::mem::take(&mut self.configurers) {
501            c(&self.ctx)?;
502        }
503        self.load_kamelet_locations()?;
504        self.loaded_files.clear();
505        let before: Vec<String> = self.ctx.assign_pending_route_ids();
506        for f in self.discover_route_files()? {
507            cameleer_core::camel_info!("Main", "Loading routes from: {}", f.display());
508            cameleer_core::startup::step("routes-loader", f.display().to_string(), || self.load_route_file(&f))?;
509            let mtime = std::fs::metadata(&f).and_then(|m| m.modified()).ok();
510            self.loaded_files.push((f, mtime));
511        }
512        self.file_route_ids = self.ctx.assign_pending_route_ids().into_iter().filter(|id| !before.contains(id)).collect();
513        for (name, content) in self.route_sources.clone() {
514            let ext = Path::new(&name).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
515            let loader = self.loaders.get(&ext).cloned().ok_or_else(|| CamelError::illegal_argument(format!("No route loader for {}", name)))?;
516            cameleer_core::camel_info!("Main", "Loading routes from: {} (embedded)", name);
517            loader(&self.ctx, &content, Path::new(&name))?;
518        }
519        cameleer_core::startup::step("context", "start", || self.ctx.start())?;
520        if let Some(dir) = &self.local_dir {
521            let name = self.config.name.clone().unwrap_or_else(|| self.ctx.name().to_string());
522            // best-effort: a read-only filesystem or a home without write access must not
523            // stop the integration, only `cameleer ps/stop/log` lose sight of it
524            match local::LocalStatus::start(&self.ctx, &name, dir) {
525                Ok(l) => self.local = Some(l),
526                Err(e) => cameleer_core::camel_warn!("Main", "Local status files disabled: {}", e),
527            }
528        }
529        for line in console::startup_summary(&self.ctx) {
530            cameleer_core::camel_info!("Main", "{}", line);
531        }
532        if self.startup_recorder_logging() {
533            cameleer_core::camel_info!("Main", "Startup steps (offset from process start, duration):");
534            for line in cameleer_core::startup::report_lines() {
535                cameleer_core::camel_info!("Main", "{}", line);
536            }
537        }
538        let cfg = console::ConsoleConfig::from_properties(&self.properties)?;
539        if cfg.is_active() && self.console.is_none() {
540            match console::DevConsoleServer::start(&self.ctx, &cfg) {
541                Ok(c) => self.console = Some(c),
542                Err(e) => {
543                    let _ = self.ctx.stop();
544                    return Err(e);
545                }
546            }
547        }
548        if let Err(e) = self.start_mcp() {
549            let _ = self.ctx.stop();
550            return Err(e);
551        }
552        Ok(())
553    }
554
555    /// The MCP server (`camel.server.mcp-enabled=true`): over HTTP on `camel.server.port`, or
556    /// over stdio (`camel.server.mcp-transport=stdio`), where the process stops when stdin
557    /// ends.
558    #[cfg(feature = "ai")]
559    fn start_mcp(&self) -> Result<()> {
560        use cameleer_ai::mcp::{http::McpHttpServer, McpConfig, McpServer};
561        if self.mcp.lock().unwrap().is_some() {
562            return Ok(());
563        }
564        // MCP clients (camel.ai.mcp.NAME.*): their tools join the registry; a server that is
565        // down is retried in the background
566        for client in cameleer_ai::mcp::client::McpClientConfig::from_properties(&self.properties)? {
567            let clients = cameleer_ai::mcp::client::McpClients::of(&self.ctx);
568            let name = client.name.clone();
569            match clients.add(&self.ctx, client.clone()) {
570                Ok(n) => cameleer_core::camel_info!("mcp-client", "Imported {} tools from MCP server {}", n, name),
571                Err(e) => {
572                    cameleer_core::camel_warn!("mcp-client", "MCP server {} is not reachable ({}); retrying every 30s", name, e.message);
573                    let weak = self.ctx.downgrade();
574                    let _ = std::thread::Builder::new().name(format!("mcp-connect-{}", name)).spawn(move || loop {
575                        std::thread::sleep(Duration::from_secs(30));
576                        let Some(ctx) = weak.upgrade() else { return };
577                        if let Ok(n) = cameleer_ai::mcp::client::McpClients::of(&ctx).add(&ctx, client.clone()) {
578                            cameleer_core::camel_info!("mcp-client", "Imported {} tools from MCP server {}", n, name);
579                            return;
580                        }
581                    });
582                }
583            }
584        }
585        let cfg = McpConfig::from_properties(&self.properties)?;
586        if !cfg.enabled {
587            return Ok(());
588        }
589        let server = McpServer::new(&self.ctx, cfg.clone())?;
590        if cfg.transport == "stdio" {
591            cameleer_core::camel_info!("mcp", "MCP server on stdio (protocol {} and {})", cameleer_ai::mcp::MODERN_VERSION, cameleer_ai::mcp::LEGACY_VERSIONS.join(", "));
592            std::thread::Builder::new()
593                .name("mcp-stdio".into())
594                .spawn(move || {
595                    let stdin = std::io::stdin();
596                    cameleer_ai::mcp::stdio::serve(server, stdin.lock(), std::io::stdout());
597                    cameleer_core::camel_info!("mcp", "stdin closed");
598                    signal::trigger();
599                })
600                .map_err(|e| CamelError::io(format!("Cannot start the MCP stdio server: {}", e)))?;
601            *self.mcp.lock().unwrap() = Some(Box::new(()));
602        } else {
603            let http = McpHttpServer::start(server)?;
604            *self.mcp.lock().unwrap() = Some(Box::new(http));
605        }
606        Ok(())
607    }
608
609    #[cfg(not(feature = "ai"))]
610    fn start_mcp(&self) -> Result<()> {
611        let on = console::prop(&self.properties, "camel.server.mcp-enabled").map(|v| v.trim().eq_ignore_ascii_case("true")).unwrap_or(false);
612        if on {
613            return Err(CamelError::illegal_argument("camel.server.mcp-enabled=true needs the ai feature: rebuild with --features ai"));
614        }
615        Ok(())
616    }
617
618    /// Whether `camel.main.startupRecorder` (`startup-recorder`) is `logging`.
619    fn startup_recorder_logging(&self) -> bool {
620        config::relaxed_get(&self.properties, "camel.main.startup-recorder").map(|v| v.trim().eq_ignore_ascii_case("logging")).unwrap_or(false)
621    }
622
623    /// Tracing, message history and route controller options
624    /// (`camel.main.tracing`, `camel.main.backlog-tracing`, `camel.main.message-history`,
625    /// `camel.main.jmxManagementStatisticsLevel`,
626    /// `camel.main.route-controller-supervise-enabled` / `camel.routecontroller.enabled`,
627    /// `...-initial-delay`, `...-back-off-delay`, `...-back-off-max-attempts`).
628    fn apply_management_properties(&mut self) -> Result<()> {
629        let flag = |k: &str| console::prop(&self.properties, k).map(|v| v.trim().eq_ignore_ascii_case("true"));
630        if let Some(t) = flag("camel.main.tracing") {
631            self.ctx.set_tracing(t);
632        }
633        if let Some(t) = flag("camel.main.backlog-tracing") {
634            self.ctx.set_backlog_tracing(t);
635        }
636        if let Some(t) = flag("camel.main.message-history") {
637            self.ctx.set_message_history(t);
638        }
639        // Camel's name; the kebab-case spelling is accepted too
640        let level = console::prop(&self.properties, "camel.main.jmxManagementStatisticsLevel")
641            .or_else(|| console::prop(&self.properties, "camel.main.jmx-management-statistics-level"));
642        if let Some(l) = level {
643            self.ctx.set_statistics_level(cameleer_core::management::ManagementStatisticsLevel::parse(&l)?);
644        }
645        if flag("camel.main.route-controller-supervise-enabled").or_else(|| flag("camel.routecontroller.enabled")).unwrap_or(false) {
646            let dur = |k: &str| -> Result<Option<Duration>> {
647                let v = console::prop(&self.properties, &format!("camel.main.route-controller-{}", k))
648                    .or_else(|| console::prop(&self.properties, &format!("camel.routecontroller.{}", k)));
649                v.map(|v| cameleer_core::util::parse_duration(v.trim())).transpose()
650            };
651            let mut c = cameleer_core::supervising::SupervisingRouteControllerConfig::default();
652            if let Some(d) = dur("initial-delay")? {
653                c.initial_delay = d;
654            }
655            if let Some(d) = dur("back-off-delay")? {
656                c.back_off_delay = d;
657            }
658            let attempts = console::prop(&self.properties, "camel.main.route-controller-back-off-max-attempts")
659                .or_else(|| console::prop(&self.properties, "camel.routecontroller.back-off-max-attempts"));
660            if let Some(a) = attempts {
661                c.back_off_max_attempts = a.trim().parse().map_err(|_| CamelError::illegal_argument(format!("Invalid back-off-max-attempts: {}", a)))?;
662            }
663            self.ctx.enable_supervising_route_controller(c);
664        }
665        Ok(())
666    }
667
668    /// Port of the management server (`/q/health`, `/q/metrics`, `/q/dev`), if running.
669    pub fn console_port(&self) -> Option<u16> {
670        self.console.as_ref().map(|c| c.port())
671    }
672
673    fn stop_console(&self) {
674        if let Some(c) = &self.console {
675            c.stop();
676        }
677        self.mcp.lock().unwrap().take();
678        #[cfg(feature = "ai")]
679        cameleer_ai::mcp::client::McpClients::of(&self.ctx).close_all(&self.ctx);
680    }
681
682    /// Start and block until a stop condition happens, then stop the context.
683    pub fn run(&mut self) -> Result<()> {
684        let completed = Arc::new(AtomicU64::new(0));
685        let last_activity = Arc::new(std::sync::Mutex::new(Instant::now()));
686        let wake = Arc::new(Wake::default());
687        // the counter reads the limit when the first exchange completes (after start)
688        let max_messages = Arc::new(std::sync::OnceLock::new());
689        self.ctx.add_event_notifier(CompletedCounter {
690            completed: completed.clone(),
691            last_activity: last_activity.clone(),
692            max_messages: max_messages.clone(),
693            wake: wake.clone(),
694        });
695        signal::install();
696        if let Some(api) = self.lambda_api.clone() {
697            return self.run_lambda(&api);
698        }
699        self.start()?;
700        let _ = max_messages.set(self.config.duration_max_messages);
701        let started = Instant::now();
702        let reason;
703        let mut last_check = Instant::now();
704        loop {
705            if signal::received() {
706                reason = "signal received".to_string();
707                break;
708            }
709            if self.local.as_ref().map(|l| l.stop_requested()).unwrap_or(false) {
710                reason = "stop requested".to_string();
711                break;
712            }
713            let reload_requested = self.local.as_ref().map(|l| l.take_reload_request()).unwrap_or(false);
714            if reload_requested || (self.config.dev_reload && last_check.elapsed() >= Duration::from_millis(300)) {
715                last_check = Instant::now();
716                if reload_requested || self.route_files_changed() {
717                    let _ = self.reload_route_files();
718                }
719            }
720            if let Some(s) = self.config.duration_max_seconds {
721                if started.elapsed() >= Duration::from_secs(s) {
722                    reason = format!("duration max seconds ({}) reached", s);
723                    break;
724                }
725            }
726            if let Some(m) = self.config.duration_max_messages {
727                if completed.load(Ordering::SeqCst) >= m {
728                    reason = format!("duration max messages ({}) reached", m);
729                    break;
730                }
731            }
732            if let Some(s) = self.config.duration_max_idle_seconds {
733                if last_activity.lock().unwrap().elapsed() >= Duration::from_secs(s) {
734                    reason = format!("duration max idle seconds ({}) reached", s);
735                    break;
736                }
737            }
738            // signals and file changes are polled; reaching max messages wakes at once
739            wake.wait(Duration::from_millis(20));
740        }
741        cameleer_core::camel_info!("Main", "Stopping CamelContext: {}", reason);
742        self.stop_console();
743        if let Some(l) = self.local.take() {
744            l.stop();
745        }
746        self.ctx.stop()
747    }
748
749    /// [`Self::run`] as an AWS Lambda custom runtime: start, then serve invocations until a
750    /// signal (Lambda sends SIGTERM at shutdown) or `camel.main.duration-max-messages`
751    /// invocations.
752    fn run_lambda(&mut self, api: &str) -> Result<()> {
753        let started = Instant::now();
754        // HTTP events arrive through the Runtime API: platform-http routes open no sockets
755        cameleer_http::registry::registry().set_in_process_only(true);
756        if let Err(e) = self.start() {
757            let runtime = lambda::LambdaRuntime::new(api, lambda::DEFAULT_HANDLER);
758            if let Err(re) = runtime.init_error(&e) {
759                cameleer_core::camel_warn!("Lambda", "Cannot report the init error: {}", re);
760            }
761            return Err(e);
762        }
763        let property = config::relaxed_get(&self.properties, "camel.main.lambda-handler").cloned();
764        let handler = lambda::handler_endpoint(property.as_deref(), std::env::var("_HANDLER").ok().as_deref());
765        let runtime = lambda::LambdaRuntime::new(api, handler);
766        cameleer_core::camel_info!(
767            "Lambda",
768            "Serving AWS Lambda invocations from {} (init {}; HTTP events to platform-http routes, others to {})",
769            api,
770            cameleer_core::startup::format_duration(started.elapsed()),
771            runtime.handler()
772        );
773        let result = runtime.run(&self.ctx, self.config.duration_max_messages, &signal::received);
774        let reason = if signal::received() { "signal received" } else { "duration max messages reached" };
775        cameleer_core::camel_info!("Main", "Stopping CamelContext: {}", reason);
776        self.stop_console();
777        if let Some(l) = self.local.take() {
778            l.stop();
779        }
780        let stopped = self.ctx.stop();
781        result?;
782        stopped
783    }
784
785    pub fn stop(&self) -> Result<()> {
786        self.stop_console();
787        if let Some(l) = &self.local {
788            l.stop();
789        }
790        self.ctx.stop()
791    }
792}
793
794/// Resolve `{{...}}` placeholders in the values of the `camel.main.*` and `camel.server.*`
795/// settings, as Camel does: `camel.server.port={{env:PORT:8080}}` (Cloud Run, Knative and
796/// others pass the port in `PORT`). A value that cannot be resolved is kept as it is.
797fn resolve_runtime_placeholders(props: &mut BTreeMap<String, String>) {
798    let resolver = cameleer_core::context::PropertiesComponent::default();
799    resolver.set_all(props.clone());
800    for (k, v) in props.iter_mut() {
801        if v.contains("{{") && (k.starts_with("camel.main.") || k.starts_with("camel.server.")) {
802            if let Ok(r) = resolver.resolve(v) {
803                *v = r;
804            }
805        }
806    }
807}
808
809/// Expand a simple glob (`dir/*.yaml`, `dir/**/*.yaml`, or a plain path) into files.
810pub fn glob_files(pattern: &str) -> Result<Vec<PathBuf>> {
811    if !pattern.contains('*') && !pattern.contains('?') {
812        let p = PathBuf::from(pattern);
813        return Ok(if p.is_file() { vec![p] } else if p.is_dir() { list_dir(&p, false)? } else { vec![] });
814    }
815    // split at the first component containing a wildcard
816    let normalized = pattern.replace('\\', "/");
817    let parts: Vec<&str> = normalized.split('/').collect();
818    let idx = parts.iter().position(|p| p.contains('*') || p.contains('?')).unwrap();
819    let base = if idx == 0 { ".".to_string() } else { parts[..idx].join("/") };
820    let base = if base.is_empty() { "/".to_string() } else { base };
821    let rest = parts[idx..].join("/");
822    let recursive = rest.contains("**");
823    let mut out = Vec::new();
824    let base_path = PathBuf::from(&base);
825    if !base_path.is_dir() {
826        return Ok(out);
827    }
828    for f in list_dir(&base_path, recursive)? {
829        let rel = f.strip_prefix(&base_path).unwrap_or(&f).to_string_lossy().replace('\\', "/");
830        if ant_match(&rest, &rel) {
831            out.push(f);
832        }
833    }
834    out.sort();
835    Ok(out)
836}
837
838fn list_dir(dir: &Path, recursive: bool) -> Result<Vec<PathBuf>> {
839    let mut out = Vec::new();
840    for entry in std::fs::read_dir(dir)? {
841        let p = entry?.path();
842        if p.is_dir() {
843            if recursive {
844                out.extend(list_dir(&p, true)?);
845            }
846        } else {
847            out.push(p);
848        }
849    }
850    out.sort();
851    Ok(out)
852}
853
854/// Ant-style path matching: `**` matches any number of directories, `*`/`?` within a segment.
855pub fn ant_match(pattern: &str, path: &str) -> bool {
856    let p: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
857    let s: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
858    fn rec(p: &[&str], s: &[&str]) -> bool {
859        match p.first() {
860            None => s.is_empty(),
861            Some(&"**") => (0..=s.len()).any(|i| rec(&p[1..], &s[i..])),
862            Some(seg) => !s.is_empty() && cameleer_core::util::wildcard_match(s[0], seg) && rec(&p[1..], &s[1..]),
863        }
864    }
865    rec(&p, &s)
866}
867
868/// Create a [`Main`] with all available components and route loaders registered.
869pub fn new_main() -> Main {
870    cameleer_core::startup::step("main", "register components", new_main_inner)
871}
872
873fn new_main_inner() -> Main {
874    let mut main = Main::new();
875    // families left out of this build explain themselves in "No component found" errors
876    cameleer_core::component::set_unavailable_hint(features::unavailable_hint);
877    #[cfg(feature = "xml")]
878    cameleer_xml::register(main.context()); // xpath 1.0, xslt, validator (XSD), xj (before others: overrides core's xpath/validator)
879    cameleer_http::register(main.context());
880    cameleer_yaml::register_kamelet_component(main.context());
881    #[cfg(feature = "ssh")]
882    cameleer_ssh::register(main.context()); // sftp, ssh (before cameleer_protocols, whose sftp is a placeholder)
883    // ftp, smtp/pop3/imap, websocket, mllp, syslog
884    #[cfg(feature = "protocols")]
885    cameleer_protocols::register(main.context());
886    #[cfg(feature = "kafka-mqtt")]
887    cameleer_kafka_mqtt::register(main.context()); // kafka, paho, paho-mqtt5
888    // sql, jdbc, sql-stored + `camel.datasource[.<name>].url` data sources
889    #[cfg(feature = "sql")]
890    {
891        cameleer_sql::register(main.context());
892        main.configure(cameleer_sql::configure_datasources_from_properties);
893    }
894    #[cfg(feature = "ai")]
895    cameleer_ai::register(main.context()); // ai-tool, ai-resource, ai-prompt
896    #[cfg(feature = "amqp")]
897    cameleer_amqp::register(main.context());
898    #[cfg(feature = "amqp10")]
899    cameleer_amqp10::register(main.context()); // amqp (AMQP 1.0, replaces cameleer-amqp's STOMP flavour, now `amqp-stomp`), azure-servicebus, azure-eventhubs
900    #[cfg(feature = "grpc")]
901    cameleer_grpc::register(main.context()); // grpc component + protobuf data format
902    #[cfg(feature = "cloud")]
903    cameleer_cloud::register(main.context()); // aws2-s3/sqs/sns/ddb/lambda, azure-storage-blob/queue, google-storage/pubsub
904    #[cfg(feature = "datastores")]
905    cameleer_datastores::register(main.context()); // spring-redis, mongodb, elasticsearch/opensearch, cql, couchdb
906    #[cfg(feature = "script")]
907    cameleer_script::register(main.context()); // js, groovy, python, ognl, mvel, spel, datasonnet, csimple, joor
908    #[cfg(feature = "saas")]
909    cameleer_saas::register(main.context()); // slack, telegram, github, twilio, jira, openai, webhook, graphql, rss, atom
910    #[cfg(feature = "soap")]
911    cameleer_soap::register(main.context()); // cxf, cxfrs, spring-ws, fhir + jaxb, fhirJson, fhirXml data formats
912    #[cfg(feature = "dataformats")]
913    cameleer_dataformats::register(main.context()); // avro, bindy, yaml, tarFile, zipFile, crypto, pgp, cbor, thrift, asn1, iCal, mimeMultipart, univocity
914    // REST DSL elements (`rest`, `restConfiguration`) in YAML/XML route files
915    yaml_rest::register();
916    for ext in ["yaml", "yml"] {
917        main.add_route_loader(ext, |ctx, text, _path| match integration::parse_integration(text)? {
918            // Camel K Integration resource: its properties become placeholders (camel.main.*
919            // options of explicit route files were applied before startup)
920            Some(it) => {
921                for (k, v) in &it.properties {
922                    if ctx.properties().get(k).is_none() {
923                        ctx.properties().set(k.clone(), v.clone());
924                    }
925                }
926                integration::load_integration(ctx, &it)
927            }
928            None => cameleer_yaml::add_routes_from_yaml(ctx, text),
929        });
930    }
931    main.add_route_loader("xml", |ctx, text, _path| cameleer_yaml::add_routes_from_xml(ctx, text));
932    main
933}
934
935#[cfg(test)]
936mod tests {
937    use super::*;
938
939    #[test]
940    fn ant() {
941        assert!(ant_match("*.yaml", "a.yaml"));
942        assert!(!ant_match("*.yaml", "x/a.yaml"));
943        assert!(ant_match("**/*.yaml", "x/y/a.yaml"));
944        assert!(ant_match("**/*.yaml", "a.yaml"));
945    }
946
947    #[test]
948    #[cfg(feature = "full")]
949    fn new_main_binds_datasources_from_properties() {
950        let mut main = new_main();
951        main.set_property("camel.datasource.url", "jdbc:rustsql:mem:camelMainDsTest");
952        main.configure(|ctx| {
953            ctx.add_routes(|b| {
954                b.from("direct:q").to("sql:select 6 * 7 as answer?dataSource=#dataSource&outputType=SelectOne");
955            })
956        });
957        main.start().unwrap();
958        let v = main.context().create_producer_template().request_body("direct:q", 0).unwrap();
959        assert_eq!(v, cameleer_core::value::Value::Int(42));
960        main.stop().unwrap();
961    }
962
963    #[test]
964    #[cfg(feature = "full")]
965    fn new_main_registers_xml_and_saas() {
966        let main = new_main();
967        let ctx = main.context();
968        for scheme in ["xslt", "xslt-saxon", "validator", "xj", "slack", "telegram", "github", "twilio", "jira", "openai", "webhook", "graphql", "rss", "atom"] {
969            assert!(ctx.has_component(scheme), "{}", scheme);
970        }
971        // the full XPath 1.0 engine replaces core's subset (following axis)
972        let e = ctx.resolve_language("xpath").unwrap().create_expression("count(/a/b[1]/following::*)", &Default::default()).unwrap();
973        let ex = cameleer_core::message::Exchange::with_body(Some(ctx.clone()), "<a><b/><b/><c/></a>");
974        assert_eq!(e.evaluate(&ex).unwrap(), cameleer_core::value::Value::Float(2.0));
975    }
976
977    #[test]
978    #[cfg(feature = "full")]
979    fn new_main_registers_dataformats() {
980        let main = new_main();
981        for name in ["avro", "bindyCsv", "yaml", "tarFile", "pgp", "crypto", "cbor", "iCal", "mimeMultipart"] {
982            assert!(cameleer_core::dataformat::lookup_data_format_factory(main.context().registry(), name).is_some(), "{}", name);
983        }
984        let df = cameleer_core::dataformat::DataFormatDefinition::new("cbor").resolve(main.context()).unwrap();
985        let mut ex = cameleer_core::message::Exchange::with_body(Some(main.context().clone()), cameleer_core::value::Value::Int(500));
986        df.marshal(&mut ex).unwrap();
987        assert_eq!(ex.message.body, cameleer_core::value::Value::Bytes(vec![0x19, 0x01, 0xf4]));
988    }
989
990    #[test]
991    fn reaching_max_messages_stops_without_waiting_for_the_next_poll() {
992        // a run-once job (FaaS, batch): the process must not linger after its last message
993        let mut main = Main::new();
994        main.set_property("camel.main.duration-max-messages", "1");
995        main.configure(|ctx| {
996            ctx.add_routes(|b| {
997                b.from("timer:once?repeatCount=1&delay=0").set_body(constant("x")).to("mock:out");
998            })
999        });
1000        let t0 = Instant::now();
1001        main.run().unwrap();
1002        let wake = Wake::default();
1003        // the wake itself is immediate once notified
1004        wake.notify();
1005        let w0 = Instant::now();
1006        wake.wait(Duration::from_secs(5));
1007        assert!(w0.elapsed() < Duration::from_secs(1));
1008        assert!(t0.elapsed() < Duration::from_secs(5), "{:?}", t0.elapsed());
1009        assert_eq!(main.context().status(), ServiceStatus::Stopped);
1010    }
1011
1012    #[test]
1013    fn main_runs_with_max_messages() {
1014        let mut main = Main::new();
1015        main.set_property("camel.main.duration-max-messages", "2");
1016        main.set_property("greeting", "hi");
1017        main.configure(|ctx| {
1018            ctx.add_routes(|b| {
1019                b.from("direct:in").set_body(constant("x")).to("mock:out");
1020            })
1021        });
1022        let ctx = main.context().clone();
1023        let h = std::thread::spawn(move || {
1024            std::thread::sleep(Duration::from_millis(200));
1025            let t = ctx.create_producer_template();
1026            t.send_body("direct:in", 1).unwrap();
1027            t.send_body("direct:in", 2).unwrap();
1028        });
1029        main.run().unwrap();
1030        h.join().unwrap();
1031        assert_eq!(main.context().resolve_placeholders("{{greeting}}").unwrap(), "hi");
1032        assert_eq!(main.context().status(), ServiceStatus::Stopped);
1033    }
1034}