Skip to main content

elasticctl_api/value_lists/
cli.rs

1use std::path::{Path, PathBuf};
2
3use elasticctl_core::{Error, ErrorKind, Result, Transport};
4use serde::Serialize;
5
6use super::{
7    Container, ContainerAction, DeleteOutcome, ListOutcome, ListPlan, Outcome, PhaseOrder,
8    StoredValues, ValueListType, ValueSet, Warning, encode_container, encode_values,
9    find_containers, plan_stems, read_list, read_pair, stored_warnings,
10};
11
12#[derive(Serialize)]
13pub struct ValidationReport {
14    pub valid: bool,
15    pub id: String,
16    pub warnings: Vec<Warning>,
17}
18
19pub fn validate_path(path: &Path) -> Result<ValidationReport> {
20    let pair = read_pair(path)?;
21    let stored = StoredValues::new(pair.values.iter().cloned().collect());
22    Ok(ValidationReport {
23        valid: true,
24        id: pair.container.id,
25        warnings: stored_warnings(&pair.container.list_type, &stored),
26    })
27}
28
29#[derive(Debug, Serialize)]
30pub struct ListSummary {
31    pub id: String,
32    #[serde(rename = "type")]
33    pub list_type: ValueListType,
34    pub name: String,
35    pub description: String,
36    pub supported: bool,
37}
38
39pub async fn list_op(
40    t: &Transport,
41    list_type: Option<&ValueListType>,
42    search: Option<&str>,
43) -> Result<Vec<ListSummary>> {
44    if let Some(kind) = list_type {
45        kind.require_supported()?;
46    }
47    let search = search.map(str::to_lowercase);
48    Ok(find_containers(t, None, 1000)
49        .await?
50        .into_iter()
51        .map(|r| r.container)
52        .filter(|c| list_type.is_none_or(|kind| c.list_type == *kind))
53        .filter(|c| {
54            search.as_ref().is_none_or(|s| {
55                c.id.to_lowercase().contains(s) || c.name.to_lowercase().contains(s)
56            })
57        })
58        .map(|c| ListSummary {
59            supported: c.list_type.require_supported().is_ok(),
60            id: c.id,
61            list_type: c.list_type,
62            name: c.name,
63            description: c.description,
64        })
65        .collect())
66}
67
68#[derive(Debug, Serialize)]
69pub struct GetReport {
70    #[serde(flatten)]
71    pub container: Container,
72    pub distinct_values: usize,
73    pub stored_values: usize,
74    pub warnings: Vec<Warning>,
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub values: Option<ValueSet>,
77}
78
79pub async fn get_op(t: &Transport, id: &str, values: bool) -> Result<GetReport> {
80    let list = read_list(t, id)
81        .await?
82        .ok_or_else(|| Error::new(ErrorKind::NotFound, format!("value list {id} not found")))?;
83    let set = list.stored.set();
84    Ok(GetReport {
85        distinct_values: set.len(),
86        stored_values: list.stored.values.len(),
87        warnings: stored_warnings(&list.container.container.list_type, &list.stored),
88        container: list.container.container,
89        values: values.then_some(set),
90    })
91}
92
93#[derive(Debug, Serialize)]
94pub struct ExportPair {
95    pub container: Container,
96    pub values: ValueSet,
97}
98
99#[derive(Debug, Serialize)]
100pub struct ExportFiles {
101    pub id: String,
102    pub container: PathBuf,
103    pub values: PathBuf,
104}
105
106#[derive(Debug, Serialize)]
107#[serde(untagged)]
108pub enum ExportReport {
109    Pair(ExportPair),
110    Files(Vec<ExportFiles>),
111}
112
113pub async fn export_op(
114    t: &Transport,
115    ids: &[String],
116    dir: Option<&Path>,
117    json: bool,
118    report_path: Option<&Path>,
119) -> Result<ExportReport> {
120    if ids.is_empty() {
121        return Err(super::invalid("name at least one value list to export"));
122    }
123    let stems = plan_stems(ids.iter().map(String::as_str))?;
124    if dir.is_none() && ids.len() == 1 && json {
125        let report = get_op(t, &ids[0], true).await?;
126        return Ok(ExportReport::Pair(ExportPair {
127            container: report.container,
128            values: report.values.unwrap_or_default(),
129        }));
130    }
131    let dir = dir.unwrap_or(Path::new("."));
132    let files: Vec<_> = stems
133        .iter()
134        .map(|(id, stem)| ExportFiles {
135            id: id.clone(),
136            container: dir.join(format!("{stem}.yaml")),
137            values: dir.join(format!("{stem}.txt")),
138        })
139        .collect();
140    let mut existing = Vec::new();
141    for path in files.iter().flat_map(|f| [&f.container, &f.values]) {
142        match std::fs::symlink_metadata(path) {
143            Ok(_) => existing.push(path.display().to_string()),
144            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
145            Err(error) => {
146                return Err(super::invalid(format!(
147                    "checking {}: {error}",
148                    path.display()
149                )));
150            }
151        }
152    }
153    if !existing.is_empty() {
154        return Err(Error::new(
155            ErrorKind::Conflict,
156            format!(
157                "refusing to overwrite existing files: {}",
158                existing.join(", ")
159            ),
160        ));
161    }
162    if let Some(report_path) = report_path {
163        let report = path_identity(report_path, 0)?;
164        for destination in files.iter().flat_map(|f| [&f.container, &f.values]) {
165            let target = path_identity(destination, 0)?;
166            let same = report == target
167                || (cfg!(any(windows, target_os = "macos"))
168                    && report
169                        .as_os_str()
170                        .to_string_lossy()
171                        .eq_ignore_ascii_case(&target.as_os_str().to_string_lossy()));
172            if same {
173                return Err(Error::new(
174                    ErrorKind::Conflict,
175                    format!(
176                        "report path {} aliases exported file {}",
177                        report_path.display(),
178                        destination.display()
179                    ),
180                ));
181            }
182        }
183    }
184    let mut contents = Vec::new();
185    for f in &files {
186        let report = get_op(t, &f.id, true).await?;
187        contents.push((
188            encode_container(&report.container)?,
189            encode_values(&report.values.unwrap_or_default()),
190        ));
191    }
192    std::fs::create_dir_all(dir)
193        .map_err(|error| super::invalid(format!("creating {}: {error}", dir.display())))?;
194    for (f, (container, values)) in files.iter().zip(contents) {
195        write_new(&f.container, container.as_bytes())?;
196        write_new(&f.values, values.as_bytes())?;
197    }
198    Ok(ExportReport::Files(files))
199}
200
201fn path_identity(path: &Path, depth: usize) -> Result<PathBuf> {
202    use std::path::Component;
203    if depth > 40 {
204        return Err(super::invalid(format!(
205            "too many symlinks resolving {}",
206            path.display()
207        )));
208    }
209    let absolute = if path.is_absolute() {
210        path.to_path_buf()
211    } else {
212        std::env::current_dir()
213            .map_err(|error| super::invalid(format!("reading current directory: {error}")))?
214            .join(path)
215    };
216    let mut resolved = PathBuf::new();
217    for component in absolute.components() {
218        match component {
219            Component::CurDir => continue,
220            Component::ParentDir => {
221                resolved.pop();
222                continue;
223            }
224            _ => resolved.push(component.as_os_str()),
225        }
226        match std::fs::symlink_metadata(&resolved) {
227            Ok(meta) if meta.is_symlink() => {
228                let target = std::fs::read_link(&resolved).map_err(|error| {
229                    super::invalid(format!("reading symlink {}: {error}", resolved.display()))
230                })?;
231                let target = if target.is_absolute() {
232                    target
233                } else {
234                    resolved.parent().unwrap_or(Path::new(".")).join(target)
235                };
236                resolved = path_identity(&target, depth + 1)?;
237            }
238            Ok(_) => {
239                resolved = std::fs::canonicalize(&resolved).map_err(|error| {
240                    super::invalid(format!("resolving {}: {error}", resolved.display()))
241                })?;
242            }
243            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
244            Err(error) => {
245                return Err(super::invalid(format!(
246                    "resolving {}: {error}",
247                    resolved.display()
248                )));
249            }
250        }
251    }
252    Ok(resolved)
253}
254
255fn write_new(path: &Path, bytes: &[u8]) -> Result<()> {
256    use std::io::Write;
257    let mut file = std::fs::OpenOptions::new()
258        .write(true)
259        .create_new(true)
260        .open(path)
261        .map_err(|error| super::invalid(format!("creating {}: {error}", path.display())))?;
262    file.write_all(bytes)
263        .map_err(|error| super::invalid(format!("writing {}: {error}", path.display())))
264}
265
266#[derive(Serialize)]
267pub struct ImportPreview<'a> {
268    pub id: &'a str,
269    #[serde(rename = "type")]
270    pub list_type: &'a ValueListType,
271    pub container_action: ContainerAction,
272    pub adds: &'a ValueSet,
273    pub removes: &'a ValueSet,
274    pub phase_order: &'static str,
275    pub phase_reason: &'a str,
276    pub warnings: &'a [Warning],
277}
278
279impl<'a> From<&'a ListPlan> for ImportPreview<'a> {
280    fn from(plan: &'a ListPlan) -> Self {
281        Self {
282            id: &plan.local.container.id,
283            list_type: &plan.local.container.list_type,
284            container_action: plan.container_action,
285            adds: &plan.adds,
286            removes: &plan.removes,
287            phase_order: if plan.adds.is_empty() && plan.removes.is_empty() {
288                "none"
289            } else {
290                match plan.order {
291                    PhaseOrder::AddsFirst => "adds_first",
292                    PhaseOrder::RemovesFirst => "removes_first",
293                }
294            },
295            phase_reason: &plan.order_reason,
296            warnings: &plan.warnings,
297        }
298    }
299}
300
301#[derive(Serialize)]
302pub struct MutationReport<T> {
303    pub applied: bool,
304    pub lists: Vec<T>,
305}
306
307impl MutationReport<ListOutcome> {
308    pub fn import(lists: Vec<ListOutcome>) -> Self {
309        Self {
310            applied: lists.iter().all(|r| r.outcome == Outcome::Applied),
311            lists,
312        }
313    }
314}
315
316impl MutationReport<DeleteOutcome> {
317    pub fn delete(lists: Vec<DeleteOutcome>) -> Self {
318        Self {
319            applied: lists.iter().all(|r| r.result.outcome == Outcome::Applied),
320            lists,
321        }
322    }
323}
324
325#[cfg(test)]
326mod tests;