# # Author:: Lamont Granquist () # Copyright:: Copyright (c) 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. # # # example of a NodeMap entry for the user resource (as typed on the DSL): # # :user=> # [{:klass=>Chef::Resource::User::AixUser, :os=>"aix"}, # {:klass=>Chef::Resource::User::DsclUser, :os=>"darwin"}, # {:klass=>Chef::Resource::User::PwUser, :os=>"freebsd"}, # {:klass=>Chef::Resource::User::LinuxUser, :os=>"linux"}, # {:klass=>Chef::Resource::User::SolarisUser, # :os=>["omnios", "solaris2"]}, # {:klass=>Chef::Resource::User::WindowsUser, :os=>"windows"}], # # the entries in the array are pre-sorted into priority order (blocks/platform_version/platform/platform_family/os/none) so that # the first entry's :klass that matches the filter is returned when doing a get. # # note that as this examples show filter values may be a scalar string or an array of scalar strings. # # XXX: confusingly, in the *_priority_map the :klass may be an array of Strings of class names # require "chef-utils/dist" unless defined?(ChefUtils::Dist) class Chef class NodeMap COLLISION_WARNING = <<~EOH.gsub(/\s+/, " ").strip %{type_caps} %{key} built into %{client_name} is being overridden by the %{type} from a cookbook. Please upgrade your cookbook or remove the cookbook from your run_list. EOH # # Set a key/value pair on the map with a filter. The filter must be true # when applied to the node in order to retrieve the value. # # @param key [Object] Key to store # @param value [Object] Value associated with the key # @param filters [Hash] Node filter options to apply to key retrieval # @param chef_version [String] version constraint to match against the running Chef::VERSION # # @yield [node] Arbitrary node filter as a block which takes a node argument # # @return [NodeMap] Returns self for possible chaining # def set(key, klass, platform: nil, platform_version: nil, platform_family: nil, os: nil, override: nil, chef_version: nil, target_mode: nil, &block) new_matcher = { klass: klass } new_matcher[:platform] = platform if platform new_matcher[:platform_version] = platform_version if platform_version new_matcher[:platform_family] = platform_family if platform_family new_matcher[:os] = os if os new_matcher[:block] = block if block new_matcher[:override] = override if override new_matcher[:target_mode] = target_mode if chef_version && Chef::VERSION !~ chef_version return map end # Check if the key is already present and locked, unless the override is allowed. # The checks to see if we should reject, in order: # 1. Core override mode is not set. # 2. The key exists. # 3. At least one previous `provides` is now locked. if map[key] && map[key].any? { |matcher| matcher[:locked] } && !map[key].any? { |matcher| matcher[:cookbook_override].is_a?(String) ? Chef::VERSION =~ matcher[:cookbook_override] : matcher[:cookbook_override] } # If we ever use locked mode on things other than the resource and provider handler maps, this probably needs a tweak. type_of_thing = if klass < Chef::Resource "resource" elsif klass < Chef::Provider "provider" else klass.superclass.to_s end Chef::Log.warn( COLLISION_WARNING % { type: type_of_thing, key: key, type_caps: type_of_thing.capitalize, client_name: ChefUtils::Dist::Infra::PRODUCT } ) end # The map is sorted in order of preference already; we just need to find # our place in it (just before the first value with the same preference level). insert_at = nil map[key] ||= [] map[key].each_with_index do |matcher, index| cmp = compare_matchers(key, new_matcher, matcher) if cmp && cmp <= 0 insert_at = index break end end if insert_at map[key].insert(insert_at, new_matcher) else map[key] << new_matcher end map end # # Get a value from the NodeMap via applying the node to the filters that # were set on the key. # # @param node [Chef::Node] The Chef::Node object for the run, or `nil` to # ignore all filters. # @param key [Object] Key to look up # # @return [Object] Class # def get(node, key) return nil unless map.key?(key) map[key].map do |matcher| return matcher[:klass] if node_matches?(node, matcher) end nil end # # List all matches for the given node and key from the NodeMap, from # most-recently added to oldest. # # @param node [Chef::Node] The Chef::Node object for the run, or `nil` to # ignore all filters. # @param key [Object] Key to look up # # @return [Object] Class # def list(node, key) return [] unless map.key?(key) map[key].select do |matcher| node_matches?(node, matcher) end.map { |matcher| matcher[:klass] } end # Remove a class from all its matchers in the node_map, will remove mappings completely if its the last matcher left # # Note that this leaks the internal structure out a bit, but the main consumer of this (poise/halite) cares only about # the keys in the returned Hash. # # @param klass [Class] the class to seek and destroy # # @return [Hash] deleted entries in the same format as the @map def delete_class(klass) raise "please use a Class type for the klass argument" unless klass.is_a?(Class) deleted = {} map.each do |key, matchers| deleted_matchers = [] matchers.delete_if do |matcher| # because matcher[:klass] may be a string (which needs to die), coerce both to strings to compare somewhat canonically if matcher[:klass].to_s == klass.to_s deleted_matchers << matcher true end end deleted[key] = deleted_matchers unless deleted_matchers.empty? map.delete(key) if matchers.empty? end deleted end # Check if this map has been locked. # # @api internal # @since 14.2 # @return [Boolean] def locked? if defined?(@locked) @locked else false end end # Set this map to locked mode. This is used to prevent future overwriting # of existing names. # # @api internal # @since 14.2 # @return [void] def lock! map.each do |key, matchers| matchers.each do |matcher| matcher[:locked] = true end end @locked = true end private def platform_family_query_helper?(node, m) method = "#{m}?".to_sym ChefUtils::DSL::PlatformFamily.respond_to?(method) && ChefUtils::DSL::PlatformFamily.send(method, node) end # # Succeeds if: # - no negative matches (!value) # - at least one positive match (value or :all), or no positive filters # def matches_block_allow_list?(node, filters, attribute) # It's super common for the filter to be nil. Catch that so we don't # spend any time here. return true unless filters[attribute] filter_values = Array(filters[attribute]) value = node[attribute] # Split the blocklist and allowlist blocklist, allowlist = filter_values.partition { |v| v.is_a?(String) && v.start_with?("!") } if attribute == :platform_family # If any blocklist value matches, we don't match return false if blocklist.any? { |v| v[1..] == value || platform_family_query_helper?(node, v[1..]) } # If the allowlist is empty, or anything matches, we match. allowlist.empty? || allowlist.any? { |v| v == :all || v == value || platform_family_query_helper?(node, v) } else # If any blocklist value matches, we don't match return false if blocklist.any? { |v| v[1..] == value } # If the allowlist is empty, or anything matches, we match. allowlist.empty? || allowlist.any? { |v| v == :all || v == value } end end def matches_version_list?(node, filters, attribute) # It's super common for the filter to be nil. Catch that so we don't # spend any time here. return true unless filters[attribute] filter_values = Array(filters[attribute]) value = node[attribute] filter_values.empty? || Array(filter_values).any? do |v| Gem::Requirement.new(v).satisfied_by?(Gem::Version.new(value)) end end # Succeeds if: # - we are in target mode, and the target_mode filter is true # - we are not in target mode # def matches_target_mode?(filters) return true unless Chef::Config.target_mode? !!filters[:target_mode] end def filters_match?(node, filters) matches_block_allow_list?(node, filters, :os) && matches_block_allow_list?(node, filters, :platform_family) && matches_block_allow_list?(node, filters, :platform) && matches_version_list?(node, filters, :platform_version) && matches_target_mode?(filters) end def block_matches?(node, block) return true if block.nil? block.call node end def node_matches?(node, matcher) return true unless node filters_match?(node, matcher) && block_matches?(node, matcher[:block]) end # # "provides" lines with identical filters sort by class name (ascending). # def compare_matchers(key, new_matcher, matcher) cmp = compare_matcher_properties(new_matcher[:block], matcher[:block]) return cmp if cmp != 0 cmp = compare_matcher_properties(new_matcher[:platform_version], matcher[:platform_version]) return cmp if cmp != 0 cmp = compare_matcher_properties(new_matcher[:platform], matcher[:platform]) return cmp if cmp != 0 cmp = compare_matcher_properties(new_matcher[:platform_family], matcher[:platform_family]) return cmp if cmp != 0 cmp = compare_matcher_properties(new_matcher[:os], matcher[:os]) return cmp if cmp != 0 cmp = compare_matcher_properties(new_matcher[:override], matcher[:override]) return cmp if cmp != 0 # If all things are identical, return 0 0 end def compare_matcher_properties(a, b) # falsity comparisons here handle both "nil" and "false" return 1 if !a && b return -1 if !b && a return 0 if !a && !b # Check for blocklists ('!windows'). Those always come *after* positive # allowlists. a_negated = Array(a).any? { |f| f.is_a?(String) && f.start_with?("!") } b_negated = Array(b).any? { |f| f.is_a?(String) && f.start_with?("!") } return 1 if a_negated && !b_negated return -1 if b_negated && !a_negated a <=> b end def map @map ||= {} end end end