Permalink
Browse files

Initial creation of the chef-solo-search project

The chef-solo-search 'patch' allows using search() statements in recipes
and using this recipes with chef-solo. For now only a basic sub-set of
the lucine query language is supported.
  • Loading branch information...
0 parents commit 37e5771828ffdbc2ef675652baeb853b8fa0c25f @thekorn thekorn committed Jul 27, 2011
@@ -0,0 +1,89 @@
+================
+chef-solo-search
+================
+
+This is a patch to add basic search() functionality to chef-solo.
+Please see `Supported queries` for a list of query types which are supported.
+
+Requirements
+============
+
+ * ruby >= 1.8
+ * ruby-chef >= 0.10
+
+Supported queries
+=================
+
+The search methods supportes a basic sub-set of the lucene query language.
+Sample supported queries are:
+
+ General queries:
+ ~~~~~~~~~~~~~~~~
+
+ search(:users, "*:*")
+ search(:users)
+ search(:users, nil)
+ getting all items in ':users'
+ search(:users, "username:*")
+ search(:users, "username:[* TO *]")
+ getting all items from ':users' which have a 'username' attribute
+ search(:users, "(NOT username:*)")
+ search(:users, "(NOT username:[* TO *])")
+ getting all items from ':users' which don't have a 'username' attribute
+
+ Queries on attributes with string values:
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ search(:users, "username:speedy")
+ getting all items from ':users' with username equals 'speedy'
+ search(:users, "NOT username:speedy")
+ getting all items from ':users' with username is unequal to 'speedy'
+ search(:users, "username:spe*")
+ getting all items which 'username'-value begins with 'spe'
+
+ Queries on attributes with array values:
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ search(:users, "children:tom")
+ getting all items which 'children' attribute contains 'tom'
+ search(:users, "children:t*")
+ getting all items which have at least one element in 'children'
+ which starts with 't'
+
+ Queries on attributes with boolean values:
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ search(:users, "married:true")
+
+ Queries in attributes with integer values:
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ search(:users, "age:35")
+
+ OR conditions in queries:
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ search(:users, "age:42 OR age:22")
+
+ AND conditions in queries:
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ search(:users, "married:true AND age:35")
+
+ NOT condition in queries:
+ ~~~~~~~~~~~~~~~~~~~~~~~~~
+
+ search(:users, "children:tom NOT gender:female")
+
+ More complex queries:
+ ~~~~~~~~~~~~~~~~~~~~~
+
+ search(:users, "children:tom NOT gender:female AND age:42")
+
+Running tests
+=============
+
+Running tests is as simple as:
+
+ % ruby tests/test_search.rb -v
+
@@ -0,0 +1,253 @@
+#
+# Copyright 2011, edelight GmbH
+#
+# Authors:
+# Markus Korn <markus.korn@edelight.de>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# based on Brian Akins's patch:
+# http://lists.opscode.com/sympa/arc/chef/2011-02/msg00000.html
+#
+
+if Chef::Config[:solo]
+
+ # Checks if a given `value` is equal to `match`
+ # If value is an Array, then `match` is checked against each of the value's
+ # members, and true is returned if any of the members matches.
+ # The comparison is string based, means: if value is not an Array then value
+ # gets converted into a string (using .to_s) and then checked.
+ def match_value(value, match)
+ if value.is_a?(Array)
+ return value.any?{ |x| match_value(x, match) }
+ else
+ return value.to_s == match
+ end
+ end
+
+ # Factory function to parse the query string into a `Query` object
+ # Returns `nil` if the query is not supported.
+ def make_query(query)
+ if query.nil? or query === "*:*"
+ return NilQuery.new(query)
+ end
+
+ query.gsub!("[* TO *]", "*")
+ if query.count("()") == 2 and query.start_with?("(") and query.end_with?(")")
+ query.tr!("()", "")
+ end
+
+ if query.include?(" AND ")
+ return AndQuery.new(query.split(" AND ").collect{ |x| make_query(x) })
+ elsif query.include?(" OR ")
+ return OrQuery.new(query.split(" OR ").collect{ |x| make_query(x) })
+ elsif query.include?(" NOT ")
+ return NotQuery.new(query.split(" NOT ").collect{ |x| make_query(x) })
+ end
+
+ if query.start_with?("NOT")
+ negation = true
+ query = query[3..-1] # strip leading NOT
+ else
+ negation = false
+ end
+
+ if query.split(":", 2).length == 2
+ field, query_string = query.split(":", 2)
+ if query_string.end_with?("*")
+ return WildCardFieldQuery.new(query, negation)
+ else
+ return FieldQuery.new(query, negation)
+ end
+ else
+ return nil
+ end
+ end
+
+ # BaseClass for all queries
+ class Query
+ def initialize( query )
+ @query = query
+ end
+
+ def match( item )
+ return false
+ end
+ end
+
+ # BaseClass for all queries with AND, OR or NOT conditions
+ class NestedQuery < Query
+ def initialize( conditions )
+ @conditions = conditions
+ end
+ end
+
+ # AndQuery matches if all sub-queries match
+ class AndQuery < NestedQuery
+ def match( item )
+ return @conditions.all?{ |x| x.match(item) }
+ end
+ end
+
+ # NotQuery matches if the *leftmost* condition matches, but the others don't
+ class NotQuery < NestedQuery
+ def match( item )
+ base_condition = @conditions[0]
+ last_conditions = @conditions[1..-1] || []
+ return base_condition.match(item) & last_conditions.all?{ |x| !x.match(item) }
+ end
+ end
+
+ # OrQuery matches if any of the sub-queries match
+ class OrQuery < NestedQuery
+ def match( item )
+ return @conditions.any?{ |x| x.match(item) }
+ end
+ end
+
+ # NilQuery always matches
+ class NilQuery < Query
+ def match( item )
+ return true
+ end
+ end
+
+ # FieldQuery looks for a certain attribute in the item to match and checks
+ # the value of this attribute for equality.
+ # If `negation` is true the oposite result will be returned.
+ class FieldQuery < Query
+ def initialize( query, negation=false )
+ @field, @query = query.split(":", 2)
+ @negation = negation
+ @field.strip!
+ end
+
+ def match( item )
+ value = item[@field]
+ if value.nil?
+ result = false
+ end
+ result = match_value(value, @query)
+ if @negation
+ return !result
+ else
+ return result
+ end
+ end
+ end
+
+ # WildCardFieldQuery is exactly like FieldQuery, but allows trailing stars.
+ # Instead of checking for exact matches it just checks if the value begins
+ # with a certain string, or (in case of an Array) any of its items value
+ # begins with a string.
+ class WildCardFieldQuery < FieldQuery
+
+ def initialize( query, negation=false )
+ super
+ @query = @query.chop
+ end
+
+ def match( item )
+ value = item[@field]
+ if value.nil?
+ result = false
+ elsif value.is_a?(String)
+ if value.strip().empty?
+ result = true
+ else
+ result = value.start_with?(@query)
+ end
+ else
+ result = value.any?{ |x| x.start_with?(@query) }
+ end
+ if @negation
+ return !result
+ else
+ return result
+ end
+ end
+ end
+
+ class Chef
+ module Mixin
+ module Language
+ # Hook into Chef which reads all items in a given `bag` and converts
+ # them into one single Hash
+ def data_bag(bag)
+ @solo_data_bags = {} if @solo_data_bags.nil?
+ unless @solo_data_bags[bag]
+ @solo_data_bags[bag] = {}
+ data_bag_path = Chef::Config[:data_bag_path]
+ Dir.glob(File.join(data_bag_path, bag, "*.json")).each do |f|
+ item = JSON.parse(IO.read(f))
+ @solo_data_bags[bag][item['id']] = item
+ end
+ end
+ @solo_data_bags[bag].keys
+ end
+
+ # Hook into Chef which returns the ruby representation of a given
+ # data_bag item
+ def data_bag_item(bag, item)
+ data_bag(bag) unless ( !@solo_data_bags.nil? && @solo_data_bags[bag])
+ @solo_data_bags[bag][item]
+ end
+
+ end
+ end
+ end
+
+ class Chef
+ class Recipe
+
+ # Overwrite the search method of recipes to operate locally by using
+ # data found in data_bags.
+ # Only very basic lucene syntax is supported and also sorting the result
+ # is not implemented, if this search method does not support a given query
+ # an exception is raised.
+ # This search() method returns a block iterator or an Array, depending
+ # on how this method is called.
+ def search(bag_name, query=nil, sort=nil, start=0, rows=1000, &block)
+ if !sort.nil?
+ raise "Sorting search results is not supported"
+ end
+ @_query = make_query(query)
+ if @_query.nil?
+ raise "Query #{query} is not supported"
+ end
+ if block_given?
+ pos = 0
+ else
+ result = []
+ end
+ data_bag(bag_name.to_s).each do |bag_item_id|
+ bag_item = data_bag_item(bag_name.to_s, bag_item_id)
+ if @_query.match(bag_item)
+ if block_given?
+ if (pos >= start and pos < (start + rows))
+ yield bag_item
+ end
+ pos += 1
+ else
+ result << bag_item
+ end
+ end
+ end
+ if !block_given?
+ return result.slice(start, rows)
+ end
+ end
+ end
+ end
+end
+
@@ -0,0 +1,8 @@
+{
+ "id": "jerry",
+ "username": "speedy",
+ "age": 22,
+ "gender": "male",
+ "married": true,
+ "color": "green"
+}
@@ -0,0 +1,10 @@
+{
+ "id": "lea",
+ "username": "lea",
+ "age": 35,
+ "gender": "female",
+ "married": true,
+ "children": ["tom"],
+ "tag": "tag::test",
+ "tags": ["tag::first", "tag::second"]
+}
@@ -0,0 +1,8 @@
+{
+ "id": "mike",
+ "username": "mike the hammer",
+ "age": 42,
+ "gender": "male",
+ "married": true,
+ "children": ["tom", "jerry"]
+}
@@ -0,0 +1,7 @@
+{
+ "id": "tom",
+ "username": "tom von homme",
+ "age": 13,
+ "gender": "male",
+ "married": false
+}
Oops, something went wrong.

0 comments on commit 37e5771

Please sign in to comment.