elasticctl_api/state/
pull.rs1use super::reports::PullReport;
4use crate::codec::Format;
5use crate::exceptions;
6use crate::model::{ExceptionItem, ExceptionList, ListKey, Rule};
7use crate::normalize;
8use crate::rules::RuleSource;
9use crate::state::mirror::{encode_list_file, encode_rule_file};
10use crate::state::transaction::{StagedFile, acquire_pull, recover_pull, replace_staged_files};
11use elasticctl_core::{Error, ErrorKind, Result, Transport};
12use serde_json::Value;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::Path;
15
16pub async fn pull(
19 t: &Transport,
20 dir: &Path,
21 format: Format,
22 selectors: &[String],
23 tag: Option<&str>,
24 search: Option<&str>,
25 source: RuleSource,
26) -> Result<PullReport> {
27 let lock = acquire_pull(dir)?;
31 recover_pull(&lock)?;
32
33 let scope = super::scope_of(t, selectors, tag, search, source, &[], "pull").await?;
36 let mut remote = scope.remote(t).await?;
37 if remote.is_empty()
41 && !scope.is_scoped()
42 && matches!(scope.source, RuleSource::Custom | RuleSource::Prebuilt)
43 {
44 crate::rules::verify_source_partition(t).await?;
45 }
46 normalize::sort_rules(&mut remote);
48
49 let wanted: BTreeSet<ListKey> = super::referenced_keys(&remote);
52 let mut fetched: BTreeMap<ListKey, (ExceptionList, Vec<ExceptionItem>)> = BTreeMap::new();
53 for key in &wanted {
54 let list = match exceptions::get_list(t, key).await {
55 Ok(list) => list,
56 Err(e) if e.kind == ErrorKind::NotFound => {
60 return Err(Error::new(
61 ErrorKind::NotFound,
62 format!(
63 "a rule references exception list \"{}\" ({}), which does not exist on \
64 this stack",
65 key.list_id, key.namespace_type
66 ),
67 ));
68 }
69 Err(e) => return Err(e),
70 };
71 let items = exceptions::find_items(t, key).await?;
76 fetched.insert(key.clone(), (list, items));
77 }
78
79 let ext = match format {
80 Format::Yaml => "yaml",
81 Format::Ndjson => "ndjson",
82 };
83
84 let mut lists_to_write: Vec<(ListKey, ExceptionList)> = Vec::new();
87 let mut inlines: Vec<(ListKey, ExceptionList)> = Vec::new();
88 let mut item_count = 0usize;
89 for (key, (list, items)) in &fetched {
90 let mut canonical = normalize::canonical_list(list);
91 let canonical_items: Vec<ExceptionItem> =
92 items.iter().map(normalize::canonical_item).collect();
93 item_count += canonical_items.len();
94 let items_value = Value::Array(
95 canonical_items
96 .iter()
97 .map(|i| i.clone().into_value())
98 .collect(),
99 );
100 canonical.as_map_mut().insert("items".into(), items_value);
101 if list.list_type() == "rule_default" {
102 inlines.push((key.clone(), canonical));
103 } else {
104 lists_to_write.push((key.clone(), canonical));
105 }
106 }
107
108 let mut inline_by_rule: BTreeMap<String, Vec<ExceptionList>> = BTreeMap::new();
111 for (key, list) in &inlines {
112 let owner = remote.iter().find_map(|rule| {
113 let matches = crate::model::exception_refs(rule)
114 .iter()
115 .any(|rf| rf.list_id == key.list_id && rf.namespace_type == key.namespace_type);
116 matches
117 .then(|| rule.rule_id().ok())
118 .flatten()
119 .map(str::to_string)
120 });
121 if let Some(rule_id) = owner {
122 inline_by_rule
123 .entry(rule_id)
124 .or_default()
125 .push(list.clone());
126 }
127 }
128
129 let mut claimed_rules: BTreeMap<String, String> = BTreeMap::new();
132 let mut planned_rules: Vec<(String, Rule)> = Vec::with_capacity(remote.len());
133 let mut collisions: Vec<String> = Vec::new();
134
135 for rule in &remote {
136 let canonical = normalize::canonical(rule);
137 let rule_id = canonical.rule_id()?.to_string();
138 let filename = super::safe_filename(&rule_id, ext);
139
140 match claimed_rules.get(&filename) {
141 Some(other) => collisions.push(format!(
142 "\"{other}\" and \"{rule_id}\" both sanitise to \"{filename}\""
143 )),
144 None => {
145 claimed_rules.insert(filename.clone(), rule_id);
146 planned_rules.push((filename, canonical));
147 }
148 }
149 }
150
151 let mut claimed_lists: BTreeMap<String, String> = BTreeMap::new();
152 for (key, _) in &lists_to_write {
153 let qualified = format!("{} ({})", key.list_id, key.namespace_type);
154 let filename = super::safe_filename(&key.list_id, ext);
155 match claimed_lists.get(&filename) {
156 Some(other) => collisions.push(format!(
157 "\"{other}\" and \"{qualified}\" both sanitise to \"{filename}\""
158 )),
159 None => {
160 claimed_lists.insert(filename.clone(), qualified);
161 }
162 }
163 }
164
165 if !collisions.is_empty() {
166 return Err(Error::new(
167 ErrorKind::Conflict,
168 format!(
169 "{} filename collision(s); rename one id in each pair: {}",
170 collisions.len(),
171 collisions.join("; ")
172 ),
173 ));
174 }
175
176 let mut staged = Vec::with_capacity(planned_rules.len() + lists_to_write.len());
179 for (filename, canonical) in &planned_rules {
180 let rule_id = canonical.rule_id()?;
181 let inline = inline_by_rule.get(rule_id).cloned().unwrap_or_default();
182 let body = encode_rule_file(canonical, &inline, format)?;
183 staged.push(StagedFile {
184 relative: Path::new("rules").join(filename),
185 bytes: body.into_bytes(),
186 });
187 }
188
189 for (key, list) in &lists_to_write {
190 let filename = super::safe_filename(&key.list_id, ext);
191 let body = encode_list_file(list, format)?;
192 staged.push(StagedFile {
193 relative: Path::new("exceptions").join(filename),
194 bytes: body.into_bytes(),
195 });
196 }
197 replace_staged_files(&lock, &staged)?;
198
199 let target = super::rules_dir(dir);
200
201 Ok(PullReport {
202 pulled: planned_rules.len(),
203 exception_lists: fetched.len(),
204 exception_items: item_count,
205 dir: target.display().to_string(),
206 selected: scope.is_scoped().then(|| scope.selected()),
207 })
208}