mod common;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Output, Stdio};
use std::sync::{Arc, Mutex, OnceLock};
use std::thread;
use std::time::{Duration, Instant};
use camel_cli::compile::runtime::{ArtifactArgs, EmbeddedRequest};
use camel_cli::compile::trailer;
use common::{KillOnDrop, drain_to_buffer, send_signal};
const CHILD_ENV: &str = "CAMEL_COMPILED_ARTIFACT_CHILD";
const ROUTE_DOC: &str = "\
routes:
- id: demo
from: timer:tick?period=300
steps:
- to: log:demo
";
const JOB_DOC: &str = "\
execute:
mode: one-shot
timeout: 60s
capture-reply: true
send:
to: direct:transform
body: ping
routes:
- id: job-transform
from: direct:transform
steps:
- set_body:
value: job-done
";
const FAILING_JOB_DOC: &str = "\
execute:
mode: one-shot
timeout: 60s
send:
to: direct:boom
routes:
- id: job-fail
from: direct:boom
steps:
- to: direct:missing-consumer
";
const ENV_DOC: &str = "\
execute:
mode: one-shot
timeout: 60s
capture-reply: true
send:
to: direct:transform
body: ping
routes:
- id: job-transform
from: direct:transform
steps:
- set_body:
value: ${env:DEPLOY_GREETING}
";
fn compile(dir: &Path, doc: &str, artifact: &str, envs: &[(&str, &str)]) -> Output {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_camel"));
cmd.env_clear()
.envs(envs.iter().copied())
.current_dir(dir)
.args(["compile", doc, "-o", artifact]);
cmd.output().expect("spawn `camel compile`")
}
struct Fixture {
route: PathBuf,
job: PathBuf,
failing_job: PathBuf,
env: PathBuf,
}
static FIXTURE: OnceLock<Fixture> = OnceLock::new();
fn fixture_dir() -> PathBuf {
std::env::temp_dir().join(format!("camel-compiled-fixture-{}", std::process::id()))
}
fn spawn_reaper(dir: &Path) {
let dir = dir.to_string_lossy().into_owned();
let pid = std::process::id().to_string();
let _ = Command::new("sh")
.arg("-c")
.arg(format!(
"while kill -0 {pid} 2>/dev/null; do sleep 1; done; rm -rf -- '{dir}'"
))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
fn sweep_stale_fixtures() {
let Ok(entries) = std::fs::read_dir(std::env::temp_dir()) else {
return;
};
for entry in entries.flatten() {
let file_name = entry.file_name();
let Some(name) = file_name.to_str() else {
continue;
};
let Some(pid_str) = name.strip_prefix("camel-compiled-fixture-") else {
continue;
};
let Ok(pid) = pid_str.parse::<u32>() else {
continue;
};
if pid == std::process::id() {
continue;
}
let alive = Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !alive {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
fn fixture() -> &'static Fixture {
FIXTURE.get_or_init(|| {
sweep_stale_fixtures();
let dir = fixture_dir();
if dir.exists() {
std::fs::remove_dir_all(&dir).expect("remove stale fixture dir");
}
std::fs::create_dir_all(&dir).expect("create fixture dir");
spawn_reaper(&dir);
let compile_one =
|doc_name: &str, doc: &str, artifact: &str, envs: &[(&str, &str)]| -> PathBuf {
std::fs::write(dir.join(doc_name), doc).expect("write document");
let output = compile(&dir, doc_name, artifact, envs);
assert_eq!(
output.status.code(),
Some(0),
"document must compile: {}",
String::from_utf8_lossy(&output.stderr)
);
dir.join(artifact)
};
let route = compile_one("app.yaml", ROUTE_DOC, "route.bin", &[]);
let job = compile_one("ingest.job.yaml", JOB_DOC, "job.bin", &[]);
let failing_job = compile_one("fail.job.yaml", FAILING_JOB_DOC, "fail.bin", &[]);
let env = compile_one(
"greet.job.yaml",
ENV_DOC,
"env.bin",
&[("DEPLOY_GREETING", "compile-secret-value")],
);
Fixture {
route,
job,
failing_job,
env,
}
})
}
fn deploy_artifact(artifact: &Path) -> (tempfile::TempDir, PathBuf) {
let deploy_dir = tempfile::tempdir().expect("deploy tempdir");
let target = deploy_dir.path().join("app.bin");
if std::fs::hard_link(artifact, &target).is_err() {
std::fs::copy(artifact, &target).expect("copy artifact");
}
(deploy_dir, target)
}
fn run_child() -> i32 {
let artifact = std::env::var(CHILD_ENV).expect("child env names the artifact");
let argv: Vec<String> = std::env::args().skip_while(|a| a != "--").skip(1).collect();
let bytes = std::fs::read(&artifact).expect("child reads the artifact");
let decoded = trailer::decode(&bytes)
.expect("trailer must decode")
.expect("artifact must carry the terminal marker");
let args = match ArtifactArgs::parse(&argv) {
Ok(args) => args,
Err(e) => {
eprintln!("{e}");
return 2;
}
};
let request = EmbeddedRequest::from_trailer(decoded, args).expect("embedded request");
tokio::runtime::Runtime::new()
.expect("tokio runtime")
.block_on(async { camel_cli::compile::runtime::run_embedded_document_code(request).await })
}
fn child_guard() {
if std::env::var(CHILD_ENV).is_ok() {
std::process::exit(run_child());
}
}
fn spawn_child_output(
test: &str,
dir: &Path,
artifact: &Path,
argv: &[&str],
envs: &[(&str, &str)],
) -> (i32, String, String) {
let mut cmd = Command::new(std::env::current_exe().expect("current test exe"));
cmd.env(CHILD_ENV, artifact)
.envs(envs.iter().copied())
.current_dir(dir)
.args(["--exact", test, "--nocapture", "--"])
.args(argv)
.stdin(Stdio::null())
.output()
.expect("spawn harness child (to completion)")
.into_code_and_strings()
}
trait CodeAndStrings {
fn into_code_and_strings(self) -> (i32, String, String);
}
impl CodeAndStrings for std::process::Output {
fn into_code_and_strings(self) -> (i32, String, String) {
(
self.status.code().unwrap_or(-1),
String::from_utf8_lossy(&self.stdout).into_owned(),
String::from_utf8_lossy(&self.stderr).into_owned(),
)
}
}
fn spawn_child(
test: &str,
dir: &Path,
artifact: &Path,
argv: &[&str],
envs: &[(&str, &str)],
) -> KillOnDrop {
let mut cmd = Command::new(std::env::current_exe().expect("current test exe"));
cmd.env(CHILD_ENV, artifact)
.envs(envs.iter().copied())
.current_dir(dir)
.args(["--exact", test, "--nocapture", "--"])
.args(argv)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
KillOnDrop(cmd.spawn().expect("spawn harness child"))
}
struct Drained {
out: Arc<Mutex<String>>,
err: Arc<Mutex<String>>,
}
impl Drained {
fn captured(&self) -> String {
format!(
"stdout:\n{}\nstderr:\n{}",
self.out.lock().expect("stdout lock").clone(),
self.err.lock().expect("stderr lock").clone()
)
}
}
fn spawn_drained(child: &mut Child) -> Drained {
let out = Arc::new(Mutex::new(String::new()));
let err = Arc::new(Mutex::new(String::new()));
let stdout = child.stdout.take().expect("child stdout piped");
let stderr = child.stderr.take().expect("child stderr piped");
thread::spawn({
let buf = Arc::clone(&out);
move || drain_to_buffer(stdout, buf)
});
thread::spawn({
let buf = Arc::clone(&err);
move || drain_to_buffer(stderr, buf)
});
Drained { out, err }
}
fn wait_for_marker(drained: &Drained, marker: &str, timeout: Duration) -> bool {
let start = Instant::now();
loop {
if drained.out.lock().expect("stdout lock").contains(marker)
|| drained.err.lock().expect("stderr lock").contains(marker)
{
return true;
}
if start.elapsed() >= timeout {
return false;
}
thread::sleep(Duration::from_millis(20));
}
}
fn wait_exit_code(child: &mut KillOnDrop, timeout: Duration) -> i32 {
let start = Instant::now();
loop {
match child.0.try_wait() {
Ok(Some(status)) => return status.code().unwrap_or(-1),
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.0.kill();
let _ = child.0.wait();
return -1;
}
thread::sleep(Duration::from_millis(25));
}
Err(e) => panic!("try_wait failed: {e}"),
}
}
}
fn graceful_shutdown(child: &mut KillOnDrop, drained: &Drained, test: &str) -> i32 {
assert!(
wait_for_marker(drained, "context started", Duration::from_secs(60)),
"artifact must boot through the embedded document: {}",
drained.captured()
);
send_signal(&child.0, "-TERM");
let code = wait_exit_code(child, Duration::from_secs(30));
assert_eq!(code, 0, "SIGTERM must shut down gracefully: {}", test);
code
}
#[test]
fn compiled_route_runs_without_source_tree() {
child_guard();
let (deploy, artifact) = deploy_artifact(&fixture().route);
assert!(!deploy.path().join("app.yaml").exists(), "no source doc");
assert!(!deploy.path().join("Camel.toml").exists(), "no config");
let mut child = spawn_child(
"compiled_route_runs_without_source_tree",
deploy.path(),
&artifact,
&[],
&[],
);
let drained = spawn_drained(&mut child);
graceful_shutdown(
&mut child,
&drained,
"compiled_route_runs_without_source_tree",
);
}
#[test]
fn compiled_job_uses_existing_outcome_report() {
child_guard();
let (deploy, artifact) = deploy_artifact(&fixture().job);
let (code, stdout, stderr) = spawn_child_output(
"compiled_job_uses_existing_outcome_report",
deploy.path(),
&artifact,
&["--report", "report.json"],
&[],
);
assert_eq!(
code, 0,
"completed job must exit 0;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let report: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(deploy.path().join("report.json"))
.expect("job report must be written"),
)
.expect("job report is JSON");
assert_eq!(report["outcome"], "Completed", "report: {report}");
assert_eq!(
report["document"], "compiled://ingest.job.yaml",
"report: {report}"
);
assert_eq!(report["mode"], "one-shot", "report: {report}");
assert_eq!(report["terminated_early"], false, "report: {report}");
assert_eq!(report["reply"]["body"], "job-done", "report: {report}");
let (deploy, artifact) = deploy_artifact(&fixture().failing_job);
let (code, stdout, stderr) = spawn_child_output(
"compiled_job_uses_existing_outcome_report",
deploy.path(),
&artifact,
&["--report", "fail-report.json"],
&[],
);
assert_eq!(
code, 1,
"pipeline failure must exit 1;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let report: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(deploy.path().join("fail-report.json"))
.expect("failed job report must be written"),
)
.expect("job report is JSON");
assert_eq!(report["outcome"], "Failed", "report: {report}");
assert!(
report["error"].as_str().is_some_and(|e| !e.is_empty()),
"report: {report}"
);
}
#[test]
fn compiled_artifact_resolves_deploy_environment() {
child_guard();
let (deploy, artifact) = deploy_artifact(&fixture().env);
let artifact_bytes = std::fs::read(&artifact).expect("artifact exists");
assert!(
artifact_bytes
.windows(b"${env:DEPLOY_GREETING}".len())
.any(|w| w == b"${env:DEPLOY_GREETING}"),
"artifact must keep the env expression"
);
assert!(
!artifact_bytes
.windows(b"compile-secret-value".len())
.any(|w| w == b"compile-secret-value"),
"artifact must not embed the compile-time value"
);
let (code, stdout, stderr) = spawn_child_output(
"compiled_artifact_resolves_deploy_environment",
deploy.path(),
&artifact,
&["--report", "env-report.json"],
&[("DEPLOY_GREETING", "deploy-value")],
);
assert_eq!(
code, 0,
"job must complete;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let report: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(deploy.path().join("env-report.json")).expect("report written"),
)
.expect("report is JSON");
assert_eq!(
report["reply"]["body"], "deploy-value",
"route must observe the deployment value: {report}"
);
}
#[test]
fn compiled_artifact_does_not_extract_or_watch() {
child_guard();
let (deploy, artifact) = deploy_artifact(&fixture().route);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(deploy.path(), std::fs::Permissions::from_mode(0o555))
.expect("chmod read-only");
}
let mut child = spawn_child(
"compiled_artifact_does_not_extract_or_watch",
deploy.path(),
&artifact,
&[],
&[],
);
let drained = spawn_drained(&mut child);
assert!(
wait_for_marker(&drained, "context started", Duration::from_secs(60)),
"artifact must boot on a read-only root: {}",
drained.captured()
);
let all_output = format!(
"{}{}",
drained.out.lock().expect("stdout lock"),
drained.err.lock().expect("stderr lock")
);
assert!(
!all_output.contains("hot-reload watching"),
"the watcher must never activate: {all_output}"
);
send_signal(&child.0, "-TERM");
let code = wait_exit_code(&mut child, Duration::from_secs(30));
assert_eq!(code, 0, "graceful shutdown on read-only root");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(deploy.path(), std::fs::Permissions::from_mode(0o755))
.expect("restore writable for cleanup");
}
let mut entries: Vec<String> = std::fs::read_dir(deploy.path())
.expect("read deploy dir")
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
entries.sort();
assert_eq!(
entries,
vec!["app.bin".to_string()],
"no extraction or other writes: {entries:?}"
);
}
#[test]
fn compiled_route_report_writes_status_json() {
child_guard();
let (deploy, artifact) = deploy_artifact(&fixture().route);
let mut child = spawn_child(
"compiled_route_report_writes_status_json",
deploy.path(),
&artifact,
&["--report", "status.json"],
&[],
);
let drained = spawn_drained(&mut child);
graceful_shutdown(
&mut child,
&drained,
"compiled_route_report_writes_status_json",
);
let report = std::fs::read_to_string(deploy.path().join("status.json"))
.expect("route status report must be written");
assert_eq!(
report.trim(),
r#"{"kind":"route","status":"completed","error":null}"#,
"exact RouteReport JSON"
);
}
#[cfg(unix)]
fn make_executable(path: &Path) {
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755))
.expect("chmod executable");
}
#[test]
fn trailer_free_binary_keeps_normal_cli() {
let camel = PathBuf::from(env!("CARGO_BIN_EXE_camel"));
let dir = tempfile::tempdir().expect("tempdir");
let (code, stdout, stderr) = common::run_binary(dir.path(), &camel, &["--version"], &[]);
assert_eq!(
code, 0,
"plain `--version` exits 0;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.trim().starts_with("camel "),
"Clap version output: {stdout}"
);
let (code, stdout, stderr) = common::run_binary(dir.path(), &camel, &["--watch"], &[]);
assert_eq!(
code, 2,
"unknown flag is Clap misuse;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stderr.starts_with("error:"),
"Clap error fingerprint: {stderr}"
);
}
#[test]
fn artifact_manifest_exits_without_boot() {
let (deploy, artifact) = deploy_artifact(&fixture().route);
let (code, stdout, stderr) = common::run_binary(deploy.path(), &artifact, &["--manifest"], &[]);
assert_eq!(
code, 0,
"--manifest exits 0;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let manifest: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("stdout is manifest JSON");
assert_eq!(manifest["kind"], "route", "manifest: {manifest}");
assert_eq!(manifest["source_name"], "app.yaml", "manifest: {manifest}");
assert_eq!(
manifest["runtime_version"],
camel_cli::compile::manifest::RUNTIME_VERSION,
"manifest: {manifest}"
);
assert!(
manifest["components"]
.as_array()
.is_some_and(|c| c.iter().any(|s| s.as_str() == Some("timer"))),
"embedded components listed: {manifest}"
);
assert!(
manifest["env_names"].as_array().is_some(),
"required env names listed: {manifest}"
);
assert!(
manifest["listeners"].as_array().is_some(),
"listener declarations listed: {manifest}"
);
let all = format!("{stdout}{stderr}");
assert!(!all.contains("context started"), "no route boot: {all}");
}
#[test]
fn artifact_rejects_unknown_and_positional_args() {
let (deploy, artifact) = deploy_artifact(&fixture().route);
let cases: &[(&[&str], &str)] = &[
(&["--help", "--version"], "--version"),
(&["--report", "a.json", "--report", "b.json"], "--report"),
(&["--report"], "--report"),
(&["--watch"], "--watch"),
(&["routes.yaml"], "routes.yaml"),
];
for (argv, named) in cases {
let (code, stdout, stderr) = common::run_binary(deploy.path(), &artifact, argv, &[]);
assert_eq!(
code, 2,
"argv {argv:?} must exit 2;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains(named),
"argv {argv:?} must name the rejected argument: {combined}"
);
assert!(!combined.contains("context started"), "no boot: {combined}");
}
}
#[test]
fn artifact_rejects_marked_corruption() {
let (deploy, artifact) = deploy_artifact(&fixture().route);
let valid = std::fs::read(&artifact).expect("artifact bytes");
let mut corrupt_data = valid.clone();
let data_end = corrupt_data.len() - trailer::FOOTER_LEN;
corrupt_data[data_end - 1] ^= 0xFF;
let mut corrupt_footer = valid.clone();
corrupt_footer[data_end + 28] ^= 0xFF;
for (name, bytes) in [("data", corrupt_data), ("footer", corrupt_footer)] {
let path = deploy.path().join(format!("corrupt-{name}.bin"));
std::fs::write(&path, bytes).expect("write corrupt artifact");
#[cfg(unix)]
make_executable(&path);
let (code, stdout, stderr) = common::run_binary(deploy.path(), &path, &[], &[]);
assert_eq!(
code, 2,
"corrupt {name} must fail closed;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
let combined = format!("{stdout}{stderr}");
assert!(
combined.contains("integrity error"),
"corrupt {name} must carry an integrity diagnostic: {combined}"
);
assert!(!combined.contains("context started"), "no boot: {combined}");
}
}
#[test]
fn artifact_truncated_without_marker_keeps_clap_fallback() {
let (deploy, artifact) = deploy_artifact(&fixture().route);
let mut bytes = std::fs::read(&artifact).expect("artifact bytes");
bytes.truncate(bytes.len() - trailer::MAGIC.len());
assert_eq!(
trailer::decode(&bytes),
Ok(None),
"truncation must remove the marker"
);
let path = deploy.path().join("truncated.bin");
std::fs::write(&path, bytes).expect("write truncated artifact");
#[cfg(unix)]
make_executable(&path);
let (code, stdout, stderr) = common::run_binary(deploy.path(), &path, &["--watch"], &[]);
assert_eq!(
code, 2,
"Clap misuse exits 2;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stderr.starts_with("error:"),
"unchanged Clap fallback: {stderr}"
);
}
#[test]
fn artifact_help_and_version_exit_zero() {
let (deploy, artifact) = deploy_artifact(&fixture().route);
let (code, stdout, stderr) = common::run_binary(deploy.path(), &artifact, &["--help"], &[]);
assert_eq!(
code, 0,
"--help exits 0;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert!(
stdout.contains("camel compiled artifact usage"),
"artifact usage text: {stdout}"
);
let (code, stdout, stderr) = common::run_binary(deploy.path(), &artifact, &["--version"], &[]);
assert_eq!(
code, 0,
"--version exits 0;\nstdout:\n{stdout}\nstderr:\n{stderr}"
);
assert_eq!(
stdout.trim(),
format!("camel {}", camel_cli::compile::manifest::RUNTIME_VERSION),
"artifact version line"
);
for stream in [&stdout, &stderr] {
assert!(!stream.contains("context started"), "no boot: {stream}");
}
}