snapsync 0.1.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.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.travis.yml +4 -0
- data/Gemfile +4 -0
- data/LICENSE.txt +21 -0
- data/README.md +44 -0
- data/Rakefile +10 -0
- data/bin/snapsync +3 -0
- data/lib/snapsync.rb +92 -0
- data/lib/snapsync/auto_sync.rb +74 -0
- data/lib/snapsync/cleanup.rb +40 -0
- data/lib/snapsync/cli.rb +125 -0
- data/lib/snapsync/default_sync_policy.rb +42 -0
- data/lib/snapsync/exceptions.rb +8 -0
- data/lib/snapsync/local_sync.rb +223 -0
- data/lib/snapsync/local_target.rb +150 -0
- data/lib/snapsync/partitions_monitor.rb +105 -0
- data/lib/snapsync/snapper_config.rb +125 -0
- data/lib/snapsync/snapshot.rb +164 -0
- data/lib/snapsync/sync_all.rb +67 -0
- data/lib/snapsync/sync_last_policy.rb +24 -0
- data/lib/snapsync/test.rb +62 -0
- data/lib/snapsync/timeline_sync_policy.rb +163 -0
- data/lib/snapsync/version.rb +3 -0
- data/snapsync.gemspec +30 -0
- metadata +216 -0
@@ -0,0 +1,125 @@
|
|
1
|
+
module Snapsync
|
2
|
+
# A snapper configuration
|
3
|
+
class SnapperConfig
|
4
|
+
# The configuration name
|
5
|
+
attr_reader :name
|
6
|
+
|
7
|
+
# Path to the subvolume
|
8
|
+
#
|
9
|
+
# @return [Pathname]
|
10
|
+
attr_reader :subvolume
|
11
|
+
|
12
|
+
# The filesystem type
|
13
|
+
attr_reader :fstype
|
14
|
+
|
15
|
+
def initialize(name)
|
16
|
+
@name = name.to_str
|
17
|
+
@subvolume, @fstype = nil
|
18
|
+
end
|
19
|
+
|
20
|
+
# The directory containing the snapshots
|
21
|
+
def snapshot_dir
|
22
|
+
subvolume + ".snapshots"
|
23
|
+
end
|
24
|
+
|
25
|
+
# Enumerate the valid snapshots in this configuration
|
26
|
+
#
|
27
|
+
# @yieldparam [Snapshot] snapshot
|
28
|
+
def each_snapshot(&block)
|
29
|
+
Snapshot.each(snapshot_dir, &block)
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.default_config_dir
|
33
|
+
Pathname.new('/etc/snapper/configs')
|
34
|
+
end
|
35
|
+
|
36
|
+
# Enumerates the valid snapper configurations present in a directory
|
37
|
+
def self.each_in_dir(path = default_config_dir)
|
38
|
+
path.each_entry do |config_file|
|
39
|
+
config_name = config_file.to_s
|
40
|
+
config_file = path + config_file
|
41
|
+
next if !config_file.file?
|
42
|
+
begin
|
43
|
+
config = SnapperConfig.load(config_file)
|
44
|
+
rescue Interrupt
|
45
|
+
raise
|
46
|
+
rescue Exception => e
|
47
|
+
Snapsync.warn "cannot load #{config_file}: #{e.message}"
|
48
|
+
e.backtrace.each do |line|
|
49
|
+
Snapsync.debug " #{line}"
|
50
|
+
end
|
51
|
+
next
|
52
|
+
end
|
53
|
+
|
54
|
+
yield(config)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Create a new snapshot
|
59
|
+
#
|
60
|
+
# @return [Snapshot]
|
61
|
+
def create(type: 'single', description: '', user_data: Hash.new)
|
62
|
+
user_data = user_data.map { |k,v| "#{k}=#{v}" }.join(",")
|
63
|
+
snapshot_id = IO.popen(["snapper", "-c", name, "create",
|
64
|
+
"--type", type,
|
65
|
+
"--print-number",
|
66
|
+
"--description", description,
|
67
|
+
"--userdata", user_data]) do |io|
|
68
|
+
Integer(io.read.strip)
|
69
|
+
end
|
70
|
+
Snapshot.new(snapshot_dir + snapshot_id.to_s)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Delete one of this configuration's snapshots
|
74
|
+
def delete(snapshot)
|
75
|
+
system("snapper", "-c", name, "delete", snapshot.num.to_s)
|
76
|
+
end
|
77
|
+
|
78
|
+
# Create a SnapperConfig object from the data in a configuration file
|
79
|
+
#
|
80
|
+
# @param [#readlines] path the file
|
81
|
+
# @param [String] name the configuration name
|
82
|
+
# @return [SnapperConfig]
|
83
|
+
# @raise (see #load)
|
84
|
+
def self.load(path, name: path.basename.to_s)
|
85
|
+
config = new(name)
|
86
|
+
config.load(path)
|
87
|
+
config
|
88
|
+
end
|
89
|
+
|
90
|
+
# @api private
|
91
|
+
#
|
92
|
+
# Extract the key and value from a snapper configuration file
|
93
|
+
#
|
94
|
+
# @return [(String,String)] the key and value pair, or nil if it is an
|
95
|
+
# empty or comment line
|
96
|
+
def parse_line(line)
|
97
|
+
line = line.strip.gsub(/#.*/, '')
|
98
|
+
if !line.empty?
|
99
|
+
if line =~ /^(\w+)="?([^"]*)"?$/
|
100
|
+
return $1, $2
|
101
|
+
else
|
102
|
+
raise ArgumentError, "cannot parse #{line}"
|
103
|
+
end
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Load the information from a configuration file into this object
|
108
|
+
#
|
109
|
+
# @see SnapperConfig.load
|
110
|
+
def load(path)
|
111
|
+
path.readlines.each do |line|
|
112
|
+
key, value = parse_line(line)
|
113
|
+
case key
|
114
|
+
when NilClass then next
|
115
|
+
else
|
116
|
+
instance_variable_set("@#{key.downcase}", value)
|
117
|
+
end
|
118
|
+
end
|
119
|
+
if @subvolume
|
120
|
+
@subvolume = Pathname.new(subvolume)
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
@@ -0,0 +1,164 @@
|
|
1
|
+
module Snapsync
|
2
|
+
# Representation of a single Snapper snapshot
|
3
|
+
class Snapshot
|
4
|
+
# The path to the snapshot directory
|
5
|
+
#
|
6
|
+
# @return [Pathname]
|
7
|
+
attr_reader :snapshot_dir
|
8
|
+
|
9
|
+
# The path to the snapshot's subvolume
|
10
|
+
#
|
11
|
+
# @return [Pathname]
|
12
|
+
def subvolume_dir; snapshot_dir + "snapshot" end
|
13
|
+
|
14
|
+
# The snapshot's date
|
15
|
+
#
|
16
|
+
# @return [DateTime]
|
17
|
+
attr_reader :date
|
18
|
+
|
19
|
+
# The snapshot number
|
20
|
+
attr_reader :num
|
21
|
+
|
22
|
+
# The snapshot's user data
|
23
|
+
#
|
24
|
+
# @return [Hash<String,String>]
|
25
|
+
attr_reader :user_data
|
26
|
+
|
27
|
+
PARTIAL_MARKER = "snapsync-partial"
|
28
|
+
|
29
|
+
def self.partial_marker_path(snapshot_dir)
|
30
|
+
snapshot_dir + PARTIAL_MARKER
|
31
|
+
end
|
32
|
+
|
33
|
+
# A file that is used to mark the snapshot has having only been
|
34
|
+
# partially synchronized
|
35
|
+
#
|
36
|
+
# @return [Pathname]
|
37
|
+
def partial_marker_path
|
38
|
+
self.class.partial_marker_path(snapshot_dir)
|
39
|
+
end
|
40
|
+
|
41
|
+
# Whether this snapshot has only been partially synchronized
|
42
|
+
def partial?
|
43
|
+
partial_marker_path.exist?
|
44
|
+
end
|
45
|
+
|
46
|
+
# This snapshot's reference time
|
47
|
+
def to_time
|
48
|
+
date.to_time
|
49
|
+
end
|
50
|
+
|
51
|
+
# Whether this snapshot is one of snapsync's synchronization points
|
52
|
+
def synchronization_point?
|
53
|
+
user_data['snapsync']
|
54
|
+
end
|
55
|
+
|
56
|
+
# Whether this snapshot is one of snapsync's synchronization points for
|
57
|
+
# the given target
|
58
|
+
def synchronization_point_for?(target)
|
59
|
+
user_data['snapsync'] == target.uuid
|
60
|
+
end
|
61
|
+
|
62
|
+
def initialize(snapshot_dir)
|
63
|
+
@snapshot_dir = snapshot_dir
|
64
|
+
|
65
|
+
if !snapshot_dir.directory?
|
66
|
+
raise InvalidSnapshot, "#{snapshot_dir} does not exist"
|
67
|
+
elsif !subvolume_dir.directory?
|
68
|
+
raise InvalidSnapshot, "#{snapshot_dir}'s subvolume directory does not exist (#{subvolume_dir})"
|
69
|
+
end
|
70
|
+
|
71
|
+
# This loads the information and also validates that snapshot_dir is
|
72
|
+
# indeed a snapper snapshot
|
73
|
+
load_info
|
74
|
+
end
|
75
|
+
|
76
|
+
# Compute the size difference between the given snapshot and self
|
77
|
+
#
|
78
|
+
# This is an estimate of the size required to send this snapshot using
|
79
|
+
# the given snapshot as parent
|
80
|
+
def size_diff_from(snapshot)
|
81
|
+
info = `sudo btrfs subvolume show '#{snapshot.subvolume_dir}'`
|
82
|
+
info =~ /Generation[^:]*:\s+(\d+)/
|
83
|
+
size_diff_from_gen(Integer($1))
|
84
|
+
end
|
85
|
+
|
86
|
+
# Compute the size of the snapshot
|
87
|
+
def size
|
88
|
+
size_diff_from_gen(0)
|
89
|
+
end
|
90
|
+
|
91
|
+
def size_diff_from_gen(gen)
|
92
|
+
new = `sudo btrfs subvolume find-new '#{subvolume_dir}' #{gen}`
|
93
|
+
new.split("\n").inject(0) do |size, line|
|
94
|
+
if line.strip =~ /len (\d+)/
|
95
|
+
size + Integer($1)
|
96
|
+
else size
|
97
|
+
end
|
98
|
+
end
|
99
|
+
end
|
100
|
+
|
101
|
+
# Enumerate the snapshots from the given directory
|
102
|
+
#
|
103
|
+
# The directory is supposed to be maintained in a snapper-compatible
|
104
|
+
# foramt, meaning that the snapshot directory name must be the
|
105
|
+
# snapshot's number
|
106
|
+
def self.each(snapshot_dir, with_partial: false)
|
107
|
+
return enum_for(__method__, snapshot_dir, with_partial: with_partial) if !block_given?
|
108
|
+
snapshot_dir.each_child do |path|
|
109
|
+
if path.directory? && path.basename.to_s =~ /^\d+$/
|
110
|
+
begin
|
111
|
+
snapshot = Snapshot.new(path)
|
112
|
+
rescue InvalidSnapshot => e
|
113
|
+
Snapsync.warn "ignored #{path} in #{self}: #{e}"
|
114
|
+
end
|
115
|
+
if snapshot
|
116
|
+
if snapshot.num != Integer(path.basename.to_s)
|
117
|
+
Snapsync.warn "ignored #{path} in #{self}: the snapshot reports num=#{snapshot.num} but its directory is called #{path.basename}"
|
118
|
+
elsif !with_partial && snapshot.partial?
|
119
|
+
Snapsync.warn "ignored #{path} in #{self}: this is a partial snapshot"
|
120
|
+
else
|
121
|
+
yield snapshot
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
# Loads snapper's info.xml, validates it and assigns the information to
|
129
|
+
# the relevant attributes
|
130
|
+
def load_info
|
131
|
+
info_xml = snapshot_dir + "info.xml"
|
132
|
+
if !info_xml.file?
|
133
|
+
raise InvalidSnapshot, "#{snapshot_dir}/info.xml does not exist, is this really a snapper snapshot ?"
|
134
|
+
end
|
135
|
+
|
136
|
+
xml = REXML::Document.new(info_xml.read)
|
137
|
+
if xml.root.name != 'snapshot'
|
138
|
+
raise InvalidInfoFile, "#{snapshot_dir}/info.xml does not look like a snapper info file (root is not 'snapshot')"
|
139
|
+
end
|
140
|
+
|
141
|
+
date = xml.root.elements.to_a('date')
|
142
|
+
if date.empty?
|
143
|
+
raise InvalidInfoFile, "#{snapshot_dir}/info.xml does not have a date element"
|
144
|
+
else
|
145
|
+
@date = DateTime.parse(date.first.text)
|
146
|
+
end
|
147
|
+
|
148
|
+
num = xml.root.elements.to_a('num')
|
149
|
+
if num.empty?
|
150
|
+
raise InvalidInfoFile, "#{snapshot_dir}/info.xml does not have a num element"
|
151
|
+
else
|
152
|
+
@num = Integer(num.first.text)
|
153
|
+
end
|
154
|
+
|
155
|
+
@user_data = Hash.new
|
156
|
+
xml.root.elements.to_a('userdata').each do |node|
|
157
|
+
k = node.elements['key'].text
|
158
|
+
v = node.elements['value'].text
|
159
|
+
user_data[k] = v
|
160
|
+
end
|
161
|
+
end
|
162
|
+
end
|
163
|
+
end
|
164
|
+
|
@@ -0,0 +1,67 @@
|
|
1
|
+
module Snapsync
|
2
|
+
# Synchronizes all snapshots to a directory
|
3
|
+
#
|
4
|
+
# A snapshot will be synchronized if (1) the target directory has a
|
5
|
+
# subdirectory of the config's name and (2) this directory is not
|
6
|
+
# disabled through its config file
|
7
|
+
class SyncAll
|
8
|
+
# The path to the directory containing snapper configuration files
|
9
|
+
attr_reader :config_dir
|
10
|
+
# The path to the root directory to which we should sync
|
11
|
+
attr_reader :target_dir
|
12
|
+
|
13
|
+
# Creates a sync-all operation for the given target directory
|
14
|
+
#
|
15
|
+
# @param [Pathname] target_dir the target directory
|
16
|
+
# @param [Boolean,nil] autoclean if true or false, will control
|
17
|
+
# whether the targets should be cleaned of obsolete snapshots
|
18
|
+
# after synchronization. If nil (the default), the target's own
|
19
|
+
# autoclean flag will be used to determine this
|
20
|
+
def initialize(target_dir, config_dir: Pathname.new('/etc/snapper/configs'), autoclean: nil)
|
21
|
+
@config_dir = config_dir
|
22
|
+
@target_dir = target_dir
|
23
|
+
@autoclean = autoclean
|
24
|
+
end
|
25
|
+
|
26
|
+
# Whether the given target should be cleaned after synchronization.
|
27
|
+
#
|
28
|
+
# This is determined either by {#autoclean?} if {.new} was called with
|
29
|
+
# true or false, or by the target's own autoclean flag if {.new} was
|
30
|
+
# called with nil
|
31
|
+
def should_autoclean_target?(target)
|
32
|
+
if @autoclean.nil?
|
33
|
+
target.autoclean?
|
34
|
+
else
|
35
|
+
@autoclean
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
def run
|
40
|
+
SnapperConfig.each_in_dir(config_dir) do |config|
|
41
|
+
dir = target_dir + config.name
|
42
|
+
if !dir.exist?
|
43
|
+
Snapsync.warn "not synchronizing #{config.name}, there are no corresponding directory in #{target_dir}. Call snapsync init to create a proper target directory"
|
44
|
+
else
|
45
|
+
target = LocalTarget.new(dir)
|
46
|
+
if !target.enabled?
|
47
|
+
Snapsync.warn "not synchronizing #{config.name}, it is disabled"
|
48
|
+
next
|
49
|
+
end
|
50
|
+
|
51
|
+
LocalSync.new(config, target).sync
|
52
|
+
if should_autoclean_target?(target)
|
53
|
+
if target.cleanup
|
54
|
+
Snapsync.info "running cleanup"
|
55
|
+
target.cleanup.cleanup(target)
|
56
|
+
else
|
57
|
+
Snapsync.info "#{target.sync_policy.class.name} policy set, no cleanup to do"
|
58
|
+
end
|
59
|
+
else
|
60
|
+
Snapsync.info "autoclean not set"
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
@@ -0,0 +1,24 @@
|
|
1
|
+
module Snapsync
|
2
|
+
# A simple policy that synchronizes only the last snapshot (that is,
|
3
|
+
# snapsync's own synchronization point)
|
4
|
+
class SyncLastPolicy
|
5
|
+
def self.from_config(config)
|
6
|
+
new
|
7
|
+
end
|
8
|
+
|
9
|
+
def to_config
|
10
|
+
Array.new
|
11
|
+
end
|
12
|
+
|
13
|
+
def pretty_print(pp)
|
14
|
+
pp.text "will keep only the latest snapshot"
|
15
|
+
end
|
16
|
+
|
17
|
+
# (see DefaultSyncPolicy#filter_snapshots_to_sync)
|
18
|
+
def filter_snapshots_to_sync(target, snapshots)
|
19
|
+
last = snapshots.sort_by(&:num).reverse.
|
20
|
+
find { |s| !s.synchronization_point? }
|
21
|
+
[last]
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
# simplecov must be loaded FIRST. Only the files required after it gets loaded
|
2
|
+
# will be profiled !!!
|
3
|
+
if ENV['TEST_ENABLE_COVERAGE'] == '1'
|
4
|
+
begin
|
5
|
+
require 'simplecov'
|
6
|
+
SimpleCov.start
|
7
|
+
rescue LoadError
|
8
|
+
require 'snapsync'
|
9
|
+
Snapsync.warn "coverage is disabled because the 'simplecov' gem cannot be loaded"
|
10
|
+
rescue Exception => e
|
11
|
+
require 'snapsync'
|
12
|
+
Snapsync.warn "coverage is disabled: #{e.message}"
|
13
|
+
end
|
14
|
+
end
|
15
|
+
|
16
|
+
require 'minitest/autorun'
|
17
|
+
require 'snapsync'
|
18
|
+
require 'flexmock/test_unit'
|
19
|
+
require 'minitest/spec'
|
20
|
+
|
21
|
+
if ENV['TEST_ENABLE_PRY'] != '0'
|
22
|
+
begin
|
23
|
+
require 'pry'
|
24
|
+
rescue Exception
|
25
|
+
Snapsync.warn "debugging is disabled because the 'pry' gem cannot be loaded"
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
module Snapsync
|
30
|
+
# This module is the common setup for all tests
|
31
|
+
#
|
32
|
+
# It should be included in the toplevel describe blocks
|
33
|
+
#
|
34
|
+
# @example
|
35
|
+
# require 'snapsync/test'
|
36
|
+
# describe Snapsync do
|
37
|
+
# include Snapsync::SelfTest
|
38
|
+
# end
|
39
|
+
#
|
40
|
+
module SelfTest
|
41
|
+
if defined? FlexMock
|
42
|
+
include FlexMock::ArgumentTypes
|
43
|
+
include FlexMock::MockContainer
|
44
|
+
end
|
45
|
+
|
46
|
+
def setup
|
47
|
+
# Setup code for all the tests
|
48
|
+
end
|
49
|
+
|
50
|
+
def teardown
|
51
|
+
if defined? FlexMock
|
52
|
+
flexmock_teardown
|
53
|
+
end
|
54
|
+
super
|
55
|
+
# Teardown code for all the tests
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
Minitest::Test.include Snapsync::SelfTest
|
61
|
+
|
62
|
+
|
@@ -0,0 +1,163 @@
|
|
1
|
+
module Snapsync
|
2
|
+
class TimelineSyncPolicy < DefaultSyncPolicy
|
3
|
+
attr_reader :reference
|
4
|
+
attr_reader :timeline
|
5
|
+
|
6
|
+
attr_reader :periods
|
7
|
+
|
8
|
+
def initialize(reference: Time.now)
|
9
|
+
@reference = reference
|
10
|
+
@timeline = Array.new
|
11
|
+
@periods = Array.new
|
12
|
+
end
|
13
|
+
|
14
|
+
def self.from_config(config)
|
15
|
+
policy = new
|
16
|
+
policy.parse_config(config)
|
17
|
+
policy
|
18
|
+
end
|
19
|
+
|
20
|
+
def parse_config(config)
|
21
|
+
config.each_slice(2) do |period, count|
|
22
|
+
add(period.to_sym, Integer(count))
|
23
|
+
end
|
24
|
+
end
|
25
|
+
|
26
|
+
def to_config
|
27
|
+
periods.flatten
|
28
|
+
end
|
29
|
+
|
30
|
+
def pretty_print(pp)
|
31
|
+
pp.text "timeline policy"
|
32
|
+
pp.nest(2) do
|
33
|
+
pp.seplist(periods) do |pair|
|
34
|
+
pp.breakable
|
35
|
+
pp.text "#{pair[0]}: #{pair[1]}"
|
36
|
+
end
|
37
|
+
end
|
38
|
+
end
|
39
|
+
|
40
|
+
# Add an element to the timeline
|
41
|
+
#
|
42
|
+
# @param [Symbol] period the period (:year, :month, :week, :day, :hour)
|
43
|
+
# @param [Integer] count how many units of this period should be kept
|
44
|
+
#
|
45
|
+
# @example keep one snapshot every day for the last 10 days
|
46
|
+
# cleanup.add(:day, 10)
|
47
|
+
#
|
48
|
+
def add(period, count)
|
49
|
+
beginning_of_day = reference.to_date
|
50
|
+
beginning_of_week = beginning_of_day.prev_day(beginning_of_day.wday + 1)
|
51
|
+
beginning_of_month = beginning_of_day.prev_day(beginning_of_day.mday - 1)
|
52
|
+
beginning_of_year = beginning_of_day.prev_day(beginning_of_day.yday - 1)
|
53
|
+
beginning_of_hour = beginning_of_day.to_time + (reference.hour * 3600)
|
54
|
+
|
55
|
+
timeline = self.timeline.dup
|
56
|
+
if period == :year
|
57
|
+
count.times do
|
58
|
+
timeline << beginning_of_year.to_time
|
59
|
+
beginning_of_year = beginning_of_year.prev_year
|
60
|
+
end
|
61
|
+
elsif period == :month
|
62
|
+
count.times do
|
63
|
+
timeline << begining_of_month.to_time
|
64
|
+
begining_of_month = begining_of_month.prev_month
|
65
|
+
end
|
66
|
+
elsif period == :week
|
67
|
+
count.times do
|
68
|
+
timeline << beginning_of_week.to_time
|
69
|
+
beginning_of_week = beginning_of_week.prev_day(7)
|
70
|
+
end
|
71
|
+
elsif period == :day
|
72
|
+
count.times do
|
73
|
+
timeline << beginning_of_day.to_time
|
74
|
+
beginning_of_day = beginning_of_day.prev_day
|
75
|
+
end
|
76
|
+
elsif period == :hour
|
77
|
+
count.times do |i|
|
78
|
+
timeline << beginning_of_hour
|
79
|
+
beginning_of_hour = beginning_of_hour - 3600
|
80
|
+
end
|
81
|
+
else
|
82
|
+
raise ArgumentError, "unknown period name #{period}"
|
83
|
+
end
|
84
|
+
|
85
|
+
periods << [period, count]
|
86
|
+
@timeline = timeline.sort.uniq
|
87
|
+
end
|
88
|
+
|
89
|
+
# Given a list of snapshots, computes those that should be kept to honor
|
90
|
+
# the timeline constraints
|
91
|
+
def compute_required_snapshots(target_snapshots)
|
92
|
+
keep_flags = Hash.new { |h,k| h[k] = [false, []] }
|
93
|
+
|
94
|
+
# Mark all important snapshots as kept
|
95
|
+
target_snapshots.each do |s|
|
96
|
+
if s.user_data['important'] == 'yes'
|
97
|
+
keep_flags[s.num][0] = true
|
98
|
+
keep_flags[s.num][1] << "marked as important"
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# For each timepoint in the timeline, find the newest snapshot that
|
103
|
+
# is not before the timepoint
|
104
|
+
merged_timelines = (target_snapshots.to_a + timeline).sort_by do |s|
|
105
|
+
s.to_time
|
106
|
+
end
|
107
|
+
matching_snapshots = [target_snapshots.first]
|
108
|
+
merged_timelines.each do |obj|
|
109
|
+
if obj.kind_of?(Snapshot)
|
110
|
+
matching_snapshots[-1] = obj
|
111
|
+
else
|
112
|
+
s = matching_snapshots.last
|
113
|
+
matching_snapshots[-1] = [s, obj]
|
114
|
+
matching_snapshots << s
|
115
|
+
end
|
116
|
+
end
|
117
|
+
matching_snapshots.pop
|
118
|
+
matching_snapshots.each do |(s, timepoint)|
|
119
|
+
keep_flags[s.num][0] = true
|
120
|
+
keep_flags[s.num][1] << "timeline(#{timepoint})"
|
121
|
+
end
|
122
|
+
|
123
|
+
# Finally, guard against race conditions. Always keep all snapshots
|
124
|
+
# between the last-to-keep and the last
|
125
|
+
target_snapshots.sort_by(&:num).reverse.each do |s|
|
126
|
+
break if keep_flags[s.num][0]
|
127
|
+
keep_flags[s.num][0] = true
|
128
|
+
keep_flags[s.num][1] << "last snapshot"
|
129
|
+
end
|
130
|
+
keep_flags
|
131
|
+
end
|
132
|
+
|
133
|
+
def filter_snapshots_to_sync(target, source_snapshots)
|
134
|
+
Snapsync.debug do
|
135
|
+
Snapsync.debug "Filtering snapshots according to timeline"
|
136
|
+
timeline.each do |t|
|
137
|
+
Snapsync.debug " #{t}"
|
138
|
+
end
|
139
|
+
break
|
140
|
+
end
|
141
|
+
|
142
|
+
default_policy = DefaultSyncPolicy.new
|
143
|
+
source_snapshots = default_policy.filter_snapshots_to_sync(target, source_snapshots)
|
144
|
+
|
145
|
+
keep_flags = compute_required_snapshots(source_snapshots)
|
146
|
+
source_snapshots.sort_by(&:num).find_all do |s|
|
147
|
+
keep, reason = keep_flags.fetch(s.num, nil)
|
148
|
+
if keep
|
149
|
+
Snapsync.debug "Timeline: selected snapshot #{s.num} #{s.date.to_time}"
|
150
|
+
reason.each do |r|
|
151
|
+
Snapsync.debug " #{r}"
|
152
|
+
end
|
153
|
+
else
|
154
|
+
Snapsync.debug "Timeline: not selected snapshot #{s.num} #{s.date.to_time}"
|
155
|
+
end
|
156
|
+
|
157
|
+
keep
|
158
|
+
end
|
159
|
+
end
|
160
|
+
end
|
161
|
+
end
|
162
|
+
|
163
|
+
|