Skip to main content

elasticctl_api/fleet/
agent_policies.rs

1use crate::compose::require_path_segment;
2use elasticctl_core::{Error, ErrorKind, Feature, Result, Transport};
3use serde::{Deserialize, Deserializer, Serialize};
4use serde_json::{Map, Value, json};
5
6const BASE: &str = "/api/fleet/agent_policies";
7const PACKAGES: &str = "/api/fleet/epm/packages";
8
9pub const DEFAULT_INACTIVITY_TIMEOUT: u64 = 1_209_600;
10
11pub const PLATFORM_FLAGS: [&str; 7] = [
12    "has_fleet_server",
13    "is_default",
14    "is_default_fleet_server",
15    "is_managed",
16    "is_preconfigured",
17    "is_verifier",
18    "supports_agentless",
19];
20
21pub const AGENTLESS_FIELD: &str = "agentless";
22
23pub const ENVIRONMENT_IDS: [&str; 4] = [
24    "data_output_id",
25    "download_source_id",
26    "fleet_server_host_id",
27    "monitoring_output_id",
28];
29
30const MONITORING_VALUES: [&str; 3] = ["logs", "metrics", "traces"];
31
32#[derive(Debug, Clone, PartialEq, Serialize)]
33pub struct AgentPolicySpec {
34    pub id: String,
35    pub name: String,
36    pub namespace: String,
37    #[serde(default, skip_serializing_if = "Option::is_none")]
38    pub description: Option<String>,
39    pub inactivity_timeout: u64,
40    #[serde(default, skip_serializing_if = "Option::is_none")]
41    pub unenroll_timeout: Option<u64>,
42    pub monitoring_enabled: Vec<String>,
43    pub agent_features: Vec<Value>,
44    pub global_data_tags: Vec<Value>,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub advanced_settings: Option<Map<String, Value>>,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub overrides: Option<Map<String, Value>>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub keep_monitoring_alive: Option<bool>,
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub monitoring_pprof_enabled: Option<bool>,
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub monitoring_http: Option<Map<String, Value>>,
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub monitoring_diagnostics: Option<Map<String, Value>>,
57}
58
59#[derive(Deserialize)]
60#[serde(deny_unknown_fields)]
61struct RawAgentPolicySpec {
62    id: String,
63    name: String,
64    namespace: String,
65    #[serde(default)]
66    description: Option<String>,
67    #[serde(default)]
68    inactivity_timeout: Option<u64>,
69    #[serde(default)]
70    unenroll_timeout: Option<u64>,
71    #[serde(default)]
72    monitoring_enabled: Option<Vec<String>>,
73    #[serde(default)]
74    agent_features: Option<Vec<Value>>,
75    #[serde(default)]
76    global_data_tags: Option<Vec<Value>>,
77    #[serde(default)]
78    advanced_settings: Option<Map<String, Value>>,
79    #[serde(default)]
80    overrides: Option<Map<String, Value>>,
81    #[serde(default)]
82    keep_monitoring_alive: Option<bool>,
83    #[serde(default)]
84    monitoring_pprof_enabled: Option<bool>,
85    #[serde(default)]
86    monitoring_http: Option<Map<String, Value>>,
87    #[serde(default)]
88    monitoring_diagnostics: Option<Map<String, Value>>,
89}
90
91impl AgentPolicySpec {
92    fn from_raw(raw: RawAgentPolicySpec) -> Result<Self> {
93        let spec = Self {
94            id: raw.id,
95            name: raw.name,
96            namespace: raw.namespace,
97            description: raw.description,
98            inactivity_timeout: raw.inactivity_timeout.unwrap_or(DEFAULT_INACTIVITY_TIMEOUT),
99            unenroll_timeout: raw.unenroll_timeout,
100            monitoring_enabled: raw.monitoring_enabled.unwrap_or_default(),
101            agent_features: raw.agent_features.unwrap_or_default(),
102            global_data_tags: raw.global_data_tags.unwrap_or_default(),
103            advanced_settings: raw.advanced_settings,
104            overrides: raw.overrides,
105            keep_monitoring_alive: raw.keep_monitoring_alive,
106            monitoring_pprof_enabled: raw.monitoring_pprof_enabled,
107            monitoring_http: raw.monitoring_http,
108            monitoring_diagnostics: raw.monitoring_diagnostics,
109        };
110        spec.validate()?;
111        Ok(spec)
112    }
113
114    pub fn validate(&self) -> Result<()> {
115        for (field, value) in [
116            ("id", &self.id),
117            ("name", &self.name),
118            ("namespace", &self.namespace),
119        ] {
120            if value.trim().is_empty() {
121                return Err(Error::new(
122                    ErrorKind::Error,
123                    format!("agent policy {field} must not be empty"),
124                ));
125            }
126        }
127        for value in &self.monitoring_enabled {
128            if !MONITORING_VALUES.contains(&value.as_str()) {
129                return Err(Error::new(
130                    ErrorKind::Error,
131                    format!(
132                        "monitoring_enabled value '{value}' must be one of logs, metrics, traces"
133                    ),
134                ));
135            }
136        }
137        validate_agent_features(&self.agent_features)?;
138        validate_global_data_tags(&self.global_data_tags)?;
139        Ok(())
140    }
141}
142
143fn object_name<'a>(value: &'a Value, field: &str, index: usize) -> Result<&'a str> {
144    value
145        .as_object()
146        .and_then(|object| object.get("name"))
147        .and_then(Value::as_str)
148        .filter(|name| !name.trim().is_empty())
149        .ok_or_else(|| {
150            Error::new(
151                ErrorKind::Error,
152                format!("{field}[{index}].name must be a non-empty string"),
153            )
154        })
155}
156
157fn validate_agent_features(values: &[Value]) -> Result<()> {
158    for (index, value) in values.iter().enumerate() {
159        object_name(value, "agent_features", index)?;
160        if value
161            .as_object()
162            .and_then(|object| object.get("enabled"))
163            .and_then(Value::as_bool)
164            .is_none()
165        {
166            return Err(Error::new(
167                ErrorKind::Error,
168                format!("agent_features[{index}].enabled must be a boolean"),
169            ));
170        }
171    }
172    Ok(())
173}
174
175fn validate_global_data_tags(values: &[Value]) -> Result<()> {
176    let mut names = std::collections::BTreeSet::new();
177    for (index, value) in values.iter().enumerate() {
178        let name = object_name(value, "global_data_tags", index)?;
179        if name.chars().any(char::is_whitespace) {
180            return Err(Error::new(
181                ErrorKind::Error,
182                format!("global_data_tags[{index}].name must not contain whitespace"),
183            ));
184        }
185        if !names.insert(name) {
186            return Err(Error::new(
187                ErrorKind::Error,
188                format!("duplicate global_data_tags name '{name}'"),
189            ));
190        }
191        let valid_value = value
192            .as_object()
193            .and_then(|object| object.get("value"))
194            .is_some_and(|value| value.is_string() || value.is_number());
195        if !valid_value {
196            return Err(Error::new(
197                ErrorKind::Error,
198                format!("global_data_tags[{index}].value must be a string or number"),
199            ));
200        }
201    }
202    Ok(())
203}
204
205impl TryFrom<Value> for AgentPolicySpec {
206    type Error = Error;
207
208    fn try_from(value: Value) -> Result<Self> {
209        serde_json::from_value(value).map_err(|error| {
210            Error::new(ErrorKind::Error, format!("decoding agent policy: {error}"))
211        })
212    }
213}
214
215impl<'de> Deserialize<'de> for AgentPolicySpec {
216    fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
217    where
218        D: Deserializer<'de>,
219    {
220        let raw = RawAgentPolicySpec::deserialize(deserializer)?;
221        Self::from_raw(raw).map_err(serde::de::Error::custom)
222    }
223}
224
225#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
226pub struct AgentPolicySummary {
227    pub id: String,
228    pub name: String,
229    pub namespace: String,
230    pub description: Option<String>,
231    pub agents: Option<u64>,
232}
233
234impl AgentPolicySummary {
235    pub fn from_item(item: &Map<String, Value>) -> Result<Self> {
236        Ok(Self {
237            id: required_string(item, "id")?,
238            name: required_string(item, "name")?,
239            namespace: required_string(item, "namespace")?,
240            description: optional_string(item, "description")?,
241            agents: optional_u64(item, "agents")?,
242        })
243    }
244}
245
246#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
247pub struct AgentPolicyDetail {
248    pub id: String,
249    pub name: String,
250    pub namespace: String,
251    pub description: Option<String>,
252    pub agents: u64,
253    pub status: Option<String>,
254    pub attached_integrations: Vec<String>,
255    pub blocked_by: Vec<String>,
256}
257
258#[derive(Debug, Clone, PartialEq)]
259pub struct AgentPolicyPage {
260    pub items: Vec<Map<String, Value>>,
261    pub total: u64,
262    pub page: u64,
263    pub per_page: u64,
264}
265
266#[derive(Debug, Clone, PartialEq)]
267pub struct AgentPolicy {
268    pub item: Map<String, Value>,
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
272pub struct PackageStatus {
273    pub name: String,
274    pub status: String,
275    pub installed_version: Option<String>,
276}
277
278fn required_string(item: &Map<String, Value>, field: &str) -> Result<String> {
279    item.get(field)
280        .and_then(Value::as_str)
281        .filter(|value| !value.trim().is_empty())
282        .map(str::to_owned)
283        .ok_or_else(|| {
284            Error::new(
285                ErrorKind::Http,
286                format!("decoding agent policy field `{field}`: expected a non-empty string"),
287            )
288        })
289}
290
291fn optional_string(item: &Map<String, Value>, field: &str) -> Result<Option<String>> {
292    match item.get(field) {
293        None | Some(Value::Null) => Ok(None),
294        Some(Value::String(value)) => Ok(Some(value.clone())),
295        Some(_) => Err(Error::new(
296            ErrorKind::Http,
297            format!("decoding agent policy field `{field}`: expected a string or null"),
298        )),
299    }
300}
301
302fn optional_u64(item: &Map<String, Value>, field: &str) -> Result<Option<u64>> {
303    match item.get(field) {
304        None | Some(Value::Null) => Ok(None),
305        Some(value) => value.as_u64().map(Some).ok_or_else(|| {
306            Error::new(
307                ErrorKind::Http,
308                format!(
309                    "decoding agent policy field `{field}`: expected an unsigned integer or null"
310                ),
311            )
312        }),
313    }
314}
315
316pub async fn list_page(transport: &Transport, page: u64) -> Result<AgentPolicyPage> {
317    transport.require_feature(Feature::FleetPolicies).await?;
318    let body = transport
319        .get(&format!(
320            "{BASE}?page={page}&perPage=1000&sortField=created_at&sortOrder=asc"
321        ))
322        .await?;
323    let envelope: PageEnvelope = decode(&body, "agent policies list")?;
324    Ok(AgentPolicyPage {
325        items: envelope.items,
326        total: envelope.total,
327        page: envelope.page,
328        per_page: envelope.per_page,
329    })
330}
331
332pub async fn get(transport: &Transport, id: &str) -> Result<AgentPolicy> {
333    get_selector(transport, require_path_segment(id)?).await
334}
335
336pub(crate) async fn get_selector(transport: &Transport, selector: &str) -> Result<AgentPolicy> {
337    transport.require_feature(Feature::FleetPolicies).await?;
338    decode_item(
339        &transport.get(&policy_path(selector)).await?,
340        "agent policy get",
341    )
342}
343
344pub async fn create(transport: &Transport, spec: &AgentPolicySpec) -> Result<AgentPolicy> {
345    transport.require_feature(Feature::FleetPolicies).await?;
346    spec.validate()?;
347    let body = serde_json::to_value(spec)
348        .map_err(|error| Error::new(ErrorKind::Error, format!("encoding agent policy: {error}")))?;
349    decode_item(
350        &transport
351            .post(&format!("{BASE}?sys_monitoring=false"), Some(&body))
352            .await?,
353        "agent policy create",
354    )
355}
356
357pub async fn update(transport: &Transport, id: &str, body: &Value) -> Result<AgentPolicy> {
358    let id = require_path_segment(id)?;
359    transport.require_feature(Feature::FleetPolicies).await?;
360    decode_item(
361        &transport.put(&policy_path(id), body).await?,
362        "agent policy update",
363    )
364}
365
366pub async fn delete(transport: &Transport, id: &str) -> Result<()> {
367    transport.require_feature(Feature::FleetPolicies).await?;
368    let body = json!({"agentPolicyId": id});
369    let response = transport
370        .post(&format!("{BASE}/delete"), Some(&body))
371        .await?;
372    let deleted: DeleteEnvelope = decode(&response, "agent policy delete")?;
373    if deleted.id != id {
374        return Err(Error::with_status(
375            ErrorKind::Http,
376            200,
377            format!(
378                "decoding agent policy delete: expected id '{id}', got '{}'",
379                deleted.id
380            ),
381        ));
382    }
383    Ok(())
384}
385
386pub async fn package_status(transport: &Transport, name: &str) -> Result<PackageStatus> {
387    let name = require_path_segment(name)?;
388    transport.require_feature(Feature::FleetPolicies).await?;
389    let body = transport
390        .get(&format!("{PACKAGES}/{}", elasticctl_core::urlencode(name)))
391        .await?;
392    let item = body
393        .get("item")
394        .and_then(Value::as_object)
395        .ok_or_else(|| Error::new(ErrorKind::Http, "decoding package status: expected item"))?;
396    let returned_name = required_string(item, "name")?;
397    if returned_name != name {
398        return Err(Error::new(
399            ErrorKind::Http,
400            format!("decoding package status: expected name '{name}', got '{returned_name}'"),
401        ));
402    }
403    let status = required_string(item, "status")?;
404    let installed_version = match item.get("installationInfo") {
405        None | Some(Value::Null) => None,
406        Some(Value::Object(info)) => optional_non_empty_string(info, "version")?,
407        Some(_) => {
408            return Err(Error::new(
409                ErrorKind::Http,
410                "decoding package status: installationInfo must be an object or null",
411            ));
412        }
413    };
414    if status == "installed" && installed_version.is_none() {
415        return Err(Error::new(
416            ErrorKind::Http,
417            "decoding package status: installed package has no installed version",
418        ));
419    }
420    Ok(PackageStatus {
421        name: returned_name,
422        status,
423        installed_version,
424    })
425}
426
427fn optional_non_empty_string(item: &Map<String, Value>, field: &str) -> Result<Option<String>> {
428    match item.get(field) {
429        None | Some(Value::Null) => Ok(None),
430        Some(Value::String(value)) if !value.trim().is_empty() => Ok(Some(value.clone())),
431        Some(_) => Err(Error::new(
432            ErrorKind::Http,
433            format!("decoding package status field `{field}`: expected a non-empty string or null"),
434        )),
435    }
436}
437
438fn policy_path(id: &str) -> String {
439    format!("{BASE}/{}", elasticctl_core::urlencode(id))
440}
441
442fn decode_item(body: &Value, context: &str) -> Result<AgentPolicy> {
443    let envelope: ItemEnvelope = decode(body, context)?;
444    Ok(AgentPolicy {
445        item: envelope.item,
446    })
447}
448
449fn decode<T: serde::de::DeserializeOwned>(body: &Value, context: &str) -> Result<T> {
450    serde_json::from_value(body.clone())
451        .map_err(|error| Error::new(ErrorKind::Http, format!("decoding {context}: {error}")))
452}
453
454#[derive(Deserialize)]
455#[serde(deny_unknown_fields)]
456struct PageEnvelope {
457    items: Vec<Map<String, Value>>,
458    total: u64,
459    page: u64,
460    #[serde(rename = "perPage")]
461    per_page: u64,
462}
463
464#[derive(Deserialize)]
465#[serde(deny_unknown_fields)]
466struct ItemEnvelope {
467    item: Map<String, Value>,
468}
469
470#[derive(Deserialize)]
471#[serde(deny_unknown_fields)]
472struct DeleteEnvelope {
473    id: String,
474    #[allow(dead_code)]
475    name: String,
476}