Skip to main content

elasticctl_api/fleet/
agent_policies.rs

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