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 is_json = path
53 .extension()
54 .map(|e| e.eq_ignore_ascii_case("json"))
55 .unwrap_or(false);
56 let mut file: spec::TriggersFile = if is_json {
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 let raw: serde_json::Value = if is_json {
69 serde_json::from_str(&text)
70 .map_err(|e| CliError::Serve(format!("parsing triggers JSON: {e}")))?
71 } else {
72 serde_yaml::from_str(&text)
73 .map_err(|e| CliError::Serve(format!("parsing triggers YAML: {e}")))?
74 };
75 if let Some((trigger, field)) = spec::unknown_trigger_fields(&raw, &file).into_iter().next() {
76 return Err(CliError::Serve(format!(
77 "triggers: unknown field `{field}` in trigger `{trigger}` \
78 (check for a typo; run `faucet schema triggers` for the valid fields)"
79 )));
80 }
81
82 if let Some(base_dir) = path.parent() {
86 for trigger in &mut file.triggers {
87 if let spec::PipelineRef::Path(ref p) = trigger.config {
88 let p_path = std::path::Path::new(p);
89 if p_path.is_relative() {
90 let resolved = base_dir.join(p_path);
91 trigger.config =
92 spec::PipelineRef::Path(resolved.to_string_lossy().into_owned());
93 }
94 }
95 }
96 }
97
98 CompiledTriggers::compile(file).map_err(CliError::Serve)
99}
100
101pub fn spawn_watchers(
105 state: ServerState,
106 compiled: &CompiledTriggers,
107 #[cfg_attr(
108 not(any(
109 feature = "triggers-object-store",
110 feature = "triggers-redis",
111 feature = "triggers-kafka"
112 )),
113 allow(unused_variables)
114 )]
115 shutdown: CancellationToken,
116) -> Vec<JoinHandle<()>> {
117 #[cfg_attr(
118 not(any(
119 feature = "triggers-object-store",
120 feature = "triggers-redis",
121 feature = "triggers-kafka"
122 )),
123 allow(unused_mut)
124 )]
125 let mut handles = Vec::new();
126 #[cfg_attr(
127 not(any(
128 feature = "triggers-object-store",
129 feature = "triggers-redis",
130 feature = "triggers-kafka"
131 )),
132 allow(unused_variables)
133 )]
134 let health = state.triggers().clone();
135 let mut active = 0usize;
136 for t in &compiled.triggers {
137 if !t.spec.enabled {
138 tracing::info!(trigger = t.name(), "trigger disabled; not spawning");
139 continue;
140 }
141 metrics::preinit(t.name(), t.kind_label());
144 match &t.spec.kind {
145 spec::TriggerKind::Webhook { .. } => {
146 active += 1; tracing::info!(trigger = t.name(), path = ?t.webhook_path, "webhook trigger registered");
148 }
149 #[cfg(feature = "triggers-object-store")]
150 spec::TriggerKind::ObjectArrival {
151 store,
152 poll_interval_secs,
153 mode,
154 start_at,
155 } => match object_arrival::ObjectArrivalWatcher::build_store(store) {
156 Ok((s, bucket, prefix)) => {
157 let w = object_arrival::ObjectArrivalWatcher::new(
158 Arc::new(t.clone()),
159 s,
160 bucket,
161 prefix,
162 *mode,
163 Duration::from_secs(*poll_interval_secs),
164 *start_at,
165 chrono::Utc::now(),
166 );
167 handles.push(tokio::spawn(watcher::run_supervised(
168 w,
169 state.clone(),
170 health.clone(),
171 shutdown.clone(),
172 )));
173 active += 1;
174 }
175 Err(e) => {
176 tracing::error!(trigger = t.name(), error = %e, "failed to build object store; skipping watcher")
177 }
178 },
179 #[cfg(any(feature = "triggers-redis", feature = "triggers-kafka"))]
180 spec::TriggerKind::QueueDepth {
181 queue,
182 threshold,
183 poll_interval_secs,
184 } => match queue_depth::build_probe(queue) {
185 Ok(probe) => {
186 let w = queue_depth::QueueDepthWatcher::new(
187 Arc::new(t.clone()),
188 probe,
189 *threshold,
190 Duration::from_secs(*poll_interval_secs),
191 );
192 handles.push(tokio::spawn(watcher::run_supervised(
193 w,
194 state.clone(),
195 health.clone(),
196 shutdown.clone(),
197 )));
198 active += 1;
199 }
200 Err(e) => {
201 tracing::error!(trigger = t.name(), error = %e, "failed to build queue probe; skipping watcher")
202 }
203 },
204 #[cfg(not(feature = "triggers-object-store"))]
207 spec::TriggerKind::ObjectArrival { .. } => {}
208 #[cfg(not(any(feature = "triggers-redis", feature = "triggers-kafka")))]
209 spec::TriggerKind::QueueDepth { .. } => {}
210 }
211 }
212 metrics::active(active);
213 handles
214}
215
216pub use compiled::CompiledTriggers as Compiled;
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use std::io::Write;
223
224 #[tokio::test]
225 async fn load_triggers_resolves_relative_config_path() {
226 let dir = tempfile::tempdir().expect("tempdir");
227 let base = dir.path();
228
229 let pipeline_path = base.join("inner.yaml");
232 std::fs::write(
233 &pipeline_path,
234 "version: 1\npipeline:\n source:\n type: rest\n config: {url: 'http://x'}\n sink:\n type: stdout\n config: {}\n",
235 )
236 .unwrap();
237
238 let triggers_path = base.join("triggers.yaml");
240 let yaml = "version: 1\ntriggers:\n - name: t1\n type: webhook\n config: ./inner.yaml\n methods: [POST]\n".to_string();
241 {
242 let mut f = std::fs::File::create(&triggers_path).unwrap();
243 f.write_all(yaml.as_bytes()).unwrap();
244 }
245
246 let compiled = load_triggers(&triggers_path)
247 .await
248 .expect("load_triggers failed");
249 assert_eq!(compiled.triggers.len(), 1);
250
251 match &compiled.triggers[0].spec.config {
253 crate::serve::triggers::spec::PipelineRef::Path(p) => {
254 let abs = std::path::Path::new(p);
255 assert!(abs.is_absolute(), "expected absolute path, got: {p}");
256 assert!(
257 abs.starts_with(base),
258 "expected path under temp dir {}, got: {p}",
259 base.display()
260 );
261 }
262 _ => panic!("expected PipelineRef::Path"),
263 }
264 }
265
266 #[tokio::test]
267 async fn load_triggers_rejects_unknown_field() {
268 let dir = tempfile::tempdir().expect("tempdir");
269 let triggers_path = dir.path().join("triggers.yaml");
270 let yaml = "version: 1\ntriggers:\n - name: hook\n type: webhook\n config: ./inner.yaml\n methods: [POST]\n debounce_sec: 5\n";
273 std::fs::write(&triggers_path, yaml).unwrap();
274
275 let err = load_triggers(&triggers_path)
276 .await
277 .expect_err("expected unknown-field rejection");
278 let msg = format!("{err}");
279 assert!(
280 msg.contains("debounce_sec"),
281 "error must name the field: {msg}"
282 );
283 assert!(msg.contains("hook"), "error must name the trigger: {msg}");
284 }
285}