Skip to main content

elasticctl_api/fleet/agent_policy_ops/
import.rs

1//! Agent policy import planning and apply.
2
3use super::{
4    AgentPolicySpec, AgentPolicySummary, BTreeMap, BTreeSet, Error, ErrorKind, LiveAgentPolicy,
5    MutationPlan, Path, Result, Serialize, Transport, Value, agent_policies, collect, http, json,
6    live_from_policy, read_live, validate,
7};
8
9/// What `plan_import` computed and `apply_import` uploads. Public fields are
10/// the guard preview and the summary counts; the rest are the exact
11/// snapshots and bodies `apply_import` rechecks against before every write.
12#[derive(Debug, Clone, PartialEq)]
13pub struct AgentPolicyImportPlan {
14    pub preview: MutationPlan,
15    pub skipped: Vec<Value>,
16    pub package_installs: Vec<String>,
17    pub total: usize,
18    source: std::path::PathBuf,
19    specs: Vec<AgentPolicySpec>,
20    before: BTreeMap<String, Option<LiveAgentPolicy>>,
21    bodies: BTreeMap<String, Value>,
22    monitoring_package: Option<agent_policies::PackageStatus>,
23    overwrite: bool,
24}
25
26#[derive(Debug, Clone, PartialEq, Serialize)]
27pub struct AgentPolicyImportReport {
28    pub applied: bool,
29    pub succeeded: Vec<Value>,
30    pub unchanged: Vec<Value>,
31    pub skipped: Vec<Value>,
32    pub failed: Vec<Value>,
33    pub total: usize,
34    pub affected_agents: u64,
35    pub package_installs: Vec<String>,
36}
37
38const MONITORING_PACKAGE: &str = "elastic_agent";
39const SERVER_SELECTED_INSTALL: &str = "elastic_agent@server-selected";
40
41/// Build the full-spec PUT body for a merge-semantics update route.
42pub fn build_replace_body(current: &AgentPolicySpec, desired: &AgentPolicySpec) -> Result<Value> {
43    current.validate()?;
44    desired.validate()?;
45    if current.id != desired.id {
46        return unsupported(
47            "changing agent policy id is not supported by the agent-policy update API",
48        );
49    }
50    let removed = [
51        (
52            "description",
53            current.description.is_some() && desired.description.is_none(),
54        ),
55        (
56            "unenroll_timeout",
57            current.unenroll_timeout.is_some() && desired.unenroll_timeout.is_none(),
58        ),
59        (
60            "monitoring_pprof_enabled",
61            current.monitoring_pprof_enabled.is_some()
62                && desired.monitoring_pprof_enabled.is_none(),
63        ),
64        (
65            "advanced_settings",
66            current.advanced_settings.is_some() && desired.advanced_settings.is_none(),
67        ),
68        (
69            "monitoring_http",
70            current.monitoring_http.is_some() && desired.monitoring_http.is_none(),
71        ),
72        (
73            "monitoring_diagnostics",
74            current.monitoring_diagnostics.is_some() && desired.monitoring_diagnostics.is_none(),
75        ),
76    ];
77    if let Some((field, _)) = removed.iter().find(|(_, gone)| *gone) {
78        return unsupported(format!(
79            "removing {field} is not supported by the agent-policy update API"
80        ));
81    }
82    // Nested objects need no removal check: Kibana maps them `flattened` and
83    // replaces the stored object with the supplied one.
84    let mut body = serde_json::to_value(desired)
85        .map_err(|error| Error::new(ErrorKind::Error, format!("encoding agent policy: {error}")))?
86        .as_object()
87        .cloned()
88        .expect("specs serialize to objects");
89    body.remove("id");
90    if current.overrides.is_some() && desired.overrides.is_none() {
91        body.insert("overrides".into(), Value::Null);
92    }
93    if current.keep_monitoring_alive.is_some() && desired.keep_monitoring_alive.is_none() {
94        body.insert("keep_monitoring_alive".into(), Value::Null);
95    }
96    Ok(Value::Object(body))
97}
98
99fn unsupported<T>(message: impl Into<String>) -> Result<T> {
100    Err(Error::new(ErrorKind::Unsupported, message))
101}
102
103pub async fn plan_import(
104    transport: &Transport,
105    path: &Path,
106    overwrite: bool,
107    skip_existing: bool,
108) -> Result<AgentPolicyImportPlan> {
109    let mut specs = validate(path)?;
110    if specs.is_empty() {
111        return Err(Error::new(
112            ErrorKind::Error,
113            "agent-policy import needs at least one agent policy",
114        ));
115    }
116    if overwrite && skip_existing {
117        return Err(Error::new(
118            ErrorKind::Error,
119            "--overwrite and --skip-existing cannot be used together",
120        ));
121    }
122    let total = specs.len();
123
124    // Read raw first: an existing policy that will only be skipped or
125    // reported conflict must never pay `normalize`'s portability refusal.
126    // Only a policy that survives to the overwrite path below is normalized.
127    let mut before_raw = BTreeMap::new();
128    let mut conflicts = Vec::new();
129    for spec in &specs {
130        match agent_policies::get(transport, &spec.id).await {
131            Ok(policy) => {
132                if !overwrite && !skip_existing {
133                    conflicts.push(spec.id.clone());
134                }
135                before_raw.insert(spec.id.clone(), Some(policy));
136            }
137            Err(error) if error.kind == ErrorKind::NotFound => {
138                before_raw.insert(spec.id.clone(), None);
139            }
140            Err(error) => return Err(error),
141        }
142    }
143    // Fleet enforces unique names with a 409; catch it before the guard.
144    let mut live_names = BTreeMap::new();
145    for item in collect(transport).await? {
146        let row = AgentPolicySummary::from_item(&item)?;
147        if live_names
148            .insert(row.name.clone(), row.id.clone())
149            .is_some()
150        {
151            return Err(http(format!(
152                "decoding agent policies list: duplicate name '{}'",
153                row.name
154            )));
155        }
156    }
157    let taken: Vec<String> = specs
158        .iter()
159        .filter_map(|spec| {
160            live_names
161                .get(&spec.name)
162                .filter(|owner| **owner != spec.id)
163                .map(|owner| format!("{} ({owner})", spec.name))
164        })
165        .collect();
166    if !taken.is_empty() {
167        return Err(Error::new(
168            ErrorKind::Conflict,
169            format!("agent policy names already exist: {}", taken.join(", ")),
170        ));
171    }
172    if !conflicts.is_empty() {
173        return Err(Error::new(
174            ErrorKind::Conflict,
175            format!("agent policies already exist: {}", conflicts.join(", ")),
176        ));
177    }
178    let mut skipped = Vec::new();
179    if skip_existing {
180        specs.retain(|spec| match before_raw.get(&spec.id) {
181            Some(Some(_)) => {
182                skipped.push(json!({"id": spec.id, "reason": "exists"}));
183                false
184            }
185            _ => true,
186        });
187        before_raw.retain(|id, _| specs.iter().any(|spec| spec.id == *id));
188    }
189
190    // Every id remaining here is either absent or, having passed both the
191    // conflict guard above and the skip filter, is about to be replaced:
192    // `--overwrite` is required by this point for any `Some` entry. Only now
193    // is the existing policy normalized, so an unsupported existing policy
194    // still fails the plan here, exactly as it must for a replace.
195    let mut before = BTreeMap::new();
196    for (id, raw) in before_raw {
197        let live = match raw {
198            Some(policy) => Some(live_from_policy(&policy, &id, transport.space())?),
199            None => None,
200        };
201        before.insert(id, live);
202    }
203
204    let mut bodies = BTreeMap::new();
205    for spec in &specs {
206        if let Some(Some(current)) = before.get(&spec.id)
207            && current.spec != *spec
208        {
209            bodies.insert(spec.id.clone(), build_replace_body(&current.spec, spec)?);
210        }
211    }
212
213    let mut package_installs = Vec::new();
214    let needs_monitoring = specs
215        .iter()
216        .any(|spec| monitoring_can_install(before.get(&spec.id).and_then(Option::as_ref), spec));
217    let monitoring_package = if needs_monitoring {
218        let status = agent_policies::package_status(transport, MONITORING_PACKAGE).await?;
219        if status.status != "installed" {
220            package_installs.push(SERVER_SELECTED_INSTALL.to_string());
221        }
222        Some(status)
223    } else {
224        None
225    };
226
227    let preview = MutationPlan {
228        preview_action: format!(
229            "Import {} agent policy(ies) from {}",
230            specs.len(),
231            path.display()
232        ),
233        preview_details: import_details(&specs, &before, &package_installs),
234        targets: specs.iter().map(|spec| spec.id.clone()).collect(),
235    };
236    Ok(AgentPolicyImportPlan {
237        preview,
238        skipped,
239        package_installs,
240        total,
241        source: path.to_path_buf(),
242        specs,
243        before,
244        bodies,
245        monitoring_package,
246        overwrite,
247    })
248}
249
250fn monitoring_can_install(current: Option<&LiveAgentPolicy>, desired: &AgentPolicySpec) -> bool {
251    !desired.monitoring_enabled.is_empty()
252        && current.is_none_or(|current| current.spec.monitoring_enabled.is_empty())
253}
254
255fn import_details(
256    specs: &[AgentPolicySpec],
257    before: &BTreeMap<String, Option<LiveAgentPolicy>>,
258    package_installs: &[String],
259) -> Vec<String> {
260    let mut details: Vec<String> = specs
261        .iter()
262        .filter_map(|spec| match before.get(&spec.id) {
263            Some(None) => Some(format!("{}  create  {}", spec.id, spec.name)),
264            Some(Some(current)) if current.spec == *spec => {
265                Some(format!("{}  unchanged  {}", spec.id, spec.name))
266            }
267            Some(Some(current)) => {
268                let name = if current.spec.name == spec.name {
269                    spec.name.clone()
270                } else {
271                    format!("{} -> {}", current.spec.name, spec.name)
272                };
273                Some(format!(
274                    "{}  replace  {name}  agents {}",
275                    spec.id, current.agents
276                ))
277            }
278            None => None,
279        })
280        .collect();
281    details.extend(
282        package_installs
283            .iter()
284            .map(|install| format!("package install  {install}")),
285    );
286    details
287}
288
289pub async fn apply_import(
290    transport: &Transport,
291    plan: &AgentPolicyImportPlan,
292) -> Result<AgentPolicyImportReport> {
293    validate_import_plan(plan)?;
294    let mut succeeded = Vec::new();
295    let mut unchanged = Vec::new();
296    let mut failed = Vec::new();
297    let mut affected_agents = 0;
298    let mut expected_package = plan.monitoring_package.clone();
299    let mut package_installs = Vec::new();
300
301    for desired in &plan.specs {
302        let Some(before) = plan.before.get(&desired.id) else {
303            failed.push(failed_row(&desired.id, false, "missing preflight snapshot"));
304            continue;
305        };
306        let current = match read_live(transport, &desired.id).await {
307            Ok(live) => Some(live),
308            Err(error) if error.kind == ErrorKind::NotFound => None,
309            Err(error) => {
310                failed.push(failed_row(&desired.id, false, error.message));
311                continue;
312            }
313        };
314        match (before, current) {
315            (None, Some(_)) => failed.push(failed_row(
316                &desired.id,
317                false,
318                "agent policy appeared since preview",
319            )),
320            (Some(_), None) => failed.push(failed_row(
321                &desired.id,
322                false,
323                "agent policy disappeared since preview",
324            )),
325            (Some(before), Some(live)) if before != &live => failed.push(failed_row(
326                &desired.id,
327                false,
328                "agent policy changed since preview",
329            )),
330            (before, current) => {
331                let package_can_change = monitoring_can_install(before.as_ref(), desired);
332                if package_can_change {
333                    let Some(expected) = expected_package.as_ref() else {
334                        failed.push(failed_row(
335                            &desired.id,
336                            false,
337                            "missing monitoring package snapshot",
338                        ));
339                        continue;
340                    };
341                    match agent_policies::package_status(transport, MONITORING_PACKAGE).await {
342                        Ok(actual) if actual == *expected => {}
343                        Ok(_) => {
344                            failed.push(failed_row(
345                                &desired.id,
346                                false,
347                                "elastic_agent package changed since preview",
348                            ));
349                            continue;
350                        }
351                        Err(error) => {
352                            failed.push(failed_row(&desired.id, false, error.message));
353                            continue;
354                        }
355                    }
356                }
357
358                let (action, applied, route_error) = match (before, current) {
359                    (None, None) => {
360                        match other_owner_of_name(transport, &desired.name).await {
361                            Ok(Some(owner)) => {
362                                failed.push(failed_row(
363                                    &desired.id,
364                                    false,
365                                    format!(
366                                        "agent policy name appeared since preview: {} ({owner})",
367                                        desired.name
368                                    ),
369                                ));
370                                continue;
371                            }
372                            Ok(None) => {}
373                            Err(error) => {
374                                failed.push(failed_row(&desired.id, false, error));
375                                continue;
376                            }
377                        }
378                        match agent_policies::create(transport, desired).await {
379                            Ok(_) => ("created", true, None),
380                            Err(error) => ("created", false, Some(error.message)),
381                        }
382                    }
383                    (Some(before), Some(_)) if before.spec == *desired => {
384                        unchanged.push(json!({"id": desired.id}));
385                        continue;
386                    }
387                    (Some(before), Some(_)) => {
388                        let body = plan
389                            .bodies
390                            .get(&desired.id)
391                            .expect("validated replacement body");
392                        match agent_policies::update(transport, &desired.id, body).await {
393                            Ok(_) => {
394                                affected_agents += before.agents;
395                                ("replaced", true, None)
396                            }
397                            Err(error) => ("replaced", false, Some(error.message)),
398                        }
399                    }
400                    _ => unreachable!("appearance and disappearance handled above"),
401                };
402
403                let stored_error = if applied {
404                    verify_stored(transport, desired).await.err()
405                } else {
406                    None
407                };
408                let package_error = if package_can_change {
409                    let expected = expected_package
410                        .as_ref()
411                        .expect("validated package snapshot");
412                    match observe_package_after_write(transport, expected).await {
413                        Ok((after, installed)) => {
414                            expected_package = Some(after);
415                            if let Some(installed) = installed
416                                && !package_installs.contains(&installed)
417                            {
418                                package_installs.push(installed);
419                            }
420                            None
421                        }
422                        Err(error) => Some(error),
423                    }
424                } else {
425                    None
426                };
427
428                let errors = [route_error, stored_error, package_error]
429                    .into_iter()
430                    .flatten()
431                    .collect::<Vec<_>>();
432                if errors.is_empty() {
433                    succeeded.push(json!({"id": desired.id, "action": action}));
434                } else {
435                    failed.push(failed_row(&desired.id, applied, errors.join("; ")));
436                }
437            }
438        }
439    }
440    Ok(AgentPolicyImportReport {
441        applied: true,
442        succeeded,
443        unchanged,
444        skipped: plan.skipped.clone(),
445        failed,
446        total: plan.total,
447        affected_agents,
448        package_installs,
449    })
450}
451
452async fn verify_stored(
453    transport: &Transport,
454    desired: &AgentPolicySpec,
455) -> std::result::Result<(), String> {
456    match read_live(transport, &desired.id).await {
457        Ok(live) if live.spec == *desired => Ok(()),
458        Ok(_) => Err("server stored a different agent-policy spec".into()),
459        Err(error) => Err(error.message),
460    }
461}
462
463/// Recheck the live list for a policy name claimed by a different id, right
464/// before a planned create's POST. Fleet enforces unique names with a 409 on
465/// create, so a name another client claimed since planning must fail the row
466/// locally rather than reach the server.
467async fn other_owner_of_name(
468    transport: &Transport,
469    name: &str,
470) -> std::result::Result<Option<String>, String> {
471    let items = collect(transport).await.map_err(|error| error.message)?;
472    for item in &items {
473        let row = AgentPolicySummary::from_item(item).map_err(|error| error.message)?;
474        if row.name == name {
475            return Ok(Some(row.id));
476        }
477    }
478    Ok(None)
479}
480
481/// Re-read the monitoring package after a write that could install it. The
482/// install is an observation: Fleet's create path tolerates an install error,
483/// and a replace installs only from an absent stored value, so a package that
484/// stays absent is not a failure. Only the read itself can fail the row.
485async fn observe_package_after_write(
486    transport: &Transport,
487    before: &agent_policies::PackageStatus,
488) -> std::result::Result<(agent_policies::PackageStatus, Option<String>), String> {
489    let after = agent_policies::package_status(transport, MONITORING_PACKAGE)
490        .await
491        .map_err(|error| error.message)?;
492    if before.status != "installed" && after.status == "installed" {
493        let version = after
494            .installed_version
495            .clone()
496            .expect("decoder requires installed version");
497        return Ok((after, Some(format!("{MONITORING_PACKAGE}@{version}"))));
498    }
499    Ok((after, None))
500}
501
502fn failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
503    json!({"id": id, "applied": applied, "error": error.into()})
504}
505
506fn validate_import_plan(plan: &AgentPolicyImportPlan) -> Result<()> {
507    let invalid = |message: &str| {
508        Err(Error::new(
509            ErrorKind::Error,
510            format!("invalid agent-policy import plan: {message}"),
511        ))
512    };
513    if plan.total == 0 || plan.total != plan.specs.len() + plan.skipped.len() {
514        return invalid("total does not equal pending and skipped agent policies");
515    }
516    let mut previous_id: Option<&str> = None;
517    let mut names = BTreeSet::new();
518    let mut expected_body_ids = BTreeSet::new();
519    for spec in &plan.specs {
520        spec.validate()?;
521        if previous_id.is_some_and(|previous| previous >= spec.id.as_str()) {
522            return invalid("pending agent policies must be unique and sorted by id");
523        }
524        previous_id = Some(&spec.id);
525        if !names.insert(spec.name.as_str()) {
526            return invalid("pending agent-policy names must be unique");
527        }
528        let Some(before) = plan.before.get(&spec.id) else {
529            return invalid("preflight snapshots do not match pending agent policies");
530        };
531        if let Some(current) = before {
532            current.spec.validate()?;
533            if current.spec.id != spec.id {
534                return invalid("live snapshot id does not match its pending policy");
535            }
536            if current.attached.windows(2).any(|ids| ids[0] >= ids[1]) {
537                return invalid("attached integration ids must be unique and sorted");
538            }
539        }
540        match before {
541            None if plan.bodies.contains_key(&spec.id) => {
542                return invalid("planned creates must not carry a replacement body");
543            }
544            None => {}
545            Some(current) if current.spec == *spec => {
546                if plan.bodies.contains_key(&spec.id) {
547                    return invalid("unchanged agent policies must not carry a replacement body");
548                }
549            }
550            Some(current) => {
551                expected_body_ids.insert(spec.id.as_str());
552                if !plan.overwrite {
553                    return invalid("replacement plan requires overwrite");
554                }
555                if plan.bodies.get(&spec.id) != Some(&build_replace_body(&current.spec, spec)?) {
556                    return invalid("replacement body does not match its snapshots");
557                }
558            }
559        }
560    }
561    if plan.before.len() != plan.specs.len() {
562        return invalid("preflight snapshots do not match pending agent policies");
563    }
564    if plan
565        .bodies
566        .keys()
567        .map(String::as_str)
568        .collect::<BTreeSet<_>>()
569        != expected_body_ids
570    {
571        return invalid("replacement bodies do not match changed agent policies");
572    }
573    let mut previous_skipped: Option<&str> = None;
574    for skipped in &plan.skipped {
575        let object = skipped.as_object().ok_or_else(|| {
576            Error::new(
577                ErrorKind::Error,
578                "invalid agent-policy import plan: skipped row must be an object",
579            )
580        })?;
581        if object.len() != 2 || object.get("reason").and_then(Value::as_str) != Some("exists") {
582            return invalid("skipped rows must contain only id and reason exists");
583        }
584        let id = object
585            .get("id")
586            .and_then(Value::as_str)
587            .filter(|id| !id.is_empty())
588            .ok_or_else(|| {
589                Error::new(
590                    ErrorKind::Error,
591                    "invalid agent-policy import plan: skipped id must be non-empty",
592                )
593            })?;
594        if previous_skipped.is_some_and(|previous| previous >= id) {
595            return invalid("skipped agent policies must be unique and sorted by id");
596        }
597        if plan.before.contains_key(id) {
598            return invalid("an agent policy cannot be both pending and skipped");
599        }
600        previous_skipped = Some(id);
601    }
602
603    let needs_monitoring = plan.specs.iter().any(|spec| {
604        monitoring_can_install(plan.before.get(&spec.id).and_then(Option::as_ref), spec)
605    });
606    match (&plan.monitoring_package, needs_monitoring) {
607        (Some(status), true) if status.name == MONITORING_PACKAGE => {
608            let expected = if status.status == "installed" {
609                Vec::new()
610            } else {
611                vec![SERVER_SELECTED_INSTALL.to_string()]
612            };
613            if status.status == "installed" && status.installed_version.is_none() {
614                return invalid("installed monitoring package needs an exact version");
615            }
616            if plan.package_installs != expected {
617                return invalid("monitoring package preview does not match its snapshot");
618            }
619        }
620        (None, false) if plan.package_installs.is_empty() => {}
621        _ => return invalid("monitoring package snapshot does not match pending transitions"),
622    }
623
624    let expected_preview = MutationPlan {
625        preview_action: format!(
626            "Import {} agent policy(ies) from {}",
627            plan.specs.len(),
628            plan.source.display()
629        ),
630        preview_details: import_details(&plan.specs, &plan.before, &plan.package_installs),
631        targets: plan.specs.iter().map(|spec| spec.id.clone()).collect(),
632    };
633    if plan.preview != expected_preview {
634        return invalid("preview does not match the canonical plan");
635    }
636    Ok(())
637}