Skip to main content

elasticctl_api/fleet/integration_policy_ops/
import_apply.rs

1use super::common::*;
2use super::import_shared::{
3    IntegrationPolicyImportPlan, IntegrationPolicyImportTarget, import_remote_error,
4    relevant_name_owners,
5};
6use super::import_validation::validate_import_plan;
7use super::normalization::normalize;
8
9#[derive(Debug, Clone, PartialEq, Serialize)]
10pub struct IntegrationPolicyImportReport {
11    pub applied: bool,
12    pub succeeded: Vec<Value>,
13    pub unchanged: Vec<Value>,
14    pub skipped: Vec<Value>,
15    pub failed: Vec<Value>,
16    pub total: usize,
17    pub affected_agents: u64,
18    pub package_installs: Vec<String>,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22enum ImportAction {
23    Create,
24    Replace,
25    Unchanged,
26}
27
28/// Apply a previously validated import plan. Rows are independent except for
29/// their exact package group and shared parent-attachment snapshots.
30pub async fn apply_import(
31    transport: &Transport,
32    plan: &IntegrationPolicyImportPlan,
33) -> Result<IntegrationPolicyImportReport> {
34    validate_import_plan(plan)?;
35    if plan.host != transport.kibana_url() || plan.space != transport.space() {
36        return Err(Error::new(
37            ErrorKind::Conflict,
38            "integration import target changed since preview",
39        ));
40    }
41    let mut succeeded = Vec::new();
42    let mut unchanged = Vec::new();
43    let mut failed = Vec::new();
44    let mut expected_groups = plan.package_groups.clone();
45    let mut expected_parents = plan.parent_snapshots.clone();
46    let mut blocked_packages = BTreeMap::<String, String>::new();
47    let mut affected_parents = BTreeMap::<String, u64>::new();
48    let mut observed_installs = BTreeSet::new();
49
50    for target in &plan.targets {
51        let package_name = &target.effective.package.name;
52        if let Some(error) = blocked_packages.get(package_name) {
53            failed.push(import_failed_row(
54                &target.effective.id,
55                false,
56                format!("package dependency is unavailable: {error}"),
57            ));
58            continue;
59        }
60
61        let action = match recheck_import_object(transport, target).await {
62            Ok(action) => action,
63            Err(error) => {
64                failed.push(import_failed_row(
65                    &target.effective.id,
66                    false,
67                    error.message,
68                ));
69                continue;
70            }
71        };
72        if let Err(error) = recheck_import_name_owner(transport, target, &plan.name_owners).await {
73            failed.push(import_failed_row(
74                &target.effective.id,
75                false,
76                error.message,
77            ));
78            continue;
79        }
80        if let Err(error) = recheck_import_parents(transport, target, &expected_parents).await {
81            failed.push(import_failed_row(
82                &target.effective.id,
83                false,
84                error.message,
85            ));
86            continue;
87        }
88
89        let group = expected_groups
90            .get_mut(package_name)
91            .expect("validated target package group");
92        let actual_state = match read_dependencies(transport, &target.effective.package).await {
93            Ok(state) if state == group.state => state,
94            Ok(_) => {
95                let message = "package changed since preview".to_owned();
96                blocked_packages.insert(package_name.clone(), message.clone());
97                failed.push(import_failed_row(&target.effective.id, false, message));
98                continue;
99            }
100            Err(error) => {
101                let message = import_remote_error(error, "apply package read").message;
102                blocked_packages.insert(package_name.clone(), message.clone());
103                failed.push(import_failed_row(&target.effective.id, false, message));
104                continue;
105            }
106        };
107        debug_assert_eq!(actual_state, group.state);
108
109        if action == ImportAction::Unchanged {
110            unchanged.push(json!({"id": target.effective.id}));
111            continue;
112        }
113
114        let (label, applied, route_error) = match action {
115            ImportAction::Create => {
116                match integration_policies::create(transport, &target.effective).await {
117                    Ok(_) => ("created", true, None),
118                    Err(error) => (
119                        "created",
120                        false,
121                        Some(import_remote_error(error, "create request").message),
122                    ),
123                }
124            }
125            ImportAction::Replace => {
126                let _body = target
127                    .replacement_body
128                    .as_ref()
129                    .expect("validated replacement body");
130                match integration_policies::update(
131                    transport,
132                    &target.effective.id,
133                    &target.effective,
134                )
135                .await
136                {
137                    Ok(_) => ("replaced", true, None),
138                    Err(error) => (
139                        "replaced",
140                        false,
141                        Some(import_remote_error(error, "update request").message),
142                    ),
143                }
144            }
145            ImportAction::Unchanged => unreachable!("unchanged rows continue above"),
146        };
147
148        if applied {
149            record_affected_parents(&mut affected_parents, target);
150            advance_parent_snapshots(&mut expected_parents, target);
151        }
152
153        // A missing package is a shared dependency. Fleet's create path can
154        // install it even when the policy write fails, so observation is
155        // mandatory after every create attempt, not only decoded success.
156        let mut observed_after_create = None;
157        let mut package_observation_error = None;
158        if action == ImportAction::Create
159            && matches!(group.state.state, PackageDependencyState::NotInstalled)
160        {
161            match read_dependencies(transport, &target.effective.package).await {
162                Ok(after) => {
163                    if is_exact_installed(&after, &target.effective.package) {
164                        group.state = after.clone();
165                        observed_installs.insert(format!(
166                            "{}@{}",
167                            target.effective.package.name, target.effective.package.version
168                        ));
169                    } else if !matches!(after.state, PackageDependencyState::NotInstalled) {
170                        let message =
171                            "package installed a different version after create".to_owned();
172                        blocked_packages.insert(package_name.clone(), message.clone());
173                        package_observation_error = Some(message);
174                    }
175                    observed_after_create = Some(after);
176                }
177                Err(error) => {
178                    let message = import_remote_error(error, "post-create package read").message;
179                    blocked_packages.insert(package_name.clone(), message.clone());
180                    package_observation_error = Some(message);
181                }
182            }
183        }
184
185        let mut errors = route_error.into_iter().collect::<Vec<_>>();
186        if applied {
187            if let Err(error) = verify_import_stored(transport, &target.effective).await {
188                errors.push(error.message);
189            }
190            let package_result = match observed_after_create.as_ref() {
191                Some(after) => verify_exact_installed(after, &target.effective.package),
192                None => match read_dependencies(transport, &target.effective.package).await {
193                    Ok(after) => verify_exact_installed(&after, &target.effective.package),
194                    Err(error) => Err(import_remote_error(error, "package verification").message),
195                },
196            };
197            if let Err(message) = package_result {
198                blocked_packages
199                    .entry(package_name.clone())
200                    .or_insert_with(|| message.clone());
201                errors.push(message);
202            }
203        }
204        if let Some(error) = package_observation_error
205            && !errors.contains(&error)
206        {
207            errors.push(error);
208        }
209
210        if errors.is_empty() {
211            succeeded.push(json!({"id": target.effective.id, "action": label}));
212        } else {
213            failed.push(import_failed_row(
214                &target.effective.id,
215                applied,
216                errors.join("; "),
217            ));
218        }
219    }
220
221    Ok(IntegrationPolicyImportReport {
222        applied: true,
223        succeeded,
224        unchanged,
225        skipped: plan.skipped.clone(),
226        failed,
227        total: plan.total,
228        affected_agents: affected_parents.values().sum(),
229        package_installs: observed_installs.into_iter().collect(),
230    })
231}
232
233async fn recheck_import_object(
234    transport: &Transport,
235    target: &IntegrationPolicyImportTarget,
236) -> Result<ImportAction> {
237    match &target.current {
238        None => match integration_policies::get(transport, &target.effective.id).await {
239            Err(error) if error.kind == ErrorKind::NotFound => Ok(ImportAction::Create),
240            Ok(_) => Err(Error::new(
241                ErrorKind::Conflict,
242                "integration policy appeared since preview",
243            )),
244            Err(error) => Err(import_remote_error(error, "apply integration-policy read")),
245        },
246        Some(expected) => match integration_policies::get(transport, &target.effective.id).await {
247            Ok(actual) if actual.item == expected.item => {
248                if expected.spec == target.effective {
249                    Ok(ImportAction::Unchanged)
250                } else {
251                    Ok(ImportAction::Replace)
252                }
253            }
254            Ok(_) => Err(Error::new(
255                ErrorKind::Conflict,
256                "integration policy changed since preview",
257            )),
258            Err(error) if error.kind == ErrorKind::NotFound => Err(Error::new(
259                ErrorKind::Conflict,
260                "integration policy disappeared since preview",
261            )),
262            Err(error) => Err(import_remote_error(error, "apply integration-policy read")),
263        },
264    }
265}
266
267async fn recheck_import_name_owner(
268    transport: &Transport,
269    target: &IntegrationPolicyImportTarget,
270    expected_owners: &BTreeMap<String, BTreeSet<String>>,
271) -> Result<()> {
272    let names = BTreeSet::from([target.effective.name.clone()]);
273    let owners = relevant_name_owners(transport, &names)
274        .await
275        .map_err(|error| import_remote_error(error, "apply name read"))?;
276    if owners.get(&target.effective.name) == expected_owners.get(&target.effective.name) {
277        Ok(())
278    } else {
279        Err(Error::new(
280            ErrorKind::Conflict,
281            "integration policy name ownership changed since preview",
282        ))
283    }
284}
285
286async fn recheck_import_parents(
287    transport: &Transport,
288    target: &IntegrationPolicyImportTarget,
289    expected_parents: &BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
290) -> Result<()> {
291    for parent_id in target.parents.keys() {
292        let expected = expected_parents.get(parent_id).ok_or_else(|| {
293            Error::new(
294                ErrorKind::Error,
295                "integration import lost a shared parent snapshot",
296            )
297        })?;
298        let actual = agent_policy_ops::read_parent_snapshot(transport, parent_id)
299            .await
300            .map_err(|error| import_remote_error(error, "apply parent read"))?;
301        if actual != *expected {
302            return Err(Error::new(
303                ErrorKind::Conflict,
304                "integration policy parent changed since preview",
305            ));
306        }
307    }
308    Ok(())
309}
310
311fn record_affected_parents(
312    affected: &mut BTreeMap<String, u64>,
313    target: &IntegrationPolicyImportTarget,
314) {
315    for parent in target.parents.values() {
316        affected.entry(parent.id.clone()).or_insert(parent.agents);
317    }
318}
319
320fn advance_parent_snapshots(
321    parents: &mut BTreeMap<String, agent_policy_ops::AgentPolicyParentSnapshot>,
322    target: &IntegrationPolicyImportTarget,
323) {
324    let desired = target
325        .effective
326        .policy_ids
327        .iter()
328        .map(String::as_str)
329        .collect::<BTreeSet<_>>();
330    for parent_id in target.parents.keys() {
331        let parent = parents
332            .get_mut(parent_id)
333            .expect("validated shared parent snapshot");
334        if desired.contains(parent_id.as_str()) {
335            if parent
336                .attached_integrations
337                .binary_search_by(|attached| attached.as_str().cmp(&target.effective.id))
338                .is_err()
339            {
340                parent
341                    .attached_integrations
342                    .push(target.effective.id.clone());
343                parent.attached_integrations.sort();
344            }
345        } else {
346            parent
347                .attached_integrations
348                .retain(|attached| attached != &target.effective.id);
349        }
350    }
351}
352
353async fn verify_import_stored(
354    transport: &Transport,
355    desired: &IntegrationPolicySpec,
356) -> Result<()> {
357    let stored = integration_policies::get(transport, &desired.id)
358        .await
359        .map_err(|error| import_remote_error(error, "stored-policy read"))?;
360    let stored = normalize(&stored.item, transport.space())
361        .map_err(|error| import_remote_error(error, "stored-policy read"))?;
362    if stored == *desired {
363        Ok(())
364    } else {
365        Err(Error::new(
366            ErrorKind::Http,
367            "server stored a different integration-policy spec",
368        ))
369    }
370}
371
372fn import_failed_row(id: &str, applied: bool, error: impl Into<String>) -> Value {
373    json!({"id": id, "applied": applied, "error": error.into()})
374}