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 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    // Resolve relative `config: <path>` entries relative to the triggers file's
65    // directory. This makes `config: ../pipelines/load.yaml` work regardless of
66    // the process CWD, consistent with `!include`/`extends` path semantics.
67    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
83/// Spawn supervised watcher tasks for every enabled polling trigger. Webhook
84/// triggers need no task (they are served by the route). Returns the join handles
85/// (the caller aborts them on shutdown, like the maintenance/lease loops).
86pub 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        // Pre-emit this trigger's per-trigger series at zero so they exist in
124        // `/metrics` from startup (including webhooks, which spawn no task).
125        metrics::preinit(t.name(), t.kind_label());
126        match &t.spec.kind {
127            spec::TriggerKind::Webhook { .. } => {
128                active += 1; // served by the route, no task
129                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            // Backends not compiled in were already rejected by `compile`, but the
187            // match must be exhaustive when their features are off.
188            #[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
198// Bring the compiled types into the public surface for `server.rs`.
199pub 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        // Create a minimal pipeline file that `CompiledTriggers::compile` won't
212        // reject for being absent (compile only checks the spec, not the file).
213        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        // Triggers file lives in the temp dir and references inner.yaml relatively.
221        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        // The compiled trigger's config path must be absolute (starts with base_dir).
234        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}