Skip to main content

elasticctl_api/dashboards_ops/
bundle.rs

1use 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}