cookbooks/fb_helpers/libraries/node_methods.rb (731 lines of code) (raw):

# vim: syntax=ruby:expandtab:shiftwidth=2:softtabstop=2:tabstop=2 # # Copyright (c) 2016-present, Facebook, Inc. # All rights reserved. # # 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 # Our extensions of the node object class Node def centos? self['platform'] == 'centos' end def centos9? self.centos? && self['platform_version'].start_with?('9') end def centos8? self.centos? && self['platform_version'].start_with?('8') end def centos7? self.centos? && self['platform_version'].start_with?('7') end def centos6? self.centos? && self['platform_version'].start_with?('6') end def centos5? self.centos? && self['platform_version'].start_with?('5') end def major_platform_version self['platform_version'].split('.')[0] end def fedora? self['platform'] == 'fedora' end def fedora27? self.fedora? && self['platform_version'] == '27' end def fedora28? self.fedora? && self['platform_version'] == '28' end def fedora29? self.fedora? && self['platform_version'] == '29' end def fedora30? self.fedora? && self['platform_version'] == '30' end def fedora31? self.fedora? && self['platform_version'] == '31' end def fedora32? self.fedora? && self['platform_version'] == '32' end def fedora33? self.fedora? && self['platform_version'] == '33' end def fedora34? self.fedora? && self['platform_version'] == '34' end def fedora35? self.fedora? && self['platform_version'] == '35' end def redhat? self['platform'] == 'redhat' end def redhat6? self.redhat? && self['platform_version'].start_with?('6') end def redhat7? self.redhat? && self['platform_version'].start_with?('7') end def redhat8? self.redhat? && self['platform_version'].start_with?('8') end def redhat9? self.redhat? && self['platform_version'].start_with?('9') end def rhel? self['platform_family'] == 'rhel' end def rhel7? self.rhel? && self['platform_version'].start_with?('7') end def rhel8? self.rhel? && self['platform_version'].start_with?('8') end def rhel9? self.rhel? && self['platform_version'].start_with?('9') end def oracle? self['platform'] == 'oracle' end def oracle8? self.oracle? && self['platform_version'].start_with?('8') end def oracle7? self.oracle? && self['platform_version'].start_with?('7') end def oracle6? self.oracle? && self['platform_version'].start_with?('6') end def oracle5? self.oracle? && self['platform_version'].start_with?('5') end def debian? self['platform'] == 'debian' end def debian_sid? debian? && self['platform_version'].include?('sid') end def ubuntu? self['platform'] == 'ubuntu' end def ubuntu12? ubuntu? && self['platform_version'].start_with?('12') end def ubuntu14? ubuntu? && self['platform_version'].start_with?('14.') end def ubuntu15? ubuntu? && self['platform_version'].start_with?('15.') end def ubuntu16? ubuntu? && self['platform_version'].start_with?('16.') end def ubuntu1610? ubuntu? && self['platform_version'] == '16.10' end def ubuntu17? ubuntu? && self['platform_version'].start_with?('17') end def ubuntu1704? ubuntu? && self['platform_version'] == '17.04' end def ubuntu18? ubuntu? && self['platform_version'].start_with?('18.') end def ubuntu1804? ubuntu? && self['platform_version'] == '18.04' end def ubuntu20? ubuntu? && self['platform_version'].start_with?('20.') end def linuxmint? self['platform'] == 'linuxmint' end def linux? self['os'] == 'linux' end def arch? self['platform'] == 'arch' end def debian_family? self['platform_family'] == 'debian' end def arch_family? self['platform_family'] == 'arch' end def fedora_family? self['platform_family'] == 'fedora' end def macos? # rubocop:disable Chef/Correctness/InvalidPlatformValueForPlatformHelper # # Almost certainly `macos` was never a platform from Ohai, but it seems # to be showing up in FB datasets, so for now, we'll just keep both, # but it's likely a handler doing some munging. Once that gets sorted, # this should get fixed. # rubocop:enable Chef/Correctness/InvalidPlatformValueForPlatformHelper %w{mac_os_x macos}.include?(self['platform']) rescue StandardError RUBY_PLATFORM.include?('darwin') end alias macosx? macos? def macos10? macos? && node['platform_version'].start_with?('10.') end def macos11? macos? && node['platform_version'].start_with?('11.') end def macos12? macos? && node['platform_version'].start_with?('12.') end def mac_mini_2014? macos? && node['hardware']['machine_model'] == 'Macmini7,1' end def mac_mini_2018? macos? && node['hardware']['machine_model'] == 'Macmini8,1' end def mac_mini_2020? macos? && node['hardware']['machine_model'] == 'Macmini9,1' end def windows? self['platform_family'] == 'windows' end def windows8? windows? && self['platform_version'].start_with?('6.2') end def windows8_1? windows? && self['platform_version'].start_with?('6.3') end def windows10? windows? && self['platform_version'].start_with?('10.0') end def windows2008? windows? && self['platform_version'] == '6.0' end def windows2008r2? windows? && self['platform_version'] == '6.1.7600' end def windows2008r2sp1? windows? && self['platform_version'] == '6.1.7601' end def windows2012? windows? && self['platform_version'].start_with?('6.2') end def windows2012r2? windows? && self['platform_version'].start_with?('6.3') end def windows2016? windows? && self['platform_version'] == '10.0.14393' end def windows2019? windows? && self['platform_version'] == '10.0.17763' end def windows2022? windows? && self['platform_version'] == '10.0.20348' end def aristaeos? self['platform'] == 'arista_eos' end def embedded? self.aristaeos? end def systemd? ::File.directory?('/run/systemd/system') end def freebsd? self['platform_family'] == 'freebsd' end def virtual? vm_systems = %w{ bhyve hyperv kvm parallels vbox vmware xen } self['virtualization'] && self['virtualization']['role'] == 'guest' && vm_systems.include?(self['virtualization']['system']) end def virtual_macos_type unless macos? Chef::Log.warn( 'fb_helpers: node.virtual_macos_type called on non-macOS!', ) return end return self['virtual_macos'] if self['virtual_macos'] if self['hardware']['boot_rom_version'].include? 'VMW' virtual_type = 'vmware' elsif self['hardware']['boot_rom_version'].include? 'VirtualBox' virtual_type = 'virtualbox' else virtual_type = shell_out( '/usr/sbin/system_profiler SPEthernetDataType', ).run_command.stdout.to_s[/Vendor ID: (.*)/, 1] if virtual_type&.include?('0x1ab8') virtual_type = 'parallels' else virtual_type = 'physical' end end virtual_type end def parallels? virtual_macos_type == 'parallels' end def vmware? virtual_macos_type == 'vmware' end def virtualbox? virtual_macos_type == 'virtualbox' end def container? container_systems = %w{ docker linux-vserver lxc openvz nspawn } result = (self['virtualization'] && self['virtualization']['role'] == 'guest' && container_systems.include?(self['virtualization']['system'])) result.nil? ? false : result end def vagrant? File.directory?('/vagrant') end def cloud? self['cloud'] && !self['cloud']['provider'].nil? end def aws? self.cloud? && self['cloud']['provider'] == 'ec2' end # Takes one or more AWS account IDs as strings and return true if this node # is in any of those accounts. def in_aws_account?(*accts) return false if self.quiescent? return false unless self['ec2'] accts.flatten! accts.include?(self['ec2']['account_id']) end def ohai_fs_ver @ohai_fs_ver ||= node['filesystem2'] ? 'filesystem2' : 'filesystem' end # Take a string representing a mount point, and return the # device it resides on. def device_of_mount(m) fs = self.filesystem_data unless Pathname.new(m).mountpoint? Chef::Log.warn( "fb_helpers: #{m} is not a mount point - I can't determine its " + 'device.', ) return nil end unless fs && fs['by_pair'] Chef::Log.warn( 'fb_helpers: no filesystem data so no node.device_of_mount', ) return nil end fs['by_pair'].to_hash.each do |pair, info| # we skip fake filesystems 'rootfs', etc. next unless pair.start_with?('/') # is this our FS? next unless pair.end_with?(",#{m}") # make sure this isn't some fake entry next unless info['kb_size'] return info['device'] end Chef::Log.warn( "fb_helpers: #{m} shows as valid mountpoint, but Ohai can't find it.", ) nil end def device_formatted_as?(device, fstype) fs = self.filesystem_data if fs && fs['by_device'] && fs['by_device'][device] && fs['by_device'][device]['fs_type'] return fs['by_device'][device]['fs_type'] == fstype end false end def resolve_dns_name(hostname, brackets = false, force_v4 = false) ip_addrs = Addrinfo.getaddrinfo(hostname, nil) ip_addrs_v4 = ip_addrs.select(&:ipv4?) ip_addrs_v6 = ip_addrs.select(&:ipv6?) if !ip_addrs_v6.empty? && !force_v4 # Host supports IPv6, the answer has AAAA, let's go: v6_addr = ip_addrs_v6.map(&:ip_address).uniq[0] if brackets return "[#{v6_addr}]" else return v6_addr end elsif !ip_addrs_v4.empty? return ip_addrs_v4.map(&:ip_address).uniq[0] else fail SocketError, 'fb_helpers: No ipv4 addrs found for a non-v6 host' end end # Takes a string corresponding to a filesystem. Returns the size # in GB of that filesystem. def fs_value(p, val) key = case val when 'size' 'kb_size' when 'used' 'kb_used' when 'available' 'kb_available' when 'percent' 'percent_used' else fail "fb_helpers: Unknown FS val #{val} for node.fs_value" end fs = self.filesystem_data # Some things like /dev/root and rootfs have same mount point... if fs && fs['by_mountpoint'] && fs['by_mountpoint'][p] && fs['by_mountpoint'][p][key] return fs['by_mountpoint'][p][key].to_f end Chef::Log.warn( "fb_helpers: Tried to get filesystem information for '#{p}', but it " + 'is not a recognized filesystem, or does not have the requested info.', ) nil end def fs_available_kb(p) self.fs_value(p, 'available') end def fs_available_gb(p) k = self.fs_value(p, 'available') if k return k / (1024 * 1024) end nil end def fs_size_kb(p) self.fs_value(p, 'size') end def fs_size_gb(p) k = self.fs_size_kb(p) if k return k / (1024 * 1024) end nil end def efi? File.directory?('/sys/firmware/efi') end def coreboot? File.directory?('/sys/firmware/vpd') || node['dmi']['bios']['vendor'] == 'coreboot' end def aarch64? node['kernel']['machine'] == 'aarch64' end def x64? node['kernel']['machine'] == 'x86_64' end def cgroup_mounted? fs = self.filesystem_data fs && fs['by_mountpoint'] && fs['by_mountpoint'].include?('/sys/fs/cgroup') end def cgroup1? cgroup_mounted? && self.filesystem_data['by_mountpoint'][ '/sys/fs/cgroup']['fs_type'] != 'cgroup2' end def cgroup2? cgroup_mounted? && self.filesystem_data['by_mountpoint'][ '/sys/fs/cgroup']['fs_type'] == 'cgroup2' end def get_flexible_shard(shard_size) @flexible_shard_value ||= {} @flexible_shard_value[shard_size] ||= if node['shard_seed'] node['shard_seed'] % shard_size else # backwards compat for Facebook until # https://github.com/chef/ohai/pull/877 is out node['fb']['shard_seed'] % shard_size end @flexible_shard_value[shard_size] end def get_seeded_flexible_shard(shard_size, string_seed = '') Digest::MD5.hexdigest(self['fqdn'] + string_seed)[0...7].to_i(16) % shard_size end def get_shard self.get_flexible_shard(100) end def in_flexible_shard?(shard_threshold, shard_size) self.get_flexible_shard(shard_size) <= shard_threshold end def in_shard?(shard_threshold) @in_shard ||= self.get_flexible_shard(100) @in_shard <= shard_threshold end def _timeshard_value(duration) # The timeshard will be the number of seconds into the duration. duration == 0 ? duration : self.get_flexible_shard(duration) end def timeshard_parsed_values(start_time, duration) # Validate the start_time string matches our prescribed format. st = FB::Helpers.parse_timeshard_start(start_time) # Coerce duration into acceptable format duration = FB::Helpers.parse_timeshard_duration(duration) # The timeshard will be the number of seconds into the duration. time_shard = self._timeshard_value(duration) # The time threshold is the sum of the start time and time shard. time_threshold = st + time_shard Chef::Log.debug( "fb_helpers: timeshard start time: #{start_time}, " + "time threshold: #{Time.at(time_threshold)}", ) { 'start_time' => st, 'duration' => duration, 'time_threshold' => time_threshold, } end def in_timeshard?(start_time, duration, stack_depth = 1) # Parse all of our values and vals = self.timeshard_parsed_values(start_time, duration) st = vals['start_time'] duration = vals['duration'] time_threshold = vals['time_threshold'] # If the current time is greater than the threshold then the node will be # within the threshold of time as defined by the start time and duration, # and will return true. curtime = Time.now.tv_sec if curtime > st + duration FB::Helpers.warn_to_remove(stack_depth + 1) end curtime >= time_threshold end # This method allows you to conditionally shard chef resources # @param threshold [Fixnum] An integer value that you are sharding up to. # @yields The contents of the ruby block if the node is in the shard. # @example # This will log 'hello' during the chef run for all nodes <= shard 5 # node.shard_block(5) do # log 'hello' do # level :info # end # end def shard_block(threshold, &block) yield block if block_given? && in_shard?(threshold) end def shard_over_a_week_starting(start_date) in_shard?(rollout_shard(start_date)) end def shard_over_a_week_ending(end_date) start_date = Date.parse(end_date) - 7 in_shard?(rollout_shard(start_date.to_s)) end # Shard range is 0-99 def rollout_shard(start_date) rollout_map = [ 1, 10, 25, 50, 99, ] rd = Date.parse(start_date) # Now we use today as an index into the rollout map, except we have to # discount weekends today = Date.today numdays = (today - rd).to_i num_weekend_days = 0 (0..numdays).each do |i| t = rd + i if t.saturday? || t.sunday? num_weekend_days += 1 end end # Subtract that from how far into the index we go numdays -= num_weekend_days # Return -1 because in_shard?() does a >= comparison to shard number if numdays < 0 return -1 end Chef::Log.debug( "fb_helpers: rollout_shard: days into rollout: #{numdays}", ) if numdays >= rollout_map.size FB::Helpers.warn_to_remove(3) shard = 99 else shard = rollout_map[numdays] end Chef::Log.debug( "fb_helpers: rollout_shard: rollout_shard: #{shard}", ) return shard end def firstboot_os? # this has to work even when we fail early on so we can call this from # broken runs in handlers node['fb_init']['firstboot_os'] rescue StandardError prefix = macos? ? '/var/root' : '/root' File.exist?(File.join(prefix, 'firstboot_os')) end def firstboot_tier? # this has to work even when we fail early on so we can call this from # broken runs in handlers node['fb_init']['firstboot_tier'] rescue StandardError prefix = macos? ? '/var/root' : '/root' File.exist?(File.join(prefix, 'firstboot_tier')) end def firstboot_any_phase? self.firstboot_os? || self.firstboot_tier? end # is this device a SSD? If it's not rotational, then it's SSD # expects a short device name, e.g. 'sda', not '/dev/sda', not '/dev/sda3' def device_ssd?(device) unless node['block_device'][device] fail "fb_helpers: Device '#{device}' passed to node.device_ssd? " + "doesn't appear to be a block device!" end node['block_device'][device]['rotational'] == '0' end def root_compressed? fs = self.filesystem_data fs && fs['by_mountpoint'] && fs['by_mountpoint']['/'] && !fs['by_mountpoint']['/']['mount_options']. grep(/compress(-force)?=zstd/).empty? end def root_btrfs? fs = self.filesystem_data fs && fs['by_mountpoint'] && fs['by_mountpoint']['/'] && fs['by_mountpoint']['/']['fs_type'] == 'btrfs' end def solo? Chef::Config[:solo] || Chef::Config[:local_mode] end def root_user value_for_platform( 'windows' => { 'default' => 'Administrator' }, 'default' => 'root', ) end def root_group # rubocop:disable Chef/Correctness/InvalidPlatformValueForPlatformHelper # See the `macos?` method above value_for_platform( %w{openbsd freebsd mac_os_x macos} => { 'default' => 'wheel' }, 'windows' => { 'default' => 'Administrators' }, 'default' => 'root', ) # rubocop:enable Chef/Correctness/InvalidPlatformValueForPlatformHelper end def quiescent? # if this is set, we're trying to be small and anonymous File.exist?('/root/quiesce') end # On Linux and Mac, as of Chef 13, FS and FS2 were identical # and in Chef 14, FS2 is dropped. # # For FreeBSD and other platforms, they become identical in late 15 # and FS2 is dropped in late 16 # # So we always try 2 and fail back to 1 (if 2 isn't around, then 1 # is the new format) # # This will return modern filesystem data, where it exists *if* it exists. # Otherwise it will fail def filesystem_data self['filesystem2'] || self['filesystem'] end # returns the version-release of an rpm installed, or nil if not present def rpm_version(name) if (self.centos? && !self.centos7?) || self.fedora? # returns epoch.version v = Chef::Provider::Package::Dnf::PythonHelper.instance. package_query(:whatinstalled, name).version unless v.nil? v.split(':')[1] end elsif self.centos7? && (FB::Version.new(Chef::VERSION) > FB::Version.new('14')) # returns epoch.version.arch v = Chef::Provider::Package::Yum::PythonHelper.instance. package_query(:whatinstalled, name).version unless v.nil? v.split(':')[1] end else # return version Chef::Provider::Package::Yum::YumCache.instance. installed_version(name) end end def selinux_mode node['selinux']['status']['current_mode'] || 'unknown' end def selinux_policy node['selinux']['status']['loaded_policy_name'] end def selinux_enabled? node['selinux']['status']['selinux_status'] == 'enabled' end def host_chef_base_path if node.windows? File.join('C:', 'chef') else File.join('/var', 'chef') end end def solo_chef_base_path if node.windows? File.join('C:', 'chef', 'solo') else File.join('/opt', 'chef-solo') end end def chef_base_path if node.solo? self.solo_chef_base_path else self.host_chef_base_path end end def taste_tester_mode? Chef::Config[:mode] == 'taste-tester' end # Safely dig through the node's attributes based on the specified `path`, # with the option to provide a default value # in the event the key does not exist. # # @param path [required] [String] A string representing the path to search # for the key. # @param delim [opt] [String] A character that you will split the path on. # @param default [opt] [Object] An object to return if the key is not found. # @return [Object] Returns an arbitrary object in the event the key isn't # there. # @note Returns nil by default # @note Returns the default value in the event of an exception # @example # irb> node.default.awesome = 'yup' # => "yup" # irb> node.attr_lookup('awesome/not_there') # => nil # irb> node.attr_lookup('awesome') # => "yup" # irb> node.override.not_cool = 'but still functional' # => "but still functional" # irb> node.attr_lookup('not_cool') # => "but still functional" # irb> node.attr_lookup('default_val', default: 'I get this back anyway') # => "I get this back anyway" # irb> node.automatic.a.very.deeply.nested.value = ':)' # => ":)" # irb> node.attr_lookup('a/very/deeply/nested/value') # => ":)" def attr_lookup(path, delim: '/', default: nil) return default if path.nil? node_path = path.split(delim) # implicit-begin is a function of ruby2.5 and later, but we still # support 2.4, so.... until then node_path.inject(self) do |location, key| if key.respond_to?(:to_s) && location.respond_to?(:attribute?) location.attribute?(key.to_s) ? location[key] : default else default end end end def default_package_manager cls = Chef::ResourceResolver.resolve(:package, :node => node) if cls m = cls.to_s.match(/Chef::Resource::(\w+)Package/) if m[1] m[1].downcase else fail "fb_helpers: unknown package manager resource class: #{cls}" end else fail 'fb_helpers: undefined package manager resource class!' end end def eth_is_affinitized? # we only care about ethernet MSI vectors # mlx is special cased because of their device naming convention r = /^(eth(.*[Rr]x|\d+-\d+)|mlx4-\d+@.*|mlx5_comp\d+@.*)/ irqs = node['interrupts']['irq'].select do |_irq, v| v['device'] && r.match?(v['device']) && v['type'] && v['type'].end_with?('MSI') end if irqs.empty? Chef::Log.debug( 'fb_helpers: no eth MSI vectors found, this host does ' + 'not need affinity', ) return true end default_affinity = node['interrupts']['smp_affinity_by_cpu'] # When all interrupts are affinitized, smp_affinity will be different # from the default one, and won't be global. Global technically says # that interrupts can be processed on all CPUs, but in reality what's # going to happen is that it'll *always* be processed by the lowest # numbered CPU, which is a problem when you have multiple IRQs in play. affinitized_irqs = irqs.reject do |_irq, v| my_affinity = v['smp_affinity_by_cpu'] my_affinity == default_affinity || my_affinity == my_affinity.select do |_cpu, is_affinitized| is_affinitized end end if irqs == affinitized_irqs Chef::Log.info( "fb_helpers: all #{irqs.size} MSI eth rx IRQs are " + 'affinitized to CPUs.', ) return true else Chef::Log.warn( "fb_helpers: only #{affinitized_irqs.size}/#{irqs.size} " + 'MSI eth rx IRQs are affinitized to CPUs', ) return false end end def validate_and_fail_on_dynamic_addresses node['network']['interfaces'].each do |if_str, if_data| next unless if_data['addresses'] if_data['addresses'].each do |addr_str, addr_data| next unless addr_data['family'] == 'inet6' if Array(addr_data['tags']).include?('dynamic') fail "fb_helpers: interface #{if_str} has a dynamic " + "address: #{addr_str}." end end end end def nw_changes_allowed? method = node['fb_helpers']['network_changes_allowed_method'] if method return method.call(node) else return @nw_changes_allowed unless @nw_changes_allowed.nil? @nw_changes_allowed = node.firstboot_any_phase? || ::File.exist?(::FB::Helpers::NW_CHANGES_ALLOWED) end end # We can change interface configs if nw_changes_allowed? or we are operating # on a DSR VIP def interface_change_allowed?(interface) method = node['fb_helpers']['interface_change_allowed_method'] if method return method.call(node, interface) else return self.nw_changes_allowed? || ['ip6tnl0', 'tunlany0', 'tunl0'].include?(interface) || interface.match(Regexp.new('^tunlany\d+:\d+')) end end def interface_start_allowed?(interface) method = node['fb_helpers']['interface_start_allowed_method'] if method return method.call(node, interface) else return self.interface_change_allowed?(interface) end end end end