Skip to main content

elasticctl_api/dashboards_ops/
bundle.rs

1//! Dashboard bundle export and import.
2
3use 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/// The immutable, guard-ready work computed from one opaque Saved Objects
9/// dashboard bundle.
10#[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/// The per-object report from applying an opaque Saved Objects bundle import.
19#[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}
26/// Export selected dashboards and their deep Saved Objects references as
27/// opaque NDJSON.
28pub 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
77/// Fully read and scan an opaque bundle before presenting its mutation guard.
78pub 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
104/// Apply a guard-approved opaque bundle without rereading its source file.
105pub 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}