Skip to main content

fakecloud_iam/
persistence.rs

1//! Shared IAM snapshot persistence.
2//!
3//! Both `IamService` and `StsService` operate on the same `SharedIamState`
4//! and therefore share a single on-disk snapshot. The save routine and the
5//! serializing Mutex live here so both services route through the same
6//! critical section.
7
8use std::sync::Arc;
9
10use fakecloud_persistence::SnapshotStore;
11use tokio::sync::Mutex as AsyncMutex;
12
13use crate::state::{IamSnapshot, SharedIamState, IAM_SNAPSHOT_SCHEMA_VERSION};
14
15/// Serializes concurrent snapshot writes across both IAM and STS services.
16/// Without it, two tasks could clone state under the RwLock, serialize
17/// independently, and race on `store.save()`, leaving older bytes as the
18/// final on-disk state.
19pub type IamSnapshotLock = Arc<AsyncMutex<()>>;
20
21pub fn new_snapshot_lock() -> IamSnapshotLock {
22    Arc::new(AsyncMutex::new(()))
23}
24
25/// Persist the current IAM state as a snapshot. Offloads the serde +
26/// blocking file write to the Tokio blocking pool so the async runtime
27/// stays responsive.
28///
29/// Noop when `store` is `None` (memory mode).
30pub async fn save_iam_snapshot(
31    state: &SharedIamState,
32    store: Option<Arc<dyn SnapshotStore>>,
33    lock: &IamSnapshotLock,
34) {
35    let Some(store) = store else {
36        return;
37    };
38    let _guard = lock.lock().await;
39    let snapshot = IamSnapshot {
40        schema_version: IAM_SNAPSHOT_SCHEMA_VERSION,
41        accounts: Some(state.read().clone()),
42        state: None,
43    };
44    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
45        let bytes = serde_json::to_vec(&snapshot)
46            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
47        store.save(&bytes)
48    })
49    .await;
50    match join {
51        Ok(Ok(())) => {}
52        Ok(Err(err)) => tracing::error!(%err, "failed to write iam snapshot"),
53        Err(err) => tracing::error!(%err, "iam snapshot task panicked"),
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60    use crate::state::IamState;
61    use fakecloud_core::multi_account::MultiAccountState;
62    use fakecloud_persistence::DiskSnapshotStore;
63    use parking_lot::RwLock;
64
65    fn shared_state() -> SharedIamState {
66        let multi: MultiAccountState<IamState> =
67            MultiAccountState::new("123456789012", "us-east-1", "http://localhost:4566");
68        std::sync::Arc::new(RwLock::new(multi))
69    }
70
71    #[test]
72    fn new_snapshot_lock_returns_arc_mutex() {
73        let lock: IamSnapshotLock = new_snapshot_lock();
74        let _c = lock.clone();
75    }
76
77    #[tokio::test]
78    async fn save_snapshot_none_store_is_noop() {
79        let state = shared_state();
80        let lock = new_snapshot_lock();
81        save_iam_snapshot(&state, None, &lock).await;
82    }
83
84    #[tokio::test]
85    async fn save_snapshot_writes_bytes_to_disk_store() {
86        let state = shared_state();
87        let lock = new_snapshot_lock();
88        let tmp = tempfile::tempdir().unwrap();
89        let path = tmp.path().join("iam.json");
90        let store: std::sync::Arc<dyn SnapshotStore> =
91            std::sync::Arc::new(DiskSnapshotStore::new(path.clone()));
92        save_iam_snapshot(&state, Some(store), &lock).await;
93        let bytes = std::fs::read(&path).unwrap();
94        assert!(!bytes.is_empty());
95        let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
96        assert_eq!(v["schema_version"], IAM_SNAPSHOT_SCHEMA_VERSION);
97    }
98}