1use 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#[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 desired: BTreeMap<String, Rule>,
32 list_ops: Vec<ListOp>,
34 item_ops: Vec<ItemOp>,
36}
37
38#[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
48pub 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 let scope = super::scope_of(t, selectors, tag, search, source, &local_all, "apply").await?;
68 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 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 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 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 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 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 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 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 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 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
324pub async fn apply_push(t: &Transport, mut plan: PushPlan) -> Result<PushPlan> {
330 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 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 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 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 entries.push(entry);
510 continue;
511 }
512
513 let Some(desired) = plan.desired.get(&entry.rule_id) else {
514 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 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 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
590fn 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}