# # Copyright:: Copyright 2018-2019, 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" class Chef class ActionCollection < EventDispatch::Base include Enumerable extend Forwardable class ActionRecord # XXX: this is buggy since we (ab)use this resource for "after" state and it may be # inaccurate and it may be mutated by the user. A third after_resource should be added # to new_resource + current_resource to properly implement this. # # @return [Chef::Resource] The declared resource state. # attr_accessor :new_resource # @return [Chef::Resource] The current_resource object (before-state). This can be nil # for non-why-run-safe resources in why-run mode, or if load_current_resource itself # threw an exception (which should be considered a bug in that load_current_resource # implementation, but must be handled), or for unprocessed resources. attr_accessor :current_resource # @return [Symbol] # The action that was run (or scheduled to run in the case of "unprocessed" resources). attr_accessor :action # @return [Exception] The exception that was thrown attr_accessor :exception # @return [Numeric] The elapsed time in seconds with machine precision attr_accessor :elapsed_time # @return [Chef::Resource::Conditional] The conditional that caused the resource to be skipped attr_accessor :conditional # The status of the resource: # - updated: ran and converged # - up_to_date: skipped due to idempotency # - skipped: skipped due to a conditional # - failed: failed with an exception # - unprocessed: resources that were not touched by a run that failed # # @return [Symbol] status # attr_accessor :status # The "nesting" level. Outer resources in recipe context are 0 here, while for every # sub-resource_collection inside of a custom resource this number is incremented by 1. # Resources that are fired via build-resource or manually creating and firing # # @return [Integer] # attr_accessor :nesting_level def initialize(new_resource, action, nesting_level) @new_resource = new_resource @action = action @nesting_level = nesting_level end # @return [Boolean] true if there was no exception def success? !exception end end attr_reader :action_records attr_reader :pending_updates attr_reader :run_context attr_reader :consumers attr_reader :events def initialize(events, run_context = nil, action_records = []) @action_records = action_records @pending_updates = [] @consumers = [] @events = events @run_context = run_context end def_delegators :@action_records, :each, :last # Allows getting at the action_records collection filtered by nesting level and status. # # TODO: filtering by resource type+name # # @return [Chef::ActionCollection] # def filtered_collection(max_nesting: nil, up_to_date: true, skipped: true, updated: true, failed: true, unprocessed: true) subrecords = action_records.select do |rec| ( max_nesting.nil? || rec.nesting_level <= max_nesting ) && ( rec.status == :up_to_date && up_to_date || rec.status == :skipped && skipped || rec.status == :updated && updated || rec.status == :failed && failed || rec.status == :unprocessed && unprocessed ) end self.class.new(events, run_context, subrecords) end # This hook gives us the run_context immediately after it is created so that we can wire up this object to it. # # This also causes the action_collection_registration event to fire, all consumers that have not yet registered with the # action_collection must register via this callback. This is the latest point before resources actually start to get # evaluated. # # (see EventDispatch::Base#) # def cookbook_compilation_start(run_context) run_context.action_collection = self # fire the action_colleciton_registration hook after cookbook_compilation_start -- last chance for consumers to register run_context.events.enqueue(:action_collection_registration, self) @run_context = run_context end # Consumers must call register -- either directly or through the action_collection_registration hook. If # nobody has registered any interest, then no action tracking will be done. # # @params object [Object] callers should call with `self` # def register(object) consumers << object end # End of an unsuccessful converge used to fire off detect_unprocessed_resources. # # (see EventDispatch::Base#) # def converge_failed(exception) return if consumers.empty? detect_unprocessed_resources end # Hook to start processing a resource. May be called within processing of an outer resource # so the pending_updates array forms a stack that sub-resources are popped onto and off of. # This is always called. # # (see EventDispatch::Base#) # def resource_action_start(new_resource, action, notification_type = nil, notifier = nil) return if consumers.empty? pending_updates << ActionRecord.new(new_resource, action, pending_updates.length) end # Hook called after a resource is loaded. If load_current_resource fails, this hook will # not be called and current_resource will be nil, and the resource_failed hook will be called. # # (see EventDispatch::Base#) # def resource_current_state_loaded(new_resource, action, current_resource) return if consumers.empty? current_record.current_resource = current_resource end # Hook called after an action is determined to be up to date. # # (see EventDispatch::Base#) # def resource_up_to_date(new_resource, action) return if consumers.empty? current_record.status = :up_to_date end # Hook called after an action is determined to be skipped due to a conditional. # # (see EventDispatch::Base#) # def resource_skipped(resource, action, conditional) return if consumers.empty? current_record.status = :skipped current_record.conditional = conditional end # Hook called after an action modifies the system and is marked updated. # # (see EventDispatch::Base#) # def resource_updated(new_resource, action) return if consumers.empty? current_record.status = :updated end # Hook called after an action fails. # # (see EventDispatch::Base#) # def resource_failed(new_resource, action, exception) return if consumers.empty? current_record.status = :failed current_record.exception = exception end # Hook called after an action is completed. This is always called, even if the action fails. # # (see EventDispatch::Base#) # def resource_completed(new_resource) return if consumers.empty? current_record.elapsed_time = new_resource.elapsed_time # Verify if the resource has sensitive data and create a new blank resource with only # the name so we can report it back without sensitive data # XXX?: what about sensitive data in the current_resource? # FIXME: this needs to be display-logic if current_record.new_resource.sensitive klass = current_record.new_resource.class resource_name = current_record.new_resource.name current_record.new_resource = klass.new(resource_name) end action_records << pending_updates.pop end private # @return [Chef::ActionCollection::ActionRecord] the current record we are working on at the top of the stack def current_record pending_updates[-1] end # If the chef-client run fails in the middle, we are left with a half-completed resource_collection, this # method is responsible for adding all of the resources which have not yet been touched. They are marked # as being "unprocessed". # def detect_unprocessed_resources run_context.resource_collection.all_resources.select { |resource| resource.executed_by_runner == false }.each do |resource| Array(resource.action).each do |action| record = ActionRecord.new(resource, action, 0) record.status = :unprocessed action_records << record end end end end end