Skip to main content

elasticctl_api/fleet/
agent_policy_ops.rs

1use crate::content_codec::{self, ContentFormat};
2use crate::fleet::agent_policies::{
3    self, AGENTLESS_FIELD, AgentPolicyDetail, AgentPolicySpec, AgentPolicySummary, ENVIRONMENT_IDS,
4    PLATFORM_FLAGS,
5};
6use crate::ops::{ExportOutcome, MutationPlan};
7use elasticctl_core::{Error, ErrorKind, Result, Transport};
8use serde::Serialize;
9use serde_json::{Map, Value, json};
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::Path;
12
13mod artifact;
14mod delete;
15mod import;
16pub use artifact::{export, validate};
17pub use delete::{
18    AgentPolicyDeletePlan, AgentPolicyDeleteReport, AgentPolicyDeleteTarget, apply_delete,
19    plan_delete,
20};
21pub use import::{
22    AgentPolicyImportPlan, AgentPolicyImportReport, apply_import, build_replace_body, plan_import,
23};
24
25const PAGE_SIZE: u64 = 1000;
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct AgentPolicyFilter {
29    pub search: Option<String>,
30    pub limit: Option<usize>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct AgentPolicyList {
35    pub total: u64,
36    pub agent_policies: Vec<AgentPolicySummary>,
37    pub truncated: bool,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41struct ResolvedAgentPolicy {
42    summary: AgentPolicySummary,
43    item: Map<String, Value>,
44}
45
46#[derive(Debug, Clone, PartialEq)]
47pub struct LiveAgentPolicy {
48    pub spec: AgentPolicySpec,
49    pub agents: u64,
50    pub attached: Vec<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub(crate) struct AgentPolicyParentSnapshot {
55    pub id: String,
56    pub name: String,
57    pub namespace: String,
58    pub agents: u64,
59    pub attached_integrations: Vec<String>,
60    pub platform_owned: bool,
61    pub protected: bool,
62}
63
64pub async fn collect(transport: &Transport) -> Result<Vec<Map<String, Value>>> {
65    let mut page_number = 1;
66    let mut total = None;
67    let mut items = Vec::new();
68    let mut ids = BTreeSet::new();
69    loop {
70        let page = agent_policies::list_page(transport, page_number).await?;
71        if page.page != page_number || page.per_page != PAGE_SIZE {
72            return Err(http(
73                "decoding agent policies list: unexpected page metadata",
74            ));
75        }
76        match total {
77            Some(total) if total != page.total => {
78                return Err(http(
79                    "decoding agent policies list: total changed while paging",
80                ));
81            }
82            Some(_) => {}
83            None => total = Some(page.total),
84        }
85        let page_len = page.items.len() as u64;
86        for item in page.items {
87            let id = item
88                .get("id")
89                .and_then(Value::as_str)
90                .filter(|id| !id.is_empty())
91                .ok_or_else(|| http("decoding agent policies list: item without id"))?
92                .to_owned();
93            if !ids.insert(id.clone()) {
94                return Err(http(format!(
95                    "decoding agent policies list: duplicate agent policy id '{id}'"
96                )));
97            }
98            items.push(item);
99        }
100        let expected = total.expect("set from the first page");
101        if items.len() as u64 >= expected {
102            break;
103        }
104        if page_len != PAGE_SIZE {
105            return Err(http(
106                "decoding agent policies list: page was short before total",
107            ));
108        }
109        page_number += 1;
110    }
111    if items.len() as u64 > total.unwrap_or(0) {
112        return Err(http(
113            "decoding agent policies list: returned more items than total",
114        ));
115    }
116    items.sort_by(|left, right| left["id"].as_str().cmp(&right["id"].as_str()));
117    Ok(items)
118}
119
120pub async fn list_op(transport: &Transport, filter: &AgentPolicyFilter) -> Result<AgentPolicyList> {
121    let items = collect(transport).await?;
122    let total = items.len() as u64;
123    let needle = filter.search.as_ref().map(|search| search.to_lowercase());
124    let mut rows = Vec::new();
125    for item in &items {
126        let summary = AgentPolicySummary::from_item(item)?;
127        let keep = needle.as_ref().is_none_or(|needle| {
128            summary.id.to_lowercase().contains(needle)
129                || summary.name.to_lowercase().contains(needle)
130        });
131        if keep {
132            rows.push(summary);
133        }
134    }
135    let limit = filter.limit.unwrap_or(usize::MAX);
136    let truncated = rows.len() > limit;
137    rows.truncate(limit);
138    Ok(AgentPolicyList {
139        total,
140        agent_policies: rows,
141        truncated,
142    })
143}
144
145pub async fn resolve(transport: &Transport, selector: &str) -> Result<AgentPolicySummary> {
146    Ok(resolve_item(transport, selector).await?.summary)
147}
148
149async fn resolve_item(transport: &Transport, selector: &str) -> Result<ResolvedAgentPolicy> {
150    match agent_policies::get_selector(transport, selector).await {
151        Ok(policy) => {
152            return Ok(ResolvedAgentPolicy {
153                summary: AgentPolicySummary::from_item(&policy.item)?,
154                item: policy.item,
155            });
156        }
157        Err(error) if error.kind == ErrorKind::NotFound => {}
158        Err(error) => return Err(error),
159    }
160    let items = collect(transport).await?;
161    let matches: Vec<AgentPolicySummary> = items
162        .iter()
163        .filter(|item| item.get("name").and_then(Value::as_str) == Some(selector))
164        .map(AgentPolicySummary::from_item)
165        .collect::<Result<_>>()?;
166    match matches.as_slice() {
167        [] => Err(Error::new(
168            ErrorKind::NotFound,
169            format!("no agent policy with id or name '{selector}'"),
170        )),
171        [one] => {
172            let policy = agent_policies::get(transport, &one.id).await?;
173            Ok(ResolvedAgentPolicy {
174                summary: one.clone(),
175                item: policy.item,
176            })
177        }
178        many => Err(Error::new(
179            ErrorKind::Conflict,
180            format!(
181                "agent policy '{selector}' is ambiguous: {}",
182                many.iter()
183                    .map(|row| row.id.as_str())
184                    .collect::<Vec<_>>()
185                    .join(", ")
186            ),
187        )),
188    }
189}
190
191pub async fn get_op(transport: &Transport, selector: &str) -> Result<AgentPolicyDetail> {
192    let ResolvedAgentPolicy { summary, item } = resolve_item(transport, selector).await?;
193    let agents = required_agents(&item, &summary.id)?;
194    let attached_integrations = attached_integration_ids(&item, &summary.id)?;
195    let status = match item.get("status") {
196        None | Some(Value::Null) => None,
197        Some(Value::String(status)) => Some(status.clone()),
198        Some(_) => {
199            return Err(http(format!(
200                "decoding agent policy '{}': status must be a string or null",
201                summary.id
202            )));
203        }
204    };
205    let blocked_by = portability_reasons(&item, transport.space())?
206        .into_iter()
207        .map(str::to_owned)
208        .collect();
209    Ok(AgentPolicyDetail {
210        id: summary.id,
211        name: summary.name,
212        namespace: summary.namespace,
213        description: summary.description,
214        agents,
215        status,
216        attached_integrations,
217        blocked_by,
218    })
219}
220
221pub fn is_platform_owned(item: &Map<String, Value>) -> Result<bool> {
222    for flag in PLATFORM_FLAGS {
223        if optional_server_bool(item, flag)? == Some(true) {
224            return Ok(true);
225        }
226    }
227    match item.get(AGENTLESS_FIELD) {
228        None | Some(Value::Null) => Ok(false),
229        Some(Value::Object(_)) => Ok(true),
230        Some(_) => Err(http(
231            "decoding agent policy: agentless must be an object or null",
232        )),
233    }
234}
235
236pub(crate) async fn read_parent_snapshot(
237    transport: &Transport,
238    id: &str,
239) -> Result<AgentPolicyParentSnapshot> {
240    let policy = agent_policies::get(transport, id).await?;
241    let item = &policy.item;
242    let returned_id = item
243        .get("id")
244        .and_then(Value::as_str)
245        .filter(|value| !value.trim().is_empty())
246        .ok_or_else(|| http("decoding agent policy parent: id must be a non-empty string"))?;
247    if returned_id != id {
248        return Err(http(format!(
249            "decoding agent policy parent: expected id '{id}', got '{returned_id}'"
250        )));
251    }
252    let name = item
253        .get("name")
254        .and_then(Value::as_str)
255        .filter(|value| !value.trim().is_empty())
256        .ok_or_else(|| {
257            http(format!(
258                "decoding agent policy '{id}': name must be a non-empty string"
259            ))
260        })?
261        .to_owned();
262    let namespace = item
263        .get("namespace")
264        .and_then(Value::as_str)
265        .filter(|value| !value.trim().is_empty())
266        .ok_or_else(|| {
267            http(format!(
268                "decoding agent policy '{id}': namespace must be a non-empty string"
269            ))
270        })?
271        .to_owned();
272    Ok(AgentPolicyParentSnapshot {
273        id: returned_id.to_owned(),
274        name,
275        namespace,
276        agents: required_agents(item, id)?,
277        attached_integrations: attached_integration_ids(item, id)?,
278        platform_owned: is_platform_owned(item)?,
279        protected: optional_server_bool(item, "is_protected")?.unwrap_or(false),
280    })
281}
282
283const PORTABLE_OPTIONAL: [&str; 13] = [
284    "description",
285    "inactivity_timeout",
286    "unenroll_timeout",
287    "monitoring_enabled",
288    "agent_features",
289    "global_data_tags",
290    "advanced_settings",
291    "overrides",
292    "keep_monitoring_alive",
293    "monitoring_pprof_enabled",
294    "monitoring_http",
295    "monitoring_diagnostics",
296    "namespace",
297];
298
299const REMOVED_FIELDS: [&str; 31] = [
300    "agentless",
301    "agents",
302    "agents_per_version",
303    "created_at",
304    "created_by",
305    "data_output_id",
306    "download_source_id",
307    "fips_agents",
308    "fleet_server_host_id",
309    "has_agent_version_conditions",
310    "has_fleet_server",
311    "is_default",
312    "is_default_fleet_server",
313    "is_managed",
314    "is_preconfigured",
315    "is_protected",
316    "is_verifier",
317    "min_agent_version",
318    "monitoring_output_id",
319    "package_agent_version_conditions",
320    "package_policies",
321    "required_versions",
322    "revision",
323    "schema_version",
324    "space_ids",
325    "status",
326    "supports_agentless",
327    "unprivileged_agents",
328    "updated_at",
329    "updated_by",
330    "version",
331];
332
333pub fn normalize(item: &Map<String, Value>, active_space: &str) -> Result<AgentPolicySpec> {
334    let id = item
335        .get("id")
336        .and_then(Value::as_str)
337        .ok_or_else(|| http("decoding agent policy: expected string id"))?;
338    let reasons = portability_reasons(item, active_space)?;
339    if !reasons.is_empty() {
340        return Err(Error::new(
341            ErrorKind::Unsupported,
342            format!(
343                "agent policy '{id}' is not portable: {}",
344                reasons.into_iter().collect::<Vec<_>>().join(", ")
345            ),
346        ));
347    }
348
349    let mut portable = Map::new();
350    for key in ["id", "name"] {
351        if let Some(value) = item.get(key) {
352            portable.insert(key.to_string(), value.clone());
353        }
354    }
355    for key in PORTABLE_OPTIONAL {
356        if let Some(value) = item.get(key)
357            && !value.is_null()
358        {
359            portable.insert(key.to_string(), value.clone());
360        }
361    }
362    let known: BTreeSet<&str> = ["id", "name"]
363        .into_iter()
364        .chain(PORTABLE_OPTIONAL)
365        .chain(REMOVED_FIELDS)
366        .collect();
367    let unknown: BTreeSet<&str> = item
368        .keys()
369        .map(String::as_str)
370        .filter(|key| !known.contains(key))
371        .collect();
372    if let Some(first) = unknown.into_iter().next() {
373        return Err(Error::new(
374            ErrorKind::Unsupported,
375            format!("agent policy '{id}' carries unknown field '{first}'"),
376        ));
377    }
378    AgentPolicySpec::try_from(Value::Object(portable))
379        .map_err(|error| http(format!("decoding agent policy '{id}': {}", error.message)))
380}
381
382fn portability_reasons(
383    item: &Map<String, Value>,
384    active_space: &str,
385) -> Result<BTreeSet<&'static str>> {
386    let active = if active_space.is_empty() {
387        "default"
388    } else {
389        active_space
390    };
391    let mut reasons = BTreeSet::new();
392    for flag in PLATFORM_FLAGS.into_iter().chain(["is_protected"]) {
393        if optional_server_bool(item, flag)? == Some(true) {
394            reasons.insert(flag);
395        }
396    }
397    match item.get(AGENTLESS_FIELD) {
398        None | Some(Value::Null) => {}
399        Some(Value::Object(_)) => {
400            reasons.insert(AGENTLESS_FIELD);
401        }
402        Some(_) => {
403            return Err(http(
404                "decoding agent policy: agentless must be an object or null",
405            ));
406        }
407    }
408    for field in ENVIRONMENT_IDS {
409        match item.get(field) {
410            None | Some(Value::Null) => {}
411            Some(Value::String(value)) if !value.trim().is_empty() => {
412                reasons.insert(field);
413            }
414            Some(_) => {
415                return Err(http(format!(
416                    "decoding agent policy: {field} must be a non-empty string or null"
417                )));
418            }
419        }
420    }
421    match item.get("required_versions") {
422        None | Some(Value::Null) => {}
423        Some(Value::Array(_)) => {
424            reasons.insert("required_versions");
425        }
426        Some(_) => {
427            return Err(http(
428                "decoding agent policy: required_versions must be an array or null",
429            ));
430        }
431    }
432    match item.get("space_ids") {
433        None | Some(Value::Null) => {}
434        Some(Value::Array(spaces)) => {
435            let decoded =
436                spaces
437                    .iter()
438                    .map(|space| {
439                        space.as_str().filter(|space| !space.is_empty()).ok_or_else(|| {
440                        http("decoding agent policy: space_ids must contain non-empty strings")
441                    })
442                    })
443                    .collect::<Result<Vec<_>>>()?;
444            if decoded.iter().any(|space| *space != active) {
445                reasons.insert("space_ids");
446            }
447        }
448        Some(_) => {
449            return Err(http(
450                "decoding agent policy: space_ids must be an array or null",
451            ));
452        }
453    }
454    Ok(reasons)
455}
456
457fn optional_server_bool(item: &Map<String, Value>, field: &str) -> Result<Option<bool>> {
458    match item.get(field) {
459        None | Some(Value::Null) => Ok(None),
460        Some(Value::Bool(value)) => Ok(Some(*value)),
461        Some(_) => Err(http(format!(
462            "decoding agent policy: {field} must be a boolean or null"
463        ))),
464    }
465}
466
467pub(crate) async fn read_live(transport: &Transport, id: &str) -> Result<LiveAgentPolicy> {
468    let policy = agent_policies::get(transport, id).await?;
469    live_from_policy(&policy, id, transport.space())
470}
471
472fn live_from_policy(
473    policy: &agent_policies::AgentPolicy,
474    id: &str,
475    active_space: &str,
476) -> Result<LiveAgentPolicy> {
477    live_from_item(&policy.item, id, active_space)
478}
479
480fn live_from_item(
481    item: &Map<String, Value>,
482    id: &str,
483    active_space: &str,
484) -> Result<LiveAgentPolicy> {
485    let spec = normalize(item, active_space)?;
486    if spec.id != id {
487        return Err(http(format!(
488            "decoding agent policy: expected id '{id}', got '{}'",
489            spec.id
490        )));
491    }
492    let agents = required_agents(item, id)?;
493    let attached = attached_integration_ids(item, id)?;
494    Ok(LiveAgentPolicy {
495        spec,
496        agents,
497        attached,
498    })
499}
500
501fn attached_integration_ids(item: &Map<String, Value>, id: &str) -> Result<Vec<String>> {
502    let entries = item
503        .get("package_policies")
504        .ok_or_else(|| {
505            http(format!(
506                "decoding agent policy '{id}': missing package_policies"
507            ))
508        })?
509        .as_array()
510        .ok_or_else(|| {
511            http(format!(
512                "decoding agent policy '{id}': package_policies must be an array"
513            ))
514        })?;
515    let mut ids = Vec::with_capacity(entries.len());
516    for entry in entries {
517        let attached_id = match entry {
518            Value::String(attached_id) => Some(attached_id.as_str()),
519            Value::Object(object) => object.get("id").and_then(Value::as_str),
520            _ => None,
521        }
522        .filter(|attached_id| !attached_id.is_empty())
523        .ok_or_else(|| {
524            http(format!(
525                "decoding agent policy '{id}': package_policies entry without id"
526            ))
527        })?;
528        ids.push(attached_id.to_owned());
529    }
530    ids.sort();
531    if ids.windows(2).any(|ids| ids[0] == ids[1]) {
532        return Err(http(format!(
533            "decoding agent policy '{id}': duplicate package_policies id"
534        )));
535    }
536    Ok(ids)
537}
538
539fn required_agents(item: &Map<String, Value>, id: &str) -> Result<u64> {
540    match item.get("agents") {
541        None => Err(Error::new(
542            ErrorKind::Permission,
543            format!(
544                "agent policy '{id}' has no agents count; the API key lacks the Fleet agents read privilege"
545            ),
546        )),
547        Some(value) => value.as_u64().ok_or_else(|| {
548            http(format!(
549                "decoding agent policy '{id}': agents must be an unsigned integer"
550            ))
551        }),
552    }
553}
554
555fn http(message: impl Into<String>) -> Error {
556    Error::new(ErrorKind::Http, message)
557}