Skip to main content

elasticctl_api/state/
push.rs

1//! `state push`: plan and apply, in container-then-item-then-rule order.
2
3use super::diff::{ItemOp, ListOp};
4use super::reports::{DanglingPointer, Mirror, PushReport, StackIdentity};
5use crate::diff::{Change, Drift};
6use crate::exceptions;
7use crate::model::{ListKey, Rule};
8use crate::normalize;
9use crate::report::{ChangeReport, ReportEntry};
10use crate::rules as api;
11use elasticctl_core::{Error, ErrorKind, Result, Transport};
12use serde_json::Value;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::Path;
15
16mod references;
17use references::{inject_list_ids, value_list_refs};
18
19/// What `plan_push` computed and `apply_push` performs.
20///
21/// The preview fields feed the caller's guard banner; `report` is the change
22/// ticket; `summary` is the JSON report.
23#[derive(Debug, Clone)]
24pub struct PushPlan {
25    pub preview_action: String,
26    pub preview_details: Vec<String>,
27    pub report: ChangeReport,
28    pub summary: PushReport,
29    /// The exact rules the preview described, resolved once at plan time so
30    /// `apply_push` never re-reads the mirror after the guard.
31    desired: BTreeMap<String, Rule>,
32    /// Container writes, ordered before any item or rule write.
33    list_ops: Vec<ListOp>,
34    /// Item creates, updates, and removals, ordered before rule writes.
35    item_ops: Vec<ItemOp>,
36}
37
38/// The exception writes `apply_push` performed, folded into `PushReport`.
39#[derive(Default, Clone, Copy)]
40struct ExceptionCounts {
41    lists_created: usize,
42    lists_updated: usize,
43    items_created: usize,
44    items_updated: usize,
45    items_removed: usize,
46}
47
48/// Compute the push preview and dry-run report without mutating the stack,
49/// scoped by `source`. The `custom`/`all` default lives on the clap flag, where
50/// `--help` shows it (spec 5.5).
51pub async fn plan_push(
52    t: &Transport,
53    dir: &Path,
54    selectors: &[String],
55    tag: Option<&str>,
56    search: Option<&str>,
57    source: crate::rules::RuleSource,
58    identity: &StackIdentity,
59) -> Result<PushPlan> {
60    let Mirror {
61        rules: local_all,
62        lists,
63        items,
64    } = super::mirror::read_mirror(dir)?;
65    // Resolve locally first because disk-only rules have no remote ID and may
66    // be created by a scoped push.
67    let scope = super::scope_of(t, selectors, tag, search, source, &local_all, "apply").await?;
68    // A local file outside the `--source` scope is never a pending create
69    // (spec 5.5). Selectors narrow both sides and leave the count at zero.
70    let (local, out_of_scope) = if scope.is_scoped() {
71        (scope.narrow(local_all), 0)
72    } else {
73        scope.split_by_source(local_all)
74    };
75    // A value-list reference is active only if its exception container is
76    // reachable from a rule in this push's active closure. Reading every
77    // mirror item here would let an out-of-scope rule block this preview.
78    let active_list_keys = super::referenced_keys(&local);
79    let value_lists = value_list_refs(&items, &active_list_keys);
80    let remote = scope.remote(t).await?;
81    let drift = Drift::compute(&local, &remote)?;
82
83    let plan = super::diff::exception_plan(t, lists, items, &local, &remote).await?;
84    let exceptions = plan.drift;
85    let list_ops = plan.list_ops;
86    let item_ops = plan.item_ops;
87    let resolvable = plan.resolvable;
88
89    let by_id = |id: &str| local.iter().find(|r| r.rule_id().ok() == Some(id)).cloned();
90    let remote_by_id = |id: &str| {
91        remote
92            .iter()
93            .find(|r| r.rule_id().ok() == Some(id))
94            .cloned()
95    };
96
97    let actionable = drift.actionable();
98    let actionable_ids: BTreeSet<String> =
99        actionable.iter().map(|c| c.rule_id().to_string()).collect();
100
101    // A dangling pointer is drift the normalized diff cannot see, so a rule
102    // whose normalized form is unchanged still needs a write to repair it.
103    // Remote-only rules are skipped: push never touches what it has no local
104    // form for. Dedupe by `rule_id`: a rule referencing two wrong pointers
105    // emits two `DanglingPointer`s but is one rule write.
106    let mut repairs: Vec<DanglingPointer> = Vec::new();
107    let mut repaired_ids: BTreeSet<String> = BTreeSet::new();
108    for dangling in &exceptions.dangling {
109        if actionable_ids.contains(&dangling.rule_id)
110            || by_id(&dangling.rule_id).is_none()
111            || !repaired_ids.insert(dangling.rule_id.clone())
112        {
113            continue;
114        }
115        repairs.push(dangling.clone());
116    }
117
118    let mut preview_details = Vec::new();
119
120    // Name containers and items first, matching apply order.
121    for op in &list_ops {
122        match op {
123            ListOp::Create(list) => {
124                preview_details.push(format!("{}  {}  create", list.list_id()?, list.name()))
125            }
126            ListOp::Update { after, .. } => {
127                preview_details.push(format!("{}  {}  update", after.list_id()?, after.name()))
128            }
129        }
130    }
131    for op in &item_ops {
132        match op {
133            ItemOp::Create(item) => {
134                preview_details.push(format!("{}  {}  create", item.item_id()?, item.list_id()?))
135            }
136            ItemOp::Update { after, .. } => preview_details.push(format!(
137                "{}  {}  update",
138                after.item_id()?,
139                after.list_id()?
140            )),
141            ItemOp::Remove { before, .. } => preview_details.push(format!(
142                "{}  {}  delete",
143                before.item_id()?,
144                before.list_id()?
145            )),
146        }
147    }
148    for change in &actionable {
149        let line = match change {
150            Change::Added { rule_id, name } => format!("{rule_id}  {name}  create"),
151            Change::Modified {
152                rule_id,
153                name,
154                fields,
155            } => {
156                let names: Vec<&str> = fields.iter().map(|f| f.field.as_str()).collect();
157                format!("{rule_id}  {name}  update ({})", names.join(", "))
158            }
159            _ => String::new(),
160        };
161        if !line.is_empty() {
162            preview_details.push(line);
163        }
164    }
165    for dangling in &repairs {
166        let name = by_id(&dangling.rule_id)
167            .map(|r| r.name().to_string())
168            .unwrap_or_default();
169        preview_details.push(format!("{}  {}  update (pointer)", dangling.rule_id, name));
170    }
171
172    // A value list is data, not configuration, and its content is not managed
173    // here (spec 7.7), but a referenced value list that cannot exist must be
174    // reported, never silently pushed. Absence is judged on the data streams:
175    // when they are not bootstrapped, no value list can exist. The `?` refuses
176    // on any failure that is not a clean "absent" 404, so an unverifiable
177    // reference is a failure rather than a silent omission.
178    if !value_lists.is_empty() {
179        if !exceptions::value_lists_bootstrapped(t).await? {
180            for value_list in &value_lists {
181                preview_details.push(format!(
182                    "value list \"{}\" is absent; run POST /api/lists/index to bootstrap the data streams",
183                    value_list.id
184                ));
185            }
186        } else {
187            for value_list in &value_lists {
188                if !exceptions::value_list_exists(t, &value_list.id).await? {
189                    preview_details.push(format!("value list \"{}\" is absent", value_list.id));
190                }
191            }
192        }
193    }
194
195    let mut entries: Vec<ReportEntry> = Vec::new();
196    let mut desired: BTreeMap<String, Rule> = BTreeMap::new();
197
198    // Record remote-only rules before applying changes, including in dry runs.
199    // `actionable()` excludes them because push never deletes remote rules.
200    for change in &drift.changes {
201        if let Change::RemoteOnly { rule_id, name } = change {
202            entries.push(ReportEntry {
203                rule_id: rule_id.clone(),
204                name: name.clone(),
205                action: "skipped_remote_only".into(),
206                before: remote_by_id(rule_id).map(|r| normalize::canonical(&r).into_value()),
207                after: None,
208                applied: false,
209                error: None,
210            });
211        }
212    }
213
214    // Record every actionable change as a pending entry. The report and JSON
215    // `pending` count describe proposed creates and updates.
216    for change in &actionable {
217        let (rule_id, name, action) = match change {
218            Change::Added { rule_id, name } => (rule_id.clone(), name.clone(), "create"),
219            Change::Modified { rule_id, name, .. } => (rule_id.clone(), name.clone(), "update"),
220            _ => continue,
221        };
222
223        let Some(desired_rule) = by_id(&rule_id) else {
224            continue;
225        };
226        let before = remote_by_id(&rule_id).map(|r| normalize::canonical(&r).into_value());
227
228        desired.insert(rule_id.clone(), desired_rule.clone());
229
230        entries.push(ReportEntry {
231            rule_id,
232            name,
233            action: action.into(),
234            before,
235            after: Some(normalize::canonical(&desired_rule).into_value()),
236            applied: false,
237            error: None,
238        });
239    }
240
241    // A repaired pointer is an update whose `before`/`after` normalization
242    // cannot show the difference; the write still happens.
243    for dangling in &repairs {
244        let desired_rule = by_id(&dangling.rule_id).expect("repair rule was found above");
245        let before = remote_by_id(&dangling.rule_id).map(|r| normalize::canonical(&r).into_value());
246        desired.insert(dangling.rule_id.clone(), desired_rule.clone());
247        entries.push(ReportEntry {
248            rule_id: dangling.rule_id.clone(),
249            name: desired_rule.name().to_string(),
250            action: "update".into(),
251            before,
252            after: Some(normalize::canonical(&desired_rule).into_value()),
253            applied: false,
254            error: None,
255        });
256    }
257
258    // Refuse, before any write, a rule that references a list neither on the
259    // stack nor in the mirror. The ids are injected at write time against the
260    // target, but resolvability is known here.
261    let desired_rules: Vec<Rule> = desired.values().cloned().collect();
262    let unresolved: Vec<String> = super::referenced_keys(&desired_rules)
263        .into_iter()
264        .filter(|key| !resolvable.contains(key))
265        .map(|key| format!("\"{}\" ({})", key.list_id, key.namespace_type))
266        .collect();
267    if !unresolved.is_empty() {
268        return Err(Error::new(
269            ErrorKind::NotFound,
270            format!(
271                "rule(s) reference exception list(s) that do not exist on this stack and are \
272                 not in the mirror: {}",
273                unresolved.join(", ")
274            ),
275        ));
276    }
277
278    // Name the selection so a scoped preview differs from a full preview. The
279    // banner names rule, list, and item counts (spec 6.1). Removals get their
280    // own count: the number an operator reads before `--yes` must not read the
281    // same for a run that deletes three items and one that creates three.
282    let item_removals = item_ops
283        .iter()
284        .filter(|op| matches!(op, ItemOp::Remove { .. }))
285        .count();
286    let item_writes = item_ops.len() - item_removals;
287    let mut preview_action = format!(
288        "Push {} rule change(s), {} exception list(s) and {} item(s)",
289        actionable.len() + repairs.len(),
290        list_ops.len(),
291        item_writes,
292    );
293    if item_removals > 0 {
294        preview_action.push_str(&format!(", {} item deletion(s)", item_removals));
295    }
296    preview_action.push_str(&format!(" from {}{}", dir.display(), scope.describe()));
297
298    let report = ChangeReport {
299        profile: identity.profile.clone(),
300        host: identity.host.clone(),
301        space: identity.space.clone(),
302        applied: false,
303        entries,
304    };
305    let summary = push_summary(
306        &report,
307        scope.is_scoped().then(|| scope.selected()),
308        scope.is_scoped().then_some(scope.local_total),
309        out_of_scope,
310        ExceptionCounts::default(),
311    );
312
313    Ok(PushPlan {
314        preview_action,
315        preview_details,
316        report,
317        summary,
318        desired,
319        list_ops,
320        item_ops,
321    })
322}
323
324/// Perform the mutations `plan_push` proposed.
325///
326/// The caller runs this only after its guard approves; a caller that never
327/// calls it has performed a dry run by construction. It reads only from the
328/// plan, never the mirror, so the preview and the apply cannot diverge.
329pub async fn apply_push(t: &Transport, mut plan: PushPlan) -> Result<PushPlan> {
330    // Resolve the live ids for every list a rule to write references, then
331    // create or update containers, then items, then rules. The pointer is
332    // injected only here, against the target stack, never at plan time.
333    let desired_rules: Vec<Rule> = plan.desired.values().cloned().collect();
334    let wanted: Vec<ListKey> = super::referenced_keys(&desired_rules).into_iter().collect();
335    let mut resolved = exceptions::resolve_ids(t, &wanted).await?;
336
337    let mut counts = ExceptionCounts::default();
338    let mut exception_entries = Vec::with_capacity(plan.list_ops.len() + plan.item_ops.len());
339
340    // 1. Containers. A failure records the evidence and stops: the ordering
341    // invariant means later writes depend on this one.
342    for op in &plan.list_ops {
343        let failure = match op {
344            ListOp::Create(list) => match exceptions::create_list(t, list).await {
345                Ok(created) => {
346                    if let Some(id) = created.as_map().get("id").and_then(Value::as_str) {
347                        resolved.insert(list.key()?, id.to_string());
348                    }
349                    counts.lists_created += 1;
350                    exception_entries.push(ReportEntry {
351                        rule_id: list.list_id().unwrap_or("<unreadable>").to_string(),
352                        name: list.name().to_string(),
353                        action: "create_list".into(),
354                        before: None,
355                        after: Some(normalize::canonical_list(&created).into_value()),
356                        applied: true,
357                        error: None,
358                    });
359                    None
360                }
361                Err(e) => Some(ReportEntry {
362                    rule_id: list.list_id().unwrap_or("<unreadable>").to_string(),
363                    name: list.name().to_string(),
364                    action: "create_list".into(),
365                    before: None,
366                    after: None,
367                    applied: false,
368                    error: Some(e.message),
369                }),
370            },
371            ListOp::Update { before, after } => match exceptions::update_list(t, after).await {
372                Ok(applied) => {
373                    counts.lists_updated += 1;
374                    exception_entries.push(ReportEntry {
375                        rule_id: after.list_id().unwrap_or("<unreadable>").to_string(),
376                        name: after.name().to_string(),
377                        action: "update_list".into(),
378                        before: Some(before.clone().into_value()),
379                        after: Some(normalize::canonical_list(&applied).into_value()),
380                        applied: true,
381                        error: None,
382                    });
383                    None
384                }
385                Err(e) => Some(ReportEntry {
386                    rule_id: after.list_id().unwrap_or("<unreadable>").to_string(),
387                    name: after.name().to_string(),
388                    action: "update_list".into(),
389                    before: Some(before.clone().into_value()),
390                    after: None,
391                    applied: false,
392                    error: Some(e.message),
393                }),
394            },
395        };
396        if let Some(failed_entry) = failure {
397            return Ok(finish_after_exception_failure(
398                plan,
399                exception_entries,
400                failed_entry,
401                counts,
402            ));
403        }
404    }
405
406    // 2. Items: create, update, or delete. A failure records the evidence and
407    // stops, like a container failure; a retry re-plans against the partial
408    // state and re-converges.
409    for op in &plan.item_ops {
410        let failure = match op {
411            ItemOp::Create(item) => match exceptions::create_item(t, item).await {
412                Ok(applied) => {
413                    counts.items_created += 1;
414                    exception_entries.push(ReportEntry {
415                        rule_id: item.item_id().unwrap_or("<unreadable>").to_string(),
416                        name: item.list_id().unwrap_or("<unreadable>").to_string(),
417                        action: "create_item".into(),
418                        before: None,
419                        after: Some(normalize::canonical_item(&applied).into_value()),
420                        applied: true,
421                        error: None,
422                    });
423                    None
424                }
425                Err(e) => Some(ReportEntry {
426                    rule_id: item.item_id().unwrap_or("<unreadable>").to_string(),
427                    name: item.list_id().unwrap_or("<unreadable>").to_string(),
428                    action: "create_item".into(),
429                    before: None,
430                    after: None,
431                    applied: false,
432                    error: Some(e.message),
433                }),
434            },
435            ItemOp::Update { before, after } => match exceptions::update_item(t, after).await {
436                Ok(applied) => {
437                    counts.items_updated += 1;
438                    exception_entries.push(ReportEntry {
439                        rule_id: after.item_id().unwrap_or("<unreadable>").to_string(),
440                        name: after.list_id().unwrap_or("<unreadable>").to_string(),
441                        action: "update_item".into(),
442                        before: Some(before.clone().into_value()),
443                        after: Some(normalize::canonical_item(&applied).into_value()),
444                        applied: true,
445                        error: None,
446                    });
447                    None
448                }
449                Err(e) => Some(ReportEntry {
450                    rule_id: after.item_id().unwrap_or("<unreadable>").to_string(),
451                    name: after.list_id().unwrap_or("<unreadable>").to_string(),
452                    action: "update_item".into(),
453                    before: Some(before.clone().into_value()),
454                    after: None,
455                    applied: false,
456                    error: Some(e.message),
457                }),
458            },
459            ItemOp::Remove {
460                before,
461                namespace_type,
462            } => match exceptions::delete_item(
463                t,
464                before.item_id().unwrap_or("<unreadable>"),
465                namespace_type,
466            )
467            .await
468            {
469                Ok(_) => {
470                    counts.items_removed += 1;
471                    exception_entries.push(ReportEntry {
472                        rule_id: before.item_id().unwrap_or("<unreadable>").to_string(),
473                        name: before.list_id().unwrap_or("<unreadable>").to_string(),
474                        action: "delete_item".into(),
475                        before: Some(before.clone().into_value()),
476                        after: None,
477                        applied: true,
478                        error: None,
479                    });
480                    None
481                }
482                Err(e) => Some(ReportEntry {
483                    rule_id: before.item_id().unwrap_or("<unreadable>").to_string(),
484                    name: before.list_id().unwrap_or("<unreadable>").to_string(),
485                    action: "delete_item".into(),
486                    before: Some(before.clone().into_value()),
487                    after: None,
488                    applied: false,
489                    error: Some(e.message),
490                }),
491            },
492        };
493        if let Some(failed_entry) = failure {
494            return Ok(finish_after_exception_failure(
495                plan,
496                exception_entries,
497                failed_entry,
498                counts,
499            ));
500        }
501    }
502
503    // 3. Rules, injecting the resolved pointer into each.
504    let mut entries = exception_entries;
505    entries.reserve(plan.report.entries.len());
506    for entry in plan.report.entries {
507        if entry.action != "create" && entry.action != "update" {
508            // `skipped_remote_only` entries pass through untouched.
509            entries.push(entry);
510            continue;
511        }
512
513        let Some(desired) = plan.desired.get(&entry.rule_id) else {
514            // `plan_push` records a desired rule for every actionable change,
515            // so this is defensive. Record the inconsistency as a failure
516            // rather than dropping the planned mutation from the report.
517            let missing = entry.rule_id.clone();
518            entries.push(ReportEntry {
519                rule_id: entry.rule_id,
520                name: entry.name,
521                action: entry.action,
522                before: entry.before,
523                after: None,
524                applied: false,
525                error: Some(format!("the plan has no desired rule for \"{missing}\"")),
526            });
527            continue;
528        };
529
530        let mut to_write = desired.clone();
531        // `plan_push` verified resolvability, so a miss here is a container
532        // whose live id could not be read (or a list deleted since planning).
533        // Record it per-rule, like any other write failure, and continue.
534        if let Err(e) = inject_list_ids(&mut to_write, &resolved) {
535            entries.push(ReportEntry {
536                rule_id: entry.rule_id,
537                name: entry.name,
538                action: entry.action,
539                before: entry.before,
540                after: None,
541                applied: false,
542                error: Some(e.message),
543            });
544            continue;
545        }
546        let before = entry.before;
547        let is_create = entry.action == "create";
548
549        // Continue after a per-rule failure so the report records every
550        // outcome.
551        let outcome = if is_create {
552            api::create(t, &to_write).await
553        } else {
554            api::update(t, &to_write).await
555        };
556
557        match outcome {
558            Ok(applied) => entries.push(ReportEntry {
559                rule_id: entry.rule_id,
560                name: entry.name,
561                action: entry.action,
562                before,
563                after: Some(normalize::canonical(&applied).into_value()),
564                applied: true,
565                error: None,
566            }),
567            Err(e) => entries.push(ReportEntry {
568                rule_id: entry.rule_id,
569                name: entry.name,
570                action: entry.action,
571                before,
572                after: None,
573                applied: false,
574                error: Some(e.message),
575            }),
576        }
577    }
578
579    let (selected, local_total, out_of_scope) = (
580        plan.summary.selected,
581        plan.summary.local_total,
582        plan.summary.out_of_scope,
583    );
584    plan.report.entries = entries;
585    plan.report.applied = true;
586    plan.summary = push_summary(&plan.report, selected, local_total, out_of_scope, counts);
587    Ok(plan)
588}
589
590/// Record a failed exception write in the change ticket and finalize the plan,
591/// returning it so the caller keeps the evidence of what landed before the
592/// failure.
593fn finish_after_exception_failure(
594    mut plan: PushPlan,
595    mut exception_entries: Vec<ReportEntry>,
596    failed_entry: ReportEntry,
597    counts: ExceptionCounts,
598) -> PushPlan {
599    exception_entries.push(failed_entry);
600    exception_entries.append(&mut plan.report.entries);
601    plan.report.entries = exception_entries;
602    plan.report.applied = true;
603    let (selected, local_total, out_of_scope) = (
604        plan.summary.selected,
605        plan.summary.local_total,
606        plan.summary.out_of_scope,
607    );
608    plan.summary = push_summary(&plan.report, selected, local_total, out_of_scope, counts);
609    plan
610}
611
612fn push_summary(
613    report: &ChangeReport,
614    selected: Option<usize>,
615    local_total: Option<usize>,
616    out_of_scope: usize,
617    counts: ExceptionCounts,
618) -> PushReport {
619    let (created, updated, skipped, failed) = report.counts();
620    PushReport {
621        applied: report.applied,
622        created,
623        updated,
624        skipped_remote_only: skipped,
625        failed,
626        pending: report.pending(),
627        lists_created: counts.lists_created,
628        lists_updated: counts.lists_updated,
629        items_created: counts.items_created,
630        items_updated: counts.items_updated,
631        items_removed: counts.items_removed,
632        out_of_scope,
633        selected,
634        local_total,
635    }
636}