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(¤t.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(¤t.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}