Skip to main content

elasticctl_api/state/
diff.rs

1//! `state diff`, and the exception-list drift it shares with push.
2//!
3//! `diff` reports the plan `exception_plan` computes; `push` applies it. The
4//! plan is one concern with two consumers, which is why it lives here rather
5//! than in either command.
6
7use super::reports::{DanglingPointer, DiffReport, ExceptionDrift, ListChange, Mirror};
8use crate::diff::{Change, Drift, FieldChange};
9use crate::exceptions;
10use crate::model::{ExceptionItem, ExceptionList, ListKey, Rule, exception_refs};
11use crate::normalize;
12use elasticctl_core::{Error, ErrorKind, Result, Transport};
13use serde_json::{Map, Value};
14use std::collections::{BTreeMap, BTreeSet};
15use std::path::Path;
16
17/// A container write `push` will perform, in apply order.
18#[derive(Debug, Clone)]
19pub(crate) enum ListOp {
20    Create(ExceptionList),
21    Update {
22        before: ExceptionList,
23        after: ExceptionList,
24    },
25}
26
27/// An item write `push` will perform, in apply order.
28///
29/// `Remove` is the only deletion the state engine performs anywhere. It is
30/// sound only because `pull` always writes a container's full item set: the
31/// invariant is documented on `item_reconciliation` and at `pull.rs`'s
32/// fetch-site cross-reference (spec 5.4). An item absent locally is a delete
33/// instruction solely because no item-level selector exists.
34#[derive(Debug, Clone)]
35pub(crate) enum ItemOp {
36    Create(ExceptionItem),
37    Update {
38        before: ExceptionItem,
39        after: ExceptionItem,
40    },
41    Remove {
42        before: ExceptionItem,
43        namespace_type: String,
44    },
45}
46
47/// What `exception_plan` computed: the drift to report and the ordered writes
48/// `push` applies.
49#[derive(Debug)]
50pub(crate) struct ExceptionPlan {
51    pub drift: ExceptionDrift,
52    pub list_ops: Vec<ListOp>,
53    pub item_ops: Vec<ItemOp>,
54    /// Keys resolvable at apply time: already on the stack or about to be
55    /// created. `plan_push` refuses any other referenced key before a write.
56    pub resolvable: BTreeSet<ListKey>,
57}
58
59/// Compare the mirror to the stack, scoped by `source`. The `custom`/`all`
60/// default lives on the clap flag, where `--help` shows it (spec 5.5).
61pub async fn diff(
62    t: &Transport,
63    dir: &Path,
64    selectors: &[String],
65    tag: Option<&str>,
66    search: Option<&str>,
67    source: crate::rules::RuleSource,
68) -> Result<DiffReport> {
69    let Mirror {
70        rules: local_all,
71        lists,
72        items,
73    } = super::mirror::read_mirror(dir)?;
74    let scope = super::scope_of(t, selectors, tag, search, source, &local_all, "compare").await?;
75    // A selector narrows both sides; with none, the `--source` scope decides.
76    // A local file outside that scope is reported as `out_of_scope`, never as a
77    // pending create (spec 5.5, the 0.1 upgrade guard).
78    let (local, out_of_scope) = if scope.is_scoped() {
79        (scope.narrow(local_all), 0)
80    } else {
81        scope.split_by_source(local_all)
82    };
83    let remote = scope.remote(t).await?;
84    let drift = Drift::compute(&local, &remote)?;
85
86    let plan = exception_plan(t, lists, items, &local, &remote).await?;
87
88    // Omit unchanged rules so the diff shows only differences.
89    let changes: Vec<Change> = drift
90        .changes
91        .iter()
92        .filter(|c| !matches!(c, Change::Unchanged { .. }))
93        .cloned()
94        .collect();
95
96    let exceptions = plan.drift;
97    Ok(DiffReport {
98        clean: drift.is_clean() && exceptions.is_clean(),
99        local: local.len(),
100        remote: remote.len(),
101        changes,
102        exceptions,
103        out_of_scope,
104        selected: scope.is_scoped().then(|| scope.selected()),
105        local_total: scope.is_scoped().then_some(scope.local_total),
106    })
107}
108
109/// Field-level differences between two object maps, in key order.
110fn map_field_changes(before: &Map<String, Value>, after: &Map<String, Value>) -> Vec<FieldChange> {
111    let mut keys: Vec<&String> = before.keys().chain(after.keys()).collect();
112    keys.sort();
113    keys.dedup();
114    keys.into_iter()
115        .filter_map(|k| {
116            let bv = before.get(k).cloned().unwrap_or(Value::Null);
117            let av = after.get(k).cloned().unwrap_or(Value::Null);
118            (bv != av).then(|| FieldChange {
119                field: k.clone(),
120                before: bv,
121                after: av,
122            })
123        })
124        .collect()
125}
126
127/// Field-level drift between two canonical containers.
128fn list_field_changes(before: &ExceptionList, after: &ExceptionList) -> Vec<FieldChange> {
129    map_field_changes(before.as_map(), after.as_map())
130}
131
132/// Index containers by identity, canonicalizing each and refusing a duplicate
133/// `list_id` within one side.
134fn index_lists(lists: &[ExceptionList], side: &str) -> Result<BTreeMap<ListKey, ExceptionList>> {
135    let mut map = BTreeMap::new();
136    for (idx, list) in lists.iter().enumerate() {
137        let key = list.key().map_err(|_| {
138            Error::new(
139                ErrorKind::Error,
140                format!("{side} exception list at position {idx} has an unreadable list_id"),
141            )
142        })?;
143        if map
144            .insert(key.clone(), normalize::canonical_list(list))
145            .is_some()
146        {
147            return Err(Error::new(
148                ErrorKind::Conflict,
149                format!(
150                    "{side} has two exception lists with list_id \"{}\" in namespace \"{}\"",
151                    key.list_id, key.namespace_type
152                ),
153            ));
154        }
155    }
156    Ok(map)
157}
158
159/// Container drift and the ordered container writes. Item drift is computed
160/// separately, because items reconcile only for containers present on both
161/// sides.
162fn list_drift(
163    local: &BTreeMap<ListKey, ExceptionList>,
164    remote: &BTreeMap<ListKey, ExceptionList>,
165) -> Result<(ExceptionDrift, Vec<ListOp>)> {
166    let mut changes = Vec::new();
167    let mut ops = Vec::new();
168    let mut keys: Vec<&ListKey> = local.keys().chain(remote.keys()).collect();
169    keys.sort();
170    keys.dedup();
171
172    for key in keys {
173        match (local.get(key), remote.get(key)) {
174            (Some(local_list), None) => {
175                changes.push(ListChange::Added {
176                    list_id: key.list_id.clone(),
177                    name: local_list.name().to_string(),
178                });
179                ops.push(ListOp::Create(local_list.clone()));
180            }
181            (None, Some(remote_list)) => {
182                changes.push(ListChange::RemoteOnly {
183                    list_id: key.list_id.clone(),
184                    name: remote_list.name().to_string(),
185                });
186            }
187            (Some(local_list), Some(remote_list)) => {
188                let fields = list_field_changes(remote_list, local_list);
189                if fields.is_empty() {
190                    changes.push(ListChange::Unchanged {
191                        list_id: key.list_id.clone(),
192                    });
193                } else {
194                    changes.push(ListChange::Modified {
195                        list_id: key.list_id.clone(),
196                        name: local_list.name().to_string(),
197                        fields,
198                    });
199                    ops.push(ListOp::Update {
200                        before: remote_list.clone(),
201                        after: local_list.clone(),
202                    });
203                }
204            }
205            (None, None) => unreachable!("a key came from one of the two maps"),
206        }
207    }
208
209    Ok((
210        ExceptionDrift {
211            local: local.len(),
212            remote: remote.len(),
213            changes,
214            dangling: Vec::new(),
215        },
216        ops,
217    ))
218}
219
220/// Fetch the remote containers for the given keys, skipping a key with no live
221/// container (a dangling pointer `diff` reports in spec 4.5).
222async fn fetch_remote_lists(t: &Transport, keys: &BTreeSet<ListKey>) -> Result<Vec<ExceptionList>> {
223    let mut out = Vec::new();
224    for key in keys {
225        match exceptions::get_list(t, key).await {
226            Ok(list) => out.push(list),
227            Err(e) if e.kind == ErrorKind::NotFound => {}
228            Err(e) => return Err(e),
229        }
230    }
231    Ok(out)
232}
233
234/// Spec 4.5. Compare each remote rule's stored pointer against the live
235/// container for its `list_id`. `comparable` has stripped this field by the
236/// time `Drift::compute` runs, so the check works on the raw response.
237fn dangling_pointers(
238    raw_remote: &[Rule],
239    live: &BTreeMap<ListKey, String>,
240) -> Vec<DanglingPointer> {
241    let mut out = Vec::new();
242    for rule in raw_remote {
243        let Ok(rule_id) = rule.rule_id() else {
244            continue;
245        };
246        for r in exception_refs(rule) {
247            let key = ListKey {
248                list_id: r.list_id.clone(),
249                namespace_type: r.namespace_type.clone(),
250            };
251            let live_id = live.get(&key).cloned();
252            let stored = r.id.clone();
253            if live_id.as_deref() != stored.as_deref() {
254                out.push(DanglingPointer {
255                    rule_id: rule_id.to_string(),
256                    list_id: r.list_id,
257                    stored_id: stored.map(Value::String).unwrap_or(Value::Null),
258                    live_id,
259                });
260            }
261        }
262    }
263    out
264}
265
266/// Compare local and remote items for each container in `both`, emitting
267/// `ItemAdded`, `ItemModified`, `ItemRemoved`, or nothing per `item_id`.
268/// `ItemRemoved` is the engine's only actionable deletion (spec 5.4).
269///
270/// `ItemRemoved` assumes the local item set for a mirrored container is
271/// complete. That holds because `pull` always writes a container's items in
272/// full: state commands resolve selectors to `rule_id`s through `scope_of`,
273/// which narrows rules and nothing else, and there is no parameter by which a
274/// caller can mirror part of a container's items. If an item-level selector is
275/// ever added, an item absent locally stops being a delete instruction and the
276/// `ItemRemoved` handling below must be revisited before that selector ships.
277fn item_reconciliation(
278    both: &BTreeSet<ListKey>,
279    local_items: &BTreeMap<ListKey, Vec<ExceptionItem>>,
280    remote_items: &BTreeMap<ListKey, Vec<ExceptionItem>>,
281) -> Result<(Vec<ListChange>, Vec<ItemOp>)> {
282    // A duplicate `item_id` within one side is mirror corruption; refusing
283    // matches `index_lists`, which refuses a duplicate `list_id`. First-wins
284    // would silently hide which item the operator intended to keep.
285    let index = |items: &[ExceptionItem], side: &str| -> Result<BTreeMap<String, ExceptionItem>> {
286        let mut m = BTreeMap::new();
287        for i in items {
288            let Some(id) = i.item_id().ok() else { continue };
289            if m.insert(id.to_string(), i.clone()).is_some() {
290                return Err(Error::new(
291                    ErrorKind::Conflict,
292                    format!("{side} has two exception items with item_id \"{id}\""),
293                ));
294            }
295        }
296        Ok(m)
297    };
298
299    let mut changes = Vec::new();
300    let mut ops = Vec::new();
301
302    for key in both {
303        let local = local_items.get(key).cloned().unwrap_or_default();
304        let remote = remote_items.get(key).cloned().unwrap_or_default();
305        let local_by_id = index(&local, "local")?;
306        let remote_by_id = index(&remote, "remote")?;
307
308        let mut ids: Vec<&String> = local_by_id.keys().chain(remote_by_id.keys()).collect();
309        ids.sort();
310        ids.dedup();
311
312        for item_id in ids {
313            match (local_by_id.get(item_id), remote_by_id.get(item_id)) {
314                (Some(l), None) => {
315                    changes.push(ListChange::ItemAdded {
316                        list_id: key.list_id.clone(),
317                        item_id: item_id.clone(),
318                    });
319                    ops.push(ItemOp::Create(l.clone()));
320                }
321                (None, Some(remote_item)) => {
322                    changes.push(ListChange::ItemRemoved {
323                        list_id: key.list_id.clone(),
324                        item_id: item_id.clone(),
325                    });
326                    ops.push(ItemOp::Remove {
327                        before: normalize::canonical_item(remote_item),
328                        namespace_type: key.namespace_type.clone(),
329                    });
330                }
331                (Some(l), Some(r)) => {
332                    let local_canon = normalize::canonical_item(l);
333                    let remote_canon = normalize::canonical_item(r);
334                    if local_canon != remote_canon {
335                        let fields = map_field_changes(remote_canon.as_map(), local_canon.as_map());
336                        changes.push(ListChange::ItemModified {
337                            list_id: key.list_id.clone(),
338                            item_id: item_id.clone(),
339                            fields,
340                        });
341                        ops.push(ItemOp::Update {
342                            before: remote_canon,
343                            after: l.clone(),
344                        });
345                    }
346                }
347                (None, None) => unreachable!("an item id came from one of the two maps"),
348            }
349        }
350    }
351
352    Ok((changes, ops))
353}
354
355/// Compute exception drift and the ordered container/item writes, closing over
356/// the lists referenced by the local and remote rules in scope.
357pub(crate) async fn exception_plan(
358    t: &Transport,
359    mirror_lists: Vec<ExceptionList>,
360    mirror_items: Vec<ExceptionItem>,
361    local_rules: &[Rule],
362    remote_rules: &[Rule],
363) -> Result<ExceptionPlan> {
364    let wanted: BTreeSet<ListKey> = super::referenced_keys(local_rules)
365        .into_iter()
366        .chain(super::referenced_keys(remote_rules))
367        .collect();
368
369    let remote_lists = fetch_remote_lists(t, &wanted).await?;
370    let local_lists: Vec<ExceptionList> = mirror_lists
371        .into_iter()
372        .filter(|l| l.key().map(|k| wanted.contains(&k)).unwrap_or(false))
373        .collect();
374
375    // Live container ids, read from the raw fetched containers before
376    // `canonical_list` strips `id`. `dangling_pointers` compares the stored
377    // pointer against this.
378    let mut live: BTreeMap<ListKey, String> = BTreeMap::new();
379    for list in &remote_lists {
380        if let Ok(key) = list.key()
381            && let Some(id) = list.as_map().get("id").and_then(Value::as_str)
382        {
383            live.insert(key, id.to_string());
384        }
385    }
386
387    let local_indexed = index_lists(&local_lists, "local")?;
388    let remote_indexed = index_lists(&remote_lists, "remote")?;
389    let (mut drift, ops) = list_drift(&local_indexed, &remote_indexed)?;
390
391    // Only a container present on both sides reconciles its items. A created
392    // container writes its items wholesale; a remote-only container is never
393    // deleted, so its items are not touched.
394    let both: BTreeSet<ListKey> = local_indexed
395        .keys()
396        .filter(|k| remote_indexed.contains_key(*k))
397        .cloned()
398        .collect();
399
400    let mut resolvable: BTreeSet<ListKey> =
401        remote_lists.iter().filter_map(|l| l.key().ok()).collect();
402    for op in &ops {
403        if let ListOp::Create(list) = op {
404            resolvable.insert(list.key()?);
405        }
406    }
407
408    let local_items = group_items(mirror_items)?;
409    let mut remote_items: BTreeMap<ListKey, Vec<ExceptionItem>> = BTreeMap::new();
410    for key in &both {
411        remote_items.insert(key.clone(), exceptions::find_items(t, key).await?);
412    }
413
414    let mut item_ops = Vec::new();
415    // Items for a newly created container are written wholesale.
416    for op in &ops {
417        if let ListOp::Create(list) = op {
418            let key = list.key()?;
419            if let Some(items) = local_items.get(&key) {
420                item_ops.extend(items.iter().map(|i| ItemOp::Create(i.clone())));
421            }
422        }
423    }
424
425    let (item_changes, reconciled) = item_reconciliation(&both, &local_items, &remote_items)?;
426    item_ops.extend(reconciled);
427
428    drift.dangling = dangling_pointers(remote_rules, &live);
429    drift.changes.extend(item_changes);
430
431    Ok(ExceptionPlan {
432        drift,
433        list_ops: ops,
434        item_ops,
435        resolvable,
436    })
437}
438
439fn group_items(items: Vec<ExceptionItem>) -> Result<BTreeMap<ListKey, Vec<ExceptionItem>>> {
440    let mut map: BTreeMap<ListKey, Vec<ExceptionItem>> = BTreeMap::new();
441    for item in items {
442        validate_grouped_item(&item)?;
443        let key = ListKey {
444            list_id: item.list_id()?.to_string(),
445            namespace_type: item.namespace_type().to_string(),
446        };
447        map.entry(key).or_default().push(item);
448    }
449    for grouped in map.values_mut() {
450        normalize::sort_items(grouped);
451    }
452    Ok(map)
453}
454
455fn validate_grouped_item(item: &ExceptionItem) -> Result<()> {
456    let item_id = item.item_id()?;
457    if item_id.is_empty() {
458        return Err(Error::new(
459            ErrorKind::Error,
460            "exception item field item_id must be a non-empty string",
461        ));
462    }
463    let list_id = item.list_id()?;
464    if list_id.is_empty() {
465        return Err(Error::new(
466            ErrorKind::Error,
467            "exception item field list_id must be a non-empty string",
468        ));
469    }
470    match item.as_map().get("namespace_type") {
471        None => Ok(()),
472        Some(Value::String(value)) if !value.is_empty() => Ok(()),
473        Some(_) => Err(Error::new(
474            ErrorKind::Error,
475            "exception item field namespace_type must be a non-empty string",
476        )),
477    }
478}
479
480#[cfg(test)]
481mod tests {
482    use super::*;
483
484    #[test]
485    fn grouping_rejects_an_item_without_a_list_id() {
486        let item = ExceptionItem::from_value(serde_json::json!({
487            "item_id": "orphan",
488            "type": "simple",
489            "entries": [],
490        }))
491        .unwrap();
492
493        let error = group_items(vec![item]).unwrap_err();
494
495        assert_eq!(error.kind, ErrorKind::Error);
496        assert!(error.message.contains("list_id"), "{}", error.message);
497    }
498}