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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -0
- data/CHANGELOG.md +17 -0
- data/README.md +83 -44
- data/lib/yobi/argv_builder.rb +1 -35
- data/lib/yobi/cancellable_proxy.rb +48 -0
- data/lib/yobi/cancellation.rb +97 -0
- data/lib/yobi/errors.rb +45 -30
- data/lib/yobi/fancy_hash.rb +1 -5
- data/lib/yobi/io_handle.rb +9 -17
- data/lib/yobi/mount_handle.rb +7 -11
- data/lib/yobi/repository/backup.rb +59 -90
- data/lib/yobi/repository/cat.rb +23 -43
- data/lib/yobi/repository/check.rb +21 -34
- data/lib/yobi/repository/copy.rb +11 -13
- data/lib/yobi/repository/diff.rb +18 -49
- data/lib/yobi/repository/dump.rb +14 -15
- data/lib/yobi/repository/find.rb +17 -37
- data/lib/yobi/repository/forget.rb +35 -39
- data/lib/yobi/repository/init.rb +19 -24
- data/lib/yobi/repository/key.rb +18 -33
- data/lib/yobi/repository/list.rb +5 -6
- data/lib/yobi/repository/ls.rb +20 -43
- data/lib/yobi/repository/migrate.rb +6 -6
- data/lib/yobi/repository/mount.rb +22 -32
- data/lib/yobi/repository/prune.rb +11 -9
- data/lib/yobi/repository/recover.rb +2 -4
- data/lib/yobi/repository/repair.rb +16 -23
- data/lib/yobi/repository/restore.rb +31 -54
- data/lib/yobi/repository/rewrite.rb +15 -20
- data/lib/yobi/repository/snapshots.rb +7 -8
- data/lib/yobi/repository/stats.rb +26 -21
- data/lib/yobi/repository/tag.rb +23 -33
- data/lib/yobi/repository/unlock.rb +2 -4
- data/lib/yobi/repository.rb +96 -25
- data/lib/yobi/restic.rb +93 -114
- data/lib/yobi/restic_output.rb +2 -74
- data/lib/yobi/snapshot.rb +15 -27
- data/lib/yobi/version.rb +1 -2
- data/lib/yobi.rb +2 -0
- data/sig/yobi.rbs +83 -6
- metadata +5 -2
data/lib/yobi/repository.rb
CHANGED
|
@@ -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
|
-
#
|
|
10
|
+
# The repository location.
|
|
11
11
|
attr_reader :url
|
|
12
|
-
#
|
|
12
|
+
# The repository's encryption password, as given to #initialize.
|
|
13
13
|
attr_reader :password
|
|
14
|
-
#
|
|
14
|
+
# The storage backend's own credentials, as given to #initialize.
|
|
15
15
|
attr_reader :backend_credentials
|
|
16
16
|
|
|
17
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
#
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
# re-
|
|
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
|
-
#
|
|
10
|
+
# Yobi::Repository instead.
|
|
11
11
|
class Restic
|
|
12
|
-
# The oldest Restic version
|
|
12
|
+
# The oldest Restic version #run/#run_dump/#run_mount verify the
|
|
13
13
|
# installed binary meets before executing a real command. See
|
|
14
|
-
#
|
|
15
|
-
MINIMUM_VERSION = Gem::Version.new("0.
|
|
14
|
+
# Yobi::UnsupportedResticVersion.
|
|
15
|
+
MINIMUM_VERSION = Gem::Version.new("0.18.0") # :nodoc:
|
|
16
16
|
|
|
17
|
-
# Env vars
|
|
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
|
-
#
|
|
27
|
-
|
|
28
|
-
#
|
|
25
|
+
# Path to the Restic binary.
|
|
26
|
+
attr_accessor :restic_path
|
|
27
|
+
# +$RESTIC_CACHE_DIR+
|
|
29
28
|
attr_accessor :cache_dir
|
|
30
|
-
#
|
|
29
|
+
# +$RESTIC_COMPRESSION+
|
|
31
30
|
attr_accessor :compression
|
|
32
|
-
#
|
|
31
|
+
# +$RESTIC_PACK_SIZE+
|
|
33
32
|
attr_accessor :pack_size
|
|
34
|
-
#
|
|
33
|
+
# +$RESTIC_READ_CONCURRENCY+
|
|
35
34
|
attr_accessor :read_concurrency
|
|
36
|
-
#
|
|
35
|
+
# +$RESTIC_HOST+
|
|
37
36
|
attr_accessor :host
|
|
38
|
-
#
|
|
37
|
+
# +$RESTIC_PROGRESS_FPS+
|
|
39
38
|
attr_accessor :progress_fps
|
|
40
|
-
#
|
|
39
|
+
# +$RESTIC_CACERT+
|
|
41
40
|
attr_accessor :cacert
|
|
42
|
-
#
|
|
41
|
+
# +$RESTIC_TLS_CLIENT_CERT+
|
|
43
42
|
attr_accessor :tls_client_cert
|
|
44
|
-
#
|
|
43
|
+
# +$RESTIC_KEY_HINT+
|
|
45
44
|
attr_accessor :key_hint
|
|
46
|
-
#
|
|
45
|
+
# +--limit-download+
|
|
47
46
|
attr_accessor :limit_download
|
|
48
|
-
#
|
|
47
|
+
# +--limit-upload+
|
|
49
48
|
attr_accessor :limit_upload
|
|
50
|
-
#
|
|
49
|
+
# +--retry-lock+
|
|
51
50
|
attr_accessor :retry_lock
|
|
52
|
-
#
|
|
51
|
+
# +--no-lock+
|
|
53
52
|
attr_accessor :no_lock
|
|
54
|
-
#
|
|
53
|
+
# +--no-cache+
|
|
55
54
|
attr_accessor :no_cache
|
|
56
|
-
#
|
|
55
|
+
# +--cleanup-cache+
|
|
57
56
|
attr_accessor :cleanup_cache
|
|
58
|
-
#
|
|
57
|
+
# +--no-extra-verify+
|
|
59
58
|
attr_accessor :no_extra_verify
|
|
60
|
-
#
|
|
59
|
+
# +--stuck-request-timeout+
|
|
61
60
|
attr_accessor :stuck_request_timeout
|
|
62
|
-
#
|
|
61
|
+
# +--option+, one per element.
|
|
63
62
|
attr_accessor :options
|
|
64
|
-
#
|
|
63
|
+
# +--http-user-agent+
|
|
65
64
|
attr_accessor :http_user_agent
|
|
66
|
-
#
|
|
65
|
+
# +--quiet+
|
|
67
66
|
attr_accessor :quiet
|
|
68
67
|
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
72
|
-
#
|
|
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.
|
|
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
|
|
141
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
#
|
|
193
|
-
#
|
|
194
|
-
#
|
|
195
|
-
#
|
|
196
|
-
#
|
|
197
|
-
|
|
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
|
-
#
|
|
212
|
-
#
|
|
213
|
-
|
|
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
|
|
231
|
-
#
|
|
211
|
+
# framing (currently only Repository#dump). Spawns Restic with stdout
|
|
212
|
+
# wired to a pipe.
|
|
232
213
|
#
|
|
233
|
-
# Without a block, returns a
|
|
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
|
|
270
|
-
#
|
|
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
|
|
339
|
+
# The result of one Yobi::Restic#version call.
|
|
361
340
|
class ResticVersion < Yobi::FancyHash
|
|
362
|
-
#
|
|
341
|
+
# The Restic version string, e.g. +"0.19.1"+.
|
|
363
342
|
def version
|
|
364
343
|
self["version"]
|
|
365
344
|
end
|
|
366
345
|
|
|
367
|
-
#
|
|
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
|
-
#
|
|
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
|
-
#
|
|
356
|
+
# The CPU architecture Restic was built for, if reported.
|
|
378
357
|
def go_arch
|
|
379
358
|
self["go_arch"]
|
|
380
359
|
end
|