diff options
-rwxr-xr-x | README.md | 8 | ||||
-rwxr-xr-x | TODO.md | 2 | ||||
-rwxr-xr-x | byteback-prune | 112 | ||||
-rwxr-xr-x | byteback-snapshot | 64 | ||||
-rw-r--r-- | lib/byteback.rb | 2 | ||||
-rw-r--r-- | lib/byteback/backup_directory.rb | 175 | ||||
-rw-r--r-- | lib/byteback/disk_free.rb | 41 | ||||
-rwxr-xr-x | lib/byteback/disk_free_history.rb | 110 | ||||
-rw-r--r-- | lib/sys/filesystem.rb | 591 |
9 files changed, 942 insertions, 163 deletions
@@ -167,3 +167,11 @@ daily backups, but as they get too numerous, we make sure that we are reluctant to delete our very oldest. [TODO: model it] + +Acknowledgements +---------------- +For maximum portability, I've included two libraries. Thanks very much to +their authors: + +sys-filesystem by Daniel J. Berger: https://github.com/djberg96/sys-filesystem +trollop by William Morgan: https://github.com/wjessop/trollop
\ No newline at end of file @@ -12,6 +12,8 @@ TODO list for byteback * pruning doesn't work, assumes "btrfs subvolume delete" is synchronous, which it is not. +* try to deal with https://btrfs.wiki.kernel.org/index.php/Problem_FAQ#I_get_.22No_space_left_on_device.22_errors.2C_but_df_says_I.27ve_got_lots_of_space ? + * (so introduce server-local cron job to keep on top of pruning and other stuff later) * byteback-restore program diff --git a/byteback-prune b/byteback-prune new file mode 100755 index 0000000..ed5bf0c --- /dev/null +++ b/byteback-prune @@ -0,0 +1,112 @@ +#!/usr/bin/ruby +# +# Program to prune a byteback installation + +$LOAD_PATH.unshift("/usr/lib/byteback") +require 'trollop' +require 'byteback' +require 'sys/filesystem' +include Byteback +include Byteback::Log +include Byteback::Util + +opts = Trollop::options do + + banner "Prune old backup directories to ensure there's enough space" + + opt :minpercent, "Start prune when disk has less than this %age free", + :type => :integer, + :default => 5 + + opt :maxpercent, "Stop prune when disk has more than this %age free", + :type => :integer, + :default => 10 + + opt :list, "List backups in pruning order, no other action" + + opt :prune, "Prune the next backup if necessary" + + opt :order, "Order backups by 'age' or 'importance'", + :type => :string, + :default => "importance" + + opt :verbose, "Show debugging messages" + +end + +@order = opts[:order] +@verbose = opts[:verbose] +@do_list = opts[:list] +@do_prune = opts[:prune] +@minpercent = opts[:minpercent] +@maxpercent = opts[:maxpercent] + +fatal("Must specify one of --prune or --list") unless + (@do_prune || @do_list) && + !(@do_prune && @do_list) + +fatal("Must specify --order as 'age' or 'importance'") unless + @order == 'age' || @order == 'importance' + +if BackupDirectory.all.empty? + fatal("No backup directories found, need to run byteback-snapshot") +end + +@df_history = DiskFreeHistory.new(ENV['HOME']) +@df_history.new_reading! + +# Don't do anything if we've not got two hours of readings +# +if @df_history.list.last.time - @df_history.list.first.time < 7200 + warn("Not enough disc space history to make a decision") + exit 0 +end + +gradient_30m = @df_history.gradient(1800) +debug("Disc space gradient over 30m = #{gradient_30m}") +exit + +# Check whether we should still be pruning +# +@free = @df_history.list.last.percent_free +PRUNING_FLAG = "#{ENV['HOME']}/.byteback.pruning" + +if @free <= @minpercent && !File.exists?(PRUNING_FLAG) + warn("Starting prune, #{@free}% free (aiming for #{@maxpercent})") + File.write(PRUNING_FLAG,"") +elsif @free >= @maxpercent && File.exists?(PRUNING_FLAG) + warn("Stopping prune, #{@free}% free") + File.unlink(PRUNING_FLAG) +elsif File.exists?(PRUNING_FLAG) + warn("Continuing prune, #{@free}% free (aiming for #{@maxpercent})") +end + +def snapshots_in_order + list = BackupDirectory.all_snapshots + if list.empty? + warn("Couldn't find any snapshots (yet?)") + end + if @order == 'importance' + Snapshot.sort_by_importance(list) + elsif @order == 'age' + list.sort.reverse + else + raise ArgumentError.new("Unknown snapshot sort method #{method}") + end +end + +snapshots = snapshots_in_order + +if @do_list + print "Backups by #{@order}:\n" + snapshots.each_with_index do |snapshot, index| + print "#{sprintf('% 3d',index)}: #{snapshot.path}\n" + end +end + +exit 0 unless File.exists?(PRUNING_FLAG) && @do_prune + +exit 0 if gradient_30m != 0 + +info("Deleting #{snapshots.last.path}") +log_system("sudo btrfs subvolume delete #{snapshots.last.path}") diff --git a/byteback-snapshot b/byteback-snapshot index 37834d4..a14e0f8 100755 --- a/byteback-snapshot +++ b/byteback-snapshot @@ -14,13 +14,7 @@ opts = Trollop::options do opt :root, "Backups directory (must be a btrfs subvolume)", :type => :string - opt :snapshot, "Take a new snapshot" - - opt :prune, "Prune old backups (by 'age' or 'importance')", - :type => :string - - opt :list, "List backups (by 'age' or 'importance')", - :type => :string + opt :snapshot, "(ignored for compatibility)" opt :verbose, "Print diagnostics" @@ -28,61 +22,17 @@ end @root = opts[:root] @verbose = opts[:verbose] -@do_snapshot = opts[:snapshot] -@do_list = opts[:list] -@do_prune = opts[:prune] - -fatal("Must specify snapshot, prune or list") unless @do_snapshot || @do_prune || @do_list fatal("--root not readable") unless File.directory?("#{@root}") @backups = BackupDirectory.new(@root) -def get_snapshots_by(method) - if method == 'importance' - @backups.snapshot_times_by_importance.reverse # least important first - elsif method == 'age' - @backups.snapshot_times - else - raise ArgumentError.new("Unknown snapshot sort method #{method}") - end -end - -if @do_snapshot - last_snapshot_time = @backups.snapshot_times.last - fatal("Last snapshot was less than six hours ago") unless - !last_snapshot_time || - Time.now - @backups.snapshot_times.last >= 6*60*60 # FIXME: make configurable +last_snapshot_time = @backups.snapshot_times.last +fatal("Last snapshot was less than six hours ago") unless + !last_snapshot_time || + Time.now - @backups.snapshot_times.last >= 6*60*60 # FIXME: make configurable - info "Making new snapshot" - @backups.new_snapshot! -end - -if @do_list - list = get_snapshots_by(@do_list) - print "Backups in #{@root} by #{@do_list}:\n" - list.each_with_index do |time, index| - print "#{sprintf('% 3d',index)}: #{time}\n" - end -end - -if @do_prune - info "Counting last 10 backups" - target_free_space = 1.5 * @backups.average_snapshot_size(10) - info "Want to ensure we have #{target_free_space}" - - if @backups.free >= target_free_space - info "(we have #{@backups.free} so no action needed)" - else - list = get_snapshots_by(@do_prune) - - while @backups.free < target_free_space && !list.empty? - to_delete = list.pop - info "Deleting #{to_delete}" - @backups.delete_snapshot!(to_delete) - info "Leaves us with #{@backups.free}" - end - end -end +info "Making new snapshot" +@backups.new_snapshot! info "Finished" diff --git a/lib/byteback.rb b/lib/byteback.rb index b6c2ab2..be329d1 100644 --- a/lib/byteback.rb +++ b/lib/byteback.rb @@ -1,6 +1,6 @@ require 'trollop' require 'time' require 'byteback/backup_directory' -require 'byteback/disk_free' +require 'byteback/disk_free_history' require 'byteback/util' require 'byteback/log'
\ No newline at end of file diff --git a/lib/byteback/backup_directory.rb b/lib/byteback/backup_directory.rb index f0ceabc..e5ab8c0 100644 --- a/lib/byteback/backup_directory.rb +++ b/lib/byteback/backup_directory.rb @@ -1,13 +1,113 @@ module Byteback + + # Represents a particular timestamped backup directory + class Snapshot + class << self + # What order to remove snapshots in to regain disk space? + # + # Order backups by their closeness to defined backup times, which are + # listed in a set order (i.e. today's backup is more important than yesterday's). + # + BACKUP_IMPORTANCE = [0, 1, 2, 3, 7, 14, 21, 28, 56, 112] + + def sort_by_importance(snapshots_unsorted, now=Time.now) + snapshots_sorted = [] + + # FIXME: takes about a minute to sort 900 items, + # seems like that ought to be quicker than O(n^2) + # + while !snapshots_unsorted.empty? + BACKUP_IMPORTANCE.each do |days| + target_time = now - (days*86400) + closest = snapshots_unsorted.inject(nil) do |best, snapshot| + if best.nil? || (snapshot.time-target_time).abs < (best.time-target_time).abs + snapshot + else + best + end + end + break unless closest + snapshots_sorted << snapshots_unsorted.delete(closest) + end + end + + snapshots_sorted + end + end + + attr_reader :backup_directory, :path + + def initialize(backup_directory, snapshot_path) + @backup_directory = backup_directory + @path = snapshot_path + time # throws ArgumentError if it can't parse + nil + end + + def time + Time.parse(path) + end + + def <=>(b) + time <=> b.time + end + + def create!(from) + system_no_error("btrfs subvolume snapshot #{from} #{path}") + end + + def delete! + system_no_error("btrfs subvolume delete #{path}") + end + + # Returns the size of the given snapshot (runs du, may be slow) + # + # Would much prefer to take advantage of this feature: + # http://dustymabe.com/2013/09/22/btrfs-how-big-are-my-snapshots/ + # but it's not currently in Debian/wheezy. + # + def du + `du -s -b #{path}`.to_i + end + + protected + + def system_no_error(*args) + args[-1] += " > /dev/null" unless @verbose + raise RuntimeError.new("Command failed: "+args.join(" ")) unless + system(*args) + end + end + # Represent a directory full of backups where "current" is a subvolume # which is snapshotted to frozen backup directories called e.g. # "yyyy-mm-ddThh:mm+zzzz". # class BackupDirectory + class << self + # Return all backup directories + # + def all + Dir.new(ENV['HOME']).entries.map do |entry| + next if entry[0] == '.' + name = File.expand_path(ENV['HOME'] + "/" + entry) + File.directory?(name + "/current") ? BackupDirectory.new(name) : nil + end. + compact + end + + # Returns every snapshot in every backup directory + # + def all_snapshots + all.map { |dir| dir.snapshots }.flatten + end + end + attr_reader :dir def initialize(dir) @dir = Dir.new(dir) + raise Errno::ENOENT unless File.directory?(dir) current end @@ -21,83 +121,30 @@ module Byteback # Return an array of Times representing the current list of # snapshots. # - def snapshot_times + def snapshots @dir.entries.map do |entry| + next if entry[0] == '.' || entry == 'current' + snapshot_path = File.expand_path(@dir.path + "/" + entry) + next unless File.directory?(snapshot_path) begin - Time.parse(entry) - rescue ArgumentError => error + Snapshot.new(self, snapshot_path) + rescue ArgumentError => ae + # directory name must represent a parseable Time nil end end. - compact. - sort - end - - # What order to remove snapshots in to regain disk space? - # - # Order backups by their closeness to defined backup times, which are - # listed in a set order (i.e. today's backup is more important than yesterday's). - # - BACKUP_IMPORTANCE = [0, 1, 2, 3, 7, 14, 21, 28, 56, 112] - def snapshot_times_by_importance - now = Time.now - snapshot_times_unsorted = snapshot_times - snapshot_times_sorted = [] - while !snapshot_times_unsorted.empty? - BACKUP_IMPORTANCE.each do |days| - target_time = now + (days*86400) - closest = snapshot_times_unsorted.inject(nil) do |best, time| - if best.nil? || (time-target_time).abs < (best-target_time).abs - time - else - best - end - end - break unless closest - snapshot_times_sorted << snapshot_times_unsorted.delete(closest) - end - end - snapshot_times_sorted - end - - # Returns the size of the given snapshot (runs du, may be slow) - # - # Would much prefer to take advantage of this feature: - # http://dustymabe.com/2013/09/22/btrfs-how-big-are-my-snapshots/ - # but it's not currently in Debian/wheezy. - # - def snapshot_size(time=snapshot_times.last) - `du -s -b #{snapshot_path(time)}`.to_i - end - - def average_snapshot_size(number=10) - snapshot_times.sort[0..number].inject(0) { |total, time| snapshot_size(time) } / number + compact end # Create a new snapshot of 'current' # - def new_snapshot! - system_no_error("btrfs subvolume snapshot -r #{current.path} #{snapshot_path}") - end - - def delete_snapshot!(time) - system_no_error("btrfs subvolume delete #{snapshot_path(time)}") + def new_snapshot!(time = Time.now) + snapshot_path = time.strftime("%Y-%m-%dT%H:%M%z") + Snapshot.new(self, snapshot_path).create!(current.path) end def current Dir.new("#{dir.path}/current") end - - def snapshot_path(time=Time.now) - "#{dir.path}/#{time.strftime("%Y-%m-%dT%H:%M%z")}" - end - - protected - - def system_no_error(*args) - args[-1] += " > /dev/null" unless @verbose - raise RuntimeError.new("Command failed: "+args.join(" ")) unless - system(*args) - end end end diff --git a/lib/byteback/disk_free.rb b/lib/byteback/disk_free.rb deleted file mode 100644 index 33952a3..0000000 --- a/lib/byteback/disk_free.rb +++ /dev/null @@ -1,41 +0,0 @@ - -module Byteback - # Icky way to find out free disc space on our mount - # - class DiskFree - def initialize(mount) - @mount = mount - end - - def total - all[2] - end - - def used - all[3] - end - - def available - all[4] - end - - def fraction_used - disk_device, disk_fs, disk_total, disk_used, disk_available, *rest = all - disk_used.to_f / disk_available - end - - protected - - def all - disk_device, disk_fs, disk_total, disk_used, disk_available, *rest = - df. - split("\n")[1]. - split(/\s+/). - map { |i| /^[0-9]+$/.match(i) ? i.to_i : i } - end - - def df - `/bin/df -T -P -B1 #{@mount}` - end - end -end diff --git a/lib/byteback/disk_free_history.rb b/lib/byteback/disk_free_history.rb new file mode 100755 index 0000000..ba66863 --- /dev/null +++ b/lib/byteback/disk_free_history.rb @@ -0,0 +1,110 @@ +#!/usr/bin/ruby + +require 'sys/filesystem' + +module Byteback + class DiskFreeReading < Struct.new(:fsstat, :time) + def initialize(fsstat,time=Time.now) + self.fsstat = fsstat + self.time = time + end + + # helper method to return %age of disc space free + # + def percent_free + fsstat.blocks_available * 100 / fsstat.blocks + end + end + + # A simple round-robin list to store a short history of a given mount + # point's disk space history. + # + class DiskFreeHistory + MINIMUM_INTERVAL = 5*60 # don't take readings more than 5 mins apart + MAXIMUM_AGE = 7*24*60*60 # delete readings after a week + + # Initialize a new list storing the disc space history for the given + # mount point. + # + def initialize(mountpoint, history_file=nil) + history_file = "#{mountpoint}/.disk_free_history" unless + history_file + @history_file = history_file + @mountpoint = mountpoint + load! + end + + # Take a new reading + # + def new_reading! + reading = DiskFreeReading.new(Sys::Filesystem.stat(@mountpoint)) + + # Don't record a new reading if it's exactly the same as last time, + # and less than the minimum interval. + # + return nil if @list.last && + @list.last.fsstat.blocks_available == reading.fsstat.blocks_available + Time.now - @list.last.time < MINIMUM_INTERVAL + + @list << reading + + save! + end + + def list + load! unless @list + @list + end + + def gradient(last_n_seconds, &value_from_reading) + value_from_reading ||= proc { |r| r.fsstat.blocks_available } + earliest = Time.now - last_n_seconds + + total = 0 + readings = 0 + later_reading = nil + + list.reverse.each do |reading| + if later_reading + difference = + value_from_reading.call(reading) - + value_from_reading.call(later_reading) + total += difference + p difference + end + break if reading.time < earliest + readings += 1 + later_reading = reading + end + + total / readings + end + + protected + + def load! + begin + File.open(@history_file) do |fh| + @list = Marshal.restore(fh.read(1000000)) + end + rescue Errno::ENOENT, TypeError => err + @list = [] + new_reading! + end + end + + def save! + list.shift while Time.now - list.first.time > MAXIMUM_AGE + + tmp = "@history_file.#{$$}.#{rand(9999999999)}" + begin + File.open(tmp, "w") do |fh| + fh.write(Marshal.dump(list)) + File.rename(tmp, @history_file) + end + ensure + File.unlink(tmp) if File.exists?(tmp) + end + end + end +end diff --git a/lib/sys/filesystem.rb b/lib/sys/filesystem.rb new file mode 100644 index 0000000..34b2d3e --- /dev/null +++ b/lib/sys/filesystem.rb @@ -0,0 +1,591 @@ +require 'ffi' +require 'rbconfig' + +# The Sys module serves as a namespace only. +module Sys + # The Filesystem class serves as an abstract base class. Its methods + # return objects of other types. Do not instantiate. + class Filesystem + extend FFI::Library + ffi_lib FFI::Library::LIBC + + # The version of the sys-filesystem library. + VERSION = '1.1.3' + + private + + if RbConfig::CONFIG['host_os'] =~ /sunos|solaris/i + attach_function(:statvfs, :statvfs64, [:string, :pointer], :int) + else + attach_function(:statvfs, [:string, :pointer], :int) + end + + attach_function(:strerror, [:int], :string) + + private_class_method :statvfs, :strerror + + begin + if RbConfig::CONFIG['host_os'] =~ /sunos|solaris/i + attach_function(:fopen, [:string, :string], :pointer) + attach_function(:fclose, [:pointer], :int) + attach_function(:getmntent, [:pointer, :pointer], :int) + private_class_method :fopen, :fclose, :getmntent + else + attach_function(:getmntent, [:pointer], :pointer) + attach_function(:setmntent, [:string, :string], :pointer) + attach_function(:endmntent, [:pointer], :int) + private_class_method :getmntent, :setmntent, :endmntent + end + rescue FFI::NotFoundError + if RbConfig::CONFIG['host_os'] =~ /darwin|osx|mach/i + attach_function(:getmntinfo, :getmntinfo64, [:pointer, :int], :int) + else + attach_function(:getmntinfo, [:pointer, :int], :int) + end + private_class_method :getmntinfo + end + + MNT_RDONLY = 0x00000001 # read only filesystem + MNT_SYNCHRONOUS = 0x00000002 # file system written synchronously + MNT_NOEXEC = 0x00000004 # can't exec from filesystem + MNT_NOSUID = 0x00000008 # don't honor setuid bits on fs + MNT_NODEV = 0x00000010 # don't interpret special files + MNT_UNION = 0x00000020 # union with underlying filesystem + MNT_ASYNC = 0x00000040 # file system written asynchronously + MNT_CPROTECT = 0x00000080 # file system supports content protection + MNT_EXPORTED = 0x00000100 # file system is exported + MNT_QUARANTINE = 0x00000400 # file system is quarantined + MNT_LOCAL = 0x00001000 # filesystem is stored locally + MNT_QUOTA = 0x00002000 # quotas are enabled on filesystem + MNT_ROOTFS = 0x00004000 # identifies the root filesystem + MNT_DOVOLFS = 0x00008000 # FS supports volfs (deprecated) + MNT_DONTBROWSE = 0x00100000 # FS is not appropriate path to user data + MNT_IGNORE_OWNERSHIP = 0x00200000 # VFS will ignore ownership info on FS objects + MNT_AUTOMOUNTED = 0x00400000 # filesystem was mounted by automounter + MNT_JOURNALED = 0x00800000 # filesystem is journaled + MNT_NOUSERXATTR = 0x01000000 # Don't allow user extended attributes + MNT_DEFWRITE = 0x02000000 # filesystem should defer writes + MNT_MULTILABEL = 0x04000000 # MAC support for individual labels + MNT_NOATIME = 0x10000000 # disable update of file access time + + MNT_VISFLAGMASK = ( + MNT_RDONLY | MNT_SYNCHRONOUS | MNT_NOEXEC | + MNT_NOSUID | MNT_NODEV | MNT_UNION | + MNT_ASYNC | MNT_EXPORTED | MNT_QUARANTINE | + MNT_LOCAL | MNT_QUOTA | + MNT_ROOTFS | MNT_DOVOLFS | MNT_DONTBROWSE | + MNT_IGNORE_OWNERSHIP | MNT_AUTOMOUNTED | MNT_JOURNALED | + MNT_NOUSERXATTR | MNT_DEFWRITE | MNT_MULTILABEL | + MNT_NOATIME | MNT_CPROTECT + ) + + @@opt_names = { + MNT_RDONLY => 'read-only', + MNT_SYNCHRONOUS => 'synchronous', + MNT_NOEXEC => 'noexec', + MNT_NOSUID => 'nosuid', + MNT_NODEV => 'nodev', + MNT_UNION => 'union', + MNT_ASYNC => 'asynchronous', + MNT_CPROTECT => 'content-protection', + MNT_EXPORTED => 'exported', + MNT_QUARANTINE => 'quarantined', + MNT_LOCAL => 'local', + MNT_QUOTA => 'quotas', + MNT_ROOTFS => 'rootfs', + MNT_DONTBROWSE => 'nobrowse', + MNT_IGNORE_OWNERSHIP => 'noowners', + MNT_AUTOMOUNTED => 'automounted', + MNT_JOURNALED => 'journaled', + MNT_NOUSERXATTR => 'nouserxattr', + MNT_DEFWRITE => 'defwrite', + MNT_NOATIME => 'noatime' + } + + # File used to read mount informtion from. + if File.exist?('/etc/mtab') + MOUNT_FILE = '/etc/mtab' + elsif File.exist?('/etc/mnttab') + MOUNT_FILE = '/etc/mnttab' + else + MOUNT_FILE = 'getmntinfo' + end + + class Statfs < FFI::Struct + if RbConfig::CONFIG['host_os'] =~ /bsd/i + layout( + :f_version, :uint32, + :f_type, :uint32, + :f_flags, :uint64, + :f_bsize, :uint64, + :f_iosize, :int64, + :f_blocks, :uint64, + :f_bfree, :uint64, + :f_bavail, :int64, + :f_files, :uint64, + :f_ffree, :uint64, + :f_syncwrites, :uint64, + :f_asyncwrites, :uint64, + :f_syncreads, :uint64, + :f_asyncreads, :uint64, + :f_spare, [:uint64, 10], + :f_namemax, :uint32, + :f_owner, :int32, + :f_fsid, [:int32, 2], + :f_charspare, [:char, 80], + :f_fstypename, [:char, 16], + :f_mntfromname, [:char, 88], + :f_mntonname, [:char, 88] + ) + else + layout( + :f_bsize, :uint32, + :f_iosize, :int32, + :f_blocks, :uint64, + :f_bfree, :uint64, + :f_bavail, :uint64, + :f_files, :uint64, + :f_ffree, :uint64, + :f_fsid, [:int32, 2], + :f_owner, :int32, + :f_type, :uint32, + :f_flags, :uint32, + :f_fssubtype, :uint32, + :f_fstypename, [:char, 16], + :f_mntonname, [:char, 1024], + :f_mntfromname, [:char, 1024], + :f_reserved, [:uint32, 8] + ) + end + end + + # The Statvfs struct represents struct statvfs from sys/statvfs.h. + class Statvfs < FFI::Struct + if RbConfig::CONFIG['host_os'] =~ /darwin|osx|mach/i + layout( + :f_bsize, :ulong, + :f_frsize, :ulong, + :f_blocks, :uint, + :f_bfree, :uint, + :f_bavail, :uint, + :f_files, :uint, + :f_ffree, :uint, + :f_favail, :uint, + :f_fsid, :ulong, + :f_flag, :ulong, + :f_namemax, :ulong + ) + elsif RbConfig::CONFIG['host'] =~ /bsd/i + layout( + :f_bavail, :uint64, + :f_bfree, :uint64, + :f_blocks, :uint64, + :f_favail, :uint64, + :f_ffree, :uint64, + :f_files, :uint64, + :f_bsize, :ulong, + :f_flag, :ulong, + :f_frsize, :ulong, + :f_fsid, :ulong, + :f_namemax, :ulong + ) + elsif RbConfig::CONFIG['host'] =~ /sunos|solaris/i + layout( + :f_bsize, :ulong, + :f_frsize, :ulong, + :f_blocks, :uint64_t, + :f_bfree, :uint64_t, + :f_bavail, :uint64_t, + :f_files, :uint64_t, + :f_ffree, :uint64_t, + :f_favail, :uint64_t, + :f_fsid, :ulong, + :f_basetype, [:char, 16], + :f_flag, :ulong, + :f_namemax, :ulong, + :f_fstr, [:char, 32], + :f_filler, [:ulong, 16] + ) + else + layout( + :f_bsize, :ulong, + :f_frsize, :ulong, + :f_blocks, :ulong, + :f_bfree, :ulong, + :f_bavail, :ulong, + :f_files, :ulong, + :f_ffree, :ulong, + :f_favail, :ulong, + :f_fsid, :ulong, + :f_flag, :ulong, + :f_namemax, :ulong, + :f_ftype, :ulong, + :f_basetype, [:char, 16], + :f_str, [:char, 16] + ) + end + end + + # The Mnttab struct represents struct mnnttab from sys/mnttab.h on Solaris. + class Mnttab < FFI::Struct + layout( + :mnt_special, :string, + :mnt_mountp, :string, + :mnt_fstype, :string, + :mnt_mntopts, :string, + :mnt_time, :string + ) + end + + # The Mntent struct represents struct mntent from sys/mount.h on Unix. + class Mntent < FFI::Struct + layout( + :mnt_fsname, :string, + :mnt_dir, :string, + :mnt_type, :string, + :mnt_opts, :string, + :mnt_freq, :int, + :mnt_passno, :int + ) + end + + public + + # The error raised if any of the Filesystem methods fail. + class Error < StandardError; end + + # Stat objects are returned by the Sys::Filesystem.stat method. + class Stat + # Read-only filesystem + RDONLY = 1 + + # Filesystem does not support suid or sgid semantics. + NOSUID = 2 + + # Filesystem does not truncate file names longer than +name_max+. + NOTRUNC = 3 + + # The path of the filesystem. + attr_accessor :path + + # The preferred system block size. + attr_accessor :block_size + + # The fragment size, i.e. fundamental filesystem block size. + attr_accessor :fragment_size + + # The total number of +fragment_size+ blocks in the filesystem. + attr_accessor :blocks + + # The total number of free blocks in the filesystem. + attr_accessor :blocks_free + + # The number of free blocks available to unprivileged processes. + attr_accessor :blocks_available + + # The total number of files/inodes that can be created. + attr_accessor :files + + # The total number of files/inodes on the filesystem. + attr_accessor :files_free + + # The number of free files/inodes available to unprivileged processes. + attr_accessor :files_available + + # The filesystem identifier. + attr_accessor :filesystem_id + + # A bit mask of flags. + attr_accessor :flags + + # The maximum length of a file name permitted on the filesystem. + attr_accessor :name_max + + # The filesystem type, e.g. UFS. + attr_accessor :base_type + + alias inodes files + alias inodes_free files_free + alias inodes_available files_available + + # Creates a new Sys::Filesystem::Stat object. This is meant for + # internal use only. Do not instantiate directly. + # + def initialize + @path = nil + @block_size = nil + @fragment_size = nil + @blocks = nil + @blocks_free = nil + @blocks_available = nil + @files = nil + @files_free = nil + @files_available = nil + @filesystem_id = nil + @flags = nil + @name_max = nil + @base_type = nil + end + + # Returns the total space on the partition. + def bytes_total + blocks * block_size + end + + # Returns the total amount of free space on the partition. + def bytes_free + blocks_available * block_size + end + + # Returns the total amount of used space on the partition. + def bytes_used + bytes_total - bytes_free + end + + # Returns the percentage of the partition that has been used. + def percent_used + 100 - (100.0 * bytes_free.to_f / bytes_total.to_f) + end + end + + # Mount objects are returned by the Sys::Filesystem.mounts method. + class Mount + # The name of the mounted resource. + attr_accessor :name + + # The mount point/directory. + attr_accessor :mount_point + + # The type of filesystem mount, e.g. ufs, nfs, etc. + attr_accessor :mount_type + + # A list of comma separated options for the mount, e.g. nosuid, etc. + attr_accessor :options + + # The time the filesystem was mounted. May be nil. + attr_accessor :mount_time + + # The dump frequency in days. May be nil. + attr_accessor :dump_frequency + + # The pass number of the filessytem check. May be nil. + attr_accessor :pass_number + + alias fsname name + alias dir mount_point + alias opts options + alias passno pass_number + alias freq dump_frequency + + # Creates a Sys::Filesystem::Mount object. This is meant for internal + # use only. Do no instantiate directly. + # + def initialize + @name = nil + @mount_point = nil + @mount_type = nil + @options = nil + @mount_time = nil + @dump_frequency = nil + @pass_number = nil + end + end + + # Returns a Sys::Filesystem::Stat object containing information about the + # +path+ on the filesystem. + # + def self.stat(path) + fs = Statvfs.new + + if statvfs(path, fs) < 0 + raise Error, 'statvfs() function failed: ' + strerror(FFI.errno) + end + + obj = Sys::Filesystem::Stat.new + obj.path = path + obj.block_size = fs[:f_bsize] + obj.fragment_size = fs[:f_frsize] + obj.blocks = fs[:f_blocks] + obj.blocks_free = fs[:f_bfree] + obj.blocks_available = fs[:f_bavail] + obj.files = fs[:f_files] + obj.files_free = fs[:f_ffree] + obj.files_available = fs[:f_favail] + obj.filesystem_id = fs[:f_fsid] + obj.flags = fs[:f_flag] + obj.name_max = fs[:f_namemax] + + # OSX does things a little differently + if RbConfig::CONFIG['host_os'] =~ /darwin|osx|mach/i + obj.block_size /= 256 + end + + if fs.members.include?(:f_basetype) + obj.base_type = fs[:f_basetype].to_s + end + + obj.freeze + end + + # In block form, yields a Sys::Filesystem::Mount object for each mounted + # filesytem on the host. Otherwise it returns an array of Mount objects. + # + # Example: + # + # Sys::Filesystem.mounts{ |fs| + # p fs.name # => '/dev/dsk/c0t0d0s0' + # p fs.mount_time # => Thu Dec 11 15:07:23 -0700 2008 + # p fs.mount_type # => 'ufs' + # p fs.mount_point # => '/' + # p fs.options # => local, noowner, nosuid + # } + # + def self.mounts + array = block_given? ? nil : [] + + if respond_to?(:getmntinfo, true) + buf = FFI::MemoryPointer.new(:pointer) + + num = getmntinfo(buf, 2) + + if num == 0 + raise Error, 'getmntinfo() function failed: ' + strerror(FFI.errno) + end + + ptr = buf.get_pointer(0) + + num.times{ |i| + mnt = Statfs.new(ptr) + obj = Sys::Filesystem::Mount.new + obj.name = mnt[:f_mntfromname].to_s + obj.mount_point = mnt[:f_mntonname].to_s + obj.mount_type = mnt[:f_fstypename].to_s + + string = "" + flags = mnt[:f_flags] & MNT_VISFLAGMASK + + @@opt_names.each{ |key,val| + if flags & key > 0 + if string.empty? + string << val + else + string << ", #{val}" + end + end + flags &= ~key + } + + obj.options = string + + if block_given? + yield obj.freeze + else + array << obj.freeze + end + + ptr += Statfs.size + } + else + begin + if respond_to?(:setmntent, true) + fp = setmntent(MOUNT_FILE, 'r') + else + fp = fopen(MOUNT_FILE, 'r') + end + + if RbConfig::CONFIG['host_os'] =~ /sunos|solaris/i + mt = Mnttab.new + while getmntent(fp, mt) == 0 + obj = Sys::Filesystem::Mount.new + obj.name = mt[:mnt_special].to_s + obj.mount_point = mt[:mnt_mountp].to_s + obj.mount_type = mt[:mnt_fstype].to_s + obj.options = mt[:mnt_mntopts].to_s + obj.mount_time = Time.at(Integer(mt[:mnt_time])) + + if block_given? + yield obj.freeze + else + array << obj.freeze + end + end + else + while ptr = getmntent(fp) + break if ptr.null? + mt = Mntent.new(ptr) + + obj = Sys::Filesystem::Mount.new + obj.name = mt[:mnt_fsname] + obj.mount_point = mt[:mnt_dir] + obj.mount_type = mt[:mnt_type] + obj.options = mt[:mnt_opts] + obj.mount_time = nil + obj.dump_frequency = mt[:mnt_freq] + obj.pass_number = mt[:mnt_passno] + + if block_given? + yield obj.freeze + else + array << obj.freeze + end + end + end + ensure + if fp && !fp.null? + if respond_to?(:endmntent, true) + endmntent(fp) + else + fclose(fp) + end + end + end + end + + array + end + + # Returns the mount point of the given +file+, or itself if it cannot + # be found. + # + # Example: + # + # Sys::Filesystem.mount_point('/home/some_user') # => /home + # + def self.mount_point(file) + dev = File.stat(file).dev + val = file + + self.mounts.each{ |mnt| + mp = mnt.mount_point + if File.stat(mp).dev == dev + val = mp + break + end + } + + val + end + end +end + +class Numeric + # Converts a number to kilobytes. + def to_kb + self / 1024 + end + + # Converts a number to megabytes. + def to_mb + self / 1048576 + end + + # Converts a number to gigabytes. + def to_gb + self / 1073741824 + end + + # Converts a number to terabytes. + def to_tb + self / 1099511627776 + end +end |