Skip to main content

elasticctl_api/fleet/
agent_policy_ops.rs

1//! Agent-policy selection, normalization, portability, planning, and apply.
2
3use 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/// A single live read reduced to what planning needs.
37#[derive(Debug, Clone, PartialEq)]
38pub struct LiveAgentPolicy {
39    pub spec: AgentPolicySpec,
40    pub agents: u64,
41    pub attached: Vec<String>,
42}
43
44/// The narrow parent facts an integration-policy operation needs to compare
45/// attachment, namespace, ownership, and blast radius. This deliberately does
46/// not apply agent-policy portability checks: environment references are not
47/// integration-parent facts.
48#[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
59/// Collect every page in the measured deterministic order, then sort by id.
60pub 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
141/// Stable id first through the single-object route; exact name second.
142pub async fn resolve(transport: &Transport, selector: &str) -> Result<AgentPolicySummary> {
143    Ok(resolve_item(transport, selector).await?.summary)
144}
145
146/// Resolve a selector and retain the single-object response needed by callers
147/// that require populated agent and integration facts.
148async 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
220/// True when a boolean platform flag is true or `agentless` is non-null.
221/// Used only by `--all-custom` filtering;
222/// `normalize` still refuses these policies when selected explicitly.
223pub 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
238/// Read one agent-policy parent while retaining only integration-operation
239/// facts. Missing `agents` is a Fleet privilege error; all other malformed
240/// known parent fields are malformed HTTP responses.
241pub(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
304/// Live top-level fields normalization removes or refuses as server-owned or
305/// derived, per spec 5.2: audit and saved-object identity, agent and
306/// version-condition facts, populated `package_policies`, the platform and
307/// portability-refusal fields, and the active space's `space_ids`. Sorted.
308/// A live field outside this list and the portable set is `unsupported`.
309const 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
343/// Convert a live policy into its filled portable form, or refuse it.
344pub 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
478/// Read one policy and reduce it to the facts planning compares and rechecks.
479pub(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
484/// Reduce an already-read policy to the facts planning compares and rechecks,
485/// without a further route call. Shared by `read_live` and `plan_import`'s
486/// conversion of a raw existing snapshot, which must defer this call (and its
487/// `normalize` refusal) until the row is known to need it: a skipped or
488/// conflicting existing policy is never normalized.
489fn 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
518/// `package_policies` is a list of ids or of populated objects carrying `id`.
519fn 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
557/// Kibana populates `agents` only for a caller with Fleet agents read, so an
558/// absent field is a privilege gap, not a malformed response.
559fn 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
575/// Read, decode, validate, and sort a portable artifact.
576pub 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
622/// Export selected policies, or every custom policy, as a portable artifact.
623pub 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/// What `plan_import` computed and `apply_import` uploads. Public fields are
677/// the guard preview and the summary counts; the rest are the exact
678/// snapshots and bodies `apply_import` rechecks against before every write.
679#[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
708/// Build the full-spec PUT body for a merge-semantics update route.
709pub 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    // Nested objects need no removal check: Kibana maps them `flattened` and
750    // replaces the stored object with the supplied one.
751    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    // Read raw first: an existing policy that will only be skipped or
792    // reported conflict must never pay `normalize`'s portability refusal.
793    // Only a policy that survives to the overwrite path below is normalized.
794    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    // Fleet enforces unique names with a 409; catch it before the guard.
811    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    // Every id remaining here is either absent or, having passed both the
858    // conflict guard above and the skip filter, is about to be replaced:
859    // `--overwrite` is required by this point for any `Some` entry. Only now
860    // is the existing policy normalized, so an unsupported existing policy
861    // still fails the plan here, exactly as it must for a replace.
862    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(&current.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
1130/// Recheck the live list for a policy name claimed by a different id, right
1131/// before a planned create's POST. Fleet enforces unique names with a 409 on
1132/// create, so a name another client claimed since planning must fail the row
1133/// locally rather than reach the server.
1134async 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
1148/// Re-read the monitoring package after a write that could install it. The
1149/// install is an observation: Fleet's create path tolerates an install error,
1150/// and a replace installs only from an absent stored value, so a package that
1151/// stays absent is not a failure. Only the read itself can fail the row.
1152async 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(&current.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                // A 2xx echoing the wrong id means the server acknowledged
1432                // deleting something; only a non-2xx status (or none, for a
1433                // transport/decode failure) leaves the target untouched.
1434                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}