snapsync 0.3.7 → 0.4.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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