Skip to main content

camel_core/lifecycle/adapters/
route_controller_trait.rs

1//! `RouteController` trait implementation for `DefaultRouteController`.
2//!
3//! Extracted from `route_controller.rs` to reduce file size. All lifecycle methods
4//! (start, stop, suspend, resume, etc.) live here.
5
6use std::sync::Arc;
7use std::time::Duration;
8
9use tokio::sync::mpsc;
10use tokio_util::sync::CancellationToken;
11use tower::Service;
12use tracing::{error, info, warn};
13
14use camel_api::security_policy::RouteSecurityPlan;
15use camel_api::{CamelError, NoOpMetrics, StepLifecycle, StepShutdownReason};
16use camel_component_api::Consumer;
17use camel_component_api::{ConcurrencyModel, ConsumerContext, consumer::ExchangeEnvelope};
18
19use crate::lifecycle::adapters::consumer_management;
20use crate::lifecycle::adapters::controller_component_context::ControllerComponentContext;
21use crate::lifecycle::adapters::route_compiler::CANCEL_TOKEN;
22use crate::lifecycle::adapters::route_controller::DefaultRouteController;
23#[cfg(test)]
24use crate::lifecycle::adapters::route_helpers::emit_start_route_event;
25use crate::lifecycle::adapters::route_helpers::{
26    DrainGuard, handle_is_running, inferred_lifecycle_label, ready_with_backoff,
27};
28use crate::lifecycle::adapters::route_registry::DEFAULT_SHUTDOWN_TIMEOUT;
29use crate::lifecycle::adapters::route_runtime_state::CompiledRoute;
30
31/// Operator acknowledgements for public exposure per bind address and the
32/// per-bind exposure gate (ADR-0061).
33///
34/// Canonical home is `camel_auth::bind_gate` (moved in Task 2.6 so
35/// transports — which may not reference `camel_core::`, see
36/// `xtask lint-component-deps` — enforce the same gate; MCP's registry is
37/// the first). Re-exported here so controller call sites, the CLI, and the
38/// gate tests keep their historical import paths.
39pub use camel_auth::bind_gate::{BindExposureAcks, enforce_bind_exposure_gate};
40
41/// Canonical gate key + loopback classification for a listener `from` URI.
42/// Only listener schemes (http/https/ws/wss/grpc) bind sockets, so only they gate;
43/// `mcp:` binds live in `McpServerConfig` (gated at the McpServerRegistry level,
44/// Task 2.6) and everything else (timer, direct) never binds.
45pub(super) struct BindKey {
46    pub(super) key: String,
47    pub(super) loopback: bool,
48}
49
50pub(super) fn bind_key_from_uri(uri: &str) -> Option<BindKey> {
51    let scheme = uri.split(':').next()?;
52    if !matches!(scheme, "http" | "https" | "ws" | "wss" | "grpc") {
53        return None;
54    }
55    let authority = uri.split("://").nth(1)?;
56    let authority = authority.split('/').next()?;
57    if authority.is_empty() {
58        return None;
59    }
60    if let Ok(addr) = authority.parse::<std::net::SocketAddr>() {
61        return Some(BindKey {
62            key: addr.to_string(),
63            loopback: addr.ip().is_loopback(),
64        });
65    }
66    // Hostname authority: loopback only for `localhost` (deterministic,
67    // fail-closed for every other hostname — no DNS). The host is the
68    // authority minus its port; bracketed IPv6 authorities are stripped
69    // to the bare host before the check.
70    let host = authority
71        .rsplit_once(':')
72        .map(|(h, _)| h)
73        .unwrap_or(authority)
74        .trim_matches(['[', ']']);
75    let loopback = host.eq_ignore_ascii_case("localhost");
76    Some(BindKey {
77        key: authority.to_string(),
78        loopback,
79    })
80}
81
82/// Wire the route's security context onto a freshly created consumer —
83/// the start and resume paths share this delivery.
84///
85/// Policy-backed routes (`sp_config` + authenticator marker) receive the
86/// policy with its credential sources, the named providers, and the
87/// compiled plan. Without a policy, every staged server route still
88/// carries a compiled plan (Task 1.2) — deliver it plan-only so consumers
89/// enforce the kernel classification (e.g. strict dispatch) from day one.
90/// Routes with neither get no context.
91fn deliver_security_context(consumer: &mut dyn Consumer, compiled: &CompiledRoute) {
92    use camel_component_api::SecurityContext;
93
94    if let (Some(sp_config), Some(_)) = (
95        compiled.security_policy.as_ref(),
96        compiled.security_authenticator.as_ref(),
97    ) {
98        let mut sec_ctx = SecurityContext::from_arc(Arc::clone(&sp_config.policy))
99            .with_credential_sources(sp_config.credential_sources.clone());
100        // Inject the route's named providers so Phase-2 transports (grpc
101        // 2.1, mcp 2.6, ws 2.8, http 2.9) can resolve them from the
102        // SecurityContext instead of holding their own authenticator.
103        if let Some(registry) = &compiled.provider_registry {
104            sec_ctx = sec_ctx.with_providers(Arc::clone(registry));
105        }
106        // Thread the compiled plan (Task 1.8) so transports drive
107        // per-route dispatch enforcement from it.
108        if let Some(plan) = &compiled.security_plan {
109            sec_ctx = sec_ctx.with_plan(plan.clone());
110        }
111        consumer.set_security_context(sec_ctx);
112    } else if let Some(plan) = compiled.security_plan.clone() {
113        // Plan-only delivery (Task 1.2): every staged server route
114        // carries a compiled plan even without a policy declaration —
115        // deliver it so consumers enforce the kernel classification
116        // (e.g. strict dispatch) from day one.
117        let mut sec_ctx = SecurityContext::from_plan(plan);
118        if let Some(registry) = &compiled.provider_registry {
119            sec_ctx = sec_ctx.with_providers(Arc::clone(registry));
120        }
121        consumer.set_security_context(sec_ctx);
122    }
123}
124
125/// ADR-0061 Task 2.9 strict-mode dispatch check (the flip deferred from
126/// Task 2.2): every transport mints the typed carrier at its request
127/// boundary (grpc 2.1, mcp 2.6, ws 2.8, http 2.9), so a non-Public plan
128/// REQUIRES the carrier on the Exchange — absent or wrong-provider is
129/// denied BEFORE the pipeline runs; the transport renders the denial in
130/// its own idiom via `reply_tx`. Returns `true` when the dispatch was
131/// denied (caller must `continue`).
132fn strict_dispatch_denies(
133    dispatch_plan: &Option<camel_api::security_policy::RouteSecurityPlan>,
134    exchange: &camel_api::Exchange,
135    reply_tx: &mut Option<tokio::sync::oneshot::Sender<Result<camel_api::Exchange, CamelError>>>,
136    route_id: &str,
137) -> bool {
138    if let Some(plan) = dispatch_plan.as_ref()
139        && let Err(denial) = camel_auth::enforce_dispatch(plan, exchange)
140    {
141        if let Some(tx) = reply_tx.take() {
142            let _ = tx.send(Err(denial));
143        } else {
144            // log-policy: handler-owned
145            warn!(
146                route_id = %route_id,
147                error = %denial,
148                "dispatch denied: no kernel carrier on Exchange"
149            );
150        }
151        return true;
152    }
153    false
154}
155
156#[cfg(test)]
157mod bind_key_tests {
158    use super::bind_key_from_uri;
159
160    #[test]
161    fn https_and_wss_listeners_gate() {
162        assert_eq!(
163            bind_key_from_uri("https://0.0.0.0:8443/api").map(|b| b.key),
164            Some("0.0.0.0:8443".to_string())
165        );
166        assert_eq!(
167            bind_key_from_uri("wss://0.0.0.0:9000").map(|b| b.key),
168            Some("0.0.0.0:9000".to_string())
169        );
170    }
171
172    #[test]
173    fn non_listener_schemes_skip() {
174        assert!(bind_key_from_uri("timer:tick?period=1s").is_none());
175        assert!(bind_key_from_uri("mcp:server/tool/x").is_none());
176    }
177
178    #[test]
179    fn bracketed_ipv6_hostname_check_uses_bare_host() {
180        let b = bind_key_from_uri("ws://[::1]:8080/path").expect("parses"); // allow-unwrap
181        assert!(b.loopback, "[::1] is loopback");
182    }
183
184    #[test]
185    fn localhost_authority_with_port_is_loopback() {
186        // Regression: rsplit(':') once compared the PORT segment ("8080"),
187        // never matching "localhost"; the host must exclude the port.
188        let b = bind_key_from_uri("http://localhost:8080/api").expect("parses"); // allow-unwrap
189        assert!(b.loopback, "localhost is loopback");
190        let b = bind_key_from_uri("http://myhost.example:8080").expect("parses"); // allow-unwrap
191        assert!(!b.loopback, "other hostnames stay non-loopback");
192    }
193}
194
195/// Best-effort, reverse-order shutdown of already-started `StepLifecycle`
196/// handles when `start_route` must abort. Used both mid-start-loop (the
197/// `[0..idx)` already-started prefix) and for any post-start failure path
198/// (e.g. `create_route_consumer`, the aggregate spawn branch, the consumer
199/// startup handshake) so the ADR-0022 SPI holds: if `start_route` returns
200/// `Err`, no started handle is left running.
201///
202/// Mirrors `StepLifecycle::shutdown`'s best-effort contract — each error is
203/// logged and swallowed so one failing shutdown cannot block rollback of the
204/// remaining handles.
205async fn rollback_started(route_id: &str, handles: &[Arc<dyn StepLifecycle>]) {
206    for handle in handles.iter().rev() {
207        if let Err(e) = handle.shutdown(StepShutdownReason::RouteStop).await {
208            warn!(
209                route_id = %route_id,
210                step = handle.name(),
211                error = %e,
212                "best-effort step shutdown during start rollback failed"
213            );
214        }
215    }
216}
217
218#[async_trait::async_trait]
219impl camel_api::RouteController for DefaultRouteController {
220    async fn start_route(&mut self, route_id: &str) -> Result<(), CamelError> {
221        // Check if route exists and can be started.
222        {
223            let managed = self
224                .routes
225                .get_mut(route_id)
226                .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
227
228            let consumer_running = handle_is_running(&managed.consumer_handle);
229            let pipeline_running = handle_is_running(&managed.pipeline_handle);
230            if consumer_running && pipeline_running {
231                return Ok(());
232            }
233            if !consumer_running && pipeline_running {
234                return Err(CamelError::RouteError(format!(
235                    "Route '{}' is suspended; use resume_route() to resume, or stop_route() then start_route() for full restart",
236                    route_id
237                )));
238            }
239            if consumer_running && !pipeline_running {
240                return Err(CamelError::RouteError(format!(
241                    "Route '{}' has inconsistent execution state; stop_route() then retry start_route()",
242                    route_id
243                )));
244            }
245        }
246
247        info!(route_id = %route_id, "Starting route");
248
249        // Get the resolved route info
250        let (from_uri, pipeline, concurrency, dispatch_plan) = {
251            let managed = self
252                .routes
253                .get(route_id)
254                .expect("invariant: route must exist after prior existence check"); // allow-unwrap
255            (
256                managed.from_uri.clone(),
257                Arc::clone(&managed.pipeline),
258                managed.concurrency.clone(),
259                managed.compiled.security_plan.clone(),
260            )
261        };
262
263        // ADR-0061 per-bind exposure gate: refuse to start Public routes on
264        // non-loopback binds without operator acknowledgement. Runs before
265        // any lifecycle step starts, so nothing needs rolling back. All
266        // sibling plans on the same bind are aggregated so the error/warn
267        // names every Public route on the bind.
268        if let Some(bind) = bind_key_from_uri(&from_uri) {
269            let owned = self.plans_for_bind(&bind.key);
270            let siblings: Vec<(&str, &RouteSecurityPlan)> =
271                owned.iter().map(|(id, plan)| (id.as_str(), plan)).collect();
272            enforce_bind_exposure_gate(
273                &bind.key,
274                bind.loopback,
275                &siblings,
276                self.bind_acks.acknowledged(&bind.key),
277            )?;
278        }
279
280        // ADR-0022: await each stateful step's `start()` before spawning the
281        // pipeline or consumer. On the Nth failure, roll back the already-
282        // started steps in reverse order (best-effort) and return the original
283        // start error WITHOUT spawning anything. Handles come from the compiled
284        // pipeline assembly, already collected in route order at compile time.
285        let lifecycle_handles: Vec<Arc<dyn StepLifecycle>> = pipeline.load().lifecycle.clone();
286        for (idx, handle) in lifecycle_handles.iter().enumerate() {
287            if let Err(start_err) = handle.start().await {
288                warn!(
289                    route_id = %route_id,
290                    step = handle.name(),
291                    "step start failed; rolling back already-started steps"
292                );
293                // Only [0..idx) have started; the Nth handle itself never did.
294                rollback_started(route_id, &lifecycle_handles[0..idx]).await;
295                return Err(start_err);
296            }
297        }
298
299        // Clone crash notifier for consumer task
300        let crash_notifier = self.crash_notifier.clone();
301        let runtime_for_consumer = self.runtime.clone();
302
303        let consumer_component_ctx = Arc::new(ControllerComponentContext::new(
304            Arc::clone(&self.registry),
305            Arc::clone(&self.languages),
306            self.tracer_metrics
307                .clone()
308                .unwrap_or_else(|| Arc::new(NoOpMetrics)),
309            Arc::clone(&self.platform_service),
310            self.health_registry(),
311            Some(route_id.to_string()),
312        ));
313        let consumer_rt: Arc<dyn camel_component_api::RuntimeObservability> =
314            Arc::clone(&consumer_component_ctx) as Arc<_>;
315        let (mut consumer, consumer_concurrency) = match consumer_management::create_route_consumer(
316            consumer_rt,
317            &self.registry,
318            &from_uri,
319            consumer_component_ctx.as_ref(),
320        ) {
321            Ok(v) => v,
322            // ADR-0022 SPI: every started handle must be rolled back
323            // before start_route returns Err, so no stateful step is left
324            // running. This is the first post-start fallible step.
325            Err(e) => {
326                rollback_started(route_id, &lifecycle_handles).await;
327                return Err(e);
328            }
329        };
330
331        // Resolve effective concurrency: route override > consumer default
332        let effective_concurrency = concurrency.unwrap_or(consumer_concurrency);
333
334        // Wire security context before spawning consumer. The
335        // `security_authenticator` marker stays in the guard as the
336        // route's security classification; the authenticator itself no
337        // longer rides the context (kernel plan + providers do). DSL
338        // compile sets the marker only alongside the policy path, so the
339        // marker term is redundant for DSL routes — it bites programmatic
340        // ones (marker without sp_config classifies non-Public but injects
341        // no context; strict dispatch fails closed downstream).
342        let managed = self
343            .routes
344            .get_mut(route_id)
345            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
346        deliver_security_context(consumer.as_mut(), &managed.compiled);
347
348        // Create channel for consumer to send exchanges
349        let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(256);
350        // Create child tokens for independent lifecycle control
351        let consumer_cancel = managed.consumer_cancel_token.child_token();
352        let pipeline_cancel = managed.pipeline_cancel_token.child_token();
353        let drain_in_flight = Arc::clone(&managed.drain_in_flight);
354        // Clone sender for storage (to reuse on resume)
355        let tx_for_storage = tx.clone();
356        let consumer_ctx = ConsumerContext::new(tx, consumer_cancel.clone(), route_id.to_string());
357
358        // --- Aggregator v2: check for aggregate route with timeout ---
359        let split_clone = managed.aggregate_split.clone();
360        if let Some(split) = split_clone {
361            let result = self
362                .start_aggregate_route(
363                    route_id,
364                    split,
365                    consumer,
366                    consumer_ctx,
367                    rx,
368                    crash_notifier,
369                    runtime_for_consumer,
370                    tx_for_storage,
371                    pipeline_cancel,
372                    drain_in_flight,
373                )
374                .await;
375            // ADR-0022 SPI: roll back already-started handles if the aggregate
376            // spawn/startup path returns Err.
377            if result.is_err() {
378                // rc-kh7c: cancel consumer's cancel token to stop child tasks
379                // spawned by consumer.start() that observe ctx.cancelled().
380                if let Some(managed) = self.routes.get_mut(route_id) {
381                    managed.consumer_cancel_token.cancel();
382                }
383                rollback_started(route_id, &lifecycle_handles).await;
384            }
385            return result;
386        }
387        // --- End aggregator v2 branch ---
388
389        // Clone for the startup-failure cleanup path (rc-kh7c): pipeline_cancel
390        // is moved into the spawn closure below; this clone stays in scope so
391        // the error handler can cancel it to force immediate pipeline exit.
392        let pipeline_cancel_for_cleanup = pipeline_cancel.clone();
393
394        // Spawn pipeline task with its own cancellation token
395        let pipeline_handle = match effective_concurrency {
396            ConcurrencyModel::Concurrent { max } => {
397                // Owned for the spawned 'static task (route_id is a borrow).
398                let route_id = route_id.to_string();
399                let sem = max.map(|n| Arc::new(tokio::sync::Semaphore::new(n)));
400                tokio::spawn(async move {
401                    loop {
402                        // B2 (ADR-0044): acquire permit BEFORE dequeue.
403                        // Cancel-aware: route stop is not blocked waiting for a permit.
404                        let permit = match &sem {
405                            Some(s) => {
406                                let acquired = tokio::select! {
407                                    p = Arc::clone(s).acquire_owned() => p.expect("semaphore closed"), // allow-unwrap
408                                    _ = pipeline_cancel.cancelled() => return,
409                                };
410                                Some(acquired)
411                            }
412                            None => None,
413                        };
414
415                        let envelope = tokio::select! {
416                            envelope = rx.recv() => match envelope {
417                                Some(e) => e,
418                                None => return,
419                            },
420                            _ = pipeline_cancel.cancelled() => return,
421                        };
422                        let ExchangeEnvelope {
423                            exchange,
424                            mut reply_tx,
425                        } = envelope;
426                        // ADR-0061 Task 2.9 strict-mode dispatch check (the
427                        // flip deferred from Task 2.2): every transport now
428                        // mints the typed carrier at its request boundary
429                        // (grpc 2.1, mcp 2.6, ws 2.8, http 2.9), so a
430                        // non-Public plan REQUIRES the carrier on the
431                        // Exchange — absent or wrong-provider is denied
432                        // BEFORE the pipeline runs; the transport renders
433                        // the denial in its own idiom via reply_tx.
434                        if strict_dispatch_denies(
435                            &dispatch_plan,
436                            &exchange,
437                            &mut reply_tx,
438                            route_id.as_str(),
439                        ) {
440                            continue;
441                        }
442                        let pipe_ref = Arc::clone(&pipeline);
443                        let cancel = pipeline_cancel.clone();
444                        let drain_clone = Arc::clone(&drain_in_flight);
445                        tokio::spawn(async move {
446                            // Permit owned by this task — released on completion (RAII).
447                            let _permit = permit;
448                            let _drain_guard = DrainGuard::new(drain_clone);
449
450                            // Load current pipeline from ArcSwap
451                            let mut pipe = pipe_ref.load().processor.clone_inner();
452
453                            // Wait for service ready with circuit breaker backoff
454                            if let Err(e) = ready_with_backoff(&mut pipe, &cancel).await {
455                                if let Some(tx) = reply_tx {
456                                    let _ = tx.send(Err(e));
457                                }
458                                return;
459                            }
460
461                            // B1: scope CANCEL_TOKEN so run_steps can check
462                            // cancellation between steps.
463                            let result = CANCEL_TOKEN
464                                .scope(cancel, async move { pipe.call(exchange).await })
465                                .await;
466                            if let Some(tx) = reply_tx {
467                                let _ = tx.send(result);
468                            } else if let Err(ref e) = result {
469                                // log-policy: system-broken
470                                error!("Pipeline error: {e}");
471                            }
472                        });
473                    }
474                })
475            }
476            // Forward-compat: an unknown future variant is treated as
477            // Sequential — the safe, simplest pipeline topology. A consumer
478            // that needs Concurrent semantics for a future variant must
479            // override the route's `?concurrent=` setting explicitly so the
480            // operator (not the wildcard) chooses the topology.
481            _ => {
482                // Owned for the spawned 'static task (route_id is a borrow).
483                let route_id = route_id.to_string();
484                tokio::spawn(async move {
485                    loop {
486                        // Use select! to exit promptly on cancellation even when idle
487                        let envelope = tokio::select! {
488                            envelope = rx.recv() => match envelope {
489                                Some(e) => e,
490                                None => return, // Channel closed
491                            },
492                            _ = pipeline_cancel.cancelled() => {
493                                // Cancellation requested - exit gracefully
494                                return;
495                            }
496                        };
497                        let ExchangeEnvelope {
498                            exchange,
499                            mut reply_tx,
500                        } = envelope;
501
502                        // ADR-0061 Task 2.9 strict-mode dispatch check — see
503                        // the Concurrent branch above for the full contract.
504                        if strict_dispatch_denies(
505                            &dispatch_plan,
506                            &exchange,
507                            &mut reply_tx,
508                            route_id.as_str(),
509                        ) {
510                            continue;
511                        }
512
513                        // Load current pipeline from ArcSwap (picks up hot-reloaded pipelines)
514                        let mut pipeline = pipeline.load().processor.clone_inner();
515
516                        if let Err(e) = ready_with_backoff(&mut pipeline, &pipeline_cancel).await {
517                            if let Some(tx) = reply_tx {
518                                let _ = tx.send(Err(e));
519                            }
520                            return;
521                        }
522
523                        // B1: scope CANCEL_TOKEN so run_steps can check cancellation
524                        // between steps. Per-start task-local — child token expires
525                        // when this pipeline task exits; the next start re-scopes a
526                        // fresh one (avoids the lifecycle bug where a compiled-in
527                        // child token stays cancelled after stop→restart).
528                        let cancel = pipeline_cancel.clone();
529                        let _drain_guard = DrainGuard::new(Arc::clone(&drain_in_flight));
530                        let result = CANCEL_TOKEN
531                            .scope(cancel, async move { pipeline.call(exchange).await })
532                            .await;
533                        if let Some(tx) = reply_tx {
534                            let _ = tx.send(result);
535                        } else if let Err(ref e) = result {
536                            // log-policy: system-broken
537                            error!("Pipeline error: {e}");
538                        }
539                    }
540                })
541            }
542        };
543        #[cfg(test)]
544        emit_start_route_event("pipeline_spawned", route_id);
545
546        // Start consumer after pipeline task is spawned to minimize the chance of
547        // fire-and-forget events being produced before the pipeline loop is active.
548        let (consumer_handle, startup_rx) = consumer_management::spawn_consumer_task(
549            route_id.to_string(),
550            consumer,
551            consumer_ctx,
552            crash_notifier,
553            runtime_for_consumer,
554            false,
555        );
556        #[cfg(test)]
557        emit_start_route_event("consumer_spawned", route_id);
558
559        // rc-w1u9: await consumer startup handshake before returning. For
560        // Immediate consumers this is a no-op (pre-resolved receiver); for
561        // Explicit consumers (HTTP, WebSocket) it propagates bind failures as
562        // proper startup errors instead of silent background logs.
563        match consumer_management::await_consumer_startup(startup_rx, "startup").await {
564            Ok(()) => {}
565            Err(e) => {
566                // rc-kh7c: abort the orphaned consumer task and cancel the
567                // pipeline so neither runs detached after start_route returns
568                // Err. Dropping a JoinHandle detaches the task (Tokio
569                // contract); abort() forces termination. The pipeline task
570                // would eventually self-clean via rx-drop, but explicit
571                // cancellation makes it immediate.
572                consumer_handle.abort();
573                pipeline_cancel_for_cleanup.cancel();
574                // Cancel the consumer's cancel token so child tasks spawned
575                // by consumer.start() that observe ctx.cancelled() also stop.
576                consumer_cancel.cancel();
577                rollback_started(route_id, &lifecycle_handles).await;
578                return Err(e);
579            }
580        }
581
582        // Store handles and update status
583        let managed = self
584            .routes
585            .get_mut(route_id)
586            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
587        managed.consumer_handle = Some(consumer_handle);
588        managed.pipeline_handle = Some(pipeline_handle);
589        managed.channel_sender = Some(tx_for_storage);
590
591        info!(route_id = %route_id, "Route started");
592        self.health_registry().mark_route_started(route_id);
593        Ok(())
594    }
595
596    async fn stop_route(&mut self, route_id: &str) -> Result<(), CamelError> {
597        self.stop_route_internal(route_id).await?;
598        self.health_registry().mark_route_stopped(route_id);
599        Ok(())
600    }
601
602    async fn restart_route(&mut self, route_id: &str) -> Result<(), CamelError> {
603        self.stop_route(route_id).await?;
604        tokio::time::sleep(Duration::from_millis(100)).await;
605        self.start_route(route_id).await
606    }
607
608    async fn suspend_route(&mut self, route_id: &str) -> Result<(), CamelError> {
609        // Check route exists and state.
610        let managed = self
611            .routes
612            .get_mut(route_id)
613            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
614
615        let consumer_running = handle_is_running(&managed.consumer_handle);
616        let pipeline_running = handle_is_running(&managed.pipeline_handle);
617
618        // Can only suspend from active started state.
619        if !consumer_running || !pipeline_running {
620            return Err(CamelError::RouteError(format!(
621                "Cannot suspend route '{}' with execution lifecycle {}",
622                route_id,
623                inferred_lifecycle_label(managed)
624            )));
625        }
626
627        info!(route_id = %route_id, "Suspending route (consumer only, keeping pipeline)");
628
629        // Cancel consumer token only (keep pipeline running)
630        let managed = self
631            .routes
632            .get_mut(route_id)
633            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
634        managed.consumer_cancel_token.cancel();
635
636        // Take and join consumer handle
637        let managed = self
638            .routes
639            .get_mut(route_id)
640            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
641        let consumer_handle = managed.consumer_handle.take();
642
643        // Wait for consumer task to complete with timeout
644        let timeout_result = tokio::time::timeout(DEFAULT_SHUTDOWN_TIMEOUT, async {
645            if let Some(handle) = consumer_handle {
646                let _ = handle.await;
647            }
648        })
649        .await;
650
651        if timeout_result.is_err() {
652            warn!(route_id = %route_id, "Consumer shutdown timed out during suspend");
653        }
654
655        // Get the managed route again (can't hold across await)
656        let managed = self
657            .routes
658            .get_mut(route_id)
659            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
660
661        // Create fresh cancellation token for consumer (for resume)
662        managed.consumer_cancel_token = CancellationToken::new();
663
664        info!(route_id = %route_id, "Route suspended (pipeline still running)");
665        self.health_registry().mark_route_stopped(route_id);
666        Ok(())
667    }
668
669    async fn resume_route(&mut self, route_id: &str) -> Result<(), CamelError> {
670        // Check route exists and is Suspended-equivalent execution state.
671        let managed = self
672            .routes
673            .get(route_id)
674            .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
675
676        let consumer_running = handle_is_running(&managed.consumer_handle);
677        let pipeline_running = handle_is_running(&managed.pipeline_handle);
678        if consumer_running || !pipeline_running {
679            return Err(CamelError::RouteError(format!(
680                "Cannot resume route '{}' with execution lifecycle {} (expected Suspended)",
681                route_id,
682                inferred_lifecycle_label(managed)
683            )));
684        }
685
686        // Get the stored channel sender (must exist for a suspended route)
687        let sender = managed.channel_sender.clone().ok_or_else(|| {
688            CamelError::RouteError("Suspended route has no channel sender".into())
689        })?;
690
691        // Get from_uri and concurrency for creating new consumer
692        let from_uri = managed.from_uri.clone();
693
694        // ADR-0061 per-bind exposure gate on resume too (see start path).
695        if let Some(bind) = bind_key_from_uri(&from_uri) {
696            let owned = self.plans_for_bind(&bind.key);
697            let siblings: Vec<(&str, &RouteSecurityPlan)> =
698                owned.iter().map(|(id, plan)| (id.as_str(), plan)).collect();
699            enforce_bind_exposure_gate(
700                &bind.key,
701                bind.loopback,
702                &siblings,
703                self.bind_acks.acknowledged(&bind.key),
704            )?;
705        }
706
707        info!(route_id = %route_id, "Resuming route (spawning consumer only)");
708
709        let consumer_component_ctx = Arc::new(ControllerComponentContext::new(
710            Arc::clone(&self.registry),
711            Arc::clone(&self.languages),
712            self.tracer_metrics
713                .clone()
714                .unwrap_or_else(|| Arc::new(NoOpMetrics)),
715            Arc::clone(&self.platform_service),
716            self.health_registry(),
717            Some(route_id.to_string()),
718        ));
719        let consumer_rt: Arc<dyn camel_component_api::RuntimeObservability> =
720            Arc::clone(&consumer_component_ctx) as Arc<_>;
721        let (mut consumer, _) = consumer_management::create_route_consumer(
722            consumer_rt,
723            &self.registry,
724            &from_uri,
725            consumer_component_ctx.as_ref(),
726        )?;
727
728        // Wire security context before spawning consumer (authenticator
729        // marker guard: see start path above).
730        let managed = self
731            .routes
732            .get(route_id)
733            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
734        deliver_security_context(consumer.as_mut(), &managed.compiled);
735
736        // Get the managed route for mutation
737        let managed = self
738            .routes
739            .get_mut(route_id)
740            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
741
742        // Create child token for consumer lifecycle
743        let consumer_cancel = managed.consumer_cancel_token.child_token();
744
745        let crash_notifier = self.crash_notifier.clone();
746        let runtime_for_consumer = self.runtime.clone();
747
748        // Create ConsumerContext with the stored sender
749        let consumer_ctx =
750            ConsumerContext::new(sender, consumer_cancel.clone(), route_id.to_string());
751
752        // Spawn consumer task
753        let (consumer_handle, startup_rx) = consumer_management::spawn_consumer_task(
754            route_id.to_string(),
755            consumer,
756            consumer_ctx,
757            crash_notifier,
758            runtime_for_consumer,
759            true,
760        );
761
762        // rc-w1u9: await consumer startup handshake on resume too — bind
763        // failures during resume must surface as resume errors.
764        consumer_management::await_consumer_startup(startup_rx, "resume").await?;
765
766        // Store consumer handle and update status
767        let managed = self
768            .routes
769            .get_mut(route_id)
770            .expect("invariant: route must exist after prior existence check"); // allow-unwrap
771        managed.consumer_handle = Some(consumer_handle);
772
773        info!(route_id = %route_id, "Route resumed");
774        self.health_registry().mark_route_started(route_id);
775        Ok(())
776    }
777
778    async fn start_all_routes(&mut self) -> Result<(), CamelError> {
779        // Only start routes where auto_startup() == true
780        // Sort by startup_order() ascending before starting
781        let route_ids: Vec<String> = {
782            let pairs = self.routes.auto_startup_sorted();
783            pairs.into_iter().map(|(id, _)| id).collect()
784        };
785
786        info!("Starting {} auto-startup routes", route_ids.len());
787
788        // Collect errors but continue starting remaining routes
789        let mut errors: Vec<String> = Vec::new();
790        for route_id in route_ids {
791            if let Err(e) = self.start_route(&route_id).await {
792                errors.push(format!("Route '{}': {}", route_id, e));
793            }
794        }
795
796        if !errors.is_empty() {
797            return Err(CamelError::RouteError(format!(
798                "Failed to start routes: {}",
799                errors.join(", ")
800            )));
801        }
802
803        info!("All auto-startup routes started");
804        Ok(())
805    }
806
807    async fn stop_all_routes(&mut self) -> Result<(), CamelError> {
808        // Sort by startup_order descending (reverse order)
809        let route_ids: Vec<String> = {
810            let pairs = self.routes.shutdown_sorted();
811            pairs.into_iter().map(|(id, _)| id).collect()
812        };
813
814        info!("Stopping {} routes", route_ids.len());
815
816        for route_id in route_ids {
817            let _ = self.stop_route(&route_id).await;
818        }
819
820        info!("All routes stopped");
821        Ok(())
822    }
823}
824
825#[cfg(test)]
826#[path = "route_controller_trait_tests.rs"]
827mod bind_exposure_gate;