Skip to main content

alembic_engine/
extract.rs

1//! import of canonical inventory from backend state.
2
3use crate::state::StateStore;
4use crate::types::Adapter;
5use alembic_core::{key_string, uid_v5, Inventory, 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 Adapter + '_),
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 object in objects {
29        let uid = uid_v5(object.type_name.as_str(), &key_string(&object.key));
30        inventory_objects.push(Object {
31            uid,
32            type_name: object.type_name,
33            key: object.key,
34            attrs: object.attrs,
35            source: None,
36        });
37    }
38
39    Ok(ImportReport {
40        inventory: Inventory {
41            schema: schema.clone(),
42            objects: inventory_objects,
43        },
44    })
45}
46
47#[cfg(test)]
48mod tests {
49    use super::*;
50    use crate::types::{BackendId, ObservedState};
51    use crate::Adapter;
52    use alembic_core::{
53        key_string, FieldSchema, FieldType, JsonMap, Key, Schema, TypeName, TypeSchema,
54    };
55    use async_trait::async_trait;
56    use futures::executor::block_on;
57    use serde_json::json;
58    use std::collections::BTreeMap;
59
60    struct MockAdapter {
61        observed: ObservedState,
62    }
63
64    #[async_trait]
65    impl Adapter for MockAdapter {
66        async fn read(
67            &self,
68            _schema: &Schema,
69            _types: &[TypeName],
70            _state: &crate::state::StateStore,
71        ) -> anyhow::Result<ObservedState> {
72            Ok(self.observed.clone())
73        }
74
75        async fn write(
76            &self,
77            _schema: &Schema,
78            _ops: &[crate::Op],
79            _state: &crate::state::StateStore,
80        ) -> anyhow::Result<crate::ApplyReport> {
81            unimplemented!("not used in import tests")
82        }
83    }
84
85    fn observed_state() -> ObservedState {
86        let mut state = ObservedState::default();
87        state.insert(crate::ObservedObject {
88            type_name: TypeName::new("dcim.site"),
89            key: key_str("site=fra1"),
90            attrs: attrs_map(json!({
91                "name": "FRA1",
92                "slug": "fra1",
93                "status": "active"
94            })),
95            backend_id: Some(BackendId::Int(1)),
96        });
97        state
98    }
99
100    fn key_str(raw: &str) -> Key {
101        let mut map = BTreeMap::new();
102        for segment in raw.split('/') {
103            let (field, value) = segment
104                .split_once('=')
105                .unwrap_or_else(|| panic!("invalid key segment: {segment}"));
106            map.insert(
107                field.to_string(),
108                serde_json::Value::String(value.to_string()),
109            );
110        }
111        Key::from(map)
112    }
113
114    fn attrs_map(value: serde_json::Value) -> JsonMap {
115        let serde_json::Value::Object(map) = value else {
116            panic!("attrs must be a json object");
117        };
118        map.into_iter().collect::<BTreeMap<_, _>>().into()
119    }
120
121    fn schema_for_observed(state: &ObservedState) -> Schema {
122        let mut types: BTreeMap<String, TypeSchema> = BTreeMap::new();
123        for object in state.by_key.values() {
124            let entry = types
125                .entry(object.type_name.as_str().to_string())
126                .or_insert_with(|| TypeSchema {
127                    key: BTreeMap::new(),
128                    fields: BTreeMap::new(),
129                });
130            for field in object.key.keys() {
131                entry.key.entry(field.clone()).or_insert(FieldSchema {
132                    r#type: FieldType::Json,
133                    required: true,
134                    nullable: false,
135                    description: None,
136                    format: None,
137                    pattern: None,
138                });
139            }
140            for field in object.attrs.keys() {
141                entry.fields.entry(field.clone()).or_insert(FieldSchema {
142                    r#type: FieldType::Json,
143                    required: false,
144                    nullable: true,
145                    description: None,
146                    format: None,
147                    pattern: None,
148                });
149            }
150        }
151        Schema { types }
152    }
153
154    #[test]
155    fn import_inventory_uses_stable_uid() {
156        let adapter = MockAdapter {
157            observed: observed_state(),
158        };
159        let schema = schema_for_observed(&adapter.observed);
160        let state = crate::state::StateStore::new(None, crate::state::StateData::default());
161        let report = block_on(import_inventory(&adapter, &schema, &[], &state)).unwrap();
162        assert_eq!(report.inventory.objects.len(), 1);
163        let object = &report.inventory.objects[0];
164        let key = key_str("site=fra1");
165        assert_eq!(object.key, key);
166        assert_eq!(object.uid, uid_v5("dcim.site", &key_string(&key)));
167    }
168}