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#[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}