snapsync 0.3.8 → 0.4.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
- SHA1:
3
- metadata.gz: e55815742cc612afdb33329e9525e6bcf3a9f153
4
- data.tar.gz: 557aa91321084a513e65f36fb8da919e2389ddbc
2
+ SHA256:
3
+ metadata.gz: c87326d0c598d929dd5d82c91fb635ef9e9542c6b55add928967f4979eb9d86f
4
+ data.tar.gz: ff4fc4dec816b21d5a1f9944f137b4ef43a48f5198e3b9b23951c7320d5f1777
5
5
  SHA512:
6
- metadata.gz: ef8db4c76dcc301c75dde68728fcf485fe0f73b5032702af3d94e935b5928e7b0a6acb646f7af500dd0e9cfd523761c87bc8f470036d7eefbe9715a51ee9a2bd
7
- data.tar.gz: 726e09e82549925643c11c2c308e7a1a1e72f88da0110376d1b42ece06dd142cf891c7c79b8f09508de46bdc1efb09d61b68ef5504094def33e639798e818b09
6
+ metadata.gz: d5483525d7f72e59e42f88c00c5ced3473c3d2bbd6682f390fc8a7b0fa68494f2197ebc654d47ef97821654844f9d26abf897919ab2b4cee108bd883e18efc82
7
+ data.tar.gz: 70f52a2ba5d403d23bc06a580a80bcb410ee5cecdb1e08d414463f03e1461b43d5970fb84a7acd8ea2baa6c5c43327e84597a6d1a6b8f0dda3975b68208887ca
data/README.md CHANGED
@@ -7,7 +7,7 @@ snapper snapshot directory to a different location using btrfs send and
7
7
  receive.
8
8
 
9
9
  It can be used in two modes:
10
- - in manual mode, you run snapsync
10
+ - in manual mode, you run snapsync
11
11
 
12
12
  ## Installation
13
13
 
@@ -15,7 +15,7 @@ You need to make sure that you've installed Ruby's bundler. On Ubuntu, run
15
15
  $ apt install bundler
16
16
 
17
17
  Then, the following will install snapsync in /opt/snapsync
18
-
18
+
19
19
  $ wget https://raw.githubusercontent.com/doudou/snapsync/master/install.sh
20
20
  $ sh install.sh
21
21
 
@@ -39,6 +39,10 @@ configurations, it will create /path/to/the/drive/snapsync/root and
39
39
  /path/to/the/drive/snapsync/home). The 'default' synchronization policy is used
40
40
  (see below for other options).
41
41
 
42
+ [EXPERIMENTAL] Snapsync targets can also be remote (ssh-reachable) filesystems by using the following scp-like target format:
43
+
44
+ $ snapsync init [user[:password]@]host:/path/to/drive/snapsync
45
+
42
46
  If you use systemd, the background systemd job will from now on synchronize the
43
47
  new target whenever it is present (i.e. as soon as it is plugged in). If you
44
48
  don't, or if you decided to disable the service's auto-start, run (and keep on
@@ -64,6 +68,15 @@ Policies can be set at initialization time by passing additional arguments to
64
68
  'snapsync init', or later with 'snapsync policy'. Run
65
69
  'snapsync help init' and 'snapsync help policy' for more information.
66
70
 
71
+ E.g. If you want to keep the most recent 23 hourly, 6 daily, 3 weekly, 11
72
+ monthly, and 10 yearly snapshots:
73
+
74
+ $ snapsync policy /path/to/the/drive/snapsync/config_dir hour 23 day 6 week 3 month 11 year 10
75
+
76
+ If you only want the most 10 most recent day's snapshots:
77
+
78
+ $ snapsync policy /path/to/the/drive/snapsync/config_dir day 10
79
+
67
80
  ## Manual usage
68
81
 
69
82
  If you prefer using snapsync manually, or use different automation that the one
@@ -5,47 +5,89 @@ module Snapsync
5
5
  # partition availability, and will run sync-all on each (declared) targets
6
6
  # when they are available, optionally auto-mounting them
7
7
  class AutoSync
8
- AutoSyncTarget = Struct.new :partition_uuid, :path, :automount, :name
8
+ AutoSyncTarget = Struct.new :partition_uuid, :mountpoint, :relative, :automount, :name
9
9
 
10
10
  attr_reader :config_dir
11
+ # @return [Hash<String,Array<AutoSyncTarget>>]
11
12
  attr_reader :targets
12
13
  attr_reader :partitions
13
14
 
14
15
  DEFAULT_CONFIG_PATH = Pathname.new('/etc/snapsync.conf')
15
16
 
16
- def self.load_default
17
- result = new
18
- result.load_config
19
- result
20
- end
21
-
22
- def initialize(config_dir = SnapperConfig.default_config_dir)
17
+ def initialize(config_dir = SnapperConfig.default_config_dir, snapsync_config_file: DEFAULT_CONFIG_PATH)
23
18
  @config_dir = config_dir
24
19
  @targets = Hash.new
25
20
  @partitions = PartitionsMonitor.new
21
+ partitions.poll
22
+
23
+ if snapsync_config_file.exist?
24
+ load_config snapsync_config_file
25
+ end
26
26
  end
27
27
 
28
- def load_config(path = DEFAULT_CONFIG_PATH)
28
+ private def load_config(path = DEFAULT_CONFIG_PATH)
29
29
  conf = YAML.load(path.read) || Array.new
30
+ parse_config_migrate_if_needed(path, conf)
31
+ end
32
+
33
+ private def parse_config_migrate_if_needed(path, conf)
34
+ migrated = false
35
+ if conf.is_a? Array
36
+ # Version 1
37
+ Snapsync.info "Migrating config from v1 to v2"
38
+ conf = config_migrate_v1_v2(conf)
39
+ migrated = true
40
+ elsif conf['version'] != 2
41
+ raise 'Unknown snapsync config version: %d ' % [conf.version]
42
+ end
30
43
  parse_config(conf)
44
+ if migrated
45
+ write_config(path)
46
+ end
31
47
  end
32
48
 
33
- def parse_config(conf)
34
- conf.each do |hash|
49
+ private def config_migrate_v1_v2(conf)
50
+ Snapsync.info "Migrating config from version 1 to version 2"
51
+ targets = conf.map do |target|
52
+ target['relative'] = target['path']
53
+ target.delete('path')
54
+ begin
55
+ target['mountpoint'] = partitions.mountpoint_of_uuid(target['partition_uuid'])
56
+ rescue
57
+ raise "Could not migrate config."
58
+ end
59
+
60
+ target
61
+ end
62
+ {
63
+ 'version' => 2,
64
+ 'targets' => targets
65
+ }
66
+ end
67
+
68
+ private def parse_config(conf)
69
+ conf['targets'].each do |hash|
35
70
  target = AutoSyncTarget.new
36
71
  hash.each { |k, v| target[k] = v }
37
- target.path = Pathname.new(target.path)
72
+ target.mountpoint = Snapsync::path(target.mountpoint)
73
+ target.relative = Snapsync::path(target.relative)
38
74
  add(target)
39
75
  end
40
76
  end
41
77
 
42
78
  def write_config(path)
43
- data = each_autosync_target.map do |target|
44
- Hash['partition_uuid' => target.partition_uuid,
45
- 'path' => target.path.to_s,
46
- 'automount' => !!target.automount,
47
- 'name' => target.name]
48
- end
79
+ data = {
80
+ 'version' => 2,
81
+ 'targets' => each_autosync_target.map do |target|
82
+ target.to_h do |k,v|
83
+ if v.is_a? Snapsync::RemotePathname or v.is_a? Pathname
84
+ [k.to_s,v.to_s]
85
+ else
86
+ [k.to_s,v]
87
+ end
88
+ end
89
+ end
90
+ }
49
91
  File.open(path, 'w') do |io|
50
92
  YAML.dump(data, io)
51
93
  end
@@ -53,7 +95,7 @@ module Snapsync
53
95
 
54
96
  # Enumerates the declared autosync targets
55
97
  #
56
- # @yieldparam [AutoSync] target
98
+ # @yieldparam [AutoSyncTarget] target
57
99
  # @return [void]
58
100
  def each_autosync_target
59
101
  return enum_for(__method__) if !block_given?
@@ -72,41 +114,34 @@ module Snapsync
72
114
  # @return [void]
73
115
  def each_available_autosync_target
74
116
  return enum_for(__method__) if !block_given?
75
- partitions.poll
76
-
77
- partitions.known_partitions.each do |uuid, fs|
78
- autosync_targets = targets[uuid]
79
- next if autosync_targets.empty?
80
-
81
- mp = fs['MountPoints'].first
82
- if mp
83
- mp = Pathname.new(mp[0..-2].pack("U*"))
84
- end
85
117
 
118
+ each_autosync_target do |target|
119
+ # @type [RemotePathname]
86
120
  begin
87
121
  mounted = false
88
-
89
- if !mp
90
- if !autosync_targets.any?(&:automount)
122
+ mountpoint = target.mountpoint
123
+ if not mountpoint.mountpoint?
124
+ if not target.automount
91
125
  Snapsync.info "partition #{uuid} is present, but not mounted and automount is false. Ignoring"
92
126
  next
93
127
  end
94
128
 
95
129
  Snapsync.info "partition #{uuid} is present, but not mounted, automounting"
96
130
  begin
97
- mp = fs.Mount([]).first
131
+ if mountpoint.is_a? RemotePathname
132
+ # TODO: automounting of remote paths
133
+ raise 'TODO'
134
+ else
135
+ fs.Mount([]).first
136
+ end
137
+ mounted = true
98
138
  rescue Exception => e
99
139
  Snapsync.warn "failed to mount, ignoring this target"
100
140
  next
101
141
  end
102
- mp = Pathname.new(mp)
103
- mounted = true
104
- end
105
-
106
- autosync_targets.each do |target|
107
- yield(mp + target.path, target)
108
142
  end
109
143
 
144
+ yield(target.mountpoint + target.relative, target)
110
145
  ensure
111
146
  if mounted
112
147
  fs.Unmount([])
@@ -131,6 +166,7 @@ module Snapsync
131
166
  end
132
167
  end
133
168
 
169
+ # @param [AutoSyncTarget] target
134
170
  def add(target)
135
171
  targets[target.partition_uuid] ||= Array.new
136
172
  targets[target.partition_uuid] << target
@@ -1,9 +1,16 @@
1
1
  module Snapsync
2
- module Btrfs
2
+ class Btrfs
3
+ class << self
4
+ # @return [Hash]
5
+ attr_accessor :_mountpointCache
6
+ end
7
+ self._mountpointCache = {}
8
+
3
9
  class Error < RuntimeError
4
10
  attr_reader :error_lines
5
11
  def initialize(error_lines = Array.new)
6
12
  @error_lines = error_lines
13
+ super error_lines
7
14
  end
8
15
 
9
16
  def pretty_print(pp)
@@ -20,49 +27,72 @@ module Snapsync
20
27
  class UnexpectedBtrfsOutput < Error
21
28
  end
22
29
 
23
- def self.btrfs_prog
30
+ include Comparable
31
+ def <=>(other)
32
+ mountpoint.to_s <=> other.mountpoint.to_s
33
+ end
34
+
35
+ # @return [AgnosticPath]
36
+ attr_reader :mountpoint
37
+
38
+ # @return [Array<SubvolumeMinimalInfo>]
39
+ attr_reader :subvolume_table
40
+
41
+ # @param [AgnosticPath] mountpoint
42
+ def initialize(mountpoint)
43
+ raise "Trying to create Btrfs wrapper on non-mountpoint #{mountpoint}" unless mountpoint.mountpoint?
44
+
45
+ Snapsync.debug "Creating Btrfs wrapper at #{mountpoint}"
46
+ @mountpoint = mountpoint
47
+
48
+ @subvolume_table = read_subvolume_table
49
+ end
50
+
51
+ # @param [AgnosticPath] mountpoint
52
+ # @return [Btrfs]
53
+ def self.get(mountpoint)
54
+ mountpoint = mountpoint.findmnt
55
+
56
+ self._mountpointCache.fetch(mountpoint.to_s) do
57
+ btrfs = Btrfs.new mountpoint
58
+ self._mountpointCache[mountpoint.to_s] = btrfs
59
+ btrfs
60
+ end
61
+ end
62
+
63
+ def btrfs_prog
24
64
  ENV['BTRFS_PROG'] || 'btrfs'
25
65
  end
26
66
 
27
67
  # @api private
28
68
  #
29
69
  # A IO.popen-like API to btrfs subcommands
30
- def self.popen(*args, mode: 'r', raise_on_error: true, **options)
31
- err_r, err_w = IO.pipe
32
-
33
- block_error, block_result = nil
34
- IO.popen([btrfs_prog, *args, err: err_w, **options], mode) do |io|
35
- err_w.close
70
+ # @yieldparam [IO] io
71
+ def popen(*args, mode: 'r', raise_on_error: true, **options)
72
+ Snapsync.debug "Btrfs(\"#{mountpoint}\").popen: #{args}"
36
73
 
74
+ if @mountpoint.is_a? RemotePathname
75
+ proc = SSHPopen.new(@mountpoint, [btrfs_prog, *args], **options)
76
+ return yield(proc)
77
+ else
78
+ # @type [IO,IO]
79
+ err_r, err_w = IO.pipe
37
80
  begin
38
- block_result = yield(io)
39
- rescue Error
40
- raise
41
- rescue Exception => block_error
81
+ IO.popen([btrfs_prog, *args], err: err_w, mode: mode) do |io|
82
+ err_w.close
83
+ return yield(io)
84
+ end
85
+ if not $?.success?
86
+ raise Error.new, err_r.read.lines
87
+ end
88
+ ensure err_r.close
42
89
  end
43
- end
44
90
 
45
- if $?.success? && !block_error
46
- block_result
47
- elsif raise_on_error
48
- if block_error
49
- raise Error.new, block_error.message
50
- else
51
- raise Error.new, "btrfs failed"
52
- end
53
91
  end
54
-
55
- rescue Error => e
56
- prefix = args.join(" ")
57
- lines = err_r.readlines.map do |line|
58
- "#{prefix}: #{line.chomp}"
59
- end
60
- raise Error.new(e.error_lines + lines), e.message, e.backtrace
61
-
62
- ensure err_r.close
63
92
  end
64
93
 
65
- def self.run(*args, **options)
94
+ # @return [String]
95
+ def run(*args, **options)
66
96
  popen(*args, **options) do |io|
67
97
  io.read.encode('utf-8', undef: :replace, invalid: :replace)
68
98
  end
@@ -72,12 +102,13 @@ module Snapsync
72
102
  #
73
103
  # @param [Pathname] path the subvolume path
74
104
  # @return [Integer] the subvolume's generation
75
- def self.generation_of(path)
76
- info = Btrfs.run('subvolume', 'show', path.to_s)
105
+ def generation_of(path)
106
+ info = run('subvolume', 'show', path.to_s)
77
107
  if info =~ /Generation[^:]*:\s+(\d+)/
78
108
  Integer($1)
79
109
  else
80
- raise UnexpectedBtrfsOutput, "unexpected output for 'btrfs subvolume show', expected #{info} to contain a Generation: line"
110
+ raise UnexpectedBtrfsOutput, "unexpected output for 'btrfs subvolume show'," \
111
+ +" expected '#{info}' to contain a 'Generation:' line"
81
112
  end
82
113
  end
83
114
 
@@ -94,9 +125,66 @@ module Snapsync
94
125
  #
95
126
  # @overload find_new(subvolume_dir, last_gen)
96
127
  # @return [#each] an enumeration of the lines of the find-new output
97
- def self.find_new(subvolume_dir, last_gen, &block)
128
+ def find_new(subvolume_dir, last_gen, &block)
98
129
  run('subvolume', 'find-new', subvolume_dir.to_s, last_gen.to_s).
99
130
  each_line(&block)
100
131
  end
132
+
133
+ # @return [Array<SubvolumeMinimalInfo>]
134
+ def read_subvolume_table
135
+ table = []
136
+
137
+ text = run('subvolume', 'list','-pcgquR', mountpoint.path_part)
138
+ text.each_line do |l|
139
+ item = SubvolumeMinimalInfo.new
140
+ l.gsub!('top level', 'top_level')
141
+ l = l.split
142
+ l.each_slice(2) do |kv|
143
+ k,v = kv
144
+ if v == '-'
145
+ v = nil
146
+ else
147
+ begin
148
+ v = Integer(v)
149
+ rescue
150
+ # ignore
151
+ end
152
+ end
153
+ item.instance_variable_set '@'+k, v
154
+ end
155
+ table.push item
156
+ end
157
+
158
+ table
159
+ end
160
+
161
+ # @param [AgnosticPath] subvolume_dir
162
+ # @return [Hash<String>]
163
+ def subvolume_show(subvolume_dir)
164
+ # @type [String]
165
+ info = run('subvolume', 'show', subvolume_dir.path_part)
166
+
167
+ data = {}
168
+
169
+ data['absolute_dir'] = info.lines[0].strip
170
+
171
+ lines = info.lines[1..-1]
172
+ lines.each_index do |i|
173
+ l = lines[i]
174
+ k, _, v = l.partition ':'
175
+ k = k.strip.downcase.gsub ' ', '_'
176
+
177
+ if k == 'snapshot(s)'
178
+ data['snapshots'] = lines[i+1..-1].map do |s|
179
+ s.strip
180
+ end
181
+ break
182
+ else
183
+ data[k] = v.strip
184
+ end
185
+ end
186
+
187
+ data
188
+ end
101
189
  end
102
190
  end
@@ -0,0 +1,103 @@
1
+ module Snapsync
2
+ # Output of `btrfs subvolume list`
3
+ class SubvolumeMinimalInfo
4
+ attr_reader :id
5
+ attr_reader :uuid
6
+ attr_reader :path
7
+
8
+ attr_reader :gen
9
+ attr_reader :cgen
10
+
11
+ attr_reader :parent
12
+ attr_reader :top_level
13
+
14
+ # @return [String,nil]
15
+ attr_reader :parent_uuid
16
+ # @return [String, nil]
17
+ attr_reader :received_uuid
18
+ end
19
+
20
+ class SubvolumeInfo
21
+
22
+ # @return [Btrfs]
23
+ attr_reader :btrfs
24
+
25
+ # @return [AgnosticPath]
26
+ attr_reader :subvolume_dir
27
+
28
+ # The absolute path in the btrfs filesystem
29
+ # @return [String]
30
+ attr_reader :absolute_dir
31
+
32
+ # @return [String]
33
+ attr_reader :name
34
+
35
+ # @return [String]
36
+ attr_reader :uuid
37
+
38
+ # Denotes a subvolume that's a direct parent in the snapshot's timeline.
39
+ # I.e. [parent -> self] difference possible
40
+ # @return [String]
41
+ attr_reader :parent_uuid
42
+
43
+ # Denotes the UUID of the subvolume sent by 'btrfs send'
44
+ # @return [String]
45
+ attr_reader :received_uuid
46
+
47
+ # @return [String]
48
+ attr_reader :creation_time
49
+
50
+ # @return [Integer]
51
+ attr_reader :subvolume_id
52
+
53
+ # @return [Integer]
54
+ attr_reader :generation
55
+
56
+ # @return [Integer]
57
+ attr_reader :gen_at_creation
58
+
59
+ # @return [Integer]
60
+ attr_reader :parent_id
61
+
62
+ # @return [Integer]
63
+ attr_reader :top_level_id
64
+
65
+ # @return [String]
66
+ attr_reader :flags
67
+
68
+ # A transaction id in the sending btrfs filesystem for the `btrfs send` action.
69
+ # Does not correspond to anything in subvolumes.
70
+ # @return [Integer]
71
+ attr_reader :send_transid
72
+
73
+ # @return [String]
74
+ attr_reader :send_time
75
+
76
+
77
+ # The transaction of id of the start of the receive. The next transaction_id holds actual data and changes.
78
+ # It is +1 of the subvolume's, created by btrfs receive, gen_at_creation
79
+ # @return [Integer]
80
+ attr_reader :receive_transid
81
+
82
+ # @return [String]
83
+ attr_reader :receive_time
84
+
85
+ # @return [Array<String>]
86
+ attr_reader :snapshots
87
+
88
+ # @param [AgnosticPath] subvolume_dir
89
+ def initialize(subvolume_dir)
90
+ @subvolume_dir = subvolume_dir
91
+ @btrfs = Btrfs.get(subvolume_dir)
92
+
93
+ integers = Set[:subvolume_id, :generation, :gen_at_creation, :parent_id, :top_level_id, :send_transid, :receive_transid]
94
+ btrfs.subvolume_show(subvolume_dir).each do |k, v|
95
+ if integers.include? k.to_sym
96
+ instance_variable_set '@'+k, Integer(v)
97
+ else
98
+ instance_variable_set '@'+k, v
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -8,6 +8,7 @@ module Snapsync
8
8
  @policy = policy
9
9
  end
10
10
 
11
+ # @param [SyncTarget] target
11
12
  def cleanup(target, dry_run: false)
12
13
  snapshots = target.each_snapshot.to_a
13
14
  filtered_snapshots = policy.filter_snapshots(snapshots).to_set