Skip to main content

elasticctl_api/dashboards_ops/
import.rs

1use super::{
2    BTreeMap, BTreeSet, DashboardSpec, Error, ErrorKind, Path, Result, Serialize, Transport, Value,
3    dashboards, data_views, invalid_plan, validate,
4};
5
6#[derive(Debug, Clone, PartialEq)]
7pub struct DashboardImportPlan {
8    pub preview: crate::ops::MutationPlan,
9    pub specs: Vec<DashboardSpec>,
10    pub before: BTreeMap<String, Option<DashboardSpec>>,
11    pub skipped: Vec<Value>,
12    pub total: usize,
13    pub overwrite: bool,
14}
15
16#[derive(Debug, Clone, PartialEq, Serialize)]
17pub struct DashboardImportReport {
18    pub applied: bool,
19    pub succeeded: Vec<Value>,
20    pub skipped: Vec<Value>,
21    pub failed: Vec<Value>,
22    pub lossy: Vec<Value>,
23    pub total: usize,
24}
25pub async fn plan_import(
26    transport: Option<&Transport>,
27    path: &Path,
28    overwrite: bool,
29    skip_existing: bool,
30) -> Result<DashboardImportPlan> {
31    let mut specs = validate(path)?;
32    if specs.is_empty() {
33        return Err(Error::new(
34            ErrorKind::Error,
35            "dashboard import needs at least one dashboard",
36        ));
37    }
38    if overwrite && skip_existing {
39        return Err(Error::new(
40            ErrorKind::Error,
41            "--overwrite and --skip-existing cannot be used together",
42        ));
43    }
44
45    let references: BTreeSet<_> = specs
46        .iter()
47        .flat_map(|spec| dashboards::collect_data_view_refs(&Value::Object(spec.data.clone())))
48        .collect();
49    let requires_server =
50        overwrite || skip_existing || transport.is_some() || !references.is_empty();
51    let transport = if requires_server { transport } else { None };
52    if (overwrite || skip_existing || !references.is_empty()) && transport.is_none() {
53        return Err(Error::new(
54            ErrorKind::Error,
55            "dashboard import preflight needs a transport",
56        ));
57    }
58
59    let total = specs.len();
60    let mut before = BTreeMap::new();
61    let mut conflicts = Vec::new();
62    if let Some(transport) = transport {
63        for spec in &specs {
64            match read_spec(transport, &spec.id).await {
65                Ok(current) => {
66                    if !overwrite && !skip_existing {
67                        conflicts.push(spec.id.clone());
68                    }
69                    before.insert(spec.id.clone(), Some(current));
70                }
71                Err(error) if error.kind == ErrorKind::NotFound => {
72                    before.insert(spec.id.clone(), None);
73                }
74                Err(error) => return Err(error),
75            }
76        }
77
78        let mut missing = Vec::new();
79        for id in references {
80            match data_views::get(transport, &id).await {
81                Ok(data_view) => match data_view.data_view.get("id").and_then(Value::as_str) {
82                    Some(actual) if actual == id => {}
83                    Some(actual) => {
84                        return Err(Error::new(
85                            ErrorKind::Http,
86                            format!("decoding data view: expected id '{id}', got '{actual}'"),
87                        ));
88                    }
89                    None => {
90                        return Err(Error::new(
91                            ErrorKind::Http,
92                            format!(
93                                "decoding data view: expected id '{id}', got missing or non-string id"
94                            ),
95                        ));
96                    }
97                },
98                Err(error) if error.kind == ErrorKind::NotFound => missing.push(id),
99                Err(error) => return Err(error),
100            }
101        }
102        if !missing.is_empty() {
103            return Err(Error::new(
104                ErrorKind::NotFound,
105                format!("referenced data views do not exist: {}", missing.join(", ")),
106            ));
107        }
108    } else {
109        before.extend(specs.iter().map(|spec| (spec.id.clone(), None)));
110    }
111
112    if !conflicts.is_empty() {
113        return Err(Error::new(
114            ErrorKind::Conflict,
115            format!("dashboards already exist: {}", conflicts.join(", ")),
116        ));
117    }
118
119    let mut skipped = Vec::new();
120    if skip_existing {
121        specs.retain(|spec| match before.get(&spec.id) {
122            Some(Some(_)) => {
123                skipped.push(serde_json::json!({"id": spec.id, "reason": "exists"}));
124                false
125            }
126            _ => true,
127        });
128        before.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
129    }
130
131    let preview = crate::ops::MutationPlan {
132        preview_action: format!("Import {} dashboard(s)", specs.len()),
133        preview_details: import_preview_details(&specs, &before),
134        targets: specs.iter().map(|spec| spec.id.clone()).collect(),
135    };
136    Ok(DashboardImportPlan {
137        preview,
138        specs,
139        before,
140        skipped,
141        total,
142        overwrite,
143    })
144}
145
146pub async fn apply_import(
147    transport: &Transport,
148    plan: &DashboardImportPlan,
149) -> Result<DashboardImportReport> {
150    validate_import_plan(plan)?;
151    let mut succeeded = Vec::new();
152    let mut failed = Vec::new();
153    let mut lossy = Vec::new();
154
155    for desired in &plan.specs {
156        let Some(before) = plan.before.get(&desired.id) else {
157            failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
158            continue;
159        };
160        let current = match read_spec(transport, &desired.id).await {
161            Ok(current) => Some(current),
162            Err(error) if error.kind == ErrorKind::NotFound => None,
163            Err(error) => {
164                failed.push(failed_row(&desired.id, false, error.message));
165                continue;
166            }
167        };
168
169        match (before, current) {
170            (None, Some(_)) => {
171                failed.push(failed_row(
172                    &desired.id,
173                    false,
174                    "dashboard appeared since preview",
175                ));
176            }
177            (Some(_), None) => {
178                failed.push(failed_row(
179                    &desired.id,
180                    false,
181                    "dashboard disappeared since preview",
182                ));
183            }
184            (Some(before), Some(current)) if before != &current => {
185                failed.push(failed_row(
186                    &desired.id,
187                    false,
188                    "dashboard changed since preview",
189                ));
190            }
191            (Some(_), Some(current)) if current == *desired => {
192                succeeded.push(serde_json::json!({"id": desired.id, "action": "unchanged"}));
193            }
194            (None, None) => {
195                apply_put(
196                    transport,
197                    desired,
198                    "created",
199                    &mut succeeded,
200                    &mut failed,
201                    &mut lossy,
202                )
203                .await;
204            }
205            (Some(_), Some(_)) => {
206                apply_put(
207                    transport,
208                    desired,
209                    "replaced",
210                    &mut succeeded,
211                    &mut failed,
212                    &mut lossy,
213                )
214                .await;
215            }
216        }
217    }
218
219    Ok(DashboardImportReport {
220        applied: true,
221        succeeded,
222        skipped: plan.skipped.clone(),
223        failed,
224        lossy,
225        total: plan.total,
226    })
227}
228async fn apply_put(
229    transport: &Transport,
230    desired: &DashboardSpec,
231    action: &str,
232    succeeded: &mut Vec<Value>,
233    failed: &mut Vec<Value>,
234    lossy: &mut Vec<Value>,
235) {
236    let response = match dashboards::put(transport, desired).await {
237        Ok(response) => response,
238        Err(error) => {
239            failed.push(failed_row(&desired.id, false, error.message));
240            return;
241        }
242    };
243    if response.id != desired.id {
244        failed.push(failed_row(
245            &desired.id,
246            true,
247            format!(
248                "dashboard PUT returned id '{}' instead of '{}'",
249                response.id, desired.id
250            ),
251        ));
252        return;
253    }
254
255    let mut paths: Vec<_> = dashboards::subset_losses(
256        &Value::Object(desired.data.clone()),
257        &Value::Object(response.data),
258    )
259    .into_iter()
260    .map(|loss| loss.path)
261    .collect();
262    if paths.is_empty() {
263        succeeded.push(serde_json::json!({"id": desired.id, "action": action}));
264        return;
265    }
266    paths.sort();
267    let warnings = match dashboards::get(transport, &desired.id).await {
268        Ok(dashboard) if dashboard.id == desired.id => dashboard
269            .warnings
270            .into_iter()
271            .map(|warning| warning.message)
272            .collect(),
273        Ok(dashboard) => {
274            failed.push(failed_row(
275                &desired.id,
276                true,
277                format!(
278                    "dashboard loss audit returned id '{}' instead of '{}'",
279                    dashboard.id, desired.id
280                ),
281            ));
282            Vec::new()
283        }
284        Err(error) => {
285            failed.push(failed_row(
286                &desired.id,
287                true,
288                format!("dashboard loss audit failed: {}", error.message),
289            ));
290            Vec::new()
291        }
292    };
293    lossy.push(serde_json::json!({
294        "id": desired.id,
295        "applied": true,
296        "paths": paths,
297        "warnings": warnings,
298    }));
299}
300
301fn validate_import_plan(plan: &DashboardImportPlan) -> Result<()> {
302    if plan.total == 0 {
303        return invalid_plan("total must be greater than zero");
304    }
305    if plan.total != plan.specs.len() + plan.skipped.len() {
306        return invalid_plan("total does not equal pending and skipped dashboards");
307    }
308    if !plan.skipped.is_empty() && plan.overwrite {
309        return invalid_plan("skipped dashboards require skip-existing mode");
310    }
311
312    let mut ids = Vec::with_capacity(plan.specs.len());
313    for spec in &plan.specs {
314        dashboards::validate_spec(spec)?;
315        if ids
316            .last()
317            .is_some_and(|previous: &String| previous >= &spec.id)
318        {
319            return invalid_plan("pending dashboards must be unique and sorted by id");
320        }
321        ids.push(spec.id.clone());
322    }
323    let pending: BTreeSet<_> = ids.iter().cloned().collect();
324    if plan.preview.targets != ids {
325        return invalid_plan("preview targets do not match pending dashboards");
326    }
327    if plan.preview.preview_action != format!("Import {} dashboard(s)", plan.specs.len()) {
328        return invalid_plan("preview action does not match pending dashboards");
329    }
330    if plan.preview.preview_details != import_preview_details(&plan.specs, &plan.before) {
331        return invalid_plan("preview details do not match pending dashboards");
332    }
333
334    if plan.before.keys().cloned().collect::<BTreeSet<_>>() != pending {
335        return invalid_plan("preflight snapshots do not match pending dashboards");
336    }
337    for spec in &plan.specs {
338        match plan.before.get(&spec.id).expect("checked key set") {
339            None => {}
340            Some(snapshot) => {
341                dashboards::validate_spec(snapshot)?;
342                if snapshot.id != spec.id {
343                    return invalid_plan("preflight snapshot id does not match its target");
344                }
345                if !plan.overwrite {
346                    return invalid_plan("replacement plan requires overwrite");
347                }
348            }
349        }
350    }
351
352    let mut skipped_ids = BTreeSet::new();
353    let mut previous = None;
354    for row in &plan.skipped {
355        let object = row
356            .as_object()
357            .filter(|object| object.len() == 2)
358            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid dashboard import skipped row"))?;
359        if !object.keys().map(String::as_str).eq(["id", "reason"]) {
360            return invalid_plan("invalid dashboard import skipped row order");
361        }
362        let id = object
363            .get("id")
364            .and_then(Value::as_str)
365            .filter(|id| !id.trim().is_empty())
366            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid dashboard import skipped row"))?;
367        if object.get("reason").and_then(Value::as_str) != Some("exists")
368            || previous.is_some_and(|previous: &str| previous >= id)
369            || !skipped_ids.insert(id.to_owned())
370            || pending.contains(id)
371        {
372            return invalid_plan("invalid dashboard import skipped rows");
373        }
374        previous = Some(id);
375    }
376    Ok(())
377}
378
379fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
380    serde_json::json!({"id": id, "applied": applied, "error": error.into()})
381}
382async fn read_spec(transport: &Transport, id: &str) -> Result<DashboardSpec> {
383    let dashboard = dashboards::get(transport, id).await?;
384    if dashboard.id != id {
385        return Err(Error::new(
386            ErrorKind::Http,
387            format!(
388                "decoding dashboard: expected id '{id}', got '{}'",
389                dashboard.id
390            ),
391        ));
392    }
393    let spec = DashboardSpec {
394        id: dashboard.id,
395        data: dashboard.data,
396    };
397    dashboards::validate_spec(&spec)?;
398    Ok(spec)
399}
400
401fn import_preview_details(
402    specs: &[DashboardSpec],
403    before: &BTreeMap<String, Option<DashboardSpec>>,
404) -> Vec<String> {
405    specs
406        .iter()
407        .filter_map(|spec| match before.get(&spec.id) {
408            Some(None) => Some(format!("{}  create  {}", spec.id, dashboard_title(spec))),
409            Some(Some(current)) if current == spec => {
410                Some(format!("{}  no-op  {}", spec.id, dashboard_title(spec)))
411            }
412            Some(Some(current)) => Some(format!(
413                "{}  replace  {} -> {}",
414                spec.id,
415                dashboard_title(current),
416                dashboard_title(spec)
417            )),
418            None => None,
419        })
420        .collect()
421}
422
423fn dashboard_title(spec: &DashboardSpec) -> &str {
424    spec.data
425        .get("title")
426        .and_then(Value::as_str)
427        .expect("validated dashboard specs have a title")
428}