1use super::diff::{ItemOp, ListOp};
4use super::reports::{DanglingPointer, Mirror, PushReport, StackIdentity};
5use crate::diff::{Change, Drift};
6use crate::exceptions;
7use crate::model::{ExceptionItem, 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, json};
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::Path;
15
16#[derive(Debug, Clone)]
21pub struct PushPlan {
22 pub preview_action: String,
23 pub preview_details: Vec<String>,
24 pub report: ChangeReport,
25 pub summary: PushReport,
26 desired: BTreeMap<String, Rule>,
29 list_ops: Vec<ListOp>,
31 item_ops: Vec<ItemOp>,
33}
34
35#[derive(Default, Clone, Copy)]
37struct ExceptionCounts {
38 lists_created: usize,
39 lists_updated: usize,
40 items_created: usize,
41 items_updated: usize,
42 items_removed: usize,
43}
44
45pub async fn plan_push(
49 t: &Transport,
50 dir: &Path,
51 selectors: &[String],
52 tag: Option<&str>,
53 search: Option<&str>,
54 source: crate::rules::RuleSource,
55 identity: &StackIdentity,
56) -> Result<PushPlan> {
57 let Mirror {
58 rules: local_all,
59 lists,
60 items,
61 } = super::mirror::read_mirror(dir)?;
62 let scope = super::scope_of(t, selectors, tag, search, source, &local_all, "apply").await?;
65 let (local, out_of_scope) = if scope.is_scoped() {
68 (scope.narrow(local_all), 0)
69 } else {
70 scope.split_by_source(local_all)
71 };
72 let active_list_keys = super::referenced_keys(&local);
76 let value_lists = value_list_refs(&items, &active_list_keys);
77 let remote = scope.remote(t).await?;
78 let drift = Drift::compute(&local, &remote)?;
79
80 let plan = super::diff::exception_plan(t, lists, items, &local, &remote).await?;
81 let exceptions = plan.drift;
82 let list_ops = plan.list_ops;
83 let item_ops = plan.item_ops;
84 let resolvable = plan.resolvable;
85
86 let by_id = |id: &str| local.iter().find(|r| r.rule_id().ok() == Some(id)).cloned();
87 let remote_by_id = |id: &str| {
88 remote
89 .iter()
90 .find(|r| r.rule_id().ok() == Some(id))
91 .cloned()
92 };
93
94 let actionable = drift.actionable();
95 let actionable_ids: BTreeSet<String> =
96 actionable.iter().map(|c| c.rule_id().to_string()).collect();
97
98 let mut repairs: Vec<DanglingPointer> = Vec::new();
104 let mut repaired_ids: BTreeSet<String> = BTreeSet::new();
105 for dangling in &exceptions.dangling {
106 if actionable_ids.contains(&dangling.rule_id)
107 || by_id(&dangling.rule_id).is_none()
108 || !repaired_ids.insert(dangling.rule_id.clone())
109 {
110 continue;
111 }
112 repairs.push(dangling.clone());
113 }
114
115 let mut preview_details = Vec::new();
116
117 for op in &list_ops {
119 match op {
120 ListOp::Create(list) => {
121 preview_details.push(format!("{} {} create", list.list_id()?, list.name()))
122 }
123 ListOp::Update { after, .. } => {
124 preview_details.push(format!("{} {} update", after.list_id()?, after.name()))
125 }
126 }
127 }
128 for op in &item_ops {
129 match op {
130 ItemOp::Create(item) => {
131 preview_details.push(format!("{} {} create", item.item_id()?, item.list_id()?))
132 }
133 ItemOp::Update { after, .. } => preview_details.push(format!(
134 "{} {} update",
135 after.item_id()?,
136 after.list_id()?
137 )),
138 ItemOp::Remove { before, .. } => preview_details.push(format!(
139 "{} {} delete",
140 before.item_id()?,
141 before.list_id()?
142 )),
143 }
144 }
145 for change in &actionable {
146 let line = match change {
147 Change::Added { rule_id, name } => format!("{rule_id} {name} create"),
148 Change::Modified {
149 rule_id,
150 name,
151 fields,
152 } => {
153 let names: Vec<&str> = fields.iter().map(|f| f.field.as_str()).collect();
154 format!("{rule_id} {name} update ({})", names.join(", "))
155 }
156 _ => String::new(),
157 };
158 if !line.is_empty() {
159 preview_details.push(line);
160 }
161 }
162 for dangling in &repairs {
163 let name = by_id(&dangling.rule_id)
164 .map(|r| r.name().to_string())
165 .unwrap_or_default();
166 preview_details.push(format!("{} {} update (pointer)", dangling.rule_id, name));
167 }
168
169 if !value_lists.is_empty() {
176 if !exceptions::value_lists_bootstrapped(t).await? {
177 for value_list in &value_lists {
178 preview_details.push(format!(
179 "value list \"{}\" is absent; run POST /api/lists/index to bootstrap the data streams",
180 value_list.id
181 ));
182 }
183 } else {
184 for value_list in &value_lists {
185 if !exceptions::value_list_exists(t, &value_list.id).await? {
186 preview_details.push(format!("value list \"{}\" is absent", value_list.id));
187 }
188 }
189 }
190 }
191
192 let mut entries: Vec<ReportEntry> = Vec::new();
193 let mut desired: BTreeMap<String, Rule> = BTreeMap::new();
194
195 for change in &drift.changes {
198 if let Change::RemoteOnly { rule_id, name } = change {
199 entries.push(ReportEntry {
200 rule_id: rule_id.clone(),
201 name: name.clone(),
202 action: "skipped_remote_only".into(),
203 before: remote_by_id(rule_id).map(|r| normalize::canonical(&r).into_value()),
204 after: None,
205 applied: false,
206 error: None,
207 });
208 }
209 }
210
211 for change in &actionable {
214 let (rule_id, name, action) = match change {
215 Change::Added { rule_id, name } => (rule_id.clone(), name.clone(), "create"),
216 Change::Modified { rule_id, name, .. } => (rule_id.clone(), name.clone(), "update"),
217 _ => continue,
218 };
219
220 let Some(desired_rule) = by_id(&rule_id) else {
221 continue;
222 };
223 let before = remote_by_id(&rule_id).map(|r| normalize::canonical(&r).into_value());
224
225 desired.insert(rule_id.clone(), desired_rule.clone());
226
227 entries.push(ReportEntry {
228 rule_id,
229 name,
230 action: action.into(),
231 before,
232 after: Some(normalize::canonical(&desired_rule).into_value()),
233 applied: false,
234 error: None,
235 });
236 }
237
238 for dangling in &repairs {
241 let desired_rule = by_id(&dangling.rule_id).expect("repair rule was found above");
242 let before = remote_by_id(&dangling.rule_id).map(|r| normalize::canonical(&r).into_value());
243 desired.insert(dangling.rule_id.clone(), desired_rule.clone());
244 entries.push(ReportEntry {
245 rule_id: dangling.rule_id.clone(),
246 name: desired_rule.name().to_string(),
247 action: "update".into(),
248 before,
249 after: Some(normalize::canonical(&desired_rule).into_value()),
250 applied: false,
251 error: None,
252 });
253 }
254
255 let desired_rules: Vec<Rule> = desired.values().cloned().collect();
259 let unresolved: Vec<String> = super::referenced_keys(&desired_rules)
260 .into_iter()
261 .filter(|key| !resolvable.contains(key))
262 .map(|key| format!("\"{}\" ({})", key.list_id, key.namespace_type))
263 .collect();
264 if !unresolved.is_empty() {
265 return Err(Error::new(
266 ErrorKind::NotFound,
267 format!(
268 "rule(s) reference exception list(s) that do not exist on this stack and are \
269 not in the mirror: {}",
270 unresolved.join(", ")
271 ),
272 ));
273 }
274
275 let item_removals = item_ops
280 .iter()
281 .filter(|op| matches!(op, ItemOp::Remove { .. }))
282 .count();
283 let item_writes = item_ops.len() - item_removals;
284 let mut preview_action = format!(
285 "Push {} rule change(s), {} exception list(s) and {} item(s)",
286 actionable.len() + repairs.len(),
287 list_ops.len(),
288 item_writes,
289 );
290 if item_removals > 0 {
291 preview_action.push_str(&format!(", {} item deletion(s)", item_removals));
292 }
293 preview_action.push_str(&format!(" from {}{}", dir.display(), scope.describe()));
294
295 let report = ChangeReport {
296 profile: identity.profile.clone(),
297 host: identity.host.clone(),
298 space: identity.space.clone(),
299 applied: false,
300 entries,
301 };
302 let summary = push_summary(
303 &report,
304 scope.is_scoped().then(|| scope.selected()),
305 scope.is_scoped().then_some(scope.local_total),
306 out_of_scope,
307 ExceptionCounts::default(),
308 );
309
310 Ok(PushPlan {
311 preview_action,
312 preview_details,
313 report,
314 summary,
315 desired,
316 list_ops,
317 item_ops,
318 })
319}
320
321pub async fn apply_push(t: &Transport, mut plan: PushPlan) -> Result<PushPlan> {
327 let desired_rules: Vec<Rule> = plan.desired.values().cloned().collect();
331 let wanted: Vec<ListKey> = super::referenced_keys(&desired_rules).into_iter().collect();
332 let mut resolved = exceptions::resolve_ids(t, &wanted).await?;
333
334 let mut counts = ExceptionCounts::default();
335 let mut exception_entries = Vec::with_capacity(plan.list_ops.len() + plan.item_ops.len());
336
337 for op in &plan.list_ops {
340 let failure = match op {
341 ListOp::Create(list) => match exceptions::create_list(t, list).await {
342 Ok(created) => {
343 if let Some(id) = created.as_map().get("id").and_then(Value::as_str) {
344 resolved.insert(list.key()?, id.to_string());
345 }
346 counts.lists_created += 1;
347 exception_entries.push(ReportEntry {
348 rule_id: list.list_id().unwrap_or("<unreadable>").to_string(),
349 name: list.name().to_string(),
350 action: "create_list".into(),
351 before: None,
352 after: Some(normalize::canonical_list(&created).into_value()),
353 applied: true,
354 error: None,
355 });
356 None
357 }
358 Err(e) => Some(ReportEntry {
359 rule_id: list.list_id().unwrap_or("<unreadable>").to_string(),
360 name: list.name().to_string(),
361 action: "create_list".into(),
362 before: None,
363 after: None,
364 applied: false,
365 error: Some(e.message),
366 }),
367 },
368 ListOp::Update { before, after } => match exceptions::update_list(t, after).await {
369 Ok(applied) => {
370 counts.lists_updated += 1;
371 exception_entries.push(ReportEntry {
372 rule_id: after.list_id().unwrap_or("<unreadable>").to_string(),
373 name: after.name().to_string(),
374 action: "update_list".into(),
375 before: Some(before.clone().into_value()),
376 after: Some(normalize::canonical_list(&applied).into_value()),
377 applied: true,
378 error: None,
379 });
380 None
381 }
382 Err(e) => Some(ReportEntry {
383 rule_id: after.list_id().unwrap_or("<unreadable>").to_string(),
384 name: after.name().to_string(),
385 action: "update_list".into(),
386 before: Some(before.clone().into_value()),
387 after: None,
388 applied: false,
389 error: Some(e.message),
390 }),
391 },
392 };
393 if let Some(failed_entry) = failure {
394 return Ok(finish_after_exception_failure(
395 plan,
396 exception_entries,
397 failed_entry,
398 counts,
399 ));
400 }
401 }
402
403 for op in &plan.item_ops {
407 let failure = match op {
408 ItemOp::Create(item) => match exceptions::create_item(t, item).await {
409 Ok(applied) => {
410 counts.items_created += 1;
411 exception_entries.push(ReportEntry {
412 rule_id: item.item_id().unwrap_or("<unreadable>").to_string(),
413 name: item.list_id().unwrap_or("<unreadable>").to_string(),
414 action: "create_item".into(),
415 before: None,
416 after: Some(normalize::canonical_item(&applied).into_value()),
417 applied: true,
418 error: None,
419 });
420 None
421 }
422 Err(e) => Some(ReportEntry {
423 rule_id: item.item_id().unwrap_or("<unreadable>").to_string(),
424 name: item.list_id().unwrap_or("<unreadable>").to_string(),
425 action: "create_item".into(),
426 before: None,
427 after: None,
428 applied: false,
429 error: Some(e.message),
430 }),
431 },
432 ItemOp::Update { before, after } => match exceptions::update_item(t, after).await {
433 Ok(applied) => {
434 counts.items_updated += 1;
435 exception_entries.push(ReportEntry {
436 rule_id: after.item_id().unwrap_or("<unreadable>").to_string(),
437 name: after.list_id().unwrap_or("<unreadable>").to_string(),
438 action: "update_item".into(),
439 before: Some(before.clone().into_value()),
440 after: Some(normalize::canonical_item(&applied).into_value()),
441 applied: true,
442 error: None,
443 });
444 None
445 }
446 Err(e) => Some(ReportEntry {
447 rule_id: after.item_id().unwrap_or("<unreadable>").to_string(),
448 name: after.list_id().unwrap_or("<unreadable>").to_string(),
449 action: "update_item".into(),
450 before: Some(before.clone().into_value()),
451 after: None,
452 applied: false,
453 error: Some(e.message),
454 }),
455 },
456 ItemOp::Remove {
457 before,
458 namespace_type,
459 } => match exceptions::delete_item(
460 t,
461 before.item_id().unwrap_or("<unreadable>"),
462 namespace_type,
463 )
464 .await
465 {
466 Ok(_) => {
467 counts.items_removed += 1;
468 exception_entries.push(ReportEntry {
469 rule_id: before.item_id().unwrap_or("<unreadable>").to_string(),
470 name: before.list_id().unwrap_or("<unreadable>").to_string(),
471 action: "delete_item".into(),
472 before: Some(before.clone().into_value()),
473 after: None,
474 applied: true,
475 error: None,
476 });
477 None
478 }
479 Err(e) => Some(ReportEntry {
480 rule_id: before.item_id().unwrap_or("<unreadable>").to_string(),
481 name: before.list_id().unwrap_or("<unreadable>").to_string(),
482 action: "delete_item".into(),
483 before: Some(before.clone().into_value()),
484 after: None,
485 applied: false,
486 error: Some(e.message),
487 }),
488 },
489 };
490 if let Some(failed_entry) = failure {
491 return Ok(finish_after_exception_failure(
492 plan,
493 exception_entries,
494 failed_entry,
495 counts,
496 ));
497 }
498 }
499
500 let mut entries = exception_entries;
502 entries.reserve(plan.report.entries.len());
503 for entry in plan.report.entries {
504 if entry.action != "create" && entry.action != "update" {
505 entries.push(entry);
507 continue;
508 }
509
510 let Some(desired) = plan.desired.get(&entry.rule_id) else {
511 let missing = entry.rule_id.clone();
515 entries.push(ReportEntry {
516 rule_id: entry.rule_id,
517 name: entry.name,
518 action: entry.action,
519 before: entry.before,
520 after: None,
521 applied: false,
522 error: Some(format!("the plan has no desired rule for \"{missing}\"")),
523 });
524 continue;
525 };
526
527 let mut to_write = desired.clone();
528 if let Err(e) = inject_list_ids(&mut to_write, &resolved) {
532 entries.push(ReportEntry {
533 rule_id: entry.rule_id,
534 name: entry.name,
535 action: entry.action,
536 before: entry.before,
537 after: None,
538 applied: false,
539 error: Some(e.message),
540 });
541 continue;
542 }
543 let before = entry.before;
544 let is_create = entry.action == "create";
545
546 let outcome = if is_create {
549 api::create(t, &to_write).await
550 } else {
551 api::update(t, &to_write).await
552 };
553
554 match outcome {
555 Ok(applied) => entries.push(ReportEntry {
556 rule_id: entry.rule_id,
557 name: entry.name,
558 action: entry.action,
559 before,
560 after: Some(normalize::canonical(&applied).into_value()),
561 applied: true,
562 error: None,
563 }),
564 Err(e) => entries.push(ReportEntry {
565 rule_id: entry.rule_id,
566 name: entry.name,
567 action: entry.action,
568 before,
569 after: None,
570 applied: false,
571 error: Some(e.message),
572 }),
573 }
574 }
575
576 let (selected, local_total, out_of_scope) = (
577 plan.summary.selected,
578 plan.summary.local_total,
579 plan.summary.out_of_scope,
580 );
581 plan.report.entries = entries;
582 plan.report.applied = true;
583 plan.summary = push_summary(&plan.report, selected, local_total, out_of_scope, counts);
584 Ok(plan)
585}
586
587fn finish_after_exception_failure(
591 mut plan: PushPlan,
592 mut exception_entries: Vec<ReportEntry>,
593 failed_entry: ReportEntry,
594 counts: ExceptionCounts,
595) -> PushPlan {
596 exception_entries.push(failed_entry);
597 exception_entries.append(&mut plan.report.entries);
598 plan.report.entries = exception_entries;
599 plan.report.applied = true;
600 let (selected, local_total, out_of_scope) = (
601 plan.summary.selected,
602 plan.summary.local_total,
603 plan.summary.out_of_scope,
604 );
605 plan.summary = push_summary(&plan.report, selected, local_total, out_of_scope, counts);
606 plan
607}
608
609fn push_summary(
610 report: &ChangeReport,
611 selected: Option<usize>,
612 local_total: Option<usize>,
613 out_of_scope: usize,
614 counts: ExceptionCounts,
615) -> PushReport {
616 let (created, updated, skipped, failed) = report.counts();
617 PushReport {
618 applied: report.applied,
619 created,
620 updated,
621 skipped_remote_only: skipped,
622 failed,
623 pending: report.pending(),
624 lists_created: counts.lists_created,
625 lists_updated: counts.lists_updated,
626 items_created: counts.items_created,
627 items_updated: counts.items_updated,
628 items_removed: counts.items_removed,
629 out_of_scope,
630 selected,
631 local_total,
632 }
633}
634
635fn inject_list_ids(rule: &mut Rule, live: &BTreeMap<ListKey, String>) -> Result<()> {
643 let Some(Value::Array(refs)) = rule.as_map_mut().get_mut("exceptions_list") else {
644 return Ok(());
645 };
646 for reference in refs.iter_mut() {
647 let Value::Object(map) = reference else {
648 continue;
649 };
650 let Some(list_id) = map.get("list_id").and_then(Value::as_str) else {
651 continue;
652 };
653 let namespace = map
654 .get("namespace_type")
655 .and_then(Value::as_str)
656 .unwrap_or("single");
657 let key = ListKey {
658 list_id: list_id.to_string(),
659 namespace_type: namespace.to_string(),
660 };
661 match live.get(&key) {
662 Some(id) => {
663 map.insert("id".into(), json!(id));
664 }
665 None => {
666 return Err(Error::new(
667 ErrorKind::NotFound,
668 format!(
669 "rule references exception list \"{list_id}\" ({namespace}), whose live \
670 id could not be resolved on this stack"
671 ),
672 ));
673 }
674 }
675 }
676 Ok(())
677}
678
679fn value_list_refs(
685 items: &[ExceptionItem],
686 active_list_keys: &BTreeSet<ListKey>,
687) -> BTreeSet<exceptions::ValueListRef> {
688 let mut ids = BTreeSet::new();
689 for item in items {
690 let Ok(list_id) = item.list_id() else {
691 continue;
692 };
693 let key = ListKey {
694 list_id: list_id.to_string(),
695 namespace_type: item.namespace_type().to_string(),
696 };
697 if !active_list_keys.contains(&key) {
698 continue;
699 }
700 let Some(entries) = item.as_map().get("entries").and_then(Value::as_array) else {
701 continue;
702 };
703 for entry in entries {
704 let Some(obj) = entry.as_object() else {
705 continue;
706 };
707 if obj.get("type").and_then(Value::as_str) != Some("list") {
708 continue;
709 }
710 if let Some(id) = obj
711 .get("list")
712 .and_then(Value::as_object)
713 .and_then(|l| l.get("id"))
714 .and_then(Value::as_str)
715 {
716 ids.insert(exceptions::ValueListRef { id: id.to_string() });
717 }
718 }
719 }
720 ids
721}