cookbooks/fb_fstab/libraries/provider.rb (544 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.
#
module FB
# Module to be loaded into the provider namespace
module FstabProvider
def mount(mount_data, in_maint_disks, in_maint_mounts)
if in_maint_mounts.include?(mount_data['mount_point'])
Chef::Log.warn(
"fb_fstab: Skipping mount of #{mount_data['mount_point']} because " +
'the mount is marked as in-maintenance',
)
return true
end
relevant_disk = nil
in_maint_disks.each do |disk|
if mount_data['device'].start_with?(disk)
relevant_disk = disk
break
end
end
if relevant_disk
Chef::Log.warn(
"fb_fstab: Skipping mount of #{mount_data['mount_point']} because " +
" device #{relevant_disk} is marked as in-maintenance",
)
return true
end
# We don't use a 'directory' resource, because that would happen
# later. Also, we don't want to conflict with resources that may
# actually managed this directory (though they better check it's
# not mounted :)). We just make sure there's a safe directory
# to mount to before we call 'mount'
#
# Further, because we're called in a 'converge' block, this is whyrun
# safe, just like the Mixlib::ShellOut below.
Chef::Log.info("fb_fstab: Mounting #{mount_data['mount_point']}")
if ::File.exist?(mount_data['mount_point'])
if ::File.symlink?(mount_data['mount_point'])
if mount_data['allow_mount_failure']
msg = "fb_fstab: #{mount_data['mount_point']} is a symlink. " +
'This is probably not what you want and could cause SEVs.'
Chef::Log.warn(msg)
return false
else
fail "fb_fstab: #{mount_data['mount_point']} is a symlink, thus" +
' I will not mount over it.'
end
end
else
# we pass in a relatively sane perm which is subject to umask If they
# sent in perms, we'll do a real chmod right afterward which isn't
# subject to umask.
FileUtils.mkdir_p(mount_data['mount_point'], :mode => 0o755)
if mount_data['mp_perms']
FileUtils.chmod(mount_data['mp_perms'].to_i(8),
mount_data['mount_point'])
end
if mount_data['mp_owner'] || mount_data['mp_group']
FileUtils.chown(mount_data['mp_owner'], mount_data['mp_group'],
mount_data['mount_point'])
end
if mount_data['mp_immutable']
readme = File.join(mount_data['mount_point'], 'README.txt')
readme_body = 'This directory was created by chef to be an ' +
'immutable mountpoint. If you can see this, ' +
"the mount is missing!\n"
File.open(readme, 'w') do |f| # ~FB030
f.write(readme_body)
end
s = Mixlib::ShellOut.new(
"/bin/chattr +i #{mount_data['mount_point']}",
).run_command
s.error!
end
end
if node.systemd?
# If you use FUSE mounts, and you're running Chef from a systemd timer,
# the FUSE helpers will be killed after a chef run, which unmounts your
# filesystem, which is clearly not what you want. So we instead want to
# ask systemd to do the mount for us. The cleanest way to do that is to
# ask systemd what it's generated unit for that mount is and then
# "start" that unit.
#
# HOWEVER!!!! NOTE WELL!!!!
# If you don't do a `systemctl daemon-reload` after updating /etc/fstab,
# then this won't be guaranteed to mount the right thing, so we have
# that notify in the recipe
s = Mixlib::ShellOut.new(
"/bin/systemd-escape -p --suffix=mount #{mount_data['mount_point']}",
).run_command
s.error!
mountunit = s.stdout.chomp.shellescape
s = Mixlib::ShellOut.new("/bin/systemctl start #{mountunit}")
else
# We cd into /dev/shm because otherwise mount will be dumb and
# change the device of 'foo' to be '/foo' if /foo happens to exist.
#
# I COULD call --no-canonicalize except that when /bin/mount calls
# /sbin/mount.tmpfs, it won't preserve that option, and then calls
# /bin/mount -i without it and it's canonicalized anyway. Further on
# other FS's --no-canonicalize may not be safe. THUS, we cd to a place
# that should be emptyish.
s = Mixlib::ShellOut.new(
"cd /dev/shm && /bin/mount #{mount_data['mount_point']}",
)
end
_run_command_flocked(s,
mount_data['lock_file'],
mount_data['mount_point'])
if s.error? && mount_data['allow_mount_failure']
Chef::Log.warn(
"fb_fstab: Mounting #{mount_data['mount_point']} failed, but " +
'"allow_mount_failure" was set, so moving on.',
)
else
s.error!
end
true
end
def umount(mount_point, lock_file)
Chef::Log.info("fb_fstab: Unmounting #{mount_point}")
s = Mixlib::ShellOut.new("/bin/umount #{mount_point}")
_run_command_flocked(s, lock_file, mount_point)
if s.error? && node['fb_fstab']['allow_lazy_umount']
Chef::Log.warn("fb_fstab: #{s.stderr.chomp}")
Chef::Log.warn(
"fb_fstab: Unmounting #{mount_point} failed, " +
'trying lazy unmount.',
)
sl = Mixlib::ShellOut.new("/bin/umount -l #{mount_point}")
_run_command_flocked(sl, lock_file, mount_point)
else
s.error!
end
true
end
def remount(mount_data)
mount_point = mount_data['mount_point']
with_umount = mount_data['remount_with_umount']
lock_file = mount_data['lock_file']
Chef::Log.info("fb_fstab: Remounting #{mount_point}")
if with_umount
Chef::Log.debug("fb_fstab: umount and mounting #{mount_point}")
cmd = "/bin/umount #{mount_point}; /bin/mount #{mount_point}"
else
Chef::Log.debug("fb_fstab: 'mount -o remount' on #{mount_point}")
cmd = "/bin/mount -o remount #{mount_point}"
end
s = Mixlib::ShellOut.new(cmd)
_run_command_flocked(s, lock_file, mount_point)
if s.error? && mount_data['allow_remount_failure']
Chef::Log.warn(
"fb_fstab: Remounting #{mount_point} failed, but " +
'"allow_remount_failure" was set, so moving on.',
)
else
s.error!
end
end
def get_unmasked_base_mounts(format)
FB::Fstab.get_unmasked_base_mounts(format, node)
end
def canonicalize_device(device)
FB::Fstab.canonicalize_device(device, node)
end
# Given a *mounted* device from node['filesystem'] in `mounted_data`, check
# to see if we want to keep it. It looks in `desired_mounts` (an export of
# node['fb_fstab']['mounts'] as well as `base_mounts` (a hash
# representation of the saved OS mounts file).
def should_keep(mounted_data, desired_mounts, base_mounts)
Chef::Log.debug(
"fb_fstab: Should we keep #{mounted_data}?",
)
# Does it look like something in desired mounts?
desired_mounts.each_value do |desired_data|
begin
desired_device = canonicalize_device(desired_data['device'])
rescue RuntimeError
next if desired_data['allow_mount_failure']
raise
end
Chef::Log.debug("fb_fstab: --> Lets see if it matches #{desired_data}")
# if the devices are the same *and* are real devices, the
# rest doesn't matter - we won't unmount a moved device. moves
# option changes, etc. are all the work of the 'mount' step later.
if mounted_data['device']&.start_with?('/dev/')
if desired_device == mounted_data['device']
Chef::Log.debug(
"fb_fstab: Device #{mounted_data['device']} is supposed to be " +
' mounted, not considering for unmount',
)
return true
end
# If it's a virtual device, we just check the type and mount
# point are the same
elsif desired_data['mount_point'] == mounted_data['mount'] &&
compare_fstype(desired_data['type'], mounted_data['fs_type'])
Chef::Log.debug(
"fb_fstab: Virtual fs of type #{mounted_data['fs_type']} is " +
"desired at #{mounted_data['mount']}, not considering for unmount",
)
return true
end
Chef::Log.debug('fb_fstab: --> ... nope')
end
# If not, is it autofs controlled?
if FB::Fstab.autofs_parent(mounted_data['mount'], node)
Chef::Log.debug(
"fb_fstab: #{mounted_data['device']} (#{mounted_data['mount']}) is" +
' autofs-controlled.',
)
return true
end
# If not, is it a base mount?
# Note that if it's not in desired mounts, we can be more strict,
# no one is trying to move things... it should be same device and point.
Chef::Log.debug('fb_fstab: --> OK, well is it a base mount?')
if base_mounts[mounted_data['device']] &&
base_mounts[mounted_data['device']]['mount_point'] ==
mounted_data['mount']
Chef::Log.debug(
"fb_fstab: #{mounted_data['device']} on #{mounted_data['mount']} is" +
' a base mount, not considering for unmount',
)
return true
end
false
end
# Walk all mounted filesystems and umount anything we don't know about
def check_unwanted_filesystems
# extra things to skip
devs_to_skip = node['fb_fstab']['umount_ignores']['devices'].dup
dev_prefixes_to_skip =
node['fb_fstab']['umount_ignores']['device_prefixes'].dup
mounts_to_skip =
node['fb_fstab']['umount_ignores']['mount_points'].dup
mount_prefixes_to_skip =
node['fb_fstab']['umount_ignores']['mount_point_prefixes'].dup
fstypes_to_skip = node['fb_fstab']['umount_ignores']['types'].dup
base_mounts = get_unmasked_base_mounts(:hash)
# we're going to iterate over specified mounts a lot, lets dump it
desired_mounts = node['fb_fstab']['mounts'].to_hash
fs_data = node.filesystem_data
fs_data['by_pair'].to_hash.each_value do |mounted_data|
# ohai uses many things to populate this structure, one of which
# is 'blkid' which gives info on devices that are not currently
# mounted. This skips those, plus swap, of course.
unless mounted_data['mount']
Chef::Log.debug(
"fb_fstab: Skipping umount check for #{mounted_data['device']} " +
"- it isn't mounted.",
)
next
end
# Work around chef 12 ohai bug
if mounted_data.key?('inodes_used') && !mounted_data.key?('kb_used')
Chef::Log.debug(
'fb_fstab: Skipping mal-formed Chef 12 "df -i" entry ' +
mounted_data.to_s,
)
next
end
# skip anything seemingly magical
if devs_to_skip.include?(mounted_data['device'])
Chef::Log.debug(
"fb_fstab: Skipping umount check for #{mounted_data['device']} " +
"(#{mounted_data['mount']}): exempted device",
)
next
elsif mounts_to_skip.include?(mounted_data['mount'])
Chef::Log.debug(
"fb_fstab: Skipping umount check for #{mounted_data['device']} " +
"(#{mounted_data['mount']}): exempted mountpoint",
)
next
elsif fstypes_to_skip.include?(mounted_data['fs_type'])
Chef::Log.debug(
"fb_fstab: Skipping umount check for #{mounted_data['device']} " +
"(#{mounted_data['mount']}): exempted fstype",
)
next
elsif dev_prefixes_to_skip.any? do |i|
mounted_data['device']&.start_with?(i)
end
Chef::Log.debug(
"fb_fstab: Skipping umount check for #{mounted_data['device']} " +
"(#{mounted_data['mount']}) - exempted device prefix",
)
next
elsif mount_prefixes_to_skip.any? do |i|
mounted_data['mount']&.start_with?(i)
end
Chef::Log.debug(
"fb_fstab: Skipping umount check for #{mounted_data['device']} " +
"(#{mounted_data['mount']}) - exempted mount_point prefix",
)
next
end
# Is this device in our list of desired mounts?
next if should_keep(mounted_data, desired_mounts, base_mounts)
if node['fb_fstab']['enable_unmount']
converge_by "unmount #{mounted_data['mount']}" do
umount(mounted_data['mount'], mounted_data['lock_file'])
end
else
Chef::Log.warn(
"fb_fstab: Would umount #{mounted_data['device']} from " +
"#{mounted_data['mount']}, but " +
'node["fb_fstab"]["enable_unmount"] is false',
)
Chef::Log.debug("fb_fstab: #{mounted_data}")
end
end
end
def _normalize_type(type)
if node['fb_fstab']['type_normalization_map'][type]
return node['fb_fstab']['type_normalization_map'][type]
end
type
end
# We consider a filesystem type the "same" if they are identical or if
# one is auto.
def compare_fstype(type1, type2)
type1 == type2 || _normalize_type(type1) == _normalize_type(type2)
end
def delete_ignored_opts!(tlist)
ignorable_opts_s = node['fb_fstab']['ignorable_opts'].select do |x|
x.is_a?(::String)
end
ignorable_opts_r = node['fb_fstab']['ignorable_opts'].select do |x|
x.is_a?(::Regexp)
end
tlist.delete_if do |x|
ignorable_opts_s.include?(x) ||
ignorable_opts_r.any? do |regex|
x =~ regex
end
end
end
# This translates human-readable size mount options into their
# canonical bytes form. I.e. "size=4G" into "size=4194304"
#
# Assumes it's really a size opt - validate before calling!
def translate_size_opt(opt)
val = opt.split('=').last
mag = val[-1].downcase
mags = ['k', 'm', 'g', 't']
return opt unless mags.include?(mag)
num = val[0..-2].to_i
mags.each do |d|
num *= 1024
return "size=#{num}" if mag == d
end
fail RangeError "fb_fstab: Failed to translate #{opt}"
end
def canonicalize_opts(opts)
# ensure both are arrays
optsl = opts.is_a?(Array) ? opts.dup : opts.split(',')
# 'rw' is implied, so if no readability is specified, add it to both,
# so missing on one if them doesn't cause a false-negative
optsl << 'rw' unless optsl.include?('ro') || optsl.include?('rw')
delete_ignored_opts!(optsl)
optsl.map! do |x|
x.start_with?('size=') ? translate_size_opt(x) : x
end
# sort
optsl.sort
end
# Take opts in a variety of forms, and compare them intelligently
def compare_opts(opts1, opts2)
c1 = canonicalize_opts(opts1)
c2 = canonicalize_opts(opts2)
# Check that they're the same
c1 == c2
end
# Given a tmpfs desired mount `desired` check to see what it's status is;
# mounted (:same), needs remount (:remount), not mounted (:missing) or
# something else is mounted in the way (:conflict)
#
# This is roughly the same as mount_status() below but we make many
# exceptions for tmpfs filesystems.
#
# Unlike mount_status() we will never return :moved since there's no unique
# device to move.
def tmpfs_mount_status(desired)
# Start with checking if it was mounted the way we would mount it
# this is ALMOST the same as the 'is it identical' check for non-tmpfs
# filesystems except that with tmpfs we don't treat 'auto' as equivalent
fs_data = node.filesystem_data
key = "#{desired['device']},#{desired['mount_point']}"
if fs_data['by_pair'][key]
mounted = fs_data['by_pair'][key].to_hash
if mounted['fs_type'] == 'tmpfs'
Chef::Log.debug(
"fb_fstab: tmpfs #{desired['device']} on " +
"#{desired['mount_point']} is currently mounted...",
)
if compare_opts(desired['opts'], mounted['mount_options'])
Chef::Log.debug('fb_fstab: ... with identical options.')
return :same
else
Chef::Log.debug(
"fb_fstab: ... with different options #{desired['opts']} vs " +
mounted['mount_options'].join(','),
)
Chef::Log.info(
"fb_fstab: #{desired['mount_point']} is mounted with options " +
"#{canonicalize_opts(mounted['mount_options'])} instead of " +
canonicalize_opts(desired['opts']).to_s,
)
return :remount
end
end
end
# OK, if that's not the case, we don't have the same device, which
# is OK. Find out if we have something mounted at the same spot, and
# get its device name so we can find it's entry in node['filesystem']
if fs_data['by_mountpoint'][desired['mount_point']]
# If we are here the mountpoints are the same...
mounted = fs_data['by_mountpoint'][desired['mount_point']].to_hash
# OK, if it's tmpfs as well, we're diong good
if mounted['fs_type'] == 'tmpfs'
Chef::Log.warn(
"fb_fstab: Treating #{mounted['devices']} on " +
"#{desired['mount_point']} the same as #{desired['device']} on " +
"#{desired['mount_point']} because they are both tmpfs.",
)
Chef::Log.debug(
"fb_fstab: tmpfs #{desired['device']} on " +
"#{desired['mount_point']} is currently mounted...",
)
Chef::Log.debug("fb_fstab: #{desired} vs #{mounted}")
if compare_opts(desired['opts'], mounted['mount_options'])
Chef::Log.debug('fb_fstab: ... with identical options.')
return :same
else
Chef::Log.debug(
"fb_fstab: ... with different options #{desired['opts']} vs " +
mounted['mount_options'].join(','),
)
Chef::Log.info(
"fb_fstab: #{desired['mount_point']} is mounted with options " +
"#{canonicalize_opts(mounted['mount_options'])} instead of " +
canonicalize_opts(desired['opts']).to_s,
)
return :remount
end
end
Chef::Log.warn(
"fb_fstab: tmpfs is desired on #{desired['mount_point']}, but " +
"non-tmpfs #{mounted['devices']} (#{mounted['fs_type']}) currently " +
'mounted there.',
)
return :conflict
end
:missing
end
# Given a desired mount `desired` check to see what it's status is;
# mounted (:same), needs remount (:remount), not mounted (:missing),
# moved (:moved), or something else is mounted in the way (:conflict)
def mount_status(desired)
# We treat tmpfs specially. While we don't want people to mount tmpfs with
# a device of 'none' or 'tmpfs', we also don't want to make them remount
# (and lose all their data) just to convert to fb_fstab. So we'll make
# them use a new name in the config, but we will treat the pre-mounted
# mounts as valid/the same. Besides, since the device is meaningless, we
# can just ignore it for the purposes of this test anyway.
if desired['type'] == 'tmpfs'
return tmpfs_mount_status(desired)
end
key = "#{desired['device']},#{desired['mount_point']}"
fs_data = node.filesystem_data
mounted = nil
if fs_data['by_pair'][key]
mounted = fs_data['by_pair'][key].to_hash
else
key = "#{desired['device']}/,#{desired['mount_point']}"
if fs_data['by_pair'][key]
mounted = fs_data['by_pair'][key].to_hash
end
end
if mounted
Chef::Log.debug(
"fb_fstab: There is an entry in node['filesystem'] for #{key}",
)
# If it's a virtual device, we require the fs type to be identical.
# otherwise, we require them to be similar. This is because 'auto'
# is meaningless without a physical device, so we don't want to allow
# it to be the same.
if compare_fstype(desired['type'], mounted['fs_type']) ||
(desired['device'].start_with?('/') &&
[desired['type'], mounted['fs_type']].include?('auto'))
Chef::Log.debug(
"fb_fstab: FS #{desired['device']} on #{desired['mount_point']}" +
' is currently mounted...',
)
if compare_opts(desired['opts'], mounted['mount_options'])
Chef::Log.debug('fb_fstab: ... with identical options.')
return :same
else
Chef::Log.debug(
"fb_fstab: ... with different options #{desired['opts']} vs " +
mounted['mount_options'].join(','),
)
Chef::Log.info(
"fb_fstab: #{desired['mount_point']} is mounted with options " +
"#{canonicalize_opts(mounted['mount_options'])} instead of " +
canonicalize_opts(desired['opts']).to_s,
)
return :remount
end
else
Chef::Log.warn(
"fb_fstab: Device #{desired['device']} is mounted at " +
"#{mounted['mount']} as desired, but with fstype " +
"#{mounted['fs_type']} instead of #{desired['type']}",
)
return :conflict
end
end
# In this case we don't have the device we expect at the mountpoint we
# expect. Assuming it's not NFS/Gluster which can be mounted in more than
# once place, we look up this device and see if it moved or just isn't
# mounted
unless ['nfs', 'nfs4', 'glusterfs'].include?(desired['type'])
device = fs_data['by_device'][desired['device']]
# Here we are checking if the device we want
# has already a mount defined
# We want to return :moved if it does except
# in the case when it's a btrfs
# disk and our desired and current options
# are trying to mount different subvolumes
if device && device['mounts'] && !device['mounts'].empty? &&
!(
FB::Fstab.btrfs_subvol?(
device['fs_type'],
device['mount_options'].join(','),
) &&
FB::Fstab.btrfs_subvol?(
desired['type'],
desired['opts'],
) &&
!FB::Fstab.same_subvol?(
device['mounts'][0],
device['mount_options'].join(','),
desired['opts'],
)
)
Chef::Log.warn(
"fb_fstab: #{desired['device']} is at #{device['mounts']}, but" +
" we want it at #{desired['mount_point']}",
)
return :moved
end
end
# Ok, this device isn't mounted, but before we return we need to check
# if anything else is mounted where we want to be.
if fs_data['by_mountpoint'][desired['mount_point']]
devices = fs_data['by_mountpoint'][
desired['mount_point']]['devices']
Chef::Log.warn(
"fb_fstab: Device #{desired['device']} desired at " +
"#{desired['mount_point']} but something #{devices} already " +
'mounted there.',
)
return :conflict
end
:missing
end
def check_wanted_filesystems
# before we do anything... node['filesystem'] is a mapping of devices
# to mountpoint, but that won't work for things with a device of "none",
# so build a reverse mapping too
in_maint_disks = FB::Fstab.get_in_maint_disks
in_maint_mounts = FB::Fstab.get_in_maint_mounts
# walk desired mounts, see if it's mounted, and mount/update
# as appropriate.
node['fb_fstab']['mounts'].to_hash.each_value do |desired_data|
# Using "none" as a device is deprecated. You can use descriptive
# strings now. Doing so is not only the new hotness, but it also
# prevents dupes in node['filesystem'] - so we require it.
if desired_data['device'] == 'none'
Chef::Log.warn('fb_fstab: We do not permit "none" devices, please ' +
'use a descriptive device name')
next
end
if desired_data['type'] == 'swap'
Chef::Log.debug('fb_fstab: We do not change swap from fb_fstab, ' +
'moving on...')
next
end
if desired_data['opts']
opt_list = desired_data['opts'].split(',')
if opt_list.include?('noauto')
Chef::Log.debug(
"fb_fstab: '#{desired_data['device']}' is configured with " +
"'noauto' option, we will not mount.",
)
next
end
end
begin
desired_data['device'] = canonicalize_device(desired_data['device'])
rescue RuntimeError
next if desired_data['allow_mount_failure']
end
status = mount_status(desired_data)
case status
when :same
Chef::Log.debug(
"fb_fstab: Skipping #{desired_data['mount_point']}; looks good!",
)
next
when :missing
converge_by "mount #{desired_data['mount_point']}" do
mount(desired_data, in_maint_disks, in_maint_mounts)
end
# Device mounted, move on...
next
when :conflict
# We already threw a warning, just note we're moving on
Chef::Log.info(
"fb_fstab: Skipping #{desired_data['mount_point']} due to conflict",
)
next
when :moved
Chef::Log.info(
"fb_fstab: Skipping #{desired_data['mount_point']} since it " +
'moved. Moving filesystems is scary.',
)
next
when :remount
base_msg = "fb_fstab: Mountpoint #{desired_data['mount_point']} " +
'options changed'
if node['fb_fstab']['enable_remount'] &&
desired_data['enable_remount']
Chef::Log.debug("fb_fstab: #{base_msg} - remounting")
converge_by "remount #{desired_data['mount_point']}" do
remount(desired_data)
end
# There's nothing after us in the loop at this point, but I'm being
# explicit with the 'next' here so that we never accidentally
# remount an FS twice
next
else
Chef::Log.warn("#{base_msg}, but remounts are not enabled")
end
end
end
end
def _run_command_flocked(shellout, lock_file, mount_point)
if lock_file.nil?
return shellout.run_command
else
lock_fd = File.open(lock_file, 'a+')
unless lock_fd.flock(File::LOCK_EX | File::LOCK_NB)
fail IOError, "Couldn't grab lock at #{lock_file} for #{mount_point}"
end
begin
return shellout.run_command
ensure
lock_fd.flock(File::LOCK_UN)
end
end
end
end
end