Skip to main content

elasticctl_api/data_views_ops/
import.rs

1//! Data view import planning and apply.
2
3use super::{
4    BTreeMap, BTreeSet, DataViewSpec, DataViewUpdate, Error, ErrorKind, Map, MutationPlan, Path,
5    Result, Serialize, Transport, Value, data_views, invalid_plan, json, normalize, validate,
6};
7
8/// The two documented routes needed to replace a portable data view.
9#[derive(Debug, Clone, Default, PartialEq, Serialize)]
10pub struct DataViewPatch {
11    pub base: Option<DataViewUpdate>,
12    pub field_metadata: Map<String, Value>,
13}
14
15/// The immutable, guard-ready import work computed from one portable artifact.
16#[derive(Debug, Clone, PartialEq)]
17pub struct DataViewImportPlan {
18    pub preview: MutationPlan,
19    pub specs: Vec<DataViewSpec>,
20    pub before: BTreeMap<String, Option<DataViewSpec>>,
21    pub patches: BTreeMap<String, DataViewPatch>,
22    pub skipped: Vec<Value>,
23    pub total: usize,
24    pub overwrite: bool,
25}
26
27/// The per-object result of applying a guarded data-view import.
28#[derive(Debug, Clone, PartialEq, Serialize)]
29pub struct DataViewImportReport {
30    pub applied: bool,
31    pub succeeded: Vec<Value>,
32    pub skipped: Vec<Value>,
33    pub failed: Vec<Value>,
34    pub total: usize,
35}
36/// Build the exact documented replacement delta between canonical data views.
37pub fn build_patch(current: &DataViewSpec, desired: &DataViewSpec) -> Result<DataViewPatch> {
38    current.validate()?;
39    desired.validate()?;
40    if current.id != desired.id {
41        return unsupported("changing data view id is not supported by the data-view update API");
42    }
43    if current.allow_hidden != desired.allow_hidden {
44        return unsupported("changing allowHidden is not supported by the data-view update API");
45    }
46    if current.name.is_some() && desired.name.is_none() {
47        return unsupported("removing name is not supported by the data-view update API");
48    }
49    if current.time_field_name.is_some() && desired.time_field_name.is_none() {
50        return unsupported("removing timeFieldName is not supported by the data-view update API");
51    }
52    if current.view_type.is_some() && desired.view_type.is_none() {
53        return unsupported("removing type is not supported by the data-view update API");
54    }
55    if current.type_meta.is_some() && desired.type_meta.is_none() {
56        return unsupported("removing typeMeta is not supported by the data-view update API");
57    }
58
59    let base = DataViewUpdate {
60        allow_no_index: changed(&current.allow_no_index, &desired.allow_no_index)
61            .then_some(desired.allow_no_index),
62        field_formats: changed(&current.field_formats, &desired.field_formats)
63            .then(|| desired.field_formats.clone()),
64        fields: changed(&current.fields, &desired.fields).then(|| desired.fields.clone()),
65        name: changed(&current.name, &desired.name)
66            .then(|| desired.name.clone())
67            .flatten(),
68        runtime_field_map: changed(&current.runtime_field_map, &desired.runtime_field_map)
69            .then(|| desired.runtime_field_map.clone()),
70        source_filters: changed(&current.source_filters, &desired.source_filters)
71            .then(|| desired.source_filters.clone()),
72        time_field_name: changed(&current.time_field_name, &desired.time_field_name)
73            .then(|| desired.time_field_name.clone())
74            .flatten(),
75        title: changed(&current.title, &desired.title).then(|| desired.title.clone()),
76        view_type: changed(&current.view_type, &desired.view_type)
77            .then(|| desired.view_type.clone())
78            .flatten(),
79        type_meta: changed(&current.type_meta, &desired.type_meta)
80            .then(|| desired.type_meta.clone())
81            .flatten(),
82    };
83    let base = (!is_empty_update(&base)).then_some(base);
84
85    let mut field_metadata = Map::new();
86    let names: BTreeSet<_> = current
87        .field_attrs
88        .keys()
89        .chain(desired.field_attrs.keys())
90        .collect();
91    for name in names {
92        let current_values = current.field_attrs.get(name).and_then(Value::as_object);
93        let desired_values = desired.field_attrs.get(name).and_then(Value::as_object);
94        let keys: BTreeSet<_> = current_values
95            .into_iter()
96            .flat_map(|values| values.keys())
97            .chain(desired_values.into_iter().flat_map(|values| values.keys()))
98            .collect();
99        let mut delta = Map::new();
100        for key in keys {
101            let before = current_values.and_then(|values| values.get(key));
102            let after = desired_values.and_then(|values| values.get(key));
103            if before != after {
104                delta.insert(key.clone(), after.cloned().unwrap_or(Value::Null));
105            }
106        }
107        if !delta.is_empty() {
108            field_metadata.insert(name.clone(), Value::Object(delta));
109        }
110    }
111    Ok(DataViewPatch {
112        base,
113        field_metadata,
114    })
115}
116
117/// Fully validate a portable artifact and prepare the exact guarded import.
118pub async fn plan_import(
119    transport: Option<&Transport>,
120    path: &Path,
121    overwrite: bool,
122    skip_existing: bool,
123) -> Result<DataViewImportPlan> {
124    let mut specs = validate(path)?;
125    if specs.is_empty() {
126        return Err(Error::new(
127            ErrorKind::Error,
128            "data-view import needs at least one data view",
129        ));
130    }
131    if overwrite && skip_existing {
132        return Err(Error::new(
133            ErrorKind::Error,
134            "--overwrite and --skip-existing cannot be used together",
135        ));
136    }
137    let total = specs.len();
138    let requires_server = overwrite || skip_existing || transport.is_some();
139    let transport = if requires_server { transport } else { None };
140    if (overwrite || skip_existing) && transport.is_none() {
141        return Err(Error::new(
142            ErrorKind::Error,
143            "data-view import conflict mode needs a transport",
144        ));
145    }
146
147    let mut before = BTreeMap::new();
148    let mut skipped = Vec::new();
149    let mut conflicts = Vec::new();
150    if let Some(transport) = transport {
151        for spec in &specs {
152            match read_spec(transport, &spec.id).await {
153                Ok(current) => {
154                    if !overwrite && !skip_existing {
155                        conflicts.push(spec.id.clone());
156                    }
157                    before.insert(spec.id.clone(), Some(current));
158                }
159                Err(error) if error.kind == ErrorKind::NotFound => {
160                    before.insert(spec.id.clone(), None);
161                }
162                Err(error) => return Err(error),
163            }
164        }
165    } else {
166        before.extend(specs.iter().map(|spec| (spec.id.clone(), None)));
167    }
168    if !conflicts.is_empty() {
169        return Err(Error::new(
170            ErrorKind::Conflict,
171            format!("data views already exist: {}", conflicts.join(", ")),
172        ));
173    }
174    if skip_existing {
175        specs.retain(|spec| match before.get(&spec.id) {
176            Some(Some(_)) => {
177                skipped.push(json!({"id": spec.id, "reason": "exists"}));
178                false
179            }
180            _ => true,
181        });
182        before.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
183    }
184
185    let mut patches = BTreeMap::new();
186    for spec in &specs {
187        match before.get(&spec.id).and_then(Option::as_ref) {
188            Some(current) if current == spec => {
189                patches.insert(spec.id.clone(), DataViewPatch::default());
190            }
191            Some(current) => {
192                let patch = build_patch(current, spec)?;
193                patches.insert(spec.id.clone(), patch);
194            }
195            None => {}
196        }
197    }
198    let preview = MutationPlan {
199        preview_action: format!(
200            "Import {} data view(s) from {}",
201            specs.len(),
202            path.display()
203        ),
204        preview_details: preview_details(&specs, &before),
205        targets: specs.iter().map(|spec| spec.id.clone()).collect(),
206    };
207    Ok(DataViewImportPlan {
208        preview,
209        specs,
210        before,
211        patches,
212        skipped,
213        total,
214        overwrite,
215    })
216}
217
218/// Apply a previously planned data-view import without rereading or replanning.
219///
220/// Each object gets one final pre-write read. The server exposes no conditional
221/// write token, so the final verification read remains the smallest unavoidable
222/// race window. Earlier successful writes are deliberately never rolled back.
223pub async fn apply_import(
224    transport: &Transport,
225    plan: &DataViewImportPlan,
226) -> Result<DataViewImportReport> {
227    validate_import_plan(plan)?;
228    let mut succeeded = Vec::new();
229    let mut failed = Vec::new();
230
231    for desired in &plan.specs {
232        let before = match plan.before.get(&desired.id) {
233            Some(before) => before,
234            None => {
235                failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
236                continue;
237            }
238        };
239        let current = match read_spec(transport, &desired.id).await {
240            Ok(current) => Some(current),
241            Err(error) if error.kind == ErrorKind::NotFound => None,
242            Err(error) => {
243                failed.push(failed_row(&desired.id, false, error.message));
244                continue;
245            }
246        };
247
248        match (before, current) {
249            (None, Some(_)) => {
250                failed.push(failed_row(
251                    &desired.id,
252                    false,
253                    "data view appeared since preview",
254                ));
255                continue;
256            }
257            (Some(_), None) => {
258                failed.push(failed_row(
259                    &desired.id,
260                    false,
261                    "data view disappeared since preview",
262                ));
263                continue;
264            }
265            (Some(before), Some(current)) if before != &current => {
266                failed.push(failed_row(
267                    &desired.id,
268                    false,
269                    "data view changed since preview",
270                ));
271                continue;
272            }
273            (None, None) => {
274                let mut applied = false;
275                match data_views::create(transport, desired).await {
276                    Ok(_) => applied = true,
277                    Err(error) => {
278                        failed.push(failed_row(&desired.id, applied, error.message));
279                        continue;
280                    }
281                }
282                match read_spec(transport, &desired.id).await {
283                    Ok(stored) if stored == *desired => {
284                        succeeded.push(json!({"id": desired.id, "action": "created"}));
285                    }
286                    Ok(_) => failed.push(failed_row(
287                        &desired.id,
288                        applied,
289                        "server stored a different data-view spec",
290                    )),
291                    Err(error) => failed.push(failed_row(&desired.id, applied, error.message)),
292                }
293            }
294            (Some(_), Some(_)) => {
295                let patch = match plan.patches.get(&desired.id) {
296                    Some(patch) => patch,
297                    None => {
298                        failed.push(failed_row(&desired.id, false, "missing replacement patch"));
299                        continue;
300                    }
301                };
302                if patch.base.is_none() && patch.field_metadata.is_empty() {
303                    succeeded.push(json!({"id": desired.id, "action": "unchanged"}));
304                    continue;
305                }
306
307                let mut applied = false;
308                if let Some(base) = &patch.base {
309                    if let Err(error) = data_views::update(transport, &desired.id, base).await {
310                        failed.push(failed_row(
311                            &desired.id,
312                            applied,
313                            format!("base update failed: {}", error.message),
314                        ));
315                        continue;
316                    }
317                    applied = true;
318                }
319                if !patch.field_metadata.is_empty()
320                    && let Err(error) = data_views::update_fields_metadata(
321                        transport,
322                        &desired.id,
323                        &patch.field_metadata,
324                    )
325                    .await
326                {
327                    let message = if applied {
328                        format!("base updated; field metadata failed: {}", error.message)
329                    } else {
330                        format!("field metadata failed: {}", error.message)
331                    };
332                    failed.push(failed_row(&desired.id, applied, message));
333                    continue;
334                }
335                if !patch.field_metadata.is_empty() {
336                    applied = true;
337                }
338                match read_spec(transport, &desired.id).await {
339                    Ok(stored) if stored == *desired => {
340                        succeeded.push(json!({"id": desired.id, "action": "replaced"}));
341                    }
342                    Ok(_) => failed.push(failed_row(
343                        &desired.id,
344                        applied,
345                        "server stored a different data-view spec",
346                    )),
347                    Err(error) => failed.push(failed_row(&desired.id, applied, error.message)),
348                }
349            }
350        }
351    }
352
353    Ok(DataViewImportReport {
354        applied: true,
355        succeeded,
356        skipped: plan.skipped.clone(),
357        failed,
358        total: plan.total,
359    })
360}
361
362fn changed<T: PartialEq>(current: &T, desired: &T) -> bool {
363    current != desired
364}
365
366fn is_empty_update(update: &DataViewUpdate) -> bool {
367    update.allow_no_index.is_none()
368        && update.field_formats.is_none()
369        && update.fields.is_none()
370        && update.name.is_none()
371        && update.runtime_field_map.is_none()
372        && update.source_filters.is_none()
373        && update.time_field_name.is_none()
374        && update.title.is_none()
375        && update.view_type.is_none()
376        && update.type_meta.is_none()
377}
378
379fn unsupported(message: impl Into<String>) -> Result<DataViewPatch> {
380    Err(Error::new(ErrorKind::Unsupported, message))
381}
382
383fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
384    json!({"id": id, "applied": applied, "error": error.into()})
385}
386
387async fn read_spec(transport: &Transport, id: &str) -> Result<DataViewSpec> {
388    let spec = normalize(&Value::Object(
389        data_views::get(transport, id).await?.data_view,
390    ))?;
391    if spec.id != id {
392        return Err(Error::new(
393            ErrorKind::Http,
394            format!("decoding data view: expected id '{id}', got '{}'", spec.id),
395        ));
396    }
397    Ok(spec)
398}
399
400fn validate_import_plan(plan: &DataViewImportPlan) -> Result<()> {
401    if plan.total == 0 {
402        return invalid_plan("total must be greater than zero");
403    }
404    if plan.total != plan.specs.len() + plan.skipped.len() {
405        return invalid_plan("total does not equal pending and skipped data views");
406    }
407
408    let mut pending_ids = Vec::with_capacity(plan.specs.len());
409    for spec in &plan.specs {
410        validate_canonical_spec(spec)?;
411        if pending_ids
412            .last()
413            .is_some_and(|previous| previous >= &spec.id)
414        {
415            return invalid_plan("pending data views must be unique and sorted by id");
416        }
417        pending_ids.push(spec.id.clone());
418    }
419    let pending: BTreeSet<_> = pending_ids.iter().cloned().collect();
420    if !plan.skipped.is_empty() && plan.overwrite {
421        return invalid_plan("skipped data views require skip-existing mode");
422    }
423    let mut skipped_ids = BTreeSet::new();
424    let mut previous_skipped = None;
425    for row in &plan.skipped {
426        let object = row
427            .as_object()
428            .filter(|object| object.len() == 2)
429            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid data-view import skipped row"))?;
430        if !object.keys().map(String::as_str).eq(["id", "reason"]) {
431            return invalid_plan("invalid data-view import skipped row order");
432        }
433        let id = object
434            .get("id")
435            .and_then(Value::as_str)
436            .filter(|id| !id.trim().is_empty())
437            .ok_or_else(|| Error::new(ErrorKind::Error, "invalid data-view import skipped row"))?;
438        if object.get("reason").and_then(Value::as_str) != Some("exists")
439            || previous_skipped.is_some_and(|previous: &str| previous >= id)
440            || !skipped_ids.insert(id.to_owned())
441            || pending.contains(id)
442        {
443            return invalid_plan("invalid data-view import skipped rows");
444        }
445        previous_skipped = Some(id);
446    }
447    if plan.preview.targets != pending_ids {
448        return invalid_plan("preview targets do not match pending data views");
449    }
450    let prefix = format!("Import {} data view(s) from ", plan.specs.len());
451    if !plan.preview.preview_action.starts_with(&prefix)
452        || plan.preview.preview_action[prefix.len()..].is_empty()
453    {
454        return invalid_plan("preview action does not match pending data views");
455    }
456    if plan.preview.preview_details != preview_details(&plan.specs, &plan.before) {
457        return invalid_plan("preview details do not match pending data views");
458    }
459
460    let before_ids: BTreeSet<_> = plan.before.keys().cloned().collect();
461    if before_ids != pending {
462        return invalid_plan("preflight snapshots do not match pending data views");
463    }
464    let mut patch_ids = BTreeSet::new();
465    for spec in &plan.specs {
466        let before = plan.before.get(&spec.id).ok_or_else(|| {
467            Error::new(
468                ErrorKind::Error,
469                "preflight snapshots do not match pending data views",
470            )
471        })?;
472        match before {
473            None => {
474                if plan.patches.contains_key(&spec.id) {
475                    return invalid_plan("planned creates must not carry a replacement patch");
476                }
477            }
478            Some(snapshot) => {
479                validate_canonical_spec(snapshot)?;
480                if snapshot.id != spec.id {
481                    return invalid_plan("preflight snapshot id does not match its target");
482                }
483                if !plan.overwrite {
484                    return invalid_plan("replacement plan requires overwrite");
485                }
486                let expected = build_patch(snapshot, spec)?;
487                let patch = plan.patches.get(&spec.id).ok_or_else(|| {
488                    Error::new(ErrorKind::Error, "planned replacement is missing its patch")
489                })?;
490                if patch != &expected {
491                    return invalid_plan("planned replacement patch does not match its snapshots");
492                }
493                patch_ids.insert(spec.id.clone());
494            }
495        }
496    }
497    if plan.patches.keys().cloned().collect::<BTreeSet<_>>() != patch_ids {
498        return invalid_plan("replacement patches do not match preflight snapshots");
499    }
500    Ok(())
501}
502
503fn validate_canonical_spec(spec: &DataViewSpec) -> Result<()> {
504    spec.validate()?;
505    if matches!(spec.type_meta.as_ref(), Some(values) if values.is_empty()) {
506        return Err(Error::new(
507            ErrorKind::Error,
508            "data view typeMeta must not be empty",
509        ));
510    }
511    Ok(())
512}
513
514fn preview_details(
515    specs: &[DataViewSpec],
516    before: &BTreeMap<String, Option<DataViewSpec>>,
517) -> Vec<String> {
518    specs
519        .iter()
520        .filter_map(|spec| match before.get(&spec.id) {
521            Some(None) => Some(format!("{}  create  {}", spec.id, spec.title)),
522            Some(Some(current)) if current == spec => {
523                Some(format!("{}  no-op  {}", spec.id, spec.title))
524            }
525            Some(Some(current)) => Some(format!(
526                "{}  replace  {} -> {}",
527                spec.id, current.title, spec.title
528            )),
529            None => None,
530        })
531        .collect()
532}