From d8260305ec82a91dbfc7da6a06346633913cdfc7 Mon Sep 17 00:00:00 2001 From: Alexis Mousset Date: Sun, 20 Oct 2024 19:23:57 +0200 Subject: [PATCH] Fixes #25695: Software update campaign does not work on Debian 10 Buster --- policies/minifusion/src/os_release.rs | 4 +- .../module-types/system-updates/README.md | 130 ++++++- .../system-updates/src/campaign.rs | 229 ++++++----- .../module-types/system-updates/src/cli.rs | 49 +-- .../module-types/system-updates/src/db.rs | 304 ++++++++++----- .../module-types/system-updates/src/hooks.rs | 92 +++-- .../module-types/system-updates/src/lib.rs | 263 +++++++++++++ .../module-types/system-updates/src/main.rs | 254 +------------ .../module-types/system-updates/src/output.rs | 34 +- .../system-updates/src/package_manager.rs | 48 +-- .../system-updates/src/package_manager/apt.rs | 289 +++++++------- .../src/package_manager/apt/filter.rs | 225 +++++++++++ .../system-updates/src/package_manager/yum.rs | 47 ++- .../src/package_manager/zypper.rs | 61 +-- .../system-updates/src/packages.sql | 8 +- .../module-types/system-updates/src/runner.rs | 356 ++++++++++++++++++ .../module-types/system-updates/src/state.rs | 20 +- .../module-types/system-updates/src/system.rs | 24 +- .../tests/parameters/immediate.json | 2 +- .../tests/parameters/scheduled.json | 18 +- policies/rudder-module-type/src/lib.rs | 14 + policies/rudder-module-type/src/os_release.rs | 267 +++++++++++++ policies/rudderc/src/technique.schema.json | 2 +- .../tests/cases/general/min/technique.yml | 2 +- 24 files changed, 1991 insertions(+), 751 deletions(-) create mode 100644 policies/module-types/system-updates/src/lib.rs create mode 100644 policies/module-types/system-updates/src/package_manager/apt/filter.rs create mode 100644 policies/module-types/system-updates/src/runner.rs create mode 100644 policies/rudder-module-type/src/os_release.rs diff --git a/policies/minifusion/src/os_release.rs b/policies/minifusion/src/os_release.rs index a9624fe05e0..23f923524e1 100644 --- a/policies/minifusion/src/os_release.rs +++ b/policies/minifusion/src/os_release.rs @@ -1,7 +1,7 @@ //! Type for parsing the `/etc/os-release` file. //! -//! For a broad list of sample `/etc/os-release` files, see: -//! https://github.com/chef/os_release +//! For a broad list of sample `/etc/os-release` files, see +//! https://github.com/chef/os_release. //! //! For the specs, see: //! https://www.freedesktop.org/software/systemd/man/latest/os-release.html diff --git a/policies/module-types/system-updates/README.md b/policies/module-types/system-updates/README.md index 4ef7de29353..82330648a7f 100644 --- a/policies/module-types/system-updates/README.md +++ b/policies/module-types/system-updates/README.md @@ -1,4 +1,59 @@ -# System update module +# System updates module + +This modules implements the patch management feature on Linux systems. + +## Usage + +The module is automatically used by the system update campaigns when applied from an 8.2+ server to an 8.2+ node on a compatible +operating system. +It is called by the agent, and provides a native interaction with it. + +### Compatibility & fallback + +The minimal Rudder agent versions supporting the module is 8.2, and the minimal versions supported by the module are: + +* Ubuntu 18.04 LTS +* Debian 10 +* RHEL 7 +* SLES 12 + +When the OS is older or on agents older than Rudder 8.2, the fallback Python-based implementation, distributed +with the technique, is used. + +### CLI + +The module provides a CLI to help debug and track the past events on a system. + +```shell +$ /opt/rudder/bin/rudder-module-system-updates --help +Usage: /vagrant/rudder/target/debug/rudder-module-system-updates [OPTIONS] + +Optional arguments: + -h, --help print help message + -v, --verbose be verbose + +Available commands: + list list upgrade events + show show details about a specific event + run run an upgrade + clear drop database content +``` + +### Trigger an immediate event + +Once an event is scheduled on a system using the technique, it is possible to trigger an immediate execution +by defining a special condition in the agent. You need the event id (in the form of a UUID), and to use: + +```shell +rudder agent run -D ${event_id}_force_now +rudder agent run -D 70fbe730-3a88-4c27-bfbe-26f799c04b1a_force_now +``` + +This will modify the scheduling and run the event immediately. + +## Design + +### Interface with the agent As our first module implementation in production, we chose to target the patch management feature of Rudder. On Linux it currently runs using a Python script that was assembled for the technical preview phase, but now needs to be replaced by a more industrialized solution. @@ -24,10 +79,51 @@ An additional complication is that we want to capture the logs for sending them ``` +### System detection +We need a generic system detection method for the modules in general. This is provided by an +[os-release](https://www.freedesktop.org/software/systemd/man/latest/os-release.html) parser, +as all supported Linux systems should have it. +We made the choice to avoid any limitation in the component itself, and let the modules +chose their OS enums depending on their needs. +In the system-updates module, we need to identify the package manager to use, +and for APT, to identify the distribution. +### System actions -## Design +All the supported operating systems in the Linux patch management come with systemd, so we can +rely on it for services management and system reboot, providing a unified interface. + +In practice, we use the `systemctl reboot` and `systemctl restart ` commands. A future +deeper integration could rely on a library leveraging the C or DBus APIs. + +### Storage + +The event data is stored in a SQLite database stored in `/var/rudder/system-update/rudder-module-system-updates.sqlite`. +You can access it with the SQLite CLI: + +```shell +sqlite3 /var/rudder/system-update/rudder-module-system-updates.sqlite +``` + +### Runner + +This module is quite different for most others, as it is not stateless but requires storage as the actions +can span acros several agent runs. + +```mermaid +--- +title: System updates states +--- +stateDiagram-v2 + [*] --> ScheduledUpdate + ScheduledUpdate --> RunningUpdate: now() >= schedule + RunningUpdate --> PendingPostActions: Update + PendingPostActions --> RunningPostAction: Direct + PendingPostActions --> RunningPostAction: Reboot and next run + RunningPostAction --> Completed: Post-actions + report + Completed --> [*]: Cleanup +``` ### Logging/Output @@ -35,19 +131,28 @@ We want to capture the logs for sending them to the server, but also to display ### Package managers -We need to support the most common package managers on Linux. There are two possible approaches, either using a generic package manager interface or using an interface specific to each package manager. +We need to support the most common package managers on Linux. There are two possible approaches, either using a generic +package manager interface or using an interface specific to each package manager. Even if the first approach is more flexible, we decided to use the second approach for the following reasons: * The package manager interface is quite simple and the code duplication is minimal. * We can use the package manager CLI interface for debugging and understanding what happens. -#### Dnf/Yum +#### DNF/YUM -We use a single interface for both Dnf and Yum, as they are quite similar. We use the `yum` CLI interface, as it compatible with both package managers. +We use a single interface for both `dnf` and `yum`, as they are quite similar. We use the `yum` CLI interface, as it compatible with both package managers, +and `yum` is aliased to `dnf` on recent systems: + +```shell +[root@centos9 ~]# ls -ahl /usr/bin/yum +lrwxrwxrwx. 1 root root 5 Apr 1 2024 /usr/bin/yum -> dnf-3 +``` #### Zypper +We use the `zypper` command line. + #### APT We decided to use the `libapt` C++ library through Rust bindings instead of the CLI interface. This has several advantages: @@ -61,15 +166,8 @@ The drawbacks: We are on par with other tools using the `python-apt` library, which provides a similar interface. +The APT support is enabled with the `apt` feature: -``` -Unattended upgrade result: Lock could not be acquired - -Unattended-upgrades log: -Starting unattended upgrades script -Lock could not be acquired (another package manager running?)Unattended upgrade result: Lock could not be acquired - -Unattended-upgrades log: -Starting unattended upgrades script -Lock could not be acquired (another package manager running?) -``` +```shell +cargo build --release --features=apt +``` \ No newline at end of file diff --git a/policies/module-types/system-updates/src/campaign.rs b/policies/module-types/system-updates/src/campaign.rs index 880e173beb0..030575681b0 100644 --- a/policies/module-types/system-updates/src/campaign.rs +++ b/policies/module-types/system-updates/src/campaign.rs @@ -1,31 +1,95 @@ // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2024 Normation SAS -use crate::output::Status; use crate::{ db::PackageDatabase, hooks::Hooks, - output::{Report, ScheduleReport}, + output::{Report, ScheduleReport, Status}, package_manager::{LinuxPackageManager, PackageSpec}, - scheduler, system::System, - CampaignType, PackageParameters, RebootType, + CampaignType, PackageParameters, RebootType, Schedule, }; use anyhow::Result; use chrono::{DateTime, Duration, Utc}; -use rudder_module_type::{rudder_debug, Outcome}; -use std::path::PathBuf; -use std::{fs, path::Path}; +use rudder_module_type::Outcome; +use std::{fs, path::PathBuf}; /// How long to keep events in the database -const RETENTION_DAYS: u32 = 60; +pub(crate) const RETENTION_DAYS: u32 = 60; + +#[derive(Clone, Debug)] +pub struct RunnerParameters { + pub campaign_type: FullCampaignType, + pub event_id: String, + pub campaign_name: String, + pub schedule: FullSchedule, + pub reboot_type: RebootType, + pub report_file: Option, + pub schedule_file: Option, +} + +impl RunnerParameters { + pub fn new( + package_parameters: PackageParameters, + node_id: String, + agent_frequency: Duration, + ) -> Self { + Self { + campaign_type: FullCampaignType::new( + package_parameters.campaign_type, + package_parameters.package_list, + ), + event_id: package_parameters.event_id, + campaign_name: package_parameters.campaign_name, + schedule: FullSchedule::new(&package_parameters.schedule, node_id, agent_frequency), + reboot_type: package_parameters.reboot_type, + report_file: package_parameters.report_file, + schedule_file: package_parameters.schedule_file, + } + } + + pub fn new_immediate(package_parameters: PackageParameters) -> Self { + Self { + campaign_type: FullCampaignType::new( + package_parameters.campaign_type, + package_parameters.package_list, + ), + event_id: package_parameters.event_id, + campaign_name: package_parameters.campaign_name, + schedule: FullSchedule::Immediate, + reboot_type: package_parameters.reboot_type, + report_file: package_parameters.report_file, + schedule_file: package_parameters.schedule_file, + } + } +} + +#[derive(Clone, Debug)] +pub enum FullCampaignType { + /// Install all available upgrades + SystemUpdate, + /// Install all available security upgrades + SecurityUpdate, + /// Install the updates from the provided package list + SoftwareUpdate(Vec), +} + +impl FullCampaignType { + pub fn new(c: CampaignType, p: Vec) -> Self { + match c { + CampaignType::SystemUpdate => FullCampaignType::SystemUpdate, + CampaignType::SecurityUpdate => FullCampaignType::SecurityUpdate, + CampaignType::SoftwareUpdate => FullCampaignType::SoftwareUpdate(p), + } + } +} #[derive(Clone, Debug, PartialEq, Eq)] pub struct FullScheduleParameters { - pub(crate) start: DateTime, - pub(crate) end: DateTime, - pub(crate) node_id: String, - pub(crate) agent_frequency: Duration, + pub start: DateTime, + pub end: DateTime, + pub node_id: String, + pub agent_frequency: Duration, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -34,76 +98,70 @@ pub enum FullSchedule { Immediate, } +impl FullSchedule { + pub fn new(schedule: &Schedule, node_id: String, agent_frequency: Duration) -> Self { + match schedule { + Schedule::Scheduled(ref s) => FullSchedule::Scheduled(FullScheduleParameters { + start: s.start, + end: s.end, + node_id, + agent_frequency, + }), + Schedule::Immediate => FullSchedule::Immediate, + } + } +} + /// Called at each module run /// /// The returned outcome is not linked to the success of the update, but to the success of the /// process. The update itself can fail, but the process can be successful. -// FIXME: send all errors as reports -pub fn check_update( - state_dir: &Path, - schedule: FullSchedule, - p: PackageParameters, + +pub fn do_schedule( + p: &RunnerParameters, + db: &mut PackageDatabase, + schedule_datetime: DateTime, ) -> Result { - let mut db = PackageDatabase::new(Some(Path::new(state_dir)))?; - rudder_debug!("Cleaning events older than {} days", RETENTION_DAYS); - db.clean(Duration::days(RETENTION_DAYS as i64))?; - let pm = p.package_manager.get()?; - - let schedule_datetime = match schedule { - FullSchedule::Immediate => Utc::now(), - FullSchedule::Scheduled(ref s) => { - scheduler::splayed_start(s.start, s.end, s.agent_frequency, s.node_id.as_str())? - } - }; - let already_scheduled = db.schedule_event(&p.event_id, &p.campaign_name, schedule_datetime)?; - - // Update should have started already - let now = Utc::now(); - if schedule == FullSchedule::Immediate || now >= schedule_datetime { - let do_update = db.start_event(&p.event_id, now)?; - if do_update { - let report = update(pm, p.reboot_type, p.campaign_type, p.package_list)?; - db.store_report(&p.event_id, &report)?; - } + db.schedule_event(&p.event_id, &p.campaign_name, schedule_datetime)?; + let report = ScheduleReport::new(schedule_datetime); + if let Some(ref f) = p.schedule_file { + // Write the report into the destination tmp file + fs::write(f, serde_json::to_string(&report)?.as_bytes())?; + } + Ok(Outcome::Repaired("Schedule has been sent".to_string())) +} - // Update takes time - let do_post_actions = db.post_event(&p.event_id)?; +pub fn do_update( + p: &RunnerParameters, + db: &mut PackageDatabase, + package_manager: &mut Box, + system: &Box, +) -> Result { + db.start_event(&p.event_id, Utc::now())?; + let (report, reboot) = update(package_manager, p.reboot_type, &p.campaign_type, system)?; + db.schedule_post_event(&p.event_id, &report)?; + Ok(reboot) +} - if do_post_actions { - let init_report = db.get_report(&p.event_id)?; - let report = post_update(init_report)?; - db.store_report(&p.event_id, &report)?; +pub fn do_post_update(p: &RunnerParameters, db: &mut PackageDatabase) -> Result { + db.post_event(&p.event_id)?; + let init_report = db.get_report(&p.event_id)?; + let report = post_update(init_report)?; - if let Some(ref f) = p.report_file { - // Write the report into the destination tmp file - fs::write(f, serde_json::to_string(&report)?.as_bytes())?; - } + if let Some(ref f) = p.report_file { + // Write the report into the destination tmp file + fs::write(f, serde_json::to_string(&report)?.as_bytes())?; + } - let now_finished = Utc::now(); - db.sent(&p.event_id, now_finished)?; + let now_finished = Utc::now(); + db.completed(&p.event_id, now_finished, &report)?; - // The repaired status is the trigger to read and send the report. - Ok(Outcome::Repaired("Update has run".to_string())) - } else { - Ok(Outcome::Success(None)) - } - } else { - // Not the time yet, send the schedule if pending. - if !already_scheduled { - let report = ScheduleReport::new(schedule_datetime); - if let Some(ref f) = p.schedule_file { - // Write the report into the destination tmp file - fs::write(f, serde_json::to_string(&report)?.as_bytes())?; - } - Ok(Outcome::Repaired("Send schedule".to_string())) - } else { - Ok(Outcome::Success(None)) - } - } + // The repaired status is the trigger to read and send the report. + Ok(Outcome::Repaired("Update has run".to_string())) } /// Shortcut method to send an error report directly -pub fn fail_campaign(reason: &str, report_file: Option) -> Result { +pub fn fail_campaign(reason: &str, report_file: Option<&PathBuf>) -> Result { let mut report = Report::new(); report.stderr(reason); report.status = Status::Error; @@ -116,11 +174,11 @@ pub fn fail_campaign(reason: &str, report_file: Option) -> Result, + pm: &mut Box, reboot_type: RebootType, - campaign_type: CampaignType, - packages: Vec, -) -> Result { + campaign_type: &FullCampaignType, + system: &Box, +) -> Result<(Report, bool)> { let mut report = Report::new(); let pre_result = Hooks::PreUpgrade.run(); @@ -128,7 +186,7 @@ fn update( // Pre-run hooks are a blocker if report.is_err() { report.stderr("Pre-run hooks failed, aborting upgrade"); - return Ok(report); + return Ok((report, false)); } // We consider failure to probe system state a blocking error @@ -140,7 +198,7 @@ fn update( report.step(before); if report.is_err() { report.stderr("Failed to list installed packages, aborting upgrade"); - return Ok(report); + return Ok((report, false)); } let before_list = before_list.unwrap(); @@ -150,11 +208,7 @@ fn update( let cache_result = pm.update_cache(); report.step(cache_result); - let update_result = match campaign_type { - CampaignType::SystemUpdate => pm.full_upgrade(), - CampaignType::SoftwareUpdate => pm.upgrade(packages), - CampaignType::SecurityUpdate => pm.security_upgrade(), - }; + let update_result = pm.upgrade(campaign_type); report.step(update_result); let after = pm.list_installed(); @@ -165,7 +219,7 @@ fn update( report.step(after); if report.is_err() { report.stderr("Failed to list installed packages, aborting upgrade"); - return Ok(report); + return Ok((report, false)); } let after_list = after_list.unwrap(); @@ -173,8 +227,6 @@ fn update( report.diff(before_list.diff(after_list)); // Now take system actions - let system = System::new(); - let pre_reboot_result = Hooks::PreReboot.run(); report.step(pre_reboot_result); @@ -189,10 +241,8 @@ fn update( report.step(pending); if reboot_type == RebootType::Always || (reboot_type == RebootType::AsNeeded && is_pending) { - let reboot_result = system.reboot(); - report.step(reboot_result); // Stop there - return Ok(report); + return Ok((report, true)); } let services = pm.services_to_restart(); @@ -213,7 +263,7 @@ fn update( report.step(restart_result); } - Ok(report) + Ok((report, false)) } /// Can run just after upgrade, or at next run in case of reboot. @@ -222,3 +272,8 @@ fn post_update(mut report: Report) -> Result { report.step(post_result); Ok(report) } + +#[cfg(test)] +mod tests { + // FIXME tests for do_* with mock pm +} diff --git a/policies/module-types/system-updates/src/cli.rs b/policies/module-types/system-updates/src/cli.rs index b5ccaa43158..0479d94f570 100644 --- a/policies/module-types/system-updates/src/cli.rs +++ b/policies/module-types/system-updates/src/cli.rs @@ -3,10 +3,12 @@ #![allow(unused_imports)] use crate::{ - campaign::{check_update, FullSchedule}, + campaign::{FullCampaignType, FullSchedule, RunnerParameters}, cli, db::{Event, PackageDatabase}, - package_manager::PackageManager, + package_manager::{LinuxPackageManager, PackageManager}, + runner::Runner, + system::{System, Systemd}, CampaignType, PackageParameters, RebootType, Schedule, SystemUpdateModule, MODULE_DIR, }; use anyhow::{bail, Result}; @@ -14,7 +16,8 @@ use chrono::{Duration, SecondsFormat}; use cli_table::format::{HorizontalLine, Separator, VerticalLine}; use gumdrop::Options; use rudder_module_type::{ - inventory::system_node_id, parameters::Parameters, ModuleType0, PolicyMode, + inventory::system_node_id, os_release::OsRelease, parameters::Parameters, ModuleType0, + PolicyMode, }; use std::{fs, path::PathBuf}; use uuid::Uuid; @@ -65,15 +68,15 @@ struct RunOpts { help: bool, #[options(help = "directory where the database is stored")] state_dir: Option, - #[options(long = "dry-run", help = "do not apply changes")] - dry_run: bool, #[options(long = "security", help = "only apply security upgrades")] security: bool, #[options(help = "package manager to use (defaults to system detection)")] package_manager: Option, + #[options(help = "reboot/restart behavior")] + reboot_type: RebootType, + #[options(help = "name of the campaign")] + name: Option, /* - "campaign_name": "My campaign", - "reboot_type": "as_needed", "package_list": [], */ } @@ -115,29 +118,29 @@ impl Cli { } Some(Command::Run(opts)) => { let state_dir = opts.state_dir.unwrap_or(PathBuf::from(MODULE_DIR)); - let package_parameters = PackageParameters { + let os_release = OsRelease::new()?; + let pm: Box = opts + .package_manager + .unwrap_or_else(|| PackageManager::detect(&os_release).unwrap()) + .get()?; + let package_parameters = RunnerParameters { campaign_type: if opts.security { - CampaignType::SecurityUpdate + FullCampaignType::SecurityUpdate } else { - CampaignType::SystemUpdate + FullCampaignType::SystemUpdate }, - package_manager: opts - .package_manager - .unwrap_or_else(|| PackageManager::detect().unwrap()), event_id: Uuid::new_v4().to_string(), - campaign_name: "CLI".to_string(), - schedule: Schedule::Immediate, - reboot_type: RebootType::Disabled, - package_list: vec![], + campaign_name: opts.name.unwrap_or("CLI".to_string()), + schedule: FullSchedule::Immediate, + reboot_type: opts.reboot_type, report_file: None, schedule_file: None, }; - - check_update( - state_dir.as_path(), - FullSchedule::Immediate, - package_parameters, - )?; + let db = PackageDatabase::new(Some(state_dir.as_path()))?; + let system: Box = Box::new(Systemd::new()); + let pid = std::process::id(); + let mut runner = Runner::new(db, pm, package_parameters, system, pid); + runner.run()?; } Some(Command::Clear(l)) => { let dir = l.state_dir.unwrap_or(PathBuf::from(MODULE_DIR)); diff --git a/policies/module-types/system-updates/src/db.rs b/policies/module-types/system-updates/src/db.rs index f2e1103e56a..991051254fd 100644 --- a/policies/module-types/system-updates/src/db.rs +++ b/policies/module-types/system-updates/src/db.rs @@ -1,22 +1,23 @@ // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2023 Normation SAS +/// Database using SQLite +/// +/// The module is responsible for maintaining the database: schema upgrade, cleanup, etc. +/// +use crate::MODULE_NAME; +use crate::{output::Report, state::UpdateStatus}; use chrono::{DateTime, Duration, SecondsFormat, Utc}; use rudder_module_type::rudder_debug; use rusqlite::{self, Connection, Row}; +use std::fs::Permissions; +use std::os::unix::prelude::PermissionsExt; use std::{ fmt::{Display, Formatter}, fs, path::{Path, PathBuf}, }; -/// Database using SQLite -/// -/// The module is responsible for maintaining the database: schema upgrade, cleanup, etc. -/// -use crate::MODULE_NAME; -use crate::{output::Report, state::UpdateStatus}; - const DB_EXTENSION: &str = "sqlite"; pub struct PackageDatabase { @@ -72,16 +73,18 @@ impl PackageDatabase { /// Open the database, creating it if necessary. /// When no path is provided, the database is created in memory. pub fn new(path: Option<&Path>) -> anyhow::Result { - let db_name = Self::db_name(); let conn = if let Some(p) = path { fs::create_dir_all(p)?; - let full_path = p.join(db_name); + let full_path = p.join(Self::db_name()); rudder_debug!( "Opening database {} (sqlite {})", full_path.display(), rusqlite::version() ); - Connection::open(full_path) + let conn = Connection::open(full_path.as_path()); + // Set lowest permissions + fs::set_permissions(full_path.as_path(), Permissions::from_mode(0o600))?; + conn } else { rudder_debug!( "Opening in-memory database (sqlite {})", @@ -89,10 +92,43 @@ impl PackageDatabase { ); Connection::open_in_memory() }?; - // Migrations are included in the file + let mut s = Self { conn }; + + // Initialize schema + s.init_schema()?; + // Run migrations that can't be expressed in the .sql file + s.migration_add_pid()?; + + Ok(s) + } + + #[cfg(test)] + fn open_existing(conn: Connection) -> Self { + Self { conn } + } + + #[cfg(test)] + fn into_connection(self) -> Connection { + self.conn + } + + fn init_schema(&mut self) -> Result<(), rusqlite::Error> { let schema = include_str!("packages.sql"); - conn.execute(schema, ())?; - Ok(Self { conn }) + self.conn.execute(schema, ())?; + Ok(()) + } + + fn migration_add_pid(&mut self) -> Result<(), rusqlite::Error> { + rudder_debug!("Running pid migration"); + let r = self + .conn + .execute("select pid from update_events limit 1", ()); + if r.is_err() { + rudder_debug!("Adding the pid column"); + self.conn + .execute("alter table update_events add pid integer", ())?; + } + Ok(()) } /// Purge entries, regardless of their status. @@ -105,108 +141,136 @@ impl PackageDatabase { Ok(()) } - /// Schedule an event + /// Lock for the current process on a given campaign /// - /// The insertion also acts as the locking mechanism - pub fn schedule_event( - &mut self, - event_id: &str, - campaign_name: &str, - schedule_datetime: DateTime, - ) -> Result { + pub fn lock(&mut self, pid: u32, event_id: &str) -> Result, rusqlite::Error> { let tx = self.conn.transaction()?; - let r = tx.query_row( - "select id from update_events where event_id = ?1", + "select pid from update_events where event_id = ?1", [&event_id], - |_| Ok(()), + |row| { + let v: Option = row.get(0)?; + Ok(v) + }, ); - let already_scheduled = match r { - Ok(_) => true, - Err(rusqlite::Error::QueryReturnedNoRows) => false, + let current_pid = match r { + Ok(pid) => pid, + Err(rusqlite::Error::QueryReturnedNoRows) => None, Err(e) => return Err(e), }; - if !already_scheduled { - tx.execute( - "insert into update_events (event_id, campaign_name, status, schedule_datetime) values (?1, ?2, ?3, ?4)", - (&event_id, &campaign_name, UpdateStatus::Scheduled.to_string(), schedule_datetime.to_rfc3339()), - )?; + match current_pid { + None => { + rudder_debug!("Setting lock for event {} to process {}", event_id, pid); + tx.execute( + "update update_events set pid = ?1 where event_id = ?2", + (pid, &event_id), + )?; + } + Some(p) => rudder_debug!("Lock is already set by process {}", p), } - tx.commit()?; - Ok(already_scheduled) + Ok(current_pid) + } + + /// Unlock for the current process on a given campaign + /// + pub fn unlock(&mut self, event_id: &str) -> Result<(), rusqlite::Error> { + let null: Option = None; + rudder_debug!("Removing lock for event {}", event_id); + + self.conn + .execute( + "update update_events set pid = ?1 where event_id = ?2", + (null, &event_id), + ) + .map(|_| ()) + } + + pub fn get_status(&self, event_id: &str) -> Result, rusqlite::Error> { + let r = self.conn.query_row( + "select status from update_events where event_id = ?1", + [&event_id], + |row| { + let v: String = row.get(0)?; + let p: UpdateStatus = v.parse().unwrap(); + Ok(p) + }, + ); + match r { + Ok(p) => Ok(Some(p)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e), + } + } + + /// Schedule an event + /// + pub fn schedule_event( + &mut self, + event_id: &str, + campaign_name: &str, + schedule_datetime: DateTime, + ) -> Result<(), rusqlite::Error> { + self.conn.execute( + "insert into update_events (event_id, campaign_name, status, schedule_datetime) values (?1, ?2, ?3, ?4)", + (&event_id, &campaign_name, UpdateStatus::ScheduledUpdate.to_string(), schedule_datetime.to_rfc3339()), + ).map(|_| ()) } /// Start an event /// /// The update also acts as the locking mechanism + /// + /// We take the actual start time, which is >= scheduled. pub fn start_event( &mut self, event_id: &str, start_datetime: DateTime, - ) -> Result { - let tx = self.conn.transaction()?; - - let r = tx.query_row( - "select id from update_events where event_id = ?1 and status = ?2", - (&event_id, UpdateStatus::Scheduled.to_string()), - |_| Ok(()), - ); - let pending_update = match r { - Ok(_) => true, - Err(rusqlite::Error::QueryReturnedNoRows) => false, - Err(e) => return Err(e), - }; - if pending_update { - tx.execute( + ) -> Result<(), rusqlite::Error> { + self.conn + .execute( "update update_events set status = ?1, run_datetime = ?2 where event_id = ?3", ( - UpdateStatus::Running.to_string(), + UpdateStatus::RunningUpdate.to_string(), start_datetime.to_rfc3339(), &event_id, ), - )?; - } + ) + .map(|_| ()) + } - tx.commit()?; - Ok(pending_update) + /// Schedule post-event action. Can happen after a reboot in a separate run. + /// + pub fn schedule_post_event( + &mut self, + event_id: &str, + report: &Report, + ) -> Result<(), rusqlite::Error> { + self.conn + .execute( + "update update_events set status = ?1, report = ?2 where event_id = ?3", + ( + UpdateStatus::PendingPostActions.to_string(), + serde_json::to_string(report).unwrap(), + &event_id, + ), + ) + .map(|_| ()) } /// Start post-event action. Can happen after a reboot in a separate run. /// /// The update also acts as the locking mechanism - pub fn post_event(&mut self, event_id: &str) -> Result { - let tx = self.conn.transaction()?; - - let r = tx.query_row( - "select event_id from update_events where event_id = ?1 and status = ?2", - (&event_id, UpdateStatus::Running.to_string()), - |_| Ok(()), - ); - let pending_post_actions = match r { - Ok(_) => true, - Err(rusqlite::Error::QueryReturnedNoRows) => false, - Err(e) => return Err(e), - }; - if pending_post_actions { - tx.execute( + pub fn post_event(&mut self, event_id: &str) -> Result<(), rusqlite::Error> { + self.conn + .execute( "update update_events set status = ?1 where event_id = ?2", - (UpdateStatus::PendingPostActions.to_string(), &event_id), - )?; - } - - tx.commit()?; - Ok(pending_post_actions) - } - - pub fn store_report(&self, event_id: &str, report: &Report) -> Result<(), rusqlite::Error> { - self.conn.execute( - "update update_events set report = ?1 where event_id = ?2", - (serde_json::to_string(report).unwrap(), &event_id), - )?; - Ok(()) + (UpdateStatus::RunningPostActions.to_string(), &event_id), + ) + .map(|_| ()) } + /// Assumes the report exists, fails otherwise pub fn get_report(&self, event_id: &str) -> Result { self.conn.query_row( "select report from update_events where event_id = ?1", @@ -220,16 +284,18 @@ impl PackageDatabase { } /// Mark the event as completed - pub fn sent( + pub fn completed( &self, event_id: &str, report_datetime: DateTime, + report: &Report, ) -> Result<(), rusqlite::Error> { self.conn.execute( - "update update_events set status = ?1, report_datetime = ?2 where event_id = ?3", + "update update_events set status = ?1, report_datetime = ?2, report = ?3 where event_id = ?4", ( UpdateStatus::Completed.to_string(), report_datetime.to_rfc3339(), + serde_json::to_string(report).unwrap(), &event_id, ), )?; @@ -280,18 +346,36 @@ impl PackageDatabase { #[cfg(test)] mod tests { - use chrono::{Duration, Utc}; - use std::ops::Add; - use super::{Event, PackageDatabase}; use crate::{output::Report, state::UpdateStatus}; + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use rusqlite::Connection; + use std::ops::Add; + use std::os::unix::prelude::PermissionsExt; #[test] fn new_creates_new_database() { let t = tempfile::tempdir().unwrap(); PackageDatabase::new(Some(t.path())).unwrap(); let db_name = PackageDatabase::db_name(); - assert!(t.path().join(db_name).exists()); + let p = t.path().join(db_name); + assert!(p.exists()); + assert_eq!(p.metadata().unwrap().permissions().mode(), 0o100600); + } + + #[test] + fn migration_add_pid_column() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute("create table update_events (id integer primary key)", ()) + .unwrap(); + + let mut db = PackageDatabase::open_existing(conn); + db.migration_add_pid().unwrap(); + + let conn = db.into_connection(); + let r = conn.execute("select pid from update_events", ()); + assert!(r.is_ok()); } #[test] @@ -303,14 +387,42 @@ mod tests { } #[test] - fn start_event_inserts_and_returns_false_for_new_events() { + fn locks_locks() { + let mut db = PackageDatabase::new(None).unwrap(); + let event_id = "TEST"; + let campaign_id = "CAMPAIGN"; + let now = Utc::now(); + + db.schedule_event(event_id, campaign_id, now).unwrap(); + assert_eq!(db.lock(0, event_id).unwrap(), None); + assert_eq!(db.lock(0, event_id).unwrap(), Some(0)); + } + + #[test] + fn unlock_unlocks() { let mut db = PackageDatabase::new(None).unwrap(); let event_id = "TEST"; let campaign_id = "CAMPAIGN"; let now = Utc::now(); - // If the event was not present before, this should be false. - assert!(!db.schedule_event(event_id, campaign_id, now).unwrap()); - assert!(db.schedule_event(event_id, campaign_id, now).unwrap()); + + db.schedule_event(event_id, campaign_id, now).unwrap(); + assert_eq!(db.lock(0, event_id).unwrap(), None); + db.unlock(event_id).unwrap(); + assert_eq!(db.lock(0, event_id).unwrap(), None); + } + + #[test] + fn start_event_inserts_and_sets_running_update() { + let mut db = PackageDatabase::new(None).unwrap(); + let event_id = "TEST"; + let campaign_id = "CAMPAIGN"; + let now = Utc::now(); + db.schedule_event(event_id, campaign_id, now).unwrap(); + db.start_event(event_id, now).unwrap(); + assert_eq!( + db.get_status(event_id).unwrap().unwrap(), + UpdateStatus::RunningUpdate + ) } #[test] @@ -324,7 +436,7 @@ mod tests { db.schedule_event(event_id, campaign_name, schedule) .unwrap(); db.start_event(event_id, start).unwrap(); - db.store_report(event_id, &report).unwrap(); + db.schedule_post_event(event_id, &report).unwrap(); // Now get the data back and see if it matches let got_report = db.get_report(event_id).unwrap(); @@ -334,7 +446,7 @@ mod tests { let event = Event { id: event_id.to_string(), campaign_name: campaign_name.to_string(), - status: UpdateStatus::Running, + status: UpdateStatus::PendingPostActions, scheduled_datetime: schedule, run_datetime: Some(start), report_datetime: None, @@ -346,7 +458,7 @@ mod tests { let ref_event = Event { id: event_id.to_string(), campaign_name: campaign_name.to_string(), - status: UpdateStatus::Running, + status: UpdateStatus::PendingPostActions, scheduled_datetime: schedule, run_datetime: Some(start), report_datetime: None, diff --git a/policies/module-types/system-updates/src/hooks.rs b/policies/module-types/system-updates/src/hooks.rs index 2cf0ad5e926..7a1156740f4 100644 --- a/policies/module-types/system-updates/src/hooks.rs +++ b/policies/module-types/system-updates/src/hooks.rs @@ -91,7 +91,7 @@ impl Hooks { return res; } - let mut hooks: Vec<_> = fs::read_dir("/").unwrap().map(|r| r.unwrap()).collect(); + let mut hooks: Vec<_> = fs::read_dir(path).unwrap().map(|r| r.unwrap()).collect(); // Sort hooks by lexicographical order hooks.sort_by_key(|e| e.path()); @@ -103,15 +103,18 @@ impl Hooks { res.stderr(format!("Running hook '{}'", p.display())); rudder_info!("Running hook '{}'", p.display()); let hook_res = ResultOutput::command(Command::new(&p)); - if hook_res.inner.is_err() { - res.stderr(format!("Hook '{}' failed, aborting upgrade", p.display())); - rudder_error!("Hook '{}' failed, aborting upgrade", p.display()); + res.stdout_lines(hook_res.stdout); + res.stderr_lines(hook_res.stderr); + if let Err(e) = hook_res.inner { + res.stderr(format!("Hook '{}' failed", p.display())); + rudder_error!("Hook '{}' failed", p.display()); + res.inner = Err(e.context(format!("Hook '{}' failed", p.display()))); return res; } } Err(e) => { - res.stderr(format!("Skipping hook '{}' : {:?}", p.display(), e)); - rudder_info!("Skipping hook '{}' : {:?}", p.display(), e); + res.stderr(format!("Skipping hook '{}': {:?}", p.display(), e)); + rudder_info!("Skipping hook '{}': {:?}", p.display(), e); continue; } }; @@ -121,35 +124,6 @@ impl Hooks { } } -/* -def run_hooks(directory): - if os.path.isdir(directory): - hooks = os.listdir(directory) - else: - hooks = [] - stdout = [] - stderr = [] - - hooks.sort() - - for hook in hooks: - hook_file = directory + os.path.sep + hook - - (runnable, reason) = is_hook_runnable(hook_file) - if not runnable: - stdout.append('# Skipping hook ' + hook + ': ' + reason) - continue - - (code, o, e) = run(hook_file) - - if code != 0: - # Fail early - stderr.append('# Hook ' + hook + ' failed, aborting upgrade') - return False, '\n'.join(stdout), '\n'.join(stderr) - - return True, '\n'.join(stdout), '\n'.join(stderr) - */ - #[cfg(test)] mod tests { use std::{fs::File, os::unix::fs::PermissionsExt}; @@ -158,6 +132,11 @@ mod tests { use super::*; + #[test] + fn geteuid_gets_an_id() { + assert!(geteuid() > 0); + } + #[test] fn test_hook_is_runnable() { let euid = geteuid(); @@ -172,6 +151,10 @@ mod tests { file.set_permissions(permissions.clone()).unwrap(); assert!(hook_is_runnable(&file_path, euid).is_ok()); + permissions.set_mode(0o700); + file.set_permissions(permissions.clone()).unwrap(); + assert!(hook_is_runnable(&file_path, euid + 1).is_err()); + permissions.set_mode(0o777); file.set_permissions(permissions.clone()).unwrap(); assert!(hook_is_runnable(&file_path, euid).is_err()); @@ -182,7 +165,22 @@ mod tests { } #[test] - fn test_run_hooks() { + fn test_run_hooks_on_nonexisting_directory() { + let dir = "/does/not/exist"; + let result = Hooks::run_dir(Path::new(dir)); + assert!(result.inner.is_ok()); + } + + #[test] + fn test_run_hooks_on_empty_directory() { + let dir = tempdir().unwrap(); + let dir_path = dir.path(); + let result = Hooks::run_dir(dir_path); + assert!(result.inner.is_ok()); + } + + #[test] + fn test_run_hooks_with_empty_script() { let dir = tempdir().unwrap(); let dir_path = dir.path(); let file_path = dir_path.join("tempfile"); @@ -191,4 +189,26 @@ mod tests { let result = Hooks::run_dir(dir_path); assert!(result.inner.is_ok()); } + + #[test] + fn test_run_hooks_with_multiple_succeeding_scripts() { + let dir = Path::new("tests/hooks/success"); + assert!(dir.exists()); + let result = Hooks::run_dir(dir); + assert!(result.inner.is_ok()); + assert!(result.stdout.contains(&"success1\n".to_string())); + assert!(result.stdout.contains(&"success2\n".to_string())); + assert!(result.stdout.contains(&"success3\n".to_string())); + } + + #[test] + fn test_run_hooks_stops_on_failure() { + let dir = Path::new("tests/hooks/failure"); + assert!(dir.exists()); + let result = Hooks::run_dir(dir); + assert!(result.inner.is_err()); + assert!(result.stdout.contains(&"success1\n".to_string())); + assert!(result.stderr.contains(&"failure2\n".to_string())); + assert!(!result.stdout.contains(&"success3\n".to_string())); + } } diff --git a/policies/module-types/system-updates/src/lib.rs b/policies/module-types/system-updates/src/lib.rs new file mode 100644 index 00000000000..b41bb3b3100 --- /dev/null +++ b/policies/module-types/system-updates/src/lib.rs @@ -0,0 +1,263 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 Normation SAS + +pub mod campaign; +mod cli; +pub mod db; +mod hooks; +pub mod output; +pub mod package_manager; +mod runner; +mod scheduler; +pub mod state; +pub mod system; + +use crate::{ + campaign::{RunnerParameters, RETENTION_DAYS}, + cli::Cli, + db::PackageDatabase, + package_manager::PackageManager, + runner::Runner, + system::{System, Systemd}, +}; +use anyhow::{anyhow, Context, Result}; +use chrono::{DateTime, Duration, Utc}; +use package_manager::PackageSpec; +use rudder_module_type::{ + cfengine::called_from_agent, parameters::Parameters, rudder_debug, run_module, + CheckApplyResult, ModuleType0, ModuleTypeMetadata, PolicyMode, ValidateResult, +}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::{env, path::PathBuf, str::FromStr}; + +const MODULE_NAME: &str = env!("CARGO_PKG_NAME"); + +// Same as the python implementation +pub const MODULE_DIR: &str = "/var/rudder/system-update"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Copy, Default)] +#[serde(rename_all = "kebab-case")] +pub enum RebootType { + #[serde(alias = "enabled")] + Always, + AsNeeded, + ServicesOnly, + #[default] + Disabled, +} + +impl FromStr for RebootType { + type Err = std::io::Error; + + fn from_str(s: &str) -> anyhow::Result { + match s { + "always" => Ok(Self::Always), + "as-needed" => Ok(Self::AsNeeded), + "services-only" => Ok(Self::ServicesOnly), + "disabled" => Ok(Self::Disabled), + _ => Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "Invalid input", + )), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Copy, Default)] +#[serde(rename_all = "kebab-case")] +pub enum CampaignType { + #[default] + /// Install all available upgrades + SystemUpdate, + /// Install all available security upgrades + SecurityUpdate, + /// Install the updates from the provided package list + SoftwareUpdate, +} + +/// Parameters required to schedule an event +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct ScheduleParameters { + pub start: DateTime, + pub end: DateTime, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Schedule { + Scheduled(ScheduleParameters), + Immediate, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct PackageParameters { + #[serde(default)] + pub campaign_type: CampaignType, + /// Rely on the agent to detect the OS and chose the right package manager. + /// Avoid multiplying the number of environment detection sources. + pub package_manager: PackageManager, + pub event_id: String, + pub campaign_name: String, + pub schedule: Schedule, + pub reboot_type: RebootType, + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub package_list: Vec, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub report_file: Option, + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub schedule_file: Option, +} + +/// We store no state in the module itself. +/// +/// We could store package manager state, but it is unnecessary for now +/// as we have few calls to this module anyway. +struct SystemUpdateModule {} + +impl SystemUpdateModule { + fn new() -> Self { + Self {} + } +} + +impl ModuleType0 for SystemUpdateModule { + fn metadata(&self) -> ModuleTypeMetadata { + let meta = include_str!("../rudder_module_type.yml"); + let docs = include_str!("../README.md"); + ModuleTypeMetadata::from_metadata(meta) + .expect("invalid metadata") + .documentation(docs) + } + + fn validate(&self, parameters: &Parameters) -> ValidateResult { + // Parse as parameter types + let p_parameters: PackageParameters = + serde_json::from_value(Value::Object(parameters.data.clone())) + .context("Parsing module parameters")?; + assert!(!p_parameters.event_id.is_empty()); + // Not doing more checks here as we want to send the errors as "system-update reports". + Ok(()) + } + + fn check_apply(&mut self, mode: PolicyMode, parameters: &Parameters) -> CheckApplyResult { + assert!(self.validate(parameters).is_ok()); + let package_parameters: PackageParameters = + serde_json::from_value(Value::Object(parameters.data.clone())) + .context("Parsing module parameters")?; + let pm = package_parameters.package_manager.get()?; + + let i_am_root = parameters.node_id == "root"; + if mode == PolicyMode::Audit { + Err(anyhow!("{} does not support audit mode", MODULE_NAME)) + } else if i_am_root && package_parameters.campaign_type != CampaignType::SoftwareUpdate { + Err(anyhow!( + "System campaigns are not allowed on the Rudder root server, aborting." + )) + } else if package_parameters.campaign_type == CampaignType::SoftwareUpdate + && package_parameters.package_list.is_empty() + { + Err(anyhow!( + "Software update without a package list. This is inconsistent, aborting." + )) + } else { + let db = PackageDatabase::new(Some(parameters.state_dir.as_path()))?; + rudder_debug!("Cleaning events older than {} days", RETENTION_DAYS); + db.clean(Duration::days(RETENTION_DAYS as i64))?; + let agent_freq = Duration::minutes(parameters.agent_frequency_minutes as i64); + let system: Box = Box::new(Systemd::new()); + let rp = + RunnerParameters::new(package_parameters, parameters.node_id.clone(), agent_freq); + let pid = std::process::id(); + let mut runner = Runner::new(db, pm, rp, system, pid); + runner.run() + } + } +} + +/// Start runner +pub fn entry() -> Result<(), anyhow::Error> { + env::set_var("LC_ALL", "C"); + + if called_from_agent() { + run_module(SystemUpdateModule::new()) + } else { + // The CLI does not use the module API + Cli::run() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + use std::{fs, path::Path}; + + #[test] + fn test_module_type() { + let module = SystemUpdateModule::new(); + + let data1 = PackageParameters { + campaign_type: CampaignType::SystemUpdate, + package_manager: PackageManager::Yum, + event_id: "30c87fee-f6b0-42b1-9adb-be067217f1a9".to_string(), + campaign_name: "My campaign".to_string(), + schedule: Schedule::Immediate, + reboot_type: RebootType::AsNeeded, + package_list: vec![], + report_file: None, + schedule_file: None, + }; + let data1 = serde_json::from_str(&serde_json::to_string(&data1).unwrap()).unwrap(); + let mut reference1 = Parameters::new("node".to_string(), data1, PathBuf::from("/tmp")); + reference1.temporary_dir = "/var/rudder/tmp/".into(); + reference1.backup_dir = "/var/rudder/modified-files/".into(); + + let test1 = fs::read_to_string(Path::new("tests/parameters/immediate.json")).unwrap(); + let parameters1: Parameters = serde_json::from_str(&test1).unwrap(); + + assert_eq!(parameters1, reference1); + assert!(module.validate(¶meters1).is_ok()); + + let data2 = PackageParameters { + campaign_type: CampaignType::SoftwareUpdate, + package_manager: PackageManager::Zypper, + event_id: "30c87fee-f6b0-42b1-9adb-be067217f1a9".to_string(), + campaign_name: "My campaign".to_string(), + schedule: Schedule::Scheduled(ScheduleParameters { + start: "2021-01-01T00:00:00Z".parse().unwrap(), + end: "2023-01-01T00:00:00Z".parse().unwrap(), + }), + reboot_type: RebootType::AsNeeded, + package_list: vec![ + PackageSpec::new( + "apache2".to_string(), + Some("2.4.29-1ubuntu4.14".to_string()), + None, + ), + PackageSpec::new( + "nginx".to_string(), + Some("1.14.0-0ubuntu1.7".to_string()), + None, + ), + ], + report_file: Some(PathBuf::from("/tmp/report.json")), + schedule_file: Some(PathBuf::from("/tmp/schedule.json")), + }; + let data2 = serde_json::from_str(&serde_json::to_string(&data2).unwrap()).unwrap(); + let mut reference2 = Parameters::new("node".to_string(), data2, PathBuf::from("/tmp")); + reference2.temporary_dir = "/var/rudder/tmp/".into(); + reference2.backup_dir = "/var/rudder/modified-files/".into(); + reference2.agent_frequency_minutes = 30; + + let test2 = fs::read_to_string(Path::new("tests/parameters/scheduled.json")).unwrap(); + let parameters2: Parameters = serde_json::from_str(&test2).unwrap(); + + assert_eq!(parameters2, reference2); + assert!(module.validate(¶meters2).is_ok()); + } +} diff --git a/policies/module-types/system-updates/src/main.rs b/policies/module-types/system-updates/src/main.rs index 66346915841..a361f8a31e3 100644 --- a/policies/module-types/system-updates/src/main.rs +++ b/policies/module-types/system-updates/src/main.rs @@ -1,258 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2024 Normation SAS -mod campaign; -mod cli; -mod db; -mod hooks; -mod output; -mod package_manager; -mod scheduler; -mod state; -mod system; - -use crate::campaign::fail_campaign; -use crate::{ - campaign::{check_update, FullSchedule}, - cli::Cli, - package_manager::PackageManager, -}; -use anyhow::{anyhow, Context}; -use chrono::{DateTime, Duration, Utc}; -use package_manager::PackageSpec; -use rudder_module_type::{ - cfengine::called_from_agent, parameters::Parameters, run_module, CheckApplyResult, ModuleType0, - ModuleTypeMetadata, PolicyMode, ValidateResult, -}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use std::{env, path::PathBuf}; - -const MODULE_NAME: &str = env!("CARGO_PKG_NAME"); - -// Same as the python implementation -pub const MODULE_DIR: &str = "/var/rudder/system-update"; - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Copy)] -#[serde(rename_all = "kebab-case")] -pub enum RebootType { - #[serde(alias = "enabled")] - Always, - AsNeeded, - ServicesOnly, - Disabled, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Copy, Default)] -#[serde(rename_all = "kebab-case")] -pub enum CampaignType { - #[default] - /// Install all available upgrades - SystemUpdate, - /// Install all available security upgrades - SecurityUpdate, - /// Install the updates from the provided package list - SoftwareUpdate, -} - -/// Parameters required to schedule an event -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub struct ScheduleParameters { - start: DateTime, - end: DateTime, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum Schedule { - Scheduled(ScheduleParameters), - Immediate, -} - -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct PackageParameters { - #[serde(default)] - campaign_type: CampaignType, - /// Rely on the agent to detect the OS and chose the right package manager. - /// Avoid multiplying the number of environment detection sources. - package_manager: PackageManager, - event_id: String, - campaign_name: String, - schedule: Schedule, - reboot_type: RebootType, - #[serde(default)] - #[serde(skip_serializing_if = "Vec::is_empty")] - package_list: Vec, - #[serde(default)] - #[serde(skip_serializing_if = "Option::is_none")] - report_file: Option, - #[serde(default)] - #[serde(skip_serializing_if = "Option::is_none")] - schedule_file: Option, -} - -/// We store no state in the module itself. -/// -/// We could store package manager state, but it is unnecessary for now -/// as we have few calls to this module anyway. -struct SystemUpdateModule {} - -impl SystemUpdateModule { - fn new() -> Self { - Self {} - } -} - -impl ModuleType0 for SystemUpdateModule { - fn metadata(&self) -> ModuleTypeMetadata { - let meta = include_str!("../rudder_module_type.yml"); - let docs = include_str!("../README.md"); - ModuleTypeMetadata::from_metadata(meta) - .expect("invalid metadata") - .documentation(docs) - } - - fn validate(&self, parameters: &Parameters) -> ValidateResult { - // Parse as parameter types - let p_parameters: PackageParameters = - serde_json::from_value(Value::Object(parameters.data.clone())) - .context("Parsing module parameters")?; - assert!(!p_parameters.event_id.is_empty()); - // Not doing more checks here as we want to send the errors as "system-update reports". - Ok(()) - } - - fn check_apply(&mut self, mode: PolicyMode, parameters: &Parameters) -> CheckApplyResult { - assert!(self.validate(parameters).is_ok()); - let package_parameters: PackageParameters = - serde_json::from_value(Value::Object(parameters.data.clone())) - .context("Parsing module parameters")?; - let agent_freq = Duration::minutes(parameters.agent_frequency_minutes as i64); - let full_schedule = match package_parameters.schedule { - Schedule::Scheduled(ref s) => { - FullSchedule::Scheduled(campaign::FullScheduleParameters { - start: s.start, - end: s.end, - node_id: parameters.node_id.clone(), - agent_frequency: agent_freq, - }) - } - Schedule::Immediate => FullSchedule::Immediate, - }; - let report_file = package_parameters.report_file.clone(); - let r = { - let i_am_root = parameters.node_id == "root"; - if mode == PolicyMode::Audit { - Err(anyhow!("{} does not support audit mode", MODULE_NAME)) - } else if i_am_root && package_parameters.campaign_type != CampaignType::SoftwareUpdate - { - Err(anyhow!( - "System campaigns are not allowed on the Rudder root server, aborting." - )) - } else if package_parameters.campaign_type == CampaignType::SoftwareUpdate - && package_parameters.package_list.is_empty() - { - Err(anyhow!( - "Software update without a package list. This is inconsistent, aborting." - )) - } else { - check_update( - parameters.state_dir.as_path(), - full_schedule, - package_parameters, - ) - } - }; - - if let Err(e) = r { - // Send the report to server - fail_campaign(&format!("{:?}", e), report_file) - } else { - r - } - } -} - -/// Start runner fn main() -> Result<(), anyhow::Error> { - env::set_var("LC_ALL", "C"); - - if called_from_agent() { - run_module(SystemUpdateModule::new()) - } else { - // The CLI does not use the module API - Cli::run() - } -} - -#[cfg(test)] -mod tests { - use super::*; - use pretty_assertions::assert_eq; - use std::{fs, path::Path}; - - #[test] - fn test_module_type() { - let module = SystemUpdateModule::new(); - - let data1 = PackageParameters { - campaign_type: CampaignType::SystemUpdate, - package_manager: PackageManager::Yum, - event_id: "30c87fee-f6b0-42b1-9adb-be067217f1a9".to_string(), - campaign_name: "My campaign".to_string(), - schedule: Schedule::Immediate, - reboot_type: RebootType::AsNeeded, - package_list: vec![], - report_file: None, - schedule_file: None, - }; - let data1 = serde_json::from_str(&serde_json::to_string(&data1).unwrap()).unwrap(); - let mut reference1 = Parameters::new("node".to_string(), data1, PathBuf::from("/tmp")); - reference1.temporary_dir = "/var/rudder/tmp/".into(); - reference1.backup_dir = "/var/rudder/modified-files/".into(); - - let test1 = fs::read_to_string(Path::new("tests/parameters/immediate.json")).unwrap(); - let parameters1: Parameters = serde_json::from_str(&test1).unwrap(); - - assert_eq!(parameters1, reference1); - assert!(module.validate(¶meters1).is_ok()); - - let data2 = PackageParameters { - campaign_type: CampaignType::SoftwareUpdate, - package_manager: PackageManager::Zypper, - event_id: "30c87fee-f6b0-42b1-9adb-be067217f1a9".to_string(), - campaign_name: "My campaign".to_string(), - schedule: Schedule::Scheduled(ScheduleParameters { - start: "2021-01-01T00:00:00Z".parse().unwrap(), - end: "2023-01-01T00:00:00Z".parse().unwrap(), - }), - reboot_type: RebootType::AsNeeded, - package_list: vec![ - PackageSpec::new( - "apache2".to_string(), - Some("2.4.29-1ubuntu4.14".to_string()), - None, - ), - PackageSpec::new( - "nginx".to_string(), - Some("1.14.0-0ubuntu1.7".to_string()), - None, - ), - ], - report_file: Some(PathBuf::from("/tmp/report.json")), - schedule_file: Some(PathBuf::from("/tmp/schedule.json")), - }; - let data2 = serde_json::from_str(&serde_json::to_string(&data2).unwrap()).unwrap(); - let mut reference2 = Parameters::new("node".to_string(), data2, PathBuf::from("/tmp")); - reference2.temporary_dir = "/var/rudder/tmp/".into(); - reference2.backup_dir = "/var/rudder/modified-files/".into(); - reference2.agent_frequency_minutes = 30; - - let test2 = fs::read_to_string(Path::new("tests/parameters/scheduled.json")).unwrap(); - let parameters2: Parameters = serde_json::from_str(&test2).unwrap(); - - assert_eq!(parameters2, reference2); - assert!(module.validate(¶meters2).is_ok()); - } + rudder_module_system_updates::entry() } diff --git a/policies/module-types/system-updates/src/output.rs b/policies/module-types/system-updates/src/output.rs index cdc2859baa4..47f70d4bb3f 100644 --- a/policies/module-types/system-updates/src/output.rs +++ b/policies/module-types/system-updates/src/output.rs @@ -1,7 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2024 Normation SAS -use anyhow::Result; +use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use log::debug; use serde::{Deserialize, Serialize}; @@ -14,7 +14,7 @@ use crate::package_manager::PackageDiff; /// Outcome of each function /// -/// We need to collect outputs for reporting, but also to log in live for debugging purposes. +/// We need to collect outputs for reporting, but also to log in live for debugging purposes. #[must_use] pub struct ResultOutput { pub inner: Result, @@ -44,11 +44,25 @@ impl ResultOutput { self.stdout.push(s) } + /// Add log lines to stdout + pub fn stdout_lines(&mut self, s: Vec) { + for l in s { + self.stdout(l) + } + } + /// Add logs to stderr pub fn stderr(&mut self, s: String) { self.stderr.push(s) } + /// Add log lines to stderr + pub fn stderr_lines(&mut self, s: Vec) { + for l in s { + self.stderr(l) + } + } + /// Chain a `ResultOutput` to another pub fn step(self, s: ResultOutput) -> ResultOutput { let mut res = ResultOutput::new(s.inner); @@ -94,6 +108,12 @@ impl ResultOutput { let stderr_s = String::from_utf8_lossy(&o.stderr); res.stderr.push(stderr_s.to_string()); debug!("stderr: {stderr_s}"); + if !o.status.success() { + res.inner = Err(match o.status.code() { + Some(code) => anyhow!("Command failed with code: {code}",), + None => anyhow!("Command terminated by signal"), + }); + } }; res } @@ -122,7 +142,7 @@ impl Display for Status { // Same as the Python implementation in 8.1. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) struct Report { +pub struct Report { pub software_updated: Vec, pub status: Status, pub output: String, @@ -130,6 +150,12 @@ pub(crate) struct Report { pub errors: Option, } +impl Default for Report { + fn default() -> Self { + Self::new() + } +} + impl Report { pub fn new() -> Self { Self { @@ -209,7 +235,7 @@ impl Report { /// Same as the Python implementation in 8.1. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "kebab-case")] -pub(crate) struct ScheduleReport { +pub struct ScheduleReport { pub status: Status, pub date: DateTime, } diff --git a/policies/module-types/system-updates/src/package_manager.rs b/policies/module-types/system-updates/src/package_manager.rs index 2e66f43f405..2934cb3d995 100644 --- a/policies/module-types/system-updates/src/package_manager.rs +++ b/policies/module-types/system-updates/src/package_manager.rs @@ -5,14 +5,16 @@ /// use anyhow::{bail, Result}; use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, fs::read_to_string}; +use std::collections::HashMap; #[cfg(feature = "apt")] use crate::package_manager::apt::AptPackageManager; use crate::{ + campaign::FullCampaignType, output::ResultOutput, package_manager::{yum::YumPackageManager, zypper::ZypperPackageManager}, }; +use rudder_module_type::os_release::OsRelease; use std::str::FromStr; #[cfg(feature = "apt")] @@ -30,7 +32,7 @@ pub struct PackageList { /// Details of a package (installed or available) in a package manager context. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub(crate) struct PackageInfo { +pub struct PackageInfo { pub(crate) version: String, pub(crate) from: String, pub(crate) source: PackageManager, @@ -42,7 +44,6 @@ impl PackageList { } pub fn diff(&self, new: Self) -> Vec { - // FIXME: check package managers? let mut changes = vec![]; for (p, info) in &self.inner { @@ -173,33 +174,28 @@ impl FromStr for PackageManager { impl PackageManager { pub fn get(self) -> Result> { Ok(match self { - PackageManager::Yum => Box::new(YumPackageManager::new()), + PackageManager::Yum => Box::new(YumPackageManager::new()?), #[cfg(feature = "apt")] - PackageManager::Apt => Box::new(AptPackageManager::new()?), + PackageManager::Apt => { + let os_release = OsRelease::new()?; + Box::new(AptPackageManager::new(&os_release)?) + } #[cfg(not(feature = "apt"))] PackageManager::Apt => bail!("This module was not build with APT support"), - PackageManager::Zypper => Box::new(ZypperPackageManager::new()), + PackageManager::Zypper => Box::new(ZypperPackageManager::new()?), _ => bail!("This package manager does not provide patch management features"), }) } /// Only used in CLI mode - pub fn detect() -> Result { - let os_release = read_to_string("/etc/os-release")?; - for l in os_release.lines() { - if l.starts_with("ID=") { - let id = l.split('=').skip(1).next().unwrap(); - return Ok(match id { - "debian" | "ubuntu" => Self::Apt, - "fedora" | "centos" | "rhel" | "rocky" | "ol" | "almalinux" | "amzn" => { - Self::Yum - } - "sles" | "sled" => Self::Zypper, - _ => bail!("Unknown package manager for OS: {}", id), - }); - } - } - bail!("No OS id found, could not detect package manager"); + pub fn detect(os_release: &OsRelease) -> Result { + let id = os_release.id.as_str(); + Ok(match id { + "debian" | "ubuntu" => Self::Apt, + "fedora" | "centos" | "rhel" | "rocky" | "ol" | "almalinux" | "amzn" => Self::Yum, + "sles" | "sled" => Self::Zypper, + _ => bail!("Unknown package manager for OS: '{}'", id), + }) } } @@ -215,14 +211,8 @@ pub trait LinuxPackageManager { /// It doesn't use a cache and queries the package manager directly. fn list_installed(&mut self) -> ResultOutput; - /// Apply all available upgrades - fn full_upgrade(&mut self) -> ResultOutput<()>; - - /// Apply all security upgrades - fn security_upgrade(&mut self) -> ResultOutput<()>; - /// Upgrade specific packages - fn upgrade(&mut self, packages: Vec) -> ResultOutput<()>; + fn upgrade(&mut self, update_type: &FullCampaignType) -> ResultOutput<()>; /// Is a reboot pending? fn reboot_pending(&self) -> ResultOutput; diff --git a/policies/module-types/system-updates/src/package_manager/apt.rs b/policies/module-types/system-updates/src/package_manager/apt.rs index 7bca188f939..299ffa86682 100644 --- a/policies/module-types/system-updates/src/package_manager/apt.rs +++ b/policies/module-types/system-updates/src/package_manager/apt.rs @@ -1,29 +1,46 @@ // SPDX-License-Identifier: GPL-3.0-or-later // SPDX-FileCopyrightText: 2024 Normation SAS +mod filter; + use crate::{ + campaign::FullCampaignType, output::ResultOutput, package_manager::{ + apt::filter::{Distribution, PackageFileFilter}, LinuxPackageManager, PackageId, PackageInfo, PackageList, PackageManager, PackageSpec, }, }; use anyhow::{anyhow, Context, Result}; use memfile::MemFile; use regex::Regex; +#[cfg(not(debug_assertions))] +use rudder_module_type::ensure_root_user; +use rudder_module_type::os_release::OsRelease; use rust_apt::{ cache::Upgrade, config::Config, error::AptErrors, new_cache, progress::{AcquireProgress, InstallProgress}, - PackageSort, + Cache, PackageSort, +}; +use std::{ + collections::HashMap, + env, + fs::File, + io::{Read, Seek}, + path::Path, + process::Command, }; -use std::{collections::HashMap, env, io::Read, path::Path, process::Command}; -use stdio_override::{StderrOverride, StderrOverrideGuard, StdoutOverride, StdoutOverrideGuard}; +use stdio_override::{StdoutOverride, StdoutOverrideGuard}; -/// Reference guides: +/// References: /// * https://www.debian.org/doc/manuals/debian-faq/uptodate.en.html /// * https://www.debian.org/doc/manuals/debian-handbook/sect.apt-get.en.html#sect.apt-upgrade +/// * https://help.ubuntu.com/community/AutomaticSecurityUpdates +/// * https://www.debian.org/doc/manuals/securing-debian-manual/security-update.en.html +/// * https://wiki.debian.org/UnattendedUpgrades /// /// Our reference model is unattended-upgrades, which is the only “official” way to handle automatic upgrades. /// We need to be compatible with: @@ -40,12 +57,15 @@ const REBOOT_REQUIRED_FILE_PATH: &str = "/var/run/reboot-required"; pub struct AptPackageManager { /// Use an `Option` as some methods will consume the cache. - cache: Option, + cache: Option, + distribution: Distribution, } impl AptPackageManager { - pub fn new() -> Result { - // TODO: do we need this with the lib? + pub fn new(os_release: &OsRelease) -> Result { + #[cfg(not(debug_assertions))] + ensure_root_user()?; + env::set_var("DEBIAN_FRONTEND", "noninteractive"); // TODO: do we really want to disable list changes? // It will be switched to non-interactive mode automatically. @@ -59,12 +79,20 @@ impl AptPackageManager { let dpkg_options = vec!["--force-confold", "--force-confdef"]; let conf = Config::new(); conf.set_vector("Dpkg::Options", &dpkg_options); + // Ensure we log the commandline in apt logs + conf.set( + "Commandline::AsString", + &env::args().collect::>().join(" "), + ); - Ok(Self { cache: Some(cache) }) + Ok(Self { + cache: Some(cache), + distribution: Distribution::new(os_release), + }) } /// Take the existing cache, or create a new one if it is not available. - fn cache(&mut self) -> ResultOutput { + fn cache(&mut self) -> ResultOutput { if self.cache.is_some() { ResultOutput::new(Ok(self.cache.take().unwrap())) } else { @@ -90,6 +118,10 @@ impl AptPackageManager { .collect()) } + fn all_installed() -> PackageSort { + PackageSort::default().installed().include_virtual() + } + /// Converts a list of APT errors to a `ResultOutput`. fn apt_errors_to_output(res: Result) -> ResultOutput { let mut stderr = vec![]; @@ -103,44 +135,102 @@ impl AptPackageManager { stderr.push(format!("Warning: {}", error.msg)); } } - Err(anyhow!("Apt error")) + Err(anyhow!("APT error")) } }; ResultOutput::new_output(r, vec![], stderr) } + + fn mark_security_upgrades( + &self, + cache: &mut Cache, + security_origins: &[PackageFileFilter], + ) -> std::result::Result<(), AptErrors> { + for p in cache.packages(&Self::all_installed()) { + if p.is_upgradable() { + for v in p.versions() { + if PackageFileFilter::is_in_allowed_origin(&v, &security_origins) { + if v > p.installed().unwrap() { + v.set_candidate(); + p.mark_install(true, !p.is_auto_installed()); + break; + } + } + } + } + } + Ok(()) + } + + fn mark_package_upgrades( + &self, + packages: &[PackageSpec], + cache: &mut Cache, + ) -> std::result::Result<(), AptErrors> { + for p in packages { + let package_id = if let Some(ref a) = p.architecture { + format!("{}:{}", p.name, a) + } else { + p.name.clone() + }; + // Get package from cache + if let Some(pkg) = cache.get(&package_id) { + if !pkg.is_installed() { + // We only upgrade + continue; + } + if let Some(ref spec_version) = p.version { + let candidate = pkg + .versions() + .find(|v| v.version() == spec_version.as_str()); + if let Some(candidate) = candidate { + // Don't allow downgrade + if candidate > pkg.installed().unwrap() { + candidate.set_candidate(); + pkg.mark_install(true, !pkg.is_auto_installed()); + } + } + } else { + if pkg.is_upgradable() { + pkg.mark_install(true, !pkg.is_auto_installed()); + } + } + } + } + Ok(()) + } + + fn mark_all_upgrades(&self, cache: &mut Cache) -> std::result::Result<(), AptErrors> { + // Upgrade type: `apt upgrade` (not `dist-upgrade`). + // Allows adding packages, but not removing. + let upgrade_type = Upgrade::Upgrade; + // Mark all packages for upgrade + cache.upgrade(upgrade_type) + } } // Catch stdout/stderr from the library pub struct OutputCatcher { - out_file: MemFile, + out_file: File, out_guard: StdoutOverrideGuard, - err_file: MemFile, - err_guard: StderrOverrideGuard, } impl OutputCatcher { pub fn new() -> Self { - let out_file = MemFile::create_default("stdout").unwrap(); - let err_file = MemFile::create_default("stderr").unwrap(); + let out_file = MemFile::create_default("stdout").unwrap().into_file(); let out_guard = StdoutOverride::override_raw(out_file.try_clone().unwrap()).unwrap(); - let err_guard = StderrOverride::override_raw(err_file.try_clone().unwrap()).unwrap(); - Self { out_file, out_guard, - err_file, - err_guard, } } - pub fn read(mut self) -> (String, String) { + pub fn read(mut self) -> String { drop(self.out_guard); - drop(self.err_guard); let mut out = String::new(); + self.out_file.rewind().unwrap(); self.out_file.read_to_string(&mut out).unwrap(); - let mut err = String::new(); - self.err_file.read_to_string(&mut err).unwrap(); - (out, err) + out } } @@ -150,14 +240,10 @@ impl LinuxPackageManager for AptPackageManager { if let Ok(o) = cache.inner { let mut progress = AcquireProgress::apt(); - - // Collect stdout through an in-memory fd. let catch = OutputCatcher::new(); let mut r = Self::apt_errors_to_output(o.update(&mut progress)); - let (out, err) = catch.read(); - // FIXME should be inserted before + let out = catch.read(); r.stdout(out); - r.stderr(err); r } else { cache.clear_ok() @@ -168,15 +254,14 @@ impl LinuxPackageManager for AptPackageManager { let cache = self.cache(); let step = if let Ok(ref c) = cache.inner { - // FIXME: compare with dpkg output - let filter = PackageSort::default().installed().include_virtual(); + let filter = Self::all_installed(); let mut list = HashMap::new(); for p in c.packages(&filter) { let v = p.installed().expect("Only installed packages are listed"); let info = PackageInfo { version: v.version().to_string(), - from: "FIXME".to_string(), + from: v.source_name().to_string(), source: PackageManager::Apt, }; let id = PackageId { @@ -195,113 +280,27 @@ impl LinuxPackageManager for AptPackageManager { cache.step(step) } - fn full_upgrade(&mut self) -> ResultOutput<()> { + fn upgrade(&mut self, update_type: &FullCampaignType) -> ResultOutput<()> { let cache = self.cache(); - - if let Ok(c) = cache.inner { - // Upgrade type: `apt upgrade` (not `dist-upgrade`). - // Allows adding packages, but not removing. - // FIXME: release notes, we did a dist-upgrade before - let upgrade_type = Upgrade::Upgrade; - - // Mark all packages for upgrade - let res_mark = Self::apt_errors_to_output(c.upgrade(upgrade_type)); - if res_mark.inner.is_err() { - return res_mark; - } - - // Resolve dependencies - let res_resolve = Self::apt_errors_to_output(c.resolve(true)); - if res_resolve.inner.is_err() { - return res_resolve; - } - let res = res_mark.step(res_resolve); - - // Do the changes - let mut install_progress = InstallProgress::apt(); - let mut acquire_progress = AcquireProgress::apt(); - let catch = OutputCatcher::new(); - let mut res_commit = - Self::apt_errors_to_output(c.commit(&mut acquire_progress, &mut install_progress)); - let (out, err) = catch.read(); - res_commit.stdout(out); - res_commit.stderr(err); - res.step(res_commit) - } else { - cache.clear_ok() - } - } - - fn security_upgrade(&mut self) -> ResultOutput<()> { - // This is tricky, there is nothing built-in in apt CLI. The only official way to do this is to use `unattended-upgrades`. - // We try to copy the logic from `unattended-upgrades` here. - // - // https://help.ubuntu.com/community/AutomaticSecurityUpdates - // https://www.debian.org/doc/manuals/securing-debian-manual/security-update.en.html - // https://wiki.debian.org/UnattendedUpgrades - - let cache = self.cache(); - if let Ok(c) = cache.inner { - let filter = PackageSort::default().installed().include_virtual(); - - for p in c.packages(&filter) { - p.versions().for_each(|v| { - // FIXME: get actual default rules from unattended-upgrades - if v.source_name().contains("security") { - v.set_candidate(); - p.mark_install(false, false); - } - }); - } - - // Resolve dependencies - let res_resolve = Self::apt_errors_to_output(c.resolve(true)); - if res_resolve.inner.is_err() { - return res_resolve; - } - - // Do the changes - let mut acquire_progress = AcquireProgress::apt(); - let mut install_progress = InstallProgress::apt(); - let catch = OutputCatcher::new(); - let mut res_commit = - Self::apt_errors_to_output(c.commit(&mut acquire_progress, &mut install_progress)); - let (out, err) = catch.read(); - res_commit.stdout(out); - res_commit.stderr(err); - res_resolve.step(res_commit) - } else { - cache.clear_ok() - } - } - - fn upgrade(&mut self, packages: Vec) -> ResultOutput<()> { - let cache = self.cache(); - - if let Ok(c) = cache.inner { - for p in packages { - let package_id = if let Some(a) = p.architecture { - format!("{}:{}", p.name, a) - } else { - p.name - }; - // Get package from cache - // FIXME: handle absent package - let pkg = c.get(&package_id).unwrap(); - - if let Some(spec_version) = p.version { - let candidate = pkg - .versions() - .find(|v| v.version() == spec_version.as_str()); - if let Some(candidate) = candidate { - candidate.set_candidate(); - // FIXME check result, error if not found, but still do the others - pkg.mark_install(false, false); - } - } else { - // FIXME check marking because it's not clear - pkg.mark_install(false, false); + if let Ok(mut c) = cache.inner { + //if c.get_changes(false).peekable().next().is_some() { + // c.clear() + //} + + let mark_res = AptPackageManager::apt_errors_to_output(match update_type { + FullCampaignType::SystemUpdate => self.mark_all_upgrades(&mut c), + FullCampaignType::SecurityUpdate => { + let security_origins = match self.distribution.security_origins() { + Ok(origins) => origins, + // Fail loudly if not supported + Err(e) => return ResultOutput::new(Err(e)), + }; + self.mark_security_upgrades(&mut c, &security_origins) } + FullCampaignType::SoftwareUpdate(p) => self.mark_package_upgrades(p, &mut c), + }); + if mark_res.inner.is_err() { + return mark_res; } // Resolve dependencies @@ -316,9 +315,8 @@ impl LinuxPackageManager for AptPackageManager { let catch = OutputCatcher::new(); let mut res_commit = Self::apt_errors_to_output(c.commit(&mut acquire_progress, &mut install_progress)); - let (out, err) = catch.read(); + let out = catch.read(); res_commit.stdout(out); - res_commit.stderr(err); res_resolve.step(res_commit) } else { cache.clear_ok() @@ -361,7 +359,8 @@ mod tests { #[test] fn test_parse_services_to_restart() { - let apt = AptPackageManager::new().unwrap(); + let os_release = OsRelease::from_string(""); + let apt = AptPackageManager::new(&os_release).unwrap(); let output1 = "NEEDRESTART-VER: 3.6 NEEDRESTART-KCUR: 6.1.0-20-amd64 @@ -388,4 +387,14 @@ NEEDRESTART-SESS: amousset @ user manager service"; expected2 ); } + + #[test] + // Needs "-- --nocapture --ignored" to run in tests as cargo test also messes with stdout/stderr + #[ignore] + fn it_captures_stdout() { + let catch = OutputCatcher::new(); + println!("plouf"); + let out = catch.read(); + assert_eq!(out, "plouf\n".to_string()); + } } diff --git a/policies/module-types/system-updates/src/package_manager/apt/filter.rs b/policies/module-types/system-updates/src/package_manager/apt/filter.rs new file mode 100644 index 00000000000..87f2e693337 --- /dev/null +++ b/policies/module-types/system-updates/src/package_manager/apt/filter.rs @@ -0,0 +1,225 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2024 Normation SAS + +use anyhow::{bail, Result}; +use rudder_module_type::os_release::OsRelease; +use rust_apt::{PackageFile, Version}; + +/// Equivalent to the `Unattended-Upgrade::Origins-Pattern`/`Unattended-Upgrade::Allowed-Origins` settings. +#[derive(Default, Clone, Debug)] +pub(crate) struct PackageFileFilter { + origin: Option, + codename: Option, + archive: Option, + label: Option, +} + +#[derive(Default, Clone, Debug)] +struct PackageVersionInfo<'a> { + origin: Option<&'a str>, + codename: Option<&'a str>, + archive: Option<&'a str>, + label: Option<&'a str>, + component: Option<&'a str>, + site: Option<&'a str>, +} + +impl<'a> PackageVersionInfo<'a> { + fn from_package_file(v: &'a PackageFile) -> Self { + Self { + origin: v.origin(), + codename: v.codename(), + archive: v.archive(), + label: v.label(), + component: v.component(), + site: v.site(), + } + } + + fn is_local(&self) -> bool { + match (self.component, self.archive, self.label, self.site) { + (Some("now"), Some("now"), None, None) => true, + _ => false, + } + } +} + +impl PackageFileFilter { + fn debian(origin: String, codename: String, label: String) -> Self { + Self { + origin: Some(origin), + codename: Some(codename), + label: Some(label), + ..Default::default() + } + } + + fn ubuntu(origin: String, archive: String) -> Self { + Self { + origin: Some(origin), + archive: Some(archive), + ..Default::default() + } + } + + // All specified fields need to match + fn matches(&self, package_file: &PackageVersionInfo) -> bool { + fn matches_value(v: Option<&str>, reference: Option<&String>) -> bool { + if let Some(v_ref) = reference { + // If specified, must match + if let Some(v_val) = v { + v_val == v_ref + } else { + false + } + } else { + true + } + } + + if !matches_value(package_file.origin, self.origin.as_ref()) { + return false; + } + if !matches_value(package_file.archive, self.archive.as_ref()) { + return false; + } + if !matches_value(package_file.label, self.label.as_ref()) { + return false; + } + if !matches_value(package_file.codename, self.codename.as_ref()) { + return false; + } + + true + } + + pub(crate) fn is_in_allowed_origin( + ver: &Version, + allowed_origins: &[PackageFileFilter], + ) -> bool { + for package_file in ver.package_files() { + let info = PackageVersionInfo::from_package_file(&package_file); + + // local origin is allowed by default + if info.is_local() { + return true; + } + + // We need to match any of the allowed origin + for filter in allowed_origins { + if filter.matches(&info) { + return true; + } + } + } + false + } +} + +/// For security-only patching, we need the distribution to +/// act more precisely over repository names. +#[derive(Debug, PartialEq)] +pub enum Distribution { + /// Codename + Ubuntu(String), + /// Codename + Debian(String), + /// OS id + Other(String), +} + +impl Distribution { + pub(crate) fn new(os_release: &OsRelease) -> Self { + match (os_release.id.as_str(), os_release.version_codename.as_ref()) { + ("debian", Some(c)) => Distribution::Debian(c.to_string()), + ("ubuntu", Some(c)) => Distribution::Ubuntu(c.to_string()), + _ => Distribution::Other(os_release.id.clone()), + } + } + + /// Adapted from unattended-upgrades' default configuration + pub(crate) fn security_origins(&self) -> Result> { + match self { + Distribution::Ubuntu(distro_codename) => Ok(vec![ + // "${distro_id}:${distro_codename}" + PackageFileFilter::ubuntu("Ubuntu".to_string(), distro_codename.clone()), + // "${distro_id}:${distro_codename}-security" + PackageFileFilter::ubuntu( + "Ubuntu".to_string(), + format!("${distro_codename}-security"), + ), + // "${distro_id}ESMApps:${distro_codename}-apps-security" + PackageFileFilter::ubuntu( + "UbuntuESMApps".to_string(), + format!("${distro_codename}-apps-security"), + ), + // "${distro_id}ESM:${distro_codename}-infra-security" + PackageFileFilter::ubuntu( + "UbuntuESM".to_string(), + format!("${distro_codename}-infra-security"), + ), + ]), + Distribution::Debian(distro_codename) => Ok(vec![ + // "origin=Debian,codename=${distro_codename},label=Debian" + PackageFileFilter::debian( + "Debian".to_string(), + distro_codename.clone(), + "Debian".to_string(), + ), + // "origin=Debian,codename=${distro_codename},label=Debian-Security" + PackageFileFilter::debian( + "Debian".to_string(), + distro_codename.clone(), + "Debian-Security".to_string(), + ), + // "origin=Debian,codename=${distro_codename}-security,label=Debian-Security" + PackageFileFilter::debian( + "Debian".to_string(), + format!("${distro_codename}-security"), + "Debian-Security".to_string(), + ), + ]), + Distribution::Other(o) => bail!( + "Unsupported distribution for security-only upgrade: '{}'", + o + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn filter_matches_empty_rules() { + let filter = PackageFileFilter::default(); + let info = PackageVersionInfo { + origin: Some("Debian"), + codename: Some("bookworm"), + archive: Some("archive"), + label: Some("42"), + component: None, + site: None, + }; + assert!(filter.matches(&info)) + } + + #[test] + fn filter_matches_partial_rules() { + let filter = PackageFileFilter { + origin: Some("Ubuntu".to_string()), + ..Default::default() + }; + let debian_info = PackageVersionInfo { + origin: Some("Debian"), + ..Default::default() + }; + let ubuntu_info = PackageVersionInfo { + origin: Some("Ubuntu"), + ..Default::default() + }; + assert!(!filter.matches(&debian_info)); + assert!(filter.matches(&ubuntu_info)); + } +} diff --git a/policies/module-types/system-updates/src/package_manager/yum.rs b/policies/module-types/system-updates/src/package_manager/yum.rs index cd096b8124a..badb99c7f17 100644 --- a/policies/module-types/system-updates/src/package_manager/yum.rs +++ b/policies/module-types/system-updates/src/package_manager/yum.rs @@ -3,10 +3,15 @@ use std::process::Command; +use anyhow::Result; + use crate::{ + campaign::FullCampaignType, output::ResultOutput, package_manager::{rpm::RpmPackageManager, LinuxPackageManager, PackageList, PackageSpec}, }; +#[cfg(not(debug_assertions))] +use rudder_module_type::ensure_root_user; /// We need to be compatible with: /// @@ -19,29 +24,25 @@ pub struct YumPackageManager { } impl YumPackageManager { - pub fn new() -> Self { + pub fn new() -> Result { + #[cfg(not(debug_assertions))] + ensure_root_user()?; let rpm = RpmPackageManager::new(); - Self { rpm } + Ok(Self { rpm }) } - fn package_spec_as_argument(p: PackageSpec) -> String { - let mut res = p.name; - if let Some(v) = p.version { + fn package_spec_as_argument(p: &PackageSpec) -> String { + let mut res = p.name.clone(); + if let Some(ref v) = p.version { res.push('-'); - res.push_str(&v); + res.push_str(v); } - if let Some(a) = p.architecture { + if let Some(ref a) = p.architecture { res.push('.'); - res.push_str(&a); + res.push_str(a); } res } -} - -impl LinuxPackageManager for YumPackageManager { - fn list_installed(&mut self) -> ResultOutput { - self.rpm.installed() - } fn full_upgrade(&mut self) -> ResultOutput<()> { // https://serverfault.com/a/1075175 @@ -58,13 +59,27 @@ impl LinuxPackageManager for YumPackageManager { ResultOutput::command(c).clear_ok() } - fn upgrade(&mut self, packages: Vec) -> ResultOutput<()> { + fn software_upgrade(&mut self, packages: &[PackageSpec]) -> ResultOutput<()> { let mut c = Command::new("yum"); c.arg("--assumeyes") .arg("update") - .args(packages.into_iter().map(Self::package_spec_as_argument)); + .args(packages.iter().map(Self::package_spec_as_argument)); ResultOutput::command(c).clear_ok() } +} + +impl LinuxPackageManager for YumPackageManager { + fn list_installed(&mut self) -> ResultOutput { + self.rpm.installed() + } + + fn upgrade(&mut self, update_type: &FullCampaignType) -> ResultOutput<()> { + match update_type { + FullCampaignType::SystemUpdate => self.full_upgrade(), + FullCampaignType::SecurityUpdate => self.security_upgrade(), + FullCampaignType::SoftwareUpdate(p) => self.software_upgrade(p), + } + } fn reboot_pending(&self) -> ResultOutput { // only report whether a reboot is required (exit code 1) or not (exit code 0) diff --git a/policies/module-types/system-updates/src/package_manager/zypper.rs b/policies/module-types/system-updates/src/package_manager/zypper.rs index 3f4a4c547e0..58b7e34c331 100644 --- a/policies/module-types/system-updates/src/package_manager/zypper.rs +++ b/policies/module-types/system-updates/src/package_manager/zypper.rs @@ -3,10 +3,15 @@ use std::process::Command; +use anyhow::Result; + use crate::{ + campaign::FullCampaignType, output::ResultOutput, package_manager::{rpm::RpmPackageManager, LinuxPackageManager, PackageList, PackageSpec}, }; +#[cfg(not(debug_assertions))] +use rudder_module_type::ensure_root_user; /// We need to be compatible with: /// @@ -18,37 +23,26 @@ pub struct ZypperPackageManager { } impl ZypperPackageManager { - pub fn new() -> Self { + pub fn new() -> Result { + #[cfg(not(debug_assertions))] + ensure_root_user()?; let rpm = RpmPackageManager::new(); - Self { rpm } + Ok(Self { rpm }) } - pub fn package_spec_as_argument(p: PackageSpec) -> String { - let mut res = p.name; + pub fn package_spec_as_argument(p: &PackageSpec) -> String { + let mut res = p.name.clone(); - if let Some(a) = p.architecture { + if let Some(ref a) = p.architecture { res.push('.'); - res.push_str(&a); + res.push_str(a); } - if let Some(v) = p.version { + if let Some(ref v) = p.version { res.push('='); - res.push_str(&v); + res.push_str(v); } res } -} - -impl LinuxPackageManager for ZypperPackageManager { - fn update_cache(&mut self) -> ResultOutput<()> { - let mut c = Command::new("zypper"); - c.arg("--non-interactive").arg("refresh"); - let res_update = ResultOutput::command(c); - res_update.clear_ok() - } - - fn list_installed(&mut self) -> ResultOutput { - self.rpm.installed() - } fn full_upgrade(&mut self) -> ResultOutput<()> { let mut c = Command::new("zypper"); @@ -67,14 +61,35 @@ impl LinuxPackageManager for ZypperPackageManager { res_update.clear_ok() } - fn upgrade(&mut self, packages: Vec) -> ResultOutput<()> { + fn software_upgrade(&mut self, packages: &[PackageSpec]) -> ResultOutput<()> { let mut c = Command::new("zypper"); c.arg("--non-interactive").arg("update"); - c.args(packages.into_iter().map(Self::package_spec_as_argument)); + c.args(packages.iter().map(Self::package_spec_as_argument)); + let res_update = ResultOutput::command(c); + res_update.clear_ok() + } +} + +impl LinuxPackageManager for ZypperPackageManager { + fn update_cache(&mut self) -> ResultOutput<()> { + let mut c = Command::new("zypper"); + c.arg("--non-interactive").arg("refresh"); let res_update = ResultOutput::command(c); res_update.clear_ok() } + fn list_installed(&mut self) -> ResultOutput { + self.rpm.installed() + } + + fn upgrade(&mut self, update_type: &FullCampaignType) -> ResultOutput<()> { + match update_type { + FullCampaignType::SystemUpdate => self.full_upgrade(), + FullCampaignType::SecurityUpdate => self.security_upgrade(), + FullCampaignType::SoftwareUpdate(p) => self.software_upgrade(p), + } + } + fn reboot_pending(&self) -> ResultOutput { let mut c = Command::new("zypper"); c.arg("ps").arg("-s"); diff --git a/policies/module-types/system-updates/src/packages.sql b/policies/module-types/system-updates/src/packages.sql index e61b3fb8598..06cfe0b80ec 100644 --- a/policies/module-types/system-updates/src/packages.sql +++ b/policies/module-types/system-updates/src/packages.sql @@ -11,7 +11,7 @@ create table if not exists update_events ( event_id text not null collate nocase unique, -- campaign name campaign_name text not null, - -- scheduled, running, pending-post-action, completed + -- scheduled, running, pending-post-action, running-post-action, completed status text not null, -- timestamps @@ -22,7 +22,11 @@ create table if not exists update_events ( report_datetime text, -- report sent to the server - report text -- json string + report text, -- json string + + -- process id of the current process handling the upgrade + -- when not null, acts as a lock + pid integer ); create index if not exists idx_event_id on update_events (event_id); diff --git a/policies/module-types/system-updates/src/runner.rs b/policies/module-types/system-updates/src/runner.rs new file mode 100644 index 00000000000..656bcc3a208 --- /dev/null +++ b/policies/module-types/system-updates/src/runner.rs @@ -0,0 +1,356 @@ +use crate::{ + campaign::{ + do_post_update, do_schedule, do_update, fail_campaign, FullSchedule, RunnerParameters, + }, + db::PackageDatabase, + package_manager::LinuxPackageManager, + scheduler, + state::UpdateStatus, + system::System, + RebootType, +}; +use anyhow::{bail, Result}; +use chrono::{DateTime, Utc}; +use rudder_module_type::Outcome; + +#[derive(PartialEq, Debug, Clone, Copy)] +enum Action { + Schedule(DateTime), + Update, + PostUpdate, +} + +#[derive(PartialEq, Debug, Clone)] +enum Continuation { + Continue, + Stop(Outcome), +} + +pub struct Runner { + db: PackageDatabase, + pm: Box, + parameters: RunnerParameters, + system: Box, + pid: u32, +} + +impl Runner { + pub(crate) fn new( + db: PackageDatabase, + pm: Box, + parameters: RunnerParameters, + system: Box, + pid: u32, + ) -> Self { + Self { + db, + pm, + parameters, + system, + pid, + } + } + + fn update_one_step( + &mut self, + state: Option, + ) -> Result<(Continuation, UpdateStatus)> { + let maybe_action = self.decide(state)?; + match maybe_action { + None => Ok((Continuation::Stop(Outcome::Success(None)), state.unwrap())), + Some(action) => { + // TODO add method to handle dead process + let current_pid = self.db.lock(self.pid, &self.parameters.event_id)?; + if current_pid.is_some() { + // Could not acquire lock => no action + return Ok((Continuation::Stop(Outcome::Success(None)), state.unwrap())); + } + let cont = self.execute(action)?; + self.db.unlock(&self.parameters.event_id)?; + let new_state = self.db.get_status(&self.parameters.event_id)?; + Ok((cont, new_state.unwrap())) + } + } + } + + fn execute(&mut self, action: Action) -> Result { + match action { + Action::Schedule(datetime) => { + do_schedule(&self.parameters, &mut self.db, datetime)?; + Ok(Continuation::Continue) + } + Action::Update => { + let reboot_needed = + do_update(&self.parameters, &mut self.db, &mut self.pm, &self.system)?; + + if self.parameters.reboot_type == RebootType::Always + || (self.parameters.reboot_type == RebootType::AsNeeded && reboot_needed) + { + // Async reboot + let result = self.system.reboot(); + match result.inner { + Ok(_) => Ok(Continuation::Stop(Outcome::Success(None))), + Err(e) => bail!("Reboot failed: {:?}", e), + } + } else { + Ok(Continuation::Continue) + } + } + Action::PostUpdate => { + self.db.post_event(&self.parameters.event_id)?; + let outcome = do_post_update(&self.parameters, &mut self.db)?; + Ok(Continuation::Stop(outcome)) + } + } + } + + fn decide(&mut self, current_status: Option) -> Result> { + let now = Utc::now(); + let schedule_datetime = match self.parameters.schedule { + FullSchedule::Immediate => now, + FullSchedule::Scheduled(ref s) => { + scheduler::splayed_start(s.start, s.end, s.agent_frequency, s.node_id.as_str())? + } + }; + + match current_status { + None => Ok(Some(Action::Schedule(schedule_datetime))), + Some(s) => match s { + UpdateStatus::ScheduledUpdate => { + if now >= schedule_datetime { + Ok(Some(Action::Update)) + } else { + Ok(None) + } + } + UpdateStatus::PendingPostActions => Ok(Some(Action::PostUpdate)), + _ => Ok(None), + }, + } + } + + /// Entry point + pub fn run(&mut self) -> Result { + loop { + let status = self.db.get_status(&self.parameters.event_id)?; + let res = self.update_one_step(status); + match res { + Ok((Continuation::Continue, _)) => {} + Ok((Continuation::Stop(o), _)) => { + return Ok(o); + } + Err(e) => { + // Send the report to server + fail_campaign(&format!("{:?}", e), self.parameters.report_file.as_ref())?; + return Err(e); + } + } + } + } +} +#[cfg(test)] +mod tests { + use crate::{ + campaign::{FullCampaignType, FullSchedule, RunnerParameters}, + db::PackageDatabase, + output::ResultOutput, + package_manager::{LinuxPackageManager, PackageList}, + runner::{Action, Runner}, + state::UpdateStatus, + system::System, + RebootType, Schedule, ScheduleParameters, + }; + use chrono::{Duration, Utc}; + use pretty_assertions::assert_eq; + use rudder_module_type::Outcome; + use std::collections::HashMap; + + struct MockSystem {} + + impl System for MockSystem { + fn reboot(&self) -> ResultOutput<()> { + ResultOutput::new(Ok(())) + } + + fn restart_services(&self, _services: &[String]) -> ResultOutput<()> { + ResultOutput::new(Ok(())) + } + } + + #[derive(Default, Clone, Copy)] + struct MockPackageManager { + reboot_needed: bool, + } + + impl LinuxPackageManager for MockPackageManager { + fn list_installed(&mut self) -> ResultOutput { + ResultOutput::new(Ok(PackageList::new(HashMap::new()))) + } + + fn upgrade(&mut self, _update_type: &FullCampaignType) -> ResultOutput<()> { + ResultOutput::new(Ok(())) + } + + fn reboot_pending(&self) -> ResultOutput { + ResultOutput::new(Ok(self.reboot_needed)) + } + + fn services_to_restart(&self) -> ResultOutput> { + ResultOutput::new(Ok(vec![])) + } + } + + fn mock_package_manager(reboot_needed: bool) -> MockPackageManager { + MockPackageManager { reboot_needed } + } + + fn in_memory_package_db() -> PackageDatabase { + PackageDatabase::new(None).unwrap() + } + + fn test_runner(should_reboot: bool, p: RunnerParameters) -> Runner { + Runner::new( + in_memory_package_db(), + Box::new(mock_package_manager(should_reboot)), + p, + Box::new(MockSystem {}), + 0, + ) + } + + fn default_runner_parameters() -> RunnerParameters { + RunnerParameters { + campaign_type: FullCampaignType::SystemUpdate, + event_id: "".to_string(), + campaign_name: "".to_string(), + schedule: FullSchedule::Immediate, + reboot_type: RebootType::Disabled, + report_file: None, + schedule_file: None, + } + } + + fn future_schedule() -> FullSchedule { + let start = Utc::now() + Duration::days(10); + let end = start + Duration::days(1); + let schedule = Schedule::Scheduled(ScheduleParameters { start, end }); + FullSchedule::new(&schedule, "id".to_string(), Duration::minutes(5)) + } + + #[test] + pub fn initial_state_with_immediate_schedule_should_return_schedule() { + let parameters = default_runner_parameters(); + match test_runner(false, parameters).decide(None).unwrap() { + Some(Action::Schedule(_)) => {} + _ => panic!(), + } + } + + #[test] + pub fn initial_state_with_future_schedule_should_return_schedule() { + let parameters = RunnerParameters { + schedule: future_schedule(), + ..default_runner_parameters() + }; + match test_runner(false, parameters).decide(None).unwrap() { + Some(Action::Schedule(_)) => {} + _ => panic!(), + } + } + + #[test] + pub fn scheduled_update_state_with_immediate_schedule_should_return_update() { + let parameters = default_runner_parameters(); + assert_eq!( + test_runner(false, parameters) + .decide(Some(UpdateStatus::ScheduledUpdate)) + .unwrap(), + Some(Action::Update) + ); + } + + #[test] + pub fn scheduled_update_state_with_future_schedule_should_return_no_action() { + let parameters = RunnerParameters { + schedule: future_schedule(), + ..default_runner_parameters() + }; + + assert_eq!( + test_runner(false, parameters) + .decide(Some(UpdateStatus::ScheduledUpdate)) + .unwrap(), + None + ); + } + + #[test] + pub fn update_loop_with_immediate_schedule_should_complete() { + let parameters = default_runner_parameters(); + assert_eq!( + test_runner(false, parameters).run().unwrap(), + Outcome::Repaired("Update has run".to_string()) + ); + } + + #[test] + pub fn update_loop_with_immediate_schedule_and_reboot_should_stop_on_pending_post_actions() { + let parameters = RunnerParameters { + reboot_type: RebootType::Always, + ..default_runner_parameters() + }; + assert_eq!( + test_runner(false, parameters).run().unwrap(), + Outcome::Success(None) + ); + } + + #[test] + pub fn update_loop_with_immediate_schedule_and_reboot_as_needed_should_stop_on_pending_post_actions( + ) { + let parameters = RunnerParameters { + reboot_type: RebootType::AsNeeded, + ..default_runner_parameters() + }; + assert_eq!( + test_runner(true, parameters).run().unwrap(), + Outcome::Success(None) + ); + } + + #[test] + pub fn update_loop_with_future_schedule_should_stay_in_scheduled_update() { + let parameters = RunnerParameters { + schedule: future_schedule(), + ..default_runner_parameters() + }; + assert_eq!( + test_runner(false, parameters).run().unwrap(), + Outcome::Success(None) + ); + } + + #[test] + pub fn update_loop_with_immediate_schedule_and_reboot_called_twice_should_complete() { + let parameters = RunnerParameters { + reboot_type: RebootType::Always, + ..default_runner_parameters() + }; + let mut scheduler = test_runner(false, parameters); + scheduler.run().unwrap(); + let actual = scheduler.run().unwrap(); + assert_eq!(actual, Outcome::Repaired("Update has run".to_string())); + } + + #[test] + pub fn update_loop_with_immediate_schedule_and_reboot_as_needed_called_twice_should_complete() { + let parameters = RunnerParameters { + reboot_type: RebootType::AsNeeded, + ..default_runner_parameters() + }; + let mut scheduler = test_runner(true, parameters); + scheduler.run().unwrap(); + let actual = scheduler.run().unwrap(); + assert_eq!(actual, Outcome::Repaired("Update has run".to_string())); + } +} diff --git a/policies/module-types/system-updates/src/state.rs b/policies/module-types/system-updates/src/state.rs index 25dbd4a86d5..b7a6f3b5434 100644 --- a/policies/module-types/system-updates/src/state.rs +++ b/policies/module-types/system-updates/src/state.rs @@ -11,19 +11,26 @@ use std::{ /// in case of interruption. #[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] pub enum UpdateStatus { - Scheduled, - Running, + /// Wait until schedule + ScheduledUpdate, + /// Schedule is reached, start update and reboot if necessary + RunningUpdate, + /// Update is over, waiting for next agent run for post-actions in case of a reboot PendingPostActions, + /// Running post-actions and report + RunningPostActions, + /// Nothing to do Completed, } impl Display for UpdateStatus { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { f.write_str(match self { - Self::Scheduled => "scheduled", - Self::Running => "running", + Self::ScheduledUpdate => "scheduled", + Self::RunningUpdate => "running", Self::Completed => "completed", Self::PendingPostActions => "pending-post-actions", + Self::RunningPostActions => "running-post-actions", }) } } @@ -33,10 +40,11 @@ impl FromStr for UpdateStatus { fn from_str(s: &str) -> anyhow::Result { match s { - "running" => Ok(Self::Running), - "scheduled" => Ok(Self::Scheduled), + "running" => Ok(Self::RunningUpdate), + "scheduled" => Ok(Self::ScheduledUpdate), "completed" => Ok(Self::Completed), "pending-post-actions" => Ok(Self::PendingPostActions), + "running-post-actions" => Ok(Self::RunningPostActions), _ => Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, "Invalid input", diff --git a/policies/module-types/system-updates/src/system.rs b/policies/module-types/system-updates/src/system.rs index a884c96f2bd..6d0638b384a 100644 --- a/policies/module-types/system-updates/src/system.rs +++ b/policies/module-types/system-updates/src/system.rs @@ -4,23 +4,35 @@ use std::process::Command; use crate::output::ResultOutput; -// We have systemd everywhere. -pub struct System {} +pub trait System { + fn reboot(&self) -> ResultOutput<()>; + fn restart_services(&self, services: &[String]) -> ResultOutput<()>; +} + +/// We have systemd everywhere. +pub struct Systemd {} + +impl Default for Systemd { + fn default() -> Self { + Self::new() + } +} -impl System { +impl Systemd { pub fn new() -> Self { Self {} } +} - // Maybe add a delay before rebooting? - pub fn reboot(&self) -> ResultOutput<()> { +impl System for Systemd { + fn reboot(&self) -> ResultOutput<()> { let mut c = Command::new("systemctl"); c.arg("reboot"); ResultOutput::command(c).clear_ok() } - pub fn restart_services(&self, services: &[String]) -> ResultOutput<()> { + fn restart_services(&self, services: &[String]) -> ResultOutput<()> { // Make sure the units are up-to-date let mut c = Command::new("systemctl"); c.arg("daemon-reload"); diff --git a/policies/module-types/system-updates/tests/parameters/immediate.json b/policies/module-types/system-updates/tests/parameters/immediate.json index 57722db5673..e70c6927eeb 100644 --- a/policies/module-types/system-updates/tests/parameters/immediate.json +++ b/policies/module-types/system-updates/tests/parameters/immediate.json @@ -5,7 +5,7 @@ "event_id": "30c87fee-f6b0-42b1-9adb-be067217f1a9", "campaign_name": "My campaign", "schedule": "immediate", - "reboot_type": "as_needed" + "reboot_type": "as-needed" }, "node_id": "node", "agent_frequency_minutes": 5, diff --git a/policies/module-types/system-updates/tests/parameters/scheduled.json b/policies/module-types/system-updates/tests/parameters/scheduled.json index 4281f92e79f..82fac4741a2 100644 --- a/policies/module-types/system-updates/tests/parameters/scheduled.json +++ b/policies/module-types/system-updates/tests/parameters/scheduled.json @@ -10,16 +10,16 @@ "end": "2023-01-01T00:00:00Z" } }, - "reboot_type": "as_needed", + "reboot_type": "as-needed", "package_list": [ - { - "name": "apache2", - "version": "2.4.29-1ubuntu4.14" - }, - { - "name": "nginx", - "version": "1.14.0-0ubuntu1.7" - } + { + "name": "apache2", + "version": "2.4.29-1ubuntu4.14" + }, + { + "name": "nginx", + "version": "1.14.0-0ubuntu1.7" + } ], "report_file": "/tmp/report.json", "schedule_file": "/tmp/schedule.json" diff --git a/policies/rudder-module-type/src/lib.rs b/policies/rudder-module-type/src/lib.rs index 37ea3f170f5..83bdbaf4b2d 100644 --- a/policies/rudder-module-type/src/lib.rs +++ b/policies/rudder-module-type/src/lib.rs @@ -13,6 +13,7 @@ use serde::{Deserialize, Serialize}; use crate::{cfengine::CfengineRunner, parameters::Parameters}; pub mod cfengine; +pub mod os_release; pub mod parameters; /// Information about the module type to pass to the library @@ -242,6 +243,19 @@ pub mod inventory { } } +pub fn ensure_root_user() -> Result<()> { + use anyhow::bail; + use std::os::unix::fs::MetadataExt; + + let uid = std::fs::metadata("/proc/self") + .map(|m| m.uid()) + .unwrap_or(0); + if uid != 0 { + bail!("This program needs to run as root, aborting."); + } + Ok(()) +} + #[cfg(feature = "backup")] pub mod backup { //! Helper to produce Rudder-compatible backup files diff --git a/policies/rudder-module-type/src/os_release.rs b/policies/rudder-module-type/src/os_release.rs new file mode 100644 index 00000000000..2b939366bba --- /dev/null +++ b/policies/rudder-module-type/src/os_release.rs @@ -0,0 +1,267 @@ +//! Type for parsing the `/etc/os-release` file. +//! Only provide data useful in the Rudder context to identify an operating system. +//! +//! We don't provide enums and stay as agnostic as possible. Each module works at the most generic +//! level possible. +//! +//! For a broad list of sample `/etc/os-release` files, see +//! https://github.com/chef/os_release. +//! +//! For the specs, see: +//! https://www.freedesktop.org/software/systemd/man/latest/os-release.html + +use std::{ + fs::File, + io::{self, BufRead, BufReader}, + iter::FromIterator, + path::Path, +}; + +// Adapted from https://github.com/pop-os/os-release/tree/master +// By System76, under MIT license + +fn is_enclosed_with(line: &str, pattern: char) -> bool { + line.starts_with(pattern) && line.ends_with(pattern) +} + +fn unquote(line: &str) -> &str { + if is_enclosed_with(line, '"') || is_enclosed_with(line, '\'') { + &line[1..line.len() - 1] + } else { + line + } +} + +/// Contents of the `/etc/os-release` file, as a data structure. +#[derive(Clone, Debug, PartialEq)] +pub struct OsRelease { + /// A space-separated list of operating system identifiers in the same syntax as the ID= setting. It should list identifiers of operating systems that are closely related to the local operating system in regards to packaging and programming interfaces, for example listing one or more OS identifiers the local OS is a derivative from. An OS should generally only list other OS identifiers it itself is a derivative of, and not any OSes that are derived from it, though symmetric relationships are possible. Build scripts and similar should check this variable if they need to identify the local operating system and the value of ID= is not recognized. Operating systems should be listed in order of how closely the local operating system relates to the listed ones, starting with the closest. This field is optional. + /// + /// Examples: for an operating system with "ID=centos", an assignment of "ID_LIKE="rhel fedora"" would be appropriate. For an operating system with "ID=ubuntu", an assignment of "ID_LIKE=debian" is appropriate. + pub id_like: Vec, + /// A lower-case string (no spaces or other characters outside of 0–9, a–z, ".", "_" and "-") identifying the operating system, excluding any version information and suitable for processing by scripts or usage in generated filenames. If not set, a default of "ID=linux" may be used. Note that even though this string may not include characters that require shell quoting, quoting may nevertheless be used. + /// + /// Examples: "ID=fedora", "ID=debian". + pub id: String, + /// A string identifying the operating system, without a version component, and suitable for presentation to the user. If not set, a default of "NAME=Linux" may be used. + /// + /// Examples: "NAME=Fedora", "NAME="Debian GNU/Linux"". + pub name: String, + /// A pretty operating system name in a format suitable for presentation to the user. May or may not contain a release code name or OS version of some kind, as suitable. If not set, a default of "PRETTY_NAME="Linux"" may be used + /// + /// Example: "PRETTY_NAME="Fedora 17 (Beefy Miracle)"". + pub pretty_name: String, + /// A lower-case string (no spaces or other characters outside of 0–9, a–z, ".", "_" and "-") identifying the operating system release code name, excluding any OS name information or release version, and suitable for processing by scripts or usage in generated filenames. This field is optional and may not be implemented on all systems. + /// + /// Examples: "VERSION_CODENAME=buster", "VERSION_CODENAME=xenial". + pub version_codename: Option, + /// A lower-case string (mostly numeric, no spaces or other characters outside of 0–9, a–z, ".", "_" and "-") identifying the operating system version, excluding any OS name information or release code name, and suitable for processing by scripts or usage in generated filenames. This field is optional. + /// + /// Examples: "VERSION_ID=17", "VERSION_ID=11.04". + pub version_id: Option, + /// A string identifying the operating system version, excluding any OS name information, possibly including a release code name, and suitable for presentation to the user. This field is optional. + /// + /// Examples: "VERSION=17", "VERSION="17 (Beefy Miracle)"". + pub version: Option, +} + +impl Default for OsRelease { + #[cfg(target_os = "linux")] + fn default() -> Self { + Self { + id_like: vec![], + id: "linux".to_string(), + name: "Linux".to_string(), + pretty_name: "Linux".to_string(), + version_codename: None, + version_id: None, + version: None, + } + } + + #[cfg(not(target_os = "linux"))] + fn default() -> Self { + unimplemented!() + } +} + +impl OsRelease { + /// Attempt to parse the contents of `/etc/os-release`, with a fallback to `/usr/lib/os-release`. + /// + /// The specs say: + /// + /// > The file `/etc/os-release` takes precedence over `/usr/lib/os-release`. + /// > Applications should check for the former, and exclusively use its data if it exists, + /// > and only fall back to `/usr/lib/os-release` if it is missing. Applications should not + /// > read data from both files at the same time. `/usr/lib/os-release` is the recommended place + /// > to store OS release information as part of vendor trees. `/etc/os-release` should be a + /// > relative symlink to `/usr/lib/os-release`, to provide compatibility with applications only + /// > looking at `/etc/`. A relative symlink instead of an absolute symlink is necessary to avoid + /// > breaking the link in a chroot or initrd environment. + pub fn new() -> io::Result { + let etc = Path::new("/etc/os-release"); + let usr = Path::new("/usr/lib/os-release"); + let file = match (etc.exists(), usr.exists()) { + (true, _) => Some(etc), + (false, true) => Some(usr), + _ => None, + }; + if let Some(path) = file { + let file = BufReader::new(File::open(path)?); + Ok(OsRelease::from_iter(file.lines().map_while(Result::ok))) + } else { + Ok(OsRelease::default()) + } + } + + pub fn from_string(s: &str) -> OsRelease { + OsRelease::from_iter(s.lines().map(|s| s.to_string())) + } +} + +impl FromIterator for OsRelease { + /// > os-release must not contain repeating keys. Nevertheless, readers should pick the entries + /// > later in the file in case of repeats, similarly to how a shell sourcing the file would. + /// > A reader may warn about repeating entries. + fn from_iter>(lines: I) -> Self { + let mut os_release = Self::default(); + + for line in lines { + // > The format of os-release is a newline-separated list of environment-like shell-compatible + // > variable assignments. It is possible to source the configuration from Bourne shell scripts, + // > however, beyond mere variable assignments, no shell features are supported (this means variable + // > expansion is explicitly not supported), allowing applications to read the file without + // > implementing a shell compatible execution engine. Variable assignment values must be enclosed + // > in double or single quotes if they include spaces, semicolons or other special characters + // > outside of A–Z, a–z, 0–9. (Assignments that do not include these special characters may be + // > enclosed in quotes too, but this is optional.) Shell special characters + // > ("$", quotes, backslash, backtick) must be escaped with backslashes, following shell style. + // > All strings should be in UTF-8 encoding, and non-printable characters should not be used. + // > Concatenation of multiple individually quoted strings is not supported. Lines beginning + // > with "#" are treated as comments. Blank lines are permitted and ignored. + if let Some((k, v)) = line.split_once('=') { + let k = k.trim(); + // TODO: unescape special shell chars + let v = unquote(v.trim()); + + match k { + "NAME" => os_release.name = v.to_string(), + "VERSION" => os_release.version = Some(v.to_string()), + "ID" => os_release.id = v.to_string(), + "PRETTY_NAME" => os_release.pretty_name = v.to_string(), + "VERSION_ID" => os_release.version_id = Some(v.to_string()), + "VERSION_CODENAME" => os_release.version_codename = Some(v.to_string()), + "ID_LIKE" => { + os_release.id_like = v.split_whitespace().map(String::from).collect() + } + _ => continue, + } + } + } + os_release + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ROCKY: &str = r#"NAME="Rocky Linux" +VERSION="9.1 (Blue Onyx)" +ID="rocky" +ID_LIKE="rhel centos fedora" +VERSION_ID="9.1" +PLATFORM_ID="platform:el9" +PRETTY_NAME="Rocky Linux 9.1 (Blue Onyx)" +ANSI_COLOR="0;32" +LOGO="fedora-logo-icon" +CPE_NAME="cpe:/o:rocky:rocky:9::baseos" +HOME_URL="https://rockylinux.org/" +BUG_REPORT_URL="https://bugs.rockylinux.org/" +ROCKY_SUPPORT_PRODUCT="Rocky-Linux-9" +ROCKY_SUPPORT_PRODUCT_VERSION="9.1" +REDHAT_SUPPORT_PRODUCT="Rocky Linux" +REDHAT_SUPPORT_PRODUCT_VERSION="9.1" +"#; + + const POPOS: &str = r#"NAME="Pop!_OS" +VERSION="18.04 LTS" +ID=ubuntu +ID_LIKE=debian +PRETTY_NAME="Pop!_OS 18.04 LTS" +VERSION_ID="18.04" +HOME_URL="https://system76.com/pop" +SUPPORT_URL="http://support.system76.com" +BUG_REPORT_URL="https://github.com/pop-os/pop/issues" +PRIVACY_POLICY_URL="https://system76.com/privacy" +VERSION_CODENAME=bionic +EXTRA_KEY=thing +ANOTHER_KEY="#; + + #[test] + fn it_parses_os_release_on_current_system() { + assert!(OsRelease::new().is_ok()); + } + + #[test] + fn it_parses_empty_os_release() { + let rocky = OsRelease::from_string(""); + assert_eq!( + rocky, + OsRelease { + name: "Linux".into(), + version: None, + id: "linux".into(), + id_like: vec![], + pretty_name: "Linux".into(), + version_id: None, + version_codename: None, + } + ); + } + + #[test] + fn it_parses_rocky_os_release() { + let rocky = OsRelease::from_string(ROCKY); + assert_eq!( + rocky, + OsRelease { + name: "Rocky Linux".into(), + version: Some("9.1 (Blue Onyx)".into()), + id: "rocky".into(), + id_like: vec![ + "rhel".to_string(), + "centos".to_string(), + "fedora".to_string() + ], + pretty_name: "Rocky Linux 9.1 (Blue Onyx)".into(), + version_id: Some("9.1".into()), + version_codename: None, + } + ); + } + + #[test] + fn it_parses_popos_os_release() { + let ubuntu = OsRelease::from_string(POPOS); + assert_eq!( + ubuntu, + OsRelease { + name: "Pop!_OS".into(), + version: Some("18.04 LTS".into()), + id: "ubuntu".into(), + id_like: vec!["debian".into()], + pretty_name: "Pop!_OS 18.04 LTS".into(), + version_id: Some("18.04".into()), + version_codename: Some("bionic".into()), + } + ); + } + + #[test] + fn it_overrides_values() { + let overridden = "ID=ubuntu\nID=debian"; + let debian = OsRelease::from_string(overridden); + assert_eq!(debian.id, "debian".to_string(),); + } +} diff --git a/policies/rudderc/src/technique.schema.json b/policies/rudderc/src/technique.schema.json index f5fa4676c37..631e7d809ee 100644 --- a/policies/rudderc/src/technique.schema.json +++ b/policies/rudderc/src/technique.schema.json @@ -522,4 +522,4 @@ } } } -} +} \ No newline at end of file diff --git a/policies/rudderc/tests/cases/general/min/technique.yml b/policies/rudderc/tests/cases/general/min/technique.yml index 750a7197199..794edbce4d1 100644 --- a/policies/rudderc/tests/cases/general/min/technique.yml +++ b/policies/rudderc/tests/cases/general/min/technique.yml @@ -7,4 +7,4 @@ items: method: package_present params: name: "htop" - version: "2.3.4" \ No newline at end of file + version: "2.3.4"