1use 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#[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
41pub 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 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 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 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 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(¤t.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
463async 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
481async 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(¤t.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}