summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Ball <tyleraball@gmail.com>2014-12-29 15:56:52 -0800
committerTyler Ball <tyleraball@gmail.com>2014-12-29 15:56:52 -0800
commit005f75e158ce702bb28fd6199e9654b3d1115d3b (patch)
treed049236793b5ce8ade14f594218eac917ed31291
parentb7b7dad4e476b3fde67f0d9881e15efe7e5b60ac (diff)
parentc1676b32aa08b618f4c2317676f5590388b3bc53 (diff)
downloadchef-005f75e158ce702bb28fd6199e9654b3d1115d3b.tar.gz
Merge pull request #2674 from opscode/audit-mode
Audit mode
-rw-r--r--.gitignore3
-rw-r--r--.kitchen.yml82
-rw-r--r--CHANGELOG.md1
-rw-r--r--DOC_CHANGES.md36
-rw-r--r--RELEASE_NOTES.md54
-rw-r--r--chef.gemspec11
-rw-r--r--kitchen-tests/.chef/client.rb9
-rw-r--r--kitchen-tests/cookbooks/audit_test/.gitignore16
-rw-r--r--kitchen-tests/cookbooks/audit_test/.kitchen.yml16
-rw-r--r--kitchen-tests/cookbooks/audit_test/Berksfile3
-rw-r--r--kitchen-tests/cookbooks/audit_test/README.md12
-rw-r--r--kitchen-tests/cookbooks/audit_test/chefignore95
-rw-r--r--kitchen-tests/cookbooks/audit_test/metadata.rb8
-rw-r--r--kitchen-tests/cookbooks/audit_test/recipes/default.rb26
-rw-r--r--kitchen-tests/cookbooks/audit_test/recipes/error_duplicate_control_groups.rb17
-rw-r--r--kitchen-tests/cookbooks/audit_test/recipes/error_no_block.rb7
-rw-r--r--kitchen-tests/cookbooks/audit_test/recipes/error_orphan_control.rb13
-rw-r--r--kitchen-tests/cookbooks/audit_test/recipes/failed_specs.rb14
-rw-r--r--kitchen-tests/cookbooks/audit_test/recipes/serverspec_collision.rb31
-rw-r--r--kitchen-tests/cookbooks/audit_test/recipes/serverspec_support.rb37
-rw-r--r--kitchen-tests/cookbooks/audit_test/recipes/with_include_recipe.rb16
-rw-r--r--kitchen-tests/cookbooks/webapp/README.md1
-rw-r--r--lib/chef/application/client.rb41
-rw-r--r--lib/chef/application/solo.rb3
-rw-r--r--lib/chef/audit/audit_event_proxy.rb93
-rw-r--r--lib/chef/audit/audit_reporter.rb169
-rw-r--r--lib/chef/audit/control_group_data.rb140
-rw-r--r--lib/chef/audit/rspec_formatter.rb37
-rw-r--r--lib/chef/audit/runner.rb178
-rw-r--r--lib/chef/client.rb99
-rw-r--r--lib/chef/config.rb8
-rw-r--r--lib/chef/dsl/audit.rb51
-rw-r--r--lib/chef/event_dispatch/base.rb35
-rw-r--r--lib/chef/exceptions.rb40
-rw-r--r--lib/chef/formatters/doc.rb48
-rw-r--r--lib/chef/monologger.rb2
-rw-r--r--lib/chef/recipe.rb2
-rw-r--r--lib/chef/run_context.rb4
-rw-r--r--spec/functional/audit/rspec_formatter_spec.rb53
-rw-r--r--spec/functional/audit/runner_spec.rb136
-rw-r--r--spec/functional/resource/deploy_revision_spec.rb3
-rw-r--r--spec/functional/resource/git_spec.rb2
-rw-r--r--spec/integration/client/client_spec.rb40
-rw-r--r--spec/support/audit_helper.rb65
-rw-r--r--spec/unit/application/client_spec.rb105
-rw-r--r--spec/unit/application/solo_spec.rb67
-rw-r--r--spec/unit/audit/audit_event_proxy_spec.rb311
-rw-r--r--spec/unit/audit/audit_reporter_spec.rb393
-rw-r--r--spec/unit/audit/control_group_data_spec.rb478
-rw-r--r--spec/unit/audit/rspec_formatter_spec.rb29
-rw-r--r--spec/unit/audit/runner_spec.rb133
-rw-r--r--spec/unit/client_spec.rb192
-rw-r--r--spec/unit/dsl/audit_spec.rb43
-rw-r--r--spec/unit/exceptions_spec.rb46
-rw-r--r--spec/unit/recipe_spec.rb7
55 files changed, 3465 insertions, 96 deletions
diff --git a/.gitignore b/.gitignore
index a9e4338e2a..ecba9f4030 100644
--- a/.gitignore
+++ b/.gitignore
@@ -38,3 +38,6 @@ Berksfile.lock
# Vagrant
Vagrantfile
.vagrant/
+
+# Kitchen Tests Local Mode Data
+kitchen-tests/nodes/*
diff --git a/.kitchen.yml b/.kitchen.yml
new file mode 100644
index 0000000000..ed49eb3e57
--- /dev/null
+++ b/.kitchen.yml
@@ -0,0 +1,82 @@
+driver:
+ name: vagrant
+ forward_agent: yes
+ customize:
+ cpus: 4
+ memory: 4096
+ synced_folders:
+ - ['.', '/home/vagrant/chef']
+
+provisioner:
+ name: chef_zero
+ require_chef_omnibus: 12.0.0.rc.1
+
+platforms:
+ - name: centos-5.10
+ run_list:
+ - name: centos-6.5
+ run_list:
+ - name: debian-7.2.0
+ run_list:
+ - name: debian-7.4
+ run_list:
+ - name: debian-6.0.8
+ run_list:
+ - name: freebsd-9.2
+ run_list:
+ - name: freebsd-10.0
+ run_list:
+ - name: ubuntu-10.04
+ run_list:
+ - name: ubuntu-12.04
+ run_list:
+ - name: ubuntu-12.10
+ run_list:
+ - name: ubuntu-13.04
+ run_list:
+ - name: ubuntu-13.10
+ run_list:
+ - name: ubuntu-14.04
+ run_list:
+ # The following boxes are shared via VagrantCloud. Until kitchen-vagrant
+ # is updated you'll need to add the box manually:
+ #
+ # vagrant box add chef/windows-8.1-professional
+ #
+ # Please note this may require a `vagrant login` if the box is private.
+ #
+ # The following boxes are VMware only also. You can enable VMware Fusion
+ # as the default provider by copying `.kitchen.local.yml.vmware.example`
+ # over to `.kitchen.local.yml`.
+ #
+ - name: macosx-10.8
+ driver:
+ box: chef/macosx-10.8 # private
+ - name: macosx-10.9
+ driver:
+ box: chef/macosx-10.9 # private
+ - name: macosx-10.10
+ driver:
+ box: chef/macosx-10.10 # private
+ # - name: windows-7-professional
+ # provisioner:
+ # name: windows_chef_zero
+ # require_chef_omnibus: 11.12.4
+ # driver:
+ # box: chef/windows-7-professional # private
+ # - name: windows-8.1-professional
+ # provisioner:
+ # name: windows_chef_zero
+ # require_chef_omnibus: 11.12.4
+ # driver:
+ # box: chef/windows-8.1-professional # private
+ # - name: windows-2008r2-standard
+ # provisioner:
+ # name: windows_chef_zero
+ # require_chef_omnibus: 11.12.4
+ # driver:
+ # box: chef/windows-server-2008r2-standard # private
+
+suites:
+ - name: chef
+ run_list:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8d9628347..0ea4b68264 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,7 @@
* Update Chef to use RSpec 3.
* Cleaned up script and execute provider + specs
* Added deprecation warnings around the use of command attribute in script resources
+* Audit mode feature added - see the RELEASE_NOTES for details
## 12.0.3
* [**Phil Dibowitz**](https://github.com/jaymzh):
diff --git a/DOC_CHANGES.md b/DOC_CHANGES.md
index bcf3a27286..7429baca2a 100644
--- a/DOC_CHANGES.md
+++ b/DOC_CHANGES.md
@@ -1,3 +1,37 @@
+<!---
+This file is reset every time a new release is done. This file describes changes that have not yet been released.
+
+Example Doc Change:
+### Headline for the required change
+Description of the required change.
+-->
+
### Chef now handles URI Schemes in a case insensitive manner
-Previously, when a URI scheme contained all uppercase letters, Chef would reject the URI as invalid. In compliance with RFC3986, Chef now treats URI schemes in a case insensitive manner. This applies to all resources which accept URIs such as remote_file etc. \ No newline at end of file
+Previously, when a URI scheme contained all uppercase letters, Chef would reject the URI as invalid. In compliance with RFC3986, Chef now treats URI schemes in a case insensitive manner. This applies to all resources which accept URIs such as remote_file etc.
+
+### Experimental Audit Mode Feature
+
+There is a new command_line flag provided for `chef-client`: `--audit-mode`. This accepts 1 of 3 arguments:
+
+* `disabled` (default) - Audits are disabled and the phase is skipped. This is the default while Audit mode is an
+experimental feature.
+* `enabled` - Audits are enabled and will be performed after the converge phase.
+* `audit-only` - Audits are enabled and convergence is disabled. Only audits will be performed.
+
+This can also be configured in your node's client.rb with the key `audit_mode` and a value of `:disabled`, `:enabled` or `:audit_only`.
+
+### Chef Why Run Mode Ignores Audit Phase
+
+Because most users enable `why_run` mode to determine what resources convergence will update on their system, the audit
+phase is not executed. There is no way to get both `why_run` output and audit output in 1 single command. To get
+audit output without performing convergence use the `--audit-mode` flag.
+
+#### Editors note 1
+
+The `--audit-mode` flag should be a link to the documentation for that flag
+
+#### Editors node 2
+
+This probably only needs to be a bullet point added to http://docs.getchef.com/nodes.html#about-why-run-mode under the
+`certain assumptions` section
diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md
index a99d44bc56..43c8f06d93 100644
--- a/RELEASE_NOTES.md
+++ b/RELEASE_NOTES.md
@@ -1,7 +1,59 @@
-# Chef Client Release Notes 12.2.0:
+# Chef Client Release Notes 12.1.0:
# Internal API Changes in this Release
+## Experimental Audit Mode Feature
+
+This is a new feature intended to provide _infrastructure audits_. Chef already allows you to configure your infrastructure
+with code, but there are some use cases that are not covered by resource convergence. What if you want to check that
+the application Chef just installed is functioning correctly? If it provides a status page an audit can check this
+and validate that the application has database connectivity.
+
+Audits are performed by leveraging [Serverspec](http://serverspec.org/) and [RSpec](https://relishapp.com/rspec) on the
+node. As such the syntax is very similar to a normal RSpec spec.
+
+### Syntax
+
+```ruby
+controls "Database Audit" do
+
+ control "postgres package" do
+ it "should not be installed" do
+ expect(package("postgresql")).to_not be_installed
+ end
+ end
+
+ let(:p) { port(111) }
+ control p do
+ it "has nothing listening" do
+ expect(p).to_not be_listening
+ end
+ end
+
+end
+```
+
+Using the example above I will break down the components of an Audit:
+
+* `controls` - This named block contains all the audits to be performed during the audit phase. During Chef convergence
+ the audits will be collected and ran in a separate phase at the end of the Chef run. Any `controls` block defined in
+ a recipe that is ran on the node will be performed.
+* `control` - This keyword describes a section of audits to perform. The name here should either be a string describing
+the system under test, or a [Serverspec resource](http://serverspec.org/resource_types.html).
+* `it` - Inside this block you can use [RSpec expectations](https://relishapp.com/rspec/rspec-expectations/docs) to
+write the audits. You can use the Serverspec resources here or regular ruby code. Any raised errors will fail the
+audit.
+
+### Output and error handling
+
+Output from the audit run will appear in your `Chef::Config[:log_location]`. If an audit fails then Chef will raise
+an error and exit with a non-zero status.
+
+### Further reading
+
+More information about the audit mode can be found in its
+[RFC](https://github.com/opscode/chef-rfc/blob/master/rfc035-audit-mode.md)
+
# End-User Changes
## OpenBSD Package provider was added
diff --git a/chef.gemspec b/chef.gemspec
index f623f8bb82..52babdc5a1 100644
--- a/chef.gemspec
+++ b/chef.gemspec
@@ -35,16 +35,17 @@ Gem::Specification.new do |s|
s.add_dependency 'plist', '~> 3.1.0'
+ # Audit mode requires these, so they are non-developmental dependencies now
+ %w(rspec-core rspec-expectations rspec-mocks).each { |gem| s.add_dependency gem, "~> 3.1" }
+ s.add_dependency "rspec_junit_formatter", "~> 0.2.0"
+ s.add_dependency "serverspec", "~> 2.7"
+ s.add_dependency "specinfra", "~> 2.10"
+
s.add_development_dependency "rack"
# Rake 10.2 drops Ruby 1.8 support
s.add_development_dependency "rake", "~> 10.1.0"
- # rspec_junit_formatter 0.2.0 drops ruby 1.8.7 support
- s.add_development_dependency "rspec_junit_formatter", "~> 0.2.0"
-
- %w(rspec-core rspec-expectations rspec-mocks).each { |gem| s.add_development_dependency gem, "~> 3.0" }
-
s.bindir = "bin"
s.executables = %w( chef-client chef-solo knife chef-shell chef-apply )
diff --git a/kitchen-tests/.chef/client.rb b/kitchen-tests/.chef/client.rb
index 5eb200a939..98f773d691 100644
--- a/kitchen-tests/.chef/client.rb
+++ b/kitchen-tests/.chef/client.rb
@@ -1,7 +1,8 @@
-chef_dir = File.expand_path(File.dirame(__FILE__))
-repo_dir = File.expand_path(Fild.join(chef_dir, '..'))
+chef_dir = File.expand_path(File.dirname(__FILE__))
+repo_dir = File.expand_path(File.join(chef_dir, '..'))
-log_level :info
+log_level :info
chef_repo_path repo_dir
-local_mode true
+local_mode true
+cache_path "#{ENV['HOME']}/.cache/chef"
diff --git a/kitchen-tests/cookbooks/audit_test/.gitignore b/kitchen-tests/cookbooks/audit_test/.gitignore
new file mode 100644
index 0000000000..ec2a890bd3
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/.gitignore
@@ -0,0 +1,16 @@
+.vagrant
+Berksfile.lock
+*~
+*#
+.#*
+\#*#
+.*.sw[a-z]
+*.un~
+
+# Bundler
+Gemfile.lock
+bin/*
+.bundle/*
+
+.kitchen/
+.kitchen.local.yml
diff --git a/kitchen-tests/cookbooks/audit_test/.kitchen.yml b/kitchen-tests/cookbooks/audit_test/.kitchen.yml
new file mode 100644
index 0000000000..be11e33081
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/.kitchen.yml
@@ -0,0 +1,16 @@
+---
+driver:
+ name: vagrant
+
+provisioner:
+ name: chef_zero
+
+platforms:
+ - name: ubuntu-12.04
+ - name: centos-6.5
+
+suites:
+ - name: default
+ run_list:
+ - recipe[audit_test::default]
+ attributes:
diff --git a/kitchen-tests/cookbooks/audit_test/Berksfile b/kitchen-tests/cookbooks/audit_test/Berksfile
new file mode 100644
index 0000000000..0ac9b78cf7
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/Berksfile
@@ -0,0 +1,3 @@
+source "https://supermarket.getchef.com"
+
+metadata
diff --git a/kitchen-tests/cookbooks/audit_test/README.md b/kitchen-tests/cookbooks/audit_test/README.md
new file mode 100644
index 0000000000..75e2f44808
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/README.md
@@ -0,0 +1,12 @@
+# audit_test
+
+This cookbook has some basic recipes to test audit mode.
+
+In order to run these tests on your dev box:
+
+```
+$ bundle install
+$ bundle exec chef-client -c kitchen-tests/.chef/client.rb -z -o audit_test::default -l debug
+```
+
+Expected JSON output for the tests will be printed to `debug` log.
diff --git a/kitchen-tests/cookbooks/audit_test/chefignore b/kitchen-tests/cookbooks/audit_test/chefignore
new file mode 100644
index 0000000000..80dc2d20ef
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/chefignore
@@ -0,0 +1,95 @@
+# Put files/directories that should be ignored in this file when uploading
+# or sharing to the community site.
+# Lines that start with '# ' are comments.
+
+# OS generated files #
+######################
+.DS_Store
+Icon?
+nohup.out
+ehthumbs.db
+Thumbs.db
+
+# SASS #
+########
+.sass-cache
+
+# EDITORS #
+###########
+\#*
+.#*
+*~
+*.sw[a-z]
+*.bak
+REVISION
+TAGS*
+tmtags
+*_flymake.*
+*_flymake
+*.tmproj
+.project
+.settings
+mkmf.log
+
+## COMPILED ##
+##############
+a.out
+*.o
+*.pyc
+*.so
+*.com
+*.class
+*.dll
+*.exe
+*/rdoc/
+
+# Testing #
+###########
+.watchr
+.rspec
+spec/*
+spec/fixtures/*
+test/*
+features/*
+Guardfile
+Procfile
+
+# SCM #
+#######
+.git
+*/.git
+.gitignore
+.gitmodules
+.gitconfig
+.gitattributes
+.svn
+*/.bzr/*
+*/.hg/*
+*/.svn/*
+
+# Berkshelf #
+#############
+Berksfile
+Berksfile.lock
+cookbooks/*
+tmp
+
+# Cookbooks #
+#############
+CONTRIBUTING
+
+# Strainer #
+############
+Colanderfile
+Strainerfile
+.colander
+.strainer
+
+# Vagrant #
+###########
+.vagrant
+Vagrantfile
+
+# Travis #
+##########
+.travis.yml
diff --git a/kitchen-tests/cookbooks/audit_test/metadata.rb b/kitchen-tests/cookbooks/audit_test/metadata.rb
new file mode 100644
index 0000000000..4a60104e92
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/metadata.rb
@@ -0,0 +1,8 @@
+name 'audit_test'
+maintainer 'The Authors'
+maintainer_email 'you@example.com'
+license 'all_rights'
+description 'Installs/Configures audit_test'
+long_description 'Installs/Configures audit_test'
+version '0.1.0'
+
diff --git a/kitchen-tests/cookbooks/audit_test/recipes/default.rb b/kitchen-tests/cookbooks/audit_test/recipes/default.rb
new file mode 100644
index 0000000000..4f634d73c1
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/recipes/default.rb
@@ -0,0 +1,26 @@
+#
+# Cookbook Name:: audit_test
+# Recipe:: default
+#
+# Copyright (c) 2014 The Authors, All Rights Reserved.
+
+controls "basic control group" do
+ control "basic math" do
+ it "should pass" do
+ expect(2 - 2).to eq(0)
+ end
+ end
+end
+
+controls "control group without top level control" do
+ it "should pass" do
+ expect(2 - 2).to eq(0)
+ end
+end
+
+controls "control group with empty control" do
+ control "empty"
+end
+
+controls "empty control group with block" do
+end
diff --git a/kitchen-tests/cookbooks/audit_test/recipes/error_duplicate_control_groups.rb b/kitchen-tests/cookbooks/audit_test/recipes/error_duplicate_control_groups.rb
new file mode 100644
index 0000000000..77a4592e9d
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/recipes/error_duplicate_control_groups.rb
@@ -0,0 +1,17 @@
+#
+# Cookbook Name:: audit_test
+# Recipe:: error_duplicate_control_groups
+#
+# Copyright (c) 2014 The Authors, All Rights Reserved.
+
+controls "basic control group" do
+ it "should pass" do
+ expect(2 - 2).to eq(0)
+ end
+end
+
+controls "basic control group" do
+ it "should pass" do
+ expect(2 - 2).to eq(0)
+ end
+end
diff --git a/kitchen-tests/cookbooks/audit_test/recipes/error_no_block.rb b/kitchen-tests/cookbooks/audit_test/recipes/error_no_block.rb
new file mode 100644
index 0000000000..76a8817b5d
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/recipes/error_no_block.rb
@@ -0,0 +1,7 @@
+#
+# Cookbook Name:: audit_test
+# Recipe:: error_no_block
+#
+# Copyright (c) 2014 The Authors, All Rights Reserved.
+
+controls "empty control group without block"
diff --git a/kitchen-tests/cookbooks/audit_test/recipes/error_orphan_control.rb b/kitchen-tests/cookbooks/audit_test/recipes/error_orphan_control.rb
new file mode 100644
index 0000000000..d74acd6c6b
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/recipes/error_orphan_control.rb
@@ -0,0 +1,13 @@
+#
+# Cookbook Name:: audit_test
+# Recipe:: error_orphan_control
+#
+# Copyright (c) 2014 The Authors, All Rights Reserved.
+
+controls "basic control group" do
+ it "should pass" do
+ expect(2 - 2).to eq(0)
+ end
+end
+
+control "orphan control"
diff --git a/kitchen-tests/cookbooks/audit_test/recipes/failed_specs.rb b/kitchen-tests/cookbooks/audit_test/recipes/failed_specs.rb
new file mode 100644
index 0000000000..3225d3983e
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/recipes/failed_specs.rb
@@ -0,0 +1,14 @@
+#
+# Cookbook Name:: audit_test
+# Recipe:: failed_specs
+#
+# Copyright (c) 2014 The Authors, All Rights Reserved.
+
+controls "basic control group" do
+ control "basic math" do
+ # Can not write a good control :(
+ it "should pass" do
+ expect(2 - 0).to eq(0)
+ end
+ end
+end
diff --git a/kitchen-tests/cookbooks/audit_test/recipes/serverspec_collision.rb b/kitchen-tests/cookbooks/audit_test/recipes/serverspec_collision.rb
new file mode 100644
index 0000000000..70109d84b8
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/recipes/serverspec_collision.rb
@@ -0,0 +1,31 @@
+#
+# Cookbook Name:: audit_test
+# Recipe:: serverspec_collision
+#
+# Copyright (c) 2014 The Authors, All Rights Reserved.
+
+file "/tmp/audit_test_file" do
+ action :create
+ content "Welcome to audit mode."
+end
+
+controls "file auditing" do
+ describe "test file" do
+ it "says welcome" do
+ expect(file("/tmp/audit_test_file")).to contain("Welcome")
+ end
+ end
+end
+
+file "/tmp/audit_test_file_2" do
+ action :create
+ content "Bye to audit mode."
+end
+
+controls "end file auditing" do
+ describe "end file" do
+ it "says bye" do
+ expect(file("/tmp/audit_test_file_2")).to contain("Bye")
+ end
+ end
+end
diff --git a/kitchen-tests/cookbooks/audit_test/recipes/serverspec_support.rb b/kitchen-tests/cookbooks/audit_test/recipes/serverspec_support.rb
new file mode 100644
index 0000000000..0396cc0de7
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/recipes/serverspec_support.rb
@@ -0,0 +1,37 @@
+#
+# Cookbook Name:: audit_test
+# Recipe:: serverspec_support
+#
+# Copyright (c) 2014 The Authors, All Rights Reserved.
+
+file "/tmp/audit_test_file" do
+ action :create
+ content "Welcome to audit mode."
+end
+
+# package "curl" do
+# action :install
+# end
+
+controls "serverspec helpers with types" do
+ control "file helper" do
+ it "says welcome" do
+ expect(file("/tmp/audit_test_file")).to contain("Welcome")
+ end
+ end
+
+ control service("com.apple.CoreRAID") do
+ it { is_expected.to be_enabled }
+ it { is_expected.not_to be_running }
+ end
+
+ # describe "package helper" do
+ # it "works" do
+ # expect(package("curl")).to be_installed
+ # end
+ # end
+
+ control package("postgresql") do
+ it { is_expected.to_not be_installed }
+ end
+end
diff --git a/kitchen-tests/cookbooks/audit_test/recipes/with_include_recipe.rb b/kitchen-tests/cookbooks/audit_test/recipes/with_include_recipe.rb
new file mode 100644
index 0000000000..ff39cde117
--- /dev/null
+++ b/kitchen-tests/cookbooks/audit_test/recipes/with_include_recipe.rb
@@ -0,0 +1,16 @@
+#
+# Cookbook Name:: audit_test
+# Recipe:: with_include_recipe
+#
+# Copyright (c) 2014 The Authors, All Rights Reserved.
+
+include_recipe "audit_test::serverspec_collision"
+
+controls "basic example" do
+ it "should pass" do
+ expect(2 - 2).to eq(0)
+ end
+end
+
+include_recipe "audit_test::serverspec_collision"
+include_recipe "audit_test::default"
diff --git a/kitchen-tests/cookbooks/webapp/README.md b/kitchen-tests/cookbooks/webapp/README.md
index e8de6ee467..f19ab46735 100644
--- a/kitchen-tests/cookbooks/webapp/README.md
+++ b/kitchen-tests/cookbooks/webapp/README.md
@@ -1,4 +1,3 @@
# webapp
TODO: Enter the cookbook description here.
-
diff --git a/lib/chef/application/client.rb b/lib/chef/application/client.rb
index 295dc2470e..40772c0f8f 100644
--- a/lib/chef/application/client.rb
+++ b/lib/chef/application/client.rb
@@ -239,9 +239,9 @@ class Chef::Application::Client < Chef::Application
end
option :audit_mode,
- :long => "--[no-]audit-mode",
- :description => "If not specified, run converge and audit phase. If true, run only audit phase. If false, run only converge phase.",
- :boolean => true
+ :long => "--audit-mode MODE",
+ :description => "Enable audit-mode with `enabled`. Disable audit-mode with `disabled`. Skip converge and only perform audits with `audit-only`",
+ :proc => lambda { |mo| mo.gsub("-", "_").to_sym }
IMMEDIATE_RUN_SIGNAL = "1".freeze
@@ -280,6 +280,19 @@ class Chef::Application::Client < Chef::Application
config_fetcher = Chef::ConfigFetcher.new(Chef::Config[:json_attribs])
@chef_client_json = config_fetcher.fetch_json
end
+
+ if mode = config[:audit_mode] || Chef::Config[:audit_mode]
+ expected_modes = [:enabled, :disabled, :audit_only]
+ unless expected_modes.include?(mode)
+ Chef::Application.fatal!(unrecognized_audit_mode(mode))
+ end
+
+ unless mode == :disabled
+ # This should be removed when audit-mode is enabled by default/no longer
+ # an experimental feature.
+ Chef::Log.warn(audit_mode_experimental_message)
+ end
+ end
end
def load_config_file
@@ -400,4 +413,26 @@ class Chef::Application::Client < Chef::Application
"#{"\n interval = #{Chef::Config[:interval]} seconds" if Chef::Config[:interval]}" +
"\nEnable chef-client interval runs by setting `:client_fork = true` in your config file or adding `--fork` to your command line options."
end
+
+ def audit_mode_settings_explaination
+ "\n* To enable audit mode after converge, use command line option `--audit-mode enabled` or set `:audit_mode = :enabled` in your config file." +
+ "\n* To disable audit mode, use command line option `--audit-mode disabled` or set `:audit_mode = :disabled` in your config file." +
+ "\n* To only run audit mode, use command line option `--audit-mode audit-only` or set `:audit_mode = :audit_only` in your config file." +
+ "\nAudit mode is disabled by default."
+ end
+
+ def unrecognized_audit_mode(mode)
+ "Unrecognized setting #{mode} for audit mode." + audit_mode_settings_explaination
+ end
+
+ def audit_mode_experimental_message
+ msg = if Chef::Config[:audit_mode] == :audit_only
+ "Chef-client has been configured to skip converge and run only audits."
+ else
+ "Chef-client has been configured to run audits after it converges."
+ end
+ msg += " Audit mode is an experimental feature currently under development. API changes may occur. Use at your own risk."
+ msg += audit_mode_settings_explaination
+ return msg
+ end
end
diff --git a/lib/chef/application/solo.rb b/lib/chef/application/solo.rb
index 6e568ddbb1..c3f5444ef7 100644
--- a/lib/chef/application/solo.rb
+++ b/lib/chef/application/solo.rb
@@ -211,6 +211,9 @@ class Chef::Application::Solo < Chef::Application
config_fetcher = Chef::ConfigFetcher.new(Chef::Config[:json_attribs])
@chef_client_json = config_fetcher.fetch_json
end
+
+ # Disable auditing for solo
+ Chef::Config[:audit_mode] = :disabled
end
def setup_application
diff --git a/lib/chef/audit/audit_event_proxy.rb b/lib/chef/audit/audit_event_proxy.rb
new file mode 100644
index 0000000000..2512b8bfe2
--- /dev/null
+++ b/lib/chef/audit/audit_event_proxy.rb
@@ -0,0 +1,93 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+RSpec::Support.require_rspec_core "formatters/base_text_formatter"
+
+class Chef
+ class Audit
+ class AuditEventProxy < ::RSpec::Core::Formatters::BaseFormatter
+ ::RSpec::Core::Formatters.register self, :stop, :example_group_started
+
+ # TODO I don't like this, but I don't see another way to pass this in
+ # see rspec files configuration.rb#L671 and formatters.rb#L129
+ def self.events=(events)
+ @@events = events
+ end
+
+ def events
+ @@events
+ end
+
+ def example_group_started(notification)
+ if notification.group.parent_groups.size == 1
+ # top level `controls` block
+ desc = notification.group.description
+ Chef::Log.debug("Entered `controls` block named #{desc}")
+ events.control_group_started(desc)
+ end
+ end
+
+ def stop(notification)
+ Chef::Log.info("Successfully executed all `controls` blocks and contained examples")
+ notification.examples.each do |example|
+ control_group_name, control_data = build_control_from(example)
+ e = example.exception
+ if e
+ events.control_example_failure(control_group_name, control_data, e)
+ else
+ events.control_example_success(control_group_name, control_data)
+ end
+ end
+ end
+
+ private
+
+ def build_control_from(example)
+ described_class = example.metadata[:described_class]
+ if described_class
+ resource_type = described_class.class.name.split(':')[-1]
+ resource_name = described_class.name
+ end
+
+ # The following code builds up the context - the list of wrapping `describe` or `control` blocks
+ describe_groups = []
+ group = example.metadata[:example_group]
+ # If the innermost block has a resource instead of a string, don't include it in context
+ describe_groups.unshift(group[:description]) if described_class.nil?
+ group = group[:parent_example_group]
+ while !group.nil?
+ describe_groups.unshift(group[:description])
+ group = group[:parent_example_group]
+ end
+
+ # We know all of our examples each live in a top-level `controls` block - get this name now
+ outermost_group_desc = describe_groups.shift
+
+ return outermost_group_desc, {
+ :name => example.description,
+ :desc => example.full_description,
+ :resource_type => resource_type,
+ :resource_name => resource_name,
+ :context => describe_groups,
+ :line_number => example.metadata[:line_number]
+ }
+ end
+
+ end
+ end
+end
diff --git a/lib/chef/audit/audit_reporter.rb b/lib/chef/audit/audit_reporter.rb
new file mode 100644
index 0000000000..a5dd9a6c48
--- /dev/null
+++ b/lib/chef/audit/audit_reporter.rb
@@ -0,0 +1,169 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+#
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'chef/event_dispatch/base'
+require 'chef/audit/control_group_data'
+require 'time'
+
+class Chef
+ class Audit
+ class AuditReporter < EventDispatch::Base
+
+ attr_reader :rest_client, :audit_data, :ordered_control_groups, :run_status
+ private :rest_client, :audit_data, :ordered_control_groups, :run_status
+
+ PROTOCOL_VERSION = '0.1.1'
+
+ def initialize(rest_client)
+ @rest_client = rest_client
+ # Ruby 1.9.3 and above "enumerate their values in the order that the corresponding keys were inserted."
+ @ordered_control_groups = Hash.new
+ end
+
+ def run_context
+ run_status.run_context
+ end
+
+ def audit_phase_start(run_status)
+ Chef::Log.debug("Audit Reporter starting")
+ @audit_data = AuditData.new(run_status.node.name, run_status.run_id)
+ @run_status = run_status
+ end
+
+ def audit_phase_complete
+ Chef::Log.debug("Audit Reporter completed successfully without errors.")
+ ordered_control_groups.each do |name, control_group|
+ audit_data.add_control_group(control_group)
+ end
+ end
+
+ # If the audit phase failed, its because there was some kind of error in the framework
+ # that runs tests - normal errors are interpreted as EXAMPLE failures and captured.
+ # We still want to send available audit information to the server so we process the
+ # known control groups.
+ def audit_phase_failed(error)
+ # The stacktrace information has already been logged elsewhere
+ Chef::Log.debug("Audit Reporter failed.")
+ ordered_control_groups.each do |name, control_group|
+ audit_data.add_control_group(control_group)
+ end
+ end
+
+ def run_completed(node)
+ post_auditing_data
+ end
+
+ def run_failed(error)
+ post_auditing_data(error)
+ end
+
+ def control_group_started(name)
+ if ordered_control_groups.has_key?(name)
+ raise Chef::Exceptions::AuditControlGroupDuplicate.new(name)
+ end
+ metadata = run_context.audits[name].metadata
+ ordered_control_groups.store(name, ControlGroupData.new(name, metadata))
+ end
+
+ def control_example_success(control_group_name, example_data)
+ control_group = ordered_control_groups[control_group_name]
+ control_group.example_success(example_data)
+ end
+
+ def control_example_failure(control_group_name, example_data, error)
+ control_group = ordered_control_groups[control_group_name]
+ control_group.example_failure(example_data, error.message)
+ end
+
+ # If @audit_enabled is nil or true, we want to run audits
+ def auditing_enabled?
+ Chef::Config[:audit_mode] != :disabled
+ end
+
+ private
+
+ def post_auditing_data(error = nil)
+ unless auditing_enabled?
+ Chef::Log.debug("Audit Reports are disabled. Skipping sending reports.")
+ return
+ end
+
+ unless run_status
+ Chef::Log.debug("Run failed before audits were initialized, not sending audit report to server")
+ return
+ end
+
+ audit_data.start_time = iso8601ify(run_status.start_time)
+ audit_data.end_time = iso8601ify(run_status.end_time)
+
+ audit_history_url = "controls"
+ Chef::Log.debug("Sending audit report (run-id: #{audit_data.run_id})")
+ run_data = audit_data.to_hash
+
+ if error
+ run_data[:error] = "#{error.class.to_s}: #{error.message}\n#{error.backtrace.join("\n")}"
+ end
+
+ Chef::Log.debug "Audit Report:\n#{Chef::JSONCompat.to_json_pretty(run_data)}"
+ # Since we're posting compressed data we can not directly call post_rest which expects JSON
+ begin
+ audit_url = rest_client.create_url(audit_history_url)
+ rest_client.post(audit_url, run_data, headers)
+ rescue StandardError => e
+ if e.respond_to? :response
+ # 404 error code is OK. This means the version of server we're running against doesn't support
+ # audit reporting. Don't alarm failure in this case.
+ if e.response.code == "404"
+ Chef::Log.debug("Server doesn't support audit reporting. Skipping report.")
+ return
+ else
+ # Save the audit report to local disk
+ error_file = "failed-audit-data.json"
+ Chef::FileCache.store(error_file, Chef::JSONCompat.to_json_pretty(run_data), 0640)
+ Chef::Log.error("Failed to post audit report to server. Saving report to #{Chef::FileCache.load(error_file, false)}")
+ end
+ else
+ Chef::Log.error("Failed to post audit report to server (#{e})")
+ end
+
+ if Chef::Config[:enable_reporting_url_fatals]
+ Chef::Log.error("Reporting fatals enabled. Aborting run.")
+ raise
+ end
+ end
+ end
+
+ def headers(additional_headers = {})
+ options = {'X-Ops-Audit-Report-Protocol-Version' => PROTOCOL_VERSION}
+ options.merge(additional_headers)
+ end
+
+ def encode_gzip(data)
+ "".tap do |out|
+ Zlib::GzipWriter.wrap(StringIO.new(out)){|gz| gz << data }
+ end
+ end
+
+ def iso8601ify(time)
+ time.utc.iso8601.to_s
+ end
+
+ end
+ end
+end
diff --git a/lib/chef/audit/control_group_data.rb b/lib/chef/audit/control_group_data.rb
new file mode 100644
index 0000000000..204d7f8070
--- /dev/null
+++ b/lib/chef/audit/control_group_data.rb
@@ -0,0 +1,140 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+#
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'securerandom'
+
+class Chef
+ class Audit
+ class AuditData
+ attr_reader :node_name, :run_id, :control_groups
+ attr_accessor :start_time, :end_time
+
+ def initialize(node_name, run_id)
+ @node_name = node_name
+ @run_id = run_id
+ @control_groups = []
+ end
+
+ def add_control_group(control_group)
+ control_groups << control_group
+ end
+
+ def to_hash
+ {
+ :node_name => node_name,
+ :run_id => run_id,
+ :start_time => start_time,
+ :end_time => end_time,
+ :control_groups => control_groups.collect { |c| c.to_hash }
+ }
+ end
+ end
+
+ class ControlGroupData
+ attr_reader :name, :status, :number_succeeded, :number_failed, :controls, :metadata
+
+ def initialize(name, metadata={})
+ @status = "success"
+ @controls = []
+ @number_succeeded = 0
+ @number_failed = 0
+ @name = name
+ @metadata = metadata
+ end
+
+
+ def example_success(control_data)
+ @number_succeeded += 1
+ control = create_control(control_data)
+ control.status = "success"
+ controls << control
+ control
+ end
+
+ def example_failure(control_data, details)
+ @number_failed += 1
+ @status = "failure"
+ control = create_control(control_data)
+ control.details = details if details
+ control.status = "failure"
+ controls << control
+ control
+ end
+
+ def to_hash
+ # We sort it so the examples appear in the output in the same order
+ # they appeared in the recipe
+ controls.sort! {|x,y| x.line_number <=> y.line_number}
+ h = {
+ :name => name,
+ :status => status,
+ :number_succeeded => number_succeeded,
+ :number_failed => number_failed,
+ :controls => controls.collect { |c| c.to_hash }
+ }
+ # If there is a duplicate key, metadata will overwrite it
+ add_display_only_data(h).merge(metadata)
+ end
+
+ private
+
+ def create_control(control_data)
+ ControlData.new(control_data)
+ end
+
+ # The id and control sequence number are ephemeral data - they are not needed
+ # to be persisted and can be regenerated at will. They are only needed
+ # for display purposes.
+ def add_display_only_data(group)
+ group[:id] = SecureRandom.uuid
+ group[:controls].collect!.with_index do |c, i|
+ # i is zero-indexed, and we want the display one-indexed
+ c[:sequence_number] = i+1
+ c
+ end
+ group
+ end
+
+ end
+
+ class ControlData
+ attr_reader :name, :resource_type, :resource_name, :context, :line_number
+ attr_accessor :status, :details
+
+ def initialize(control_data={})
+ control_data.each do |k, v|
+ self.instance_variable_set("@#{k}", v)
+ end
+ end
+
+ def to_hash
+ h = {
+ :name => name,
+ :status => status,
+ :details => details,
+ :resource_type => resource_type,
+ :resource_name => resource_name
+ }
+ h[:context] = context || []
+ h
+ end
+ end
+
+ end
+end
diff --git a/lib/chef/audit/rspec_formatter.rb b/lib/chef/audit/rspec_formatter.rb
new file mode 100644
index 0000000000..074a11bed3
--- /dev/null
+++ b/lib/chef/audit/rspec_formatter.rb
@@ -0,0 +1,37 @@
+#
+# Author:: Serdar Sutay (<serdar@chef.io>)
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'rspec/core'
+
+class Chef
+ class Audit
+ class RspecFormatter < RSpec::Core::Formatters::DocumentationFormatter
+ RSpec::Core::Formatters.register self, :close
+
+ # @api public
+ #
+ # Invoked at the very end, `close` allows the formatter to clean
+ # up resources, e.g. open streams, etc.
+ #
+ # @param _notification [NullNotification] (Ignored)
+ def close(_notification)
+ # Normally Rspec closes the streams it's given. We don't want it for Chef.
+ end
+ end
+ end
+end
diff --git a/lib/chef/audit/runner.rb b/lib/chef/audit/runner.rb
new file mode 100644
index 0000000000..7ef17a4301
--- /dev/null
+++ b/lib/chef/audit/runner.rb
@@ -0,0 +1,178 @@
+#
+# Author:: Claire McQuin (<claire@getchef.com>)
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+class Chef
+ class Audit
+ class Runner
+
+ attr_reader :run_context
+ private :run_context
+
+ def initialize(run_context)
+ @run_context = run_context
+ end
+
+ def run
+ setup
+ register_controls
+ do_run
+ end
+
+ def failed?
+ RSpec.world.reporter.failed_examples.size > 0
+ end
+
+ def num_failed
+ RSpec.world.reporter.failed_examples.size
+ end
+
+ def num_total
+ RSpec.world.reporter.examples.size
+ end
+
+ private
+ # Prepare to run audits:
+ # - Require files
+ # - Configure RSpec
+ # - Configure Specinfra/Serverspec
+ def setup
+ require_deps
+ configure_rspec
+ configure_specinfra
+ end
+
+ # RSpec uses a global configuration object, RSpec.configuration. We found
+ # there was interference between the configuration for audit-mode and
+ # the configuration for our own spec tests in these cases:
+ # 1. Specinfra and Serverspec modify RSpec.configuration when loading.
+ # 2. Setting output/error streams.
+ # 3. Adding formatters.
+ # 4. Defining example group aliases.
+ #
+ # Moreover, Serverspec loads its DSL methods into the global namespace,
+ # which causes conflicts with the Chef namespace for resources and packages.
+ #
+ # We wait until we're in the audit-phase of the chef-client run to load
+ # these files. This helps with the namespacing problems we saw, and
+ # prevents Specinfra and Serverspec from modifying the RSpec configuration
+ # used by our spec tests.
+ def require_deps
+ require 'rspec'
+ require 'rspec/its'
+ require 'specinfra'
+ require 'serverspec/helper'
+ require 'serverspec/matcher'
+ require 'serverspec/subject'
+ require 'chef/audit/audit_event_proxy'
+ require 'chef/audit/rspec_formatter'
+ end
+
+ # Configure RSpec just the way we like it:
+ # - Set location of error and output streams
+ # - Add custom audit-mode formatters
+ # - Explicitly disable :should syntax
+ # - Set :color option according to chef config
+ # - Disable exposure of global DSL
+ def configure_rspec
+ set_streams
+ add_formatters
+ disable_should_syntax
+
+ RSpec.configure do |c|
+ c.color = Chef::Config[:color]
+ c.expose_dsl_globally = false
+ end
+ end
+
+ # Set the error and output streams which audit-mode will use to report
+ # human-readable audit information.
+ #
+ # This should always be called before #add_formatters. RSpec won't allow
+ # the output stream to be changed for a formatter once the formatter has
+ # been added.
+ def set_streams
+ RSpec.configuration.output_stream = Chef::Config[:log_location]
+ RSpec.configuration.error_stream = Chef::Config[:log_location]
+ end
+
+ # Add formatters which we use to
+ # 1. Output human-readable data to the output stream,
+ # 2. Collect JSON data to send back to the analytics server.
+ def add_formatters
+ RSpec.configuration.add_formatter(Chef::Audit::AuditEventProxy)
+ RSpec.configuration.add_formatter(Chef::Audit::RspecFormatter)
+ Chef::Audit::AuditEventProxy.events = run_context.events
+ end
+
+ # Audit-mode uses RSpec 3. :should syntax is deprecated by default in
+ # RSpec 3, so we explicitly disable it here.
+ #
+ # This can be removed once :should is removed from RSpec.
+ def disable_should_syntax
+ RSpec.configure do |config|
+ config.expect_with :rspec do |c|
+ c.syntax = :expect
+ end
+ end
+ end
+
+ # Set up the backend for Specinfra/Serverspec. :exec is the local system.
+ def configure_specinfra
+ Specinfra.configuration.backend = :exec
+ end
+
+ # Iterates through the controls registered to this run_context, builds an
+ # example group (RSpec::Core::ExampleGroup) object per controls, and
+ # registers the group with the RSpec.world.
+ #
+ # We could just store an array of example groups and not use RSpec.world,
+ # but it may be useful later if we decide to apply our own ordering scheme
+ # or use example group filters.
+ def register_controls
+ add_example_group_methods
+ run_context.audits.each do |name, group|
+ ctl_grp = RSpec::Core::ExampleGroup.__controls__(*group.args, &group.block)
+ RSpec.world.register(ctl_grp)
+ end
+ end
+
+ # Add example group method aliases to RSpec.
+ #
+ # __controls__: Used internally to create example groups from the controls
+ # saved in the run_context.
+ # control: Used within the context of a controls block, like RSpec's
+ # describe or context.
+ def add_example_group_methods
+ RSpec::Core::ExampleGroup.define_example_group_method :__controls__
+ RSpec::Core::ExampleGroup.define_example_group_method :control
+ end
+
+ # Run the audits!
+ def do_run
+ # RSpec::Core::Runner wants to be initialized with an
+ # RSpec::Core::ConfigurationOptions object, which is used to process
+ # command line configuration arguments. We directly fiddle with the
+ # internal RSpec configuration object, so we give nil here and let
+ # RSpec pick up its own configuration and world.
+ runner = RSpec::Core::Runner.new(nil)
+ runner.run_specs(RSpec.world.ordered_example_groups)
+ end
+
+ end
+ end
+end
diff --git a/lib/chef/client.rb b/lib/chef/client.rb
index 4f37bd0ee3..77f63671d7 100644
--- a/lib/chef/client.rb
+++ b/lib/chef/client.rb
@@ -25,6 +25,7 @@ require 'chef/log'
require 'chef/rest'
require 'chef/api_client'
require 'chef/api_client/registration'
+require 'chef/audit/runner'
require 'chef/node'
require 'chef/role'
require 'chef/file_cache'
@@ -43,6 +44,7 @@ require 'chef/formatters/doc'
require 'chef/formatters/minimal'
require 'chef/version'
require 'chef/resource_reporter'
+require 'chef/audit/audit_reporter'
require 'chef/run_lock'
require 'chef/policy_builder'
require 'chef/request_id'
@@ -209,6 +211,17 @@ class Chef
end
end
+ # Resource repoters send event information back to the chef server for processing.
+ # Can only be called after we have a @rest object
+ def register_reporters
+ [
+ Chef::ResourceReporter.new(rest),
+ Chef::Audit::AuditReporter.new(rest)
+ ].each do |r|
+ events.register(r)
+ end
+ end
+
# Instantiates a Chef::Node object, possibly loading the node's prior state
# when using chef-client. Delegates to policy_builder
#
@@ -246,7 +259,6 @@ class Chef
@policy_builder ||= Chef::PolicyBuilder.strategy.new(node_name, ohai.data, json_attribs, @override_runlist, events)
end
-
def save_updated_node
if Chef::Config[:solo]
# nothing to do
@@ -260,6 +272,7 @@ class Chef
def run_ohai
ohai.all_plugins
+ @events.ohai_completed(node)
end
def node_name
@@ -295,8 +308,7 @@ class Chef
end
# We now have the client key, and should use it from now on.
@rest = Chef::REST.new(config[:chef_server_url], client_name, config[:client_key])
- @resource_reporter = Chef::ResourceReporter.new(@rest)
- @events.register(@resource_reporter)
+ register_reporters
rescue Exception => e
# TODO: munge exception so a semantic failure message can be given to the
# user
@@ -307,18 +319,56 @@ class Chef
# Converges the node.
#
# === Returns
- # true:: Always returns true
+ # The thrown exception, if there was one. If this returns nil the converge was successful.
def converge(run_context)
- @events.converge_start(run_context)
- Chef::Log.debug("Converging node #{node_name}")
- @runner = Chef::Runner.new(run_context)
- runner.converge
- @events.converge_complete
- true
- rescue Exception
- # TODO: should this be a separate #converge_failed(exception) method?
- @events.converge_complete
- raise
+ converge_exception = nil
+ catch(:end_client_run_early) do
+ begin
+ @events.converge_start(run_context)
+ Chef::Log.debug("Converging node #{node_name}")
+ @runner = Chef::Runner.new(run_context)
+ runner.converge
+ @events.converge_complete
+ rescue Exception => e
+ Chef::Log.error("Converge failed with error message #{e.message}")
+ @events.converge_failed(e)
+ converge_exception = e
+ end
+ end
+ converge_exception
+ end
+
+ # We don't want to change the old API on the `converge` method to have it perform
+ # saving. So we wrap it in this method.
+ def converge_and_save(run_context)
+ converge_exception = converge(run_context)
+ unless converge_exception
+ begin
+ save_updated_node
+ rescue Exception => e
+ converge_exception = e
+ end
+ end
+ converge_exception
+ end
+
+ def run_audits(run_context)
+ audit_exception = nil
+ begin
+ @events.audit_phase_start(run_status)
+ Chef::Log.info("Starting audit phase")
+ auditor = Chef::Audit::Runner.new(run_context)
+ auditor.run
+ if auditor.failed?
+ raise Chef::Exceptions::AuditsFailed.new(auditor.num_failed, auditor.num_total)
+ end
+ @events.audit_phase_complete
+ rescue Exception => e
+ Chef::Log.error("Audit phase failed with error message: #{e.message}")
+ @events.audit_phase_failed(e)
+ audit_exception = e
+ end
+ audit_exception
end
# Expands the run list. Delegates to the policy_builder.
@@ -333,7 +383,6 @@ class Chef
policy_builder.expand_run_list
end
-
def do_windows_admin_check
if Chef::Platform.windows?
Chef::Log.debug("Checking for administrator privileges....")
@@ -380,7 +429,7 @@ class Chef
Chef::Log.debug("Chef-client request_id: #{request_id}")
enforce_path_sanity
run_ohai
- @events.ohai_completed(node)
+
register unless Chef::Config[:solo]
load_node
@@ -396,11 +445,22 @@ class Chef
run_context = setup_run_context
- catch(:end_client_run_early) do
- converge(run_context)
+ if Chef::Config[:audit_mode] != :audit_only
+ converge_error = converge_and_save(run_context)
end
- save_updated_node
+ if Chef::Config[:why_run] == true
+ # why_run should probably be renamed to why_converge
+ Chef::Log.debug("Not running audits in 'why_run' mode - this mode is used to see potential converge changes")
+ elsif Chef::Config[:audit_mode] != :disabled
+ audit_error = run_audits(run_context)
+ end
+
+ if converge_error || audit_error
+ e = Chef::Exceptions::RunFailedWrappingError.new(converge_error, audit_error)
+ e.fill_backtrace
+ raise e
+ end
run_status.stop_clock
Chef::Log.info("Chef Run complete in #{run_status.elapsed_time} seconds")
@@ -411,6 +471,7 @@ class Chef
Chef::Platform::Rebooter.reboot_if_needed!(node)
true
+
rescue Exception => e
# CHEF-3336: Send the error first in case something goes wrong below and we don't know why
Chef::Log.debug("Re-raising exception: #{e.class} - #{e.message}\n#{e.backtrace.join("\n ")}")
diff --git a/lib/chef/config.rb b/lib/chef/config.rb
index 4b83a0eca3..453a8f83da 100644
--- a/lib/chef/config.rb
+++ b/lib/chef/config.rb
@@ -320,6 +320,14 @@ class Chef
default :ez, false
default :enable_reporting, true
default :enable_reporting_url_fatals, false
+ # Possible values for :audit_mode
+ # :enabled, :disabled, :audit_only,
+ #
+ # TODO: 11 Dec 2014: Currently audit-mode is an experimental feature
+ # and is disabled by default. When users choose to enable audit-mode,
+ # a warning is issued in application/client#reconfigure.
+ # This can be removed when audit-mode is enabled by default.
+ default :audit_mode, :disabled
# Policyfile is an experimental feature where a node gets its run list and
# cookbook version set from a single document on the server instead of
diff --git a/lib/chef/dsl/audit.rb b/lib/chef/dsl/audit.rb
new file mode 100644
index 0000000000..022bbcce01
--- /dev/null
+++ b/lib/chef/dsl/audit.rb
@@ -0,0 +1,51 @@
+#
+# Author:: Tyler Ball (<tball@getchef.com>)
+# Copyright:: Copyright (c) 2014 Opscode, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'chef/exceptions'
+
+class Chef
+ module DSL
+ module Audit
+
+ # Can encompass tests in a `control` block or `describe` block
+ # Adds the controls group and block (containing controls to execute) to the runner's list of pending examples
+ def controls(*args, &block)
+ raise Chef::Exceptions::NoAuditsProvided unless block
+
+ name = args[0]
+ if name.nil? || name.empty?
+ raise Chef::Exceptions::AuditNameMissing
+ elsif run_context.audits.has_key?(name)
+ raise Chef::Exceptions::AuditControlGroupDuplicate.new(name)
+ end
+
+ # This DSL will only work in the Recipe class because that exposes the cookbook_name
+ cookbook_name = self.cookbook_name
+ metadata = {
+ cookbook_name: cookbook_name,
+ cookbook_version: self.run_context.cookbook_collection[cookbook_name].version,
+ recipe_name: self.recipe_name,
+ line_number: block.source_location[1]
+ }
+
+ run_context.audits[name] = Struct.new(:args, :block, :metadata).new(args, block, metadata)
+ end
+
+ end
+ end
+end
diff --git a/lib/chef/event_dispatch/base.rb b/lib/chef/event_dispatch/base.rb
index 50d261cecd..25dd9fd1b2 100644
--- a/lib/chef/event_dispatch/base.rb
+++ b/lib/chef/event_dispatch/base.rb
@@ -225,6 +225,41 @@ class Chef
def converge_complete
end
+ # Called if the converge phase fails
+ def converge_failed(exception)
+ end
+
+ ##################################
+ # Audit Mode Events
+ # This phase is currently experimental and these event APIs are subject to change
+ ##################################
+
+ # Called before audit phase starts
+ def audit_phase_start(run_status)
+ end
+
+ # Called when audit phase successfully finishes
+ def audit_phase_complete
+ end
+
+ # Called if there is an uncaught exception during the audit phase. The audit runner should
+ # be catching and handling errors from the examples, so this is only uncaught errors (like
+ # bugs in our handling code)
+ def audit_phase_failed(exception)
+ end
+
+ # Signifies the start of a `controls` block with a defined name
+ def control_group_started(name)
+ end
+
+ # An example in a `controls` block completed successfully
+ def control_example_success(control_group_name, example_data)
+ end
+
+ # An example in a `controls` block failed with the provided error
+ def control_example_failure(control_group_name, example_data, error)
+ end
+
# TODO: need events for notification resolve?
# def notifications_resolved
# end
diff --git a/lib/chef/exceptions.rb b/lib/chef/exceptions.rb
index 0868341849..b949e7b975 100644
--- a/lib/chef/exceptions.rb
+++ b/lib/chef/exceptions.rb
@@ -385,5 +385,45 @@ class Chef
super "Found more than one provider for #{resource.resource_name} resource: #{classes}"
end
end
+
+ class AuditControlGroupDuplicate < RuntimeError
+ def initialize(name)
+ super "Audit control group with name '#{name}' has already been defined"
+ end
+ end
+ class AuditNameMissing < RuntimeError; end
+ class NoAuditsProvided < RuntimeError
+ def initialize
+ super "You must provide a block with audits"
+ end
+ end
+ class AuditsFailed < RuntimeError
+ def initialize(num_failed, num_total)
+ super "Audit phase found failures - #{num_failed}/#{num_total} audits failed"
+ end
+ end
+
+ # If a converge or audit fails, we want to wrap the output from those errors into 1 error so we can
+ # see both issues in the output. It is possible that nil will be provided. You must call `fill_backtrace`
+ # to correctly populate the backtrace with the wrapped backtraces.
+ class RunFailedWrappingError < RuntimeError
+ attr_reader :wrapped_errors
+ def initialize(*errors)
+ errors = errors.select {|e| !e.nil?}
+ output = "Found #{errors.size} errors, they are stored in the backtrace"
+ @wrapped_errors = errors
+ super output
+ end
+
+ def fill_backtrace
+ backtrace = []
+ wrapped_errors.each_with_index do |e,i|
+ backtrace << "#{i+1}) #{e.class} - #{e.message}"
+ backtrace += e.backtrace if e.backtrace
+ backtrace << ""
+ end
+ set_backtrace(backtrace)
+ end
+ end
end
end
diff --git a/lib/chef/formatters/doc.rb b/lib/chef/formatters/doc.rb
index 4a08b9d095..489888db8f 100644
--- a/lib/chef/formatters/doc.rb
+++ b/lib/chef/formatters/doc.rb
@@ -8,7 +8,9 @@ class Chef
# "specdoc"
class Doc < Formatters::Base
- attr_reader :start_time, :end_time
+ attr_reader :start_time, :end_time, :successful_audits, :failed_audits
+ private :successful_audits, :failed_audits
+
cli_name(:doc)
def initialize(out, err)
@@ -16,6 +18,8 @@ class Chef
@updated_resources = 0
@up_to_date_resources = 0
+ @successful_audits = 0
+ @failed_audits = 0
@start_time = Time.now
@end_time = @start_time
end
@@ -32,12 +36,19 @@ class Chef
@up_to_date_resources + @updated_resources
end
+ def total_audits
+ successful_audits + failed_audits
+ end
+
def run_completed(node)
@end_time = Time.now
if Chef::Config[:why_run]
puts_line "Chef Client finished, #{@updated_resources}/#{total_resources} resources would have been updated"
else
puts_line "Chef Client finished, #{@updated_resources}/#{total_resources} resources updated in #{elapsed_time} seconds"
+ if total_audits > 0
+ puts_line " #{successful_audits}/#{total_audits} Audits succeeded"
+ end
end
end
@@ -47,6 +58,9 @@ class Chef
puts_line "Chef Client failed. #{@updated_resources} resources would have been updated"
else
puts_line "Chef Client failed. #{@updated_resources} resources updated in #{elapsed_time} seconds"
+ if total_audits > 0
+ puts_line " #{successful_audits} Audits succeeded"
+ end
end
end
@@ -151,6 +165,38 @@ class Chef
unindent if @current_recipe
end
+ def converge_failed(e)
+ # Currently a failed converge is handled the same way as a successful converge
+ converge_complete
+ end
+
+ # Called before audit phase starts
+ def audit_phase_start(run_status)
+ puts_line "Starting audit phase"
+ end
+
+ def audit_phase_complete
+ puts_line "Auditing complete"
+ end
+
+ def audit_phase_failed(error)
+ puts_line ""
+ puts_line "Audit phase exception:"
+ indent
+ puts_line "#{error.message}"
+ error.backtrace.each do |l|
+ puts_line l
+ end
+ end
+
+ def control_example_success(control_group_name, example_data)
+ @successful_audits += 1
+ end
+
+ def control_example_failure(control_group_name, example_data, error)
+ @failed_audits += 1
+ end
+
# Called before action is executed on a resource.
def resource_action_start(resource, action, notification_type=nil, notifier=nil)
if resource.cookbook_name && resource.recipe_name
diff --git a/lib/chef/monologger.rb b/lib/chef/monologger.rb
index 464b21bdd3..f7d226f82e 100644
--- a/lib/chef/monologger.rb
+++ b/lib/chef/monologger.rb
@@ -1,5 +1,4 @@
require 'logger'
-
require 'pp'
#== MonoLogger
@@ -89,4 +88,3 @@ class MonoLogger < Logger
end
-
diff --git a/lib/chef/recipe.rb b/lib/chef/recipe.rb
index e54a1d98e3..621d93099b 100644
--- a/lib/chef/recipe.rb
+++ b/lib/chef/recipe.rb
@@ -24,6 +24,7 @@ require 'chef/dsl/platform_introspection'
require 'chef/dsl/include_recipe'
require 'chef/dsl/registry_helper'
require 'chef/dsl/reboot_pending'
+require 'chef/dsl/audit'
require 'chef/mixin/from_file'
@@ -40,6 +41,7 @@ class Chef
include Chef::DSL::Recipe
include Chef::DSL::RegistryHelper
include Chef::DSL::RebootPending
+ include Chef::DSL::Audit
include Chef::Mixin::FromFile
include Chef::Mixin::Deprecation
diff --git a/lib/chef/run_context.rb b/lib/chef/run_context.rb
index 22679822a4..6803dc5796 100644
--- a/lib/chef/run_context.rb
+++ b/lib/chef/run_context.rb
@@ -50,6 +50,9 @@ class Chef
# recipes, which is triggered by #load. (See also: CookbookCompiler)
attr_accessor :resource_collection
+ # The list of audits (control groups) to execute during the audit phase
+ attr_accessor :audits
+
# A Hash containing the immediate notifications triggered by resources
# during the converge phase of the chef run.
attr_accessor :immediate_notification_collection
@@ -73,6 +76,7 @@ class Chef
@node = node
@cookbook_collection = cookbook_collection
@resource_collection = Chef::ResourceCollection.new
+ @audits = {}
@immediate_notification_collection = Hash.new {|h,k| h[k] = []}
@delayed_notification_collection = Hash.new {|h,k| h[k] = []}
@definitions = Hash.new
diff --git a/spec/functional/audit/rspec_formatter_spec.rb b/spec/functional/audit/rspec_formatter_spec.rb
new file mode 100644
index 0000000000..43d3c2f6dd
--- /dev/null
+++ b/spec/functional/audit/rspec_formatter_spec.rb
@@ -0,0 +1,53 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+# Author:: Claire McQuin (<claire@getchef.com>)
+#
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'spec_helper'
+require 'spec/support/audit_helper'
+require 'chef/audit/runner'
+require 'rspec/support/spec/in_sub_process'
+require 'chef/audit/rspec_formatter'
+
+describe Chef::Audit::RspecFormatter do
+ include RSpec::Support::InSubProcess
+
+ let(:events) { double("events").as_null_object }
+ let(:audits) { {} }
+ let(:run_context) { instance_double(Chef::RunContext, :events => events, :audits => audits) }
+ let(:runner) { Chef::Audit::Runner.new(run_context) }
+
+ let(:output) { double("output") }
+ # aggressively define this so we can mock out the new call later
+ let!(:formatter) { Chef::Audit::RspecFormatter.new(output) }
+
+ around(:each) do |ex|
+ Sandboxing.sandboxed { ex.run }
+ end
+
+ it "should not close the output using our formatter" do
+ in_sub_process do
+ expect_any_instance_of(Chef::Audit::RspecFormatter).to receive(:new).and_return(formatter)
+ expect(formatter).to receive(:close).and_call_original
+ expect(output).to_not receive(:close)
+
+ runner.run
+ end
+ end
+
+end
diff --git a/spec/functional/audit/runner_spec.rb b/spec/functional/audit/runner_spec.rb
new file mode 100644
index 0000000000..aa35548f2f
--- /dev/null
+++ b/spec/functional/audit/runner_spec.rb
@@ -0,0 +1,136 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'spec_helper'
+require 'spec/support/audit_helper'
+require 'chef/audit/runner'
+require 'rspec/support/spec/in_sub_process'
+require 'tempfile'
+
+##
+# This functional test ensures that our runner can be setup to not interfere with existing RSpec
+# configuration and world objects. When normally running Chef, there is only 1 RSpec instance
+# so this isn't needed. In unit testing the Runner should be mocked appropriately.
+
+describe Chef::Audit::Runner do
+
+ # The functional tests must be run in a sub_process. Including Serverspec includes the Serverspec DSL - this
+ # conflicts with our `package` DSL (among others) when we try to test `package` inside an RSpec example.
+ # Our DSL leverages `method_missing` while the Serverspec DSL defines a method on the RSpec::Core::ExampleGroup.
+ # The defined method wins our and returns before our `method_missing` DSL can be called.
+ #
+ # Running in a sub_process means the serverspec libraries will only be included in a forked process, not the main one.
+ include RSpec::Support::InSubProcess
+
+ let(:events) { double("events").as_null_object }
+ let(:runner) { Chef::Audit::Runner.new(run_context) }
+ let(:stdout) { StringIO.new }
+
+ around(:each) do |ex|
+ Sandboxing.sandboxed { ex.run }
+ end
+
+ before do
+ Chef::Config[:log_location] = stdout
+ end
+
+ describe "#run" do
+
+ let(:audits) { {} }
+ let(:run_context) { instance_double(Chef::RunContext, :events => events, :audits => audits) }
+ let(:controls_name) { "controls_name" }
+
+ it "Correctly runs an empty controls block" do
+ in_sub_process do
+ runner.run
+ end
+ end
+
+ shared_context "passing audit" do
+ let(:audits) do
+ should_pass = lambda do
+ it "should pass" do
+ expect(2 - 2).to eq(0)
+ end
+ end
+ { controls_name => Struct.new(:args, :block).new([controls_name], should_pass)}
+ end
+ end
+
+ shared_context "failing audit" do
+ let(:audits) do
+ should_fail = lambda do
+ it "should fail" do
+ expect(2 - 1).to eq(0)
+ end
+ end
+ { controls_name => Struct.new(:args, :block).new([controls_name], should_fail)}
+ end
+ end
+
+ context "there is a single successful control" do
+ include_context "passing audit"
+ it "correctly runs" do
+ in_sub_process do
+ runner.run
+
+ expect(stdout.string).to match(/1 example, 0 failures/)
+ end
+ end
+ end
+
+ context "there is a single failing control" do
+ include_context "failing audit"
+ it "correctly runs" do
+ in_sub_process do
+ runner.run
+
+ expect(stdout.string).to match(/Failure\/Error: expect\(2 - 1\)\.to eq\(0\)/)
+ expect(stdout.string).to match(/1 example, 1 failure/)
+ expect(stdout.string).to match(/# controls_name should fail/)
+ end
+ end
+ end
+
+ describe "log location is a file" do
+ let(:tmpfile) { Tempfile.new("audit") }
+ before do
+ Chef::Config[:log_location] = tmpfile.path
+ end
+
+ after do
+ tmpfile.close
+ tmpfile.unlink
+ end
+
+ include_context "failing audit"
+ it "correctly runs" do
+ in_sub_process do
+ runner.run
+
+ contents = tmpfile.read
+ expect(contents).to match(/Failure\/Error: expect\(2 - 1\)\.to eq\(0\)/)
+ expect(contents).to match(/1 example, 1 failure/)
+ expect(contents).to match(/# controls_name should fail/)
+ end
+ end
+ end
+
+ end
+
+end
diff --git a/spec/functional/resource/deploy_revision_spec.rb b/spec/functional/resource/deploy_revision_spec.rb
index 7bc3da9a05..e5f5341fcd 100644
--- a/spec/functional/resource/deploy_revision_spec.rb
+++ b/spec/functional/resource/deploy_revision_spec.rb
@@ -45,11 +45,10 @@ describe Chef::Resource::DeployRevision, :unix_only => true do
before(:all) do
@ohai = Ohai::System.new
- @ohai.all_plugins("os")
+ @ohai.all_plugins(["platform", "os"])
end
let(:node) do
-
Chef::Node.new.tap do |n|
n.name "rspec-test"
n.consume_external_attrs(@ohai.data, {})
diff --git a/spec/functional/resource/git_spec.rb b/spec/functional/resource/git_spec.rb
index 4f462b7cb6..9d3b82f19e 100644
--- a/spec/functional/resource/git_spec.rb
+++ b/spec/functional/resource/git_spec.rb
@@ -92,7 +92,7 @@ E
before(:all) do
@ohai = Ohai::System.new
- @ohai.all_plugins("os")
+ @ohai.all_plugins(["platform", "os"])
end
context "working with pathes with special characters" do
diff --git a/spec/integration/client/client_spec.rb b/spec/integration/client/client_spec.rb
index f4bb124781..62660bb852 100644
--- a/spec/integration/client/client_spec.rb
+++ b/spec/integration/client/client_spec.rb
@@ -239,4 +239,44 @@ EOM
end
end
+
+ when_the_repository "has a cookbook with only an audit recipe" do
+
+ before do
+ file 'config/client.rb', <<EOM
+local_mode true
+cookbook_path "#{path_to('cookbooks')}"
+audit_mode :enabled
+EOM
+ end
+
+ it "should exit with a zero code when there is not an audit failure" do
+ file 'cookbooks/audit_test/recipes/succeed.rb', <<-RECIPE
+controls "control group without top level control" do
+ it "should succeed" do
+ expect(2 - 2).to eq(0)
+ end
+end
+ RECIPE
+
+ result = shell_out("#{chef_client} -c \"#{path_to('config/client.rb')}\" -o 'audit_test::succeed'", :cwd => chef_dir)
+ expect(result.error?).to be_falsey
+ expect(result.stdout).to include("Successfully executed all `controls` blocks and contained examples")
+ end
+
+ it "should exit with a non-zero code when there is an audit failure" do
+ file 'cookbooks/audit_test/recipes/fail.rb', <<-RECIPE
+controls "control group without top level control" do
+ it "should fail" do
+ expect(2 - 2).to eq(1)
+ end
+end
+ RECIPE
+
+ result = shell_out("#{chef_client} -c \"#{path_to('config/client.rb')}\" -o 'audit_test::fail'", :cwd => chef_dir)
+ expect(result.error?).to be_truthy
+ expect(result.stdout).to include("Failure/Error: expect(2 - 2).to eq(1)")
+ end
+ end
+
end
diff --git a/spec/support/audit_helper.rb b/spec/support/audit_helper.rb
new file mode 100644
index 0000000000..8fd3f4d719
--- /dev/null
+++ b/spec/support/audit_helper.rb
@@ -0,0 +1,65 @@
+# This code comes from https://github.com/rspec/rspec-core/blob/master/spec/spec_helper.rb and
+# https://github.com/rspec/rspec-core/blob/master/spec/support/sandboxing.rb
+
+# To leverage the sandboxing use an `around` block:
+# around(:each) do |ex|
+# Sandboxing.sandboxed { ex.run }
+# end
+
+# rspec-core did not include a license on Github
+# TODO when this API is exposed publicly from rspec-core, get rid of this copy pasta
+
+# Adding these as writers is necessary, otherwise we cannot set the new configuration.
+# Only want to do this in the specs.
+class << RSpec
+ attr_writer :configuration, :world
+end
+
+class NullObject
+ private
+ def method_missing(method, *args, &block)
+ # ignore
+ end
+end
+
+# TODO remove this when RSPec exposes this functionality publically
+# https://github.com/rspec/rspec-core/pull/1808
+module Sandboxing
+ def self.sandboxed(&block)
+ orig_load_path = $LOAD_PATH.dup
+ orig_config = RSpec.configuration
+ orig_world = RSpec.world
+ orig_example = RSpec.current_example
+ new_config = RSpec::Core::Configuration.new
+ new_config.expose_dsl_globally = false
+ new_config.expecting_with_rspec = true
+ new_world = RSpec::Core::World.new(new_config)
+ RSpec.configuration = new_config
+ RSpec.world = new_world
+ object = Object.new
+ object.extend(RSpec::Core::SharedExampleGroup)
+
+ (class << RSpec::Core::ExampleGroup; self; end).class_exec do
+ alias_method :orig_run, :run
+ def run(reporter=nil)
+ RSpec.current_example = nil
+ orig_run(reporter || NullObject.new)
+ end
+ end
+
+ RSpec::Mocks.with_temporary_scope do
+ object.instance_exec(&block)
+ end
+ ensure
+ (class << RSpec::Core::ExampleGroup; self; end).class_exec do
+ remove_method :run
+ alias_method :run, :orig_run
+ remove_method :orig_run
+ end
+
+ RSpec.configuration = orig_config
+ RSpec.world = orig_world
+ RSpec.current_example = orig_example
+ $LOAD_PATH.replace(orig_load_path)
+ end
+end
diff --git a/spec/unit/application/client_spec.rb b/spec/unit/application/client_spec.rb
index c2d3ec0507..33af9bc5c1 100644
--- a/spec/unit/application/client_spec.rb
+++ b/spec/unit/application/client_spec.rb
@@ -18,18 +18,20 @@
require 'spec_helper'
describe Chef::Application::Client, "reconfigure" do
+ let(:app) do
+ a = described_class.new
+ a.cli_arguments = []
+ a
+ end
+
before do
allow(Kernel).to receive(:trap).and_return(:ok)
@original_argv = ARGV.dup
ARGV.clear
- @app = Chef::Application::Client.new
- allow(@app).to receive(:trap)
- allow(@app).to receive(:configure_opt_parser).and_return(true)
- allow(@app).to receive(:configure_chef).and_return(true)
- allow(@app).to receive(:configure_logging).and_return(true)
- @app.cli_arguments = []
+ allow(app).to receive(:trap)
+ allow(app).to receive(:configure_logging).and_return(true)
Chef::Config[:interval] = 10
Chef::Config[:once] = false
@@ -60,7 +62,7 @@ Configuration settings:
interval = 600 seconds
Enable chef-client interval runs by setting `:client_fork = true` in your config file or adding `--fork` to your command line options."
)
- @app.reconfigure
+ app.reconfigure
end
end
@@ -72,7 +74,7 @@ Enable chef-client interval runs by setting `:client_fork = true` in your config
it "should not terminate" do
expect(Chef::Application).not_to receive(:fatal!)
- @app.reconfigure
+ app.reconfigure
end
end
@@ -83,7 +85,7 @@ Enable chef-client interval runs by setting `:client_fork = true` in your config
end
it "should reconfigure chef-client" do
- @app.reconfigure
+ app.reconfigure
expect(Chef::Config[:interval]).to be_nil
end
end
@@ -96,7 +98,7 @@ Enable chef-client interval runs by setting `:client_fork = true` in your config
end
it "should set the interval to 1800" do
- @app.reconfigure
+ app.reconfigure
expect(Chef::Config.interval).to eq(1800)
end
end
@@ -110,12 +112,12 @@ Enable chef-client interval runs by setting `:client_fork = true` in your config
end
it "ignores the splay" do
- @app.reconfigure
+ app.reconfigure
expect(Chef::Config.splay).to be_nil
end
it "forces the interval to nil" do
- @app.reconfigure
+ app.reconfigure
expect(Chef::Config.interval).to be_nil
end
@@ -128,14 +130,89 @@ Enable chef-client interval runs by setting `:client_fork = true` in your config
let(:json_source) { "https://foo.com/foo.json" }
before do
+ allow(app).to receive(:configure_chef).and_return(true)
Chef::Config[:json_attribs] = json_source
expect(Chef::ConfigFetcher).to receive(:new).with(json_source).
and_return(config_fetcher)
end
it "reads the JSON attributes from the specified source" do
- @app.reconfigure
- expect(@app.chef_client_json).to eq(json_attribs)
+ app.reconfigure
+ expect(app.chef_client_json).to eq(json_attribs)
+ end
+ end
+
+ describe "audit mode" do
+ shared_examples "experimental feature" do
+ before do
+ allow(Chef::Log).to receive(:warn)
+ end
+
+ it "emits a warning that audit mode is an experimental feature" do
+ expect(Chef::Log).to receive(:warn).with(/Audit mode is an experimental feature/)
+ app.reconfigure
+ end
+ end
+
+ shared_examples "unrecognized setting" do
+ it "fatals with a message including the incorrect setting" do
+ expect(Chef::Application).to receive(:fatal!).with(/Unrecognized setting #{mode} for audit mode/)
+ app.reconfigure
+ end
+ end
+
+ shared_context "set via config file" do
+ before do
+ Chef::Config[:audit_mode] = mode
+ end
+ end
+
+ shared_context "set via command line" do
+ before do
+ ARGV.replace(["--audit-mode", mode])
+ end
+ end
+
+ describe "enabled via config file" do
+ include_context "set via config file" do
+ let(:mode) { :enabled }
+ include_examples "experimental feature"
+ end
+ end
+
+ describe "enabled via command line" do
+ include_context "set via command line" do
+ let(:mode) { "enabled" }
+ include_examples "experimental feature"
+ end
+ end
+
+ describe "audit_only via config file" do
+ include_context "set via config file" do
+ let(:mode) { :audit_only }
+ include_examples "experimental feature"
+ end
+ end
+
+ describe "audit-only via command line" do
+ include_context "set via command line" do
+ let(:mode) { "audit-only" }
+ include_examples "experimental feature"
+ end
+ end
+
+ describe "unrecognized setting via config file" do
+ include_context "set via config file" do
+ let(:mode) { :derp }
+ include_examples "unrecognized setting"
+ end
+ end
+
+ describe "unrecognized setting via command line" do
+ include_context "set via command line" do
+ let(:mode) { "derp" }
+ include_examples "unrecognized setting"
+ end
end
end
end
diff --git a/spec/unit/application/solo_spec.rb b/spec/unit/application/solo_spec.rb
index 80f0bead8b..2a07ff38ad 100644
--- a/spec/unit/application/solo_spec.rb
+++ b/spec/unit/application/solo_spec.rb
@@ -18,13 +18,16 @@
require 'spec_helper'
describe Chef::Application::Solo do
+
+ let(:app) { Chef::Application::Solo.new }
+
before do
allow(Kernel).to receive(:trap).and_return(:ok)
- @app = Chef::Application::Solo.new
- allow(@app).to receive(:configure_opt_parser).and_return(true)
- allow(@app).to receive(:configure_chef).and_return(true)
- allow(@app).to receive(:configure_logging).and_return(true)
- allow(@app).to receive(:trap)
+ allow(app).to receive(:configure_opt_parser).and_return(true)
+ allow(app).to receive(:configure_chef).and_return(true)
+ allow(app).to receive(:configure_logging).and_return(true)
+ allow(app).to receive(:trap)
+
Chef::Config[:recipe_url] = false
Chef::Config[:json_attribs] = false
Chef::Config[:solo] = true
@@ -32,10 +35,15 @@ describe Chef::Application::Solo do
describe "configuring the application" do
it "should set solo mode to true" do
- @app.reconfigure
+ app.reconfigure
expect(Chef::Config[:solo]).to be_truthy
end
+ it "should set audit-mode to :disabled" do
+ app.reconfigure
+ expect(Chef::Config[:audit_mode]).to be :disabled
+ end
+
describe "when configured to not fork the client process" do
before do
Chef::Config[:client_fork] = false
@@ -56,7 +64,7 @@ Configuration settings:
interval = 600 seconds
Enable chef-client interval runs by setting `:client_fork = true` in your config file or adding `--fork` to your command line options."
)
- @app.reconfigure
+ app.reconfigure
end
end
end
@@ -68,7 +76,7 @@ Enable chef-client interval runs by setting `:client_fork = true` in your config
it "should set the interval to 1800" do
Chef::Config[:interval] = nil
- @app.reconfigure
+ app.reconfigure
expect(Chef::Config[:interval]).to eq(1800)
end
end
@@ -85,44 +93,46 @@ Enable chef-client interval runs by setting `:client_fork = true` in your config
end
it "reads the JSON attributes from the specified source" do
- @app.reconfigure
- expect(@app.chef_client_json).to eq(json_attribs)
+ app.reconfigure
+ expect(app.chef_client_json).to eq(json_attribs)
end
end
describe "when the recipe_url configuration option is specified" do
+ let(:tarfile) { StringIO.new("remote_tarball_content") }
+ let(:target_file) { StringIO.new }
+
before do
Chef::Config[:cookbook_path] = "#{Dir.tmpdir}/chef-solo/cookbooks"
Chef::Config[:recipe_url] = "http://junglist.gen.nz/recipes.tgz"
+
allow(FileUtils).to receive(:rm_rf).and_return(true)
allow(FileUtils).to receive(:mkdir_p).and_return(true)
- @tarfile = StringIO.new("remote_tarball_content")
- allow(@app).to receive(:open).with("http://junglist.gen.nz/recipes.tgz").and_yield(@tarfile)
- @target_file = StringIO.new
- allow(File).to receive(:open).with("#{Dir.tmpdir}/chef-solo/recipes.tgz", "wb").and_yield(@target_file)
+ allow(app).to receive(:open).with("http://junglist.gen.nz/recipes.tgz").and_yield(tarfile)
+ allow(File).to receive(:open).with("#{Dir.tmpdir}/chef-solo/recipes.tgz", "wb").and_yield(target_file)
allow(Chef::Mixin::Command).to receive(:run_command).and_return(true)
end
it "should create the recipes path based on the parent of the cookbook path" do
expect(FileUtils).to receive(:mkdir_p).with("#{Dir.tmpdir}/chef-solo").and_return(true)
- @app.reconfigure
+ app.reconfigure
end
it "should download the recipes" do
- expect(@app).to receive(:open).with("http://junglist.gen.nz/recipes.tgz").and_yield(@tarfile)
- @app.reconfigure
+ expect(app).to receive(:open).with("http://junglist.gen.nz/recipes.tgz").and_yield(tarfile)
+ app.reconfigure
end
it "should write the recipes to the target path" do
- @app.reconfigure
- expect(@target_file.string).to eq("remote_tarball_content")
+ app.reconfigure
+ expect(target_file.string).to eq("remote_tarball_content")
end
it "should untar the target file to the parent of the cookbook path" do
expect(Chef::Mixin::Command).to receive(:run_command).with({:command => "tar zxvf #{Dir.tmpdir}/chef-solo/recipes.tgz -C #{Dir.tmpdir}/chef-solo"}).and_return(true)
- @app.reconfigure
+ app.reconfigure
end
end
end
@@ -142,9 +152,9 @@ Enable chef-client interval runs by setting `:client_fork = true` in your config
end
it "should fetch the recipe_url first" do
- expect(@app).to receive(:fetch_recipe_tarball).ordered
+ expect(app).to receive(:fetch_recipe_tarball).ordered
expect(Chef::ConfigFetcher).to receive(:new).ordered.and_return(config_fetcher)
- @app.reconfigure
+ app.reconfigure
end
end
@@ -153,18 +163,17 @@ Enable chef-client interval runs by setting `:client_fork = true` in your config
Chef::Config[:solo] = true
allow(Chef::Daemon).to receive(:change_privilege)
- @chef_client = double("Chef::Client")
- allow(Chef::Client).to receive(:new).and_return(@chef_client)
- @app = Chef::Application::Solo.new
+ chef_client = double("Chef::Client")
+ allow(Chef::Client).to receive(:new).and_return(chef_client)
# this is all stuff the reconfigure method needs
- allow(@app).to receive(:configure_opt_parser).and_return(true)
- allow(@app).to receive(:configure_chef).and_return(true)
- allow(@app).to receive(:configure_logging).and_return(true)
+ allow(app).to receive(:configure_opt_parser).and_return(true)
+ allow(app).to receive(:configure_chef).and_return(true)
+ allow(app).to receive(:configure_logging).and_return(true)
end
it "should change privileges" do
expect(Chef::Daemon).to receive(:change_privilege).and_return(true)
- @app.setup_application
+ app.setup_application
end
end
diff --git a/spec/unit/audit/audit_event_proxy_spec.rb b/spec/unit/audit/audit_event_proxy_spec.rb
new file mode 100644
index 0000000000..899ba468b1
--- /dev/null
+++ b/spec/unit/audit/audit_event_proxy_spec.rb
@@ -0,0 +1,311 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+# Author:: Claire McQuin (<claire@getchef.com>)
+#
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'spec_helper'
+require 'chef/audit/audit_event_proxy'
+
+describe Chef::Audit::AuditEventProxy do
+
+ let(:stdout) { StringIO.new }
+ let(:events) { double("Chef::Events") }
+ let(:audit_event_proxy) { Chef::Audit::AuditEventProxy.new(stdout) }
+
+ before do
+ Chef::Audit::AuditEventProxy.events = events
+ end
+
+ describe "#example_group_started" do
+
+ let(:description) { "poots" }
+ let(:group) { double("ExampleGroup", :parent_groups => parents,
+ :description => description) }
+ let(:notification) { double("Notification", :group => group) }
+
+ context "when notified from a top-level example group" do
+
+ let(:parents) { [double("ExampleGroup")] }
+
+ it "notifies control_group_started event" do
+ expect(Chef::Log).to receive(:debug).
+ with("Entered \`controls\` block named poots")
+ expect(events).to receive(:control_group_started).
+ with(description)
+ audit_event_proxy.example_group_started(notification)
+ end
+ end
+
+ context "when notified from an inner-level example group" do
+
+ let(:parents) { [double("ExampleGroup"), double("OuterExampleGroup")] }
+
+ it "does nothing" do
+ expect(events).to_not receive(:control_group_started)
+ audit_event_proxy.example_group_started(notification)
+ end
+ end
+ end
+
+ describe "#stop" do
+
+ let(:examples) { [] }
+ let(:notification) { double("Notification", :examples => examples) }
+ let(:exception) { nil }
+ let(:example) { double("Example", :exception => exception) }
+ let(:control_group_name) { "audit test" }
+ let(:control_data) { double("ControlData") }
+
+ before do
+ allow(Chef::Log).to receive(:info) # silence messages to output stream
+ end
+
+ it "sends a message that audits completed" do
+ expect(Chef::Log).to receive(:info).with("Successfully executed all \`controls\` blocks and contained examples")
+ audit_event_proxy.stop(notification)
+ end
+
+ context "when an example succeeded" do
+
+ let(:examples) { [example] }
+ let(:excpetion) { nil }
+
+ before do
+ allow(audit_event_proxy).to receive(:build_control_from).
+ with(example).
+ and_return([control_group_name, control_data])
+ end
+
+ it "notifies events" do
+ expect(events).to receive(:control_example_success).
+ with(control_group_name, control_data)
+ audit_event_proxy.stop(notification)
+ end
+ end
+
+ context "when an example failed" do
+
+ let(:examples) { [example] }
+ let(:exception) { double("ExpectationNotMet") }
+
+ before do
+ allow(audit_event_proxy).to receive(:build_control_from).
+ with(example).
+ and_return([control_group_name, control_data])
+ end
+
+ it "notifies events" do
+ expect(events).to receive(:control_example_failure).
+ with(control_group_name, control_data, exception)
+ audit_event_proxy.stop(notification)
+ end
+ end
+
+ describe "#build_control_from" do
+
+ let(:examples) { [example] }
+
+ let(:example) { double("Example", :metadata => metadata,
+ :description => example_description,
+ :full_description => full_description, :exception => nil) }
+
+ let(:metadata) {
+ {
+ :described_class => described_class,
+ :example_group => example_group,
+ :line_number => line
+ }
+ }
+
+ let(:example_group) {
+ {
+ :description => group_description,
+ :parent_example_group => parent_group
+ }
+ }
+
+ let(:parent_group) {
+ {
+ :description => control_group_name,
+ :parent_example_group => nil
+ }
+ }
+
+ let(:line) { 27 }
+
+ let(:control_data) {
+ {
+ :name => example_description,
+ :desc => full_description,
+ :resource_type => resource_type,
+ :resource_name => resource_name,
+ :context => context,
+ :line_number => line
+ }
+ }
+
+ shared_examples "built control" do
+
+ before do
+ if described_class
+ allow(described_class).to receive(:instance_variable_get).
+ with(:@name).
+ and_return(resource_name)
+ allow(described_class.class).to receive(:name).
+ and_return(described_class.class)
+ end
+ end
+
+ it "returns the controls block name and example metadata for reporting" do
+ expect(events).to receive(:control_example_success).
+ with(control_group_name, control_data)
+ audit_event_proxy.stop(notification)
+ end
+ end
+
+ describe "a top-level example" do
+ # controls "port 111" do
+ # it "has nobody listening" do
+ # expect(port("111")).to_not be_listening
+ # end
+ # end
+
+ # Description parts
+ let(:group_description) { "port 111" }
+ let(:example_description) { "has nobody listening" }
+ let(:full_description) { group_description + " " + example_description }
+
+ # Metadata fields
+ let(:described_class) { nil }
+
+ # Example group (metadata[:example_group]) fields
+ let(:parent_group) { nil }
+
+ # Expected returns
+ let(:control_group_name) { group_description }
+
+ # Control data fields
+ let(:resource_type) { nil }
+ let(:resource_name) { nil }
+ let(:context) { [] }
+
+ include_examples "built control"
+ end
+
+ describe "an example with an implicit subject" do
+ # controls "application ports" do
+ # control port(111) do
+ # it { is_expected.to_not be_listening }
+ # end
+ # end
+
+ # Description parts
+ let(:control_group_name) { "application ports" }
+ let(:group_description) { "#{resource_type} #{resource_name}" }
+ let(:example_description) { "should not be listening" }
+ let(:full_description) { [control_group_name, group_description,
+ example_description].join(" ") }
+
+ # Metadata fields
+ let(:described_class) { double("Serverspec::Type::Port",
+ :class => "Serverspec::Type::Port", :name => resource_name) }
+
+ # Control data fields
+ let(:resource_type) { "Port" }
+ let(:resource_name) { "111" }
+ let(:context) { [] }
+
+ include_examples "built control"
+ end
+
+ describe "an example in a nested context" do
+ # controls "application ports" do
+ # control "port 111" do
+ # it "is not listening" do
+ # expect(port(111)).to_not be_listening
+ # end
+ # end
+ # end
+
+ # Description parts
+ let(:control_group_name) { "application ports" }
+ let(:group_description) { "port 111" }
+ let(:example_description) { "is not listening" }
+ let(:full_description) { [control_group_name, group_description,
+ example_description].join(" ") }
+
+ # Metadata fields
+ let(:described_class) { nil }
+
+ # Control data fields
+ let(:resource_type) { nil }
+ let(:resource_name) { nil }
+ let(:context) { [group_description] }
+
+ include_examples "built control"
+ end
+
+ describe "an example in a nested context including Serverspec" do
+ # controls "application directory" do
+ # control file("/tmp/audit") do
+ # describe file("/tmp/audit/test_file") do
+ # it "is a file" do
+ # expect(subject).to be_file
+ # end
+ # end
+ # end
+ # end
+
+ # Description parts
+ let(:control_group_name) { "application directory" }
+ let(:outer_group_description) { "File \"tmp/audit\"" }
+ let(:group_description) { "#{resource_type} #{resource_name}" }
+ let(:example_description) { "is a file" }
+ let(:full_description) { [control_group_name, outer_group_description,
+ group_description, example_description].join(" ") }
+
+ # Metadata parts
+ let(:described_class) { double("Serverspec::Type::File",
+ :class => "Serverspec::Type::File", :name => resource_name) }
+
+ # Example group parts
+ let(:parent_group) {
+ {
+ :description => outer_group_description,
+ :parent_example_group => control_group
+ }
+ }
+
+ let(:control_group) {
+ {
+ :description => control_group_name,
+ :parent_example_group => nil
+ }
+ }
+
+ # Control data parts
+ let(:resource_type) { "File" }
+ let(:resource_name) { "/tmp/audit/test_file" }
+ let(:context) { [outer_group_description] }
+
+ include_examples "built control"
+ end
+ end
+ end
+
+end
diff --git a/spec/unit/audit/audit_reporter_spec.rb b/spec/unit/audit/audit_reporter_spec.rb
new file mode 100644
index 0000000000..84d7ea82f0
--- /dev/null
+++ b/spec/unit/audit/audit_reporter_spec.rb
@@ -0,0 +1,393 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+# Author:: Claire McQuin (<claire@getchef.com>)
+#
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'spec_helper'
+
+describe Chef::Audit::AuditReporter do
+
+ let(:rest) { double("rest") }
+ let(:reporter) { described_class.new(rest) }
+ let(:node) { double("node", :name => "sofreshsoclean") }
+ let(:run_id) { 0 }
+ let(:start_time) { Time.new(2014, 12, 3, 9, 31, 05, "-08:00") }
+ let(:end_time) { Time.new(2014, 12, 3, 9, 36, 14, "-08:00") }
+ let(:run_status) { instance_double(Chef::RunStatus, :node => node, :run_id => run_id,
+ :start_time => start_time, :end_time => end_time) }
+
+ describe "#audit_phase_start" do
+
+ it "notifies audit phase start to debug log" do
+ expect(Chef::Log).to receive(:debug).with(/Audit Reporter starting/)
+ reporter.audit_phase_start(run_status)
+ end
+
+ it "initializes an AuditData object" do
+ expect(Chef::Audit::AuditData).to receive(:new).with(run_status.node.name, run_status.run_id)
+ reporter.audit_phase_start(run_status)
+ end
+
+ it "saves the run status" do
+ reporter.audit_phase_start(run_status)
+ expect(reporter.instance_variable_get(:@run_status)).to eq run_status
+ end
+ end
+
+ describe "#run_completed" do
+
+ let(:audit_data) { Chef::Audit::AuditData.new(node.name, run_id) }
+ let(:run_data) { audit_data.to_hash }
+
+ before do
+ allow(reporter).to receive(:auditing_enabled?).and_return(true)
+ allow(reporter).to receive(:run_status).and_return(run_status)
+ allow(rest).to receive(:create_url).and_return(true)
+ allow(rest).to receive(:post).and_return(true)
+ allow(reporter).to receive(:audit_data).and_return(audit_data)
+ allow(reporter).to receive(:run_status).and_return(run_status)
+ allow(audit_data).to receive(:to_hash).and_return(run_data)
+ end
+
+ describe "a successful run with auditing enabled" do
+ it "sets run start and end times" do
+ iso_start_time = "2014-12-03T17:31:05Z"
+ iso_end_time = "2014-12-03T17:36:14Z"
+
+ reporter.run_completed(node)
+ expect(audit_data.start_time).to eq iso_start_time
+ expect(audit_data.end_time).to eq iso_end_time
+ end
+
+ it "posts audit data to server endpoint" do
+ endpoint = "api.opscode.us/orgname/controls"
+ headers = {
+ 'X-Ops-Audit-Report-Protocol-Version' => Chef::Audit::AuditReporter::PROTOCOL_VERSION
+ }
+
+ expect(rest).to receive(:create_url).
+ with("controls").
+ and_return(endpoint)
+ expect(rest).to receive(:post).
+ with(endpoint, run_data, headers)
+ reporter.run_completed(node)
+ end
+
+ context "when unable to post to server" do
+
+ let(:error) do
+ e = StandardError.new
+ e.set_backtrace(caller)
+ e
+ end
+
+ before do
+ expect(rest).to receive(:post).and_raise(error)
+ allow(error).to receive(:respond_to?).and_call_original
+ end
+
+ context "the error is an http error" do
+
+ let(:response) { double("response", :code => code) }
+
+ before do
+ expect(Chef::Log).to receive(:debug).with(/Sending audit report/)
+ expect(Chef::Log).to receive(:debug).with(/Audit Report/)
+ allow(error).to receive(:response).and_return(response)
+ expect(error).to receive(:respond_to?).with(:response).and_return(true)
+ end
+
+ context "when the code is 404" do
+
+ let(:code) { "404" }
+
+ it "logs that the server doesn't support audit reporting" do
+ expect(Chef::Log).to receive(:debug).with(/Server doesn't support audit reporting/)
+ reporter.run_completed(node)
+ end
+ end
+
+ shared_examples "non-404 error code" do
+
+ it "saves the error report" do
+ expect(Chef::FileCache).to receive(:store).
+ with("failed-audit-data.json", an_instance_of(String), 0640).
+ and_return(true)
+ expect(Chef::FileCache).to receive(:load).
+ with("failed-audit-data.json", false).
+ and_return(true)
+ expect(Chef::Log).to receive(:error).with(/Failed to post audit report to server/)
+ reporter.run_completed(node)
+ end
+
+ end
+
+ context "when the code is not 404" do
+ include_examples "non-404 error code" do
+ let(:code) { "505" }
+ end
+ end
+
+ context "when there is no code" do
+ include_examples "non-404 error code" do
+ let(:code) { nil }
+ end
+ end
+
+ end
+
+ context "the error is not an http error" do
+
+ it "logs the error" do
+ expect(error).to receive(:respond_to?).with(:response).and_return(false)
+ expect(Chef::Log).to receive(:error).with(/Failed to post audit report to server/)
+ reporter.run_completed(node)
+ end
+
+ end
+
+ context "when reporting url fatals are enabled" do
+
+ before do
+ allow(Chef::Config).to receive(:[]).
+ with(:enable_reporting_url_fatals).
+ and_return(true)
+ end
+
+ it "raises the error" do
+ expect(error).to receive(:respond_to?).with(:response).and_return(false)
+ allow(Chef::Log).to receive(:error).and_return(true)
+ expect(Chef::Log).to receive(:error).with(/Reporting fatals enabled. Aborting run./)
+ expect{ reporter.run_completed(node) }.to raise_error(error)
+ end
+
+ end
+ end
+ end
+
+ context "when auditing is not enabled" do
+
+ before do
+ allow(Chef::Log).to receive(:debug)
+ end
+
+ it "doesn't send reports" do
+ expect(reporter).to receive(:auditing_enabled?).and_return(false)
+ expect(Chef::Log).to receive(:debug).with("Audit Reports are disabled. Skipping sending reports.")
+ reporter.run_completed(node)
+ end
+
+ end
+
+ context "when the run fails before audits" do
+
+ before do
+ allow(Chef::Log).to receive(:debug)
+ end
+
+ it "doesn't send reports" do
+ expect(reporter).to receive(:auditing_enabled?).and_return(true)
+ expect(reporter).to receive(:run_status).and_return(nil)
+ expect(Chef::Log).to receive(:debug).with("Run failed before audits were initialized, not sending audit report to server")
+ reporter.run_completed(node)
+ end
+
+ end
+ end
+
+ describe "#run_failed" do
+
+ let(:audit_data) { Chef::Audit::AuditData.new(node.name, run_id) }
+ let(:run_data) { audit_data.to_hash }
+
+ let(:error) { double("AuditError", :class => "Chef::Exception::AuditError",
+ :message => "Well that certainly didn't work",
+ :backtrace => ["line 0", "line 1", "line 2"]) }
+
+ before do
+ allow(reporter).to receive(:auditing_enabled?).and_return(true)
+ allow(reporter).to receive(:run_status).and_return(run_status)
+ allow(reporter).to receive(:audit_data).and_return(audit_data)
+ allow(audit_data).to receive(:to_hash).and_return(run_data)
+ end
+
+ it "adds the error information to the reported data" do
+ expect(rest).to receive(:create_url)
+ expect(rest).to receive(:post)
+ reporter.run_failed(error)
+ expect(run_data).to have_key(:error)
+ expect(run_data[:error]).to eq "Chef::Exception::AuditError: Well that certainly didn't work\n" +
+ "line 0\nline 1\nline 2"
+ end
+
+ end
+
+ shared_context "audit data" do
+
+ let(:control_group_foo) { instance_double(Chef::Audit::ControlGroupData,
+ :metadata => double("foo metadata")) }
+ let(:control_group_bar) { instance_double(Chef::Audit::ControlGroupData,
+ :metadata => double("bar metadata")) }
+
+ let(:ordered_control_groups) {
+ {
+ "foo" => control_group_foo,
+ "bar" => control_group_bar
+ }
+ }
+
+ let(:audit_data) { instance_double(Chef::Audit::AuditData,
+ :add_control_group => true) }
+
+ let(:run_context) { instance_double(Chef::RunContext,
+ :audits => ordered_control_groups) }
+
+ before do
+ allow(reporter).to receive(:ordered_control_groups).and_return(ordered_control_groups)
+ allow(reporter).to receive(:audit_data).and_return(audit_data)
+ allow(reporter).to receive(:run_status).and_return(run_status)
+ allow(run_status).to receive(:run_context).and_return(run_context)
+ end
+ end
+
+ describe "#audit_phase_complete" do
+ include_context "audit data"
+
+ it "notifies audit phase finished to debug log" do
+ expect(Chef::Log).to receive(:debug).with(/Audit Reporter completed/)
+ reporter.audit_phase_complete
+ end
+
+ it "collects audit data" do
+ ordered_control_groups.each do |_name, group|
+ expect(audit_data).to receive(:add_control_group).with(group)
+ end
+ reporter.audit_phase_complete
+ end
+ end
+
+ describe "#audit_phase_failed" do
+ include_context "audit data"
+
+ let(:error) { double("Exception") }
+
+ it "notifies audit phase failed to debug log" do
+ expect(Chef::Log).to receive(:debug).with(/Audit Reporter failed/)
+ reporter.audit_phase_failed(error)
+ end
+
+ it "collects audit data" do
+ ordered_control_groups.each do |_name, group|
+ expect(audit_data).to receive(:add_control_group).with(group)
+ end
+ reporter.audit_phase_failed(error)
+ end
+ end
+
+ describe "#control_group_started" do
+ include_context "audit data"
+
+ let(:name) { "bat" }
+ let(:control_group) { instance_double(Chef::Audit::ControlGroupData,
+ :metadata => double("metadata")) }
+
+ before do
+ allow(Chef::Audit::ControlGroupData).to receive(:new).
+ with(name, control_group.metadata).
+ and_return(control_group)
+ end
+
+ it "stores the control group" do
+ expect(ordered_control_groups).to receive(:has_key?).with(name).and_return(false)
+ allow(run_context.audits).to receive(:[]).with(name).and_return(control_group)
+ expect(ordered_control_groups).to receive(:store).
+ with(name, control_group).
+ and_call_original
+ reporter.control_group_started(name)
+ # stubbed :has_key? above, which is used by the have_key matcher,
+ # so instead we check the response to Hash's #key? because luckily
+ # #key? does not call #has_key?
+ expect(ordered_control_groups.key?(name)).to be true
+ expect(ordered_control_groups[name]).to eq control_group
+ end
+
+ context "when a control group with the same name has been seen" do
+ it "raises an exception" do
+ expect(ordered_control_groups).to receive(:has_key?).with(name).and_return(true)
+ expect{ reporter.control_group_started(name) }.to raise_error(Chef::Exceptions::AuditControlGroupDuplicate)
+ end
+ end
+ end
+
+ describe "#control_example_success" do
+ include_context "audit data"
+
+ let(:name) { "foo" }
+ let(:example_data) { double("example data") }
+
+ it "notifies the control group the example succeeded" do
+ expect(control_group_foo).to receive(:example_success).with(example_data)
+ reporter.control_example_success(name, example_data)
+ end
+ end
+
+ describe "#control_example_failure" do
+ include_context "audit data"
+
+ let(:name) { "bar" }
+ let(:example_data) { double("example data") }
+ let(:error) { double("Exception", :message => "oopsie") }
+
+ it "notifies the control group the example failed" do
+ expect(control_group_bar).to receive(:example_failure).
+ with(example_data, error.message)
+ reporter.control_example_failure(name, example_data, error)
+ end
+ end
+
+ describe "#auditing_enabled?" do
+ shared_examples "enabled?" do |true_or_false|
+
+ it "returns #{true_or_false}" do
+ expect(Chef::Config).to receive(:[]).
+ with(:audit_mode).
+ and_return(audit_setting)
+ expect(reporter.auditing_enabled?).to be true_or_false
+ end
+ end
+
+ context "when auditing is disabled" do
+ include_examples "enabled?", false do
+ let(:audit_setting) { :disabled }
+ end
+ end
+
+ context "when auditing in audit-only mode" do
+ include_examples "enabled?", true do
+ let(:audit_setting) { :audit_only }
+ end
+ end
+
+ context "when auditing is enabled" do
+ include_examples "enabled?", true do
+ let(:audit_setting) { :enabled }
+ end
+ end
+ end
+
+end
diff --git a/spec/unit/audit/control_group_data_spec.rb b/spec/unit/audit/control_group_data_spec.rb
new file mode 100644
index 0000000000..e21ab066fd
--- /dev/null
+++ b/spec/unit/audit/control_group_data_spec.rb
@@ -0,0 +1,478 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+# Author:: Claire McQuin (<claire@getchef.com>)
+#
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'spec_helper'
+require 'securerandom'
+
+describe Chef::Audit::AuditData do
+
+ let(:node_name) { "noodles" }
+ let(:run_id) { SecureRandom.uuid }
+ let(:audit_data) { described_class.new(node_name, run_id) }
+
+ let(:control_group_1) { double("control group 1") }
+ let(:control_group_2) { double("control group 2") }
+
+ describe "#add_control_group" do
+ context "when no control groups have been added" do
+ it "stores the control group" do
+ audit_data.add_control_group(control_group_1)
+ expect(audit_data.control_groups).to include(control_group_1)
+ end
+
+ end
+
+ context "when adding additional control groups" do
+
+ before do
+ audit_data.add_control_group(control_group_1)
+ end
+
+ it "stores the control group" do
+ audit_data.add_control_group(control_group_2)
+ expect(audit_data.control_groups).to include(control_group_2)
+ end
+
+ it "stores all control groups" do
+ audit_data.add_control_group(control_group_2)
+ expect(audit_data.control_groups).to include(control_group_1)
+ end
+ end
+ end
+
+ describe "#to_hash" do
+
+ let(:audit_data_hash) { audit_data.to_hash }
+
+ it "returns a hash" do
+ expect(audit_data_hash).to be_a(Hash)
+ end
+
+ it "describes a Chef::Audit::AuditData object" do
+ keys = [:node_name, :run_id, :start_time, :end_time, :control_groups]
+ expect(audit_data_hash.keys).to match_array(keys)
+ end
+
+ describe ":control_groups" do
+
+ let(:control_hash_1) { {:name => "control group 1"} }
+ let(:control_hash_2) { {:name => "control group 2"} }
+
+ let(:control_groups) { audit_data_hash[:control_groups] }
+
+ context "with no control groups added" do
+ it "is an empty list" do
+ expect(control_groups).to eq []
+ end
+ end
+
+ context "with one control group added" do
+
+ before do
+ allow(audit_data).to receive(:control_groups).and_return([control_group_1])
+ end
+
+ it "is a one-element list containing the control group hash" do
+ expect(control_group_1).to receive(:to_hash).once.and_return(control_hash_1)
+ expect(control_groups.size).to eq 1
+ expect(control_groups).to include(control_hash_1)
+ end
+ end
+
+ context "with multiple control groups added" do
+
+ before do
+ allow(audit_data).to receive(:control_groups).and_return([control_group_1, control_group_2])
+ end
+
+ it "is a list of control group hashes" do
+ expect(control_group_1).to receive(:to_hash).and_return(control_hash_1)
+ expect(control_group_2).to receive(:to_hash).and_return(control_hash_2)
+ expect(control_groups.size).to eq 2
+ expect(control_groups).to include(control_hash_1)
+ expect(control_groups).to include(control_hash_2)
+ end
+ end
+ end
+ end
+end
+
+describe Chef::Audit::ControlData do
+
+ let(:name) { "ramen" }
+ let(:resource_type) { double("Service") }
+ let(:resource_name) { "mysql" }
+ let(:context) { nil }
+ let(:line_number) { 27 }
+
+ let(:control_data) { described_class.new(name: name,
+ resource_type: resource_type, resource_name: resource_name,
+ context: context, line_number: line_number) }
+
+
+ describe "#to_hash" do
+
+ let(:control_data_hash) { control_data.to_hash }
+
+ it "returns a hash" do
+ expect(control_data_hash).to be_a(Hash)
+ end
+
+ it "describes a Chef::Audit::ControlData object" do
+ keys = [:name, :resource_type, :resource_name, :context, :status, :details]
+ expect(control_data_hash.keys).to match_array(keys)
+ end
+
+ context "when context is nil" do
+
+ it "sets :context to an empty array" do
+ expect(control_data_hash[:context]).to eq []
+ end
+
+ end
+
+ context "when context is non-nil" do
+
+ let(:context) { ["outer"] }
+
+ it "sets :context to its value" do
+ expect(control_data_hash[:context]).to eq context
+ end
+ end
+ end
+end
+
+describe Chef::Audit::ControlGroupData do
+
+ let(:name) { "balloon" }
+ let(:control_group_data) { described_class.new(name) }
+
+ shared_context "control data" do
+
+ let(:name) { "" }
+ let(:resource_type) { nil }
+ let(:resource_name) { nil }
+ let(:context) { nil }
+ let(:line_number) { 0 }
+
+ let(:control_data) {
+ {
+ :name => name,
+ :resource_type => resource_type,
+ :resource_name => resource_name,
+ :context => context,
+ :line_number => line_number
+ }
+ }
+
+ end
+
+ shared_context "control" do
+ include_context "control data"
+
+ let(:control) { Chef::Audit::ControlData.new(name: name,
+ resource_type: resource_type, resource_name: resource_name,
+ context: context, line_number: line_number) }
+
+ before do
+ allow(Chef::Audit::ControlData).to receive(:new).
+ with(name: name, resource_type: resource_type,
+ resource_name: resource_name, context: context,
+ line_number: line_number).
+ and_return(control)
+ end
+ end
+
+ describe "#new" do
+ it "has status \"success\"" do
+ expect(control_group_data.status).to eq "success"
+ end
+ end
+
+ describe "#example_success" do
+ include_context "control"
+
+ def notify_success
+ control_group_data.example_success(control_data)
+ end
+
+ it "increments the number of successful audits" do
+ num_success = control_group_data.number_succeeded
+ notify_success
+ expect(control_group_data.number_succeeded).to eq (num_success + 1)
+ end
+
+ it "does not increment the number of failed audits" do
+ num_failed = control_group_data.number_failed
+ notify_success
+ expect(control_group_data.number_failed).to eq (num_failed)
+ end
+
+ it "marks the audit's status as success" do
+ notify_success
+ expect(control.status).to eq "success"
+ end
+
+ it "does not modify its own status" do
+ expect(control_group_data).to_not receive(:status=)
+ status = control_group_data.status
+ notify_success
+ expect(control_group_data.status).to eq status
+ end
+
+ it "saves the control" do
+ controls = control_group_data.controls
+ expect(controls).to_not include(control)
+ notify_success
+ expect(controls).to include(control)
+ end
+ end
+
+ describe "#example_failure" do
+ include_context "control"
+
+ let(:details) { "poop" }
+
+ def notify_failure
+ control_group_data.example_failure(control_data, details)
+ end
+
+ it "does not increment the number of successful audits" do
+ num_success = control_group_data.number_succeeded
+ notify_failure
+ expect(control_group_data.number_succeeded).to eq num_success
+ end
+
+ it "increments the number of failed audits" do
+ num_failed = control_group_data.number_failed
+ notify_failure
+ expect(control_group_data.number_failed).to eq (num_failed + 1)
+ end
+
+ it "marks the audit's status as failure" do
+ notify_failure
+ expect(control.status).to eq "failure"
+ end
+
+ it "marks its own status as failure" do
+ notify_failure
+ expect(control_group_data.status).to eq "failure"
+ end
+
+ it "saves the control" do
+ controls = control_group_data.controls
+ expect(controls).to_not include(control)
+ notify_failure
+ expect(controls).to include(control)
+ end
+
+ context "when details are not provided" do
+
+ let(:details) { nil }
+
+ it "does not save details to the control" do
+ default_details = control.details
+ expect(control).to_not receive(:details=)
+ notify_failure
+ expect(control.details).to eq default_details
+ end
+ end
+
+ context "when details are provided" do
+
+ let(:details) { "yep that didn't work" }
+
+ it "saves details to the control" do
+ notify_failure
+ expect(control.details).to eq details
+ end
+ end
+ end
+
+ shared_examples "multiple audits" do |success_or_failure|
+ include_context "control"
+
+ let(:num_success) { 0 }
+ let(:num_failure) { 0 }
+
+ before do
+ if num_failure == 0
+ num_success.times { control_group_data.example_success(control_data) }
+ elsif num_success == 0
+ num_failure.times { control_group_data.example_failure(control_data, nil) }
+ end
+ end
+
+ it "counts the number of successful audits" do
+ expect(control_group_data.number_succeeded).to eq num_success
+ end
+
+ it "counts the number of failed audits" do
+ expect(control_group_data.number_failed).to eq num_failure
+ end
+
+ it "marks its status as \"#{success_or_failure}\"" do
+ expect(control_group_data.status).to eq success_or_failure
+ end
+ end
+
+ context "when all audits pass" do
+ include_examples "multiple audits", "success" do
+ let(:num_success) { 3 }
+ end
+ end
+
+ context "when one audit fails" do
+ shared_examples "mixed audit results" do
+ include_examples "multiple audits", "failure" do
+
+ let(:audit_results) { [] }
+ let(:num_success) { audit_results.count("success") }
+ let(:num_failure) { 1 }
+
+ before do
+ audit_results.each do |result|
+ if result == "success"
+ control_group_data.example_success(control_data)
+ else
+ control_group_data.example_failure(control_data, nil)
+ end
+ end
+ end
+ end
+ end
+
+ context "and it's the first audit" do
+ include_examples "mixed audit results" do
+ let(:audit_results) { ["failure", "success", "success"] }
+ end
+ end
+
+ context "and it's an audit in the middle" do
+ include_examples "mixed audit results" do
+ let(:audit_results) { ["success", "failure", "success"] }
+ end
+ end
+
+ context "and it's the last audit" do
+ include_examples "mixed audit results" do
+ let(:audit_results) { ["success", "success", "failure"] }
+ end
+ end
+ end
+
+ context "when all audits fail" do
+ include_examples "multiple audits", "failure" do
+ let(:num_failure) { 3 }
+ end
+ end
+
+ describe "#to_hash" do
+
+ let(:control_group_data_hash) { control_group_data.to_hash }
+
+ it "returns a hash" do
+ expect(control_group_data_hash).to be_a(Hash)
+ end
+
+ it "describes a Chef::Audit::ControlGroupData object" do
+ keys = [:name, :status, :number_succeeded, :number_failed,
+ :controls, :id]
+ expect(control_group_data_hash.keys).to match_array(keys)
+ end
+
+ describe ":controls" do
+
+ let(:control_group_controls) { control_group_data_hash[:controls] }
+
+ context "with no controls added" do
+ it "is an empty list" do
+ expect(control_group_controls).to eq []
+ end
+ end
+
+ context "with one control added" do
+ include_context "control"
+
+ let(:control_list) { [control_data] }
+ let(:control_hash) { control.to_hash }
+
+ before do
+ expect(control_group_data).to receive(:controls).twice.and_return(control_list)
+ expect(control_data).to receive(:to_hash).and_return(control_hash)
+ end
+
+ it "is a one-element list containing the control hash" do
+ expect(control_group_controls.size).to eq 1
+ expect(control_group_controls).to include(control_hash)
+ end
+
+ it "adds a sequence number to the control" do
+ control_group_data.to_hash
+ expect(control_hash).to have_key(:sequence_number)
+ end
+
+ end
+
+ context "with multiple controls added" do
+
+ let(:control_hash_1) { {:line_number => 27} }
+ let(:control_hash_2) { {:line_number => 13} }
+ let(:control_hash_3) { {:line_number => 35} }
+
+ let(:control_1) { double("control 1",
+ :line_number => control_hash_1[:line_number],
+ :to_hash => control_hash_1) }
+ let(:control_2) { double("control 2",
+ :line_number => control_hash_2[:line_number],
+ :to_hash => control_hash_2) }
+ let(:control_3) { double("control 3",
+ :line_number => control_hash_3[:line_number],
+ :to_hash => control_hash_3) }
+
+ let(:control_list) { [control_1, control_2, control_3] }
+ let(:ordered_control_hashes) { [control_hash_2, control_hash_1, control_hash_3] }
+
+ before do
+ # Another way to do this would be to call #example_success
+ # or #example_failure per control hash, but we'd have to
+ # then stub #create_control and it's a lot of extra stubbing work.
+ # We can't stub the controls reader to return a list of
+ # controls because of the call to sort! and the following
+ # reading of controls.
+ control_group_data.instance_variable_set(:@controls, control_list)
+ end
+
+ it "is a list of control group hashes ordered by line number" do
+ expect(control_group_controls.size).to eq 3
+ expect(control_group_controls).to eq ordered_control_hashes
+ end
+
+ it "assigns sequence numbers in order" do
+ control_group_data.to_hash
+ ordered_control_hashes.each_with_index do |control_hash, idx|
+ expect(control_hash[:sequence_number]).to eq idx + 1
+ end
+ end
+ end
+ end
+ end
+
+end
diff --git a/spec/unit/audit/rspec_formatter_spec.rb b/spec/unit/audit/rspec_formatter_spec.rb
new file mode 100644
index 0000000000..471473e387
--- /dev/null
+++ b/spec/unit/audit/rspec_formatter_spec.rb
@@ -0,0 +1,29 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+# Author:: Claire McQuin (<claire@getchef.com>)
+#
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'spec_helper'
+require 'chef/audit/rspec_formatter'
+
+describe Chef::Audit::RspecFormatter do
+ let(:formatter) { Chef::Audit::RspecFormatter.new(nil) }
+ it "should respond to close" do
+ expect(formatter).to respond_to(:close)
+ end
+end
diff --git a/spec/unit/audit/runner_spec.rb b/spec/unit/audit/runner_spec.rb
new file mode 100644
index 0000000000..67590fecf9
--- /dev/null
+++ b/spec/unit/audit/runner_spec.rb
@@ -0,0 +1,133 @@
+#
+# Author:: Tyler Ball (<tball@chef.io>)
+# Copyright:: Copyright (c) 2014 Chef Software, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'spec_helper'
+require 'spec/support/audit_helper'
+require 'chef/audit/runner'
+require 'chef/audit/audit_event_proxy'
+require 'chef/audit/rspec_formatter'
+require 'rspec/support/spec/in_sub_process'
+
+describe Chef::Audit::Runner do
+ include RSpec::Support::InSubProcess
+
+ let(:events) { double("events") }
+ let(:run_context) { instance_double(Chef::RunContext, :events => events) }
+ let(:runner) { Chef::Audit::Runner.new(run_context) }
+
+ around(:each) do |ex|
+ Sandboxing.sandboxed { ex.run }
+ end
+
+ describe "#initialize" do
+ it "correctly sets the run_context during initialization" do
+ expect(runner.instance_variable_get(:@run_context)).to eq(run_context)
+ end
+ end
+
+ context "during #run" do
+
+ describe "#setup" do
+ let(:log_location) { File.join(Dir.tmpdir, 'audit_log') }
+ let(:color) { false }
+
+ before do
+ Chef::Config[:log_location] = log_location
+ Chef::Config[:color] = color
+ end
+
+ it "sets all the config values" do
+ # This runs the Serverspec includes - we don't want these hanging around in all subsequent tests so
+ # we run this in a forked process. Keeps Serverspec files from getting loaded into main process.
+ in_sub_process do
+ runner.send(:setup)
+
+ expect(RSpec.configuration.output_stream).to eq(log_location)
+ expect(RSpec.configuration.error_stream).to eq(log_location)
+
+ expect(RSpec.configuration.formatters.size).to eq(2)
+ expect(RSpec.configuration.formatters).to include(instance_of(Chef::Audit::AuditEventProxy))
+ expect(RSpec.configuration.formatters).to include(instance_of(Chef::Audit::RspecFormatter))
+ expect(Chef::Audit::AuditEventProxy.class_variable_get(:@@events)).to eq(run_context.events)
+
+ expect(RSpec.configuration.expectation_frameworks).to eq([RSpec::Matchers])
+ expect(RSpec::Matchers.configuration.syntax).to eq([:expect])
+
+ expect(RSpec.configuration.color).to eq(color)
+ expect(RSpec.configuration.expose_dsl_globally?).to eq(false)
+
+ expect(Specinfra.configuration.backend).to eq(:exec)
+ end
+ end
+ end
+
+ describe "#register_controls" do
+ let(:audits) { [] }
+ let(:run_context) { instance_double(Chef::RunContext, :audits => audits) }
+
+ it "adds the control group aliases" do
+ runner.send(:register_controls)
+
+ expect(RSpec::Core::DSL.example_group_aliases).to include(:__controls__)
+ expect(RSpec::Core::DSL.example_group_aliases).to include(:control)
+ end
+
+ context "audits exist" do
+ let(:audits) { {"audit_name" => group} }
+ let(:group) {Struct.new(:args, :block).new(["group_name"], nil)}
+
+ it "sends the audits to the world" do
+ runner.send(:register_controls)
+
+ expect(RSpec.world.example_groups.size).to eq(1)
+ # For whatever reason, `kind_of` is not working
+ # expect(RSpec.world.example_groups).to include(kind_of(RSpec::Core::ExampleGroup)) => FAIL
+ g = RSpec.world.example_groups[0]
+ expect(g.ancestors).to include(RSpec::Core::ExampleGroup)
+ expect(g.description).to eq("group_name")
+ end
+ end
+ end
+
+ describe "#do_run" do
+ let(:rspec_runner) { instance_double(RSpec::Core::Runner) }
+
+ it "executes the runner" do
+ expect(RSpec::Core::Runner).to receive(:new).with(nil).and_return(rspec_runner)
+ expect(rspec_runner).to receive(:run_specs).with([])
+
+ runner.send(:do_run)
+ end
+ end
+ end
+
+ describe "counters" do
+ it "correctly calculates failed?" do
+ expect(runner.failed?).to eq(false)
+ end
+
+ it "correctly calculates num_failed" do
+ expect(runner.num_failed).to eq(0)
+ end
+
+ it "correctly calculates num_total" do
+ expect(runner.num_total).to eq(0)
+ end
+ end
+
+end
diff --git a/spec/unit/client_spec.rb b/spec/unit/client_spec.rb
index 10958d628c..2ec32b32ac 100644
--- a/spec/unit/client_spec.rb
+++ b/spec/unit/client_spec.rb
@@ -187,11 +187,12 @@ describe Chef::Client do
end
describe "a full client run" do
- shared_examples_for "a successful client run" do
+ shared_context "a client run" do
let(:http_node_load) { double("Chef::REST (node)") }
let(:http_cookbook_sync) { double("Chef::REST (cookbook sync)") }
let(:http_node_save) { double("Chef::REST (node save)") }
let(:runner) { double("Chef::Runner") }
+ let(:audit_runner) { instance_double("Chef::Audit::Runner", :failed? => false) }
let(:api_client_exists?) { false }
@@ -204,7 +205,11 @@ describe Chef::Client do
# --Client.register
# Make sure Client#register thinks the client key doesn't
# exist, so it tries to register and create one.
- expect(File).to receive(:exists?).with(Chef::Config[:client_key]).exactly(1).times.and_return(api_client_exists?)
+ allow(File).to receive(:exists?).and_call_original
+ expect(File).to receive(:exists?).
+ with(Chef::Config[:client_key]).
+ exactly(:once).
+ and_return(api_client_exists?)
unless api_client_exists?
# Client.register will register with the validation client name.
@@ -218,7 +223,7 @@ describe Chef::Client do
# previous step.
expect(Chef::REST).to receive(:new).
with(Chef::Config[:chef_server_url], fqdn, Chef::Config[:client_key]).
- exactly(1).
+ exactly(:once).
and_return(http_node_load)
# --Client#build_node
@@ -246,11 +251,12 @@ describe Chef::Client do
# --Client#converge
expect(Chef::Runner).to receive(:new).and_return(runner)
expect(runner).to receive(:converge).and_return(true)
+ end
- # --ResourceReporter#run_completed
- # updates the server with the resource history
- # (has its own tests, so stubbing it here.)
- expect_any_instance_of(Chef::ResourceReporter).to receive(:run_completed)
+ def stub_for_audit
+ # -- Client#run_audits
+ expect(Chef::Audit::Runner).to receive(:new).and_return(audit_runner)
+ expect(audit_runner).to receive(:run).and_return(true)
end
def stub_for_node_save
@@ -269,11 +275,22 @@ describe Chef::Client do
# Post conditions: check that node has been filled in correctly
expect(client).to receive(:run_started)
expect(client).to receive(:run_completed_successfully)
+
+ # --ResourceReporter#run_completed
+ # updates the server with the resource history
+ # (has its own tests, so stubbing it here.)
+ expect_any_instance_of(Chef::ResourceReporter).to receive(:run_completed)
+ # --AuditReporter#run_completed
+ # posts the audit data to server.
+ # (has its own tests, so stubbing it here.)
+ expect_any_instance_of(Chef::Audit::AuditReporter).to receive(:run_completed)
end
before do
Chef::Config[:client_fork] = enable_fork
Chef::Config[:cache_path] = windows? ? 'C:\chef' : '/var/chef'
+ Chef::Config[:why_run] = false
+ Chef::Config[:audit_mode] = :enabled
stub_const("Chef::Client::STDOUT_FD", stdout)
stub_const("Chef::Client::STDERR_FD", stderr)
@@ -282,11 +299,16 @@ describe Chef::Client do
stub_for_node_load
stub_for_sync_cookbooks
stub_for_converge
+ stub_for_audit
stub_for_node_save
stub_for_run
end
+ end
- it "runs ohai, sets up authentication, loads node state, synchronizes policy, and converges" do
+ shared_examples_for "a successful client run" do
+ include_context "a client run"
+
+ it "runs ohai, sets up authentication, loads node state, synchronizes policy, converges, and runs audits" do
# This is what we're testing.
client.run
@@ -296,16 +318,12 @@ describe Chef::Client do
end
end
-
describe "when running chef-client without fork" do
-
include_examples "a successful client run"
end
describe "when the client key already exists" do
-
let(:api_client_exists?) { true }
-
include_examples "a successful client run"
end
@@ -344,7 +362,6 @@ describe Chef::Client do
end
describe "when a permanent run list is passed as an option" do
-
include_examples "a successful client run" do
let(:new_runlist) { "recipe[new_run_list_recipe]" }
@@ -374,6 +391,155 @@ describe Chef::Client do
end
end
+ describe "when converge fails" do
+ include_context "a client run" do
+ let(:e) { Exception.new }
+ def stub_for_converge
+ expect(Chef::Runner).to receive(:new).and_return(runner)
+ expect(runner).to receive(:converge).and_raise(e)
+ expect(Chef::Application).to receive(:debug_stacktrace).with an_instance_of(Chef::Exceptions::RunFailedWrappingError)
+ end
+
+ def stub_for_node_save
+ expect(client).to_not receive(:save_updated_node)
+ end
+
+ def stub_for_run
+ expect_any_instance_of(Chef::RunLock).to receive(:acquire)
+ expect_any_instance_of(Chef::RunLock).to receive(:save_pid)
+ expect_any_instance_of(Chef::RunLock).to receive(:release)
+
+ # Post conditions: check that node has been filled in correctly
+ expect(client).to receive(:run_started)
+ expect(client).to receive(:run_failed)
+
+ expect_any_instance_of(Chef::ResourceReporter).to receive(:run_failed)
+ expect_any_instance_of(Chef::Audit::AuditReporter).to receive(:run_failed)
+ end
+ end
+
+ it "runs the audits and raises the error" do
+ expect{ client.run }.to raise_error(Chef::Exceptions::RunFailedWrappingError) do |error|
+ expect(error.wrapped_errors.size).to eq(1)
+ expect(error.wrapped_errors[0]).to eq(e)
+ end
+ end
+ end
+
+ describe "when the audit phase fails" do
+ context "with an exception" do
+ include_context "a client run" do
+ let(:e) { Exception.new }
+ def stub_for_audit
+ expect(Chef::Audit::Runner).to receive(:new).and_return(audit_runner)
+ expect(audit_runner).to receive(:run).and_raise(e)
+ expect(Chef::Application).to receive(:debug_stacktrace).with an_instance_of(Chef::Exceptions::RunFailedWrappingError)
+ end
+
+ def stub_for_run
+ expect_any_instance_of(Chef::RunLock).to receive(:acquire)
+ expect_any_instance_of(Chef::RunLock).to receive(:save_pid)
+ expect_any_instance_of(Chef::RunLock).to receive(:release)
+
+ # Post conditions: check that node has been filled in correctly
+ expect(client).to receive(:run_started)
+ expect(client).to receive(:run_failed)
+
+ expect_any_instance_of(Chef::ResourceReporter).to receive(:run_failed)
+ expect_any_instance_of(Chef::Audit::AuditReporter).to receive(:run_failed)
+ end
+ end
+
+ it "should save the node after converge and raise exception" do
+ expect{ client.run }.to raise_error(Chef::Exceptions::RunFailedWrappingError) do |error|
+ expect(error.wrapped_errors.size).to eq(1)
+ expect(error.wrapped_errors[0]).to eq(e)
+ end
+ end
+ end
+
+ context "with failed audits" do
+ include_context "a client run" do
+ let(:audit_runner) do
+ instance_double("Chef::Audit::Runner", :run => true, :failed? => true, :num_failed => 1, :num_total => 1)
+ end
+
+ def stub_for_audit
+ expect(Chef::Audit::Runner).to receive(:new).and_return(audit_runner)
+ expect(Chef::Application).to receive(:debug_stacktrace).with an_instance_of(Chef::Exceptions::RunFailedWrappingError)
+ end
+
+ def stub_for_run
+ expect_any_instance_of(Chef::RunLock).to receive(:acquire)
+ expect_any_instance_of(Chef::RunLock).to receive(:save_pid)
+ expect_any_instance_of(Chef::RunLock).to receive(:release)
+
+ # Post conditions: check that node has been filled in correctly
+ expect(client).to receive(:run_started)
+ expect(client).to receive(:run_failed)
+
+ expect_any_instance_of(Chef::ResourceReporter).to receive(:run_failed)
+ expect_any_instance_of(Chef::Audit::AuditReporter).to receive(:run_failed)
+ end
+ end
+
+ it "should save the node after converge and raise exception" do
+ expect{ client.run }.to raise_error(Chef::Exceptions::RunFailedWrappingError) do |error|
+ expect(error.wrapped_errors.size).to eq(1)
+ expect(error.wrapped_errors[0]).to be_instance_of(Chef::Exceptions::AuditsFailed)
+ end
+ end
+ end
+ end
+
+ describe "when why_run mode is enabled" do
+ include_context "a client run" do
+
+ before do
+ Chef::Config[:why_run] = true
+ end
+
+ def stub_for_audit
+ expect(Chef::Audit::Runner).to_not receive(:new)
+ end
+
+ def stub_for_node_save
+ # This is how we should be mocking external calls - not letting it fall all the way through to the
+ # REST call
+ expect(node).to receive(:save)
+ end
+
+ it "runs successfully without enabling the audit runner" do
+ client.run
+
+ # fork is stubbed, so we can see the outcome of the run
+ expect(node.automatic_attrs[:platform]).to eq("example-platform")
+ expect(node.automatic_attrs[:platform_version]).to eq("example-platform-1.0")
+ end
+ end
+ end
+
+ describe "when audits are disabled" do
+ include_context "a client run" do
+
+ before do
+ Chef::Config[:audit_mode] = :disabled
+ end
+
+ def stub_for_audit
+ expect(Chef::Audit::Runner).to_not receive(:new)
+ end
+
+ it "runs successfully without enabling the audit runner" do
+ client.run
+
+ # fork is stubbed, so we can see the outcome of the run
+ expect(node.automatic_attrs[:platform]).to eq("example-platform")
+ expect(node.automatic_attrs[:platform_version]).to eq("example-platform-1.0")
+ end
+ end
+ end
+
end
diff --git a/spec/unit/dsl/audit_spec.rb b/spec/unit/dsl/audit_spec.rb
new file mode 100644
index 0000000000..38707127f0
--- /dev/null
+++ b/spec/unit/dsl/audit_spec.rb
@@ -0,0 +1,43 @@
+
+require 'spec_helper'
+require 'chef/dsl/audit'
+
+class AuditDSLTester < Chef::Recipe
+ include Chef::DSL::Audit
+end
+
+class BadAuditDSLTester
+ include Chef::DSL::Audit
+end
+
+describe Chef::DSL::Audit do
+ let(:auditor) { AuditDSLTester.new("cookbook_name", "recipe_name", run_context) }
+ let(:run_context) { instance_double(Chef::RunContext, :audits => audits, :cookbook_collection => cookbook_collection) }
+ let(:audits) { {} }
+ let(:cookbook_collection) { {} }
+
+ it "raises an error when a block of audits is not provided" do
+ expect{ auditor.controls "name" }.to raise_error(Chef::Exceptions::NoAuditsProvided)
+ end
+
+ it "raises an error when no audit name is given" do
+ expect{ auditor.controls do end }.to raise_error(Chef::Exceptions::AuditNameMissing)
+ end
+
+ context "audits already populated" do
+ let(:audits) { {"unique" => {} } }
+
+ it "raises an error if the audit name is a duplicate" do
+ expect { auditor.controls "unique" do end }.to raise_error(Chef::Exceptions::AuditControlGroupDuplicate)
+ end
+ end
+
+ context "included in a class without recipe DSL" do
+ let(:auditor) { BadAuditDSLTester.new }
+
+ it "fails because it relies on the recipe DSL existing" do
+ expect { auditor.controls "unique" do end }.to raise_error(NoMethodError, /undefined method `cookbook_name'/)
+ end
+ end
+
+end
diff --git a/spec/unit/exceptions_spec.rb b/spec/unit/exceptions_spec.rb
index 6318ec9227..d35ecc8ec8 100644
--- a/spec/unit/exceptions_spec.rb
+++ b/spec/unit/exceptions_spec.rb
@@ -81,4 +81,50 @@ describe Chef::Exceptions do
end
end
end
+
+ describe Chef::Exceptions::RunFailedWrappingError do
+ shared_examples "RunFailedWrappingError expectations" do
+ it "should initialize with a default message" do
+ expect(e.message).to eq("Found #{num_errors} errors, they are stored in the backtrace")
+ end
+
+ it "should provide a modified backtrace when requested" do
+ e.fill_backtrace
+ expect(e.backtrace).to eq(backtrace)
+ end
+ end
+
+ context "initialized with nothing" do
+ let(:e) { Chef::Exceptions::RunFailedWrappingError.new }
+ let(:num_errors) { 0 }
+ let(:backtrace) { [] }
+
+ include_examples "RunFailedWrappingError expectations"
+ end
+
+ context "initialized with nil" do
+ let(:e) { Chef::Exceptions::RunFailedWrappingError.new(nil, nil) }
+ let(:num_errors) { 0 }
+ let(:backtrace) { [] }
+
+ include_examples "RunFailedWrappingError expectations"
+ end
+
+ context "initialized with 1 error and nil" do
+ let(:e) { Chef::Exceptions::RunFailedWrappingError.new(RuntimeError.new("foo"), nil) }
+ let(:num_errors) { 1 }
+ let(:backtrace) { ["1) RuntimeError - foo", ""] }
+
+ include_examples "RunFailedWrappingError expectations"
+ end
+
+ context "initialized with 2 errors" do
+ let(:e) { Chef::Exceptions::RunFailedWrappingError.new(RuntimeError.new("foo"), RuntimeError.new("bar")) }
+ let(:num_errors) { 2 }
+ let(:backtrace) { ["1) RuntimeError - foo", "", "2) RuntimeError - bar", ""] }
+
+ include_examples "RunFailedWrappingError expectations"
+ end
+
+ end
end
diff --git a/spec/unit/recipe_spec.rb b/spec/unit/recipe_spec.rb
index e1a42362ef..e8c1358ba2 100644
--- a/spec/unit/recipe_spec.rb
+++ b/spec/unit/recipe_spec.rb
@@ -484,4 +484,11 @@ describe Chef::Recipe do
expect(node[:tags]).to eql([])
end
end
+
+ describe "included DSL" do
+ it "should include features from Chef::DSL::Audit" do
+ expect(recipe.singleton_class.included_modules).to include(Chef::DSL::Audit)
+ expect(recipe.respond_to?(:controls)).to be true
+ end
+ end
end