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