mod batch;
mod document;
#[cfg(test)]
mod document_tests;
use std::collections::HashSet;
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};
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 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) = &args.document 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 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 project_root = crate::commands::run::canonical_project_root(Path::new(&args.config));
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(&doc, &doc_dir, &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!(
"{}: job route source resolved zero route definitions",
document_path.display()
);
return 2;
}
for def in &defs {
if let Err(e) = document::validate_consumer_uri(def.from_uri()) {
eprintln!(
"{}: route `{}` rejected: {e}",
document_path.display(),
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!(
"{}: send target `{}` has no matching consumer route",
document_path.display(),
doc.execute.send.to
);
return 2;
}
1 => {}
count => {
eprintln!(
"{}: send target `{}` is ambiguous: {} consumer routes share its base: {}",
document_path.display(),
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, &args.args);
let timeout_report = || JobReport {
document: document_path.display().to_string(),
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 mut report = match tokio::time::timeout_at(tokio_deadline, send).await {
Err(_) => timeout_report(),
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;
}
Ok(Err(SendError::Pipeline(e))) => JobReport {
document: document_path.display().to_string(),
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,
},
Ok(Ok(reply)) => {
let drained = match &batch_probe {
Some(probe) => {
probe.reset();
batch::drain_until_empty(probe, tokio_deadline).await
}
None => true,
};
if !drained {
timeout_report()
} else {
JobReport {
document: document_path.display().to_string(),
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,
}
}
}
};
let budget = shutdown_budget(doc.execute.mode, deadline);
if let Err(detail) = shutdown(&mut ctx, &boot_handle, budget).await {
eprintln!("{detail}");
if budget > Duration::ZERO {
report.shutdown_error = Some(detail);
}
write_report(args, &report);
return 2;
}
let code = match report.outcome {
"Completed" => 0,
"Failed" => 1,
_ => 2,
};
if !write_report(args, &report) {
return 2;
}
code
}
fn load_route_definitions(
doc: &JobDocument,
doc_dir: &Path,
camel_config: &camel_config::config::CamelConfig,
security_compile_context: &camel_dsl::SecurityCompileContext,
) -> Result<Vec<camel_core::RouteDefinition>, String> {
match document::resolve_route_source(doc, doc_dir).map_err(|e| e.to_string())? {
JobRouteSource::Patterns(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())
}
JobRouteSource::Inline(text) => {
let ambient = &|name: &str| std::env::var(name).ok();
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}")),
}
}
}
}
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),
}
}
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)
)),
}
}
#[cfg(test)]
mod report_tests {
use super::JobReport;
#[test]
fn shutdown_error_serializes_alongside_error() {
let report = JobReport {
document: "doc".to_string(),
mode: "one-shot".to_string(),
outcome: "Failed",
terminated_early: false,
duration_ms: 1,
reply: None,
error: Some("pipeline failed".to_string()),
shutdown_error: Some("shutdown failure: x".to_string()),
};
let json = serde_json::to_string(&report).expect("report must serialize");
assert!(
json.contains("pipeline failed"),
"verdict error must serialize: {json}"
);
assert!(
json.contains("shutdown failure: x"),
"shutdown detail must serialize: {json}"
);
}
#[test]
fn shutdown_error_omitted_when_absent() {
let report = JobReport {
document: "doc".to_string(),
mode: "one-shot".to_string(),
outcome: "Failed",
terminated_early: false,
duration_ms: 1,
reply: None,
error: Some("pipeline failed".to_string()),
shutdown_error: None,
};
let json = serde_json::to_string(&report).expect("report must serialize");
assert!(
!json.contains("shutdown_error"),
"absent shutdown_error must be omitted: {json}"
);
}
}
#[cfg(test)]
mod shutdown_budget_tests {
use super::{MIN_SHUTDOWN_BUDGET, shutdown_budget};
use crate::commands::job::document::JobMode;
use std::time::{Duration, Instant};
#[test]
fn shutdown_budget_batch_is_remaining() {
let deadline = Instant::now() + Duration::from_secs(3);
let budget = shutdown_budget(JobMode::Batch, deadline);
assert!(
budget <= Duration::from_secs(3) && budget > Duration::from_secs(2),
"expected ~3s remaining, got {budget:?}"
);
}
#[test]
fn shutdown_budget_batch_zero_when_past() {
let deadline = Instant::now() - Duration::from_secs(1);
assert_eq!(shutdown_budget(JobMode::Batch, deadline), Duration::ZERO);
}
#[test]
fn shutdown_budget_one_shot_floored() {
let deadline = Instant::now() - Duration::from_secs(1);
assert_eq!(
shutdown_budget(JobMode::OneShot, deadline),
MIN_SHUTDOWN_BUDGET
);
}
#[test]
fn shutdown_budget_one_shot_is_remaining_when_large() {
let deadline = Instant::now() + Duration::from_secs(10);
let budget = shutdown_budget(JobMode::OneShot, deadline);
assert!(
budget <= Duration::from_secs(10) && budget > MIN_SHUTDOWN_BUDGET,
"expected ~10s remaining, got {budget:?}"
);
}
}
fn write_report(args: &JobArgs, 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 &args.report {
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
}
}
}