1use crate::content_codec::{self, ContentFormat};
4use crate::dashboards::{self, Dashboard, DashboardSpec, DashboardSummary};
5use crate::data_views;
6use crate::ops::{DeleteOutcome, ExportOutcome, MutationPlan};
7use crate::saved_objects;
8use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport};
9use serde::Serialize;
10use serde_json::{Map, Value, json};
11use std::collections::{BTreeMap, BTreeSet};
12use std::path::Path;
13
14#[derive(Debug, Clone, Default, PartialEq, Eq)]
16pub struct DashboardFilter {
17 pub search: Option<String>,
18 pub tag: Option<String>,
19 pub limit: Option<usize>,
20}
21
22#[derive(Debug, Clone, PartialEq, Serialize)]
24pub struct DashboardList {
25 pub total: u64,
26 pub dashboards: Vec<DashboardSummary>,
27 pub truncated: bool,
28}
29
30#[derive(Debug, Clone, PartialEq)]
33pub struct DashboardImportPlan {
34 pub preview: crate::ops::MutationPlan,
35 pub specs: Vec<DashboardSpec>,
36 pub before: BTreeMap<String, Option<DashboardSpec>>,
37 pub skipped: Vec<Value>,
38 pub total: usize,
39 pub overwrite: bool,
40}
41
42#[derive(Debug, Clone, PartialEq, Serialize)]
46pub struct DashboardImportReport {
47 pub applied: bool,
48 pub succeeded: Vec<Value>,
49 pub skipped: Vec<Value>,
50 pub failed: Vec<Value>,
51 pub lossy: Vec<Value>,
52 pub total: usize,
53}
54
55#[derive(Debug, Clone, PartialEq)]
58pub struct BundleImportPlan {
59 pub preview: MutationPlan,
60 pub ndjson: String,
61 pub scan: saved_objects::BundleScan,
62 pub overwrite: bool,
63}
64
65#[derive(Debug, Clone, PartialEq, Serialize)]
67pub struct BundleImportOutcome {
68 pub applied: bool,
69 pub succeeded: Vec<Value>,
70 pub failed: Vec<Value>,
71 pub total: usize,
72}
73
74#[derive(Debug, Clone, PartialEq)]
76pub struct DashboardDeletePlan {
77 pub preview: MutationPlan,
78 pub targets: Vec<DashboardSummary>,
79}
80
81pub fn resolve_from_summaries(
86 dashboards: &[DashboardSummary],
87 selector: &str,
88) -> Result<DashboardSummary> {
89 if let Some(dashboard) = dashboards.iter().find(|dashboard| dashboard.id == selector) {
90 return Ok(dashboard.clone());
91 }
92
93 let mut matches: Vec<_> = dashboards
94 .iter()
95 .filter(|dashboard| dashboard.title == selector)
96 .cloned()
97 .collect();
98 matches.sort_by(|left, right| left.id.cmp(&right.id));
99 match matches.as_slice() {
100 [] => Err(Error::new(
101 ErrorKind::NotFound,
102 format!("no dashboard with id or title '{selector}'"),
103 )),
104 [dashboard] => Ok(dashboard.clone()),
105 _ => Err(Error::new(
106 ErrorKind::Conflict,
107 format!(
108 "dashboard title '{selector}' is ambiguous: {}",
109 matches
110 .iter()
111 .map(|dashboard| dashboard.id.as_str())
112 .collect::<Vec<_>>()
113 .join(", ")
114 ),
115 )),
116 }
117}
118
119pub async fn resolve(transport: &Transport, selector: &str) -> Result<DashboardSummary> {
121 match dashboards::get(transport, selector).await {
122 Ok(dashboard) if dashboard.id == selector => summary_from_dashboard(dashboard),
123 Ok(dashboard) => Err(Error::new(
124 ErrorKind::Http,
125 format!(
126 "decoding dashboard get: expected id '{selector}', got '{}'",
127 dashboard.id
128 ),
129 )),
130 Err(error) if error.kind == ErrorKind::NotFound => {
131 let listed = list_op(transport, &DashboardFilter::default()).await?;
132 resolve_from_summaries(&listed.dashboards, selector)
133 }
134 Err(error) => Err(error),
135 }
136}
137
138pub async fn list_op(transport: &Transport, filter: &DashboardFilter) -> Result<DashboardList> {
140 let tags = filter.tag.iter().cloned().collect::<Vec<_>>();
141 let mut page_number = 1;
142 let mut total = None;
143 let mut dashboards = Vec::new();
144 let mut ids = BTreeSet::new();
145
146 loop {
147 let page =
148 dashboards::search(transport, page_number, filter.search.as_deref(), &tags).await?;
149 if page.page != page_number || page.per_page != 1000 {
150 return Err(Error::new(
151 ErrorKind::Http,
152 "decoding dashboard search: unexpected page metadata",
153 ));
154 }
155 if let Some(total) = total {
156 if page.total != total {
157 return Err(Error::new(
158 ErrorKind::Http,
159 "decoding dashboard search: total changed while paging",
160 ));
161 }
162 } else {
163 total = Some(page.total);
164 }
165 let page_len = page.data.len();
166 for dashboard in page.data {
167 if !ids.insert(dashboard.id.clone()) {
168 return Err(Error::new(
169 ErrorKind::Http,
170 format!(
171 "decoding dashboard search: duplicate dashboard id '{}'",
172 dashboard.id
173 ),
174 ));
175 }
176 dashboards.push(dashboard);
177 }
178 let expected_total = total.expect("set from the first page");
179 if dashboards.len() as u64 >= expected_total {
180 break;
181 }
182 if page_len != 1000 {
183 return Err(Error::new(
184 ErrorKind::Http,
185 "decoding dashboard search: page was short before total",
186 ));
187 }
188 page_number += 1;
189 }
190
191 dashboards.sort_by(|left, right| left.id.cmp(&right.id));
192 let total = total.unwrap_or(0);
193 if dashboards.len() as u64 > total {
194 return Err(Error::new(
195 ErrorKind::Http,
196 "decoding dashboard search: returned more dashboards than total",
197 ));
198 }
199 let limit = filter.limit.unwrap_or(usize::MAX);
200 let truncated = dashboards.len() > limit;
201 dashboards.truncate(limit);
202 Ok(DashboardList {
203 total,
204 dashboards,
205 truncated,
206 })
207}
208
209pub async fn get_op(transport: &Transport, selector: &str) -> Result<Dashboard> {
211 let dashboard = resolve(transport, selector).await?;
212 dashboards::get(transport, &dashboard.id).await
213}
214
215pub fn validate(path: &Path) -> Result<Vec<DashboardSpec>> {
217 let body = std::fs::read_to_string(path).map_err(|error| {
218 Error::new(
219 ErrorKind::Error,
220 format!("reading {}: {error}", path.display()),
221 )
222 })?;
223 let mut specs = content_codec::decode_sequence::<DashboardSpec>(
224 &body,
225 ContentFormat::from_path(path),
226 "dashboard",
227 )?;
228 let mut seen = BTreeSet::new();
229 let mut duplicates = BTreeSet::new();
230 for spec in &specs {
231 dashboards::validate_spec(spec)?;
232 if !seen.insert(spec.id.as_str()) {
233 duplicates.insert(spec.id.as_str());
234 }
235 }
236 if !duplicates.is_empty() {
237 return Err(Error::new(
238 ErrorKind::Error,
239 format!(
240 "duplicate dashboard ids: {}",
241 duplicates.into_iter().collect::<Vec<_>>().join(", ")
242 ),
243 ));
244 }
245 specs.sort_by(|left, right| left.id.cmp(&right.id));
246 Ok(specs)
247}
248
249pub async fn plan_import(
253 transport: Option<&Transport>,
254 path: &Path,
255 overwrite: bool,
256 skip_existing: bool,
257) -> Result<DashboardImportPlan> {
258 let mut specs = validate(path)?;
259 if specs.is_empty() {
260 return Err(Error::new(
261 ErrorKind::Error,
262 "dashboard import needs at least one dashboard",
263 ));
264 }
265 if overwrite && skip_existing {
266 return Err(Error::new(
267 ErrorKind::Error,
268 "--overwrite and --skip-existing cannot be used together",
269 ));
270 }
271
272 let references: BTreeSet<_> = specs
273 .iter()
274 .flat_map(|spec| dashboards::collect_data_view_refs(&Value::Object(spec.data.clone())))
275 .collect();
276 let requires_server =
277 overwrite || skip_existing || transport.is_some() || !references.is_empty();
278 let transport = if requires_server { transport } else { None };
279 if (overwrite || skip_existing || !references.is_empty()) && transport.is_none() {
280 return Err(Error::new(
281 ErrorKind::Error,
282 "dashboard import preflight needs a transport",
283 ));
284 }
285
286 let total = specs.len();
287 let mut before = BTreeMap::new();
288 let mut conflicts = Vec::new();
289 if let Some(transport) = transport {
290 for spec in &specs {
291 match read_spec(transport, &spec.id).await {
292 Ok(current) => {
293 if !overwrite && !skip_existing {
294 conflicts.push(spec.id.clone());
295 }
296 before.insert(spec.id.clone(), Some(current));
297 }
298 Err(error) if error.kind == ErrorKind::NotFound => {
299 before.insert(spec.id.clone(), None);
300 }
301 Err(error) => return Err(error),
302 }
303 }
304
305 let mut missing = Vec::new();
306 for id in references {
307 match data_views::get(transport, &id).await {
308 Ok(data_view) => match data_view.data_view.get("id").and_then(Value::as_str) {
309 Some(actual) if actual == id => {}
310 Some(actual) => {
311 return Err(Error::new(
312 ErrorKind::Http,
313 format!("decoding data view: expected id '{id}', got '{actual}'"),
314 ));
315 }
316 None => {
317 return Err(Error::new(
318 ErrorKind::Http,
319 format!(
320 "decoding data view: expected id '{id}', got missing or non-string id"
321 ),
322 ));
323 }
324 },
325 Err(error) if error.kind == ErrorKind::NotFound => missing.push(id),
326 Err(error) => return Err(error),
327 }
328 }
329 if !missing.is_empty() {
330 return Err(Error::new(
331 ErrorKind::NotFound,
332 format!("referenced data views do not exist: {}", missing.join(", ")),
333 ));
334 }
335 } else {
336 before.extend(specs.iter().map(|spec| (spec.id.clone(), None)));
337 }
338
339 if !conflicts.is_empty() {
340 return Err(Error::new(
341 ErrorKind::Conflict,
342 format!("dashboards already exist: {}", conflicts.join(", ")),
343 ));
344 }
345
346 let mut skipped = Vec::new();
347 if skip_existing {
348 specs.retain(|spec| match before.get(&spec.id) {
349 Some(Some(_)) => {
350 skipped.push(serde_json::json!({"id": spec.id, "reason": "exists"}));
351 false
352 }
353 _ => true,
354 });
355 before.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
356 }
357
358 let preview = crate::ops::MutationPlan {
359 preview_action: format!("Import {} dashboard(s)", specs.len()),
360 preview_details: import_preview_details(&specs, &before),
361 targets: specs.iter().map(|spec| spec.id.clone()).collect(),
362 };
363 Ok(DashboardImportPlan {
364 preview,
365 specs,
366 before,
367 skipped,
368 total,
369 overwrite,
370 })
371}
372
373pub async fn apply_import(
380 transport: &Transport,
381 plan: &DashboardImportPlan,
382) -> Result<DashboardImportReport> {
383 validate_import_plan(plan)?;
384 let mut succeeded = Vec::new();
385 let mut failed = Vec::new();
386 let mut lossy = Vec::new();
387
388 for desired in &plan.specs {
389 let Some(before) = plan.before.get(&desired.id) else {
390 failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
391 continue;
392 };
393 let current = match read_spec(transport, &desired.id).await {
394 Ok(current) => Some(current),
395 Err(error) if error.kind == ErrorKind::NotFound => None,
396 Err(error) => {
397 failed.push(failed_row(&desired.id, false, error.message));
398 continue;
399 }
400 };
401
402 match (before, current) {
403 (None, Some(_)) => {
404 failed.push(failed_row(
405 &desired.id,
406 false,
407 "dashboard appeared since preview",
408 ));
409 }
410 (Some(_), None) => {
411 failed.push(failed_row(
412 &desired.id,
413 false,
414 "dashboard disappeared since preview",
415 ));
416 }
417 (Some(before), Some(current)) if before != ¤t => {
418 failed.push(failed_row(
419 &desired.id,
420 false,
421 "dashboard changed since preview",
422 ));
423 }
424 (Some(_), Some(current)) if current == *desired => {
425 succeeded.push(serde_json::json!({"id": desired.id, "action": "unchanged"}));
426 }
427 (None, None) => {
428 apply_put(
429 transport,
430 desired,
431 "created",
432 &mut succeeded,
433 &mut failed,
434 &mut lossy,
435 )
436 .await;
437 }
438 (Some(_), Some(_)) => {
439 apply_put(
440 transport,
441 desired,
442 "replaced",
443 &mut succeeded,
444 &mut failed,
445 &mut lossy,
446 )
447 .await;
448 }
449 }
450 }
451
452 Ok(DashboardImportReport {
453 applied: true,
454 succeeded,
455 skipped: plan.skipped.clone(),
456 failed,
457 lossy,
458 total: plan.total,
459 })
460}
461
462pub async fn plan_delete(
464 transport: &Transport,
465 selectors: &[String],
466) -> Result<DashboardDeletePlan> {
467 if selectors.is_empty() {
468 return Err(Error::new(
469 ErrorKind::Error,
470 "dashboard delete needs at least one dashboard",
471 ));
472 }
473 let mut seen = BTreeSet::new();
474 let mut targets = Vec::new();
475 for selector in selectors {
476 let dashboard = resolve(transport, selector).await?;
477 if seen.insert(dashboard.id.clone()) {
478 targets.push(dashboard);
479 }
480 }
481 Ok(DashboardDeletePlan {
482 preview: delete_preview(&targets),
483 targets,
484 })
485}
486
487pub async fn apply_delete(
489 transport: &Transport,
490 plan: &DashboardDeletePlan,
491) -> Result<DeleteOutcome> {
492 validate_delete_plan(plan)?;
493 let mut deleted = Vec::new();
494 let mut failed = Vec::new();
495 for target in &plan.targets {
496 match dashboards::delete(transport, &target.id).await {
497 Ok(()) => deleted.push(serde_json::json!({"id": target.id})),
498 Err(error) => failed.push(serde_json::json!({
499 "id": target.id,
500 "error": error.message,
501 })),
502 }
503 }
504 Ok(DeleteOutcome {
505 applied: true,
506 deleted,
507 failed,
508 total: plan.targets.len(),
509 })
510}
511
512fn delete_preview(targets: &[DashboardSummary]) -> MutationPlan {
513 MutationPlan {
514 preview_action: format!("Delete {} dashboard(s)", targets.len()),
515 preview_details: targets
516 .iter()
517 .map(|dashboard| format!("{} {}", dashboard.id, dashboard.title))
518 .collect(),
519 targets: targets
520 .iter()
521 .map(|dashboard| dashboard.id.clone())
522 .collect(),
523 }
524}
525
526fn validate_delete_plan(plan: &DashboardDeletePlan) -> Result<()> {
527 if plan.targets.is_empty() {
528 return invalid_plan("dashboard delete plan needs at least one target");
529 }
530 let mut ids = BTreeSet::new();
531 for target in &plan.targets {
532 if target.id.trim().is_empty() || target.title.trim().is_empty() {
533 return invalid_plan("dashboard delete target identity must not be empty");
534 }
535 if !ids.insert(target.id.clone()) {
536 return invalid_plan("dashboard delete targets must be unique by id");
537 }
538 }
539 if plan.preview != delete_preview(&plan.targets) {
540 return invalid_plan("dashboard delete preview does not match guarded targets");
541 }
542 Ok(())
543}
544
545async fn apply_put(
546 transport: &Transport,
547 desired: &DashboardSpec,
548 action: &str,
549 succeeded: &mut Vec<Value>,
550 failed: &mut Vec<Value>,
551 lossy: &mut Vec<Value>,
552) {
553 let response = match dashboards::put(transport, desired).await {
554 Ok(response) => response,
555 Err(error) => {
556 failed.push(failed_row(&desired.id, false, error.message));
557 return;
558 }
559 };
560 if response.id != desired.id {
561 failed.push(failed_row(
562 &desired.id,
563 true,
564 format!(
565 "dashboard PUT returned id '{}' instead of '{}'",
566 response.id, desired.id
567 ),
568 ));
569 return;
570 }
571
572 let mut paths: Vec<_> = dashboards::subset_losses(
573 &Value::Object(desired.data.clone()),
574 &Value::Object(response.data),
575 )
576 .into_iter()
577 .map(|loss| loss.path)
578 .collect();
579 if paths.is_empty() {
580 succeeded.push(serde_json::json!({"id": desired.id, "action": action}));
581 return;
582 }
583 paths.sort();
584 let warnings = match dashboards::get(transport, &desired.id).await {
585 Ok(dashboard) if dashboard.id == desired.id => dashboard
586 .warnings
587 .into_iter()
588 .map(|warning| warning.message)
589 .collect(),
590 Ok(dashboard) => {
591 failed.push(failed_row(
592 &desired.id,
593 true,
594 format!(
595 "dashboard loss audit returned id '{}' instead of '{}'",
596 dashboard.id, desired.id
597 ),
598 ));
599 Vec::new()
600 }
601 Err(error) => {
602 failed.push(failed_row(
603 &desired.id,
604 true,
605 format!("dashboard loss audit failed: {}", error.message),
606 ));
607 Vec::new()
608 }
609 };
610 lossy.push(serde_json::json!({
611 "id": desired.id,
612 "applied": true,
613 "paths": paths,
614 "warnings": warnings,
615 }));
616}
617
618fn validate_import_plan(plan: &DashboardImportPlan) -> Result<()> {
619 if plan.total == 0 {
620 return invalid_plan("total must be greater than zero");
621 }
622 if plan.total != plan.specs.len() + plan.skipped.len() {
623 return invalid_plan("total does not equal pending and skipped dashboards");
624 }
625 if !plan.skipped.is_empty() && plan.overwrite {
626 return invalid_plan("skipped dashboards require skip-existing mode");
627 }
628
629 let mut ids = Vec::with_capacity(plan.specs.len());
630 for spec in &plan.specs {
631 dashboards::validate_spec(spec)?;
632 if ids
633 .last()
634 .is_some_and(|previous: &String| previous >= &spec.id)
635 {
636 return invalid_plan("pending dashboards must be unique and sorted by id");
637 }
638 ids.push(spec.id.clone());
639 }
640 let pending: BTreeSet<_> = ids.iter().cloned().collect();
641 if plan.preview.targets != ids {
642 return invalid_plan("preview targets do not match pending dashboards");
643 }
644 if plan.preview.preview_action != format!("Import {} dashboard(s)", plan.specs.len()) {
645 return invalid_plan("preview action does not match pending dashboards");
646 }
647 if plan.preview.preview_details != import_preview_details(&plan.specs, &plan.before) {
648 return invalid_plan("preview details do not match pending dashboards");
649 }
650
651 if plan.before.keys().cloned().collect::<BTreeSet<_>>() != pending {
652 return invalid_plan("preflight snapshots do not match pending dashboards");
653 }
654 for spec in &plan.specs {
655 match plan.before.get(&spec.id).expect("checked key set") {
656 None => {}
657 Some(snapshot) => {
658 dashboards::validate_spec(snapshot)?;
659 if snapshot.id != spec.id {
660 return invalid_plan("preflight snapshot id does not match its target");
661 }
662 if !plan.overwrite {
663 return invalid_plan("replacement plan requires overwrite");
664 }
665 }
666 }
667 }
668
669 let mut skipped_ids = BTreeSet::new();
670 let mut previous = None;
671 for row in &plan.skipped {
672 let object = row
673 .as_object()
674 .filter(|object| object.len() == 2)
675 .ok_or_else(|| Error::new(ErrorKind::Error, "invalid dashboard import skipped row"))?;
676 if !object.keys().map(String::as_str).eq(["id", "reason"]) {
677 return invalid_plan("invalid dashboard import skipped row order");
678 }
679 let id = object
680 .get("id")
681 .and_then(Value::as_str)
682 .filter(|id| !id.trim().is_empty())
683 .ok_or_else(|| Error::new(ErrorKind::Error, "invalid dashboard import skipped row"))?;
684 if object.get("reason").and_then(Value::as_str) != Some("exists")
685 || previous.is_some_and(|previous: &str| previous >= id)
686 || !skipped_ids.insert(id.to_owned())
687 || pending.contains(id)
688 {
689 return invalid_plan("invalid dashboard import skipped rows");
690 }
691 previous = Some(id);
692 }
693 Ok(())
694}
695
696fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
697 serde_json::json!({"id": id, "applied": applied, "error": error.into()})
698}
699
700fn invalid_plan(message: impl Into<String>) -> Result<()> {
701 Err(Error::new(ErrorKind::Error, message))
702}
703
704pub async fn export(
706 transport: &Transport,
707 selectors: &[String],
708 format: ContentFormat,
709) -> Result<ExportOutcome> {
710 let selected = if selectors.is_empty() {
711 list_op(transport, &DashboardFilter::default())
712 .await?
713 .dashboards
714 } else {
715 let mut selected = Vec::with_capacity(selectors.len());
716 for selector in selectors {
717 selected.push(resolve(transport, selector).await?);
718 }
719 selected
720 };
721 let selected: BTreeMap<_, _> = selected
722 .into_iter()
723 .map(|dashboard| (dashboard.id.clone(), dashboard))
724 .collect();
725 let mut specs = Vec::with_capacity(selected.len());
726 for (id, _) in selected {
727 let dashboard = dashboards::get(transport, &id).await?;
728 if dashboard.id != id {
729 return Err(Error::new(
730 ErrorKind::Http,
731 format!("dashboard export was short: expected id '{id}'"),
732 ));
733 }
734 if !dashboard.warnings.is_empty() {
735 return Err(Error::new(
736 ErrorKind::Unsupported,
737 format!(
738 "dashboard '{id}' cannot be exported through the typed API without loss: {}; use `dashboards bundle export {id}`",
739 dashboard
740 .warnings
741 .iter()
742 .map(|warning| warning.message.as_str())
743 .collect::<Vec<_>>()
744 .join("; ")
745 ),
746 ));
747 }
748 let spec = DashboardSpec {
749 id: dashboard.id,
750 data: dashboard.data,
751 };
752 dashboards::validate_spec(&spec)?;
753 specs.push(spec);
754 }
755 specs.sort_by(|left, right| left.id.cmp(&right.id));
756 let body = content_codec::encode_sequence(&specs, format)?;
757 Ok(ExportOutcome {
758 body,
759 exported: specs.len() as u64,
760 missing: Vec::<Value>::new(),
761 })
762}
763
764pub async fn export_bundle(transport: &Transport, selectors: &[String]) -> Result<ExportOutcome> {
767 let selected = if selectors.is_empty() {
768 list_op(transport, &DashboardFilter::default())
769 .await?
770 .dashboards
771 } else {
772 let mut selected = Vec::with_capacity(selectors.len());
773 for selector in selectors {
774 selected.push(resolve(transport, selector).await?);
775 }
776 selected
777 };
778 let ids: Vec<_> = selected
779 .into_iter()
780 .map(|dashboard| dashboard.id)
781 .collect::<BTreeSet<_>>()
782 .into_iter()
783 .collect();
784 let body = saved_objects::export(transport, &ids).await?;
785 if !ids.is_empty() {
786 let scan = saved_objects::scan_bundle(&body).map_err(|error| {
787 Error::new(
788 ErrorKind::Http,
789 format!("decoding dashboard bundle export: {}", error.message),
790 )
791 })?;
792 let exported: BTreeSet<_> = scan.dashboards.into_iter().collect();
793 let missing: Vec<_> = ids
794 .iter()
795 .filter(|id| !exported.contains(*id))
796 .cloned()
797 .collect();
798 if !missing.is_empty() {
799 return Err(Error::new(
800 ErrorKind::Http,
801 format!(
802 "dashboard bundle export was short: missing {}",
803 missing.join(", ")
804 ),
805 ));
806 }
807 }
808 Ok(ExportOutcome {
809 body,
810 exported: ids.len() as u64,
811 missing: Vec::new(),
812 })
813}
814
815pub fn plan_bundle_import(path: &Path, overwrite: bool) -> Result<BundleImportPlan> {
817 let bytes = std::fs::read(path).map_err(|error| {
818 Error::new(
819 ErrorKind::Error,
820 format!("reading {}: {error}", path.display()),
821 )
822 })?;
823 let ndjson = String::from_utf8(bytes).map_err(|error| {
824 Error::new(
825 ErrorKind::Error,
826 format!(
827 "reading {}: bundle is not valid UTF-8: {error}",
828 path.display()
829 ),
830 )
831 })?;
832 let scan = saved_objects::scan_bundle(&ndjson)?;
833 let preview = bundle_import_preview(&scan, overwrite);
834 Ok(BundleImportPlan {
835 preview,
836 ndjson,
837 scan,
838 overwrite,
839 })
840}
841
842pub async fn apply_bundle_import(
844 transport: &Transport,
845 plan: &BundleImportPlan,
846) -> Result<BundleImportOutcome> {
847 validate_bundle_import_plan(plan)?;
848 transport.require_feature(Feature::Dashboards).await?;
849 let report = saved_objects::import(transport, &plan.ndjson, plan.overwrite).await?;
850 Ok(BundleImportOutcome {
851 applied: true,
852 succeeded: sanitize_bundle_success_rows(&report.success_results)?,
853 failed: sanitize_bundle_failure_rows(&report.errors)?,
854 total: plan.scan.total,
855 })
856}
857
858fn sanitize_bundle_success_rows(rows: &[Value]) -> Result<Vec<Value>> {
859 rows.iter()
860 .map(|row| {
861 let object = bundle_report_object(row, "success result")?;
862 let object_type = bundle_report_string(object, "type", "success result")?;
863 let id = bundle_report_string(object, "id", "success result")?;
864 Ok(json!({"type": object_type, "id": id}))
865 })
866 .collect()
867}
868
869fn sanitize_bundle_failure_rows(rows: &[Value]) -> Result<Vec<Value>> {
870 rows.iter()
871 .map(|row| {
872 let object = bundle_report_object(row, "error result")?;
873 let object_type = bundle_report_string(object, "type", "error result")?;
874 let id = bundle_report_string(object, "id", "error result")?;
875 let error = object
876 .get("error")
877 .and_then(Value::as_object)
878 .ok_or_else(|| {
879 bundle_import_response_error("error result error must be an object")
880 })?;
881 let error_type = bundle_report_string(error, "type", "error result error")?;
882 Ok(json!({"type": object_type, "id": id, "error": error_type}))
883 })
884 .collect()
885}
886
887fn bundle_report_object<'a>(row: &'a Value, kind: &str) -> Result<&'a Map<String, Value>> {
888 row.as_object()
889 .ok_or_else(|| bundle_import_response_error(&format!("{kind} must be an object")))
890}
891
892fn bundle_report_string<'a>(
893 object: &'a Map<String, Value>,
894 field: &str,
895 kind: &str,
896) -> Result<&'a str> {
897 object
898 .get(field)
899 .and_then(Value::as_str)
900 .filter(|value| !value.trim().is_empty())
901 .ok_or_else(|| {
902 bundle_import_response_error(&format!("{kind} {field} must be a non-empty string"))
903 })
904}
905
906fn bundle_import_response_error(message: &str) -> Error {
907 Error::new(
908 ErrorKind::Http,
909 format!("decoding dashboard bundle import response: {message}"),
910 )
911}
912
913fn summary_from_dashboard(dashboard: Dashboard) -> Result<DashboardSummary> {
914 let title = dashboard
915 .data
916 .get("title")
917 .and_then(Value::as_str)
918 .filter(|title| !title.trim().is_empty())
919 .ok_or_else(|| {
920 Error::new(
921 ErrorKind::Http,
922 "decoding dashboard get: data.title must be a non-empty string",
923 )
924 })?;
925 Ok(DashboardSummary {
926 id: dashboard.id,
927 title: title.to_string(),
928 description: dashboard
929 .data
930 .get("description")
931 .and_then(Value::as_str)
932 .map(str::to_string),
933 tags: dashboard
934 .data
935 .get("tags")
936 .and_then(Value::as_array)
937 .map(|tags| {
938 tags.iter()
939 .filter_map(Value::as_str)
940 .map(str::to_string)
941 .collect()
942 }),
943 })
944}
945
946async fn read_spec(transport: &Transport, id: &str) -> Result<DashboardSpec> {
949 let dashboard = dashboards::get(transport, id).await?;
950 if dashboard.id != id {
951 return Err(Error::new(
952 ErrorKind::Http,
953 format!(
954 "decoding dashboard: expected id '{id}', got '{}'",
955 dashboard.id
956 ),
957 ));
958 }
959 let spec = DashboardSpec {
960 id: dashboard.id,
961 data: dashboard.data,
962 };
963 dashboards::validate_spec(&spec)?;
964 Ok(spec)
965}
966
967fn import_preview_details(
968 specs: &[DashboardSpec],
969 before: &BTreeMap<String, Option<DashboardSpec>>,
970) -> Vec<String> {
971 specs
972 .iter()
973 .filter_map(|spec| match before.get(&spec.id) {
974 Some(None) => Some(format!("{} create {}", spec.id, dashboard_title(spec))),
975 Some(Some(current)) if current == spec => {
976 Some(format!("{} no-op {}", spec.id, dashboard_title(spec)))
977 }
978 Some(Some(current)) => Some(format!(
979 "{} replace {} -> {}",
980 spec.id,
981 dashboard_title(current),
982 dashboard_title(spec)
983 )),
984 None => None,
985 })
986 .collect()
987}
988
989fn dashboard_title(spec: &DashboardSpec) -> &str {
990 spec.data
991 .get("title")
992 .and_then(Value::as_str)
993 .expect("validated dashboard specs have a title")
994}
995
996fn bundle_import_preview(scan: &saved_objects::BundleScan, overwrite: bool) -> MutationPlan {
997 let mut dashboards = scan.dashboards.clone();
998 dashboards.sort();
999 let mut preview_details = dashboards
1000 .iter()
1001 .map(|id| format!("dashboard/{id}"))
1002 .collect::<Vec<_>>();
1003 preview_details.extend(
1004 scan.counts
1005 .iter()
1006 .filter(|(object_type, _)| object_type.as_str() != "dashboard")
1007 .map(|(object_type, count)| format!("{object_type} {count}")),
1008 );
1009 let dashboard_count = scan.dashboards.len();
1010 let related_count = scan.total.saturating_sub(dashboard_count);
1011 MutationPlan {
1012 preview_action: format!(
1013 "{} {} dashboard(s) and {related_count} related saved object(s)",
1014 if overwrite {
1015 "Import or replace"
1016 } else {
1017 "Import"
1018 },
1019 dashboard_count,
1020 ),
1021 preview_details,
1022 targets: scan.dashboards.clone(),
1023 }
1024}
1025
1026fn validate_bundle_import_plan(plan: &BundleImportPlan) -> Result<()> {
1027 let scan = saved_objects::scan_bundle(&plan.ndjson)?;
1028 if scan != plan.scan {
1029 return invalid_plan("bundle scan does not match the planned NDJSON");
1030 }
1031 if plan.preview != bundle_import_preview(&scan, plan.overwrite) {
1032 return invalid_plan("bundle import preview does not match the planned NDJSON");
1033 }
1034 Ok(())
1035}