mod batch;
mod document;
mod help;
mod signal;
#[cfg(test)]
mod document_tests;
#[cfg(test)]
mod help_tests;
#[cfg(test)]
mod tests;
use std::collections::HashSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::{Duration, Instant};
use camel_api::{Body, CamelError, Exchange, Message};
use camel_component_api::NoOpComponentContext;
use clap::Args;
use noyalib::compat::serde_yaml;
use serde::{Deserialize, Serialize};
use tower::ServiceExt;
use document::{JobBody, JobDocument, JobRouteSource};
pub(crate) use document::validate_job_declarations_for_compile;
use signal::{JobSignals, JobWaitOutcome, await_job_operation_or_signal};
const SEND_RETRY_SLEEP: Duration = Duration::from_millis(20);
const SEND_RETRY_WINDOW: Duration = Duration::from_secs(3);
const MIN_SHUTDOWN_BUDGET: Duration = Duration::from_secs(5);
#[derive(Args, Debug)]
#[command(disable_help_flag = true)]
pub struct JobArgs {
#[arg(value_name = "FILE")]
pub document: Option<PathBuf>,
#[arg(long = "help", short = 'h', action = clap::ArgAction::SetTrue)]
pub help: bool,
#[arg(long, value_name = "FILE")]
pub report: Option<PathBuf>,
#[arg(
long,
value_name = "FILE",
default_value = "Camel.toml",
env = "CAMEL_CONFIG_FILE"
)]
pub config: String,
#[arg(
long = "arg",
value_name = "NAME=VALUE",
value_parser = parse_arg_pair
)]
pub args: Vec<(String, String)>,
}
fn parse_arg_pair(raw: &str) -> Result<(String, String), String> {
match raw.split_once('=') {
None => Err(format!("invalid --arg value `{raw}`: expected NAME=VALUE")),
Some(("", _)) => Err(format!("invalid --arg value `{raw}`: name is empty")),
Some((name, value)) => Ok((name.to_string(), value.to_string())),
}
}
const LEGACY_ARG_DEPRECATION: &str = "camel job: --arg header injection on documents \
without an `args:` block is deprecated; declare arguments in a top-level `args:` block instead";
#[derive(Serialize)]
struct JobReport {
document: String,
mode: String,
outcome: &'static str,
terminated_early: bool,
duration_ms: u128,
#[serde(skip_serializing_if = "Option::is_none")]
reply: Option<ReplyReport>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
shutdown_error: Option<String>,
}
#[derive(Serialize)]
struct ReplyReport {
body: serde_json::Value,
headers: serde_json::Map<String, serde_json::Value>,
}
enum SendError {
Pipeline(CamelError),
Transport(String),
}
const LISTING_MAX_DEPTH: usize = 8;
const LISTING_MAX_FILES: usize = 512;
fn jobs_roots(
args: &JobArgs,
camel_config: &camel_config::config::CamelConfig,
) -> Result<Vec<(String, PathBuf)>, String> {
crate::commands::run::try_canonical_project_root(Path::new(&args.config))
.map(|root| {
camel_config
.jobs
.resolved_dirs()
.into_iter()
.map(|label| {
let path = root.join(&label);
(label, path)
})
.collect()
})
.map_err(|e| {
format!(
"cannot resolve project root from --config {}: {e}",
args.config
)
})
}
fn resolve_job_path(raw: &Path, roots: &[(String, PathBuf)]) -> Result<PathBuf, String> {
let name = raw.to_string_lossy();
let lower = name.to_lowercase();
let explicit = raw.components().count() > 1
|| lower.ends_with(".yaml")
|| lower.ends_with(".yml")
|| lower.ends_with(".json");
if explicit {
return Ok(raw.to_path_buf());
}
let probes: Vec<PathBuf> = roots
.iter()
.map(|(_, root)| root.join(format!("{name}.job.yaml")))
.collect();
let matches: Vec<PathBuf> = probes
.iter()
.filter(|probe| probe.exists())
.cloned()
.collect();
match matches.as_slice() {
[] => Err(format!(
"no job `{name}` in any configured root (looked for {})",
probes
.iter()
.map(|probe| probe.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)),
[only] => Ok(only.clone()),
many => Err(format!(
"job `{name}` is ambiguous: matches {} configured roots: {}",
many.len(),
many.iter()
.map(|path| path.display().to_string())
.collect::<Vec<_>>()
.join(", ")
)),
}
}
#[derive(Deserialize)]
struct JobListProbe {
#[serde(default)]
description: Option<String>,
}
fn probe_description_str(text: &str) -> Option<Option<String>> {
let probe: JobListProbe = serde_yaml::from_str(text).ok()?;
Some(probe.description)
}
fn probe_description(path: &Path) -> Option<Option<String>> {
let text = std::fs::read_to_string(path).ok()?;
probe_description_str(&text)
}
fn job_stem(name: &str) -> &str {
name.strip_suffix(".job.yaml")
.or_else(|| name.strip_suffix(".job.yml"))
.unwrap_or(name)
}
struct ListedJob {
display: String,
description: Option<Option<String>>,
}
struct RootScan {
jobs: Vec<ListedJob>,
truncated_at: Option<usize>,
}
fn scan_root(root: &Path) -> std::io::Result<RootScan> {
let mut scan = RootScan {
jobs: Vec::new(),
truncated_at: None,
};
let mut files_seen = 0usize;
walk_level(root, root, 0, &mut scan, &mut files_seen)?;
Ok(scan)
}
fn walk_level(
dir: &Path,
root: &Path,
depth: usize,
scan: &mut RootScan,
files_seen: &mut usize,
) -> std::io::Result<bool> {
let mut cursor: Option<std::ffi::OsString> = None;
loop {
let mut next: Option<(std::ffi::OsString, bool)> = None;
for entry in std::fs::read_dir(dir)? {
let Ok(entry) = entry else {
continue;
};
let Ok(file_type) = entry.file_type() else {
continue;
};
let name = entry.file_name();
if cursor
.as_ref()
.is_some_and(|seen| name.as_os_str() <= seen.as_os_str())
{
continue;
}
let take = match &next {
Some((best, _)) => &name < best,
None => true,
};
if take {
next = Some((name, file_type.is_dir()));
}
}
let Some((name, is_dir)) = next else {
return Ok(true);
};
cursor = Some(name.clone());
let path = dir.join(&name);
if is_dir {
if depth >= LISTING_MAX_DEPTH {
scan.truncated_at.get_or_insert(LISTING_MAX_DEPTH);
continue;
}
if !walk_level(&path, root, depth + 1, scan, files_seen).unwrap_or(true) {
return Ok(false);
}
continue;
}
if !path.is_file() {
continue;
}
*files_seen += 1;
if *files_seen > LISTING_MAX_FILES {
scan.truncated_at.get_or_insert(LISTING_MAX_FILES);
return Ok(false);
}
if !camel_dsl::discovery::is_job_document(&path) {
continue;
}
let name = name.to_string_lossy().into_owned();
let stem = job_stem(&name).to_string();
let display = match path.strip_prefix(root) {
Ok(relative) if relative.components().count() > 1 => {
format!("{}: {}", relative.display(), stem)
}
_ => stem,
};
scan.jobs.push(ListedJob {
display,
description: probe_description(&path),
});
}
}
fn list_jobs(roots: &[(String, PathBuf)]) -> i32 {
let mut exit = 0;
for (label, root) in roots {
let scan = match scan_root(root) {
Ok(scan) => scan,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
println!(
"No jobs found in {label}/. Create a `<name>.job.yaml` there, or run `camel job <path>`."
);
continue;
}
Err(e) => {
eprintln!("cannot read jobs dir `{}`: {e}", root.display());
exit = 2;
continue;
}
};
if let Some(cap) = scan.truncated_at {
eprintln!("camel job: root `{label}` listing truncated at {cap}; narrow [jobs].dirs");
}
if scan.jobs.is_empty() {
println!(
"No jobs found in {label}/. Create a `<name>.job.yaml` there, or run `camel job <path>`."
);
continue;
}
println!("Jobs in {label}/:");
for job in &scan.jobs {
let rendered = match &job.description {
None => "(unparseable)".to_string(),
Some(None) => "(no description)".to_string(),
Some(Some(d)) => d.replace(['\n', '\r'], " "),
};
println!("{} — {rendered}", job.display);
}
}
exit
}
pub async fn run_job(args: &JobArgs) -> i32 {
let signals = (args.document.is_some() && !args.help).then(JobSignals::arm);
if signals.is_some() && std::env::var_os("CAMEL_JOB_SIGNAL_MARKER").is_some() {
eprintln!("camel job: signal streams armed");
}
let started = Instant::now();
let camel_config = match crate::commands::run::load_config_or_default(&args.config) {
Ok(config) => config,
Err(e) => {
eprintln!("camel-cli job failed: {e}");
return 2;
}
};
let jobs_roots = match jobs_roots(args, &camel_config) {
Ok(roots) => roots,
Err(msg) => {
eprintln!("{msg}");
return 2;
}
};
let Some(raw_document) = &args.document else {
if args.help {
print!(
"{}",
JobArgs::augment_args(clap::Command::new("camel job")).render_help()
);
return 0;
}
if args.report.is_some() {
eprintln!("--report requires a job document");
return 2;
}
return list_jobs(&jobs_roots);
};
let resolved = match resolve_job_path(raw_document, &jobs_roots) {
Ok(path) => path,
Err(msg) => {
eprintln!("{msg}");
return 2;
}
};
let document_path = match std::fs::canonicalize(&resolved) {
Ok(path) => path,
Err(e) => {
eprintln!("{}: {e}", resolved.display());
return 2;
}
};
let text = match std::fs::read_to_string(&document_path) {
Ok(text) => text,
Err(e) => {
eprintln!("{}: {e}", document_path.display());
return 2;
}
};
if args.help {
let info = match document::parse_job_document_for_help(&document_path, &text) {
Ok(info) => info,
Err(e) => {
eprintln!("{}: {e}", document_path.display());
return 2;
}
};
let file_name = document_path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| document_path.display().to_string());
let description = probe_description_str(&text);
println!(
"{}",
help::render_job_help(
job_stem(&file_name),
description.flatten().as_deref(),
&info
)
);
return 0;
}
let doc = match document::parse_job_document_with_args(&document_path, &text, &args.args) {
Ok(doc) => doc,
Err(e) => {
eprintln!("{}: {e}", document_path.display());
return 2;
}
};
let legacy_header_args = doc.legacy_arg_headers();
if legacy_header_args && !args.args.is_empty() {
eprintln!("{LEGACY_ARG_DEPRECATION}");
}
let doc_dir = document_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let route_load = match document::resolve_route_source(&doc, &doc_dir) {
Ok(JobRouteSource::Patterns(patterns)) => RouteLoad::Discovery(patterns),
Ok(JobRouteSource::Inline(text)) => RouteLoad::Inline(text),
Err(e) => {
eprintln!("{}: {e}", document_path.display());
return 2;
}
};
let run = JobRun {
label: document_path.display().to_string(),
started,
route_load,
project_root: crate::commands::run::canonical_project_root(Path::new(&args.config)),
report_path: args.report.clone(),
cli_args: if legacy_header_args {
args.args.clone()
} else {
Vec::new()
},
};
execute_job(doc, run, camel_config, signals).await
}
enum RouteLoad {
Discovery(Vec<String>),
Inline(String),
Embedded { text: String, source_name: String },
Discovered(Vec<camel_core::RouteDefinition>),
}
struct JobRun {
label: String,
started: Instant,
route_load: RouteLoad,
project_root: PathBuf,
report_path: Option<PathBuf>,
cli_args: Vec<(String, String)>,
}
pub(crate) async fn run_embedded_job(
source_name: &str,
text: &str,
report: Option<PathBuf>,
) -> i32 {
let started = Instant::now();
let camel_config = match crate::commands::run::in_memory_default_config() {
Ok(config) => config,
Err(e) => {
eprintln!("camel-cli job failed: {e}");
return 2;
}
};
let doc = match document::parse_job_document_with_args(Path::new(source_name), text, &[]) {
Ok(doc) => doc,
Err(document::JobDocError::MissingRequiredArgument { name }) => {
eprintln!(
"compiled://{source_name}: missing required argument `{name}`: compiled \
artifacts cannot accept --arg; declare a `default` for the argument in \
the document instead"
);
return 2;
}
Err(e) => {
eprintln!("compiled://{source_name}: {e}");
return 2;
}
};
let route_load = match document::resolve_route_source(&doc, Path::new(".")) {
Ok(JobRouteSource::Inline(text)) => RouteLoad::Embedded {
text,
source_name: source_name.to_string(),
},
Ok(JobRouteSource::Patterns(_)) => {
eprintln!(
"compiled://{source_name}: embedded job document declares file route \
sources; compiled artifacts reject compile-time route-file assets at \
runtime"
);
return 2;
}
Err(e) => {
eprintln!("compiled://{source_name}: {e}");
return 2;
}
};
let run = JobRun {
label: format!("compiled://{source_name}"),
started,
route_load,
project_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
report_path: report,
cli_args: Vec::new(),
};
execute_job(doc, run, camel_config, None).await
}
fn filter_store_source_plan(
store: &mut crate::compile::store::VirtualDocumentStore,
) -> Option<String> {
use crate::compile::store::StoreEntryKind;
let entry_point = store.index.entry_point.clone();
let unexpected = store
.index
.source_plan
.references
.iter()
.find(|path| {
*path != &entry_point
&& store
.index
.entries
.iter()
.any(|entry| entry.path == **path && entry.kind == StoreEntryKind::Job)
})
.cloned();
if unexpected.is_some() {
return unexpected;
}
store.index.source_plan.references.retain(|path| {
*path != entry_point
&& store
.index
.entries
.iter()
.any(|entry| entry.path == *path && entry.kind == StoreEntryKind::Route)
});
None
}
pub(crate) async fn run_embedded_job_store(
mut store: crate::compile::store::VirtualDocumentStore,
report: Option<PathBuf>,
) -> i32 {
let started = Instant::now();
let entry_point = store.index.entry_point.clone();
let identity = format!("compiled://{entry_point}");
if let Some(path) = filter_store_source_plan(&mut store) {
eprintln!("{identity}: store plan carries an unexpected job document reference: {path}");
return 2;
}
let ambient = |name: &str| std::env::var(name).ok();
let (camel_config, routes) =
match crate::compile::runtime::resolve_virtual_store(&store, &ambient) {
Ok(resolved) => resolved,
Err(crate::compile::runtime::VirtualStoreResolveError::Discovery(e)) => {
eprintln!("{identity}: {e}");
return 2;
}
Err(crate::compile::runtime::VirtualStoreResolveError::Config(e)) => {
eprintln!("camel-cli job failed: {e}");
return 2;
}
};
let text = match store.read_text(&entry_point) {
Some(text) => text.to_string(),
None => {
eprintln!("{identity}: entry point names no store entry");
return 2;
}
};
let doc = match document::parse_job_document_with_args(Path::new(&entry_point), &text, &[]) {
Ok(doc) => doc,
Err(document::JobDocError::MissingRequiredArgument { name }) => {
eprintln!(
"{identity}: missing required argument `{name}`: compiled artifacts cannot accept \
--arg; declare a `default` for the argument in the document instead"
);
return 2;
}
Err(e) => {
eprintln!("{identity}: {e}");
return 2;
}
};
let route_load = if doc.routes.is_some() {
match document::resolve_route_source(&doc, Path::new(".")) {
Ok(JobRouteSource::Inline(text)) => RouteLoad::Embedded {
text,
source_name: entry_point,
},
Ok(JobRouteSource::Patterns(_)) | Err(_) => {
eprintln!("{identity}: job document route source did not resolve inline");
return 2;
}
}
} else {
RouteLoad::Discovered(routes)
};
let run = JobRun {
label: identity,
started,
route_load,
project_root: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
report_path: report,
cli_args: Vec::new(),
};
execute_job(doc, run, camel_config, None).await
}
async fn await_job_operation<T>(
signals: Option<&mut JobSignals>,
operation: impl Future<Output = T>,
) -> JobWaitOutcome<T> {
match signals {
Some(signals) => await_job_operation_or_signal(signals.next(), operation).await,
None => JobWaitOutcome::Completed(operation.await),
}
}
async fn execute_job(
doc: JobDocument,
run: JobRun,
camel_config: camel_config::config::CamelConfig,
mut signals: Option<JobSignals>,
) -> i32 {
let JobRun {
label: document_label,
started,
route_load,
project_root,
report_path,
cli_args,
} = run;
let beans_registry = {
let bean_reg = std::sync::Arc::new(std::sync::Mutex::new(camel_bean::BeanRegistry::new()));
if camel_config.beans.is_empty() {
None
} else {
Some(bean_reg)
}
};
let mut ctx = match camel_config::config::CamelConfig::configure_context_with_beans(
&camel_config,
beans_registry.clone(),
)
.await
{
Ok(ctx) => ctx,
Err(e) => {
eprintln!("camel-cli job failed: {e}");
return 2;
}
};
tracing::warn!(
"camel job trusts the current working directory and will execute route \
scripts and WASM route components resolved from it; only run from a \
trusted directory"
);
match camel_function::FunctionRuntimeService::with_default_container_provider(
camel_function::FunctionConfig::default(),
) {
Ok(svc) => ctx = ctx.with_lifecycle(svc),
Err(e) => tracing::warn!("Function runtime disabled: {e}"),
}
#[cfg(feature = "security")]
let security_compile_context =
match camel_bundles::security_boot::build_security_compile_context_from_config(
&camel_config,
ctx.registry_arc(),
)
.await
{
Ok(context) => context,
Err(e) => {
eprintln!("camel-cli job failed: {e}");
return 2;
}
};
#[cfg(not(feature = "security"))]
let security_compile_context =
match camel_bundles::security_boot::ensure_security_supported(&camel_config) {
Ok(()) => camel_dsl::SecurityCompileContext::default(),
Err(e) => {
eprintln!("camel-cli job failed: {e}");
return 2;
}
};
camel_bundles::security_boot::install_bind_exposure_acks(&mut ctx, &camel_config).await;
let boot_handle = match camel_bundles::boot(&mut ctx, &camel_config, &project_root).await {
Ok(handle) => handle,
Err(e) => {
tracing::error!("Failed to boot component cascade: {e}");
eprintln!("camel-cli job failed: {e}");
return 2;
}
};
let deadline = started + doc.execute.timeout;
let batch_probe = match setup_booted_job(
&mut ctx,
&doc,
&document_label,
route_load,
&security_compile_context,
&camel_config,
)
.await
{
Ok(batch_probe) => batch_probe,
Err(EarlyJobFailure) => {
let budget = shutdown_budget(doc.execute.mode, deadline);
if let Err(detail) = shutdown(&mut ctx, &boot_handle, budget).await {
eprintln!("{detail}");
}
return 2;
}
};
let tokio_deadline = tokio::time::Instant::from_std(deadline);
let send_to = if document::scheme_of_uri(&doc.execute.send.to) == Some("seda") {
document::seda_send_uri(&doc.execute.send.to)
} else {
doc.execute.send.to.clone()
};
let send = send_with_startup_retry(&ctx, &doc.execute.send, &send_to, &cli_args);
let timeout_report = || JobReport {
document: document_label.clone(),
mode: doc.execute.mode.as_str().to_string(),
outcome: "Timeout",
terminated_early: false,
duration_ms: started.elapsed().as_millis(),
reply: None,
error: Some(format!(
"job timed out after {}",
humantime::format_duration(doc.execute.timeout)
)),
shutdown_error: None,
};
let interrupted_report = || JobReport {
document: document_label.clone(),
mode: doc.execute.mode.as_str().to_string(),
outcome: "Interrupted",
terminated_early: false,
duration_ms: started.elapsed().as_millis(),
reply: None,
error: Some("interrupted by signal (SIGINT/SIGTERM)".to_string()),
shutdown_error: None,
};
let mut interrupted = false;
let mut report = {
let operation = tokio::time::timeout_at(tokio_deadline, send);
match await_job_operation(signals.as_mut(), operation).await {
JobWaitOutcome::Signaled => {
interrupted = true;
interrupted_report()
}
JobWaitOutcome::Completed(Err(_)) => timeout_report(),
JobWaitOutcome::Completed(Ok(Err(SendError::Transport(detail)))) => {
tracing::error!("Job send apparatus failure: {detail}");
eprintln!("{detail}");
let transport_budget = shutdown_budget(doc.execute.mode, deadline);
if let Err(shutdown_detail) =
shutdown(&mut ctx, &boot_handle, transport_budget).await
{
eprintln!("{shutdown_detail}");
}
return 2;
}
JobWaitOutcome::Completed(Ok(Err(SendError::Pipeline(e)))) => JobReport {
document: document_label.clone(),
mode: doc.execute.mode.as_str().to_string(),
outcome: "Failed",
terminated_early: false,
duration_ms: started.elapsed().as_millis(),
reply: None,
error: Some(e.to_string()),
shutdown_error: None,
},
JobWaitOutcome::Completed(Ok(Ok(reply))) => {
let drained = match &batch_probe {
Some(probe) => {
probe.reset();
match await_job_operation(
signals.as_mut(),
batch::drain_until_empty(probe, tokio_deadline),
)
.await
{
JobWaitOutcome::Completed(drained) => drained,
JobWaitOutcome::Signaled => {
interrupted = true;
false
}
}
}
None => true,
};
if interrupted {
interrupted_report()
} else if !drained {
timeout_report()
} else {
JobReport {
document: document_label.clone(),
mode: doc.execute.mode.as_str().to_string(),
outcome: "Completed",
terminated_early: false,
duration_ms: started.elapsed().as_millis(),
reply: doc
.execute
.capture_reply
.then(|| reply_report(&reply))
.map(|(body, headers)| ReplyReport { body, headers }),
error: None,
shutdown_error: None,
}
}
}
}
};
if interrupted {
let budget = shutdown_budget(doc.execute.mode, deadline);
let guard = signals
.take()
.map(|signals| tokio::spawn(signals.force_exit()));
let shutdown_result = shutdown(&mut ctx, &boot_handle, budget).await;
if let Some(guard) = guard {
guard.abort();
}
if let Err(detail) = shutdown_result {
eprintln!("{detail}");
record_shutdown_failure(&mut report, detail, budget);
}
if !write_report(report_path.as_deref(), &report) {
return 2;
}
return exit_code_for(report.outcome);
}
let budget = shutdown_budget(doc.execute.mode, deadline);
if let Err(detail) = shutdown(&mut ctx, &boot_handle, budget).await {
eprintln!("{detail}");
record_shutdown_failure(&mut report, detail, budget);
write_report(report_path.as_deref(), &report);
return 2;
}
let code = exit_code_for(report.outcome);
if !write_report(report_path.as_deref(), &report) {
return 2;
}
code
}
struct EarlyJobFailure;
async fn setup_booted_job(
ctx: &mut camel_core::CamelContext,
doc: &JobDocument,
document_label: &str,
route_load: RouteLoad,
security_compile_context: &camel_dsl::SecurityCompileContext,
camel_config: &camel_config::config::CamelConfig,
) -> Result<Option<std::sync::Arc<batch::BatchDepthProbe>>, EarlyJobFailure> {
let defs = match load_route_definitions(route_load, camel_config, security_compile_context) {
Ok(defs) => defs,
Err(e) => {
tracing::error!("Failed to load job routes: {e}");
eprintln!("{e}");
return Err(EarlyJobFailure);
}
};
if defs.is_empty() {
eprintln!("{document_label}: job route source resolved zero route definitions");
return Err(EarlyJobFailure);
}
for def in &defs {
if let Err(e) = document::validate_consumer_uri(def.from_uri()) {
eprintln!("{document_label}: route `{}` rejected: {e}", def.route_id());
return Err(EarlyJobFailure);
}
}
let mut expected_queues = HashSet::new();
for def in &defs {
if document::scheme_of_uri(def.from_uri()) == Some("seda") {
expected_queues.insert(document::uri_base(def.from_uri()).to_string());
}
}
let target_base = document::uri_base(&doc.execute.send.to);
let target_ids = document::target_route_ids(&defs, target_base);
match target_ids.len() {
0 => {
eprintln!(
"{document_label}: send target `{}` has no matching consumer route",
doc.execute.send.to
);
return Err(EarlyJobFailure);
}
1 => {}
count => {
eprintln!(
"{document_label}: send target `{}` is ambiguous: {} consumer routes share its base: {}",
doc.execute.send.to,
count,
target_ids.join(", ")
);
return Err(EarlyJobFailure);
}
}
let defs: Vec<_> = defs
.into_iter()
.map(|def| def.with_auto_startup(true))
.collect();
#[cfg(feature = "exec")]
{
let exec_used =
camel_core::startup_validation::route_definitions_reference_scheme(&defs, "exec");
let exec_configured = camel_config.components.raw.contains_key("exec");
if (exec_used || exec_configured)
&& let Err(e) = camel_bundles::register_bundle::<camel_component_exec::ExecBundle>(
ctx,
camel_config,
)
{
eprintln!("camel-cli job failed: {e}");
return Err(EarlyJobFailure);
}
}
camel_bundles::security_boot::install_sql_startup_checks(ctx, &defs);
for def in defs {
let id = def.route_id().to_string();
if let Err(e) = ctx.add_route_definition(def).await {
tracing::error!("Failed to add route '{id}': {e}");
eprintln!("camel-cli job failed: {e}");
return Err(EarlyJobFailure);
}
}
let batch_probe = match doc.execute.mode {
document::JobMode::Batch => {
let probe = std::sync::Arc::new(batch::BatchDepthProbe::new(expected_queues));
ctx.add_lifecycle(batch::BatchProbeLifecycle(std::sync::Arc::clone(&probe)));
Some(probe)
}
document::JobMode::OneShot => None,
};
if let Err(e) = ctx.start().await {
tracing::error!("Failed to start CamelContext: {e}");
eprintln!("camel-cli job failed: {e}");
return Err(EarlyJobFailure);
}
Ok(batch_probe)
}
fn load_route_definitions(
load: RouteLoad,
camel_config: &camel_config::config::CamelConfig,
security_compile_context: &camel_dsl::SecurityCompileContext,
) -> Result<Vec<camel_core::RouteDefinition>, String> {
let ambient = &|name: &str| std::env::var(name).ok();
match load {
RouteLoad::Discovery(patterns) => camel_dsl::discover_routes_with_threshold_and_security(
&patterns,
camel_config.stream_caching.threshold,
security_compile_context.clone(),
)
.map_err(|e| e.to_string()),
RouteLoad::Inline(text) => match camel_dsl::parse_routes_with_env(&text, ambient) {
Ok(defs) => Ok(defs),
Err(camel_dsl::RoutesEnvError::Unresolved(var)) => Err(format!(
"Environment variable '{var}' not set (required by inline routes)"
)),
Err(camel_dsl::RoutesEnvError::Parse(e)) => Err(format!("inline routes: {e}")),
},
RouteLoad::Embedded { text, source_name } => camel_dsl::discover_embedded_text(
&text,
&source_name,
camel_dsl::EmbeddedDocumentKind::Job,
ambient,
)
.map_err(|e| e.to_string()),
RouteLoad::Discovered(defs) => Ok(defs),
}
}
async fn send_with_startup_retry(
ctx: &camel_core::CamelContext,
send: &document::JobSendAction,
send_to: &str,
cli_args: &[(String, String)],
) -> Result<Exchange, SendError> {
let body = match &send.body {
Some(JobBody::Text(s)) => Body::Text(s.clone()),
Some(JobBody::Json(v)) => Body::Json(v.clone()),
None => Body::Empty,
};
let mut message = Message::new(body);
if let Some(headers) = &send.headers {
for (k, v) in headers {
message.set_header(k.clone(), v.clone());
}
}
for (k, v) in cli_args {
message.set_header(k.clone(), serde_json::Value::String(v.clone()));
}
let exchange = Exchange::new(message);
let scheme = document::scheme_of_uri(send_to)
.unwrap_or_default()
.to_string();
let retry_until = Instant::now() + SEND_RETRY_WINDOW;
loop {
match attempt_send(ctx, &scheme, send_to, exchange.clone()).await {
Ok(Ok(reply)) => return Ok(reply),
Ok(Err(e)) => {
let retryable = camel_component_seda::is_no_active_consumers_gate(&e)
|| matches!(e, CamelError::EndpointCreationFailed(_))
|| e.to_string().contains("not registered");
if retryable && Instant::now() < retry_until {
tokio::time::sleep(SEND_RETRY_SLEEP).await;
continue;
}
return Err(SendError::Pipeline(e));
}
Err(detail) => {
if Instant::now() < retry_until {
tokio::time::sleep(SEND_RETRY_SLEEP).await;
continue;
}
return Err(SendError::Transport(detail));
}
}
}
}
async fn attempt_send(
ctx: &camel_core::CamelContext,
scheme: &str,
uri: &str,
exchange: Exchange,
) -> Result<Result<Exchange, CamelError>, String> {
let producer = {
let registry = ctx.registry();
let component = registry.get(scheme).ok_or_else(|| {
format!("failed to send to {uri}: `{scheme}:` component not registered")
})?;
let endpoint = component
.create_endpoint(uri, ctx)
.map_err(|e| format!("failed to create endpoint {uri}: {e}"))?;
let producer_ctx = ctx.producer_context();
endpoint
.create_producer(std::sync::Arc::new(NoOpComponentContext), &producer_ctx)
.map_err(|e| format!("failed to create producer for {uri}: {e}"))?
};
Ok(producer.oneshot(exchange).await)
}
fn reply_report(
reply: &Exchange,
) -> (
serde_json::Value,
serde_json::Map<String, serde_json::Value>,
) {
let message = reply.output.as_ref().unwrap_or(&reply.input);
let body = match &message.body {
Body::Json(value) => value.clone(),
body => body
.as_text()
.map(|s| serde_json::Value::String(s.to_string()))
.unwrap_or(serde_json::Value::Null),
};
let headers = message
.headers
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
(body, headers)
}
fn shutdown_budget(mode: document::JobMode, deadline: Instant) -> Duration {
let remaining = deadline.saturating_duration_since(Instant::now());
match mode {
document::JobMode::Batch => remaining,
document::JobMode::OneShot => remaining.max(MIN_SHUTDOWN_BUDGET),
}
}
fn exit_code_for(outcome: &str) -> i32 {
match outcome {
"Completed" => 0,
"Failed" => 1,
_ => 2,
}
}
fn record_shutdown_failure(report: &mut JobReport, detail: String, budget: Duration) {
if budget > Duration::ZERO {
report.shutdown_error = Some(detail);
}
}
async fn shutdown(
ctx: &mut camel_core::CamelContext,
boot_handle: &camel_bundles::BootHandle,
budget: Duration,
) -> Result<(), String> {
let result =
tokio::time::timeout(budget, boot_handle.shutdown_with_deadline(ctx, budget)).await;
if std::env::var_os("CAMEL_JOB_SHUTDOWN_MARKER").is_some() {
eprintln!("camel job: shutdown complete");
}
match result {
Ok(Ok(())) => Ok(()),
Ok(Err(e)) => Err(format!("shutdown failure: {e}")),
Err(_) => Err(format!(
"drain timeout: job teardown exceeded {}",
humantime::format_duration(budget)
)),
}
}
fn write_report(report_path: Option<&Path>, report: &JobReport) -> bool {
let rendered = match serde_json::to_string_pretty(report) {
Ok(text) => text,
Err(e) => {
eprintln!("failed to render job report: {e}");
return false;
}
};
match report_path {
Some(path) => match std::fs::write(path, format!("{rendered}\n")) {
Ok(()) => true,
Err(e) => {
eprintln!("failed to write {}: {e}", path.display());
false
}
},
None => {
println!("{rendered}");
true
}
}
}