fakecloud_appconfig/
persistence.rs1use 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}