Skip to main content

regent_sdk/state/attribute/package/
apt.rs

1//! APT package management attribute
2//!
3//! This module provides the `AptExpectedState` type for managing Debian/Ubuntu packages
4//! using the APT package manager.
5//!
6//! **Compatible OS:** Linux (Debian-based distributions: Debian, Ubuntu, etc.)
7//!
8//! # Examples
9//!
10//! ## Rust API
11//!
12//! ```no_run
13//! use regent_sdk::state::attribute::package::apt::AptExpectedState;
14//! use regent_sdk::{Attribute, ExpectedState, Privilege};
15//!
16//! // Install a package
17//! let apache = AptExpectedState::package_present("apache2");
18//!
19//! // Remove a package
20//! let nginx = AptExpectedState::package_absent("nginx");
21//!
22//! // Trigger a full system upgrade
23//! let upgrade = AptExpectedState::full_system_upgrade();
24//!
25//! let expected_state = ExpectedState::new()
26//!     .with_attribute(Attribute::apt(apache, Privilege::WithSudo, None))
27//!     .build();
28//! ```
29//!
30//! ## YAML API
31//!
32//! ```yaml
33//! Attributes:
34//!   - Name: Apache2 package must be present
35//!     Privilege: !WithSudo
36//!     Detail: !Apt
37//!       Package: apache2
38//!       State: Present
39//! ```
40//!
41//! For a full system upgrade:
42//!
43//! ```yaml
44//! Attributes:
45//!   - Name: All packages must be up to date
46//!     Privilege: !WithSudo
47//!     Detail: !Apt
48//!       SystemUpToDate
49//! ```
50
51use crate::error::RegentError;
52use crate::hosts::managed_host::InternalApiCallOutcome;
53use crate::hosts::managed_host::{AssessCompliance, ReachCompliance, Timeout};
54use crate::hosts::properties::{HostProperties, LinuxFlavor, LinuxSpecifics, OsKind};
55use crate::secrets::SecretProvidersPool;
56use crate::state::Check;
57use crate::state::attribute::HostHandler;
58use crate::state::attribute::Privilege;
59use crate::state::attribute::Remediation;
60use crate::state::attribute::RemediationsList;
61use crate::state::attribute::package::PackageExpectedState;
62use crate::state::compliance::AttributeComplianceAssessment;
63use serde::{Deserialize, Serialize};
64use std::time::Duration;
65
66#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
67#[serde(rename_all = "PascalCase")]
68pub enum AptModuleInternalApiCall {
69    Install(String),
70    Remove(String),
71    Upgrade,
72}
73
74impl std::fmt::Display for AptModuleInternalApiCall {
75    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
76        match self {
77            AptModuleInternalApiCall::Install(package) => write!(f, "install {}", package),
78            AptModuleInternalApiCall::Remove(package) => write!(f, "remove {}", package),
79            AptModuleInternalApiCall::Upgrade => write!(f, "upgrade"),
80        }
81    }
82}
83
84#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
85#[serde(rename_all_fields = "PascalCase")]
86#[serde(deny_unknown_fields)]
87pub enum AptExpectedState {
88    /// Perform a full system upgrade (apt-get update && apt-get upgrade)
89    SystemUpToDate,
90    #[serde(untagged)]
91    PackageState {
92        package: String,
93        state: PackageExpectedState,
94    },
95}
96
97impl Timeout for AptExpectedState {
98    fn default_timeout(&self) -> Duration {
99        match self {
100            Self::SystemUpToDate => Duration::from_secs(300),
101            Self::PackageState {
102                package: _,
103                state: _,
104            } => Duration::from_secs(60),
105        }
106    }
107}
108
109impl AptExpectedState {
110    pub fn full_system_upgrade() -> AptExpectedState {
111        AptExpectedState::SystemUpToDate
112    }
113
114    pub fn package_state(package: &str, state: PackageExpectedState) -> AptExpectedState {
115        AptExpectedState::PackageState {
116            package: package.to_string(),
117            state,
118        }
119    }
120
121    pub fn package_present(package: &str) -> AptExpectedState {
122        AptExpectedState::PackageState {
123            package: package.to_string(),
124            state: PackageExpectedState::Present,
125        }
126    }
127
128    pub fn package_absent(package: &str) -> AptExpectedState {
129        AptExpectedState::PackageState {
130            package: package.to_string(),
131            state: PackageExpectedState::Absent,
132        }
133    }
134}
135
136impl Check for AptExpectedState {
137    fn check(&self) -> Result<(), RegentError> {
138        Ok(())
139    }
140
141    fn check_host_compatibility(
142        &self,
143        host_properties: &HostProperties,
144    ) -> Result<(), RegentError> {
145        match host_properties.os_kind() {
146            OsKind::Linux(LinuxSpecifics {
147                linux_flavor: LinuxFlavor::Debian,
148                ..
149            }) => Ok(()),
150            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
151                "Host is {:?} but APT is only supported on Debian-based Linux distributions",
152                incompatible_os_kind
153            ))),
154        }
155    }
156}
157
158impl<Handler: HostHandler> AssessCompliance<Handler> for AptExpectedState {
159    async fn assess_compliance(
160        &self,
161        host_handler: &mut Handler,
162        host_properties: &Option<HostProperties>,
163        privilege: &Privilege,
164        _optional_secret_provider: &Option<SecretProvidersPool>,
165    ) -> Result<AttributeComplianceAssessment, RegentError> {
166        if let Some(props) = host_properties {
167            self.check_host_compatibility(props)?;
168        }
169
170        let apt_available = match host_handler
171            .is_this_command_available("apt-get", &Privilege::None)
172            .await
173        {
174            Ok(availability) => availability,
175            Err(details) => {
176                return Err(RegentError::FailedDryRunEvaluation(format!(
177                    "{:?}",
178                    details
179                )));
180            }
181        };
182        let dpkg_available = match host_handler
183            .is_this_command_available("dpkg", &Privilege::None)
184            .await
185        {
186            Ok(availability) => availability,
187            Err(details) => {
188                return Err(RegentError::FailedDryRunEvaluation(format!(
189                    "{:?}",
190                    details
191                )));
192            }
193        };
194
195        if !apt_available || !dpkg_available {
196            return Err(RegentError::FailedDryRunEvaluation(
197                "APT not working on this host. Is this really a debian-flavored linux distribution ?"
198                    .to_string(),
199            ));
200        }
201
202        let mut remediations: Vec<Remediation> = Vec::new();
203
204        match &self {
205            Self::SystemUpToDate => {
206                // `apt-get -s -u upgrade` simulates an upgrade: every line
207                // starting with "Inst" is a package that would be installed.
208                match host_handler
209                    .run_command(
210                        "apt-get update -y && apt-get -s -u upgrade",
211                        &Privilege::None,
212                    )
213                    .await
214                {
215                    Ok(r) if r.return_code == 0 => {
216                        if r.stdout.lines().any(|l| l.starts_with("Inst")) {
217                            remediations.push(Remediation::Apt(AptApiCall::from(
218                                AptModuleInternalApiCall::Upgrade,
219                                privilege.clone(),
220                            )));
221                        }
222                    }
223                    Ok(r) => {
224                        return Err(RegentError::FailedDryRunEvaluation(format!(
225                            "Unable to check available updates: {:?}",
226                            r
227                        )));
228                    }
229                    Err(e) => {
230                        return Err(RegentError::FailedDryRunEvaluation(format!(
231                            "Unable to check available updates: {:?}",
232                            e
233                        )));
234                    }
235                }
236            }
237            Self::PackageState {
238                package,
239                state: expected_state,
240            } => {
241                let package_is_currently_installed =
242                    is_package_installed(host_handler, package).await;
243
244                match (package_is_currently_installed, expected_state) {
245                    (true, PackageExpectedState::Present) => {}
246                    (true, PackageExpectedState::Absent) => {
247                        remediations.push(Remediation::Apt(AptApiCall::from(
248                            AptModuleInternalApiCall::Remove(package.clone()),
249                            privilege.clone(),
250                        )));
251                    }
252                    (false, PackageExpectedState::Present) => {
253                        remediations.push(Remediation::Apt(AptApiCall::from(
254                            AptModuleInternalApiCall::Install(package.clone()),
255                            privilege.clone(),
256                        )));
257                    }
258                    (false, PackageExpectedState::Absent) => {}
259                }
260            }
261        }
262
263        if remediations.is_empty() {
264            Ok(AttributeComplianceAssessment::Compliant)
265        } else {
266            Ok(AttributeComplianceAssessment::NonCompliant(
267                RemediationsList::from(remediations)?,
268            ))
269        }
270    }
271}
272
273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
274pub struct AptApiCall {
275    pub api_call: AptModuleInternalApiCall,
276    privilege: Privilege,
277}
278
279impl Check for AptApiCall {
280    fn check(&self) -> Result<(), RegentError> {
281        Ok(())
282    }
283
284    fn check_host_compatibility(
285        &self,
286        host_properties: &HostProperties,
287    ) -> Result<(), RegentError> {
288        match host_properties.os_kind() {
289            OsKind::Linux(LinuxSpecifics {
290                linux_flavor: LinuxFlavor::Debian,
291                ..
292            }) => Ok(()),
293            incompatible_os_kind => Err(RegentError::IncompatibleHost(format!(
294                "Host is {:?} but APT is only supported on Debian-based Linux distributions",
295                incompatible_os_kind
296            ))),
297        }
298    }
299}
300
301impl AptApiCall {
302    pub fn display(&self) -> String {
303        match &self.api_call {
304            AptModuleInternalApiCall::Install(package_name) => {
305                format!("Install - {}", package_name)
306            }
307            AptModuleInternalApiCall::Remove(package_name) => {
308                format!("Remove - {}", package_name)
309            }
310            AptModuleInternalApiCall::Upgrade => String::from("Upgrade"),
311        }
312    }
313}
314
315impl<Handler: HostHandler> ReachCompliance<Handler> for AptApiCall {
316    async fn call(
317        &self,
318        host_handler: &mut Handler,
319        host_properties: &Option<HostProperties>,
320        _optional_secret_provider: &Option<SecretProvidersPool>,
321    ) -> Result<InternalApiCallOutcome, RegentError> {
322        if let Some(props) = host_properties {
323            self.check_host_compatibility(props)?;
324        }
325
326        let (cmd, privilege, package_name) = match &self.api_call {
327            AptModuleInternalApiCall::Install(package_name) => (
328                format!(
329                    "DEBIAN_FRONTEND=noninteractive apt-get install -y {}",
330                    package_name
331                ),
332                &self.privilege,
333                Some(package_name.clone()),
334            ),
335            AptModuleInternalApiCall::Remove(package_name) => (
336                format!(
337                    "DEBIAN_FRONTEND=noninteractive apt-get remove --purge -y {}",
338                    package_name
339                ),
340                &self.privilege,
341                Some(package_name.clone()),
342            ),
343            AptModuleInternalApiCall::Upgrade => (
344                "apt-get update && DEBIAN_FRONTEND=noninteractive apt-get upgrade -y".to_string(),
345                &self.privilege,
346                None,
347            ),
348        };
349
350        let cmd_result = host_handler
351            .run_command(cmd.as_str(), privilege)
352            .await
353            .unwrap();
354
355        if cmd_result.return_code == 0 {
356            if let Some(pkg_name) = &package_name {
357                let verification_result = match &self.api_call {
358                    AptModuleInternalApiCall::Install(_) => {
359                        is_package_installed(host_handler, pkg_name).await
360                    }
361                    AptModuleInternalApiCall::Remove(_) => {
362                        !is_package_installed(host_handler, pkg_name).await
363                    }
364                    AptModuleInternalApiCall::Upgrade => {
365                        // System-wide operation - no per-package post-verification
366                        true
367                    }
368                };
369
370                if verification_result {
371                    Ok(InternalApiCallOutcome::Success(None))
372                } else {
373                    Ok(InternalApiCallOutcome::Failure(format!(
374                        "Command succeeded but post-verification failed: package {} state does not match expected",
375                        pkg_name
376                    )))
377                }
378            } else {
379                Ok(InternalApiCallOutcome::Success(None))
380            }
381        } else {
382            Ok(InternalApiCallOutcome::Failure(format!(
383                "RC : {}, STDOUT : {}, STDERR : {}",
384                cmd_result.return_code, cmd_result.stdout, cmd_result.stderr
385            )))
386        }
387    }
388}
389
390impl AptApiCall {
391    fn from(api_call: AptModuleInternalApiCall, privilege: Privilege) -> AptApiCall {
392        AptApiCall {
393            api_call,
394            privilege,
395        }
396    }
397}
398
399async fn is_package_installed<Handler: HostHandler>(
400    host_handler: &mut Handler,
401    package: &str,
402) -> bool {
403    let test = host_handler
404        .run_command(format!("dpkg -s {}", package).as_str(), &Privilege::None)
405        .await
406        .unwrap();
407
408    test.return_code == 0
409}
410
411#[cfg(test)]
412mod tests {
413
414    use super::*;
415
416    #[test]
417    fn parsing_apt_module_block_from_yaml_str() {
418        let raw_attributes = "---
419- SystemUpToDate
420
421- Package: apache2
422  State: Present
423
424- Package: apache2
425  State: Absent
426    ";
427
428        let attributes: Vec<AptExpectedState> = yaml_serde::from_str(raw_attributes).unwrap();
429
430        assert_eq!(attributes[0], AptExpectedState::SystemUpToDate);
431
432        assert_eq!(
433            attributes[1],
434            AptExpectedState::PackageState {
435                package: "apache2".to_string(),
436                state: PackageExpectedState::Present
437            }
438        );
439
440        assert_eq!(
441            attributes[2],
442            AptExpectedState::PackageState {
443                package: "apache2".to_string(),
444                state: PackageExpectedState::Absent
445            }
446        );
447    }
448
449    #[test]
450    fn rejecting_incorrect_apt_module_block_from_yaml_str() {
451        let raw_attribute = "---
452Package: apache2
453    ";
454        assert!(yaml_serde::from_str::<AptExpectedState>(raw_attribute).is_err());
455
456        let raw_attribute = "---
457Package:
458State: Absent
459    ";
460        assert!(yaml_serde::from_str::<AptExpectedState>(raw_attribute).is_err());
461
462        let raw_attribute = "---
463Package: apache2
464State: Absent
465unknown_key: unknown_value
466    ";
467        assert!(yaml_serde::from_str::<AptExpectedState>(raw_attribute).is_err());
468    }
469}