Skip to main content

elasticctl_api/fleet/integration_policy_ops/
read.rs

1use 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/// A resolved selector retains the one-object response for later operations.
21#[derive(Debug, Clone, PartialEq)]
22pub(crate) struct ResolvedIntegrationPolicy {
23    pub(crate) summary: IntegrationPolicySummary,
24    pub(crate) item: Map<String, Value>,
25}
26
27/// Collect all measured pages then sort by stable id locally.
28pub 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
84/// List with local case-insensitive search and post-sort limiting.
85pub 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
112/// Resolve a stable id first, then a unique exact name.
113pub async fn resolve(transport: &Transport, selector: &str) -> Result<IntegrationPolicySummary> {
114    Ok(resolve_item(transport, selector).await?.summary)
115}
116
117/// Resolve a selector and retain the checked one-object response for later
118/// read-only operations.
119pub(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
156/// Bind a one-object response to its route id and, when it came from a list
157/// selection, to that row's safe summary. Do not include raw values in errors:
158/// simplified items can contain user configuration.
159fn 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
178/// Fleet can return parent ids in a different order between its list and
179/// single-item routes. Sort for comparison without removing duplicates, so a
180/// malformed repeated parent remains visible to later validation.
181fn 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
196/// Return a safe integration-policy view. Parent reads are both the attachment
197/// race check and the sole source of affected-agent counts.
198pub 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
239/// Validate every live shape that a safe detail can reason about without
240/// erasing its direct portability blockers. The projection keeps package-owned
241/// configuration intact while replacing only values that a detail reports in
242/// `blocked_by`, so `normalize` remains the single structural validator.
243fn 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
261/// Export selected integrations or every custom integration. A selector is
262/// resolved once and deduplicated by its stable id before any parent, package,
263/// or metadata reads.
264pub 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    // A package policy can only have been compiled from the exact installed
366    // coordinate. Treat a divergent or absent state as a loud conflict.
367    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}