Skip to main content

camel_integration_test/
boot_scenario.rs

1//! The embedded FULL-tier scenario boot (ADR-0069 sections 4, 5, 10).
2//!
3//! [`boot_scenario`] boots the same composition root `camel run`
4//! boots, through the same seams and in the same order: the sealed
5//! config load (pinned profile, no ambient `CAMEL_*` overrides,
6//! `${env:}` through the layered environment), context preparation
7//! through `camel_config`, the offline tier security gate (keycloak/
8//! oidc and wasm policies/permissions fail closed — the tier has no
9//! network and v1 supports the native provider only), the security
10//! compile context through the shared `camel_bundles` builder, the
11//! `[binds]` public-exposure acknowledgements (ADR-0061), the
12//! component-bundle cascade through `camel_bundles::boot`, the
13//! document's route source through `camel_dsl` route discovery
14//! (two-pass template materialization included; every `${env:NAME}`
15//! resolves through the layered environment, never the process
16//! environment), the ADR-0033 fail-closed SQL startup checks from the
17//! discovered definitions, and `ctx.start()`. Oversize route files now
18//! surface as `CamelError::Io` (discovery's capped-read error),
19//! matching `camel run`; the pre-delegation loader used `RouteError`
20//! for this case.
21//!
22//! Inbound provisioning (feature `http`, rc-5yon) runs inside the
23//! boot: when the document declares `inbound:`, the listener binds
24//! `127.0.0.1:0` and stages on the HTTP component's global registry
25//! (ADR-0070 staged consumption) before the bundle cascade, and the
26//! discovery environment gains the bound URL under the declared
27//! bindVar (`LayeredEnv::with_harness_var`) so route-file consumer
28//! templates interpolate the staged socket. The boot result carries
29//! the bound address in `ScenarioRun::inbound_bound`.
30//!
31//! Partners are NOT owned here: the caller constructs them before the
32//! boot (bind `127.0.0.1:0`), builds the harness-provisioned map into
33//! the [`LayeredEnv`] passed in, and tears the partners down after
34//! [`BootHandle::shutdown`]. Route stimulus for `direct:` endpoints
35//! rides the caller's router through
36//! [`DirectStimulus`](crate::adapters::DirectStimulus).
37
38use std::path::Path;
39
40use camel_api::CamelError;
41use camel_bundles::BootHandle;
42use camel_config::config::CamelConfig;
43use camel_core::CamelContext;
44
45use crate::document::{RouteSource, ScenarioDocument};
46use crate::env_layers::LayeredEnv;
47
48/// A booted scenario: the started context the caller drives (route
49/// stimulus, shutdown) and the teardown handle that owns pool
50/// shutdown ordering.
51pub struct ScenarioRun {
52    /// The started, route-loaded context. The caller keeps ownership;
53    /// wrap it in an `Arc<tokio::sync::Mutex<..>>` to share it with a
54    /// [`DirectStimulus`](crate::adapters::DirectStimulus) adapter.
55    pub ctx: CamelContext,
56    /// The `camel_bundles` teardown sequencer; call
57    /// `shutdown(&mut ctx)` after the verdict to drain lifecycles and
58    /// pools.
59    pub boot: BootHandle,
60    /// The bound address of the document's `inbound:` listener
61    /// (rc-5yon, ADR-0070): the same address the discovery environment
62    /// resolved under the declared bindVar. `None` when the document
63    /// declares no `inbound:` section. Boot-owning library callers and
64    /// tests carry it into `DocumentOutcome::inbound_bound` to target
65    /// the ephemeral listener without re-deriving it.
66    pub inbound_bound: Option<std::net::SocketAddr>,
67}
68
69/// Boots the full composition root for one scenario document.
70///
71/// Sequence (the `camel run` wiring order, ADR-0069 sections 4, 10):
72/// load `<root>/Camel.toml` through the sealed loader with the
73/// document's pinned profile (defaulting to `"default"`; ambient
74/// `CAMEL_PROFILE` and allowlisted `CAMEL_*` overrides never apply),
75/// prepare the context from that config, gate `[security.*]` for the
76/// offline tier, build the security compile context through the
77/// shared builder, install the `[binds]` exposure acknowledgements,
78/// register the component cascade through `camel_bundles::boot`,
79/// discover the document's route source with the config's
80/// `stream_caching.threshold`, register the ADR-0033 SQL startup
81/// checks from the discovered routes, and start the context. Binding
82/// waits at `ctx.start()` through the operator readiness signal.
83///
84/// `root` is the project root: the directory holding `Camel.toml`.
85/// The sealed config load and `routeFilesFromRoot` resolution anchor
86/// there; relative `routeFiles` stay anchored to the document's own
87/// directory (`source_path`'s parent), so a nested document boots
88/// from the nearest ancestor `Camel.toml` without relocating its
89/// colocated route files (rc-jjzy5). For flat layouts the two
90/// anchors coincide.
91pub async fn boot_scenario(
92    doc: &ScenarioDocument,
93    root: &Path,
94    env: &LayeredEnv,
95) -> Result<ScenarioRun, CamelError> {
96    let doc_dir = doc
97        .source_path
98        .parent()
99        .map(Path::to_path_buf)
100        .unwrap_or_else(|| Path::new(".").to_path_buf());
101    let config_path = root.join("Camel.toml");
102    let config = CamelConfig::from_file_sealed(
103        config_path.to_str().ok_or_else(|| {
104            CamelError::Config(format!(
105                "scenario config path is not valid utf-8: {}",
106                config_path.display()
107            ))
108        })?,
109        doc.profile.as_deref().unwrap_or("default"),
110        &|name| env.lookup(name),
111    )
112    .map_err(|e| {
113        CamelError::Config(format!(
114            "failed to load scenario config {}: {e}",
115            config_path.display()
116        ))
117    })?;
118
119    // Boot-time lint (ungated): reject per-connection sqlite `:memory:`
120    // datasource URLs before any context preparation — a config-shape
121    // check, so it runs regardless of cargo features and before any
122    // pool is created.
123    crate::sql_action::ensure_sqlite_memory_shared(&config)?;
124
125    let mut ctx = CamelConfig::configure_context_with_beans(&config, None).await?;
126
127    // Tier security gate — a config-shape check, so it runs ungated
128    // by cargo features and before any builder call: the scenario
129    // tier has no network, so keycloak/oidc (network-prefetching auth
130    // providers) and wasm policies/permissions (a later wave) fail
131    // closed here, never at a fetch.
132    if config.security.keycloak.is_some() || config.security.oidc.is_some() {
133        return Err(CamelError::AuthProviderUnavailable(
134            "scenario tier runs offline (no network): keycloak/oidc security requires \
135             a network-prefetching auth provider; v1 supports the native provider only"
136                .to_string(),
137        ));
138    }
139    if config.security.policies.is_some() || config.security.permissions.is_some() {
140        return Err(CamelError::Config(
141            "wasm security policies/permissions are not supported in the scenario \
142             tier in v1 (offline tier; later wave)"
143                .to_string(),
144        ));
145    }
146
147    // Security compile context through the shared builder (the `camel
148    // run` seam): with the `security` feature the builder owns every
149    // remaining `[security.*]` section (native); without it the
150    // fail-closed guard rejects any configured section and the
151    // default context compiles the routes.
152    #[cfg(feature = "security")]
153    let security_ctx = camel_bundles::security_boot::build_security_compile_context_from_config(
154        &config,
155        ctx.registry_arc(),
156    )
157    .await?;
158    #[cfg(not(feature = "security"))]
159    let security_ctx = {
160        camel_bundles::security_boot::ensure_security_supported(&config)?;
161        camel_dsl::SecurityCompileContext::default()
162    };
163
164    // ADR-0061: per-bind public-exposure acknowledgements from
165    // `[binds]`, installed before any route starts staging — the
166    // shared installer, same as `camel run`.
167    camel_bundles::security_boot::install_bind_exposure_acks(&mut ctx, &config).await;
168
169    // Inbound provisioning (feature `http`, rc-5yon): stage the
170    // document's listener BEFORE any component bundle can spawn a
171    // consumer, and extend the discovery environment with the bound
172    // URL under the declared bindVar, so route-file consumer templates
173    // (`http://${NAME}/...`) resolve to the staged socket (ADR-0070
174    // staged consumption). The no-feature build rejects `inbound:` at
175    // load and, defense-in-depth, in the `#[cfg(not(feature =
176    // "http"))]` arm below, so a declaration reaching this arm implies
177    // the feature.
178    #[cfg(feature = "http")]
179    let provisioned = match doc.inbound.as_ref() {
180        Some(entry) => {
181            let bound = crate::inbound::provision_inbound(entry).await?;
182            Some((
183                env.with_harness_var(&entry.bind_var, format!("http://{bound}")),
184                bound,
185            ))
186        }
187        None => None,
188    };
189    #[cfg(feature = "http")]
190    let (discovery_env, inbound_bound) = match &provisioned {
191        Some((extended, bound)) => (extended, Some(*bound)),
192        None => (env, None),
193    };
194    #[cfg(not(feature = "http"))]
195    let (discovery_env, inbound_bound) = if doc.inbound.is_some() {
196        return Err(CamelError::Config(
197            "inbound listeners need the `http` feature to boot: enable it to \
198             provision the staged listener (the load-time doc gate now fires \
199             first; this rejection is defense-in-depth for directly-constructed \
200             documents)"
201                .to_string(),
202        ));
203    } else {
204        (env, None)
205    };
206
207    let boot = camel_bundles::boot(&mut ctx, &config, root).await?;
208
209    let defs = camel_dsl::discover_routes_with_threshold_security_and_env(
210        &route_patterns(doc, root, &doc_dir)?,
211        config.stream_caching.threshold,
212        security_ctx,
213        &|name| discovery_env.lookup(name),
214    )
215    .map_err(map_discovery_error)?;
216
217    // ADR-0033: fail-closed SQL startup checks from the discovered
218    // routes; they run at the head of `ctx.start()`, before any route
219    // consumer starts.
220    camel_bundles::security_boot::install_sql_startup_checks(&mut ctx, &defs);
221
222    for def in defs {
223        ctx.add_route_definition(def).await?;
224    }
225    ctx.start().await?;
226    Ok(ScenarioRun {
227        ctx,
228        boot,
229        inbound_bound,
230    })
231}
232
233/// Builds the route-discovery patterns for the document's route
234/// source.
235///
236/// `routeFilesFromRoot` resolves against `root` (the nearest
237/// ancestor `Camel.toml` directory); relative `routeFiles` resolve
238/// against the document's own directory (`doc_dir`), so a nested
239/// document keeps its colocated route files while booting from the
240/// ancestor root (rc-jjzy5). Each declared file gets an existence
241/// pre-check: a glob pattern that matches nothing is silent, and the
242/// missing-file error must name the file the document declared. A
243/// declared file with discovery's reserved `.test.yaml`/`.test.yml`
244/// suffix is rejected here too: the document explicitly names the
245/// file, so discovery's silent reserved-suffix skip would boot zero
246/// routes — test documents belong to `camel test`, not scenario
247/// routeFiles.
248///
249/// Inline routes cannot boot in v1: the document parser owns the
250/// definitions, and this entry receives the document by reference, so
251/// the definitions cannot move into the context. A FULL-tier
252/// scenario that wants the embedded boot declares `routeFiles`. The
253/// document parser already rejects inline route sources at load
254/// (rc-9dpx), before partners bind; this rejection stays only as
255/// defense-in-depth for documents constructed directly, bypassing
256/// `parse_scenario_document`.
257fn route_patterns(
258    doc: &ScenarioDocument,
259    root: &Path,
260    doc_dir: &Path,
261) -> Result<Vec<String>, CamelError> {
262    /// Resolves one declared file against its anchor directory and
263    /// runs the shared pre-checks (existence, reserved test suffix).
264    fn anchored(base: &Path, file: &Path) -> Result<String, CamelError> {
265        let full = base.join(file);
266        std::fs::metadata(&full).map_err(|e| CamelError::Io(format!("{}: {e}", full.display())))?;
267        // Same predicate as discovery's reserved-document gate,
268        // but fail loud: the route file was declared, not
269        // glob-expanded, so a silent skip has no excuse.
270        if camel_dsl::discovery::is_reserved_document(&full) {
271            return Err(CamelError::Config(format!(
272                "{}: reserved documents (*.test.yaml, *.test.yml belong to \
273                 `camel test`; *.job.yaml, *.job.yml belong to `camel job`) \
274                 are not scenario routeFiles",
275                full.display()
276            )));
277        }
278        Ok(full.display().to_string())
279    }
280    match &doc.route_source {
281        RouteSource::RouteFiles(files) => {
282            files.iter().map(|file| anchored(doc_dir, file)).collect()
283        }
284        RouteSource::RouteFilesFromRoot(files) => {
285            files.iter().map(|file| anchored(root, file)).collect()
286        }
287        RouteSource::Inline(_) => Err(CamelError::Config(
288            "inline route sources cannot boot in v1: declare routeFiles \
289             (the load-time doc gate now fires first; this rejection is \
290             defense-in-depth for directly-constructed documents)"
291                .to_string(),
292        )),
293    }
294}
295
296/// Maps a discovery error into the `CamelError` shapes the scenario
297/// boot reports. The env mapping keeps the message shape the per-file
298/// loader produced (file path, variable name, and the no-layer
299/// hermeticity note); the io and parse mappings keep the previous
300/// per-file read/parse error classes.
301fn map_discovery_error(err: camel_dsl::DiscoveryError) -> CamelError {
302    match err {
303        camel_dsl::DiscoveryError::Env { path, var_name } => CamelError::Config(format!(
304            "{path}: unresolved ${{env:{var_name}}} placeholder \
305             (no layer of the scenario environment defines it)"
306        )),
307        camel_dsl::DiscoveryError::Io { path, source } => {
308            CamelError::Io(format!("{path}: {source}"))
309        }
310        camel_dsl::DiscoveryError::Yaml { path, error } => {
311            CamelError::RouteError(format!("{path}: {error}"))
312        }
313        camel_dsl::DiscoveryError::Json { path, error } => {
314            CamelError::RouteError(format!("{path}: {error}"))
315        }
316        other => CamelError::RouteError(other.to_string()),
317    }
318}