Skip to main content

elasticctl_api/
exceptions.rs

1//! Typed wrappers for the exception-list API.
2//!
3//! Functions use the stable `list_id` plus `namespace_type` identity, never the
4//! volatile saved-object `id` (spec 4.5). The one exception is `export_lists`,
5//! which fetches `id` at the single boundary where the route demands it.
6
7mod list_endpoints;
8mod value_lists;
9pub use list_endpoints::{
10    ListFilter, create_item, create_list, delete_item, delete_list, export_lists, find_items,
11    find_lists, get_list, import_lists, resolve_ids, update_item, update_list,
12};
13pub(crate) use value_lists::{ValueListRef, value_list_exists, value_lists_bootstrapped};
14
15use crate::codec::{self, Bundle, Format};
16use crate::model::{ExceptionItem, ExceptionList, ListKey};
17use crate::normalize;
18use crate::ops::{DeleteOutcome, ExportOutcome, ImportPlan, ImportReport, MutationPlan};
19use elasticctl_core::{Error, ErrorKind, Result, Transport};
20use serde::Serialize;
21use serde_json::{Value, json};
22use std::collections::BTreeSet;
23use std::path::Path;
24
25/// The two namespaces a `list_id` can live in. Spec 4.5: `namespace_type` is
26/// half of a list's identity, so a command that scopes to one namespace and a
27/// command that scopes to the other read disjoint objects.
28const NAMESPACES: [&str; 2] = ["single", "agnostic"];
29
30/// The report `list` renders: every matching container, in stable order.
31#[derive(Debug, Clone, PartialEq, Serialize)]
32pub struct ListReport {
33    pub total: usize,
34    pub lists: Vec<ExceptionList>,
35}
36
37/// The report `get` renders: one container and every item inside it.
38#[derive(Debug, Clone, PartialEq, Serialize)]
39pub struct ListDetail {
40    pub list: ExceptionList,
41    pub items: Vec<ExceptionItem>,
42}
43
44/// The namespaces a command scoped by `--namespace` reads, or every namespace
45/// when the flag is absent.
46fn namespaces_to_search(namespace: Option<&str>) -> Vec<&str> {
47    match namespace {
48        Some(ns) => vec![ns],
49        None => NAMESPACES.to_vec(),
50    }
51}
52
53/// Resolve a `list_id` selector to its `ListKey`.
54///
55/// With `--namespace`, the selector is looked up in that namespace alone. A
56/// miss is `not_found` naming the selector. Without the flag, the namespace has
57/// to be found: a list that exists in neither is refused with `not_found`, and
58/// one that exists in both is refused with `conflict` naming `--namespace` as
59/// the remedy rather than silently picking a side (spec 4.5, 5.2).
60async fn resolve_list_key(
61    t: &Transport,
62    list_id: &str,
63    namespace: Option<&str>,
64) -> Result<ListKey> {
65    if let Some(ns) = namespace {
66        let key = ListKey {
67            list_id: list_id.to_string(),
68            namespace_type: ns.to_string(),
69        };
70        match get_list(t, &key).await {
71            Ok(_) => return Ok(key),
72            // Name the selector, not the raw server 404 (spec 4.3).
73            Err(e) if e.kind == ErrorKind::NotFound => {
74                return Err(Error::new(
75                    ErrorKind::NotFound,
76                    format!("exception list not found: {list_id} ({ns})"),
77                ));
78            }
79            Err(e) => return Err(e),
80        }
81    }
82
83    let mut matches = Vec::new();
84    for ns in NAMESPACES {
85        let key = ListKey {
86            list_id: list_id.to_string(),
87            namespace_type: ns.to_string(),
88        };
89        match get_list(t, &key).await {
90            Ok(_) => matches.push(key),
91            Err(e) if e.kind == ErrorKind::NotFound => {}
92            Err(e) => return Err(e),
93        }
94    }
95    match matches.len() {
96        1 => Ok(matches.pop().expect("one match")),
97        0 => Err(Error::new(
98            ErrorKind::NotFound,
99            format!("exception list not found: {list_id}"),
100        )),
101        _ => Err(Error::new(
102            ErrorKind::Conflict,
103            format!(
104                "exception list '{list_id}' exists in both the 'single' and 'agnostic' \
105                 namespaces; pass --namespace to select one"
106            ),
107        )),
108    }
109}
110
111/// Every live container's key, in the requested namespace or both.
112async fn all_list_keys(t: &Transport, namespace: Option<&str>) -> Result<Vec<ListKey>> {
113    let mut keys = Vec::new();
114    for ns in namespaces_to_search(namespace) {
115        let filter = ListFilter {
116            namespace: Some(ns.to_string()),
117            ..Default::default()
118        };
119        for list in find_lists(t, &filter).await? {
120            keys.push(list.key()?);
121        }
122    }
123    keys.sort();
124    keys.dedup();
125    Ok(keys)
126}
127
128/// Resolve selectors and an optional tag to list keys. `None` means "every
129/// list". A selector matching nothing is refused by `resolve_list_key`; a tag
130/// matching nothing is refused here even when a selector resolved (spec 4.3).
131async fn resolve_selection(
132    t: &Transport,
133    selectors: &[String],
134    tag: Option<&str>,
135    namespace: Option<&str>,
136    noun: &str,
137) -> Result<Option<Vec<ListKey>>> {
138    if selectors.is_empty() && tag.is_none() {
139        return Ok(None);
140    }
141
142    let mut keys = Vec::new();
143    for s in selectors {
144        keys.push(resolve_list_key(t, s, namespace).await?);
145    }
146
147    let mut tag_matched = false;
148    if let Some(tag) = tag {
149        for ns in namespaces_to_search(namespace) {
150            let filter = ListFilter {
151                tag: Some(tag.to_string()),
152                namespace: Some(ns.to_string()),
153                ..Default::default()
154            };
155            for list in find_lists(t, &filter).await? {
156                tag_matched = true;
157                keys.push(list.key()?);
158            }
159        }
160        if !tag_matched {
161            return Err(Error::new(
162                ErrorKind::NotFound,
163                format!("No exception lists matched tag '{tag}'; nothing to {noun}"),
164            ));
165        }
166    }
167
168    keys.sort();
169    keys.dedup();
170    Ok(Some(keys))
171}
172
173/// Every matching container. With no `--namespace`, both namespaces are read
174/// and merged so an `agnostic` container is never silently omitted: a list
175/// command that showed only half the namespace space would be lying (spec 5.2).
176pub async fn list_op(t: &Transport, f: &ListFilter) -> Result<ListReport> {
177    let mut lists = Vec::new();
178    if f.namespace.is_some() {
179        lists = find_lists(t, f).await?;
180    } else {
181        for ns in NAMESPACES {
182            let mut per_ns = f.clone();
183            per_ns.namespace = Some(ns.to_string());
184            lists.extend(find_lists(t, &per_ns).await?);
185        }
186    }
187    normalize::sort_lists(&mut lists);
188    let total = lists.len();
189    Ok(ListReport { total, lists })
190}
191
192/// Resolve a selector and fetch the container with all of its items.
193pub async fn get_op(t: &Transport, list_id: &str, namespace: Option<&str>) -> Result<ListDetail> {
194    let key = resolve_list_key(t, list_id, namespace).await?;
195    let list = get_list(t, &key).await?;
196    let items = find_items(t, &key).await?;
197    Ok(ListDetail { list, items })
198}
199
200/// Parse and validate a local file without contacting a server.
201///
202/// Exception bundles use NDJSON. YAML bundle input is unsupported, so YAML
203/// paths are refused rather than half-decoded.
204pub fn validate_op(path: &Path) -> Result<Bundle> {
205    let body = std::fs::read_to_string(path)
206        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
207    if Format::from_path(path) == Format::Yaml {
208        return Err(Error::new(
209            ErrorKind::Unsupported,
210            format!(
211                "{} is YAML, which cannot represent exception lists or items; validate an NDJSON bundle",
212                path.display()
213            ),
214        ));
215    }
216    codec::decode_bundle(&body)
217}
218
219/// Export the selected containers and their items.
220///
221/// The body is the raw `_export` concatenation, which `_import` accepts
222/// verbatim. YAML bundle export is unsupported.
223pub async fn export_op(
224    t: &Transport,
225    list_ids: &[String],
226    tag: Option<&str>,
227    namespace: Option<&str>,
228    format: Format,
229) -> Result<ExportOutcome> {
230    if format == Format::Yaml {
231        return Err(Error::new(
232            ErrorKind::Unsupported,
233            "exception bundles have no YAML form; re-run with --format-file ndjson",
234        ));
235    }
236    let keys = match resolve_selection(t, list_ids, tag, namespace, "export").await? {
237        Some(keys) => keys,
238        None => all_list_keys(t, namespace).await?,
239    };
240    export_lists(t, &keys).await
241}
242
243/// What `plan_delete_op` resolved, so the apply acts on exactly the keys the
244/// preview named rather than re-resolving a bare `list_id` after the guard.
245#[derive(Debug, Clone, PartialEq)]
246pub struct DeletePlan {
247    pub preview: MutationPlan,
248    pub keys: Vec<ListKey>,
249}
250
251/// Resolve every selector before previewing, so the preview is accurate and an
252/// unresolved selector fails before any write. The resolved keys travel with
253/// the plan so the apply cannot resolve a different namespace than the preview
254/// showed.
255pub async fn plan_delete_op(
256    t: &Transport,
257    list_ids: &[String],
258    namespace: Option<&str>,
259) -> Result<DeletePlan> {
260    let mut resolved = BTreeSet::new();
261    for id in list_ids {
262        resolved.insert(resolve_list_key(t, id, namespace).await?);
263    }
264    let keys: Vec<_> = resolved.into_iter().collect();
265    let targets: Vec<_> = keys
266        .iter()
267        .map(|key| format!("{} ({})", key.list_id, key.namespace_type))
268        .collect();
269    Ok(DeletePlan {
270        preview: MutationPlan {
271            preview_action: format!("Delete {} exception list(s)", targets.len()),
272            preview_details: targets.clone(),
273            targets,
274        },
275        keys,
276    })
277}
278
279/// Continue after per-container failures so the result records every deletion
280/// and every container that remains.
281pub async fn apply_delete_op(t: &Transport, plan: &DeletePlan) -> Result<DeleteOutcome> {
282    let mut deleted = Vec::new();
283    let mut failed = Vec::new();
284    for key in &plan.keys {
285        match delete_list(t, key).await {
286            Ok(_) => deleted.push(json!({
287                "list_id": key.list_id,
288                "namespace_type": key.namespace_type,
289            })),
290            Err(e) => failed.push(json!({
291                "list_id": key.list_id,
292                "namespace_type": key.namespace_type,
293                "error": e.message,
294            })),
295        }
296    }
297    Ok(DeleteOutcome {
298        applied: true,
299        deleted,
300        failed,
301        total: plan.keys.len(),
302    })
303}
304
305/// Compute the import preview and the NDJSON to upload.
306///
307/// The file is decoded as a bundle, never as rules only: `decode_ndjson` drops
308/// exception lines and would leave the operator with rules referencing lists
309/// that were never created. The preview counts containers and items, so an
310/// items-only file previews as the non-zero mutation it is. The transport is
311/// `None` unless `skip_existing` is set, so a dry run that only reads the file
312/// never needs a credential.
313pub async fn plan_import_op(
314    t: Option<&Transport>,
315    path: &Path,
316    overwrite: bool,
317    skip_existing: bool,
318) -> Result<ImportPlan> {
319    let body = std::fs::read_to_string(path)
320        .map_err(|e| Error::new(ErrorKind::Error, format!("reading {}: {e}", path.display())))?;
321
322    if Format::from_path(path) == Format::Yaml {
323        return Err(Error::new(
324            ErrorKind::Unsupported,
325            format!(
326                "{} is YAML, which cannot represent exception lists or items; import an NDJSON bundle",
327                path.display()
328            ),
329        ));
330    }
331
332    let bundle = codec::decode_bundle(&body)?;
333
334    // The exception import route imports containers and items only. A file that
335    // also carries rules would drop them silently (spec 5.2).
336    if !bundle.rules.is_empty() {
337        return Err(Error::new(
338            ErrorKind::Unsupported,
339            format!(
340                "this file carries {} rule(s), which the exception import route cannot import; \
341                 use `rules import` for a rules bundle",
342                bundle.rules.len()
343            ),
344        ));
345    }
346
347    // An item whose list_id is unreadable has no home; uploading it would
348    // strand it (spec 5.2). Refuse before any skip decision so both paths share
349    // the guard instead of one silently defaulting to an empty list_id.
350    for item in &bundle.items {
351        if item.list_id().is_err() {
352            return Err(Error::new(
353                ErrorKind::Error,
354                format!(
355                    "an exception item ('{}') has no readable list_id",
356                    item.item_id().unwrap_or("<unreadable>")
357                ),
358            ));
359        }
360    }
361
362    let total = bundle.lists.len() + bundle.items.len();
363    let mut lists = bundle.lists;
364    let mut items = bundle.items;
365    let mut skipped: Vec<Value> = Vec::new();
366
367    if skip_existing {
368        let t = t.ok_or_else(|| {
369            Error::new(ErrorKind::Error, "import --skip-existing needs a transport")
370        })?;
371        let keys: Vec<ListKey> = lists
372            .iter()
373            .map(ExceptionList::key)
374            .collect::<Result<_>>()?;
375        let existing = resolve_ids(t, &keys).await?;
376
377        let mut keep = Vec::with_capacity(lists.len());
378        let mut skip_keys = Vec::new();
379        for list in lists {
380            let key = list.key()?;
381            if existing.contains_key(&key) {
382                skipped.push(json!({
383                    "list_id": key.list_id,
384                    "namespace_type": key.namespace_type,
385                    "reason": "exists",
386                }));
387                skip_keys.push(key);
388            } else {
389                keep.push(list);
390            }
391        }
392        lists = keep;
393
394        // Items inside a skipped container are skipped with it: their home was
395        // never written, so writing them alone would strand them.
396        if !skip_keys.is_empty() {
397            let skip_set: std::collections::BTreeSet<ListKey> = skip_keys.into_iter().collect();
398            items.retain(|i| {
399                let key = ListKey {
400                    list_id: i.list_id().expect("list_id validated above").to_string(),
401                    namespace_type: i.namespace_type().to_string(),
402                };
403                !skip_set.contains(&key)
404            });
405        }
406    }
407
408    let mut details = Vec::with_capacity(lists.len() + items.len() + skipped.len());
409    for l in &lists {
410        details.push(format!("{}  {}  import", l.list_id()?, l.name()));
411    }
412    for i in &items {
413        details.push(format!("{}  {}  import", i.item_id()?, i.list_id()?));
414    }
415    details.extend(skipped.iter().map(|s| {
416        format!(
417            "{}  skip (already exists)",
418            s["list_id"].as_str().unwrap_or_default()
419        )
420    }));
421
422    let mut targets = Vec::with_capacity(lists.len() + items.len());
423    for l in &lists {
424        targets.push(l.list_id()?.to_string());
425    }
426    for i in &items {
427        targets.push(i.item_id()?.to_string());
428    }
429
430    let qualifier = if overwrite {
431        ", overwriting existing".to_string()
432    } else if skip_existing && !skipped.is_empty() {
433        format!(", skipping {} that already exist", skipped.len())
434    } else {
435        String::new()
436    };
437    let preview = MutationPlan {
438        preview_action: format!(
439            "Import {} exception list(s) and {} item(s) from {}{qualifier}",
440            lists.len(),
441            items.len(),
442            path.display()
443        ),
444        preview_details: details,
445        targets,
446    };
447
448    // Kibana's import accepts export trailers, but this plan may skip objects.
449    // Re-encode only the planned objects so stale trailer counts do not describe
450    // containers or items that will not be uploaded.
451    let ndjson = codec::encode_bundle(&Bundle {
452        rules: Vec::new(),
453        lists,
454        items,
455        summary: None,
456    })?;
457
458    Ok(ImportPlan {
459        preview,
460        ndjson,
461        total,
462        skipped,
463    })
464}
465
466/// Upload the NDJSON `plan_import_op` prepared.
467pub async fn apply_import_op(t: &Transport, ndjson: &str, overwrite: bool) -> Result<ImportReport> {
468    // Do not upload empty NDJSON when every container already exists.
469    if ndjson.is_empty() {
470        return Ok(ImportReport {
471            succeeded: json!(0),
472            failed: json!([]),
473        });
474    }
475
476    let response = import_lists(t, ndjson, overwrite).await?;
477    crate::ops::decode_import_report(&response, "exceptions")
478}