yobi 0.1.0 → 0.3.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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ # Base class for every wrapper around one raw Restic JSON object.
5
+ class FancyHash < Hash
6
+ # @private
7
+ def initialize(raw)
8
+ super()
9
+ replace(raw)
10
+ end
11
+
12
+ # @return [String]
13
+ def inspect
14
+ "#<#{self.class} #{super}>"
15
+ end
16
+
17
+ # @return [void]
18
+ def pretty_print(q)
19
+ q.object_group(self) do
20
+ each do |key, value|
21
+ next if key == "message_type"
22
+
23
+ value = public_send(key) if self.class.method_defined?(key, false)
24
+ q.breakable
25
+ q.text "#{key}="
26
+ q.pp(value)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -22,14 +22,11 @@ module Yobi
22
22
  # @return [Integer] the Restic process's pid
23
23
  attr_reader :pid
24
24
 
25
- # @param io [IO]
26
- # @param pid [Integer]
27
- # @param output_file [File]
28
- # @param argv [Array<String>]
29
- def initialize(io, pid:, output_file:, argv:)
25
+ # @private
26
+ def initialize(io, pid:, output:, argv:)
30
27
  @io = io
31
28
  @pid = pid
32
- @output_file = output_file
29
+ @output = output
33
30
  @argv = argv
34
31
  @closed = false
35
32
  end
@@ -39,6 +36,11 @@ module Yobi
39
36
  @closed
40
37
  end
41
38
 
39
+ # @return [String]
40
+ def inspect
41
+ "#<#{self.class} pid=#{pid} closed=#{closed?}>"
42
+ end
43
+
42
44
  # Reaps the Restic process and raises based on its exit code. Safe to
43
45
  # call more than once.
44
46
  #
@@ -49,7 +51,7 @@ module Yobi
49
51
  @closed = true
50
52
  @io.close unless @io.closed?
51
53
  _, status = Process.wait2(@pid)
52
- Restic.dispatch(exit_code: status.exitstatus, output: Yobi::ResticOutput.new(@output_file), argv: @argv)
54
+ Restic.dispatch(exit_code: status.exitstatus, output: @output, argv: @argv)
53
55
  end
54
56
 
55
57
  # Yields binary-safe chunks of {#io} until EOF, then calls {#close}.
@@ -5,20 +5,16 @@ module Yobi
5
5
  # Restic has reported itself ready. The mounted filesystem itself is
6
6
  # browsed with ordinary file I/O at {#mountpoint}; this object only
7
7
  # manages the Restic process's lifetime.
8
- class Mount
8
+ class MountHandle
9
9
  # @return [String] the path the repository is mounted at
10
10
  attr_reader :mountpoint
11
11
 
12
- # @param wait_thr [Process::Waiter] as returned by `Open3.popen2e`
13
- # @param mountpoint [String]
14
- # @param output [IO]
15
- # @param output_file [File]
16
- # @param argv [Array<String>]
17
- def initialize(wait_thr:, mountpoint:, output:, output_file:, argv:)
12
+ # @private
13
+ def initialize(wait_thr:, mountpoint:, pipe:, output:, argv:)
18
14
  @wait_thr = wait_thr
19
15
  @mountpoint = mountpoint
16
+ @pipe = pipe
20
17
  @output = output
21
- @output_file = output_file
22
18
  @argv = argv
23
19
  @stopped = false
24
20
  end
@@ -33,6 +29,11 @@ module Yobi
33
29
  @stopped
34
30
  end
35
31
 
32
+ # @return [String]
33
+ def inspect
34
+ "#<#{self.class} pid=#{pid} mountpoint=#{mountpoint.inspect} stopped=#{stopped?}>"
35
+ end
36
+
36
37
  # Sends the Restic process SIGINT, waits for it to unmount and exit,
37
38
  # then raises based on its exit code. Safe to call more than once, and
38
39
  # safe to call after the mount has already been stopped externally
@@ -52,13 +53,13 @@ module Yobi
52
53
 
53
54
  drain_remaining_output
54
55
  status = @wait_thr.value
55
- Restic.dispatch(exit_code: status.exitstatus, output: Yobi::ResticOutput.new(@output_file), argv: @argv)
56
+ Restic.dispatch(exit_code: status.exitstatus, output: @output, argv: @argv)
56
57
  end
57
58
 
58
59
  private
59
60
 
60
61
  def drain_remaining_output
61
- @output.each_line { |line| @output_file.write(line) }
62
+ @pipe.each_line { |line| @output.file.write(line) }
62
63
  rescue IOError, Errno::EBADF
63
64
  nil
64
65
  end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
3
  require "time"
5
- require "delegate"
6
4
  require "shellwords"
7
5
 
8
6
  module Yobi
@@ -40,7 +38,7 @@ module Yobi
40
38
  # @param time [String, nil] timestamp to record instead of now
41
39
  # @param verbose [Boolean] stream a {Yobi::BackupVerboseStatus} per file to the block
42
40
  # @param with_atime [Boolean] also store files' access times
43
- # @yieldparam message [Yobi::BackupStatus, Yobi::BackupError, Yobi::BackupVerboseStatus]
41
+ # @yieldparam message [Yobi::BackupStatus, Yobi::BackupError, Yobi::BackupVerboseStatus, Yobi::BackupSummary]
44
42
  # @return [Yobi::BackupOutcome]
45
43
  def backup(source:, excludes: [], exclude_files: [], exclude_if_present: [], exclude_larger_than: nil,
46
44
  files_from: [], files_from_raw: [], files_from_verbatim: [], iexcludes: [], iexclude_files: [],
@@ -85,109 +83,126 @@ module Yobi
85
83
  a.flag(:stdin_from_command)
86
84
  a.flag(:stdin_filename, filename)
87
85
  a.end_of_options.append(tokenize(command))
86
+ else
87
+ raise ArgumentError, "invalid source shape"
88
88
  end
89
89
  end
90
90
 
91
- execution = if block
92
- run_restic(argv) { |raw| dispatch_backup_message(raw, &block) }
93
- else
94
- run_restic(argv)
95
- end
96
- BackupOutcome.new(execution[:exit_code], execution[:output])
91
+ output = Yobi::ResticOutput.new(transform: Yobi::BackupMessageWrapper)
92
+ execution = run_restic(argv, output: output, &block)
93
+ BackupOutcome.new(execution)
97
94
  end
98
95
 
99
96
  private
100
97
 
101
- def dispatch_backup_message(raw)
102
- case raw["message_type"]
103
- when "status"
104
- yield Yobi::BackupStatus.new(raw)
105
- when "error"
106
- yield Yobi::BackupError.new(raw)
107
- when "verbose_status"
108
- yield Yobi::BackupVerboseStatus.new(raw)
109
- end
110
- end
111
-
112
98
  def tokenize(command)
113
99
  case command
114
100
  in String => value
115
101
  Shellwords.split(value)
116
102
  in Array => values
117
103
  values.map(&:to_s)
104
+ else
105
+ raise ArgumentError, "invalid stdin_from_command command shape"
106
+ end
107
+ end
108
+ end
109
+
110
+ # Classifies a raw backup message Hash by its own message_type, wrapping
111
+ # it in the matching typed class. Used both by {Yobi::Repository#backup}
112
+ # (as the {Yobi::ResticOutput} `transform:` for a live streaming run) and
113
+ # by {Yobi::BackupOutcome}'s own post-hoc accessors.
114
+ #
115
+ # @private
116
+ module BackupMessageWrapper
117
+ # @param raw [Hash]
118
+ # @return [Yobi::BackupStatus, Yobi::BackupError, Yobi::BackupVerboseStatus, Yobi::BackupSummary, Hash]
119
+ # the raw Hash itself for a message_type this version of Yobi doesn't recognize
120
+ def self.call(raw)
121
+ case raw["message_type"]
122
+ when "status" then BackupStatus.new(raw)
123
+ when "error" then BackupError.new(raw)
124
+ when "verbose_status" then BackupVerboseStatus.new(raw)
125
+ when "summary" then BackupSummary.new(raw)
126
+ else raw
118
127
  end
119
128
  end
120
129
  end
121
130
 
122
131
  # The outcome of one {Yobi::Repository#backup} call.
123
- class BackupOutcome < Struct.new(:exit_code, :output)
124
- # Matches Restic's own log line format for stderr relayed from a
125
- # `source: [:stdin_from_command, ...]` command's own subprocess.
132
+ class BackupOutcome
133
+ # @private
126
134
  COMMAND_OUTPUT_LINE_PATTERN = /\Asubprocess [^:]+: (.*)/
127
135
 
128
- # @return [Boolean]
129
- def success?
130
- exit_code.zero?
131
- end
136
+ # @return [Yobi::ResticOutput]
137
+ attr_reader :output
132
138
 
133
- # @return [Boolean] true if the backup completed with some files skipped
134
- def partial?
135
- exit_code == 3
139
+ # @private
140
+ def initialize(execution)
141
+ @output = execution[:output]
136
142
  end
137
143
 
138
144
  # @return [Enumerable<Yobi::BackupError>]
139
145
  def errors
140
- @errors ||= BackupErrors.new(output)
146
+ @errors ||= output.messages("error")
147
+ end
148
+
149
+ # Every message from the run, in file order, each wrapped in its own
150
+ # {Yobi::BackupStatus}/{Yobi::BackupError}/{Yobi::BackupVerboseStatus}/{Yobi::BackupSummary}.
151
+ #
152
+ # @return [Enumerable<Yobi::BackupStatus, Yobi::BackupError, Yobi::BackupVerboseStatus, Yobi::BackupSummary>]
153
+ def messages
154
+ @messages ||= output.messages
141
155
  end
142
156
 
143
157
  # The `source: [:stdin_from_command, ...]` subprocess's own stderr, if any, de-prefixed.
144
158
  #
145
- # @return [Array<String>]
159
+ # @yieldparam line [String]
160
+ # @return [Enumerator] if no block is given
146
161
  def command_output
147
- @command_output ||= output.each_line.filter_map do |line|
162
+ return enum_for(:command_output) unless block_given?
163
+
164
+ output.stderr_lines.each do |line|
148
165
  match = COMMAND_OUTPUT_LINE_PATTERN.match(line)
149
- match[1] if match
166
+ yield match[1] if match
150
167
  end
151
168
  end
152
169
 
153
- # @return [Hash] Restic's own `"summary"` fields, with `"backup_start"`/`"backup_end"` parsed into `Time`
154
- def report
155
- @report ||= begin
156
- hash = summary_hash
157
- hash.merge(
158
- "backup_start" => (Time.parse(hash["backup_start"]) if hash["backup_start"]),
159
- "backup_end" => (Time.parse(hash["backup_end"]) if hash["backup_end"])
160
- )
161
- end
170
+ # @return [Yobi::BackupSummary] Restic's own `"summary"` fields
171
+ def summary
172
+ @summary ||= output.messages("summary").first || BackupSummary.new({})
162
173
  end
163
-
164
- private
165
-
166
- def summary_hash
167
- line = output.last_line
168
- hash = line && JSON.parse(line)
169
- if hash && hash["message_type"] == "summary"
170
- hash
171
- else
172
- find_summary || {}
174
+ alias_method :report, :summary
175
+
176
+ # @return [void]
177
+ def pretty_print(q)
178
+ q.object_group(self) do
179
+ q.breakable
180
+ q.text("(has errors)") if errors.any?
181
+ q.breakable
182
+ q.pp summary
173
183
  end
174
184
  end
185
+ end
175
186
 
176
- def find_summary
177
- output.each_line do |line|
178
- next if line.strip.empty?
179
-
180
- hash = JSON.parse(line)
181
- return hash if hash["message_type"] == "summary"
182
- end
187
+ # The `"summary"` message from a backup run, the final result once the
188
+ # command finishes. Dispatched to {Yobi::Repository#backup}'s block, and
189
+ # also what {Yobi::BackupOutcome#summary} returns.
190
+ # https://restic.readthedocs.io/en/stable/075_scripting.html#summary
191
+ class BackupSummary < Yobi::FancyHash
192
+ # @return [Time, nil]
193
+ def backup_start
194
+ @backup_start ||= Time.parse(self["backup_start"]) if self["backup_start"]
195
+ end
183
196
 
184
- nil
197
+ # @return [Time, nil]
198
+ def backup_end
199
+ @backup_end ||= Time.parse(self["backup_end"]) if self["backup_end"]
185
200
  end
186
201
  end
187
202
 
188
203
  # One `"status"` message from a live backup run.
189
204
  # https://restic.readthedocs.io/en/stable/075_scripting.html#status
190
- class BackupStatus < SimpleDelegator
205
+ class BackupStatus < Yobi::FancyHash
191
206
  # @return [Float, nil]
192
207
  def percent_done
193
208
  self["percent_done"]
@@ -226,7 +241,7 @@ module Yobi
226
241
 
227
242
  # One `"error"` message from a backup run.
228
243
  # https://restic.readthedocs.io/en/stable/075_scripting.html#error
229
- class BackupError < SimpleDelegator
244
+ class BackupError < Yobi::FancyHash
230
245
  # @return [String, nil]
231
246
  def message
232
247
  dig("error", "message")
@@ -243,30 +258,9 @@ module Yobi
243
258
  end
244
259
  end
245
260
 
246
- # Enumerable over every {Yobi::BackupError} in a run.
247
- class BackupErrors
248
- include Enumerable
249
-
250
- # @param output [Yobi::ResticOutput]
251
- def initialize(output)
252
- @output = output
253
- end
254
-
255
- # @yieldparam error [Yobi::BackupError]
256
- # @return [Enumerator] if no block is given
257
- def each
258
- return enum_for(:each) unless block_given?
259
-
260
- @output.index["error"].each do |offset|
261
- line = @output.read_line_at(offset)
262
- yield BackupError.new(JSON.parse(line))
263
- end
264
- end
265
- end
266
-
267
261
  # One `"verbose_status"` message from a backup run, one per file. Only
268
262
  # emitted when `verbose: true` is passed to {Yobi::Repository#backup}.
269
- class BackupVerboseStatus < SimpleDelegator
263
+ class BackupVerboseStatus < Yobi::FancyHash
270
264
  # @return [String]
271
265
  def action
272
266
  self["action"]
@@ -4,6 +4,16 @@ require "json"
4
4
 
5
5
  module Yobi
6
6
  class Repository
7
+ # `restic cat config`: the repository's own config document.
8
+ #
9
+ # @return [Hash]
10
+ # @raise [Yobi::RepositoryNotFound, Yobi::AuthenticationFailed]
11
+ def cat_config
12
+ execution = run_restic(build_argv("cat", "config"))
13
+ JSON.parse(execution[:output].to_s)
14
+ end
15
+ alias_method :config, :cat_config
16
+
7
17
  # `restic cat snapshot ID`: one snapshot's own raw stored record.
8
18
  #
9
19
  # @param id [String, Yobi::Snapshot]
@@ -1,8 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
- require "delegate"
5
-
6
3
  module Yobi
7
4
  class Repository
8
5
  # `restic check`: tests the repository for errors.
@@ -23,74 +20,93 @@ module Yobi
23
20
  a.repeat_flag(:tag, tags)
24
21
  a.flag(:with_cache) if with_cache
25
22
  end
26
- execution = run_restic(argv)
27
- CheckOutcome.new(execution[:exit_code], execution[:output])
23
+ output = Yobi::ResticOutput.new(transform: Yobi::CheckMessageWrapper)
24
+ execution = run_restic(argv, output: output)
25
+ CheckOutcome.new(execution)
28
26
  end
29
27
  end
30
28
 
31
- # The outcome of one {Yobi::Repository#check} call.
32
- class CheckOutcome < Struct.new(:exit_code, :output)
33
- # @return [Hash] Restic's own `"summary"` fields (`"num_errors"`, `"broken_packs"`, ...)
34
- def report
35
- @report ||= summary_hash
29
+ # Classifies a raw check message Hash by its own message_type, wrapping
30
+ # it in the matching typed class. Used both as the {Yobi::ResticOutput}
31
+ # `transform:` for {Yobi::Repository#check} and by {Yobi::CheckOutcome}'s
32
+ # own post-hoc accessors.
33
+ #
34
+ # @private
35
+ module CheckMessageWrapper
36
+ # @param raw [Hash]
37
+ # @return [Yobi::CheckError, Yobi::CheckSummary, Hash]
38
+ # the raw Hash itself for a message_type this version of Yobi doesn't recognize
39
+ def self.call(raw)
40
+ case raw["message_type"]
41
+ when "error" then CheckError.new(raw)
42
+ when "summary" then CheckSummary.new(raw)
43
+ else raw
44
+ end
36
45
  end
46
+ end
37
47
 
38
- # @return [Enumerable<Yobi::CheckError>]
39
- def errors
40
- @errors ||= CheckErrors.new(output)
41
- end
48
+ # The outcome of one {Yobi::Repository#check} call.
49
+ class CheckOutcome
50
+ # @return [Yobi::ResticOutput]
51
+ attr_reader :output
42
52
 
43
- private
53
+ # @private
54
+ def initialize(execution)
55
+ @output = execution[:output]
56
+ end
44
57
 
45
- def summary_hash
46
- line = output.last_line
47
- hash = line && JSON.parse(line)
48
- if hash && hash["message_type"] == "summary"
49
- hash
50
- else
51
- find_summary || {}
52
- end
58
+ # @return [Yobi::CheckSummary] Restic's own `"summary"` fields
59
+ def summary
60
+ @summary ||= output.messages("summary").first || CheckSummary.new({})
53
61
  end
62
+ alias_method :report, :summary
54
63
 
55
- def find_summary
56
- output.each_line do |line|
57
- next if line.strip.empty?
64
+ # @return [Enumerable<Yobi::CheckError>]
65
+ def errors
66
+ @errors ||= output.messages("error")
67
+ end
58
68
 
59
- hash = JSON.parse(line)
60
- return hash if hash["message_type"] == "summary"
69
+ # @return [void]
70
+ def pretty_print(q)
71
+ q.object_group(self) do
72
+ q.breakable
73
+ q.text("(has errors)") if errors.any?
74
+ q.breakable
75
+ q.pp summary
61
76
  end
62
-
63
- nil
64
77
  end
65
78
  end
66
79
 
67
80
  # One `"error"` message from a check run.
68
81
  # https://restic.readthedocs.io/en/stable/075_scripting.html#error
69
- class CheckError < SimpleDelegator
82
+ class CheckError < Yobi::FancyHash
70
83
  # @return [String]
71
84
  def message
72
85
  self["message"]
73
86
  end
74
87
  end
75
88
 
76
- # Enumerable over every {Yobi::CheckError} in a run.
77
- class CheckErrors
78
- include Enumerable
89
+ # The `"summary"` message from a check run, the final result once the
90
+ # command finishes. Dispatched to {Yobi::CheckOutcome#summary}.
91
+ class CheckSummary < Yobi::FancyHash
92
+ # @return [Integer]
93
+ def num_errors
94
+ self["num_errors"] || 0
95
+ end
79
96
 
80
- # @param output [Yobi::ResticOutput]
81
- def initialize(output)
82
- @output = output
97
+ # @return [Array<String>] pack IDs needing `repair_packs`/`repair_snapshots`
98
+ def broken_packs
99
+ self["broken_packs"] || []
83
100
  end
84
101
 
85
- # @yieldparam error [Yobi::CheckError]
86
- # @return [Enumerator] if no block is given
87
- def each
88
- return enum_for(:each) unless block_given?
102
+ # @return [Boolean] whether to run {Yobi::Repository#repair_index}
103
+ def suggest_repair_index?
104
+ self["suggest_repair_index"]
105
+ end
89
106
 
90
- @output.index["error"].each do |offset|
91
- line = @output.read_line_at(offset)
92
- yield CheckError.new(JSON.parse(line))
93
- end
107
+ # @return [Boolean] whether to run {Yobi::Repository#prune}
108
+ def suggest_prune?
109
+ self["suggest_prune"]
94
110
  end
95
111
  end
96
112
  end
@@ -5,33 +5,31 @@ module Yobi
5
5
  # `restic copy`: replicates snapshots from another repository into
6
6
  # this one. Already-copied snapshots are skipped automatically.
7
7
  #
8
- # @param from_repo [String, Yobi::Repository] the source repository, or its URL
9
- # (given a `Repository`, its own `#url`/`#password` are used automatically)
8
+ # @param from_repo [String, Yobi::Repository, Array] the source repository's
9
+ # URL; a `Repository` instance (its own `#url`/`#password` are used
10
+ # automatically); or a `[:file, "..."]` tuple reading the URL from a file
10
11
  # @param from_password [String, Array, Symbol, #call, nil] the source
11
12
  # repository's password, same shape as {#initialize}'s `password:`
12
13
  # @param snapshot_ids [Array<String>, String] snapshots to copy; all of them if empty
13
14
  # @param from_key_hint [String, nil]
14
- # @param from_repository_file [String, nil] read the source repository's URL from a file
15
15
  # @param hosts [Array<String>, String] filter by hostname(s)
16
16
  # @param paths [Array<String>, String] filter by originally backed-up path(s)
17
17
  # @param tags [Array<String>, String] filter by tag(s)
18
18
  # @return [true]
19
- def copy(from_repo:, from_password: nil, snapshot_ids: [], from_key_hint: nil,
20
- from_repository_file: nil, hosts: [], paths: [], tags: [])
21
- if from_repo.is_a?(Repository)
22
- from_password = from_repo.password if from_password.nil?
23
- from_repo = from_repo.url
24
- end
19
+ def copy(from_repo:, from_password: nil, snapshot_ids: [], from_key_hint: nil, hosts: [], paths: [], tags: [])
20
+ from_repo_flag, from_repo_value = resolved_from_repo_flag(from_repo)
21
+ from_password = from_repo_password(from_repo) if from_password.nil?
22
+ validate_from_password_shape!(from_password)
25
23
 
26
24
  argv = build_argv("copy", snapshot_ids) do |a|
27
- a.flag(:from_repo, from_repo)
25
+ a.flag(from_repo_flag, from_repo_value)
28
26
  a.flag(:from_insecure_no_password) if from_password == :insecure_no_password
29
27
  a.flag(:from_key_hint, from_key_hint) unless from_key_hint.nil?
30
- a.flag(:from_repository_file, from_repository_file) unless from_repository_file.nil?
31
28
  a.repeat_flag(:host, hosts)
32
29
  a.repeat_flag(:path, paths)
33
30
  a.repeat_flag(:tag, tags)
34
31
  end
32
+
35
33
  run_restic(argv, extra_env: password_env(from_password, "RESTIC_FROM_PASSWORD"))
36
34
  true
37
35
  end