yobi 0.1.0 → 0.2.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: [],
@@ -88,27 +86,13 @@ module Yobi
88
86
  end
89
87
  end
90
88
 
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])
89
+ output = Yobi::ResticOutput.new(transform: Yobi::BackupMessageWrapper)
90
+ execution = run_restic(argv, output: output, &block)
91
+ BackupOutcome.new(execution)
97
92
  end
98
93
 
99
94
  private
100
95
 
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
96
  def tokenize(command)
113
97
  case command
114
98
  in String => value
@@ -119,75 +103,102 @@ module Yobi
119
103
  end
120
104
  end
121
105
 
106
+ # Classifies a raw backup message Hash by its own message_type, wrapping
107
+ # it in the matching typed class. Used both by {Yobi::Repository#backup}
108
+ # (as the {Yobi::ResticOutput} `transform:` for a live streaming run) and
109
+ # by {Yobi::BackupOutcome}'s own post-hoc accessors.
110
+ #
111
+ # @private
112
+ module BackupMessageWrapper
113
+ # @param raw [Hash]
114
+ # @return [Yobi::BackupStatus, Yobi::BackupError, Yobi::BackupVerboseStatus, Yobi::BackupSummary, Hash]
115
+ # the raw Hash itself for a message_type this version of Yobi doesn't recognize
116
+ def self.call(raw)
117
+ case raw["message_type"]
118
+ when "status" then BackupStatus.new(raw)
119
+ when "error" then BackupError.new(raw)
120
+ when "verbose_status" then BackupVerboseStatus.new(raw)
121
+ when "summary" then BackupSummary.new(raw)
122
+ else raw
123
+ end
124
+ end
125
+ end
126
+
122
127
  # 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.
128
+ class BackupOutcome
129
+ # @private
126
130
  COMMAND_OUTPUT_LINE_PATTERN = /\Asubprocess [^:]+: (.*)/
127
131
 
128
- # @return [Boolean]
129
- def success?
130
- exit_code.zero?
131
- end
132
+ # @return [Yobi::ResticOutput]
133
+ attr_reader :output
132
134
 
133
- # @return [Boolean] true if the backup completed with some files skipped
134
- def partial?
135
- exit_code == 3
135
+ # @private
136
+ def initialize(execution)
137
+ @output = execution[:output]
136
138
  end
137
139
 
138
140
  # @return [Enumerable<Yobi::BackupError>]
139
141
  def errors
140
- @errors ||= BackupErrors.new(output)
142
+ @errors ||= output.messages("error")
143
+ end
144
+
145
+ # Every message from the run, in file order, each wrapped in its own
146
+ # {Yobi::BackupStatus}/{Yobi::BackupError}/{Yobi::BackupVerboseStatus}/{Yobi::BackupSummary}.
147
+ #
148
+ # @return [Enumerable<Yobi::BackupStatus, Yobi::BackupError, Yobi::BackupVerboseStatus, Yobi::BackupSummary>]
149
+ def messages
150
+ @messages ||= output.messages
141
151
  end
142
152
 
143
153
  # The `source: [:stdin_from_command, ...]` subprocess's own stderr, if any, de-prefixed.
144
154
  #
145
- # @return [Array<String>]
155
+ # @yieldparam line [String]
156
+ # @return [Enumerator] if no block is given
146
157
  def command_output
147
- @command_output ||= output.each_line.filter_map do |line|
158
+ return enum_for(:command_output) unless block_given?
159
+
160
+ output.stderr_lines.each do |line|
148
161
  match = COMMAND_OUTPUT_LINE_PATTERN.match(line)
149
- match[1] if match
162
+ yield match[1] if match
150
163
  end
151
164
  end
152
165
 
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
166
+ # @return [Yobi::BackupSummary] Restic's own `"summary"` fields
167
+ def summary
168
+ @summary ||= output.messages("summary").first || BackupSummary.new({})
162
169
  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 || {}
170
+ alias_method :report, :summary
171
+
172
+ # @return [void]
173
+ def pretty_print(q)
174
+ q.object_group(self) do
175
+ q.breakable
176
+ q.text("(has errors)") if errors.any?
177
+ q.breakable
178
+ q.pp summary
173
179
  end
174
180
  end
181
+ end
175
182
 
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
183
+ # The `"summary"` message from a backup run, the final result once the
184
+ # command finishes. Dispatched to {Yobi::Repository#backup}'s block, and
185
+ # also what {Yobi::BackupOutcome#summary} returns.
186
+ # https://restic.readthedocs.io/en/stable/075_scripting.html#summary
187
+ class BackupSummary < Yobi::FancyHash
188
+ # @return [Time, nil]
189
+ def backup_start
190
+ @backup_start ||= Time.parse(self["backup_start"]) if self["backup_start"]
191
+ end
183
192
 
184
- nil
193
+ # @return [Time, nil]
194
+ def backup_end
195
+ @backup_end ||= Time.parse(self["backup_end"]) if self["backup_end"]
185
196
  end
186
197
  end
187
198
 
188
199
  # One `"status"` message from a live backup run.
189
200
  # https://restic.readthedocs.io/en/stable/075_scripting.html#status
190
- class BackupStatus < SimpleDelegator
201
+ class BackupStatus < Yobi::FancyHash
191
202
  # @return [Float, nil]
192
203
  def percent_done
193
204
  self["percent_done"]
@@ -226,7 +237,7 @@ module Yobi
226
237
 
227
238
  # One `"error"` message from a backup run.
228
239
  # https://restic.readthedocs.io/en/stable/075_scripting.html#error
229
- class BackupError < SimpleDelegator
240
+ class BackupError < Yobi::FancyHash
230
241
  # @return [String, nil]
231
242
  def message
232
243
  dig("error", "message")
@@ -243,30 +254,9 @@ module Yobi
243
254
  end
244
255
  end
245
256
 
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
257
  # One `"verbose_status"` message from a backup run, one per file. Only
268
258
  # emitted when `verbose: true` is passed to {Yobi::Repository#backup}.
269
- class BackupVerboseStatus < SimpleDelegator
259
+ class BackupVerboseStatus < Yobi::FancyHash
270
260
  # @return [String]
271
261
  def action
272
262
  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