faucet_cli/serve/triggers/
mod.rs1pub mod compiled;
10pub mod context;
11pub mod enqueue;
12pub mod health;
13pub mod metrics;
14pub mod spec;
15pub mod watcher;
16pub mod webhook;
17
18#[cfg(feature = "triggers-object-store")]
19pub mod object_arrival;
20#[cfg(any(feature = "triggers-redis", feature = "triggers-kafka"))]
21pub mod queue_depth;
22
23use crate::error::{CliError, CliResult};
24use crate::serve::state::ServerState;
25#[allow(unused_imports)]
26use compiled::{CompiledTrigger, CompiledTriggers};
27#[cfg(any(
28 feature = "triggers-object-store",
29 feature = "triggers-redis",
30 feature = "triggers-kafka"
31))]
32use std::sync::Arc;
33#[cfg(any(
34 feature = "triggers-object-store",
35 feature = "triggers-redis",
36 feature = "triggers-kafka"
37))]
38use std::time::Duration;
39use tokio::task::JoinHandle;
40use tokio_util::sync::CancellationToken;
41
42pub async fn load_triggers(path: &std::path::Path) -> CliResult<CompiledTriggers> {
49 let text = tokio::fs::read_to_string(path)
50 .await
51 .map_err(|e| CliError::Serve(format!("reading triggers file {}: {e}", path.display())))?;
52 let mut file: spec::TriggersFile = if path
53 .extension()
54 .map(|e| e.eq_ignore_ascii_case("json"))
55 .unwrap_or(false)
56 {
57 serde_json::from_str(&text)
58 .map_err(|e| CliError::Serve(format!("parsing triggers JSON: {e}")))?
59 } else {
60 serde_yaml::from_str(&text)
61 .map_err(|e| CliError::Serve(format!("parsing triggers YAML: {e}")))?
62 };
63
64 if let Some(base_dir) = path.parent() {
68 for trigger in &mut file.triggers {
69 if let spec::PipelineRef::Path(ref p) = trigger.config {
70 let p_path = std::path::Path::new(p);
71 if p_path.is_relative() {
72 let resolved = base_dir.join(p_path);
73 trigger.config =
74 spec::PipelineRef::Path(resolved.to_string_lossy().into_owned());
75 }
76 }
77 }
78 }
79
80 CompiledTriggers::compile(file).map_err(CliError::Serve)
81}
82
83pub fn spawn_watchers(
87 state: ServerState,
88 compiled: &CompiledTriggers,
89 #[cfg_attr(
90 not(any(
91 feature = "triggers-object-store",
92 feature = "triggers-redis",
93 feature = "triggers-kafka"
94 )),
95 allow(unused_variables)
96 )]
97 shutdown: CancellationToken,
98) -> Vec<JoinHandle<()>> {
99 #[cfg_attr(
100 not(any(
101 feature = "triggers-object-store",
102 feature = "triggers-redis",
103 feature = "triggers-kafka"
104 )),
105 allow(unused_mut)
106 )]
107 let mut handles = Vec::new();
108 #[cfg_attr(
109 not(any(
110 feature = "triggers-object-store",
111 feature = "triggers-redis",
112 feature = "triggers-kafka"
113 )),
114 allow(unused_variables)
115 )]
116 let health = state.triggers().clone();
117 let mut active = 0usize;
118 for t in &compiled.triggers {
119 if !t.spec.enabled {
120 tracing::info!(trigger = t.name(), "trigger disabled; not spawning");
121 continue;
122 }
123 metrics::preinit(t.name(), t.kind_label());
126 match &t.spec.kind {
127 spec::TriggerKind::Webhook { .. } => {
128 active += 1; tracing::info!(trigger = t.name(), path = ?t.webhook_path, "webhook trigger registered");
130 }
131 #[cfg(feature = "triggers-object-store")]
132 spec::TriggerKind::ObjectArrival {
133 store,
134 poll_interval_secs,
135 mode,
136 start_at,
137 } => match object_arrival::ObjectArrivalWatcher::build_store(store) {
138 Ok((s, bucket, prefix)) => {
139 let w = object_arrival::ObjectArrivalWatcher::new(
140 Arc::new(t.clone()),
141 s,
142 bucket,
143 prefix,
144 *mode,
145 Duration::from_secs(*poll_interval_secs),
146 *start_at,
147 chrono::Utc::now(),
148 );
149 handles.push(tokio::spawn(watcher::run_supervised(
150 w,
151 state.clone(),
152 health.clone(),
153 shutdown.clone(),
154 )));
155 active += 1;
156 }
157 Err(e) => {
158 tracing::error!(trigger = t.name(), error = %e, "failed to build object store; skipping watcher")
159 }
160 },
161 #[cfg(any(feature = "triggers-redis", feature = "triggers-kafka"))]
162 spec::TriggerKind::QueueDepth {
163 queue,
164 threshold,
165 poll_interval_secs,
166 } => match queue_depth::build_probe(queue) {
167 Ok(probe) => {
168 let w = queue_depth::QueueDepthWatcher::new(
169 Arc::new(t.clone()),
170 probe,
171 *threshold,
172 Duration::from_secs(*poll_interval_secs),
173 );
174 handles.push(tokio::spawn(watcher::run_supervised(
175 w,
176 state.clone(),
177 health.clone(),
178 shutdown.clone(),
179 )));
180 active += 1;
181 }
182 Err(e) => {
183 tracing::error!(trigger = t.name(), error = %e, "failed to build queue probe; skipping watcher")
184 }
185 },
186 #[cfg(not(feature = "triggers-object-store"))]
189 spec::TriggerKind::ObjectArrival { .. } => {}
190 #[cfg(not(any(feature = "triggers-redis", feature = "triggers-kafka")))]
191 spec::TriggerKind::QueueDepth { .. } => {}
192 }
193 }
194 metrics::active(active);
195 handles
196}
197
198pub use compiled::CompiledTriggers as Compiled;
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204 use std::io::Write;
205
206 #[tokio::test]
207 async fn load_triggers_resolves_relative_config_path() {
208 let dir = tempfile::tempdir().expect("tempdir");
209 let base = dir.path();
210
211 let pipeline_path = base.join("inner.yaml");
214 std::fs::write(
215 &pipeline_path,
216 "version: 1\npipeline:\n source:\n type: rest\n config: {url: 'http://x'}\n sink:\n type: stdout\n config: {}\n",
217 )
218 .unwrap();
219
220 let triggers_path = base.join("triggers.yaml");
222 let yaml = "version: 1\ntriggers:\n - name: t1\n type: webhook\n config: ./inner.yaml\n methods: [POST]\n".to_string();
223 {
224 let mut f = std::fs::File::create(&triggers_path).unwrap();
225 f.write_all(yaml.as_bytes()).unwrap();
226 }
227
228 let compiled = load_triggers(&triggers_path)
229 .await
230 .expect("load_triggers failed");
231 assert_eq!(compiled.triggers.len(), 1);
232
233 match &compiled.triggers[0].spec.config {
235 crate::serve::triggers::spec::PipelineRef::Path(p) => {
236 let abs = std::path::Path::new(p);
237 assert!(abs.is_absolute(), "expected absolute path, got: {p}");
238 assert!(
239 abs.starts_with(base),
240 "expected path under temp dir {}, got: {p}",
241 base.display()
242 );
243 }
244 _ => panic!("expected PipelineRef::Path"),
245 }
246 }
247}