elasticctl_api/dashboards_ops/
bundle.rs1use super::{
2 BTreeSet, DashboardFilter, Error, ErrorKind, ExportOutcome, Feature, Map, MutationPlan, Path,
3 Result, Serialize, Transport, Value, invalid_plan, json, list_op, resolve, saved_objects,
4};
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct BundleImportPlan {
8 pub preview: MutationPlan,
9 pub ndjson: String,
10 pub scan: saved_objects::BundleScan,
11 pub overwrite: bool,
12}
13
14#[derive(Debug, Clone, PartialEq, Serialize)]
15pub struct BundleImportOutcome {
16 pub applied: bool,
17 pub succeeded: Vec<Value>,
18 pub failed: Vec<Value>,
19 pub total: usize,
20}
21pub async fn export_bundle(transport: &Transport, selectors: &[String]) -> Result<ExportOutcome> {
22 let selected = if selectors.is_empty() {
23 list_op(transport, &DashboardFilter::default())
24 .await?
25 .dashboards
26 } else {
27 let mut selected = Vec::with_capacity(selectors.len());
28 for selector in selectors {
29 selected.push(resolve(transport, selector).await?);
30 }
31 selected
32 };
33 let ids: Vec<_> = selected
34 .into_iter()
35 .map(|dashboard| dashboard.id)
36 .collect::<BTreeSet<_>>()
37 .into_iter()
38 .collect();
39 let body = saved_objects::export(transport, &ids).await?;
40 if !ids.is_empty() {
41 let scan = saved_objects::scan_bundle(&body).map_err(|error| {
42 Error::new(
43 ErrorKind::Http,
44 format!("decoding dashboard bundle export: {}", error.message),
45 )
46 })?;
47 let exported: BTreeSet<_> = scan.dashboards.into_iter().collect();
48 let missing: Vec<_> = ids
49 .iter()
50 .filter(|id| !exported.contains(*id))
51 .cloned()
52 .collect();
53 if !missing.is_empty() {
54 return Err(Error::new(
55 ErrorKind::Http,
56 format!(
57 "dashboard bundle export was short: missing {}",
58 missing.join(", ")
59 ),
60 ));
61 }
62 }
63 Ok(ExportOutcome {
64 body,
65 exported: ids.len() as u64,
66 missing: Vec::new(),
67 })
68}
69
70pub fn plan_bundle_import(path: &Path, overwrite: bool) -> Result<BundleImportPlan> {
71 let bytes = std::fs::read(path).map_err(|error| {
72 Error::new(
73 ErrorKind::Error,
74 format!("reading {}: {error}", path.display()),
75 )
76 })?;
77 let ndjson = String::from_utf8(bytes).map_err(|error| {
78 Error::new(
79 ErrorKind::Error,
80 format!(
81 "reading {}: bundle is not valid UTF-8: {error}",
82 path.display()
83 ),
84 )
85 })?;
86 let scan = saved_objects::scan_bundle(&ndjson)?;
87 let preview = bundle_import_preview(&scan, overwrite);
88 Ok(BundleImportPlan {
89 preview,
90 ndjson,
91 scan,
92 overwrite,
93 })
94}
95
96pub async fn apply_bundle_import(
97 transport: &Transport,
98 plan: &BundleImportPlan,
99) -> Result<BundleImportOutcome> {
100 validate_bundle_import_plan(plan)?;
101 transport.require_feature(Feature::Dashboards).await?;
102 let report = saved_objects::import(transport, &plan.ndjson, plan.overwrite).await?;
103 Ok(BundleImportOutcome {
104 applied: true,
105 succeeded: sanitize_bundle_success_rows(&report.success_results)?,
106 failed: sanitize_bundle_failure_rows(&report.errors)?,
107 total: plan.scan.total,
108 })
109}
110
111fn sanitize_bundle_success_rows(rows: &[Value]) -> Result<Vec<Value>> {
112 rows.iter()
113 .map(|row| {
114 let object = bundle_report_object(row, "success result")?;
115 let object_type = bundle_report_string(object, "type", "success result")?;
116 let id = bundle_report_string(object, "id", "success result")?;
117 Ok(json!({"type": object_type, "id": id}))
118 })
119 .collect()
120}
121
122fn sanitize_bundle_failure_rows(rows: &[Value]) -> Result<Vec<Value>> {
123 rows.iter()
124 .map(|row| {
125 let object = bundle_report_object(row, "error result")?;
126 let object_type = bundle_report_string(object, "type", "error result")?;
127 let id = bundle_report_string(object, "id", "error result")?;
128 let error = object
129 .get("error")
130 .and_then(Value::as_object)
131 .ok_or_else(|| {
132 bundle_import_response_error("error result error must be an object")
133 })?;
134 let error_type = bundle_report_string(error, "type", "error result error")?;
135 Ok(json!({"type": object_type, "id": id, "error": error_type}))
136 })
137 .collect()
138}
139
140fn bundle_report_object<'a>(row: &'a Value, kind: &str) -> Result<&'a Map<String, Value>> {
141 row.as_object()
142 .ok_or_else(|| bundle_import_response_error(&format!("{kind} must be an object")))
143}
144
145fn bundle_report_string<'a>(
146 object: &'a Map<String, Value>,
147 field: &str,
148 kind: &str,
149) -> Result<&'a str> {
150 object
151 .get(field)
152 .and_then(Value::as_str)
153 .filter(|value| !value.trim().is_empty())
154 .ok_or_else(|| {
155 bundle_import_response_error(&format!("{kind} {field} must be a non-empty string"))
156 })
157}
158
159fn bundle_import_response_error(message: &str) -> Error {
160 Error::new(
161 ErrorKind::Http,
162 format!("decoding dashboard bundle import response: {message}"),
163 )
164}
165fn bundle_import_preview(scan: &saved_objects::BundleScan, overwrite: bool) -> MutationPlan {
166 let mut dashboards = scan.dashboards.clone();
167 dashboards.sort();
168 let mut preview_details = dashboards
169 .iter()
170 .map(|id| format!("dashboard/{id}"))
171 .collect::<Vec<_>>();
172 preview_details.extend(
173 scan.counts
174 .iter()
175 .filter(|(object_type, _)| object_type.as_str() != "dashboard")
176 .map(|(object_type, count)| format!("{object_type} {count}")),
177 );
178 let dashboard_count = scan.dashboards.len();
179 let related_count = scan.total.saturating_sub(dashboard_count);
180 MutationPlan {
181 preview_action: format!(
182 "{} {} dashboard(s) and {related_count} related saved object(s)",
183 if overwrite {
184 "Import or replace"
185 } else {
186 "Import"
187 },
188 dashboard_count,
189 ),
190 preview_details,
191 targets: scan.dashboards.clone(),
192 }
193}
194
195fn validate_bundle_import_plan(plan: &BundleImportPlan) -> Result<()> {
196 let scan = saved_objects::scan_bundle(&plan.ndjson)?;
197 if scan != plan.scan {
198 return invalid_plan("bundle scan does not match the planned NDJSON");
199 }
200 if plan.preview != bundle_import_preview(&scan, plan.overwrite) {
201 return invalid_plan("bundle import preview does not match the planned NDJSON");
202 }
203 Ok(())
204}