elasticctl_api/value_lists/
cli.rs1use 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;