faucet_cli/serve/triggers/
compiled.rs1use 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 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 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 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 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
161fn 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
178fn 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
210fn 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 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 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}