Skip to main content

elasticctl_api/fleet/
agent_policies.rs

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