Skip to main content

boatramp_core/
blob_provision.rs

1//! Cloud blob-change notification **provisioning** (PLAN-faas FA-5b2) — the IO
2//! side of [`blob_notify`](crate::blob_notify). A [`WatchProvider`] provisions,
3//! verifies, retracts, or describes (dry-run) the native notification pipeline for
4//! one cloud object store (S3→SQS, GCS→Pub/Sub, Azure→Event Grid). The
5//! [`ensure_watch`] orchestrator threads the four operator tiers and records what
6//! it created in the managed-notification ledger so it can be retracted later —
7//! the same reconcile/retract discipline as auto-DNS.
8//!
9//! This module is the **provider-agnostic scaffolding + a mock**; the concrete
10//! cloud providers (which pull the cloud SDKs and are validated live) plug into
11//! the same trait.
12
13use async_trait::async_trait;
14
15use crate::blob_notify::{ManagedNotification, ManagedResource, ProvisionTier};
16
17/// A provisioning failure.
18#[derive(Debug, Clone, thiserror::Error)]
19pub enum ProvisionError {
20    /// The cloud API (or credential) failed.
21    #[error("notification provisioning: {0}")]
22    Backend(String),
23    /// The operator's `verify-only` tier found no configured pipeline.
24    #[error("no notification pipeline is configured")]
25    NotConfigured,
26    /// A conflict boatramp must not clobber (e.g. an S3 bucket that already has a
27    /// *different* owner's notification config).
28    #[error("notification conflict: {0}")]
29    Conflict(String),
30}
31
32/// Provisions the native change-notification pipeline for one cloud object store.
33/// Every method is idempotent; `provision` returns the resources created (for the
34/// ledger), and `retract` deletes exactly those.
35#[async_trait]
36pub trait WatchProvider: Send + Sync {
37    /// The provider token recorded in the ledger (`s3` / `gcs` / `azure`).
38    fn name(&self) -> &str;
39
40    /// A human-readable recipe (the `dry-run` tier): the exact resources + policy
41    /// an operator would apply by hand. No side effects, no credentials.
42    fn recipe(&self, prefix: &str) -> String;
43
44    /// Provision the pipeline for `prefix` idempotently, returning the resources
45    /// created (recorded in the ledger for retraction).
46    async fn provision(&self, prefix: &str) -> Result<Vec<ManagedResource>, ProvisionError>;
47
48    /// Whether a working pipeline already exists for `prefix` (the `verify-only`
49    /// tier).
50    async fn verify(&self, prefix: &str) -> Result<bool, ProvisionError>;
51
52    /// Delete the given resources (retraction). Deleting an already-gone resource
53    /// is not an error.
54    async fn retract(&self, resources: &[ManagedResource]) -> Result<(), ProvisionError>;
55}
56
57/// The outcome of an [`ensure_watch`] call.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub enum ProvisionOutcome {
60    /// The pipeline is ready (provisioned now, or verified as already present).
61    Ready,
62    /// The `dry-run` recipe — printed for the operator, nothing applied.
63    Recipe(String),
64    /// Fail-closed: no pipeline and no provisioning (the `refuse` tier, or
65    /// `verify-only` with nothing configured). The trigger must not activate.
66    Refused(String),
67}
68
69/// Ensure a blob-change notification pipeline for `(function, prefix)` per the
70/// operator `tier`, recording provisioned resources in the ledger. Pure control
71/// flow over the provider + a tiny ledger sink, so it is fully unit-testable with
72/// a mock provider + an in-memory ledger.
73pub 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
106/// Retract the pipeline recorded in `record`: delete its resources via `provider`,
107/// then drop the ledger entry. Idempotent.
108pub 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/// The ledger persistence the orchestrator needs — a thin seam so the pure logic
119/// is testable in-memory and the real one is the [`DeployStore`](crate::deploy)
120/// (which is `&self`/`Arc`-based, hence the shared receiver).
121#[async_trait]
122pub trait LedgerSink: Send + Sync {
123    /// Record (create/replace) a provisioned pipeline.
124    async fn put(&self, record: &ManagedNotification) -> Result<(), ProvisionError>;
125    /// Drop the ledger entry for `(function, prefix)`.
126    async fn delete(&self, function: &str, prefix: &str) -> Result<(), ProvisionError>;
127}
128
129/// The real ledger is the control-plane store.
130///
131/// The blob-notify ledger is project-scoped like every function record. This seam
132/// carries no project, so it targets [`ProjectRef::DEFAULT`] — correct while
133/// functions live under the default project; threading a caller-supplied project
134/// through the `LedgerSink` trait is a Step-7 follow-up (per-project blob triggers).
135#[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    /// A mock provider recording its calls, standing in for a cloud SDK.
156    #[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    /// An in-memory ledger sink (interior mutability, like the real store).
193    #[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        // Both resources were deleted and the ledger entry dropped.
282        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        // verify-only with a configured pipeline → ready.
291        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        // verify-only with nothing configured → refused (fail-closed).
303        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        // refuse tier → refused, nothing touched.
315        assert!(matches!(
316            ensure_watch(&ok, ProvisionTier::Refuse, "f", "p/", &ledger, 1)
317                .await
318                .unwrap(),
319            ProvisionOutcome::Refused(_)
320        ));
321    }
322}