snapsync 0.3.8 → 0.4.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,330 @@
1
+ require 'weakref'
2
+
3
+ class Pathname
4
+ class NonZeroExitCode < RuntimeError
5
+ end
6
+
7
+ def path_part
8
+ to_s
9
+ end
10
+
11
+ def touch
12
+ FileUtils.touch(to_s)
13
+ end
14
+
15
+ def findmnt
16
+ # An optimization to quickly get the mountpoint.
17
+ # 3 levels are needed to get from `.snapshots/<id>/snapshot` to a cached entry.
18
+ # Will probably be fine.
19
+ cached = Snapsync._mountpointCache.fetch(self.to_s, nil)
20
+ cached = Snapsync._mountpointCache.fetch(self.parent.to_s, nil) unless cached
21
+ cached = Snapsync._mountpointCache.fetch(self.parent.parent.to_s, nil) unless cached
22
+ return cached.dup if cached
23
+
24
+ Snapsync.debug "Pathname ('#{self}').findmnt"
25
+
26
+ proc = IO.popen(Shellwords.join ['findmnt','-n','-o','TARGET','-T', self.to_s])
27
+ path = proc.read.strip
28
+ proc.close
29
+ if not $?.success?
30
+ raise NonZeroExitCode, "findmnt failed for #{self}"
31
+ end
32
+
33
+ raise "findmnt failed" unless path
34
+ p = Pathname.new path
35
+ # Update cache
36
+ p2 = self.dup
37
+ while p != p2
38
+ Snapsync._mountpointCache[p2.to_s] = p
39
+ p2 = p2.parent
40
+ end
41
+ p
42
+ end
43
+ end
44
+
45
+ module Snapsync
46
+ class << self
47
+ # @return [Hash]
48
+ attr_accessor :_mountpointCache
49
+ end
50
+ self._mountpointCache = {}
51
+
52
+ class AgnosticPath
53
+ def exist?
54
+ raise NotImplementedError
55
+ end
56
+ def file?
57
+ raise NotImplementedError
58
+ end
59
+ def directory?
60
+ raise NotImplementedError
61
+ end
62
+ def mountpoint?
63
+ raise NotImplementedError
64
+ end
65
+ def basename
66
+ raise NotImplementedError
67
+ end
68
+ def dirname
69
+ raise NotImplementedError
70
+ end
71
+ def parent
72
+ raise NotImplementedError
73
+ end
74
+ def each_child
75
+ raise NotImplementedError
76
+ end
77
+ def expand_path
78
+ raise NotImplementedError
79
+ end
80
+ def cleanpath
81
+ raise NotImplementedError
82
+ end
83
+ def mkdir
84
+ raise NotImplementedError
85
+ end
86
+ def mkpath
87
+ raise NotImplementedError
88
+ end
89
+ def rmtree
90
+ raise NotImplementedError
91
+ end
92
+ def unlink
93
+ raise NotImplementedError
94
+ end
95
+ # @return [AgnosticPath]
96
+ def +(path)
97
+ raise NotImplementedError
98
+ end
99
+ def read
100
+ raise NotImplementedError
101
+ end
102
+ def open(flags, &block)
103
+ raise NotImplementedError
104
+ end
105
+ def touch
106
+ raise NotImplementedError
107
+ end
108
+
109
+ def findmnt
110
+ raise NotImplementedError
111
+ end
112
+
113
+ # @return [String]
114
+ def path_part
115
+ raise NotImplementedError
116
+ end
117
+ end
118
+
119
+ # Ideally this would also inherit from AgnosticPath...
120
+ class LocalPathname < Pathname
121
+ end
122
+
123
+ class RemotePathname < AgnosticPath
124
+
125
+ # @return [URI::SshGit::Generic]
126
+ attr_reader :uri
127
+
128
+ # @return [Net::SSH::Connection::Session]
129
+ attr_reader :ssh
130
+
131
+ # @return [Net::SFTP::Session]
132
+ attr_reader :sftp
133
+
134
+ # @return [Net::SFTP::Operations::FileFactory]
135
+ attr_reader :sftp_f
136
+
137
+ # @param [String] dir
138
+ def initialize(dir)
139
+ if dir.instance_of? RemotePathname
140
+ @uri = dir.uri.dup
141
+ @ssh = dir.ssh
142
+ @sftp = dir.sftp
143
+ @sftp_f = dir.sftp_f
144
+ else
145
+ @uri = URI::SshGit.parse(dir)
146
+
147
+ raise RuntimeError.new('Host cannot be nil for remote pathname') if uri.host.nil?
148
+
149
+ Snapsync.debug "Opening new ssh session: "+uri.to_s
150
+ @ssh = Net::SSH.start(uri.host, uri.user, password: uri.password, non_interactive: true)
151
+
152
+ @sftp = @ssh.sftp
153
+ @sftp_f = @sftp.file
154
+
155
+ # # FIXME: these probably don't work
156
+ # @ssh_thr = Thread.new {
157
+ # ssh = WeakRef.new(@ssh)
158
+ # while ssh.weakref_alive?
159
+ # ssh.process 0.1
160
+ # end
161
+ # }
162
+ #
163
+ # @sftp_thr = Thread.new {
164
+ # sftp = WeakRef.new(@sftp)
165
+ # sftp.loop do
166
+ # sftp.weakref_alive?
167
+ # end
168
+ # }
169
+ end
170
+ end
171
+
172
+ def initialize_dup(other)
173
+ super
174
+ @uri = @uri.dup
175
+ end
176
+
177
+ # Duplicates a new ssh session with same connection options
178
+ # @return [Net::SSH::Connection::Session]
179
+ # @yieldparam ssh [Net::SSH::Connection::Session]
180
+ def dup_ssh(&block)
181
+ Snapsync.debug "Opening new ssh session: "+uri.to_s
182
+ Net::SSH.start(uri.host, uri.user, password: uri.password, non_interactive: true, &block)
183
+ end
184
+
185
+ def exist?
186
+ begin
187
+ sftp_f.open(uri.path).close
188
+ return true
189
+ rescue Net::SFTP::StatusException
190
+ return directory?
191
+ end
192
+ end
193
+
194
+ def file?
195
+ begin
196
+ sftp_f.open(uri.path).close
197
+ return true
198
+ rescue Net::SFTP::StatusException
199
+ return false
200
+ end
201
+ end
202
+
203
+ def directory?
204
+ begin
205
+ sftp_f.directory? uri.path
206
+ rescue Net::SFTP::StatusException
207
+ return false
208
+ end
209
+ end
210
+
211
+ def findmnt
212
+ cached = Snapsync._mountpointCache.fetch(self.to_s, nil)
213
+ cached = Snapsync._mountpointCache.fetch(self.parent.to_s, nil) unless cached
214
+ return cached if cached
215
+
216
+ Snapsync.debug "RemotePathname ('#{uri}').findmnt"
217
+
218
+ path = ssh.exec!(Shellwords.join ['findmnt','-n','-o','TARGET','-T',uri.path]).strip
219
+ p = self.dup
220
+ p.uri.path = path
221
+
222
+ # Update cache
223
+ p2 = self.dup
224
+ while p.uri.path != p2.uri.path
225
+ Snapsync._mountpointCache[p2.to_s] = p
226
+ p2 = p2.parent
227
+ end
228
+
229
+ p
230
+ end
231
+
232
+ def mountpoint?
233
+ Snapsync.debug "RemotePathname ('#{uri}').mountpoint?"
234
+ ssh.exec!(Shellwords.join ['mountpoint','-q',uri.path]).exitstatus == 0
235
+ end
236
+
237
+ def basename
238
+ Pathname.new(uri.path).basename
239
+ end
240
+
241
+ def dirname
242
+ o = self.dup
243
+ o.uri.path = Pathname.new(uri.path).dirname.to_s
244
+ o
245
+ end
246
+
247
+ def parent
248
+ o = self.dup
249
+ if o.uri.path == '/'
250
+ raise "Trying to get parent of root directory"
251
+ end
252
+ o.uri.path = Pathname.new(uri.path).parent.to_s
253
+ o
254
+ end
255
+
256
+ def each_child(with_directory=true, &block)
257
+ raise 'Only supports default value for with_directory' if not with_directory
258
+
259
+ sftp.dir.foreach(uri.path) do |entry|
260
+ next if entry.name == '.' or entry.name == '..'
261
+
262
+ o = self.dup
263
+ o.uri.path = (Pathname.new(o.uri.path) + entry.name).to_s
264
+ yield o
265
+ end
266
+ end
267
+
268
+ def expand_path
269
+ o = self.dup
270
+ o.uri.path = ssh.exec!(Shellwords.join ['readlink', '-f', uri.path]).chomp
271
+ o
272
+ end
273
+
274
+ def cleanpath
275
+ o = self.dup
276
+ o.uri.path = Pathname.new(uri.path).cleanpath.to_s
277
+ o
278
+ end
279
+
280
+ def mkdir
281
+ sftp.mkdir!(uri.path)
282
+ end
283
+
284
+ def mkpath
285
+ sftp.mkdir!(uri.path)
286
+ end
287
+
288
+ def rmtree
289
+ raise 'Failed' unless ssh.exec!(Shellwords.join ['rm','-r', uri.path]).exitstatus == 0
290
+ end
291
+
292
+ def unlink
293
+ raise 'Failed' unless ssh.exec!(Shellwords.join ['rm', uri.path]).exitstatus == 0
294
+ end
295
+
296
+ def +(path)
297
+ o = self.dup
298
+ o.uri.path = (Pathname.new(uri.path) + path).to_s
299
+ o
300
+ end
301
+
302
+ def read
303
+ begin
304
+ sftp_f.open(uri.path).read
305
+ rescue Net::SFTP::StatusException => e
306
+ raise Errno::ENOENT, e.message, e.backtrace
307
+ end
308
+ end
309
+
310
+ def open(flags, &block)
311
+ sftp_f.open uri.path, flags, &block
312
+ end
313
+
314
+ def touch
315
+ raise 'Failed' unless ssh.exec!(Shellwords.join ['touch', uri.path]).exitstatus == 0
316
+ end
317
+
318
+ def path_part
319
+ uri.path
320
+ end
321
+
322
+ def to_s
323
+ uri.to_s
324
+ end
325
+
326
+ def inspect
327
+ uri.to_s
328
+ end
329
+ end
330
+ end
@@ -10,6 +10,7 @@ module Snapsync
10
10
  attr_reader :subvolume
11
11
 
12
12
  # The filesystem type
13
+ # @return [String]
13
14
  attr_reader :fstype
14
15
 
15
16
  def initialize(name)
@@ -75,6 +76,11 @@ module Snapsync
75
76
  system("snapper", "-c", name, "delete", snapshot.num.to_s)
76
77
  end
77
78
 
79
+ def cleanup
80
+ Snapsync.debug "SnapperConfig.cleanup"
81
+ system('snapper', '-c', name, 'cleanup', 'all')
82
+ end
83
+
78
84
  # Create a SnapperConfig object from the data in a configuration file
79
85
  #
80
86
  # @param [#readlines] path the file
@@ -1,11 +1,24 @@
1
1
  module Snapsync
2
2
  # Representation of a single Snapper snapshot
3
3
  class Snapshot
4
+ class SnapshotCompareError < RuntimeError
5
+ end
6
+
4
7
  # The path to the snapshot directory
5
8
  #
6
9
  # @return [Pathname]
7
10
  attr_reader :snapshot_dir
8
11
 
12
+ # @return [Btrfs]
13
+ attr_reader :btrfs
14
+
15
+ # @return [SubvolumeInfo]
16
+ def info
17
+ # Load btrfs subvolume info
18
+ @info = SubvolumeInfo.new(subvolume_dir) unless @info
19
+ @info
20
+ end
21
+
9
22
  # The path to the snapshot's subvolume
10
23
  #
11
24
  # @return [Pathname]
@@ -59,8 +72,11 @@ module Snapsync
59
72
  user_data['snapsync'] == target.uuid
60
73
  end
61
74
 
75
+
76
+ # @param [AgnosticPath] snapshot_dir
62
77
  def initialize(snapshot_dir)
63
78
  @snapshot_dir = snapshot_dir
79
+ @btrfs = Btrfs.get(snapshot_dir)
64
80
 
65
81
  if !snapshot_dir.directory?
66
82
  raise InvalidSnapshot, "#{snapshot_dir} does not exist"
@@ -78,12 +94,21 @@ module Snapsync
78
94
  # This is an estimate of the size required to send this snapshot using
79
95
  # the given snapshot as parent
80
96
  #
81
- # @param [Snapshot] a reference snapshot, which would be used as parent
97
+ # @param [Snapshot] snapshot a reference snapshot, which would be used as parent
82
98
  # in the 'send' operation
83
99
  # @return [Integer] the size in bytes of the difference between the
84
100
  # given snapshot and the current subvolume's state
85
101
  def size_diff_from(snapshot)
86
- snapshot_gen = Btrfs.generation_of(snapshot.subvolume_dir)
102
+ if btrfs.mountpoint != snapshot.btrfs.mountpoint
103
+ recv_uuid = snapshot.info.received_uuid
104
+ local_snapshot = btrfs.subvolume_table.find do |s|
105
+ s.uuid == recv_uuid
106
+ end
107
+ raise "Cannot find snapshot with uuid #{recv_uuid} locally." if local_snapshot.nil?
108
+ snapshot_gen = local_snapshot.cgen
109
+ else
110
+ snapshot_gen = snapshot.info.gen_at_creation
111
+ end
87
112
  size_diff_from_gen(snapshot_gen)
88
113
  end
89
114
 
@@ -103,7 +128,7 @@ module Snapsync
103
128
  # @return [Integer] size in bytes
104
129
  # @see size_diff_from size
105
130
  def size_diff_from_gen(gen)
106
- Btrfs.find_new(subvolume_dir, gen).inject(0) do |size, line|
131
+ btrfs.find_new(subvolume_dir, gen).inject(0) do |size, line|
107
132
  if line =~ /len (\d+)/
108
133
  size + Integer($1)
109
134
  else size
@@ -111,6 +136,9 @@ module Snapsync
111
136
  end
112
137
  end
113
138
 
139
+ # @yieldparam path [AgnosticPath]
140
+ # @yieldparam snapshot [Snapshot, nil]
141
+ # @yieldparam err [InvalidSnapshot, nil]
114
142
  def self.each_snapshot_raw(snapshot_dir)
115
143
  return enum_for(__method__, snapshot_dir) if !block_given?
116
144
  snapshot_dir.each_child do |path|
@@ -130,6 +158,7 @@ module Snapsync
130
158
  # The directory is supposed to be maintained in a snapper-compatible
131
159
  # foramt, meaning that the snapshot directory name must be the
132
160
  # snapshot's number
161
+ # @yieldparam snapshot [Snapshot]
133
162
  def self.each(snapshot_dir, with_partial: false)
134
163
  return enum_for(__method__, snapshot_dir, with_partial: with_partial) if !block_given?
135
164
  each_snapshot_raw(snapshot_dir) do |path, snapshot, error|
@@ -1,17 +1,26 @@
1
1
  module Snapsync
2
- # Synchronization between local file systems
3
- class LocalSync
2
+ # Snapshot transfer between two btrfs filesystems
3
+ class SnapshotTransfer
4
4
  # The snapper configuration we should synchronize
5
5
  #
6
6
  # @return [SnapperConfig]
7
7
  attr_reader :config
8
8
  # The target directory into which to synchronize
9
9
  #
10
- # @return [LocalTarget]
10
+ # @return [SyncTarget]
11
11
  attr_reader :target
12
+
13
+ # @return [Btrfs] src filesystem
14
+ attr_reader :btrfs_src
15
+
16
+ # @return [Btrfs] dest filesystem
17
+ attr_reader :btrfs_dest
12
18
 
13
19
  def initialize(config, target)
14
20
  @config, @target = config, target
21
+
22
+ @btrfs_src = Btrfs.get(config.subvolume)
23
+ @btrfs_dest = Btrfs.get(@target.dir)
15
24
  end
16
25
 
17
26
  def create_synchronization_point
@@ -61,6 +70,7 @@ module Snapsync
61
70
  counter
62
71
  end
63
72
 
73
+ # @param [AgnosticPath] target_snapshot_dir
64
74
  def synchronize_snapshot(target_snapshot_dir, src, parent: nil)
65
75
  partial_marker_path = Snapshot.partial_marker_path(target_snapshot_dir)
66
76
 
@@ -79,23 +89,26 @@ module Snapsync
79
89
  else
80
90
  target_snapshot_dir.mkdir
81
91
  end
82
- FileUtils.touch(partial_marker_path.to_s)
92
+ partial_marker_path.touch
83
93
  end
84
94
 
85
95
  if copy_snapshot(target_snapshot_dir, src, parent: parent)
86
96
  partial_marker_path.unlink
87
- Btrfs.run("filesystem", "sync", target_snapshot_dir.to_s)
97
+ btrfs_dest.run("filesystem", "sync", target_snapshot_dir.path_part)
88
98
  Snapsync.info "Successfully synchronized #{src.snapshot_dir}"
89
99
  true
90
100
  end
91
101
  end
92
102
 
103
+ # @param [AgnosticPath] target_snapshot_dir
104
+ # @param [Snapshot] src
105
+ # @param [Snapshot, nil] parent
93
106
  def copy_snapshot(target_snapshot_dir, src, parent: nil)
94
107
  # This variable is used in the 'ensure' block. Make sure it is
95
108
  # initialized properly
96
109
  success = false
97
110
 
98
- File.open(target_snapshot_dir + "info.xml", 'w') do |io|
111
+ (target_snapshot_dir + "info.xml").open('w') do |io|
99
112
  io.write (src.snapshot_dir + "info.xml").read
100
113
  end
101
114
 
@@ -112,15 +125,15 @@ module Snapsync
112
125
  start = Time.now
113
126
  bytes_transferred = nil
114
127
  bytes_transferred =
115
- Btrfs.popen('send', *parent_opt, src.subvolume_dir.to_s) do |send_io|
116
- Btrfs.popen('receive', target_snapshot_dir.to_s, mode: 'w', out: '/dev/null') do |receive_io|
128
+ btrfs_src.popen('send', *parent_opt, src.subvolume_dir.to_s) do |send_io|
129
+ btrfs_dest.popen('receive', target_snapshot_dir.path_part, mode: 'w', out: '/dev/null') do |receive_io|
117
130
  receive_io.sync = true
118
131
  copy_stream(send_io, receive_io, estimated_size: estimated_size)
119
132
  end
120
133
  end
121
134
 
122
135
  Snapsync.info "Flushing data to disk"
123
- Btrfs.run("filesystem", "sync", target_snapshot_dir.to_s)
136
+ btrfs_dest.run("filesystem", "sync", target_snapshot_dir.path_part)
124
137
  duration = Time.now - start
125
138
  rate = bytes_transferred / duration
126
139
  Snapsync.info "Transferred #{human_readable_size(bytes_transferred)} in #{human_readable_time(duration)} (#{human_readable_size(rate)}/s)"
@@ -128,10 +141,10 @@ module Snapsync
128
141
  true
129
142
 
130
143
  rescue Exception => e
131
- Snapsync.warn "Failed to synchronize #{src.snapshot_dir}, deleting target directory"
132
144
  subvolume_dir = target_snapshot_dir + "snapshot"
145
+ Snapsync.warn "Failed to synchronize #{src.snapshot_dir}, deleting target directory #{subvolume_dir}"
133
146
  if subvolume_dir.directory?
134
- Btrfs.run("subvolume", "delete", subvolume_dir.to_s)
147
+ btrfs_dest.run("subvolume", "delete", subvolume_dir.path_part)
135
148
  end
136
149
  if target_snapshot_dir.directory?
137
150
  target_snapshot_dir.rmtree
@@ -143,6 +156,9 @@ module Snapsync
143
156
  def sync
144
157
  STDOUT.sync = true
145
158
 
159
+ # Do a snapper cleanup before syncing
160
+ config.cleanup
161
+
146
162
  # First, create a snapshot and protect it against cleanup, to use as
147
163
  # synchronization point
148
164
  #
@@ -191,23 +207,6 @@ module Snapsync
191
207
 
192
208
  last_common_snapshot
193
209
  end
194
-
195
- def human_readable_time(time)
196
- hrs = time / 3600
197
- min = (time / 60) % 60
198
- sec = time % 60
199
- "%02i:%02i:%02i" % [hrs, min, sec]
200
- end
201
-
202
- def human_readable_size(size, digits: 1)
203
- order = ['B', 'kB', 'MB', 'GB']
204
- magnitude =
205
- if size > 0
206
- Integer(Math.log2(size) / 10)
207
- else 0
208
- end
209
- "%.#{digits}f#{order[magnitude]}" % [Float(size) / (1024 ** magnitude)]
210
- end
211
210
  end
212
211
  end
213
212
 
@@ -0,0 +1,107 @@
1
+ module Snapsync
2
+ class SSHPopen
3
+ class NonZeroExitCode < RuntimeError
4
+ end
5
+ class ExitSignal < RuntimeError
6
+ end
7
+
8
+ # @return [IO]
9
+ attr_reader :read_buffer
10
+
11
+ # @return [IO]
12
+ attr_reader :write_buffer
13
+
14
+ # @param machine [RemotePathname]
15
+ # @param [Array] command
16
+ # @param options [Hash]
17
+ def initialize(machine, command, **options)
18
+ @read_buffer, read_buffer_in = IO.pipe
19
+ write_buffer_out, @write_buffer = IO.pipe
20
+
21
+ if options[:out]
22
+ read_buffer_in = File.open(options[:out], "w")
23
+ end
24
+
25
+ ready = Concurrent::AtomicBoolean.new(false)
26
+ @ssh_thr = Thread.new do
27
+ machine.dup_ssh do |ssh|
28
+ ready.make_true
29
+ if Snapsync.SSH_DEBUG
30
+ log = Logger.new(STDOUT)
31
+ log.level = Logger::DEBUG
32
+ ssh.logger = log
33
+ ssh.logger.sev_threshold = Logger::Severity::DEBUG
34
+ end
35
+ # @type [Net::SSH::Connection::Channel]
36
+ channel = ssh.open_channel do |channel|
37
+ Snapsync.debug "SSHPopen channel opened: #{channel}"
38
+
39
+ channel.on_data do |ch, data|
40
+ read_buffer_in.write(data)
41
+ end
42
+ channel.on_extended_data do |ch, data|
43
+ data = data.chomp
44
+ if data.length > 0
45
+ Snapsync.error data
46
+ end
47
+ end
48
+
49
+ channel.on_request("exit-status") do |ch2, data|
50
+ code = data.read_long
51
+ if code != 0
52
+ raise NonZeroExitCode, "Exited with code: #{code}"
53
+ else
54
+ Snapsync.debug "SSHPopen command finished."
55
+ end
56
+ end
57
+
58
+ channel.on_request("exit-signal") do |ch2, data|
59
+ exit_signal = data.read_long
60
+ raise ExitSignal, "Exited due to signal: #{exit_signal}"
61
+ end
62
+
63
+ channel.on_process do
64
+ begin
65
+ channel.send_data(write_buffer_out.read_nonblock(2 << 20))
66
+ rescue IO::EAGAINWaitReadable
67
+ end
68
+ end
69
+
70
+ channel.exec(Shellwords.join command)
71
+ end
72
+
73
+ ssh.loop(0.001) {
74
+ if write_buffer_out.closed?
75
+ channel.close
76
+ end
77
+
78
+ channel.active?
79
+ }
80
+
81
+ Snapsync.debug "SSHPopen session closed"
82
+ read_buffer_in.close
83
+ write_buffer_out.close
84
+ end
85
+ end
86
+ while ready.false?
87
+ sleep 0.001
88
+ end
89
+ end
90
+
91
+ def sync=(sync)
92
+ # ignore
93
+ end
94
+
95
+ def read(nbytes = nil)
96
+ read_buffer.read nbytes
97
+ end
98
+
99
+ def write(data)
100
+ write_buffer.write data
101
+ end
102
+
103
+ def close
104
+ write_buffer.close
105
+ end
106
+ end
107
+ end