summaryrefslogtreecommitdiff
path: root/lib/byteback
diff options
context:
space:
mode:
authorMatthew Bloch <matthew@bytemark.co.uk>2014-10-31 02:43:35 +0000
committerMatthew Bloch <matthew@bytemark.co.uk>2014-10-31 02:43:35 +0000
commit1238c74fa01c009d7f76327f3beb30fee4b9f98f (patch)
tree2e0e0abde1b35f03ef515acc9ebd57f638af1c25 /lib/byteback
parentf23c6c91e2e2a8eb4154ec545199a5ecbe5136a1 (diff)
Refactored to improve logging and reduce cut & paste code, bumped Debian version number.
Diffstat (limited to 'lib/byteback')
-rwxr-xr-xlib/byteback/backup_directory.rb103
-rwxr-xr-xlib/byteback/disk_free.rb41
-rwxr-xr-xlib/byteback/log.rb46
-rwxr-xr-xlib/byteback/util.rb68
4 files changed, 258 insertions, 0 deletions
diff --git a/lib/byteback/backup_directory.rb b/lib/byteback/backup_directory.rb
new file mode 100755
index 0000000..f0ceabc
--- /dev/null
+++ b/lib/byteback/backup_directory.rb
@@ -0,0 +1,103 @@
+module Byteback
+ # 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
+ attr_reader :dir
+
+ def initialize(dir)
+ @dir = Dir.new(dir)
+ current
+ end
+
+ # Return total amount of free space in backup directory (bytes)
+ #
+ def free
+ df = DiskFree.new(@dir.path)
+ df.total - df.used
+ end
+
+ # Return an array of Times representing the current list of
+ # snapshots.
+ #
+ def snapshot_times
+ @dir.entries.map do |entry|
+ begin
+ Time.parse(entry)
+ rescue ArgumentError => error
+ 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
+ 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)}")
+ 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
new file mode 100755
index 0000000..33952a3
--- /dev/null
+++ b/lib/byteback/disk_free.rb
@@ -0,0 +1,41 @@
+
+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/log.rb b/lib/byteback/log.rb
new file mode 100755
index 0000000..66b86ef
--- /dev/null
+++ b/lib/byteback/log.rb
@@ -0,0 +1,46 @@
+require 'logger'
+require 'syslog'
+
+module Byteback
+ # Translates Ruby's Logger calls to similar calls to Syslog
+ # (implemented in Ruby 2.0 as Syslog::Logger)
+ #
+ class SyslogProxy
+ class << self
+ def debug(*a); Syslog.log(Syslog::LOG_DEBUG, *a); end
+ def info(*a); Syslog.log(Syslog::LOG_INFO, *a); end
+ def warn(*a); Syslog.log(Syslog::LOG_WARN, *a); end
+ def error(*a); Syslog.log(Syslog::LOG_ERR, *a); end
+ def fatal(*a); Syslog.log(Syslog::LOG_EMERG, *a); end
+ end
+ end
+
+ # Log proxy class that we can include in our scripts for some simple
+ # logging defaults.
+ #
+ module Log
+ @@me = File.expand_path($0).split("/").last
+
+ @@logger = if STDIN.tty? && !ENV['BYTEBACK_TO_SYSLOG']
+ logger = Logger.new(STDERR)
+ logger.level = Logger::DEBUG
+ logger.formatter = proc { |severity, datetime, progname, msg|
+ if severity == "FATAL" || severity == "ERROR"
+ "*** #{msg}\n"
+ else
+ "#{msg}\n"
+ end
+ }
+ logger
+ else
+ Syslog.open(@@me)
+ SyslogProxy
+ end
+
+ def debug(*a); @@logger.__send__(:debug, *a); end
+ def info(*a); @@logger.__send__(:info, *a); end
+ def warn(*a); @@logger.__send__(:warn, *a); end
+ def fatal(*a); @@logger.__send__(:fatal, *a); exit 1; end
+ def error(*a); @@logger.__send__(:error, *a); end
+ end
+end
diff --git a/lib/byteback/util.rb b/lib/byteback/util.rb
new file mode 100755
index 0000000..f9f6c62
--- /dev/null
+++ b/lib/byteback/util.rb
@@ -0,0 +1,68 @@
+require 'tempfile'
+
+module Byteback
+ module Util
+ @@lockfile = "/var/run/byteback/byteback.lock"
+
+ def remove_lockfile!
+ begin
+ File.unlink(@@lockfile)
+ rescue Errno::ENOENT
+ end
+ end
+
+ def claim_lockfile!
+ # Check the lockfile first
+ if File.directory?(File.dirname(@@lockfile))
+ if File.exists? @@lockfile
+ # check the lockfile is sane
+ exist_pid = File.read(@@lockfile).to_i
+ if exist_pid > 1 and exist_pid < (File.read("/proc/sys/kernel/pid_max").to_i)
+ begin
+ Process.getpgid(exist_pid)
+ # if no exception, process is running, abort
+ fatal("Process is running (#{exist_pid} from #{@@lockfile})")
+ rescue Errno::ESRCH
+ # no process running with that pid, pidfile is stale
+ remove_lockfile!
+ end
+ else
+ # lockfile isn't sane, remove it and continue
+ remove_lockfile!
+ end
+ end
+ else
+ Dir.mkdir(File.dirname(@@lockfile))
+ # lockfile didn't exist so just carry on
+ end
+
+ # Own the pidfile ourselves
+ File.open(@@lockfile, "w") do |lockfile|
+ lockfile.puts Process::pid
+ end
+ end
+
+ def lock_out_other_processes(name)
+ @@lockfile = "/var/run/byteback/#{name}.lock"
+ claim_lockfile!
+ at_exit { remove_lockfile! }
+ end
+
+ def log_system(*args)
+ debug("system: " + args.map { |a| / /.match(a) ? "\"#{a}\"" : a }.join(" "))
+ rd, wr = IO.pipe
+ pid = fork
+ if pid.nil? # child
+ rd.close
+ STDOUT.reopen(wr)
+ STDERR.reopen(wr)
+ # any cleanup actually necessary here?
+ exec(*args)
+ end
+ wr.close
+ rd.each_line { |line| debug(line.chomp) }
+ pid2, status = Process.waitpid2(pid, 0)
+ status.exitstatus
+ end
+ end
+end