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