yobi 0.3.1 → 1.1.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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -0
  3. data/CHANGELOG.md +17 -0
  4. data/README.md +83 -44
  5. data/lib/yobi/argv_builder.rb +1 -35
  6. data/lib/yobi/cancellable_proxy.rb +48 -0
  7. data/lib/yobi/cancellation.rb +97 -0
  8. data/lib/yobi/errors.rb +45 -30
  9. data/lib/yobi/fancy_hash.rb +1 -5
  10. data/lib/yobi/io_handle.rb +9 -17
  11. data/lib/yobi/mount_handle.rb +7 -11
  12. data/lib/yobi/repository/backup.rb +59 -90
  13. data/lib/yobi/repository/cat.rb +23 -43
  14. data/lib/yobi/repository/check.rb +21 -34
  15. data/lib/yobi/repository/copy.rb +11 -13
  16. data/lib/yobi/repository/diff.rb +18 -49
  17. data/lib/yobi/repository/dump.rb +14 -15
  18. data/lib/yobi/repository/find.rb +17 -37
  19. data/lib/yobi/repository/forget.rb +35 -39
  20. data/lib/yobi/repository/init.rb +19 -24
  21. data/lib/yobi/repository/key.rb +18 -33
  22. data/lib/yobi/repository/list.rb +5 -6
  23. data/lib/yobi/repository/ls.rb +20 -43
  24. data/lib/yobi/repository/migrate.rb +6 -6
  25. data/lib/yobi/repository/mount.rb +22 -32
  26. data/lib/yobi/repository/prune.rb +11 -9
  27. data/lib/yobi/repository/recover.rb +2 -4
  28. data/lib/yobi/repository/repair.rb +16 -23
  29. data/lib/yobi/repository/restore.rb +31 -54
  30. data/lib/yobi/repository/rewrite.rb +15 -20
  31. data/lib/yobi/repository/snapshots.rb +7 -8
  32. data/lib/yobi/repository/stats.rb +26 -21
  33. data/lib/yobi/repository/tag.rb +23 -33
  34. data/lib/yobi/repository/unlock.rb +2 -4
  35. data/lib/yobi/repository.rb +96 -25
  36. data/lib/yobi/restic.rb +93 -114
  37. data/lib/yobi/restic_output.rb +2 -74
  38. data/lib/yobi/snapshot.rb +15 -27
  39. data/lib/yobi/version.rb +1 -2
  40. data/lib/yobi.rb +2 -0
  41. data/sig/yobi.rbs +83 -6
  42. metadata +5 -2
@@ -7,24 +7,48 @@ module Yobi
7
7
  # One Restic repository. Every Restic subcommand that operates on a
8
8
  # repository is a method here.
9
9
  class Repository
10
- # @return [String] the repository location
10
+ # The repository location.
11
11
  attr_reader :url
12
- # @return [String, Array, Symbol, #call] the repository's encryption password, as given to {#initialize}
12
+ # The repository's encryption password, as given to #initialize.
13
13
  attr_reader :password
14
- # @return [Hash, #call] the storage backend's own credentials, as given to {#initialize}
14
+ # The storage backend's own credentials, as given to #initialize.
15
15
  attr_reader :backend_credentials
16
16
 
17
- # @param url [String] the repository location, e.g. `"s3:s3.amazonaws.com/bucket"`
18
- # @param password [String, Array, Symbol, #call] a literal password; a
19
- # `[:command, "..."]`/`[:file, "..."]` tuple, resolved natively by Restic
20
- # itself; `:insecure_no_password`; or anything responding to `#call`
21
- # (invoked fresh immediately before every Restic invocation)
22
- # @param backend_credentials [Hash{String => String}, #call] the storage
23
- # backend's own env vars (`AWS_*`/`AZURE_*`/etc.), or anything
24
- # responding to `#call` returning such a Hash
25
- # @param restic [Yobi::Restic, String, nil] a `Restic` instance to share,
26
- # a bare Restic binary path, or `nil` to create a default one
27
- # @raise [ArgumentError] if `password:` is `nil`, or if `password:`/`backend_credentials:`/`restic:` is an invalid shape
17
+ # Sets #url and re-extracts any REST credentials embedded in it.
18
+ def url=(value)
19
+ @url, @extracted_rest_credentials = extract_rest_credentials(value)
20
+ end
21
+
22
+ # Sets #password. Validated on assignment with the same shapes #initialize accepts.
23
+ def password=(value)
24
+ validate_password_shape!(value)
25
+ @password = value
26
+ end
27
+
28
+ # Sets #backend_credentials. Validated on assignment with the same shapes #initialize accepts.
29
+ def backend_credentials=(value)
30
+ validate_backend_credentials_shape!(value)
31
+ @backend_credentials = value
32
+ end
33
+
34
+ # Builds a repository handle. Does not touch the remote or spawn Restic.
35
+ #
36
+ # +url+ is a String repository location, e.g. <tt>"s3:s3.amazonaws.com/bucket"</tt>.
37
+ #
38
+ # +password+ is the repository's encryption password. Accepts one of:
39
+ # a literal String; a <tt>[:command, "..."]</tt> or <tt>[:file, "..."]</tt>
40
+ # tuple, resolved natively by Restic itself; <tt>:insecure_no_password</tt>;
41
+ # or any object responding to +#call+ (invoked fresh immediately before every
42
+ # Restic invocation).
43
+ #
44
+ # +backend_credentials+ is the storage backend's own env vars
45
+ # (+AWS_*+/+AZURE_*+/etc.) as a Hash, or a callable returning such a Hash.
46
+ #
47
+ # +restic+ is a Yobi::Restic instance to share, a bare Restic binary path,
48
+ # or +nil+ to create a default one.
49
+ #
50
+ # Raises ArgumentError when +password:+ is +nil+, or when +password:+,
51
+ # +backend_credentials:+, or +restic:+ has an invalid shape.
28
52
  def initialize(url:, password:, backend_credentials: {}, restic: nil)
29
53
  validate_password_shape!(password)
30
54
  validate_backend_credentials_shape!(backend_credentials)
@@ -35,22 +59,42 @@ module Yobi
35
59
  @restic = initialize_restic(restic)
36
60
  end
37
61
 
38
- # @return [Hash{String => String}]
62
+ # The full env Hash Restic will see for this repository: +RESTIC_REPOSITORY+
63
+ # plus resolved password and backend credentials.
39
64
  def env
40
65
  {"RESTIC_REPOSITORY" => url}
41
66
  .merge(resolved_password)
42
67
  .merge(resolved_backend_credentials)
43
68
  end
44
69
 
45
- # @return [String]
70
+ # Redacts sensitive values so a stray +pp+/+puts+/log call never prints a
71
+ # credential in plaintext.
46
72
  def inspect
47
73
  "#<#{self.class} url=#{url.inspect} password=#{redacted_password.inspect} backend_credentials=#{redacted_backend_credentials.inspect}>"
48
74
  end
49
75
 
76
+ # Returns a Yobi::CancellableProxy wrapping this repository, exposing
77
+ # only its long-running methods (#backup, #restore, #check, #prune,
78
+ # #forget, #copy) with +token+ threaded through them, so #cancel! can
79
+ # stop whichever one is called:
80
+ #
81
+ # token = Yobi::Cancellation.new
82
+ # Thread.new { token.cancel! if user_clicked_cancel }
83
+ # repo.with_cancellation(token).backup(source: "/data")
84
+ def with_cancellation(token)
85
+ CancellableProxy.new(self, token)
86
+ end
87
+
50
88
  private
51
89
 
90
+ # Set for the duration of one call by a Yobi::CancellableProxy, on the
91
+ # calling thread only.
92
+ def cancellation
93
+ Thread.current[:yobi_cancellation]
94
+ end
95
+
52
96
  def run_restic(argv, extra_env: {}, output: nil, &block)
53
- @restic.run(argv, extra_env: env.merge(extra_env), output: output, &block)
97
+ @restic.run(argv, extra_env: env.merge(extra_env), output: output, cancellation: cancellation, &block)
54
98
  end
55
99
 
56
100
  def build_argv(*base)
@@ -63,14 +107,10 @@ module Yobi
63
107
  builder.to_a
64
108
  end
65
109
 
66
- # Parses a command's captured output as JSON. If parsing fails, raises
67
- # {Yobi::ResticCommandFailed} when `execution[:exit_code]` is `3`
68
- # (Restic reported a problem alongside otherwise-JSON output), or
69
- # re-raises the original +JSON::ParserError+ when it's `0`.
70
- #
71
- # @param execution [Hash] `{exit_code:, output:, argv:}`
72
- # @return [Object] the parsed JSON
73
- # @raise [Yobi::ResticCommandFailed] if parsing fails and `exit_code` is `3`
110
+ # Restic exit code 3 means "succeeded with warnings" and may mix a
111
+ # plain-text diagnostic into otherwise-JSON output. Surface that as a
112
+ # ResticCommandFailed instead of a bare JSON::ParserError. Exit code 0
113
+ # with unparseable output is a real bug; re-raise the parse error.
74
114
  def parse_json_output(execution)
75
115
  JSON.parse(execution[:output].to_s)
76
116
  rescue JSON::ParserError
@@ -78,6 +118,37 @@ module Yobi
78
118
  raise Yobi::ResticCommandFailed, execution
79
119
  end
80
120
 
121
+ # Same as parse_json_output but tolerant of extra lines around the
122
+ # JSON, from either direction. Known cases at time of writing:
123
+ #
124
+ # - Restic 0.18.0 and 0.18.1 tack prune chatter ("loading indexes...",
125
+ # counts, etc.) onto the end of `restic forget --prune`'s output,
126
+ # after the summary line. Fixed in 0.19.0.
127
+ # - Restic 0.19.0 prints a "[0:00] 100.00% ..." progress line before
128
+ # `restic stats`'s JSON summary. Fixed in 0.19.1. Note that the
129
+ # progress line itself starts with "[", so checking the first char
130
+ # alone isn't enough - each candidate line has to actually parse.
131
+ # - `restic forget` on any version prints a
132
+ # "Remove(snapshot/<id>) failed: <error>" line before its JSON summary
133
+ # when a snapshot listed for removal couldn't actually be deleted
134
+ # (e.g. an append-only backend returns 403). The summary itself is
135
+ # still valid JSON, so the caller can see what did/didn't get removed.
136
+ #
137
+ # The first line that both looks like JSON ("[" or "{") and actually
138
+ # parses wins. If none does, falls back to parsing the whole output
139
+ # verbatim so an empty/malformed response still raises the right thing.
140
+ def parse_json_output_permissively(execution)
141
+ execution[:output].to_s.each_line do |line|
142
+ next unless line.start_with?("[", "{")
143
+ return JSON.parse(line)
144
+ rescue JSON::ParserError
145
+ next
146
+ end
147
+ # No parseable JSON line found. Fall back to strict parsing on the
148
+ # whole output so exit-code-based error dispatch takes over.
149
+ parse_json_output(execution)
150
+ end
151
+
81
152
  def resolved_password
82
153
  password_env(password)
83
154
  end
data/lib/yobi/restic.rb CHANGED
@@ -7,87 +7,68 @@ module Yobi
7
7
  # A specific Restic binary, plus its process-level settings that apply
8
8
  # regardless of which repository is being operated on.
9
9
  # Repository-specific identity (url, credentials) lives on
10
- # {Yobi::Repository} instead.
10
+ # Yobi::Repository instead.
11
11
  class Restic
12
- # The oldest Restic version {#run}/{#run_dump}/{#run_mount} verify the
12
+ # The oldest Restic version #run/#run_dump/#run_mount verify the
13
13
  # installed binary meets before executing a real command. See
14
- # {Yobi::UnsupportedResticVersion}.
15
- MINIMUM_VERSION = Gem::Version.new("0.17.1")
14
+ # Yobi::UnsupportedResticVersion.
15
+ MINIMUM_VERSION = Gem::Version.new("0.18.0") # :nodoc:
16
16
 
17
- # Env vars {#inspect} shows in the clear; anything else is redacted.
18
- ALLOWED_ENV_VARS = %w[
17
+ # Env vars #inspect shows in the clear; anything else is redacted.
18
+ ALLOWED_ENV_VARS = %w[ # :nodoc:
19
19
  RESTIC_REPOSITORY RESTIC_CACHE_DIR RESTIC_COMPRESSION RESTIC_PACK_SIZE
20
20
  RESTIC_READ_CONCURRENCY RESTIC_HOST RESTIC_PROGRESS_FPS RESTIC_CACERT
21
21
  RESTIC_TLS_CLIENT_CERT RESTIC_KEY_HINT TMPDIR TMP AWS_DEFAULT_REGION
22
22
  AWS_SHARED_CREDENTIALS_FILE AZURE_ENDPOINT_SUFFIX AZURE_FORCE_CLI_CREDENTIAL
23
- RCLONE_BWLIMIT
24
- ].to_set.freeze
23
+ RCLONE_BWLIMIT].to_set.freeze
25
24
 
26
- # @return [String] path to the Restic binary
27
- attr_reader :restic_path
28
- # @return [String, nil] `$RESTIC_CACHE_DIR`
25
+ # Path to the Restic binary.
26
+ attr_accessor :restic_path
27
+ # +$RESTIC_CACHE_DIR+
29
28
  attr_accessor :cache_dir
30
- # @return [String, nil] `$RESTIC_COMPRESSION`
29
+ # +$RESTIC_COMPRESSION+
31
30
  attr_accessor :compression
32
- # @return [Integer, nil] `$RESTIC_PACK_SIZE`
31
+ # +$RESTIC_PACK_SIZE+
33
32
  attr_accessor :pack_size
34
- # @return [Integer, nil] `$RESTIC_READ_CONCURRENCY`
33
+ # +$RESTIC_READ_CONCURRENCY+
35
34
  attr_accessor :read_concurrency
36
- # @return [String, nil] `$RESTIC_HOST`
35
+ # +$RESTIC_HOST+
37
36
  attr_accessor :host
38
- # @return [Integer, nil] `$RESTIC_PROGRESS_FPS`
37
+ # +$RESTIC_PROGRESS_FPS+
39
38
  attr_accessor :progress_fps
40
- # @return [String, nil] `$RESTIC_CACERT`
39
+ # +$RESTIC_CACERT+
41
40
  attr_accessor :cacert
42
- # @return [String, nil] `$RESTIC_TLS_CLIENT_CERT`
41
+ # +$RESTIC_TLS_CLIENT_CERT+
43
42
  attr_accessor :tls_client_cert
44
- # @return [String, nil] `$RESTIC_KEY_HINT`
43
+ # +$RESTIC_KEY_HINT+
45
44
  attr_accessor :key_hint
46
- # @return [String, nil] `--limit-download`
45
+ # +--limit-download+
47
46
  attr_accessor :limit_download
48
- # @return [String, nil] `--limit-upload`
47
+ # +--limit-upload+
49
48
  attr_accessor :limit_upload
50
- # @return [String, nil] `--retry-lock`
49
+ # +--retry-lock+
51
50
  attr_accessor :retry_lock
52
- # @return [Boolean] `--no-lock`
51
+ # +--no-lock+
53
52
  attr_accessor :no_lock
54
- # @return [Boolean] `--no-cache`
53
+ # +--no-cache+
55
54
  attr_accessor :no_cache
56
- # @return [Boolean] `--cleanup-cache`
55
+ # +--cleanup-cache+
57
56
  attr_accessor :cleanup_cache
58
- # @return [Boolean] `--no-extra-verify`
57
+ # +--no-extra-verify+
59
58
  attr_accessor :no_extra_verify
60
- # @return [String, nil] `--stuck-request-timeout`
59
+ # +--stuck-request-timeout+
61
60
  attr_accessor :stuck_request_timeout
62
- # @return [Array<String>] `--option`, one per element
61
+ # +--option+, one per element.
63
62
  attr_accessor :options
64
- # @return [String, nil] `--http-user-agent`
63
+ # +--http-user-agent+
65
64
  attr_accessor :http_user_agent
66
- # @return [Boolean] `--quiet`
65
+ # +--quiet+
67
66
  attr_accessor :quiet
68
67
 
69
- # @param restic_path [String, nil] defaults to `$RESTIC_PATH`, then `"restic"`
70
- # @param env [Hash{String => String}] extra env vars, merged over the named settings below
71
- # @param cache_dir [String, nil]
72
- # @param compression [String, nil]
73
- # @param pack_size [Integer, nil]
74
- # @param read_concurrency [Integer, nil]
75
- # @param host [String, nil]
76
- # @param progress_fps [Integer, nil]
77
- # @param cacert [String, nil]
78
- # @param tls_client_cert [String, nil]
79
- # @param key_hint [String, nil]
80
- # @param limit_download [String, nil]
81
- # @param limit_upload [String, nil]
82
- # @param retry_lock [String, nil]
83
- # @param no_lock [Boolean]
84
- # @param no_cache [Boolean]
85
- # @param cleanup_cache [Boolean]
86
- # @param no_extra_verify [Boolean]
87
- # @param stuck_request_timeout [String, nil]
88
- # @param options [Array<String>]
89
- # @param http_user_agent [String, nil]
90
- # @param quiet [Boolean]
68
+ # Builds a Restic executor. +restic_path+ defaults to +$RESTIC_PATH+,
69
+ # then +"restic"+. +env:+ is extra env vars merged over the named
70
+ # settings below (env-var-backed accessors like +cache_dir:+,
71
+ # +compression:+, etc.).
91
72
  def initialize(restic_path = nil, env: {}, cache_dir: nil, compression: nil, pack_size: nil,
92
73
  read_concurrency: nil, host: nil, progress_fps: nil, cacert: nil, tls_client_cert: nil,
93
74
  key_hint: nil, limit_download: nil, limit_upload: nil, retry_lock: nil, no_lock: false,
@@ -118,10 +99,8 @@ module Yobi
118
99
  end
119
100
 
120
101
  # The env-var-backed settings above, computed fresh from their current
121
- # accessor values on every call. `env:` given at construction wins over
102
+ # accessor values on every call. +env:+ given at construction wins over
122
103
  # any of these on key collision.
123
- #
124
- # @return [Hash{String => String}]
125
104
  def env
126
105
  {
127
106
  "RESTIC_CACHE_DIR" => cache_dir,
@@ -137,12 +116,9 @@ module Yobi
137
116
  end
138
117
 
139
118
  # Appends the CLI-only global flags (the ones above with no env var
140
- # equivalent) to a builder. Called from both {#build_argv} and
141
- # `Repository#build_argv`.
142
- #
143
- # @param a [Yobi::ArgvBuilder]
144
- # @return [void]
145
- def append_global_flags(a)
119
+ # equivalent) to a builder. Called from both #build_argv and
120
+ # Repository#build_argv.
121
+ def append_global_flags(a) # :nodoc:
146
122
  a.flag(:limit_download, limit_download) unless limit_download.nil?
147
123
  a.flag(:limit_upload, limit_upload) unless limit_upload.nil?
148
124
  a.flag(:retry_lock, retry_lock) unless retry_lock.nil?
@@ -156,26 +132,22 @@ module Yobi
156
132
  a.flag(:quiet) if quiet
157
133
  end
158
134
 
159
- # @return [String]
135
+ # Redacts sensitive env values so a stray +pp+/+puts+/log call never
136
+ # prints a credential in plaintext.
160
137
  def inspect
161
138
  "#<#{self.class} restic_path=#{restic_path.inspect} env=#{redacted_env.inspect}>"
162
139
  end
163
140
 
164
- # `restic version`: the installed binary's own version info.
165
- #
166
- # @return [Yobi::ResticVersion]
141
+ # +restic version+: the installed binary's own version info. Returns a
142
+ # Yobi::ResticVersion.
167
143
  def version
168
144
  execution = run(build_argv("version"), skip_version_check: true)
169
145
  Yobi::ResticVersion.new(parse_version_output(execution[:output].to_s))
170
146
  end
171
147
 
172
- # `restic cache`: lists and optionally cleans local cache directories.
173
- # Not repository-scoped.
174
- #
175
- # @param cleanup [Boolean] `--cleanup`
176
- # @param max_age [String, nil] `--max-age`
177
- # @param no_size [Boolean] `--no-size`
178
- # @return [true]
148
+ # +restic cache+: lists and optionally cleans local cache directories.
149
+ # Not repository-scoped. +cleanup:+ triggers +--cleanup+, +max_age:+
150
+ # sets +--max-age+, +no_size:+ triggers +--no-size+. Returns +true+.
179
151
  def cache(cleanup: false, max_age: nil, no_size: false)
180
152
  argv = build_argv("cache") do |a|
181
153
  a.flag(:cleanup) if cleanup
@@ -186,32 +158,39 @@ module Yobi
186
158
  true
187
159
  end
188
160
 
189
- # Runs argv against this Restic binary, merging extra_env with this
190
- # instance's own global env.
161
+ # Runs +argv+ against this Restic binary, merging +extra_env:+ with
162
+ # this instance's own global env. +output:+ takes a caller-configured
163
+ # Yobi::ResticOutput (e.g. with its own +transform:+); passing one forces
164
+ # streaming even without a block. When a block is given, each parsed
165
+ # message is yielded live as the command runs.
166
+ #
167
+ # +skip_version_check:+ is used internally by #version to avoid recursing
168
+ # into #ensure_minimum_version!
191
169
  #
192
- # @param argv [Array<String>]
193
- # @param extra_env [Hash{String => String}]
194
- # @param skip_version_check [Boolean] used internally by {#version} to avoid recursing into {#ensure_minimum_version!}
195
- # @param output [Yobi::ResticOutput, nil] a caller-configured one (e.g. with its own `transform:`),
196
- # used instead of a plain one this creates itself; forces streaming even without a block
197
- # @yieldparam message [Object] each message, live, as the command runs - transformed via
198
- # `output`'s own `transform:` if it has one, the raw parsed Hash otherwise
199
- # @return [Hash] `{exit_code:, output:, argv:}` on exit code 0/3
200
- # @raise [Yobi::RepositoryNotFound, Yobi::RepositoryLocked, Yobi::AuthenticationFailed, Yobi::ResticCommandFailed]
201
- def run(argv, extra_env: {}, skip_version_check: false, output: nil, &block)
170
+ # Returns +{exit_code:, output:, argv:}+ on exit code 0 or 3.
171
+ # Raises Yobi::RepositoryNotFound, Yobi::RepositoryLocked,
172
+ # Yobi::AuthenticationFailed, Yobi::Cancelled (exit code 130, e.g. from
173
+ # a Yobi::Cancellation token's #cancel!), or Yobi::ResticCommandFailed
174
+ # otherwise.
175
+ def run(argv, extra_env: {}, skip_version_check: false, output: nil, cancellation: nil, &block) # :nodoc:
202
176
  ensure_minimum_version! unless skip_version_check
177
+ raise Yobi::Cancelled.new({exit_code: nil, output: nil, argv: argv}) if cancellation&.cancelled?
178
+
179
+ # Only forwarded when a token was actually supplied, so the common
180
+ # short metadata commands keep their original call shape.
181
+ cancel_kwarg = cancellation ? {cancellation: cancellation} : {}
203
182
  execution = if output || block
204
- execute_with_streaming(argv, extra_env, output: output, &block)
183
+ execute_with_streaming(argv, extra_env, output: output, **cancel_kwarg, &block)
205
184
  else
206
- execute(argv, extra_env)
185
+ execute(argv, extra_env, **cancel_kwarg)
207
186
  end
187
+
208
188
  self.class.dispatch(execution)
209
189
  end
210
190
 
211
- # @param execution [Hash] `{exit_code:, output:, argv:}`
212
- # @return [Hash] `execution`, unchanged, on exit code 0/3
213
- # @raise [Yobi::RepositoryNotFound, Yobi::RepositoryLocked, Yobi::AuthenticationFailed, Yobi::ResticCommandFailed]
214
- def self.dispatch(execution)
191
+ # Maps Restic's exit codes to Yobi's typed errors. Returns +execution+
192
+ # unchanged on 0/3.
193
+ def self.dispatch(execution) # :nodoc:
215
194
  case execution[:exit_code]
216
195
  when 0, 3
217
196
  execution
@@ -221,27 +200,23 @@ module Yobi
221
200
  raise Yobi::RepositoryLocked, execution
222
201
  when 12
223
202
  raise Yobi::AuthenticationFailed, execution
203
+ when 130
204
+ raise Yobi::Cancelled, execution
224
205
  else
225
206
  raise Yobi::ResticCommandFailed, execution
226
207
  end
227
208
  end
228
209
 
229
210
  # For commands whose success output is raw bytes with no JSON message
230
- # framing (currently only `Repository#dump`). Spawns Restic with
231
- # stdout wired to a pipe.
211
+ # framing (currently only Repository#dump). Spawns Restic with stdout
212
+ # wired to a pipe.
232
213
  #
233
- # Without a block, returns a {Yobi::IOHandle} immediately; closing,
214
+ # Without a block, returns a Yobi::IOHandle immediately; closing,
234
215
  # reaping, and exit-code dispatch are the caller's own responsibility.
235
216
  # With one, yields the pipe's read end; it's always closed and the
236
217
  # process always reaped once the block returns or raises, before any
237
218
  # exit-code dispatch runs.
238
- #
239
- # @param argv [Array<String>]
240
- # @param extra_env [Hash{String => String}]
241
- # @yieldparam io [IO]
242
- # @return [Yobi::IOHandle] if no block is given
243
- # @return [Object] the block's own return value, otherwise
244
- def run_dump(argv, extra_env: {})
219
+ def run_dump(argv, extra_env: {}) # :nodoc:
245
220
  ensure_minimum_version!
246
221
  output = Yobi::ResticOutput.new
247
222
  read_end, write_end = IO.pipe
@@ -266,13 +241,11 @@ module Yobi
266
241
  raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
267
242
  end
268
243
 
269
- # Verifies the installed Restic binary meets {MINIMUM_VERSION}, once
270
- # per instance (memoized). {#run}/{#run_dump}/{#run_mount} call this
244
+ # Verifies the installed Restic binary meets MINIMUM_VERSION, once per
245
+ # instance (memoized). #run/#run_dump/#run_mount call this
271
246
  # automatically before executing a real command; public so a caller
272
- # can also call it explicitly to fail fast.
273
- #
274
- # @return [void]
275
- # @raise [Yobi::UnsupportedResticVersion]
247
+ # can also call it explicitly to fail fast. Raises
248
+ # Yobi::UnsupportedResticVersion when too old.
276
249
  def ensure_minimum_version!
277
250
  return if defined?(@version_checked)
278
251
  @version_checked = true
@@ -288,7 +261,7 @@ module Yobi
288
261
  # An old enough Restic ignores --json for `version` entirely and
289
262
  # prints plain text instead (e.g. "restic 0.9.6 compiled with
290
263
  # go1.13.4 on linux/amd64"), which JSON.parse can't handle.
291
- VERSION_LINE_PATTERN = /restic\s+(\d+\.\d+\.\d+\S*)/
264
+ VERSION_LINE_PATTERN = /restic\s+(\d+\.\d+\.\d+\S*)/ # :nodoc:
292
265
 
293
266
  def parse_version_output(raw)
294
267
  JSON.parse(raw)
@@ -318,21 +291,25 @@ module Yobi
318
291
  end
319
292
  end
320
293
 
321
- def execute(argv, extra_env)
294
+ def execute(argv, extra_env, cancellation: nil)
322
295
  output = Yobi::ResticOutput.new
323
296
  pid = Process.spawn(env.merge(extra_env), restic_path, *argv, out: output.file, err: output.file)
297
+ cancellation&.attach(pid)
324
298
  _, status = Process.wait2(pid)
325
299
  {exit_code: status.exitstatus, output: output, argv: argv}
326
300
  rescue Errno::ENOENT
327
301
  output.file.close
328
302
  raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
303
+ ensure
304
+ cancellation&.detach
329
305
  end
330
306
 
331
- def execute_with_streaming(argv, extra_env, output: nil)
307
+ def execute_with_streaming(argv, extra_env, output: nil, cancellation: nil)
332
308
  output ||= Yobi::ResticOutput.new
333
309
 
334
310
  Open3.popen3(env.merge(extra_env), restic_path, *argv) do |stdin, stdout, stderr, wait_thr|
335
311
  stdin.close
312
+ cancellation&.attach(wait_thr.pid)
336
313
  readers = {stdout => :stdout, stderr => :stderr}
337
314
 
338
315
  until readers.empty?
@@ -354,27 +331,29 @@ module Yobi
354
331
  rescue Errno::ENOENT
355
332
  output.file.close
356
333
  raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
334
+ ensure
335
+ cancellation&.detach
357
336
  end
358
337
  end
359
338
 
360
- # The result of one {Yobi::Restic#version} call.
339
+ # The result of one Yobi::Restic#version call.
361
340
  class ResticVersion < Yobi::FancyHash
362
- # @return [String]
341
+ # The Restic version string, e.g. +"0.19.1"+.
363
342
  def version
364
343
  self["version"]
365
344
  end
366
345
 
367
- # @return [String, nil]
346
+ # The Go compiler version Restic was built with, if reported.
368
347
  def go_version
369
348
  self["go_version"]
370
349
  end
371
350
 
372
- # @return [String, nil]
351
+ # The OS Restic was built for, if reported.
373
352
  def go_os
374
353
  self["go_os"]
375
354
  end
376
355
 
377
- # @return [String, nil]
356
+ # The CPU architecture Restic was built for, if reported.
378
357
  def go_arch
379
358
  self["go_arch"]
380
359
  end