1use 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
10pub const DEFAULT_INACTIVITY_TIMEOUT: u64 = 1_209_600;
12
13pub 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
24pub const AGENTLESS_FIELD: &str = "agentless";
26
27pub 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#[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 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#[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#[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#[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#[derive(Debug, Clone, PartialEq)]
280pub struct AgentPolicy {
281 pub item: Map<String, Value>,
282}
283
284#[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
332pub 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
349pub 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
355pub 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
369pub 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
379pub 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 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
402pub 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}