Add a small filter language that allows rich expressions to be used when doing discovery.
A simple parser/scanner was included in MCollective::Matcher and it's available on all SimpleRPC clients with the -S option and on any MCollective::RPC::Client using the #compound_filter method. Signed-off-by: Pieter Loubser <ploub...@gmail.com> --- Local-branch: feature/master/8181 lib/mcollective.rb | 1 + lib/mcollective/matcher.rb | 17 +++++ lib/mcollective/matcher/parser.rb | 93 +++++++++++++++++++++++++++ lib/mcollective/matcher/scanner.rb | 123 ++++++++++++++++++++++++++++++++++++ lib/mcollective/optionparser.rb | 4 + lib/mcollective/rpc/client.rb | 6 ++ lib/mcollective/security/base.rb | 30 +++++++++ lib/mcollective/util.rb | 18 +++++ spec/unit/matcher/parser_spec.rb | 106 +++++++++++++++++++++++++++++++ spec/unit/matcher/scanner_spec.rb | 71 +++++++++++++++++++++ spec/unit/util_spec.rb | 23 +++++++ website/simplerpc/clients.md | 2 +- 12 files changed, 493 insertions(+), 1 deletions(-) create mode 100644 lib/mcollective/matcher.rb create mode 100644 lib/mcollective/matcher/parser.rb create mode 100644 lib/mcollective/matcher/scanner.rb create mode 100644 spec/unit/matcher/parser_spec.rb create mode 100644 spec/unit/matcher/scanner_spec.rb diff --git a/lib/mcollective.rb b/lib/mcollective.rb index 994b06d..98be7de 100644 --- a/lib/mcollective.rb +++ b/lib/mcollective.rb @@ -46,6 +46,7 @@ module MCollective autoload :Registration, "mcollective/registration" autoload :PluginManager, "mcollective/pluginmanager" autoload :RPC, "mcollective/rpc" + autoload :Matcher, "mcollective/matcher" autoload :Message, "mcollective/message" autoload :SSL, "mcollective/ssl" autoload :Application, "mcollective/application" diff --git a/lib/mcollective/matcher.rb b/lib/mcollective/matcher.rb new file mode 100644 index 0000000..431a378 --- /dev/null +++ b/lib/mcollective/matcher.rb @@ -0,0 +1,17 @@ +module MCollective + # A parser and scanner that creates a stack machine for a simple + # fact and class matching language used on the CLI to facilitate + # a rich discovery language + # + # Language EBNF + # + # compound = ["("] expression [")"] {["("] expression [")"]} + # expression = [!|not]statement ["and"|"or"] [!|not] statement + # char = A-Z | a-z | < | > | => | =< | _ | - |* | / { A-Z | a-z | < | > | => | =< | _ | - | * | / | } + # int = 0|1|2|3|4|5|6|7|8|9{|0|1|2|3|4|5|6|7|8|9|0} + module Matcher + autoload :Parser, "mcollective/matcher/parser" + autoload :Scanner, "mcollective/matcher/scanner" + end +end +# vi:tabstop=4:expandtab:ai diff --git a/lib/mcollective/matcher/parser.rb b/lib/mcollective/matcher/parser.rb new file mode 100644 index 0000000..67331b2 --- /dev/null +++ b/lib/mcollective/matcher/parser.rb @@ -0,0 +1,93 @@ +module MCollective + module Matcher + class Parser + attr_reader :scanner, :execution_stack + + def initialize(args) + @scanner = Scanner.new(args) + @execution_stack = [] + parse + end + + # Parse the input string, one token at a time a contruct the call stack + def parse + p_token,p_token_value = nil + c_token,c_token_value = @scanner.get_token + parenth = 0 + + while (c_token != nil) + @scanner.token_index += 1 + n_token, n_token_value = @scanner.get_token + + unless n_token == " " + case c_token + when "and" + unless (n_token =~ /not|statement|\(/) || (scanner.token_index == scanner.arguments.size) + raise "Error at column #{scanner.token_index}. \nExpected 'not', 'statement' or '('. Found '#{n_token_value}'" + end + + if p_token == nil + raise "Error at column #{scanner.token_index}. \n Expression cannot start with 'and'" + elsif (p_token == "and" || p_token == "or") + raise "Error at column #{scanner.token_index}. \n #{p_token} cannot be followed by 'and'" + end + + when "or" + unless (n_token =~ /not|statement|\(/) || (scanner.token_index == scanner.arguments.size) + raise "Error at column #{scanner.token_index}. \nExpected 'not', 'statement', '('. Found '#{n_token_value}'" + end + + if p_token == nil + raise "Error at column #{scanner.token_index}. \n Expression cannot start with 'or'" + elsif (p_token == "and" || p_token == "or") + raise "Error at column #{scanner.token_index}. \n #{p_token} cannot be followed by 'or'" + end + + when "not" + unless n_token =~ /statement|\(|not/ + raise "Error at column #{scanner.token_index}. \nExpected 'statement' or '('. Found '#{n_token_value}'" + end + + when "statement" + unless n_token =~ /and|or|\)/ + unless scanner.token_index == scanner.arguments.size + raise "Error at column #{scanner.token_index}. \nExpected 'and', 'or', ')'. Found '#{n_token_value}'" + end + end + + when ")" + unless (n_token =~ /|and|or|not|\(/) + unless(scanner.token_index == scanner.arguments.size) + raise "Error at column #{scanner.token_index}. \nExpected 'and', 'or', 'not' or '('. Found '#{n_token_value}'" + end + end + parenth += 1 + + when "(" + unless n_token =~ /statement|not|\(/ + raise "Error at column #{scanner.token_index}. \nExpected 'statement', '(', not. Found '#{n_token_value}'" + end + parenth -= 1 + + else + raise "Unexpected token found at column #{scanner.token_index}. '#{c_token_value}'" + end + + unless n_token == " " + @execution_stack << {c_token => c_token_value} + end + + p_token, p_token_value = c_token, c_token_value + c_token, c_token_value = n_token, n_token_value + end + end + + if parenth < 0 + raise "Error. Missing parentheses ')'." + elsif parenth > 0 + raise "Error. Missing parentheses '('." + end + end + end + end +end diff --git a/lib/mcollective/matcher/scanner.rb b/lib/mcollective/matcher/scanner.rb new file mode 100644 index 0000000..8dcb91b --- /dev/null +++ b/lib/mcollective/matcher/scanner.rb @@ -0,0 +1,123 @@ +module MCollective + module Matcher + class Scanner + attr_accessor :arguments, :token_index + + def initialize(arguments) + @token_index = 0 + @arguments = arguments + end + + # Scans the input string and identifies single language tokens + def get_token + if @token_index >= @arguments.size + return nil + end + + begin + case @arguments.split("")[@token_index] + when "(" + return "(", "(" + + when ")" + return ")", ")" + + when "n" + if (@arguments.split("")[@token_index + 1] == "o") && (@arguments.split("")[@token_index + 2] == "t") && ((@arguments.split("")[@token_index + 3] == " ") || (@arguments.split("")[@token_index + 3] == "(")) + @token_index += 2 + return "not", "not" + else + gen_statement + end + + when "!" + return "not", "not" + + when "a" + if (@arguments.split("")[@token_index + 1] == "n") && (@arguments.split("")[@token_index + 2] == "d") && ((@arguments.split("")[@token_index + 3] == " ") || (@arguments.split("")[@token_index + 3] == "(")) + @token_index += 2 + return "and", "and" + else + gen_statement + end + + when "o" + if (@arguments.split("")[@token_index + 1] == "r") && ((@arguments.split("")[@token_index + 2] == " ") || (@arguments.split("")[@token_index + 2] == "(")) + @token_index += 1 + return "or", "or" + else + gen_statement + end + + when " " + return " ", " " + + else + gen_statement + end + end + rescue NoMethodError => e + pp e + raise "Cannot end statement with 'and', 'or', 'not'" + end + + private + # Helper generates a statement token + def gen_statement + current_token_value = "" + j = @token_index + + begin + if (@arguments.split("")[j] == "/") + begin + current_token_value << @arguments.split("")[j] + j += 1 + if @arguments.split("")[j] == "/" + current_token_value << "/" + break + end + end until (j >= @arguments.size) || (@arguments.split("")[j] =~ /\//) + elsif (@arguments.split("")[j] =~ /=|<|>/) + while !(@arguments.split("")[j] =~ /=|<|>/) + current_token_value << @arguments.split("")[j] + j += 1 + end + + current_token_value << @arguments.split("")[j] + j += 1 + + if @arguments.split("")[j] == "/" + begin + current_token_value << @arguments.split("")[j] + j += 1 + if @arguments.split("")[j] == "/" + current_token_value << "/" + break + end + end until (j >= @arguments.size) || (@arguments.split("")[j] =~ /\//) + else + while (j < @arguments.size) && ((@arguments.split("")[j] != " ") && (@arguments.split("")[j] != ")")) + current_token_value << @arguments.split("")[j] + j += 1 + end + end + else + begin + current_token_value << @arguments.split("")[j] + j += 1 + end until (j >= @arguments.size) || (@arguments.split("")[j] =~ /\s|\)/) + end + rescue Exception => e + raise "Invalid token found - '#{current_token_value}'" + end + + if current_token_value =~ /^(and|or|not|!)$/ + raise "Class name cannot be 'and', 'or', 'not'. Found '#{current_token_value}'" + end + + @token_index += current_token_value.size - 1 + return "statement", current_token_value + end + end + end +end diff --git a/lib/mcollective/optionparser.rb b/lib/mcollective/optionparser.rb index a6bfcb7..9460fa3 100644 --- a/lib/mcollective/optionparser.rb +++ b/lib/mcollective/optionparser.rb @@ -73,6 +73,10 @@ module MCollective end end + @parser.on('-S', '--select FILTER', 'Compound filter combining facts and classes') do |f| + @options[:filter]["compound"] = MCollective::Matcher::Parser.new(f).execution_stack + end + @parser.on('-F', '--wf', '--with-fact fact=val', 'Match hosts with a certain fact') do |f| fact_parsed = parse_fact(f) diff --git a/lib/mcollective/rpc/client.rb b/lib/mcollective/rpc/client.rb index ba62841..547c47a 100644 --- a/lib/mcollective/rpc/client.rb +++ b/lib/mcollective/rpc/client.rb @@ -303,6 +303,12 @@ module MCollective reset end + # Set a compound filter + def compound_filter(filter) + @filter["compound"] = Matcher::Parser.new(filter).execution_stack + reset + end + # Resets various internal parts of the class, most importantly it clears # out the cached discovery def reset diff --git a/lib/mcollective/security/base.rb b/lib/mcollective/security/base.rb index af33d01..b8ebdb2 100644 --- a/lib/mcollective/security/base.rb +++ b/lib/mcollective/security/base.rb @@ -72,6 +72,36 @@ module MCollective end end + when "compound" + result = [] + + filter[key].each do |expression| + case expression.keys.first + when "statement" + result << Util.eval_compound_statement(expression).to_s + when "and" + result << "&&" + when "or" + result << "||" + when "(" + result << "(" + when ")" + result << ")" + when "not" + result << "!" + end + end + + result = eval(result.join(" ")) + + if result + Log.debug("Passing based on class and fact composition") + passed +=1 + else + Log.debug("Failing based on class and fact composition") + failed +=1 + end + when "agent" filter[key].each do |f| if Util.has_agent?(f) || f == "mcollective" diff --git a/lib/mcollective/util.rb b/lib/mcollective/util.rb index 1a909ae..22abe1a 100644 --- a/lib/mcollective/util.rb +++ b/lib/mcollective/util.rb @@ -233,6 +233,24 @@ module MCollective return str end + + def self.eval_compound_statement(expression) + if expression.values.first =~ /^\// + return Util.has_cf_class?(expression.values.first) + elsif expression.values.first =~ />=|<=|=|<|>/ + optype = expression.values.first.match(/>=|<=|=|<|>/) + name, value = expression.values.first.split(optype[0]) + unless value.split("")[0] == "/" + optype[0] == "=" ? optype = "==" : optype = optype[0] + else + optype = "=~" + end + + return Util.has_fact?(name,value, optype).to_s + else + return Util.has_cf_class?(expression.values.first) + end + end end end diff --git a/spec/unit/matcher/parser_spec.rb b/spec/unit/matcher/parser_spec.rb new file mode 100644 index 0000000..319bb6e --- /dev/null +++ b/spec/unit/matcher/parser_spec.rb @@ -0,0 +1,106 @@ +#! /usr/bin/env ruby + +require File.dirname(__FILE__) + '/../../spec_helper' + +module MCollective + module Matcher + describe Parser do + describe '#parse' do + it "should parse statements seperated by '='" do + parser = Parser.new("foo=bar") + parser.execution_stack.should == [{"statement" => "foo=bar"}] + end + + it "should parse statements seperated by '<'" do + parser = Parser.new("foo<bar") + parser.execution_stack.should == [{"statement" => "foo<bar"}] + end + + it "should parse statements seperated by '>'" do + parser = Parser.new("foo>bar") + parser.execution_stack.should == [{"statement" => "foo>bar"}] + end + + it "should parse statements seperated by '<='" do + parser = Parser.new("foo<=bar") + parser.execution_stack.should == [{"statement" => "foo<=bar"}] + end + + it "should parse statements seperated by '>='" do + parser = Parser.new("foo>=bar") + parser.execution_stack.should == [{"statement" => "foo>=bar"}] + end + + it "should parse class regex statements" do + parser = Parser.new("/foo/") + parser.execution_stack.should == [{"statement" => "/foo/"}] + end + + it "should parse fact regex statements" do + parser = Parser.new("foo=/bar/") + parser.execution_stack.should == [{"statement" => "foo=/bar/"}] + end + + it "should parse a correct 'and' token" do + parser = Parser.new("foo=bar and bar=foo") + parser.execution_stack.should == [{"statement" => "foo=bar"}, {"and" => "and"}, {"statement" => "bar=foo"}] + end + + it "should not parse an incorrect and token" do + expect { + parser = Parser.new("and foo=bar") + }.to raise_error("Error at column 10. \n Expression cannot start with 'and'") + end + + it "should parse a correct 'or' token" do + parser = Parser.new("foo=bar or bar=foo") + parser.execution_stack.should == [{"statement" => "foo=bar"}, {"or" => "or"}, {"statement" => "bar=foo"}] + end + + it "should not parse an incorrect and token" do + expect { + parser = Parser.new("or foo=bar") + }.to raise_error("Error at column 9. \n Expression cannot start with 'or'") + end + + it "should parse a correct 'not' token" do + parser = Parser.new("! bar=foo") + parser.execution_stack.should == [{"not" => "not"}, {"statement" => "bar=foo"}] + parser = Parser.new("not bar=foo") + parser.execution_stack.should == [{"not" => "not"}, {"statement" => "bar=foo"}] + end + + it "should not parse an incorrect 'not' token" do + expect { + parser = Parser.new("foo=bar !") + }.to raise_error("Error at column 8. \nExpected 'and', 'or', ')'. Found 'not'") + end + + it "should parse correct parentheses" do + parser = Parser.new("(foo=bar)") + parser.execution_stack.should == [{"(" => "("}, {"statement" => "foo=bar"}, {")" => ")"}] + end + + it "should fail on incorrect parentheses" do + expect { + parser = Parser.new(")foo=bar(") + }.to raise_error("Error. Missing parentheses '('.") + end + + it "should fail on missing parentheses" do + expect { + parser = Parser.new("(foo=bar") + }.to raise_error("Error. Missing parentheses ')'.") + end + + it "should parse correctly formatted compound statements" do + parser = Parser.new("(foo=bar or foo=rab) and (bar=foo)") + parser.execution_stack.should == [{"(" => "("}, {"statement"=>"foo=bar"}, {"or"=>"or"}, {"statement"=>"foo=rab"}, + {")"=>")"}, {"and"=>"and"}, {"("=>"("}, {"statement"=>"bar=foo"}, + {")"=>")"}] + end + + end + end + end +end diff --git a/spec/unit/matcher/scanner_spec.rb b/spec/unit/matcher/scanner_spec.rb new file mode 100644 index 0000000..aab2172 --- /dev/null +++ b/spec/unit/matcher/scanner_spec.rb @@ -0,0 +1,71 @@ +#! /usr/bin/env ruby + +require File.dirname(__FILE__) + '/../../spec_helper' + +module MCollective + module Matcher + describe 'scanner' do + it "should identify a '(' token" do + scanner = Scanner.new("(") + token = scanner.get_token + token.should == ["(", "("] + end + + it "should identify a ')' token" do + scanner = Scanner.new(")") + token = scanner.get_token + token.should == [")", ")"] + end + + it "should identify a 'and' token" do + scanner = Scanner.new("and ") + token = scanner.get_token + token.should == ["and", "and"] + end + + it "should identify a 'or' token" do + scanner = Scanner.new("or ") + token = scanner.get_token + token.should == ["or", "or"] + end + + it "should identify a 'not' token" do + scanner = Scanner.new("not ") + token = scanner.get_token + token.should == ["not", "not"] + end + + it "should identify a '!' token" do + scanner = Scanner.new("!") + token = scanner.get_token + token.should == ["not", "not"] + end + + it "should identify a fact statement token" do + scanner = Scanner.new("foo=bar") + token = scanner.get_token + token.should == ["statement", "foo=bar"] + end + + it "should identify a fact statement token" do + scanner = Scanner.new("foo=bar") + token = scanner.get_token + token.should == ["statement", "foo=bar"] + end + + it "should identify a class statement token" do + scanner = Scanner.new("/class/") + token = scanner.get_token + token.should == ["statement", "/class/"] + end + + it "should fail if expression terminates with 'and'" do + scanner = Scanner.new("and") + + expect { + token = scanner.get_token + }.to raise_error("Class name cannot be 'and', 'or', 'not'. Found 'and'") + end + end + end +end diff --git a/spec/unit/util_spec.rb b/spec/unit/util_spec.rb index 0f29b90..5b91f62 100644 --- a/spec/unit/util_spec.rb +++ b/spec/unit/util_spec.rb @@ -283,5 +283,28 @@ module MCollective Util.parse_fact_string("foo == bar").should == {:fact => "foo", :value => "bar", :operator => "=="} end end + + describe "#eval_compound_statement" do + it "should return correctly on a regex class statement" do + Util.expects(:has_cf_class?).with("/foo/").returns(true) + Util.eval_compound_statement({"statement" => "/foo/"}).should == true + Util.expects(:has_cf_class?).with("/foo/").returns(false) + Util.eval_compound_statement({"statement" => "/foo/"}).should == false + end + + it "should return correcly for string and regex facts" do + Util.expects(:has_fact?).with("foo", "bar", "==").returns(true) + Util.eval_compound_statement({"statement" => "foo=bar"}).should == "true" + Util.expects(:has_fact?).with("foo", "/bar/", "=~").returns(false) + Util.eval_compound_statement({"statement" => "foo=/bar/"}).should == "false" + end + + it "should return correctly on a string class statement" do + Util.expects(:has_cf_class?).with("foo").returns(true) + Util.eval_compound_statement({"statement" => "foo"}).should == true + Util.expects(:has_cf_class?).with("foo").returns(false) + Util.eval_compound_statement({"statement" => "foo"}).should == false + end + end end end diff --git a/website/simplerpc/clients.md b/website/simplerpc/clients.md index 5bd89aa..a19f1f1 100644 --- a/website/simplerpc/clients.md +++ b/website/simplerpc/clients.md @@ -186,7 +186,7 @@ mc.fact_filter "country", "uk" printrpc mc.echo(:msg => "Welcome to MCollective Simple RPC") {% endhighlight %} -You can set other filters like _agent`_`filter_ and _identity`_`filter_. +You can set other filters like _agent`_`filter_, _identity`_`filter_ and _compound`_`filter_. As of version 1.1.0 the fact_filter method supports a few other forms in adition to above: -- 1.7.1 -- You received this message because you are subscribed to the Google Groups "Puppet Developers" group. To post to this group, send email to puppet-dev@googlegroups.com. To unsubscribe from this group, send email to puppet-dev+unsubscr...@googlegroups.com. For more options, visit this group at http://groups.google.com/group/puppet-dev?hl=en.