mod batch;
mod document;
mod signal;
#[cfg(test)]
mod document_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};
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)]
pub struct JobArgs {
#[arg(value_name = "FILE")]
pub document: Option<PathBuf>,
#[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())),
}
}
#[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),
}
fn jobs_root(
args: &JobArgs,
camel_config: &camel_config::config::CamelConfig,
) -> Result<PathBuf, String> {
crate::commands::run::try_canonical_project_root(Path::new(&args.config))
.map(|root| root.join(&camel_config.jobs.dir))
.map_err(|e| {
format!(
"cannot resolve project root from --config {}: {e}",
args.config
)
})
}
fn resolve_job_path(raw: &Path, jobs_root: &Path) -> 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 probe = jobs_root.join(format!("{name}.job.yaml"));
if probe.exists() {
Ok(probe)
} else {
Err(format!(
"no job `{name}` in `{}` (looked for {name}.job.yaml)",
jobs_root.display()
))
}
}
#[derive(Deserialize)]
struct JobListProbe {
#[serde(default)]
description: Option<String>,
}
fn probe_description(path: &Path) -> Option<Option<String>> {
let text = std::fs::read_to_string(path).ok()?;
let probe: JobListProbe = serde_yaml::from_str(&text).ok()?;
Some(probe.description)
}
fn list_jobs(
_args: &JobArgs,
camel_config: &camel_config::config::CamelConfig,
root: &Path,
) -> i32 {
let dir_label = camel_config.jobs.dir.as_str();
let entries = match std::fs::read_dir(root) {
Ok(entries) => entries,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
println!(
"No jobs found in {dir_label}/. Create a `<name>.job.yaml` there, or run `camel job <path>`."
);
return 0;
}
Err(e) => {
eprintln!("cannot read jobs dir `{}`: {e}", root.display());
return 2;
}
};
let mut jobs: Vec<(String, Option<Option<String>>)> = entries
.filter_map(|entry| entry.ok())
.map(|entry| entry.path())
.filter(|path| path.is_file() && camel_dsl::discovery::is_job_document(path))
.map(|path| {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let display = name
.strip_suffix(".job.yaml")
.or_else(|| name.strip_suffix(".job.yml"))
.unwrap_or(&name)
.to_string();
(display, probe_description(&path))
})
.collect();
jobs.sort_by(|a, b| a.0.cmp(&b.0));
if jobs.is_empty() {
println!(
"No jobs found in {dir_label}/. Create a `<name>.job.yaml` there, or run `camel job <path>`."
);
return 0;
}
println!("Jobs in {dir_label}/:");
for (name, description) in &jobs {
let rendered = match description {
None => "(unparseable)".to_string(),
Some(None) => "(no description)".to_string(),
Some(Some(d)) => d.replace(['\n', '\r'], " "),
};
println!(" {name} {rendered}");
}
0
}
pub async fn run_job(args: &JobArgs) -> i32 {
let signals = args.document.is_some().then(JobSignals::arm);
if signals.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_root = match jobs_root(args, &camel_config) {
Ok(root) => root,
Err(msg) => {
eprintln!("{msg}");
return 2;
}
};
let (Some(raw_document), Some(signals)) = (&args.document, signals) else {
if args.report.is_some() {
eprintln!("--report requires a job document");
return 2;
}
return list_jobs(args, &camel_config, &jobs_root);
};
let resolved = match resolve_job_path(raw_document, &jobs_root) {
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;
}
};
let doc = match document::parse_job_document(&document_path, &text) {
Ok(doc) => doc,
Err(e) => {
eprintln!("{}: {e}", document_path.display());
return 2;
}
};
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: args.args.clone(),
};
execute_job(doc, run, camel_config, Some(signals)).await
}
enum RouteLoad {
Discovery(Vec<String>),
Inline(String),
Embedded { text: String, source_name: String },
}
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(Path::new(source_name), text) {
Ok(doc) => doc,
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
}
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 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 2;
}
};
if defs.is_empty() {
eprintln!("{document_label}: job route source resolved zero route definitions");
return 2;
}
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 2;
}
}
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 2;
}
1 => {}
count => {
eprintln!(
"{document_label}: send target `{}` is ambiguous: {} consumer routes share its base: {}",
doc.execute.send.to,
count,
target_ids.join(", ")
);
return 2;
}
}
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>(
&mut ctx,
&camel_config,
)
{
eprintln!("camel-cli job failed: {e}");
return 2;
}
}
camel_bundles::security_boot::install_sql_startup_checks(&mut 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 2;
}
}
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 2;
}
let deadline = started + doc.execute.timeout;
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
}
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()),
}
}
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> {
match tokio::time::timeout(budget, boot_handle.shutdown_with_deadline(ctx, budget)).await {
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
}
}
}