Skip to main content

fakecloud_appconfig/
persistence.rs

1//! Snapshot save/load for AWS AppConfig state.
2
3use std::sync::Arc;
4
5use tokio::sync::Mutex as AsyncMutex;
6
7use fakecloud_persistence::SnapshotStore;
8
9use crate::state::{AppConfigSnapshot, SharedAppConfigState, APPCONFIG_SNAPSHOT_SCHEMA_VERSION};
10
11#[derive(Debug, PartialEq, Eq)]
12pub enum LoadOutcome {
13    Empty,
14    Loaded(usize),
15}
16
17#[derive(Debug, thiserror::Error)]
18pub enum LoadError {
19    #[error("failed to read appconfig persistence snapshot: {0}")]
20    Io(String),
21    #[error("failed to parse appconfig persistence snapshot: {0}")]
22    Parse(String),
23    #[error("appconfig persistence schema too new: on-disk={on_disk}, max supported={supported}")]
24    SchemaTooNew { on_disk: u32, supported: u32 },
25}
26
27pub fn load_into(
28    store: &dyn SnapshotStore,
29    state: &SharedAppConfigState,
30) -> Result<LoadOutcome, LoadError> {
31    let Some(bytes) = store.load().map_err(|e| LoadError::Io(e.to_string()))? else {
32        return Ok(LoadOutcome::Empty);
33    };
34    let snapshot: AppConfigSnapshot =
35        serde_json::from_slice(&bytes).map_err(|e| LoadError::Parse(e.to_string()))?;
36    if snapshot.schema_version > APPCONFIG_SNAPSHOT_SCHEMA_VERSION {
37        return Err(LoadError::SchemaTooNew {
38            on_disk: snapshot.schema_version,
39            supported: APPCONFIG_SNAPSHOT_SCHEMA_VERSION,
40        });
41    }
42    let accounts = snapshot.accounts.account_count();
43    *state.write() = snapshot.accounts;
44    Ok(LoadOutcome::Loaded(accounts))
45}
46
47pub async fn save_snapshot(
48    state: &SharedAppConfigState,
49    store: Option<Arc<dyn SnapshotStore>>,
50    lock: &AsyncMutex<()>,
51) {
52    let Some(store) = store else {
53        return;
54    };
55    let _guard = lock.lock().await;
56    let snapshot = AppConfigSnapshot {
57        schema_version: APPCONFIG_SNAPSHOT_SCHEMA_VERSION,
58        accounts: state.read().clone(),
59    };
60    let join = tokio::task::spawn_blocking(move || -> std::io::Result<()> {
61        let bytes = serde_json::to_vec(&snapshot)
62            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))?;
63        store.save(&bytes)
64    })
65    .await;
66    match join {
67        Ok(Ok(())) => {}
68        Ok(Err(err)) => tracing::error!(%err, "failed to write appconfig snapshot"),
69        Err(err) => tracing::error!(%err, "appconfig snapshot task panicked"),
70    }
71}
72
73#[cfg(test)]
74mod tests {
75    use super::*;
76    use crate::state::{AppConfigState, ApplicationRecord, HostedVersionRecord, ProfileRecord};
77    use fakecloud_core::multi_account::MultiAccountState;
78    use parking_lot::RwLock;
79    use std::collections::BTreeMap;
80    use std::sync::Mutex;
81
82    struct MemStore(Mutex<Option<Vec<u8>>>);
83    impl SnapshotStore for MemStore {
84        fn load(&self) -> std::io::Result<Option<Vec<u8>>> {
85            Ok(self.0.lock().unwrap().clone())
86        }
87        fn save(&self, bytes: &[u8]) -> std::io::Result<()> {
88            *self.0.lock().unwrap() = Some(bytes.to_vec());
89            Ok(())
90        }
91    }
92
93    fn state() -> SharedAppConfigState {
94        Arc::new(RwLock::new(MultiAccountState::new(
95            "000000000000",
96            "us-east-1",
97            "",
98        )))
99    }
100
101    #[test]
102    fn empty_store_is_empty() {
103        assert_eq!(
104            load_into(&MemStore(Mutex::new(None)), &state()).unwrap(),
105            LoadOutcome::Empty
106        );
107    }
108
109    #[test]
110    fn round_trip_restores_hosted_version_bytes() {
111        let mut accounts: MultiAccountState<AppConfigState> =
112            MultiAccountState::new("000000000000", "us-east-1", "");
113        {
114            let acct = accounts.get_or_create("000000000000");
115            let mut profile = ProfileRecord {
116                id: "prof123".to_string(),
117                application_id: "app1234".to_string(),
118                name: "p".to_string(),
119                description: None,
120                location_uri: "hosted".to_string(),
121                retrieval_role_arn: None,
122                validators: serde_json::json!([]),
123                profile_type: "AWS.Freeform".to_string(),
124                kms_key_arn: None,
125                kms_key_identifier: None,
126                hosted_versions: BTreeMap::new(),
127                next_version_number: 2,
128            };
129            profile.hosted_versions.insert(
130                1,
131                HostedVersionRecord {
132                    version_number: 1,
133                    description: None,
134                    content: b"\x00\x01\x02hello".to_vec(),
135                    content_type: "application/json".to_string(),
136                    version_label: None,
137                },
138            );
139            let mut app = ApplicationRecord {
140                id: "app1234".to_string(),
141                name: "app".to_string(),
142                description: None,
143                environments: BTreeMap::new(),
144                profiles: BTreeMap::new(),
145                experiments: BTreeMap::new(),
146            };
147            app.profiles.insert("prof123".to_string(), profile);
148            acct.applications.insert("app1234".to_string(), app);
149        }
150        let snap = AppConfigSnapshot {
151            schema_version: APPCONFIG_SNAPSHOT_SCHEMA_VERSION,
152            accounts,
153        };
154        let store = MemStore(Mutex::new(Some(serde_json::to_vec(&snap).unwrap())));
155        let restored = state();
156        assert_eq!(
157            load_into(&store, &restored).unwrap(),
158            LoadOutcome::Loaded(1)
159        );
160        let guard = restored.read();
161        let acct = guard.get("000000000000").unwrap();
162        let bytes = &acct.applications["app1234"].profiles["prof123"].hosted_versions[&1].content;
163        assert_eq!(bytes, b"\x00\x01\x02hello");
164    }
165
166    #[test]
167    fn rejects_future_schema() {
168        let accounts: MultiAccountState<AppConfigState> =
169            MultiAccountState::new("000000000000", "us-east-1", "");
170        let bytes = serde_json::to_vec(&serde_json::json!({
171            "schema_version": APPCONFIG_SNAPSHOT_SCHEMA_VERSION + 1,
172            "accounts": accounts,
173        }))
174        .unwrap();
175        let store = MemStore(Mutex::new(Some(bytes)));
176        assert!(matches!(
177            load_into(&store, &state()),
178            Err(LoadError::SchemaTooNew { .. })
179        ));
180    }
181}