Please review pull request #703: usability improvements to resource_type API opened by (cprice-puppet)
Description:
These commits do the following:
- Allow you to filter the output of a "resource_type" search by "kind" (defined_type, class, node)
- Change the output to use terminology that is more consistent with our documentation
- Opened: Tue Apr 24 00:46:03 UTC 2012
- Based on: puppetlabs:master (54e1c83afde1b721e5433adea17a4dd0caffbc63)
- Requested merge: cprice-puppet:feature/master/filter_resource_type_by_type (0e41832a8045c0784544284f0089ec5ba08d8495)
Diff follows:
diff --git a/lib/puppet/indirector/resource_type/parser.rb b/lib/puppet/indirector/resource_type/parser.rb
index 4bcaf3f..6afb554 100644
--- a/lib/puppet/indirector/resource_type/parser.rb
+++ b/lib/puppet/indirector/resource_type/parser.rb
@@ -20,11 +20,44 @@ def find(request)
nil
end
+ # This is the "search" indirection method for resource types. It searches
+ # through a specified environment for all custom declared classes
+ # (a.k.a 'hostclasses'), defined types (a.k.a. 'definitions'), and nodes.
+ #
+ # @param [Puppet::Indirector::Request] request
+ # Important properties of the request parameter:
+ # 1. request.environment : The environment in which to look for types.
+ # 2. request.key : A String that will be treated as a regular _expression_ to
+ # be matched against the names of the available types. You may also
+ # pass a "*", which will match all available types.
+ # 3. request.options[:kind] : a String that can be used to filter the output
+ # to only return the desired kinds. The current supported values are
+ # 'class', 'defined_type', and 'node'.
def search(request)
krt = request.environment.known_resource_types
# Make sure we've got all of the types loaded.
krt.loader.import_all
- result = [krt.hostclasses.values, krt.definitions.values, krt.nodes.values].flatten.reject { |t| t.name == "" }
+
+ result_candidates = []
+ # We need to check and see if the request contains a 'kind' filter, and, if so, filter accordingly.s
+ if request.options.has_key?(:kind)
+ case request.options[:kind]
+ when "class"
+ result_candidates = krt.hostclasses.values
+ when "defined_type"
+ result_candidates = krt.definitions.values
+ when "node"
+ result_candidates = krt.nodes.values
+ else
+ raise ArgumentError, "Unrecognized kind filter: " +
+ "'#{request.options[:kind]}', expected one " +
+ " of 'class', 'defined_type', or 'node'."
+ end
+ else
+ result_candidates = [krt.hostclasses.values, krt.definitions.values, krt.nodes.values]
+ end
+
+ result = result_candidates.flatten.reject { |t| t.name == "" }
return nil if result.empty?
return result if request.key == "*"
diff --git a/lib/puppet/network/format.rb b/lib/puppet/network/format.rb
index 69895c3..940ec09 100644
--- a/lib/puppet/network/format.rb
+++ b/lib/puppet/network/format.rb
@@ -68,9 +68,9 @@ def render(instance)
raise NotImplementedError, "#{instance.class} does not respond to #{render_method}; can not render instances to #{mime}"
end
- def render_multiple(instances)
+ def render_multiple(instances, options = {})
# This method implicitly assumes that all instances are of the same type.
- return instances[0].class.send(render_multiple_method, instances) if instances[0].class.respond_to?(render_multiple_method)
+ return instances[0].class.send(render_multiple_method, instances, options) if instances[0].class.respond_to?(render_multiple_method)
raise NotImplementedError, "#{instances[0].class} does not respond to #{render_multiple_method}; can not intern multiple instances to #{mime}"
end
diff --git a/lib/puppet/network/format_handler.rb b/lib/puppet/network/format_handler.rb
index b94a4f9..1c8e3f0 100644
--- a/lib/puppet/network/format_handler.rb
+++ b/lib/puppet/network/format_handler.rb
@@ -112,8 +112,16 @@ def convert_from_multiple(format, data)
format_handler.protected_format(format).intern_multiple(self, data)
end
- def render_multiple(format, instances)
- format_handler.protected_format(format).render_multiple(instances)
+ # render an array of objects
+ #
+ # @param [Puppet::Netork::Format] - the formatter to use to render with
+ # @param [Array] instances - the list of objects to render
+ # @param [Hash] options - an optional hash of rendering hints / options to use to
+ # control formatting. Options/hints may not be supported by all renderers.
+ # Example options are:
+ # * :pp - "pretty print". request for output to be formatted in a more human-readable fashion.
+ def render_multiple(format, instances, options = {})
+ format_handler.protected_format(format).render_multiple(instances, options)
end
def default_format
diff --git a/lib/puppet/network/formats.rb b/lib/puppet/network/formats.rb
index c5af288..c29ae77 100644
--- a/lib/puppet/network/formats.rb
+++ b/lib/puppet/network/formats.rb
@@ -16,7 +16,7 @@ def render(instance)
end
# Yaml monkey-patches Array, so this works.
- def render_multiple(instances)
+ def render_multiple(instances, options = {})
instances.to_yaml
end
@@ -56,7 +56,7 @@ def render(instance)
encode(instance.to_yaml)
end
- def render_multiple(instances)
+ def render_multiple(instances, options = {})
encode(instances.to_yaml)
end
@@ -85,7 +85,7 @@ def intern_multiple(klass, text)
raise NotImplementedError
end
- def render_multiple(instances)
+ def render_multiple(instances, options = {})
raise NotImplementedError
end
@@ -113,9 +113,19 @@ def intern_multiple(klass, text)
end
end
- # PSON monkey-patches Array, so this works.
- def render_multiple(instances)
- instances.to_pson
+ # render an array of objects as PSON.
+ #
+ # @param [Array] instances - the list of objects to render
+ # @param [Hash] options - an optional hash of rendering hints / options to use to
+ # control formatting. Currently supported options are:
+ # * :pp - "pretty print". output will be formatted in a more human-readable fashion.
+ def render_multiple(instances, options = {})
+ if (options.has_key?(:pp) and options[:pp] != false)
+ PSON::pretty_generate(instances)
+ else
+ # PSON monkey-patches Array, so this works.
+ instances.to_pson
+ end
end
# If they pass class information, we want to ignore it. By default,
@@ -165,7 +175,7 @@ def render(datum)
return json.render(datum)
end
- def render_multiple(data)
+ def render_multiple(data, options = {})
data.collect(&:render).join("\n")
end
end
diff --git a/lib/puppet/network/http/api/v1.rb b/lib/puppet/network/http/api/v1.rb
index ef19fe4..29146ff 100644
--- a/lib/puppet/network/http/api/v1.rb
+++ b/lib/puppet/network/http/api/v1.rb
@@ -54,7 +54,7 @@ def indirection_method(http_method, indirection)
raise ArgumentError, "No support for http method #{http_method}" unless METHOD_MAP[http_method]
unless method = METHOD_MAP[http_method][plurality(indirection)]
- raise ArgumentError, "No support for plural #{http_method} operations"
+ raise ArgumentError, "No support for plurality #{plurality(indirection)} for #{http_method} operations"
end
method
diff --git a/lib/puppet/network/http/handler.rb b/lib/puppet/network/http/handler.rb
index c25798d..b97091c 100644
--- a/lib/puppet/network/http/handler.rb
+++ b/lib/puppet/network/http/handler.rb
@@ -143,7 +143,12 @@ def do_search(indirection_name, key, params, request, response)
format = format_to_use(request)
set_content_type(response, format)
- set_response(response, model.render_multiple(format, result))
+ render_options = {}
+ if (params.has_key?(:pp))
+ render_options[:pp] = true
+ end
+
+ set_response(response, model.render_multiple(format, result, render_options))
end
# Execute our destroy.
diff --git a/lib/puppet/resource/type.rb b/lib/puppet/resource/type.rb
index 72dbf22..abd2853 100644
--- a/lib/puppet/resource/type.rb
+++ b/lib/puppet/resource/type.rb
@@ -11,12 +11,35 @@ class Puppet::Resource::Type
include Puppet::Util::Warnings
include Puppet::Util::Errors
- RESOURCE_SUPERTYPES = [:hostclass, :node, :definition]
+ RESOURCE_KINDS = [:hostclass, :node, :definition]
+
+ # We have reached a point where we've established some naming conventions
+ # in our documentation that don't entirely match up with our internal names
+ # for things. Ideally we'd change the internal representation to match the
+ # conventions expressed in our docs, but that would be a fairly far-reaching
+ # and risky change. For the time being, we're settling for mapping the
+ # internal names to the external ones (and vice-versa) during serialization
+ # and deserialization. These two hashes is here to help with that mapping.
+ RESOURCE_KINDS_TO_EXTERNAL_NAMES = {
+ :hostclass => "class",
+ :node => "node",
+ :definition => "defined_type",
+ }
+ RESOURCE_EXTERNAL_NAMES_TO_KINDS = {
+ "class" => :hostclass,
+ "node" => :node,
+ "defined_type" => :definition,
+ }
attr_accessor :file, :line, :doc, :code, :ruby_code, :parent, :resource_type_collection
- attr_reader :type, :namespace, :arguments, :behaves_like, :module_name
+ attr_reader :namespace, :arguments, :behaves_like, :module_name
- RESOURCE_SUPERTYPES.each do |t|
+ # This should probably be renamed to 'kind' eventually, in accordance with the changes
+ # made for serialization and API usability (#14137). At the moment that seems like
+ # it would touch a whole lot of places in the code, though. --cprice 2012-04-23
+ attr_reader :type
+
+ RESOURCE_KINDS.each do |t|
define_method("#{t}?") { self.type == t }
end
@@ -26,13 +49,36 @@ class Puppet::Resource::Type
def self.from_pson(data)
name = data.delete('name') or raise ArgumentError, "Resource Type names must be specified"
- type = data.delete('type') || "definition"
+ kind = data.delete('kind') || "definition"
+
+ unless RESOURCE_EXTERNAL_NAMES_TO_KINDS.has_key?(kind)
+ raise ArgumentError, "Unsupported resource kind '#{kind}'"
+ end
+
+ type = RESOURCE_EXTERNAL_NAMES_TO_KINDS[kind]
data = "" { |result, ary| result[ary[0].intern] = ary[1]; result }
+ # This is a bit of a hack; when we serialize, we use the term "parameters" because that
+ # is the terminology that we use in our documentation. However, internally to this
+ # class we use the term "arguments". Ideally we'd change the implementation to be consistent
+ # with the documentation, but that would be challenging right now because it could potentially
+ # touch a lot of places in the code, not to mention that we already have another meaning for
+ # "parameters" internally. So, for now, we will simply transform the internal "arguments"
+ # value to "parameters" when serializing, and the opposite when deserializing.
+ # --cprice 2012-04-23
+ data[:arguments] = data.delete(:parameters)
+
new(type, name, data)
end
+ # This method doesn't seem like it has anything to do with PSON in particular, and it shouldn't.
+ # It's just transforming to a simple object that can be serialized and de-serialized via
+ # any transport format. Should probably be renamed if we get a chance to clean up our
+ # serialization / deserialization, and there are probably many other similar methods in
+ # other classes.
+ # --cprice 2012-04-23
+
def to_pson_data_hash
data = "" :line, :file, :parent].inject({}) do |hash, param|
next hash unless (value = self.send(param)) and (value != "")
@@ -40,14 +86,34 @@ def to_pson_data_hash
hash
end
- data['arguments'] = arguments.dup unless arguments.empty?
+ # This is a bit of a hack; when we serialize, we use the term "parameters" because that
+ # is the terminology that we use in our documentation. However, internally to this
+ # class we use the term "arguments". Ideally we'd change the implementation to be consistent
+ # with the documentation, but that would be challenging right now because it could potentially
+ # touch a lot of places in the code, not to mention that we already have another meaning for
+ # "parameters" internally. So, for now, we will simply transform the internal "arguments"
+ # value to "parameters" when serializing, and the opposite when deserializing.
+ # --cprice 2012-04-23
+ data['parameters'] = arguments.dup unless arguments.empty?
data['name'] = name
- data['type'] = type
+ unless RESOURCE_KINDS_TO_EXTERNAL_NAMES.has_key?(type)
+ raise ArgumentError, "Unsupported resource kind '#{type}'"
+ end
+ data['kind'] = RESOURCE_KINDS_TO_EXTERNAL_NAMES[type]
data
end
+ # It seems wrong that we have a 'to_pson' method on this class, but not a 'to_yaml'.
+ # As a result, if you use the REST API to retrieve one or more objects of this type,
+ # you will receive different data if you use 'Accept: yaml' vs 'Accept: pson'. That
+ # seems really, really wrong. The "Accept" header should never affect what data is
+ # being returned--only the format of the data. If the data itself is going to differ,
+ # then there should be a different request URL. Documenting the REST API becomes
+ # a much more complex problem when the "Accept" header can change the semantics
+ # of the response. --cprice 2012-04-23
+
def to_pson(*args)
to_pson_data_hash.to_pson(*args)
end
@@ -80,7 +146,7 @@ def evaluate_code(resource)
def initialize(type, name, options = {})
@type = type.to_s.downcase.to_sym
- raise ArgumentError, "Invalid resource supertype '#{type}'" unless RESOURCE_SUPERTYPES.include?(@type)
+ raise ArgumentError, "Invalid resource supertype '#{type}'" unless RESOURCE_KINDS.include?(@type)
name = convert_from_ast(name) if name.is_a?(Puppet::Parser::AST::HostName)
diff --git a/spec/unit/indirector/resource_type/parser_spec.rb b/spec/unit/indirector/resource_type/parser_spec.rb
index fa2aa10..734faf8 100755
--- a/spec/unit/indirector/resource_type/parser_spec.rb
+++ b/spec/unit/indirector/resource_type/parser_spec.rb
@@ -56,94 +56,158 @@
end
describe "when searching" do
- before do
- @request.key = "*"
+ describe "when the search key is a wildcard" do
+ before do
+ @request.key = "*"
+ end
+
+ it "should use the request's environment's list of known resource types" do
+ @request.environment.known_resource_types.expects(:hostclasses).returns({})
+
+ @terminus.search(@request)
+ end
+
+ it "should return all results if '*' is provided as the search string" do
+ type = @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
+ node = @krt.add(Puppet::Resource::Type.new(:node, "bar"))
+ define = @krt.add(Puppet::Resource::Type.new(:definition, "baz"))
+
+ result = @terminus.search(@request)
+ result.should be_include(type)
+ result.should be_include(node)
+ result.should be_include(define)
+ end
+
+ it "should return all known types" do
+ type = @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
+ node = @krt.add(Puppet::Resource::Type.new(:node, "bar"))
+ define = @krt.add(Puppet::Resource::Type.new(:definition, "baz"))
+
+ result = @terminus.search(@request)
+ result.should be_include(type)
+ result.should be_include(node)
+ result.should be_include(define)
+ end
+
+ it "should not return the 'main' class" do
+ main = @krt.add(Puppet::Resource::Type.new(:hostclass, ""))
+
+ # So there is a return value
+ foo = @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
+
+ @terminus.search(@request).should_not be_include(main)
+ end
+
+ it "should return nil if no types can be found" do
+ @terminus.search(@request).should be_nil
+ end
+
+ it "should load all resource types from all search paths" do
+ dir = tmpdir("searching_in_all")
+ first = File.join(dir, "first")
+ second = File.join(dir, "second")
+ FileUtils.mkdir_p(first)
+ FileUtils.mkdir_p(second)
+ Puppet[:modulepath] = "#{first}#{File::PATH_SEPARATOR}#{second}"
+
+ # Make a new request, since we've reset the env
+ @request = Puppet::Indirector::Request.new(:resource_type, :search, "*")
+
+ _onepath_ = File.join(first, "one", "manifests")
+ FileUtils.mkdir_p(onepath)
+ twopath = File.join(first, "two", "manifests")
+ FileUtils.mkdir_p(twopath)
+
+ File.open(File.join(onepath, "oneklass.pp"), "w") { |f| f.puts "class one::oneklass {}" }
+ File.open(File.join(twopath, "twoklass.pp"), "w") { |f| f.puts "class two::twoklass {}" }
+
+ result = @terminus.search(@request)
+ result.find { |t| t.name == "one::oneklass" }.should be_instance_of(Puppet::Resource::Type)
+ result.find { |t| t.name == "two::twoklass" }.should be_instance_of(Puppet::Resource::Type)
+ end
+
+ context "when specifying a 'kind' parameter" do
+ before :each do
+ @klass = @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
+ @node = @krt.add(Puppet::Resource::Type.new(:node, "bar"))
+ @define = @krt.add(Puppet::Resource::Type.new(:definition, "baz"))
+ end
+
+ it "should raise an error if you pass an invalid kind filter" do
+ @request.options[:kind] = "i bet you don't have a kind called this"
+ expect {
+ @terminus.search(@request)
+ }.to raise_error(ArgumentError, /Unrecognized kind filter/)
+
+ end
+
+ it "should support filtering for only hostclass results" do
+ @request.options[:kind] = "class"
+
+ result = @terminus.search(@request)
+ result.should be_include(@klass)
+ result.should_not be_include(@node)
+ result.should_not be_include(@define)
+ end
+
+ it "should support filtering for only node results" do
+ @request.options[:kind] = "node"
+
+ result = @terminus.search(@request)
+ result.should_not be_include(@klass)
+ result.should be_include(@node)
+ result.should_not be_include(@define)
+ end
+
+ it "should support filtering for only definition results" do
+ @request.options[:kind] = "defined_type"
+
+ result = @terminus.search(@request)
+ result.should_not be_include(@klass)
+ result.should_not be_include(@node)
+ result.should be_include(@define)
+ end
+ end
end
- it "should use the request's environment's list of known resource types" do
- @request.environment.known_resource_types.expects(:hostclasses).returns({})
-
- @terminus.search(@request)
- end
-
- it "should return all results if '*' is provided as the search string" do
- @request.key = "*"
- type = @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
- node = @krt.add(Puppet::Resource::Type.new(:node, "bar"))
- define = @krt.add(Puppet::Resource::Type.new(:definition, "baz"))
-
- result = @terminus.search(@request)
- result.should be_include(type)
- result.should be_include(node)
- result.should be_include(define)
- end
-
- it "should treat any search string not '*' as a regex" do
- @request.key = "a"
- foo = @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
- bar = @krt.add(Puppet::Resource::Type.new(:hostclass, "bar"))
- baz = @krt.add(Puppet::Resource::Type.new(:hostclass, "baz"))
-
- result = @terminus.search(@request)
- result.should be_include(bar)
- result.should be_include(baz)
- result.should_not be_include(foo)
- end
-
- it "should fail if a provided search string is not '*' and is not a valid regex" do
- @request.key = "*foo*"
-
- # Add one instance so we don't just get an empty array"
- @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
- lambda { @terminus.search(@request) }.should raise_error(ArgumentError)
+ context "when the search string is not a wildcard" do
+
+ it "should treat any search string as a regex" do
+ @request.key = "a"
+ foo = @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
+ bar = @krt.add(Puppet::Resource::Type.new(:hostclass, "bar"))
+ baz = @krt.add(Puppet::Resource::Type.new(:hostclass, "baz"))
+
+ result = @terminus.search(@request)
+ result.should be_include(bar)
+ result.should be_include(baz)
+ result.should_not be_include(foo)
+ end
+
+ it "should support kind filtering with a regex" do
+ @request.key = "foo"
+ @request.options[:kind] = "class"
+
+ foobar = @krt.add(Puppet::Resource::Type.new(:hostclass, "foobar"))
+ foobaz = @krt.add(Puppet::Resource::Type.new(:hostclass, "foobaz"))
+ foobam = @krt.add(Puppet::Resource::Type.new(:definition, "foobam"))
+ fooball = @krt.add(Puppet::Resource::Type.new(:node, "fooball"))
+
+ result = @terminus.search(@request)
+ result.should be_include(foobar)
+ result.should be_include(foobaz)
+ result.should_not be_include(foobam)
+ result.should_not be_include(fooball)
+ end
+
+ it "should fail if a provided search string is not a valid regex" do
+ @request.key = "*foo*"
+
+ # Add one instance so we don't just get an empty array"
+ @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
+ lambda { @terminus.search(@request) }.should raise_error(ArgumentError)
+ end
end
- it "should return all known types" do
- type = @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
- node = @krt.add(Puppet::Resource::Type.new(:node, "bar"))
- define = @krt.add(Puppet::Resource::Type.new(:definition, "baz"))
-
- result = @terminus.search(@request)
- result.should be_include(type)
- result.should be_include(node)
- result.should be_include(define)
- end
-
- it "should not return the 'main' class" do
- main = @krt.add(Puppet::Resource::Type.new(:hostclass, ""))
-
- # So there is a return value
- foo = @krt.add(Puppet::Resource::Type.new(:hostclass, "foo"))
-
- @terminus.search(@request).should_not be_include(main)
- end
-
- it "should return nil if no types can be found" do
- @terminus.search(@request).should be_nil
- end
-
- it "should load all resource types from all search paths" do
- dir = tmpdir("searching_in_all")
- first = File.join(dir, "first")
- second = File.join(dir, "second")
- FileUtils.mkdir_p(first)
- FileUtils.mkdir_p(second)
- Puppet[:modulepath] = "#{first}#{File::PATH_SEPARATOR}#{second}"
-
- # Make a new request, since we've reset the env
- @request = Puppet::Indirector::Request.new(:resource_type, :search, "*")
-
- _onepath_ = File.join(first, "one", "manifests")
- FileUtils.mkdir_p(onepath)
- twopath = File.join(first, "two", "manifests")
- FileUtils.mkdir_p(twopath)
-
- File.open(File.join(onepath, "oneklass.pp"), "w") { |f| f.puts "class one::oneklass {}" }
- File.open(File.join(twopath, "twoklass.pp"), "w") { |f| f.puts "class two::twoklass {}" }
-
- result = @terminus.search(@request)
- result.find { |t| t.name == "one::oneklass" }.should be_instance_of(Puppet::Resource::Type)
- result.find { |t| t.name == "two::twoklass" }.should be_instance_of(Puppet::Resource::Type)
- end
end
end
diff --git a/spec/unit/network/format_handler_spec.rb b/spec/unit/network/format_handler_spec.rb
index 8b535c3..e4ed2e6 100755
--- a/spec/unit/network/format_handler_spec.rb
+++ b/spec/unit/network/format_handler_spec.rb
@@ -168,13 +168,13 @@ class FormatTester
end
it "should be able to use a specific hook for rendering multiple instances" do
- @format.expects(:render_multiple).with("mydata")
+ @format.expects(:render_multiple).with("mydata", {})
FormatTester.render_multiple(:my_format, "mydata")
end
it "should raise a FormatError when an exception is encountered when rendering multiple items into a format" do
- @format.expects(:render_multiple).with("mydata").raises "foo"
+ @format.expects(:render_multiple).with("mydata", {}).raises "foo"
lambda { FormatTester.render_multiple(:my_format, "mydata") }.should raise_error(Puppet::Network::FormatHandler::FormatError)
end
end
diff --git a/spec/unit/network/http/handler_spec.rb b/spec/unit/network/http/handler_spec.rb
index c709d82..871a011 100755
--- a/spec/unit/network/http/handler_spec.rb
+++ b/spec/unit/network/http/handler_spec.rb
@@ -313,7 +313,7 @@ def stub_server_interface
@indirection.stubs(:search).returns(@result)
- @model_class.expects(:render_multiple).with(@oneformat, @result).returns "my rendered instances"
+ @model_class.expects(:render_multiple).with(@oneformat, @result, {}).returns "my rendered instances"
@handler.expects(:set_response).with { |response, data| data == "my rendered instances" }
@handler.do_search("my_handler", "my_result", {}, @request, @response)
@@ -322,7 +322,7 @@ def stub_server_interface
it "should return [] when searching returns an empty array" do
@handler.expects(:accept_header).with(@request).returns "one,two"
@indirection.stubs(:search).returns([])
- @model_class.expects(:render_multiple).with(@oneformat, []).returns "[]"
+ @model_class.expects(:render_multiple).with(@oneformat, [], {}).returns "[]"
@handler.expects(:set_response).with { |response, data| data == "[]" }
-- You received this message because you are subscribed to the Google Groups "Puppet Developers" group.
To post to this group, send email to [email protected].
To unsubscribe from this group, send email to [email protected].
For more options, visit this group at http://groups.google.com/group/puppet-dev?hl=en.
