cookbooks/fb_helpers/libraries/fb_helpers.rb (333 lines of code) (raw):
# 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.
#
require 'chef/json_compat'
require 'chef/log'
module FB
# Various utility grab-bag.
class Helpers
NW_CHANGES_ALLOWED = '/run/chef/chef_nw_changes_allowed'.freeze
NW_CHANGES_NEEDED = '/run/chef/chef_pending_nw_changes_needed'.freeze
# commentify() takes a text string and converts it to a (possibly)
# multi-line comment suitable for dropping into a config file.
#
# Usage:
# commentify(text, argHash)
#
# Arguments:
# text: Required string. The string to convert to a comment
# arghash: An optional hash with the following (optional) keys:
# arghash['start']: String to use for starting comment char(s)
# Defaults to '#'
# arghash['finish']: String used to close off entire
# section of text, instead of leading each line
# Defaults to empty string.
# arghash['width']: Total line width (integer) of the config file.
# Defaults to 80.
#
# Notes:
# - 'text' arg will have all whitespace (including newlines) collapsed to a
# single space.
# - If only 'start' is specified, string will be broken up into
# multiple lines, with each preceded by the start tag and padding
# - If 'finish' is also specified, the entire string will be broken up
# similarly to above, except that only one leading and one trailing
# tag are used in total.
# - Line width is the total width of the file you are inserting comments
# into, including the leading comment chars and padding
# - Any words (tokens) longer than a full line are inserted at the start of
# their own line and printed contiguously in the config file. They will
# NOT be split up among multiple-lines.
# - A minimum of one trailing space will be used as padding for each line
def self.commentify(comment, arghash = {})
defaults = { 'start' => '#', 'finish' => '', 'width' => 80 }
arghash = defaults.merge(arghash)
usage = %{
commentify(text, args)
commentify() takes one required string argument, followed by an optional hash.
If the has is specified, it takes one or more of the following keys:
'start', 'finish', 'width'
}
# First arg must be a string that is not all whitespace
if !comment.is_a?(String) || comment.match(/^\s*$/)
fail usage
end
# Extract variable
start = arghash['start']
finish = arghash['finish']
width = arghash['width']
# Adjust the following variables to control padding:
# - For comments with comment char at front of each line
single_line_pad = 1
# - Amount of padding at end of each line of all comments
end_pad = 1
# Contents of each line as we go
curr_line = ''
# Put comment in here to return
final_comment = ''
# >>> Start constructing the actual comment <<<
# First thing to go into comment (always) is comment start tag
curr_line = start
# For multi-line comments, line-break right after initial start tag
if finish == ''
# For single-line comments, just pad and keep going w/ first line
curr_line += ' ' * single_line_pad
prefix = start + ' ' * single_line_pad
else
final_comment += "#{curr_line}\n"
curr_line = ''
prefix = ''
end
# Split on whitespace into an array
words = comment.split(/\s+/)
words.each do |word|
# Determine how much writing space we have left
space_avail = width - (curr_line.size + end_pad)
# If the next word won't fit, handle a couple of special cases
if word.length > space_avail
# If we already have content on the line, then just line-break it
if curr_line.empty?
# If we get here, we are at start of line, and word is STILL
# too long; force it in and violate the line-width.
final_comment += "#{prefix}#{word}\n"
curr_line = ''
else
final_comment += "#{curr_line.strip}\n"
curr_line = "#{prefix}#{word} "
end
else
# Space is available. Add word to line, adding start tag if needed
if curr_line.empty?
curr_line = prefix
end
curr_line += "#{word} "
end
end
# No more words.
# Add final line but only if the line already has content.
unless curr_line.empty?
final_comment += curr_line.strip
end
# Add closing comment if defined
if finish != ''
final_comment += "\n#{finish}"
end
final_comment
end
# filter_hash() takes two Hash objects and applies the second as a filter
# on the first one.
#
# Usage:
# filter_hash(hash, filter)
#
# Arguments:
# hash: Required Hash. The Hash to be filtered
# filter: Required Hash. The filter to apply against hash
#
# Sample usage:
# hash = {
# 'foo' => 1,
# 'bar' => 2,
# 'baz' => {
# 'cake' => 'asdf',
# 'pie' => 42,
# },
# }
#
# filter = ['foo', 'baz/cake']
#
# filter_hash(hash, filter) = {
# 'foo' => 1,
# 'baz' => {
# 'cake' => 'asdf',
# },
# }
def self.filter_hash(hash, filter)
self._filter_hash(hash, self._expand_filter(filter))
# self._filter_hash_alt(hash, filter)
end
# helper method to convert AttributeAllowlist-style filters to something
# usable by _filter_hash
def self._expand_filter(array_filter)
hash_filter = {}
array_filter.each do |f|
keys = f.split('/')
h = nil
keys.reverse_each do |k|
h = { k => h }
end
self.merge_hash!(hash_filter, h)
end
hash_filter
end
# alternate implementation using AttributeAllowlist; does not work yet due
# to https://github.com/chef/chef/issues/10276
def self._filter_hash_alt(hash, filter)
if FB::Version.new(Chef::VERSION) >= FB::Version.new('16.3')
require 'chef/attribute_allowlist'
Chef::AttributeAllowlist.filter(hash, filter)
else
require 'chef/whitelist'
Chef::Whitelist.filter(hash, filter)
end
end
# private method to implement filter_hash using recursion
# note: we can't use Chef::AttributeAllowlist here because it doesn't
# handle empty values properly at the moment
def self._filter_hash(hash, filter, depth = 0)
unless filter.is_a?(Hash)
fail 'fb_helpers: the filter argument to filter_hash needs to be a ' +
"Hash (actual: #{filter.class})"
end
filtered_hash = {}
# We loop over the filter and pull out only allowed items from the hash
# provided by the user. Since users might pass in something huge like
# the entire saved node object, don't make the performance of this code
# be defined by them.
filter.each do |k, v|
if hash.include?(k)
if v.nil?
filtered_hash[k] = hash[k]
elsif v.is_a?(Hash)
# we need to go deeper
ret = self._filter_hash(hash[k], v, depth + 1)
# if the filter returned nil, it means it had no matches, so
# don't add it to the filtered_hash to avoid creating spurious
# entries
unless ret.nil?
filtered_hash[k] = ret
end
else
fail "fb_helpers: invalid filter passed to filter_hash: #{filter}"
end
else
Chef::Log.debug(
"fb_helpers: skipping key #{k} as it is missing from the hash",
)
end
end
# if we're recursing and get an empty hash here, it means we had no
# matches; change it to nil so we can detect it appropriately in the
# loop above
if depth > 0 && filtered_hash == {}
filtered_hash = nil
end
filtered_hash
end
# safe_dup() takes an object and duplicates it. This method always returns
# a valid object, even if thing is not dup'able.
#
# This method is based on lib/chef/mixin/deep_merge.rb from
# https://github.com/chef/chef at revision
# 5c8383fedd13b07f13d64a58f7cc78664a235ced.
#
# Usage:
# safe_dup(thing)
#
# Arguments:
# thing: Required object. The object to duplicate.
def self.safe_dup(thing)
thing.dup
rescue TypeError
thing
end
# merge_hash() takes two hashes and returns the result of recursively
# merging one onto the other. Only hashes are merged -- other objects,
# including arrays, will be replaced. Leaf hashes are also merged by
# default; this can be changed with overwrite_leaves, which will replace
# them instead.
#
# This method is based on lib/chef/mixin/deep_merge.rb from
# https://github.com/chef/chef at revision
# 5c8383fedd13b07f13d64a58f7cc78664a235ced.
#
# Usage:
# merge_hash(merge_onto, merge_with, overwrite_leaves)
#
# Arguments:
# merge_onto: Required hash. The base hash that will be merged onto
# merge_with: Required hash. The hash that will be merged on merge_onto
# overwrite_leaves: Optional boolean. Whether to overwrite leaves or not
def self.merge_hash(merge_onto, merge_with, overwrite_leaves = false)
self.merge_hash!(safe_dup(merge_onto), safe_dup(merge_with),
overwrite_leaves)
end
# merge_hash!() takes two hashes and recursively merges one onto the
# other, altering it in place, and returns the merged hash. See
# merge_hash() for details on the merge semantics.
#
# This method is based on lib/chef/mixin/deep_merge.rb from
# https://github.com/chef/chef at revision
# 5c8383fedd13b07f13d64a58f7cc78664a235ced.
#
# Usage:
# merge_hash(merge_onto, merge_with, overwrite_leaves)
#
# Arguments:
# merge_onto: Required hash. The base hash that will be merged onto
# merge_with: Required hash. The hash that will be merged on merge_onto
# overwrite_leaves: Optional boolean. Whether to overwrite leaves or not
def self.merge_hash!(merge_onto, merge_with, overwrite_leaves = false)
# If there are two Hashes, recursively merge.
if merge_onto.is_a?(Hash) && merge_with.is_a?(Hash)
merge_with.each do |key, merge_with_value|
is_leaf = false
if overwrite_leaves && merge_with_value.is_a?(Hash)
# if we're overwriting leaves, we need to know when we have one
merge_with_value.each do |_k, v|
if v.is_a?(Hash)
is_leaf = true
break
end
end
end
if merge_onto.key?(key)
if is_leaf
value = merge_onto[key]
merge_with_value.each do |k, _v|
value[k] = merge_with_value[k]
end
else
value = self.merge_hash(merge_onto[key], merge_with_value,
overwrite_leaves)
end
else
value = merge_with_value
end
merge_onto[key] = value
end
merge_onto
else
# In all other cases, replace merge_onto with merge_with
merge_with
end
end
# parse_json() takes a JSON string and converts it to a Ruby object,
# also enforcing that the top-level object matches what is expected.
#
# Usage:
# parse_json(json_string, top_level_class)
#
# Arguments:
# json_string: Required string. The JSON string to parse.
# top_level_class: Optional class, defaults to Hash.
# fallback: Optional boolean, defaults to false.
def self.parse_json(json_string, top_level_class = Hash, fallback = false)
unless [Array, Hash, String].include?(top_level_class)
fail 'fb_helpers: top_level_class can only be Array, Hash or ' +
"(actual: #{top_level_class})"
end
begin
parsed_json = Chef::JSONCompat.parse(json_string)
rescue Chef::Exceptions::JSON::ParseError => e
m = 'fb_helpers: cannot parse string as JSON; returning an empty ' +
"#{top_level_class} instead: #{e}"
if fallback
Chef::Log.error(m)
return top_level_class.new
else
# rubocop:disable Style/SignalException
fail m
# rubocop:enable Style/SignalException
end
end
unless parsed_json.is_a?(top_level_class)
m = 'fb_helpers: parsed JSON does not match the expected ' +
"#{top_level_class} (actual: #{parsed_json.class})"
if fallback
Chef::Log.error(m)
return top_level_class.new
else
fail m
end
end
parsed_json
end
# parse_json_file() takes a path string and converts its contents to a
# Ruby object, also enforcing that the top-level object matches what is
# expected.
#
# Usage:
# parse_json_file(path, top_level_class, fallback)
#
# Arguments:
# path: Required string. Path to the file to parse.
# top_level_class: Optional class, defaults to Hash.
# fallback: Optional boolean, defaults to false.
def self.parse_json_file(path, top_level_class = Hash, fallback = false)
Chef::Log.debug(
"fb_helpers: parsing #{path} as JSON (expecting: #{top_level_class})",
)
begin
content = File.read(path)
rescue IOError, SystemCallError => e
# SystemCallError is because of -ENOENT
m = "fb_helpers: cannot read #{path}: #{e}"
if fallback
Chef::Log.error(m)
return top_level_class.new
else
# rubocop:disable Style/SignalException
fail m
# rubocop:enable Style/SignalException
end
end
self.parse_json(content, top_level_class, fallback)
end
# parse_timeshard_start() takes a time string and converts its contents to a
# unix timestamp, to be used in computing timeshard information
#
# Usage:
# parse_timeshard_start(time)
#
# Arguments:
# time: A valid time string
def self.parse_timeshard_start(time)
# Validate the time string matches our prescribed format.
begin
st = Time.parse(time).tv_sec
rescue ArgumentError
errmsg = "fb_helpers: Invalid start_time arg '#{time}' for " +
'FB::Helpers.parse_timeshard_start'
raise errmsg
end
st
end
# parse_timeshard_duration() takes a duration string and converts
# its contents to a to an int to be used in computing timeshard information
#
# Usage:
# parse_timeshard_duration(duration)
#
# Arguments:
# duration: A valid duration string, in days or hours
def self.parse_timeshard_duration(duration)
# Multiply the number of days by 1440 min and 60 s to convert a day into
# seconds.
if duration.match('^[0-9]+[dD]$')
duration = duration.to_i * 1440 * 60
# Multiply the number of hours by 3600 s to convert hours into seconds.
elsif duration.match('^[0-9]+[hH]$')
duration = duration.to_i * 3600
else
errmsg = "fb_helpers: Invalid duration arg, '#{duration}' for " +
'FB::Helpers.parse_timeshard_duration'
fail errmsg
end
duration
end
def self.linux?
RUBY_PLATFORM.include?('linux')
end
def self.windows?
RUBY_PLATFORM =~ /mswin|mingw32|windows/
end
# mountpoint? determines if a path string represents a mountpoint
#
# Usage:
# mountpoint?(path)
#
# Arguments:
# path: A string-compatible object that represents a path to test
def self.mountpoint?(path)
Pathname.new(path.to_s).mountpoint?
end
# date_of_last() takes a day of the week and finds the most recent
# date this day fell on
#
# Usage:
# date_of_last(day)
#
# Arguments:
# day: A string representing a day of the week. Can be long-form
# (e.g. "Monday") or abbreviate (e.g. "Wed")
def self.date_of_last(day)
# Gets you the date of the last day passed
date = Date.parse(day)
delta = date < Date.today ? 0 : 7
(date - delta).to_s
end
# sysnative_path() determines the sysnative path on Windows
def self.sysnative_path
fail unless self.windows?
if RUBY_PLATFORM.include?('64')
"#{ENV['WINDIR']}\\system32\\"
else
"#{ENV['WINDIR']}\\sysnative\\"
end
end
# warn_to_remove() is used in sharding operations to help
# discover old sharding code
def self.warn_to_remove(
stack_depth, msg = 'fb_helpers: Past time shard duration! Please cleanup!'
)
stack = caller(stack_depth, 1)[0]
parts = %r{^.*/cookbooks/([^/]*)/([^/]*)/(.*)\.rb:(\d+)}.match(stack)
if parts
where = "(#{parts[1]}::#{parts[3]} line #{parts[4]})"
else
where = stack
end
Chef::Log.warn("#{msg} #{where}")
end
# Normally preferred testing for existence of a user is via
# node['etc']['passwd'], but if the user was added in the same chef run
# then ohai won't have it.
def self.user_exist?(user_name)
Etc.getpwnam(user_name)
true
rescue ArgumentError
false
end
# Normally preferred testing for existence of a group is via
# node['etc']['group'], but if the group was added in the same chef run
# then ohai won't have it.
def self.group_exist?(group_name)
Etc.getgrnam(group_name)
true
rescue ArgumentError
false
end
def self._request_nw_changes_permission(run_context, new_resource)
run_context.node.default['fb_helpers']['_nw_perm_requested'] = true
notification = Chef::Resource::Notification.new(
'fb_helpers_request_nw_changes[manage]',
:request_nw_changes,
new_resource,
)
notification.fix_resource_reference(run_context.resource_collection)
run_context.root_run_context.add_delayed_action(notification)
end
end
# Helper class to compare software versions.
# Sample usage:
# Version.new('1.3') < Version.new('1.21')
# => true
# Version.new('4.5') < Version.new('4.5')
# => false
# Version.new('3.3.10') > Version.new('3.4')
# => false
# Version.new('10.2') >= Version.new('10.2')
# => true
# Version.new('1.2.36') == Version.new('1.2.36')
# => true
# Version.new('3.3.4') <= Version.new('3.3.02')
# => false
# Our version comparison class
class Version < Array
# This is intentional.
# rubocop:disable Lint/MissingSuper
def initialize(s)
@string_form = s
if s.nil?
@arr = []
return
end
@arr = s.split(/[._-]/).map(&:to_i)
end
# rubocop:enable Lint/MissingSuper
def to_s
@string_form
end
def to_a
@arr
end
def compare(other, exact = true)
other ||= new
unless other.is_a?(FB::Version)
other = FB::Version.new(other)
end
if exact
@arr <=> other.to_a
else
len = [@arr.length, other.to_a.length].min
@arr[0, len] <=> other[0, len]
end
end
alias_method '<=>', :compare
def <=(other)
compare(other) <= 0
end
def >=(other)
compare(other) >= 0
end
def <(other)
compare(other).negative?
end
def >(other)
compare(other).positive?
end
def ==(other)
compare(other).zero?
end
def [](*args)
@arr[*args]
end
def ===(other)
# Useful to use in case statements
compare(other, false).zero?
end
# Oh, come on rubocop...
def inspect
@string_form
end
end
end