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