On Oct 6, 11:00 am, Luke Kanies <[email protected]> wrote:
> On Oct 5, 2009, at 8:56 AM, Michael Gliwinski wrote:
>
>
Attached is a patch, which I thought to float to get some comments.
I'll polish it some more and add tests.
* autorequire all file resources using the bucket, is this the right/
clean way to ensure a commit/tag is done @ the end
* Don't like the bounce of addfile from one class to the other I
suppose ProxyClient would help
* will factor out more common bits out of the 2 repo classes.
* Don't like the way the filebucket resource logs dirty -> commit, but
don't know how to suppress it
*
>
>
>
> > On Monday 28 September 2009 09:16:37 sam wrote:
> >> On Sep 28, 3:02 pm, Luke Kanies <[email protected]> wrote:
> >>> On Sep 27, 2009, at 5:41 PM, sam wrote:
> >>>> Hello,
> >>>> I am thinking of wiring filebucket to save to a git repo.
> >>>> It would allow diffs and history, is that something worthwhile for
> >>>> inclusion ?
>
> >>> I think it's a great idea. In fact, I've written a prototype of it:
>
> >>>http://gist.github.com/77811
>
> >>> It's just a thin executable, without the Puppet integration, and
> >>> it's
> >>> all execs rather than library calls. It also doesn't do any of the
> >>> history, which you'd obviously want -- it's just the blobs, with no
> >>> branches or anything.
>
> >>> I'd love to have this supported. How were you thinking of doing it?
>
> >> the filebucket would store the replaced files in a git repo on the
> >> local host, using rubygem-git and commit at the end of a puppet
> >> run, a
> >> file would be placed into $GITROOT/$FULLPATH of original file. no
> >> symlinks.
> >> filebucket { main: path => git://$gitpath }
>
> >> A centralized git server I suppose is nice, keep a branch per server
> >> (all lost on server renames). Would the best way be keep a git
> >> clone -
> >> l per server, then pull, add the file, then push back to the branch ?
> >> sounds like a bottle neck. if there is interest I would prefer to
> >> keep
> >> it as a stage 2.
>
> >> The history/diffs would be something a person would run on the git
> >> repo themselves, I don't have good ideas of integrating that part
> >> into
> >> puppet or it's usability. it's so much easier to use git to find the
> >> rev you want and you would need to add the file back to puppetmaster
> >> manually as the restored file would have been a puppet template or a
> >> file resource (tidy aside)
>
> >> did you expect more or have a more thought out idea?
> > This is a very good idea in general. I would however urge you to
> > consider
> > having a thin "glue" layer between the client code and the actual
> > VCS (in
> > this case git). In other words what I mean is, regardless if you call
> > commands or use a library to talk to git, do it through an
> > abstraction layer.
> > This way it would be possible for ppl to use different VC systems.
>
This patch makes git and the current filebucket as SCM providers so
it's trivial (if Luke's concerns are addressed) to add a 3rd or 4th
but yeah it could be made easier.
> > In my company we use bazaar for example and I know some ppl on the
> > list use
> > subversion, it would be better to allow them to use their VCS of
> > choice.
>
> I've thought about this a bit, and if it's possible I'd like to do so,
> but I'm not sure it is actually possible.
>
> The problem is that git actually works really well as a content-
> addressable file system -- you can provide content and get a checksum
> back, and provide a checksum and get content back. While other VCSes
> do well at the basic interactions, git's design as a CAF first and VCS
> second gives it additional functionality here.
>
true, can we not make do with md5 as the uri for content in the
"filebucket directory hashing" SCM and path + version is the uri for
svn?
> I'd like to be proven wrong, though.
>
> --
> One of the Ten Commandments for Technicians:
> (7) Work thou not on energized equipment, for if thou dost, thy
> fellow workers will surely buy beers for thy widow and
> console her in other ways.
> ---------------------------------------------------------------------
> Luke Kanies |http://reductivelabs.com|http://madstop.com
diff --git a/lib/puppet/application/filebucket.rb b/lib/puppet/
application/filebucket.rb
index 0723054..f1da359 100644
--- a/lib/puppet/application/filebucket.rb
+++ b/lib/puppet/application/filebucket.rb
@@ -70,10 +70,10 @@ Puppet::Application.new(:filebucket) do
begin
if options[:local] or options[:bucket]
path = options[:bucket] || Puppet[:bucketdir]
- @client = Puppet::Network::Client.dipper.new(:Path =>
path)
+ @client = Puppet::Network::Client.dipper.new(:Path =>
path, :Scheme => :dhash)
else
require 'puppet/network/handler'
- @client = Puppet::Network::Client.dipper.new(:Server
=> Puppet[:server])
+ @client = Puppet::Network::Client.dipper.new(:Server
=> Puppet[:server], :Scheme => :dhash)
end
rescue => detail
$stderr.puts detail
diff --git a/lib/puppet/network/client/dipper.rb b/lib/puppet/network/
client/dipper.rb
index 0e2dc14..b16187a 100644
--- a/lib/puppet/network/client/dipper.rb
+++ b/lib/puppet/network/client/dipper.rb
@@ -4,15 +4,14 @@ class Puppet::Network::Client::Dipper <
Puppet::Network::Client
@drivername = :Bucket
attr_accessor :name
-
# Create our bucket client
def initialize(hash = {})
if hash.include?(:Path)
- bucket = self.class.handler.new(:Path => hash[:Path])
+ bucket = self.class.handler.new(:Path => hash
[:Path], :Scheme => hash[:Scheme])
hash.delete(:Path)
+ hash.delete(:Server)
hash[:Bucket] = bucket
end
-
super(hash)
end
@@ -81,5 +80,8 @@ class Puppet::Network::Client::Dipper <
Puppet::Network::Client
return nil
end
end
-end
+ def commit(version)
+ return @driver.commit(version) if local?
+ end
+end
diff --git a/lib/puppet/network/handler/filebucket.rb b/lib/puppet/
network/handler/filebucket.rb
index 4973886..b81ebe5 100755
--- a/lib/puppet/network/handler/filebucket.rb
+++ b/lib/puppet/network/handler/filebucket.rb
@@ -1,6 +1,8 @@
require 'fileutils'
require 'digest/md5'
require 'puppet/external/base64'
+require 'puppet/util/dhashbucket'
+require 'puppet/util/gitbucket'
class Puppet::Network::Handler # :nodoc:
class BucketError < RuntimeError; end
@@ -17,27 +19,10 @@ class Puppet::Network::Handler # :nodoc:
}
Puppet::Util.logmethods(self, true)
- attr_reader :name, :path
-
- # this doesn't work for relative paths
- def self.oldpaths(base,md5)
- return [
- File.join(base, md5),
- File.join(base, md5, "contents"),
- File.join(base, md5, "paths")
- ]
- end
+ attr_reader :name, :path, :scheme
+
+ @@schemeList = { :dhash => DhashBucket, :git => GitBucket }
- # this doesn't work for relative paths
- def self.paths(base,md5)
- dir = File.join(md5[0..7].split(""))
- basedir = File.join(base, dir, md5)
- return [
- basedir,
- File.join(basedir, "contents"),
- File.join(basedir, "paths")
- ]
- end
# Should we check each file as it comes in to make sure the
md5
# sums match? Defaults to false.
@@ -46,6 +31,9 @@ class Puppet::Network::Handler # :nodoc:
end
def initialize(hash)
+ hash[:Scheme] = hash[:Scheme] || :dhash
+ @scheme = @@schemeList[ hash[:Scheme] ]
+
if hash.include?(:ConflictCheck)
@conflictchk = hash[:ConflictCheck]
hash.delete(:ConflictCheck)
@@ -67,6 +55,7 @@ class Puppet::Network::Handler # :nodoc:
Puppet.settings.use(:filebucket)
@name = "filebucket...@path}]"
+
end
# Accept a file from a client and store it by md5 sum,
returning
@@ -75,108 +64,20 @@ class Puppet::Network::Handler # :nodoc:
if client
contents = Base64.decode64(contents)
end
- md5 = Digest::MD5.hexdigest(contents)
-
- bpath, bfile, pathpath = FileBucket.paths(@path,md5)
-
- # If the file already exists, just return the md5 sum.
- if FileTest.exists?(bfile)
- # If verification is enabled, then make sure the text
matches.
- if conflict_check?
- verify(contents, md5, bfile)
- end
- return md5
- end
-
- # Make the directories if necessary.
- unless FileTest.directory?(bpath)
- Puppet::Util.withumask(0007) do
- FileUtils.mkdir_p(bpath)
- end
- end
-
- # Write the file to disk.
- msg = "Adding %s(%s)" % [path, md5]
- msg += " from #{client}" if client
- self.info msg
-
- # ...then just create the file
- Puppet::Util.withumask(0007) do
- File.open(bfile, File::WRONLY|File::CREAT, 0440) { |
of|
- of.print contents
- }
- end
-
- # Write the path to the paths file.
- add_path(path, pathpath)
- return md5
+ return @scheme.addfile(contents, path, client,
conflict_check?, @path, self )
end
- # Return the contents associated with a given md5 sum.
- def getfile(md5, client = nil, clientip = nil)
- bpath, bfile, bpaths = FileBucket.paths(@path,md5)
-
- unless FileTest.exists?(bfile)
- # Try the old flat style.
- bpath, bfile, bpaths = FileBucket.oldpaths(@path,md5)
- unless FileTest.exists?(bfile)
- return false
- end
- end
-
- contents = nil
- File.open(bfile) { |of|
- contents = of.read
- }
-
- if client
- return Base64.encode64(contents)
- else
- return contents
- end
- end
-
- def paths(md5)
- self.class(@path, md5)
+ def getfile(filetag, client = nil, clientip = nil)
+ return @scheme.getfile(filetag, @path, client, clientip,
self)
end
def to_s
self.name
end
- private
-
- # Add our path to the paths file if necessary.
- def add_path(path, file)
- if FileTest.exists?(file)
- File.open(file) { |of|
- return if of.readlines.collect { |l|
l.chomp }.include?(path)
- }
+ def commit(version)
+ @scheme.commit(@path, version)
end
-
- # if it's a new file, or if our path isn't in the file
yet, add it
- File.open(file, File::WRONLY|File::CREAT|File::APPEND) { |
of|
- of.puts path
- }
end
-
- # If conflict_check is enabled, verify that the passed text
is
- # the same as the text in our file.
- def verify(content, md5, bfile)
- curfile = File.read(bfile)
-
- # If the contents don't match, then we've found a
conflict.
- # Unlikely, but quite bad.
- if curfile != contents
- raise(BucketError,
- "Got passed new contents for sum %s" % md5,
caller)
- else
- msg = "Got duplicate %s(%s)" % [path, md5]
- msg += " from #{client}" if client
- self.info msg
end
- end
- end
-end
-
diff --git a/lib/puppet/type/filebucket.rb b/lib/puppet/type/
filebucket.rb
index 468f926..68ebb08 100755
--- a/lib/puppet/type/filebucket.rb
+++ b/lib/puppet/type/filebucket.rb
@@ -56,6 +56,27 @@ module Puppet
defaultto { Puppet[:clientbucketdir] }
end
+ newparam(:uri) do
+ desc "The destination URI for the bucket. Implemented URI
schemes are
+ dhash and git.
+ scheme://[hostname|localhost]/[path]
+ localhost signifies a repository on the client.
+ if no host field is specified the puppetmaster"
+
+ validate do |dest|
+ begin
+ uri = URI.parse(URI.escape(dest))
+ rescue => detail
+ self.fail "Could not understand dest %s: %s" %
[dest, detail.to_s]
+ end
+
+ unless uri.scheme.nil? or %w{dhash git}.include?
(uri.scheme)
+ self.fail "Cannot use URLs of type '%s' as dest
for filebucket" % [uri.scheme]
+ end
+ end
+ end
+
+
# Create a default filebucket.
def self.mkdefaultbucket
new(:name => "puppet", :path => Puppet[:clientbucketdir])
@@ -66,6 +87,35 @@ module Puppet
return @bucket
end
+ newproperty(:commit) do
+ desc "Commit repo"
+ defaultto :commited
+
+ def retrieve
+ @resource.bucket
+ return :dirty
+ end
+
+ def sync
+ #puts "SYNCingiiiiiiiiiiiiiiii #
{self.resource.title}"
+ @resource.bucket.commit
(self.resource.catalog.version)
+ return :commit
+ end
+
+ end
+
+ autorequire(:file) do
+ # Autorequire all files using the filebucket
+ autos = []
+ catalog.resources.each { |r|
+ res = catalog.resource(r)
+ if ( res.is_a?(Puppet::Type.type(:file)) and
(res.parameter(:backup).value() == self.name) )
+ autos << res
+ end
+ }
+ autos
+ end
+
private
def mkbucket
@@ -75,11 +125,25 @@ module Puppet
type = "local"
args = {}
- if self[:path]
+
+ if self[:uri]
+ uri = URI.parse(self[:uri])
+ args[:Server] = uri.host || self
[:server]
+
+ args[:Port] = uri.port || self[:port]
+ args[:Scheme] = uri.scheme.to_sym
+ args[:Path] = uri.path #no default to avoid mixing
schemes in the same dir
+ elsif self[:path]
args[:Path] = self[:path]
+ args[:Scheme] = :dhash
else
args[:Server] = self[:server]
args[:Port] = self[:port]
+ args[:Scheme] = :dhash
+ end
+
+ if ( args[:Server] && args[:Server]!= :localhost.to_s()
&& ( args[:Scheme] == :git ) )
+ self.fail("Scheme %s not implemented for remote
buckets" %[args[:Scheme]])
end
begin
@@ -93,4 +157,3 @@ module Puppet
end
end
end
-
diff --git a/lib/puppet/util/dhashbucket.rb b/lib/puppet/util/
dhashbucket.rb
new file mode 100644
index 0000000..8b7dc08
--- /dev/null
+++ b/lib/puppet/util/dhashbucket.rb
@@ -0,0 +1,119 @@
+class DhashBucket
+ def self.addfile(contents, path, client, validate, savebasepath,
fb)
+ md5 = Digest::MD5.hexdigest(contents)
+ bpath, cfile, pfile = paths(savebasepath, md5)
+
+ # If the file already exists, just return the md5 sum.
+ if FileTest.exists?(cfile)
+ # If verification is enabled, then make sure the text
matches.
+ if validate
+ verify(contents, client, cfile, md5, bpath, fb)
+ end
+ return md5
+ end
+
+ # Make the directories if necessary.
+ unless FileTest.directory?(bpath)
+ Puppet::Util.withumask(0007) do
+ FileUtils.mkdir_p(bpath)
+ end
+ end
+
+ self.logmessage(path, md5, client, fb)
+ self.createfile(cfile, contents)
+
+ #SA how would the paths file already exist with the
shortcircuit above
+ # Write the path to the paths file.
+ if FileTest.exists?(pfile)
+ File.open(pfile) { |of|
+ return md5 if of.readlines.collect { |l|
l.chomp }.include?(path)
+ }
+ end
+
+ # if it's a new file, or if our path isn't in the file yet,
add it
+ File.open(pfile, File::WRONLY|File::CREAT|File::APPEND) { |
of|
+ of.puts path
+ }
+
+ return md5
+ end
+
+ # Return the contents associated with a given md5 sum.
+ def self.getfile(md5, savebasepath, client, clientip, fb)
+ bpath, cfile, pfile = self.paths(savebasepath, md5)
+ unless FileTest.exists?(cfile)
+ # Try the old flat style.
+ bpath, cfile, pfile = self.oldpaths(savebasepath, md5)
+ unless FileTest.exists?(cfile)
+ return false
+ end
+ end
+
+ contents = nil
+ File.open(cfile) { |of|
+ contents = of.read
+ }
+
+ if client
+ return Base64.encode64(contents)
+ else
+ return contents
+ end
+ end
+
+ # this doesn't work for relative paths
+ def self.paths(savebasepath, md5)
+ dir = File.join(md5[0..7].split(""))
+ basedir = File.join(savebasepath, dir, md5)
+ return [
+ basedir,
+ File.join(basedir, "contents"),
+ File.join(basedir, "paths")
+ ]
+ end
+
+ # this doesn't work for relative paths
+ def self.oldpaths(savebasepath,md5)
+ return [
+ File.join(savebasepath, md5),
+ File.join(savebasepath, md5, "contents"),
+ File.join(savebasepath, md5, "paths")
+ ]
+ end
+
+ # If conflict_check is enabled, verify that the passed text is
+ # the same as the text in our file.
+ def self.verify(contents, client, cfile, md5, bpath, fb)
+ curfile = File.read(cfile)
+ # If the contents don't match, then we've found a conflict.
+ # Unlikely, but quite bad.
+ if curfile != contents
+ raise(BucketError,
+ "Got passed new contents for sum %s" % md5, caller)
+ else
+ msg = "Got duplicate %s(%s)" % [bpath, md5]
+ msg += " from #{client}" if client
+ fb.info(msg)
+ end
+ end
+
+ def self.logmessage(path, description, client, fb)
+ msg = "Adding %s(%s)" % [path, description]
+ msg += " from #{client}" if client
+ fb.info(msg)
+ end
+
+ # Create the file
+ def self.createfile(destfile, contents)
+ Puppet::Util.withumask(0007) do
+ File.open(destfile, File::WRONLY|File::CREAT, 0440) { |
of|
+ of.print contents
+ }
+ end
+ end
+
+ def self.commit(savebasepath, version)
+ return
+ end
+
+end
diff --git a/lib/puppet/util/gitbucket.rb b/lib/puppet/util/
gitbucket.rb
new file mode 100644
index 0000000..90229f7
--- /dev/null
+++ b/lib/puppet/util/gitbucket.rb
@@ -0,0 +1,133 @@
+require 'fileutils'
+require 'digest/sha1'
+require 'rubygems'
+require 'git'
+
+require 'puppet'
+
+#module Puppet::Util::Bucket
+
+ class GitBucket
+ def self.addfile(contents, path, client, validate,
savebasepath, fb)
+ #TODO test
+ if (File.dirname(path) == File::SEPARATOR) &&
(File.basename(path) == ".git")
+ raise Puppet::Error, "Adding a file /.git to a
filebucket is not implemented"
+ end
+
+ bpath, cfile = paths(savebasepath, path)
+ puts "bpath = #{bpath} cfile=#{cfile}"
+ #TODO test
+ makepath(bpath, savebasepath)
+ begin
+ git = Git.open(savebasepath)
+ rescue
+ git = Git.init(savebasepath)
+ end
+
+ self.createfile(cfile, contents)
+ git.add(cfile)
+
+ checksum = self.gitchecksum(path, contents)
+
+ self.logmessage(path, checksum, client, fb)
+
+ return checksum
+ end
+
+ # Return the contents associated with a given md5 sum.
+ def self.getfile(checksum, savebasepath, client, clientip,
fb)
+ bpath, cfile, pfile = self.paths(savebasepath, md5)
+ if ! FileTest.exists?(cfile)
+ return false
+ end
+
+ contents = nil
+ git = Git.open(savebasepath)
+ contents = git.cat_file(checksum)
+
+
+ if client
+ return Base64.encode64(contents)
+ else
+ return contents
+ end
+ end
+
+ # this doesn't work for relative paths
+ def self.paths(savebasepath, path)
+ return [
+ File.join(savebasepath, File.dirname(path)),
+ File.join(savebasepath, path)
+ ]
+ end
+
+ # If conflict_check is enabled, verify that the passed text
is
+ # the same as the text in our file.
+ def self.verify(contents, client, cfile, md5, bpath, fb)
+
+ end
+
+ def self.logmessage(path, description, client, fb)
+ msg = "Adding %s(%s)" % [path, description]
+ msg += " from #{client}" if client
+ fb.info(msg)
+ puts "catalog"
+ p Puppet::Configurer.instance.catalog
+
+ end
+
+ def self.makepath(realdirname, topdir)
+ # Make the directories if necessary.
+ unless FileTest.directory?(realdirname)
+ Puppet::Util.withumask(0007) do
+ begin
+ FileUtils.mkdir_p(realdirname)
+ rescue
+ self.walkmakepath(realdirname, topdir)
+ end
+ end
+ end
+ end
+
+ def self.walkmakepath(realdirname, topdir)
+ path = realdirname
+ stack = []
+ until path == topdir
+ stack.push path
+ path = File.dirname(path)
+ end
+
+ stack.reverse_each do |d|
+ begin
+ # A file changed to a dir
+ if FileTest.exist?(d) && ! FileTest.directory?(d)
+ FileUtils.rm_f([d])
+ end
+ end
+ end
+ end
+
+ #TODO merge with dhash
+ # Create the file
+ def self.createfile(destfile, contents)
+ Puppet::Util.withumask(0007) do
+ File.open(destfile, File::WRONLY|File::CREAT, 0440)
{ |of|
+ of.print contents
+ }
+ end
+ end
+
+ def self.gitchecksum(path, contents)
+ size=File.size(path)
+ return Digest::SHA1.hexdigest("blob " + size.to_s + "\0"
+ contents)
+ end
+
+ def self.commit(savebasepath, version)
+ git = Git.init(savebasepath)
+ if g.status.added.length > 0
+ git.commit("run "+ version.to_s)
+ g.add_tag("catalog version "+ version.to_s)
+ end
+ end
+ end
+#end
diff --git a/test/network/handler/bucket.rb b/test/network/handler/
bucket.rb
index 24e70cf..7ff1bbd 100755
--- a/test/network/handler/bucket.rb
+++ b/test/network/handler/bucket.rb
@@ -219,7 +219,7 @@ class TestBucket < Test::Unit::TestCase
bucket.addfile("yayness", "/my/file")
}
- a, b, pathfile = bucket.class.paths(bucket.path, sum)
+ a, b, pathfile = bucket.scheme.paths(bucket.path, sum)
assert(FileTest.exists?(pathfile), "No path file at %s" %
pathfile)
@@ -249,7 +249,7 @@ class TestBucket < Test::Unit::TestCase
dirs = File.join(md5[0..7].split(""))
dir = File.join(@bucket, dirs, md5)
- filedir, contents, paths = bucket.class.paths(@bucket, md5)
+ filedir, contents, paths = bucket.scheme.paths(@bucket, md5)
assert_equal(dir, filedir, "did not use a deeper file
structure")
assert_equal(File.join(dir, "contents"), contents,
@@ -274,40 +274,40 @@ class TestBucket < Test::Unit::TestCase
assert_equal(text, result, "did not retrieve new content
correctly")
end
- def test_add_path
- bucket = Puppet::Network::Handler.filebucket.new(:Path =>
@bucket)
-
- file = tempfile()
-
- assert(! FileTest.exists?(file), "file already exists")
-
- path = "/some/thing"
- assert_nothing_raised("Could not add path") do
- bucket.send(:add_path, path, file)
- end
- assert_equal(path + "\n", File.read(file), "path was not
added")
-
- assert_nothing_raised("Could not add path second time") do
- bucket.send(:add_path, path, file)
- end
- assert_equal(path + "\n", File.read(file), "path was
duplicated")
-
- # Now try a new path
- newpath = "/another/path"
- assert_nothing_raised("Could not add path second time") do
- bucket.send(:add_path, newpath, file)
- end
- text = [path, newpath].join("\n") + "\n"
- assert_equal(text, File.read(file), "path was duplicated")
-
- assert_nothing_raised("Could not add path third time") do
- bucket.send(:add_path, path, file)
- end
- assert_equal(text, File.read(file), "path was duplicated")
- assert_nothing_raised("Could not add second path second
time") do
- bucket.send(:add_path, newpath, file)
- end
- assert_equal(text, File.read(file), "path was duplicated")
- end
+# def test_add_path
+# bucket = Puppet::Network::Handler.filebucket.new(:Path =>
@bucket)
+#
+# file = tempfile()
+#
+# assert(! FileTest.exists?(file), "file already exists")
+#
+# path = "/some/thing"
+# assert_nothing_raised("Could not add path") do
+# bucket.send(:add_path, path, file)
+# end
+# assert_equal(path + "\n", File.read(file), "path was not
added")
+#
+# assert_nothing_raised("Could not add path second time") do
+# bucket.send(:add_path, path, file)
+# end
+# assert_equal(path + "\n", File.read(file), "path was
duplicated")
+#
+# # Now try a new path
+# newpath = "/another/path"
+# assert_nothing_raised("Could not add path second time") do
+# bucket.send(:add_path, newpath, file)
+# end
+# text = [path, newpath].join("\n") + "\n"
+# assert_equal(text, File.read(file), "path was duplicated")
+#
+# assert_nothing_raised("Could not add path third time") do
+# bucket.send(:add_path, path, file)
+# end
+# assert_equal(text, File.read(file), "path was duplicated")
+# assert_nothing_raised("Could not add second path second
time") do
+# bucket.send(:add_path, newpath, file)
+# end
+# assert_equal(text, File.read(file), "path was duplicated")
+# end
end
--~--~---------~--~----~------------~-------~--~----~
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
-~----------~----~----~----~------~----~------~--~---