1use 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
11pub const DEFAULT_INACTIVITY_TIMEOUT: u64 = 1_209_600;
13
14pub 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
25pub const AGENTLESS_FIELD: &str = "agentless";
27
28pub 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#[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 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#[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#[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#[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#[derive(Debug, Clone, PartialEq)]
281pub struct AgentPolicy {
282 pub item: Map<String, Value>,
283}
284
285#[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
333pub 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
350pub async fn get(transport: &Transport, id: &str) -> Result<AgentPolicy> {
352 get_selector(transport, require_path_segment(id)?).await
353}
354
355pub(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
365pub 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
379pub 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
390pub 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 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
413pub 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}