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