1use async_trait::async_trait;
14
15use crate::blob_notify::{ManagedNotification, ManagedResource, ProvisionTier};
16
17#[derive(Debug, Clone, thiserror::Error)]
19pub enum ProvisionError {
20 #[error("notification provisioning: {0}")]
22 Backend(String),
23 #[error("no notification pipeline is configured")]
25 NotConfigured,
26 #[error("notification conflict: {0}")]
29 Conflict(String),
30}
31
32#[async_trait]
36pub trait WatchProvider: Send + Sync {
37 fn name(&self) -> &str;
39
40 fn recipe(&self, prefix: &str) -> String;
43
44 async fn provision(&self, prefix: &str) -> Result<Vec<ManagedResource>, ProvisionError>;
47
48 async fn verify(&self, prefix: &str) -> Result<bool, ProvisionError>;
51
52 async fn retract(&self, resources: &[ManagedResource]) -> Result<(), ProvisionError>;
55}
56
57#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum ProvisionOutcome {
60 Ready,
62 Recipe(String),
64 Refused(String),
67}
68
69pub async fn ensure_watch(
74 provider: &dyn WatchProvider,
75 tier: ProvisionTier,
76 function: &str,
77 prefix: &str,
78 ledger: &dyn LedgerSink,
79 now_unix: u64,
80) -> Result<ProvisionOutcome, ProvisionError> {
81 match tier {
82 ProvisionTier::DryRun => Ok(ProvisionOutcome::Recipe(provider.recipe(prefix))),
83 ProvisionTier::Refuse => Ok(ProvisionOutcome::Refused(
84 "blob-change triggers refuse without a notification pipeline (set a provisioning tier)"
85 .to_string(),
86 )),
87 ProvisionTier::VerifyOnly => {
88 if provider.verify(prefix).await? {
89 Ok(ProvisionOutcome::Ready)
90 } else {
91 Ok(ProvisionOutcome::Refused(
92 "verify-only: no pipeline is configured for this prefix".to_string(),
93 ))
94 }
95 }
96 ProvisionTier::Provision => {
97 let resources = provider.provision(prefix).await?;
98 let record =
99 ManagedNotification::new(function, prefix, provider.name(), resources, now_unix);
100 ledger.put(&record).await?;
101 Ok(ProvisionOutcome::Ready)
102 }
103 }
104}
105
106pub async fn retract_watch(
109 provider: &dyn WatchProvider,
110 record: &ManagedNotification,
111 ledger: &dyn LedgerSink,
112) -> Result<(), ProvisionError> {
113 provider.retract(&record.resources).await?;
114 ledger.delete(&record.function, &record.prefix).await?;
115 Ok(())
116}
117
118#[async_trait]
122pub trait LedgerSink: Send + Sync {
123 async fn put(&self, record: &ManagedNotification) -> Result<(), ProvisionError>;
125 async fn delete(&self, function: &str, prefix: &str) -> Result<(), ProvisionError>;
127}
128
129#[async_trait]
136impl LedgerSink for crate::deploy::DeployStore {
137 async fn put(&self, record: &ManagedNotification) -> Result<(), ProvisionError> {
138 self.put_managed_notification(crate::project::ProjectRef::DEFAULT, record)
139 .await
140 .map_err(|e| ProvisionError::Backend(e.to_string()))
141 }
142 async fn delete(&self, function: &str, prefix: &str) -> Result<(), ProvisionError> {
143 self.remove_managed_notification(crate::project::ProjectRef::DEFAULT, function, prefix)
144 .await
145 .map_err(|e| ProvisionError::Backend(e.to_string()))
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152 use std::collections::HashMap;
153 use std::sync::{Arc, Mutex};
154
155 #[derive(Default)]
157 struct MockProvider {
158 provisioned: Arc<Mutex<Vec<String>>>,
159 retracted: Arc<Mutex<Vec<String>>>,
160 verify_result: bool,
161 }
162
163 #[async_trait]
164 impl WatchProvider for MockProvider {
165 fn name(&self) -> &str {
166 "mock"
167 }
168 fn recipe(&self, prefix: &str) -> String {
169 format!("create a queue + notification for prefix {prefix:?}")
170 }
171 async fn provision(&self, prefix: &str) -> Result<Vec<ManagedResource>, ProvisionError> {
172 self.provisioned.lock().unwrap().push(prefix.to_string());
173 Ok(vec![
174 ManagedResource::new(
175 "queue",
176 format!("q-{}", crate::blob_notify::prefix_slug(prefix)),
177 ),
178 ManagedResource::new("bucket-notification", "bn-1"),
179 ])
180 }
181 async fn verify(&self, _prefix: &str) -> Result<bool, ProvisionError> {
182 Ok(self.verify_result)
183 }
184 async fn retract(&self, resources: &[ManagedResource]) -> Result<(), ProvisionError> {
185 for r in resources {
186 self.retracted.lock().unwrap().push(r.id.clone());
187 }
188 Ok(())
189 }
190 }
191
192 #[derive(Default)]
194 struct MemLedger {
195 entries: Mutex<HashMap<String, ManagedNotification>>,
196 }
197 impl MemLedger {
198 fn get(&self, function: &str, prefix: &str) -> Option<ManagedNotification> {
199 self.entries
200 .lock()
201 .unwrap()
202 .get(&crate::blob_notify::blobnotify_key(
203 crate::project::ProjectRef::DEFAULT.as_str(),
204 function,
205 prefix,
206 ))
207 .cloned()
208 }
209 fn len(&self) -> usize {
210 self.entries.lock().unwrap().len()
211 }
212 }
213 #[async_trait]
214 impl LedgerSink for MemLedger {
215 async fn put(&self, record: &ManagedNotification) -> Result<(), ProvisionError> {
216 self.entries.lock().unwrap().insert(
217 crate::blob_notify::blobnotify_key(
218 crate::project::ProjectRef::DEFAULT.as_str(),
219 &record.function,
220 &record.prefix,
221 ),
222 record.clone(),
223 );
224 Ok(())
225 }
226 async fn delete(&self, function: &str, prefix: &str) -> Result<(), ProvisionError> {
227 self.entries
228 .lock()
229 .unwrap()
230 .remove(&crate::blob_notify::blobnotify_key(
231 crate::project::ProjectRef::DEFAULT.as_str(),
232 function,
233 prefix,
234 ));
235 Ok(())
236 }
237 }
238
239 #[tokio::test]
240 async fn dry_run_prints_a_recipe_and_provisions_nothing() {
241 let provider = MockProvider::default();
242 let ledger = MemLedger::default();
243 let out = ensure_watch(
244 &provider,
245 ProvisionTier::DryRun,
246 "ingest",
247 "uploads/",
248 &ledger,
249 1,
250 )
251 .await
252 .unwrap();
253 assert!(matches!(out, ProvisionOutcome::Recipe(_)));
254 assert!(provider.provisioned.lock().unwrap().is_empty());
255 assert_eq!(ledger.len(), 0);
256 }
257
258 #[tokio::test]
259 async fn provision_records_resources_then_retract_removes_them() {
260 let provider = MockProvider::default();
261 let ledger = MemLedger::default();
262 let out = ensure_watch(
263 &provider,
264 ProvisionTier::Provision,
265 "ingest",
266 "uploads/",
267 &ledger,
268 5,
269 )
270 .await
271 .unwrap();
272 assert_eq!(out, ProvisionOutcome::Ready);
273 assert_eq!(provider.provisioned.lock().unwrap().len(), 1);
274 let record = ledger
275 .get("ingest", "uploads/")
276 .expect("ledger records the provisioned pipeline");
277 assert_eq!(record.provider, "mock");
278 assert_eq!(record.resources.len(), 2);
279
280 retract_watch(&provider, &record, &ledger).await.unwrap();
281 assert_eq!(provider.retracted.lock().unwrap().len(), 2);
283 assert_eq!(ledger.len(), 0);
284 }
285
286 #[tokio::test]
287 async fn verify_only_is_ready_or_refuses_and_refuse_fails_closed() {
288 let ledger = MemLedger::default();
289
290 let ok = MockProvider {
292 verify_result: true,
293 ..Default::default()
294 };
295 assert_eq!(
296 ensure_watch(&ok, ProvisionTier::VerifyOnly, "f", "p/", &ledger, 1)
297 .await
298 .unwrap(),
299 ProvisionOutcome::Ready
300 );
301
302 let missing = MockProvider {
304 verify_result: false,
305 ..Default::default()
306 };
307 assert!(matches!(
308 ensure_watch(&missing, ProvisionTier::VerifyOnly, "f", "p/", &ledger, 1)
309 .await
310 .unwrap(),
311 ProvisionOutcome::Refused(_)
312 ));
313
314 assert!(matches!(
316 ensure_watch(&ok, ProvisionTier::Refuse, "f", "p/", &ledger, 1)
317 .await
318 .unwrap(),
319 ProvisionOutcome::Refused(_)
320 ));
321 }
322}