elasticctl_api/fleet/integration_policy_ops/
import_plan.rs1use 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
16pub 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
33pub 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 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 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 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(¤t.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
324pub 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
337pub 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}