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}