1use 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
48fn 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 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 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 assert_eq!(object.key, key_str("cable=c1"));
267 }
268
269 #[test]
270 fn import_keeps_attrs_for_unknown_type() {
271 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 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}