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,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "delegate"
5
+
6
+ module Yobi
7
+ class Repository
8
+ # `restic restore`: extracts a snapshot's contents to a target directory.
9
+ #
10
+ # @param snapshot_id [String] also accepts `"latest"`
11
+ # @param target [String] directory to restore into
12
+ # @param excludes [Array<String>, String] exclude files matching these glob patterns
13
+ # @param exclude_files [Array<String>, String] path(s) to file(s) listing exclude patterns
14
+ # @param exclude_xattrs [Array<String>, String] exclude extended attributes matching these pattern(s)
15
+ # @param hosts [Array<String>, String] only relevant when `snapshot_id:` is `"latest"`
16
+ # @param iexcludes [Array<String>, String] like `excludes`, case-insensitive
17
+ # @param iexclude_files [Array<String>, String] like `exclude_files`, case-insensitive
18
+ # @param iincludes [Array<String>, String] like `includes`, case-insensitive
19
+ # @param iinclude_files [Array<String>, String] like `include_files`, case-insensitive
20
+ # @param includes [Array<String>, String] only restore files matching these glob patterns
21
+ # @param include_files [Array<String>, String] path(s) to file(s) listing include patterns
22
+ # @param include_xattrs [Array<String>, String] only restore extended attributes matching these pattern(s)
23
+ # @param paths [Array<String>, String] only relevant when `snapshot_id:` is `"latest"`
24
+ # @param tags [Array<String>, String] only relevant when `snapshot_id:` is `"latest"`
25
+ # @param delete [Boolean] delete files in `target:` not present in the snapshot
26
+ # @param dry_run [Boolean] report what would happen without doing it
27
+ # @param overwrite [String, Symbol, nil] `"always"`, `"if-changed"`, or `"if-newer"`
28
+ # @param ownership_by_name [Boolean] map ownership by user/group name instead of numeric ID
29
+ # @param sparse [Boolean] write sparse files
30
+ # @param verbose [Boolean] stream a {Yobi::RestoreVerboseStatus} per file to the block
31
+ # @param verify [Boolean] verify restored file content against the repository
32
+ # @yieldparam message [Yobi::RestoreStatus, Yobi::RestoreVerboseStatus]
33
+ # @return [Yobi::RestoreOutcome]
34
+ def restore(snapshot_id:, target:, excludes: [], exclude_files: [], exclude_xattrs: [], hosts: [],
35
+ iexcludes: [], iexclude_files: [], iincludes: [], iinclude_files: [], includes: [], include_files: [],
36
+ include_xattrs: [], paths: [], tags: [], delete: false, dry_run: false, overwrite: nil,
37
+ ownership_by_name: false, sparse: false, verbose: false, verify: false, &block)
38
+ argv = build_argv("restore", snapshot_id) do |a|
39
+ a.flag(:target, target)
40
+ a.repeat_flag(:exclude, excludes)
41
+ a.repeat_flag(:exclude_file, exclude_files)
42
+ a.repeat_flag(:exclude_xattr, exclude_xattrs)
43
+ a.repeat_flag(:host, hosts)
44
+ a.repeat_flag(:iexclude, iexcludes)
45
+ a.repeat_flag(:iexclude_file, iexclude_files)
46
+ a.repeat_flag(:iinclude, iincludes)
47
+ a.repeat_flag(:iinclude_file, iinclude_files)
48
+ a.repeat_flag(:include, includes)
49
+ a.repeat_flag(:include_file, include_files)
50
+ a.repeat_flag(:include_xattr, include_xattrs)
51
+ a.repeat_flag(:path, paths)
52
+ a.repeat_flag(:tag, tags)
53
+ a.flag(:delete) if delete
54
+ a.flag(:dry_run) if dry_run
55
+ a.flag(:overwrite, overwrite) unless overwrite.nil?
56
+ a.flag(:ownership_by_name) if ownership_by_name
57
+ a.flag(:sparse) if sparse
58
+ a.short_flag(:vv) if verbose
59
+ a.flag(:verify) if verify
60
+ end
61
+ execution = if block
62
+ run_restic(argv) { |raw| dispatch_restore_message(raw, &block) }
63
+ else
64
+ run_restic(argv)
65
+ end
66
+ RestoreOutcome.new(execution[:exit_code], execution[:output])
67
+ end
68
+
69
+ private
70
+
71
+ def dispatch_restore_message(raw)
72
+ case raw["message_type"]
73
+ when "status"
74
+ yield Yobi::RestoreStatus.new(raw)
75
+ when "verbose_status"
76
+ yield Yobi::RestoreVerboseStatus.new(raw)
77
+ end
78
+ end
79
+ end
80
+
81
+ # The outcome of one {Yobi::Repository#restore} call.
82
+ class RestoreOutcome < Struct.new(:exit_code, :output)
83
+ # @return [Hash] Restic's own `"summary"` fields (`"total_files"`, `"files_restored"`, ...)
84
+ def report
85
+ @report ||= summary_hash
86
+ end
87
+
88
+ private
89
+
90
+ def summary_hash
91
+ line = output.last_line
92
+ hash = line && JSON.parse(line)
93
+ if hash && hash["message_type"] == "summary"
94
+ hash
95
+ else
96
+ find_summary || {}
97
+ end
98
+ end
99
+
100
+ def find_summary
101
+ output.each_line do |line|
102
+ next if line.strip.empty?
103
+
104
+ hash = JSON.parse(line)
105
+ return hash if hash["message_type"] == "summary"
106
+ end
107
+
108
+ nil
109
+ end
110
+ end
111
+
112
+ # One `"status"` message from a live restore run.
113
+ # https://restic.readthedocs.io/en/stable/075_scripting.html#restore
114
+ class RestoreStatus < SimpleDelegator
115
+ # @return [Float, nil]
116
+ def percent_done
117
+ self["percent_done"]
118
+ end
119
+
120
+ # @return [Integer, nil]
121
+ def total_files
122
+ self["total_files"]
123
+ end
124
+
125
+ # @return [Integer, nil]
126
+ def files_restored
127
+ self["files_restored"]
128
+ end
129
+
130
+ # @return [Integer, nil]
131
+ def total_bytes
132
+ self["total_bytes"]
133
+ end
134
+
135
+ # @return [Integer, nil]
136
+ def bytes_restored
137
+ self["bytes_restored"]
138
+ end
139
+ end
140
+
141
+ # One `"verbose_status"` message from a restore run, one per file. Only
142
+ # emitted when `verbose: true` is passed to {Yobi::Repository#restore}.
143
+ class RestoreVerboseStatus < SimpleDelegator
144
+ # @return [String]
145
+ def action
146
+ self["action"]
147
+ end
148
+
149
+ # @return [String]
150
+ def item
151
+ self["item"]
152
+ end
153
+
154
+ # @return [Integer]
155
+ def size
156
+ self["size"]
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ class Repository
5
+ # `restic rewrite`: creates new snapshots from existing ones with
6
+ # exclude/include filters applied, or metadata changed. With
7
+ # `snapshot_ids:` and the other filters all left at their defaults,
8
+ # rewrites every snapshot in the repository.
9
+ #
10
+ # @param snapshot_ids [Array<String>, String] snapshots to rewrite; all of them if empty
11
+ # @param hosts [Array<String>, String] filter by hostname(s)
12
+ # @param tags [Array<String>, String] filter by tag(s)
13
+ # @param paths [Array<String>, String] filter by originally backed-up path(s)
14
+ # @param excludes [Array<String>, String] exclude files matching these glob patterns
15
+ # @param exclude_files [Array<String>, String] path(s) to file(s) listing exclude patterns
16
+ # @param iexcludes [Array<String>, String] like `excludes`, case-insensitive
17
+ # @param iexclude_files [Array<String>, String] like `exclude_files`, case-insensitive
18
+ # @param includes [Array<String>, String] only include files matching these glob patterns
19
+ # @param include_files [Array<String>, String] path(s) to file(s) listing include patterns
20
+ # @param iincludes [Array<String>, String] like `includes`, case-insensitive
21
+ # @param iinclude_files [Array<String>, String] like `include_files`, case-insensitive
22
+ # @param dry_run [Boolean] report what would happen without doing it
23
+ # @param forget [Boolean] remove the original snapshots afterward, instead of tagging the new ones `"rewrite"` and keeping both
24
+ # @param new_host [String, nil] change the recorded hostname
25
+ # @param new_time [String, nil] change the recorded timestamp
26
+ # @param snapshot_summary [Boolean] regenerate the snapshot summary
27
+ # @return [true]
28
+ def rewrite(snapshot_ids: [], hosts: [], tags: [], paths: [], excludes: [], exclude_files: [],
29
+ iexcludes: [], iexclude_files: [], includes: [], include_files: [], iincludes: [], iinclude_files: [],
30
+ dry_run: false, forget: false, new_host: nil, new_time: nil, snapshot_summary: false)
31
+ argv = build_argv("rewrite", snapshot_ids) do |a|
32
+ a.repeat_flag(:host, hosts)
33
+ a.repeat_flag(:tag, tags)
34
+ a.repeat_flag(:path, paths)
35
+ a.repeat_flag(:exclude, excludes)
36
+ a.repeat_flag(:exclude_file, exclude_files)
37
+ a.repeat_flag(:iexclude, iexcludes)
38
+ a.repeat_flag(:iexclude_file, iexclude_files)
39
+ a.repeat_flag(:include, includes)
40
+ a.repeat_flag(:include_file, include_files)
41
+ a.repeat_flag(:iinclude, iincludes)
42
+ a.repeat_flag(:iinclude_file, iinclude_files)
43
+ a.flag(:dry_run) if dry_run
44
+ a.flag(:forget) if forget
45
+ a.flag(:new_host, new_host) unless new_host.nil?
46
+ a.flag(:new_time, new_time) unless new_time.nil?
47
+ a.flag(:snapshot_summary) if snapshot_summary
48
+ end
49
+ run_restic(argv)
50
+ true
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Yobi
6
+ class Repository
7
+ # `restic snapshots`: lists snapshots, optionally filtered.
8
+ #
9
+ # @param tags [Array<String>, String] filter by tag(s)
10
+ # @param hosts [Array<String>, String] filter by hostname(s)
11
+ # @param paths [Array<String>, String] filter by originally backed-up path(s)
12
+ # @param compact [Boolean] compact the printed listing
13
+ # @param group_by [String, nil] group results, e.g. `"host"`
14
+ # @param latest [Integer, nil] limit to the N most recent per group
15
+ # @return [Enumerable<Yobi::Snapshot>]
16
+ def snapshots(tags: [], hosts: [], paths: [], compact: false, group_by: nil, latest: nil)
17
+ argv = build_argv("snapshots") do |a|
18
+ a.repeat_flag(:tag, tags)
19
+ a.repeat_flag(:host, hosts)
20
+ a.repeat_flag(:path, paths)
21
+ a.flag(:compact) if compact
22
+ a.flag(:group_by, group_by) unless group_by.nil?
23
+ a.flag(:latest, latest) unless latest.nil?
24
+ end
25
+ execution = run_restic(argv)
26
+ Snapshots.new(execution[:output])
27
+ end
28
+ end
29
+
30
+ # Enumerable over every {Yobi::Snapshot} in one {Yobi::Repository#snapshots} call.
31
+ class Snapshots
32
+ include Enumerable
33
+
34
+ # @return [Yobi::ResticOutput]
35
+ attr_reader :output
36
+
37
+ # @param output [Yobi::ResticOutput]
38
+ def initialize(output)
39
+ @output = output
40
+ end
41
+
42
+ # @yieldparam snapshot [Yobi::Snapshot]
43
+ # @return [Enumerator] if no block is given
44
+ def each(&block)
45
+ return enum_for(:each) unless block_given?
46
+
47
+ entries.each(&block)
48
+ end
49
+
50
+ private
51
+
52
+ def entries
53
+ @entries ||= JSON.parse(output.to_s).map { |raw| Yobi::Snapshot.new(raw) }
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Yobi
6
+ class Repository
7
+ # Maps every valid `mode:` value for {#stats}, as both String and Symbol,
8
+ # to the String Restic itself expects.
9
+ STATS_MODES = %w[restore-size files-by-contents blobs-per-file raw-data].each_with_object({}) do |value, hash|
10
+ hash[value] = value
11
+ hash[value.tr("-", "_").to_sym] = value
12
+ end.freeze
13
+
14
+ # `restic stats`: accumulates statistics about the repository's data.
15
+ #
16
+ # @param snapshot_ids [Array<String>, String] restrict to these snapshot ID(s); the whole repository if empty
17
+ # @param hosts [Array<String>, String] filter by hostname(s)
18
+ # @param mode [String, Symbol, nil] `"restore-size"`/`:restore_size` (default), `"files-by-contents"`/`:files_by_contents`, `"blobs-per-file"`/`:blobs_per_file`, or `"raw-data"`/`:raw_data`
19
+ # @param paths [Array<String>, String] filter by originally backed-up path(s)
20
+ # @param tags [Array<String>, String] filter by tag(s)
21
+ # @return [Hash] `"total_size"`, `"total_file_count"`, `"snapshots_count"`, ...
22
+ # @raise [ArgumentError] if `mode:` isn't one of the values listed above
23
+ def stats(snapshot_ids: [], hosts: [], mode: nil, paths: [], tags: [])
24
+ argv = build_argv("stats", snapshot_ids) do |a|
25
+ a.repeat_flag(:host, hosts)
26
+ unless mode.nil?
27
+ a.flag(:mode, STATS_MODES.fetch(mode) { raise ArgumentError, "invalid mode: #{mode.inspect}" })
28
+ end
29
+ a.repeat_flag(:path, paths)
30
+ a.repeat_flag(:tag, tags)
31
+ end
32
+ execution = run_restic(argv)
33
+ JSON.parse(execution[:output].to_s)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "delegate"
5
+
6
+ module Yobi
7
+ class Repository
8
+ # `restic tag`: modifies tags on existing snapshots. Tags are part of
9
+ # a snapshot's content-addressed identity, so changing them produces a
10
+ # new snapshot ID for each affected snapshot.
11
+ #
12
+ # @param snapshot_ids [Array<String>, String] snapshots to modify; all matching `hosts:`/`paths:` if empty
13
+ # @param add [Array<String>, String] tags to add, keeping existing ones
14
+ # @param remove [Array<String>, String] tags to remove
15
+ # @param set [Array<String>, String] replace all tags with exactly this set (exclusive with `add:`/`remove:`)
16
+ # @param tags [Array<String>, String] filter which snapshots to modify, by their current tags
17
+ # @param hosts [Array<String>, String] filter which snapshots to modify, by hostname
18
+ # @param paths [Array<String>, String] filter which snapshots to modify, by originally backed-up path
19
+ # @return [Yobi::TagOutcome]
20
+ def tag(snapshot_ids: [], add: [], remove: [], set: [], tags: [], hosts: [], paths: [])
21
+ argv = build_argv("tag", snapshot_ids) do |a|
22
+ a.repeat_flag(:add, add)
23
+ a.repeat_flag(:remove, remove)
24
+ a.repeat_flag(:set, set)
25
+ a.repeat_flag(:tag, tags)
26
+ a.repeat_flag(:host, hosts)
27
+ a.repeat_flag(:path, paths)
28
+ end
29
+ execution = run_restic(argv)
30
+ TagOutcome.new(execution[:exit_code], execution[:output])
31
+ end
32
+ end
33
+
34
+ # The outcome of one {Yobi::Repository#tag} call.
35
+ class TagOutcome < Struct.new(:exit_code, :output)
36
+ # @return [Hash] Restic's own `"summary"` fields (`"changed_snapshots"`)
37
+ def report
38
+ @report ||= summary_hash
39
+ end
40
+
41
+ # @return [Array<Yobi::TagChange>] one per snapshot actually modified
42
+ def changes
43
+ @changes ||= output.each_line.filter_map do |line|
44
+ next if line.strip.empty?
45
+
46
+ hash = JSON.parse(line)
47
+ Yobi::TagChange.new(hash) if hash["message_type"] == "changed"
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def summary_hash
54
+ line = output.last_line
55
+ hash = line && JSON.parse(line)
56
+ if hash && hash["message_type"] == "summary"
57
+ hash
58
+ else
59
+ find_summary || {}
60
+ end
61
+ end
62
+
63
+ def find_summary
64
+ output.each_line do |line|
65
+ next if line.strip.empty?
66
+
67
+ hash = JSON.parse(line)
68
+ return hash if hash["message_type"] == "summary"
69
+ end
70
+
71
+ nil
72
+ end
73
+ end
74
+
75
+ # One `"changed"` message from a tag run. `old_snapshot_id` is now stale:
76
+ # tags are part of a snapshot's content-addressed identity, so changing
77
+ # them produces a new snapshot ID.
78
+ class TagChange < SimpleDelegator
79
+ # @return [String]
80
+ def old_snapshot_id
81
+ self["old_snapshot_id"]
82
+ end
83
+
84
+ # @return [String]
85
+ def new_snapshot_id
86
+ self["new_snapshot_id"]
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ class Repository
5
+ # `restic unlock`: removes stale locks left by other Restic processes.
6
+ #
7
+ # @param remove_all [Boolean] remove every lock, not just stale ones
8
+ # @return [true]
9
+ def unlock(remove_all: false)
10
+ argv = build_argv("unlock") do |a|
11
+ a.flag(:remove_all) if remove_all
12
+ end
13
+ run_restic(argv)
14
+ true
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "json"
5
+
6
+ module Yobi
7
+ # One Restic repository. Every Restic subcommand that operates on a
8
+ # repository is a method here.
9
+ class Repository
10
+ # @return [String] the repository location
11
+ attr_reader :url
12
+ # @return [String, Array, Symbol, #call, nil] the repository's encryption password, as given to {#initialize}
13
+ attr_reader :password
14
+ # @return [Hash, #call] the storage backend's own credentials, as given to {#initialize}
15
+ attr_reader :backend_credentials
16
+
17
+ # @param url [String] the repository location, e.g. `"s3:s3.amazonaws.com/bucket"`
18
+ # @param password [String, Array, Symbol, #call, nil] 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
+ def initialize(url:, password: nil, backend_credentials: {}, restic: nil)
28
+ @url, @extracted_rest_credentials = extract_rest_credentials(url)
29
+ @password = password
30
+ @backend_credentials = backend_credentials
31
+ @restic = initialize_restic(restic)
32
+ end
33
+
34
+ # @return [Hash{String => String}]
35
+ def env
36
+ {"RESTIC_REPOSITORY" => url}
37
+ .merge(resolved_password)
38
+ .merge(resolved_backend_credentials)
39
+ end
40
+
41
+ # @return [String]
42
+ def inspect
43
+ "#<#{self.class} url=#{url.inspect} password=#{redacted_password.inspect} backend_credentials=#{redacted_backend_credentials.inspect}>"
44
+ end
45
+
46
+ # `restic init`: creates the repository at {#url}.
47
+ #
48
+ # @param copy_chunker_params [Boolean] copy chunker parameters from `from_repo:`/`from_repository_file:`
49
+ # @param from_insecure_no_password [Boolean]
50
+ # @param from_key_hint [String, nil]
51
+ # @param from_password_command [String, nil]
52
+ # @param from_password_file [String, nil]
53
+ # @param from_repo [String, nil]
54
+ # @param from_repository_file [String, nil]
55
+ # @param repository_version [String, nil]
56
+ # @return [Hash] Restic's own `"initialized"` message
57
+ def init(copy_chunker_params: false, from_insecure_no_password: false, from_key_hint: nil,
58
+ from_password_command: nil, from_password_file: nil, from_repo: nil, from_repository_file: nil,
59
+ repository_version: nil)
60
+ argv = build_argv("init") do |a|
61
+ a.flag(:copy_chunker_params) if copy_chunker_params
62
+ a.flag(:from_insecure_no_password) if from_insecure_no_password
63
+ a.flag(:from_key_hint, from_key_hint) unless from_key_hint.nil?
64
+ a.flag(:from_password_command, from_password_command) unless from_password_command.nil?
65
+ a.flag(:from_password_file, from_password_file) unless from_password_file.nil?
66
+ a.flag(:from_repo, from_repo) unless from_repo.nil?
67
+ a.flag(:from_repository_file, from_repository_file) unless from_repository_file.nil?
68
+ a.flag(:repository_version, repository_version) unless repository_version.nil?
69
+ end
70
+ execution = run_restic(argv)
71
+ JSON.parse(execution[:output].to_s)
72
+ end
73
+
74
+ # `restic cat config`: the repository's own config document.
75
+ #
76
+ # @return [Hash]
77
+ # @raise [Yobi::RepositoryNotFound, Yobi::AuthenticationFailed]
78
+ def config
79
+ execution = run_restic(build_argv("cat", "config"))
80
+ JSON.parse(execution[:output].to_s)
81
+ end
82
+
83
+ private
84
+
85
+ def run_restic(argv, extra_env: {}, &block)
86
+ @restic.run(argv, extra_env: env.merge(extra_env), &block)
87
+ end
88
+
89
+ def build_argv(*base)
90
+ builder = ArgvBuilder.new
91
+ builder.append(*base)
92
+ builder.flag(:insecure_no_password) if password == :insecure_no_password
93
+ builder.flag(:json)
94
+ @restic.append_global_flags(builder)
95
+ yield builder if block_given?
96
+ builder.to_a
97
+ end
98
+
99
+ def resolved_password
100
+ password_env(password)
101
+ end
102
+
103
+ def password_env(value, base = "RESTIC_PASSWORD")
104
+ case value
105
+ in nil | :insecure_no_password
106
+ {}
107
+ in String => string
108
+ {base => string}
109
+ in [:command, String => command]
110
+ {"#{base}_COMMAND" => command}
111
+ in [:file, String => file]
112
+ {"#{base}_FILE" => file}
113
+ in callable if callable.respond_to?(:call)
114
+ {base => callable.call.to_s}
115
+ end
116
+ end
117
+
118
+ def resolved_backend_credentials
119
+ @extracted_rest_credentials.merge(resolved_backend_credentials_value)
120
+ end
121
+
122
+ def resolved_backend_credentials_value
123
+ case backend_credentials
124
+ in Hash => value
125
+ value
126
+ in callable if callable.respond_to?(:call)
127
+ callable.call
128
+ end
129
+ end
130
+
131
+ def redacted_password
132
+ case password
133
+ in nil | :insecure_no_password
134
+ password
135
+ in String | [:command, String] | [:file, String]
136
+ "[FILTERED]"
137
+ in callable if callable.respond_to?(:call)
138
+ "[RESOLVER]"
139
+ end
140
+ end
141
+
142
+ def redacted_backend_credentials
143
+ case backend_credentials
144
+ in Hash => value
145
+ value.each_with_object({}) do |(key, val), filtered|
146
+ filtered[key] = if Yobi::Restic::ALLOWED_ENV_VARS.include?(key)
147
+ val
148
+ else
149
+ "[FILTERED]"
150
+ end
151
+ end
152
+ in callable if callable.respond_to?(:call)
153
+ "[RESOLVER]"
154
+ end
155
+ end
156
+
157
+ def extract_rest_credentials(url)
158
+ return [url, {}] unless url.start_with?("rest:")
159
+
160
+ uri = URI.parse(url.delete_prefix("rest:"))
161
+ return [url, {}] unless uri.password || uri.user
162
+
163
+ extracted = {"RESTIC_REST_USERNAME" => uri.user, "RESTIC_REST_PASSWORD" => uri.password}.compact
164
+ uri.user = nil
165
+ uri.password = nil
166
+ ["rest:#{uri}", extracted]
167
+ rescue URI::InvalidURIError
168
+ [url, {}]
169
+ end
170
+
171
+ def initialize_restic(restic_or_restic_path)
172
+ case restic_or_restic_path
173
+ in Yobi::Restic
174
+ restic_or_restic_path
175
+ in String | nil
176
+ Yobi::Restic.new(restic_or_restic_path)
177
+ end
178
+ end
179
+ end
180
+ end