Skip to main content

fakecloud_core/
multi_account.rs

1//! Generic multi-account state container.
2//!
3//! Wraps a `HashMap<AccountId, T>` so each AWS account gets its own isolated
4//! state instance. Accounts are created lazily via [`MultiAccountState::get_or_create`]
5//! the first time a request targets them — matching the design in #381 where
6//! "an account exists because a credential resolves to it."
7
8use std::collections::HashMap;
9
10use serde::{Deserialize, Serialize};
11
12/// Trait implemented by per-service state structs that participate in
13/// multi-account isolation.
14pub trait AccountState: Sized {
15    /// Create a fresh, empty state for the given account.
16    fn new_for_account(account_id: &str, region: &str, endpoint: &str) -> Self;
17
18    /// Called after a new account state is created via [`MultiAccountState::get_or_create`],
19    /// with a reference to an existing sibling state. Services can override
20    /// this to propagate shared resources (e.g. body caches) to the new state.
21    fn inherit_from(&mut self, _sibling: &Self) {}
22}
23
24/// Account-partitioned state container.
25///
26/// Holds one `T` per account id. The `default_account_id` is pre-created at
27/// startup so unauthenticated requests (which fall back to `--account-id`)
28/// always have a state to land in.
29#[derive(Debug, Clone, Serialize, Deserialize)]
30pub struct MultiAccountState<T> {
31    default_account_id: String,
32    region: String,
33    endpoint: String,
34    accounts: HashMap<String, T>,
35}
36
37impl<T: AccountState> MultiAccountState<T> {
38    /// Create a new container, pre-populating the default account.
39    pub fn new(default_account_id: &str, region: &str, endpoint: &str) -> Self {
40        let mut accounts = HashMap::new();
41        accounts.insert(
42            default_account_id.to_string(),
43            T::new_for_account(default_account_id, region, endpoint),
44        );
45        Self {
46            default_account_id: default_account_id.to_string(),
47            region: region.to_string(),
48            endpoint: endpoint.to_string(),
49            accounts,
50        }
51    }
52
53    /// Project account states while preserving the container's routing defaults.
54    pub fn map<U>(&self, mut f: impl FnMut(&T) -> U) -> MultiAccountState<U> {
55        MultiAccountState {
56            default_account_id: self.default_account_id.clone(),
57            region: self.region.clone(),
58            endpoint: self.endpoint.clone(),
59            accounts: self
60                .accounts
61                .iter()
62                .map(|(k, v)| (k.clone(), f(v)))
63                .collect(),
64        }
65    }
66
67    /// Get or lazily create the state for `account_id`.
68    ///
69    /// When a new account is created, [`AccountState::inherit_from`] is called
70    /// with the default account's state so services can propagate shared
71    /// resources (e.g. body caches).
72    pub fn get_or_create(&mut self, account_id: &str) -> &mut T {
73        if !self.accounts.contains_key(account_id) {
74            let mut state = T::new_for_account(account_id, &self.region, &self.endpoint);
75            // Let the new state inherit shared resources from the default account.
76            if let Some(sibling) = self.accounts.get(&self.default_account_id) {
77                state.inherit_from(sibling);
78            }
79            self.accounts.insert(account_id.to_string(), state);
80        }
81        self.accounts.get_mut(account_id).unwrap()
82    }
83
84    /// Get or lazily create the state for `account_id`, then run `init` on
85    /// the newly created state. The callback is only invoked when the account
86    /// is freshly created, not on subsequent lookups.
87    pub fn get_or_create_with<F>(&mut self, account_id: &str, init: F) -> &mut T
88    where
89        F: FnOnce(&mut T),
90    {
91        if !self.accounts.contains_key(account_id) {
92            let mut state = T::new_for_account(account_id, &self.region, &self.endpoint);
93            init(&mut state);
94            self.accounts.insert(account_id.to_string(), state);
95        }
96        self.accounts.get_mut(account_id).unwrap()
97    }
98
99    /// Read-only lookup. Returns `None` if the account has never been seen.
100    pub fn get(&self, account_id: &str) -> Option<&T> {
101        self.accounts.get(account_id)
102    }
103
104    /// Mutable lookup without auto-creation.
105    pub fn get_mut(&mut self, account_id: &str) -> Option<&mut T> {
106        self.accounts.get_mut(account_id)
107    }
108
109    /// Iterate over all account states (read-only).
110    pub fn iter(&self) -> impl Iterator<Item = (&str, &T)> {
111        self.accounts.iter().map(|(k, v)| (k.as_str(), v))
112    }
113
114    /// Iterate over all account states (mutable).
115    pub fn iter_mut(&mut self) -> impl Iterator<Item = (&str, &mut T)> {
116        self.accounts.iter_mut().map(|(k, v)| (k.as_str(), v))
117    }
118
119    /// The default account id configured via `--account-id`.
120    pub fn default_account_id(&self) -> &str {
121        &self.default_account_id
122    }
123
124    /// Mutable reference to the default account's state (always exists).
125    pub fn default_mut(&mut self) -> &mut T {
126        self.accounts.get_mut(&self.default_account_id).unwrap()
127    }
128
129    /// Reference to the default account's state (always exists).
130    pub fn default_ref(&self) -> &T {
131        self.accounts.get(&self.default_account_id).unwrap()
132    }
133
134    /// Reset all accounts back to empty state. The default account is
135    /// recreated; all other accounts are dropped.
136    pub fn reset(&mut self) {
137        self.accounts.clear();
138        self.accounts.insert(
139            self.default_account_id.clone(),
140            T::new_for_account(&self.default_account_id, &self.region, &self.endpoint),
141        );
142    }
143
144    /// Find the first account whose state satisfies `predicate` and return
145    /// the account id. Useful for resolving globally-unique resources (e.g.
146    /// S3 bucket names) back to their owning account.
147    pub fn find_account<F>(&self, predicate: F) -> Option<&str>
148    where
149        F: Fn(&T) -> bool,
150    {
151        self.accounts
152            .iter()
153            .find(|(_, v)| predicate(v))
154            .map(|(k, _)| k.as_str())
155    }
156
157    /// Number of accounts with state.
158    pub fn account_count(&self) -> usize {
159        self.accounts.len()
160    }
161
162    /// Region shared by all accounts.
163    pub fn region(&self) -> &str {
164        &self.region
165    }
166
167    /// Endpoint shared by all accounts.
168    pub fn endpoint(&self) -> &str {
169        &self.endpoint
170    }
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[derive(Debug, Clone, Serialize, Deserialize)]
178    struct TestState {
179        account_id: String,
180        items: Vec<String>,
181    }
182
183    impl AccountState for TestState {
184        fn new_for_account(account_id: &str, _region: &str, _endpoint: &str) -> Self {
185            Self {
186                account_id: account_id.to_string(),
187                items: Vec::new(),
188            }
189        }
190    }
191
192    #[test]
193    fn default_account_exists_on_creation() {
194        let mas: MultiAccountState<TestState> =
195            MultiAccountState::new("111111111111", "us-east-1", "http://localhost:4566");
196        assert_eq!(mas.account_count(), 1);
197        assert!(mas.get("111111111111").is_some());
198    }
199
200    #[test]
201    fn get_or_create_makes_new_account() {
202        let mut mas: MultiAccountState<TestState> =
203            MultiAccountState::new("111111111111", "us-east-1", "http://localhost:4566");
204        let state = mas.get_or_create("222222222222");
205        assert_eq!(state.account_id, "222222222222");
206        assert_eq!(mas.account_count(), 2);
207    }
208
209    #[test]
210    fn get_returns_none_for_unknown() {
211        let mas: MultiAccountState<TestState> =
212            MultiAccountState::new("111111111111", "us-east-1", "http://localhost:4566");
213        assert!(mas.get("999999999999").is_none());
214    }
215
216    #[test]
217    fn reset_clears_all_but_default() {
218        let mut mas: MultiAccountState<TestState> =
219            MultiAccountState::new("111111111111", "us-east-1", "http://localhost:4566");
220        mas.get_or_create("222222222222");
221        mas.get_or_create("333333333333");
222        assert_eq!(mas.account_count(), 3);
223        mas.reset();
224        assert_eq!(mas.account_count(), 1);
225        assert!(mas.get("111111111111").is_some());
226        assert!(mas.get("222222222222").is_none());
227    }
228
229    #[test]
230    fn iter_visits_all_accounts() {
231        let mut mas: MultiAccountState<TestState> =
232            MultiAccountState::new("111111111111", "us-east-1", "http://localhost:4566");
233        mas.get_or_create("222222222222");
234        let ids: Vec<&str> = mas.iter().map(|(id, _)| id).collect();
235        assert_eq!(ids.len(), 2);
236        assert!(ids.contains(&"111111111111"));
237        assert!(ids.contains(&"222222222222"));
238    }
239}