use async_trait::async_trait;
use crate::blob_notify::{ManagedNotification, ManagedResource, ProvisionTier};
#[derive(Debug, Clone, thiserror::Error)]
pub enum ProvisionError {
#[error("notification provisioning: {0}")]
Backend(String),
#[error("no notification pipeline is configured")]
NotConfigured,
#[error("notification conflict: {0}")]
Conflict(String),
}
#[async_trait]
pub trait WatchProvider: Send + Sync {
fn name(&self) -> &str;
fn recipe(&self, prefix: &str) -> String;
async fn provision(&self, prefix: &str) -> Result<Vec<ManagedResource>, ProvisionError>;
async fn verify(&self, prefix: &str) -> Result<bool, ProvisionError>;
async fn retract(&self, resources: &[ManagedResource]) -> Result<(), ProvisionError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProvisionOutcome {
Ready,
Recipe(String),
Refused(String),
}
pub async fn ensure_watch(
provider: &dyn WatchProvider,
tier: ProvisionTier,
function: &str,
prefix: &str,
ledger: &dyn LedgerSink,
now_unix: u64,
) -> Result<ProvisionOutcome, ProvisionError> {
match tier {
ProvisionTier::DryRun => Ok(ProvisionOutcome::Recipe(provider.recipe(prefix))),
ProvisionTier::Refuse => Ok(ProvisionOutcome::Refused(
"blob-change triggers refuse without a notification pipeline (set a provisioning tier)"
.to_string(),
)),
ProvisionTier::VerifyOnly => {
if provider.verify(prefix).await? {
Ok(ProvisionOutcome::Ready)
} else {
Ok(ProvisionOutcome::Refused(
"verify-only: no pipeline is configured for this prefix".to_string(),
))
}
}
ProvisionTier::Provision => {
let resources = provider.provision(prefix).await?;
let record =
ManagedNotification::new(function, prefix, provider.name(), resources, now_unix);
ledger.put(&record).await?;
Ok(ProvisionOutcome::Ready)
}
}
}
pub async fn retract_watch(
provider: &dyn WatchProvider,
record: &ManagedNotification,
ledger: &dyn LedgerSink,
) -> Result<(), ProvisionError> {
provider.retract(&record.resources).await?;
ledger.delete(&record.function, &record.prefix).await?;
Ok(())
}
#[async_trait]
pub trait LedgerSink: Send + Sync {
async fn put(&self, record: &ManagedNotification) -> Result<(), ProvisionError>;
async fn delete(&self, function: &str, prefix: &str) -> Result<(), ProvisionError>;
}
#[async_trait]
impl LedgerSink for crate::deploy::DeployStore {
async fn put(&self, record: &ManagedNotification) -> Result<(), ProvisionError> {
self.put_managed_notification(crate::project::ProjectRef::DEFAULT, record)
.await
.map_err(|e| ProvisionError::Backend(e.to_string()))
}
async fn delete(&self, function: &str, prefix: &str) -> Result<(), ProvisionError> {
self.remove_managed_notification(crate::project::ProjectRef::DEFAULT, function, prefix)
.await
.map_err(|e| ProvisionError::Backend(e.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Default)]
struct MockProvider {
provisioned: Arc<Mutex<Vec<String>>>,
retracted: Arc<Mutex<Vec<String>>>,
verify_result: bool,
}
#[async_trait]
impl WatchProvider for MockProvider {
fn name(&self) -> &str {
"mock"
}
fn recipe(&self, prefix: &str) -> String {
format!("create a queue + notification for prefix {prefix:?}")
}
async fn provision(&self, prefix: &str) -> Result<Vec<ManagedResource>, ProvisionError> {
self.provisioned.lock().unwrap().push(prefix.to_string());
Ok(vec![
ManagedResource::new(
"queue",
format!("q-{}", crate::blob_notify::prefix_slug(prefix)),
),
ManagedResource::new("bucket-notification", "bn-1"),
])
}
async fn verify(&self, _prefix: &str) -> Result<bool, ProvisionError> {
Ok(self.verify_result)
}
async fn retract(&self, resources: &[ManagedResource]) -> Result<(), ProvisionError> {
for r in resources {
self.retracted.lock().unwrap().push(r.id.clone());
}
Ok(())
}
}
#[derive(Default)]
struct MemLedger {
entries: Mutex<HashMap<String, ManagedNotification>>,
}
impl MemLedger {
fn get(&self, function: &str, prefix: &str) -> Option<ManagedNotification> {
self.entries
.lock()
.unwrap()
.get(&crate::blob_notify::blobnotify_key(
crate::project::ProjectRef::DEFAULT.as_str(),
function,
prefix,
))
.cloned()
}
fn len(&self) -> usize {
self.entries.lock().unwrap().len()
}
}
#[async_trait]
impl LedgerSink for MemLedger {
async fn put(&self, record: &ManagedNotification) -> Result<(), ProvisionError> {
self.entries.lock().unwrap().insert(
crate::blob_notify::blobnotify_key(
crate::project::ProjectRef::DEFAULT.as_str(),
&record.function,
&record.prefix,
),
record.clone(),
);
Ok(())
}
async fn delete(&self, function: &str, prefix: &str) -> Result<(), ProvisionError> {
self.entries
.lock()
.unwrap()
.remove(&crate::blob_notify::blobnotify_key(
crate::project::ProjectRef::DEFAULT.as_str(),
function,
prefix,
));
Ok(())
}
}
#[tokio::test]
async fn dry_run_prints_a_recipe_and_provisions_nothing() {
let provider = MockProvider::default();
let ledger = MemLedger::default();
let out = ensure_watch(
&provider,
ProvisionTier::DryRun,
"ingest",
"uploads/",
&ledger,
1,
)
.await
.unwrap();
assert!(matches!(out, ProvisionOutcome::Recipe(_)));
assert!(provider.provisioned.lock().unwrap().is_empty());
assert_eq!(ledger.len(), 0);
}
#[tokio::test]
async fn provision_records_resources_then_retract_removes_them() {
let provider = MockProvider::default();
let ledger = MemLedger::default();
let out = ensure_watch(
&provider,
ProvisionTier::Provision,
"ingest",
"uploads/",
&ledger,
5,
)
.await
.unwrap();
assert_eq!(out, ProvisionOutcome::Ready);
assert_eq!(provider.provisioned.lock().unwrap().len(), 1);
let record = ledger
.get("ingest", "uploads/")
.expect("ledger records the provisioned pipeline");
assert_eq!(record.provider, "mock");
assert_eq!(record.resources.len(), 2);
retract_watch(&provider, &record, &ledger).await.unwrap();
assert_eq!(provider.retracted.lock().unwrap().len(), 2);
assert_eq!(ledger.len(), 0);
}
#[tokio::test]
async fn verify_only_is_ready_or_refuses_and_refuse_fails_closed() {
let ledger = MemLedger::default();
let ok = MockProvider {
verify_result: true,
..Default::default()
};
assert_eq!(
ensure_watch(&ok, ProvisionTier::VerifyOnly, "f", "p/", &ledger, 1)
.await
.unwrap(),
ProvisionOutcome::Ready
);
let missing = MockProvider {
verify_result: false,
..Default::default()
};
assert!(matches!(
ensure_watch(&missing, ProvisionTier::VerifyOnly, "f", "p/", &ledger, 1)
.await
.unwrap(),
ProvisionOutcome::Refused(_)
));
assert!(matches!(
ensure_watch(&ok, ProvisionTier::Refuse, "f", "p/", &ledger, 1)
.await
.unwrap(),
ProvisionOutcome::Refused(_)
));
}
}