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/// A single live read reduced to what planning needs.
31#[derive(Debug, Clone, PartialEq)]
32pub struct LiveAgentPolicy {
33    pub spec: AgentPolicySpec,
34    pub agents: u64,
35    pub attached: Vec<String>,
36}
37
38/// Collect every page in the measured deterministic order, then sort by id.
39pub 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
120/// Stable id first through the single-object route; exact name second.
121pub 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
183/// True when a boolean platform flag is true or `agentless` is non-null.
184/// Used only by `--all-custom` filtering;
185/// `normalize` still refuses these policies when selected explicitly.
186pub 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
217/// Live top-level fields normalization removes or refuses as server-owned or
218/// derived, per spec 5.2: audit and saved-object identity, agent and
219/// version-condition facts, populated `package_policies`, the platform and
220/// portability-refusal fields, and the active space's `space_ids`. Sorted.
221/// A live field outside this list and the portable set is `unsupported`.
222const 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
256/// Convert a live policy into its filled portable form, or refuse it.
257pub 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
391/// Read one policy and reduce it to the facts planning compares and rechecks.
392pub(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
397/// Reduce an already-read policy to the facts planning compares and rechecks,
398/// without a further route call. Shared by `read_live` and `plan_import`'s
399/// conversion of a raw existing snapshot, which must defer this call (and its
400/// `normalize` refusal) until the row is known to need it: a skipped or
401/// conflicting existing policy is never normalized.
402fn 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
423/// `package_policies` is a list of ids or of populated objects carrying `id`.
424fn 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
462/// Kibana populates `agents` only for a caller with Fleet agents read, so an
463/// absent field is a privilege gap, not a malformed response.
464fn 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
480/// Read, decode, validate, and sort a portable artifact.
481pub 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
527/// Export selected policies, or every custom policy, as a portable artifact.
528pub 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/// What `plan_import` computed and `apply_import` uploads. Public fields are
575/// the guard preview and the summary counts; the rest are the exact
576/// snapshots and bodies `apply_import` rechecks against before every write.
577#[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
606/// Build the full-spec PUT body for a merge-semantics update route.
607pub 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    // Nested objects need no removal check: Kibana maps them `flattened` and
648    // replaces the stored object with the supplied one.
649    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    // Read raw first: an existing policy that will only be skipped or
690    // reported conflict must never pay `normalize`'s portability refusal.
691    // Only a policy that survives to the overwrite path below is normalized.
692    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    // Fleet enforces unique names with a 409; catch it before the guard.
709    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    // Every id remaining here is either absent or, having passed both the
756    // conflict guard above and the skip filter, is about to be replaced:
757    // `--overwrite` is required by this point for any `Some` entry. Only now
758    // is the existing policy normalized, so an unsupported existing policy
759    // still fails the plan here, exactly as it must for a replace.
760    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(&current.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
1028/// Recheck the live list for a policy name claimed by a different id, right
1029/// before a planned create's POST. Fleet enforces unique names with a 409 on
1030/// create, so a name another client claimed since planning must fail the row
1031/// locally rather than reach the server.
1032async 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
1046/// Re-read the monitoring package after a write that could install it. The
1047/// install is an observation: Fleet's create path tolerates an install error,
1048/// and a replace installs only from an absent stored value, so a package that
1049/// stays absent is not a failure. Only the read itself can fail the row.
1050async 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(&current.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                // A 2xx echoing the wrong id means the server acknowledged
1329                // deleting something; only a non-2xx status (or none, for a
1330                // transport/decode failure) leaves the target untouched.
1331                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}