Skip to main content

elasticctl_api/fleet/agent_policy_ops/
import.rs

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