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.
@@ -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
+