#
# Author:: Lamont Granquist (<lamont@chef.io>)
# 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