yobi 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +93 -124
- data/lib/yobi/argv_builder.rb +5 -0
- data/lib/yobi/errors.rb +6 -10
- data/lib/yobi/fancy_hash.rb +31 -0
- data/lib/yobi/io_handle.rb +9 -7
- data/lib/yobi/{mount.rb → mount_handle.rb} +11 -10
- data/lib/yobi/repository/backup.rb +75 -85
- data/lib/yobi/repository/cat.rb +10 -0
- data/lib/yobi/repository/check.rb +61 -45
- data/lib/yobi/repository/diff.rb +130 -47
- data/lib/yobi/repository/dump.rb +1 -1
- data/lib/yobi/repository/find.rb +11 -33
- data/lib/yobi/repository/forget.rb +6 -48
- data/lib/yobi/repository/init.rb +70 -0
- data/lib/yobi/repository/key.rb +59 -52
- data/lib/yobi/repository/ls.rb +48 -37
- data/lib/yobi/repository/mount.rb +16 -18
- data/lib/yobi/repository/restore.rb +42 -40
- data/lib/yobi/repository/snapshots.rb +2 -29
- data/lib/yobi/repository/stats.rb +47 -4
- data/lib/yobi/repository/tag.rb +50 -35
- data/lib/yobi/repository.rb +8 -43
- data/lib/yobi/restic.rb +73 -32
- data/lib/yobi/restic_output.rb +169 -12
- data/lib/yobi/snapshot.rb +81 -2
- data/lib/yobi/version.rb +2 -2
- data/lib/yobi.rb +2 -1
- data/sig/yobi.rbs +252 -139
- metadata +5 -7
- data/Rakefile +0 -12
data/lib/yobi/repository/ls.rb
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
3
|
require "time"
|
|
5
|
-
require "delegate"
|
|
6
4
|
|
|
7
5
|
module Yobi
|
|
8
6
|
class Repository
|
|
@@ -32,33 +30,67 @@ module Yobi
|
|
|
32
30
|
a.repeat_flag(:tag, tags)
|
|
33
31
|
a.end_of_options.append(dirs)
|
|
34
32
|
end
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
output = Yobi::ResticOutput.new(transform: Yobi::LsMessageWrapper)
|
|
34
|
+
execution = run_restic(argv, output: output)
|
|
35
|
+
LsOutcome.new(execution)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Classifies a raw ls message Hash by its own message_type, wrapping it
|
|
40
|
+
# in the matching typed class. Used both as the {Yobi::ResticOutput}
|
|
41
|
+
# `transform:` for {Yobi::Repository#ls} and by {Yobi::LsOutcome}'s own
|
|
42
|
+
# post-hoc accessors.
|
|
43
|
+
#
|
|
44
|
+
# @private
|
|
45
|
+
module LsMessageWrapper
|
|
46
|
+
# @param raw [Hash]
|
|
47
|
+
# @return [Yobi::Snapshot, Yobi::Node, Hash]
|
|
48
|
+
# the raw Hash itself for a message_type this version of Yobi doesn't recognize
|
|
49
|
+
def self.call(raw)
|
|
50
|
+
case raw["message_type"]
|
|
51
|
+
when "snapshot" then Snapshot.new(raw)
|
|
52
|
+
when "node" then Node.new(raw)
|
|
53
|
+
else raw
|
|
54
|
+
end
|
|
37
55
|
end
|
|
38
56
|
end
|
|
39
57
|
|
|
40
58
|
# The outcome of one {Yobi::Repository#ls} call.
|
|
41
|
-
class LsOutcome
|
|
59
|
+
class LsOutcome
|
|
60
|
+
# @return [Yobi::ResticOutput]
|
|
61
|
+
attr_reader :output
|
|
62
|
+
|
|
63
|
+
# @private
|
|
64
|
+
def initialize(execution)
|
|
65
|
+
@output = execution[:output]
|
|
66
|
+
end
|
|
67
|
+
|
|
42
68
|
# @return [Yobi::Snapshot] the resolved snapshot, useful when `snapshot_id:` was `"latest"`
|
|
43
69
|
def snapshot
|
|
44
|
-
@snapshot ||=
|
|
70
|
+
@snapshot ||= output.messages("snapshot").first
|
|
45
71
|
end
|
|
46
72
|
|
|
47
|
-
# @return [Enumerable<Yobi::
|
|
48
|
-
def
|
|
49
|
-
@
|
|
73
|
+
# @return [Enumerable<Yobi::Node>]
|
|
74
|
+
def nodes
|
|
75
|
+
@nodes ||= output.messages("node")
|
|
50
76
|
end
|
|
77
|
+
alias_method :entries, :nodes
|
|
51
78
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
79
|
+
# @return [void]
|
|
80
|
+
def pretty_print(q)
|
|
81
|
+
q.object_group(self) do
|
|
82
|
+
q.breakable
|
|
83
|
+
q.pp snapshot
|
|
84
|
+
q.breakable
|
|
85
|
+
q.pp nodes
|
|
86
|
+
end
|
|
57
87
|
end
|
|
58
88
|
end
|
|
59
89
|
|
|
60
|
-
# One file/directory entry from a {Yobi::Repository#ls} call.
|
|
61
|
-
|
|
90
|
+
# One file/directory entry from a {Yobi::Repository#ls} call. Restic's
|
|
91
|
+
# own term for this is a "node" - the same structure it uses internally
|
|
92
|
+
# for every entry in a snapshot's tree, not just ones `ls` happens to show.
|
|
93
|
+
class Node < Yobi::FancyHash
|
|
62
94
|
# @return [String]
|
|
63
95
|
def name
|
|
64
96
|
self["name"]
|
|
@@ -114,25 +146,4 @@ module Yobi
|
|
|
114
146
|
@ctime ||= Time.parse(self["ctime"])
|
|
115
147
|
end
|
|
116
148
|
end
|
|
117
|
-
|
|
118
|
-
# Enumerable over every {Yobi::LsEntry} in a run.
|
|
119
|
-
class LsEntries
|
|
120
|
-
include Enumerable
|
|
121
|
-
|
|
122
|
-
# @param output [Yobi::ResticOutput]
|
|
123
|
-
def initialize(output)
|
|
124
|
-
@output = output
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# @yieldparam entry [Yobi::LsEntry]
|
|
128
|
-
# @return [Enumerator] if no block is given
|
|
129
|
-
def each
|
|
130
|
-
return enum_for(:each) unless block_given?
|
|
131
|
-
|
|
132
|
-
@output.index["node"].each do |offset|
|
|
133
|
-
line = @output.read_line_at(offset)
|
|
134
|
-
yield LsEntry.new(JSON.parse(line))
|
|
135
|
-
end
|
|
136
|
-
end
|
|
137
|
-
end
|
|
138
149
|
end
|
|
@@ -1,16 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "open3"
|
|
4
|
-
require "tempfile"
|
|
5
4
|
|
|
6
5
|
module Yobi
|
|
7
6
|
class Repository
|
|
8
7
|
# `restic mount`: serves this repository as a read-only FUSE
|
|
9
8
|
# filesystem at `mountpoint`, which must already exist.
|
|
10
9
|
#
|
|
11
|
-
# Without a block, returns a {Yobi::
|
|
12
|
-
# ready; call `#stop` yourself once done. With one, yields the
|
|
13
|
-
# and stops it automatically once the block returns or raises.
|
|
10
|
+
# Without a block, returns a {Yobi::MountHandle} once Restic reports
|
|
11
|
+
# itself ready; call `#stop` yourself once done. With one, yields the
|
|
12
|
+
# `MountHandle` and stops it automatically once the block returns or raises.
|
|
14
13
|
#
|
|
15
14
|
# @param mountpoint [String] must already exist
|
|
16
15
|
# @param hosts [Array<String>, String] restrict which snapshots appear under `snapshots/`
|
|
@@ -22,8 +21,8 @@ module Yobi
|
|
|
22
21
|
# @param path_templates [Array<String>, String] directory naming scheme(s) under `snapshots/`
|
|
23
22
|
# @param time_template [String, nil] directory naming scheme for time-based paths
|
|
24
23
|
# @param ready_timeout [Numeric] seconds to wait for Restic's readiness message before raising
|
|
25
|
-
# @yieldparam mount [Yobi::
|
|
26
|
-
# @return [Yobi::
|
|
24
|
+
# @yieldparam mount [Yobi::MountHandle]
|
|
25
|
+
# @return [Yobi::MountHandle] if no block is given
|
|
27
26
|
# @return [Object] the block's own return value, otherwise
|
|
28
27
|
# @raise [Yobi::MountTimeout]
|
|
29
28
|
def mount(mountpoint:, hosts: [], paths: [], tags: [], allow_other: false,
|
|
@@ -59,7 +58,7 @@ module Yobi
|
|
|
59
58
|
end
|
|
60
59
|
|
|
61
60
|
class Restic
|
|
62
|
-
#
|
|
61
|
+
# @private
|
|
63
62
|
READY_LINE = "Now serving the repository at"
|
|
64
63
|
|
|
65
64
|
# For {Yobi::Repository#mount}. Spawns Restic and waits for its
|
|
@@ -70,43 +69,42 @@ module Yobi
|
|
|
70
69
|
# @param mountpoint [String]
|
|
71
70
|
# @param extra_env [Hash{String => String}]
|
|
72
71
|
# @param ready_timeout [Numeric]
|
|
73
|
-
# @return [Yobi::
|
|
72
|
+
# @return [Yobi::MountHandle]
|
|
74
73
|
# @raise [Yobi::MountTimeout]
|
|
75
74
|
def run_mount(argv, mountpoint:, extra_env: {}, ready_timeout: 10)
|
|
76
75
|
ensure_minimum_version!
|
|
77
|
-
|
|
78
|
-
file.unlink
|
|
76
|
+
output = Yobi::ResticOutput.new
|
|
79
77
|
|
|
80
|
-
stdin,
|
|
78
|
+
stdin, pipe, wait_thr = Open3.popen2e(env.merge(extra_env), restic_path, *argv)
|
|
81
79
|
stdin.close
|
|
82
80
|
|
|
83
|
-
case wait_for_ready(
|
|
81
|
+
case wait_for_ready(pipe, output.file, mountpoint, ready_timeout)
|
|
84
82
|
when :ready
|
|
85
|
-
Yobi::
|
|
83
|
+
Yobi::MountHandle.new(wait_thr: wait_thr, mountpoint: mountpoint, pipe: pipe, output: output, argv: argv)
|
|
86
84
|
when :timeout
|
|
87
85
|
Process.kill("INT", wait_thr.pid)
|
|
88
86
|
wait_thr.value
|
|
89
87
|
raise Yobi::MountTimeout.new(argv: argv, timeout: ready_timeout)
|
|
90
88
|
when :exited
|
|
91
89
|
status = wait_thr.value
|
|
92
|
-
self.class.dispatch(exit_code: status.exitstatus, output:
|
|
90
|
+
self.class.dispatch(exit_code: status.exitstatus, output: output, argv: argv)
|
|
93
91
|
end
|
|
94
92
|
rescue Errno::ENOENT
|
|
95
|
-
file&.close
|
|
93
|
+
output&.file&.close
|
|
96
94
|
raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
|
|
97
95
|
end
|
|
98
96
|
|
|
99
97
|
private
|
|
100
98
|
|
|
101
|
-
def wait_for_ready(
|
|
99
|
+
def wait_for_ready(pipe, file, mountpoint, timeout)
|
|
102
100
|
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
103
101
|
|
|
104
102
|
loop do
|
|
105
103
|
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
106
104
|
return :timeout if remaining <= 0
|
|
107
|
-
return :timeout if IO.select([
|
|
105
|
+
return :timeout if IO.select([pipe], nil, nil, remaining).nil?
|
|
108
106
|
|
|
109
|
-
line =
|
|
107
|
+
line = pipe.gets
|
|
110
108
|
return :exited if line.nil?
|
|
111
109
|
|
|
112
110
|
file.write(line)
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "delegate"
|
|
5
|
-
|
|
6
3
|
module Yobi
|
|
7
4
|
class Repository
|
|
8
5
|
# `restic restore`: extracts a snapshot's contents to a target directory.
|
|
@@ -58,60 +55,65 @@ module Yobi
|
|
|
58
55
|
a.short_flag(:vv) if verbose
|
|
59
56
|
a.flag(:verify) if verify
|
|
60
57
|
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
58
|
|
|
69
|
-
|
|
59
|
+
output = Yobi::ResticOutput.new(transform: Yobi::RestoreMessageWrapper)
|
|
60
|
+
# Restic's own restore summary isn't meant for the live block - only
|
|
61
|
+
# {RestoreOutcome#summary}'s post-hoc access sees it - but it shares
|
|
62
|
+
# the same @transform (needed there since it isn't a status/verbose_status
|
|
63
|
+
# message), so it has to be filtered back out here instead.
|
|
64
|
+
live_block = block && proc { |message| block.call(message) if message.is_a?(RestoreStatus) || message.is_a?(RestoreVerboseStatus) }
|
|
65
|
+
execution = run_restic(argv, output: output, &live_block)
|
|
66
|
+
RestoreOutcome.new(execution)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
70
69
|
|
|
71
|
-
|
|
70
|
+
# Classifies a raw restore message Hash by its own message_type, wrapping
|
|
71
|
+
# it in the matching typed class. Used both by {Yobi::Repository#restore}
|
|
72
|
+
# (as the {Yobi::ResticOutput} `transform:` for a live streaming run) and
|
|
73
|
+
# by {Yobi::RestoreOutcome}'s own post-hoc accessors.
|
|
74
|
+
#
|
|
75
|
+
# @private
|
|
76
|
+
module RestoreMessageWrapper
|
|
77
|
+
# @param raw [Hash]
|
|
78
|
+
# @return [Yobi::RestoreStatus, Yobi::RestoreVerboseStatus, Hash]
|
|
79
|
+
# the raw Hash itself for a message_type this version of Yobi doesn't recognize
|
|
80
|
+
def self.call(raw)
|
|
72
81
|
case raw["message_type"]
|
|
73
|
-
when "status"
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
yield Yobi::RestoreVerboseStatus.new(raw)
|
|
82
|
+
when "status" then RestoreStatus.new(raw)
|
|
83
|
+
when "verbose_status" then RestoreVerboseStatus.new(raw)
|
|
84
|
+
else raw
|
|
77
85
|
end
|
|
78
86
|
end
|
|
79
87
|
end
|
|
80
88
|
|
|
81
89
|
# The outcome of one {Yobi::Repository#restore} call.
|
|
82
|
-
class RestoreOutcome
|
|
83
|
-
# @return [
|
|
84
|
-
|
|
85
|
-
@report ||= summary_hash
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
private
|
|
90
|
+
class RestoreOutcome
|
|
91
|
+
# @return [Yobi::ResticOutput]
|
|
92
|
+
attr_reader :output
|
|
89
93
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if hash && hash["message_type"] == "summary"
|
|
94
|
-
hash
|
|
95
|
-
else
|
|
96
|
-
find_summary || {}
|
|
97
|
-
end
|
|
94
|
+
# @private
|
|
95
|
+
def initialize(execution)
|
|
96
|
+
@output = execution[:output]
|
|
98
97
|
end
|
|
99
98
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
99
|
+
# @return [Hash] Restic's own `"summary"` fields (`"total_files"`, `"files_restored"`, ...)
|
|
100
|
+
def summary
|
|
101
|
+
@summary ||= output.messages("summary").first || {}
|
|
102
|
+
end
|
|
103
|
+
alias_method :report, :summary
|
|
103
104
|
|
|
104
|
-
|
|
105
|
-
|
|
105
|
+
# @return [void]
|
|
106
|
+
def pretty_print(q)
|
|
107
|
+
q.object_group(self) do
|
|
108
|
+
q.breakable
|
|
109
|
+
q.pp summary
|
|
106
110
|
end
|
|
107
|
-
|
|
108
|
-
nil
|
|
109
111
|
end
|
|
110
112
|
end
|
|
111
113
|
|
|
112
114
|
# One `"status"` message from a live restore run.
|
|
113
115
|
# https://restic.readthedocs.io/en/stable/075_scripting.html#restore
|
|
114
|
-
class RestoreStatus <
|
|
116
|
+
class RestoreStatus < Yobi::FancyHash
|
|
115
117
|
# @return [Float, nil]
|
|
116
118
|
def percent_done
|
|
117
119
|
self["percent_done"]
|
|
@@ -140,7 +142,7 @@ module Yobi
|
|
|
140
142
|
|
|
141
143
|
# One `"verbose_status"` message from a restore run, one per file. Only
|
|
142
144
|
# emitted when `verbose: true` is passed to {Yobi::Repository#restore}.
|
|
143
|
-
class RestoreVerboseStatus <
|
|
145
|
+
class RestoreVerboseStatus < Yobi::FancyHash
|
|
144
146
|
# @return [String]
|
|
145
147
|
def action
|
|
146
148
|
self["action"]
|
|
@@ -12,7 +12,7 @@ module Yobi
|
|
|
12
12
|
# @param compact [Boolean] compact the printed listing
|
|
13
13
|
# @param group_by [String, nil] group results, e.g. `"host"`
|
|
14
14
|
# @param latest [Integer, nil] limit to the N most recent per group
|
|
15
|
-
# @return [
|
|
15
|
+
# @return [Array<Yobi::Snapshot>]
|
|
16
16
|
def snapshots(tags: [], hosts: [], paths: [], compact: false, group_by: nil, latest: nil)
|
|
17
17
|
argv = build_argv("snapshots") do |a|
|
|
18
18
|
a.repeat_flag(:tag, tags)
|
|
@@ -23,34 +23,7 @@ module Yobi
|
|
|
23
23
|
a.flag(:latest, latest) unless latest.nil?
|
|
24
24
|
end
|
|
25
25
|
execution = run_restic(argv)
|
|
26
|
-
|
|
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) }
|
|
26
|
+
JSON.parse(execution[:output].to_s).map { |raw| Snapshot.new(raw) }
|
|
54
27
|
end
|
|
55
28
|
end
|
|
56
29
|
end
|
|
@@ -4,8 +4,7 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Yobi
|
|
6
6
|
class Repository
|
|
7
|
-
#
|
|
8
|
-
# to the String Restic itself expects.
|
|
7
|
+
# @private
|
|
9
8
|
STATS_MODES = %w[restore-size files-by-contents blobs-per-file raw-data].each_with_object({}) do |value, hash|
|
|
10
9
|
hash[value] = value
|
|
11
10
|
hash[value.tr("-", "_").to_sym] = value
|
|
@@ -18,7 +17,7 @@ module Yobi
|
|
|
18
17
|
# @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
18
|
# @param paths [Array<String>, String] filter by originally backed-up path(s)
|
|
20
19
|
# @param tags [Array<String>, String] filter by tag(s)
|
|
21
|
-
# @return [
|
|
20
|
+
# @return [Yobi::RepositoryStats]
|
|
22
21
|
# @raise [ArgumentError] if `mode:` isn't one of the values listed above
|
|
23
22
|
def stats(snapshot_ids: [], hosts: [], mode: nil, paths: [], tags: [])
|
|
24
23
|
argv = build_argv("stats", snapshot_ids) do |a|
|
|
@@ -30,7 +29,51 @@ module Yobi
|
|
|
30
29
|
a.repeat_flag(:tag, tags)
|
|
31
30
|
end
|
|
32
31
|
execution = run_restic(argv)
|
|
33
|
-
JSON.parse(execution[:output].to_s)
|
|
32
|
+
RepositoryStats.new(JSON.parse(execution[:output].to_s))
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# The result of one {Yobi::Repository#stats} call.
|
|
37
|
+
# https://restic.readthedocs.io/en/stable/075_scripting.html#stats
|
|
38
|
+
class RepositoryStats < Yobi::FancyHash
|
|
39
|
+
# @return [Integer] repository size in bytes
|
|
40
|
+
def total_size
|
|
41
|
+
self["total_size"] || 0
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# @return [Integer] number of files backed up in the repository
|
|
45
|
+
def total_file_count
|
|
46
|
+
self["total_file_count"] || 0
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Integer] number of blobs in the repository
|
|
50
|
+
def total_blob_count
|
|
51
|
+
self["total_blob_count"] || 0
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @return [Integer] number of processed snapshots
|
|
55
|
+
def snapshots_count
|
|
56
|
+
self["snapshots_count"] || 0
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# @return [Integer] repository size in bytes if blobs were uncompressed
|
|
60
|
+
def total_uncompressed_size
|
|
61
|
+
self["total_uncompressed_size"] || 0
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# @return [Float] factor by which the already compressed data has shrunk due to compression
|
|
65
|
+
def compression_ratio
|
|
66
|
+
self["compression_ratio"] || 0.0
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# @return [Float] percentage of already compressed data
|
|
70
|
+
def compression_progress
|
|
71
|
+
self["compression_progress"] || 0.0
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# @return [Float] overall space saving due to compression
|
|
75
|
+
def compression_space_saving
|
|
76
|
+
self["compression_space_saving"] || 0.0
|
|
34
77
|
end
|
|
35
78
|
end
|
|
36
79
|
end
|
data/lib/yobi/repository/tag.rb
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
require "delegate"
|
|
5
|
-
|
|
6
3
|
module Yobi
|
|
7
4
|
class Repository
|
|
8
5
|
# `restic tag`: modifies tags on existing snapshots. Tags are part of
|
|
@@ -26,56 +23,65 @@ module Yobi
|
|
|
26
23
|
a.repeat_flag(:host, hosts)
|
|
27
24
|
a.repeat_flag(:path, paths)
|
|
28
25
|
end
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
output = Yobi::ResticOutput.new(transform: Yobi::TagMessageWrapper)
|
|
27
|
+
execution = run_restic(argv, output: output)
|
|
28
|
+
TagOutcome.new(execution)
|
|
31
29
|
end
|
|
32
30
|
end
|
|
33
31
|
|
|
34
|
-
#
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
# Classifies a raw tag message Hash by its own message_type, wrapping it
|
|
33
|
+
# in the matching typed class. Used both as the {Yobi::ResticOutput}
|
|
34
|
+
# `transform:` for {Yobi::Repository#tag} and by {Yobi::TagOutcome}'s own
|
|
35
|
+
# post-hoc accessors.
|
|
36
|
+
#
|
|
37
|
+
# @private
|
|
38
|
+
module TagMessageWrapper
|
|
39
|
+
# @param raw [Hash]
|
|
40
|
+
# @return [Yobi::TagChange, Yobi::TagSummary, Hash]
|
|
41
|
+
# the raw Hash itself for a message_type this version of Yobi doesn't recognize
|
|
42
|
+
def self.call(raw)
|
|
43
|
+
case raw["message_type"]
|
|
44
|
+
when "changed" then TagChange.new(raw)
|
|
45
|
+
when "summary" then TagSummary.new(raw)
|
|
46
|
+
else raw
|
|
47
|
+
end
|
|
39
48
|
end
|
|
49
|
+
end
|
|
40
50
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
51
|
+
# The outcome of one {Yobi::Repository#tag} call.
|
|
52
|
+
class TagOutcome
|
|
53
|
+
# @return [Yobi::ResticOutput]
|
|
54
|
+
attr_reader :output
|
|
45
55
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
56
|
+
# @private
|
|
57
|
+
def initialize(execution)
|
|
58
|
+
@output = execution[:output]
|
|
49
59
|
end
|
|
50
60
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
# @return [Yobi::TagSummary] Restic's own `"summary"` fields (`"changed_snapshots"`)
|
|
62
|
+
def summary
|
|
63
|
+
@summary ||= output.messages("summary").first || TagSummary.new({})
|
|
61
64
|
end
|
|
65
|
+
alias_method :report, :summary
|
|
62
66
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
67
|
+
# @return [Enumerable<Yobi::TagChange>] one per snapshot actually modified
|
|
68
|
+
def changes
|
|
69
|
+
@changes ||= output.messages("changed")
|
|
70
|
+
end
|
|
66
71
|
|
|
67
|
-
|
|
68
|
-
|
|
72
|
+
# @return [void]
|
|
73
|
+
def pretty_print(q)
|
|
74
|
+
q.object_group(self) do
|
|
75
|
+
q.breakable
|
|
76
|
+
q.pp summary
|
|
69
77
|
end
|
|
70
|
-
|
|
71
|
-
nil
|
|
72
78
|
end
|
|
73
79
|
end
|
|
74
80
|
|
|
75
81
|
# One `"changed"` message from a tag run. `old_snapshot_id` is now stale:
|
|
76
82
|
# tags are part of a snapshot's content-addressed identity, so changing
|
|
77
83
|
# them produces a new snapshot ID.
|
|
78
|
-
class TagChange <
|
|
84
|
+
class TagChange < Yobi::FancyHash
|
|
79
85
|
# @return [String]
|
|
80
86
|
def old_snapshot_id
|
|
81
87
|
self["old_snapshot_id"]
|
|
@@ -86,4 +92,13 @@ module Yobi
|
|
|
86
92
|
self["new_snapshot_id"]
|
|
87
93
|
end
|
|
88
94
|
end
|
|
95
|
+
|
|
96
|
+
# The `"summary"` message from a tag run, the final result once the
|
|
97
|
+
# command finishes. Dispatched to {Yobi::TagOutcome#summary}.
|
|
98
|
+
class TagSummary < Yobi::FancyHash
|
|
99
|
+
# @return [Integer]
|
|
100
|
+
def changed_snapshots
|
|
101
|
+
self["changed_snapshots"] || 0
|
|
102
|
+
end
|
|
103
|
+
end
|
|
89
104
|
end
|
data/lib/yobi/repository.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "uri"
|
|
4
|
-
require "json"
|
|
5
4
|
|
|
6
5
|
module Yobi
|
|
7
6
|
# One Restic repository. Every Restic subcommand that operates on a
|
|
@@ -9,13 +8,13 @@ module Yobi
|
|
|
9
8
|
class Repository
|
|
10
9
|
# @return [String] the repository location
|
|
11
10
|
attr_reader :url
|
|
12
|
-
# @return [String, Array, Symbol, #call
|
|
11
|
+
# @return [String, Array, Symbol, #call] the repository's encryption password, as given to {#initialize}
|
|
13
12
|
attr_reader :password
|
|
14
13
|
# @return [Hash, #call] the storage backend's own credentials, as given to {#initialize}
|
|
15
14
|
attr_reader :backend_credentials
|
|
16
15
|
|
|
17
16
|
# @param url [String] the repository location, e.g. `"s3:s3.amazonaws.com/bucket"`
|
|
18
|
-
# @param password [String, Array, Symbol, #call
|
|
17
|
+
# @param password [String, Array, Symbol, #call] a literal password; a
|
|
19
18
|
# `[:command, "..."]`/`[:file, "..."]` tuple, resolved natively by Restic
|
|
20
19
|
# itself; `:insecure_no_password`; or anything responding to `#call`
|
|
21
20
|
# (invoked fresh immediately before every Restic invocation)
|
|
@@ -24,7 +23,10 @@ module Yobi
|
|
|
24
23
|
# responding to `#call` returning such a Hash
|
|
25
24
|
# @param restic [Yobi::Restic, String, nil] a `Restic` instance to share,
|
|
26
25
|
# a bare Restic binary path, or `nil` to create a default one
|
|
27
|
-
|
|
26
|
+
# @raise [ArgumentError] if `password:` is `nil`
|
|
27
|
+
def initialize(url:, password:, backend_credentials: {}, restic: nil)
|
|
28
|
+
raise ArgumentError, "password: is required (use :insecure_no_password if the repository truly has none)" if password.nil?
|
|
29
|
+
|
|
28
30
|
@url, @extracted_rest_credentials = extract_rest_credentials(url)
|
|
29
31
|
@password = password
|
|
30
32
|
@backend_credentials = backend_credentials
|
|
@@ -43,47 +45,10 @@ module Yobi
|
|
|
43
45
|
"#<#{self.class} url=#{url.inspect} password=#{redacted_password.inspect} backend_credentials=#{redacted_backend_credentials.inspect}>"
|
|
44
46
|
end
|
|
45
47
|
|
|
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
48
|
private
|
|
84
49
|
|
|
85
|
-
def run_restic(argv, extra_env: {}, &block)
|
|
86
|
-
@restic.run(argv, extra_env: env.merge(extra_env), &block)
|
|
50
|
+
def run_restic(argv, extra_env: {}, output: nil, &block)
|
|
51
|
+
@restic.run(argv, extra_env: env.merge(extra_env), output: output, &block)
|
|
87
52
|
end
|
|
88
53
|
|
|
89
54
|
def build_argv(*base)
|