diff --git a/README_DEVELOPER.md b/README_DEVELOPER.md index 796af989e..1085cabbd 100644 --- a/README_DEVELOPER.md +++ b/README_DEVELOPER.md @@ -1,213 +1,379 @@ # Developer README # This file is intended to provide a place for developers and contributors to document what other developers need to know about changes made to Puppet. # Use of RVM considered dangerous # Use of RVM in production situations, e.g. running CI tests against this repository, is considered dangerous. The reason we consider RVM to be dangerous is because the default behavior of RVM is to hijack the builtin behavior of the shell, causing Gemfile files to be loaded and evaluated when the shell changes directories into the project root. This behavior causes the CI Job execution environment that runs with `set -e` to be incompatible with RVM. We work around this issue by disabling the per-project RC file parsing using if ! grep -qx rvm_project_rvmrc=0 ~/.rvmrc; then echo rvm_project_rvmrc=0 >> ~/.rvmrc fi When we setup CI nodes, but this is not standard or expected behavior. Please consider rbenv instead of rvm. The default behavior of rvm is difficult to maintain with `set -e` shell environments. -# Dependencies # +# Two Types of Catalog + +When working on subsystems of Puppet that deal with the catalog it is important +to be aware of the two different types of Catalog. I often ran into this when +working in Professional Services when I built a small tool to diff two catalogs +to determine if an upgrade in Puppet produces the same configuration catalogs. +As a developer I've run into this difference while working on spec tests for +the static compiler and working on spec tests for types and providers. + +The two different types of catalog becomes relevant when writing spec tests +because we frequently need to wire up a fake catalog so that we can exercise +types, providers, or terminii that filter the catalog. + +The two different types of catalogs are so-called "resource" catalogs and "RAL" +(resource abstraction layer) catalogs. At a high level, the resource catalog +is the in-memory object we serialize and transfer around the network. The +compiler terminus is expected to produce a resource catalog. The agent takes a +resource catalog and converts it into a RAL catalog. The RAL catalog is what +is used to apply the configuration model to the system. + +Resource dependency information is most easily obtained from a RAL catalog by +walking the graph instance produced by the `relationship_graph` method. + +## Resource Catalog + +If you're writing spec tests for something that deals with a catalog "server +side," a new catalog terminus for example, then you'll be dealing with a +resource catalog. You can produce a resource catalog suitable for spec tests +using something like this: + + let(:catalog) do + catalog = Puppet::Resource::Catalog.new("node-name-val") # NOT certname! + rsrc = Puppet::Resource.new("file", "sshd_config", + :parameters => { + :ensure => 'file', + :source => 'puppet:///modules/filetest/sshd_config', + } + ) + rsrc.file = 'site.pp' + rsrc.line = 21 + catalog.add_resource(rsrc) + end + +The resources in this catalog may be accessed using `catalog.resources`. +Resource dependencies are not easily walked using a resource catalog however. +To walk the dependency tree convert the catalog to a RAL catalog as described +in + +## RAL Catalog + +The resource catalog may be converted to a RAL catalog using `catalog.to_ral`. +The RAL catalog contains `Puppet::Type` instances instead of `Puppet::Resource` +instances as is the case with the resource catalog. + +One very useful feature of the RAL catalog are the methods to work with +resource relationships. For example: + + irb> catalog = catalog.to_ral + irb> graph = catalog.relationship_graph + irb> pp graph.edges + [{ Notify[alpha] => File[/tmp/file_20.txt] }, + { Notify[alpha] => File[/tmp/file_21.txt] }, + { Notify[alpha] => File[/tmp/file_22.txt] }, + { Notify[alpha] => File[/tmp/file_23.txt] }, + { Notify[alpha] => File[/tmp/file_24.txt] }, + { Notify[alpha] => File[/tmp/file_25.txt] }, + { Notify[alpha] => File[/tmp/file_26.txt] }, + { Notify[alpha] => File[/tmp/file_27.txt] }, + { Notify[alpha] => File[/tmp/file_28.txt] }, + { Notify[alpha] => File[/tmp/file_29.txt] }, + { File[/tmp/file_20.txt] => Notify[omega] }, + { File[/tmp/file_21.txt] => Notify[omega] }, + { File[/tmp/file_22.txt] => Notify[omega] }, + { File[/tmp/file_23.txt] => Notify[omega] }, + { File[/tmp/file_24.txt] => Notify[omega] }, + { File[/tmp/file_25.txt] => Notify[omega] }, + { File[/tmp/file_26.txt] => Notify[omega] }, + { File[/tmp/file_27.txt] => Notify[omega] }, + { File[/tmp/file_28.txt] => Notify[omega] }, + { File[/tmp/file_29.txt] => Notify[omega] }] + +If the `relationship_graph` method is throwing exceptions at you, there's a +good chance the catalog is not a RAL catalog. + +# Ruby Dependencies # Puppet is considered an Application as it relates to the recommendation of adding a Gemfile.lock file to the repository and the information published at [Clarifying the Roles of the .gemspec and Gemfile](http://yehudakatz.com/2010/12/16/clarifying-the-roles-of-the-gemspec-and-gemfile/) To install the dependencies run: `bundle install` to install the dependencies. A checkout of the source repository should be used in a way that provides puppet as a gem rather than a simple Ruby library. The parent directory should be set along the `GEM_PATH`, preferably before other tools such as RVM that manage gemsets using `GEM_PATH`. For example, Puppet checked out into `/workspace/src/puppet` using `git checkout https://github.com/puppetlabs/puppet` in `/workspace/src` can be used with the following actions. The trick is to symlink `gems` to `src`. $ cd /workspace $ ln -s src gems $ mkdir specifications $ pushd specifications; ln -s ../gems/puppet/puppet.gemspec; popd $ export GEM_PATH="/workspace:${GEM_PATH}" $ gem list puppet This should list out puppet (2.7.19) ## Bundler ## With a source checkout of Puppet properly setup as a gem, dependencies can be installed using [Bundler](http://gembundler.com/) $ bundle install Fetching gem metadata from http://rubygems.org/........ Using diff-lcs (1.1.3) Installing facter (1.6.11) Using metaclass (0.0.1) Using mocha (0.10.5) Using puppet (2.7.19) from source at /workspace/puppet-2.7.x/src/puppet Using rack (1.4.1) Using rspec-core (2.10.1) Using rspec-expectations (2.10.0) Using rspec-mocks (2.10.1) Using rspec (2.10.0) Using bundler (1.1.5) Your bundle is complete! Use `bundle show [gemname]` to see where a bundled gem is installed. # UTF-8 Handling # As Ruby 1.9 becomes more commonly used with Puppet, developers should be aware of major changes to the way Strings and Regexp objects are handled. Specifically, every instance of these two classes will have an encoding attribute determined in a number of ways. * If the source file has an encoding specified in the magic comment at the top, the instance will take on that encoding. * Otherwise, the encoding will be determined by the LC\_LANG or LANG environment variables. * Otherwise, the encoding will default to ASCII-8BIT ## References ## Excellent information about the differences between encodings in Ruby 1.8 and Ruby 1.9 is published in this blog series: [Understanding M17n](http://links.puppetlabs.com/understanding_m17n) ## Encodings of Regexp and String instances ## In general, please be aware that Ruby 1.9 regular expressions need to be compatible with the encoding of a string being used to match them. If they are not compatible you can expect to receive and error such as: Encoding::CompatibilityError: incompatible encoding regexp match (ASCII-8BIT regexp with UTF-8 string) In addition, some escape sequences were valid in Ruby 1.8 are no longer valid in 1.9 if the regular expression is not marked as an ASCII-8BIT object. You may expect errors like this in this situation: SyntaxError: (irb):7: invalid multibyte escape: /\xFF/ This error is particularly common when serializing a string to other representations like JSON or YAML. To resolve the problem you can explicitly mark the regular expression as ASCII-8BIT using the /n flag: "a" =~ /\342\230\203/n Finally, any time you're thinking of a string as an array of bytes rather than an array of characters, common when escaping a string, you should work with everything in ASCII-8BIT. Changing the encoding will not change the data itself and allow the Regexp and the String to deal with bytes rather than characters. Puppet provides a monkey patch to String which returns an encoding suitable for byte manipulations: # Example of how to escape non ASCII printable characters for YAML. >> snowman = "☃" >> snowman.to_ascii8bit.gsub(/([\x80-\xFF])/n) { |x| "\\x#{x.unpack("C")[0].to_s(16)} } => "\\xe2\\x98\\x83" If the Regexp is not marked as ASCII-8BIT using /n, then you can expect the SyntaxError, invalid multibyte escape as mentioned above. # Windows # If you'd like to run Puppet from source on Windows platforms, the include `ext/envpuppet.bat` will help. To quickly run Puppet from source, assuming you already have Ruby installed from [rubyinstaller.org](http://rubyinstaller.org). gem install sys-admin win32-process win32-dir win32-taskscheduler --no-rdoc --no-ri gem install win32-service --platform=mswin32 --no-rdoc --no-ri --version 0.7.1 net use Z: "\\vmware-host\Shared Folders" /persistent:yes Z: cd set PATH=%PATH%;Z:\\ext envpuppet puppet --version 2.7.9 Some spec tests are known to fail on Windows, e.g. no mount provider on Windows, so use the following rspec exclude filter: cd envpuppet rspec --tag ~fails_on_windows spec This will give you a shared filesystem with your Mac and allow you to run Puppet directly from source without using install.rb or copying files around. ## Common Issues ## * Don't assume file paths start with '/', as that is not a valid path on Windows. Use Puppet::Util.absolute\_path? to validate that a path is fully qualified. * Use File.expand\_path('/tmp') in tests to generate a fully qualified path that is valid on POSIX and Windows. In the latter case, the current working directory will be used to expand the path. * Always use binary mode when performing file I/O, unless you explicitly want Ruby to translate between unix and dos line endings. For example, opening an executable file in text mode will almost certainly corrupt the resulting stream, as will occur when using: IO.open(path, 'r') { |f| ... } IO.read(path) If in doubt, specify binary mode explicitly: IO.open(path, 'rb') * Don't assume file paths are separated by ':'. Use `File::PATH_SEPARATOR` instead, which is ':' on POSIX and ';' on Windows. * On Windows, File::SEPARATOR is '/', and `File::ALT_SEPARATOR` is '\'. On POSIX systems, `File::ALT_SEPARATOR` is nil. In general, use '/' as the separator as most Windows APIs, e.g. CreateFile, accept both types of separators. * Don't use waitpid/waitpid2 if you need the child process' exit code, as the child process may exit before it has a chance to open the child's HANDLE and retrieve its exit code. Use Puppet::Util.execute. * Don't assume 'C' drive. Use environment variables to look these up: "#{ENV['windir']}/system32/netsh.exe" # Determining the Puppet Version If you need to programmatically work with the Puppet version, please use the following: require 'puppet/version' # Get the version baked into the sourcecode: version = Puppet.version # Set the version (e.g. in a Rakefile based on `git describe`) Puppet.version = '2.3.4' Please do not monkey patch the constant `Puppet::PUPPETVERSION` or obtain the version using the constant. The only supported way to set and get the Puppet version is through the accessor methods. +# Static Compiler + +The static compiler was added to Puppet in the 2.7.0 release. +[1](http://links.puppetlabs.com/static-compiler-announce) + +The static compiler is intended to provide a configuration catalog that +requires a minimal amount of network communication in order to apply the +catalog to the system. As implemented in Puppet 2.7.x and Puppet 3.0.x this +intention takes the form of replacing all of the source parameters of File +resources with a content parameter containing an address in the form of a +checksum. The expected behavior is that the process applying the catalog to +the node will retrieve the file content from the FileBucket instead of the +FileServer. + +The high level approach can be described as follows. The `StaticCompiler` is a +terminus that inserts itself between the "normal" compiler terminus and the +request. The static compiler takes the resource catalog produced by the +compiler and filters all File resources. Any file resource that contains a +source parameter with a value starting with 'puppet://' is filtered in the +following way in a "standard" single master / networked agents deployment +scenario: + + 1. The content, owner, group, and mode values are retrieved from th + FileServer by the master. + 2. The file content is stored in the file bucket on the master. + 3. The source parameter value is stripped from the File resource. + 4. The content parameter value is set in the File resource using the form + '{XXX}1234567890' which can be thought of as a content address indexed by + checksum. + 5. The owner, group and mode values are set in the File resource if they are + not already set. + 6. The filtered catalog is returned in the response. + +In addition to the catalog terminus, the process requesting the catalog needs +to obtain the file content. The default behavior of `puppet agent` is to +obtain file contents from the local client bucket. The method we expect users +to employ to reconfigure the agent to use the server bucket is to declare the +`Filebucket[puppet]` resource with the address of the master. For example: + + node default { + filebucket { puppet: + server => $server, + path => false, + } + class { filetest: } + } + +This special filebucket resource named "puppet" will cause the agent to fetch +file contents specified by checksum from the remote filebucket instead of the +default clientbucket. + +## Quick start + +Create a module that recursively downloads something. The jeffmccune-filetest +module will recursively copy the rubygems source tree. + + $ puppet module install jeffmccune-filetest + +Start the master with the StaticCompiler turned on: + + $ puppet master \ + --catalog_terminus=static_compiler \ + --verbose \ + --no-daemonize + +Add the special Filebucket[puppet] resource: + + # site.pp + node default { + filebucket { puppet: server => $server, path => false } + class { filetest: } + } + +Get the static catalog: + + $ puppet agent --test + +You should expect all file metadata to be contained in the catalog, including a +checksum representing the content. When managing an out of sync file resource, +the real contents should be fetched from the server instead of the +clientbucket. + EOF diff --git a/lib/puppet/indirector/catalog/static_compiler.rb b/lib/puppet/indirector/catalog/static_compiler.rb index 1d92121ed..add339200 100644 --- a/lib/puppet/indirector/catalog/static_compiler.rb +++ b/lib/puppet/indirector/catalog/static_compiler.rb @@ -1,137 +1,136 @@ require 'puppet/node' require 'puppet/resource/catalog' require 'puppet/indirector/code' class Puppet::Resource::Catalog::StaticCompiler < Puppet::Indirector::Code def compiler @compiler ||= indirection.terminus(:compiler) end def find(request) return nil unless catalog = compiler.find(request) raise "Did not get catalog back" unless catalog.is_a?(model) catalog.resources.find_all { |res| res.type == "File" }.each do |resource| next unless source = resource[:source] next unless source =~ /^puppet:/ file = resource.to_ral if file.recurse? add_children(request.key, catalog, resource, file) else find_and_replace_metadata(request.key, resource, file) end end catalog end def find_and_replace_metadata(host, resource, file) # We remove URL info from it, so it forces a local copy # rather than routing through the network. # Weird, but true. newsource = file[:source][0].sub("puppet:///", "") file[:source][0] = newsource raise "Could not get metadata for #{resource[:source]}" unless metadata = file.parameter(:source).metadata replace_metadata(host, resource, metadata) end def replace_metadata(host, resource, metadata) [:mode, :owner, :group].each do |param| resource[param] ||= metadata.send(param) end resource[:ensure] = metadata.ftype if metadata.ftype == "file" unless resource[:content] resource[:content] = metadata.checksum resource[:checksum] = metadata.checksum_type end end store_content(resource) if resource[:ensure] == "file" old_source = resource.delete(:source) Puppet.info "Metadata for #{resource} in catalog for '#{host}' added from '#{old_source}'" end def add_children(host, catalog, resource, file) file = resource.to_ral children = get_child_resources(host, catalog, resource, file) remove_existing_resources(children, catalog) children.each do |name, res| catalog.add_resource res catalog.add_edge(resource, res) end end def get_child_resources(host, catalog, resource, file) sourceselect = file[:sourceselect] children = {} source = resource[:source] # This is largely a copy of recurse_remote in File total = file[:source].collect do |source| next unless result = file.perform_recursion(source) return if top = result.find { |r| r.relative_path == "." } and top.ftype != "directory" result.each { |data| data.source = "#{source}/#{data.relative_path}" } break result if result and ! result.empty? and sourceselect == :first result end.flatten # This only happens if we have sourceselect == :all unless sourceselect == :first found = [] total.reject! do |data| result = found.include?(data.relative_path) found << data.relative_path unless found.include?(data.relative_path) result end end total.each do |meta| # This is the top-level parent directory if meta.relative_path == "." replace_metadata(host, resource, meta) next end children[meta.relative_path] ||= Puppet::Resource.new(:file, File.join(file[:path], meta.relative_path)) # I think this is safe since it's a URL, not an actual file children[meta.relative_path][:source] = source + "/" + meta.relative_path replace_metadata(host, children[meta.relative_path], meta) end children end def remove_existing_resources(children, catalog) existing_names = catalog.resources.collect { |r| r.to_s } - both = (existing_names & children.keys).inject({}) { |hash, name| hash[name] = true; hash } - both.each { |name| children.delete(name) } end def store_content(resource) @summer ||= Object.new @summer.extend(Puppet::Util::Checksums) type = @summer.sumtype(resource[:content]) sum = @summer.sumdata(resource[:content]) if Puppet::FileBucket::File.indirection.find("#{type}/#{sum}") Puppet.info "Content for '#{resource[:source]}' already exists" else Puppet.info "Storing content for source '#{resource[:source]}'" - content = Puppet::FileServing::Content.find(resource[:source]) - Puppet::FileBucket::File.new(content.content).save + content = Puppet::FileServing::Content.indirection.find(resource[:source]) + file = Puppet::FileBucket::File.new(content.content) + Puppet::FileBucket::File.indirection.save(file) end end end diff --git a/spec/unit/indirector/catalog/static_compiler_spec.rb b/spec/unit/indirector/catalog/static_compiler_spec.rb new file mode 100644 index 000000000..ac21efc60 --- /dev/null +++ b/spec/unit/indirector/catalog/static_compiler_spec.rb @@ -0,0 +1,193 @@ +#! /usr/bin/env ruby -S rspec +require 'spec_helper' +require 'puppet/indirector/catalog/static_compiler' +require 'puppet/file_serving/metadata' +require 'puppet/file_serving/content' +require 'yaml' + +describe Puppet::Resource::Catalog::StaticCompiler do + before :all do + @num_file_resources = 10 + end + + let(:request) do + Puppet::Indirector::Request.new(:the_indirection_named_foo, + :find, + "the-node-named-foo", + :environment => "production") + end + + describe "#find" do + it "returns a catalog" do + subject.find(request).should be_a_kind_of(Puppet::Resource::Catalog) + end + + it "returns nil if there is no compiled catalog" do + compiler = mock('compiler indirection') + compiler.expects(:find).with(request).returns(nil) + + subject.expects(:compiler).returns(compiler) + subject.find(request).should be_nil + end + + describe "a catalog with file resources containing source parameters with puppet:// URIs" do + it "filters file resource source URI's to checksums" do + stub_the_compiler + resource_catalog = subject.find(request) + resource_catalog.resources.each do |resource| + next unless resource.type == "File" + resource[:content].should == "{md5}361fadf1c712e812d198c4cab5712a79" + resource[:source].should be_nil + end + end + + it "does not modify file resources with non-puppet:// URI's" do + uri = "/this/is/not/a/puppet/uri.txt" + stub_the_compiler(:source => uri) + resource_catalog = subject.find(request) + resource_catalog.resources.each do |resource| + next unless resource.type == "File" + resource[:content].should be_nil + resource[:source].should == uri + end + end + + it "copies the owner, group and mode from the fileserer" do + stub_the_compiler + resource_catalog = subject.find(request) + resource_catalog.resources.each do |resource| + next unless resource.type == "File" + resource[:owner].should == 0 + resource[:group].should == 0 + resource[:mode].should == 420 + end + end + end + end + + describe "(#15193) when storing content to the filebucket" do + it "explicitly uses the indirection method" do + # Do not stub the store_content method which is stubbed by default in the + # value of the stub_methods option. + stub_the_compiler(:stub_methods => false) + + # We expect the content to be retrieved from the FileServer ... + fake_content = mock('FileServer Content') + fake_content.expects(:content).returns("HELLO WORLD") + + # Mock the FileBucket to behave as if the file content does not exist. + # NOTE, we're simulating the first call returning false, indicating the + # file is not present, then all subsequent calls returning true. This + # mocked behavior is intended to replicate the real behavior of the same + # file being stored to the filebucket multiple times. + Puppet::FileBucket::File.indirection. + expects(:find).times(@num_file_resources). + returns(false).then.returns(true) + + Puppet::FileServing::Content.indirection. + expects(:find).once. + returns(fake_content) + + # Once retrived from the FileServer, we expect the file to be stored into + # the FileBucket only once. All of the file resources in the fake + # catalog have the same content. + Puppet::FileBucket::File.indirection.expects(:save).once.with do |file| + file.contents == "HELLO WORLD" + end + + # Obtain the Static Catalog + resource_catalog = subject.find(request) + + # Ensure all of the file resources were filtered + resource_catalog.resources.each do |resource| + next unless resource.type == "File" + resource[:content].should == "{md5}361fadf1c712e812d198c4cab5712a79" + resource[:source].should be_nil + end + end + end + + # Spec helper methods + + def stub_the_compiler(options = {:stub_methods => [:store_content]}) + # Build a resource catalog suitable for specifying the behavior of the + # static compiler. + compiler = mock('indirection terminus compiler') + compiler.stubs(:find).returns(build_catalog(options)) + subject.stubs(:compiler).returns(compiler) + # Mock the store content method to prevent copying the contents to the + # file bucket. + (options[:stub_methods] || []).each do |mthd| + subject.stubs(mthd) + end + end + + def build_catalog(options = {}) + options = options.dup + options[:source] ||= 'puppet:///modules/mymodule/config_file.txt' + options[:request] ||= request + + # Build a catalog suitable for the static compiler to operate on + catalog = Puppet::Resource::Catalog.new("#{options[:request].key}") + + # Mock out the fileserver, otherwise converting the catalog to a + fake_fileserver_metadata = fileserver_metadata(options) + + # Stub the call to the FileServer metadata API so we don't have to have + # a real fileserver initialized for testing. + Puppet::FileServing::Metadata. + indirection.stubs(:find). + with(options[:source].sub('puppet:///','')). + returns(fake_fileserver_metadata) + + # I want a resource that all the file resources require and another + # that requires them. + resources = Array.new + resources << Puppet::Resource.new("notify", "alpha") + resources << Puppet::Resource.new("notify", "omega") + + # Create some File resources with source parameters. + 1.upto(@num_file_resources) do |idx| + parameters = { + :ensure => 'file', + :source => options[:source], + :require => "Notify[alpha]", + :before => "Notify[omega]" + } + # The static compiler does not operate on a RAL catalog, so we're + # using Puppet::Resource to produce a resource catalog. + rsrc = Puppet::Resource.new("file", "/tmp/file_#{idx}.txt", :parameters => parameters) + rsrc.file = 'site.pp' + rsrc.line = idx + resources << rsrc + end + + resources.each do |rsrc| + catalog.add_resource(rsrc) + end + + # Return the resource catalog + catalog + end + + def fileserver_metadata(options = {}) + yaml = <= "1.9" then line.join else line.pack "c*" end line.clear end end end ensure $stdout.sync = old_sync end results.join end def clear #system("clear") end def growl(message, status) # Strip the color codes message.gsub!(/\[\d+m/, '') growlnotify = `which growlnotify`.chomp return if growlnotify.empty? title = "Watchr Test Results" image = status == :pass ? "autotest/images/pass.png" : "autotest/images/fail.png" options = "-w -n Watchr --image '#{File.expand_path(image)}' -m '#{message}' '#{title}'" system %(#{growlnotify} #{options} &) end def file2specs(file) %w{spec/unit spec/integration}.collect { |d| file.sub('lib/puppet', d).sub(".rb", "_spec.rb") }.find_all { |f| File.exist?(f) } end def file2test(file) result = file.sub('lib/puppet', 'test') return nil unless File.exist?(result) result end def run_spec(command) clear result = run_comp(command).split("\n").last status = result.include?('0 failures') ? :pass : :fail growl result, status end def run_test(command) clear result = run_comp(command).split("\n").last status = result.include?('0 failures, 0 errors') ? :pass : :fail growl result.split("\n").last rescue nil end def run_test_file(file) run_test(%Q(#{file})) end def run_spec_files(files) files = Array(files) return if files.empty? - if File.exist?(File.expand_path("~/.rspec")) then - opts = '' # use the user defaults - else - opts = File.readlines('spec/spec.opts').collect { |l| l.chomp }.join(" ") - end begin - run_spec("rspec #{opts} --tty #{files.join(' ')}") + # End users can put additional options into ~/.rspec + run_spec("rspec --tty #{files.join(' ')}") rescue => detail puts "Failed to load #{files}: #{detail}" end end def run_all_tests run_test("rake unit") end def run_all_specs run_spec_files "spec" end def run_suite run_all_specs run_all_tests end watch('spec/spec_helper.rb') { run_all_specs } watch(%r{^spec/(unit|integration)/.*\.rb$}) { |md| run_spec_files(md[0]) } watch(%r{^lib/puppet/(.*)\.rb$}) { |md| run_spec_files(file2specs(md[0])) if t = file2test(md[0]) run_test_file(t) end } watch(%r{^spec/lib/spec.*}) { |md| run_all_specs } watch(%r{^spec/lib/monkey_patches/.*}) { |md| run_all_specs } watch(%r{test/.+\.rb}) { |md| if md[0] =~ /\/lib\// run_all_tests else run_test_file(md[0]) end } # Ctrl-\ Signal.trap 'QUIT' do puts " --- Running all tests ---\n\n" run_suite end @interrupted = false # Ctrl-C Signal.trap 'INT' do if @interrupted @wants_to_quit = true abort("\n") else puts "Interrupt a second time to quit; wait for rerun of tests" @interrupted = true Kernel.sleep 1.5 # raise Interrupt, nil # let the run loop catch it run_suite end end