Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

release initial 1.0.0.rc1

  • Loading branch information...
commit e659a9413bce6e1167b109b62db8f70737aa6db4 1 parent b27cb0a
Seth Vargo sethvargo authored
1  .gitignore
@@ -15,3 +15,4 @@ spec/reports
15 15 test/tmp
16 16 test/version_tmp
17 17 tmp
  18 +sandbox/
6 CHANGELOG.md
Source Rendered
... ... @@ -1,6 +1,12 @@
1 1 Strainer CHANGELOG
2 2 ==================
3 3
  4 +v1.0.0
  5 +------
  6 +- Moved entirely to Berkshelf integration
  7 +- Moved entirely to thor
  8 +- **Breaking** - new command `strainer`, old command is deprecated
  9 +
4 10 v0.2.1
5 11 ------
6 12 - Support a wider range of chef versions
8 CONTRIBUTING.md
Source Rendered
... ... @@ -0,0 +1,8 @@
  1 +Contributing
  2 +============
  3 +
  4 +Needs Your Help
  5 +---------------
  6 +This is a list of features or problem *you* can help solve! Fork and submit a pull request to make Strain even better!
  7 +
  8 +- **Threading** - Run each cookbook's tests (or each cookbook tests test) in a separate thread
2  LICENSE
... ... @@ -1,4 +1,4 @@
1   -Copyright 2012 CustomInk
  1 +Copyright 2012 Seth Vargo, CustomInk, LLC
2 2
3 3 Licensed under the Apache License, Version 2.0 (the "License");
4 4 you may not use this file except in compliance with the License.
52 README.md
Source Rendered
@@ -4,67 +4,47 @@ Strainer is a gem for isolating and testing individual chef cookbooks. It allows
4 4
5 5 Usage
6 6 -----
7   -Strainer is a command line tool. The first thing you should do is add the following entry into your `.gitignore` and `chefignore` files:
  7 +First, create a `Strainerfile`. Cookbook-level Strainerfiles will take precedence over root-level ones. For simplicity, let's just create one in the root of our chef repository:
8 8
9   - .colander
10   -
11   -The `.colander` directory is where strainer puts all your temporary files for testing. You should not commit it to source control, nor should you upload it if sharing this cookbook with the community.
12   -
13   -Next, create a `Colanderfile`. Cookbook-level Colanderfiles will take precedence over root-level ones. For simplicity, let's just create one in the root of our chef repository:
14   -
15   - # Colanderfile
  9 + # Strainerfile
16 10 knife test: bundle exec knife cookbook test $COOKBOOK
17 11 foodcritic: bundle exec foodcritic -f any cookbooks/$COOKBOOK
18 12
19   -`Colanderfile` exposes two variables:
  13 +`Strainerfile` exposes two variables:
20 14
21 15 - `$COOKBOOK` - the current running cookbook
22 16 - `$SANDBOX` - the sandbox path
23 17
24 18 Just like foreman, the labels don't actually matter - they are only used in formatting the output.
25 19
26   -That `Colanderfile` will run [foodcritic](https://github.com/acrmp/foodcritic) and knife test. I recommend this as the bare minimum for a cookbook test.
  20 +That `Strainerfile` will run [foodcritic](https://github.com/acrmp/foodcritic) and knife test. I recommend this as the bare minimum for a cookbook test.
27 21
28   -`Colanderfile`s commands are run in the context to the sandbox `.colander` directory. The sandbox is essentially a clone of your working directory. This can be a bit confusing. `knife cookbook test` requires that you run your command against the "root" directory, yet foodcrtitic and chefspec require you run inside an actual cookbook. Here's a quick example to clear up some confusion:
  22 +`Strainerfile`s commands are run in the context to the sandbox directory. The sandbox is essentially a clone of your working directory. This can be a bit confusing. `knife cookbook test` requires that you run your command against the "root" directory, yet foodcrtitic and chefspec require you run inside an actual cookbook. Here's a quick example to clear up some confusion:
29 23
30   - # Colanderfile
  24 + # Strainerfile
31 25 knife test: bundle exec knife cookbook test $COOKBOOK
32 26 foodcritic: bundle exec foodcritic -f any $SANDBOX/$COOKBOOK
33 27 chefspec: bundle exec rspec $SANDBOX/$COOKBOOK
34 28
35 29 To strain, simply run the `strain` command and pass in the cookbook(s) to strain:
36 30
37   - $ bundle exec strain phantomjs tmux
38   -
39   -This will first detect the cookbook dependencies, copy the cookbook and all dependencies into a sandbox. It will execute the contents of the `Colanderfile` on each cookbook.
40   -
41   -Using Berkshelf
42   ----------------
43   -[Berkshelf](http://berkshelf.com/) is a tool for managing multiple cookbooks. It works very similar to how a `Gemfile` works with Rubygems.
44   -
45   -You'll need to tell Berkshelf to install the cookbooks to a folder inside your local repository. This way, strainer can actually find them.
46   -
47   -For example, this will install your cookbooks into the `berks-cookbooks` directory:
48   -
49   - $ bundle exec berks install --path berks-cookbooks
50   -
51   -Finally, make sure that this path is **first** in your `.chef/knife.rb` file:
  31 + $ bundle exec strainer test phantomjs tmux
52 32
53   -```ruby
54   -# .chef/knife.rb
55   -current_dir = File.dirname(__FILE__)
56   -cookbook_path ["#{current_dir}/../berks-cookbooks", "#{current_dir}/../cookbooks"]
57   -```
  33 +This will first detect the cookbook dependencies, copy the cookbook and all dependencies into a sandbox. It will execute the contents of the `Strainerfile` on each cookbook.
58 34
59   -Or pass it as an argument to the `strain` command:
  35 +Berkshelf
  36 +---------
  37 +[Berkshelf](http://berkshelf.com/) is a tool for managing multiple cookbooks. It works very similar to how a `Gemfile` works with Rubygems. If you're already using Berkshelf, Strainer will work out of the box. If you're not using Berkshelf, Strainer will work out of the box.
60 38
61   - $ bundle exec strain phantomjs --cookbooks-path berks-cookbooks
  39 +Librarian Chef
  40 +--------------
  41 +Strainer does not support librarian-chef, and I have no plans to implement this feature. PRs are welcome, but Strainer is closely tied to Berkshelf, intentionally.
62 42
63 43 Failing Quickly
64 44 ---------------
65 45 As of `v0.0.4`, there's an option for `--fail-fast` that will fail immediately when any strain command returns a non-zero exit code:
66 46
67   - $ bundle exec strain phantomjs --fail-fast
  47 + $ bundle exec strainer test phantomjs --fail-fast
68 48
69 49 This can save time, especially when running tests locally. This is *not* recommended on continuous integration.
70 50
@@ -74,7 +54,7 @@ I always advocate using both [Etsy Foodcritic Rules](https://github.com/etsy/foo
74 54
75 55 Strainer runs everything in an isolated sandbox, inside your Chef Repo. When including additional foodcritic rules, you need to do something like this:
76 56
77   - # Colanderfile
  57 + # Strainerfile
78 58 foodcritic: bundle exec foodcritic -I foodcritic/* -f any $SANDBOX/$COOKBOOK
79 59
80 60 Needs Your Help
9 bin/strain
... ... @@ -1,8 +1,9 @@
1 1 #!/usr/bin/env ruby
2   -
3 2 $:.unshift File.expand_path('../lib', __FILE__)
4   -
5 3 require 'strainer'
6   -require 'strainer/cli'
7 4
8   -Strainer::CLI.run(*ARGV)
  5 +# This executable is deprecated
  6 +#
  7 +# @deprecated use `bundle exec strainer test` instead!
  8 +Strainer.ui.deprecated 'The `strain` command is deprecated. Use `strainer test $COOKBOOKS` instead!'
  9 +system "strainer test #{ARGV.join(' ')}"
11 bin/strainer
... ... @@ -0,0 +1,11 @@
  1 +#!/usr/bin/env ruby
  2 +$:.unshift File.expand_path('../lib', __FILE__)
  3 +require 'strainer'
  4 +
  5 +begin
  6 + Strainer::Cli.start
  7 +rescue Strainer::Error::Base => e
  8 + Strainer.ui.error "#{e.class} - #{e}"
  9 + Strainer.ui.error " #{e.backtrace.join("\n ")}" if ENV['DEBUG']
  10 + exit e.status_code
  11 +end
1  lib/berkshelf/extensions.rb
... ... @@ -0,0 +1 @@
  1 +require 'berkshelf/extensions/cached_cookbook'
20 lib/berkshelf/extensions/cached_cookbook.rb
... ... @@ -0,0 +1,20 @@
  1 +module Berkshelf
  2 + # Extensions on Berkshelf::CachedCookbook
  3 + #
  4 + # @author Seth Vargo <sethvargo@gmail.com>
  5 + class CachedCookbook
  6 + # @return [Pathname]
  7 + # the original location of this cookbook
  8 + attr_reader :original_path
  9 +
  10 + # Allow overriding the path, but store the old path in another
  11 + # instance variable.
  12 + #
  13 + # @param [Pathname] location
  14 + # the new location for this cookbook
  15 + def path=(location)
  16 + @original_path = @path.dup
  17 + @path = location
  18 + end
  19 + end
  20 +end
56 lib/strainer.rb
... ... @@ -1,9 +1,55 @@
1   -require 'strainer/color'
2   -require 'strainer/runner'
3   -require 'strainer/sandbox'
  1 +require 'berkshelf'
  2 +require 'berkshelf/extensions'
  3 +require 'pathname'
  4 +require 'thor'
  5 +
  6 +require 'strainer/errors'
4 7
5 8 module Strainer
6   - def self.root
7   - @@root ||= File.expand_path('../../', __FILE__)
  9 + autoload :Cli, 'strainer/cli'
  10 + autoload :Command, 'strainer/command'
  11 + autoload :Runner, 'strainer/runner'
  12 + autoload :Sandbox, 'strainer/sandbox'
  13 + autoload :Strainerfile, 'strainer/strainerfile'
  14 + autoload :UI, 'strainer/ui'
  15 + autoload :Version, 'strainer/version'
  16 +
  17 + class << self
  18 + # The root of the application
  19 + #
  20 + # @return [Pathname]
  21 + # the path to the root of Strainer
  22 + def root
  23 + @root ||= Pathname.new(File.expand_path('../../', __FILE__))
  24 + end
  25 +
  26 + # The UI instance
  27 + #
  28 + # @return [Strainer::UI]
  29 + # an instance of the strainer UI
  30 + def ui
  31 + @ui ||= Strainer::UI.new
  32 + end
  33 +
  34 + # Helper method to access a constant defined in Strainer::Sandbox that
  35 + # specifies the location of the sandbox
  36 + #
  37 + # @return [Pathname]
  38 + # the path to the sandbox
  39 + def sandbox_path
  40 + Strainer::Sandbox::SANDBOX
  41 + end
  42 +
  43 + # Helper method to access a constant defined in Strainer::Strainerfile that
  44 + # specifies the filename for Strainer
  45 + #
  46 + # @return [String]
  47 + # the filename for Strainerfile
  48 + def strainerfile_name
  49 + Strainer::Strainerfile::FILENAME
  50 + end
8 51 end
9 52 end
  53 +
  54 +# Sync STDOUT to get "real-time" output
  55 +STDOUT.sync = true
75 lib/strainer/cli.rb
... ... @@ -1,52 +1,39 @@
1   -require 'optparse'
  1 +require 'strainer'
2 2
3 3 module Strainer
4   - class CLI
5   - def self.run(*args)
6   - parse_options(*args)
7   -
8   - if @cookbooks.empty?
9   - puts Color.red { 'ERROR: You did not specify any cookbooks!' }
10   - else
11   - @sandbox = Strainer::Sandbox.new(@cookbooks, @options)
12   - @runner = Strainer::Runner.new(@sandbox, @options)
13   - end
  4 + # Use our own custom shell
  5 + Thor::Base.shell = Strainer::UI
  6 +
  7 + # Cli runner for Strainer
  8 + #
  9 + # @author Seth Vargo <sethvargo@gmail.com>
  10 + class Cli < Thor
  11 + # global options
  12 + map ['-v', '--version'] => :version
  13 + class_option :cookbooks_path, :type => :string, :aliases => '-p', :desc => 'The path to the cookbook store', :banner => 'PATH'
  14 + class_option :config, :type => :string, :aliases => '-c', :desc => 'The path to the knife.rb config'
  15 +
  16 + # strainer test *COOKBOOKS
  17 + method_option :except, :type => :array, :aliases => '-e', :desc => 'Strainerfile labels to ignore'
  18 + method_option :only, :type => :array, :aliases => '-o', :desc => 'Strainerfile labels to include'
  19 + method_option :fail_fast, :type => :boolean, :aliases => '-x', :desc => 'Stop termination immediately if a test fails', :banner => '', :default => false
  20 + desc 'test [COOKBOOKS]', 'Run tests against the given cookbooks'
  21 + def test(*cookbooks)
  22 + Strainer::Runner.new(cookbooks, options)
14 23 end
15 24
16   - private
17   - def self.parse_options(*args)
18   - @options = {}
19   -
20   - parser = OptionParser.new do |options|
21   - # remove OptionParsers Officious['version'] to avoid conflicts
22   - options.base.long.delete('version')
23   -
24   - options.on nil, '--fail-fast', 'Fail fast' do |ff|
25   - @options[:fail_fast] = ff
26   - end
27   -
28   - options.on '-p PATH', '--cookbooks-path PATH', 'Path to the cookbooks' do |cp|
29   - @options[:cookbooks_path] = cp
30   - end
31   -
32   - options.on '-h', '--help', 'Display this help screen' do
33   - puts options
34   - exit 0
35   - end
36   -
37   - options.on '-v', '--version', 'Display the current version' do
38   - require 'strainer/version'
39   - puts Strainer::VERSION
40   - exit 0
41   - end
42   - end
  25 + # strainer info
  26 + desc 'info', 'Display version and copyright information'
  27 + def info
  28 + Strainer.ui.info "Strainer (#{Strainer::VERSION})"
  29 + Strainer.ui.info "\n"
  30 + Strainer.ui.info File.read Strainer.root.join('LICENSE')
  31 + end
43 32
44   - # Get the cookbook names. The options that aren't read by optparser are assummed
45   - # to be cookbooks in this case.
46   - @cookbooks = []
47   - parser.order!(args) do |noopt|
48   - @cookbooks << noopt
49   - end
  33 + # strainer -v
  34 + desc 'version', 'Display the version information', hide: true
  35 + def version
  36 + Strainer.ui.info Strainer::VERSION
50 37 end
51 38 end
52 39 end
7 lib/strainer/color.rb
... ... @@ -1,7 +0,0 @@
1   -require 'term/ansicolor'
2   -
3   -module Strainer
4   - class Color
5   - extend Term::ANSIColor
6   - end
7   -end
93 lib/strainer/command.rb
... ... @@ -0,0 +1,93 @@
  1 +module Strainer
  2 + # The Command class is responsible for a command (test) against a cookbook.
  3 + #
  4 + # @author Seth Vargo <sethvargo@gmail.com>
  5 + class Command
  6 + # List of colors to choose from when outputting labels
  7 + COLORS = %w(yellow blue magenta cyan).freeze
  8 +
  9 + # @return [String]
  10 + # the "text" form of the command to run
  11 + attr_reader :command
  12 +
  13 + # @return [String]
  14 + # the label for this command
  15 + attr_reader :label
  16 +
  17 + # Parse a command out of the given string (line)
  18 + #
  19 + # @param [String] line
  20 + # the line to parse
  21 + # example: foodcritic -f any phantomjs
  22 + def initialize(line, cookbook, options = {})
  23 + @label, @command = line.split(':', 2).map(&:strip)
  24 + @cookbook = cookbook
  25 + end
  26 +
  27 + # Run the given command against the cookbook
  28 + #
  29 + # @return [Boolean]
  30 + # `true` if the command exited successfully, `false` otherwise
  31 + def run!
  32 + title(label)
  33 +
  34 + Dir.chdir Strainer.sandbox_path do
  35 + speak command
  36 + speak `#{command}`
  37 +
  38 + unless $?.success?
  39 + Strainer.ui.error label_with_padding + Strainer.ui.set_color('Terminated with a non-zero exit status. Strainer assumes this is a failure.', :red)
  40 + Strainer.ui.error label_with_padding + Strainer.ui.set_color('FAILURE!', :red)
  41 + false
  42 + else
  43 + Strainer.ui.success label_with_padding + Strainer.ui.set_color('SUCCESS!', :green)
  44 + true
  45 + end
  46 + end
  47 + end
  48 +
  49 + # Have this command output text, prefixing with its output with the
  50 + # command name
  51 + #
  52 + # @param [String] message
  53 + # the message to speak
  54 + # @param [Hash] options
  55 + # a list of options to pass along
  56 + def speak(message, options = {})
  57 + message.to_s.strip.split("\n").each do |line|
  58 + next if line.strip.empty?
  59 +
  60 + line.gsub! Strainer.sandbox_path.to_s, @cookbook.original_path.dirname.to_s
  61 + Strainer.ui.say label_with_padding + line, options
  62 + end
  63 + end
  64 +
  65 + private
  66 + # Return the color associated with this label
  67 + #
  68 + # @return [Symbol]
  69 + # the color (as a symbol) associated with this label
  70 + def color
  71 + @color ||= COLORS[label.length%COLORS.length].to_sym
  72 + end
  73 +
  74 + # Update the current process name and terminal title with
  75 + # the given title.
  76 + #
  77 + # @param [String] title
  78 + # the title to update with
  79 + def title(title)
  80 + $0 = title
  81 + printf "\033]0;#{title}\007"
  82 + end
  83 +
  84 + # Get the label corresponding to this command with spacial padding
  85 + #
  86 + # @return [String]
  87 + # the padding and colored label
  88 + def label_with_padding
  89 + padded_label = label[0..20].ljust(20) + ' | '
  90 + Strainer.ui.set_color padded_label, color
  91 + end
  92 + end
  93 +end
27 lib/strainer/errors.rb
... ... @@ -0,0 +1,27 @@
  1 +module Strainer
  2 + module Error
  3 + # Base class for our custom errors to inherit from.
  4 + #
  5 + # @author Seth Vargo <sethvargo@gmail.com>
  6 + class Base < StandardError
  7 + # Helper method for creating errors using a given status code
  8 + #
  9 + # @param [Integer] code
  10 + # the status code for this error
  11 + def self.status_code(code)
  12 + define_method(:status_code) { code }
  13 + define_singleton_method(:status_code) { code }
  14 + end
  15 + end
  16 +
  17 + # Raised when a required cookbook is not found.
  18 + #
  19 + # @author Seth Vargo <sethvargo@gmail.com>
  20 + class CookbookNotFound < Base; status_code(100); end
  21 +
  22 + # Raised when Strainer is unable to find a Strainerfile.
  23 + #
  24 + # @author Seth Vargo <sethvargo@gmail.com>
  25 + class StrainerfileNotFound < Base; status_code(101); end
  26 + end
  27 +end
177 lib/strainer/runner.rb
... ... @@ -1,132 +1,87 @@
1 1 module Strainer
  2 + # The Runner class is responsible for executing the tests against cookbooks.
  3 + #
  4 + # @author Seth Vargo <sethvargo@gmail.com>
2 5 class Runner
3   - def initialize(sandbox, options = {})
4   - @sandbox = sandbox
5   - @cookbooks = @sandbox.cookbooks
6   - @options = options
7   -
8   - # need a variable at the root level to track whether the
9   - # build actually passed
10   - success = true
11   -
12   - @cookbooks.each do |cookbook|
13   - $stdout.puts
14   - $stdout.puts Color.negative{ "# Straining '#{cookbook.name}'" }
15   -
16   - commands_for(cookbook.name.to_s).collect do |command|
17   - success &= run(command)
18   -
19   - if fail_fast? && !success
20   - $stdout.puts [ label_with_padding(command[:label]), Color.red{ 'Exited early because --fail-fast was specified. Some tests may have been skipped!' } ].join(' ')
21   - abort
22   - end
  6 + # List taken from: http://wiki.opscode.com/display/chef/Chef+Configuration+Settings
  7 + # Listed in order of preferred preference
  8 + KNIFE_LOCATIONS = [
  9 + './.chef/knife.rb',
  10 + '~/.chef/knife.rb',
  11 + '/etc/chef/solo.rb',
  12 + '/etc/chef/client.rb'
  13 + ].freeze
  14 +
  15 + class << self
  16 + # Perform a smart search for knife.rb chef configuration file
  17 + #
  18 + # @return [Pathname]
  19 + # the path to the chef configuration
  20 + def chef_config_path
  21 + @chef_config_path ||= begin
  22 + location = KNIFE_LOCATIONS.find{ |location| File.exists?(File.expand_path(location)) }
  23 + location ||= '~/.chef/knife.rb'
  24 +
  25 + Pathname.new(File.expand_path(location))
23 26 end
24   -
25   - $stdout.puts
26 27 end
27 28
28   - # fail unless all commands returned successfully
29   - abort unless success
30   - end
31   -
32   - private
33   - def commands_for(cookbook_name)
34   - file = File.read( colanderfile_for(cookbook_name) )
35   -
36   - file = file.strip
37   - file = file.gsub('$COOKBOOK', cookbook_name)
38   - file = file.gsub('$SANDBOX', @sandbox.sandbox_path)
39   -
40   - # drop empty lines and comments
41   - lines = file.split("\n").reject{|c| c.strip.empty? || c.start_with?('#')}.compact
42   -
43   - # parse the line and split it into the label and command parts
  29 + # Set the chef_config_path
44 30 #
45   - # example line: foodcritic -f any phantomjs
46   - lines.collect do |line|
47   - split_line = line.split(':', 2)
48   -
49   - {
50   - :label => split_line[0].strip,
51   - :command => split_line[1].strip
52   - }
53   - end || []
54   - end
55   -
56   - def colanderfile_for(cookbook_name)
57   - cookbook_level = File.join(@sandbox.sandbox_path(cookbook_name), 'Colanderfile')
58   - root_level = File.expand_path('Colanderfile')
59   -
60   - if File.exists?(cookbook_level)
61   - cookbook_level
62   - elsif File.exists?(root_level)
63   - root_level
64   - else
65   - raise "Could not find Colanderfile in #{cookbook_level} or #{root_level}"
  31 + # @param [String] path
  32 + # the path to the config file
  33 + # @return [Pathname]
  34 + # the supplied string as a Pathname
  35 + def chef_config_path=(path)
  36 + @chef_config = nil
  37 + @chef_config_path = Pathname.new(path)
  38 + @chef_config_path
66 39 end
67   - end
68 40
69   - def label_with_padding(label)
70   - max_length = 12
71   - colors = [ :blue, :cyan, :magenta, :yellow ]
72   - color = colors[label.length%colors.length]
73   -
74   - Color.send(color) do
75   - "#{label[0...max_length].ljust(max_length)} | "
  41 + # Get the best chef configuration
  42 + #
  43 + # @return [Chef::Config]
  44 + # a chef configuration
  45 + def chef_config
  46 + @chef_config ||= begin
  47 + Chef::Config.from_file(chef_config_path)
  48 + Chef::Config
  49 + rescue
  50 + Chef::Config
  51 + end
76 52 end
77 53 end
78 54
79   - def run(command)
80   - Dir.chdir('.colander') do
81   - label = command[:label]
82   - command = command[:command]
83   - pretty_command = begin
84   - split = command.split(' ')
85   - path = split.pop
86   -
87   - if path =~ /\.colander/
88   - short_path = path.split('.colander').last[1..-1]
89   - else
90   - short_path = path
91   - end
  55 + # Creates a Strainer runner
  56 + #
  57 + # @param [Array<String>] cookbook_names
  58 + # an array of cookbook_names to test and load into the sandbox
  59 + # @param [Hash] options
  60 + # a list of options to pass along
  61 + def initialize(cookbook_names, options = {})
  62 + @options = options
  63 + @sandbox = Strainer::Sandbox.new(cookbook_names, @options)
  64 + @cookbooks = @sandbox.cookbooks
  65 + @report = {}
92 66
93   - split.push short_path
94   - split.join(' ')
95   - end
  67 + @cookbooks.each do |cookbook|
  68 + strainerfile = Strainer::Strainerfile.for(cookbook, options)
  69 + Strainer.ui.header("# Straining '#{cookbook.cookbook_name} (v#{cookbook.version})'")
96 70
97   - $stdout.puts [ label_with_padding(label), Color.bold{ Color.underscore{ pretty_command } } ].join(' ')
  71 + strainerfile.commands.each do |command|
  72 + success = command.run!
98 73
99   - result = format(label, `#{command}`)
100   - $stdout.puts result unless result.strip.empty?
  74 + @report[cookbook.cookbook_name] ||= {}
  75 + @report[cookbook.cookbook_name][command.label] = success
101 76
102   - if $?.success?
103   - $stdout.puts format(label, Color.green{'Success!'})
104   - $stdout.flush
105   - return true
106   - else
107   - $stdout.puts format(label, Color.red{'Failure!'})
108   - $stdout.flush
109   - return false
  77 + if @options[:fail_fast] && !success
  78 + Strainer.ui.fatal "Exited early because '--fail-fast' was specified. Some tests may have been skipped!"
  79 + abort
  80 + end
110 81 end
111 82 end
112   - end
113   -
114   - def format(label, data)
115   - data.to_s.strip.split("\n").collect do |line|
116   - if %w(fatal error alert).any?{ |e| line =~ /^#{e}/i }
117   - [ label_with_padding(label), Color.red{ line } ].join(' ')
118   - elsif %w(warn).any?{ |e| line =~ /^#{e}/i }
119   - [ label_with_padding(label), Color.yellow{ line } ].join(' ')
120   - elsif %w(info debug).any?{ |e| line =~ /^#{e}/i }
121   - [ label_with_padding(label), Color.cyan{ line } ].join(' ')
122   - else
123   - [ label_with_padding(label), line ].join(' ')
124   - end
125   - end.join("\n")
126   - end
127 83
128   - def fail_fast?
129   - @options[:fail_fast]
  84 + abort unless @report.values.collect(&:values).flatten.all?{|v| v == true}
130 85 end
131 86 end
132 87 end
205 lib/strainer/sandbox.rb
... ... @@ -1,117 +1,156 @@
1   -require 'chef'
2   -require 'chef/knife'
3   -require 'fileutils'
4   -
5 1 module Strainer
  2 + # Manages the Strainer sandbox (playground) for isolated testing.
  3 + #
  4 + # @author Seth Vargo <sethvargo@gmail.com>
6 5 class Sandbox
  6 + # The path to the testing sandbox inside the gem.
  7 + SANDBOX = Strainer.root.join('sandbox').freeze
  8 +
  9 + # @return [Array<Berkshelf::CachedCookbook>]
  10 + # an array of cookbooks in this sandbox
7 11 attr_reader :cookbooks
8 12
9   - def initialize(cookbooks = [], options = {})
10   - @options = options
11   - @cookbooks = load_cookbooks([cookbooks].flatten)
  13 + # Create a new sandbox for the given cookbooks
  14 + #
  15 + # @param [Array<String>] cookbook_names
  16 + # the list of cookbooks to copy into the sandbox
  17 + # @param [Hash] options
  18 + # a list of options to pass along
  19 + def initialize(cookbook_names, options = {})
  20 + @options = options
  21 + @cookbooks = load_cookbooks(cookbook_names)
  22 +
  23 + reset_sandbox
  24 + copy_cookbooks
  25 + end
12 26
13   - clear_sandbox
  27 + # Clear out the existing sandbox and create the directories
  28 + def reset_sandbox
  29 + destroy_sandbox
14 30 create_sandbox
15 31 end
16 32
17   - def cookbook_path(cookbook)
18   - found_paths = cookbooks_path.select{ |path| File.exists?( File.join(path, cookbook.name.to_s) ) }
  33 + # Destroy the current sandbox, if it exists
  34 + def destroy_sandbox
  35 + FileUtils.rm_rf(SANDBOX) if File.directory?(SANDBOX)
  36 + end
19 37
20   - if found_paths.empty?
21   - puts Color.red { "Cookbook '#{cookbook.name}' was not found in #{cookbooks_path}" }
22   - exit(1)
23   - elsif found_paths.size > 1
24   - puts Color.yellow { "Cookbook '#{cookbook.name}' was found in multiple paths: #{found_paths}." }
25   - puts Color.yellow { 'Strainer can only handle one cookbook per path at this time. Pull requests are welcome :)' }
26   - exit(1)
  38 + # Create the sandbox unless it already exits
  39 + def create_sandbox
  40 + unless File.directory?(SANDBOX)
  41 + FileUtils.mkdir_p(SANDBOX)
  42 + copy_globals
  43 + place_knife_rb
27 44 end
28   -
29   - return File.join(found_paths[0], cookbook.name.to_s)
30 45 end
31 46
32   - def sandbox_path(cookbook = nil)
33   - File.expand_path( File.join(%W(.colander cookbooks #{cookbook.is_a?(::Chef::CookbookVersion) ? cookbook.name : cookbook})) )
  47 + # Copy over a whitelist of common files into our sandbox
  48 + def copy_globals
  49 + files = Dir[*%W(#{Strainer.strainerfile_name} foodcritic .rspec spec test)]
  50 + FileUtils.cp_r(files, SANDBOX)
34 51 end
35 52
36   - private
37   - # Load a specific cookbook by name
38   - def load_cookbook(cookbook_name)
39   - return cookbook_name if cookbook_name.is_a?(::Chef::CookbookVersion)
40   - loader = ::Chef::CookbookLoader.new(cookbooks_path)
41   - loader[cookbook_name]
42   - end
  53 + # Create a basic knife.rb file to ensure tests run successfully
  54 + def place_knife_rb
  55 + chef_path = SANDBOX.join('.chef')
  56 + FileUtils.mkdir_p(chef_path)
43 57
44   - # Load an array of cookbooks by name
45   - def load_cookbooks(cookbook_names)
46   - cookbook_names = [cookbook_names].flatten
47   - cookbook_names.collect{ |cookbook_name| load_cookbook(cookbook_name) }
48   - end
  58 + # Build the contents
  59 + contents = <<-EOH
  60 +cache_type 'BasicFile'
  61 +cache_options(:path => "\#{ENV['HOME']}/.chef/checksums")
  62 +cookbook_path '#{SANDBOX}'
  63 +EOH
49 64
50   - def cookbooks_path
51   - @cookbooks_path ||= [@options[:cookbooks_path] || ( File.exists?(knife_rb_path) ? (Chef::Config.from_file(knife_rb_path) && Chef::Config.cookbook_path.compact ) : nil) || File.expand_path('cookbooks')].flatten.collect{ |p| File.expand_path(p) }
  65 + # Create knife.rb
  66 + File.open("#{chef_path}/knife.rb", 'w+'){ |f| f.write(contents) }
52 67 end
53 68
54   - def clear_sandbox
55   - FileUtils.rm_rf(sandbox_path)
56   - end
  69 + # Copy all the cookbooks provided in {#initialize} to the isolated sandbox location
  70 + def copy_cookbooks
  71 + cookbooks_and_dependencies.each do |cookbook|
  72 + sandbox_path = SANDBOX.join(cookbook.cookbook_name)
57 73
58   - def create_sandbox
59   - FileUtils.mkdir_p(sandbox_path)
  74 + # Copy the files to our sandbox
  75 + FileUtils.cp_r(cookbook.path, sandbox_path)
60 76
61   - copy_globals
62   - copy_cookbooks
63   - place_knife_rb
  77 + # Override the @path location so we don't need to create a new object
  78 + cookbook.path = sandbox_path
  79 + end
64 80 end
65 81
66   - def copy_globals
67   - files = %w(.rspec spec test foodcritic)
68   - FileUtils.cp_r( Dir["{#{files.join(',')}}"], sandbox_path('..') )
  82 + # Load a cookbook from the given array of cookbook names
  83 + #
  84 + # @param [Array<String>] cookbook_names
  85 + # the list of cookbooks to search for
  86 + # @return [Array<Berkshelf::CachedCookbook>]
  87 + # the array of cached cookbooks
  88 + def load_cookbooks(cookbook_names)
  89 + cookbook_names.collect{ |cookbook_name| load_cookbook(cookbook_name) }
69 90 end
70 91
71   - def copy_cookbooks
72   - (cookbooks + cookbooks_dependencies).each do |cookbook|
73   - FileUtils.cp_r(cookbook_path(cookbook), sandbox_path)
74   - end
75   - end
  92 + # Load an individual cookbook by its name
  93 + #
  94 + # @param [String] cookbook_name
  95 + # the name of the cookbook to load
  96 + # @return [Berkshelf::CachedCookbook]
  97 + # the cached cookbook
  98 + # @raise [Strainer::Error::CookbookNotFound]
  99 + # when the cookbook was not found in any of the sources
  100 + def load_cookbook(cookbook_name)
  101 + cookbook = cookbooks_paths.collect do |path|
  102 + begin
  103 + Berkshelf::CachedCookbook.from_path(path.join(cookbook_name))
  104 + rescue Berkshelf::CookbookNotFound
  105 + # move onto the next source...
  106 + nil
  107 + end
  108 + end.compact.last
76 109
77   - def place_knife_rb
78   - chef_path = File.join(sandbox_path, '..','.chef')
79   - FileUtils.mkdir_p(chef_path)
  110 + cookbook ||= Berkshelf.cookbook_store.cookbooks(cookbook_name).last
80 111
81   - # build the contents
82   - contents = <<-EOH
83   -cache_type 'BasicFile'
84   -cache_options(:path => "\#{ENV['HOME']}/.chef/checksums")
85   -cookbook_path '#{sandbox_path}'
86   -EOH
  112 + unless cookbook
  113 + raise Strainer::Error::CookbookNotFound, "Could not find cookbook #{cookbook_name} in any of the sources."
  114 + end
87 115
88   - # create knife.rb
89   - File.open("#{chef_path}/knife.rb", 'w+'){ |f| f.write(contents) }
  116 + cookbook
90 117 end
91 118
92   - def knife_rb_path
93   - File.join(Chef::Knife.chef_config_dir, 'knife.rb')
  119 + # Dynamically builds a list of possible cookbook paths from the
  120 + # `@options` hash, Berkshelf config, and Chef config, and a logical
  121 + # guess
  122 + #
  123 + # @return [Array<Pathname>]
  124 + # a list of possible cookbook locations
  125 + def cookbooks_paths
  126 + @cookbooks_paths ||= begin
  127 + paths = [
  128 + @options[:cookbooks_path],
  129 + Strainer::Runner.chef_config.cookbook_path,
  130 + Berkshelf::Config.chef_config.cookbook_path,
  131 + 'cookbooks'
  132 + ].flatten.compact.map{ |path| Pathname.new(File.expand_path(path)) }.uniq
  133 +
  134 + paths.select{ |path| File.exists?(path) }
  135 + end
94 136 end
95 137
96   - # Iterate over the cookbook's dependencies and ensure those cookbooks are
97   - # also included in our sandbox by adding them to the @cookbooks instance
98   - # variable. This method is actually semi-recursive because we append to the
99   - # end of the array on which we are iterating, ensuring we load all dependencies
100   - # dependencies.
101   - def cookbooks_dependencies
102   - @cookbooks_dependencies ||= begin
103   - $stdout.puts 'Loading cookbook dependencies...'
104   -
105   - loaded_dependencies = Hash.new(false)
106   -
107   - dependencies = @cookbooks.dup
108   -
109   - dependencies.each do |cookbook|
110   - cookbook.metadata.dependencies.keys.each do |dependency_name|
111   - unless loaded_dependencies[dependency_name]
112   - dependencies << load_cookbook(dependency_name)
113   - loaded_dependencies[dependency_name] = true
114   - end
  138 + # Collect all cookbooks and the dependencies specified in their metadata.rb
  139 + # for copying
  140 + #
  141 + # @return [Array<Berkshelf::CachedCookbook>]
  142 + # a list of cached cookbooks
  143 + def cookbooks_and_dependencies
  144 + loaded_dependencies = Hash.new(false)
  145 +
  146 + dependencies = @cookbooks.dup
  147 + dependencies.each do |cookbook|
  148 + loaded_dependencies[cookbook.cookbook_name] = true
  149 +
  150 + cookbook.metadata.dependencies.keys.each do |dependency_name|
  151 + unless loaded_dependencies[dependency_name]
  152 + dependencies << load_cookbook(dependency_name)
  153 + loaded_dependencies[dependency_name] = true
115 154 end
116 155 end
117 156 end
77 lib/strainer/strainerfile.rb
... ... @@ -0,0 +1,77 @@
  1 +module Strainer
  2 + # An instance of a Strainerfile.
  3 + #
  4 + # @author Seth Vargo <sethvargo@gmail.com>
  5 + class Strainerfile
  6 + # The filename for the Strainerfile
  7 + FILENAME = 'Strainerfile'
  8 +
  9 + class << self
  10 + # (see #initialize)
  11 + def for(cookbook, options = {})
  12 + new(cookbook, options)
  13 + end
  14 + end
  15 +
  16 + # Instantiate an instance of this class from a cookbook
  17 + #
  18 + # @param [Berkshelf::CachedCookbook] cookbook
  19 + # the cached cookbook to search for a Strainerfile
  20 + # @param [Hash] options
  21 + # a list of options to pass along
  22 + # @return [Strainerfile]
  23 + # an instance of this class
  24 + def initialize(cookbook, options = {})
  25 + @cookbook = cookbook
  26 + @options = options
  27 +
  28 + locations = [
  29 + @cookbook.path.join(FILENAME),
  30 + Strainer.sandbox_path.join(FILENAME)
  31 + ]
  32 +
  33 + @strainerfile = locations.find{ |location| File.exists?(location) }
  34 +
  35 + raise Strainer::Error::StrainerfileNotFound, "Could not find a Strainerfile for cookbook '#{cookbook.cookbook_name}'." unless @strainerfile
  36 +
  37 + load!
  38 + end
  39 +
  40 + # Get the list of commands to run, filtered by the `@options` hash for either
  41 + # `:ignore` or `:only`
  42 + #
  43 + # @return [Array<Strainer::Command>]
  44 + # the list of commands to execute
  45 + def commands
  46 + @commands ||= if @options[:except]
  47 + @all_commands.reject{ |command| @options[:except].include?(command.label) }
  48 + elsif @options[:only]
  49 + @all_commands.select{ |command| @options[:only].include?(command.label) }
  50 + else
  51 + @all_commands
  52 + end
  53 + end
  54 +
  55 + private
  56 + # Parse the given Strainerfile
  57 + def load!
  58 + return if @all_commands
  59 + contents = File.read @strainerfile
  60 + contents.strip!
  61 + contents.gsub! '$COOKBOOK', @cookbook.cookbook_name
  62 + contents.gsub! '$SANDBOX', Strainer.sandbox_path.to_s
  63 +
  64 + # Drop empty lines and comments
  65 + lines = contents.split("\n")
  66 + lines.reject!{ |line| line.strip.empty? || line.strip.start_with?('#') }
  67 + lines.compact!
  68 + lines ||= []
  69 +
  70 + # Parse the line and split it into the label and command parts
  71 + #
  72 + # @example Example Line
  73 + # foodcritic -f any phantomjs
  74 + @all_commands = lines.collect{ |line| Command.new(line, @cookbook, @options) }
  75 + end
  76 + end
  77 +end
87 lib/strainer/ui.rb
... ... @@ -0,0 +1,87 @@
  1 +module Strainer
  2 + # Extend Thor::Shell::Color to provide nice helpers for outputting
  3 + # to the console.
  4 + #
  5 + # @author Seth Vargo <sethvargo@gmail.com>
  6 + class UI < ::Thor::Shell::Color
  7 + # Print the given message to STDOUT.
  8 + #
  9 + # @param [String]
  10 + # message the message to print
  11 + # @param [Symbol] color
  12 + # the color to use
  13 + # @param [Boolean] new_line
  14 + # include a new_line character
  15 + def say(message = '', color = nil, new_line = nil)
  16 + return if quiet?
  17 + super(message, color)
  18 + end
  19 + alias_method :info, :say
  20 +
  21 + # Print the given message to STDOUT.
  22 + #
  23 + # @param [String] status
  24 + # the status to print
  25 + # @param [String] message
  26 + # the message to print
  27 + # @param [Boolean] log_status
  28 + # whether to log the status
  29 + def say_status(status, message, log_status = true)
  30 + return if quiet?
  31 + super(status, message, log_status)
  32 + end
  33 +
  34 + # Print a header message
  35 + #
  36 + # @param [String] message
  37 + # the message to print
  38 + def header(message)
  39 + return if quiet?
  40 + say(message, [:black, :on_white])
  41 + end
  42 +
  43 + # Print a green success message to STDOUT.
  44 + #
  45 + # @param [String] message
  46 + # the message to print
  47 + # @param [Symbol] color
  48 + # the color to use
  49 + def success(message, color = :green)
  50 + return if quiet?
  51 + say(message, color)
  52 + end
  53 +
  54 + # Print a yellow warning message to STDOUT.
  55 + #
  56 + # @param [String] message
  57 + # the message to print
  58 + # @param [Symbol] color
  59 + # the color to use
  60 + def warn(message, color = :yellow)
  61 + return if quiet?
  62 + say(message, color)
  63 + end
  64 +
  65 + # Print a red error message to the STDERR.
  66 + #
  67 + # @param [String] message
  68 + # the message to print
  69 + # @param [Symbol] color
  70 + # the color to use
  71 + def error(message, color = :red)
  72 + return if quiet?
  73 + message = set_color(message, *color) if color
  74 + super(message)
  75 + end
  76 + alias_method :fatal, :error
  77 +
  78 + # Print a deprecation notice to STDERR.
  79 + #
  80 + # @param [String] message
  81 + # the message to print
  82 + def deprecated(message)
  83 + return if quiet?
  84 + error('DEPRECATION NOTICE: ' + message)
  85 + end
  86 + end
  87 +end
3  lib/strainer/version.rb
... ... @@ -1,3 +1,4 @@
1 1 module Strainer
2   - VERSION = '0.2.1'
  2 + # The current version of Strainer
  3 + VERSION = '1.0.0.rc1'
3 4 end
6 strainer.gemspec
@@ -15,8 +15,8 @@ Gem::Specification.new do |gem|
15 15 gem.name = 'strainer'
16 16 gem.require_paths = ['lib']
17 17
18   - gem.add_runtime_dependency 'chef', '>= 10.10'
19   - gem.add_runtime_dependency 'term-ansicolor', '~> 1.0.7'
  18 + gem.add_runtime_dependency 'berkshelf', '~> 1.0'
20 19
21   - gem.add_development_dependency 'yard', '~> 0.8.3'
  20 + gem.add_development_dependency 'redcarpet'
  21 + gem.add_development_dependency 'yard', '~> 0.8'
22 22 end

0 comments on commit e659a94

Please sign in to comment.
Something went wrong with that request. Please try again.