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
data/Rakefile
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yobi
|
|
4
|
+
# Builds a Restic command's argv incrementally. Not an Array subclass:
|
|
5
|
+
# only the methods below are available; {#to_a} hands back the plain
|
|
6
|
+
# Array once building is done.
|
|
7
|
+
#
|
|
8
|
+
# @private
|
|
9
|
+
class ArgvBuilder
|
|
10
|
+
# Maps a flag name Symbol to its "--dashed-string" form, e.g.
|
|
11
|
+
# `:read_data_subset` to `"--read-data-subset"`.
|
|
12
|
+
FLAGS = Hash.new { |flags, name| flags[name] = "--#{name.to_s.tr("_", "-")}" }
|
|
13
|
+
|
|
14
|
+
# Maps a short flag name Symbol to its "-name" form, e.g. `:vv` to `"-vv"`.
|
|
15
|
+
SHORT_FLAGS = Hash.new { |flags, name| flags[name] = "-#{name}" }
|
|
16
|
+
|
|
17
|
+
def initialize
|
|
18
|
+
@argv = []
|
|
19
|
+
@end_of_options_called = false
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @return [Array<String>] the argv built so far
|
|
23
|
+
def to_a
|
|
24
|
+
@argv
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Appends one or more bare positional values. `nil` is dropped; an
|
|
28
|
+
# Enumerable is flattened in; everything else is converted with `#to_s`.
|
|
29
|
+
#
|
|
30
|
+
# @param values [Array<Object>]
|
|
31
|
+
# @return [self]
|
|
32
|
+
def append(*values)
|
|
33
|
+
values.each do |v|
|
|
34
|
+
case v
|
|
35
|
+
in nil
|
|
36
|
+
in String
|
|
37
|
+
@argv << v
|
|
38
|
+
in Enumerable
|
|
39
|
+
append(*v)
|
|
40
|
+
else
|
|
41
|
+
@argv << v.to_s
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
self
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Appends a flag, plus its value when one is given.
|
|
48
|
+
#
|
|
49
|
+
# @param name [Symbol] flag name, looked up in {FLAGS}
|
|
50
|
+
# @param value [Object, nil] the flag's value; omit for a boolean flag
|
|
51
|
+
# @return [self]
|
|
52
|
+
def flag(name, value = nil)
|
|
53
|
+
@argv << FLAGS[name]
|
|
54
|
+
@argv << value.to_s unless value.nil?
|
|
55
|
+
self
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Appends a repeatable flag once per value, e.g. `--host a --host b`.
|
|
59
|
+
#
|
|
60
|
+
# @param name [Symbol] flag name, looked up in {FLAGS}
|
|
61
|
+
# @param values [Array<Object>, Object, nil]
|
|
62
|
+
# @return [self]
|
|
63
|
+
def repeat_flag(name, values)
|
|
64
|
+
name = FLAGS[name]
|
|
65
|
+
array_of_strings(values).each { |value| @argv << name << value }
|
|
66
|
+
self
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Appends a short flag, e.g. `short_flag(:vv)` for `-vv`.
|
|
70
|
+
#
|
|
71
|
+
# @param name [Symbol] flag name, looked up in {SHORT_FLAGS}
|
|
72
|
+
# @return [self]
|
|
73
|
+
def short_flag(name)
|
|
74
|
+
@argv << SHORT_FLAGS[name]
|
|
75
|
+
self
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Appends Restic's `--` end-of-options marker.
|
|
79
|
+
#
|
|
80
|
+
# @return [self]
|
|
81
|
+
# @raise [RuntimeError] if called more than once on the same builder
|
|
82
|
+
def end_of_options
|
|
83
|
+
raise "end_of_options already called - Restic only honors the first \"--\"" if @end_of_options_called
|
|
84
|
+
|
|
85
|
+
@end_of_options_called = true
|
|
86
|
+
@argv << "--"
|
|
87
|
+
self
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def array_of_strings(value)
|
|
93
|
+
if value.nil?
|
|
94
|
+
[]
|
|
95
|
+
elsif value.respond_to?(:to_ary)
|
|
96
|
+
value.to_ary.map(&:to_s)
|
|
97
|
+
else
|
|
98
|
+
[value.to_s]
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
data/lib/yobi/errors.rb
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "delegate"
|
|
5
|
+
|
|
6
|
+
module Yobi
|
|
7
|
+
# Base class for all errors raised by Yobi.
|
|
8
|
+
class Error < StandardError; end
|
|
9
|
+
|
|
10
|
+
# The `exit_error` message from a fatal Restic run, if one was printed.
|
|
11
|
+
# https://restic.readthedocs.io/en/stable/075_scripting.html#exit-errors
|
|
12
|
+
class ExitError < SimpleDelegator
|
|
13
|
+
# Matches a JSON line with `"message_type": "exit_error"`.
|
|
14
|
+
LINE_PATTERN = /"message_type"\s*:\s*"exit_error"/
|
|
15
|
+
|
|
16
|
+
# @param output [Yobi::ResticOutput]
|
|
17
|
+
# @return [Yobi::ExitError, nil] `nil` if no `exit_error` line is present
|
|
18
|
+
def self.from_output(output)
|
|
19
|
+
line = output.each_line.find { |candidate| LINE_PATTERN.match?(candidate) }
|
|
20
|
+
new(JSON.parse(line)) if line
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @return [String, nil]
|
|
24
|
+
def code
|
|
25
|
+
self["code"]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @return [String, nil]
|
|
29
|
+
def message
|
|
30
|
+
self["message"]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Raised when the installed Restic binary is older than
|
|
35
|
+
# {Yobi::Restic::MINIMUM_VERSION}.
|
|
36
|
+
class UnsupportedResticVersion < Error
|
|
37
|
+
# @return [String]
|
|
38
|
+
attr_reader :installed_version, :minimum_version
|
|
39
|
+
|
|
40
|
+
# @param installed_version [String]
|
|
41
|
+
# @param minimum_version [String]
|
|
42
|
+
def initialize(installed_version:, minimum_version:)
|
|
43
|
+
@installed_version = installed_version
|
|
44
|
+
@minimum_version = minimum_version
|
|
45
|
+
super("Restic #{installed_version} does not meet the minimum supported version #{minimum_version}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Raised when the Restic binary itself can't be found or executed.
|
|
50
|
+
class ResticNotFound < Error
|
|
51
|
+
# @return [String]
|
|
52
|
+
attr_reader :restic_path
|
|
53
|
+
# @return [Array<String>]
|
|
54
|
+
attr_reader :argv
|
|
55
|
+
|
|
56
|
+
# @param restic_path [String]
|
|
57
|
+
# @param argv [Array<String>]
|
|
58
|
+
def initialize(restic_path:, argv:)
|
|
59
|
+
@restic_path = restic_path
|
|
60
|
+
@argv = argv
|
|
61
|
+
super("Restic binary not found: #{restic_path.inspect} (tried to run #{argv.inspect})")
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Raised when Restic exits with a failure code. Base class for the
|
|
66
|
+
# specific typed errors below.
|
|
67
|
+
class ResticExecutionError < Error
|
|
68
|
+
# @return [Hash] the raw `{exit_code:, output:, argv:}` execution result
|
|
69
|
+
attr_reader :execution
|
|
70
|
+
# @return [Yobi::ExitError, nil]
|
|
71
|
+
attr_reader :exit_error
|
|
72
|
+
|
|
73
|
+
# @param execution [Hash]
|
|
74
|
+
def initialize(execution)
|
|
75
|
+
@execution = execution
|
|
76
|
+
@exit_error = Yobi::ExitError.from_output(execution[:output])
|
|
77
|
+
super(exit_error&.message || "Restic exited with status #{execution[:exit_code]}: #{execution[:output]}")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Raised when the repository does not exist (Restic exit code 10).
|
|
82
|
+
class RepositoryNotFound < ResticExecutionError; end
|
|
83
|
+
|
|
84
|
+
# Raised when the repository is locked by another process (Restic exit code 11).
|
|
85
|
+
class RepositoryLocked < ResticExecutionError; end
|
|
86
|
+
|
|
87
|
+
# Raised when the repository password is incorrect (Restic exit code 12).
|
|
88
|
+
class AuthenticationFailed < ResticExecutionError; end
|
|
89
|
+
|
|
90
|
+
# Raised when Restic exits with a failure code not otherwise classified above.
|
|
91
|
+
class ResticCommandFailed < ResticExecutionError; end
|
|
92
|
+
|
|
93
|
+
# Raised by {Yobi::Repository#mount} when Restic doesn't report itself
|
|
94
|
+
# ready within `ready_timeout:` seconds.
|
|
95
|
+
class MountTimeout < Error
|
|
96
|
+
# @return [Array<String>]
|
|
97
|
+
attr_reader :argv
|
|
98
|
+
# @return [Numeric]
|
|
99
|
+
attr_reader :timeout
|
|
100
|
+
|
|
101
|
+
# @param argv [Array<String>]
|
|
102
|
+
# @param timeout [Numeric]
|
|
103
|
+
def initialize(argv:, timeout:)
|
|
104
|
+
@argv = argv
|
|
105
|
+
@timeout = timeout
|
|
106
|
+
super("Restic mount didn't report readiness within #{timeout}s (tried to run #{argv.inspect})")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yobi
|
|
4
|
+
# A live Restic process streaming raw bytes to its own stdout, returned by
|
|
5
|
+
# {Yobi::Restic#run_dump} when no block is given. Satisfies Rack's Body
|
|
6
|
+
# contract (`#each`, `#close`).
|
|
7
|
+
#
|
|
8
|
+
# Unlike the block form, nothing closes this automatically. Call {#close}
|
|
9
|
+
# yourself once done reading, in an `ensure`, so it still runs if reading
|
|
10
|
+
# raises:
|
|
11
|
+
#
|
|
12
|
+
# @example
|
|
13
|
+
# handle = repo.dump(snapshot_id: "latest", file: "/etc/hosts")
|
|
14
|
+
# begin
|
|
15
|
+
# IO.copy_stream(handle.io, "/tmp/hosts")
|
|
16
|
+
# ensure
|
|
17
|
+
# handle.close
|
|
18
|
+
# end
|
|
19
|
+
class IOHandle
|
|
20
|
+
# @return [IO] the readable end of the pipe Restic's stdout is wired to
|
|
21
|
+
attr_reader :io
|
|
22
|
+
# @return [Integer] the Restic process's pid
|
|
23
|
+
attr_reader :pid
|
|
24
|
+
|
|
25
|
+
# @param io [IO]
|
|
26
|
+
# @param pid [Integer]
|
|
27
|
+
# @param output_file [File]
|
|
28
|
+
# @param argv [Array<String>]
|
|
29
|
+
def initialize(io, pid:, output_file:, argv:)
|
|
30
|
+
@io = io
|
|
31
|
+
@pid = pid
|
|
32
|
+
@output_file = output_file
|
|
33
|
+
@argv = argv
|
|
34
|
+
@closed = false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Boolean]
|
|
38
|
+
def closed?
|
|
39
|
+
@closed
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Reaps the Restic process and raises based on its exit code. Safe to
|
|
43
|
+
# call more than once.
|
|
44
|
+
#
|
|
45
|
+
# @return [void]
|
|
46
|
+
def close
|
|
47
|
+
return if @closed
|
|
48
|
+
|
|
49
|
+
@closed = true
|
|
50
|
+
@io.close unless @io.closed?
|
|
51
|
+
_, status = Process.wait2(@pid)
|
|
52
|
+
Restic.dispatch(exit_code: status.exitstatus, output: Yobi::ResticOutput.new(@output_file), argv: @argv)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Yields binary-safe chunks of {#io} until EOF, then calls {#close}.
|
|
56
|
+
#
|
|
57
|
+
# @yieldparam chunk [String]
|
|
58
|
+
# @return [void]
|
|
59
|
+
def each
|
|
60
|
+
loop do
|
|
61
|
+
yield @io.readpartial(64 * 1024)
|
|
62
|
+
rescue EOFError
|
|
63
|
+
break
|
|
64
|
+
end
|
|
65
|
+
close
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
data/lib/yobi/mount.rb
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Yobi
|
|
4
|
+
# A live Restic mount process, returned by {Yobi::Restic#run_mount} once
|
|
5
|
+
# Restic has reported itself ready. The mounted filesystem itself is
|
|
6
|
+
# browsed with ordinary file I/O at {#mountpoint}; this object only
|
|
7
|
+
# manages the Restic process's lifetime.
|
|
8
|
+
class Mount
|
|
9
|
+
# @return [String] the path the repository is mounted at
|
|
10
|
+
attr_reader :mountpoint
|
|
11
|
+
|
|
12
|
+
# @param wait_thr [Process::Waiter] as returned by `Open3.popen2e`
|
|
13
|
+
# @param mountpoint [String]
|
|
14
|
+
# @param output [IO]
|
|
15
|
+
# @param output_file [File]
|
|
16
|
+
# @param argv [Array<String>]
|
|
17
|
+
def initialize(wait_thr:, mountpoint:, output:, output_file:, argv:)
|
|
18
|
+
@wait_thr = wait_thr
|
|
19
|
+
@mountpoint = mountpoint
|
|
20
|
+
@output = output
|
|
21
|
+
@output_file = output_file
|
|
22
|
+
@argv = argv
|
|
23
|
+
@stopped = false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# @return [Integer] the Restic process's pid
|
|
27
|
+
def pid
|
|
28
|
+
@wait_thr.pid
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# @return [Boolean]
|
|
32
|
+
def stopped?
|
|
33
|
+
@stopped
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Sends the Restic process SIGINT, waits for it to unmount and exit,
|
|
37
|
+
# then raises based on its exit code. Safe to call more than once, and
|
|
38
|
+
# safe to call after the mount has already been stopped externally
|
|
39
|
+
# (e.g. via `kill -INT` or the OS's own `umount`/`fusermount`).
|
|
40
|
+
#
|
|
41
|
+
# @return [void]
|
|
42
|
+
def stop
|
|
43
|
+
return if @stopped
|
|
44
|
+
|
|
45
|
+
@stopped = true
|
|
46
|
+
|
|
47
|
+
begin
|
|
48
|
+
Process.kill("INT", pid)
|
|
49
|
+
rescue Errno::ESRCH
|
|
50
|
+
nil
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
drain_remaining_output
|
|
54
|
+
status = @wait_thr.value
|
|
55
|
+
Restic.dispatch(exit_code: status.exitstatus, output: Yobi::ResticOutput.new(@output_file), argv: @argv)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def drain_remaining_output
|
|
61
|
+
@output.each_line { |line| @output_file.write(line) }
|
|
62
|
+
rescue IOError, Errno::EBADF
|
|
63
|
+
nil
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "time"
|
|
5
|
+
require "delegate"
|
|
6
|
+
require "shellwords"
|
|
7
|
+
|
|
8
|
+
module Yobi
|
|
9
|
+
class Repository
|
|
10
|
+
# `restic backup`: creates a new snapshot from a source path.
|
|
11
|
+
#
|
|
12
|
+
# @param source [String, Array] a path to back up, or a
|
|
13
|
+
# `[:stdin_from_command, command]`/`[:stdin_from_command, command, filename]`
|
|
14
|
+
# tuple. Restic spawns and executes `command` itself (a String,
|
|
15
|
+
# tokenized with `Shellwords.split`, or an Array of already-discrete
|
|
16
|
+
# arguments), capturing its stdout as the backup content
|
|
17
|
+
# @param excludes [Array<String>, String] exclude files matching these glob patterns
|
|
18
|
+
# @param exclude_files [Array<String>, String] path(s) to file(s) listing exclude patterns, one per line
|
|
19
|
+
# @param exclude_if_present [Array<String>, String] skip a directory containing a marker file with this name (optionally `name:content`)
|
|
20
|
+
# @param exclude_larger_than [String, nil] skip files larger than this size, e.g. `"1G"`
|
|
21
|
+
# @param files_from [Array<String>, String] read the files/dirs to back up from a file, one per line
|
|
22
|
+
# @param files_from_raw [Array<String>, String] like `files_from`, NUL-separated
|
|
23
|
+
# @param files_from_verbatim [Array<String>, String] like `files_from`, paths taken literally with no glob expansion
|
|
24
|
+
# @param iexcludes [Array<String>, String] like `excludes`, case-insensitive
|
|
25
|
+
# @param iexclude_files [Array<String>, String] like `exclude_files`, case-insensitive
|
|
26
|
+
# @param tags [Array<String>, String] tags to attach to the new snapshot
|
|
27
|
+
# @param dry_run [Boolean] report what would happen without doing it
|
|
28
|
+
# @param exclude_caches [Boolean] skip directories containing a CACHEDIR.TAG marker
|
|
29
|
+
# @param exclude_cloud_files [Boolean] skip files not fully present on disk (e.g. OneDrive placeholders)
|
|
30
|
+
# @param force [Boolean] back up unchanged files instead of skipping them
|
|
31
|
+
# @param group_by [String, nil] grouping used to find the parent snapshot, e.g. `"host,paths"`
|
|
32
|
+
# @param host [String, nil] hostname to record on the new snapshot, instead of the OS hostname
|
|
33
|
+
# @param ignore_ctime [Boolean] relax change detection to ignore ctime
|
|
34
|
+
# @param ignore_inode [Boolean] relax change detection to ignore inode number
|
|
35
|
+
# @param no_scan [Boolean] skip the pre-backup scan (disables percentage progress)
|
|
36
|
+
# @param one_file_system [Boolean] don't cross filesystem boundaries
|
|
37
|
+
# @param parent [String, nil] snapshot ID to use as the parent instead of the latest one
|
|
38
|
+
# @param read_concurrency [Integer, nil] number of concurrent file reads
|
|
39
|
+
# @param skip_if_unchanged [Boolean] don't create a snapshot if nothing changed
|
|
40
|
+
# @param time [String, nil] timestamp to record instead of now
|
|
41
|
+
# @param verbose [Boolean] stream a {Yobi::BackupVerboseStatus} per file to the block
|
|
42
|
+
# @param with_atime [Boolean] also store files' access times
|
|
43
|
+
# @yieldparam message [Yobi::BackupStatus, Yobi::BackupError, Yobi::BackupVerboseStatus]
|
|
44
|
+
# @return [Yobi::BackupOutcome]
|
|
45
|
+
def backup(source:, excludes: [], exclude_files: [], exclude_if_present: [], exclude_larger_than: nil,
|
|
46
|
+
files_from: [], files_from_raw: [], files_from_verbatim: [], iexcludes: [], iexclude_files: [],
|
|
47
|
+
tags: [], dry_run: false, exclude_caches: false, exclude_cloud_files: false, force: false,
|
|
48
|
+
group_by: nil, host: nil, ignore_ctime: false, ignore_inode: false, no_scan: false,
|
|
49
|
+
one_file_system: false, parent: nil, read_concurrency: nil, skip_if_unchanged: false,
|
|
50
|
+
time: nil, verbose: false, with_atime: false, &block)
|
|
51
|
+
argv = build_argv("backup") do |a|
|
|
52
|
+
a.repeat_flag(:exclude, excludes)
|
|
53
|
+
a.repeat_flag(:exclude_file, exclude_files)
|
|
54
|
+
a.repeat_flag(:exclude_if_present, exclude_if_present)
|
|
55
|
+
a.flag(:exclude_larger_than, exclude_larger_than) unless exclude_larger_than.nil?
|
|
56
|
+
a.repeat_flag(:files_from, files_from)
|
|
57
|
+
a.repeat_flag(:files_from_raw, files_from_raw)
|
|
58
|
+
a.repeat_flag(:files_from_verbatim, files_from_verbatim)
|
|
59
|
+
a.repeat_flag(:iexclude, iexcludes)
|
|
60
|
+
a.repeat_flag(:iexclude_file, iexclude_files)
|
|
61
|
+
a.repeat_flag(:tag, tags)
|
|
62
|
+
a.flag(:dry_run) if dry_run
|
|
63
|
+
a.flag(:exclude_caches) if exclude_caches
|
|
64
|
+
a.flag(:exclude_cloud_files) if exclude_cloud_files
|
|
65
|
+
a.flag(:force) if force
|
|
66
|
+
a.flag(:group_by, group_by) unless group_by.nil?
|
|
67
|
+
a.flag(:host, host) unless host.nil?
|
|
68
|
+
a.flag(:ignore_ctime) if ignore_ctime
|
|
69
|
+
a.flag(:ignore_inode) if ignore_inode
|
|
70
|
+
a.flag(:no_scan) if no_scan
|
|
71
|
+
a.flag(:one_file_system) if one_file_system
|
|
72
|
+
a.flag(:parent, parent) unless parent.nil?
|
|
73
|
+
a.flag(:read_concurrency, read_concurrency) unless read_concurrency.nil?
|
|
74
|
+
a.flag(:skip_if_unchanged) if skip_if_unchanged
|
|
75
|
+
a.flag(:time, time) unless time.nil?
|
|
76
|
+
a.short_flag(:v) if verbose
|
|
77
|
+
a.flag(:with_atime) if with_atime
|
|
78
|
+
case source
|
|
79
|
+
in String => path
|
|
80
|
+
a.end_of_options.append(path)
|
|
81
|
+
in [:stdin_from_command, command]
|
|
82
|
+
a.flag(:stdin_from_command)
|
|
83
|
+
a.end_of_options.append(tokenize(command))
|
|
84
|
+
in [:stdin_from_command, command, String => filename]
|
|
85
|
+
a.flag(:stdin_from_command)
|
|
86
|
+
a.flag(:stdin_filename, filename)
|
|
87
|
+
a.end_of_options.append(tokenize(command))
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
execution = if block
|
|
92
|
+
run_restic(argv) { |raw| dispatch_backup_message(raw, &block) }
|
|
93
|
+
else
|
|
94
|
+
run_restic(argv)
|
|
95
|
+
end
|
|
96
|
+
BackupOutcome.new(execution[:exit_code], execution[:output])
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
private
|
|
100
|
+
|
|
101
|
+
def dispatch_backup_message(raw)
|
|
102
|
+
case raw["message_type"]
|
|
103
|
+
when "status"
|
|
104
|
+
yield Yobi::BackupStatus.new(raw)
|
|
105
|
+
when "error"
|
|
106
|
+
yield Yobi::BackupError.new(raw)
|
|
107
|
+
when "verbose_status"
|
|
108
|
+
yield Yobi::BackupVerboseStatus.new(raw)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def tokenize(command)
|
|
113
|
+
case command
|
|
114
|
+
in String => value
|
|
115
|
+
Shellwords.split(value)
|
|
116
|
+
in Array => values
|
|
117
|
+
values.map(&:to_s)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# The outcome of one {Yobi::Repository#backup} call.
|
|
123
|
+
class BackupOutcome < Struct.new(:exit_code, :output)
|
|
124
|
+
# Matches Restic's own log line format for stderr relayed from a
|
|
125
|
+
# `source: [:stdin_from_command, ...]` command's own subprocess.
|
|
126
|
+
COMMAND_OUTPUT_LINE_PATTERN = /\Asubprocess [^:]+: (.*)/
|
|
127
|
+
|
|
128
|
+
# @return [Boolean]
|
|
129
|
+
def success?
|
|
130
|
+
exit_code.zero?
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# @return [Boolean] true if the backup completed with some files skipped
|
|
134
|
+
def partial?
|
|
135
|
+
exit_code == 3
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# @return [Enumerable<Yobi::BackupError>]
|
|
139
|
+
def errors
|
|
140
|
+
@errors ||= BackupErrors.new(output)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# The `source: [:stdin_from_command, ...]` subprocess's own stderr, if any, de-prefixed.
|
|
144
|
+
#
|
|
145
|
+
# @return [Array<String>]
|
|
146
|
+
def command_output
|
|
147
|
+
@command_output ||= output.each_line.filter_map do |line|
|
|
148
|
+
match = COMMAND_OUTPUT_LINE_PATTERN.match(line)
|
|
149
|
+
match[1] if match
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# @return [Hash] Restic's own `"summary"` fields, with `"backup_start"`/`"backup_end"` parsed into `Time`
|
|
154
|
+
def report
|
|
155
|
+
@report ||= begin
|
|
156
|
+
hash = summary_hash
|
|
157
|
+
hash.merge(
|
|
158
|
+
"backup_start" => (Time.parse(hash["backup_start"]) if hash["backup_start"]),
|
|
159
|
+
"backup_end" => (Time.parse(hash["backup_end"]) if hash["backup_end"])
|
|
160
|
+
)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def summary_hash
|
|
167
|
+
line = output.last_line
|
|
168
|
+
hash = line && JSON.parse(line)
|
|
169
|
+
if hash && hash["message_type"] == "summary"
|
|
170
|
+
hash
|
|
171
|
+
else
|
|
172
|
+
find_summary || {}
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def find_summary
|
|
177
|
+
output.each_line do |line|
|
|
178
|
+
next if line.strip.empty?
|
|
179
|
+
|
|
180
|
+
hash = JSON.parse(line)
|
|
181
|
+
return hash if hash["message_type"] == "summary"
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
nil
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# One `"status"` message from a live backup run.
|
|
189
|
+
# https://restic.readthedocs.io/en/stable/075_scripting.html#status
|
|
190
|
+
class BackupStatus < SimpleDelegator
|
|
191
|
+
# @return [Float, nil]
|
|
192
|
+
def percent_done
|
|
193
|
+
self["percent_done"]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# @return [Integer, nil]
|
|
197
|
+
def total_files
|
|
198
|
+
self["total_files"]
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# @return [Integer, nil]
|
|
202
|
+
def files_done
|
|
203
|
+
self["files_done"]
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# @return [Integer, nil]
|
|
207
|
+
def total_bytes
|
|
208
|
+
self["total_bytes"]
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# @return [Integer, nil]
|
|
212
|
+
def bytes_done
|
|
213
|
+
self["bytes_done"]
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# @return [Array<String>]
|
|
217
|
+
def current_files
|
|
218
|
+
self["current_files"] || []
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @return [Integer]
|
|
222
|
+
def error_count
|
|
223
|
+
self["error_count"] || 0
|
|
224
|
+
end
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# One `"error"` message from a backup run.
|
|
228
|
+
# https://restic.readthedocs.io/en/stable/075_scripting.html#error
|
|
229
|
+
class BackupError < SimpleDelegator
|
|
230
|
+
# @return [String, nil]
|
|
231
|
+
def message
|
|
232
|
+
dig("error", "message")
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# @return [String, nil]
|
|
236
|
+
def during
|
|
237
|
+
self["during"]
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# @return [String, nil]
|
|
241
|
+
def item
|
|
242
|
+
self["item"]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Enumerable over every {Yobi::BackupError} in a run.
|
|
247
|
+
class BackupErrors
|
|
248
|
+
include Enumerable
|
|
249
|
+
|
|
250
|
+
# @param output [Yobi::ResticOutput]
|
|
251
|
+
def initialize(output)
|
|
252
|
+
@output = output
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# @yieldparam error [Yobi::BackupError]
|
|
256
|
+
# @return [Enumerator] if no block is given
|
|
257
|
+
def each
|
|
258
|
+
return enum_for(:each) unless block_given?
|
|
259
|
+
|
|
260
|
+
@output.index["error"].each do |offset|
|
|
261
|
+
line = @output.read_line_at(offset)
|
|
262
|
+
yield BackupError.new(JSON.parse(line))
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# One `"verbose_status"` message from a backup run, one per file. Only
|
|
268
|
+
# emitted when `verbose: true` is passed to {Yobi::Repository#backup}.
|
|
269
|
+
class BackupVerboseStatus < SimpleDelegator
|
|
270
|
+
# @return [String]
|
|
271
|
+
def action
|
|
272
|
+
self["action"]
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @return [String]
|
|
276
|
+
def item
|
|
277
|
+
self["item"]
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# @return [Integer]
|
|
281
|
+
def duration
|
|
282
|
+
self["duration"]
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# @return [Integer]
|
|
286
|
+
def data_size
|
|
287
|
+
self["data_size"]
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
# @return [Integer]
|
|
291
|
+
def data_size_in_repo
|
|
292
|
+
self["data_size_in_repo"]
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# @return [Integer]
|
|
296
|
+
def metadata_size
|
|
297
|
+
self["metadata_size"]
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
# @return [Integer]
|
|
301
|
+
def metadata_size_in_repo
|
|
302
|
+
self["metadata_size_in_repo"]
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# @return [Integer]
|
|
306
|
+
def total_files
|
|
307
|
+
self["total_files"]
|
|
308
|
+
end
|
|
309
|
+
end
|
|
310
|
+
end
|