1use crate::content_codec::{self, ContentFormat};
2use crate::fleet::agent_policies::{
3 self, AGENTLESS_FIELD, AgentPolicyDetail, AgentPolicySpec, AgentPolicySummary, ENVIRONMENT_IDS,
4 PLATFORM_FLAGS,
5};
6use crate::ops::{ExportOutcome, MutationPlan};
7use elasticctl_core::{Error, ErrorKind, Result, Transport};
8use serde::Serialize;
9use serde_json::{Map, Value, json};
10use std::collections::{BTreeMap, BTreeSet};
11use std::path::Path;
12
13mod artifact;
14mod delete;
15mod import;
16pub use artifact::{export, validate};
17pub use delete::{
18 AgentPolicyDeletePlan, AgentPolicyDeleteReport, AgentPolicyDeleteTarget, apply_delete,
19 plan_delete,
20};
21pub use import::{
22 AgentPolicyImportPlan, AgentPolicyImportReport, apply_import, build_replace_body, plan_import,
23};
24
25const PAGE_SIZE: u64 = 1000;
26
27#[derive(Debug, Clone, Default, PartialEq, Eq)]
28pub struct AgentPolicyFilter {
29 pub search: Option<String>,
30 pub limit: Option<usize>,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct AgentPolicyList {
35 pub total: u64,
36 pub agent_policies: Vec<AgentPolicySummary>,
37 pub truncated: bool,
38}
39
40#[derive(Debug, Clone, PartialEq)]
41struct ResolvedAgentPolicy {
42 summary: AgentPolicySummary,
43 item: Map<String, Value>,
44}
45
46#[derive(Debug, Clone, PartialEq)]
47pub struct LiveAgentPolicy {
48 pub spec: AgentPolicySpec,
49 pub agents: u64,
50 pub attached: Vec<String>,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub(crate) struct AgentPolicyParentSnapshot {
55 pub id: String,
56 pub name: String,
57 pub namespace: String,
58 pub agents: u64,
59 pub attached_integrations: Vec<String>,
60 pub platform_owned: bool,
61 pub protected: bool,
62}
63
64pub async fn collect(transport: &Transport) -> Result<Vec<Map<String, Value>>> {
65 let mut page_number = 1;
66 let mut total = None;
67 let mut items = Vec::new();
68 let mut ids = BTreeSet::new();
69 loop {
70 let page = agent_policies::list_page(transport, page_number).await?;
71 if page.page != page_number || page.per_page != PAGE_SIZE {
72 return Err(http(
73 "decoding agent policies list: unexpected page metadata",
74 ));
75 }
76 match total {
77 Some(total) if total != page.total => {
78 return Err(http(
79 "decoding agent policies list: total changed while paging",
80 ));
81 }
82 Some(_) => {}
83 None => total = Some(page.total),
84 }
85 let page_len = page.items.len() as u64;
86 for item in page.items {
87 let id = item
88 .get("id")
89 .and_then(Value::as_str)
90 .filter(|id| !id.is_empty())
91 .ok_or_else(|| http("decoding agent policies list: item without id"))?
92 .to_owned();
93 if !ids.insert(id.clone()) {
94 return Err(http(format!(
95 "decoding agent policies list: duplicate agent policy id '{id}'"
96 )));
97 }
98 items.push(item);
99 }
100 let expected = total.expect("set from the first page");
101 if items.len() as u64 >= expected {
102 break;
103 }
104 if page_len != PAGE_SIZE {
105 return Err(http(
106 "decoding agent policies list: page was short before total",
107 ));
108 }
109 page_number += 1;
110 }
111 if items.len() as u64 > total.unwrap_or(0) {
112 return Err(http(
113 "decoding agent policies list: returned more items than total",
114 ));
115 }
116 items.sort_by(|left, right| left["id"].as_str().cmp(&right["id"].as_str()));
117 Ok(items)
118}
119
120pub async fn list_op(transport: &Transport, filter: &AgentPolicyFilter) -> Result<AgentPolicyList> {
121 let items = collect(transport).await?;
122 let total = items.len() as u64;
123 let needle = filter.search.as_ref().map(|search| search.to_lowercase());
124 let mut rows = Vec::new();
125 for item in &items {
126 let summary = AgentPolicySummary::from_item(item)?;
127 let keep = needle.as_ref().is_none_or(|needle| {
128 summary.id.to_lowercase().contains(needle)
129 || summary.name.to_lowercase().contains(needle)
130 });
131 if keep {
132 rows.push(summary);
133 }
134 }
135 let limit = filter.limit.unwrap_or(usize::MAX);
136 let truncated = rows.len() > limit;
137 rows.truncate(limit);
138 Ok(AgentPolicyList {
139 total,
140 agent_policies: rows,
141 truncated,
142 })
143}
144
145pub async fn resolve(transport: &Transport, selector: &str) -> Result<AgentPolicySummary> {
146 Ok(resolve_item(transport, selector).await?.summary)
147}
148
149async fn resolve_item(transport: &Transport, selector: &str) -> Result<ResolvedAgentPolicy> {
150 match agent_policies::get_selector(transport, selector).await {
151 Ok(policy) => {
152 return Ok(ResolvedAgentPolicy {
153 summary: AgentPolicySummary::from_item(&policy.item)?,
154 item: policy.item,
155 });
156 }
157 Err(error) if error.kind == ErrorKind::NotFound => {}
158 Err(error) => return Err(error),
159 }
160 let items = collect(transport).await?;
161 let matches: Vec<AgentPolicySummary> = items
162 .iter()
163 .filter(|item| item.get("name").and_then(Value::as_str) == Some(selector))
164 .map(AgentPolicySummary::from_item)
165 .collect::<Result<_>>()?;
166 match matches.as_slice() {
167 [] => Err(Error::new(
168 ErrorKind::NotFound,
169 format!("no agent policy with id or name '{selector}'"),
170 )),
171 [one] => {
172 let policy = agent_policies::get(transport, &one.id).await?;
173 Ok(ResolvedAgentPolicy {
174 summary: one.clone(),
175 item: policy.item,
176 })
177 }
178 many => Err(Error::new(
179 ErrorKind::Conflict,
180 format!(
181 "agent policy '{selector}' is ambiguous: {}",
182 many.iter()
183 .map(|row| row.id.as_str())
184 .collect::<Vec<_>>()
185 .join(", ")
186 ),
187 )),
188 }
189}
190
191pub async fn get_op(transport: &Transport, selector: &str) -> Result<AgentPolicyDetail> {
192 let ResolvedAgentPolicy { summary, item } = resolve_item(transport, selector).await?;
193 let agents = required_agents(&item, &summary.id)?;
194 let attached_integrations = attached_integration_ids(&item, &summary.id)?;
195 let status = match item.get("status") {
196 None | Some(Value::Null) => None,
197 Some(Value::String(status)) => Some(status.clone()),
198 Some(_) => {
199 return Err(http(format!(
200 "decoding agent policy '{}': status must be a string or null",
201 summary.id
202 )));
203 }
204 };
205 let blocked_by = portability_reasons(&item, transport.space())?
206 .into_iter()
207 .map(str::to_owned)
208 .collect();
209 Ok(AgentPolicyDetail {
210 id: summary.id,
211 name: summary.name,
212 namespace: summary.namespace,
213 description: summary.description,
214 agents,
215 status,
216 attached_integrations,
217 blocked_by,
218 })
219}
220
221pub fn is_platform_owned(item: &Map<String, Value>) -> Result<bool> {
222 for flag in PLATFORM_FLAGS {
223 if optional_server_bool(item, flag)? == Some(true) {
224 return Ok(true);
225 }
226 }
227 match item.get(AGENTLESS_FIELD) {
228 None | Some(Value::Null) => Ok(false),
229 Some(Value::Object(_)) => Ok(true),
230 Some(_) => Err(http(
231 "decoding agent policy: agentless must be an object or null",
232 )),
233 }
234}
235
236pub(crate) async fn read_parent_snapshot(
237 transport: &Transport,
238 id: &str,
239) -> Result<AgentPolicyParentSnapshot> {
240 let policy = agent_policies::get(transport, id).await?;
241 let item = &policy.item;
242 let returned_id = item
243 .get("id")
244 .and_then(Value::as_str)
245 .filter(|value| !value.trim().is_empty())
246 .ok_or_else(|| http("decoding agent policy parent: id must be a non-empty string"))?;
247 if returned_id != id {
248 return Err(http(format!(
249 "decoding agent policy parent: expected id '{id}', got '{returned_id}'"
250 )));
251 }
252 let name = item
253 .get("name")
254 .and_then(Value::as_str)
255 .filter(|value| !value.trim().is_empty())
256 .ok_or_else(|| {
257 http(format!(
258 "decoding agent policy '{id}': name must be a non-empty string"
259 ))
260 })?
261 .to_owned();
262 let namespace = item
263 .get("namespace")
264 .and_then(Value::as_str)
265 .filter(|value| !value.trim().is_empty())
266 .ok_or_else(|| {
267 http(format!(
268 "decoding agent policy '{id}': namespace must be a non-empty string"
269 ))
270 })?
271 .to_owned();
272 Ok(AgentPolicyParentSnapshot {
273 id: returned_id.to_owned(),
274 name,
275 namespace,
276 agents: required_agents(item, id)?,
277 attached_integrations: attached_integration_ids(item, id)?,
278 platform_owned: is_platform_owned(item)?,
279 protected: optional_server_bool(item, "is_protected")?.unwrap_or(false),
280 })
281}
282
283const PORTABLE_OPTIONAL: [&str; 13] = [
284 "description",
285 "inactivity_timeout",
286 "unenroll_timeout",
287 "monitoring_enabled",
288 "agent_features",
289 "global_data_tags",
290 "advanced_settings",
291 "overrides",
292 "keep_monitoring_alive",
293 "monitoring_pprof_enabled",
294 "monitoring_http",
295 "monitoring_diagnostics",
296 "namespace",
297];
298
299const REMOVED_FIELDS: [&str; 31] = [
300 "agentless",
301 "agents",
302 "agents_per_version",
303 "created_at",
304 "created_by",
305 "data_output_id",
306 "download_source_id",
307 "fips_agents",
308 "fleet_server_host_id",
309 "has_agent_version_conditions",
310 "has_fleet_server",
311 "is_default",
312 "is_default_fleet_server",
313 "is_managed",
314 "is_preconfigured",
315 "is_protected",
316 "is_verifier",
317 "min_agent_version",
318 "monitoring_output_id",
319 "package_agent_version_conditions",
320 "package_policies",
321 "required_versions",
322 "revision",
323 "schema_version",
324 "space_ids",
325 "status",
326 "supports_agentless",
327 "unprivileged_agents",
328 "updated_at",
329 "updated_by",
330 "version",
331];
332
333pub fn normalize(item: &Map<String, Value>, active_space: &str) -> Result<AgentPolicySpec> {
334 let id = item
335 .get("id")
336 .and_then(Value::as_str)
337 .ok_or_else(|| http("decoding agent policy: expected string id"))?;
338 let reasons = portability_reasons(item, active_space)?;
339 if !reasons.is_empty() {
340 return Err(Error::new(
341 ErrorKind::Unsupported,
342 format!(
343 "agent policy '{id}' is not portable: {}",
344 reasons.into_iter().collect::<Vec<_>>().join(", ")
345 ),
346 ));
347 }
348
349 let mut portable = Map::new();
350 for key in ["id", "name"] {
351 if let Some(value) = item.get(key) {
352 portable.insert(key.to_string(), value.clone());
353 }
354 }
355 for key in PORTABLE_OPTIONAL {
356 if let Some(value) = item.get(key)
357 && !value.is_null()
358 {
359 portable.insert(key.to_string(), value.clone());
360 }
361 }
362 let known: BTreeSet<&str> = ["id", "name"]
363 .into_iter()
364 .chain(PORTABLE_OPTIONAL)
365 .chain(REMOVED_FIELDS)
366 .collect();
367 let unknown: BTreeSet<&str> = item
368 .keys()
369 .map(String::as_str)
370 .filter(|key| !known.contains(key))
371 .collect();
372 if let Some(first) = unknown.into_iter().next() {
373 return Err(Error::new(
374 ErrorKind::Unsupported,
375 format!("agent policy '{id}' carries unknown field '{first}'"),
376 ));
377 }
378 AgentPolicySpec::try_from(Value::Object(portable))
379 .map_err(|error| http(format!("decoding agent policy '{id}': {}", error.message)))
380}
381
382fn portability_reasons(
383 item: &Map<String, Value>,
384 active_space: &str,
385) -> Result<BTreeSet<&'static str>> {
386 let active = if active_space.is_empty() {
387 "default"
388 } else {
389 active_space
390 };
391 let mut reasons = BTreeSet::new();
392 for flag in PLATFORM_FLAGS.into_iter().chain(["is_protected"]) {
393 if optional_server_bool(item, flag)? == Some(true) {
394 reasons.insert(flag);
395 }
396 }
397 match item.get(AGENTLESS_FIELD) {
398 None | Some(Value::Null) => {}
399 Some(Value::Object(_)) => {
400 reasons.insert(AGENTLESS_FIELD);
401 }
402 Some(_) => {
403 return Err(http(
404 "decoding agent policy: agentless must be an object or null",
405 ));
406 }
407 }
408 for field in ENVIRONMENT_IDS {
409 match item.get(field) {
410 None | Some(Value::Null) => {}
411 Some(Value::String(value)) if !value.trim().is_empty() => {
412 reasons.insert(field);
413 }
414 Some(_) => {
415 return Err(http(format!(
416 "decoding agent policy: {field} must be a non-empty string or null"
417 )));
418 }
419 }
420 }
421 match item.get("required_versions") {
422 None | Some(Value::Null) => {}
423 Some(Value::Array(_)) => {
424 reasons.insert("required_versions");
425 }
426 Some(_) => {
427 return Err(http(
428 "decoding agent policy: required_versions must be an array or null",
429 ));
430 }
431 }
432 match item.get("space_ids") {
433 None | Some(Value::Null) => {}
434 Some(Value::Array(spaces)) => {
435 let decoded =
436 spaces
437 .iter()
438 .map(|space| {
439 space.as_str().filter(|space| !space.is_empty()).ok_or_else(|| {
440 http("decoding agent policy: space_ids must contain non-empty strings")
441 })
442 })
443 .collect::<Result<Vec<_>>>()?;
444 if decoded.iter().any(|space| *space != active) {
445 reasons.insert("space_ids");
446 }
447 }
448 Some(_) => {
449 return Err(http(
450 "decoding agent policy: space_ids must be an array or null",
451 ));
452 }
453 }
454 Ok(reasons)
455}
456
457fn optional_server_bool(item: &Map<String, Value>, field: &str) -> Result<Option<bool>> {
458 match item.get(field) {
459 None | Some(Value::Null) => Ok(None),
460 Some(Value::Bool(value)) => Ok(Some(*value)),
461 Some(_) => Err(http(format!(
462 "decoding agent policy: {field} must be a boolean or null"
463 ))),
464 }
465}
466
467pub(crate) async fn read_live(transport: &Transport, id: &str) -> Result<LiveAgentPolicy> {
468 let policy = agent_policies::get(transport, id).await?;
469 live_from_policy(&policy, id, transport.space())
470}
471
472fn live_from_policy(
473 policy: &agent_policies::AgentPolicy,
474 id: &str,
475 active_space: &str,
476) -> Result<LiveAgentPolicy> {
477 live_from_item(&policy.item, id, active_space)
478}
479
480fn live_from_item(
481 item: &Map<String, Value>,
482 id: &str,
483 active_space: &str,
484) -> Result<LiveAgentPolicy> {
485 let spec = normalize(item, active_space)?;
486 if spec.id != id {
487 return Err(http(format!(
488 "decoding agent policy: expected id '{id}', got '{}'",
489 spec.id
490 )));
491 }
492 let agents = required_agents(item, id)?;
493 let attached = attached_integration_ids(item, id)?;
494 Ok(LiveAgentPolicy {
495 spec,
496 agents,
497 attached,
498 })
499}
500
501fn attached_integration_ids(item: &Map<String, Value>, id: &str) -> Result<Vec<String>> {
502 let entries = item
503 .get("package_policies")
504 .ok_or_else(|| {
505 http(format!(
506 "decoding agent policy '{id}': missing package_policies"
507 ))
508 })?
509 .as_array()
510 .ok_or_else(|| {
511 http(format!(
512 "decoding agent policy '{id}': package_policies must be an array"
513 ))
514 })?;
515 let mut ids = Vec::with_capacity(entries.len());
516 for entry in entries {
517 let attached_id = match entry {
518 Value::String(attached_id) => Some(attached_id.as_str()),
519 Value::Object(object) => object.get("id").and_then(Value::as_str),
520 _ => None,
521 }
522 .filter(|attached_id| !attached_id.is_empty())
523 .ok_or_else(|| {
524 http(format!(
525 "decoding agent policy '{id}': package_policies entry without id"
526 ))
527 })?;
528 ids.push(attached_id.to_owned());
529 }
530 ids.sort();
531 if ids.windows(2).any(|ids| ids[0] == ids[1]) {
532 return Err(http(format!(
533 "decoding agent policy '{id}': duplicate package_policies id"
534 )));
535 }
536 Ok(ids)
537}
538
539fn required_agents(item: &Map<String, Value>, id: &str) -> Result<u64> {
540 match item.get("agents") {
541 None => Err(Error::new(
542 ErrorKind::Permission,
543 format!(
544 "agent policy '{id}' has no agents count; the API key lacks the Fleet agents read privilege"
545 ),
546 )),
547 Some(value) => value.as_u64().ok_or_else(|| {
548 http(format!(
549 "decoding agent policy '{id}': agents must be an unsigned integer"
550 ))
551 }),
552 }
553}
554
555fn http(message: impl Into<String>) -> Error {
556 Error::new(ErrorKind::Http, message)
557}