pub mod compiled;
pub mod context;
pub mod enqueue;
pub mod health;
pub mod metrics;
pub mod spec;
pub mod watcher;
pub mod webhook;
#[cfg(feature = "triggers-object-store")]
pub mod object_arrival;
#[cfg(any(feature = "triggers-redis", feature = "triggers-kafka"))]
pub mod queue_depth;
use crate::error::{CliError, CliResult};
use crate::serve::state::ServerState;
#[allow(unused_imports)]
use compiled::{CompiledTrigger, CompiledTriggers};
#[cfg(any(
feature = "triggers-object-store",
feature = "triggers-redis",
feature = "triggers-kafka"
))]
use std::sync::Arc;
#[cfg(any(
feature = "triggers-object-store",
feature = "triggers-redis",
feature = "triggers-kafka"
))]
use std::time::Duration;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
pub async fn load_triggers(path: &std::path::Path) -> CliResult<CompiledTriggers> {
let text = tokio::fs::read_to_string(path)
.await
.map_err(|e| CliError::Serve(format!("reading triggers file {}: {e}", path.display())))?;
let is_json = path
.extension()
.map(|e| e.eq_ignore_ascii_case("json"))
.unwrap_or(false);
let mut file: spec::TriggersFile = if is_json {
serde_json::from_str(&text)
.map_err(|e| CliError::Serve(format!("parsing triggers JSON: {e}")))?
} else {
serde_yaml::from_str(&text)
.map_err(|e| CliError::Serve(format!("parsing triggers YAML: {e}")))?
};
let raw: serde_json::Value = if is_json {
serde_json::from_str(&text)
.map_err(|e| CliError::Serve(format!("parsing triggers JSON: {e}")))?
} else {
serde_yaml::from_str(&text)
.map_err(|e| CliError::Serve(format!("parsing triggers YAML: {e}")))?
};
if let Some((trigger, field)) = spec::unknown_trigger_fields(&raw, &file).into_iter().next() {
return Err(CliError::Serve(format!(
"triggers: unknown field `{field}` in trigger `{trigger}` \
(check for a typo; run `faucet schema triggers` for the valid fields)"
)));
}
if let Some(base_dir) = path.parent() {
for trigger in &mut file.triggers {
if let spec::PipelineRef::Path(ref p) = trigger.config {
let p_path = std::path::Path::new(p);
if p_path.is_relative() {
let resolved = base_dir.join(p_path);
trigger.config =
spec::PipelineRef::Path(resolved.to_string_lossy().into_owned());
}
}
}
}
CompiledTriggers::compile(file).map_err(CliError::Serve)
}
pub fn spawn_watchers(
state: ServerState,
compiled: &CompiledTriggers,
#[cfg_attr(
not(any(
feature = "triggers-object-store",
feature = "triggers-redis",
feature = "triggers-kafka"
)),
allow(unused_variables)
)]
shutdown: CancellationToken,
) -> Vec<JoinHandle<()>> {
#[cfg_attr(
not(any(
feature = "triggers-object-store",
feature = "triggers-redis",
feature = "triggers-kafka"
)),
allow(unused_mut)
)]
let mut handles = Vec::new();
#[cfg_attr(
not(any(
feature = "triggers-object-store",
feature = "triggers-redis",
feature = "triggers-kafka"
)),
allow(unused_variables)
)]
let health = state.triggers().clone();
let mut active = 0usize;
for t in &compiled.triggers {
if !t.spec.enabled {
tracing::info!(trigger = t.name(), "trigger disabled; not spawning");
continue;
}
metrics::preinit(t.name(), t.kind_label());
match &t.spec.kind {
spec::TriggerKind::Webhook { .. } => {
active += 1; tracing::info!(trigger = t.name(), path = ?t.webhook_path, "webhook trigger registered");
}
#[cfg(feature = "triggers-object-store")]
spec::TriggerKind::ObjectArrival {
store,
poll_interval_secs,
mode,
start_at,
} => match object_arrival::ObjectArrivalWatcher::build_store(store) {
Ok((s, bucket, prefix)) => {
let w = object_arrival::ObjectArrivalWatcher::new(
Arc::new(t.clone()),
s,
bucket,
prefix,
*mode,
Duration::from_secs(*poll_interval_secs),
*start_at,
chrono::Utc::now(),
);
handles.push(tokio::spawn(watcher::run_supervised(
w,
state.clone(),
health.clone(),
shutdown.clone(),
)));
active += 1;
}
Err(e) => {
tracing::error!(trigger = t.name(), error = %e, "failed to build object store; skipping watcher")
}
},
#[cfg(any(feature = "triggers-redis", feature = "triggers-kafka"))]
spec::TriggerKind::QueueDepth {
queue,
threshold,
poll_interval_secs,
} => match queue_depth::build_probe(queue) {
Ok(probe) => {
let w = queue_depth::QueueDepthWatcher::new(
Arc::new(t.clone()),
probe,
*threshold,
Duration::from_secs(*poll_interval_secs),
);
handles.push(tokio::spawn(watcher::run_supervised(
w,
state.clone(),
health.clone(),
shutdown.clone(),
)));
active += 1;
}
Err(e) => {
tracing::error!(trigger = t.name(), error = %e, "failed to build queue probe; skipping watcher")
}
},
#[cfg(not(feature = "triggers-object-store"))]
spec::TriggerKind::ObjectArrival { .. } => {}
#[cfg(not(any(feature = "triggers-redis", feature = "triggers-kafka")))]
spec::TriggerKind::QueueDepth { .. } => {}
}
}
metrics::active(active);
handles
}
pub use compiled::CompiledTriggers as Compiled;
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[tokio::test]
async fn load_triggers_resolves_relative_config_path() {
let dir = tempfile::tempdir().expect("tempdir");
let base = dir.path();
let pipeline_path = base.join("inner.yaml");
std::fs::write(
&pipeline_path,
"version: 1\npipeline:\n source:\n type: rest\n config: {url: 'http://x'}\n sink:\n type: stdout\n config: {}\n",
)
.unwrap();
let triggers_path = base.join("triggers.yaml");
let yaml = "version: 1\ntriggers:\n - name: t1\n type: webhook\n config: ./inner.yaml\n methods: [POST]\n".to_string();
{
let mut f = std::fs::File::create(&triggers_path).unwrap();
f.write_all(yaml.as_bytes()).unwrap();
}
let compiled = load_triggers(&triggers_path)
.await
.expect("load_triggers failed");
assert_eq!(compiled.triggers.len(), 1);
match &compiled.triggers[0].spec.config {
crate::serve::triggers::spec::PipelineRef::Path(p) => {
let abs = std::path::Path::new(p);
assert!(abs.is_absolute(), "expected absolute path, got: {p}");
assert!(
abs.starts_with(base),
"expected path under temp dir {}, got: {p}",
base.display()
);
}
_ => panic!("expected PipelineRef::Path"),
}
}
#[tokio::test]
async fn load_triggers_rejects_unknown_field() {
let dir = tempfile::tempdir().expect("tempdir");
let triggers_path = dir.path().join("triggers.yaml");
let yaml = "version: 1\ntriggers:\n - name: hook\n type: webhook\n config: ./inner.yaml\n methods: [POST]\n debounce_sec: 5\n";
std::fs::write(&triggers_path, yaml).unwrap();
let err = load_triggers(&triggers_path)
.await
.expect_err("expected unknown-field rejection");
let msg = format!("{err}");
assert!(
msg.contains("debounce_sec"),
"error must name the field: {msg}"
);
assert!(msg.contains("hook"), "error must name the trigger: {msg}");
}
}