# # Author:: Adam Jacob () # Author:: Christopher Walters () # Author:: Tim Hinderliter () # Copyright:: Copyright 2008-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_relative "resource_collection" require_relative "cookbook_version" require_relative "node" require_relative "role" require_relative "log" require_relative "recipe" require_relative "run_context/cookbook_compiler" require_relative "event_dispatch/events_output_stream" require_relative "train_transport" require_relative "exceptions" require "forwardable" unless defined?(Forwardable) require "set" unless defined?(Set) class Chef # Value object that loads and tracks the context of a Chef run class RunContext extend Forwardable # # Global state # # Common rest object for using to talk to the Chef Server, this strictly 'validates' utf8 # and will throw. (will be nil on solo-legacy runs) # # @return [Chef::ServerAPI] # attr_accessor :rest # Common rest object for using to talk to the Chef Server, this has utf8 sanitization turned # on and will replace invalid utf8 with valid characters. (will be nil on solo-legacy runs) # # @return [Chef::ServerAPI] # attr_accessor :rest_clean # # The node for this run # # @return [Chef::Node] # attr_reader :node # # The set of cookbooks involved in this run # # @return [Chef::CookbookCollection] # attr_reader :cookbook_collection # # Resource Definitions for this run. Populated when the files in # +definitions/+ are evaluated (this is triggered by #load). # # @return [Array[Chef::ResourceDefinition]] # attr_reader :definitions # Event dispatcher for this run. # # @return [Chef::EventDispatch::Dispatcher] # attr_accessor :events # Hash of factoids for a reboot request. # # @return [Hash] # attr_accessor :reboot_info # # Scoped state # # The parent run context. # # @return [Chef::RunContext] The parent run context, or `nil` if this is the # root context. # attr_reader :parent_run_context # The root run context. # # @return [Chef::RunContext] The root run context. # def root_run_context rc = self rc = rc.parent_run_context until rc.parent_run_context.nil? rc end # The collection of resources intended to be converged (and able to be # notified). # # @return [Chef::ResourceCollection] # # @see CookbookCompiler # attr_reader :resource_collection # Handle to the global action_collection of executed actions for reporting / data_collector /etc # # @return [Chef::ActionCollection # attr_accessor :action_collection # Pointer back to the Chef::Runner that created this # attr_accessor :runner # # Notification handling # # A Hash containing the before notifications triggered by resources # during the converge phase of the chef run. # # @return [Hash[String, Array[Chef::Resource::Notification]]] A hash from # => # attr_reader :before_notification_collection # A Hash containing the immediate notifications triggered by resources # during the converge phase of the chef run. # # @return [Hash[String, Array[Chef::Resource::Notification]]] A hash from # => # attr_reader :immediate_notification_collection # A Hash containing the delayed (end of run) notifications triggered by # resources during the converge phase of the chef run. # # @return [Hash[String, Array[Chef::Resource::Notification]]] A hash from # => # attr_reader :delayed_notification_collection # An Array containing the delayed (end of run) notifications triggered by # resources during the converge phase of the chef run. # # @return [Array[Chef::Resource::Notification]] An array of notification objects # attr_reader :delayed_actions # A Set keyed by the string name, of all the resources that are updated. We do not # track actions or individual resource objects, since this matches the behavior of # the notification collections which are keyed by Strings. # attr_reader :updated_resources # @return [Boolean] If the resource_collection is in unified_mode (no separate converge phase) # def_delegator :resource_collection, :unified_mode # A child of the root Chef::Log logging object. # # @return Mixlib::Log::Child A child logger # attr_reader :logger # Creates a new Chef::RunContext object and populates its fields. This object gets # used by the Chef Server to generate a fully compiled recipe list for a node. # # @param node [Chef::Node] The node to run against. # @param cookbook_collection [Chef::CookbookCollection] The cookbooks # involved in this run. # @param events [EventDispatch::Dispatcher] The event dispatcher for this # run. # def initialize(node = nil, cookbook_collection = nil, events = nil, logger = nil) @events = events @logger = logger || Chef::Log.with_child self.node = node if node self.cookbook_collection = cookbook_collection if cookbook_collection @definitions = {} @loaded_recipes_hash = {} @loaded_attributes_hash = {} @reboot_info = {} @cookbook_compiler = nil initialize_child_state end def node=(node) @node = node node.run_context = self end def cookbook_collection=(cookbook_collection) @cookbook_collection = cookbook_collection node.set_cookbook_attribute end # # Triggers the compile phase of the chef run. # # @param run_list_expansion [Chef::RunList::RunListExpansion] The run list. # @see Chef::RunContext::CookbookCompiler # def load(run_list_expansion) @cookbook_compiler = CookbookCompiler.new(self, run_list_expansion, events) cookbook_compiler.compile end # # Initialize state that applies to both Chef::RunContext and Chef::ChildRunContext # def initialize_child_state @resource_collection = Chef::ResourceCollection.new(self) @before_notification_collection = Hash.new { |h, k| h[k] = [] } @immediate_notification_collection = Hash.new { |h, k| h[k] = [] } @delayed_notification_collection = Hash.new { |h, k| h[k] = [] } @delayed_actions = [] @updated_resources = Set.new end # # Adds an before notification to the +before_notification_collection+. # # @param [Chef::Resource::Notification] notification The notification to add. # def notifies_before(notification) # Note for the future, notification.notifying_resource may be an instance # of Chef::Resource::UnresolvedSubscribes when calling {Resource#subscribes} # with a string value. if unified_mode && updated_resources.include?(notification.notifying_resource.declared_key) raise Chef::Exceptions::UnifiedModeBeforeSubscriptionEarlierResource.new(notification) end before_notification_collection[notification.notifying_resource.declared_key] << notification end # # Adds an immediate notification to the +immediate_notification_collection+. # # @param [Chef::Resource::Notification] notification The notification to add. # def notifies_immediately(notification) # Note for the future, notification.notifying_resource may be an instance # of Chef::Resource::UnresolvedSubscribes when calling {Resource#subscribes} # with a string value. immediate_notification_collection[notification.notifying_resource.declared_key] << notification end # # Adds a delayed notification to the +delayed_notification_collection+. # # @param [Chef::Resource::Notification] notification The notification to add. # def notifies_delayed(notification) # Note for the future, notification.notifying_resource may be an instance # of Chef::Resource::UnresolvedSubscribes when calling {Resource#subscribes} # with a string value. if unified_mode && updated_resources.include?(notification.notifying_resource.declared_key) add_delayed_action(notification) end delayed_notification_collection[notification.notifying_resource.declared_key] << notification end # Adds a delayed action to the delayed_actions collection # def add_delayed_action(notification) if delayed_actions.any? { |existing_notification| existing_notification.duplicates?(notification) } logger.info( "#{notification.notifying_resource} not queuing delayed action #{notification.action} on #{notification.resource}"\ " (delayed), as it's already been queued") else delayed_actions << notification end end # Get the list of before notifications sent by the given resource. # # @return [Array[Notification]] # def before_notifications(resource) key = resource.is_a?(String) ? resource : resource.declared_key before_notification_collection[key] end # Get the list of immediate notifications sent by the given resource. # # @return [Array[Notification]] # def immediate_notifications(resource) key = resource.is_a?(String) ? resource : resource.declared_key immediate_notification_collection[key] end # Get the list of immediate notifications pending to the given resource # # @return [Array[Notification]] # def reverse_immediate_notifications(resource) immediate_notification_collection.map do |k, v| v.select do |n| (n.resource.is_a?(String) && n.resource == resource.declared_key) || n.resource == resource end end.flatten end # Get the list of delayed (end of run) notifications sent by the given # resource. # # @return [Array[Notification]] # def delayed_notifications(resource) key = resource.is_a?(String) ? resource : resource.declared_key delayed_notification_collection[key] end # # Cookbook and recipe loading # # # Evaluates the recipes +recipe_names+. Used by DSL::IncludeRecipe # # @param recipe_names [Array[String]] The list of recipe names (e.g. # 'my_cookbook' or 'my_cookbook::my_resource'). # @param current_cookbook The cookbook we are currently running in. # # @see DSL::IncludeRecipe#include_recipe # def include_recipe(*recipe_names, current_cookbook: nil) result_recipes = [] recipe_names.flatten.each do |recipe_name| if result = load_recipe(recipe_name, current_cookbook: current_cookbook) result_recipes << result end end result_recipes end # # Evaluates the recipe +recipe_name+. Used by DSL::IncludeRecipe # # TODO I am sort of confused why we have both this and include_recipe ... # I don't see anything different beyond accepting and returning an # array of recipes. # # @param recipe_name [Array[String]] The recipe name (e.g 'my_cookbook' or # 'my_cookbook::my_resource'). # @param current_cookbook [String] The cookbook we are currently running in. # # @return A truthy value if the load occurred; `false` if already loaded. # # @see DSL::IncludeRecipe#load_recipe # def load_recipe(recipe_name, current_cookbook: nil) logger.trace("Loading recipe #{recipe_name} via include_recipe") cookbook_name, recipe_short_name = Chef::Recipe.parse_recipe_name(recipe_name, current_cookbook: current_cookbook) if unreachable_cookbook?(cookbook_name) # CHEF-4367 logger.warn(<<~ERROR_MESSAGE) MissingCookbookDependency: Recipe `#{recipe_name}` is not in the run_list, and cookbook '#{cookbook_name}' is not a dependency of any cookbook in the run_list. To load this recipe, first add a dependency on cookbook '#{cookbook_name}' in the cookbook you're including it from in that cookbook's metadata. ERROR_MESSAGE end if loaded_fully_qualified_recipe?(cookbook_name, recipe_short_name) logger.trace("I am not loading #{recipe_name}, because I have already seen it.") false else loaded_recipe(cookbook_name, recipe_short_name) node.loaded_recipe(cookbook_name, recipe_short_name) cookbook = cookbook_collection[cookbook_name] cookbook.load_recipe(recipe_short_name, self) end end # # Load the given recipe from a filename. # # @param recipe_file [String] The recipe filename. # # @return [Chef::Recipe] The loaded recipe. # # @raise [Chef::Exceptions::RecipeNotFound] If the file does not exist. # def load_recipe_file(recipe_file) unless File.exist?(recipe_file) raise Chef::Exceptions::RecipeNotFound, "could not find recipe file #{recipe_file}" end logger.trace("Loading recipe file #{recipe_file}") recipe = Chef::Recipe.new("@recipe_files", recipe_file, self) recipe.from_file(recipe_file) recipe end # # Look up an attribute filename. # # @param cookbook_name [String] The cookbook name of the attribute file. # @param attr_file_name [String] The attribute file's name (not path). # # @return [String] The filename. # # @see DSL::IncludeAttribute#include_attribute # # @raise [Chef::Exceptions::CookbookNotFound] If the cookbook could not be found. # @raise [Chef::Exceptions::AttributeNotFound] If the attribute file could not be found. # def resolve_attribute(cookbook_name, attr_file_name) cookbook = cookbook_collection[cookbook_name] raise Chef::Exceptions::CookbookNotFound, "could not find cookbook #{cookbook_name} while loading attribute #{name}" unless cookbook attribute_filename = cookbook.attribute_filenames_by_short_filename[attr_file_name] raise Chef::Exceptions::AttributeNotFound, "could not find filename for attribute #{attr_file_name} in cookbook #{cookbook_name}" unless attribute_filename attribute_filename end # # A list of all recipes that have been loaded. # # This is stored internally as a Hash, so ordering is predictable. # # TODO is the above statement true in a 1.9+ ruby world? Is it relevant? # # @return [Array[String]] A list of recipes in fully qualified form, e.g. # the recipe "nginx" will be given as "nginx::default". # # @see #loaded_recipe? To determine if a particular recipe has been loaded. # def loaded_recipes loaded_recipes_hash.keys end # # A list of all attributes files that have been loaded. # # Stored internally using a Hash, so order is predictable. # # TODO is the above statement true in a 1.9+ ruby world? Is it relevant? # # @return [Array[String]] A list of attribute file names in fully qualified # form, e.g. the "nginx" will be given as "nginx::default". # def loaded_attributes loaded_attributes_hash.keys end # # Find out if a given recipe has been loaded. # # @param cookbook [String] Cookbook name. # @param recipe [String] Recipe name. # # @return [Boolean] `true` if the recipe has been loaded, `false` otherwise. # def loaded_fully_qualified_recipe?(cookbook, recipe) loaded_recipes_hash.key?("#{cookbook}::#{recipe}") end # # Find out if a given recipe has been loaded. # # @param recipe [String] Recipe name. "nginx" and "nginx::default" yield # the same results. # # @return [Boolean] `true` if the recipe has been loaded, `false` otherwise. # def loaded_recipe?(recipe) cookbook, recipe_name = Chef::Recipe.parse_recipe_name(recipe) loaded_fully_qualified_recipe?(cookbook, recipe_name) end # # Mark a given recipe as having been loaded. # # @param cookbook [String] Cookbook name. # @param recipe [String] Recipe name. # def loaded_recipe(cookbook, recipe) loaded_recipes_hash["#{cookbook}::#{recipe}"] = true end # # Find out if a given attribute file has been loaded. # # @param cookbook [String] Cookbook name. # @param attribute_file [String] Attribute file name. # # @return [Boolean] `true` if the recipe has been loaded, `false` otherwise. # def loaded_fully_qualified_attribute?(cookbook, attribute_file) loaded_attributes_hash.key?("#{cookbook}::#{attribute_file}") end # # Mark a given attribute file as having been loaded. # # @param cookbook [String] Cookbook name. # @param attribute_file [String] Attribute file name. # def loaded_attribute(cookbook, attribute_file) loaded_attributes_hash["#{cookbook}::#{attribute_file}"] = true end ## # Cookbook File Introspection # # Find out if the cookbook has the given template. # # @param cookbook [String] Cookbook name. # @param template_name [String] Template name. # # @return [Boolean] `true` if the template is in the cookbook, `false` # otherwise. # @see Chef::CookbookVersion#has_template_for_node? # def has_template_in_cookbook?(cookbook, template_name) cookbook = cookbook_collection[cookbook] cookbook.has_template_for_node?(node, template_name) end # # Find out if the cookbook has the given file. # # @param cookbook [String] Cookbook name. # @param cb_file_name [String] File name. # # @return [Boolean] `true` if the file is in the cookbook, `false` # otherwise. # @see Chef::CookbookVersion#has_cookbook_file_for_node? # def has_cookbook_file_in_cookbook?(cookbook, cb_file_name) cookbook = cookbook_collection[cookbook] cookbook.has_cookbook_file_for_node?(node, cb_file_name) end # # Find out whether the given cookbook is in the cookbook dependency graph. # # @param cookbook_name [String] Cookbook name. # # @return [Boolean] `true` if the cookbook is reachable, `false` otherwise. # # @see Chef::CookbookCompiler#unreachable_cookbook? def unreachable_cookbook?(cookbook_name) cookbook_compiler.unreachable_cookbook?(cookbook_name) end # # Open a stream object that can be printed into and will dispatch to events # # @param name [String] The name of the stream. # @param options [Hash] Other options for the stream. # # @return [EventDispatch::EventsOutputStream] The created stream. # # @yield If a block is passed, it will be run and the stream will be closed # afterwards. # @yieldparam stream [EventDispatch::EventsOutputStream] The created stream. # def open_stream(name: nil, **options) stream = EventDispatch::EventsOutputStream.new(events, name: name, **options) if block_given? begin yield stream ensure stream.close end else stream end end # there are options for how to handle multiple calls to these functions: # 1. first call always wins (never change reboot_info once set). # 2. last call always wins (happily change reboot_info whenever). # 3. raise an exception on the first conflict. # 4. disable reboot after this run if anyone ever calls :cancel. # 5. raise an exception on any second call. # 6. ? def request_reboot(reboot_info) logger.info "Changing reboot status from #{self.reboot_info.inspect} to #{reboot_info.inspect}" @reboot_info = reboot_info end # # Cancels a pending reboot # def cancel_reboot logger.info "Changing reboot status from #{reboot_info.inspect} to {}" @reboot_info = {} end # # Checks to see if a reboot has been requested # @return [Boolean] # def reboot_requested? reboot_info.size > 0 end # Remote transport from Train # # @return [Train::Plugins::Transport] The child class for our train transport. # def transport @transport ||= Chef::TrainTransport.build_transport(logger) end # Remote connection object from Train # # @return [Train::Plugins::Transport::BaseConnection] # def transport_connection @transport_connection ||= transport&.connection end # # Create a child RunContext. # def create_child ChildRunContext.new(self) end # @api private attr_writer :resource_collection protected attr_reader :cookbook_compiler attr_reader :loaded_attributes_hash attr_reader :loaded_recipes_hash # # A child run context. Delegates all root context calls to its parent. # # @api private # class ChildRunContext < RunContext extend Forwardable def_delegators :parent_run_context, *%w{ action_collection action_collection= cancel_reboot config cookbook_collection cookbook_collection= cookbook_compiler definitions events events= has_cookbook_file_in_cookbook? has_template_in_cookbook? load loaded_attribute loaded_attributes loaded_attributes_hash loaded_fully_qualified_attribute? loaded_fully_qualified_recipe? loaded_recipe loaded_recipe? loaded_recipes loaded_recipes_hash logger node node= open_stream reboot_info reboot_info= reboot_requested? request_reboot resolve_attribute rest rest= rest_clean rest_clean= transport transport_connection unreachable_cookbook? } def initialize(parent_run_context) @parent_run_context = parent_run_context # We don't call super, because we don't bother initializing stuff we're # going to delegate to the parent anyway. Just initialize things that # every instance needs. initialize_child_state end CHILD_STATE = %w{ add_delayed_action before_notification_collection before_notifications create_child delayed_actions delayed_notification_collection delayed_notification_collection= delayed_notifications immediate_notification_collection immediate_notification_collection= immediate_notifications include_recipe initialize_child_state load_recipe load_recipe_file notifies_before notifies_delayed notifies_immediately parent_run_context resource_collection resource_collection= reverse_immediate_notifications root_run_context runner runner= unified_mode updated_resources }.map(&:to_sym) # Verify that we didn't miss any methods unless @__skip_method_checking # hook specifically for compat_resource missing_methods = superclass.instance_methods(false) - instance_methods(false) - CHILD_STATE unless missing_methods.empty? raise "ERROR: not all methods of RunContext accounted for in ChildRunContext! All methods must be marked as child methods with CHILD_STATE or delegated to the parent_run_context. Missing #{missing_methods.join(", ")}." end end end end end