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.
@@ -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
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A Ruby library for the Restic backup tool, wrapping the `restic` CLI in
4
+ # plain Ruby objects instead of shelling out to flags and raw JSON by hand.
5
+ module Yobi
6
+ # @return [String]
7
+ VERSION = "0.1.0"
8
+ end
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 }