Skip to main content

faucet_cli/serve/triggers/
mod.rs

1//! Event-driven pipeline triggers for `faucet serve` (#196).
2//!
3//! A static `--triggers <file>` defines watchers (object-arrival / webhook /
4//! queue-depth) that, on fire, enqueue a run via [`crate::serve::runner::submit`]
5//! — reusing the whole queue/executor/idempotency pipeline. Pure decision logic
6//! (spec validation, `${trigger.*}` substitution, cursors, edge detection) is
7//! separated from the IO shell (watchers, fire path, webhook route).
8
9pub 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
42/// Load + validate a triggers file. Surfaces a clear `CliError::Serve` on any
43/// parse/validation failure (fail-fast at startup).
44///
45/// Relative `config:` paths in the parsed triggers are resolved relative to the
46/// triggers file's parent directory (not the process CWD), matching the
47/// behaviour of `!include`/`extends` in pipeline configs.
48pub 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    // Reject unknown fields on a trigger entry. `TriggerSpec` cannot use
65    // `deny_unknown_fields` because it carries a `#[serde(flatten)]` kind, so a
66    // typo like `debounce_sec` (for `debounce_secs`) would otherwise be silently
67    // dropped (#232). Diff the raw document against the typed re-serialization.
68    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    // Resolve relative `config: <path>` entries relative to the triggers file's
83    // directory. This makes `config: ../pipelines/load.yaml` work regardless of
84    // the process CWD, consistent with `!include`/`extends` path semantics.
85    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
101/// Spawn supervised watcher tasks for every enabled polling trigger. Webhook
102/// triggers need no task (they are served by the route). Returns the join handles
103/// (the caller aborts them on shutdown, like the maintenance/lease loops).
104pub 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        // Pre-emit this trigger's per-trigger series at zero so they exist in
142        // `/metrics` from startup (including webhooks, which spawn no task).
143        metrics::preinit(t.name(), t.kind_label());
144        match &t.spec.kind {
145            spec::TriggerKind::Webhook { .. } => {
146                active += 1; // served by the route, no task
147                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            // Backends not compiled in were already rejected by `compile`, but the
205            // match must be exhaustive when their features are off.
206            #[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
216// Bring the compiled types into the public surface for `server.rs`.
217pub 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        // Create a minimal pipeline file that `CompiledTriggers::compile` won't
230        // reject for being absent (compile only checks the spec, not the file).
231        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        // Triggers file lives in the temp dir and references inner.yaml relatively.
239        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        // The compiled trigger's config path must be absolute (starts with base_dir).
252        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        // `debounce_sec` is a typo for `debounce_secs` — a flatten-bearing
271        // `TriggerSpec` would silently drop it without the explicit check.
272        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}