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
|
@@ -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
|