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,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Yobi
|
|
6
|
+
class Repository
|
|
7
|
+
# `restic cat snapshot ID`: one snapshot's own raw stored record.
|
|
8
|
+
#
|
|
9
|
+
# @param id [String, Yobi::Snapshot]
|
|
10
|
+
# @return [Hash]
|
|
11
|
+
def cat_snapshot(id)
|
|
12
|
+
id = id.id if id.is_a?(Yobi::Snapshot)
|
|
13
|
+
execution = run_restic(build_argv("cat", "snapshot", id))
|
|
14
|
+
JSON.parse(execution[:output].to_s)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# `restic cat index ID`: one index file's own raw contents. IDs come
|
|
18
|
+
# from `list(:index)`.
|
|
19
|
+
#
|
|
20
|
+
# @param id [String]
|
|
21
|
+
# @return [Hash]
|
|
22
|
+
def cat_index(id)
|
|
23
|
+
execution = run_restic(build_argv("cat", "index", id))
|
|
24
|
+
JSON.parse(execution[:output].to_s)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# `restic cat key ID`: one key's own raw stored record.
|
|
28
|
+
#
|
|
29
|
+
# @param id [String, Yobi::Key]
|
|
30
|
+
# @return [Hash]
|
|
31
|
+
def cat_key(id)
|
|
32
|
+
id = id.id if id.is_a?(Yobi::Key)
|
|
33
|
+
execution = run_restic(build_argv("cat", "key", id))
|
|
34
|
+
JSON.parse(execution[:output].to_s)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# `restic cat tree snapshot:subfolder`: the raw tree object at a
|
|
38
|
+
# snapshot's root, or at `subfolder` within it.
|
|
39
|
+
#
|
|
40
|
+
# @param snapshot_id [String, Yobi::Snapshot] also accepts Restic's own `"snapshotID:subfolder"` form directly
|
|
41
|
+
# @param subfolder [String, nil]
|
|
42
|
+
# @return [Hash]
|
|
43
|
+
def cat_tree(snapshot_id, subfolder: nil)
|
|
44
|
+
snapshot_id = snapshot_id.id if snapshot_id.is_a?(Yobi::Snapshot)
|
|
45
|
+
target = if subfolder.nil?
|
|
46
|
+
snapshot_id
|
|
47
|
+
else
|
|
48
|
+
"#{snapshot_id}:#{subfolder}"
|
|
49
|
+
end
|
|
50
|
+
execution = run_restic(build_argv("cat", "tree", target))
|
|
51
|
+
JSON.parse(execution[:output].to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# `restic cat pack ID`: one pack file's raw, still-encrypted bytes.
|
|
55
|
+
# IDs come from `list(:packs)`. Same block/handle shape as {#dump}.
|
|
56
|
+
#
|
|
57
|
+
# @param id [String]
|
|
58
|
+
# @yieldparam io [IO]
|
|
59
|
+
# @return [Yobi::IOHandle] if no block is given
|
|
60
|
+
def cat_pack(id, &block)
|
|
61
|
+
run_restic_dump(build_argv("cat", "pack", id), &block)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# `restic cat blob ID`: one data blob's raw, decrypted bytes. IDs come
|
|
65
|
+
# from {#cat_tree}'s own node `"content"` arrays. Same block/handle
|
|
66
|
+
# shape as {#dump}.
|
|
67
|
+
#
|
|
68
|
+
# @param id [String]
|
|
69
|
+
# @yieldparam io [IO]
|
|
70
|
+
# @return [Yobi::IOHandle] if no block is given
|
|
71
|
+
def cat_blob(id, &block)
|
|
72
|
+
run_restic_dump(build_argv("cat", "blob", id), &block)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# `restic cat masterkey`: this repository's own encryption/MAC key
|
|
76
|
+
# material. Extremely sensitive: this is the actual key, not a
|
|
77
|
+
# redacted reference to it, and there is no operation that rotates it;
|
|
78
|
+
# every other key/password management method here only manages
|
|
79
|
+
# different ways to unlock this same master key.
|
|
80
|
+
#
|
|
81
|
+
# @return [Hash]
|
|
82
|
+
def cat_masterkey_and_game_over_if_this_leaks
|
|
83
|
+
execution = run_restic(build_argv("cat", "masterkey"))
|
|
84
|
+
JSON.parse(execution[:output].to_s)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "delegate"
|
|
5
|
+
|
|
6
|
+
module Yobi
|
|
7
|
+
class Repository
|
|
8
|
+
# `restic check`: tests the repository for errors.
|
|
9
|
+
#
|
|
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 read_data [Boolean] read and verify pack file contents, not just structure
|
|
13
|
+
# @param read_data_subset [String, nil] read and verify only a subset of packs, e.g. `"5%"`
|
|
14
|
+
# @param tags [Array<String>, String] filter by tag(s)
|
|
15
|
+
# @param with_cache [Boolean] use the local cache
|
|
16
|
+
# @return [Yobi::CheckOutcome]
|
|
17
|
+
def check(hosts: [], paths: [], read_data: false, read_data_subset: nil, tags: [], with_cache: false)
|
|
18
|
+
argv = build_argv("check") do |a|
|
|
19
|
+
a.repeat_flag(:host, hosts)
|
|
20
|
+
a.repeat_flag(:path, paths)
|
|
21
|
+
a.flag(:read_data) if read_data
|
|
22
|
+
a.flag(:read_data_subset, read_data_subset) unless read_data_subset.nil?
|
|
23
|
+
a.repeat_flag(:tag, tags)
|
|
24
|
+
a.flag(:with_cache) if with_cache
|
|
25
|
+
end
|
|
26
|
+
execution = run_restic(argv)
|
|
27
|
+
CheckOutcome.new(execution[:exit_code], execution[:output])
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The outcome of one {Yobi::Repository#check} call.
|
|
32
|
+
class CheckOutcome < Struct.new(:exit_code, :output)
|
|
33
|
+
# @return [Hash] Restic's own `"summary"` fields (`"num_errors"`, `"broken_packs"`, ...)
|
|
34
|
+
def report
|
|
35
|
+
@report ||= summary_hash
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# @return [Enumerable<Yobi::CheckError>]
|
|
39
|
+
def errors
|
|
40
|
+
@errors ||= CheckErrors.new(output)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def summary_hash
|
|
46
|
+
line = output.last_line
|
|
47
|
+
hash = line && JSON.parse(line)
|
|
48
|
+
if hash && hash["message_type"] == "summary"
|
|
49
|
+
hash
|
|
50
|
+
else
|
|
51
|
+
find_summary || {}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def find_summary
|
|
56
|
+
output.each_line do |line|
|
|
57
|
+
next if line.strip.empty?
|
|
58
|
+
|
|
59
|
+
hash = JSON.parse(line)
|
|
60
|
+
return hash if hash["message_type"] == "summary"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# One `"error"` message from a check run.
|
|
68
|
+
# https://restic.readthedocs.io/en/stable/075_scripting.html#error
|
|
69
|
+
class CheckError < SimpleDelegator
|
|
70
|
+
# @return [String]
|
|
71
|
+
def message
|
|
72
|
+
self["message"]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Enumerable over every {Yobi::CheckError} in a run.
|
|
77
|
+
class CheckErrors
|
|
78
|
+
include Enumerable
|
|
79
|
+
|
|
80
|
+
# @param output [Yobi::ResticOutput]
|
|
81
|
+
def initialize(output)
|
|
82
|
+
@output = output
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @yieldparam error [Yobi::CheckError]
|
|
86
|
+
# @return [Enumerator] if no block is given
|
|
87
|
+
def each
|
|
88
|
+
return enum_for(:each) unless block_given?
|
|
89
|
+
|
|
90
|
+
@output.index["error"].each do |offset|
|
|
91
|
+
line = @output.read_line_at(offset)
|
|
92
|
+
yield CheckError.new(JSON.parse(line))
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yobi
|
|
4
|
+
class Repository
|
|
5
|
+
# `restic copy`: replicates snapshots from another repository into
|
|
6
|
+
# this one. Already-copied snapshots are skipped automatically.
|
|
7
|
+
#
|
|
8
|
+
# @param from_repo [String, Yobi::Repository] the source repository, or its URL
|
|
9
|
+
# (given a `Repository`, its own `#url`/`#password` are used automatically)
|
|
10
|
+
# @param from_password [String, Array, Symbol, #call, nil] the source
|
|
11
|
+
# repository's password, same shape as {#initialize}'s `password:`
|
|
12
|
+
# @param snapshot_ids [Array<String>, String] snapshots to copy; all of them if empty
|
|
13
|
+
# @param from_key_hint [String, nil]
|
|
14
|
+
# @param from_repository_file [String, nil] read the source repository's URL from a file
|
|
15
|
+
# @param hosts [Array<String>, String] filter by hostname(s)
|
|
16
|
+
# @param paths [Array<String>, String] filter by originally backed-up path(s)
|
|
17
|
+
# @param tags [Array<String>, String] filter by tag(s)
|
|
18
|
+
# @return [true]
|
|
19
|
+
def copy(from_repo:, from_password: nil, snapshot_ids: [], from_key_hint: nil,
|
|
20
|
+
from_repository_file: nil, hosts: [], paths: [], tags: [])
|
|
21
|
+
if from_repo.is_a?(Repository)
|
|
22
|
+
from_password = from_repo.password if from_password.nil?
|
|
23
|
+
from_repo = from_repo.url
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
argv = build_argv("copy", snapshot_ids) do |a|
|
|
27
|
+
a.flag(:from_repo, from_repo)
|
|
28
|
+
a.flag(:from_insecure_no_password) if from_password == :insecure_no_password
|
|
29
|
+
a.flag(:from_key_hint, from_key_hint) unless from_key_hint.nil?
|
|
30
|
+
a.flag(:from_repository_file, from_repository_file) unless from_repository_file.nil?
|
|
31
|
+
a.repeat_flag(:host, hosts)
|
|
32
|
+
a.repeat_flag(:path, paths)
|
|
33
|
+
a.repeat_flag(:tag, tags)
|
|
34
|
+
end
|
|
35
|
+
run_restic(argv, extra_env: password_env(from_password, "RESTIC_FROM_PASSWORD"))
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "delegate"
|
|
5
|
+
|
|
6
|
+
module Yobi
|
|
7
|
+
class Repository
|
|
8
|
+
# `restic diff`: compares two snapshots.
|
|
9
|
+
#
|
|
10
|
+
# @param from [String] snapshot ID
|
|
11
|
+
# @param to [String] snapshot ID
|
|
12
|
+
# @param metadata [Boolean] also report metadata-only changes (access mode, timestamps, ...)
|
|
13
|
+
# @return [Yobi::DiffOutcome]
|
|
14
|
+
def diff(from:, to:, metadata: false)
|
|
15
|
+
argv = build_argv("diff", from, to) do |a|
|
|
16
|
+
a.flag(:metadata) if metadata
|
|
17
|
+
end
|
|
18
|
+
execution = run_restic(argv)
|
|
19
|
+
DiffOutcome.new(execution[:exit_code], execution[:output])
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# The outcome of one {Yobi::Repository#diff} call.
|
|
24
|
+
class DiffOutcome < Struct.new(:exit_code, :output)
|
|
25
|
+
# @return [Hash] Restic's own `"statistics"` fields (`"changed_files"`, `"added"`, `"removed"`, ...)
|
|
26
|
+
def report
|
|
27
|
+
@report ||= summary_hash
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @return [Enumerable<Yobi::DiffChange>]
|
|
31
|
+
def changes
|
|
32
|
+
@changes ||= DiffChanges.new(output)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def summary_hash
|
|
38
|
+
line = output.last_line
|
|
39
|
+
hash = line && JSON.parse(line)
|
|
40
|
+
if hash && hash["message_type"] == "statistics"
|
|
41
|
+
hash
|
|
42
|
+
else
|
|
43
|
+
find_summary || {}
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_summary
|
|
48
|
+
output.each_line do |line|
|
|
49
|
+
next if line.strip.empty?
|
|
50
|
+
|
|
51
|
+
hash = JSON.parse(line)
|
|
52
|
+
return hash if hash["message_type"] == "statistics"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# One `"change"` message from a diff run. `modifier` is Restic's own
|
|
60
|
+
# single-character code: `"+"` added, `"-"` removed, `"U"` metadata
|
|
61
|
+
# updated, `"M"` content modified, `"T"` type changed, `"?"` bitrot detected.
|
|
62
|
+
class DiffChange < SimpleDelegator
|
|
63
|
+
# @return [String]
|
|
64
|
+
def path
|
|
65
|
+
self["path"]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# @return [String]
|
|
69
|
+
def modifier
|
|
70
|
+
self["modifier"]
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Enumerable over every {Yobi::DiffChange} in a run.
|
|
75
|
+
class DiffChanges
|
|
76
|
+
include Enumerable
|
|
77
|
+
|
|
78
|
+
# @param output [Yobi::ResticOutput]
|
|
79
|
+
def initialize(output)
|
|
80
|
+
@output = output
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @yieldparam change [Yobi::DiffChange]
|
|
84
|
+
# @return [Enumerator] if no block is given
|
|
85
|
+
def each
|
|
86
|
+
return enum_for(:each) unless block_given?
|
|
87
|
+
|
|
88
|
+
@output.index["change"].each do |offset|
|
|
89
|
+
line = @output.read_line_at(offset)
|
|
90
|
+
yield DiffChange.new(JSON.parse(line))
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "tempfile"
|
|
4
|
+
|
|
5
|
+
module Yobi
|
|
6
|
+
class Repository
|
|
7
|
+
# Valid `archive:` values for {#dump}, as both String and Symbol.
|
|
8
|
+
ARCHIVE_TYPES = Set.new(%w[tar zip].flat_map { [_1, _1.to_sym] }).freeze
|
|
9
|
+
|
|
10
|
+
# `restic dump`: extracts a file/folder from a snapshot. A single file's
|
|
11
|
+
# raw bytes are written as-is; a folder is written as a tar/zip archive.
|
|
12
|
+
# Give at most one of `target:` or a block.
|
|
13
|
+
#
|
|
14
|
+
# @param snapshot_id [String] also accepts Restic's own `"snapshotID:subfolder"` form directly
|
|
15
|
+
# @param file [String] path within the snapshot to extract; `"/"` dumps the whole snapshot
|
|
16
|
+
# @param target [String, nil] write straight to this file path instead of yielding a block
|
|
17
|
+
# @param archive [String, Symbol, nil] `"tar"` (default) or `"zip"`, when `file` is a folder
|
|
18
|
+
# @param hosts [Array<String>, String] only relevant when `snapshot_id` is `"latest"`
|
|
19
|
+
# @param paths [Array<String>, String] only relevant when `snapshot_id` is `"latest"`
|
|
20
|
+
# @param tags [Array<String>, String] only relevant when `snapshot_id` is `"latest"`
|
|
21
|
+
# @yieldparam io [IO]
|
|
22
|
+
# @return [true] if `target:` was given
|
|
23
|
+
# @return [Yobi::IOHandle] if no block is given
|
|
24
|
+
# @raise [ArgumentError] if given both `target:` and a block
|
|
25
|
+
def dump(snapshot_id:, file:, target: nil, archive: nil, hosts: [], paths: [], tags: [], &block)
|
|
26
|
+
raise ArgumentError, "give at most one of target: or a block, not both" if target && block
|
|
27
|
+
|
|
28
|
+
argv = build_argv("dump", snapshot_id) do |a|
|
|
29
|
+
a.flag(:archive, archive) if ARCHIVE_TYPES.include?(archive)
|
|
30
|
+
a.repeat_flag(:host, hosts)
|
|
31
|
+
a.repeat_flag(:path, paths)
|
|
32
|
+
a.repeat_flag(:tag, tags)
|
|
33
|
+
a.flag(:target, target) unless target.nil?
|
|
34
|
+
a.end_of_options.append(file)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
if target
|
|
38
|
+
run_restic(argv)
|
|
39
|
+
true
|
|
40
|
+
else
|
|
41
|
+
run_restic_dump(argv, &block)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def run_restic_dump(argv, &block)
|
|
48
|
+
@restic.run_dump(argv, extra_env: env, &block)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "delegate"
|
|
6
|
+
|
|
7
|
+
module Yobi
|
|
8
|
+
class Repository
|
|
9
|
+
# `restic find`: searches for files/directories across snapshots by
|
|
10
|
+
# name pattern.
|
|
11
|
+
#
|
|
12
|
+
# @param patterns [Array<String>, String] glob patterns to match
|
|
13
|
+
# @param blob [Boolean] match blob IDs instead of file names
|
|
14
|
+
# @param pack [Boolean] match pack IDs instead of file names
|
|
15
|
+
# @param tree [Boolean] match tree IDs instead of file names
|
|
16
|
+
# @param hosts [Array<String>, String] filter by hostname(s)
|
|
17
|
+
# @param human_readable [Boolean] format sizes for humans
|
|
18
|
+
# @param ignore_case [Boolean] case-insensitive matching
|
|
19
|
+
# @param long [Boolean] include long listing format
|
|
20
|
+
# @param newest [String, nil] only consider snapshots at or before this time
|
|
21
|
+
# @param oldest [String, nil] only consider snapshots at or after this time
|
|
22
|
+
# @param paths [Array<String>, String] filter by originally backed-up path(s)
|
|
23
|
+
# @param reverse [Boolean] reverse sort order
|
|
24
|
+
# @param show_pack_id [Boolean] include the pack ID each match is stored in
|
|
25
|
+
# @param snapshot_ids [Array<String>, String] restrict to these snapshot ID(s)
|
|
26
|
+
# @param tags [Array<String>, String] filter by tag(s)
|
|
27
|
+
# @return [Enumerable<Yobi::MatchesPerSnapshot>]
|
|
28
|
+
def find(patterns:, blob: false, pack: false, tree: false, hosts: [], human_readable: false,
|
|
29
|
+
ignore_case: false, long: false, newest: nil, oldest: nil, paths: [], reverse: false,
|
|
30
|
+
show_pack_id: false, snapshot_ids: [], tags: [])
|
|
31
|
+
argv = build_argv("find") do |a|
|
|
32
|
+
a.flag(:blob) if blob
|
|
33
|
+
a.flag(:pack) if pack
|
|
34
|
+
a.flag(:tree) if tree
|
|
35
|
+
a.repeat_flag(:host, hosts)
|
|
36
|
+
a.flag(:human_readable) if human_readable
|
|
37
|
+
a.flag(:ignore_case) if ignore_case
|
|
38
|
+
a.flag(:long) if long
|
|
39
|
+
a.flag(:newest, newest) unless newest.nil?
|
|
40
|
+
a.flag(:oldest, oldest) unless oldest.nil?
|
|
41
|
+
a.repeat_flag(:path, paths)
|
|
42
|
+
a.flag(:reverse) if reverse
|
|
43
|
+
a.flag(:show_pack_id) if show_pack_id
|
|
44
|
+
a.repeat_flag(:snapshot, snapshot_ids)
|
|
45
|
+
a.repeat_flag(:tag, tags)
|
|
46
|
+
a.end_of_options.append(patterns)
|
|
47
|
+
end
|
|
48
|
+
execution = run_restic(argv)
|
|
49
|
+
FindMatches.new(execution[:output])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# One matched file/directory from a {Yobi::Repository#find} call.
|
|
54
|
+
class FindMatch < SimpleDelegator
|
|
55
|
+
# @return [String]
|
|
56
|
+
def path
|
|
57
|
+
self["path"]
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @return [String]
|
|
61
|
+
def type
|
|
62
|
+
self["type"]
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Integer]
|
|
66
|
+
def size
|
|
67
|
+
self["size"]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @return [String]
|
|
71
|
+
def permissions
|
|
72
|
+
self["permissions"]
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @return [Integer]
|
|
76
|
+
def uid
|
|
77
|
+
self["uid"]
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# @return [Integer]
|
|
81
|
+
def gid
|
|
82
|
+
self["gid"]
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [String]
|
|
86
|
+
def user
|
|
87
|
+
self["user"]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @return [String]
|
|
91
|
+
def group
|
|
92
|
+
self["group"]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @return [Integer]
|
|
96
|
+
def inode
|
|
97
|
+
self["inode"]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# @return [Time]
|
|
101
|
+
def mtime
|
|
102
|
+
@mtime ||= Time.parse(self["mtime"])
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# @return [Time]
|
|
106
|
+
def atime
|
|
107
|
+
@atime ||= Time.parse(self["atime"])
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# @return [Time]
|
|
111
|
+
def ctime
|
|
112
|
+
@ctime ||= Time.parse(self["ctime"])
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# One snapshot's worth of matches from a {Yobi::Repository#find} call.
|
|
117
|
+
class MatchesPerSnapshot < SimpleDelegator
|
|
118
|
+
# @return [Hash]
|
|
119
|
+
def snapshot
|
|
120
|
+
self["snapshot"]
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @return [Integer]
|
|
124
|
+
def hits
|
|
125
|
+
self["hits"]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# @return [Array<Yobi::FindMatch>]
|
|
129
|
+
def matches
|
|
130
|
+
@matches ||= (self["matches"] || []).map { |raw| Yobi::FindMatch.new(raw) }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Enumerable over every {Yobi::MatchesPerSnapshot} in one {Yobi::Repository#find} call.
|
|
135
|
+
class FindMatches
|
|
136
|
+
include Enumerable
|
|
137
|
+
|
|
138
|
+
# @return [Yobi::ResticOutput]
|
|
139
|
+
attr_reader :output
|
|
140
|
+
|
|
141
|
+
# @param output [Yobi::ResticOutput]
|
|
142
|
+
def initialize(output)
|
|
143
|
+
@output = output
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @yieldparam matches [Yobi::MatchesPerSnapshot]
|
|
147
|
+
# @return [Enumerator] if no block is given
|
|
148
|
+
def each(&block)
|
|
149
|
+
return enum_for(:each) unless block_given?
|
|
150
|
+
|
|
151
|
+
matches_per_snapshot.each(&block)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
private
|
|
155
|
+
|
|
156
|
+
def matches_per_snapshot
|
|
157
|
+
@matches_per_snapshot ||= JSON.parse(output.to_s).map { |raw| Yobi::MatchesPerSnapshot.new(raw) }
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "delegate"
|
|
5
|
+
|
|
6
|
+
module Yobi
|
|
7
|
+
class Repository
|
|
8
|
+
# `restic forget`: applies a retention policy, removing snapshots
|
|
9
|
+
# that don't match any `keep_*` rule.
|
|
10
|
+
#
|
|
11
|
+
# @param keep_last [Integer, nil] always keep this many of the most recent snapshots
|
|
12
|
+
# @param keep_hourly [Integer, nil]
|
|
13
|
+
# @param keep_daily [Integer, nil]
|
|
14
|
+
# @param keep_weekly [Integer, nil]
|
|
15
|
+
# @param keep_monthly [Integer, nil]
|
|
16
|
+
# @param keep_yearly [Integer, nil]
|
|
17
|
+
# @param keep_within [String, nil] keep all snapshots within this duration, e.g. `"30d"`
|
|
18
|
+
# @param keep_within_hourly [String, nil]
|
|
19
|
+
# @param keep_within_daily [String, nil]
|
|
20
|
+
# @param keep_within_weekly [String, nil]
|
|
21
|
+
# @param keep_within_monthly [String, nil]
|
|
22
|
+
# @param keep_within_yearly [String, nil]
|
|
23
|
+
# @param keep_tags [Array<String>, String] always keep snapshots carrying any of these tags
|
|
24
|
+
# @param hosts [Array<String>, String] filter by hostname(s)
|
|
25
|
+
# @param tags [Array<String>, String] filter by tag(s)
|
|
26
|
+
# @param paths [Array<String>, String] filter by originally backed-up path(s)
|
|
27
|
+
# @param compact [Boolean] compact the printed policy summary
|
|
28
|
+
# @param group_by [String, nil] grouping used to apply the policy, e.g. `"host,paths"`
|
|
29
|
+
# @param dry_run [Boolean] report what would happen without doing it
|
|
30
|
+
# @param prune [Boolean] also reclaim disk space (equivalent to `restic forget --prune`)
|
|
31
|
+
# @param unsafe_allow_remove_all [Boolean] allow removing every snapshot
|
|
32
|
+
# @param max_unused [String, nil] passed through to the implied prune, e.g. `"10%"`
|
|
33
|
+
# @param max_repack_size [String, nil] passed through to the implied prune
|
|
34
|
+
# @param repack_cacheable_only [Boolean] passed through to the implied prune
|
|
35
|
+
# @param repack_uncompressed [Boolean] passed through to the implied prune
|
|
36
|
+
# @param repack_smaller_than [String, nil] passed through to the implied prune
|
|
37
|
+
# @return [Enumerable<Yobi::ForgetGroup>]
|
|
38
|
+
def forget(keep_last: nil, keep_hourly: nil, keep_daily: nil, keep_weekly: nil, keep_monthly: nil,
|
|
39
|
+
keep_yearly: nil, keep_within: nil, keep_within_hourly: nil, keep_within_daily: nil,
|
|
40
|
+
keep_within_weekly: nil, keep_within_monthly: nil, keep_within_yearly: nil, keep_tags: [],
|
|
41
|
+
hosts: [], tags: [], paths: [], compact: false, group_by: nil, dry_run: false, prune: false,
|
|
42
|
+
unsafe_allow_remove_all: false, max_unused: nil, max_repack_size: nil,
|
|
43
|
+
repack_cacheable_only: false, repack_uncompressed: false, repack_smaller_than: nil)
|
|
44
|
+
argv = build_argv("forget") do |a|
|
|
45
|
+
a.flag(:keep_last, keep_last) unless keep_last.nil?
|
|
46
|
+
a.flag(:keep_hourly, keep_hourly) unless keep_hourly.nil?
|
|
47
|
+
a.flag(:keep_daily, keep_daily) unless keep_daily.nil?
|
|
48
|
+
a.flag(:keep_weekly, keep_weekly) unless keep_weekly.nil?
|
|
49
|
+
a.flag(:keep_monthly, keep_monthly) unless keep_monthly.nil?
|
|
50
|
+
a.flag(:keep_yearly, keep_yearly) unless keep_yearly.nil?
|
|
51
|
+
a.flag(:keep_within, keep_within) unless keep_within.nil?
|
|
52
|
+
a.flag(:keep_within_hourly, keep_within_hourly) unless keep_within_hourly.nil?
|
|
53
|
+
a.flag(:keep_within_daily, keep_within_daily) unless keep_within_daily.nil?
|
|
54
|
+
a.flag(:keep_within_weekly, keep_within_weekly) unless keep_within_weekly.nil?
|
|
55
|
+
a.flag(:keep_within_monthly, keep_within_monthly) unless keep_within_monthly.nil?
|
|
56
|
+
a.flag(:keep_within_yearly, keep_within_yearly) unless keep_within_yearly.nil?
|
|
57
|
+
a.repeat_flag(:keep_tag, keep_tags)
|
|
58
|
+
a.repeat_flag(:host, hosts)
|
|
59
|
+
a.repeat_flag(:tag, tags)
|
|
60
|
+
a.repeat_flag(:path, paths)
|
|
61
|
+
a.flag(:compact) if compact
|
|
62
|
+
a.flag(:group_by, group_by) unless group_by.nil?
|
|
63
|
+
a.flag(:dry_run) if dry_run
|
|
64
|
+
a.flag(:prune) if prune
|
|
65
|
+
a.flag(:unsafe_allow_remove_all) if unsafe_allow_remove_all
|
|
66
|
+
a.flag(:max_unused, max_unused) unless max_unused.nil?
|
|
67
|
+
a.flag(:max_repack_size, max_repack_size) unless max_repack_size.nil?
|
|
68
|
+
a.flag(:repack_cacheable_only) if repack_cacheable_only
|
|
69
|
+
a.flag(:repack_uncompressed) if repack_uncompressed
|
|
70
|
+
a.flag(:repack_smaller_than, repack_smaller_than) unless repack_smaller_than.nil?
|
|
71
|
+
end
|
|
72
|
+
execution = run_restic(argv)
|
|
73
|
+
ForgetGroups.new(execution[:exit_code], execution[:output])
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# The outcome of one {Yobi::Repository#forget} call.
|
|
78
|
+
class ForgetGroups
|
|
79
|
+
include Enumerable
|
|
80
|
+
|
|
81
|
+
# @return [Integer]
|
|
82
|
+
attr_reader :exit_code
|
|
83
|
+
# @return [Yobi::ResticOutput]
|
|
84
|
+
attr_reader :output
|
|
85
|
+
|
|
86
|
+
# @param exit_code [Integer]
|
|
87
|
+
# @param output [Yobi::ResticOutput]
|
|
88
|
+
def initialize(exit_code, output)
|
|
89
|
+
@exit_code = exit_code
|
|
90
|
+
@output = output
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# @yieldparam group [Yobi::ForgetGroup]
|
|
94
|
+
# @return [Enumerator] if no block is given
|
|
95
|
+
def each(&block)
|
|
96
|
+
return enum_for(:each) unless block_given?
|
|
97
|
+
|
|
98
|
+
groups.each(&block)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Boolean]
|
|
102
|
+
def success?
|
|
103
|
+
exit_code.zero?
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def partial?
|
|
108
|
+
exit_code == 3
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
private
|
|
112
|
+
|
|
113
|
+
def groups
|
|
114
|
+
@groups ||= JSON.parse(output.to_s).map { |raw| Yobi::ForgetGroup.new(raw) }
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# One grouping Restic's forget policy was evaluated against (by default,
|
|
119
|
+
# grouped by host+paths).
|
|
120
|
+
class ForgetGroup < SimpleDelegator
|
|
121
|
+
# @return [String]
|
|
122
|
+
def host
|
|
123
|
+
self["host"]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# @return [Array<String>]
|
|
127
|
+
def tags
|
|
128
|
+
@tags ||= self["tags"] || []
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# @return [Array<String>]
|
|
132
|
+
def paths
|
|
133
|
+
self["paths"]
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# @return [Array<Yobi::Snapshot>]
|
|
137
|
+
def keep
|
|
138
|
+
@keep ||= (self["keep"] || []).map { |entry| Yobi::Snapshot.new(entry) }
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# @return [Array<Yobi::Snapshot>]
|
|
142
|
+
def remove
|
|
143
|
+
@remove ||= (self["remove"] || []).map { |entry| Yobi::Snapshot.new(entry) }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# @return [Array<Yobi::ForgetReason>] one per kept snapshot, explaining which policy rule(s) kept it
|
|
147
|
+
def reasons
|
|
148
|
+
@reasons ||= (self["reasons"] || []).map { |entry| Yobi::ForgetReason.new(entry) }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Why one snapshot survived a {Yobi::Repository#forget} run.
|
|
153
|
+
class ForgetReason < SimpleDelegator
|
|
154
|
+
# @return [Yobi::Snapshot]
|
|
155
|
+
def snapshot
|
|
156
|
+
@snapshot ||= Yobi::Snapshot.new(self["snapshot"])
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# @return [Array<String>] Restic's own human-readable rule-match strings, e.g. `"last snapshot"`
|
|
160
|
+
def matches
|
|
161
|
+
@matches ||= self["matches"] || []
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|