Skip to main content

elasticctl_api/state/
diff.rs

1use super::reports::{DanglingPointer, DiffReport, ExceptionDrift, ListChange, Mirror};
2use crate::diff::{Change, Drift, FieldChange};
3use crate::exceptions;
4use crate::model::{ExceptionItem, ExceptionList, ListKey, Rule, exception_refs};
5use crate::normalize;
6use elasticctl_core::{Error, ErrorKind, Result, Transport};
7use serde_json::{Map, Value};
8use std::collections::{BTreeMap, BTreeSet};
9use std::path::Path;
10
11#[derive(Debug, Clone)]
12pub(crate) enum ListOp {
13    Create(ExceptionList),
14    Update {
15        before: ExceptionList,
16        after: ExceptionList,
17    },
18}
19
20#[derive(Debug, Clone)]
21pub(crate) enum ItemOp {
22    Create(ExceptionItem),
23    Update {
24        before: ExceptionItem,
25        after: ExceptionItem,
26    },
27    Remove {
28        before: ExceptionItem,
29        namespace_type: String,
30    },
31}
32
33#[derive(Debug)]
34pub(crate) struct ExceptionPlan {
35    pub drift: ExceptionDrift,
36    pub list_ops: Vec<ListOp>,
37    pub item_ops: Vec<ItemOp>,
38    pub resolvable: BTreeSet<ListKey>,
39}
40
41pub async fn diff(
42    t: &Transport,
43    dir: &Path,
44    selectors: &[String],
45    tag: Option<&str>,
46    search: Option<&str>,
47    source: crate::rules::RuleSource,
48) -> Result<DiffReport> {
49    let Mirror {
50        rules: local_all,
51        lists,
52        items,
53        value_lists,
54        has_value_lists,
55    } = super::mirror::read_mirror(dir)?;
56    let scope = super::scope_of(t, selectors, tag, search, source, &local_all, "compare").await?;
57    let (local, out_of_scope) = if scope.is_scoped() {
58        (scope.narrow(local_all), 0)
59    } else {
60        scope.split_by_source(local_all)
61    };
62    let remote = scope.remote(t).await?;
63    let drift = Drift::compute(&local, &remote)?;
64
65    let plan = exception_plan(t, lists.clone(), items.clone(), &local, &remote).await?;
66
67    let changes: Vec<Change> = drift
68        .changes
69        .iter()
70        .filter(|c| !matches!(c, Change::Unchanged { .. }))
71        .cloned()
72        .collect();
73
74    let values = super::values::plan(
75        t,
76        super::values::Inputs {
77            pairs: &value_lists,
78            has_root: has_value_lists,
79            lists: &lists,
80            items: &items,
81            local: &local,
82            remote: &remote,
83            exception_plan: &plan,
84        },
85        false,
86    )
87    .await?;
88    let exceptions = plan.drift;
89    Ok(DiffReport {
90        clean: drift.is_clean()
91            && exceptions.is_clean()
92            && values.drift.as_ref().is_none_or(|v| v.is_clean()),
93        local: local.len(),
94        remote: remote.len(),
95        changes,
96        exceptions,
97        value_lists: values.drift,
98        out_of_scope,
99        selected: scope.is_scoped().then(|| scope.selected()),
100        local_total: scope.is_scoped().then_some(scope.local_total),
101    })
102}
103
104fn map_field_changes(before: &Map<String, Value>, after: &Map<String, Value>) -> Vec<FieldChange> {
105    let mut keys: Vec<&String> = before.keys().chain(after.keys()).collect();
106    keys.sort();
107    keys.dedup();
108    keys.into_iter()
109        .filter_map(|k| {
110            let bv = before.get(k).cloned().unwrap_or(Value::Null);
111            let av = after.get(k).cloned().unwrap_or(Value::Null);
112            (bv != av).then(|| FieldChange {
113                field: k.clone(),
114                before: bv,
115                after: av,
116            })
117        })
118        .collect()
119}
120
121fn list_field_changes(before: &ExceptionList, after: &ExceptionList) -> Vec<FieldChange> {
122    map_field_changes(before.as_map(), after.as_map())
123}
124
125fn index_lists(lists: &[ExceptionList], side: &str) -> Result<BTreeMap<ListKey, ExceptionList>> {
126    let mut map = BTreeMap::new();
127    for (idx, list) in lists.iter().enumerate() {
128        let key = list.key().map_err(|_| {
129            Error::new(
130                ErrorKind::Error,
131                format!("{side} exception list at position {idx} has an unreadable list_id"),
132            )
133        })?;
134        if map
135            .insert(key.clone(), normalize::canonical_list(list))
136            .is_some()
137        {
138            return Err(Error::new(
139                ErrorKind::Conflict,
140                format!(
141                    "{side} has two exception lists with list_id \"{}\" in namespace \"{}\"",
142                    key.list_id, key.namespace_type
143                ),
144            ));
145        }
146    }
147    Ok(map)
148}
149
150fn list_drift(
151    local: &BTreeMap<ListKey, ExceptionList>,
152    remote: &BTreeMap<ListKey, ExceptionList>,
153) -> Result<(ExceptionDrift, Vec<ListOp>)> {
154    let mut changes = Vec::new();
155    let mut ops = Vec::new();
156    let mut keys: Vec<&ListKey> = local.keys().chain(remote.keys()).collect();
157    keys.sort();
158    keys.dedup();
159
160    for key in keys {
161        match (local.get(key), remote.get(key)) {
162            (Some(local_list), None) => {
163                changes.push(ListChange::Added {
164                    list_id: key.list_id.clone(),
165                    name: local_list.name().to_string(),
166                });
167                ops.push(ListOp::Create(local_list.clone()));
168            }
169            (None, Some(remote_list)) => {
170                changes.push(ListChange::RemoteOnly {
171                    list_id: key.list_id.clone(),
172                    name: remote_list.name().to_string(),
173                });
174            }
175            (Some(local_list), Some(remote_list)) => {
176                let fields = list_field_changes(remote_list, local_list);
177                if fields.is_empty() {
178                    changes.push(ListChange::Unchanged {
179                        list_id: key.list_id.clone(),
180                    });
181                } else {
182                    changes.push(ListChange::Modified {
183                        list_id: key.list_id.clone(),
184                        name: local_list.name().to_string(),
185                        fields,
186                    });
187                    ops.push(ListOp::Update {
188                        before: remote_list.clone(),
189                        after: local_list.clone(),
190                    });
191                }
192            }
193            (None, None) => unreachable!("a key came from one of the two maps"),
194        }
195    }
196
197    Ok((
198        ExceptionDrift {
199            local: local.len(),
200            remote: remote.len(),
201            changes,
202            dangling: Vec::new(),
203        },
204        ops,
205    ))
206}
207
208async fn fetch_remote_lists(t: &Transport, keys: &BTreeSet<ListKey>) -> Result<Vec<ExceptionList>> {
209    let mut out = Vec::new();
210    for key in keys {
211        match exceptions::get_list(t, key).await {
212            Ok(list) => out.push(list),
213            Err(e) if e.kind == ErrorKind::NotFound => {}
214            Err(e) => return Err(e),
215        }
216    }
217    Ok(out)
218}
219
220fn dangling_pointers(
221    raw_remote: &[Rule],
222    live: &BTreeMap<ListKey, String>,
223) -> Vec<DanglingPointer> {
224    let mut out = Vec::new();
225    for rule in raw_remote {
226        let Ok(rule_id) = rule.rule_id() else {
227            continue;
228        };
229        for r in exception_refs(rule) {
230            let key = ListKey {
231                list_id: r.list_id.clone(),
232                namespace_type: r.namespace_type.clone(),
233            };
234            let live_id = live.get(&key).cloned();
235            let stored = r.id.clone();
236            if live_id.as_deref() != stored.as_deref() {
237                out.push(DanglingPointer {
238                    rule_id: rule_id.to_string(),
239                    list_id: r.list_id,
240                    stored_id: stored.map(Value::String).unwrap_or(Value::Null),
241                    live_id,
242                });
243            }
244        }
245    }
246    out
247}
248
249fn item_reconciliation(
250    both: &BTreeSet<ListKey>,
251    local_items: &BTreeMap<ListKey, Vec<ExceptionItem>>,
252    remote_items: &BTreeMap<ListKey, Vec<ExceptionItem>>,
253) -> Result<(Vec<ListChange>, Vec<ItemOp>)> {
254    let index = |items: &[ExceptionItem], side: &str| -> Result<BTreeMap<String, ExceptionItem>> {
255        let mut m = BTreeMap::new();
256        for i in items {
257            let Some(id) = i.item_id().ok() else { continue };
258            if m.insert(id.to_string(), i.clone()).is_some() {
259                return Err(Error::new(
260                    ErrorKind::Conflict,
261                    format!("{side} has two exception items with item_id \"{id}\""),
262                ));
263            }
264        }
265        Ok(m)
266    };
267
268    let mut changes = Vec::new();
269    let mut ops = Vec::new();
270
271    for key in both {
272        let local = local_items.get(key).cloned().unwrap_or_default();
273        let remote = remote_items.get(key).cloned().unwrap_or_default();
274        let local_by_id = index(&local, "local")?;
275        let remote_by_id = index(&remote, "remote")?;
276
277        let mut ids: Vec<&String> = local_by_id.keys().chain(remote_by_id.keys()).collect();
278        ids.sort();
279        ids.dedup();
280
281        for item_id in ids {
282            match (local_by_id.get(item_id), remote_by_id.get(item_id)) {
283                (Some(l), None) => {
284                    changes.push(ListChange::ItemAdded {
285                        list_id: key.list_id.clone(),
286                        item_id: item_id.clone(),
287                    });
288                    ops.push(ItemOp::Create(l.clone()));
289                }
290                (None, Some(remote_item)) => {
291                    changes.push(ListChange::ItemRemoved {
292                        list_id: key.list_id.clone(),
293                        item_id: item_id.clone(),
294                    });
295                    ops.push(ItemOp::Remove {
296                        before: normalize::canonical_item(remote_item),
297                        namespace_type: key.namespace_type.clone(),
298                    });
299                }
300                (Some(l), Some(r)) => {
301                    let local_canon = normalize::canonical_item(l);
302                    let remote_canon = normalize::canonical_item(r);
303                    if local_canon != remote_canon {
304                        let fields = map_field_changes(remote_canon.as_map(), local_canon.as_map());
305                        changes.push(ListChange::ItemModified {
306                            list_id: key.list_id.clone(),
307                            item_id: item_id.clone(),
308                            fields,
309                        });
310                        ops.push(ItemOp::Update {
311                            before: remote_canon,
312                            after: l.clone(),
313                        });
314                    }
315                }
316                (None, None) => unreachable!("an item id came from one of the two maps"),
317            }
318        }
319    }
320
321    Ok((changes, ops))
322}
323
324pub(crate) async fn exception_plan(
325    t: &Transport,
326    mirror_lists: Vec<ExceptionList>,
327    mirror_items: Vec<ExceptionItem>,
328    local_rules: &[Rule],
329    remote_rules: &[Rule],
330) -> Result<ExceptionPlan> {
331    let wanted: BTreeSet<ListKey> = super::referenced_keys(local_rules)
332        .into_iter()
333        .chain(super::referenced_keys(remote_rules))
334        .collect();
335
336    let remote_lists = fetch_remote_lists(t, &wanted).await?;
337    let local_lists: Vec<ExceptionList> = mirror_lists
338        .into_iter()
339        .filter(|l| l.key().map(|k| wanted.contains(&k)).unwrap_or(false))
340        .collect();
341
342    let mut live: BTreeMap<ListKey, String> = BTreeMap::new();
343    for list in &remote_lists {
344        if let Ok(key) = list.key()
345            && let Some(id) = list.as_map().get("id").and_then(Value::as_str)
346        {
347            live.insert(key, id.to_string());
348        }
349    }
350
351    let local_indexed = index_lists(&local_lists, "local")?;
352    let remote_indexed = index_lists(&remote_lists, "remote")?;
353    let (mut drift, ops) = list_drift(&local_indexed, &remote_indexed)?;
354
355    let both: BTreeSet<ListKey> = local_indexed
356        .keys()
357        .filter(|k| remote_indexed.contains_key(*k))
358        .cloned()
359        .collect();
360
361    let mut resolvable: BTreeSet<ListKey> =
362        remote_lists.iter().filter_map(|l| l.key().ok()).collect();
363    for op in &ops {
364        if let ListOp::Create(list) = op {
365            resolvable.insert(list.key()?);
366        }
367    }
368
369    let local_items = group_items(mirror_items)?;
370    let mut remote_items: BTreeMap<ListKey, Vec<ExceptionItem>> = BTreeMap::new();
371    for key in &both {
372        remote_items.insert(key.clone(), exceptions::find_items(t, key).await?);
373    }
374
375    let mut item_ops = Vec::new();
376    for op in &ops {
377        if let ListOp::Create(list) = op {
378            let key = list.key()?;
379            if let Some(items) = local_items.get(&key) {
380                item_ops.extend(items.iter().map(|i| ItemOp::Create(i.clone())));
381            }
382        }
383    }
384
385    let (item_changes, reconciled) = item_reconciliation(&both, &local_items, &remote_items)?;
386    item_ops.extend(reconciled);
387
388    drift.dangling = dangling_pointers(remote_rules, &live);
389    drift.changes.extend(item_changes);
390
391    Ok(ExceptionPlan {
392        drift,
393        list_ops: ops,
394        item_ops,
395        resolvable,
396    })
397}
398
399fn group_items(items: Vec<ExceptionItem>) -> Result<BTreeMap<ListKey, Vec<ExceptionItem>>> {
400    let mut map: BTreeMap<ListKey, Vec<ExceptionItem>> = BTreeMap::new();
401    for item in items {
402        validate_grouped_item(&item)?;
403        let key = ListKey {
404            list_id: item.list_id()?.to_string(),
405            namespace_type: item.namespace_type().to_string(),
406        };
407        map.entry(key).or_default().push(item);
408    }
409    for grouped in map.values_mut() {
410        normalize::sort_items(grouped);
411    }
412    Ok(map)
413}
414
415fn validate_grouped_item(item: &ExceptionItem) -> Result<()> {
416    let item_id = item.item_id()?;
417    if item_id.is_empty() {
418        return Err(Error::new(
419            ErrorKind::Error,
420            "exception item field item_id must be a non-empty string",
421        ));
422    }
423    let list_id = item.list_id()?;
424    if list_id.is_empty() {
425        return Err(Error::new(
426            ErrorKind::Error,
427            "exception item field list_id must be a non-empty string",
428        ));
429    }
430    match item.as_map().get("namespace_type") {
431        None => Ok(()),
432        Some(Value::String(value)) if !value.is_empty() => Ok(()),
433        Some(_) => Err(Error::new(
434            ErrorKind::Error,
435            "exception item field namespace_type must be a non-empty string",
436        )),
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use super::*;
443
444    #[test]
445    fn grouping_rejects_an_item_without_a_list_id() {
446        let item = ExceptionItem::from_value(serde_json::json!({
447            "item_id": "orphan",
448            "type": "simple",
449            "entries": [],
450        }))
451        .unwrap();
452
453        let error = group_items(vec![item]).unwrap_err();
454
455        assert_eq!(error.kind, ErrorKind::Error);
456        assert!(error.message.contains("list_id"), "{}", error.message);
457    }
458}