Skip to main content

faucet_cli/serve/triggers/
compiled.rs

1//! Validated form of a [`TriggersFile`]. `compile` surfaces every problem at
2//! startup (unique names, webhook-path collisions, resolvable pipeline ref,
3//! interval/threshold bounds, missing backend feature) so a watcher never fails
4//! mid-run from a config mistake. Pure (no IO except reading a path's existence,
5//! which is done by the caller; here we only validate shapes).
6//!
7//! Name charset: trigger names must match `^[A-Za-z0-9_-]+$`; they are embedded
8//! verbatim into the webhook route `/v1/triggers/{name}`, so whitespace or slashes
9//! would silently break routing.
10
11use super::spec::{PipelineRef, QueueSpec, StoreSpec, TriggerKind, TriggerSpec, TriggersFile};
12use std::collections::HashSet;
13
14#[derive(Debug)]
15pub struct CompiledTriggers {
16    pub triggers: Vec<CompiledTrigger>,
17}
18
19#[derive(Debug, Clone)]
20pub struct CompiledTrigger {
21    pub spec: TriggerSpec,
22    /// For webhook triggers: the route path `/v1/triggers/{name}`.
23    pub webhook_path: Option<String>,
24}
25
26impl CompiledTrigger {
27    pub fn name(&self) -> &str {
28        &self.spec.name
29    }
30    pub fn kind_label(&self) -> &'static str {
31        match self.spec.kind {
32            TriggerKind::ObjectArrival { .. } => "object_arrival",
33            TriggerKind::Webhook { .. } => "webhook",
34            TriggerKind::QueueDepth { .. } => "queue_depth",
35        }
36    }
37}
38
39impl CompiledTriggers {
40    /// Validate a parsed file. `err` strings are user-facing.
41    pub fn compile(file: TriggersFile) -> Result<Self, String> {
42        if file.version != 1 {
43            return Err(format!(
44                "triggers: unsupported version {} (expected 1)",
45                file.version
46            ));
47        }
48        let mut names = HashSet::new();
49        let mut compiled = Vec::with_capacity(file.triggers.len());
50
51        for t in file.triggers {
52            if t.name.trim().is_empty() {
53                return Err("triggers: a trigger has an empty `name`".into());
54            }
55            if !t
56                .name
57                .chars()
58                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
59            {
60                return Err(format!(
61                    "triggers: invalid trigger name '{}' (letters, digits, '_' and '-' only)",
62                    t.name
63                ));
64            }
65            if !names.insert(t.name.clone()) {
66                return Err(format!("triggers: duplicate trigger name '{}'", t.name));
67            }
68            // Pipeline ref must be present and non-empty.
69            match &t.config {
70                PipelineRef::Path(p) if p.trim().is_empty() => {
71                    return Err(format!("triggers: '{}' has an empty config path", t.name));
72                }
73                PipelineRef::Inline(v) if !v.is_object() => {
74                    return Err(format!(
75                        "triggers: '{}' inline config must be a mapping",
76                        t.name
77                    ));
78                }
79                _ => {}
80            }
81
82            let webhook_path = match &t.kind {
83                TriggerKind::ObjectArrival {
84                    poll_interval_secs,
85                    store,
86                    ..
87                } => {
88                    if *poll_interval_secs == 0 {
89                        return Err(format!(
90                            "triggers: '{}' poll_interval_secs must be >= 1",
91                            t.name
92                        ));
93                    }
94                    require_feature(&t.name, store_feature(store))?;
95                    validate_store(&t.name, store)?;
96                    None
97                }
98                TriggerKind::Webhook { methods, .. } => {
99                    if methods.is_empty() {
100                        return Err(format!("triggers: '{}' methods must not be empty", t.name));
101                    }
102                    for m in methods {
103                        let mu = m.to_ascii_uppercase();
104                        if mu != "POST" && mu != "PUT" {
105                            return Err(format!(
106                                "triggers: '{}' unsupported webhook method '{}' (POST|PUT)",
107                                t.name, m
108                            ));
109                        }
110                    }
111                    // Paths are unique because trigger names are already deduplicated above.
112                    let path = format!("/v1/triggers/{}", t.name);
113                    Some(path)
114                }
115                TriggerKind::QueueDepth {
116                    poll_interval_secs,
117                    queue,
118                    threshold,
119                } => {
120                    if *poll_interval_secs == 0 {
121                        return Err(format!(
122                            "triggers: '{}' poll_interval_secs must be >= 1",
123                            t.name
124                        ));
125                    }
126                    if *threshold == 0 {
127                        return Err(format!("triggers: '{}' threshold must be >= 1", t.name));
128                    }
129                    require_feature(&t.name, queue_feature(queue))?;
130                    validate_queue(&t.name, queue)?;
131                    None
132                }
133            };
134
135            compiled.push(CompiledTrigger {
136                spec: t,
137                webhook_path,
138            });
139        }
140        Ok(Self { triggers: compiled })
141    }
142
143    pub fn webhooks(&self) -> impl Iterator<Item = &CompiledTrigger> {
144        self.triggers.iter().filter(|t| t.webhook_path.is_some())
145    }
146}
147
148fn store_feature(store: &StoreSpec) -> &'static str {
149    match store {
150        StoreSpec::S3 { .. } | StoreSpec::Gcs { .. } => "triggers-object-store",
151    }
152}
153
154fn queue_feature(queue: &QueueSpec) -> &'static str {
155    match queue {
156        QueueSpec::Redis { .. } => "triggers-redis",
157        QueueSpec::Kafka { .. } => "triggers-kafka",
158    }
159}
160
161/// Validates that non-optional string fields in a `StoreSpec` are non-empty.
162fn validate_store(name: &str, store: &StoreSpec) -> Result<(), String> {
163    match store {
164        StoreSpec::S3 { bucket, .. } => {
165            if bucket.trim().is_empty() {
166                return Err(format!("triggers: '{name}' store bucket must not be empty"));
167            }
168        }
169        StoreSpec::Gcs { bucket, .. } => {
170            if bucket.trim().is_empty() {
171                return Err(format!("triggers: '{name}' store bucket must not be empty"));
172            }
173        }
174    }
175    Ok(())
176}
177
178/// Validates that non-optional string fields in a `QueueSpec` are non-empty.
179fn validate_queue(name: &str, queue: &QueueSpec) -> Result<(), String> {
180    match queue {
181        QueueSpec::Redis { url, key, .. } => {
182            if url.trim().is_empty() {
183                return Err(format!("triggers: '{name}' queue url must not be empty"));
184            }
185            if key.trim().is_empty() {
186                return Err(format!("triggers: '{name}' queue key must not be empty"));
187            }
188        }
189        QueueSpec::Kafka {
190            brokers,
191            topic,
192            group,
193        } => {
194            if brokers.trim().is_empty() {
195                return Err(format!(
196                    "triggers: '{name}' queue brokers must not be empty"
197                ));
198            }
199            if topic.trim().is_empty() {
200                return Err(format!("triggers: '{name}' queue topic must not be empty"));
201            }
202            if group.trim().is_empty() {
203                return Err(format!("triggers: '{name}' queue group must not be empty"));
204            }
205        }
206    }
207    Ok(())
208}
209
210/// Returns Ok if the named feature is compiled in; else a clear error naming it.
211fn require_feature(trigger: &str, feature: &str) -> Result<(), String> {
212    let compiled = match feature {
213        "triggers" => cfg!(feature = "triggers"),
214        "triggers-object-store" => cfg!(feature = "triggers-object-store"),
215        "triggers-redis" => cfg!(feature = "triggers-redis"),
216        "triggers-kafka" => cfg!(feature = "triggers-kafka"),
217        _ => true,
218    };
219    if compiled {
220        Ok(())
221    } else {
222        Err(format!(
223            "triggers: '{trigger}' requires a build with the `{feature}` feature"
224        ))
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    fn file(yaml: &str) -> TriggersFile {
233        serde_yaml::from_str(yaml).unwrap()
234    }
235
236    #[test]
237    fn rejects_duplicate_names() {
238        let f = file(
239            "version: 1\ntriggers:\n  - { name: a, type: webhook, config: ./x.yaml }\n  - { name: a, type: webhook, config: ./y.yaml }\n",
240        );
241        let err = CompiledTriggers::compile(f).unwrap_err();
242        assert!(err.contains("duplicate trigger name 'a'"), "{err}");
243    }
244
245    #[test]
246    fn rejects_bad_version() {
247        let f = file("version: 2\ntriggers: []\n");
248        assert!(
249            CompiledTriggers::compile(f)
250                .unwrap_err()
251                .contains("version")
252        );
253    }
254
255    #[test]
256    fn webhook_gets_path_and_collisions_rejected() {
257        // Two webhooks with distinct names → distinct paths, both compile.
258        let f = file(
259            "version: 1\ntriggers:\n  - { name: a, type: webhook, config: ./x.yaml }\n  - { name: b, type: webhook, config: ./y.yaml }\n",
260        );
261        let c = CompiledTriggers::compile(f).unwrap();
262        assert_eq!(c.webhooks().count(), 2);
263        assert_eq!(
264            c.triggers[0].webhook_path.as_deref(),
265            Some("/v1/triggers/a")
266        );
267        assert_eq!(
268            c.triggers[1].webhook_path.as_deref(),
269            Some("/v1/triggers/b")
270        );
271    }
272
273    #[test]
274    fn rejects_zero_threshold() {
275        let f = file(
276            "version: 1\ntriggers:\n  - name: q\n    type: queue_depth\n    config: ./x.yaml\n    threshold: 0\n    queue: { type: redis, url: \"redis://x\", key: k }\n",
277        );
278        // Only assert the threshold check when the redis backend is compiled;
279        // otherwise the missing-feature error fires first (also acceptable).
280        let err = CompiledTriggers::compile(f).unwrap_err();
281        assert!(
282            err.contains("threshold must be >= 1") || err.contains("triggers-redis"),
283            "{err}"
284        );
285    }
286
287    #[test]
288    fn rejects_empty_webhook_methods() {
289        let f = file(
290            "version: 1\ntriggers:\n  - name: hook\n    type: webhook\n    config: ./x.yaml\n    methods: []\n",
291        );
292        let err = CompiledTriggers::compile(f).unwrap_err();
293        assert!(err.contains("methods must not be empty"), "{err}");
294    }
295
296    #[test]
297    fn rejects_empty_bucket() {
298        let f = file(
299            "version: 1\ntriggers:\n  - name: obj\n    type: object_arrival\n    config: ./x.yaml\n    store: { type: s3, bucket: \"\" }\n",
300        );
301        let err = CompiledTriggers::compile(f).unwrap_err();
302        assert!(
303            err.contains("store bucket must not be empty") || err.contains("triggers-object-store"),
304            "{err}"
305        );
306    }
307
308    #[test]
309    fn rejects_empty_redis_url() {
310        let f = file(
311            "version: 1\ntriggers:\n  - name: q\n    type: queue_depth\n    config: ./x.yaml\n    queue: { type: redis, url: \"\", key: k }\n",
312        );
313        let err = CompiledTriggers::compile(f).unwrap_err();
314        assert!(
315            err.contains("queue url must not be empty") || err.contains("triggers-redis"),
316            "{err}"
317        );
318    }
319
320    #[test]
321    fn rejects_name_with_slash() {
322        let f = file(
323            "version: 1\ntriggers:\n  - { name: \"foo/bar\", type: webhook, config: ./x.yaml }\n",
324        );
325        let err = CompiledTriggers::compile(f).unwrap_err();
326        assert!(err.contains("invalid trigger name 'foo/bar'"), "{err}");
327    }
328}