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 self.tracer_gating.levers.components_enabled(),
313 ));
314 let consumer_rt: Arc<dyn camel_component_api::RuntimeObservability> =
315 Arc::clone(&consumer_component_ctx) as Arc<_>;
316 let (mut consumer, consumer_concurrency) = match consumer_management::create_route_consumer(
317 consumer_rt,
318 &self.registry,
319 &from_uri,
320 consumer_component_ctx.as_ref(),
321 ) {
322 Ok(v) => v,
323 // ADR-0022 SPI: every started handle must be rolled back
324 // before start_route returns Err, so no stateful step is left
325 // running. This is the first post-start fallible step.
326 Err(e) => {
327 rollback_started(route_id, &lifecycle_handles).await;
328 return Err(e);
329 }
330 };
331
332 // Resolve effective concurrency: route override > consumer default
333 let effective_concurrency = concurrency.unwrap_or(consumer_concurrency);
334
335 // Wire security context before spawning consumer. The
336 // `security_authenticator` marker stays in the guard as the
337 // route's security classification; the authenticator itself no
338 // longer rides the context (kernel plan + providers do). DSL
339 // compile sets the marker only alongside the policy path, so the
340 // marker term is redundant for DSL routes — it bites programmatic
341 // ones (marker without sp_config classifies non-Public but injects
342 // no context; strict dispatch fails closed downstream).
343 let managed = self
344 .routes
345 .get_mut(route_id)
346 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
347 deliver_security_context(consumer.as_mut(), &managed.compiled);
348
349 // Create channel for consumer to send exchanges
350 let (tx, mut rx) = mpsc::channel::<ExchangeEnvelope>(256);
351 // Create child tokens for independent lifecycle control
352 let consumer_cancel = managed.consumer_cancel_token.child_token();
353 let pipeline_cancel = managed.pipeline_cancel_token.child_token();
354 let drain_in_flight = Arc::clone(&managed.drain_in_flight);
355 // Clone sender for storage (to reuse on resume)
356 let tx_for_storage = tx.clone();
357 let consumer_ctx = ConsumerContext::new(tx, consumer_cancel.clone(), route_id.to_string());
358
359 // --- Aggregator v2: check for aggregate route with timeout ---
360 let split_clone = managed.aggregate_split.clone();
361 if let Some(split) = split_clone {
362 let result = self
363 .start_aggregate_route(
364 route_id,
365 split,
366 consumer,
367 consumer_ctx,
368 rx,
369 crash_notifier,
370 runtime_for_consumer,
371 tx_for_storage,
372 pipeline_cancel,
373 drain_in_flight,
374 )
375 .await;
376 // ADR-0022 SPI: roll back already-started handles if the aggregate
377 // spawn/startup path returns Err.
378 if result.is_err() {
379 // rc-kh7c: cancel consumer's cancel token to stop child tasks
380 // spawned by consumer.start() that observe ctx.cancelled().
381 if let Some(managed) = self.routes.get_mut(route_id) {
382 managed.consumer_cancel_token.cancel();
383 }
384 rollback_started(route_id, &lifecycle_handles).await;
385 }
386 return result;
387 }
388 // --- End aggregator v2 branch ---
389
390 // Clone for the startup-failure cleanup path (rc-kh7c): pipeline_cancel
391 // is moved into the spawn closure below; this clone stays in scope so
392 // the error handler can cancel it to force immediate pipeline exit.
393 let pipeline_cancel_for_cleanup = pipeline_cancel.clone();
394
395 // rc-jxkj cohort gate: the drain loop parks each dequeued envelope
396 // until the startup cohort opens the gate. Subscribed once here; the
397 // spawned task owns the receiver (`wait_for` needs &mut), mirroring
398 // the pipeline_cancel capture.
399 let mut cohort_rx = self.cohort.subscribe();
400
401 // Spawn pipeline task with its own cancellation token
402 let pipeline_handle = match effective_concurrency {
403 ConcurrencyModel::Concurrent { max } => {
404 // Owned for the spawned 'static task (route_id is a borrow).
405 let route_id = route_id.to_string();
406 let sem = max.map(|n| Arc::new(tokio::sync::Semaphore::new(n)));
407 tokio::spawn(async move {
408 loop {
409 // B2 (ADR-0044): acquire permit BEFORE dequeue.
410 // Cancel-aware: route stop is not blocked waiting for a permit.
411 let permit = match &sem {
412 Some(s) => {
413 let acquired = tokio::select! {
414 p = Arc::clone(s).acquire_owned() => p.expect("semaphore closed"), // allow-unwrap
415 _ = pipeline_cancel.cancelled() => return,
416 };
417 Some(acquired)
418 }
419 None => None,
420 };
421
422 let envelope = tokio::select! {
423 envelope = rx.recv() => match envelope {
424 Some(e) => e,
425 None => return,
426 },
427 _ = pipeline_cancel.cancelled() => return,
428 };
429 let ExchangeEnvelope {
430 exchange,
431 mut reply_tx,
432 } = envelope;
433 // rc-jxkj cohort gate: park dispatch until the startup
434 // cohort completes. Level-triggered — after the first
435 // open, later envelopes pass without parking.
436 // rc-z5qz: `biased` with the gate polled FIRST —
437 // with the cohort open and the pipeline token
438 // cancelled, an unbiased select picks randomly and
439 // may drop a deliverable envelope. Gate-open wins
440 // deterministically; a closed gate still drops.
441 tokio::select! {
442 biased;
443 _ = cohort_rx.wait_for(|open| *open) => {}
444 _ = pipeline_cancel.cancelled() => {
445 // Drop the envelope; reply_tx (if any)
446 // resolves to ChannelClosed for the
447 // send_and_wait waiter.
448 return;
449 }
450 }
451 // ADR-0061 Task 2.9 strict-mode dispatch check (the
452 // flip deferred from Task 2.2): every transport now
453 // mints the typed carrier at its request boundary
454 // (grpc 2.1, mcp 2.6, ws 2.8, http 2.9), so a
455 // non-Public plan REQUIRES the carrier on the
456 // Exchange — absent or wrong-provider is denied
457 // BEFORE the pipeline runs; the transport renders
458 // the denial in its own idiom via reply_tx.
459 if strict_dispatch_denies(
460 &dispatch_plan,
461 &exchange,
462 &mut reply_tx,
463 route_id.as_str(),
464 ) {
465 continue;
466 }
467 let pipe_ref = Arc::clone(&pipeline);
468 let cancel = pipeline_cancel.clone();
469 let drain_clone = Arc::clone(&drain_in_flight);
470 tokio::spawn(async move {
471 // Permit owned by this task — released on completion (RAII).
472 let _permit = permit;
473 let _drain_guard = DrainGuard::new(drain_clone);
474
475 // Load current pipeline from ArcSwap
476 let mut pipe = pipe_ref.load().processor.clone_inner();
477
478 // Wait for service ready with circuit breaker backoff
479 if let Err(e) = ready_with_backoff(&mut pipe, &cancel).await {
480 if let Some(tx) = reply_tx {
481 let _ = tx.send(Err(e));
482 }
483 return;
484 }
485
486 // B1: scope CANCEL_TOKEN so run_steps can check
487 // cancellation between steps.
488 let result = CANCEL_TOKEN
489 .scope(cancel, async move { pipe.call(exchange).await })
490 .await;
491 if let Some(tx) = reply_tx {
492 let _ = tx.send(result);
493 } else if let Err(ref e) = result {
494 // log-policy: system-broken
495 error!("Pipeline error: {e}");
496 }
497 });
498 }
499 })
500 }
501 // Forward-compat: an unknown future variant is treated as
502 // Sequential — the safe, simplest pipeline topology. A consumer
503 // that needs Concurrent semantics for a future variant must
504 // override the route's `?concurrent=` setting explicitly so the
505 // operator (not the wildcard) chooses the topology.
506 _ => {
507 // Owned for the spawned 'static task (route_id is a borrow).
508 let route_id = route_id.to_string();
509 tokio::spawn(async move {
510 loop {
511 // Use select! to exit promptly on cancellation even when idle
512 let envelope = tokio::select! {
513 envelope = rx.recv() => match envelope {
514 Some(e) => e,
515 None => return, // Channel closed
516 },
517 _ = pipeline_cancel.cancelled() => {
518 // Cancellation requested - exit gracefully
519 return;
520 }
521 };
522 let ExchangeEnvelope {
523 exchange,
524 mut reply_tx,
525 } = envelope;
526 // rc-jxkj cohort gate: park dispatch until the startup
527 // cohort completes. Level-triggered — after the first
528 // open, later envelopes pass without parking.
529 // rc-z5qz: `biased` with the gate polled FIRST —
530 // with the cohort open and the pipeline token
531 // cancelled, an unbiased select picks randomly and
532 // may drop a deliverable envelope. Gate-open wins
533 // deterministically; a closed gate still drops.
534 tokio::select! {
535 biased;
536 _ = cohort_rx.wait_for(|open| *open) => {}
537 _ = pipeline_cancel.cancelled() => {
538 // Drop the envelope; reply_tx (if any)
539 // resolves to ChannelClosed for the
540 // send_and_wait waiter.
541 return;
542 }
543 }
544
545 // ADR-0061 Task 2.9 strict-mode dispatch check — see
546 // the Concurrent branch above for the full contract.
547 if strict_dispatch_denies(
548 &dispatch_plan,
549 &exchange,
550 &mut reply_tx,
551 route_id.as_str(),
552 ) {
553 continue;
554 }
555
556 // Load current pipeline from ArcSwap (picks up hot-reloaded pipelines)
557 let mut pipeline = pipeline.load().processor.clone_inner();
558
559 if let Err(e) = ready_with_backoff(&mut pipeline, &pipeline_cancel).await {
560 if let Some(tx) = reply_tx {
561 let _ = tx.send(Err(e));
562 }
563 return;
564 }
565
566 // B1: scope CANCEL_TOKEN so run_steps can check cancellation
567 // between steps. Per-start task-local — child token expires
568 // when this pipeline task exits; the next start re-scopes a
569 // fresh one (avoids the lifecycle bug where a compiled-in
570 // child token stays cancelled after stop→restart).
571 let cancel = pipeline_cancel.clone();
572 let _drain_guard = DrainGuard::new(Arc::clone(&drain_in_flight));
573 let result = CANCEL_TOKEN
574 .scope(cancel, async move { pipeline.call(exchange).await })
575 .await;
576 if let Some(tx) = reply_tx {
577 let _ = tx.send(result);
578 } else if let Err(ref e) = result {
579 // log-policy: system-broken
580 error!("Pipeline error: {e}");
581 }
582 }
583 })
584 }
585 };
586 #[cfg(test)]
587 emit_start_route_event("pipeline_spawned", route_id);
588
589 // Start consumer after pipeline task is spawned to minimize the chance of
590 // fire-and-forget events being produced before the pipeline loop is active.
591 let (consumer_handle, startup_rx, watcher_inputs, outer_inputs) =
592 consumer_management::spawn_consumer_task(
593 route_id.to_string(),
594 consumer,
595 consumer_ctx,
596 crash_notifier,
597 runtime_for_consumer,
598 false,
599 );
600 #[cfg(test)]
601 emit_start_route_event("consumer_spawned", route_id);
602
603 // rc-w1u9: await consumer startup handshake. For Explicit consumers
604 // (HTTP, WebSocket) it propagates bind failures as proper startup
605 // errors. For Immediate consumers the receiver is pre-resolved
606 // (StartupReceiver::immediate) so this returns instantly — the
607 // controller never yields during the Immediate handshake (rc-slvd).
608 let startup_result =
609 consumer_management::await_consumer_startup(startup_rx, "startup").await;
610 match startup_result {
611 Ok(()) => {}
612 Err(e) => {
613 // rc-kh7c: abort the orphaned consumer task and cancel the
614 // pipeline so neither runs detached after start_route returns
615 // Err. Dropping a JoinHandle detaches the task (Tokio
616 // contract); abort() forces termination. The pipeline task
617 // would eventually self-clean via rx-drop, but explicit
618 // cancellation makes it immediate.
619 consumer_handle.abort();
620 pipeline_cancel_for_cleanup.cancel();
621 // Cancel the consumer's cancel token so child tasks spawned
622 // by consumer.start() that observe ctx.cancelled() also stop.
623 consumer_cancel.cancel();
624 rollback_started(route_id, &lifecycle_handles).await;
625 return Err(e);
626 }
627 }
628
629 // Detached failure watcher for Immediate consumers (rc-slvd).
630 // The route owns the JoinHandle; the watcher owns the AbortHandle,
631 // oneshot, and command_id — ownership split prevents any coupling.
632 if let Some(inputs) = watcher_inputs {
633 consumer_management::spawn_failure_watcher(inputs);
634 }
635
636 // Detached outer-task watcher for Explicit consumers (rc-a7rh):
637 // spawned only after the handshake resolved Ok — rollback
638 // terminations (abort-then-cancel above) happen before this point
639 // and are never watched.
640 if let Some(outer) = outer_inputs {
641 consumer_management::spawn_outer_task_watcher(outer);
642 }
643
644 // Store handles and update status
645 let managed = self
646 .routes
647 .get_mut(route_id)
648 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
649 managed.consumer_handle = Some(consumer_handle);
650 managed.pipeline_handle = Some(pipeline_handle);
651 managed.channel_sender = Some(tx_for_storage);
652
653 info!(route_id = %route_id, "Route started");
654 self.health_registry().mark_route_started(route_id);
655 Ok(())
656 }
657
658 async fn stop_route(&mut self, route_id: &str) -> Result<(), CamelError> {
659 self.stop_route_internal(route_id).await?;
660 self.health_registry().mark_route_stopped(route_id);
661 Ok(())
662 }
663
664 async fn restart_route(&mut self, route_id: &str) -> Result<(), CamelError> {
665 self.stop_route(route_id).await?;
666 tokio::time::sleep(Duration::from_millis(100)).await;
667 self.start_route(route_id).await
668 }
669
670 async fn suspend_route(&mut self, route_id: &str) -> Result<(), CamelError> {
671 // Check route exists and state.
672 let managed = self
673 .routes
674 .get_mut(route_id)
675 .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
676
677 let consumer_running = handle_is_running(&managed.consumer_handle);
678 let pipeline_running = handle_is_running(&managed.pipeline_handle);
679
680 // Can only suspend from active started state.
681 if !consumer_running || !pipeline_running {
682 return Err(CamelError::RouteError(format!(
683 "Cannot suspend route '{}' with execution lifecycle {}",
684 route_id,
685 inferred_lifecycle_label(managed)
686 )));
687 }
688
689 info!(route_id = %route_id, "Suspending route (consumer only, keeping pipeline)");
690
691 // Cancel consumer token only (keep pipeline running)
692 let managed = self
693 .routes
694 .get_mut(route_id)
695 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
696 managed.consumer_cancel_token.cancel();
697
698 // Take and join consumer handle
699 let managed = self
700 .routes
701 .get_mut(route_id)
702 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
703 let consumer_handle = managed.consumer_handle.take();
704
705 // Wait for consumer task to complete with timeout
706 let timeout_result = tokio::time::timeout(DEFAULT_SHUTDOWN_TIMEOUT, async {
707 if let Some(handle) = consumer_handle {
708 let _ = handle.await;
709 }
710 })
711 .await;
712
713 if timeout_result.is_err() {
714 warn!(route_id = %route_id, "Consumer shutdown timed out during suspend");
715 }
716
717 // Get the managed route again (can't hold across await)
718 let managed = self
719 .routes
720 .get_mut(route_id)
721 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
722
723 // Create fresh cancellation token for consumer (for resume)
724 managed.consumer_cancel_token = CancellationToken::new();
725
726 info!(route_id = %route_id, "Route suspended (pipeline still running)");
727 self.health_registry().mark_route_stopped(route_id);
728 Ok(())
729 }
730
731 async fn resume_route(&mut self, route_id: &str) -> Result<(), CamelError> {
732 // Check route exists and is Suspended-equivalent execution state.
733 let managed = self
734 .routes
735 .get(route_id)
736 .ok_or_else(|| CamelError::RouteError(format!("Route '{}' not found", route_id)))?;
737
738 let consumer_running = handle_is_running(&managed.consumer_handle);
739 let pipeline_running = handle_is_running(&managed.pipeline_handle);
740 if consumer_running || !pipeline_running {
741 return Err(CamelError::RouteError(format!(
742 "Cannot resume route '{}' with execution lifecycle {} (expected Suspended)",
743 route_id,
744 inferred_lifecycle_label(managed)
745 )));
746 }
747
748 // Get the stored channel sender (must exist for a suspended route)
749 let sender = managed.channel_sender.clone().ok_or_else(|| {
750 CamelError::RouteError("Suspended route has no channel sender".into())
751 })?;
752
753 // Get from_uri and concurrency for creating new consumer
754 let from_uri = managed.from_uri.clone();
755
756 // ADR-0061 per-bind exposure gate on resume too (see start path).
757 if let Some(bind) = bind_key_from_uri(&from_uri) {
758 let owned = self.plans_for_bind(&bind.key);
759 let siblings: Vec<(&str, &RouteSecurityPlan)> =
760 owned.iter().map(|(id, plan)| (id.as_str(), plan)).collect();
761 enforce_bind_exposure_gate(
762 &bind.key,
763 bind.loopback,
764 &siblings,
765 self.bind_acks.acknowledged(&bind.key),
766 )?;
767 }
768
769 info!(route_id = %route_id, "Resuming route (spawning consumer only)");
770
771 let consumer_component_ctx = Arc::new(ControllerComponentContext::new(
772 Arc::clone(&self.registry),
773 Arc::clone(&self.languages),
774 self.tracer_metrics
775 .clone()
776 .unwrap_or_else(|| Arc::new(NoOpMetrics)),
777 Arc::clone(&self.platform_service),
778 self.health_registry(),
779 Some(route_id.to_string()),
780 self.tracer_gating.levers.components_enabled(),
781 ));
782 let consumer_rt: Arc<dyn camel_component_api::RuntimeObservability> =
783 Arc::clone(&consumer_component_ctx) as Arc<_>;
784 let (mut consumer, _) = consumer_management::create_route_consumer(
785 consumer_rt,
786 &self.registry,
787 &from_uri,
788 consumer_component_ctx.as_ref(),
789 )?;
790
791 // Wire security context before spawning consumer (authenticator
792 // marker guard: see start path above).
793 let managed = self
794 .routes
795 .get(route_id)
796 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
797 deliver_security_context(consumer.as_mut(), &managed.compiled);
798
799 // Get the managed route for mutation
800 let managed = self
801 .routes
802 .get_mut(route_id)
803 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
804
805 // Create child token for consumer lifecycle
806 let consumer_cancel = managed.consumer_cancel_token.child_token();
807
808 let crash_notifier = self.crash_notifier.clone();
809 let runtime_for_consumer = self.runtime.clone();
810
811 // Create ConsumerContext with the stored sender
812 let consumer_ctx =
813 ConsumerContext::new(sender, consumer_cancel.clone(), route_id.to_string());
814
815 // Spawn consumer task
816 let (consumer_handle, startup_rx, watcher_inputs, outer_inputs) =
817 consumer_management::spawn_consumer_task(
818 route_id.to_string(),
819 consumer,
820 consumer_ctx,
821 crash_notifier,
822 runtime_for_consumer,
823 true,
824 );
825
826 // rc-w1u9: await consumer startup handshake on resume too — bind
827 // failures during resume must surface as resume errors.
828 // For Immediate consumers the receiver is pre-resolved (rc-slvd).
829 let resume_result = consumer_management::await_consumer_startup(startup_rx, "resume").await;
830 if let Err(e) = resume_result {
831 // rc-kh7c cleanup parity with the start path: the consumer task
832 // must not run detached after a failed resume, and child tasks
833 // spawned by consumer.start() that observe ctx.cancelled() must
834 // stop too.
835 consumer_handle.abort();
836 consumer_cancel.cancel();
837 return Err(e);
838 }
839
840 // Detached failure watcher for Immediate consumers (rc-slvd).
841 if let Some(inputs) = watcher_inputs {
842 consumer_management::spawn_failure_watcher(inputs);
843 }
844
845 // Detached outer-task watcher for Explicit consumers (rc-a7rh):
846 // spawned only after the resume handshake resolved Ok — rollback
847 // terminations (abort-then-cancel above) happen before this point
848 // and are never watched.
849 if let Some(outer) = outer_inputs {
850 consumer_management::spawn_outer_task_watcher(outer);
851 }
852
853 // Store consumer handle and update status
854 let managed = self
855 .routes
856 .get_mut(route_id)
857 .expect("invariant: route must exist after prior existence check"); // allow-unwrap
858 managed.consumer_handle = Some(consumer_handle);
859
860 info!(route_id = %route_id, "Route resumed");
861 self.health_registry().mark_route_started(route_id);
862 Ok(())
863 }
864
865 async fn start_all_routes(&mut self) -> Result<(), CamelError> {
866 // Only start routes where auto_startup() == true
867 // Sort by startup_order() ascending before starting
868 let route_ids: Vec<String> = {
869 let pairs = self.routes.auto_startup_sorted();
870 pairs.into_iter().map(|(id, _)| id).collect()
871 };
872
873 info!("Starting {} auto-startup routes", route_ids.len());
874
875 // Collect errors but continue starting remaining routes
876 let mut errors: Vec<String> = Vec::new();
877 for route_id in route_ids {
878 if let Err(e) = self.start_route(&route_id).await {
879 errors.push(format!("Route '{}': {}", route_id, e));
880 }
881 }
882
883 if !errors.is_empty() {
884 return Err(CamelError::RouteError(format!(
885 "Failed to start routes: {}",
886 errors.join(", ")
887 )));
888 }
889
890 info!("All auto-startup routes started");
891 Ok(())
892 }
893
894 async fn stop_all_routes(&mut self) -> Result<(), CamelError> {
895 // Sort by startup_order descending (reverse order)
896 let route_ids: Vec<String> = {
897 let pairs = self.routes.shutdown_sorted();
898 pairs.into_iter().map(|(id, _)| id).collect()
899 };
900
901 info!("Stopping {} routes", route_ids.len());
902
903 for route_id in route_ids {
904 let _ = self.stop_route(&route_id).await;
905 }
906
907 info!("All routes stopped");
908 Ok(())
909 }
910}
911
912#[cfg(test)]
913#[path = "route_controller_trait_tests.rs"]
914mod bind_exposure_gate;