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