Skip to main content

alembic_engine/
extract.rs

1//! import of canonical inventory from backend state.
2
3use crate::state::StateStore;
4use crate::types::Observer;
5use alembic_core::{key_string, uid_v5, Inventory, JsonMap, Object, Schema, TypeName};
6use anyhow::Result;
7
8#[derive(Debug)]
9pub struct ImportReport {
10    pub inventory: Inventory,
11}
12
13pub async fn import_inventory(
14    adapter: &(dyn Observer + '_),
15    schema: &Schema,
16    types: &[TypeName],
17    state: &StateStore,
18) -> Result<ImportReport> {
19    let observed = adapter.read(schema, types, state).await?;
20
21    let mut objects: Vec<_> = observed.by_key.values().cloned().collect();
22    objects.sort_by(|a, b| {
23        (a.type_name.as_str().to_string(), key_string(&a.key))
24            .cmp(&(b.type_name.as_str().to_string(), key_string(&b.key)))
25    });
26
27    let mut inventory_objects = Vec::new();
28    for mut object in objects {
29        project_attrs(schema, &object.type_name, &mut object.attrs);
30        let uid = uid_v5(object.type_name.as_str(), &key_string(&object.key));
31        inventory_objects.push(Object {
32            uid,
33            type_name: object.type_name,
34            key: object.key,
35            attrs: object.attrs,
36            source: None,
37        });
38    }
39
40    Ok(ImportReport {
41        inventory: Inventory {
42            schema: schema.clone(),
43            objects: inventory_objects,
44        },
45    })
46}
47
48/// project observed attrs onto the schema by dropping any attr key that is not
49/// declared in the type's `fields`.
50///
51/// backends return server-computed fields (e.g. `dcim.cable.last_updated`) that
52/// are not in the schema and could never be managed. left in place they make the
53/// imported inventory fail `validate_inventory` with `ExtraAttrField`, so we
54/// mirror that check here (validation.rs: `type_schema.fields.contains_key`) and
55/// drop the offending keys, warning once per key. types absent from the schema
56/// are left untouched, matching validation's early return for unknown types
57/// (this preserves the flat / custom-schema tier). key fields are never touched;
58/// they validate separately against `type_schema.key`.
59fn project_attrs(schema: &Schema, type_name: &TypeName, attrs: &mut JsonMap) {
60    let Some(type_schema) = schema.types.get(type_name.as_str()) else {
61        return;
62    };
63
64    let mut dropped = Vec::new();
65    for field in attrs.keys() {
66        if !type_schema.fields.contains_key(field) {
67            dropped.push(field.clone());
68        }
69    }
70
71    for field in &dropped {
72        tracing::warn!(
73            "import: dropping undeclared attr {}.{}; server-computed field is not in the schema and cannot be managed",
74            type_name.as_str(),
75            field
76        );
77    }
78
79    attrs.retain(|field, _| type_schema.fields.contains_key(field));
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85    use crate::types::{BackendId, ObservedState};
86    use crate::Observer;
87    use alembic_core::{
88        key_string, FieldSchema, FieldType, JsonMap, Key, Schema, TypeName, TypeSchema,
89    };
90    use async_trait::async_trait;
91    use futures::executor::block_on;
92    use serde_json::json;
93    use std::collections::BTreeMap;
94
95    struct MockAdapter {
96        observed: ObservedState,
97    }
98
99    #[async_trait]
100    impl Observer for MockAdapter {
101        async fn read(
102            &self,
103            _schema: &Schema,
104            _types: &[TypeName],
105            _state: &crate::state::StateStore,
106        ) -> anyhow::Result<ObservedState> {
107            Ok(self.observed.clone())
108        }
109    }
110
111    fn observed_state() -> Result<ObservedState> {
112        let mut state = ObservedState::default();
113        state.insert(crate::ObservedObject {
114            type_name: TypeName::new("dcim.site"),
115            key: key_str("site=fra1"),
116            attrs: attrs_map(json!({
117                "name": "FRA1",
118                "slug": "fra1",
119                "status": "active"
120            })),
121            backend_id: Some(BackendId::Int(1)),
122        })?;
123        Ok(state)
124    }
125
126    fn key_str(raw: &str) -> Key {
127        let mut map = BTreeMap::new();
128        for segment in raw.split('/') {
129            let (field, value) = segment
130                .split_once('=')
131                .unwrap_or_else(|| panic!("invalid key segment: {segment}"));
132            map.insert(
133                field.to_string(),
134                serde_json::Value::String(value.to_string()),
135            );
136        }
137        Key::from(map)
138    }
139
140    fn attrs_map(value: serde_json::Value) -> JsonMap {
141        let serde_json::Value::Object(map) = value else {
142            panic!("attrs must be a json object");
143        };
144        map.into_iter().collect::<BTreeMap<_, _>>().into()
145    }
146
147    fn schema_for_observed(state: &ObservedState) -> Schema {
148        let mut types: BTreeMap<String, TypeSchema> = BTreeMap::new();
149        for object in state.by_key.values() {
150            let entry = types
151                .entry(object.type_name.as_str().to_string())
152                .or_insert_with(|| TypeSchema {
153                    key: BTreeMap::new(),
154                    fields: BTreeMap::new(),
155                });
156            for field in object.key.keys() {
157                entry.key.entry(field.clone()).or_insert(FieldSchema {
158                    r#type: FieldType::Json,
159                    required: true,
160                    nullable: false,
161                    description: None,
162                    format: None,
163                    pattern: None,
164                });
165            }
166            for field in object.attrs.keys() {
167                entry.fields.entry(field.clone()).or_insert(FieldSchema {
168                    r#type: FieldType::Json,
169                    required: false,
170                    nullable: true,
171                    description: None,
172                    format: None,
173                    pattern: None,
174                });
175            }
176        }
177        Schema { types }
178    }
179
180    #[test]
181    fn import_inventory_uses_stable_uid() {
182        let adapter = MockAdapter {
183            observed: observed_state().unwrap(),
184        };
185        let schema = schema_for_observed(&adapter.observed);
186        let state = crate::state::StateStore::new(None, crate::state::StateData::default());
187        let report = block_on(import_inventory(&adapter, &schema, &[], &state)).unwrap();
188        assert_eq!(report.inventory.objects.len(), 1);
189        let object = &report.inventory.objects[0];
190        let key = key_str("site=fra1");
191        assert_eq!(object.key, key);
192        assert_eq!(object.uid, uid_v5("dcim.site", &key_string(&key)));
193    }
194
195    fn field_schema(required: bool, nullable: bool) -> FieldSchema {
196        FieldSchema {
197            r#type: FieldType::Json,
198            required,
199            nullable,
200            description: None,
201            format: None,
202            pattern: None,
203        }
204    }
205
206    fn type_schema(key_fields: &[&str], attr_fields: &[&str]) -> TypeSchema {
207        let mut key = BTreeMap::new();
208        for field in key_fields {
209            key.insert((*field).to_string(), field_schema(true, false));
210        }
211        let mut fields = BTreeMap::new();
212        for field in attr_fields {
213            fields.insert((*field).to_string(), field_schema(false, true));
214        }
215        TypeSchema { key, fields }
216    }
217
218    /// build a schema declaring exactly the given key/attr fields per type.
219    fn schema_of(entries: &[(&str, &[&str], &[&str])]) -> Schema {
220        let mut types = BTreeMap::new();
221        for (name, key_fields, attr_fields) in entries {
222            types.insert((*name).to_string(), type_schema(key_fields, attr_fields));
223        }
224        Schema { types }
225    }
226
227    fn observed_of(items: &[(&str, &str, serde_json::Value)]) -> ObservedState {
228        let mut state = ObservedState::default();
229        for (index, (type_name, key, attrs)) in items.iter().enumerate() {
230            state
231                .insert(crate::ObservedObject {
232                    type_name: TypeName::new(*type_name),
233                    key: key_str(key),
234                    attrs: attrs_map(attrs.clone()),
235                    backend_id: Some(BackendId::Int((index + 1) as u64)),
236                })
237                .unwrap();
238        }
239        state
240    }
241
242    fn import(observed: ObservedState, schema: &Schema) -> ImportReport {
243        let adapter = MockAdapter { observed };
244        let state = crate::state::StateStore::new(None, crate::state::StateData::default());
245        block_on(import_inventory(&adapter, schema, &[], &state)).unwrap()
246    }
247
248    #[test]
249    fn import_drops_undeclared_attrs() {
250        // `last_updated` is server-computed and not in the schema; `label` is declared.
251        let observed = observed_of(&[(
252            "dcim.cable",
253            "cable=c1",
254            json!({ "label": "uplink", "last_updated": "2026-06-09T00:00:00Z" }),
255        )]);
256        let schema = schema_of(&[("dcim.cable", &["cable"], &["label"])]);
257        let report = import(observed, &schema);
258
259        let object = &report.inventory.objects[0];
260        assert!(object.attrs.contains_key("label"), "declared attr is kept");
261        assert!(
262            !object.attrs.contains_key("last_updated"),
263            "undeclared attr is dropped"
264        );
265        // key fields are never projected away.
266        assert_eq!(object.key, key_str("cable=c1"));
267    }
268
269    #[test]
270    fn import_keeps_attrs_for_unknown_type() {
271        // the observed type is absent from the schema, so attrs pass through untouched
272        // (validation short-circuits for unknown types, preserving the flat-schema tier).
273        let observed = observed_of(&[(
274            "custom.thing",
275            "id=x1",
276            json!({ "anything": "goes", "count": 7 }),
277        )]);
278        let schema = schema_of(&[("dcim.cable", &["cable"], &["label"])]);
279        let report = import(observed, &schema);
280
281        let object = &report.inventory.objects[0];
282        assert!(object.attrs.contains_key("anything"));
283        assert!(object.attrs.contains_key("count"));
284        assert_eq!(object.attrs.len(), 2);
285    }
286
287    #[test]
288    fn import_inventory_passes_validation() {
289        // red-green: without projection the imported inventory carries `last_updated`,
290        // which fails validate_inventory with ExtraAttrField.
291        let observed = observed_of(&[(
292            "dcim.cable",
293            "cable=c1",
294            json!({ "label": "uplink", "last_updated": "2026-06-09T00:00:00Z" }),
295        )]);
296        let schema = schema_of(&[("dcim.cable", &["cable"], &["label"])]);
297        let report = import(observed, &schema);
298
299        let validation = alembic_core::validate_inventory(&report.inventory);
300        assert!(
301            !validation
302                .errors
303                .iter()
304                .any(|error| matches!(error, alembic_core::ValidationError::ExtraAttrField { .. })),
305            "import must not produce ExtraAttrField errors: {:?}",
306            validation.errors
307        );
308    }
309
310    #[test]
311    fn import_projects_each_type_independently() {
312        let observed = observed_of(&[
313            (
314                "dcim.cable",
315                "cable=c1",
316                json!({ "label": "uplink", "last_updated": "t" }),
317            ),
318            (
319                "dcim.site",
320                "site=fra1",
321                json!({ "name": "FRA1", "created": "t" }),
322            ),
323        ]);
324        let schema = schema_of(&[
325            ("dcim.cable", &["cable"], &["label"]),
326            ("dcim.site", &["site"], &["name"]),
327        ]);
328        let report = import(observed, &schema);
329
330        let cable = report
331            .inventory
332            .objects
333            .iter()
334            .find(|object| object.type_name.as_str() == "dcim.cable")
335            .expect("cable imported");
336        assert!(cable.attrs.contains_key("label"));
337        assert!(!cable.attrs.contains_key("last_updated"));
338
339        let site = report
340            .inventory
341            .objects
342            .iter()
343            .find(|object| object.type_name.as_str() == "dcim.site")
344            .expect("site imported");
345        assert!(site.attrs.contains_key("name"));
346        assert!(!site.attrs.contains_key("created"));
347    }
348}