summaryrefslogtreecommitdiff
path: root/lib/byteback
diff options
context:
space:
mode:
Diffstat (limited to 'lib/byteback')
-rw-r--r--lib/byteback/backup_directory.rb175
-rw-r--r--lib/byteback/disk_free.rb41
-rwxr-xr-xlib/byteback/disk_free_history.rb110
3 files changed, 221 insertions, 105 deletions
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