From e78120e53bb38c0e268ccf3811082540bb7bb96c Mon Sep 17 00:00:00 2001 From: Fabio Forni Date: Fri, 10 May 2024 15:43:04 +0200 Subject: [PATCH 1/4] crates/dag: Add Dag::with_capacity() constructor --- crates/dag/src/lib.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/dag/src/lib.rs b/crates/dag/src/lib.rs index 35f75af4..12193e4a 100644 --- a/crates/dag/src/lib.rs +++ b/crates/dag/src/lib.rs @@ -34,6 +34,10 @@ where Self::default() } + pub fn with_capacity(nodes: usize, edges: usize) -> Self { + Self(DiGraph::with_capacity(nodes, edges)) + } + /// Adds node N to the graph and returns the index. /// If N already exists, it'll return the index of that node. pub fn add_node_or_get_index(&mut self, node: N) -> NodeIndex { From 8a22a63b8eb2e547abf41511975a20779e2f98c3 Mon Sep 17 00:00:00 2001 From: Fabio Forni Date: Fri, 10 May 2024 15:45:26 +0200 Subject: [PATCH 2/4] crates/fnmatch: Allow to export Pattern as a regular glob pattern --- crates/fnmatch/src/pattern.rs | 32 +++++++++++++++++++++++++++++++- crates/fnmatch/src/token.rs | 24 ++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/crates/fnmatch/src/pattern.rs b/crates/fnmatch/src/pattern.rs index f01f5e0e..ee3776d0 100644 --- a/crates/fnmatch/src/pattern.rs +++ b/crates/fnmatch/src/pattern.rs @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: MPL-2.0 +use std::fmt; use std::{collections::HashMap, convert, path::MAIN_SEPARATOR, str::FromStr}; use serde::de; @@ -18,7 +19,6 @@ use crate::token::{tokens, Matcher, Token}; /// supported matchers. /// /// The matchers and the the characters used in the group syntax can be escaped with a backslash ("\\"). -/// #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub struct Pattern { tokens: Vec, @@ -42,6 +42,16 @@ impl<'de> de::Deserialize<'de> for Pattern { } } +impl fmt::Display for Pattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut string = String::new(); + for token in &self.tokens { + string.push_str(&token.to_string()); + } + write!(f, "{string}") + } +} + impl Pattern { /// Creates a new Pattern. Equivalent to `s.parse::()`. pub fn new(s: impl AsRef) -> Self { @@ -79,6 +89,26 @@ impl Pattern { matc.path = path.as_ref().to_string(); Some(matc) } + + /// Returns a String representation of this Pattern suitable for the [`glob`] crate. + pub fn to_std_glob(&self) -> String { + let mut glob_str = String::new(); + for tok in &self.tokens { + match tok { + Token::Text(txt) => { + glob_str.push_str(txt); + } + Token::Glob { name: _, matcher } => { + let wildcard = match matcher { + Matcher::One => "?", + Matcher::Any => "*", + }; + glob_str.push_str(wildcard); + } + } + } + glob_str + } } /// Path match for a [Pattern]. diff --git a/crates/fnmatch/src/token.rs b/crates/fnmatch/src/token.rs index 55b5e892..cfdc2191 100644 --- a/crates/fnmatch/src/token.rs +++ b/crates/fnmatch/src/token.rs @@ -17,6 +17,21 @@ pub enum Token { Glob { name: Option, matcher: Matcher }, } +impl fmt::Display for Token { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Token::Text(txt) => write!(f, "{txt}"), + Token::Glob { name, matcher } => { + if let Some(name) = name { + write!(f, "({name}:{matcher})") + } else { + write!(f, "{matcher}") + } + } + } + } +} + /// Types of globs. #[derive(Clone, Debug, Eq, Ord, PartialEq, PartialOrd)] pub enum Matcher { @@ -36,6 +51,15 @@ impl From<&RawToken> for Matcher { } } +impl fmt::Display for Matcher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Matcher::One => write!(f, "?"), + Matcher::Any => write!(f, "*"), + } + } +} + /// Parses a globbed pattern string into its components. pub fn tokens(pattern: impl AsRef) -> Vec { let mut tokens = Vec::new(); From 44f30ba47ca118af89efbf1366b3ba3ea8ee94af Mon Sep 17 00:00:00 2001 From: Fabio Forni Date: Fri, 10 May 2024 15:46:41 +0200 Subject: [PATCH 3/4] crates/triggers: Add decoupled API to make it suitable as a library --- crates/triggers/src/format.rs | 144 +++------ crates/triggers/src/iterpaths.rs | 13 + crates/triggers/src/lib.rs | 485 +++++++++++++++++++++++++------ test/trigger.yml | 33 --- test/trigger_valid.yml | 37 +++ 5 files changed, 493 insertions(+), 219 deletions(-) create mode 100644 crates/triggers/src/iterpaths.rs delete mode 100644 test/trigger.yml create mode 100644 test/trigger_valid.yml diff --git a/crates/triggers/src/format.rs b/crates/triggers/src/format.rs index 72c81552..4bbb92ac 100644 --- a/crates/triggers/src/format.rs +++ b/crates/triggers/src/format.rs @@ -3,116 +3,52 @@ // SPDX-License-Identifier: MPL-2.0 use std::collections::BTreeMap; +use std::path::PathBuf; -use fnmatch::Pattern; -use serde::Deserialize; +use serde::{Deserialize, Deserializer}; -/// Filter matched paths to a specific kind -#[derive(Debug, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum PathKind { - Directory, - Symlink, -} - -/// Execution handlers for a trigger -#[derive(Debug, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] -#[serde(untagged)] -pub enum Handler { - Run { run: String, args: Vec }, - Delete { delete: Vec }, -} +use crate::{FileKind, Inhibitor, OsEnv, Pattern}; -#[derive(Debug, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct CompiledHandler(Handler); - -impl CompiledHandler { - pub fn handler(&self) -> &Handler { - &self.0 +/// Deserializes the "inhibitors" field of a [`Trigger`]. +pub fn deserialize_inhibitors<'de, D: Deserializer<'de>>(deserializer: D) -> Result, D::Error> { + #[derive(Default, Deserialize)] + struct Inhibitors { + pub paths: Vec, + pub environment: Vec, } -} -impl Handler { - /// Substitute all paths using matched variables - pub fn compiled(&self, with_match: &fnmatch::Match) -> CompiledHandler { - match self { - Handler::Run { run, args } => { - let mut run = run.clone(); - for (key, value) in &with_match.groups { - run = run.replace(&format!("$({key})"), value); - } - let args = args - .iter() - .map(|a| { - let mut a = a.clone(); - for (key, value) in &with_match.groups { - a = a.replace(&format!("$({key})"), value); - } - a - }) - .collect(); - CompiledHandler(Handler::Run { run, args }) - } - Handler::Delete { delete } => CompiledHandler(Handler::Delete { delete: delete.clone() }), - } + let de = Inhibitors::deserialize(deserializer)?; + let mut inhibitors = vec![]; + for path in de.paths { + inhibitors.push(Inhibitor::Path(path)); + } + for env in de.environment { + inhibitors.push(Inhibitor::Environment(env)); + } + Ok(inhibitors) +} + +/// Deserializes the "paths" field of a [`Trigger`]. +pub fn deserialize_patterns<'de, D: Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + #[derive(Deserialize)] + struct PathDefinition { + pub handlers: Vec, + #[serde(rename = "type")] + pub kind: Option, } -} - -/// Inhibitors prevent handlers from running based on some constraints -#[derive(Debug, Deserialize)] -pub struct Inhibitors { - pub paths: Vec, - pub environment: Vec, -} - -/// Map handlers to a path pattern and kind filter -#[derive(Debug, Deserialize)] -pub struct PathDefinition { - pub handlers: Vec, - #[serde(rename = "type")] - pub kind: Option, -} - -/// Serialization format of triggers -#[derive(Debug, Deserialize)] -pub struct Trigger { - /// Unique (global scope) identifier - pub name: String, - - /// User friendly description - pub description: String, - - /// Run before this trigger name - pub before: Option, - - /// Run after this trigger name - pub after: Option, - - /// Optional inhibitors - pub inhibitors: Option, - - /// Map glob / patterns to their configuration - pub paths: BTreeMap, - - /// Named handlers within this trigger scope - pub handlers: BTreeMap, -} - -#[cfg(test)] -mod tests { - use crate::format::Trigger; - - #[test] - fn test_trigger_file() { - let trigger: Trigger = serde_yaml::from_str(include_str!("../../../test/trigger.yml")).unwrap(); - let (pattern, _) = trigger.paths.iter().next().expect("Missing path entry"); - let result = pattern - .matches("/usr/lib/modules/6.6.7-267.current/kernel") - .expect("Couldn't match path"); - let version = result.groups.get("version").expect("Missing kernel version"); - assert_eq!(version, "6.6.7-267.current", "Wrong kernel version match"); - eprintln!("trigger: {trigger:?}"); - eprintln!("match: {result:?}"); + let de = BTreeMap::::deserialize(deserializer)?; + let mut paths = BTreeMap::new(); + for (pattern, path_definition) in de { + paths.insert( + Pattern { + kind: path_definition.kind, + pattern, + }, + path_definition.handlers, + ); } + Ok(paths) } diff --git a/crates/triggers/src/iterpaths.rs b/crates/triggers/src/iterpaths.rs new file mode 100644 index 00000000..fad7e5ea --- /dev/null +++ b/crates/triggers/src/iterpaths.rs @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Copyright © 2020-2024 Serpent OS Developers +// +// SPDX-License-Identifier: MPL-2.0 + +//! Utility functions that accept multiple file paths. + +use std::collections::BTreeSet; + +use crate::{CompiledHandler, Trigger}; + +pub fn compiled_handlers(trigger: &Trigger, paths: impl Iterator) -> BTreeSet { + paths.flat_map(|path| trigger.compiled_handlers(path)).collect() +} diff --git a/crates/triggers/src/lib.rs b/crates/triggers/src/lib.rs index 75164b5d..2fcbbd1e 100644 --- a/crates/triggers/src/lib.rs +++ b/crates/triggers/src/lib.rs @@ -4,125 +4,446 @@ //! System trigger management facilities -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, HashMap}; +use std::fmt; +use std::fs; +use std::io; +use std::os::linux::fs::MetadataExt; +use std::path::{self, PathBuf}; +use std::process; -use format::Trigger; +use fnmatch::Match; +use serde::{de, Deserialize, Deserializer}; use thiserror::Error; -pub mod format; +mod format; +pub mod iterpaths; -/// Grouped management of a set of triggers -pub struct Collection<'a> { - handlers: Vec, - triggers: BTreeMap, - hits: BTreeMap>, +/// Collection of thematic operations to perform on certain file paths. +#[derive(Debug, Deserialize)] +#[serde(remote = "Self")] +pub struct Trigger { + /// **Unique** identifier of this trigger. + pub name: String, + + /// Optional trigger name that must be run before this trigger. + /// This helps to build a dependency chain of triggers. + pub before: Option, + + /// Optional trigger name that must be run after this trigger. + /// This helps to build a dependency chain of triggers. + pub after: Option, + + /// Optional inhibitors that prevent this trigger to run + /// under certain conditions. + #[serde(default, deserialize_with = "format::deserialize_inhibitors")] + pub inhibitors: Vec, + + /// File path patterns involved with this trigger. + /// Each pattern is associated to a list of handler names + /// that will perform operations on, or with, it. + /// + /// Use [`Self::handlers_by_pattern`] to resolve handler names and get + /// a list of [`Handler`]s for each [`Pattern`]. + #[serde(rename = "paths", deserialize_with = "format::deserialize_patterns")] + pub patterns: BTreeMap>, + + /// Name-value pairs of handlers composing this trigger. + pub handlers: BTreeMap, } -#[derive(Debug)] -struct ExtractedHandler { - id: String, - pattern: fnmatch::Pattern, - handler: format::Handler, +impl<'de> Deserialize<'de> for Trigger { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let trigger = Self::deserialize(deserializer)?; + for handler_name in trigger.patterns.values().flatten() { + if !trigger.handlers.contains_key(handler_name) { + return Err(de::Error::custom(Error::MissingHandler( + trigger.name, + handler_name.to_string(), + ))); + } + } + Ok(trigger) + } } -#[derive(Debug, Error)] -pub enum Error { - #[error("missing handler reference in {0}: {1}")] - MissingHandler(String, String), +impl PartialEq for Trigger { + fn eq(&self, other: &Self) -> bool { + self.name == other.name + } } -impl<'a> Collection<'a> { - /// Create a new [Collection] using the given triggers - pub fn new(triggers: impl IntoIterator) -> Result { - let mut handlers = vec![]; - let mut trigger_set = BTreeMap::new(); - for trigger in triggers.into_iter() { - trigger_set.insert(trigger.name.clone(), trigger); - for (p, def) in trigger.paths.iter() { - for used_handler in def.handlers.iter() { - // Ensure we have a corresponding handler - let handler = trigger - .handlers - .get(used_handler) - .ok_or(Error::MissingHandler(trigger.name.clone(), used_handler.clone()))?; - handlers.push(ExtractedHandler { - id: trigger.name.clone(), - pattern: p.clone(), - handler: handler.clone(), - }); - } +impl Eq for Trigger {} + +impl PartialOrd for Trigger { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.name.cmp(&other.name)) + } +} + +impl Ord for Trigger { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.name.cmp(&other.name) + } +} + +impl Trigger { + /// Returns all handlers that compiled against the path. + pub fn compiled_handlers(&self, path: String) -> impl Iterator + '_ { + self.handlers_by_pattern() + .flat_map(move |pat| pat.compiled(&path).collect::>()) + } + + /// Returns all [`Handler`]s associated to each [`Pattern`]. + pub fn handlers_by_pattern(&self) -> impl Iterator> { + self.patterns.iter().map(move |(pattern, handler_names)| { + let handlers = handler_names + .iter() + .filter_map(|name| self.handlers.get(name)) + .collect(); + PatternedHandlers { pattern, handlers } + }) + } + + /// Returns whether the trigger shouldn't be run because + /// an inhibitor applies for this system. + pub fn is_inhibited(&self) -> io::Result { + for inhibitor in &self.inhibitors { + if inhibitor.is_effective()? { + return Ok(true); } } + Ok(false) + } +} - Ok(Self { - handlers, - triggers: trigger_set, - hits: BTreeMap::new(), - }) +/// A condition that prevents a Trigger from running. +#[derive(Debug, PartialEq, Eq)] +pub enum Inhibitor { + /// A file path. If this path exists, the Trigger shall not run. + Path(PathBuf), + + /// An operating system environment. + /// If the OS in inside this environment, the Trigger shall not run. + Environment(OsEnv), +} + +impl Inhibitor { + /// Returns whether the inhibitor applies for this system. + pub fn is_effective(&self) -> io::Result { + match self { + Self::Path(path) => path.try_exists(), + Self::Environment(env) => Ok(OsEnv::detect()?.is_some_and(|detected| &detected == env)), + } } +} - /// Process a batch set of paths and record the "hit" - pub fn process_paths(&mut self, paths: impl Iterator) { - let results = paths.into_iter().flat_map(|p| { - self.handlers - .iter() - .filter_map(move |h| h.pattern.matches(&p).map(|m| (h.id.clone(), h.handler.compiled(&m)))) - }); +/// The operating system environment as seen by a Trigger. +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum OsEnv { + /// Indicates that the OS is a guest being run inside a container. + #[serde(alias = "chroot")] + Container, + /// Indicates that the OS is a live image. + Live, +} + +impl OsEnv { + /// Detects the environment of the operating system + /// by analyzing the root directory content ("/"). + pub fn detect() -> io::Result> { + Self::detect_from_sysroot(path::Path::new("/")) + } + + /// Detects the environment of the operating system + /// by analyzing a sysroot directory. + pub fn detect_from_sysroot(root: &path::Path) -> io::Result> { + if Self::is_container(root)? { + return Ok(Some(Self::Container)); + } + if Self::is_live(root)? { + return Ok(Some(Self::Live)); + } + Ok(None) + } + + fn is_container(root: &path::Path) -> io::Result { + // The logic above is heuristic and I'm not sure + // it works in all cases, particularly when containers + // are designed to be transparent. + // Anyway, the principle is to check that the "real" root + // directory and the root seen by the init process are the same. + let proc_root = fs::metadata(root)?; + let proc_meta = fs::metadata(root.join(ROOT_FILE))?; + if proc_root.st_dev() != proc_meta.st_dev() { + return Ok(true); + } + if proc_root.st_ino() != proc_meta.st_ino() { + return Ok(true); + } + Ok(false) + } + + fn is_live(root: &path::Path) -> io::Result { + root.join(LIVE_FILE).try_exists() + } +} + +/// An extension of [`fnmatch::Pattern`] that takes into consideration +/// the file type too, not just the file name. +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct Pattern { + /// The file name pattern. + pub pattern: fnmatch::Pattern, + + /// The file type. If None, any kind is considered valid. + pub kind: Option, +} - for (id, handler) in results { - if let Some(map) = self.hits.get_mut(&id) { - map.insert(handler); +impl Pattern { + /// Returns whether a path matches this pattern. + pub fn matches(&self, fspath: impl AsRef) -> Option { + self.pattern.matches(fspath).filter(|matc| { + if let Some(kind) = &self.kind { + let p = path::Path::new(&matc.path); + match kind { + FileKind::Directory => p.is_dir(), + FileKind::Symlink => p.is_symlink(), + } } else { - self.hits.insert(id, BTreeSet::from_iter([handler])); + true } + }) + } +} + +/// Known file types. +#[derive(Debug, Deserialize, PartialEq, Eq, PartialOrd, Ord)] +#[serde(rename_all = "lowercase")] +pub enum FileKind { + /// A directory. + Directory, + + /// A symbolic link to another file. + Symlink, +} + +/// An operation to perform. One or more operations compose a Trigger. +#[derive(Debug, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +#[serde(untagged)] +pub enum Handler { + /// Executes a process with an optional list of arguments. + /// + /// Arguments may contain the special syntax "`$(variableName)`" + /// that will be resolved into the corresponding group name contained in a [`Match`]. + Run { run: String, args: Vec }, + + /// Removes a list of files (non-recursively). + Delete { delete: Vec }, +} + +impl fmt::Display for Handler { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Handler::Run { run, args } => write!(f, "command \"{} {}\"", run, args.join(" ")), + Handler::Delete { delete } => write!(f, "deleting {}", delete.join("; ")), } } +} - /// Bake the trigger collection into a sane dependency order - pub fn bake(&mut self) -> Result, Error> { - let mut graph = dag::Dag::new(); +impl Handler { + /// Replaces variables used in this Handler with group names found by a Match. + /// If any variable doesn't match a group name, the variable syntax string is retained as it is. + pub fn compiled(&self, with_match: &fnmatch::Match) -> CompiledHandler { + match self { + Handler::Run { run, args } => { + let mut run = run.clone(); + for (key, value) in &with_match.groups { + run = run.replace(&format!("$({key})"), value); + } + let args = args + .iter() + .map(|a| { + let mut a = a.clone(); + for (key, value) in &with_match.groups { + a = a.replace(&format!("$({key})"), value); + } + a + }) + .collect(); + CompiledHandler(Handler::Run { run, args }) + } + Handler::Delete { delete } => CompiledHandler(Handler::Delete { delete: delete.clone() }), + } + } +} + +/// A [`Handler`] with variables resolved. +#[derive(Debug, Deserialize, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct CompiledHandler(Handler); + +impl fmt::Display for CompiledHandler { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.handler()) + } +} - // ensure all keys are in place - for id in self.hits.keys() { - let _ = graph.add_node_or_get_index(id.clone()); +impl CompiledHandler { + /// Returns the underlying Handler with variables resolved. + pub fn handler(&self) -> &Handler { + &self.0 + } + + /// Executes the handler with `workdir` as the working directory. + pub fn run(&self, workdir: &path::Path) -> io::Result { + match self.handler() { + Handler::Run { run, args } => process::Command::new(run).args(args).current_dir(workdir).output(), + Handler::Delete { delete } => { + for file in delete { + fs::remove_file(file)?; + } + Ok(process::Output { + status: process::ExitStatus::default(), + stderr: Vec::default(), + stdout: Vec::default(), + }) + } } + } +} - // add dependency ordering for the toplevel IDs - for id in self.hits.keys() { - let lookup = self - .triggers - .get(id) - .ok_or(Error::MissingHandler(id.clone(), id.clone()))?; +/// A collection of handlers with a [`Pattern`] in common. +pub struct PatternedHandlers<'a> { + pub pattern: &'a Pattern, + pub handlers: Vec<&'a Handler>, +} + +impl PatternedHandlers<'_> { + /// Compiles all handlers in this collection against a given path. + /// The list will be empty if [`Self::pattern`] doesn't match against the path. + pub fn compiled(&self, fspath: impl AsRef) -> impl Iterator + '_ { + self.pattern + .matches(fspath) + .into_iter() + .flat_map(|matc| self.handlers.iter().map(move |hnd| hnd.compiled(&matc))) + } +} - let node = graph.add_node_or_get_index(id.clone()); +#[derive(Default)] +/// Dependency graph of a pool of triggers. +pub struct DepGraph<'a> { + graph: dag::Dag<&'a Trigger>, +} + +impl<'a> DepGraph<'a> { + /// Creates an empty dependency graph. Identical to [`Self::default()`]. + pub fn new() -> Self { + Self::default() + } - // This runs *before* B - if let Some(before) = lookup + /// Returns an iterator over triggers in the right execution order. + pub fn iter(&'a self) -> impl Iterator { + self.graph.topo().copied() + } +} + +impl<'a> FromIterator<&'a Trigger> for DepGraph<'a> { + fn from_iter>(triggers: T) -> Self { + let triggers: HashMap<&str, &Trigger> = triggers + .into_iter() + .map(|trigger| (trigger.name.as_str(), trigger)) + .collect(); + + let mut graph = dag::Dag::with_capacity(triggers.len(), 0); + for trigger in triggers.values() { + let node = graph.add_node_or_get_index(*trigger); + + if let Some(before) = trigger .before .as_ref() - .and_then(|b| self.triggers.get(b)) - .map(|f| graph.add_node_or_get_index(f.name.clone())) + .and_then(|before_name| triggers.get(before_name.as_str())) + .map(|linked_trigger| graph.add_node_or_get_index(linked_trigger)) { graph.add_edge(node, before); } - - // This runs *after* A - if let Some(after) = lookup + if let Some(after) = trigger .after .as_ref() - .and_then(|a| self.triggers.get(a)) - .map(|f| graph.add_node_or_get_index(f.name.clone())) + .and_then(|after_name| triggers.get(after_name.as_str())) + .map(|linked_trigger| graph.add_node_or_get_index(linked_trigger)) { graph.add_edge(after, node); } } - // Recollect in dependency order - let results = graph - .topo() - .filter_map(|i| self.hits.remove(i)) - .flatten() - .collect::>(); - Ok(results) + Self { graph } + } +} + +#[derive(Debug, Error)] +pub enum Error { + #[error("missing handler reference in {0}: {1}")] + MissingHandler(String, String), +} + +/// The root directory as seen by the init process. +const ROOT_FILE: &str = "proc/1/root"; + +/// A canary file we create in live images. +const LIVE_FILE: &str = "run/livedev"; + +#[cfg(test)] +mod tests { + use std::{collections::BTreeMap, path::PathBuf}; + + use crate::{FileKind, Handler, Inhibitor, OsEnv, Pattern, Trigger}; + + #[test] + fn deserialize_trigger() { + let trigger: Trigger = serde_yaml::from_str(include_str!("../../../test/trigger_valid.yml")).unwrap(); + assert_eq!(trigger.name, "trigger".to_string()); + assert_eq!(trigger.before, Some("before_another_trigger".to_string())); + assert_eq!(trigger.after, Some("after_another_trigger".to_string())); + assert_eq!( + trigger.inhibitors, + Vec::from([ + Inhibitor::Path(PathBuf::from("/etc/file1")), + Inhibitor::Path(PathBuf::from("/etc/file2")), + Inhibitor::Environment(OsEnv::Container), + Inhibitor::Environment(OsEnv::Live), + ]) + ); + assert_eq!( + trigger.patterns, + BTreeMap::from([( + Pattern { + pattern: fnmatch::Pattern::new("/usr/lib/modules/(version:*)/kernel"), + kind: Some(FileKind::Directory), + }, + vec!["used_handler".to_string()] + )]) + ); + assert_eq!( + trigger.handlers, + BTreeMap::from([ + ( + "used_handler".to_string(), + Handler::Run { + run: "/usr/bin/used".to_string(), + args: vec!["used1".to_string(), "used2".to_string()] + } + ), + ( + "unwanted_files".to_string(), + Handler::Delete { + delete: vec!["/".to_string()] + } + ) + ]) + ); } } diff --git a/test/trigger.yml b/test/trigger.yml deleted file mode 100644 index c98be410..00000000 --- a/test/trigger.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: depmod -description: | - Needlessly verbose example to show how we could utilise YAML to make - triggers actually.. not that bad. - -before: some.trigger -after: some.trigger -needs: some.trigger - -# Inhibit execution -inhibitors: - paths: - - /etc/ssh/ssh_host_rsa_key - - /etc/ssh/ssh_host_dsa_key - environment: - - chroot - - live - -handlers: - ssh_keygen: - run: /usr/bin/ssh-keygen - args: ["-q", "-t", "rsa", "-f", "/etc/ssh/ssh_host_rsa_key", "-C", "-N"] - - depmod: - run: /sbin/depmod - args: ["-a", "$(version)"] - -# Link paths to handlers and filter on the type -paths: - "/usr/lib/modules/(version:*)/kernel" : - handlers: - - depmod - type: directory \ No newline at end of file diff --git a/test/trigger_valid.yml b/test/trigger_valid.yml new file mode 100644 index 00000000..6219f5d3 --- /dev/null +++ b/test/trigger_valid.yml @@ -0,0 +1,37 @@ +# NOTE: this trigger must have *all* Trigger fields since +# it's used to test the correctness of deserialization. + +name: trigger +description: | + Trigger's description. + +before: before_another_trigger +after: after_another_trigger + +## Prevent this trigger to run if at least one path +## exists or we're inside one of the environments. +inhibitors: +# Keep at least 2 entries per type +# to test we're parsing the whole list. + paths: + - /etc/file1 + - /etc/file2 + environment: + - chroot + - live + +## Handlers are associated to paths. They can also be shared +## among multiple paths. +handlers: + used_handler: + run: /usr/bin/used + args: ["used1", "used2"] + + unwanted_files: + delete: ["/"] + +paths: + "/usr/lib/modules/(version:*)/kernel" : + handlers: + - used_handler + type: directory From f510a50406e533ae1a714296e2455d1395a734c9 Mon Sep 17 00:00:00 2001 From: Fabio Forni Date: Fri, 26 Jul 2024 13:15:19 +0200 Subject: [PATCH 4/4] moss: client: Adapt code to the new triggers API --- moss/src/client/mod.rs | 2 +- moss/src/client/postblit.rs | 57 ++++++++++++++++++++----------------- 2 files changed, 32 insertions(+), 27 deletions(-) diff --git a/moss/src/client/mod.rs b/moss/src/client/mod.rs index cb3f0edb..e75cedb2 100644 --- a/moss/src/client/mod.rs +++ b/moss/src/client/mod.rs @@ -353,7 +353,7 @@ impl Client { postblit::TriggerScope::Transaction(&self.installation, &self.scope), &fstree, )?; - for trigger in triggers { + for trigger in triggers.iter() { trigger.execute()?; } // ephemeral system triggers diff --git a/moss/src/client/postblit.rs b/moss/src/client/postblit.rs index ae777649..e9526ca9 100644 --- a/moss/src/client/postblit.rs +++ b/moss/src/client/postblit.rs @@ -8,17 +8,14 @@ //! //! Note that currently we only load from `/usr/share/moss/triggers/{tx,sys.d}/*.yaml` //! and do not yet support local triggers -use std::{ - path::{Path, PathBuf}, - process, -}; +use std::path::{Path, PathBuf}; use crate::Installation; use container::Container; use itertools::Itertools; use serde::Deserialize; use thiserror::Error; -use triggers::format::{CompiledHandler, Handler, Trigger}; +use triggers::{CompiledHandler, Trigger}; use super::PendingFile; @@ -132,13 +129,9 @@ pub(super) fn triggers<'a>( }; // Load trigger collection, process all the paths, convert to scoped TriggerRunner vec - let mut collection = triggers::Collection::new(triggers.iter())?; - collection.process_paths(fstree.iter().map(|m| m.to_string())); - let computed_commands = collection - .bake()? - .into_iter() - .map(|trigger| TriggerRunner { scope, trigger }) - .collect_vec(); + let graph = triggers::DepGraph::from_iter(triggers.iter()); + let computed_commands = process_paths(fstree.iter().map(|m| m.to_string()), &graph, scope); + Ok(computed_commands) } @@ -184,26 +177,38 @@ impl<'a> TriggerRunner<'a> { /// Internal executor for triggers. fn execute_trigger_directly(trigger: &CompiledHandler) -> Result<(), Error> { - match trigger.handler() { - Handler::Run { run, args } => { - let cmd = process::Command::new(run).args(args).current_dir("/").output()?; - - if let Some(code) = cmd.status.code() { - if code != 0 { - eprintln!("Trigger exited with non-zero status code: {run} {args:?}"); - eprintln!(" Stdout: {}", String::from_utf8(cmd.stdout).unwrap()); - eprintln!(" Stderr: {}", String::from_utf8(cmd.stderr).unwrap()); - } - } else { - eprintln!("Failed to execute trigger: {run} {args:?}"); - } + let out = trigger.run(Path::new("/"))?; + if let Some(code) = out.status.code() { + if code != 0 { + eprintln!("Trigger exited with non-zero status code: {}", trigger); + eprintln!(" Stdout: {}", String::from_utf8(out.stdout).unwrap()); + eprintln!(" Stderr: {}", String::from_utf8(out.stderr).unwrap()); } - Handler::Delete { .. } => todo!(), + } else { + eprintln!("Failed to execute trigger: {}", trigger); } Ok(()) } +fn process_paths<'a>( + paths: impl Iterator, + triggers: &triggers::DepGraph, + scope: TriggerScope<'a>, +) -> Vec> { + paths + .flat_map(move |fspath| { + triggers + .iter() + .flat_map(move |trig| trig.compiled_handlers(fspath.clone())) // FIXME: can I avoid this clone? + }) + .map(|comp_hnd| TriggerRunner { + scope, + trigger: comp_hnd, + }) + .collect() +} + #[derive(Debug, Error)] pub enum Error { #[error("container")]