1use crate::content_codec::{self, ContentFormat};
4use crate::fleet::integration_policies::{
5 self, IntegrationPackageSpec, IntegrationPolicyDetail, IntegrationPolicySpec,
6 IntegrationPolicySummary,
7};
8use crate::fleet::{agent_policies, agent_policy_ops};
9use crate::ops::{ExportOutcome, MutationPlan};
10use elasticctl_core::{Error, ErrorKind, Result, Transport};
11use serde::Serialize;
12use serde_json::{Map, Value, json};
13use std::collections::{BTreeMap, BTreeSet};
14use std::fmt;
15use std::path::{Path, PathBuf};
16
17const PAGE_SIZE: u64 = 1000;
18const IMPORT_RACE_WARNING: &str =
19 "warning Fleet can change after the final recheck and before the write";
20const DELETE_RACE_WARNING: &str =
21 "warning Fleet can change after the final recheck and before the write";
22
23#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct IntegrationPolicyFilter {
25 pub search: Option<String>,
26 pub limit: Option<usize>,
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct IntegrationPolicyList {
31 pub total: u64,
32 pub integration_policies: Vec<IntegrationPolicySummary>,
33 pub truncated: bool,
34}
35
36#[derive(Debug, Clone, PartialEq)]
38pub(crate) struct ResolvedIntegrationPolicy {
39 pub(crate) summary: IntegrationPolicySummary,
40 pub(crate) item: Map<String, Value>,
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
47enum PackageDependencyState {
48 Installed { version: String },
49 NotInstalled,
50}
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53struct PackageDependencySnapshot {
54 name: String,
55 state: PackageDependencyState,
56}
57
58#[derive(Debug, Clone, Default, PartialEq, Eq)]
62struct SecretSchema {
63 package_vars: BTreeSet<String>,
64 input_vars: BTreeMap<String, BTreeSet<String>>,
65 stream_vars: BTreeMap<(String, String), BTreeSet<String>>,
66}
67
68#[derive(Debug, Clone, Default)]
69struct KnownSchema {
70 package_vars: BTreeSet<String>,
71 input_vars: BTreeMap<String, BTreeSet<String>>,
72 stream_vars: BTreeMap<(String, String), BTreeSet<String>>,
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Eq)]
76struct VariableDefinitions {
77 known: BTreeSet<String>,
78 secrets: BTreeSet<String>,
79}
80
81#[derive(Debug)]
82struct TemplateDefinitions {
83 inputs: BTreeMap<String, String>,
84 datasets: BTreeSet<String>,
85}
86
87pub struct IntegrationPolicyImportArtifact {
91 source: PathBuf,
92 canonical: Vec<IntegrationPolicySpec>,
93}
94
95impl fmt::Debug for IntegrationPolicyImportArtifact {
96 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
97 formatter
98 .debug_struct("IntegrationPolicyImportArtifact")
99 .field("policy_count", &self.canonical.len())
100 .finish()
101 }
102}
103
104#[derive(Clone, PartialEq)]
108pub struct IntegrationPolicyImportPlan {
109 pub preview: MutationPlan,
110 pub skipped: Vec<Value>,
111 pub package_installs: Vec<String>,
112 pub total: usize,
113 source: PathBuf,
114 host: String,
115 space: String,
116 canonical: Vec<IntegrationPolicySpec>,
117 name_owners: BTreeMap<String, BTreeSet<String>>,
118 name_owners_snapshot: BTreeMap<String, BTreeSet<String>>,
119 parent_snapshots: BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
120 skipped_snapshot: Vec<Value>,
121 existing_snapshot: BTreeMap<String, Option<Map<String, Value>>>,
125 targets: Vec<IntegrationPolicyImportTarget>,
126 package_groups: BTreeMap<String, IntegrationPackageGroup>,
127 overwrite: bool,
128 skip_existing: bool,
129}
130
131impl fmt::Debug for IntegrationPolicyImportPlan {
132 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
133 formatter
134 .debug_struct("IntegrationPolicyImportPlan")
135 .field("target_count", &self.preview.targets.len())
136 .field("skipped_count", &self.skipped.len())
137 .field("package_install_count", &self.package_installs.len())
138 .field("existing_snapshot_count", &self.existing_snapshot.len())
139 .field("total", &self.total)
140 .finish()
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Serialize)]
145pub struct IntegrationPolicyImportReport {
146 pub applied: bool,
147 pub succeeded: Vec<Value>,
148 pub unchanged: Vec<Value>,
149 pub skipped: Vec<Value>,
150 pub failed: Vec<Value>,
151 pub total: usize,
152 pub affected_agents: u64,
153 pub package_installs: Vec<String>,
154}
155
156#[derive(Clone, PartialEq)]
160pub struct IntegrationPolicyDeletePlan {
161 pub preview: MutationPlan,
162 pub total: usize,
163 host: String,
164 host_snapshot: String,
165 space: String,
166 space_snapshot: String,
167 parent_snapshots: BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
168 parent_snapshots_snapshot: BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
169 targets: Vec<IntegrationPolicyDeleteTarget>,
170}
171
172impl fmt::Debug for IntegrationPolicyDeletePlan {
173 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
174 formatter
175 .debug_struct("IntegrationPolicyDeletePlan")
176 .field("target_count", &self.targets.len())
177 .field("total", &self.total)
178 .finish()
179 }
180}
181
182#[derive(Debug, Clone, PartialEq, Serialize)]
184pub struct IntegrationPolicyDeleteReport {
185 pub applied: bool,
186 pub deleted: Vec<Value>,
187 pub failed: Vec<Value>,
188 pub total: usize,
189 pub affected_agents: u64,
190}
191
192#[derive(Debug, Clone, PartialEq)]
193struct IntegrationPolicyImportTarget {
194 effective: IntegrationPolicySpec,
195 current: Option<IntegrationPolicyCurrentSnapshot>,
196 parents: BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
197 replacement_body: Option<Value>,
198}
199
200#[derive(Debug, Clone, PartialEq)]
201struct IntegrationPolicyCurrentSnapshot {
202 item: Map<String, Value>,
203 spec: IntegrationPolicySpec,
204 parent_ids: Vec<String>,
205}
206
207#[derive(Clone, PartialEq)]
211struct IntegrationPolicyDeleteTarget {
212 id: String,
213 name: String,
214 item: Map<String, Value>,
215 item_snapshot: Map<String, Value>,
216 spec: IntegrationPolicySpec,
217 spec_snapshot: IntegrationPolicySpec,
218 parents: BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
219 package: IntegrationPackageSpec,
220 dependency: PackageDependencySnapshot,
221 dependency_snapshot: PackageDependencySnapshot,
222 metadata: Map<String, Value>,
223 metadata_snapshot: Map<String, Value>,
224}
225
226#[derive(Debug, Clone, PartialEq)]
231struct IntegrationPackageGroup {
232 package: IntegrationPackageSpec,
233 state: PackageDependencySnapshot,
234 state_snapshot: PackageDependencySnapshot,
235 metadata: Map<String, Value>,
236 metadata_snapshot: Map<String, Value>,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq)]
240enum ImportAction {
241 Create,
242 Replace,
243 Unchanged,
244}
245
246pub async fn collect(transport: &Transport) -> Result<Vec<Map<String, Value>>> {
248 let mut page_number = 1;
249 let mut total = None;
250 let mut items = Vec::new();
251 let mut ids = BTreeSet::new();
252 loop {
253 let page = integration_policies::list_page(transport, page_number).await?;
254 if page.page != page_number || page.per_page != PAGE_SIZE {
255 return Err(http(
256 "decoding integration policies list: unexpected page metadata",
257 ));
258 }
259 if page.items.len() as u64 > PAGE_SIZE {
260 return Err(http(
261 "decoding integration policies list: page returned more items than requested",
262 ));
263 }
264 match total {
265 Some(expected) if expected != page.total => {
266 return Err(http(
267 "decoding integration policies list: total changed while paging",
268 ));
269 }
270 Some(_) => {}
271 None => total = Some(page.total),
272 }
273 let page_len = page.items.len() as u64;
274 for item in page.items {
275 let id = required_string(&item, "id", "integration policies list")?;
276 if !ids.insert(id.clone()) {
277 return Err(http(format!(
278 "decoding integration policies list: duplicate integration policy id '{id}'"
279 )));
280 }
281 items.push(item);
282 }
283 let expected = total.expect("first page sets total");
284 if items.len() as u64 >= expected {
285 break;
286 }
287 if page_len != PAGE_SIZE {
288 return Err(http(
289 "decoding integration policies list: page was short before total",
290 ));
291 }
292 page_number += 1;
293 }
294 if items.len() as u64 > total.unwrap_or_default() {
295 return Err(http(
296 "decoding integration policies list: returned more items than total",
297 ));
298 }
299 items.sort_by(|left, right| left["id"].as_str().cmp(&right["id"].as_str()));
300 Ok(items)
301}
302
303pub async fn list_op(
305 transport: &Transport,
306 filter: &IntegrationPolicyFilter,
307) -> Result<IntegrationPolicyList> {
308 let items = collect(transport).await?;
309 let total = items.len() as u64;
310 let needle = filter.search.as_ref().map(|value| value.to_lowercase());
311 let mut integration_policies = Vec::new();
312 for item in &items {
313 let summary = summary_from_item(item)?;
314 if needle.as_ref().is_none_or(|needle| {
315 summary.id.to_lowercase().contains(needle)
316 || summary.name.to_lowercase().contains(needle)
317 }) {
318 integration_policies.push(summary);
319 }
320 }
321 let limit = filter.limit.unwrap_or(usize::MAX);
322 let truncated = integration_policies.len() > limit;
323 integration_policies.truncate(limit);
324 Ok(IntegrationPolicyList {
325 total,
326 integration_policies,
327 truncated,
328 })
329}
330
331pub async fn resolve(transport: &Transport, selector: &str) -> Result<IntegrationPolicySummary> {
333 Ok(resolve_item(transport, selector).await?.summary)
334}
335
336pub(crate) async fn resolve_item(
338 transport: &Transport,
339 selector: &str,
340) -> Result<ResolvedIntegrationPolicy> {
341 match integration_policies::get(transport, selector).await {
342 Ok(policy) => {
343 return Ok(ResolvedIntegrationPolicy {
344 summary: summary_from_item(&policy.item)?,
345 item: policy.item,
346 });
347 }
348 Err(error) if error.kind == ErrorKind::NotFound => {}
349 Err(error) => return Err(error),
350 }
351 let matches: Vec<IntegrationPolicySummary> = collect(transport)
352 .await?
353 .iter()
354 .filter(|item| item.get("name").and_then(Value::as_str) == Some(selector))
355 .map(summary_from_item)
356 .collect::<Result<_>>()?;
357 match matches.as_slice() {
358 [] => Err(Error::new(
359 ErrorKind::NotFound,
360 format!("no integration policy with id or name '{selector}'"),
361 )),
362 [one] => {
363 let policy = integration_policies::get(transport, &one.id).await?;
364 Ok(ResolvedIntegrationPolicy {
365 summary: one.clone(),
366 item: policy.item,
367 })
368 }
369 many => Err(Error::new(
370 ErrorKind::Conflict,
371 format!(
372 "integration policy '{selector}' is ambiguous: {}",
373 many.iter()
374 .map(|policy| policy.id.as_str())
375 .collect::<Vec<_>>()
376 .join(", ")
377 ),
378 )),
379 }
380}
381
382pub async fn get_op(transport: &Transport, selector: &str) -> Result<IntegrationPolicyDetail> {
385 let resolved = resolve_item(transport, selector).await?;
386 let mut blocked_by = live_blocked_by(&resolved.item, &resolved.summary.id, transport.space())?;
387 validate_safe_detail_shape(&resolved.item, transport.space())?;
388 let parents = read_parents(&resolved.summary.id, &resolved.item)?;
389 let parents = read_parent_snapshots(transport, &resolved.summary.id, &parents).await?;
390 for parent in parents.values() {
391 if parent.platform_owned {
392 blocked_by.insert(format!("parent:{}.platform_owned", parent.id));
393 }
394 if parent.protected {
395 blocked_by.insert(format!("parent:{}.is_protected", parent.id));
396 }
397 }
398 if parents
399 .values()
400 .map(|parent| parent.namespace.as_str())
401 .collect::<BTreeSet<_>>()
402 .len()
403 != 1
404 {
405 blocked_by.insert("namespace".into());
406 }
407 if parents
408 .values()
409 .any(|parent| parent.namespace != resolved.summary.namespace)
410 {
411 blocked_by.insert("namespace".into());
412 }
413 Ok(IntegrationPolicyDetail {
414 id: resolved.summary.id,
415 name: resolved.summary.name,
416 namespace: resolved.summary.namespace,
417 description: resolved.summary.description,
418 policy_ids: parents.keys().cloned().collect(),
419 package: resolved.summary.package,
420 affected_agents: parents.values().map(|parent| parent.agents).sum(),
421 blocked_by: blocked_by.into_iter().collect(),
422 })
423}
424
425fn validate_safe_detail_shape(item: &Map<String, Value>, active_space: &str) -> Result<()> {
430 let mut projected = item.clone();
431 projected.insert("enabled".into(), Value::Bool(true));
432 for field in [
433 "is_managed",
434 "supports_agentless",
435 "supports_cloud_connector",
436 ] {
437 projected.insert(field.into(), Value::Bool(false));
438 }
439 for field in ["output_id", "cloud_connector_id", "cloud_connector_name"] {
440 projected.insert(field.into(), Value::Null);
441 }
442 projected.insert("secret_references".into(), Value::Array(Vec::new()));
443 projected.insert("spaceIds".into(), Value::Null);
444 normalize(&projected, active_space).map(|_| ())
445}
446
447pub async fn export(
451 transport: &Transport,
452 selectors: &[String],
453 all_custom: bool,
454 format: ContentFormat,
455) -> Result<ExportOutcome> {
456 if selectors.is_empty() && !all_custom {
457 return Err(Error::new(
458 ErrorKind::Error,
459 "integration-policy export needs selectors or --all-custom",
460 ));
461 }
462 if !selectors.is_empty() && all_custom {
463 return Err(Error::new(
464 ErrorKind::Error,
465 "--all-custom cannot be combined with selectors",
466 ));
467 }
468
469 let mut rows = BTreeMap::new();
470 if all_custom {
471 for item in collect(transport).await? {
472 let summary = summary_from_item(&item)?;
473 if optional_bool(&item, "is_managed", &summary.id)? == Some(true) {
474 continue;
475 }
476 let live = integration_policies::get(transport, &summary.id).await?;
477 if optional_bool(&live.item, "is_managed", &summary.id)? == Some(true) {
478 continue;
479 }
480 rows.insert(
481 summary.id.clone(),
482 ResolvedIntegrationPolicy {
483 summary,
484 item: live.item,
485 },
486 );
487 }
488 } else {
489 for selector in selectors {
490 let resolved = resolve_item(transport, selector).await?;
491 rows.entry(resolved.summary.id.clone()).or_insert(resolved);
492 }
493 }
494
495 let mut specs = Vec::new();
496 for (id, resolved) in rows {
497 let parent_ids = read_parents(&id, &resolved.item)?;
498 let parents = read_parent_snapshots(transport, &id, &parent_ids).await?;
499 if all_custom && parents.values().any(|parent| parent.platform_owned) {
500 continue;
501 }
502 specs.push(effective_spec(transport, &id, &resolved.item, &parents).await?);
503 }
504 specs.sort_by(|left, right| left.id.cmp(&right.id));
505 Ok(ExportOutcome {
506 body: content_codec::encode_sequence(&specs, format)?,
507 exported: specs.len() as u64,
508 missing: Vec::new(),
509 })
510}
511
512fn read_parents(id: &str, item: &Map<String, Value>) -> Result<Vec<String>> {
513 let policy_ids = item
514 .get("policy_ids")
515 .and_then(Value::as_array)
516 .ok_or_else(|| {
517 http(format!(
518 "decoding integration policy '{id}': policy_ids must be an array"
519 ))
520 })?;
521 if policy_ids.is_empty() {
522 return Err(http(format!(
523 "decoding integration policy '{id}': policy_ids must not be empty"
524 )));
525 }
526 let mut ids = Vec::with_capacity(policy_ids.len());
527 for parent in policy_ids {
528 let parent = parent
529 .as_str()
530 .filter(|value| !value.trim().is_empty())
531 .ok_or_else(|| {
532 http(format!(
533 "decoding integration policy '{id}': policy_ids must contain non-empty strings"
534 ))
535 })?;
536 ids.push(parent.to_owned());
537 }
538 ids.sort();
539 if ids.windows(2).any(|pair| pair[0] == pair[1]) {
540 return Err(http(format!(
541 "decoding integration policy '{id}': duplicate policy_ids"
542 )));
543 }
544 Ok(ids)
545}
546
547async fn read_parent_snapshots(
548 transport: &Transport,
549 integration_id: &str,
550 parent_ids: &[String],
551) -> Result<BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>> {
552 let mut parents = BTreeMap::new();
553 for parent_id in parent_ids {
554 let parent = agent_policy_ops::read_parent_snapshot(transport, parent_id).await?;
555 if !parent
556 .attached_integrations
557 .binary_search_by(|attached| attached.as_str().cmp(integration_id))
558 .is_ok()
559 {
560 return Err(http(format!(
561 "decoding integration policy '{integration_id}': parent '{parent_id}' is missing its attachment"
562 )));
563 }
564 parents.insert(parent_id.clone(), parent);
565 }
566 Ok(parents)
567}
568
569async fn effective_spec(
570 transport: &Transport,
571 id: &str,
572 item: &Map<String, Value>,
573 parents: &BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
574) -> Result<IntegrationPolicySpec> {
575 for parent in parents.values() {
576 if parent.platform_owned {
577 return unsupported(format!(
578 "integration policy '{id}' is not portable: parent {} is platform-owned",
579 parent.id
580 ));
581 }
582 if parent.protected {
583 return unsupported(format!(
584 "integration policy '{id}' is not portable: parent {} is_protected",
585 parent.id
586 ));
587 }
588 }
589 let dependency =
590 read_dependencies(transport, &package_coordinate(item, "integration policy")?).await?;
591 let mut spec = normalize(item, transport.space())?;
592 if let Some(namespace) = &spec.namespace {
593 if parents
594 .values()
595 .any(|parent| &parent.namespace != namespace)
596 {
597 return unsupported(format!(
598 "integration policy '{id}' is not portable: namespace does not match every parent"
599 ));
600 }
601 } else {
602 let namespaces: BTreeSet<&str> = parents
603 .values()
604 .map(|parent| parent.namespace.as_str())
605 .collect();
606 if namespaces.len() != 1 {
607 return unsupported(format!(
608 "integration policy '{id}' is not portable: parents have different namespaces"
609 ));
610 }
611 spec.namespace = namespaces.into_iter().next().map(str::to_owned);
612 }
613 match dependency.state {
616 PackageDependencyState::Installed { ref version } if version == &spec.package.version => {}
617 PackageDependencyState::Installed { .. } => {
618 return Err(Error::new(
619 ErrorKind::Conflict,
620 format!(
621 "integration policy '{id}' package {} has a different installed version",
622 dependency.name
623 ),
624 ));
625 }
626 PackageDependencyState::NotInstalled => {
627 return Err(Error::new(
628 ErrorKind::Conflict,
629 format!(
630 "integration policy '{id}' package {} is not installed",
631 dependency.name
632 ),
633 ));
634 }
635 }
636 let metadata = integration_policies::package_metadata(
637 transport,
638 &spec.package.name,
639 &spec.package.version,
640 )
641 .await?;
642 let paths = configured_secret_paths(&spec, &metadata.item)?;
643 if !paths.is_empty() {
644 return unsupported(format!(
645 "integration policy '{id}' is not portable: {}",
646 paths
647 .into_iter()
648 .map(|path| format!("{id}:{path}"))
649 .collect::<Vec<_>>()
650 .join(", ")
651 ));
652 }
653 Ok(spec)
654}
655
656async fn read_dependencies(
657 transport: &Transport,
658 package: &IntegrationPackageSpec,
659) -> Result<PackageDependencySnapshot> {
660 let status = agent_policies::package_status(transport, &package.name).await?;
661 let state = match (status.status.as_str(), status.installed_version) {
662 ("installed", Some(version)) if !version.trim().is_empty() => {
663 PackageDependencyState::Installed { version }
664 }
665 ("not_installed", None) => PackageDependencyState::NotInstalled,
666 _ => {
667 return Err(http(format!(
668 "decoding package dependency '{}': invalid status/version state",
669 package.name
670 )));
671 }
672 };
673 Ok(PackageDependencySnapshot {
674 name: status.name,
675 state,
676 })
677}
678
679fn configured_secret_paths(
680 spec: &IntegrationPolicySpec,
681 metadata: &Map<String, Value>,
682) -> Result<Vec<String>> {
683 let (secrets, known) = secret_schema(metadata)?;
684 let mut paths = BTreeSet::new();
685 configured_vars(
686 spec.vars.as_ref(),
687 &known.package_vars,
688 &secrets.package_vars,
689 &spec.id,
690 "vars",
691 &mut paths,
692 )?;
693 for (input_key, input) in &spec.inputs {
694 let input = input.as_object().ok_or_else(|| {
695 http(format!(
696 "decoding integration policy '{}': inputs.{input_key} must be an object",
697 spec.id
698 ))
699 })?;
700 let known_vars = known.input_vars.get(input_key).ok_or_else(|| {
701 Error::new(
702 ErrorKind::Unsupported,
703 format!(
704 "integration policy '{}': {}:inputs.{input_key} has no matching package definition",
705 spec.id, spec.id
706 ),
707 )
708 })?;
709 let secret_vars = secrets
710 .input_vars
711 .get(input_key)
712 .cloned()
713 .unwrap_or_default();
714 configured_vars(
715 input.get("vars").map(expect_object).transpose()?,
716 known_vars,
717 &secret_vars,
718 &spec.id,
719 &format!("inputs.{input_key}.vars"),
720 &mut paths,
721 )?;
722 if let Some(streams) = input.get("streams") {
723 let streams = streams.as_object().ok_or_else(|| {
724 http(format!(
725 "decoding integration policy '{}': inputs.{input_key}.streams must be an object",
726 spec.id
727 ))
728 })?;
729 for (dataset, stream) in streams {
730 let stream = stream.as_object().ok_or_else(|| {
731 http(format!(
732 "decoding integration policy '{}': inputs.{input_key}.streams.{dataset} must be an object",
733 spec.id
734 ))
735 })?;
736 let key = (input_key.clone(), dataset.clone());
737 let known_vars = known.stream_vars.get(&key).ok_or_else(|| {
738 Error::new(
739 ErrorKind::Unsupported,
740 format!(
741 "integration policy '{}': {}:inputs.{input_key}.streams.{dataset} has no matching package definition",
742 spec.id, spec.id
743 ),
744 )
745 })?;
746 let secret_vars = secrets.stream_vars.get(&key).cloned().unwrap_or_default();
747 configured_vars(
748 stream.get("vars").map(expect_object).transpose()?,
749 known_vars,
750 &secret_vars,
751 &spec.id,
752 &format!("inputs.{input_key}.streams.{dataset}.vars"),
753 &mut paths,
754 )?;
755 }
756 }
757 }
758 Ok(paths.into_iter().collect())
759}
760
761fn expect_object(value: &Value) -> Result<&Map<String, Value>> {
762 value
763 .as_object()
764 .ok_or_else(|| http("decoding integration policy: configured vars must be an object"))
765}
766
767fn configured_vars(
768 configured: Option<&Map<String, Value>>,
769 known: &BTreeSet<String>,
770 secret: &BTreeSet<String>,
771 policy_id: &str,
772 prefix: &str,
773 paths: &mut BTreeSet<String>,
774) -> Result<()> {
775 let Some(configured) = configured else {
776 return Ok(());
777 };
778 for name in configured.keys() {
779 if !known.contains(name) {
780 return Err(Error::new(
781 ErrorKind::Unsupported,
782 format!(
783 "integration policy '{policy_id}' is not portable: {policy_id}:{prefix}.{name} has no matching package definition"
784 ),
785 ));
786 }
787 if secret.contains(name) {
788 paths.insert(format!("{prefix}.{name}"));
789 }
790 }
791 Ok(())
792}
793
794fn secret_schema(metadata: &Map<String, Value>) -> Result<(SecretSchema, KnownSchema)> {
795 let package_name = metadata_name(metadata, "name", "package metadata")?;
796 let package_vars = parse_var_definitions(metadata.get("vars"), "package metadata vars")?;
797 let modern_datasets = parse_modern_data_streams(metadata.get("data_streams"))?;
798
799 let mut secrets = SecretSchema {
800 package_vars: package_vars.secrets.clone(),
801 ..SecretSchema::default()
802 };
803 let mut known = KnownSchema {
804 package_vars: package_vars.known,
805 ..KnownSchema::default()
806 };
807 let mut template_names = BTreeSet::new();
808 let mut templates = Vec::new();
809 let mut legacy_streams = BTreeMap::new();
810 let policy_templates = match metadata.get("policy_templates") {
811 None => &[][..],
812 Some(Value::Array(value)) => value,
813 Some(_) => {
814 return Err(http(
815 "decoding package metadata: policy_templates must be an array",
816 ));
817 }
818 };
819
820 for template in policy_templates {
821 let template = template.as_object().ok_or_else(|| {
822 http("decoding package metadata: policy_templates entry must be an object")
823 })?;
824 let template_name = metadata_name(template, "name", "policy_templates entry")?;
825 if !template_names.insert(template_name.clone()) {
826 return Err(http(format!(
827 "decoding package metadata: duplicate template name '{template_name}'"
828 )));
829 }
830 let datasets = resolve_template_datasets(
831 template.get("data_streams"),
832 &modern_datasets,
833 &package_name,
834 &template_name,
835 )?;
836 let inputs = match template.get("inputs") {
837 None => &[][..],
838 Some(Value::Array(value)) => value,
839 Some(_) => {
840 return Err(http(
841 "decoding package metadata: policy_templates inputs must be an array",
842 ));
843 }
844 };
845 let mut template_inputs = BTreeMap::new();
846 for input in inputs {
847 let input = input.as_object().ok_or_else(|| {
848 http("decoding package metadata: policy_templates inputs entry must be an object")
849 })?;
850 let input_type = metadata_name(input, "type", "policy_templates input")?;
851 let input_key = format!("{template_name}-{input_type}");
852 let input_vars =
853 parse_var_definitions(input.get("vars"), "package metadata input vars")?;
854 if template_inputs
855 .insert(input_type.clone(), input_key.clone())
856 .is_some()
857 || known
858 .input_vars
859 .insert(input_key.clone(), input_vars.known)
860 .is_some()
861 {
862 return Err(http(format!(
863 "decoding package metadata: duplicate input key '{input_key}'"
864 )));
865 }
866 if !input_vars.secrets.is_empty() {
867 secrets
868 .input_vars
869 .insert(input_key.clone(), input_vars.secrets);
870 }
871
872 let streams = match input.get("streams") {
873 None => &[][..],
874 Some(Value::Array(value)) => value,
875 Some(_) => {
876 return Err(http(
877 "decoding package metadata: input streams must be an array",
878 ));
879 }
880 };
881 for stream in streams {
882 let stream = stream.as_object().ok_or_else(|| {
883 http("decoding package metadata: input streams entry must be an object")
884 })?;
885 let data_stream = stream
886 .get("data_stream")
887 .and_then(Value::as_object)
888 .ok_or_else(|| {
889 http("decoding package metadata: stream data_stream must be an object")
890 })?;
891 let dataset = metadata_name(data_stream, "dataset", "stream data_stream")?;
892 let definition =
893 parse_var_definitions(stream.get("vars"), "package metadata stream vars")?;
894 let key = (input_key.clone(), dataset);
895 if legacy_streams.insert(key.clone(), definition).is_some() {
896 return Err(http(format!(
897 "decoding package metadata: duplicate stream key '{}:{}'",
898 key.0, key.1
899 )));
900 }
901 }
902 }
903 templates.push(TemplateDefinitions {
904 inputs: template_inputs,
905 datasets,
906 });
907 }
908
909 let mut modern_streams = BTreeMap::new();
910 for (dataset, streams) in &modern_datasets {
911 for (input_type, definition) in streams {
912 let candidates = templates
913 .iter()
914 .filter(|template| template.datasets.contains(dataset))
915 .filter_map(|template| template.inputs.get(input_type))
916 .cloned()
917 .collect::<Vec<_>>();
918 let input_key = match candidates.as_slice() {
919 [input_key] => input_key.clone(),
920 [] => {
921 return Err(http(format!(
922 "decoding package metadata: stream '{dataset}:{input_type}' has no matching template input"
923 )));
924 }
925 _ => {
926 return Err(http(format!(
927 "decoding package metadata: stream '{dataset}:{input_type}' has multiple matching template inputs"
928 )));
929 }
930 };
931 let key = (input_key, dataset.clone());
932 if modern_streams
933 .insert(key.clone(), definition.clone())
934 .is_some()
935 {
936 return Err(http(format!(
937 "decoding package metadata: duplicate stream key '{}:{}'",
938 key.0, key.1
939 )));
940 }
941 }
942 }
943
944 for (key, definition) in legacy_streams {
945 match modern_streams.get(&key) {
946 Some(modern) if modern != &definition => {
947 return Err(http(format!(
948 "decoding package metadata: conflicting modern and legacy stream definition '{}:{}'",
949 key.0, key.1
950 )));
951 }
952 Some(_) => {}
953 None => {
954 modern_streams.insert(key, definition);
955 }
956 }
957 }
958 for (key, definition) in modern_streams {
959 if !definition.secrets.is_empty() {
960 secrets.stream_vars.insert(key.clone(), definition.secrets);
961 }
962 known.stream_vars.insert(key, definition.known);
963 }
964 Ok((secrets, known))
965}
966
967fn parse_modern_data_streams(
968 value: Option<&Value>,
969) -> Result<BTreeMap<String, BTreeMap<String, VariableDefinitions>>> {
970 let data_streams = match value {
971 None => &[][..],
972 Some(Value::Array(value)) => value,
973 Some(_) => {
974 return Err(http(
975 "decoding package metadata: data_streams must be an array",
976 ));
977 }
978 };
979 let mut datasets = BTreeMap::new();
980 for data_stream in data_streams {
981 let data_stream = data_stream.as_object().ok_or_else(|| {
982 http("decoding package metadata: data_streams entry must be an object")
983 })?;
984 let dataset = metadata_name(data_stream, "dataset", "data_streams entry")?;
985 let streams = match data_stream.get("streams") {
986 None => &[][..],
987 Some(Value::Array(value)) => value,
988 Some(_) => {
989 return Err(http(
990 "decoding package metadata: data_streams streams must be an array",
991 ));
992 }
993 };
994 let mut stream_definitions = BTreeMap::new();
995 for stream in streams {
996 let stream = stream.as_object().ok_or_else(|| {
997 http("decoding package metadata: data_streams streams entry must be an object")
998 })?;
999 let input = metadata_name(stream, "input", "data_streams stream")?;
1000 let definition =
1001 parse_var_definitions(stream.get("vars"), "package metadata stream vars")?;
1002 if stream_definitions
1003 .insert(input.clone(), definition)
1004 .is_some()
1005 {
1006 return Err(http(format!(
1007 "decoding package metadata: duplicate stream input '{input}' for dataset '{dataset}'"
1008 )));
1009 }
1010 }
1011 if datasets
1012 .insert(dataset.clone(), stream_definitions)
1013 .is_some()
1014 {
1015 return Err(http(format!(
1016 "decoding package metadata: duplicate data stream dataset '{dataset}'"
1017 )));
1018 }
1019 }
1020 Ok(datasets)
1021}
1022
1023fn resolve_template_datasets(
1024 value: Option<&Value>,
1025 datasets: &BTreeMap<String, BTreeMap<String, VariableDefinitions>>,
1026 package_name: &str,
1027 template_name: &str,
1028) -> Result<BTreeSet<String>> {
1029 let Some(value) = value else {
1030 return Ok(datasets.keys().cloned().collect());
1031 };
1032 let selectors = value
1033 .as_array()
1034 .ok_or_else(|| http("decoding package metadata: template data_streams must be an array"))?;
1035 let mut selected = BTreeSet::new();
1036 let mut seen = BTreeSet::new();
1037 for selector in selectors {
1038 let selector = selector
1039 .as_str()
1040 .filter(|selector| !selector.trim().is_empty())
1041 .ok_or_else(|| {
1042 http("decoding package metadata: template data_streams selector must be a non-empty string")
1043 })?;
1044 if !seen.insert(selector) {
1045 return Err(http(format!(
1046 "decoding package metadata: duplicate data_streams selector '{selector}' in template '{template_name}'"
1047 )));
1048 }
1049 let short_name = format!("{package_name}.{selector}");
1050 let candidates = datasets
1051 .keys()
1052 .filter(|dataset| dataset.as_str() == selector || dataset.as_str() == short_name)
1053 .collect::<Vec<_>>();
1054 match candidates.as_slice() {
1055 [dataset] => {
1056 if !selected.insert((**dataset).clone()) {
1057 return Err(http(format!(
1058 "decoding package metadata: duplicate data_streams dataset '{}' in template '{template_name}'",
1059 dataset
1060 )));
1061 }
1062 }
1063 [] => {
1064 return Err(http(format!(
1065 "decoding package metadata: data_streams selector '{selector}' in template '{template_name}' does not match a dataset"
1066 )));
1067 }
1068 _ => {
1069 return Err(http(format!(
1070 "decoding package metadata: data_streams selector '{selector}' in template '{template_name}' matches multiple datasets"
1071 )));
1072 }
1073 }
1074 }
1075 Ok(selected)
1076}
1077
1078fn parse_var_definitions(value: Option<&Value>, context: &str) -> Result<VariableDefinitions> {
1079 let values = match value {
1080 None => return Ok(VariableDefinitions::default()),
1081 Some(Value::Array(values)) => values,
1082 Some(_) => return Err(http(format!("decoding {context}: vars must be an array"))),
1083 };
1084 let mut definitions = VariableDefinitions::default();
1085 for value in values {
1086 let definition = value
1087 .as_object()
1088 .ok_or_else(|| http(format!("decoding {context}: variable must be an object")))?;
1089 let name = metadata_name(definition, "name", context)?;
1090 if !definitions.known.insert(name.clone()) {
1091 return Err(http(format!(
1092 "decoding {context}: duplicate variable name '{name}'"
1093 )));
1094 }
1095 match definition.get("secret") {
1096 None => {}
1097 Some(Value::Bool(true)) => {
1098 definitions.secrets.insert(name);
1099 }
1100 Some(Value::Bool(false)) => {}
1101 Some(_) => {
1102 return Err(http(format!(
1103 "decoding {context}: secret must be a boolean"
1104 )));
1105 }
1106 }
1107 }
1108 Ok(definitions)
1109}
1110
1111fn metadata_name(object: &Map<String, Value>, field: &str, context: &str) -> Result<String> {
1112 object
1113 .get(field)
1114 .and_then(Value::as_str)
1115 .filter(|value| !value.trim().is_empty())
1116 .map(str::to_owned)
1117 .ok_or_else(|| {
1118 http(format!(
1119 "decoding package metadata: {context} {field} must be a non-empty string"
1120 ))
1121 })
1122}
1123
1124fn live_blocked_by(
1125 item: &Map<String, Value>,
1126 id: &str,
1127 active_space: &str,
1128) -> Result<BTreeSet<String>> {
1129 let mut reasons = BTreeSet::new();
1130 match item.get("enabled") {
1131 Some(Value::Bool(true)) => {}
1132 Some(Value::Bool(false)) => {
1133 reasons.insert("enabled".into());
1134 }
1135 _ => {
1136 return Err(http(format!(
1137 "decoding integration policy '{id}': enabled must be true or false"
1138 )));
1139 }
1140 }
1141 for field in [
1142 "is_managed",
1143 "supports_agentless",
1144 "supports_cloud_connector",
1145 ] {
1146 if optional_bool(item, field, id)? == Some(true) {
1147 reasons.insert(field.to_owned());
1148 }
1149 }
1150 for field in ["output_id", "cloud_connector_id", "cloud_connector_name"] {
1151 match item.get(field) {
1152 None | Some(Value::Null) | Some(Value::Bool(false)) => {}
1153 Some(Value::String(_)) => {
1154 reasons.insert(field.to_owned());
1155 }
1156 Some(_) => {
1157 return Err(http(format!(
1158 "decoding integration policy '{id}': {field} must be a string or null"
1159 )));
1160 }
1161 }
1162 }
1163 match item.get("secret_references") {
1164 None | Some(Value::Null) => {}
1165 Some(Value::Array(values)) if values.is_empty() => {}
1166 Some(Value::Array(_)) => {
1167 reasons.insert("secret_references".into());
1168 }
1169 Some(_) => {
1170 return Err(http(format!(
1171 "decoding integration policy '{id}': secret_references must be an array or null"
1172 )));
1173 }
1174 }
1175 let active = if active_space.is_empty() {
1176 "default"
1177 } else {
1178 active_space
1179 };
1180 match item.get("spaceIds") {
1181 None | Some(Value::Null) => {}
1182 Some(Value::Array(spaces)) => {
1183 for space in spaces {
1184 let space = space.as_str().filter(|value| !value.is_empty()).ok_or_else(|| {
1185 http(format!("decoding integration policy '{id}': spaceIds must contain non-empty strings"))
1186 })?;
1187 if space != active {
1188 reasons.insert("spaceIds".into());
1189 }
1190 }
1191 }
1192 Some(_) => {
1193 return Err(http(format!(
1194 "decoding integration policy '{id}': spaceIds must be an array or null"
1195 )));
1196 }
1197 }
1198 Ok(reasons)
1199}
1200
1201const PORTABLE_OPTIONAL: [&str; 6] = [
1202 "description",
1203 "namespace",
1204 "vars",
1205 "var_group_selections",
1206 "condition",
1207 "additional_datastreams_permissions",
1208];
1209
1210const REMOVED_FIELDS: [&str; 19] = [
1211 "agents",
1212 "cloud_connector_id",
1213 "cloud_connector_name",
1214 "created_at",
1215 "created_by",
1216 "elasticsearch",
1217 "enabled",
1218 "is_managed",
1219 "output_id",
1220 "package_agent_version_condition",
1221 "policy_id",
1222 "revision",
1223 "secret_references",
1224 "spaceIds",
1225 "supports_agentless",
1226 "supports_cloud_connector",
1227 "updated_at",
1228 "updated_by",
1229 "version",
1230];
1231
1232pub fn normalize(item: &Map<String, Value>, active_space: &str) -> Result<IntegrationPolicySpec> {
1234 let id = required_string(item, "id", "integration policy")?;
1235 portability_check(item, &id, active_space)?;
1236 reject_unknown_top_level(item, &id)?;
1237
1238 let mut portable = Map::new();
1239 for field in ["id", "name"] {
1240 if let Some(value) = item.get(field) {
1241 portable.insert(field.to_owned(), value.clone());
1242 }
1243 }
1244 portable.insert(
1245 "policy_ids".to_owned(),
1246 Value::Array(
1247 read_parents(&id, item)?
1248 .into_iter()
1249 .map(Value::String)
1250 .collect(),
1251 ),
1252 );
1253 portable.insert("package".to_owned(), normalize_package(item, &id)?);
1254 portable.insert("inputs".to_owned(), normalize_inputs(item, &id)?);
1255 for field in PORTABLE_OPTIONAL {
1256 if let Some(value) = item.get(field)
1257 && !value.is_null()
1258 {
1259 portable.insert(field.to_owned(), value.clone());
1260 }
1261 }
1262 IntegrationPolicySpec::try_from(Value::Object(portable)).map_err(|error| {
1263 http(format!(
1264 "decoding integration policy '{id}': {}",
1265 error.message
1266 ))
1267 })
1268}
1269
1270fn normalize_package(item: &Map<String, Value>, id: &str) -> Result<Value> {
1271 let package = item
1272 .get("package")
1273 .and_then(Value::as_object)
1274 .ok_or_else(|| {
1275 http(format!(
1276 "decoding integration policy '{id}': package must be an object"
1277 ))
1278 })?;
1279 for (field, expected) in [("title", "a string")] {
1280 if let Some(value) = package.get(field)
1281 && !value.is_null()
1282 && !value.is_string()
1283 {
1284 return Err(http(format!(
1285 "decoding integration policy '{id}': package.{field} must be {expected} or null"
1286 )));
1287 }
1288 }
1289 for field in ["requires_root", "fips_compatible"] {
1290 if let Some(value) = package.get(field)
1291 && !value.is_null()
1292 && !value.is_boolean()
1293 {
1294 return Err(http(format!(
1295 "decoding integration policy '{id}': package.{field} must be a boolean or null"
1296 )));
1297 }
1298 }
1299 let mut portable = Map::new();
1300 for field in ["name", "version"] {
1301 if let Some(value) = package.get(field) {
1302 portable.insert(field.to_owned(), value.clone());
1303 }
1304 }
1305 let known: BTreeSet<&str> = [
1306 "name",
1307 "version",
1308 "title",
1309 "requires_root",
1310 "fips_compatible",
1311 ]
1312 .into_iter()
1313 .collect();
1314 if let Some(field) = package
1315 .keys()
1316 .map(String::as_str)
1317 .filter(|field| !known.contains(field))
1318 .min()
1319 {
1320 return Err(Error::new(
1321 ErrorKind::Unsupported,
1322 format!("integration policy '{id}' carries unknown package field '{field}'"),
1323 ));
1324 }
1325 Ok(Value::Object(portable))
1326}
1327
1328fn normalize_inputs(item: &Map<String, Value>, id: &str) -> Result<Value> {
1329 let inputs = item
1330 .get("inputs")
1331 .and_then(Value::as_object)
1332 .ok_or_else(|| {
1333 http(format!(
1334 "decoding integration policy '{id}': inputs must be an object"
1335 ))
1336 })?;
1337 let mut normalized = Map::new();
1338 for (input_id, input) in inputs {
1339 normalized.insert(
1340 input_id.clone(),
1341 normalize_package_map(input, "compiled_input")?,
1342 );
1343 }
1344 Ok(Value::Object(normalized))
1345}
1346
1347fn normalize_package_map(value: &Value, compiled_field: &str) -> Result<Value> {
1350 let object = value
1351 .as_object()
1352 .ok_or_else(|| http("decoding integration policy: input must be an object"))?;
1353 let mut normalized = Map::new();
1354 for (field, value) in object {
1355 if field == "id" {
1356 if !value.is_string() {
1357 return Err(http(
1358 "decoding integration policy: generated id must be a string",
1359 ));
1360 }
1361 continue;
1362 }
1363 if field == compiled_field {
1364 if !value.is_object() {
1365 return Err(http(format!(
1366 "decoding integration policy: {compiled_field} must be an object"
1367 )));
1368 }
1369 continue;
1370 }
1371 if field == "streams" {
1372 let streams = value.as_object().ok_or_else(|| {
1373 http("decoding integration policy: input streams must be an object")
1374 })?;
1375 let mut normalized_streams = Map::new();
1376 for (stream_id, stream) in streams {
1377 normalized_streams.insert(
1378 stream_id.clone(),
1379 normalize_package_map(stream, "compiled_stream")?,
1380 );
1381 }
1382 normalized.insert(field.clone(), Value::Object(normalized_streams));
1383 } else {
1384 normalized.insert(field.clone(), value.clone());
1385 }
1386 }
1387 Ok(Value::Object(normalized))
1388}
1389
1390fn portability_check(item: &Map<String, Value>, id: &str, active_space: &str) -> Result<()> {
1391 let mut reasons = BTreeSet::new();
1392 required_true(item, "enabled", id)?;
1393 if let Some(value) = item.get("elasticsearch")
1394 && !value.is_object()
1395 {
1396 return Err(http(format!(
1397 "decoding integration policy '{id}': elasticsearch must be an object"
1398 )));
1399 }
1400 if let Some(value) = item.get("package_agent_version_condition")
1401 && !value.is_null()
1402 && !value.is_string()
1403 {
1404 return Err(http(format!(
1405 "decoding integration policy '{id}': package_agent_version_condition must be a string or null"
1406 )));
1407 }
1408 for field in [
1409 "is_managed",
1410 "supports_agentless",
1411 "supports_cloud_connector",
1412 ] {
1413 if let Some(true) = optional_bool(item, field, id)? {
1414 reasons.insert(field);
1415 }
1416 }
1417 for field in ["output_id", "cloud_connector_id", "cloud_connector_name"] {
1418 match item.get(field) {
1419 None | Some(Value::Null) | Some(Value::Bool(false)) => {}
1420 Some(Value::String(_)) => {
1421 reasons.insert(field);
1422 }
1423 Some(_) => {
1424 return Err(http(format!(
1425 "decoding integration policy '{id}': {field} must be a string or null"
1426 )));
1427 }
1428 }
1429 }
1430 match item.get("secret_references") {
1431 None | Some(Value::Null) => {}
1432 Some(Value::Array(references)) if references.is_empty() => {}
1433 Some(Value::Array(_)) => {
1434 reasons.insert("secret_references");
1435 }
1436 Some(_) => {
1437 return Err(http(format!(
1438 "decoding integration policy '{id}': secret_references must be an array or null"
1439 )));
1440 }
1441 }
1442 let active = if active_space.is_empty() {
1443 "default"
1444 } else {
1445 active_space
1446 };
1447 match item.get("spaceIds") {
1448 None | Some(Value::Null) => {}
1449 Some(Value::Array(spaces)) => {
1450 for space in spaces {
1451 let space = space.as_str().filter(|space| !space.is_empty()).ok_or_else(|| {
1452 http(format!(
1453 "decoding integration policy '{id}': spaceIds must contain non-empty strings"
1454 ))
1455 })?;
1456 if space != active {
1457 reasons.insert("spaceIds");
1458 }
1459 }
1460 }
1461 Some(_) => {
1462 return Err(http(format!(
1463 "decoding integration policy '{id}': spaceIds must be an array or null"
1464 )));
1465 }
1466 }
1467 if let Some(policy_id) = item.get("policy_id").filter(|value| !value.is_null()) {
1468 let policy_id = policy_id
1469 .as_str()
1470 .filter(|value| !value.is_empty())
1471 .ok_or_else(|| {
1472 http(format!(
1473 "decoding integration policy '{id}': policy_id must be a non-empty string"
1474 ))
1475 })?;
1476 let policy_ids = item
1477 .get("policy_ids")
1478 .and_then(Value::as_array)
1479 .ok_or_else(|| {
1480 http(format!(
1481 "decoding integration policy '{id}': policy_ids must be an array"
1482 ))
1483 })?;
1484 if policy_ids.first().and_then(Value::as_str) != Some(policy_id) {
1485 return Err(http(format!(
1486 "decoding integration policy '{id}': policy_id must equal policy_ids[0]"
1487 )));
1488 }
1489 }
1490 if reasons.is_empty() {
1491 Ok(())
1492 } else {
1493 unsupported(format!(
1494 "integration policy '{id}' is not portable: {}",
1495 reasons.into_iter().collect::<Vec<_>>().join(", ")
1496 ))
1497 }
1498}
1499
1500fn required_true(item: &Map<String, Value>, field: &str, id: &str) -> Result<()> {
1501 match item.get(field) {
1502 Some(Value::Bool(true)) => Ok(()),
1503 Some(Value::Bool(false)) => unsupported(format!(
1504 "integration policy '{id}' is not portable: {field}"
1505 )),
1506 _ => Err(http(format!(
1507 "decoding integration policy '{id}': {field} must be true"
1508 ))),
1509 }
1510}
1511
1512fn optional_bool(item: &Map<String, Value>, field: &str, id: &str) -> Result<Option<bool>> {
1513 match item.get(field) {
1514 None | Some(Value::Null) => Ok(None),
1515 Some(Value::Bool(value)) => Ok(Some(*value)),
1516 Some(_) => Err(http(format!(
1517 "decoding integration policy '{id}': {field} must be a boolean or null"
1518 ))),
1519 }
1520}
1521
1522fn reject_unknown_top_level(item: &Map<String, Value>, id: &str) -> Result<()> {
1523 let known: BTreeSet<&str> = ["id", "name", "policy_ids", "package", "inputs"]
1524 .into_iter()
1525 .chain(PORTABLE_OPTIONAL)
1526 .chain(REMOVED_FIELDS)
1527 .collect();
1528 if let Some(field) = item
1529 .keys()
1530 .map(String::as_str)
1531 .filter(|field| !known.contains(field))
1532 .min()
1533 {
1534 return unsupported(format!(
1535 "integration policy '{id}' carries unknown field '{field}'"
1536 ));
1537 }
1538 Ok(())
1539}
1540
1541fn summary_from_item(item: &Map<String, Value>) -> Result<IntegrationPolicySummary> {
1542 let id = required_string(item, "id", "integration policy")?;
1543 let name = required_string(item, "name", "integration policy")?;
1544 let namespace = required_string(item, "namespace", "integration policy")?;
1545 let description = match item.get("description") {
1546 None | Some(Value::Null) => None,
1547 Some(Value::String(value)) => Some(value.clone()),
1548 Some(_) => {
1549 return Err(http(
1550 "decoding integration policy: description must be a string or null",
1551 ));
1552 }
1553 };
1554 let policy_ids = item
1555 .get("policy_ids")
1556 .and_then(Value::as_array)
1557 .ok_or_else(|| http("decoding integration policy: policy_ids must be an array"))?
1558 .iter()
1559 .map(|value| {
1560 value
1561 .as_str()
1562 .filter(|value| !value.is_empty())
1563 .map(str::to_owned)
1564 .ok_or_else(|| {
1565 http("decoding integration policy: policy_ids must contain non-empty strings")
1566 })
1567 })
1568 .collect::<Result<Vec<_>>>()?;
1569 let package = package_coordinate(item, "integration policy")?;
1570 Ok(IntegrationPolicySummary {
1571 id,
1572 name,
1573 namespace,
1574 description,
1575 policy_ids,
1576 package,
1577 })
1578}
1579
1580fn package_coordinate(item: &Map<String, Value>, context: &str) -> Result<IntegrationPackageSpec> {
1581 let package = item
1582 .get("package")
1583 .and_then(Value::as_object)
1584 .ok_or_else(|| http(format!("decoding {context}: package must be an object")))?;
1585 Ok(IntegrationPackageSpec {
1586 name: package_required_string(package, "name", context)?,
1587 version: package_required_string(package, "version", context)?,
1588 })
1589}
1590
1591fn package_required_string(
1592 package: &Map<String, Value>,
1593 field: &str,
1594 context: &str,
1595) -> Result<String> {
1596 package
1597 .get(field)
1598 .and_then(Value::as_str)
1599 .filter(|value| !value.trim().is_empty())
1600 .map(str::to_owned)
1601 .ok_or_else(|| {
1602 http(format!(
1603 "decoding {context}: package.{field} must be a non-empty string"
1604 ))
1605 })
1606}
1607
1608fn required_string(item: &Map<String, Value>, field: &str, context: &str) -> Result<String> {
1609 item.get(field)
1610 .and_then(Value::as_str)
1611 .filter(|value| !value.trim().is_empty())
1612 .map(str::to_owned)
1613 .ok_or_else(|| {
1614 http(format!(
1615 "decoding {context}: {field} must be a non-empty string"
1616 ))
1617 })
1618}
1619
1620pub fn prepare_import(path: &Path) -> Result<IntegrationPolicyImportArtifact> {
1623 let canonical = validate(path)?;
1624 if canonical.is_empty() {
1625 return Err(Error::new(
1626 ErrorKind::Error,
1627 "integration-policy import needs at least one integration policy",
1628 ));
1629 }
1630 validate_requested_package_versions(&canonical)?;
1631 Ok(IntegrationPolicyImportArtifact {
1632 source: path.to_path_buf(),
1633 canonical,
1634 })
1635}
1636
1637pub async fn plan_prepared_import(
1640 transport: &Transport,
1641 artifact: IntegrationPolicyImportArtifact,
1642 overwrite: bool,
1643 skip_existing: bool,
1644) -> Result<IntegrationPolicyImportPlan> {
1645 let IntegrationPolicyImportArtifact { source, canonical } = artifact;
1646 if overwrite && skip_existing {
1647 return Err(Error::new(
1648 ErrorKind::Error,
1649 "--overwrite and --skip-existing cannot be used together",
1650 ));
1651 }
1652
1653 let mut existing = BTreeMap::new();
1657 let mut conflicts = Vec::new();
1658 for spec in &canonical {
1659 match integration_policies::get(transport, &spec.id).await {
1660 Ok(policy) => {
1661 let returned_id = required_string(&policy.item, "id", "integration policy get")?;
1662 if returned_id != spec.id {
1663 return Err(http(
1664 "decoding integration policy get: response id did not match the request",
1665 ));
1666 }
1667 if !overwrite && !skip_existing {
1668 conflicts.push(spec.id.clone());
1669 }
1670 existing.insert(spec.id.clone(), Some(policy.item));
1671 }
1672 Err(error) if error.kind == ErrorKind::NotFound => {
1673 existing.insert(spec.id.clone(), None);
1674 }
1675 Err(error) => return Err(import_remote_error(error, "planning read")),
1676 }
1677 }
1678 if !conflicts.is_empty() {
1679 return Err(Error::new(
1680 ErrorKind::Conflict,
1681 format!(
1682 "integration policies already exist: {}",
1683 conflicts.join(", ")
1684 ),
1685 ));
1686 }
1687
1688 for spec in &canonical {
1692 if skip_existing && matches!(existing.get(&spec.id), Some(Some(_))) {
1693 continue;
1694 }
1695 let Some(Some(item)) = existing.get(&spec.id) else {
1696 continue;
1697 };
1698 let current = package_coordinate(item, "integration policy")
1699 .map_err(|error| import_remote_error(error, "planning read"))?;
1700 if current != spec.package {
1701 return unsupported(format!(
1702 "integration policy '{}' cannot change package {}@{} to {}@{}",
1703 spec.id, current.name, current.version, spec.package.name, spec.package.version
1704 ));
1705 }
1706 }
1707
1708 let names: BTreeSet<String> = canonical.iter().map(|spec| spec.name.clone()).collect();
1712 let name_owners = relevant_name_owners(transport, &names)
1713 .await
1714 .map_err(|error| import_remote_error(error, "planning names read"))?;
1715 let mut name_conflicts = Vec::new();
1716 for spec in &canonical {
1717 let owners = name_owners
1718 .get(&spec.name)
1719 .expect("requested name has an ownership entry");
1720 for owner in owners.iter().filter(|owner| owner.as_str() != spec.id) {
1721 name_conflicts.push(format!("{} ({owner})", spec.name));
1722 }
1723 }
1724 if !name_conflicts.is_empty() {
1725 return Err(Error::new(
1726 ErrorKind::Conflict,
1727 format!(
1728 "integration policy names already exist: {}",
1729 name_conflicts.join(", ")
1730 ),
1731 ));
1732 }
1733
1734 let mut skipped = Vec::new();
1735 let pending: Vec<IntegrationPolicySpec> = canonical
1736 .iter()
1737 .filter_map(|spec| match existing.get(&spec.id) {
1738 Some(Some(item)) if skip_existing => {
1739 skipped.push(json!({"id": spec.id, "reason": "exists"}));
1740 None
1741 }
1742 _ => Some(spec.clone()),
1743 })
1744 .collect();
1745
1746 let mut targets = Vec::with_capacity(pending.len());
1747 let mut shared_parents = BTreeMap::new();
1748 for spec in pending {
1749 let raw = existing
1750 .get(&spec.id)
1751 .expect("every canonical id was fetched")
1752 .clone();
1753 let current_parent_ids = raw
1754 .as_ref()
1755 .map(|item| {
1756 read_parents(&spec.id, item)
1757 .map_err(|error| import_remote_error(error, "planning read"))
1758 })
1759 .transpose()?;
1760 let parents = read_import_parent_snapshots(
1761 transport,
1762 &spec.id,
1763 current_parent_ids.as_deref().unwrap_or_default(),
1764 &spec.policy_ids,
1765 )
1766 .await
1767 .map_err(|error| import_remote_error(error, "planning parent read"))?;
1768 for (parent_id, parent) in &parents {
1769 match shared_parents.entry(parent_id.clone()) {
1770 std::collections::btree_map::Entry::Vacant(entry) => {
1771 entry.insert(parent.clone());
1772 }
1773 std::collections::btree_map::Entry::Occupied(entry) if entry.get() != parent => {
1774 return Err(Error::new(
1775 ErrorKind::Conflict,
1776 format!(
1777 "agent policy '{parent_id}' changed while planning integration import"
1778 ),
1779 ));
1780 }
1781 std::collections::btree_map::Entry::Occupied(_) => {}
1782 }
1783 }
1784 let effective = effective_import_spec(&spec, &parents)?;
1785 let current = raw
1786 .map(|item| {
1787 let normalized = normalize(&item, transport.space())
1788 .map_err(|error| import_remote_error(error, "planning read"))?;
1789 if normalized.id != spec.id {
1790 return Err(http(
1791 "decoding integration policy: response id did not match the request",
1792 ));
1793 }
1794 Ok(IntegrationPolicyCurrentSnapshot {
1795 item,
1796 spec: normalized,
1797 parent_ids: current_parent_ids.expect("raw policy has parents"),
1798 })
1799 })
1800 .transpose()?;
1801 targets.push(IntegrationPolicyImportTarget {
1802 effective,
1803 current,
1804 parents,
1805 replacement_body: None,
1806 });
1807 }
1808 if targets
1809 .iter()
1810 .any(|target| !target_name_owners_match(target, &name_owners))
1811 {
1812 return Err(Error::new(
1813 ErrorKind::Conflict,
1814 "integration policy name ownership changed while planning",
1815 ));
1816 }
1817
1818 let mut package_coordinates = BTreeMap::new();
1819 for target in &targets {
1820 package_coordinates
1821 .entry(target.effective.package.name.clone())
1822 .or_insert_with(|| target.effective.package.clone());
1823 }
1824 let mut package_groups = BTreeMap::new();
1825 for (name, package) in package_coordinates {
1826 let state = read_dependencies(transport, &package)
1827 .await
1828 .map_err(|error| import_remote_error(error, "planning package read"))?;
1829 match &state.state {
1830 PackageDependencyState::Installed { version } if version == &package.version => {}
1831 PackageDependencyState::Installed { .. } => {
1832 return Err(Error::new(
1833 ErrorKind::Conflict,
1834 format!("integration package {name} has a different installed version"),
1835 ));
1836 }
1837 PackageDependencyState::NotInstalled => {
1838 if targets
1839 .iter()
1840 .any(|target| target.effective.package.name == name && target.current.is_some())
1841 {
1842 return Err(Error::new(
1843 ErrorKind::Conflict,
1844 format!("integration package {name} is not installed"),
1845 ));
1846 }
1847 }
1848 }
1849 let metadata =
1850 integration_policies::package_metadata(transport, &package.name, &package.version)
1851 .await
1852 .map_err(|error| import_remote_error(error, "planning package metadata read"))?
1853 .item;
1854 validate_package_metadata_snapshot(&metadata, &package)
1855 .map_err(|error| import_remote_error(error, "planning package metadata read"))?;
1856 package_groups.insert(
1857 name,
1858 IntegrationPackageGroup {
1859 package,
1860 state: state.clone(),
1861 state_snapshot: state,
1862 metadata_snapshot: metadata.clone(),
1863 metadata,
1864 },
1865 );
1866 }
1867
1868 for target in &targets {
1869 let package = package_groups
1870 .get(&target.effective.package.name)
1871 .expect("every effective package has a group");
1872 validate_effective_input_materialization(&target.effective, &package.metadata)?;
1873 }
1874
1875 let mut secret_paths = BTreeSet::new();
1876 for target in &targets {
1877 let package = package_groups
1878 .get(&target.effective.package.name)
1879 .expect("every effective package has a group");
1880 if let Some(current) = &target.current {
1881 for path in configured_secret_paths(¤t.spec, &package.metadata)? {
1882 secret_paths.insert(format!("{}:{path}", current.spec.id));
1883 }
1884 }
1885 for path in configured_secret_paths(&target.effective, &package.metadata)? {
1886 secret_paths.insert(format!("{}:{path}", target.effective.id));
1887 }
1888 }
1889 if !secret_paths.is_empty() {
1890 return unsupported(format!(
1891 "integration policy import contains configured secrets: {}",
1892 secret_paths.into_iter().collect::<Vec<_>>().join(", ")
1893 ));
1894 }
1895
1896 for target in &mut targets {
1897 if let Some(current) = &target.current
1898 && current.spec != target.effective
1899 {
1900 target.replacement_body = Some(replace_wire_body(&target.effective)?);
1901 }
1902 }
1903 let package_installs = planned_package_installs(&package_groups);
1904 let preview = import_preview(&source, &targets, &package_installs);
1905 let plan = IntegrationPolicyImportPlan {
1906 preview,
1907 skipped_snapshot: skipped.clone(),
1908 skipped,
1909 package_installs,
1910 total: canonical.len(),
1911 source,
1912 host: transport.kibana_url().to_owned(),
1913 space: transport.space().to_owned(),
1914 canonical,
1915 name_owners_snapshot: name_owners.clone(),
1916 name_owners,
1917 parent_snapshots: shared_parents,
1918 existing_snapshot: existing.clone(),
1919 targets,
1920 package_groups,
1921 overwrite,
1922 skip_existing,
1923 };
1924 validate_import_plan(&plan)?;
1925 Ok(plan)
1926}
1927
1928pub async fn plan_import(
1932 transport: &Transport,
1933 path: &Path,
1934 overwrite: bool,
1935 skip_existing: bool,
1936) -> Result<IntegrationPolicyImportPlan> {
1937 let artifact = prepare_import(path)?;
1938 plan_prepared_import(transport, artifact, overwrite, skip_existing).await
1939}
1940
1941fn validate_requested_package_versions(specs: &[IntegrationPolicySpec]) -> Result<()> {
1942 let mut versions = BTreeMap::new();
1943 for spec in specs {
1944 match versions.entry(spec.package.name.as_str()) {
1945 std::collections::btree_map::Entry::Vacant(entry) => {
1946 entry.insert(spec.package.version.as_str());
1947 }
1948 std::collections::btree_map::Entry::Occupied(entry)
1949 if entry.get() != &spec.package.version.as_str() =>
1950 {
1951 return Err(Error::new(
1952 ErrorKind::Conflict,
1953 format!(
1954 "integration package '{}' is requested at more than one version",
1955 spec.package.name
1956 ),
1957 ));
1958 }
1959 std::collections::btree_map::Entry::Occupied(_) => {}
1960 }
1961 }
1962 Ok(())
1963}
1964
1965async fn relevant_name_owners(
1966 transport: &Transport,
1967 names: &BTreeSet<String>,
1968) -> Result<BTreeMap<String, BTreeSet<String>>> {
1969 let mut owners = names
1970 .iter()
1971 .map(|name| (name.clone(), BTreeSet::new()))
1972 .collect::<BTreeMap<_, _>>();
1973 if names.is_empty() {
1974 return Ok(owners);
1975 }
1976 for item in collect(transport).await? {
1977 let Some(name) = item.get("name").and_then(Value::as_str) else {
1978 continue;
1979 };
1980 let Some(owners) = owners.get_mut(name) else {
1981 continue;
1982 };
1983 owners.insert(required_string(&item, "id", "integration policies list")?);
1984 }
1985 Ok(owners)
1986}
1987
1988fn target_name_owners_match(
1989 target: &IntegrationPolicyImportTarget,
1990 owners: &BTreeMap<String, BTreeSet<String>>,
1991) -> bool {
1992 let Some(actual) = owners.get(&target.effective.name) else {
1993 return false;
1994 };
1995 let expected = match &target.current {
1996 None => BTreeSet::new(),
1997 Some(current) if current.spec.name == target.effective.name => {
1998 BTreeSet::from([target.effective.id.clone()])
1999 }
2000 Some(_) => BTreeSet::new(),
2001 };
2002 actual == &expected
2003}
2004
2005async fn read_import_parent_snapshots(
2006 transport: &Transport,
2007 integration_id: &str,
2008 current_parent_ids: &[String],
2009 desired_parent_ids: &[String],
2010) -> Result<BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>> {
2011 let parent_ids = current_parent_ids
2012 .iter()
2013 .chain(desired_parent_ids)
2014 .cloned()
2015 .collect::<BTreeSet<_>>();
2016 let mut parents = BTreeMap::new();
2017 for parent_id in parent_ids {
2018 let parent = agent_policy_ops::read_parent_snapshot(transport, &parent_id).await?;
2019 if current_parent_ids.binary_search(&parent_id).is_ok()
2020 && parent
2021 .attached_integrations
2022 .binary_search_by(|attached| attached.as_str().cmp(integration_id))
2023 .is_err()
2024 {
2025 return Err(http(format!(
2026 "decoding integration policy '{integration_id}': parent '{parent_id}' is missing its attachment"
2027 )));
2028 }
2029 parents.insert(parent_id, parent);
2030 }
2031 Ok(parents)
2032}
2033
2034fn effective_import_spec(
2035 canonical: &IntegrationPolicySpec,
2036 parents: &BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
2037) -> Result<IntegrationPolicySpec> {
2038 canonical.validate()?;
2039 for parent in parents.values() {
2040 if parent.platform_owned {
2041 return unsupported(format!(
2042 "integration policy '{}' is not portable: parent {} is platform-owned",
2043 canonical.id, parent.id
2044 ));
2045 }
2046 if parent.protected {
2047 return unsupported(format!(
2048 "integration policy '{}' is not portable: parent {} is_protected",
2049 canonical.id, parent.id
2050 ));
2051 }
2052 }
2053 let selected = canonical
2054 .policy_ids
2055 .iter()
2056 .map(|id| {
2057 parents.get(id).ok_or_else(|| {
2058 Error::new(
2059 ErrorKind::Error,
2060 format!(
2061 "integration policy '{}' has no parent snapshot for '{id}'",
2062 canonical.id
2063 ),
2064 )
2065 })
2066 })
2067 .collect::<Result<Vec<_>>>()?;
2068 let mut effective = canonical.clone();
2069 if let Some(namespace) = &effective.namespace {
2070 if selected.iter().any(|parent| &parent.namespace != namespace) {
2071 return unsupported(format!(
2072 "integration policy '{}' is not portable: namespace does not match every parent",
2073 canonical.id
2074 ));
2075 }
2076 } else {
2077 let namespaces = selected
2078 .iter()
2079 .map(|parent| parent.namespace.as_str())
2080 .collect::<BTreeSet<_>>();
2081 if namespaces.len() != 1 {
2082 return Err(Error::new(
2083 ErrorKind::Conflict,
2084 format!(
2085 "integration policy '{}' is not portable: parents have different namespaces",
2086 canonical.id
2087 ),
2088 ));
2089 }
2090 effective.namespace = namespaces.into_iter().next().map(str::to_owned);
2091 }
2092 Ok(effective)
2093}
2094
2095fn validate_package_metadata_snapshot(
2096 metadata: &Map<String, Value>,
2097 package: &IntegrationPackageSpec,
2098) -> Result<()> {
2099 let name = metadata_name(metadata, "name", "package metadata")?;
2100 let version = metadata_name(metadata, "version", "package metadata")?;
2101 if name != package.name || version != package.version {
2102 return Err(http(format!(
2103 "decoding package metadata: expected {}@{}, got {name}@{version}",
2104 package.name, package.version
2105 )));
2106 }
2107 secret_schema(metadata).map(|_| ())
2108}
2109
2110fn validate_effective_input_materialization(
2111 spec: &IntegrationPolicySpec,
2112 metadata: &Map<String, Value>,
2113) -> Result<()> {
2114 let (_, known) = secret_schema(metadata)?;
2115 if spec.inputs.is_empty() && !known.input_vars.is_empty() {
2116 return unsupported(format!(
2117 "integration policy '{}' has an empty inputs map but package {}@{} declares inputs",
2118 spec.id, spec.package.name, spec.package.version
2119 ));
2120 }
2121 Ok(())
2122}
2123
2124fn replace_wire_body(spec: &IntegrationPolicySpec) -> Result<Value> {
2125 spec.validate()?;
2126 let mut body = serde_json::to_value(spec)
2127 .map_err(|error| {
2128 Error::new(
2129 ErrorKind::Error,
2130 format!("encoding integration policy: {error}"),
2131 )
2132 })?
2133 .as_object()
2134 .cloned()
2135 .expect("integration policy specs serialize to objects");
2136 body.remove("id");
2137 Ok(Value::Object(body))
2138}
2139
2140fn planned_package_installs(groups: &BTreeMap<String, IntegrationPackageGroup>) -> Vec<String> {
2141 groups
2142 .values()
2143 .filter_map(|group| match group.state.state {
2144 PackageDependencyState::NotInstalled => {
2145 Some(format!("{}@{}", group.package.name, group.package.version))
2146 }
2147 PackageDependencyState::Installed { .. } => None,
2148 })
2149 .collect()
2150}
2151
2152fn import_preview(
2153 path: &Path,
2154 targets: &[IntegrationPolicyImportTarget],
2155 package_installs: &[String],
2156) -> MutationPlan {
2157 let mut details = targets
2158 .iter()
2159 .map(|target| {
2160 let parents = target
2161 .parents
2162 .values()
2163 .map(|parent| format!("{} ({})", parent.id, parent.name))
2164 .collect::<Vec<_>>()
2165 .join(", ");
2166 let agents = target
2167 .parents
2168 .values()
2169 .map(|parent| parent.agents)
2170 .sum::<u64>();
2171 let action = match &target.current {
2172 None => "create".to_owned(),
2173 Some(current) if current.spec == target.effective => "unchanged".to_owned(),
2174 Some(current) if current.spec.name == target.effective.name => "replace".to_owned(),
2175 Some(current) => format!(
2176 "replace {} -> {}",
2177 current.spec.name, target.effective.name
2178 ),
2179 };
2180 format!(
2181 "{} {action} {} parents {parents} agents {agents}",
2182 target.effective.id, target.effective.name
2183 )
2184 })
2185 .collect::<Vec<_>>();
2186 details.extend(
2187 package_installs
2188 .iter()
2189 .map(|package| format!("package install {package}")),
2190 );
2191 details.push(IMPORT_RACE_WARNING.to_owned());
2192 MutationPlan {
2193 preview_action: format!(
2194 "Import {} integration policy(ies) from {}",
2195 targets.len(),
2196 path.display()
2197 ),
2198 preview_details: details,
2199 targets: targets
2200 .iter()
2201 .map(|target| target.effective.id.clone())
2202 .collect(),
2203 }
2204}
2205
2206pub fn validate(path: &Path) -> Result<Vec<IntegrationPolicySpec>> {
2208 let body = std::fs::read_to_string(path).map_err(|error| {
2209 Error::new(
2210 ErrorKind::Error,
2211 format!("reading {}: {error}", path.display()),
2212 )
2213 })?;
2214 let mut specs = content_codec::decode_sequence::<IntegrationPolicySpec>(
2215 &body,
2216 ContentFormat::from_path(path),
2217 "integration policy",
2218 )?;
2219 duplicate_error(&specs, |spec| &spec.id, "ids")?;
2220 duplicate_error(&specs, |spec| &spec.name, "names")?;
2221 specs.sort_by(|left, right| left.id.cmp(&right.id));
2222 Ok(specs)
2223}
2224
2225fn duplicate_error<'a, F>(specs: &'a [IntegrationPolicySpec], key: F, noun: &str) -> Result<()>
2226where
2227 F: Fn(&'a IntegrationPolicySpec) -> &'a String,
2228{
2229 let mut seen = BTreeSet::new();
2230 let mut duplicates = BTreeSet::new();
2231 for spec in specs {
2232 let value = key(spec);
2233 if !seen.insert(value.as_str()) {
2234 duplicates.insert(value.as_str());
2235 }
2236 }
2237 if duplicates.is_empty() {
2238 Ok(())
2239 } else {
2240 Err(Error::new(
2241 ErrorKind::Error,
2242 format!(
2243 "duplicate integration policy {noun}: {}",
2244 duplicates.into_iter().collect::<Vec<_>>().join(", ")
2245 ),
2246 ))
2247 }
2248}
2249
2250pub async fn apply_import(
2253 transport: &Transport,
2254 plan: &IntegrationPolicyImportPlan,
2255) -> Result<IntegrationPolicyImportReport> {
2256 validate_import_plan(plan)?;
2257 if plan.host != transport.kibana_url() || plan.space != transport.space() {
2258 return Err(Error::new(
2259 ErrorKind::Conflict,
2260 "integration import target changed since preview",
2261 ));
2262 }
2263 let mut succeeded = Vec::new();
2264 let mut unchanged = Vec::new();
2265 let mut failed = Vec::new();
2266 let mut expected_groups = plan.package_groups.clone();
2267 let mut expected_parents = plan.parent_snapshots.clone();
2268 let mut blocked_packages = BTreeMap::<String, String>::new();
2269 let mut affected_parents = BTreeMap::<String, u64>::new();
2270 let mut observed_installs = BTreeSet::new();
2271
2272 for target in &plan.targets {
2273 let package_name = &target.effective.package.name;
2274 if let Some(error) = blocked_packages.get(package_name) {
2275 failed.push(import_failed_row(
2276 &target.effective.id,
2277 false,
2278 format!("package dependency is unavailable: {error}"),
2279 ));
2280 continue;
2281 }
2282
2283 let action = match recheck_import_object(transport, target).await {
2284 Ok(action) => action,
2285 Err(error) => {
2286 failed.push(import_failed_row(
2287 &target.effective.id,
2288 false,
2289 error.message,
2290 ));
2291 continue;
2292 }
2293 };
2294 if let Err(error) = recheck_import_name_owner(transport, target, &plan.name_owners).await {
2295 failed.push(import_failed_row(
2296 &target.effective.id,
2297 false,
2298 error.message,
2299 ));
2300 continue;
2301 }
2302 if let Err(error) = recheck_import_parents(transport, target, &expected_parents).await {
2303 failed.push(import_failed_row(
2304 &target.effective.id,
2305 false,
2306 error.message,
2307 ));
2308 continue;
2309 }
2310
2311 let group = expected_groups
2312 .get_mut(package_name)
2313 .expect("validated target package group");
2314 let actual_state = match read_dependencies(transport, &target.effective.package).await {
2315 Ok(state) if state == group.state => state,
2316 Ok(_) => {
2317 let message = "package changed since preview".to_owned();
2318 blocked_packages.insert(package_name.clone(), message.clone());
2319 failed.push(import_failed_row(&target.effective.id, false, message));
2320 continue;
2321 }
2322 Err(error) => {
2323 let message = import_remote_error(error, "apply package read").message;
2324 blocked_packages.insert(package_name.clone(), message.clone());
2325 failed.push(import_failed_row(&target.effective.id, false, message));
2326 continue;
2327 }
2328 };
2329 debug_assert_eq!(actual_state, group.state);
2330
2331 if action == ImportAction::Unchanged {
2332 unchanged.push(json!({"id": target.effective.id}));
2333 continue;
2334 }
2335
2336 let (label, applied, route_error) = match action {
2337 ImportAction::Create => {
2338 match integration_policies::create(transport, &target.effective).await {
2339 Ok(_) => ("created", true, None),
2340 Err(error) => (
2341 "created",
2342 false,
2343 Some(import_remote_error(error, "create request").message),
2344 ),
2345 }
2346 }
2347 ImportAction::Replace => {
2348 let _body = target
2349 .replacement_body
2350 .as_ref()
2351 .expect("validated replacement body");
2352 match integration_policies::update(
2353 transport,
2354 &target.effective.id,
2355 &target.effective,
2356 )
2357 .await
2358 {
2359 Ok(_) => ("replaced", true, None),
2360 Err(error) => (
2361 "replaced",
2362 false,
2363 Some(import_remote_error(error, "update request").message),
2364 ),
2365 }
2366 }
2367 ImportAction::Unchanged => unreachable!("unchanged rows continue above"),
2368 };
2369
2370 if applied {
2371 record_affected_parents(&mut affected_parents, target);
2372 advance_parent_snapshots(&mut expected_parents, target);
2373 }
2374
2375 let mut observed_after_create = None;
2379 let mut package_observation_error = None;
2380 if action == ImportAction::Create
2381 && matches!(group.state.state, PackageDependencyState::NotInstalled)
2382 {
2383 match read_dependencies(transport, &target.effective.package).await {
2384 Ok(after) => {
2385 if is_exact_installed(&after, &target.effective.package) {
2386 group.state = after.clone();
2387 observed_installs.insert(format!(
2388 "{}@{}",
2389 target.effective.package.name, target.effective.package.version
2390 ));
2391 } else if !matches!(after.state, PackageDependencyState::NotInstalled) {
2392 let message =
2393 "package installed a different version after create".to_owned();
2394 blocked_packages.insert(package_name.clone(), message.clone());
2395 package_observation_error = Some(message);
2396 }
2397 observed_after_create = Some(after);
2398 }
2399 Err(error) => {
2400 let message = import_remote_error(error, "post-create package read").message;
2401 blocked_packages.insert(package_name.clone(), message.clone());
2402 package_observation_error = Some(message);
2403 }
2404 }
2405 }
2406
2407 let mut errors = route_error.into_iter().collect::<Vec<_>>();
2408 if applied {
2409 if let Err(error) = verify_import_stored(transport, &target.effective).await {
2410 errors.push(error.message);
2411 }
2412 let package_result = match observed_after_create.as_ref() {
2413 Some(after) => verify_exact_installed(after, &target.effective.package),
2414 None => match read_dependencies(transport, &target.effective.package).await {
2415 Ok(after) => verify_exact_installed(&after, &target.effective.package),
2416 Err(error) => Err(import_remote_error(error, "package verification").message),
2417 },
2418 };
2419 if let Err(message) = package_result {
2420 blocked_packages
2421 .entry(package_name.clone())
2422 .or_insert_with(|| message.clone());
2423 errors.push(message);
2424 }
2425 }
2426 if let Some(error) = package_observation_error
2427 && !errors.contains(&error)
2428 {
2429 errors.push(error);
2430 }
2431
2432 if errors.is_empty() {
2433 succeeded.push(json!({"id": target.effective.id, "action": label}));
2434 } else {
2435 failed.push(import_failed_row(
2436 &target.effective.id,
2437 applied,
2438 errors.join("; "),
2439 ));
2440 }
2441 }
2442
2443 Ok(IntegrationPolicyImportReport {
2444 applied: true,
2445 succeeded,
2446 unchanged,
2447 skipped: plan.skipped.clone(),
2448 failed,
2449 total: plan.total,
2450 affected_agents: affected_parents.values().sum(),
2451 package_installs: observed_installs.into_iter().collect(),
2452 })
2453}
2454
2455async fn recheck_import_object(
2456 transport: &Transport,
2457 target: &IntegrationPolicyImportTarget,
2458) -> Result<ImportAction> {
2459 match &target.current {
2460 None => match integration_policies::get(transport, &target.effective.id).await {
2461 Err(error) if error.kind == ErrorKind::NotFound => Ok(ImportAction::Create),
2462 Ok(_) => Err(Error::new(
2463 ErrorKind::Conflict,
2464 "integration policy appeared since preview",
2465 )),
2466 Err(error) => Err(import_remote_error(error, "apply integration-policy read")),
2467 },
2468 Some(expected) => match integration_policies::get(transport, &target.effective.id).await {
2469 Ok(actual) if actual.item == expected.item => {
2470 if expected.spec == target.effective {
2471 Ok(ImportAction::Unchanged)
2472 } else {
2473 Ok(ImportAction::Replace)
2474 }
2475 }
2476 Ok(_) => Err(Error::new(
2477 ErrorKind::Conflict,
2478 "integration policy changed since preview",
2479 )),
2480 Err(error) if error.kind == ErrorKind::NotFound => Err(Error::new(
2481 ErrorKind::Conflict,
2482 "integration policy disappeared since preview",
2483 )),
2484 Err(error) => Err(import_remote_error(error, "apply integration-policy read")),
2485 },
2486 }
2487}
2488
2489async fn recheck_import_name_owner(
2490 transport: &Transport,
2491 target: &IntegrationPolicyImportTarget,
2492 expected_owners: &BTreeMap<String, BTreeSet<String>>,
2493) -> Result<()> {
2494 let names = BTreeSet::from([target.effective.name.clone()]);
2495 let owners = relevant_name_owners(transport, &names)
2496 .await
2497 .map_err(|error| import_remote_error(error, "apply name read"))?;
2498 if owners.get(&target.effective.name) == expected_owners.get(&target.effective.name) {
2499 Ok(())
2500 } else {
2501 Err(Error::new(
2502 ErrorKind::Conflict,
2503 "integration policy name ownership changed since preview",
2504 ))
2505 }
2506}
2507
2508async fn recheck_import_parents(
2509 transport: &Transport,
2510 target: &IntegrationPolicyImportTarget,
2511 expected_parents: &BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
2512) -> Result<()> {
2513 for parent_id in target.parents.keys() {
2514 let expected = expected_parents.get(parent_id).ok_or_else(|| {
2515 Error::new(
2516 ErrorKind::Error,
2517 "integration import lost a shared parent snapshot",
2518 )
2519 })?;
2520 let actual = agent_policy_ops::read_parent_snapshot(transport, parent_id)
2521 .await
2522 .map_err(|error| import_remote_error(error, "apply parent read"))?;
2523 if actual != *expected {
2524 return Err(Error::new(
2525 ErrorKind::Conflict,
2526 "integration policy parent changed since preview",
2527 ));
2528 }
2529 }
2530 Ok(())
2531}
2532
2533fn record_affected_parents(
2534 affected: &mut BTreeMap<String, u64>,
2535 target: &IntegrationPolicyImportTarget,
2536) {
2537 for parent in target.parents.values() {
2538 affected.entry(parent.id.clone()).or_insert(parent.agents);
2539 }
2540}
2541
2542fn advance_parent_snapshots(
2543 parents: &mut BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
2544 target: &IntegrationPolicyImportTarget,
2545) {
2546 let desired = target
2547 .effective
2548 .policy_ids
2549 .iter()
2550 .map(String::as_str)
2551 .collect::<BTreeSet<_>>();
2552 for parent_id in target.parents.keys() {
2553 let parent = parents
2554 .get_mut(parent_id)
2555 .expect("validated shared parent snapshot");
2556 if desired.contains(parent_id.as_str()) {
2557 if parent
2558 .attached_integrations
2559 .binary_search_by(|attached| attached.as_str().cmp(&target.effective.id))
2560 .is_err()
2561 {
2562 parent
2563 .attached_integrations
2564 .push(target.effective.id.clone());
2565 parent.attached_integrations.sort();
2566 }
2567 } else {
2568 parent
2569 .attached_integrations
2570 .retain(|attached| attached != &target.effective.id);
2571 }
2572 }
2573}
2574
2575async fn verify_import_stored(
2576 transport: &Transport,
2577 desired: &IntegrationPolicySpec,
2578) -> Result<()> {
2579 let stored = integration_policies::get(transport, &desired.id)
2580 .await
2581 .map_err(|error| import_remote_error(error, "stored-policy read"))?;
2582 let stored = normalize(&stored.item, transport.space())
2583 .map_err(|error| import_remote_error(error, "stored-policy read"))?;
2584 if stored == *desired {
2585 Ok(())
2586 } else {
2587 Err(Error::new(
2588 ErrorKind::Http,
2589 "server stored a different integration-policy spec",
2590 ))
2591 }
2592}
2593
2594fn is_exact_installed(
2595 snapshot: &PackageDependencySnapshot,
2596 package: &IntegrationPackageSpec,
2597) -> bool {
2598 snapshot.name == package.name
2599 && matches!(
2600 &snapshot.state,
2601 PackageDependencyState::Installed { version } if version == &package.version
2602 )
2603}
2604
2605fn verify_exact_installed(
2606 snapshot: &PackageDependencySnapshot,
2607 package: &IntegrationPackageSpec,
2608) -> std::result::Result<(), String> {
2609 if is_exact_installed(snapshot, package) {
2610 return Ok(());
2611 }
2612 match &snapshot.state {
2613 PackageDependencyState::Installed { .. } => Err(format!(
2614 "package {} installed a different version",
2615 package.name
2616 )),
2617 PackageDependencyState::NotInstalled => {
2618 Err(format!("package {} is not installed", package.name))
2619 }
2620 }
2621}
2622
2623fn import_remote_error(error: Error, context: &str) -> Error {
2624 let message = format!("integration-policy import {context} failed");
2625 match error.http_status {
2626 Some(status) => Error::with_status(error.kind, status, message),
2627 None => Error::new(error.kind, message),
2628 }
2629}
2630
2631fn import_failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
2632 json!({"id": id, "applied": applied, "error": error.into()})
2633}
2634
2635fn validate_import_plan(plan: &IntegrationPolicyImportPlan) -> Result<()> {
2636 let invalid = |message: &str| {
2637 Err(Error::new(
2638 ErrorKind::Error,
2639 format!("invalid integration-policy import plan: {message}"),
2640 ))
2641 };
2642 if plan.overwrite && plan.skip_existing {
2643 return invalid("overwrite and skip-existing cannot both be set");
2644 }
2645 if plan.host.trim().is_empty() {
2646 return invalid("planned Kibana host is empty");
2647 }
2648 if plan.canonical.is_empty() || plan.total != plan.canonical.len() {
2649 return invalid("total does not equal canonical integration policies");
2650 }
2651 let mut canonical_ids = BTreeMap::new();
2652 let mut canonical_names = BTreeSet::new();
2653 let mut previous: Option<&str> = None;
2654 for spec in &plan.canonical {
2655 if spec.validate().is_err() {
2656 return invalid("canonical integration policy is invalid");
2657 }
2658 if previous.is_some_and(|previous| previous >= spec.id.as_str()) {
2659 return invalid("canonical integration policies must be unique and sorted by id");
2660 }
2661 if !canonical_names.insert(spec.name.as_str()) {
2662 return invalid("canonical integration-policy names must be unique");
2663 }
2664 previous = Some(&spec.id);
2665 canonical_ids.insert(spec.id.as_str(), spec);
2666 }
2667 if validate_requested_package_versions(&plan.canonical).is_err() {
2668 return invalid("canonical package requests are inconsistent");
2669 }
2670 let canonical_id_set = canonical_ids.keys().copied().collect::<BTreeSet<_>>();
2671 if plan
2672 .existing_snapshot
2673 .keys()
2674 .map(String::as_str)
2675 .collect::<BTreeSet<_>>()
2676 != canonical_id_set
2677 {
2678 return invalid("existence snapshots do not match canonical integration policies");
2679 }
2680 for (id, existing) in &plan.existing_snapshot {
2681 if let Some(item) = existing
2682 && required_string(item, "id", "existing integration policy")
2683 .ok()
2684 .as_deref()
2685 != Some(id.as_str())
2686 {
2687 return invalid("existence snapshot has an unexpected id");
2688 }
2689 }
2690 if plan.name_owners != plan.name_owners_snapshot {
2691 return invalid("name ownership snapshots do not match");
2692 }
2693 if plan
2694 .name_owners
2695 .keys()
2696 .map(String::as_str)
2697 .collect::<BTreeSet<_>>()
2698 != canonical_names
2699 {
2700 return invalid("name ownership snapshots do not match canonical names");
2701 }
2702 for spec in &plan.canonical {
2703 let Some(owners) = plan.name_owners.get(&spec.name) else {
2704 return invalid("canonical name has no ownership snapshot");
2705 };
2706 if owners
2707 .iter()
2708 .any(|owner| owner.trim().is_empty() || owner != &spec.id)
2709 {
2710 return invalid("name ownership snapshot has a foreign or malformed owner");
2711 }
2712 }
2713
2714 let mut target_ids = BTreeSet::new();
2715 let mut expected_bodies = BTreeMap::new();
2716 let mut expected_group_names = BTreeSet::new();
2717 let mut shared_parents = BTreeMap::new();
2718 let mut previous_target: Option<&str> = None;
2719 for target in &plan.targets {
2720 if target.effective.validate().is_err() {
2721 return invalid("effective integration policy is invalid");
2722 }
2723 if previous_target.is_some_and(|previous| previous >= target.effective.id.as_str()) {
2724 return invalid("pending integration policies must be unique and sorted by id");
2725 }
2726 previous_target = Some(&target.effective.id);
2727 let Some(canonical) = canonical_ids.get(target.effective.id.as_str()) else {
2728 return invalid("pending policy is not in the canonical artifact");
2729 };
2730 if !target_ids.insert(target.effective.id.as_str()) {
2731 return invalid("pending integration policies must be unique and sorted by id");
2732 }
2733 let exact_current = match (
2734 &target.current,
2735 plan.existing_snapshot.get(&target.effective.id),
2736 ) {
2737 (None, Some(None)) => true,
2738 (Some(current), Some(Some(item))) => current.item == *item,
2739 _ => false,
2740 };
2741 if !exact_current {
2742 return invalid("target does not match its plan-time existence snapshot");
2743 }
2744 let current_parent_ids = match &target.current {
2745 None => Vec::new(),
2746 Some(current) => {
2747 if current.spec.validate().is_err()
2748 || current.spec.id != target.effective.id
2749 || normalize(¤t.item, &plan.space).ok().as_ref() != Some(¤t.spec)
2750 {
2751 return invalid("current integration snapshot does not normalize canonically");
2752 }
2753 let parent_ids = match read_parents(&target.effective.id, ¤t.item) {
2754 Ok(parent_ids) if parent_ids == current.parent_ids => parent_ids,
2755 _ => {
2756 return invalid(
2757 "current integration parent snapshot does not match its item",
2758 );
2759 }
2760 };
2761 if package_coordinate(¤t.item, "integration policy").ok()
2762 != Some(target.effective.package.clone())
2763 {
2764 return invalid("current and desired package coordinates differ");
2765 }
2766 parent_ids
2767 }
2768 };
2769 if target.current.is_some() && !plan.overwrite {
2770 return invalid("existing integration target requires overwrite");
2771 }
2772 if !target_name_owners_match(target, &plan.name_owners) {
2773 return invalid("name ownership snapshot does not match target state");
2774 }
2775 let expected_parent_ids = current_parent_ids
2776 .iter()
2777 .chain(&target.effective.policy_ids)
2778 .cloned()
2779 .collect::<BTreeSet<_>>();
2780 if target.parents.keys().cloned().collect::<BTreeSet<_>>() != expected_parent_ids {
2781 return invalid("parent snapshots do not match current and desired parents");
2782 }
2783 for (parent_id, parent) in &target.parents {
2784 if !valid_parent_snapshot(parent_id, parent)
2785 || parent.platform_owned
2786 || parent.protected
2787 {
2788 return invalid("parent snapshot is unsafe or malformed");
2789 }
2790 if current_parent_ids.binary_search(parent_id).is_ok()
2791 && parent
2792 .attached_integrations
2793 .binary_search_by(|attached| attached.as_str().cmp(&target.effective.id))
2794 .is_err()
2795 {
2796 return invalid("current parent snapshot is missing its integration attachment");
2797 }
2798 match shared_parents.entry(parent_id.as_str()) {
2799 std::collections::btree_map::Entry::Vacant(entry) => {
2800 entry.insert(parent);
2801 }
2802 std::collections::btree_map::Entry::Occupied(entry) if *entry.get() != parent => {
2803 return invalid("shared parent snapshots disagree");
2804 }
2805 std::collections::btree_map::Entry::Occupied(_) => {}
2806 }
2807 }
2808 if effective_import_spec(canonical, &target.parents)
2809 .ok()
2810 .as_ref()
2811 != Some(&target.effective)
2812 {
2813 return invalid("effective integration policy does not match canonical parents");
2814 }
2815 if let Some(current) = &target.current {
2816 if current.spec != target.effective {
2817 if !plan.overwrite {
2818 return invalid("replacement plan requires overwrite");
2819 }
2820 let body = match replace_wire_body(&target.effective) {
2821 Ok(body) => body,
2822 Err(_) => return invalid("replacement body cannot be encoded"),
2823 };
2824 if target.replacement_body.as_ref() != Some(&body) {
2825 return invalid("replacement body does not match its effective policy");
2826 }
2827 expected_bodies.insert(target.effective.id.as_str(), body);
2828 } else if target.replacement_body.is_some() {
2829 return invalid("unchanged integration policy carries a replacement body");
2830 }
2831 } else if target.replacement_body.is_some() {
2832 return invalid("planned create carries a replacement body");
2833 }
2834 expected_group_names.insert(target.effective.package.name.as_str());
2835 }
2836
2837 if plan
2838 .package_groups
2839 .keys()
2840 .map(String::as_str)
2841 .collect::<BTreeSet<_>>()
2842 != expected_group_names
2843 {
2844 return invalid("package groups do not match pending integration policies");
2845 }
2846 let expected_parent_snapshots: BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot> =
2847 shared_parents
2848 .iter()
2849 .map(|(id, parent)| ((*id).to_owned(), (*parent).clone()))
2850 .collect();
2851 if plan.parent_snapshots != expected_parent_snapshots {
2852 return invalid("shared parent snapshots do not match pending integrations");
2853 }
2854 for (name, group) in &plan.package_groups {
2855 if group.package.name != *name
2856 || group.package.name.trim().is_empty()
2857 || group.package.version.trim().is_empty()
2858 || group.state != group.state_snapshot
2859 || group.metadata != group.metadata_snapshot
2860 || group.state.name != group.package.name
2861 || !valid_package_state(&group.state)
2862 || validate_package_metadata_snapshot(&group.metadata, &group.package).is_err()
2863 {
2864 return invalid("package group snapshot is malformed or tampered");
2865 }
2866 if !is_exact_installed(&group.state, &group.package)
2867 && !matches!(group.state.state, PackageDependencyState::NotInstalled)
2868 {
2869 return invalid("package group does not hold an exact dependency state");
2870 }
2871 if matches!(group.state.state, PackageDependencyState::NotInstalled)
2872 && plan
2873 .targets
2874 .iter()
2875 .any(|target| target.effective.package.name == *name && target.current.is_some())
2876 {
2877 return invalid("existing integration cannot depend on an absent package");
2878 }
2879 }
2880 for target in &plan.targets {
2881 let Some(group) = plan.package_groups.get(&target.effective.package.name) else {
2882 return invalid("target has no package group");
2883 };
2884 if group.package != target.effective.package {
2885 return invalid("package group coordinate does not match its target");
2886 }
2887 validate_effective_input_materialization(&target.effective, &group.metadata)?;
2888 match configured_secret_paths(&target.effective, &group.metadata) {
2889 Ok(paths) if paths.is_empty() => {}
2890 _ => return invalid("effective integration policy has unsafe configured variables"),
2891 }
2892 if let Some(current) = &target.current {
2893 match configured_secret_paths(¤t.spec, &group.metadata) {
2894 Ok(paths) if paths.is_empty() => {}
2895 _ => {
2896 return invalid("current integration policy has unsafe configured variables");
2897 }
2898 }
2899 }
2900 }
2901 if expected_bodies.len()
2902 != plan
2903 .targets
2904 .iter()
2905 .filter(|target| target.replacement_body.is_some())
2906 .count()
2907 {
2908 return invalid("replacement body set does not match changed integration policies");
2909 }
2910
2911 let expected_skipped_ids = plan
2912 .canonical
2913 .iter()
2914 .filter(|spec| {
2915 plan.skip_existing && matches!(plan.existing_snapshot.get(&spec.id), Some(Some(_)))
2916 })
2917 .map(|spec| spec.id.as_str())
2918 .collect::<BTreeSet<_>>();
2919 let expected_target_ids = canonical_id_set
2920 .iter()
2921 .copied()
2922 .filter(|id| !expected_skipped_ids.contains(id))
2923 .collect::<BTreeSet<_>>();
2924 if target_ids != expected_target_ids {
2925 return invalid("pending integration policies do not match plan-time existence snapshots");
2926 }
2927 let expected_skipped = plan
2928 .canonical
2929 .iter()
2930 .filter(|spec| expected_skipped_ids.contains(spec.id.as_str()))
2931 .map(|spec| json!({"id": spec.id, "reason": "exists"}))
2932 .collect::<Vec<_>>();
2933 if plan.skipped != plan.skipped_snapshot {
2934 return invalid("skipped rows do not match their snapshot");
2935 }
2936 if (!plan.skip_existing && !plan.skipped.is_empty()) || plan.skipped != expected_skipped {
2937 return invalid("skipped rows do not match the canonical artifact");
2938 }
2939 let expected_installs = planned_package_installs(&plan.package_groups);
2940 if plan.package_installs != expected_installs {
2941 return invalid("package install preview does not match package groups");
2942 }
2943 let expected_preview = import_preview(&plan.source, &plan.targets, &plan.package_installs);
2944 if plan.preview != expected_preview {
2945 return invalid("preview does not match the canonical plan");
2946 }
2947 Ok(())
2948}
2949
2950fn valid_parent_snapshot(id: &str, parent: &agent_policy_ops::AgentPolicyParentSnapshot) -> bool {
2951 parent.id == id
2952 && !parent.id.trim().is_empty()
2953 && !parent.name.trim().is_empty()
2954 && !parent.namespace.trim().is_empty()
2955 && parent
2956 .attached_integrations
2957 .windows(2)
2958 .all(|ids| ids[0] < ids[1])
2959}
2960
2961fn valid_package_state(snapshot: &PackageDependencySnapshot) -> bool {
2962 if snapshot.name.trim().is_empty() {
2963 return false;
2964 }
2965 match &snapshot.state {
2966 PackageDependencyState::Installed { version } => !version.trim().is_empty(),
2967 PackageDependencyState::NotInstalled => true,
2968 }
2969}
2970
2971pub async fn plan_delete(
2975 transport: &Transport,
2976 selectors: &[String],
2977) -> Result<IntegrationPolicyDeletePlan> {
2978 if selectors.is_empty() {
2979 return Err(Error::new(
2980 ErrorKind::Error,
2981 "integration-policy delete needs at least one selector",
2982 ));
2983 }
2984 if selectors.iter().any(|selector| selector.trim().is_empty()) {
2985 return Err(Error::new(
2986 ErrorKind::Error,
2987 "integration-policy delete selectors must not be empty",
2988 ));
2989 }
2990
2991 let mut resolved = BTreeMap::new();
2992 for selector in selectors {
2993 let resolved_policy = resolve_delete_item(transport, selector).await?;
2994 let id = required_string(
2995 &resolved_policy.item,
2996 "id",
2997 "integration policy delete planning read",
2998 )?;
2999 if id != resolved_policy.summary.id {
3000 return Err(http(
3001 "decoding integration policy delete planning read: response id did not match its summary",
3002 ));
3003 }
3004 resolved.entry(id).or_insert(resolved_policy);
3005 }
3006
3007 let mut targets = Vec::with_capacity(resolved.len());
3008 let mut issues = Vec::new();
3009 for (id, resolved_policy) in resolved {
3010 match plan_delete_target(transport, &id, resolved_policy.item).await {
3011 Ok(target) => targets.push(target),
3012 Err(error) => issues.push(error),
3013 }
3014 }
3015 if !issues.is_empty() {
3016 return collapse_delete_planning_issues(issues);
3017 }
3018
3019 let parent_snapshots = shared_delete_parents(&targets).map_err(|_| {
3020 Error::new(
3021 ErrorKind::Conflict,
3022 "agent policy changed while planning integration deletion",
3023 )
3024 })?;
3025 let plan = IntegrationPolicyDeletePlan {
3026 preview: delete_preview(&targets),
3027 total: targets.len(),
3028 host_snapshot: transport.kibana_url().to_owned(),
3029 host: transport.kibana_url().to_owned(),
3030 space_snapshot: transport.space().to_owned(),
3031 space: transport.space().to_owned(),
3032 parent_snapshots_snapshot: parent_snapshots.clone(),
3033 parent_snapshots,
3034 targets,
3035 };
3036 validate_delete_plan(&plan)?;
3037 Ok(plan)
3038}
3039
3040async fn resolve_delete_item(
3045 transport: &Transport,
3046 selector: &str,
3047) -> Result<ResolvedIntegrationPolicy> {
3048 match integration_policies::get(transport, selector).await {
3049 Ok(policy) => {
3050 let summary = summary_from_item(&policy.item)?;
3051 if summary.id != selector {
3052 return Err(http(
3053 "decoding integration policy delete planning read: response id did not match the selector",
3054 ));
3055 }
3056 return Ok(ResolvedIntegrationPolicy {
3057 summary,
3058 item: policy.item,
3059 });
3060 }
3061 Err(error) if error.kind == ErrorKind::NotFound => {}
3062 Err(error) => return Err(delete_remote_error(error, "planning integration read")),
3063 }
3064 let matches = collect(transport)
3065 .await
3066 .map_err(|error| delete_remote_error(error, "planning integration list read"))?
3067 .iter()
3068 .filter(|item| item.get("name").and_then(Value::as_str) == Some(selector))
3069 .map(summary_from_item)
3070 .collect::<Result<Vec<_>>>()?;
3071 match matches.as_slice() {
3072 [] => Err(Error::new(
3073 ErrorKind::NotFound,
3074 format!("no integration policy with id or name '{selector}'"),
3075 )),
3076 [summary] => {
3077 let policy = integration_policies::get(transport, &summary.id)
3078 .await
3079 .map_err(|error| delete_remote_error(error, "planning name read"))?;
3080 let returned_id = required_string(
3081 &policy.item,
3082 "id",
3083 "integration policy delete planning read",
3084 )?;
3085 if returned_id != summary.id {
3086 return Err(http(
3087 "decoding integration policy delete planning read: name resolution returned an unexpected id",
3088 ));
3089 }
3090 Ok(ResolvedIntegrationPolicy {
3091 summary: summary.clone(),
3092 item: policy.item,
3093 })
3094 }
3095 many => Err(Error::new(
3096 ErrorKind::Conflict,
3097 format!(
3098 "integration policy '{selector}' is ambiguous: {}",
3099 many.iter()
3100 .map(|policy| policy.id.as_str())
3101 .collect::<Vec<_>>()
3102 .join(", ")
3103 ),
3104 )),
3105 }
3106}
3107
3108async fn plan_delete_target(
3109 transport: &Transport,
3110 id: &str,
3111 item: Map<String, Value>,
3112) -> Result<IntegrationPolicyDeleteTarget> {
3113 if required_string(&item, "id", "integration policy delete planning read")? != id {
3114 return Err(http(
3115 "decoding integration policy delete planning read: response id did not match the request",
3116 ));
3117 }
3118 let spec = normalize(&item, transport.space())?;
3119 if spec.id != id {
3120 return Err(http(
3121 "decoding integration policy delete planning read: normalized id did not match the request",
3122 ));
3123 }
3124 let parent_ids = read_parents(id, &item)?;
3125 let parents = read_parent_snapshots(transport, id, &parent_ids)
3126 .await
3127 .map_err(|error| delete_remote_error(error, "planning parent read"))?;
3128 validate_delete_parent_safety(id, &spec, &parents)?;
3129
3130 let package = package_coordinate(&item, "integration policy delete planning read")?;
3131 if package != spec.package {
3132 return Err(http(
3133 "decoding integration policy delete planning read: package did not normalize canonically",
3134 ));
3135 }
3136 let dependency = read_dependencies(transport, &package)
3137 .await
3138 .map_err(|error| delete_remote_error(error, "planning package read"))?;
3139 ensure_delete_dependency(id, &dependency, &package)?;
3140 let metadata =
3141 integration_policies::package_metadata(transport, &package.name, &package.version)
3142 .await
3143 .map_err(|error| delete_remote_error(error, "planning package metadata read"))?
3144 .item;
3145 validate_package_metadata_snapshot(&metadata, &package)?;
3146 let secret_paths = configured_secret_paths(&spec, &metadata)?;
3147 if !secret_paths.is_empty() {
3148 return unsupported(format!(
3149 "integration policy '{id}' is not portable: {}",
3150 secret_paths
3151 .into_iter()
3152 .map(|path| format!("{id}:{path}"))
3153 .collect::<Vec<_>>()
3154 .join(", ")
3155 ));
3156 }
3157
3158 Ok(IntegrationPolicyDeleteTarget {
3159 id: id.to_owned(),
3160 name: spec.name.clone(),
3161 item_snapshot: item.clone(),
3162 item,
3163 spec_snapshot: spec.clone(),
3164 spec,
3165 parents,
3166 package,
3167 dependency_snapshot: dependency.clone(),
3168 dependency,
3169 metadata_snapshot: metadata.clone(),
3170 metadata,
3171 })
3172}
3173
3174fn collapse_delete_planning_issues(mut issues: Vec<Error>) -> Result<IntegrationPolicyDeletePlan> {
3175 if issues.len() == 1 {
3176 return Err(issues.remove(0));
3177 }
3178 if issues.iter().all(|error| error.kind == ErrorKind::Conflict) {
3179 return Err(Error::new(
3180 ErrorKind::Conflict,
3181 issues
3182 .into_iter()
3183 .map(|error| error.message)
3184 .collect::<Vec<_>>()
3185 .join("; "),
3186 ));
3187 }
3188 Err(issues.remove(0))
3189}
3190
3191fn validate_delete_parent_safety(
3192 id: &str,
3193 spec: &IntegrationPolicySpec,
3194 parents: &BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
3195) -> Result<()> {
3196 if parents.len() != spec.policy_ids.len()
3197 || parents
3198 .keys()
3199 .map(String::as_str)
3200 .ne(spec.policy_ids.iter().map(String::as_str))
3201 {
3202 return Err(http(format!(
3203 "decoding integration policy '{id}': parent snapshots do not match policy_ids"
3204 )));
3205 }
3206 for parent in parents.values() {
3207 if parent.platform_owned {
3208 return unsupported(format!(
3209 "integration policy '{id}' is not portable: parent {} is platform-owned",
3210 parent.id
3211 ));
3212 }
3213 if parent.protected {
3214 return unsupported(format!(
3215 "integration policy '{id}' is not portable: parent {} is_protected",
3216 parent.id
3217 ));
3218 }
3219 if parent
3220 .attached_integrations
3221 .binary_search_by(|attached| attached.as_str().cmp(id))
3222 .is_err()
3223 {
3224 return Err(http(format!(
3225 "decoding integration policy '{id}': parent '{}' is missing its attachment",
3226 parent.id
3227 )));
3228 }
3229 }
3230 let namespaces = parents
3231 .values()
3232 .map(|parent| parent.namespace.as_str())
3233 .collect::<BTreeSet<_>>();
3234 match &spec.namespace {
3235 Some(namespace)
3236 if parents
3237 .values()
3238 .all(|parent| &parent.namespace == namespace) => {}
3239 Some(_) => {
3240 return unsupported(format!(
3241 "integration policy '{id}' is not portable: namespace does not match every parent"
3242 ));
3243 }
3244 None if namespaces.len() == 1 => {}
3245 None => {
3246 return unsupported(format!(
3247 "integration policy '{id}' is not portable: parents have different namespaces"
3248 ));
3249 }
3250 }
3251 Ok(())
3252}
3253
3254fn ensure_delete_dependency(
3255 id: &str,
3256 dependency: &PackageDependencySnapshot,
3257 package: &IntegrationPackageSpec,
3258) -> Result<()> {
3259 match &dependency.state {
3260 PackageDependencyState::Installed { version }
3261 if dependency.name == package.name && version == &package.version =>
3262 {
3263 Ok(())
3264 }
3265 PackageDependencyState::Installed { .. } => Err(Error::new(
3266 ErrorKind::Conflict,
3267 format!(
3268 "integration policy '{id}' package {} has a different installed version",
3269 package.name
3270 ),
3271 )),
3272 PackageDependencyState::NotInstalled => Err(Error::new(
3273 ErrorKind::Conflict,
3274 format!(
3275 "integration policy '{id}' package {} is not installed",
3276 package.name
3277 ),
3278 )),
3279 }
3280}
3281
3282fn shared_delete_parents(
3283 targets: &[IntegrationPolicyDeleteTarget],
3284) -> Result<BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>> {
3285 let mut shared = BTreeMap::new();
3286 for target in targets {
3287 for (id, parent) in &target.parents {
3288 match shared.entry(id.clone()) {
3289 std::collections::btree_map::Entry::Vacant(entry) => {
3290 entry.insert(parent.clone());
3291 }
3292 std::collections::btree_map::Entry::Occupied(entry) if entry.get() != parent => {
3293 return Err(Error::new(
3294 ErrorKind::Conflict,
3295 format!("agent policy '{id}' changed while planning integration deletion"),
3296 ));
3297 }
3298 std::collections::btree_map::Entry::Occupied(_) => {}
3299 }
3300 }
3301 }
3302 Ok(shared)
3303}
3304
3305fn delete_preview(targets: &[IntegrationPolicyDeleteTarget]) -> MutationPlan {
3306 let mut affected = BTreeMap::new();
3307 let mut preview_details = Vec::with_capacity(targets.len() + 2);
3308 for target in targets {
3309 let parents = target
3310 .parents
3311 .values()
3312 .map(|parent| {
3313 affected.entry(parent.id.clone()).or_insert(parent.agents);
3314 format!("{} ({}) agents {}", parent.id, parent.name, parent.agents)
3315 })
3316 .collect::<Vec<_>>();
3317 let agents = target
3318 .parents
3319 .values()
3320 .map(|parent| parent.agents)
3321 .sum::<u64>();
3322 preview_details.push(format!(
3323 "{} {} parents {} agents {agents}",
3324 target.id,
3325 target.name,
3326 parents.join(", ")
3327 ));
3328 }
3329 preview_details.push(format!(
3330 "affected agents {}",
3331 affected.values().sum::<u64>()
3332 ));
3333 preview_details.push(DELETE_RACE_WARNING.to_owned());
3334 MutationPlan {
3335 preview_action: format!("Delete {} integration policy(ies)", targets.len()),
3336 preview_details,
3337 targets: targets.iter().map(|target| target.id.clone()).collect(),
3338 }
3339}
3340
3341pub async fn apply_delete(
3345 transport: &Transport,
3346 plan: &IntegrationPolicyDeletePlan,
3347) -> Result<IntegrationPolicyDeleteReport> {
3348 validate_delete_plan(plan)?;
3349 if plan.host != transport.kibana_url() || plan.space != transport.space() {
3350 return Err(Error::new(
3351 ErrorKind::Conflict,
3352 "integration delete target changed since preview",
3353 ));
3354 }
3355
3356 let mut expected_parents = plan.parent_snapshots.clone();
3357 let mut affected = BTreeMap::new();
3358 let mut deleted = Vec::new();
3359 let mut failed = Vec::new();
3360
3361 for target in &plan.targets {
3362 match integration_policies::get(transport, &target.id).await {
3363 Ok(actual) if actual.item == target.item => {}
3364 Ok(_) => {
3365 failed.push(delete_failed_row(
3366 &target.id,
3367 false,
3368 "integration policy changed since preview",
3369 ));
3370 continue;
3371 }
3372 Err(error) if error.kind == ErrorKind::NotFound => {
3373 failed.push(delete_failed_row(
3374 &target.id,
3375 false,
3376 "integration policy disappeared since preview",
3377 ));
3378 continue;
3379 }
3380 Err(error) => {
3381 failed.push(delete_failed_row(
3382 &target.id,
3383 false,
3384 delete_remote_error(error, "apply integration-policy read").message,
3385 ));
3386 continue;
3387 }
3388 }
3389
3390 if let Err(error) = recheck_delete_parents(transport, target, &expected_parents).await {
3391 failed.push(delete_failed_row(&target.id, false, error.message));
3392 continue;
3393 }
3394 match read_dependencies(transport, &target.package).await {
3395 Ok(actual) if actual == target.dependency => {}
3396 Ok(_) => {
3397 failed.push(delete_failed_row(
3398 &target.id,
3399 false,
3400 "integration policy package changed since preview",
3401 ));
3402 continue;
3403 }
3404 Err(error) => {
3405 failed.push(delete_failed_row(
3406 &target.id,
3407 false,
3408 delete_remote_error(error, "apply package read").message,
3409 ));
3410 continue;
3411 }
3412 }
3413 if let Err(error) = recheck_delete_metadata(transport, target).await {
3414 failed.push(delete_failed_row(&target.id, false, error.message));
3415 continue;
3416 }
3417
3418 match integration_policies::delete(transport, &target.id).await {
3419 Ok(()) => {
3420 record_delete_affected_parents(&mut affected, target, &expected_parents);
3421 advance_delete_parent_snapshots(&mut expected_parents, target);
3422 deleted.push(json!({"id": target.id}));
3423 }
3424 Err(error) => {
3425 let applied = error
3426 .http_status
3427 .is_some_and(|status| (200..300).contains(&status));
3428 let message = if applied {
3429 "integration-policy delete response did not confirm the requested id"
3430 } else {
3431 "integration-policy delete request failed"
3432 };
3433 failed.push(delete_failed_row(&target.id, applied, message));
3434 }
3435 }
3436 }
3437
3438 Ok(IntegrationPolicyDeleteReport {
3439 applied: true,
3440 deleted,
3441 failed,
3442 total: plan.total,
3443 affected_agents: affected.values().sum(),
3444 })
3445}
3446
3447async fn recheck_delete_parents(
3448 transport: &Transport,
3449 target: &IntegrationPolicyDeleteTarget,
3450 expected_parents: &BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
3451) -> Result<()> {
3452 for parent_id in target.parents.keys() {
3453 let expected = expected_parents.get(parent_id).ok_or_else(|| {
3454 Error::new(
3455 ErrorKind::Error,
3456 "integration delete lost a shared parent snapshot",
3457 )
3458 })?;
3459 match agent_policy_ops::read_parent_snapshot(transport, parent_id).await {
3460 Ok(actual) if actual == *expected => {}
3461 Ok(_) => {
3462 return Err(Error::new(
3463 ErrorKind::Conflict,
3464 "integration policy parent changed since preview",
3465 ));
3466 }
3467 Err(error) if error.kind == ErrorKind::NotFound => {
3468 return Err(Error::new(
3469 ErrorKind::NotFound,
3470 "integration policy parent disappeared since preview",
3471 ));
3472 }
3473 Err(error) => return Err(delete_remote_error(error, "apply parent read")),
3474 }
3475 }
3476 Ok(())
3477}
3478
3479async fn recheck_delete_metadata(
3480 transport: &Transport,
3481 target: &IntegrationPolicyDeleteTarget,
3482) -> Result<()> {
3483 let metadata = integration_policies::package_metadata(
3484 transport,
3485 &target.package.name,
3486 &target.package.version,
3487 )
3488 .await
3489 .map_err(|error| delete_remote_error(error, "apply package metadata read"))?
3490 .item;
3491 validate_package_metadata_snapshot(&metadata, &target.package)
3492 .map_err(|error| delete_remote_error(error, "apply package metadata read"))?;
3493 if metadata != target.metadata {
3494 return Err(Error::new(
3495 ErrorKind::Conflict,
3496 "integration policy package metadata changed since preview",
3497 ));
3498 }
3499 Ok(())
3500}
3501
3502fn record_delete_affected_parents(
3503 affected: &mut BTreeMap<String, u64>,
3504 target: &IntegrationPolicyDeleteTarget,
3505 expected_parents: &BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
3506) {
3507 for parent_id in target.parents.keys() {
3508 let parent = expected_parents
3509 .get(parent_id)
3510 .expect("validated delete target parent exists in shared snapshots");
3511 affected.entry(parent.id.clone()).or_insert(parent.agents);
3512 }
3513}
3514
3515fn advance_delete_parent_snapshots(
3516 parents: &mut BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
3517 target: &IntegrationPolicyDeleteTarget,
3518) {
3519 for parent_id in target.parents.keys() {
3520 let parent = parents
3521 .get_mut(parent_id)
3522 .expect("validated delete target parent exists in shared snapshots");
3523 parent
3524 .attached_integrations
3525 .retain(|attached| attached != &target.id);
3526 }
3527}
3528
3529fn delete_failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
3530 json!({"id": id, "applied": applied, "error": error.into()})
3531}
3532
3533fn delete_remote_error(error: Error, context: &str) -> Error {
3534 let message = format!("integration-policy delete {context} failed");
3535 match error.http_status {
3536 Some(status) => Error::with_status(error.kind, status, message),
3537 None => Error::new(error.kind, message),
3538 }
3539}
3540
3541fn validate_delete_plan(plan: &IntegrationPolicyDeletePlan) -> Result<()> {
3542 let invalid = || Error::new(ErrorKind::Error, "invalid integration-policy delete plan");
3543 if plan.targets.is_empty()
3544 || plan.total != plan.targets.len()
3545 || plan.host.trim().is_empty()
3546 || plan.host != plan.host_snapshot
3547 || plan.space != plan.space_snapshot
3548 {
3549 return Err(invalid());
3550 }
3551
3552 let mut previous: Option<&str> = None;
3553 let mut shared = BTreeMap::new();
3554 for target in &plan.targets {
3555 if target.id.trim().is_empty()
3556 || target.name.trim().is_empty()
3557 || previous.is_some_and(|previous| previous >= target.id.as_str())
3558 || target.item != target.item_snapshot
3559 || target.spec.validate().is_err()
3560 || target.spec != target.spec_snapshot
3561 || target.id != target.spec.id
3562 || target.name != target.spec.name
3563 || required_string(&target.item, "id", "integration policy delete plan")
3564 .ok()
3565 .as_deref()
3566 != Some(target.id.as_str())
3567 || normalize(&target.item, &plan.space).ok().as_ref() != Some(&target.spec)
3568 || package_coordinate(&target.item, "integration policy delete plan")
3569 .ok()
3570 .as_ref()
3571 != Some(&target.package)
3572 || target.package != target.spec.package
3573 || target.dependency != target.dependency_snapshot
3574 || !valid_package_state(&target.dependency)
3575 || target.dependency.name != target.package.name
3576 || !is_exact_installed(&target.dependency, &target.package)
3577 || target.metadata != target.metadata_snapshot
3578 || validate_package_metadata_snapshot(&target.metadata, &target.package).is_err()
3579 || !matches!(configured_secret_paths(&target.spec, &target.metadata), Ok(paths) if paths.is_empty())
3580 {
3581 return Err(invalid());
3582 }
3583
3584 let parent_ids = match read_parents(&target.id, &target.item) {
3585 Ok(ids) => ids,
3586 Err(_) => return Err(invalid()),
3587 };
3588 if parent_ids.iter().collect::<BTreeSet<_>>()
3589 != target.parents.keys().collect::<BTreeSet<_>>()
3590 || validate_delete_parent_safety(&target.id, &target.spec, &target.parents).is_err()
3591 {
3592 return Err(invalid());
3593 }
3594 for (parent_id, parent) in &target.parents {
3595 if !valid_parent_snapshot(parent_id, parent)
3596 || parent.platform_owned
3597 || parent.protected
3598 || parent
3599 .attached_integrations
3600 .binary_search_by(|attached| attached.as_str().cmp(&target.id))
3601 .is_err()
3602 {
3603 return Err(invalid());
3604 }
3605 match shared.entry(parent_id.as_str()) {
3606 std::collections::btree_map::Entry::Vacant(entry) => {
3607 entry.insert(parent);
3608 }
3609 std::collections::btree_map::Entry::Occupied(entry) if *entry.get() != parent => {
3610 return Err(invalid());
3611 }
3612 std::collections::btree_map::Entry::Occupied(_) => {}
3613 }
3614 }
3615 previous = Some(&target.id);
3616 }
3617 if plan.parent_snapshots != plan.parent_snapshots_snapshot
3618 || shared_delete_parents(&plan.targets).ok().as_ref() != Some(&plan.parent_snapshots)
3619 {
3620 return Err(invalid());
3621 }
3622 if plan.preview != delete_preview(&plan.targets) {
3623 return Err(invalid());
3624 }
3625 Ok(())
3626}
3627
3628fn http(message: impl Into<String>) -> Error {
3629 Error::new(ErrorKind::Http, message)
3630}
3631
3632fn unsupported<T>(message: impl Into<String>) -> Result<T> {
3633 Err(Error::new(ErrorKind::Unsupported, message))
3634}
3635
3636#[cfg(test)]
3637mod import_plan_tests {
3638 use super::*;
3639
3640 fn valid_plan() -> IntegrationPolicyImportPlan {
3641 let effective = IntegrationPolicySpec::try_from(json!({
3642 "id": "fresh",
3643 "name": "Fresh integration",
3644 "namespace": "default",
3645 "policy_ids": ["parent-1"],
3646 "package": {"name": "system", "version": "2.0.0"},
3647 "inputs": {}
3648 }))
3649 .expect("valid test policy");
3650 let parent = agent_policy_ops::AgentPolicyParentSnapshot {
3651 id: "parent-1".into(),
3652 name: "Parent 1".into(),
3653 namespace: "default".into(),
3654 agents: 0,
3655 attached_integrations: Vec::new(),
3656 platform_owned: false,
3657 protected: false,
3658 };
3659 let targets = vec![IntegrationPolicyImportTarget {
3660 effective: effective.clone(),
3661 current: None,
3662 parents: BTreeMap::from([(parent.id.clone(), parent)]),
3663 replacement_body: None,
3664 }];
3665 let parent_snapshots = targets[0].parents.clone();
3666 let state = PackageDependencySnapshot {
3667 name: "system".into(),
3668 state: PackageDependencyState::NotInstalled,
3669 };
3670 let metadata = json!({
3671 "name": "system",
3672 "version": "2.0.0",
3673 "vars": [],
3674 "policy_templates": []
3675 })
3676 .as_object()
3677 .expect("metadata object")
3678 .clone();
3679 let package_groups = BTreeMap::from([(
3680 "system".into(),
3681 IntegrationPackageGroup {
3682 package: effective.package.clone(),
3683 state: state.clone(),
3684 state_snapshot: state,
3685 metadata_snapshot: metadata.clone(),
3686 metadata,
3687 },
3688 )]);
3689 let package_installs = vec!["system@2.0.0".into()];
3690 let source = PathBuf::from("fresh.json");
3691 let preview = import_preview(&source, &targets, &package_installs);
3692 IntegrationPolicyImportPlan {
3693 preview,
3694 skipped: Vec::new(),
3695 package_installs,
3696 total: 1,
3697 source,
3698 host: "https://fleet.example.invalid".into(),
3699 space: "default".into(),
3700 canonical: vec![effective.clone()],
3701 name_owners: BTreeMap::from([(effective.name.clone(), BTreeSet::new())]),
3702 name_owners_snapshot: BTreeMap::from([(effective.name.clone(), BTreeSet::new())]),
3703 parent_snapshots,
3704 skipped_snapshot: Vec::new(),
3705 existing_snapshot: BTreeMap::from([("fresh".into(), None)]),
3706 targets,
3707 package_groups,
3708 overwrite: false,
3709 skip_existing: false,
3710 }
3711 }
3712
3713 fn existing_plan_without_overwrite() -> IntegrationPolicyImportPlan {
3714 let mut plan = valid_plan();
3715 let existing = {
3716 let target = plan.targets.first_mut().expect("fresh target");
3717 let mut item = serde_json::to_value(&target.effective)
3718 .expect("serialize current item")
3719 .as_object()
3720 .expect("current item object")
3721 .clone();
3722 item.insert("enabled".into(), Value::Bool(true));
3723 target.current = Some(IntegrationPolicyCurrentSnapshot {
3724 item: item.clone(),
3725 spec: target.effective.clone(),
3726 parent_ids: target.effective.policy_ids.clone(),
3727 });
3728 target
3729 .parents
3730 .get_mut("parent-1")
3731 .expect("parent")
3732 .attached_integrations
3733 .push(target.effective.id.clone());
3734 item
3735 };
3736 plan.existing_snapshot
3737 .insert("fresh".into(), Some(existing));
3738
3739 let state = PackageDependencySnapshot {
3740 name: "system".into(),
3741 state: PackageDependencyState::Installed {
3742 version: "2.0.0".into(),
3743 },
3744 };
3745 let group = plan
3746 .package_groups
3747 .get_mut("system")
3748 .expect("package group");
3749 group.state = state.clone();
3750 group.state_snapshot = state;
3751 plan.package_installs.clear();
3752 plan.name_owners
3753 .get_mut("Fresh integration")
3754 .expect("name owner snapshot")
3755 .insert("fresh".into());
3756 plan.name_owners_snapshot = plan.name_owners.clone();
3757 plan.parent_snapshots = plan.targets[0].parents.clone();
3758 plan.preview = import_preview(&plan.source, &plan.targets, &plan.package_installs);
3759 plan
3760 }
3761
3762 fn valid_skip_existing_plan() -> IntegrationPolicyImportPlan {
3763 let mut plan = existing_plan_without_overwrite();
3764 plan.skip_existing = true;
3765 plan.targets.clear();
3766 plan.parent_snapshots.clear();
3767 plan.package_groups.clear();
3768 plan.skipped = vec![json!({"id": "fresh", "reason": "exists"})];
3769 plan.skipped_snapshot = plan.skipped.clone();
3770 plan.package_installs.clear();
3771 plan.preview = import_preview(&plan.source, &plan.targets, &plan.package_installs);
3772 plan
3773 }
3774
3775 fn valid_replace_plan() -> IntegrationPolicyImportPlan {
3776 let mut plan = existing_plan_without_overwrite();
3777 plan.overwrite = true;
3778 let desired = {
3779 let target = plan.targets.first_mut().expect("existing target");
3780 let mut desired = target.effective.clone();
3781 desired.description = Some("changed".into());
3782 target.effective = desired.clone();
3783 target.replacement_body = Some(replace_wire_body(&desired).expect("replace body"));
3784 desired
3785 };
3786 plan.canonical = vec![desired];
3787 plan.preview = import_preview(&plan.source, &plan.targets, &plan.package_installs);
3788 plan
3789 }
3790
3791 #[test]
3792 fn replacement_body_omits_response_only_enabled_without_changing_input_enabled() {
3793 let spec = IntegrationPolicySpec::try_from(json!({
3794 "id": "replacement",
3795 "name": "Replacement integration",
3796 "namespace": "default",
3797 "policy_ids": ["parent-1"],
3798 "package": {"name": "system", "version": "2.0.0"},
3799 "inputs": {"system-log": {"enabled": true}}
3800 }))
3801 .expect("valid replacement spec");
3802
3803 let body = replace_wire_body(&spec).expect("replacement wire body");
3804 let object = body.as_object().expect("replacement wire object");
3805
3806 assert!(object.get("id").is_none());
3807 assert!(object.get("enabled").is_none());
3808 assert_eq!(object["inputs"]["system-log"]["enabled"], true);
3809 }
3810
3811 fn valid_expanded_inputs_plan() -> IntegrationPolicyImportPlan {
3812 let mut plan = valid_plan();
3813 let inputs = json!({"system-system": {}})
3814 .as_object()
3815 .expect("inputs object")
3816 .clone();
3817 plan.canonical[0].inputs = inputs.clone();
3818 plan.targets[0].effective.inputs = inputs;
3819 let metadata = json!({
3820 "name": "system",
3821 "version": "2.0.0",
3822 "vars": [],
3823 "policy_templates": [{
3824 "name": "system",
3825 "inputs": [{"type": "system"}]
3826 }]
3827 })
3828 .as_object()
3829 .expect("metadata object")
3830 .clone();
3831 let group = plan
3832 .package_groups
3833 .get_mut("system")
3834 .expect("package group");
3835 group.metadata = metadata.clone();
3836 group.metadata_snapshot = metadata;
3837 plan.preview = import_preview(&plan.source, &plan.targets, &plan.package_installs);
3838 plan
3839 }
3840
3841 #[test]
3842 fn import_plan_rejects_a_private_create_name_owner_tamper() {
3843 let mut plan = valid_plan();
3844 plan.name_owners
3845 .get_mut("Fresh integration")
3846 .expect("name owner snapshot")
3847 .insert("fresh".into());
3848
3849 assert!(validate_import_plan(&plan).is_err());
3850 }
3851
3852 #[test]
3853 fn import_plan_rejects_a_coherent_empty_effective_inputs_tamper() {
3854 let mut plan = valid_expanded_inputs_plan();
3855 assert!(validate_import_plan(&plan).is_ok());
3856
3857 plan.canonical[0].inputs.clear();
3858 plan.targets[0].effective.inputs.clear();
3859 plan.preview = import_preview(&plan.source, &plan.targets, &plan.package_installs);
3860
3861 let error = validate_import_plan(&plan)
3862 .expect_err("an empty effective map must not reach import requests");
3863 assert_eq!(error.kind, ErrorKind::Unsupported);
3864 assert_eq!(
3865 error.message,
3866 "integration policy 'fresh' has an empty inputs map but package system@2.0.0 declares inputs"
3867 );
3868 }
3869
3870 #[test]
3871 fn import_plan_rejects_an_existing_target_without_overwrite() {
3872 let plan = existing_plan_without_overwrite();
3873
3874 assert!(validate_import_plan(&plan).is_err());
3875 }
3876
3877 #[test]
3878 fn import_plan_rejects_private_snapshot_body_group_and_order_tampering() {
3879 let replace = valid_replace_plan();
3880 assert!(validate_import_plan(&replace).is_ok());
3881
3882 let mut tampered_body = replace.clone();
3883 tampered_body.targets[0].replacement_body = Some(json!({"tampered": true}));
3884 assert!(validate_import_plan(&tampered_body).is_err());
3885
3886 let mut tampered_current = replace.clone();
3887 tampered_current.targets[0]
3888 .current
3889 .as_mut()
3890 .expect("current snapshot")
3891 .item
3892 .insert("enabled".into(), Value::Bool(false));
3893 assert!(validate_import_plan(&tampered_current).is_err());
3894
3895 let mut tampered_group = valid_plan();
3896 tampered_group
3897 .package_groups
3898 .get_mut("system")
3899 .expect("package group")
3900 .metadata
3901 .insert("version".into(), Value::String("9.9.9".into()));
3902 assert!(validate_import_plan(&tampered_group).is_err());
3903
3904 let mut tampered_order = valid_plan();
3905 tampered_order
3906 .targets
3907 .push(tampered_order.targets[0].clone());
3908 assert!(validate_import_plan(&tampered_order).is_err());
3909
3910 let mut tampered_group_key = valid_plan();
3911 let group = tampered_group_key
3912 .package_groups
3913 .remove("system")
3914 .expect("package group");
3915 tampered_group_key
3916 .package_groups
3917 .insert("other".into(), group);
3918 assert!(validate_import_plan(&tampered_group_key).is_err());
3919
3920 let mut tampered_state = valid_plan();
3921 tampered_state
3922 .package_groups
3923 .get_mut("system")
3924 .expect("package group")
3925 .state = PackageDependencySnapshot {
3926 name: "system".into(),
3927 state: PackageDependencyState::Installed {
3928 version: "2.0.0".into(),
3929 },
3930 };
3931 assert!(validate_import_plan(&tampered_state).is_err());
3932
3933 let mut tampered_coordinate = valid_replace_plan();
3934 tampered_coordinate.canonical[0].package.version = "3.0.0".into();
3935 tampered_coordinate.targets[0].effective.package.version = "3.0.0".into();
3936 tampered_coordinate.targets[0].replacement_body = Some(
3937 replace_wire_body(&tampered_coordinate.targets[0].effective).expect("replace body"),
3938 );
3939 let group = tampered_coordinate
3940 .package_groups
3941 .get_mut("system")
3942 .expect("package group");
3943 group.package.version = "3.0.0".into();
3944 group.state = PackageDependencySnapshot {
3945 name: "system".into(),
3946 state: PackageDependencyState::Installed {
3947 version: "3.0.0".into(),
3948 },
3949 };
3950 group.state_snapshot = group.state.clone();
3951 group.metadata.insert("version".into(), json!("3.0.0"));
3952 group.metadata_snapshot = group.metadata.clone();
3953 tampered_coordinate.preview = import_preview(
3954 &tampered_coordinate.source,
3955 &tampered_coordinate.targets,
3956 &tampered_coordinate.package_installs,
3957 );
3958 assert!(validate_import_plan(&tampered_coordinate).is_err());
3959 }
3960
3961 #[test]
3962 fn import_plan_rejects_a_private_parent_snapshot_tamper_even_with_preview_rebuilt() {
3963 let mut plan = valid_plan();
3964 plan.targets[0]
3965 .parents
3966 .get_mut("parent-1")
3967 .expect("parent snapshot")
3968 .agents = 42;
3969 plan.preview = import_preview(&plan.source, &plan.targets, &plan.package_installs);
3970
3971 assert!(validate_import_plan(&plan).is_err());
3972 }
3973
3974 #[test]
3975 fn import_plan_rejects_target_removal_rebuilt_as_a_skipped_row() {
3976 let mut plan = valid_plan();
3977 plan.skip_existing = true;
3978 plan.targets.clear();
3979 plan.parent_snapshots.clear();
3980 plan.package_groups.clear();
3981 plan.skipped = vec![json!({"id": "fresh", "reason": "exists"})];
3982 plan.skipped_snapshot = plan.skipped.clone();
3983 plan.package_installs.clear();
3984 plan.preview = import_preview(&plan.source, &plan.targets, &plan.package_installs);
3985
3986 assert!(validate_import_plan(&plan).is_err());
3987 }
3988
3989 #[test]
3990 fn import_plan_accepts_a_coherent_skip_existing_snapshot() {
3991 assert!(validate_import_plan(&valid_skip_existing_plan()).is_ok());
3992 }
3993
3994 #[test]
3995 fn import_plan_rejects_existence_snapshot_key_and_target_mismatches() {
3996 let mut missing_current = valid_replace_plan();
3997 missing_current
3998 .existing_snapshot
3999 .insert("fresh".into(), None);
4000 assert!(validate_import_plan(&missing_current).is_err());
4001
4002 let mut changed_current = valid_replace_plan();
4003 changed_current
4004 .existing_snapshot
4005 .get_mut("fresh")
4006 .expect("existing snapshot")
4007 .as_mut()
4008 .expect("existing item")
4009 .insert("description".into(), json!("tampered"));
4010 assert!(validate_import_plan(&changed_current).is_err());
4011
4012 let mut extra_snapshot = valid_plan();
4013 extra_snapshot
4014 .existing_snapshot
4015 .insert("other".into(), None);
4016 assert!(validate_import_plan(&extra_snapshot).is_err());
4017 }
4018
4019 #[test]
4020 fn import_plan_rejects_public_field_tampering_against_private_snapshots() {
4021 let plan = valid_plan();
4022
4023 let mut total = plan.clone();
4024 total.total = 2;
4025 assert!(validate_import_plan(&total).is_err());
4026
4027 let mut preview = plan.clone();
4028 preview.preview.preview_action = "tampered".into();
4029 assert!(validate_import_plan(&preview).is_err());
4030
4031 let mut skipped = plan.clone();
4032 skipped.skipped = vec![json!({"id": "fresh", "reason": "exists"})];
4033 assert!(validate_import_plan(&skipped).is_err());
4034
4035 let mut installs = plan;
4036 installs.package_installs.clear();
4037 assert!(validate_import_plan(&installs).is_err());
4038 }
4039}
4040
4041#[cfg(test)]
4042mod delete_plan_tests {
4043 use super::*;
4044 use elasticctl_core::{Profile, Transport};
4045 use wiremock::matchers::{method, path, query_param};
4046 use wiremock::{Mock, MockServer, ResponseTemplate};
4047
4048 async fn verified_server() -> MockServer {
4049 let server = MockServer::start().await;
4050 Mock::given(method("GET"))
4051 .and(path("/api/status"))
4052 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4053 "version": {"number": "9.5.1", "build_flavor": "traditional"}
4054 })))
4055 .mount(&server)
4056 .await;
4057 server
4058 }
4059
4060 fn transport_for(server: &MockServer) -> Transport {
4061 Transport::new(&Profile {
4062 kibana_url: server.uri(),
4063 es_url: None,
4064 api_key: Some("essu_test".into()),
4065 username: None,
4066 password: None,
4067 space: "default".into(),
4068 verify: true,
4069 timeout_secs: 5,
4070 })
4071 .expect("transport")
4072 }
4073
4074 fn valid_plan() -> IntegrationPolicyDeletePlan {
4075 let spec = IntegrationPolicySpec::try_from(json!({
4076 "id": "delete-1",
4077 "name": "Delete integration",
4078 "namespace": "default",
4079 "policy_ids": ["parent-1"],
4080 "package": {"name": "system", "version": "2.0.0"},
4081 "inputs": {}
4082 }))
4083 .expect("valid integration policy");
4084 let mut item = serde_json::to_value(&spec)
4085 .expect("serialize integration policy")
4086 .as_object()
4087 .expect("integration policy is an object")
4088 .clone();
4089 item.insert("enabled".into(), Value::Bool(true));
4090 let parent = agent_policy_ops::AgentPolicyParentSnapshot {
4091 id: "parent-1".into(),
4092 name: "Parent 1".into(),
4093 namespace: "default".into(),
4094 agents: 4,
4095 attached_integrations: vec!["delete-1".into()],
4096 platform_owned: false,
4097 protected: false,
4098 };
4099 let parents = BTreeMap::from([(parent.id.clone(), parent)]);
4100 let dependency = PackageDependencySnapshot {
4101 name: "system".into(),
4102 state: PackageDependencyState::Installed {
4103 version: "2.0.0".into(),
4104 },
4105 };
4106 let metadata = json!({
4107 "name": "system",
4108 "version": "2.0.0",
4109 "vars": [],
4110 "policy_templates": []
4111 })
4112 .as_object()
4113 .expect("metadata object")
4114 .clone();
4115 let target = IntegrationPolicyDeleteTarget {
4116 id: spec.id.clone(),
4117 name: spec.name.clone(),
4118 item_snapshot: item.clone(),
4119 item,
4120 spec_snapshot: spec.clone(),
4121 spec,
4122 parents: parents.clone(),
4123 package: IntegrationPackageSpec {
4124 name: "system".into(),
4125 version: "2.0.0".into(),
4126 },
4127 dependency_snapshot: dependency.clone(),
4128 dependency,
4129 metadata_snapshot: metadata.clone(),
4130 metadata,
4131 };
4132 let targets = vec![target];
4133 let parent_snapshots = parents;
4134 IntegrationPolicyDeletePlan {
4135 preview: delete_preview(&targets),
4136 total: targets.len(),
4137 host: "https://fleet.example.invalid".into(),
4138 host_snapshot: "https://fleet.example.invalid".into(),
4139 space: "default".into(),
4140 space_snapshot: "default".into(),
4141 parent_snapshots_snapshot: parent_snapshots.clone(),
4142 parent_snapshots,
4143 targets,
4144 }
4145 }
4146
4147 #[test]
4148 fn delete_plan_accepts_a_coherent_private_snapshot() {
4149 assert!(validate_delete_plan(&valid_plan()).is_ok());
4150 }
4151
4152 #[test]
4153 fn delete_plan_rejects_empty_total_order_and_preview_tampering() {
4154 let plan = valid_plan();
4155
4156 let mut empty = plan.clone();
4157 empty.targets.clear();
4158 empty.total = 0;
4159 empty.parent_snapshots.clear();
4160 empty.parent_snapshots_snapshot.clear();
4161 empty.preview = delete_preview(&empty.targets);
4162 assert!(validate_delete_plan(&empty).is_err());
4163
4164 let mut total = plan.clone();
4165 total.total = 2;
4166 assert!(validate_delete_plan(&total).is_err());
4167
4168 let mut duplicate = plan.clone();
4169 duplicate.targets.push(duplicate.targets[0].clone());
4170 duplicate.total = 2;
4171 duplicate.preview = delete_preview(&duplicate.targets);
4172 assert!(validate_delete_plan(&duplicate).is_err());
4173
4174 let mut preview = plan;
4175 preview.preview.preview_action = "tampered".into();
4176 assert!(validate_delete_plan(&preview).is_err());
4177
4178 let mut host = valid_plan();
4179 host.host = "https://other.example.invalid".into();
4180 assert!(validate_delete_plan(&host).is_err());
4181
4182 let mut space = valid_plan();
4183 space.space = "other".into();
4184 assert!(validate_delete_plan(&space).is_err());
4185 }
4186
4187 #[test]
4188 fn delete_plan_rejects_raw_spec_parent_package_and_metadata_tampering() {
4189 let plan = valid_plan();
4190
4191 let mut raw_and_spec = plan.clone();
4192 raw_and_spec.targets[0]
4193 .item
4194 .insert("description".into(), json!("tampered"));
4195 raw_and_spec.targets[0].spec.description = Some("tampered".into());
4196 raw_and_spec.preview = delete_preview(&raw_and_spec.targets);
4197 assert!(validate_delete_plan(&raw_and_spec).is_err());
4198
4199 let mut parent = plan.clone();
4200 parent.targets[0]
4201 .parents
4202 .get_mut("parent-1")
4203 .expect("parent")
4204 .agents = 99;
4205 parent.preview = delete_preview(&parent.targets);
4206 assert!(validate_delete_plan(&parent).is_err());
4207
4208 let mut parent_snapshot = plan.clone();
4209 parent_snapshot
4210 .parent_snapshots
4211 .get_mut("parent-1")
4212 .expect("parent")
4213 .agents = 99;
4214 assert!(validate_delete_plan(&parent_snapshot).is_err());
4215
4216 let mut dependency = plan.clone();
4217 dependency.targets[0].dependency = PackageDependencySnapshot {
4218 name: "system".into(),
4219 state: PackageDependencyState::Installed {
4220 version: "1.0.0".into(),
4221 },
4222 };
4223 assert!(validate_delete_plan(&dependency).is_err());
4224
4225 let mut metadata = plan;
4226 metadata.targets[0]
4227 .metadata
4228 .insert("version".into(), json!("9.9.9"));
4229 assert!(validate_delete_plan(&metadata).is_err());
4230 }
4231
4232 #[tokio::test]
4233 async fn delete_apply_rereads_metadata_after_coherent_secret_tampering() {
4234 let server = verified_server().await;
4235 let transport = transport_for(&server);
4236 let spec = IntegrationPolicySpec::try_from(json!({
4237 "id": "delete-1",
4238 "name": "Delete integration",
4239 "namespace": "default",
4240 "policy_ids": ["parent-1"],
4241 "package": {"name": "system", "version": "2.0.0"},
4242 "vars": {"package_secret": "live-plaintext-value-must-not-leak"},
4243 "inputs": {}
4244 }))
4245 .expect("valid integration policy");
4246 let mut item = serde_json::to_value(&spec)
4247 .expect("serialize integration policy")
4248 .as_object()
4249 .expect("integration policy is an object")
4250 .clone();
4251 item.insert("enabled".into(), Value::Bool(true));
4252 let parent = agent_policy_ops::AgentPolicyParentSnapshot {
4253 id: "parent-1".into(),
4254 name: "Parent 1".into(),
4255 namespace: "default".into(),
4256 agents: 4,
4257 attached_integrations: vec![spec.id.clone()],
4258 platform_owned: false,
4259 protected: false,
4260 };
4261 let parents = BTreeMap::from([(parent.id.clone(), parent)]);
4262 let dependency = PackageDependencySnapshot {
4263 name: "system".into(),
4264 state: PackageDependencyState::Installed {
4265 version: "2.0.0".into(),
4266 },
4267 };
4268 let original_metadata = json!({
4269 "name": "system",
4270 "version": "2.0.0",
4271 "vars": [{"name": "package_secret", "secret": true}],
4272 "policy_templates": []
4273 })
4274 .as_object()
4275 .expect("metadata object")
4276 .clone();
4277 let mut target = IntegrationPolicyDeleteTarget {
4278 id: spec.id.clone(),
4279 name: spec.name.clone(),
4280 item_snapshot: item.clone(),
4281 item,
4282 spec_snapshot: spec.clone(),
4283 spec,
4284 parents: parents.clone(),
4285 package: IntegrationPackageSpec {
4286 name: "system".into(),
4287 version: "2.0.0".into(),
4288 },
4289 dependency_snapshot: dependency.clone(),
4290 dependency,
4291 metadata_snapshot: original_metadata.clone(),
4292 metadata: original_metadata.clone(),
4293 };
4294 let mut plan = IntegrationPolicyDeletePlan {
4295 preview: delete_preview(std::slice::from_ref(&target)),
4296 total: 1,
4297 host: server.uri(),
4298 host_snapshot: server.uri(),
4299 space: "default".into(),
4300 space_snapshot: "default".into(),
4301 parent_snapshots_snapshot: parents.clone(),
4302 parent_snapshots: parents,
4303 targets: vec![target.clone()],
4304 };
4305 assert!(validate_delete_plan(&plan).is_err());
4306
4307 let forged_metadata = json!({
4308 "name": "system",
4309 "version": "2.0.0",
4310 "vars": [{"name": "package_secret", "secret": false}],
4311 "policy_templates": []
4312 })
4313 .as_object()
4314 .expect("metadata object")
4315 .clone();
4316 target.metadata = forged_metadata.clone();
4317 target.metadata_snapshot = forged_metadata;
4318 target.item_snapshot = target.item.clone();
4319 target.spec_snapshot = target.spec.clone();
4320 plan.targets = vec![target];
4321 plan.parent_snapshots = shared_delete_parents(&plan.targets).expect("shared parents");
4322 plan.parent_snapshots_snapshot = plan.parent_snapshots.clone();
4323 plan.preview = delete_preview(&plan.targets);
4324 assert!(validate_delete_plan(&plan).is_ok());
4325
4326 let item = plan.targets[0].item.clone();
4327 Mock::given(method("GET"))
4328 .and(path("/api/fleet/package_policies/delete-1"))
4329 .and(query_param("format", "simplified"))
4330 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"item": item})))
4331 .expect(1)
4332 .mount(&server)
4333 .await;
4334 Mock::given(method("GET"))
4335 .and(path("/api/fleet/agent_policies/parent-1"))
4336 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4337 "item": parent_item_for_delete_test("parent-1", "delete-1")
4338 })))
4339 .expect(1)
4340 .mount(&server)
4341 .await;
4342 Mock::given(method("GET"))
4343 .and(path("/api/fleet/epm/packages/system"))
4344 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4345 "item": {
4346 "name": "system",
4347 "status": "installed",
4348 "installationInfo": {"version": "2.0.0"}
4349 }
4350 })))
4351 .expect(1)
4352 .mount(&server)
4353 .await;
4354 Mock::given(method("GET"))
4355 .and(path("/api/fleet/epm/packages/system/2.0.0"))
4356 .respond_with(ResponseTemplate::new(200).set_body_json(json!({
4357 "item": original_metadata
4358 })))
4359 .expect(1)
4360 .mount(&server)
4361 .await;
4362 Mock::given(method("DELETE"))
4363 .and(path("/api/fleet/package_policies/delete-1"))
4364 .respond_with(ResponseTemplate::new(200).set_body_json(json!({"id": "delete-1"})))
4365 .expect(0)
4366 .mount(&server)
4367 .await;
4368
4369 let report = apply_delete(&transport, &plan)
4370 .await
4371 .expect("metadata race is a row failure");
4372 assert!(report.deleted.is_empty());
4373 assert_eq!(
4374 report.failed,
4375 vec![json!({
4376 "id": "delete-1",
4377 "applied": false,
4378 "error": "integration policy package metadata changed since preview"
4379 })]
4380 );
4381 let requests = server.received_requests().await.expect("recorded requests");
4382 assert_eq!(
4383 requests
4384 .iter()
4385 .filter(|request| request.url.path() == "/api/fleet/epm/packages/system/2.0.0")
4386 .count(),
4387 1
4388 );
4389 assert!(requests.iter().all(|request| request.method != "DELETE"));
4390 assert!(
4391 !report.failed[0]["error"]
4392 .as_str()
4393 .expect("error string")
4394 .contains("live-plaintext-value-must-not-leak")
4395 );
4396 }
4397
4398 fn parent_item_for_delete_test(id: &str, attached: &str) -> Value {
4399 json!({
4400 "id": id,
4401 "name": "Parent 1",
4402 "namespace": "default",
4403 "agents": 4,
4404 "package_policies": [attached],
4405 })
4406 }
4407}