feat(targets): complexe targets sets expressions

allows intersection and complementary
This commit is contained in:
catvayor 2024-12-23 22:49:11 +01:00
parent 71b1b660f2
commit 2b7bd80249
Signed by: lbailly
GPG key ID: CE3E645251AC63F3

View file

@ -2,7 +2,7 @@
use std::collections::HashSet;
use std::convert::AsRef;
use std::iter::{FromIterator, Iterator};
use std::iter::Iterator;
use std::str::FromStr;
use clap::Args;
@ -28,22 +28,26 @@ The list is comma-separated and globs are supported. To match tags, prepend the
pub on: Option<NodeFilter>,
}
/// A node filter containing a list of rules.
#[derive(Clone, Debug)]
pub struct NodeFilter {
rules: Vec<Rule>,
}
/// A filter rule.
///
/// The filter rules are OR'd together.
#[derive(Debug, Clone, Eq, PartialEq)]
enum Rule {
pub enum NodeFilter {
/// Matches a node's attribute name.
MatchName(GlobPattern),
/// Matches a node's `deployment.tags`.
MatchTag(GlobPattern),
/// Matches an Union
Union(Vec<Box<NodeFilter>>),
/// Matches an Intersection
Inter(Vec<Box<NodeFilter>>),
/// Matches the complementary
Not(Box<NodeFilter>),
/// Empty
Empty,
}
impl FromStr for NodeFilter {
@ -53,7 +57,144 @@ impl FromStr for NodeFilter {
}
}
fn end_delimiter(c: char) -> bool {
[',', '&', ')'].contains(&c)
}
impl NodeFilter {
fn and(a: Self, b: Self) -> Self {
match (a, b) {
(Self::Inter(mut av), Self::Inter(mut bv)) => {
av.append(&mut bv);
Self::Inter(av)
}
(Self::Inter(mut av), b) => {
av.push(Box::new(b));
Self::Inter(av)
}
(a, Self::Inter(mut bv)) => {
bv.push(Box::new(a));
Self::Inter(bv)
}
(a, b) => Self::Inter(vec![Box::new(a), Box::new(b)]),
}
}
fn or(a: Self, b: Self) -> Self {
match (a, b) {
(Self::Union(mut av), Self::Union(mut bv)) => {
av.append(&mut bv);
Self::Union(av)
}
(Self::Union(mut av), b) => {
av.push(Box::new(b));
Self::Union(av)
}
(a, Self::Union(mut bv)) => {
bv.push(Box::new(a));
Self::Union(bv)
}
(a, b) => Self::Union(vec![Box::new(a), Box::new(b)]),
}
}
fn not(a: Self) -> Self {
if let Self::Not(ae) = a {
*ae
} else {
Self::Not(Box::new(a))
}
}
fn parse_expr0(expr: &str) -> ColmenaResult<(Self, &str)> {
// tag, name, !, (e)
let expr = expr.trim_start();
if let Some(follow) = expr.strip_prefix('!') {
let (e, end) = Self::parse_expr0(follow)?;
Ok((Self::not(e), end))
} else if let Some(follow) = expr.strip_prefix('(') {
let (e, end) = Self::parse_expr2(follow)?;
// TODO: other error
Ok((
e,
end.strip_prefix(')').ok_or(ColmenaError::EmptyFilterRule)?,
))
} else if let Some(follow) = expr.strip_prefix('@') {
match follow
.find(end_delimiter)
.map(|idx| follow.split_at(idx))
.map(|(tag, end)| (tag.trim_end(), end))
{
Some((tag, end)) => {
if tag.is_empty() {
return Err(ColmenaError::EmptyFilterRule);
} else {
Ok((Self::MatchTag(GlobPattern::new(tag).unwrap()), end))
}
}
None => {
if follow.is_empty() {
Err(ColmenaError::EmptyFilterRule)
} else {
Ok((Self::MatchTag(GlobPattern::new(follow).unwrap()), ""))
}
}
}
} else {
match expr
.find(end_delimiter)
.map(|idx| expr.split_at(idx))
.map(|(tag, end)| (tag.trim_end(), end))
{
Some((tag, end)) => {
if tag.is_empty() {
Err(ColmenaError::EmptyFilterRule)
} else {
Ok((Self::MatchName(GlobPattern::new(tag).unwrap()), end))
}
}
None => {
if expr.is_empty() {
Err(ColmenaError::EmptyFilterRule)
} else {
Ok((Self::MatchName(GlobPattern::new(expr).unwrap()), ""))
}
}
}
}
}
fn parse_op1(beg: Self, expr: &str) -> ColmenaResult<(Self, &str)> {
let expr = expr.trim_start();
if let Some(follow) = expr.strip_prefix(',') {
let (e, end) = Self::parse_expr0(follow)?;
Self::parse_op1(Self::or(beg, e), end)
} else {
Ok((beg, expr))
}
}
fn parse_expr1(expr: &str) -> ColmenaResult<(Self, &str)> {
// ,
let (e, follow) = Self::parse_expr0(expr)?;
Self::parse_op1(e, follow)
}
fn parse_op2(beg: Self, expr: &str) -> ColmenaResult<(Self, &str)> {
if let Some(follow) = expr.strip_prefix('&') {
let (e, end) = Self::parse_expr1(follow)?;
Self::parse_op2(Self::and(beg, e), end)
} else {
Ok((beg, expr))
}
}
fn parse_expr2(expr: &str) -> ColmenaResult<(Self, &str)> {
// &
let (e, follow) = Self::parse_expr1(expr)?;
Self::parse_op2(e, follow)
}
/// Creates a new filter using an expression passed using `--on`.
pub fn new<S: AsRef<str>>(filter: S) -> ColmenaResult<Self> {
let filter = filter.as_ref();
@ -62,29 +203,15 @@ impl NodeFilter {
if trimmed.is_empty() {
log::warn!("Filter \"{}\" is blank and will match nothing", filter);
return Ok(Self { rules: Vec::new() });
return Ok(Self::Empty);
}
let (e, end) = Self::parse_expr2(trimmed)?;
if end != "" {
// TODO: other error
Err(ColmenaError::EmptyFilterRule)
} else {
Ok(e)
}
let rules = trimmed
.split(',')
.map(|pattern| {
let pattern = pattern.trim();
if pattern.is_empty() {
return Err(ColmenaError::EmptyFilterRule);
}
if let Some(tag_pattern) = pattern.strip_prefix('@') {
Ok(Rule::MatchTag(GlobPattern::new(tag_pattern).unwrap()))
} else {
Ok(Rule::MatchName(GlobPattern::new(pattern).unwrap()))
}
})
.collect::<Vec<ColmenaResult<Rule>>>();
let rules = Result::from_iter(rules)?;
Ok(Self { rules })
}
/// Returns whether the filter has any rule matching NodeConfig information.
@ -93,7 +220,29 @@ impl NodeFilter {
/// especially when its values (e.g., tags) depend on other parts of
/// the configuration.
pub fn has_node_config_rules(&self) -> bool {
self.rules.iter().any(|rule| rule.matches_node_config())
match self {
Self::MatchName(_) => false,
Self::MatchTag(_) => true,
Self::Union(v) => v.iter().any(|e| e.has_node_config_rules()),
Self::Inter(v) => v.iter().any(|e| e.has_node_config_rules()),
Self::Not(e) => e.has_node_config_rules(),
Self::Empty => false,
}
}
fn is_accepted(&self, name: &NodeName, config: Option<&NodeConfig>) -> bool {
match self {
Self::MatchName(pat) => pat.matches(name.as_str()),
Self::MatchTag(pat) => config
.unwrap()
.tags()
.iter()
.any(|tag| pat.matches(tag.as_str())),
Self::Union(v) => v.iter().any(|e| e.is_accepted(name, config)),
Self::Inter(v) => v.iter().all(|e| e.is_accepted(name, config)),
Self::Not(e) => !e.is_accepted(name, config),
Self::Empty => false,
}
}
/// Runs the filter against a set of NodeConfigs and returns the matched ones.
@ -101,30 +250,17 @@ impl NodeFilter {
where
I: Iterator<Item = (&'a NodeName, &'a NodeConfig)>,
{
if self.rules.is_empty() {
if self == &Self::Empty {
return HashSet::new();
}
nodes
.filter_map(|(name, node)| {
for rule in self.rules.iter() {
match rule {
Rule::MatchName(pat) => {
if pat.matches(name.as_str()) {
return Some(name);
}
}
Rule::MatchTag(pat) => {
for tag in node.tags() {
if pat.matches(tag) {
return Some(name);
}
}
}
}
if self.is_accepted(name, Some(node)) {
Some(name)
} else {
None
}
None
})
.cloned()
.collect()
@ -132,32 +268,24 @@ impl NodeFilter {
/// Runs the filter against a set of node names and returns the matched ones.
pub fn filter_node_names(&self, nodes: &[NodeName]) -> ColmenaResult<HashSet<NodeName>> {
nodes.iter().filter_map(|name| -> Option<ColmenaResult<NodeName>> {
for rule in self.rules.iter() {
match rule {
Rule::MatchName(pat) => {
if pat.matches(name.as_str()) {
return Some(Ok(name.clone()));
}
if self.has_node_config_rules() {
Err(ColmenaError::Unknown {
message: format!(
"Not enough information to run rule {:?} - We only have node names",
self
),
})
} else {
Ok(nodes
.iter()
.filter_map(|name| {
if self.is_accepted(name, None) {
Some(name.clone())
} else {
None
}
_ => {
return Some(Err(ColmenaError::Unknown {
message: format!("Not enough information to run rule {:?} - We only have node names", rule),
}));
}
}
}
None
}).collect()
}
}
impl Rule {
/// Returns whether the rule matches against the NodeConfig (i.e., `config.deployment`).
pub fn matches_node_config(&self) -> bool {
match self {
Self::MatchTag(_) => true,
Self::MatchName(_) => false,
})
.collect())
}
}
}
@ -177,13 +305,13 @@ mod tests {
#[test]
fn test_empty_filter() {
let filter = NodeFilter::new("").unwrap();
assert_eq!(0, filter.rules.len());
assert_eq!(NodeFilter::Empty, filter);
let filter = NodeFilter::new("\t").unwrap();
assert_eq!(0, filter.rules.len());
assert_eq!(NodeFilter::Empty, filter);
let filter = NodeFilter::new(" ").unwrap();
assert_eq!(0, filter.rules.len());
assert_eq!(NodeFilter::Empty, filter);
}
#[test]
@ -197,21 +325,73 @@ mod tests {
fn test_filter_rule_mixed() {
let filter = NodeFilter::new("@router,gamma-*").unwrap();
assert_eq!(
vec![
Rule::MatchTag(GlobPattern::new("router").unwrap()),
Rule::MatchName(GlobPattern::new("gamma-*").unwrap()),
],
filter.rules,
NodeFilter::Union(vec![
Box::new(NodeFilter::MatchTag(GlobPattern::new("router").unwrap())),
Box::new(NodeFilter::MatchName(GlobPattern::new("gamma-*").unwrap())),
]),
filter,
);
let filter = NodeFilter::new("a, \t@b , c-*").unwrap();
assert_eq!(
vec![
Rule::MatchName(GlobPattern::new("a").unwrap()),
Rule::MatchTag(GlobPattern::new("b").unwrap()),
Rule::MatchName(GlobPattern::new("c-*").unwrap()),
],
filter.rules,
NodeFilter::Union(vec![
Box::new(NodeFilter::MatchName(GlobPattern::new("a").unwrap())),
Box::new(NodeFilter::MatchTag(GlobPattern::new("b").unwrap())),
Box::new(NodeFilter::MatchName(GlobPattern::new("c-*").unwrap())),
]),
filter,
);
let filter = NodeFilter::new("a & \t@b , c-*").unwrap();
assert_eq!(
NodeFilter::Inter(vec![
Box::new(NodeFilter::MatchName(GlobPattern::new("a").unwrap())),
Box::new(NodeFilter::Union(vec![
Box::new(NodeFilter::MatchTag(GlobPattern::new("b").unwrap())),
Box::new(NodeFilter::MatchName(GlobPattern::new("c-*").unwrap())),
])),
]),
filter,
);
let filter = NodeFilter::new("( a & \t@b ) , c-*").unwrap();
assert_eq!(
NodeFilter::Union(vec![
Box::new(NodeFilter::Inter(vec![
Box::new(NodeFilter::MatchName(GlobPattern::new("a").unwrap())),
Box::new(NodeFilter::MatchTag(GlobPattern::new("b").unwrap())),
])),
Box::new(NodeFilter::MatchName(GlobPattern::new("c-*").unwrap())),
]),
filter,
);
let filter = NodeFilter::new("( a & \t@b ) , ! c-*").unwrap();
assert_eq!(
NodeFilter::Union(vec![
Box::new(NodeFilter::Inter(vec![
Box::new(NodeFilter::MatchName(GlobPattern::new("a").unwrap())),
Box::new(NodeFilter::MatchTag(GlobPattern::new("b").unwrap())),
])),
Box::new(NodeFilter::Not(Box::new(NodeFilter::MatchName(
GlobPattern::new("c-*").unwrap()
)))),
]),
filter,
);
let filter = NodeFilter::new("( a & \t@b ) , !!! c-*").unwrap();
assert_eq!(
NodeFilter::Union(vec![
Box::new(NodeFilter::Inter(vec![
Box::new(NodeFilter::MatchName(GlobPattern::new("a").unwrap())),
Box::new(NodeFilter::MatchTag(GlobPattern::new("b").unwrap())),
])),
Box::new(NodeFilter::Not(Box::new(NodeFilter::MatchName(
GlobPattern::new("c-*").unwrap()
)))),
]),
filter,
);
}
@ -250,6 +430,7 @@ mod tests {
privilege_escalation_command: vec![],
extra_ssh_options: vec![],
keys: HashMap::new(),
system_type: None,
};
let mut nodes = HashMap::new();
@ -315,5 +496,26 @@ mod tests {
.unwrap()
.filter_node_configs(nodes.iter()),
);
assert_eq!(
&HashSet::from_iter([]),
&NodeFilter::new("@router&@controller")
.unwrap()
.filter_node_configs(nodes.iter()),
);
assert_eq!(
&HashSet::from_iter([node!("beta")]),
&NodeFilter::new("@router&@infra-*")
.unwrap()
.filter_node_configs(nodes.iter()),
);
assert_eq!(
&HashSet::from_iter([node!("alpha")]),
&NodeFilter::new("!@router&@infra-*")
.unwrap()
.filter_node_configs(nodes.iter()),
);
}
}