diff options
Diffstat (limited to 'lib/chef/audit')
-rw-r--r-- | lib/chef/audit/audit_event_proxy.rb | 93 | ||||
-rw-r--r-- | lib/chef/audit/audit_reporter.rb | 169 | ||||
-rw-r--r-- | lib/chef/audit/control_group_data.rb | 140 | ||||
-rw-r--r-- | lib/chef/audit/rspec_formatter.rb | 37 | ||||
-rw-r--r-- | lib/chef/audit/runner.rb | 178 |
5 files changed, 617 insertions, 0 deletions
diff --git a/lib/chef/audit/audit_event_proxy.rb b/lib/chef/audit/audit_event_proxy.rb new file mode 100644 index 0000000000..b9ca39e5dc --- /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 `control_group` block + desc = notification.group.description + Chef::Log.debug("Entered `control_group` block named #{desc}") + events.control_group_started(desc) + end + end + + def stop(notification) + Chef::Log.info("Successfully executed all `control_group` 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 `control_group` 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..e7d1657d69 --- /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_control_groups + 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 control groups registered to this run_context, builds an + # example group (RSpec::Core::ExampleGroup) object per control group, 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_control_groups + add_example_group_methods + run_context.audits.each do |name, group| + ctl_grp = RSpec::Core::ExampleGroup.__control_group__(*group.args, &group.block) + RSpec.world.register(ctl_grp) + end + end + + # Add example group method aliases to RSpec. + # + # __control_group__: Used internally to create example groups from the control + # groups saved in the run_context. + # control: Used within the context of a control group block, like RSpec's + # describe or context. + def add_example_group_methods + RSpec::Core::ExampleGroup.define_example_group_method :__control_group__ + 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 |