summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rwxr-xr-xREADME.md8
-rwxr-xr-xTODO.md2
-rwxr-xr-xbyteback-prune112
-rwxr-xr-xbyteback-snapshot64
-rw-r--r--lib/byteback.rb2
-rw-r--r--lib/byteback/backup_directory.rb175
-rw-r--r--lib/byteback/disk_free.rb41
-rwxr-xr-xlib/byteback/disk_free_history.rb110
-rw-r--r--lib/sys/filesystem.rb591
9 files changed, 942 insertions, 163 deletions
diff --git a/README.md b/README.md
index 0773d05..325fb7a 100755
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/TODO.md b/TODO.md
index b1d05f0..351ec22 100755
--- a/TODO.md
+++ b/TODO.md
@@ -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