paparazzi 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/README.md +29 -4
- data/lib/paparazzi/camera.rb +31 -21
- data/lib/paparazzi/version.rb +1 -1
- data/test/unit/camera_test.rb +28 -12
- metadata +4 -4
data/README.md
CHANGED
@@ -10,8 +10,8 @@ to previously existing unchanged files, allowing for frequent backups to be made
|
|
10
10
|
directories (see 'How it works' below). Older snapshots maintain versions of files as they existed
|
11
11
|
at the time that snapshot was made.
|
12
12
|
|
13
|
-
Paparazzi automatically purges out old
|
14
|
-
|
13
|
+
Paparazzi automatically purges out old snapshots, and allows you to define how many of each snapshot
|
14
|
+
to keep in reserve.
|
15
15
|
|
16
16
|
Installation
|
17
17
|
------------
|
@@ -30,11 +30,36 @@ Create a ruby script that you'll run hourly from a cron.
|
|
30
30
|
settings = {
|
31
31
|
:source => '/full/path/to/source/directory/', # note the trailing '/'
|
32
32
|
:destination => '/mnt/external_drive/backup_folder',
|
33
|
-
:rsync_flags => '-L
|
34
|
-
}
|
33
|
+
:rsync_flags => '-L --exclude lost+found'
|
34
|
+
}
|
35
35
|
|
36
36
|
Paparazzi::Camera.trigger(settings)
|
37
37
|
|
38
|
+
|
39
|
+
Available Settings
|
40
|
+
------------------
|
41
|
+
|
42
|
+
* `:source` : **required** The source folder to be backed up. Trailing '/' recommended. See rsync manpage
|
43
|
+
for explanation of trailing '/'
|
44
|
+
* `:destination` : **required** The destination folder for backups to be written to, preferably on a different
|
45
|
+
physical drive.
|
46
|
+
* `:reserves` : A hash of snapshot intervals and number of snapshots of each to keep before purging.
|
47
|
+
default: {:hourly => 24, :daily => 7, :weekly => 5, :monthly => 12, :yearly => 9999}
|
48
|
+
* `:rsync_flags` : additional flags to pass to rsync. Paparazzi uses -aq, --delete, & --link_dest, plus
|
49
|
+
whatever you add. The author suggests considering -L and --exclude.
|
50
|
+
|
51
|
+
|
52
|
+
Supported Operating Systems
|
53
|
+
---------------------------
|
54
|
+
|
55
|
+
Paparazzi is developed and tested on Ubuntu Linux and should work fine on all other flavors. As of version 0.1.1, all
|
56
|
+
tests reportedly passed on Max OSX, and it is expected that Paparazzi will probably run very well in that environment
|
57
|
+
(although it is not actively tested, so run at your own risk).
|
58
|
+
|
59
|
+
It is highly unlikely that Paparazzi will run on an MS Windows based machine without a whole lot of TLC by the user,
|
60
|
+
if it runs at all. At a minimum, the user will need to get rsync installed on the machine, possibly through cygwin,
|
61
|
+
but this is not supported at all by the author and users are entirely on their own.
|
62
|
+
|
38
63
|
|
39
64
|
How it works.
|
40
65
|
-------------
|
data/lib/paparazzi/camera.rb
CHANGED
@@ -1,19 +1,19 @@
|
|
1
1
|
require 'admit_one'
|
2
2
|
require 'yaml'
|
3
3
|
require 'fileutils'
|
4
|
+
require 'digest/md5'
|
4
5
|
|
5
6
|
module Paparazzi
|
6
7
|
class Camera
|
7
|
-
FREQUENCIES = [:hourly,:daily,:weekly,:monthly,:yearly]
|
8
8
|
REQUIRED_SETTINGS = [:source,:destination]
|
9
9
|
|
10
10
|
class << self
|
11
|
-
attr_accessor :source, :destination, :rsync_flags
|
11
|
+
attr_accessor :source, :destination, :rsync_flags, :reserves
|
12
12
|
|
13
13
|
def trigger(settings = {})
|
14
14
|
validate_and_cache_settings(settings)
|
15
|
-
AdmitOne::LockFile.new(
|
16
|
-
|
15
|
+
AdmitOne::LockFile.new("paparazzi-#{Digest::MD5.hexdigest(destination)}") do
|
16
|
+
setup
|
17
17
|
purge_old_snapshots
|
18
18
|
make_snapshots
|
19
19
|
end
|
@@ -25,7 +25,7 @@ module Paparazzi
|
|
25
25
|
#######
|
26
26
|
|
27
27
|
def validate_and_cache_settings(settings)
|
28
|
-
[:source,:destination,:rsync_flags].each do |setting_name|
|
28
|
+
[:source,:destination,:rsync_flags,:reserves].each do |setting_name|
|
29
29
|
if REQUIRED_SETTINGS.include?(setting_name) and settings[setting_name].nil?
|
30
30
|
raise MissingSettingError, "#{setting_name} is required"
|
31
31
|
else
|
@@ -40,18 +40,18 @@ module Paparazzi
|
|
40
40
|
raise MissingFolderError, destination unless File.exist?(destination) and File.directory?(destination)
|
41
41
|
end
|
42
42
|
|
43
|
-
def
|
43
|
+
def setup
|
44
44
|
@previous_snapshot_name = {}
|
45
|
-
|
45
|
+
frequencies.each do |frequency|
|
46
46
|
Dir.mkdir(destination(frequency)) unless File.exists?(destination(frequency)) && File.directory?(destination(frequency))
|
47
47
|
|
48
48
|
full_path = Dir[destination(frequency)+'/*'].sort{|a,b| File.ctime(b) <=> File.ctime(a) }.first
|
49
49
|
@previous_snapshot_name[frequency] = full_path ? File.basename(full_path) : ''
|
50
50
|
end
|
51
51
|
|
52
|
-
if @previous_snapshot_name[
|
53
|
-
File.rename(previous_snapshot(
|
54
|
-
@previous_snapshot_name[
|
52
|
+
if @previous_snapshot_name[frequencies.first] != last_successful_snapshot and !last_successful_snapshot.nil? and File.exists?(destination(frequencies.first) + '/' + last_successful_snapshot)
|
53
|
+
File.rename(previous_snapshot(frequencies.first), current_snapshot(frequencies.first))
|
54
|
+
@previous_snapshot_name[frequencies.first] = last_successful_snapshot
|
55
55
|
end
|
56
56
|
end
|
57
57
|
|
@@ -59,20 +59,20 @@ module Paparazzi
|
|
59
59
|
frequency.nil? ? @destination : "#{@destination}/#{frequency}"
|
60
60
|
end
|
61
61
|
|
62
|
-
def
|
63
|
-
@
|
62
|
+
def last_successful_snapshot
|
63
|
+
@last_successful_snapshot ||= File.exists?("#{destination}/.paparazzi.yml") ? YAML.load_file("#{destination}/.paparazzi.yml")[:last_successful_snapshot] : nil
|
64
64
|
end
|
65
65
|
|
66
|
-
def
|
66
|
+
def last_successful_snapshot=(string)
|
67
67
|
cached_data ||= File.exists?("#{destination}/.paparazzi.yml") ? YAML.load_file("#{destination}/.paparazzi.yml") : {}
|
68
68
|
cached_data[:last_successful_snapshot] = string
|
69
69
|
File.open("#{destination}/.paparazzi.yml", 'w') {|file| file.write(cached_data.to_yaml) }
|
70
70
|
end
|
71
71
|
|
72
72
|
def purge_old_snapshots
|
73
|
-
|
74
|
-
|
75
|
-
while Dir[destination(frequency)+'/*'].size >
|
73
|
+
frequencies.each do |frequency|
|
74
|
+
#raise RuntimeError, frequency if reserves[frequency].nil?
|
75
|
+
while Dir[destination(frequency)+'/*'].size > (reserves[frequency]-1)
|
76
76
|
full_path = Dir[destination(frequency)+'/*'].sort{|a,b| File.ctime(a) <=> File.ctime(b) }.first
|
77
77
|
FileUtils.rm_rf(full_path)
|
78
78
|
end
|
@@ -80,14 +80,14 @@ module Paparazzi
|
|
80
80
|
end
|
81
81
|
|
82
82
|
def make_snapshots
|
83
|
-
|
83
|
+
frequencies.each do |frequency|
|
84
84
|
Dir.mkdir(current_snapshot(frequency)) unless File.exists?(current_snapshot(frequency))
|
85
|
-
if frequency ==
|
85
|
+
if frequency == frequencies.first and previous_snapshot_name(frequency) == ''
|
86
86
|
system 'rsync', *(['-aq', '--delete'] + rsync_flags + [source, current_snapshot(frequency)])
|
87
|
-
self.
|
87
|
+
self.last_successful_snapshot = current_snapshot_name(frequency)
|
88
88
|
elsif previous_snapshot_name(frequency) != current_snapshot_name(frequency)
|
89
89
|
system 'rsync', *(['-aq', '--delete', "--link-dest=#{link_destination(frequency)}"] + rsync_flags + [source, current_snapshot(frequency)])
|
90
|
-
self.
|
90
|
+
self.last_successful_snapshot = current_snapshot_name(frequencies.first)
|
91
91
|
end
|
92
92
|
end
|
93
93
|
end
|
@@ -116,7 +116,17 @@ module Paparazzi
|
|
116
116
|
end
|
117
117
|
|
118
118
|
def link_destination(frequency)
|
119
|
-
frequency ==
|
119
|
+
frequency == frequencies.first ? "../#{previous_snapshot_name(frequencies.first)}" : "../../#{frequencies.first}/#{current_snapshot_name(frequencies.first)}"
|
120
|
+
end
|
121
|
+
|
122
|
+
def reserves
|
123
|
+
@reserves ||= {:hourly => 24, :daily => 7, :weekly => 5, :monthly => 12, :yearly => 9999}
|
124
|
+
end
|
125
|
+
|
126
|
+
def frequencies
|
127
|
+
reserves.keys.select{ |key| reserves[key] > 0 }.sort{ |a,b|
|
128
|
+
[:hourly,:daily,:weekly,:monthly,:yearly].find_index(a) <=> [:hourly,:daily,:weekly,:monthly,:yearly].find_index(b)
|
129
|
+
}
|
120
130
|
end
|
121
131
|
|
122
132
|
end
|
data/lib/paparazzi/version.rb
CHANGED
data/test/unit/camera_test.rb
CHANGED
@@ -3,9 +3,10 @@ require 'test/unit'
|
|
3
3
|
require 'fileutils'
|
4
4
|
|
5
5
|
class CameraTest < Test::Unit::TestCase
|
6
|
+
FREQUENCIES = [:hourly,:daily,:weekly,:monthly,:yearly]
|
6
7
|
|
7
8
|
def setup
|
8
|
-
|
9
|
+
FREQUENCIES.each do |frequency|
|
9
10
|
FileUtils.rm_rf("#{destination}/#{frequency}")
|
10
11
|
end
|
11
12
|
FileUtils.rm_rf("#{destination}/.paparazzi.yml")
|
@@ -31,18 +32,18 @@ class CameraTest < Test::Unit::TestCase
|
|
31
32
|
end
|
32
33
|
|
33
34
|
def test_should_create_frequency_folders_on_initialization
|
34
|
-
|
35
|
+
FREQUENCIES.each do |frequency|
|
35
36
|
assert(!File.exists?("#{destination}/#{frequency}"))
|
36
37
|
end
|
37
38
|
Paparazzi::Camera.trigger(default_test_settings)
|
38
|
-
|
39
|
+
FREQUENCIES.each do |frequency|
|
39
40
|
assert(File.exists?("#{destination}/#{frequency}"))
|
40
41
|
end
|
41
42
|
end
|
42
43
|
|
43
44
|
def test_should_make_all_first_snapshots_of_source
|
44
45
|
Paparazzi::Camera.trigger(default_test_settings)
|
45
|
-
|
46
|
+
FREQUENCIES.each do |frequency|
|
46
47
|
assert(Dir["#{destination}/#{frequency}/*"].size > 0,"#{destination}/#{frequency}/ is empty")
|
47
48
|
file = File.open(%Q{#{Dir["#{destination}/#{frequency}/*"].first}/test.txt})
|
48
49
|
assert_equal('This is a test, this is only a test.',file.gets)
|
@@ -54,20 +55,22 @@ class CameraTest < Test::Unit::TestCase
|
|
54
55
|
assert(YAML.load_file("#{destination}/.paparazzi.yml")[:last_successful_snapshot].match(/\d{4}-\d{2}-\d{2}\.\d{2}/))
|
55
56
|
end
|
56
57
|
|
57
|
-
def
|
58
|
+
def test_should_purge_out_expired_snapshots
|
58
59
|
Dir.mkdir("#{destination}/weekly")
|
59
|
-
(1
|
60
|
+
Dir.mkdir("#{destination}/weekly/1")
|
61
|
+
sleep 1;
|
62
|
+
(2..5).each do |x|
|
60
63
|
Dir.mkdir("#{destination}/weekly/#{x}")
|
61
|
-
sleep 1;
|
62
64
|
end
|
63
65
|
assert_equal(5,Dir["#{destination}/weekly/*"].size)
|
64
66
|
previous_folder_contents = Dir["#{destination}/weekly/*"]
|
65
67
|
Paparazzi::Camera.trigger(default_test_settings)
|
66
68
|
assert_equal(5,Dir["#{destination}/weekly/*"].size)
|
69
|
+
assert(!File.exists?("#{destination}/weekly/1"))
|
67
70
|
assert_not_equal(previous_folder_contents,Dir["#{destination}/weekly/*"])
|
68
71
|
end
|
69
72
|
|
70
|
-
def
|
73
|
+
def test_should_gracefully_recover_if_last_hourly_snapshot_ended_prematurely
|
71
74
|
Paparazzi::Camera.instance_variable_set('@start_time',Time.now - 7200)
|
72
75
|
Paparazzi::Camera.trigger(default_test_settings)
|
73
76
|
successful_snapshot_name = Paparazzi::Camera.send(:current_snapshot_name,:hourly)
|
@@ -76,7 +79,7 @@ class CameraTest < Test::Unit::TestCase
|
|
76
79
|
Paparazzi::Camera.trigger(default_test_settings)
|
77
80
|
failed_snapshot_name = Paparazzi::Camera.send(:current_snapshot_name,:hourly)
|
78
81
|
|
79
|
-
Paparazzi::Camera.send(:
|
82
|
+
Paparazzi::Camera.send(:last_successful_snapshot=,successful_snapshot_name)
|
80
83
|
assert_equal(2,Dir["#{destination}/hourly/*"].size)
|
81
84
|
assert_equal(successful_snapshot_name,YAML.load_file("#{destination}/.paparazzi.yml")[:last_successful_snapshot])
|
82
85
|
|
@@ -88,16 +91,27 @@ class CameraTest < Test::Unit::TestCase
|
|
88
91
|
assert(!Dir["#{destination}/hourly/*"].include?("#{destination}/hourly/#{failed_snapshot_name}"))
|
89
92
|
end
|
90
93
|
|
91
|
-
def
|
94
|
+
def test_should_create_hard_links_to_multiple_snapshots_of_same_file
|
92
95
|
Paparazzi::Camera.trigger(default_test_settings)
|
93
|
-
|
96
|
+
inode = File.stat("#{destination}/hourly/#{Paparazzi::Camera.send(:current_snapshot_name,:hourly)}/test.txt").ino
|
97
|
+
FREQUENCIES.each do |frequency|
|
98
|
+
assert_equal(5,File.stat("#{destination}/#{frequency}/#{Paparazzi::Camera.send(:current_snapshot_name,frequency)}/test.txt").nlink)
|
99
|
+
assert_equal(inode,File.stat("#{destination}/#{frequency}/#{Paparazzi::Camera.send(:current_snapshot_name,frequency)}/test.txt").ino)
|
100
|
+
end
|
94
101
|
end
|
95
102
|
|
96
|
-
def
|
103
|
+
def test_should_not_backup_excluded_files
|
97
104
|
Paparazzi::Camera.trigger(default_test_settings)
|
98
105
|
assert(!File.exists?("#{destination}/hourly/#{Paparazzi::Camera.send(:current_snapshot_name,:hourly)}/test.exclude"))
|
99
106
|
end
|
100
107
|
|
108
|
+
def test_should_not_make_un_requested_frequency_snapshots
|
109
|
+
my_settings = default_test_settings
|
110
|
+
my_settings[:reserves][:hourly] = 0
|
111
|
+
Paparazzi::Camera.trigger(my_settings)
|
112
|
+
assert(!File.exists?("#{destination}/hourly"))
|
113
|
+
end
|
114
|
+
|
101
115
|
#######
|
102
116
|
private
|
103
117
|
#######
|
@@ -110,7 +124,9 @@ class CameraTest < Test::Unit::TestCase
|
|
110
124
|
{
|
111
125
|
:source => "#{File.expand_path('../../source', __FILE__)}/",
|
112
126
|
:destination => destination,
|
127
|
+
:reserves => {:hourly => 24, :daily => 7, :weekly => 5, :monthly => 12, :yearly => 9999},
|
113
128
|
:rsync_flags => '-L --exclude test.exclude'
|
129
|
+
|
114
130
|
}
|
115
131
|
end
|
116
132
|
end
|
metadata
CHANGED
@@ -4,9 +4,9 @@ version: !ruby/object:Gem::Version
|
|
4
4
|
prerelease: false
|
5
5
|
segments:
|
6
6
|
- 0
|
7
|
-
-
|
8
|
-
-
|
9
|
-
version: 0.
|
7
|
+
- 2
|
8
|
+
- 0
|
9
|
+
version: 0.2.0
|
10
10
|
platform: ruby
|
11
11
|
authors:
|
12
12
|
- Jonathan S. Garvin
|
@@ -14,7 +14,7 @@ autorequire:
|
|
14
14
|
bindir: bin
|
15
15
|
cert_chain: []
|
16
16
|
|
17
|
-
date: 2011-05-
|
17
|
+
date: 2011-05-23 00:00:00 -06:00
|
18
18
|
default_executable:
|
19
19
|
dependencies:
|
20
20
|
- !ruby/object:Gem::Dependency
|