diff --git a/lib/puppet/face/module/list.rb b/lib/puppet/face/module/list.rb new file mode 100644 index 000000000..772990069 --- /dev/null +++ b/lib/puppet/face/module/list.rb @@ -0,0 +1,64 @@ +Puppet::Face.define(:module, '1.0.0') do + action(:list) do + summary "List installed modules" + description <<-HEREDOC + List puppet modules from a specific environment, specified modulepath or + default to listing modules in the default modulepath: + #{Puppet.settings[:modulepath]} + HEREDOC + returns "hash of paths to module objects" + + option "--env ENVIRONMENT" do + summary "Which environments' modules to list" + end + + option "--modulepath MODULEPATH" do + summary "Which directories to look for modules in" + end + + examples <<-EOT + List installed modules: + + $ puppet module list + /etc/puppet/modules + bacula (0.0.2) + /usr/share/puppet/modules + apache (0.0.3) + bacula (0.0.1) + + List installed modules from a specified environment: + + $ puppet module list --env 'test' + /tmp/puppet/modules + rrd (0.0.2) + + List installed modules from a specified modulepath: + + $ puppet module list --modulepath /tmp/facts1:/tmp/facts2 + /tmp/facts1 + stdlib + /tmp/facts2 + nginx (1.0.0) + EOT + + when_invoked do |options| + Puppet[:modulepath] = options[:modulepath] if options[:modulepath] + environment = Puppet::Node::Environment.new(options[:env]) + + environment.modules_by_path + end + + when_rendering :console do |modules_by_path| + output = '' + modules_by_path.each do |path, modules| + output << "#{path}\n" + modules.each do |mod| + version_string = mod.version ? "(#{mod.version})" : '' + output << " #{mod.name} #{version_string}\n" + end + end + output + end + + end +end diff --git a/lib/puppet/module.rb b/lib/puppet/module.rb index 7471fc5c7..7f86df83d 100644 --- a/lib/puppet/module.rb +++ b/lib/puppet/module.rb @@ -1,196 +1,203 @@ require 'puppet/util/logging' # Support for modules class Puppet::Module class Error < Puppet::Error; end class MissingModule < Error; end class IncompatibleModule < Error; end class UnsupportedPlatform < Error; end class IncompatiblePlatform < Error; end class MissingMetadata < Error; end class InvalidName < Error; end include Puppet::Util::Logging TEMPLATES = "templates" FILES = "files" MANIFESTS = "manifests" PLUGINS = "plugins" FILETYPES = [MANIFESTS, FILES, TEMPLATES, PLUGINS] # Find and return the +module+ that +path+ belongs to. If +path+ is # absolute, or if there is no module whose name is the first component # of +path+, return +nil+ def self.find(modname, environment = nil) return nil unless modname Puppet::Node::Environment.new(environment).module(modname) end attr_reader :name, :environment attr_writer :environment attr_accessor :source, :author, :version, :license, :puppetversion, :summary, :description, :project_page def has_metadata? return false unless metadata_file return false unless FileTest.exist?(metadata_file) metadata = PSON.parse File.read(metadata_file) return metadata.is_a?(Hash) && !metadata.keys.empty? end def initialize(name, options = {}) @name = name @path = options[:path] assert_validity if options[:environment].is_a?(Puppet::Node::Environment) @environment = options[:environment] else @environment = Puppet::Node::Environment.new(options[:environment]) end load_metadata if has_metadata? validate_puppet_version validate_dependencies end FILETYPES.each do |type| # A boolean method to let external callers determine if # we have files of a given type. define_method(type +'?') do return false unless path return false unless FileTest.exist?(subpath(type)) return true end # A method for returning a given file of a given type. # e.g., file = mod.manifest("my/manifest.pp") # # If the file name is nil, then the base directory for the # file type is passed; this is used for fileserving. define_method(type.to_s.sub(/s$/, '')) do |file| return nil unless path # If 'file' is nil then they're asking for the base path. # This is used for things like fileserving. if file full_path = File.join(subpath(type), file) else full_path = subpath(type) end return nil unless FileTest.exist?(full_path) return full_path end end def exist? ! path.nil? end # Find the first 'files' directory. This is used by the XMLRPC fileserver. def file_directory subpath("files") end def license_file return @license_file if defined?(@license_file) return @license_file = nil unless path @license_file = File.join(path, "License") end def load_metadata data = PSON.parse File.read(metadata_file) [:source, :author, :version, :license, :puppetversion].each do |attr| unless value = data[attr.to_s] unless attr == :puppetversion raise MissingMetadata, "No #{attr} module metadata provided for #{self.name}" end end send(attr.to_s + "=", value) end end # Return the list of manifests matching the given glob pattern, # defaulting to 'init.{pp,rb}' for empty modules. def match_manifests(rest) pat = File.join(path, MANIFESTS, rest || 'init') [manifest("init.pp"),manifest("init.rb")].compact + Dir. glob(pat + (File.extname(pat).empty? ? '.{pp,rb}' : '')). reject { |f| FileTest.directory?(f) } end def metadata_file return @metadata_file if defined?(@metadata_file) return @metadata_file = nil unless path @metadata_file = File.join(path, "metadata.json") end # Find this module in the modulepath. def path @path ||= environment.modulepath.collect { |path| File.join(path, name) }.find { |d| FileTest.directory?(d) } end # Find all plugin directories. This is used by the Plugins fileserving mount. def plugin_directory subpath("plugins") end def supports(name, version = nil) @supports ||= [] @supports << [name, version] end def to_s result = "Module #{name}" result += "(#{path})" if path result end def validate_dependencies return unless defined?(@requires) @requires.each do |name, version| unless mod = environment.module(name) raise MissingModule, "Missing module #{name} required by #{self.name}" end if version and mod.version != version raise IncompatibleModule, "Required module #{name} is version #{mod.version} but #{self.name} requires #{version}" end end end def validate_puppet_version return unless puppetversion and puppetversion != Puppet.version raise IncompatibleModule, "Module #{self.name} is only compatible with Puppet version #{puppetversion}, not #{Puppet.version}" end private def subpath(type) return File.join(path, type) unless type.to_s == "plugins" backward_compatible_plugins_dir end def backward_compatible_plugins_dir if dir = File.join(path, "plugins") and FileTest.exist?(dir) Puppet.warning "using the deprecated 'plugins' directory for ruby extensions; please move to 'lib'" return dir else return File.join(path, "lib") end end def assert_validity raise InvalidName, "Invalid module name; module names must be alphanumeric (plus '-'), not '#{name}'" unless name =~ /^[-\w]+$/ end + + def ==(other) + self.name == other.name && + self.version == other.version && + self.path == other.path && + self.environment == other.environment + end end diff --git a/lib/puppet/node/environment.rb b/lib/puppet/node/environment.rb index 01abe19b9..326809050 100644 --- a/lib/puppet/node/environment.rb +++ b/lib/puppet/node/environment.rb @@ -1,190 +1,189 @@ require 'puppet/util/cacher' require 'monitor' # Just define it, so this class has fewer load dependencies. class Puppet::Node end # Model the environment that a node can operate in. This class just # provides a simple wrapper for the functionality around environments. class Puppet::Node::Environment module Helper def environment Puppet::Node::Environment.new(@environment) end def environment=(env) if env.is_a?(String) or env.is_a?(Symbol) @environment = env else @environment = env.name end end end include Puppet::Util::Cacher @seen = {} # Return an existing environment instance, or create a new one. def self.new(name = nil) return name if name.is_a?(self) name ||= Puppet.settings.value(:environment) raise ArgumentError, "Environment name must be specified" unless name symbol = name.to_sym return @seen[symbol] if @seen[symbol] obj = self.allocate obj.send :initialize, symbol @seen[symbol] = obj end def self.current Thread.current[:environment] || root end def self.current=(env) Thread.current[:environment] = new(env) end def self.root @root end - # This is only used for testing. def self.clear @seen.clear end attr_reader :name # Return an environment-specific setting. def [](param) Puppet.settings.value(param, self.name) end def initialize(name) @name = name extend MonitorMixin end def known_resource_types # This makes use of short circuit evaluation to get the right thread-safe # per environment semantics with an efficient most common cases; we almost # always just return our thread's known-resource types. Only at the start # of a compilation (after our thread var has been set to nil) or when the # environment has changed do we delve deeper. Thread.current[:known_resource_types] = nil if (krt = Thread.current[:known_resource_types]) && krt.environment != self Thread.current[:known_resource_types] ||= synchronize { if @known_resource_types.nil? or @known_resource_types.require_reparse? @known_resource_types = Puppet::Resource::TypeCollection.new(self) @known_resource_types.import_ast(perform_initial_import, '') end @known_resource_types } end def module(name) mod = Puppet::Module.new(name, :environment => self) return nil unless mod.exist? mod end # Cache the modulepath, so that we aren't searching through # all known directories all the time. cached_attr(:modulepath, Puppet[:filetimeout]) do dirs = self[:modulepath].split(File::PATH_SEPARATOR) dirs = ENV["PUPPETLIB"].split(File::PATH_SEPARATOR) + dirs if ENV["PUPPETLIB"] validate_dirs(dirs) end # Return all modules from this environment. # Cache the list, because it can be expensive to create. cached_attr(:modules, Puppet[:filetimeout]) do module_names = modulepath.collect { |path| Dir.entries(path) }.flatten.uniq module_names.collect do |path| begin Puppet::Module.new(path, :environment => self) rescue Puppet::Module::Error => e nil end end.compact end # Modules broken out by directory in the modulepath def modules_by_path modules_by_path = {} modulepath.each do |path| Dir.chdir(path) do module_names = Dir.glob('*').select { |d| FileTest.directory? d } modules_by_path[path] = module_names.map do |name| Puppet::Module.new(name, :environment => self, :path => File.join(path, name)) end end end modules_by_path end def to_s name.to_s end def to_sym to_s.to_sym end # The only thing we care about when serializing an environment is its # identity; everything else is ephemeral and should not be stored or # transmitted. def to_zaml(z) self.to_s.to_zaml(z) end def validate_dirs(dirs) dir_regex = Puppet.features.microsoft_windows? ? /^[A-Za-z]:#{File::SEPARATOR}/ : /^#{File::SEPARATOR}/ # REMIND: Dir.getwd on windows returns a path containing backslashes, which when joined with # dir containing forward slashes, breaks our regex matching. In general, path validation needs # to be refactored which will be handled in a future commit. dirs.collect do |dir| if dir !~ dir_regex File.expand_path(File.join(Dir.getwd, dir)) else dir end end.find_all do |p| p =~ dir_regex && FileTest.directory?(p) end end private def perform_initial_import return empty_parse_result if Puppet.settings[:ignoreimport] parser = Puppet::Parser::Parser.new(self) if code = Puppet.settings.uninterpolated_value(:code, name.to_s) and code != "" parser.string = code else file = Puppet.settings.value(:manifest, name.to_s) parser.file = file end parser.parse rescue => detail known_resource_types.parse_failed = true msg = "Could not parse for environment #{self}: #{detail}" error = Puppet::Error.new(msg) error.set_backtrace(detail.backtrace) raise error end def empty_parse_result # Return an empty toplevel hostclass to use as the result of # perform_initial_import when no file was actually loaded. return Puppet::Parser::AST::Hostclass.new('') end @root = new(:'*root*') end diff --git a/spec/unit/face/module/list_spec.rb b/spec/unit/face/module/list_spec.rb new file mode 100644 index 000000000..7c85c6896 --- /dev/null +++ b/spec/unit/face/module/list_spec.rb @@ -0,0 +1,100 @@ +require 'spec_helper' +require 'puppet/face' +require 'puppet/module_tool' + +describe "puppet module list" do + include PuppetSpec::Files + + before do + dir = tmpdir("deep_path") + + @modpath1 = File.join(dir, "modpath1") + @modpath2 = File.join(dir, "modpath2") + + FileUtils.mkdir_p(@modpath1) + FileUtils.mkdir_p(@modpath2) + end + + def mkmodule(name, path) + mod_path = File.join(path, name) + FileUtils.mkdir_p(mod_path) + mod_path + end + + it "should return an empty list per dir in path if there are no modules" do + Puppet.settings[:modulepath] = "#{@modpath1}:#{@modpath2}" + Puppet::Face[:module, :current].list.should == { + @modpath1 => [], + @modpath2 => [] + } + end + + it "should include modules separated by the environment's modulepath" do + foomod1 = mkmodule('foo', @modpath1) + barmod1 = mkmodule('bar', @modpath1) + foomod2 = mkmodule('foo', @modpath2) + + env = Puppet::Node::Environment.new + env.modulepath = [@modpath1, @modpath2] + + Puppet::Face[:module, :current].list.should == { + @modpath1 => [ + Puppet::Module.new('bar', :environment => env, :path => barmod1), + Puppet::Module.new('foo', :environment => env, :path => foomod1) + ], + @modpath2 => [Puppet::Module.new('foo', :environment => env, :path => foomod2)] + } + end + + it "should use the specified environment" do + foomod1 = mkmodule('foo', @modpath1) + barmod1 = mkmodule('bar', @modpath1) + + usedenv = Puppet::Node::Environment.new('useme') + usedenv.modulepath = [@modpath1, @modpath2] + + Puppet::Face[:module, :current].list(:env => 'useme').should == { + @modpath1 => [ + Puppet::Module.new('bar', :environment => usedenv), + Puppet::Module.new('foo', :environment => usedenv) + ], + @modpath2 => [] + } + end + + it "should use the specified modulepath" do + foomod1 = mkmodule('foo', @modpath1) + barmod2 = mkmodule('bar', @modpath2) + + Puppet::Face[:module, :current].list(:modulepath => "#{@modpath1}:#{@modpath2}").should == { + @modpath1 => [ Puppet::Module.new('foo') ], + @modpath2 => [ Puppet::Module.new('bar') ] + } + end + + it "should use the specified modulepath over the specified environment in place of the environment's default path" do + foomod1 = mkmodule('foo', @modpath1) + barmod2 = mkmodule('bar', @modpath2) + env = Puppet::Node::Environment.new('myenv') + env.modulepath = ['/tmp/notused'] + + list = Puppet::Face[:module, :current].list(:env => 'myenv', :modulepath => "#{@modpath1}:#{@modpath2}") + + # Changing Puppet[:modulepath] causes Puppet::Node::Environment.new('myenv') + # to have a different object_id than the env above + env = Puppet::Node::Environment.new('myenv') + list.should == { + @modpath1 => [ Puppet::Module.new('foo', :environment => env, :path => foomod1) ], + @modpath2 => [ Puppet::Module.new('bar', :environment => env, :path => barmod2) ] + } + end + + describe "inline documentation" do + subject { Puppet::Face[:module, :current].get_action :list } + + its(:summary) { should =~ /list.*module/im } + its(:description) { should =~ /list.*module/im } + its(:returns) { should =~ /hash of paths to module objects/i } + its(:examples) { should_not be_empty } + end +end