Skip to main content

elasticctl_api/fleet/integration_policy_ops/
import_plan.rs

1use super::common::*;
2use super::import_shared::{
3    IntegrationPackageGroup, IntegrationPolicyCurrentSnapshot, IntegrationPolicyImportArtifact,
4    IntegrationPolicyImportPlan, IntegrationPolicyImportTarget, effective_import_spec,
5    import_preview, import_remote_error, planned_package_installs, read_import_parent_snapshots,
6    relevant_name_owners, replace_wire_body, target_name_owners_match,
7    validate_requested_package_versions,
8};
9use super::import_validation::validate_import_plan;
10use super::metadata::{
11    configured_secret_paths, validate_effective_input_materialization,
12    validate_package_metadata_snapshot,
13};
14use super::normalization::normalize;
15
16/// Read, validate, and retain an integration-policy artifact before context
17/// or credential construction. The returned value has no public raw fields.
18pub fn prepare_import(path: &Path) -> Result<IntegrationPolicyImportArtifact> {
19    let canonical = validate(path)?;
20    if canonical.is_empty() {
21        return Err(Error::new(
22            ErrorKind::Error,
23            "integration-policy import needs at least one integration policy",
24        ));
25    }
26    validate_requested_package_versions(&canonical)?;
27    Ok(IntegrationPolicyImportArtifact {
28        source: path.to_path_buf(),
29        canonical,
30    })
31}
32
33/// Plan a retained integration-policy import without reopening its source or
34/// sending a write. Apply uses only the returned plan.
35pub async fn plan_prepared_import(
36    transport: &Transport,
37    artifact: IntegrationPolicyImportArtifact,
38    overwrite: bool,
39    skip_existing: bool,
40) -> Result<IntegrationPolicyImportPlan> {
41    let IntegrationPolicyImportArtifact { source, canonical } = artifact;
42    if overwrite && skip_existing {
43        return Err(Error::new(
44            ErrorKind::Error,
45            "--overwrite and --skip-existing cannot be used together",
46        ));
47    }
48
49    // Read only the requested ids. A conflicting or skipped existing policy is
50    // deliberately kept raw: normalize can refuse an unsupported object, but
51    // that object will not be written on those paths.
52    let mut existing = BTreeMap::new();
53    let mut conflicts = Vec::new();
54    for spec in &canonical {
55        match integration_policies::get(transport, &spec.id).await {
56            Ok(policy) => {
57                let returned_id = required_string(&policy.item, "id", "integration policy get")?;
58                if returned_id != spec.id {
59                    return Err(http(
60                        "decoding integration policy get: response id did not match the request",
61                    ));
62                }
63                if !overwrite && !skip_existing {
64                    conflicts.push(spec.id.clone());
65                }
66                existing.insert(spec.id.clone(), Some(policy.item));
67            }
68            Err(error) if error.kind == ErrorKind::NotFound => {
69                existing.insert(spec.id.clone(), None);
70            }
71            Err(error) => return Err(import_remote_error(error, "planning read")),
72        }
73    }
74    if !conflicts.is_empty() {
75        return Err(Error::new(
76            ErrorKind::Conflict,
77            format!(
78                "integration policies already exist: {}",
79                conflicts.join(", ")
80            ),
81        ));
82    }
83
84    // This phase comes before every parent, package-status, and metadata read.
85    // A package-coordinate replacement is a different Fleet operation, never
86    // a package-policy import update.
87    for spec in &canonical {
88        if skip_existing && matches!(existing.get(&spec.id), Some(Some(_))) {
89            continue;
90        }
91        let Some(Some(item)) = existing.get(&spec.id) else {
92            continue;
93        };
94        let current = package_coordinate(item, "integration policy")
95            .map_err(|error| import_remote_error(error, "planning read"))?;
96        if current != spec.package {
97            return unsupported(format!(
98                "integration policy '{}' cannot change package {}@{} to {}@{}",
99                spec.id, current.name, current.version, spec.package.name, spec.package.version
100            ));
101        }
102    }
103
104    // Every artifact name stays relevant even if its id is skipped. A foreign
105    // claimant is always a conflict, and this read deliberately happens before
106    // any skipped object's raw response would be normalized.
107    let names: BTreeSet<String> = canonical.iter().map(|spec| spec.name.clone()).collect();
108    let name_owners = relevant_name_owners(transport, &names)
109        .await
110        .map_err(|error| import_remote_error(error, "planning names read"))?;
111    let mut name_conflicts = Vec::new();
112    for spec in &canonical {
113        let owners = name_owners
114            .get(&spec.name)
115            .expect("requested name has an ownership entry");
116        for owner in owners.iter().filter(|owner| owner.as_str() != spec.id) {
117            name_conflicts.push(format!("{} ({owner})", spec.name));
118        }
119    }
120    if !name_conflicts.is_empty() {
121        return Err(Error::new(
122            ErrorKind::Conflict,
123            format!(
124                "integration policy names already exist: {}",
125                name_conflicts.join(", ")
126            ),
127        ));
128    }
129
130    let mut skipped = Vec::new();
131    let pending: Vec<IntegrationPolicySpec> = canonical
132        .iter()
133        .filter_map(|spec| match existing.get(&spec.id) {
134            Some(Some(item)) if skip_existing => {
135                skipped.push(json!({"id": spec.id, "reason": "exists"}));
136                None
137            }
138            _ => Some(spec.clone()),
139        })
140        .collect();
141
142    let mut targets = Vec::with_capacity(pending.len());
143    let mut shared_parents = BTreeMap::new();
144    for spec in pending {
145        let raw = existing
146            .get(&spec.id)
147            .expect("every canonical id was fetched")
148            .clone();
149        let current_parent_ids = raw
150            .as_ref()
151            .map(|item| {
152                read_parents(&spec.id, item)
153                    .map_err(|error| import_remote_error(error, "planning read"))
154            })
155            .transpose()?;
156        let parents = read_import_parent_snapshots(
157            transport,
158            &spec.id,
159            current_parent_ids.as_deref().unwrap_or_default(),
160            &spec.policy_ids,
161        )
162        .await
163        .map_err(|error| import_remote_error(error, "planning parent read"))?;
164        for (parent_id, parent) in &parents {
165            match shared_parents.entry(parent_id.clone()) {
166                std::collections::btree_map::Entry::Vacant(entry) => {
167                    entry.insert(parent.clone());
168                }
169                std::collections::btree_map::Entry::Occupied(entry) if entry.get() != parent => {
170                    return Err(Error::new(
171                        ErrorKind::Conflict,
172                        format!(
173                            "agent policy '{parent_id}' changed while planning integration import"
174                        ),
175                    ));
176                }
177                std::collections::btree_map::Entry::Occupied(_) => {}
178            }
179        }
180        let effective = effective_import_spec(&spec, &parents)?;
181        let current = raw
182            .map(|item| {
183                let normalized = normalize(&item, transport.space())
184                    .map_err(|error| import_remote_error(error, "planning read"))?;
185                if normalized.id != spec.id {
186                    return Err(http(
187                        "decoding integration policy: response id did not match the request",
188                    ));
189                }
190                Ok(IntegrationPolicyCurrentSnapshot {
191                    item,
192                    spec: normalized,
193                    parent_ids: current_parent_ids.expect("raw policy has parents"),
194                })
195            })
196            .transpose()?;
197        targets.push(IntegrationPolicyImportTarget {
198            effective,
199            current,
200            parents,
201            replacement_body: None,
202        });
203    }
204    if targets
205        .iter()
206        .any(|target| !target_name_owners_match(target, &name_owners))
207    {
208        return Err(Error::new(
209            ErrorKind::Conflict,
210            "integration policy name ownership changed while planning",
211        ));
212    }
213
214    let mut package_coordinates = BTreeMap::new();
215    for target in &targets {
216        package_coordinates
217            .entry(target.effective.package.name.clone())
218            .or_insert_with(|| target.effective.package.clone());
219    }
220    let mut package_groups = BTreeMap::new();
221    for (name, package) in package_coordinates {
222        let state = read_dependencies(transport, &package)
223            .await
224            .map_err(|error| import_remote_error(error, "planning package read"))?;
225        match &state.state {
226            PackageDependencyState::Installed { version } if version == &package.version => {}
227            PackageDependencyState::Installed { .. } => {
228                return Err(Error::new(
229                    ErrorKind::Conflict,
230                    format!("integration package {name} has a different installed version"),
231                ));
232            }
233            PackageDependencyState::NotInstalled => {
234                if targets
235                    .iter()
236                    .any(|target| target.effective.package.name == name && target.current.is_some())
237                {
238                    return Err(Error::new(
239                        ErrorKind::Conflict,
240                        format!("integration package {name} is not installed"),
241                    ));
242                }
243            }
244        }
245        let metadata =
246            integration_policies::package_metadata(transport, &package.name, &package.version)
247                .await
248                .map_err(|error| import_remote_error(error, "planning package metadata read"))?
249                .item;
250        validate_package_metadata_snapshot(&metadata, &package)
251            .map_err(|error| import_remote_error(error, "planning package metadata read"))?;
252        package_groups.insert(
253            name,
254            IntegrationPackageGroup {
255                package,
256                state: state.clone(),
257                state_snapshot: state,
258                metadata_snapshot: metadata.clone(),
259                metadata,
260            },
261        );
262    }
263
264    for target in &targets {
265        let package = package_groups
266            .get(&target.effective.package.name)
267            .expect("every effective package has a group");
268        validate_effective_input_materialization(&target.effective, &package.metadata)?;
269    }
270
271    let mut secret_paths = BTreeSet::new();
272    for target in &targets {
273        let package = package_groups
274            .get(&target.effective.package.name)
275            .expect("every effective package has a group");
276        if let Some(current) = &target.current {
277            for path in configured_secret_paths(&current.spec, &package.metadata)? {
278                secret_paths.insert(format!("{}:{path}", current.spec.id));
279            }
280        }
281        for path in configured_secret_paths(&target.effective, &package.metadata)? {
282            secret_paths.insert(format!("{}:{path}", target.effective.id));
283        }
284    }
285    if !secret_paths.is_empty() {
286        return unsupported(format!(
287            "integration policy import contains configured secrets: {}",
288            secret_paths.into_iter().collect::<Vec<_>>().join(", ")
289        ));
290    }
291
292    for target in &mut targets {
293        if let Some(current) = &target.current
294            && current.spec != target.effective
295        {
296            target.replacement_body = Some(replace_wire_body(&target.effective)?);
297        }
298    }
299    let package_installs = planned_package_installs(&package_groups);
300    let preview = import_preview(&source, &targets, &package_installs);
301    let plan = IntegrationPolicyImportPlan {
302        preview,
303        skipped_snapshot: skipped.clone(),
304        skipped,
305        package_installs,
306        total: canonical.len(),
307        source,
308        host: transport.kibana_url().to_owned(),
309        space: transport.space().to_owned(),
310        canonical,
311        name_owners_snapshot: name_owners.clone(),
312        name_owners,
313        parent_snapshots: shared_parents,
314        existing_snapshot: existing.clone(),
315        targets,
316        package_groups,
317        overwrite,
318        skip_existing,
319    };
320    validate_import_plan(&plan)?;
321    Ok(plan)
322}
323
324/// Plan a canonical integration-policy import without sending a write. This
325/// convenience wrapper reads the source once, then delegates to the retained
326/// artifact planner.
327pub async fn plan_import(
328    transport: &Transport,
329    path: &Path,
330    overwrite: bool,
331    skip_existing: bool,
332) -> Result<IntegrationPolicyImportPlan> {
333    let artifact = prepare_import(path)?;
334    plan_prepared_import(transport, artifact, overwrite, skip_existing).await
335}
336
337/// Decode one JSON or YAML artifact without constructing transport or config.
338pub fn validate(path: &Path) -> Result<Vec<IntegrationPolicySpec>> {
339    let body = std::fs::read_to_string(path).map_err(|error| {
340        Error::new(
341            ErrorKind::Error,
342            format!("reading {}: {error}", path.display()),
343        )
344    })?;
345    let mut specs = content_codec::decode_sequence::<IntegrationPolicySpec>(
346        &body,
347        ContentFormat::from_path(path),
348        "integration policy",
349    )?;
350    duplicate_error(&specs, |spec| &spec.id, "ids")?;
351    duplicate_error(&specs, |spec| &spec.name, "names")?;
352    specs.sort_by(|left, right| left.id.cmp(&right.id));
353    Ok(specs)
354}
355
356fn duplicate_error<'a, F>(specs: &'a [IntegrationPolicySpec], key: F, noun: &str) -> Result<()>
357where
358    F: Fn(&'a IntegrationPolicySpec) -> &'a String,
359{
360    let mut seen = BTreeSet::new();
361    let mut duplicates = BTreeSet::new();
362    for spec in specs {
363        let value = key(spec);
364        if !seen.insert(value.as_str()) {
365            duplicates.insert(value.as_str());
366        }
367    }
368    if duplicates.is_empty() {
369        Ok(())
370    } else {
371        Err(Error::new(
372            ErrorKind::Error,
373            format!(
374                "duplicate integration policy {noun}: {}",
375                duplicates.into_iter().collect::<Vec<_>>().join(", ")
376            ),
377        ))
378    }
379}