elasticctl_api/fleet/integration_policy_ops/
read.rs1use super::common::*;
2use super::metadata::configured_secret_paths;
3use super::normalization::normalize;
4
5const PAGE_SIZE: u64 = 1000;
6
7#[derive(Debug, Clone, Default, PartialEq, Eq)]
8pub struct IntegrationPolicyFilter {
9 pub search: Option<String>,
10 pub limit: Option<usize>,
11}
12
13#[derive(Debug, Clone, PartialEq, Serialize)]
14pub struct IntegrationPolicyList {
15 pub total: u64,
16 pub integration_policies: Vec<IntegrationPolicySummary>,
17 pub truncated: bool,
18}
19
20#[derive(Debug, Clone, PartialEq)]
21pub(crate) struct ResolvedIntegrationPolicy {
22 pub(crate) summary: IntegrationPolicySummary,
23 pub(crate) item: Map<String, Value>,
24}
25
26pub async fn collect(transport: &Transport) -> Result<Vec<Map<String, Value>>> {
27 let mut page_number = 1;
28 let mut total = None;
29 let mut items = Vec::new();
30 let mut ids = BTreeSet::new();
31 loop {
32 let page = integration_policies::list_page(transport, page_number).await?;
33 if page.page != page_number || page.per_page != PAGE_SIZE {
34 return Err(http(
35 "decoding integration policies list: unexpected page metadata",
36 ));
37 }
38 if page.items.len() as u64 > PAGE_SIZE {
39 return Err(http(
40 "decoding integration policies list: page returned more items than requested",
41 ));
42 }
43 match total {
44 Some(expected) if expected != page.total => {
45 return Err(http(
46 "decoding integration policies list: total changed while paging",
47 ));
48 }
49 Some(_) => {}
50 None => total = Some(page.total),
51 }
52 let page_len = page.items.len() as u64;
53 for item in page.items {
54 let id = required_string(&item, "id", "integration policies list")?;
55 if !ids.insert(id.clone()) {
56 return Err(http(format!(
57 "decoding integration policies list: duplicate integration policy id '{id}'"
58 )));
59 }
60 items.push(item);
61 }
62 let expected = total.expect("first page sets total");
63 if items.len() as u64 >= expected {
64 break;
65 }
66 if page_len != PAGE_SIZE {
67 return Err(http(
68 "decoding integration policies list: page was short before total",
69 ));
70 }
71 page_number += 1;
72 }
73 if items.len() as u64 > total.unwrap_or_default() {
74 return Err(http(
75 "decoding integration policies list: returned more items than total",
76 ));
77 }
78 items.sort_by(|left, right| left["id"].as_str().cmp(&right["id"].as_str()));
79 Ok(items)
80}
81
82pub async fn list_op(
83 transport: &Transport,
84 filter: &IntegrationPolicyFilter,
85) -> Result<IntegrationPolicyList> {
86 let items = collect(transport).await?;
87 let total = items.len() as u64;
88 let needle = filter.search.as_ref().map(|value| value.to_lowercase());
89 let mut integration_policies = Vec::new();
90 for item in &items {
91 let summary = summary_from_item(item)?;
92 if needle.as_ref().is_none_or(|needle| {
93 summary.id.to_lowercase().contains(needle)
94 || summary.name.to_lowercase().contains(needle)
95 }) {
96 integration_policies.push(summary);
97 }
98 }
99 let limit = filter.limit.unwrap_or(usize::MAX);
100 let truncated = integration_policies.len() > limit;
101 integration_policies.truncate(limit);
102 Ok(IntegrationPolicyList {
103 total,
104 integration_policies,
105 truncated,
106 })
107}
108
109pub async fn resolve(transport: &Transport, selector: &str) -> Result<IntegrationPolicySummary> {
110 Ok(resolve_item(transport, selector).await?.summary)
111}
112
113pub(crate) async fn resolve_item(
114 transport: &Transport,
115 selector: &str,
116) -> Result<ResolvedIntegrationPolicy> {
117 match integration_policies::get_selector(transport, selector).await {
118 Ok(policy) => return checked_read(selector, policy.item, None),
119 Err(error) if error.kind == ErrorKind::NotFound => {}
120 Err(error) => return Err(error),
121 }
122 let matches: Vec<IntegrationPolicySummary> = collect(transport)
123 .await?
124 .iter()
125 .filter(|item| item.get("name").and_then(Value::as_str) == Some(selector))
126 .map(summary_from_item)
127 .collect::<Result<_>>()?;
128 match matches.as_slice() {
129 [] => Err(Error::new(
130 ErrorKind::NotFound,
131 format!("no integration policy with id or name '{selector}'"),
132 )),
133 [one] => {
134 let policy = integration_policies::get(transport, &one.id).await?;
135 checked_read(&one.id, policy.item, Some(one))
136 }
137 many => Err(Error::new(
138 ErrorKind::Conflict,
139 format!(
140 "integration policy '{selector}' is ambiguous: {}",
141 many.iter()
142 .map(|policy| policy.id.as_str())
143 .collect::<Vec<_>>()
144 .join(", ")
145 ),
146 )),
147 }
148}
149
150fn checked_read(
151 requested_id: &str,
152 item: Map<String, Value>,
153 selected: Option<&IntegrationPolicySummary>,
154) -> Result<ResolvedIntegrationPolicy> {
155 let summary = summary_from_item(&item)?;
156 if summary.id != requested_id {
157 return Err(http(
158 "decoding integration policy selector read: response id did not match the selector",
159 ));
160 }
161 if selected.is_some_and(|selected| !same_summary(&summary, selected)) {
162 return Err(http(
163 "decoding integration policy selector read: fetched item did not match the selected list summary",
164 ));
165 }
166 Ok(ResolvedIntegrationPolicy { summary, item })
167}
168
169fn same_summary(fetched: &IntegrationPolicySummary, selected: &IntegrationPolicySummary) -> bool {
170 fetched.id == selected.id
171 && fetched.name == selected.name
172 && fetched.namespace == selected.namespace
173 && fetched.description == selected.description
174 && fetched.package == selected.package
175 && sorted_parent_ids(&fetched.policy_ids) == sorted_parent_ids(&selected.policy_ids)
176}
177
178fn sorted_parent_ids(ids: &[String]) -> Vec<&str> {
179 let mut sorted = ids.iter().map(String::as_str).collect::<Vec<_>>();
180 sorted.sort_unstable();
181 sorted
182}
183
184pub async fn get_op(transport: &Transport, selector: &str) -> Result<IntegrationPolicyDetail> {
185 let resolved = resolve_item(transport, selector).await?;
186 let mut blocked_by = live_blocked_by(&resolved.item, &resolved.summary.id, transport.space())?;
187 validate_safe_detail_shape(&resolved.item, transport.space())?;
188 let parents = read_parents(&resolved.summary.id, &resolved.item)?;
189 let parents = read_parent_snapshots(transport, &resolved.summary.id, &parents).await?;
190 for parent in parents.values() {
191 if parent.platform_owned {
192 blocked_by.insert(format!("parent:{}.platform_owned", parent.id));
193 }
194 if parent.protected {
195 blocked_by.insert(format!("parent:{}.is_protected", parent.id));
196 }
197 }
198 if parents
199 .values()
200 .map(|parent| parent.namespace.as_str())
201 .collect::<BTreeSet<_>>()
202 .len()
203 != 1
204 {
205 blocked_by.insert("namespace".into());
206 }
207 if parents
208 .values()
209 .any(|parent| parent.namespace != resolved.summary.namespace)
210 {
211 blocked_by.insert("namespace".into());
212 }
213 Ok(IntegrationPolicyDetail {
214 id: resolved.summary.id,
215 name: resolved.summary.name,
216 namespace: resolved.summary.namespace,
217 description: resolved.summary.description,
218 policy_ids: parents.keys().cloned().collect(),
219 package: resolved.summary.package,
220 affected_agents: parents.values().map(|parent| parent.agents).sum(),
221 blocked_by: blocked_by.into_iter().collect(),
222 })
223}
224
225fn validate_safe_detail_shape(item: &Map<String, Value>, active_space: &str) -> Result<()> {
226 let mut projected = item.clone();
227 projected.insert("enabled".into(), Value::Bool(true));
228 for field in [
229 "is_managed",
230 "supports_agentless",
231 "supports_cloud_connector",
232 ] {
233 projected.insert(field.into(), Value::Bool(false));
234 }
235 for field in ["output_id", "cloud_connector_id", "cloud_connector_name"] {
236 projected.insert(field.into(), Value::Null);
237 }
238 projected.insert("secret_references".into(), Value::Array(Vec::new()));
239 projected.insert("spaceIds".into(), Value::Null);
240 normalize(&projected, active_space).map(|_| ())
241}
242
243pub async fn export(
244 transport: &Transport,
245 selectors: &[String],
246 all_custom: bool,
247 format: ContentFormat,
248) -> Result<ExportOutcome> {
249 if selectors.is_empty() && !all_custom {
250 return Err(Error::new(
251 ErrorKind::Error,
252 "integration-policy export needs selectors or --all-custom",
253 ));
254 }
255 if !selectors.is_empty() && all_custom {
256 return Err(Error::new(
257 ErrorKind::Error,
258 "--all-custom cannot be combined with selectors",
259 ));
260 }
261
262 let mut rows = BTreeMap::new();
263 if all_custom {
264 for item in collect(transport).await? {
265 let summary = summary_from_item(&item)?;
266 if optional_bool(&item, "is_managed", &summary.id)? == Some(true) {
267 continue;
268 }
269 let live = integration_policies::get(transport, &summary.id).await?;
270 let resolved = checked_read(&summary.id, live.item, Some(&summary))?;
271 if optional_bool(&resolved.item, "is_managed", &summary.id)? == Some(true) {
272 continue;
273 }
274 rows.insert(summary.id.clone(), resolved);
275 }
276 } else {
277 for selector in selectors {
278 let resolved = resolve_item(transport, selector).await?;
279 rows.entry(resolved.summary.id.clone()).or_insert(resolved);
280 }
281 }
282
283 let mut specs = Vec::new();
284 for (id, resolved) in rows {
285 let parent_ids = read_parents(&id, &resolved.item)?;
286 let parents = read_parent_snapshots(transport, &id, &parent_ids).await?;
287 if all_custom && parents.values().any(|parent| parent.platform_owned) {
288 continue;
289 }
290 specs.push(effective_spec(transport, &id, &resolved.item, &parents).await?);
291 }
292 specs.sort_by(|left, right| left.id.cmp(&right.id));
293 Ok(ExportOutcome {
294 body: content_codec::encode_sequence(&specs, format)?,
295 exported: specs.len() as u64,
296 missing: Vec::new(),
297 })
298}
299
300async fn effective_spec(
301 transport: &Transport,
302 id: &str,
303 item: &Map<String, Value>,
304 parents: &BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
305) -> Result<IntegrationPolicySpec> {
306 for parent in parents.values() {
307 if parent.platform_owned {
308 return unsupported(format!(
309 "integration policy '{id}' is not portable: parent {} is platform-owned",
310 parent.id
311 ));
312 }
313 if parent.protected {
314 return unsupported(format!(
315 "integration policy '{id}' is not portable: parent {} is_protected",
316 parent.id
317 ));
318 }
319 }
320 let dependency =
321 read_dependencies(transport, &package_coordinate(item, "integration policy")?).await?;
322 let mut spec = normalize(item, transport.space())?;
323 if let Some(namespace) = &spec.namespace {
324 if parents
325 .values()
326 .any(|parent| &parent.namespace != namespace)
327 {
328 return unsupported(format!(
329 "integration policy '{id}' is not portable: namespace does not match every parent"
330 ));
331 }
332 } else {
333 let namespaces: BTreeSet<&str> = parents
334 .values()
335 .map(|parent| parent.namespace.as_str())
336 .collect();
337 if namespaces.len() != 1 {
338 return unsupported(format!(
339 "integration policy '{id}' is not portable: parents have different namespaces"
340 ));
341 }
342 spec.namespace = namespaces.into_iter().next().map(str::to_owned);
343 }
344 match dependency.state {
345 PackageDependencyState::Installed { ref version } if version == &spec.package.version => {}
346 PackageDependencyState::Installed { .. } => {
347 return Err(Error::new(
348 ErrorKind::Conflict,
349 format!(
350 "integration policy '{id}' package {} has a different installed version",
351 dependency.name
352 ),
353 ));
354 }
355 PackageDependencyState::NotInstalled => {
356 return Err(Error::new(
357 ErrorKind::Conflict,
358 format!(
359 "integration policy '{id}' package {} is not installed",
360 dependency.name
361 ),
362 ));
363 }
364 }
365 let metadata = integration_policies::package_metadata(
366 transport,
367 &spec.package.name,
368 &spec.package.version,
369 )
370 .await?;
371 let paths = configured_secret_paths(&spec, &metadata.item)?;
372 if !paths.is_empty() {
373 return unsupported(format!(
374 "integration policy '{id}' is not portable: {}",
375 paths
376 .into_iter()
377 .map(|path| format!("{id}:{path}"))
378 .collect::<Vec<_>>()
379 .join(", ")
380 ));
381 }
382 Ok(spec)
383}
384
385fn live_blocked_by(
386 item: &Map<String, Value>,
387 id: &str,
388 active_space: &str,
389) -> Result<BTreeSet<String>> {
390 let mut reasons = BTreeSet::new();
391 match item.get("enabled") {
392 Some(Value::Bool(true)) => {}
393 Some(Value::Bool(false)) => {
394 reasons.insert("enabled".into());
395 }
396 _ => {
397 return Err(http(format!(
398 "decoding integration policy '{id}': enabled must be true or false"
399 )));
400 }
401 }
402 for field in [
403 "is_managed",
404 "supports_agentless",
405 "supports_cloud_connector",
406 ] {
407 if optional_bool(item, field, id)? == Some(true) {
408 reasons.insert(field.to_owned());
409 }
410 }
411 for field in ["output_id", "cloud_connector_id", "cloud_connector_name"] {
412 match item.get(field) {
413 None | Some(Value::Null) | Some(Value::Bool(false)) => {}
414 Some(Value::String(_)) => {
415 reasons.insert(field.to_owned());
416 }
417 Some(_) => {
418 return Err(http(format!(
419 "decoding integration policy '{id}': {field} must be a string or null"
420 )));
421 }
422 }
423 }
424 match item.get("secret_references") {
425 None | Some(Value::Null) => {}
426 Some(Value::Array(values)) if values.is_empty() => {}
427 Some(Value::Array(_)) => {
428 reasons.insert("secret_references".into());
429 }
430 Some(_) => {
431 return Err(http(format!(
432 "decoding integration policy '{id}': secret_references must be an array or null"
433 )));
434 }
435 }
436 let active = if active_space.is_empty() {
437 "default"
438 } else {
439 active_space
440 };
441 match item.get("spaceIds") {
442 None | Some(Value::Null) => {}
443 Some(Value::Array(spaces)) => {
444 for space in spaces {
445 let space = space.as_str().filter(|value| !value.is_empty()).ok_or_else(|| {
446 http(format!("decoding integration policy '{id}': spaceIds must contain non-empty strings"))
447 })?;
448 if space != active {
449 reasons.insert("spaceIds".into());
450 }
451 }
452 }
453 Some(_) => {
454 return Err(http(format!(
455 "decoding integration policy '{id}': spaceIds must be an array or null"
456 )));
457 }
458 }
459 Ok(reasons)
460}