yobi 0.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 +7 -0
- data/CHANGELOG.md +3 -0
- data/LICENSE.txt +21 -0
- data/README.md +897 -0
- data/Rakefile +12 -0
- data/lib/yobi/argv_builder.rb +102 -0
- data/lib/yobi/errors.rb +109 -0
- data/lib/yobi/io_handle.rb +68 -0
- data/lib/yobi/mount.rb +66 -0
- data/lib/yobi/repository/backup.rb +310 -0
- data/lib/yobi/repository/cat.rb +87 -0
- data/lib/yobi/repository/check.rb +96 -0
- data/lib/yobi/repository/copy.rb +39 -0
- data/lib/yobi/repository/diff.rb +94 -0
- data/lib/yobi/repository/dump.rb +51 -0
- data/lib/yobi/repository/find.rb +160 -0
- data/lib/yobi/repository/forget.rb +164 -0
- data/lib/yobi/repository/key.rb +126 -0
- data/lib/yobi/repository/list.rb +17 -0
- data/lib/yobi/repository/ls.rb +138 -0
- data/lib/yobi/repository/migrate.rb +21 -0
- data/lib/yobi/repository/mount.rb +129 -0
- data/lib/yobi/repository/prune.rb +30 -0
- data/lib/yobi/repository/recover.rb +16 -0
- data/lib/yobi/repository/repair.rb +59 -0
- data/lib/yobi/repository/restore.rb +159 -0
- data/lib/yobi/repository/rewrite.rb +53 -0
- data/lib/yobi/repository/snapshots.rb +56 -0
- data/lib/yobi/repository/stats.rb +36 -0
- data/lib/yobi/repository/tag.rb +89 -0
- data/lib/yobi/repository/unlock.rb +17 -0
- data/lib/yobi/repository.rb +180 -0
- data/lib/yobi/restic.rb +341 -0
- data/lib/yobi/restic_output.rb +94 -0
- data/lib/yobi/snapshot.rb +45 -0
- data/lib/yobi/version.rb +8 -0
- data/lib/yobi.rb +13 -0
- data/sig/yobi.rbs +660 -0
- metadata +84 -0
data/lib/yobi/restic.rb
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "json"
|
|
6
|
+
|
|
7
|
+
module Yobi
|
|
8
|
+
# A specific Restic binary, plus its process-level settings that apply
|
|
9
|
+
# regardless of which repository is being operated on.
|
|
10
|
+
# Repository-specific identity (url, credentials) lives on
|
|
11
|
+
# {Yobi::Repository} instead.
|
|
12
|
+
class Restic
|
|
13
|
+
# The oldest Restic version {#run}/{#run_dump}/{#run_mount} verify the
|
|
14
|
+
# installed binary meets before executing a real command. See
|
|
15
|
+
# {Yobi::UnsupportedResticVersion}.
|
|
16
|
+
MINIMUM_VERSION = Gem::Version.new("0.17.1")
|
|
17
|
+
|
|
18
|
+
# Env vars {#inspect} shows in the clear; anything else is redacted.
|
|
19
|
+
ALLOWED_ENV_VARS = %w[
|
|
20
|
+
RESTIC_REPOSITORY RESTIC_CACHE_DIR RESTIC_COMPRESSION RESTIC_PACK_SIZE
|
|
21
|
+
RESTIC_READ_CONCURRENCY RESTIC_HOST RESTIC_PROGRESS_FPS RESTIC_CACERT
|
|
22
|
+
RESTIC_TLS_CLIENT_CERT RESTIC_KEY_HINT TMPDIR TMP AWS_DEFAULT_REGION
|
|
23
|
+
AWS_SHARED_CREDENTIALS_FILE AZURE_ENDPOINT_SUFFIX AZURE_FORCE_CLI_CREDENTIAL
|
|
24
|
+
RCLONE_BWLIMIT
|
|
25
|
+
].to_set.freeze
|
|
26
|
+
|
|
27
|
+
# @return [String] path to the Restic binary
|
|
28
|
+
attr_reader :restic_path
|
|
29
|
+
# @return [String, nil] `$RESTIC_CACHE_DIR`
|
|
30
|
+
attr_accessor :cache_dir
|
|
31
|
+
# @return [String, nil] `$RESTIC_COMPRESSION`
|
|
32
|
+
attr_accessor :compression
|
|
33
|
+
# @return [Integer, nil] `$RESTIC_PACK_SIZE`
|
|
34
|
+
attr_accessor :pack_size
|
|
35
|
+
# @return [Integer, nil] `$RESTIC_READ_CONCURRENCY`
|
|
36
|
+
attr_accessor :read_concurrency
|
|
37
|
+
# @return [String, nil] `$RESTIC_HOST`
|
|
38
|
+
attr_accessor :host
|
|
39
|
+
# @return [Integer, nil] `$RESTIC_PROGRESS_FPS`
|
|
40
|
+
attr_accessor :progress_fps
|
|
41
|
+
# @return [String, nil] `$RESTIC_CACERT`
|
|
42
|
+
attr_accessor :cacert
|
|
43
|
+
# @return [String, nil] `$RESTIC_TLS_CLIENT_CERT`
|
|
44
|
+
attr_accessor :tls_client_cert
|
|
45
|
+
# @return [String, nil] `$RESTIC_KEY_HINT`
|
|
46
|
+
attr_accessor :key_hint
|
|
47
|
+
# @return [String, nil] `--limit-download`
|
|
48
|
+
attr_accessor :limit_download
|
|
49
|
+
# @return [String, nil] `--limit-upload`
|
|
50
|
+
attr_accessor :limit_upload
|
|
51
|
+
# @return [String, nil] `--retry-lock`
|
|
52
|
+
attr_accessor :retry_lock
|
|
53
|
+
# @return [Boolean] `--no-lock`
|
|
54
|
+
attr_accessor :no_lock
|
|
55
|
+
# @return [Boolean] `--no-cache`
|
|
56
|
+
attr_accessor :no_cache
|
|
57
|
+
# @return [Boolean] `--cleanup-cache`
|
|
58
|
+
attr_accessor :cleanup_cache
|
|
59
|
+
# @return [Boolean] `--no-extra-verify`
|
|
60
|
+
attr_accessor :no_extra_verify
|
|
61
|
+
# @return [String, nil] `--stuck-request-timeout`
|
|
62
|
+
attr_accessor :stuck_request_timeout
|
|
63
|
+
# @return [Array<String>] `--option`, one per element
|
|
64
|
+
attr_accessor :options
|
|
65
|
+
# @return [String, nil] `--http-user-agent`
|
|
66
|
+
attr_accessor :http_user_agent
|
|
67
|
+
# @return [Boolean] `--quiet`
|
|
68
|
+
attr_accessor :quiet
|
|
69
|
+
|
|
70
|
+
# @param restic_path [String, nil] defaults to `$RESTIC_PATH`, then `"restic"`
|
|
71
|
+
# @param env [Hash{String => String}] extra env vars, merged over the named settings below
|
|
72
|
+
# @param cache_dir [String, nil]
|
|
73
|
+
# @param compression [String, nil]
|
|
74
|
+
# @param pack_size [Integer, nil]
|
|
75
|
+
# @param read_concurrency [Integer, nil]
|
|
76
|
+
# @param host [String, nil]
|
|
77
|
+
# @param progress_fps [Integer, nil]
|
|
78
|
+
# @param cacert [String, nil]
|
|
79
|
+
# @param tls_client_cert [String, nil]
|
|
80
|
+
# @param key_hint [String, nil]
|
|
81
|
+
# @param limit_download [String, nil]
|
|
82
|
+
# @param limit_upload [String, nil]
|
|
83
|
+
# @param retry_lock [String, nil]
|
|
84
|
+
# @param no_lock [Boolean]
|
|
85
|
+
# @param no_cache [Boolean]
|
|
86
|
+
# @param cleanup_cache [Boolean]
|
|
87
|
+
# @param no_extra_verify [Boolean]
|
|
88
|
+
# @param stuck_request_timeout [String, nil]
|
|
89
|
+
# @param options [Array<String>]
|
|
90
|
+
# @param http_user_agent [String, nil]
|
|
91
|
+
# @param quiet [Boolean]
|
|
92
|
+
def initialize(restic_path = nil, env: {}, cache_dir: nil, compression: nil, pack_size: nil,
|
|
93
|
+
read_concurrency: nil, host: nil, progress_fps: nil, cacert: nil, tls_client_cert: nil,
|
|
94
|
+
key_hint: nil, limit_download: nil, limit_upload: nil, retry_lock: nil, no_lock: false,
|
|
95
|
+
no_cache: false, cleanup_cache: false, no_extra_verify: false, stuck_request_timeout: nil,
|
|
96
|
+
options: [], http_user_agent: nil, quiet: false)
|
|
97
|
+
@restic_path = restic_path || ENV.fetch("RESTIC_PATH", "restic")
|
|
98
|
+
@env = env
|
|
99
|
+
@cache_dir = cache_dir
|
|
100
|
+
@compression = compression
|
|
101
|
+
@pack_size = pack_size
|
|
102
|
+
@read_concurrency = read_concurrency
|
|
103
|
+
@host = host
|
|
104
|
+
@progress_fps = progress_fps
|
|
105
|
+
@cacert = cacert
|
|
106
|
+
@tls_client_cert = tls_client_cert
|
|
107
|
+
@key_hint = key_hint
|
|
108
|
+
@limit_download = limit_download
|
|
109
|
+
@limit_upload = limit_upload
|
|
110
|
+
@retry_lock = retry_lock
|
|
111
|
+
@no_lock = no_lock
|
|
112
|
+
@no_cache = no_cache
|
|
113
|
+
@cleanup_cache = cleanup_cache
|
|
114
|
+
@no_extra_verify = no_extra_verify
|
|
115
|
+
@stuck_request_timeout = stuck_request_timeout
|
|
116
|
+
@options = options
|
|
117
|
+
@http_user_agent = http_user_agent
|
|
118
|
+
@quiet = quiet
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# The env-var-backed settings above, computed fresh from their current
|
|
122
|
+
# accessor values on every call. `env:` given at construction wins over
|
|
123
|
+
# any of these on key collision.
|
|
124
|
+
#
|
|
125
|
+
# @return [Hash{String => String}]
|
|
126
|
+
def env
|
|
127
|
+
{
|
|
128
|
+
"RESTIC_CACHE_DIR" => cache_dir,
|
|
129
|
+
"RESTIC_COMPRESSION" => compression,
|
|
130
|
+
"RESTIC_PACK_SIZE" => pack_size&.to_s,
|
|
131
|
+
"RESTIC_READ_CONCURRENCY" => read_concurrency&.to_s,
|
|
132
|
+
"RESTIC_HOST" => host,
|
|
133
|
+
"RESTIC_PROGRESS_FPS" => progress_fps&.to_s,
|
|
134
|
+
"RESTIC_CACERT" => cacert,
|
|
135
|
+
"RESTIC_TLS_CLIENT_CERT" => tls_client_cert,
|
|
136
|
+
"RESTIC_KEY_HINT" => key_hint
|
|
137
|
+
}.compact.merge(@env)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Appends the CLI-only global flags (the ones above with no env var
|
|
141
|
+
# equivalent) to a builder. Called from both {#build_argv} and
|
|
142
|
+
# `Repository#build_argv`.
|
|
143
|
+
#
|
|
144
|
+
# @param a [Yobi::ArgvBuilder]
|
|
145
|
+
# @return [void]
|
|
146
|
+
def append_global_flags(a)
|
|
147
|
+
a.flag(:limit_download, limit_download) unless limit_download.nil?
|
|
148
|
+
a.flag(:limit_upload, limit_upload) unless limit_upload.nil?
|
|
149
|
+
a.flag(:retry_lock, retry_lock) unless retry_lock.nil?
|
|
150
|
+
a.flag(:no_lock) if no_lock
|
|
151
|
+
a.flag(:no_cache) if no_cache
|
|
152
|
+
a.flag(:cleanup_cache) if cleanup_cache
|
|
153
|
+
a.flag(:no_extra_verify) if no_extra_verify
|
|
154
|
+
a.flag(:stuck_request_timeout, stuck_request_timeout) unless stuck_request_timeout.nil?
|
|
155
|
+
a.repeat_flag(:option, options)
|
|
156
|
+
a.flag(:http_user_agent, http_user_agent) unless http_user_agent.nil?
|
|
157
|
+
a.flag(:quiet) if quiet
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# @return [String]
|
|
161
|
+
def inspect
|
|
162
|
+
"#<#{self.class} restic_path=#{restic_path.inspect} env=#{redacted_env.inspect}>"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# `restic version`: the installed binary's own version info.
|
|
166
|
+
#
|
|
167
|
+
# @return [Hash] `"version"`, `"go_version"`, `"go_os"`, `"go_arch"`
|
|
168
|
+
def version
|
|
169
|
+
execution = run(build_argv("version"), skip_version_check: true)
|
|
170
|
+
JSON.parse(execution[:output].to_s)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# `restic cache`: lists and optionally cleans local cache directories.
|
|
174
|
+
# Not repository-scoped.
|
|
175
|
+
#
|
|
176
|
+
# @param cleanup [Boolean] `--cleanup`
|
|
177
|
+
# @param max_age [String, nil] `--max-age`
|
|
178
|
+
# @param no_size [Boolean] `--no-size`
|
|
179
|
+
# @return [true]
|
|
180
|
+
def cache(cleanup: false, max_age: nil, no_size: false)
|
|
181
|
+
argv = build_argv("cache") do |a|
|
|
182
|
+
a.flag(:cleanup) if cleanup
|
|
183
|
+
a.flag(:max_age, max_age) unless max_age.nil?
|
|
184
|
+
a.flag(:no_size) if no_size
|
|
185
|
+
end
|
|
186
|
+
run(argv)
|
|
187
|
+
true
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# Runs argv against this Restic binary, merging extra_env with this
|
|
191
|
+
# instance's own global env.
|
|
192
|
+
#
|
|
193
|
+
# @param argv [Array<String>]
|
|
194
|
+
# @param extra_env [Hash{String => String}]
|
|
195
|
+
# @param skip_version_check [Boolean] used internally by {#version} to avoid recursing into {#ensure_minimum_version!}
|
|
196
|
+
# @yieldparam message [Hash] a parsed JSON line, as the command runs
|
|
197
|
+
# @return [Hash] `{exit_code:, output:, argv:}` on exit code 0/3
|
|
198
|
+
# @raise [Yobi::RepositoryNotFound, Yobi::RepositoryLocked, Yobi::AuthenticationFailed, Yobi::ResticCommandFailed]
|
|
199
|
+
def run(argv, extra_env: {}, skip_version_check: false, &block)
|
|
200
|
+
ensure_minimum_version! unless skip_version_check
|
|
201
|
+
execution = if block
|
|
202
|
+
execute_with_streaming(argv, extra_env, &block)
|
|
203
|
+
else
|
|
204
|
+
execute(argv, extra_env)
|
|
205
|
+
end
|
|
206
|
+
self.class.dispatch(execution)
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# @param execution [Hash] `{exit_code:, output:, argv:}`
|
|
210
|
+
# @return [Hash] `execution`, unchanged, on exit code 0/3
|
|
211
|
+
# @raise [Yobi::RepositoryNotFound, Yobi::RepositoryLocked, Yobi::AuthenticationFailed, Yobi::ResticCommandFailed]
|
|
212
|
+
def self.dispatch(execution)
|
|
213
|
+
case execution[:exit_code]
|
|
214
|
+
when 0, 3
|
|
215
|
+
execution
|
|
216
|
+
when 10
|
|
217
|
+
raise Yobi::RepositoryNotFound, execution
|
|
218
|
+
when 11
|
|
219
|
+
raise Yobi::RepositoryLocked, execution
|
|
220
|
+
when 12
|
|
221
|
+
raise Yobi::AuthenticationFailed, execution
|
|
222
|
+
else
|
|
223
|
+
raise Yobi::ResticCommandFailed, execution
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# For commands whose success output is raw bytes with no JSON message
|
|
228
|
+
# framing (currently only `Repository#dump`). Spawns Restic with
|
|
229
|
+
# stdout wired to a pipe.
|
|
230
|
+
#
|
|
231
|
+
# Without a block, returns a {Yobi::IOHandle} immediately; closing,
|
|
232
|
+
# reaping, and exit-code dispatch are the caller's own responsibility.
|
|
233
|
+
# With one, yields the pipe's read end; it's always closed and the
|
|
234
|
+
# process always reaped once the block returns or raises, before any
|
|
235
|
+
# exit-code dispatch runs.
|
|
236
|
+
#
|
|
237
|
+
# @param argv [Array<String>]
|
|
238
|
+
# @param extra_env [Hash{String => String}]
|
|
239
|
+
# @yieldparam io [IO]
|
|
240
|
+
# @return [Yobi::IOHandle] if no block is given
|
|
241
|
+
# @return [Object] the block's own return value, otherwise
|
|
242
|
+
def run_dump(argv, extra_env: {})
|
|
243
|
+
ensure_minimum_version!
|
|
244
|
+
file = Tempfile.new("yobi-restic-output")
|
|
245
|
+
file.unlink
|
|
246
|
+
read_end, write_end = IO.pipe
|
|
247
|
+
read_end.binmode
|
|
248
|
+
|
|
249
|
+
pid = Process.spawn(env.merge(extra_env), restic_path, *argv, out: write_end, err: file)
|
|
250
|
+
write_end.close
|
|
251
|
+
|
|
252
|
+
return Yobi::IOHandle.new(read_end, pid: pid, output_file: file, argv: argv) unless block_given?
|
|
253
|
+
|
|
254
|
+
begin
|
|
255
|
+
result = yield read_end
|
|
256
|
+
ensure
|
|
257
|
+
read_end.close unless read_end.closed?
|
|
258
|
+
_, status = Process.wait2(pid)
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
self.class.dispatch(exit_code: status.exitstatus, output: Yobi::ResticOutput.new(file), argv: argv)
|
|
262
|
+
result
|
|
263
|
+
rescue Errno::ENOENT
|
|
264
|
+
file&.close
|
|
265
|
+
raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Verifies the installed Restic binary meets {MINIMUM_VERSION}, once
|
|
269
|
+
# per instance (memoized). {#run}/{#run_dump}/{#run_mount} call this
|
|
270
|
+
# automatically before executing a real command; public so a caller
|
|
271
|
+
# can also call it explicitly to fail fast.
|
|
272
|
+
#
|
|
273
|
+
# @return [void]
|
|
274
|
+
# @raise [Yobi::UnsupportedResticVersion]
|
|
275
|
+
def ensure_minimum_version!
|
|
276
|
+
return if defined?(@version_checked)
|
|
277
|
+
@version_checked = true
|
|
278
|
+
|
|
279
|
+
installed = Gem::Version.new(version["version"])
|
|
280
|
+
return if installed >= MINIMUM_VERSION
|
|
281
|
+
|
|
282
|
+
raise Yobi::UnsupportedResticVersion.new(installed_version: installed.to_s, minimum_version: MINIMUM_VERSION.to_s)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
private
|
|
286
|
+
|
|
287
|
+
def build_argv(*base)
|
|
288
|
+
builder = ArgvBuilder.new
|
|
289
|
+
builder.append(*base)
|
|
290
|
+
builder.flag(:json)
|
|
291
|
+
append_global_flags(builder)
|
|
292
|
+
yield builder if block_given?
|
|
293
|
+
builder.to_a
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
def redacted_env
|
|
297
|
+
env.each_with_object({}) do |(key, value), filtered|
|
|
298
|
+
filtered[key] = if ALLOWED_ENV_VARS.include?(key)
|
|
299
|
+
value
|
|
300
|
+
else
|
|
301
|
+
"[FILTERED]"
|
|
302
|
+
end
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def execute(argv, extra_env)
|
|
307
|
+
file = Tempfile.new("yobi-restic-output")
|
|
308
|
+
file.unlink
|
|
309
|
+
pid = Process.spawn(env.merge(extra_env), restic_path, *argv, out: file, err: file)
|
|
310
|
+
_, status = Process.wait2(pid)
|
|
311
|
+
{exit_code: status.exitstatus, output: Yobi::ResticOutput.new(file), argv: argv}
|
|
312
|
+
rescue Errno::ENOENT
|
|
313
|
+
file&.close
|
|
314
|
+
raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def execute_with_streaming(argv, extra_env)
|
|
318
|
+
file = Tempfile.new("yobi-restic-output")
|
|
319
|
+
file.unlink
|
|
320
|
+
|
|
321
|
+
Open3.popen2e(env.merge(extra_env), restic_path, *argv) do |stdin, merged_output, wait_thr|
|
|
322
|
+
stdin.close
|
|
323
|
+
merged_output.each_line do |line|
|
|
324
|
+
file.write(line)
|
|
325
|
+
parsed = parse_streamed_line(line)
|
|
326
|
+
yield parsed if parsed
|
|
327
|
+
end
|
|
328
|
+
{exit_code: wait_thr.value.exitstatus, output: Yobi::ResticOutput.new(file), argv: argv}
|
|
329
|
+
end
|
|
330
|
+
rescue Errno::ENOENT
|
|
331
|
+
file&.close
|
|
332
|
+
raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def parse_streamed_line(line)
|
|
336
|
+
JSON.parse(line)
|
|
337
|
+
rescue JSON::ParserError
|
|
338
|
+
nil
|
|
339
|
+
end
|
|
340
|
+
end
|
|
341
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yobi
|
|
4
|
+
# Captures a line's message_type without fully parsing it as JSON.
|
|
5
|
+
#
|
|
6
|
+
# @private
|
|
7
|
+
MESSAGE_TYPE_LINE_PATTERN = /"message_type"\s*:\s*"(\w+)"/
|
|
8
|
+
|
|
9
|
+
# Wraps the tempfile a repository operation's stdout+stderr are both
|
|
10
|
+
# written to, with random access so a caller asking only for the last
|
|
11
|
+
# line (see {#last_line}) doesn't have to read the entire output.
|
|
12
|
+
#
|
|
13
|
+
# @private
|
|
14
|
+
class ResticOutput
|
|
15
|
+
# Default read window size, in bytes, for {#last_line}.
|
|
16
|
+
DEFAULT_TAIL_CHUNK_SIZE = 4096
|
|
17
|
+
|
|
18
|
+
# @param file [File]
|
|
19
|
+
# @param tail_chunk_size [Integer] read window size for {#last_line}
|
|
20
|
+
def initialize(file, tail_chunk_size: DEFAULT_TAIL_CHUNK_SIZE)
|
|
21
|
+
@file = file
|
|
22
|
+
@tail_chunk_size = tail_chunk_size
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Byte offsets of every line, grouped by message_type. Built once and
|
|
26
|
+
# memoized.
|
|
27
|
+
#
|
|
28
|
+
# @return [Hash{String => Array<Integer>}]
|
|
29
|
+
def index
|
|
30
|
+
@index ||= build_index
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# @param offset [Integer] a byte offset, as recorded by {#index}
|
|
34
|
+
# @return [String] the line starting at that offset
|
|
35
|
+
def read_line_at(offset)
|
|
36
|
+
@file.seek(offset)
|
|
37
|
+
@file.gets
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The last non-blank line, found by seeking backward from the end of
|
|
41
|
+
# the file.
|
|
42
|
+
#
|
|
43
|
+
# @return [String, nil]
|
|
44
|
+
def last_line
|
|
45
|
+
size = @file.size
|
|
46
|
+
return nil if size.zero?
|
|
47
|
+
|
|
48
|
+
window = @tail_chunk_size
|
|
49
|
+
loop do
|
|
50
|
+
read_size = [window, size].min
|
|
51
|
+
@file.seek(size - read_size)
|
|
52
|
+
lines = @file.read(read_size).each_line.to_a
|
|
53
|
+
lines.shift if read_size < size
|
|
54
|
+
|
|
55
|
+
line = lines.reverse_each.find { |candidate| !candidate.strip.empty? }
|
|
56
|
+
return line.strip if line
|
|
57
|
+
return nil if read_size == size
|
|
58
|
+
|
|
59
|
+
window *= 2
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# @yieldparam line [String]
|
|
64
|
+
# @return [Enumerator] if no block is given
|
|
65
|
+
def each_line(&block)
|
|
66
|
+
return enum_for(:each_line) unless block_given?
|
|
67
|
+
|
|
68
|
+
@file.rewind
|
|
69
|
+
@file.each_line(&block)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# @return [String] the full captured output
|
|
73
|
+
def to_s
|
|
74
|
+
@file.rewind
|
|
75
|
+
@file.read
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def build_index
|
|
81
|
+
index = Hash.new { |hash, message_type| hash[message_type] = [] }
|
|
82
|
+
@file.rewind
|
|
83
|
+
loop do
|
|
84
|
+
offset = @file.pos
|
|
85
|
+
line = @file.gets
|
|
86
|
+
break unless line
|
|
87
|
+
|
|
88
|
+
match = MESSAGE_TYPE_LINE_PATTERN.match(line)
|
|
89
|
+
index[match[1]] << offset if match
|
|
90
|
+
end
|
|
91
|
+
index
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
require "delegate"
|
|
5
|
+
|
|
6
|
+
module Yobi
|
|
7
|
+
# One snapshot, as Restic reports it. Shared across {Yobi::Repository#snapshots},
|
|
8
|
+
# {Yobi::Repository#ls}, and {Yobi::Repository#forget}'s keep/remove entries.
|
|
9
|
+
class Snapshot < SimpleDelegator
|
|
10
|
+
# @return [String]
|
|
11
|
+
def id
|
|
12
|
+
self["id"]
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# @return [String]
|
|
16
|
+
def short_id
|
|
17
|
+
self["short_id"]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# @return [Time]
|
|
21
|
+
def time
|
|
22
|
+
@time ||= Time.parse(self["time"])
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @return [String]
|
|
26
|
+
def host
|
|
27
|
+
self["hostname"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Array<String>]
|
|
31
|
+
def tags
|
|
32
|
+
@tags ||= self["tags"] || []
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Array<String>]
|
|
36
|
+
def paths
|
|
37
|
+
self["paths"]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# @return [String, nil]
|
|
41
|
+
def parent_id
|
|
42
|
+
self["parent"]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/yobi/version.rb
ADDED
data/lib/yobi.rb
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "yobi/version"
|
|
4
|
+
require_relative "yobi/errors"
|
|
5
|
+
require_relative "yobi/argv_builder"
|
|
6
|
+
require_relative "yobi/restic_output"
|
|
7
|
+
require_relative "yobi/io_handle"
|
|
8
|
+
require_relative "yobi/mount"
|
|
9
|
+
require_relative "yobi/restic"
|
|
10
|
+
require_relative "yobi/snapshot"
|
|
11
|
+
require_relative "yobi/repository"
|
|
12
|
+
|
|
13
|
+
Dir[File.join(__dir__, "yobi", "repository", "*.rb")].sort.each { |file| require_relative file }
|