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