Skip to main content

fakecloud_iotwireless/
state.rs

1//! Account-partitioned, serializable state for the AWS IoT Wireless control
2//! plane.
3//!
4//! Like the sibling `fakecloud-iot` crate, the registry is deliberately
5//! schema-light: every named resource family (destinations, device profiles,
6//! service profiles, FUOTA tasks, multicast groups, wireless devices, wireless
7//! gateways, network-analyzer configurations, task definitions, position
8//! configurations, ...) is stored in one uniform two-level map,
9//! `resources[resource_type][id]`, where the stored value is the resource's
10//! JSON record (its persisted attributes plus any minted ARN / id /
11//! timestamps). Keeping every key a plain `String` means the snapshot never
12//! depends on the tuple-key serde adapter and new resource families need no
13//! new struct fields.
14//!
15//! Alongside the resource map are:
16//! * `tags` — resource tags keyed by ARN.
17//! * `singletons` — account-scoped singleton configurations (log levels,
18//!   event configuration by resource type, metric configuration, ...), keyed
19//!   by a stable string.
20//! * `relations` — many-to-many relationship sets keyed by a stable string
21//!   (e.g. `multicast-devices:<group>` -> wireless device ids,
22//!   `fuota-devices:<task>` -> wireless device ids). Stored as ordered vectors
23//!   so list operations are deterministic.
24
25use std::collections::BTreeMap;
26use std::sync::Arc;
27
28use parking_lot::RwLock;
29use serde::{Deserialize, Serialize};
30use serde_json::Value;
31
32use fakecloud_core::multi_account::{AccountState, MultiAccountState};
33
34pub const IOTWIRELESS_SNAPSHOT_SCHEMA_VERSION: u32 = 1;
35
36/// Per-account IoT Wireless control-plane state.
37#[derive(Debug, Clone, Default, Serialize, Deserialize)]
38pub struct IotWirelessData {
39    /// `resource_type -> id -> record`. The record is the resource's persisted
40    /// JSON attributes plus any minted ARN / id / timestamps.
41    #[serde(default)]
42    pub resources: BTreeMap<String, BTreeMap<String, Value>>,
43    /// Resource tags keyed by ARN.
44    #[serde(default)]
45    pub tags: BTreeMap<String, BTreeMap<String, String>>,
46    /// Account-scoped singleton configurations keyed by a stable string.
47    #[serde(default)]
48    pub singletons: BTreeMap<String, Value>,
49    /// Raw `@httpPayload` blobs (e.g. resource GeoJSON positions) keyed by a
50    /// stable string. Stored base64-free as UTF-8 lossy strings.
51    #[serde(default)]
52    pub blobs: BTreeMap<String, String>,
53    /// Many-to-many relationship sets keyed by a stable string.
54    #[serde(default)]
55    pub relations: BTreeMap<String, Vec<String>>,
56    /// Monotonic counter used to mint unique ids within an account.
57    #[serde(default)]
58    pub seq: u64,
59}
60
61impl IotWirelessData {
62    /// Fetch a resource record by type + id.
63    pub fn get_resource(&self, rtype: &str, id: &str) -> Option<&Value> {
64        self.resources.get(rtype).and_then(|m| m.get(id))
65    }
66
67    /// Insert / replace a resource record.
68    pub fn put_resource(&mut self, rtype: &str, id: &str, record: Value) {
69        self.resources
70            .entry(rtype.to_string())
71            .or_default()
72            .insert(id.to_string(), record);
73    }
74
75    /// Remove a resource record, returning it if present.
76    pub fn remove_resource(&mut self, rtype: &str, id: &str) -> Option<Value> {
77        let removed = self.resources.get_mut(rtype).and_then(|m| m.remove(id));
78        if let Some(m) = self.resources.get(rtype) {
79            if m.is_empty() {
80                self.resources.remove(rtype);
81            }
82        }
83        removed
84    }
85
86    /// All records of a type, ordered by id.
87    pub fn list_resources(&self, rtype: &str) -> Vec<Value> {
88        self.resources
89            .get(rtype)
90            .map(|m| m.values().cloned().collect())
91            .unwrap_or_default()
92    }
93
94    /// All records of a type as `(id, record)` pairs, ordered by id.
95    pub fn list_resource_entries(&self, rtype: &str) -> Vec<(String, Value)> {
96        self.resources
97            .get(rtype)
98            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
99            .unwrap_or_default()
100    }
101
102    /// Next unique sequence value for id minting.
103    pub fn next_seq(&mut self) -> u64 {
104        self.seq += 1;
105        self.seq
106    }
107
108    // ---- account-scoped singleton configurations ----
109
110    /// Read a singleton configuration by its stable key.
111    pub fn get_singleton(&self, key: &str) -> Option<&Value> {
112        self.singletons.get(key)
113    }
114
115    /// Insert / replace a singleton configuration.
116    pub fn put_singleton(&mut self, key: &str, value: Value) {
117        self.singletons.insert(key.to_string(), value);
118    }
119
120    // ---- many-to-many relationship edges ----
121
122    /// Add `member` to the ordered relationship set `key` (idempotent — a member
123    /// already present is not duplicated).
124    pub fn add_relation(&mut self, key: &str, member: &str) {
125        let set = self.relations.entry(key.to_string()).or_default();
126        if !set.iter().any(|m| m == member) {
127            set.push(member.to_string());
128        }
129    }
130
131    /// Remove `member` from the relationship set `key`; drops the empty set.
132    pub fn remove_relation(&mut self, key: &str, member: &str) {
133        if let Some(set) = self.relations.get_mut(key) {
134            set.retain(|m| m != member);
135            if set.is_empty() {
136                self.relations.remove(key);
137            }
138        }
139    }
140
141    /// The members of relationship set `key`, in insertion order.
142    pub fn list_relation(&self, key: &str) -> Vec<String> {
143        self.relations.get(key).cloned().unwrap_or_default()
144    }
145}
146
147impl AccountState for IotWirelessData {
148    fn new_for_account(_account_id: &str, _region: &str, _endpoint: &str) -> Self {
149        Self::default()
150    }
151}
152
153pub type SharedIotWirelessState = Arc<RwLock<MultiAccountState<IotWirelessData>>>;
154
155#[derive(Debug, Serialize, Deserialize)]
156pub struct IotWirelessSnapshot {
157    pub schema_version: u32,
158    pub accounts: MultiAccountState<IotWirelessData>,
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use serde_json::json;
165
166    #[test]
167    fn new_account_is_empty() {
168        let data = IotWirelessData::new_for_account("000000000000", "us-east-1", "");
169        assert!(data.resources.is_empty());
170        assert!(data.tags.is_empty());
171        assert!(data.singletons.is_empty());
172    }
173
174    #[test]
175    fn resource_round_trips() {
176        let mut d = IotWirelessData::default();
177        d.put_resource("destinations", "dest-1", json!({"Name": "dest-1"}));
178        assert_eq!(
179            d.get_resource("destinations", "dest-1").unwrap()["Name"],
180            "dest-1"
181        );
182        assert_eq!(d.list_resources("destinations").len(), 1);
183        assert!(d.remove_resource("destinations", "dest-1").is_some());
184        assert!(d.get_resource("destinations", "dest-1").is_none());
185        assert!(!d.resources.contains_key("destinations"));
186    }
187
188    #[test]
189    fn singleton_round_trips() {
190        let mut d = IotWirelessData::default();
191        assert!(d.get_singleton("metric-configuration").is_none());
192        d.put_singleton(
193            "metric-configuration",
194            json!({"SummaryMetric": {"Status": "Enabled"}}),
195        );
196        assert_eq!(
197            d.get_singleton("metric-configuration").unwrap()["SummaryMetric"]["Status"],
198            "Enabled"
199        );
200    }
201
202    #[test]
203    fn relation_edges_round_trip() {
204        let mut d = IotWirelessData::default();
205        d.add_relation("fuota-multicast:task-1", "mc-1");
206        d.add_relation("fuota-multicast:task-1", "mc-2");
207        // idempotent
208        d.add_relation("fuota-multicast:task-1", "mc-1");
209        assert_eq!(
210            d.list_relation("fuota-multicast:task-1"),
211            vec!["mc-1", "mc-2"]
212        );
213        d.remove_relation("fuota-multicast:task-1", "mc-1");
214        assert_eq!(d.list_relation("fuota-multicast:task-1"), vec!["mc-2"]);
215        d.remove_relation("fuota-multicast:task-1", "mc-2");
216        assert!(d.list_relation("fuota-multicast:task-1").is_empty());
217        assert!(!d.relations.contains_key("fuota-multicast:task-1"));
218    }
219}