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
15const PAGE_SIZE: u64 = 1000;
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
18pub struct AgentPolicyFilter {
19 pub search: Option<String>,
20 pub limit: Option<usize>,
21}
22
23#[derive(Debug, Clone, PartialEq, Serialize)]
24pub struct AgentPolicyList {
25 pub total: u64,
26 pub agent_policies: Vec<AgentPolicySummary>,
27 pub truncated: bool,
28}
29
30#[derive(Debug, Clone, PartialEq)]
31struct ResolvedAgentPolicy {
32 summary: AgentPolicySummary,
33 item: Map<String, Value>,
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub struct LiveAgentPolicy {
39 pub spec: AgentPolicySpec,
40 pub agents: u64,
41 pub attached: Vec<String>,
42}
43
44#[derive(Debug, Clone, PartialEq, Eq)]
49pub(crate) struct AgentPolicyParentSnapshot {
50 pub id: String,
51 pub name: String,
52 pub namespace: String,
53 pub agents: u64,
54 pub attached_integrations: Vec<String>,
55 pub platform_owned: bool,
56 pub protected: bool,
57}
58
59pub async fn collect(transport: &Transport) -> Result<Vec<Map<String, Value>>> {
61 let mut page_number = 1;
62 let mut total = None;
63 let mut items = Vec::new();
64 let mut ids = BTreeSet::new();
65 loop {
66 let page = agent_policies::list_page(transport, page_number).await?;
67 if page.page != page_number || page.per_page != PAGE_SIZE {
68 return Err(http(
69 "decoding agent policies list: unexpected page metadata",
70 ));
71 }
72 match total {
73 Some(total) if total != page.total => {
74 return Err(http(
75 "decoding agent policies list: total changed while paging",
76 ));
77 }
78 Some(_) => {}
79 None => total = Some(page.total),
80 }
81 let page_len = page.items.len() as u64;
82 for item in page.items {
83 let id = item
84 .get("id")
85 .and_then(Value::as_str)
86 .filter(|id| !id.is_empty())
87 .ok_or_else(|| http("decoding agent policies list: item without id"))?
88 .to_owned();
89 if !ids.insert(id.clone()) {
90 return Err(http(format!(
91 "decoding agent policies list: duplicate agent policy id '{id}'"
92 )));
93 }
94 items.push(item);
95 }
96 let expected = total.expect("set from the first page");
97 if items.len() as u64 >= expected {
98 break;
99 }
100 if page_len != PAGE_SIZE {
101 return Err(http(
102 "decoding agent policies list: page was short before total",
103 ));
104 }
105 page_number += 1;
106 }
107 if items.len() as u64 > total.unwrap_or(0) {
108 return Err(http(
109 "decoding agent policies list: returned more items than total",
110 ));
111 }
112 items.sort_by(|left, right| left["id"].as_str().cmp(&right["id"].as_str()));
113 Ok(items)
114}
115
116pub async fn list_op(transport: &Transport, filter: &AgentPolicyFilter) -> Result<AgentPolicyList> {
117 let items = collect(transport).await?;
118 let total = items.len() as u64;
119 let needle = filter.search.as_ref().map(|search| search.to_lowercase());
120 let mut rows = Vec::new();
121 for item in &items {
122 let summary = AgentPolicySummary::from_item(item)?;
123 let keep = needle.as_ref().is_none_or(|needle| {
124 summary.id.to_lowercase().contains(needle)
125 || summary.name.to_lowercase().contains(needle)
126 });
127 if keep {
128 rows.push(summary);
129 }
130 }
131 let limit = filter.limit.unwrap_or(usize::MAX);
132 let truncated = rows.len() > limit;
133 rows.truncate(limit);
134 Ok(AgentPolicyList {
135 total,
136 agent_policies: rows,
137 truncated,
138 })
139}
140
141pub async fn resolve(transport: &Transport, selector: &str) -> Result<AgentPolicySummary> {
143 Ok(resolve_item(transport, selector).await?.summary)
144}
145
146async fn resolve_item(transport: &Transport, selector: &str) -> Result<ResolvedAgentPolicy> {
149 match agent_policies::get(transport, selector).await {
150 Ok(policy) => {
151 return Ok(ResolvedAgentPolicy {
152 summary: AgentPolicySummary::from_item(&policy.item)?,
153 item: policy.item,
154 });
155 }
156 Err(error) if error.kind == ErrorKind::NotFound => {}
157 Err(error) => return Err(error),
158 }
159 let items = collect(transport).await?;
160 let matches: Vec<AgentPolicySummary> = items
161 .iter()
162 .filter(|item| item.get("name").and_then(Value::as_str) == Some(selector))
163 .map(AgentPolicySummary::from_item)
164 .collect::<Result<_>>()?;
165 match matches.as_slice() {
166 [] => Err(Error::new(
167 ErrorKind::NotFound,
168 format!("no agent policy with id or name '{selector}'"),
169 )),
170 [one] => {
171 let policy = agent_policies::get(transport, &one.id).await?;
172 Ok(ResolvedAgentPolicy {
173 summary: one.clone(),
174 item: policy.item,
175 })
176 }
177 many => Err(Error::new(
178 ErrorKind::Conflict,
179 format!(
180 "agent policy '{selector}' is ambiguous: {}",
181 many.iter()
182 .map(|row| row.id.as_str())
183 .collect::<Vec<_>>()
184 .join(", ")
185 ),
186 )),
187 }
188}
189
190pub async fn get_op(transport: &Transport, selector: &str) -> Result<AgentPolicyDetail> {
191 let ResolvedAgentPolicy { summary, item } = resolve_item(transport, selector).await?;
192 let agents = required_agents(&item, &summary.id)?;
193 let attached_integrations = attached_integration_ids(&item, &summary.id)?;
194 let status = match item.get("status") {
195 None | Some(Value::Null) => None,
196 Some(Value::String(status)) => Some(status.clone()),
197 Some(_) => {
198 return Err(http(format!(
199 "decoding agent policy '{}': status must be a string or null",
200 summary.id
201 )));
202 }
203 };
204 let blocked_by = portability_reasons(&item, transport.space())?
205 .into_iter()
206 .map(str::to_owned)
207 .collect();
208 Ok(AgentPolicyDetail {
209 id: summary.id,
210 name: summary.name,
211 namespace: summary.namespace,
212 description: summary.description,
213 agents,
214 status,
215 attached_integrations,
216 blocked_by,
217 })
218}
219
220pub fn is_platform_owned(item: &Map<String, Value>) -> Result<bool> {
224 for flag in PLATFORM_FLAGS {
225 if optional_server_bool(item, flag)? == Some(true) {
226 return Ok(true);
227 }
228 }
229 match item.get(AGENTLESS_FIELD) {
230 None | Some(Value::Null) => Ok(false),
231 Some(Value::Object(_)) => Ok(true),
232 Some(_) => Err(http(
233 "decoding agent policy: agentless must be an object or null",
234 )),
235 }
236}
237
238pub(crate) async fn read_parent_snapshot(
242 transport: &Transport,
243 id: &str,
244) -> Result<AgentPolicyParentSnapshot> {
245 let policy = agent_policies::get(transport, id).await?;
246 let item = &policy.item;
247 let returned_id = item
248 .get("id")
249 .and_then(Value::as_str)
250 .filter(|value| !value.trim().is_empty())
251 .ok_or_else(|| http("decoding agent policy parent: id must be a non-empty string"))?;
252 if returned_id != id {
253 return Err(http(format!(
254 "decoding agent policy parent: expected id '{id}', got '{returned_id}'"
255 )));
256 }
257 let name = item
258 .get("name")
259 .and_then(Value::as_str)
260 .filter(|value| !value.trim().is_empty())
261 .ok_or_else(|| {
262 http(format!(
263 "decoding agent policy '{id}': name must be a non-empty string"
264 ))
265 })?
266 .to_owned();
267 let namespace = item
268 .get("namespace")
269 .and_then(Value::as_str)
270 .filter(|value| !value.trim().is_empty())
271 .ok_or_else(|| {
272 http(format!(
273 "decoding agent policy '{id}': namespace must be a non-empty string"
274 ))
275 })?
276 .to_owned();
277 Ok(AgentPolicyParentSnapshot {
278 id: returned_id.to_owned(),
279 name,
280 namespace,
281 agents: required_agents(item, id)?,
282 attached_integrations: attached_integration_ids(item, id)?,
283 platform_owned: is_platform_owned(item)?,
284 protected: optional_server_bool(item, "is_protected")?.unwrap_or(false),
285 })
286}
287
288const PORTABLE_OPTIONAL: [&str; 13] = [
289 "description",
290 "inactivity_timeout",
291 "unenroll_timeout",
292 "monitoring_enabled",
293 "agent_features",
294 "global_data_tags",
295 "advanced_settings",
296 "overrides",
297 "keep_monitoring_alive",
298 "monitoring_pprof_enabled",
299 "monitoring_http",
300 "monitoring_diagnostics",
301 "namespace",
302];
303
304const REMOVED_FIELDS: [&str; 31] = [
310 "agentless",
311 "agents",
312 "agents_per_version",
313 "created_at",
314 "created_by",
315 "data_output_id",
316 "download_source_id",
317 "fips_agents",
318 "fleet_server_host_id",
319 "has_agent_version_conditions",
320 "has_fleet_server",
321 "is_default",
322 "is_default_fleet_server",
323 "is_managed",
324 "is_preconfigured",
325 "is_protected",
326 "is_verifier",
327 "min_agent_version",
328 "monitoring_output_id",
329 "package_agent_version_conditions",
330 "package_policies",
331 "required_versions",
332 "revision",
333 "schema_version",
334 "space_ids",
335 "status",
336 "supports_agentless",
337 "unprivileged_agents",
338 "updated_at",
339 "updated_by",
340 "version",
341];
342
343pub fn normalize(item: &Map<String, Value>, active_space: &str) -> Result<AgentPolicySpec> {
345 let id = item
346 .get("id")
347 .and_then(Value::as_str)
348 .ok_or_else(|| http("decoding agent policy: expected string id"))?;
349 let reasons = portability_reasons(item, active_space)?;
350 if !reasons.is_empty() {
351 return Err(Error::new(
352 ErrorKind::Unsupported,
353 format!(
354 "agent policy '{id}' is not portable: {}",
355 reasons.into_iter().collect::<Vec<_>>().join(", ")
356 ),
357 ));
358 }
359
360 let mut portable = Map::new();
361 for key in ["id", "name"] {
362 if let Some(value) = item.get(key) {
363 portable.insert(key.to_string(), value.clone());
364 }
365 }
366 for key in PORTABLE_OPTIONAL {
367 if let Some(value) = item.get(key)
368 && !value.is_null()
369 {
370 portable.insert(key.to_string(), value.clone());
371 }
372 }
373 let known: BTreeSet<&str> = ["id", "name"]
374 .into_iter()
375 .chain(PORTABLE_OPTIONAL)
376 .chain(REMOVED_FIELDS)
377 .collect();
378 let unknown: BTreeSet<&str> = item
379 .keys()
380 .map(String::as_str)
381 .filter(|key| !known.contains(key))
382 .collect();
383 if let Some(first) = unknown.into_iter().next() {
384 return Err(Error::new(
385 ErrorKind::Unsupported,
386 format!("agent policy '{id}' carries unknown field '{first}'"),
387 ));
388 }
389 AgentPolicySpec::try_from(Value::Object(portable))
390 .map_err(|error| http(format!("decoding agent policy '{id}': {}", error.message)))
391}
392
393fn portability_reasons(
394 item: &Map<String, Value>,
395 active_space: &str,
396) -> Result<BTreeSet<&'static str>> {
397 let active = if active_space.is_empty() {
398 "default"
399 } else {
400 active_space
401 };
402 let mut reasons = BTreeSet::new();
403 for flag in PLATFORM_FLAGS.into_iter().chain(["is_protected"]) {
404 if optional_server_bool(item, flag)? == Some(true) {
405 reasons.insert(flag);
406 }
407 }
408 match item.get(AGENTLESS_FIELD) {
409 None | Some(Value::Null) => {}
410 Some(Value::Object(_)) => {
411 reasons.insert(AGENTLESS_FIELD);
412 }
413 Some(_) => {
414 return Err(http(
415 "decoding agent policy: agentless must be an object or null",
416 ));
417 }
418 }
419 for field in ENVIRONMENT_IDS {
420 match item.get(field) {
421 None | Some(Value::Null) => {}
422 Some(Value::String(value)) if !value.trim().is_empty() => {
423 reasons.insert(field);
424 }
425 Some(_) => {
426 return Err(http(format!(
427 "decoding agent policy: {field} must be a non-empty string or null"
428 )));
429 }
430 }
431 }
432 match item.get("required_versions") {
433 None | Some(Value::Null) => {}
434 Some(Value::Array(_)) => {
435 reasons.insert("required_versions");
436 }
437 Some(_) => {
438 return Err(http(
439 "decoding agent policy: required_versions must be an array or null",
440 ));
441 }
442 }
443 match item.get("space_ids") {
444 None | Some(Value::Null) => {}
445 Some(Value::Array(spaces)) => {
446 let decoded =
447 spaces
448 .iter()
449 .map(|space| {
450 space.as_str().filter(|space| !space.is_empty()).ok_or_else(|| {
451 http("decoding agent policy: space_ids must contain non-empty strings")
452 })
453 })
454 .collect::<Result<Vec<_>>>()?;
455 if decoded.iter().any(|space| *space != active) {
456 reasons.insert("space_ids");
457 }
458 }
459 Some(_) => {
460 return Err(http(
461 "decoding agent policy: space_ids must be an array or null",
462 ));
463 }
464 }
465 Ok(reasons)
466}
467
468fn optional_server_bool(item: &Map<String, Value>, field: &str) -> Result<Option<bool>> {
469 match item.get(field) {
470 None | Some(Value::Null) => Ok(None),
471 Some(Value::Bool(value)) => Ok(Some(*value)),
472 Some(_) => Err(http(format!(
473 "decoding agent policy: {field} must be a boolean or null"
474 ))),
475 }
476}
477
478pub(crate) async fn read_live(transport: &Transport, id: &str) -> Result<LiveAgentPolicy> {
480 let policy = agent_policies::get(transport, id).await?;
481 live_from_policy(&policy, id, transport.space())
482}
483
484fn live_from_policy(
490 policy: &agent_policies::AgentPolicy,
491 id: &str,
492 active_space: &str,
493) -> Result<LiveAgentPolicy> {
494 live_from_item(&policy.item, id, active_space)
495}
496
497fn live_from_item(
498 item: &Map<String, Value>,
499 id: &str,
500 active_space: &str,
501) -> Result<LiveAgentPolicy> {
502 let spec = normalize(item, active_space)?;
503 if spec.id != id {
504 return Err(http(format!(
505 "decoding agent policy: expected id '{id}', got '{}'",
506 spec.id
507 )));
508 }
509 let agents = required_agents(item, id)?;
510 let attached = attached_integration_ids(item, id)?;
511 Ok(LiveAgentPolicy {
512 spec,
513 agents,
514 attached,
515 })
516}
517
518fn attached_integration_ids(item: &Map<String, Value>, id: &str) -> Result<Vec<String>> {
520 let entries = item
521 .get("package_policies")
522 .ok_or_else(|| {
523 http(format!(
524 "decoding agent policy '{id}': missing package_policies"
525 ))
526 })?
527 .as_array()
528 .ok_or_else(|| {
529 http(format!(
530 "decoding agent policy '{id}': package_policies must be an array"
531 ))
532 })?;
533 let mut ids = Vec::with_capacity(entries.len());
534 for entry in entries {
535 let attached_id = match entry {
536 Value::String(attached_id) => Some(attached_id.as_str()),
537 Value::Object(object) => object.get("id").and_then(Value::as_str),
538 _ => None,
539 }
540 .filter(|attached_id| !attached_id.is_empty())
541 .ok_or_else(|| {
542 http(format!(
543 "decoding agent policy '{id}': package_policies entry without id"
544 ))
545 })?;
546 ids.push(attached_id.to_owned());
547 }
548 ids.sort();
549 if ids.windows(2).any(|ids| ids[0] == ids[1]) {
550 return Err(http(format!(
551 "decoding agent policy '{id}': duplicate package_policies id"
552 )));
553 }
554 Ok(ids)
555}
556
557fn required_agents(item: &Map<String, Value>, id: &str) -> Result<u64> {
560 match item.get("agents") {
561 None => Err(Error::new(
562 ErrorKind::Permission,
563 format!(
564 "agent policy '{id}' has no agents count; the API key lacks the Fleet agents read privilege"
565 ),
566 )),
567 Some(value) => value.as_u64().ok_or_else(|| {
568 http(format!(
569 "decoding agent policy '{id}': agents must be an unsigned integer"
570 ))
571 }),
572 }
573}
574
575pub fn validate(path: &Path) -> Result<Vec<AgentPolicySpec>> {
577 let body = std::fs::read_to_string(path).map_err(|error| {
578 Error::new(
579 ErrorKind::Error,
580 format!("reading {}: {error}", path.display()),
581 )
582 })?;
583 let mut specs = content_codec::decode_sequence::<AgentPolicySpec>(
584 &body,
585 ContentFormat::from_path(path),
586 "agent policy",
587 )?;
588 let mut seen_ids = BTreeSet::new();
589 let mut duplicate_ids = BTreeSet::new();
590 let mut seen_names = BTreeSet::new();
591 let mut duplicate_names = BTreeSet::new();
592 for spec in &specs {
593 if !seen_ids.insert(spec.id.as_str()) {
594 duplicate_ids.insert(spec.id.as_str());
595 }
596 if !seen_names.insert(spec.name.as_str()) {
597 duplicate_names.insert(spec.name.as_str());
598 }
599 }
600 if !duplicate_ids.is_empty() {
601 return Err(Error::new(
602 ErrorKind::Error,
603 format!(
604 "duplicate agent policy ids: {}",
605 duplicate_ids.into_iter().collect::<Vec<_>>().join(", ")
606 ),
607 ));
608 }
609 if !duplicate_names.is_empty() {
610 return Err(Error::new(
611 ErrorKind::Error,
612 format!(
613 "duplicate agent policy names: {}",
614 duplicate_names.into_iter().collect::<Vec<_>>().join(", ")
615 ),
616 ));
617 }
618 specs.sort_by(|left, right| left.id.cmp(&right.id));
619 Ok(specs)
620}
621
622pub async fn export(
624 transport: &Transport,
625 selectors: &[String],
626 all_custom: bool,
627 format: ContentFormat,
628) -> Result<ExportOutcome> {
629 if selectors.is_empty() && !all_custom {
630 return Err(Error::new(
631 ErrorKind::Error,
632 "agent-policy export needs selectors or --all-custom",
633 ));
634 }
635 if !selectors.is_empty() && all_custom {
636 return Err(Error::new(
637 ErrorKind::Error,
638 "--all-custom cannot be combined with selectors",
639 ));
640 }
641 let mut resolved_items = BTreeMap::new();
642 let ids: BTreeSet<String> = if all_custom {
643 let mut ids = BTreeSet::new();
644 for item in collect(transport).await? {
645 if !is_platform_owned(&item)? {
646 ids.insert(AgentPolicySummary::from_item(&item)?.id);
647 }
648 }
649 ids
650 } else {
651 let mut ids = BTreeSet::new();
652 for selector in selectors {
653 let resolved = resolve_item(transport, selector).await?;
654 ids.insert(resolved.summary.id.clone());
655 resolved_items.insert(resolved.summary.id, resolved.item);
656 }
657 ids
658 };
659 let mut specs = Vec::with_capacity(ids.len());
660 for id in &ids {
661 let live = match resolved_items.get(id) {
662 Some(item) => live_from_item(item, id, transport.space())?,
663 None => read_live(transport, id).await?,
664 };
665 specs.push(live.spec);
666 }
667 specs.sort_by(|left, right| left.id.cmp(&right.id));
668 let body = content_codec::encode_sequence(&specs, format)?;
669 Ok(ExportOutcome {
670 body,
671 exported: specs.len() as u64,
672 missing: Vec::new(),
673 })
674}
675
676#[derive(Debug, Clone, PartialEq)]
680pub struct AgentPolicyImportPlan {
681 pub preview: MutationPlan,
682 pub skipped: Vec<Value>,
683 pub package_installs: Vec<String>,
684 pub total: usize,
685 source: std::path::PathBuf,
686 specs: Vec<AgentPolicySpec>,
687 before: BTreeMap<String, Option<LiveAgentPolicy>>,
688 bodies: BTreeMap<String, Value>,
689 monitoring_package: Option<agent_policies::PackageStatus>,
690 overwrite: bool,
691}
692
693#[derive(Debug, Clone, PartialEq, Serialize)]
694pub struct AgentPolicyImportReport {
695 pub applied: bool,
696 pub succeeded: Vec<Value>,
697 pub unchanged: Vec<Value>,
698 pub skipped: Vec<Value>,
699 pub failed: Vec<Value>,
700 pub total: usize,
701 pub affected_agents: u64,
702 pub package_installs: Vec<String>,
703}
704
705const MONITORING_PACKAGE: &str = "elastic_agent";
706const SERVER_SELECTED_INSTALL: &str = "elastic_agent@server-selected";
707
708pub fn build_replace_body(current: &AgentPolicySpec, desired: &AgentPolicySpec) -> Result<Value> {
710 current.validate()?;
711 desired.validate()?;
712 if current.id != desired.id {
713 return unsupported(
714 "changing agent policy id is not supported by the agent-policy update API",
715 );
716 }
717 let removed = [
718 (
719 "description",
720 current.description.is_some() && desired.description.is_none(),
721 ),
722 (
723 "unenroll_timeout",
724 current.unenroll_timeout.is_some() && desired.unenroll_timeout.is_none(),
725 ),
726 (
727 "monitoring_pprof_enabled",
728 current.monitoring_pprof_enabled.is_some()
729 && desired.monitoring_pprof_enabled.is_none(),
730 ),
731 (
732 "advanced_settings",
733 current.advanced_settings.is_some() && desired.advanced_settings.is_none(),
734 ),
735 (
736 "monitoring_http",
737 current.monitoring_http.is_some() && desired.monitoring_http.is_none(),
738 ),
739 (
740 "monitoring_diagnostics",
741 current.monitoring_diagnostics.is_some() && desired.monitoring_diagnostics.is_none(),
742 ),
743 ];
744 if let Some((field, _)) = removed.iter().find(|(_, gone)| *gone) {
745 return unsupported(format!(
746 "removing {field} is not supported by the agent-policy update API"
747 ));
748 }
749 let mut body = serde_json::to_value(desired)
752 .map_err(|error| Error::new(ErrorKind::Error, format!("encoding agent policy: {error}")))?
753 .as_object()
754 .cloned()
755 .expect("specs serialize to objects");
756 body.remove("id");
757 if current.overrides.is_some() && desired.overrides.is_none() {
758 body.insert("overrides".into(), Value::Null);
759 }
760 if current.keep_monitoring_alive.is_some() && desired.keep_monitoring_alive.is_none() {
761 body.insert("keep_monitoring_alive".into(), Value::Null);
762 }
763 Ok(Value::Object(body))
764}
765
766fn unsupported<T>(message: impl Into<String>) -> Result<T> {
767 Err(Error::new(ErrorKind::Unsupported, message))
768}
769
770pub async fn plan_import(
771 transport: &Transport,
772 path: &Path,
773 overwrite: bool,
774 skip_existing: bool,
775) -> Result<AgentPolicyImportPlan> {
776 let mut specs = validate(path)?;
777 if specs.is_empty() {
778 return Err(Error::new(
779 ErrorKind::Error,
780 "agent-policy import needs at least one agent policy",
781 ));
782 }
783 if overwrite && skip_existing {
784 return Err(Error::new(
785 ErrorKind::Error,
786 "--overwrite and --skip-existing cannot be used together",
787 ));
788 }
789 let total = specs.len();
790
791 let mut before_raw = BTreeMap::new();
795 let mut conflicts = Vec::new();
796 for spec in &specs {
797 match agent_policies::get(transport, &spec.id).await {
798 Ok(policy) => {
799 if !overwrite && !skip_existing {
800 conflicts.push(spec.id.clone());
801 }
802 before_raw.insert(spec.id.clone(), Some(policy));
803 }
804 Err(error) if error.kind == ErrorKind::NotFound => {
805 before_raw.insert(spec.id.clone(), None);
806 }
807 Err(error) => return Err(error),
808 }
809 }
810 let mut live_names = BTreeMap::new();
812 for item in collect(transport).await? {
813 let row = AgentPolicySummary::from_item(&item)?;
814 if live_names
815 .insert(row.name.clone(), row.id.clone())
816 .is_some()
817 {
818 return Err(http(format!(
819 "decoding agent policies list: duplicate name '{}'",
820 row.name
821 )));
822 }
823 }
824 let taken: Vec<String> = specs
825 .iter()
826 .filter_map(|spec| {
827 live_names
828 .get(&spec.name)
829 .filter(|owner| **owner != spec.id)
830 .map(|owner| format!("{} ({owner})", spec.name))
831 })
832 .collect();
833 if !taken.is_empty() {
834 return Err(Error::new(
835 ErrorKind::Conflict,
836 format!("agent policy names already exist: {}", taken.join(", ")),
837 ));
838 }
839 if !conflicts.is_empty() {
840 return Err(Error::new(
841 ErrorKind::Conflict,
842 format!("agent policies already exist: {}", conflicts.join(", ")),
843 ));
844 }
845 let mut skipped = Vec::new();
846 if skip_existing {
847 specs.retain(|spec| match before_raw.get(&spec.id) {
848 Some(Some(_)) => {
849 skipped.push(json!({"id": spec.id, "reason": "exists"}));
850 false
851 }
852 _ => true,
853 });
854 before_raw.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
855 }
856
857 let mut before = BTreeMap::new();
863 for (id, raw) in before_raw {
864 let live = match raw {
865 Some(policy) => Some(live_from_policy(&policy, &id, transport.space())?),
866 None => None,
867 };
868 before.insert(id, live);
869 }
870
871 let mut bodies = BTreeMap::new();
872 for spec in &specs {
873 if let Some(Some(current)) = before.get(&spec.id)
874 && current.spec != *spec
875 {
876 bodies.insert(spec.id.clone(), build_replace_body(¤t.spec, spec)?);
877 }
878 }
879
880 let mut package_installs = Vec::new();
881 let needs_monitoring = specs
882 .iter()
883 .any(|spec| monitoring_can_install(before.get(&spec.id).and_then(Option::as_ref), spec));
884 let monitoring_package = if needs_monitoring {
885 let status = agent_policies::package_status(transport, MONITORING_PACKAGE).await?;
886 if status.status != "installed" {
887 package_installs.push(SERVER_SELECTED_INSTALL.to_string());
888 }
889 Some(status)
890 } else {
891 None
892 };
893
894 let preview = MutationPlan {
895 preview_action: format!(
896 "Import {} agent policy(ies) from {}",
897 specs.len(),
898 path.display()
899 ),
900 preview_details: import_details(&specs, &before, &package_installs),
901 targets: specs.iter().map(|spec| spec.id.clone()).collect(),
902 };
903 Ok(AgentPolicyImportPlan {
904 preview,
905 skipped,
906 package_installs,
907 total,
908 source: path.to_path_buf(),
909 specs,
910 before,
911 bodies,
912 monitoring_package,
913 overwrite,
914 })
915}
916
917fn monitoring_can_install(current: Option<&LiveAgentPolicy>, desired: &AgentPolicySpec) -> bool {
918 !desired.monitoring_enabled.is_empty()
919 && current.is_none_or(|current| current.spec.monitoring_enabled.is_empty())
920}
921
922fn import_details(
923 specs: &[AgentPolicySpec],
924 before: &BTreeMap<String, Option<LiveAgentPolicy>>,
925 package_installs: &[String],
926) -> Vec<String> {
927 let mut details: Vec<String> = specs
928 .iter()
929 .filter_map(|spec| match before.get(&spec.id) {
930 Some(None) => Some(format!("{} create {}", spec.id, spec.name)),
931 Some(Some(current)) if current.spec == *spec => {
932 Some(format!("{} unchanged {}", spec.id, spec.name))
933 }
934 Some(Some(current)) => {
935 let name = if current.spec.name == spec.name {
936 spec.name.clone()
937 } else {
938 format!("{} -> {}", current.spec.name, spec.name)
939 };
940 Some(format!(
941 "{} replace {name} agents {}",
942 spec.id, current.agents
943 ))
944 }
945 None => None,
946 })
947 .collect();
948 details.extend(
949 package_installs
950 .iter()
951 .map(|install| format!("package install {install}")),
952 );
953 details
954}
955
956pub async fn apply_import(
957 transport: &Transport,
958 plan: &AgentPolicyImportPlan,
959) -> Result<AgentPolicyImportReport> {
960 validate_import_plan(plan)?;
961 let mut succeeded = Vec::new();
962 let mut unchanged = Vec::new();
963 let mut failed = Vec::new();
964 let mut affected_agents = 0;
965 let mut expected_package = plan.monitoring_package.clone();
966 let mut package_installs = Vec::new();
967
968 for desired in &plan.specs {
969 let Some(before) = plan.before.get(&desired.id) else {
970 failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
971 continue;
972 };
973 let current = match read_live(transport, &desired.id).await {
974 Ok(live) => Some(live),
975 Err(error) if error.kind == ErrorKind::NotFound => None,
976 Err(error) => {
977 failed.push(failed_row(&desired.id, false, error.message));
978 continue;
979 }
980 };
981 match (before, current) {
982 (None, Some(_)) => failed.push(failed_row(
983 &desired.id,
984 false,
985 "agent policy appeared since preview",
986 )),
987 (Some(_), None) => failed.push(failed_row(
988 &desired.id,
989 false,
990 "agent policy disappeared since preview",
991 )),
992 (Some(before), Some(live)) if before != &live => failed.push(failed_row(
993 &desired.id,
994 false,
995 "agent policy changed since preview",
996 )),
997 (before, current) => {
998 let package_can_change = monitoring_can_install(before.as_ref(), desired);
999 if package_can_change {
1000 let Some(expected) = expected_package.as_ref() else {
1001 failed.push(failed_row(
1002 &desired.id,
1003 false,
1004 "missing monitoring package snapshot",
1005 ));
1006 continue;
1007 };
1008 match agent_policies::package_status(transport, MONITORING_PACKAGE).await {
1009 Ok(actual) if actual == *expected => {}
1010 Ok(_) => {
1011 failed.push(failed_row(
1012 &desired.id,
1013 false,
1014 "elastic_agent package changed since preview",
1015 ));
1016 continue;
1017 }
1018 Err(error) => {
1019 failed.push(failed_row(&desired.id, false, error.message));
1020 continue;
1021 }
1022 }
1023 }
1024
1025 let (action, applied, route_error) = match (before, current) {
1026 (None, None) => {
1027 match other_owner_of_name(transport, &desired.name).await {
1028 Ok(Some(owner)) => {
1029 failed.push(failed_row(
1030 &desired.id,
1031 false,
1032 format!(
1033 "agent policy name appeared since preview: {} ({owner})",
1034 desired.name
1035 ),
1036 ));
1037 continue;
1038 }
1039 Ok(None) => {}
1040 Err(error) => {
1041 failed.push(failed_row(&desired.id, false, error));
1042 continue;
1043 }
1044 }
1045 match agent_policies::create(transport, desired).await {
1046 Ok(_) => ("created", true, None),
1047 Err(error) => ("created", false, Some(error.message)),
1048 }
1049 }
1050 (Some(before), Some(_)) if before.spec == *desired => {
1051 unchanged.push(json!({"id": desired.id}));
1052 continue;
1053 }
1054 (Some(before), Some(_)) => {
1055 let body = plan
1056 .bodies
1057 .get(&desired.id)
1058 .expect("validated replacement body");
1059 match agent_policies::update(transport, &desired.id, body).await {
1060 Ok(_) => {
1061 affected_agents += before.agents;
1062 ("replaced", true, None)
1063 }
1064 Err(error) => ("replaced", false, Some(error.message)),
1065 }
1066 }
1067 _ => unreachable!("appearance and disappearance handled above"),
1068 };
1069
1070 let stored_error = if applied {
1071 verify_stored(transport, desired).await.err()
1072 } else {
1073 None
1074 };
1075 let package_error = if package_can_change {
1076 let expected = expected_package
1077 .as_ref()
1078 .expect("validated package snapshot");
1079 match observe_package_after_write(transport, expected).await {
1080 Ok((after, installed)) => {
1081 expected_package = Some(after);
1082 if let Some(installed) = installed
1083 && !package_installs.contains(&installed)
1084 {
1085 package_installs.push(installed);
1086 }
1087 None
1088 }
1089 Err(error) => Some(error),
1090 }
1091 } else {
1092 None
1093 };
1094
1095 let errors = [route_error, stored_error, package_error]
1096 .into_iter()
1097 .flatten()
1098 .collect::<Vec<_>>();
1099 if errors.is_empty() {
1100 succeeded.push(json!({"id": desired.id, "action": action}));
1101 } else {
1102 failed.push(failed_row(&desired.id, applied, errors.join("; ")));
1103 }
1104 }
1105 }
1106 }
1107 Ok(AgentPolicyImportReport {
1108 applied: true,
1109 succeeded,
1110 unchanged,
1111 skipped: plan.skipped.clone(),
1112 failed,
1113 total: plan.total,
1114 affected_agents,
1115 package_installs,
1116 })
1117}
1118
1119async fn verify_stored(
1120 transport: &Transport,
1121 desired: &AgentPolicySpec,
1122) -> std::result::Result<(), String> {
1123 match read_live(transport, &desired.id).await {
1124 Ok(live) if live.spec == *desired => Ok(()),
1125 Ok(_) => Err("server stored a different agent-policy spec".into()),
1126 Err(error) => Err(error.message),
1127 }
1128}
1129
1130async fn other_owner_of_name(
1135 transport: &Transport,
1136 name: &str,
1137) -> std::result::Result<Option<String>, String> {
1138 let items = collect(transport).await.map_err(|error| error.message)?;
1139 for item in &items {
1140 let row = AgentPolicySummary::from_item(item).map_err(|error| error.message)?;
1141 if row.name == name {
1142 return Ok(Some(row.id));
1143 }
1144 }
1145 Ok(None)
1146}
1147
1148async fn observe_package_after_write(
1153 transport: &Transport,
1154 before: &agent_policies::PackageStatus,
1155) -> std::result::Result<(agent_policies::PackageStatus, Option<String>), String> {
1156 let after = agent_policies::package_status(transport, MONITORING_PACKAGE)
1157 .await
1158 .map_err(|error| error.message)?;
1159 if before.status != "installed" && after.status == "installed" {
1160 let version = after
1161 .installed_version
1162 .clone()
1163 .expect("decoder requires installed version");
1164 return Ok((after, Some(format!("{MONITORING_PACKAGE}@{version}"))));
1165 }
1166 Ok((after, None))
1167}
1168
1169fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
1170 json!({"id": id, "applied": applied, "error": error.into()})
1171}
1172
1173fn validate_import_plan(plan: &AgentPolicyImportPlan) -> Result<()> {
1174 let invalid = |message: &str| {
1175 Err(Error::new(
1176 ErrorKind::Error,
1177 format!("invalid agent-policy import plan: {message}"),
1178 ))
1179 };
1180 if plan.total == 0 || plan.total != plan.specs.len() + plan.skipped.len() {
1181 return invalid("total does not equal pending and skipped agent policies");
1182 }
1183 let mut previous_id: Option<&str> = None;
1184 let mut names = BTreeSet::new();
1185 let mut expected_body_ids = BTreeSet::new();
1186 for spec in &plan.specs {
1187 spec.validate()?;
1188 if previous_id.is_some_and(|previous| previous >= spec.id.as_str()) {
1189 return invalid("pending agent policies must be unique and sorted by id");
1190 }
1191 previous_id = Some(&spec.id);
1192 if !names.insert(spec.name.as_str()) {
1193 return invalid("pending agent-policy names must be unique");
1194 }
1195 let Some(before) = plan.before.get(&spec.id) else {
1196 return invalid("preflight snapshots do not match pending agent policies");
1197 };
1198 if let Some(current) = before {
1199 current.spec.validate()?;
1200 if current.spec.id != spec.id {
1201 return invalid("live snapshot id does not match its pending policy");
1202 }
1203 if current.attached.windows(2).any(|ids| ids[0] >= ids[1]) {
1204 return invalid("attached integration ids must be unique and sorted");
1205 }
1206 }
1207 match before {
1208 None if plan.bodies.contains_key(&spec.id) => {
1209 return invalid("planned creates must not carry a replacement body");
1210 }
1211 None => {}
1212 Some(current) if current.spec == *spec => {
1213 if plan.bodies.contains_key(&spec.id) {
1214 return invalid("unchanged agent policies must not carry a replacement body");
1215 }
1216 }
1217 Some(current) => {
1218 expected_body_ids.insert(spec.id.as_str());
1219 if !plan.overwrite {
1220 return invalid("replacement plan requires overwrite");
1221 }
1222 if plan.bodies.get(&spec.id) != Some(&build_replace_body(¤t.spec, spec)?) {
1223 return invalid("replacement body does not match its snapshots");
1224 }
1225 }
1226 }
1227 }
1228 if plan.before.len() != plan.specs.len() {
1229 return invalid("preflight snapshots do not match pending agent policies");
1230 }
1231 if plan
1232 .bodies
1233 .keys()
1234 .map(String::as_str)
1235 .collect::<BTreeSet<_>>()
1236 != expected_body_ids
1237 {
1238 return invalid("replacement bodies do not match changed agent policies");
1239 }
1240 let mut previous_skipped: Option<&str> = None;
1241 for skipped in &plan.skipped {
1242 let object = skipped.as_object().ok_or_else(|| {
1243 Error::new(
1244 ErrorKind::Error,
1245 "invalid agent-policy import plan: skipped row must be an object",
1246 )
1247 })?;
1248 if object.len() != 2 || object.get("reason").and_then(Value::as_str) != Some("exists") {
1249 return invalid("skipped rows must contain only id and reason exists");
1250 }
1251 let id = object
1252 .get("id")
1253 .and_then(Value::as_str)
1254 .filter(|id| !id.is_empty())
1255 .ok_or_else(|| {
1256 Error::new(
1257 ErrorKind::Error,
1258 "invalid agent-policy import plan: skipped id must be non-empty",
1259 )
1260 })?;
1261 if previous_skipped.is_some_and(|previous| previous >= id) {
1262 return invalid("skipped agent policies must be unique and sorted by id");
1263 }
1264 if plan.before.contains_key(id) {
1265 return invalid("an agent policy cannot be both pending and skipped");
1266 }
1267 previous_skipped = Some(id);
1268 }
1269
1270 let needs_monitoring = plan.specs.iter().any(|spec| {
1271 monitoring_can_install(plan.before.get(&spec.id).and_then(Option::as_ref), spec)
1272 });
1273 match (&plan.monitoring_package, needs_monitoring) {
1274 (Some(status), true) if status.name == MONITORING_PACKAGE => {
1275 let expected = if status.status == "installed" {
1276 Vec::new()
1277 } else {
1278 vec![SERVER_SELECTED_INSTALL.to_string()]
1279 };
1280 if status.status == "installed" && status.installed_version.is_none() {
1281 return invalid("installed monitoring package needs an exact version");
1282 }
1283 if plan.package_installs != expected {
1284 return invalid("monitoring package preview does not match its snapshot");
1285 }
1286 }
1287 (None, false) if plan.package_installs.is_empty() => {}
1288 _ => return invalid("monitoring package snapshot does not match pending transitions"),
1289 }
1290
1291 let expected_preview = MutationPlan {
1292 preview_action: format!(
1293 "Import {} agent policy(ies) from {}",
1294 plan.specs.len(),
1295 plan.source.display()
1296 ),
1297 preview_details: import_details(&plan.specs, &plan.before, &plan.package_installs),
1298 targets: plan.specs.iter().map(|spec| spec.id.clone()).collect(),
1299 };
1300 if plan.preview != expected_preview {
1301 return invalid("preview does not match the canonical plan");
1302 }
1303 Ok(())
1304}
1305
1306fn http(message: impl Into<String>) -> Error {
1307 Error::new(ErrorKind::Http, message)
1308}
1309
1310#[derive(Debug, Clone, PartialEq)]
1311pub struct AgentPolicyDeleteTarget {
1312 pub id: String,
1313 pub name: String,
1314 pub snapshot: LiveAgentPolicy,
1315}
1316
1317#[derive(Debug, Clone, PartialEq)]
1318pub struct AgentPolicyDeletePlan {
1319 pub preview: MutationPlan,
1320 pub targets: Vec<AgentPolicyDeleteTarget>,
1321}
1322
1323#[derive(Debug, Clone, PartialEq, Serialize)]
1324pub struct AgentPolicyDeleteReport {
1325 pub applied: bool,
1326 pub deleted: Vec<Value>,
1327 pub failed: Vec<Value>,
1328 pub total: usize,
1329 pub affected_agents: u64,
1330}
1331
1332pub async fn plan_delete(
1333 transport: &Transport,
1334 selectors: &[String],
1335) -> Result<AgentPolicyDeletePlan> {
1336 if selectors.is_empty() {
1337 return Err(Error::new(
1338 ErrorKind::Error,
1339 "agent-policy delete needs at least one selector",
1340 ));
1341 }
1342 let mut resolved_items = BTreeMap::new();
1343 for selector in selectors {
1344 let resolved = resolve_item(transport, selector).await?;
1345 resolved_items.insert(resolved.summary.id, resolved.item);
1346 }
1347 let mut targets = Vec::new();
1348 let mut conflicts = Vec::new();
1349 for (id, item) in resolved_items {
1350 let live = live_from_item(&item, &id, transport.space())?;
1351 if live.agents > 0 {
1352 conflicts.push(format!(
1353 "agent policy '{id}' has {} assigned agents",
1354 live.agents
1355 ));
1356 }
1357 if !live.attached.is_empty() {
1358 conflicts.push(format!(
1359 "agent policy '{id}' has attached integrations: {}",
1360 live.attached.join(", ")
1361 ));
1362 }
1363 targets.push(AgentPolicyDeleteTarget {
1364 id: id.clone(),
1365 name: live.spec.name.clone(),
1366 snapshot: live,
1367 });
1368 }
1369 if !conflicts.is_empty() {
1370 return Err(Error::new(ErrorKind::Conflict, conflicts.join("; ")));
1371 }
1372 Ok(AgentPolicyDeletePlan {
1373 preview: delete_preview(&targets),
1374 targets,
1375 })
1376}
1377
1378fn delete_preview(targets: &[AgentPolicyDeleteTarget]) -> MutationPlan {
1379 MutationPlan {
1380 preview_action: format!("Delete {} agent policy(ies)", targets.len()),
1381 preview_details: targets
1382 .iter()
1383 .map(|target| {
1384 format!(
1385 "{} {} agents {} integrations {}",
1386 target.id,
1387 target.name,
1388 target.snapshot.agents,
1389 target.snapshot.attached.len()
1390 )
1391 })
1392 .collect(),
1393 targets: targets.iter().map(|target| target.id.clone()).collect(),
1394 }
1395}
1396
1397pub async fn apply_delete(
1398 transport: &Transport,
1399 plan: &AgentPolicyDeletePlan,
1400) -> Result<AgentPolicyDeleteReport> {
1401 validate_delete_plan(plan)?;
1402 let mut deleted = Vec::new();
1403 let mut failed = Vec::new();
1404 for target in &plan.targets {
1405 let live = match read_live(transport, &target.id).await {
1406 Ok(live) => live,
1407 Err(error) if error.kind == ErrorKind::NotFound => {
1408 failed.push(failed_delete_row(
1409 &target.id,
1410 false,
1411 "agent policy disappeared since preview",
1412 ));
1413 continue;
1414 }
1415 Err(error) => {
1416 failed.push(failed_delete_row(&target.id, false, error.message));
1417 continue;
1418 }
1419 };
1420 if live != target.snapshot {
1421 failed.push(failed_delete_row(
1422 &target.id,
1423 false,
1424 "agent policy changed since preview",
1425 ));
1426 continue;
1427 }
1428 match agent_policies::delete(transport, &target.id).await {
1429 Ok(()) => deleted.push(json!({"id": target.id})),
1430 Err(error) => {
1431 let applied =
1435 matches!(error.http_status, Some(status) if (200..300).contains(&status));
1436 failed.push(failed_delete_row(&target.id, applied, error.message));
1437 }
1438 }
1439 }
1440 Ok(AgentPolicyDeleteReport {
1441 applied: true,
1442 deleted,
1443 failed,
1444 total: plan.targets.len(),
1445 affected_agents: 0,
1446 })
1447}
1448
1449fn failed_delete_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
1450 json!({"id": id, "applied": applied, "error": error.into()})
1451}
1452
1453fn validate_delete_plan(plan: &AgentPolicyDeletePlan) -> Result<()> {
1454 if plan.targets.is_empty() || plan.preview != delete_preview(&plan.targets) {
1455 return Err(Error::new(
1456 ErrorKind::Error,
1457 "invalid agent-policy delete plan",
1458 ));
1459 }
1460 let mut previous: Option<&str> = None;
1461 for target in &plan.targets {
1462 target.snapshot.spec.validate()?;
1463 if target.id != target.snapshot.spec.id
1464 || target.name != target.snapshot.spec.name
1465 || target.snapshot.agents != 0
1466 || !target.snapshot.attached.is_empty()
1467 || previous.is_some_and(|previous| previous >= target.id.as_str())
1468 {
1469 return Err(Error::new(
1470 ErrorKind::Error,
1471 "invalid agent-policy delete plan",
1472 ));
1473 }
1474 previous = Some(&target.id);
1475 }
1476 Ok(())
1477}