yobi 0.3.0 → 1.0.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/.ruby-version +1 -0
- data/CHANGELOG.md +17 -0
- data/README.md +42 -45
- data/lib/yobi/argv_builder.rb +1 -35
- data/lib/yobi/errors.rb +18 -30
- data/lib/yobi/fancy_hash.rb +1 -5
- data/lib/yobi/io_handle.rb +9 -17
- data/lib/yobi/mount_handle.rb +7 -11
- data/lib/yobi/repository/backup.rb +59 -90
- data/lib/yobi/repository/cat.rb +29 -51
- data/lib/yobi/repository/check.rb +21 -34
- data/lib/yobi/repository/copy.rb +11 -13
- data/lib/yobi/repository/diff.rb +18 -49
- data/lib/yobi/repository/dump.rb +14 -15
- data/lib/yobi/repository/find.rb +18 -39
- data/lib/yobi/repository/forget.rb +35 -41
- data/lib/yobi/repository/init.rb +20 -27
- data/lib/yobi/repository/key.rb +19 -34
- data/lib/yobi/repository/list.rb +5 -6
- data/lib/yobi/repository/ls.rb +20 -43
- data/lib/yobi/repository/migrate.rb +6 -6
- data/lib/yobi/repository/mount.rb +22 -32
- data/lib/yobi/repository/prune.rb +11 -9
- data/lib/yobi/repository/recover.rb +2 -4
- data/lib/yobi/repository/repair.rb +16 -23
- data/lib/yobi/repository/restore.rb +31 -54
- data/lib/yobi/repository/rewrite.rb +15 -20
- data/lib/yobi/repository/snapshots.rb +8 -9
- data/lib/yobi/repository/stats.rb +26 -23
- data/lib/yobi/repository/tag.rb +23 -33
- data/lib/yobi/repository/unlock.rb +2 -4
- data/lib/yobi/repository.rb +85 -16
- data/lib/yobi/restic.rb +73 -110
- data/lib/yobi/restic_output.rb +2 -74
- data/lib/yobi/snapshot.rb +15 -27
- data/lib/yobi/version.rb +1 -2
- data/sig/yobi.rbs +4 -4
- metadata +3 -2
|
@@ -2,29 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Yobi
|
|
4
4
|
class Repository
|
|
5
|
-
#
|
|
5
|
+
# +restic rewrite+: creates new snapshots from existing ones with
|
|
6
6
|
# exclude/include filters applied, or metadata changed. With
|
|
7
|
-
#
|
|
7
|
+
# +snapshot_ids:+ and the other filters all left at their defaults,
|
|
8
8
|
# rewrites every snapshot in the repository.
|
|
9
9
|
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
# @param forget [Boolean] remove the original snapshots afterward, instead of tagging the new ones `"rewrite"` and keeping both
|
|
24
|
-
# @param new_host [String, nil] change the recorded hostname
|
|
25
|
-
# @param new_time [String, nil] change the recorded timestamp
|
|
26
|
-
# @param snapshot_summary [Boolean] regenerate the snapshot summary
|
|
27
|
-
# @return [true]
|
|
10
|
+
# +snapshot_ids:+ names the snapshots to rewrite, or all of them if
|
|
11
|
+
# empty. +hosts:+, +tags:+, and +paths:+ each accept a single value or
|
|
12
|
+
# an Array to filter by.
|
|
13
|
+
#
|
|
14
|
+
# +excludes:+, +exclude_files:+, +includes:+, +include_files:+ and
|
|
15
|
+
# their +i+-prefixed (case-insensitive) variants each accept a single
|
|
16
|
+
# value or an Array.
|
|
17
|
+
#
|
|
18
|
+
# +dry_run:+ reports what would happen without doing it. +forget:+
|
|
19
|
+
# removes the original snapshots afterward, instead of tagging the new
|
|
20
|
+
# ones +"rewrite"+ and keeping both. +new_host:+ / +new_time:+ change
|
|
21
|
+
# the recorded hostname/timestamp. +snapshot_summary:+ regenerates the
|
|
22
|
+
# snapshot summary.
|
|
28
23
|
def rewrite(snapshot_ids: [], hosts: [], tags: [], paths: [], excludes: [], exclude_files: [],
|
|
29
24
|
iexcludes: [], iexclude_files: [], includes: [], include_files: [], iincludes: [], iinclude_files: [],
|
|
30
25
|
dry_run: false, forget: false, new_host: nil, new_time: nil, snapshot_summary: false)
|
|
@@ -4,15 +4,14 @@ require "json"
|
|
|
4
4
|
|
|
5
5
|
module Yobi
|
|
6
6
|
class Repository
|
|
7
|
-
#
|
|
7
|
+
# +restic snapshots+: lists snapshots, optionally filtered.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# @return [Array<Yobi::Snapshot>]
|
|
9
|
+
# +tags:+, +hosts:+, and +paths:+ each accept a single value or an
|
|
10
|
+
# Array to filter by. +compact:+ compacts the printed listing.
|
|
11
|
+
# +group_by:+ (e.g. +"host"+) groups results. +latest:+ limits to the
|
|
12
|
+
# N most recent per group.
|
|
13
|
+
#
|
|
14
|
+
# Returns an Array of Yobi::Snapshot.
|
|
16
15
|
def snapshots(tags: [], hosts: [], paths: [], compact: false, group_by: nil, latest: nil)
|
|
17
16
|
argv = build_argv("snapshots") do |a|
|
|
18
17
|
a.repeat_flag(:tag, tags)
|
|
@@ -23,7 +22,7 @@ module Yobi
|
|
|
23
22
|
a.flag(:latest, latest) unless latest.nil?
|
|
24
23
|
end
|
|
25
24
|
execution = run_restic(argv)
|
|
26
|
-
|
|
25
|
+
parse_json_output(execution).map { |raw| Snapshot.new(raw) }
|
|
27
26
|
end
|
|
28
27
|
end
|
|
29
28
|
end
|
|
@@ -1,24 +1,24 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
3
|
module Yobi
|
|
6
4
|
class Repository
|
|
7
|
-
#
|
|
8
|
-
STATS_MODES = %w[restore-size files-by-contents blobs-per-file raw-data].each_with_object({}) do |value, hash|
|
|
5
|
+
STATS_MODES = %w[restore-size files-by-contents blobs-per-file raw-data].each_with_object({}) do |value, hash| # :nodoc:
|
|
9
6
|
hash[value] = value
|
|
10
7
|
hash[value.tr("-", "_").to_sym] = value
|
|
11
8
|
end.freeze
|
|
12
9
|
|
|
13
|
-
#
|
|
10
|
+
# +restic stats+: accumulates statistics about the repository's data.
|
|
11
|
+
#
|
|
12
|
+
# +snapshot_ids:+ restricts to specific snapshot IDs, or the whole
|
|
13
|
+
# repository if empty. +hosts:+, +paths:+, and +tags:+ each accept a
|
|
14
|
+
# single value or an Array to filter by.
|
|
15
|
+
#
|
|
16
|
+
# +mode:+ is +"restore-size"+ / +:restore_size+ (default),
|
|
17
|
+
# +"files-by-contents"+ / +:files_by_contents+, +"blobs-per-file"+ /
|
|
18
|
+
# +:blobs_per_file+, or +"raw-data"+ / +:raw_data+. Anything else
|
|
19
|
+
# raises ArgumentError.
|
|
14
20
|
#
|
|
15
|
-
#
|
|
16
|
-
# @param hosts [Array<String>, String] filter by hostname(s)
|
|
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`
|
|
18
|
-
# @param paths [Array<String>, String] filter by originally backed-up path(s)
|
|
19
|
-
# @param tags [Array<String>, String] filter by tag(s)
|
|
20
|
-
# @return [Yobi::RepositoryStats]
|
|
21
|
-
# @raise [ArgumentError] if `mode:` isn't one of the values listed above
|
|
21
|
+
# Returns a Yobi::RepositoryStats.
|
|
22
22
|
def stats(snapshot_ids: [], hosts: [], mode: nil, paths: [], tags: [])
|
|
23
23
|
argv = build_argv("stats", snapshot_ids) do |a|
|
|
24
24
|
a.repeat_flag(:host, hosts)
|
|
@@ -29,49 +29,52 @@ module Yobi
|
|
|
29
29
|
a.repeat_flag(:tag, tags)
|
|
30
30
|
end
|
|
31
31
|
execution = run_restic(argv)
|
|
32
|
-
|
|
32
|
+
# Permissive because Restic 0.19.0 prints a "[0:00] 100.00% ..."
|
|
33
|
+
# progress line before the JSON summary. Fixed in 0.19.1.
|
|
34
|
+
RepositoryStats.new(parse_json_output_permissively(execution))
|
|
33
35
|
end
|
|
34
36
|
end
|
|
35
37
|
|
|
36
|
-
# The result of one
|
|
37
|
-
# https://restic.readthedocs.io/en/stable/075_scripting.html#stats
|
|
38
|
+
# The result of one Yobi::Repository#stats call. See
|
|
39
|
+
# https://restic.readthedocs.io/en/stable/075_scripting.html#stats.
|
|
38
40
|
class RepositoryStats < Yobi::FancyHash
|
|
39
|
-
#
|
|
41
|
+
# Repository size in bytes.
|
|
40
42
|
def total_size
|
|
41
43
|
self["total_size"] || 0
|
|
42
44
|
end
|
|
43
45
|
|
|
44
|
-
#
|
|
46
|
+
# Number of files backed up in the repository.
|
|
45
47
|
def total_file_count
|
|
46
48
|
self["total_file_count"] || 0
|
|
47
49
|
end
|
|
48
50
|
|
|
49
|
-
#
|
|
51
|
+
# Number of blobs in the repository.
|
|
50
52
|
def total_blob_count
|
|
51
53
|
self["total_blob_count"] || 0
|
|
52
54
|
end
|
|
53
55
|
|
|
54
|
-
#
|
|
56
|
+
# Number of processed snapshots.
|
|
55
57
|
def snapshots_count
|
|
56
58
|
self["snapshots_count"] || 0
|
|
57
59
|
end
|
|
58
60
|
|
|
59
|
-
#
|
|
61
|
+
# Repository size in bytes if blobs were uncompressed.
|
|
60
62
|
def total_uncompressed_size
|
|
61
63
|
self["total_uncompressed_size"] || 0
|
|
62
64
|
end
|
|
63
65
|
|
|
64
|
-
#
|
|
66
|
+
# Factor by which the already compressed data has shrunk due to
|
|
67
|
+
# compression.
|
|
65
68
|
def compression_ratio
|
|
66
69
|
self["compression_ratio"] || 0.0
|
|
67
70
|
end
|
|
68
71
|
|
|
69
|
-
#
|
|
72
|
+
# Percentage of already compressed data.
|
|
70
73
|
def compression_progress
|
|
71
74
|
self["compression_progress"] || 0.0
|
|
72
75
|
end
|
|
73
76
|
|
|
74
|
-
#
|
|
77
|
+
# Overall space saving due to compression.
|
|
75
78
|
def compression_space_saving
|
|
76
79
|
self["compression_space_saving"] || 0.0
|
|
77
80
|
end
|
data/lib/yobi/repository/tag.rb
CHANGED
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
module Yobi
|
|
4
4
|
class Repository
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# +restic tag+: modifies tags on existing snapshots. Tags are part of a
|
|
6
|
+
# snapshot's content-addressed identity, so changing them produces a
|
|
7
7
|
# new snapshot ID for each affected snapshot.
|
|
8
8
|
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
9
|
+
# +snapshot_ids:+ names the snapshots to modify, or all matching
|
|
10
|
+
# +hosts:+/+paths:+ if empty.
|
|
11
|
+
#
|
|
12
|
+
# +add:+ appends tags, keeping existing ones. +remove:+ strips tags.
|
|
13
|
+
# +set:+ replaces all tags with exactly the given set (exclusive with
|
|
14
|
+
# +add:+/+remove:+).
|
|
15
|
+
#
|
|
16
|
+
# +tags:+, +hosts:+, and +paths:+ each accept a single value or an
|
|
17
|
+
# Array to filter which snapshots to modify (by current tags,
|
|
18
|
+
# hostname, or originally backed-up path respectively).
|
|
19
|
+
#
|
|
20
|
+
# Returns a Yobi::TagOutcome.
|
|
17
21
|
def tag(snapshot_ids: [], add: [], remove: [], set: [], tags: [], hosts: [], paths: [])
|
|
18
22
|
argv = build_argv("tag", snapshot_ids) do |a|
|
|
19
23
|
a.repeat_flag(:add, add)
|
|
@@ -29,16 +33,7 @@ module Yobi
|
|
|
29
33
|
end
|
|
30
34
|
end
|
|
31
35
|
|
|
32
|
-
|
|
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
|
|
36
|
+
module TagMessageWrapper # :nodoc:
|
|
42
37
|
def self.call(raw)
|
|
43
38
|
case raw["message_type"]
|
|
44
39
|
when "changed" then TagChange.new(raw)
|
|
@@ -48,28 +43,26 @@ module Yobi
|
|
|
48
43
|
end
|
|
49
44
|
end
|
|
50
45
|
|
|
51
|
-
# The outcome of one
|
|
46
|
+
# The outcome of one Yobi::Repository#tag call.
|
|
52
47
|
class TagOutcome
|
|
53
|
-
#
|
|
48
|
+
# The Yobi::ResticOutput backing this outcome.
|
|
54
49
|
attr_reader :output
|
|
55
50
|
|
|
56
|
-
#
|
|
57
|
-
def initialize(execution)
|
|
51
|
+
def initialize(execution) # :nodoc:
|
|
58
52
|
@output = execution[:output]
|
|
59
53
|
end
|
|
60
54
|
|
|
61
|
-
#
|
|
55
|
+
# The Yobi::TagSummary of Restic's own +"summary"+ fields.
|
|
62
56
|
def summary
|
|
63
57
|
@summary ||= output.messages("summary").first || TagSummary.new({})
|
|
64
58
|
end
|
|
65
59
|
alias_method :report, :summary
|
|
66
60
|
|
|
67
|
-
#
|
|
61
|
+
# One Yobi::TagChange per snapshot actually modified.
|
|
68
62
|
def changes
|
|
69
63
|
@changes ||= output.messages("changed")
|
|
70
64
|
end
|
|
71
65
|
|
|
72
|
-
# @return [void]
|
|
73
66
|
def pretty_print(q)
|
|
74
67
|
q.object_group(self) do
|
|
75
68
|
q.breakable
|
|
@@ -78,25 +71,22 @@ module Yobi
|
|
|
78
71
|
end
|
|
79
72
|
end
|
|
80
73
|
|
|
81
|
-
# One
|
|
74
|
+
# One +"changed"+ message from a tag run. #old_snapshot_id is now stale:
|
|
82
75
|
# tags are part of a snapshot's content-addressed identity, so changing
|
|
83
76
|
# them produces a new snapshot ID.
|
|
84
77
|
class TagChange < Yobi::FancyHash
|
|
85
|
-
# @return [String]
|
|
86
78
|
def old_snapshot_id
|
|
87
79
|
self["old_snapshot_id"]
|
|
88
80
|
end
|
|
89
81
|
|
|
90
|
-
# @return [String]
|
|
91
82
|
def new_snapshot_id
|
|
92
83
|
self["new_snapshot_id"]
|
|
93
84
|
end
|
|
94
85
|
end
|
|
95
86
|
|
|
96
|
-
# The
|
|
97
|
-
# command finishes. Dispatched to
|
|
87
|
+
# The +"summary"+ message from a tag run, the final result once the
|
|
88
|
+
# command finishes. Dispatched to Yobi::TagOutcome#summary.
|
|
98
89
|
class TagSummary < Yobi::FancyHash
|
|
99
|
-
# @return [Integer]
|
|
100
90
|
def changed_snapshots
|
|
101
91
|
self["changed_snapshots"] || 0
|
|
102
92
|
end
|
|
@@ -2,10 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module Yobi
|
|
4
4
|
class Repository
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# @param remove_all [Boolean] remove every lock, not just stale ones
|
|
8
|
-
# @return [true]
|
|
5
|
+
# +restic unlock+: removes stale locks left by other Restic processes.
|
|
6
|
+
# +remove_all: true+ removes every lock, not just stale ones.
|
|
9
7
|
def unlock(remove_all: false)
|
|
10
8
|
argv = build_argv("unlock") do |a|
|
|
11
9
|
a.flag(:remove_all) if remove_all
|
data/lib/yobi/repository.rb
CHANGED
|
@@ -1,29 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "uri"
|
|
4
5
|
|
|
5
6
|
module Yobi
|
|
6
7
|
# One Restic repository. Every Restic subcommand that operates on a
|
|
7
8
|
# repository is a method here.
|
|
8
9
|
class Repository
|
|
9
|
-
#
|
|
10
|
+
# The repository location.
|
|
10
11
|
attr_reader :url
|
|
11
|
-
#
|
|
12
|
+
# The repository's encryption password, as given to #initialize.
|
|
12
13
|
attr_reader :password
|
|
13
|
-
#
|
|
14
|
+
# The storage backend's own credentials, as given to #initialize.
|
|
14
15
|
attr_reader :backend_credentials
|
|
15
16
|
|
|
16
|
-
#
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
# Sets #url and re-extracts any REST credentials embedded in it.
|
|
18
|
+
def url=(value)
|
|
19
|
+
@url, @extracted_rest_credentials = extract_rest_credentials(value)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Sets #password. Validated on assignment with the same shapes #initialize accepts.
|
|
23
|
+
def password=(value)
|
|
24
|
+
validate_password_shape!(value)
|
|
25
|
+
@password = value
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Sets #backend_credentials. Validated on assignment with the same shapes #initialize accepts.
|
|
29
|
+
def backend_credentials=(value)
|
|
30
|
+
validate_backend_credentials_shape!(value)
|
|
31
|
+
@backend_credentials = value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Builds a repository handle. Does not touch the remote or spawn Restic.
|
|
35
|
+
#
|
|
36
|
+
# +url+ is a String repository location, e.g. <tt>"s3:s3.amazonaws.com/bucket"</tt>.
|
|
37
|
+
#
|
|
38
|
+
# +password+ is the repository's encryption password. Accepts one of:
|
|
39
|
+
# a literal String; a <tt>[:command, "..."]</tt> or <tt>[:file, "..."]</tt>
|
|
40
|
+
# tuple, resolved natively by Restic itself; <tt>:insecure_no_password</tt>;
|
|
41
|
+
# or any object responding to +#call+ (invoked fresh immediately before every
|
|
42
|
+
# Restic invocation).
|
|
43
|
+
#
|
|
44
|
+
# +backend_credentials+ is the storage backend's own env vars
|
|
45
|
+
# (+AWS_*+/+AZURE_*+/etc.) as a Hash, or a callable returning such a Hash.
|
|
46
|
+
#
|
|
47
|
+
# +restic+ is a Yobi::Restic instance to share, a bare Restic binary path,
|
|
48
|
+
# or +nil+ to create a default one.
|
|
49
|
+
#
|
|
50
|
+
# Raises ArgumentError when +password:+ is +nil+, or when +password:+,
|
|
51
|
+
# +backend_credentials:+, or +restic:+ has an invalid shape.
|
|
27
52
|
def initialize(url:, password:, backend_credentials: {}, restic: nil)
|
|
28
53
|
validate_password_shape!(password)
|
|
29
54
|
validate_backend_credentials_shape!(backend_credentials)
|
|
@@ -34,14 +59,16 @@ module Yobi
|
|
|
34
59
|
@restic = initialize_restic(restic)
|
|
35
60
|
end
|
|
36
61
|
|
|
37
|
-
#
|
|
62
|
+
# The full env Hash Restic will see for this repository: +RESTIC_REPOSITORY+
|
|
63
|
+
# plus resolved password and backend credentials.
|
|
38
64
|
def env
|
|
39
65
|
{"RESTIC_REPOSITORY" => url}
|
|
40
66
|
.merge(resolved_password)
|
|
41
67
|
.merge(resolved_backend_credentials)
|
|
42
68
|
end
|
|
43
69
|
|
|
44
|
-
#
|
|
70
|
+
# Redacts sensitive values so a stray +pp+/+puts+/log call never prints a
|
|
71
|
+
# credential in plaintext.
|
|
45
72
|
def inspect
|
|
46
73
|
"#<#{self.class} url=#{url.inspect} password=#{redacted_password.inspect} backend_credentials=#{redacted_backend_credentials.inspect}>"
|
|
47
74
|
end
|
|
@@ -62,6 +89,48 @@ module Yobi
|
|
|
62
89
|
builder.to_a
|
|
63
90
|
end
|
|
64
91
|
|
|
92
|
+
# Restic exit code 3 means "succeeded with warnings" and may mix a
|
|
93
|
+
# plain-text diagnostic into otherwise-JSON output. Surface that as a
|
|
94
|
+
# ResticCommandFailed instead of a bare JSON::ParserError. Exit code 0
|
|
95
|
+
# with unparseable output is a real bug; re-raise the parse error.
|
|
96
|
+
def parse_json_output(execution)
|
|
97
|
+
JSON.parse(execution[:output].to_s)
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
raise if execution[:exit_code].zero?
|
|
100
|
+
raise Yobi::ResticCommandFailed, execution
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Same as parse_json_output but tolerant of extra lines around the
|
|
104
|
+
# JSON, from either direction. Known cases at time of writing:
|
|
105
|
+
#
|
|
106
|
+
# - Restic 0.18.0 and 0.18.1 tack prune chatter ("loading indexes...",
|
|
107
|
+
# counts, etc.) onto the end of `restic forget --prune`'s output,
|
|
108
|
+
# after the summary line. Fixed in 0.19.0.
|
|
109
|
+
# - Restic 0.19.0 prints a "[0:00] 100.00% ..." progress line before
|
|
110
|
+
# `restic stats`'s JSON summary. Fixed in 0.19.1. Note that the
|
|
111
|
+
# progress line itself starts with "[", so checking the first char
|
|
112
|
+
# alone isn't enough - each candidate line has to actually parse.
|
|
113
|
+
# - `restic forget` on any version prints a
|
|
114
|
+
# "Remove(snapshot/<id>) failed: <error>" line before its JSON summary
|
|
115
|
+
# when a snapshot listed for removal couldn't actually be deleted
|
|
116
|
+
# (e.g. an append-only backend returns 403). The summary itself is
|
|
117
|
+
# still valid JSON, so the caller can see what did/didn't get removed.
|
|
118
|
+
#
|
|
119
|
+
# The first line that both looks like JSON ("[" or "{") and actually
|
|
120
|
+
# parses wins. If none does, falls back to parsing the whole output
|
|
121
|
+
# verbatim so an empty/malformed response still raises the right thing.
|
|
122
|
+
def parse_json_output_permissively(execution)
|
|
123
|
+
execution[:output].to_s.each_line do |line|
|
|
124
|
+
next unless line.start_with?("[", "{")
|
|
125
|
+
return JSON.parse(line)
|
|
126
|
+
rescue JSON::ParserError
|
|
127
|
+
next
|
|
128
|
+
end
|
|
129
|
+
# No parseable JSON line found. Fall back to strict parsing on the
|
|
130
|
+
# whole output so exit-code-based error dispatch takes over.
|
|
131
|
+
parse_json_output(execution)
|
|
132
|
+
end
|
|
133
|
+
|
|
65
134
|
def resolved_password
|
|
66
135
|
password_env(password)
|
|
67
136
|
end
|