diff options
Diffstat (limited to 'lib/byteback')
| -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 | 
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 | 
