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