Skip to main content

elasticctl_api/fleet/
integration_policy_ops.rs

1//! Integration-policy selection, normalization, and local validation.
2
3use 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/// A resolved selector retains the one-object response for later operations.
37#[derive(Debug, Clone, PartialEq)]
38pub(crate) struct ResolvedIntegrationPolicy {
39    pub(crate) summary: IntegrationPolicySummary,
40    pub(crate) item: Map<String, Value>,
41}
42
43/// Strictly reduced package installation state. Fleet's public status decoder
44/// intentionally preserves registry text for agent-policy orchestration; an
45/// integration dependency must not accept an ambiguous state.
46#[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/// Exact package-defined secret variables. The companion `known_*` maps are
59/// deliberately private implementation detail: a configured value is safe
60/// only after Fleet metadata proves it has a definition.
61#[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
87/// A locally decoded import artifact. Its canonical specifications are kept
88/// private so callers can retain a validated source across context setup
89/// without being able to alter what remote planning will use.
90pub 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/// What `plan_import` preflights and `apply_import` rechecks. Only guard
105/// presentation is public: the canonical artifact, effective specifications,
106/// and Fleet snapshots never cross the API boundary.
107#[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    // Exact get-by-id results from before import classification. This covers
122    // every canonical id, so a mutable target or skipped row cannot turn an
123    // absent policy into an existing one (or the reverse).
124    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/// A safe, fully preflighted integration-policy deletion. Only the guard
157/// presentation and count are public: Fleet snapshots and package metadata can
158/// include configuration values, so they remain private to the API layer.
159#[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/// The result of applying an integration-policy deletion plan.
183#[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/// Private execution facts for one stable-id delete. The raw item detects a
208/// Fleet change before deletion; the normalized policy and metadata prove that
209/// no secret or environment-bound configuration has crossed the guard.
210#[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/// One package coordinate is shared by every pending policy that uses it.
227/// `*_snapshot` copies are deliberate: plan validation compares the retained
228/// exact Fleet response with the mutable execution expectation before it ever
229/// starts remote work.
230#[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
246/// Collect all measured pages then sort by stable id locally.
247pub 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
303/// List with local case-insensitive search and post-sort limiting.
304pub 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
331/// Resolve a stable id first, then a unique exact name.
332pub async fn resolve(transport: &Transport, selector: &str) -> Result<IntegrationPolicySummary> {
333    Ok(resolve_item(transport, selector).await?.summary)
334}
335
336/// Resolve a selector and retain the exact one-object response for Task 4.
337pub(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
382/// Return a safe integration-policy view. Parent reads are both the attachment
383/// race check and the sole source of affected-agent counts.
384pub 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
425/// Validate every live shape that a safe detail can reason about without
426/// erasing its direct portability blockers. The projection keeps package-owned
427/// configuration intact while replacing only values that a detail reports in
428/// `blocked_by`, so `normalize` remains the single structural validator.
429fn 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
447/// Export selected integrations or every custom integration. A selector is
448/// resolved once and deduplicated by its stable id before any parent, package,
449/// or metadata reads.
450pub 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    // A package policy can only have been compiled from the exact installed
614    // coordinate. Treat a divergent or absent state as a loud conflict.
615    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
1232/// Build a fresh portable policy from a simplified live Fleet response.
1233pub 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
1347/// Keep package-defined input and stream maps open while rebuilding them
1348/// without Fleet-generated ids or compiled content.
1349fn 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
1620/// Read, validate, and retain an integration-policy artifact before context
1621/// or credential construction. The returned value has no public raw fields.
1622pub 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
1637/// Plan a retained integration-policy import without reopening its source or
1638/// sending a write. Apply uses only the returned plan.
1639pub 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    // Read only the requested ids. A conflicting or skipped existing policy is
1654    // deliberately kept raw: normalize can refuse an unsupported object, but
1655    // that object will not be written on those paths.
1656    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    // This phase comes before every parent, package-status, and metadata read.
1689    // A package-coordinate replacement is a different Fleet operation, never
1690    // a package-policy import update.
1691    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    // Every artifact name stays relevant even if its id is skipped. A foreign
1709    // claimant is always a conflict, and this read deliberately happens before
1710    // any skipped object's raw response would be normalized.
1711    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(&current.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
1928/// Plan a canonical integration-policy import without sending a write. This
1929/// convenience wrapper reads the source once, then delegates to the retained
1930/// artifact planner.
1931pub 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
2206/// Decode one JSON or YAML artifact without constructing transport or config.
2207pub 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
2250/// Apply a previously validated import plan. Rows are independent except for
2251/// their exact package group and shared parent-attachment snapshots.
2252pub 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        // A missing package is a shared dependency. Fleet's create path can
2376        // install it even when the policy write fails, so observation is
2377        // mandatory after every create attempt, not only decoded success.
2378        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(&current.item, &plan.space).ok().as_ref() != Some(&current.spec)
2750                {
2751                    return invalid("current integration snapshot does not normalize canonically");
2752                }
2753                let parent_ids = match read_parents(&target.effective.id, &current.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(&current.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(&current.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
2971/// Plan safe, stable-id integration-policy deletion without issuing a
2972/// mutation. Each selected object is retained exactly so `apply_delete` can
2973/// reject a Fleet change before it reaches the single-id delete route.
2974pub 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
3040/// Delete planning must bind an id selector to the id Fleet returned for that
3041/// id route. The general resolver keeps its historical public behavior for
3042/// list, get, and export; a mutation cannot accept a mismatched one-object
3043/// response as a different target.
3044async 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
3341/// Recheck the exact planning snapshots, then delete each independent target.
3342/// An acknowledged wrong-id response is deliberately not treated as a clean
3343/// deletion, and never advances shared parent expectations.
3344pub 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}