Skip to main content

camel_cli/commands/
run.rs

1//! `camel run` subcommand body.
2//!
3//! Owns the 7 ADR-0012 `error!` sites migrated in Phase C (see ADR-0012 +
4//! Phase C plan). Each site has a `// log-policy: …` annotation that
5//! classifies it per the ADR taxonomy.
6//!
7//! All sites in this file are category (c) or (d) — system-broken /
8//! bootstrap. The annotations are added in the Infra cluster task (Task 9),
9//! NOT in this split commit.
10
11use camel_api::datasource::DatasourceCatalog;
12use camel_bean::BeanProcessor;
13use camel_core::datasource::RuntimeDatasourceCatalog;
14use std::sync::Arc;
15use std::time::Duration;
16use tokio_util::sync::CancellationToken;
17
18struct BridgeCleanup {
19    xslt: Arc<camel_xslt::XsltBridgeRuntime>,
20    xj: Arc<camel_xj::XjBridgeRuntime>,
21    validator: Option<Arc<camel_component_validator::xsd_bridge::XsdBridgeBackend>>,
22}
23
24#[async_trait::async_trait]
25impl camel_api::lifecycle::Lifecycle for BridgeCleanup {
26    fn name(&self) -> &str {
27        "bridge-cleanup"
28    }
29
30    async fn start(&mut self) -> Result<(), camel_api::CamelError> {
31        Ok(())
32    }
33
34    async fn stop(&mut self) -> Result<(), camel_api::CamelError> {
35        self.xslt.shutdown().await;
36        self.xj.shutdown().await;
37        if let Some(validator) = &self.validator {
38            validator.shutdown().await;
39        }
40        Ok(())
41    }
42}
43
44pub async fn run(
45    routes_override: Option<String>,
46    config_path: String,
47    cli_watch: Option<bool>,
48    otel: bool,
49    otel_endpoint: Option<String>,
50    service_name: Option<String>,
51    health_port: Option<u16>,
52) -> Result<(), camel_api::CamelError> {
53    // 1. Load config (fall back to empty config with serde defaults if Camel.toml not found)
54    let mut camel_config: camel_config::config::CamelConfig =
55        camel_config::config::CamelConfig::from_file(&config_path).unwrap_or_else(|_| {
56            // Build an empty config so serde defaults apply
57            config::Config::builder()
58                .build()
59                .and_then(|c| c.try_deserialize())
60                .unwrap_or_else(|e| {
61                    eprintln!("Failed to build default config: {e}");
62                    std::process::exit(1);
63                })
64        });
65
66    // 1b. Apply OTel CLI overrides (--otel-endpoint and --service-name imply --otel)
67    let otel_enabled = otel || otel_endpoint.is_some() || service_name.is_some();
68    if otel_enabled {
69        let otel_cfg =
70            camel_config
71                .observability
72                .otel
73                .get_or_insert(camel_config::OtelCamelConfig {
74                    enabled: true,
75                    endpoint: "http://localhost:4317".to_string(),
76                    service_name: "rust-camel".to_string(),
77                    ..Default::default()
78                });
79        otel_cfg.enabled = true;
80        if let Some(ep) = otel_endpoint {
81            otel_cfg.endpoint = ep;
82        }
83        if let Some(name) = service_name {
84            otel_cfg.service_name = name;
85        }
86    }
87
88    if let Some(port) = health_port {
89        let health_cfg = camel_config
90            .observability
91            .health
92            .get_or_insert(camel_config::config::HealthCamelConfig::default());
93        health_cfg.enabled = true;
94        health_cfg.port = port;
95    }
96
97    // 2. Build context with beans registry (also initialises tracing subscriber)
98    let beans_registry = {
99        let bean_reg = std::sync::Arc::new(std::sync::Mutex::new(camel_bean::BeanRegistry::new()));
100        if camel_config.beans.is_empty() {
101            None
102        } else {
103            Some(bean_reg)
104        }
105    };
106
107    let mut ctx = camel_config::config::CamelConfig::configure_context_with_beans(
108        &camel_config,
109        beans_registry.clone(),
110    )
111    .await
112    .unwrap_or_else(|e| {
113        eprintln!("Failed to configure CamelContext: {e}");
114        std::process::exit(1);
115    });
116
117    // R4-L4: CWD trust model — camel run executes route scripts/WASM/beans
118    // from the current working directory (dev-tool model, like cargo run).
119    tracing::warn!(
120        "camel run trusts the current working directory and will execute route \
121         scripts, WASM modules, and beans resolved from it; only run from a \
122         trusted directory"
123    );
124
125    match camel_function::FunctionRuntimeService::with_default_container_provider(
126        camel_function::FunctionConfig::default(),
127    ) {
128        Ok(svc) => ctx = ctx.with_lifecycle(svc),
129        Err(e) => tracing::warn!("Function runtime disabled: {e}"),
130    }
131
132    // 3a. Create datasource catalog from configured datasources, wiring health registry
133    let datasource_catalog: Arc<dyn DatasourceCatalog> = {
134        let catalog = RuntimeDatasourceCatalog::new(camel_config.datasources.clone())
135            .with_health_registry(ctx.health_registry());
136        Arc::new(catalog)
137    };
138
139    // Load WASM beans after context is created (needs component registry)
140    #[cfg(feature = "wasm")]
141    if let Some(ref bean_reg) = beans_registry {
142        let component_registry = ctx.registry_arc();
143        let plugins_dir_raw = camel_config
144            .components
145            .raw
146            .get("wasm")
147            .and_then(|v| v.get("plugins_dir"))
148            .and_then(|v| v.as_str())
149            .unwrap_or("plugins");
150        let config_dir = std::path::Path::new(&config_path)
151            .parent()
152            .map(|p| {
153                if p.as_os_str().is_empty() {
154                    std::path::Path::new(".")
155                } else {
156                    p
157                }
158            })
159            .unwrap_or(std::path::Path::new("."));
160        let camel_root = config_dir.canonicalize().unwrap_or_else(|e| {
161            eprintln!("Error: cannot resolve project root: {e}");
162            std::process::exit(1);
163        });
164        crate::commands::plugin::validate_plugins_dir(&camel_root, plugins_dir_raw).unwrap_or_else(
165            |e| {
166                eprintln!("Error: invalid plugins_dir: {e}");
167                std::process::exit(1);
168            },
169        );
170        let plugins_dir = camel_root.join(plugins_dir_raw);
171        for (bean_name, bean_cfg) in &camel_config.beans {
172            tracing::info!(bean = %bean_name, plugin = %bean_cfg.plugin, "registering WASM bean");
173
174            if !bean_cfg
175                .plugin
176                .chars()
177                .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
178            {
179                eprintln!(
180                    "Invalid bean plugin name '{}': must be alphanumeric with - or _",
181                    bean_cfg.plugin
182                );
183                std::process::exit(1);
184            }
185
186            let wasm_path = plugins_dir.join(format!("{}.wasm", bean_cfg.plugin));
187            let canonical_plugins = plugins_dir.canonicalize().unwrap_or_else(|_| {
188                eprintln!("Plugins directory not found: {}", plugins_dir.display());
189                std::process::exit(1);
190            });
191            let canonical_path = wasm_path.canonicalize().unwrap_or_else(|_| {
192                eprintln!("WASM bean plugin not found: {}", wasm_path.display());
193                std::process::exit(1);
194            });
195            if !canonical_path.starts_with(&canonical_plugins) {
196                eprintln!(
197                    "Bean plugin path escapes plugins directory: {}",
198                    bean_cfg.plugin
199                );
200                std::process::exit(1);
201            }
202            let wasm_config =
203                camel_component_wasm::config::WasmConfig::from_limits(&bean_cfg.limits);
204            let wasm_bean = camel_component_wasm::bean::WasmBean::new(
205                &wasm_path,
206                wasm_config,
207                component_registry.clone(),
208                bean_cfg.config.clone(),
209            )
210            .await
211            .unwrap_or_else(|e| {
212                eprintln!("Failed to load WASM bean '{}': {}", bean_name, e);
213                std::process::exit(1);
214            });
215            tracing::info!(
216                bean = %bean_name,
217                plugin = %bean_cfg.plugin,
218                methods = ?wasm_bean.methods(),
219                "WASM bean loaded"
220            );
221            bean_reg
222                .lock()
223                .expect("beans registry lock") // allow-unwrap
224                .register(bean_name, wasm_bean)
225                .unwrap_or_else(|e| {
226                    eprintln!("Bean registration failed for '{}': {}", bean_name, e);
227                    std::process::exit(1);
228                });
229        }
230    }
231
232    // 3. Determine route patterns
233    let patterns: Vec<String> = if let Some(p) = routes_override {
234        vec![p]
235    } else if !camel_config.routes.is_empty() {
236        camel_config.routes.clone()
237    } else {
238        vec!["routes/*.yaml".to_string()]
239    };
240
241    tracing::info!("camel-cli: loading routes from patterns: {:?}", patterns);
242
243    let security_compile_context =
244        crate::build_security_compile_context_from_config(&camel_config, ctx.registry_arc())
245            .await?;
246
247    // Define register_bundle! macro — looks up config key in ComponentsConfig::raw,
248    // falling back to an empty table so bundles always register with their serde defaults.
249    // Uses UFCS to invoke ComponentBundle methods without requiring trait in scope
250    macro_rules! register_bundle {
251        ($ctx:expr, $cfg:expr, $Bundle:ty) => {
252            let raw = $cfg
253                .components
254                .raw
255                .get(<$Bundle as camel_component_api::ComponentBundle>::config_key())
256                .cloned()
257                .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
258            match <$Bundle as camel_component_api::ComponentBundle>::from_toml(raw) {
259                Ok(bundle) => <$Bundle as camel_component_api::ComponentBundle>::register_all(
260                    bundle, &mut $ctx,
261                ),
262                Err(e) => {
263                    return Err(camel_api::CamelError::Config(format!(
264                        "Failed to load {} config: {}",
265                        <$Bundle as camel_component_api::ComponentBundle>::config_key(),
266                        e
267                    )));
268                }
269            }
270        };
271    }
272
273    // Register built-in components (no config needed)
274    ctx.register_component(camel_component_timer::TimerComponent::new());
275    ctx.register_component(camel_component_cron::CronComponent::new());
276    ctx.register_component(camel_component_log::LogComponent::new());
277    ctx.register_component(camel_component_direct::DirectComponent::new());
278    ctx.register_component(camel_component_seda::SedaComponent::new());
279    ctx.register_component(camel_component_mock::MockComponent::new());
280    ctx.register_component(camel_component_controlbus::ControlBusComponent::new());
281    let validator_component = camel_component_validator::ValidatorComponent::new();
282    let validator_backend = validator_component.xsd_bridge_backend();
283    ctx.register_component(validator_component);
284
285    let xslt_component = camel_xslt::XsltComponent::default();
286    let xslt_runtime = xslt_component.bridge_runtime();
287    ctx.register_component(xslt_component);
288
289    let xj_component = camel_xj::XjComponent::default();
290    let xj_runtime = xj_component.bridge_runtime();
291    ctx.register_component(xj_component);
292
293    ctx = ctx.with_lifecycle(BridgeCleanup {
294        xslt: xslt_runtime,
295        xj: xj_runtime,
296        validator: validator_backend,
297    });
298
299    // Register HTTP, WS, File, Container (always-on in camel-cli, no feature flag)
300    register_bundle!(ctx, camel_config, camel_component_http::HttpBundle);
301    #[cfg(feature = "http-static")]
302    register_bundle!(ctx, camel_config, camel_component_http::HttpStaticBundle);
303    register_bundle!(ctx, camel_config, camel_component_ws::WsBundle);
304    register_bundle!(ctx, camel_config, camel_component_file::FileBundle);
305    register_bundle!(
306        ctx,
307        camel_config,
308        camel_component_container::ContainerBundle
309    );
310
311    // Register optional/feature-gated bundles
312    let jms_pool = {
313        let raw = camel_config
314            .components
315            .raw
316            .get("jms")
317            .cloned()
318            .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
319        match <camel_component_jms::JmsBundle as camel_component_api::ComponentBundle>::from_toml(
320            raw,
321        ) {
322            Ok(bundle) => {
323                let pool = bundle.pool();
324                <camel_component_jms::JmsBundle as camel_component_api::ComponentBundle>::register_all(bundle, &mut ctx);
325                pool
326            }
327            Err(e) => {
328                return Err(camel_api::CamelError::Config(format!(
329                    "Failed to load jms config: {e}"
330                )));
331            }
332        }
333    };
334
335    let cxf_pool = {
336        let raw = camel_config
337            .components
338            .raw
339            .get("cxf")
340            .cloned()
341            .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
342        match <camel_component_cxf::CxfBundle as camel_component_api::ComponentBundle>::from_toml(
343            raw,
344        ) {
345            Ok(bundle) => {
346                let pool = bundle.pool();
347                <camel_component_cxf::CxfBundle as camel_component_api::ComponentBundle>::register_all(bundle, &mut ctx);
348                pool
349            }
350            Err(e) => {
351                return Err(camel_api::CamelError::Config(format!(
352                    "Failed to load cxf config: {e}"
353                )));
354            }
355        }
356    };
357
358    #[cfg(feature = "kafka")]
359    register_bundle!(ctx, camel_config, camel_component_kafka::KafkaBundle);
360    #[cfg(feature = "mqtt")]
361    register_bundle!(ctx, camel_config, camel_component_mqtt::MqttBundle);
362    register_bundle!(ctx, camel_config, camel_master::MasterBundle);
363    register_bundle!(
364        ctx,
365        camel_config,
366        camel_component_opensearch::OpenSearchBundle
367    );
368    register_bundle!(ctx, camel_config, camel_component_redis::RedisBundle);
369    {
370        let sql_raw = camel_config
371            .components
372            .raw
373            .get(<camel_component_sql::SqlBundle as camel_component_api::ComponentBundle>::config_key())
374            .cloned()
375            .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
376        match <camel_component_sql::SqlBundle as camel_component_api::ComponentBundle>::from_toml(
377            sql_raw,
378        ) {
379            Ok(bundle) => {
380                let bundle = bundle.with_catalog(Arc::clone(&datasource_catalog));
381                <camel_component_sql::SqlBundle as camel_component_api::ComponentBundle>::register_all(bundle, &mut ctx);
382            }
383            Err(e) => {
384                // log-policy: system-broken
385                tracing::error!("failed to initialize SQL bundle: {}", e);
386            }
387        }
388    }
389    #[cfg(feature = "surrealdb")]
390    {
391        let surrealdb_raw = camel_config
392            .components
393            .raw
394            .get(<camel_component_surrealdb::SurrealDbBundle as camel_component_api::ComponentBundle>::config_key())
395            .cloned()
396            .unwrap_or_else(|| toml::Value::Table(toml::map::Map::new()));
397        match <camel_component_surrealdb::SurrealDbBundle as camel_component_api::ComponentBundle>::from_toml(
398            surrealdb_raw,
399        ) {
400            Ok(bundle) => {
401                let bundle = bundle.with_catalog(Arc::clone(&datasource_catalog));
402                <camel_component_surrealdb::SurrealDbBundle as camel_component_api::ComponentBundle>::register_all(
403                    bundle, &mut ctx,
404                );
405            }
406            Err(e) => {
407                // log-policy: system-broken
408                tracing::error!("failed to initialize SurrealDB bundle: {}", e);
409            }
410        }
411    }
412    #[cfg(feature = "grpc")]
413    register_bundle!(ctx, camel_config, camel_component_grpc::GrpcBundle);
414
415    #[cfg(feature = "exec")]
416    register_bundle!(ctx, camel_config, camel_component_exec::ExecBundle);
417
418    #[cfg(feature = "llm")]
419    register_bundle!(ctx, camel_config, camel_component_llm::LlmBundle);
420
421    #[cfg(feature = "wasm")]
422    {
423        let base_dir = std::path::Path::new(&config_path)
424            .parent()
425            .unwrap_or(std::path::Path::new("."))
426            .to_path_buf();
427        let wasm_bundle = camel_component_wasm::WasmBundle::new(ctx.registry_arc(), base_dir);
428        <camel_component_wasm::WasmBundle as camel_component_api::ComponentBundle>::register_all(
429            wasm_bundle,
430            &mut ctx,
431        );
432    }
433
434    // Languages are registered in `configure_context_with_beans` (camel-config)
435    // via `camel_core::languages_from_config(&config.languages)`, which applies
436    // Camel.toml [languages.*.limits] and registers js/javascript/rhai/
437    // jsonpath/xpath under feature gates. A direct `CamelContext::builder().build()`
438    // caller gets `LanguagesConfig::default()` (rust-camel runtime defaults).
439
440    // 5. Discover and load initial routes
441    match camel_dsl::discover_routes_with_threshold_and_security(
442        &patterns,
443        camel_config.stream_caching.threshold,
444        security_compile_context.clone(),
445    ) {
446        Ok(defs) => {
447            // ADR-0033: register fail-closed ConfigChecks derived from the
448            // discovered routes (e.g. SqlDynamicQueryCheck for every `sql:`
449            // endpoint). The checks run synchronously at the head of
450            // `CamelContext::start()` before any route consumer is started.
451            for check in
452                camel_core::startup_validation::scan_route_definitions_for_sql_checks(&defs)
453            {
454                ctx.add_startup_check(check);
455            }
456            for def in defs {
457                let id = def.route_id().to_string();
458                if let Err(e) = ctx.add_route_definition(def).await {
459                    // log-policy: system-broken
460                    tracing::error!("Failed to add route '{}': {}", id, e);
461                }
462            }
463        }
464        Err(e) => {
465            // log-policy: system-broken
466            tracing::error!("Failed to discover routes: {}", e);
467            std::process::exit(1);
468        }
469    }
470
471    // 6. Start context
472    if let Err(e) = ctx.start().await {
473        // log-policy: system-broken
474        tracing::error!("Failed to start CamelContext: {}", e);
475        std::process::exit(1);
476    }
477
478    tracing::info!("camel-cli: context started");
479
480    // 7. Resolve whether to enable the file watcher:
481    //    CLI flag takes precedence; falls back to Camel.toml `watch` field (default: false).
482    let watch_enabled = cli_watch.unwrap_or(camel_config.watch);
483
484    // 8. Optionally start file watcher in background
485    let watcher_shutdown = CancellationToken::new();
486    if watch_enabled {
487        let ctrl = ctx.runtime_execution_handle();
488        let watch_patterns = patterns.clone();
489        let watch_security_compile_context = security_compile_context.clone();
490        let drain_timeout = std::time::Duration::from_millis(camel_config.drain_timeout_ms);
491        let debounce = std::time::Duration::from_millis(camel_config.watch_debounce_ms);
492        let watcher_token = watcher_shutdown.clone();
493        tokio::spawn(async move {
494            let watch_dirs = camel_core::reload_watcher::resolve_watch_dirs(&watch_patterns);
495            let result = camel_core::reload_watcher::watch_and_reload(
496                watch_dirs,
497                ctrl,
498                move || {
499                    camel_dsl::discover_routes_with_threshold_and_security(
500                        &watch_patterns,
501                        camel_config.stream_caching.threshold,
502                        watch_security_compile_context.clone(),
503                    )
504                    .map_err(|e| camel_api::CamelError::RouteError(e.to_string()))
505                },
506                Some(watcher_token),
507                drain_timeout,
508                debounce,
509            )
510            .await;
511            if let Err(e) = result {
512                // log-policy: system-broken
513                tracing::error!("File watcher failed: {}", e);
514            }
515        });
516        tracing::info!(
517            "camel-cli: hot-reload watching {:?}. Press Ctrl+C to stop.",
518            patterns
519        );
520    } else {
521        tracing::info!("camel-cli: running (hot-reload disabled). Press Ctrl+C to stop.");
522    }
523
524    tokio::select! {
525        _ = tokio::signal::ctrl_c() => tracing::info!("Received Ctrl+C"),
526        _ = async {
527            #[cfg(unix)]
528            {
529                tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
530                    .expect("Failed to install SIGTERM handler") // allow-unwrap
531                    .recv()
532                    .await
533            }
534            #[cfg(not(unix))]
535            {
536                std::future::pending::<()>().await
537            }
538        } => tracing::info!("Received SIGTERM"),
539    }
540
541    // Second Ctrl+C = force exit
542    let force_exit = tokio::spawn(async {
543        tokio::signal::ctrl_c().await.ok();
544        tracing::warn!("Second Ctrl+C — forcing exit");
545        std::process::exit(1);
546    });
547
548    tracing::info!("camel-cli: shutting down...");
549    watcher_shutdown.cancel();
550
551    // Signal pools to stop restarting BEFORE context shutdown
552    jms_pool.begin_shutdown();
553    cxf_pool.begin_shutdown();
554
555    // Stop context (routes + lifecycle services)
556    ctx.stop().await.unwrap_or_else(|e| {
557        // log-policy: system-broken
558        tracing::error!("Error during shutdown: {}", e);
559    });
560
561    // Tear down bridge pools with timeouts
562    const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(30);
563
564    match tokio::time::timeout(SHUTDOWN_TIMEOUT, jms_pool.shutdown()).await {
565        Ok(Ok(())) => {}
566        Ok(Err(e)) => {
567            // log-policy: system-broken
568            tracing::error!("JMS pool shutdown failed: {}", e);
569        }
570        Err(_) => tracing::warn!("JMS pool shutdown timed out after 30s"),
571    }
572
573    match tokio::time::timeout(SHUTDOWN_TIMEOUT, cxf_pool.shutdown()).await {
574        Ok(Ok(())) => {}
575        Ok(Err(e)) => {
576            // log-policy: system-broken
577            tracing::error!("CXF pool shutdown failed: {}", e);
578        }
579        Err(_) => tracing::warn!("CXF pool shutdown timed out after 30s"),
580    }
581
582    force_exit.abort();
583
584    tracing::info!("camel-cli: stopped");
585    Ok(())
586}
587
588// ---------------------------------------------------------------------------
589// Tests
590// ---------------------------------------------------------------------------
591
592#[cfg(test)]
593mod tests {
594    /// The run function must emit exactly one startup warning about the CWD trust model.
595    #[test]
596    fn startup_warning_emitted() {
597        let source = include_str!("run.rs");
598        // Build the search string from two parts so the concatenated form
599        // never appears literally in test code — only in the warn! call.
600        let a = "camel run trusts the current working directory";
601        let b = " and will execute route";
602        let msg = format!("{a}{b}");
603        let count = source.matches(&msg).count();
604        assert_eq!(
605            count, 1,
606            "expected exactly one tracing::warn! with the trust-model message in run.rs; found {count}"
607        );
608    }
609
610    /// The run command's clap help must document the trust model.
611    #[test]
612    fn clap_help_documents_trust_model() {
613        let source = include_str!("../main.rs");
614        let has_trust_doc = source
615            .contains("Trust model: `camel run` executes route scripts, WASM modules, and beans")
616            || source
617                .contains("Trust model: camel run executes route scripts, WASM modules, and beans");
618        assert!(
619            has_trust_doc,
620            "expected trust model documentation in the Run subcommand help in main.rs"
621        );
622    }
623}