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.
@@ -2,29 +2,24 @@
2
2
 
3
3
  module Yobi
4
4
  class Repository
5
- # `restic rewrite`: creates new snapshots from existing ones with
5
+ # +restic rewrite+: creates new snapshots from existing ones with
6
6
  # exclude/include filters applied, or metadata changed. With
7
- # `snapshot_ids:` and the other filters all left at their defaults,
7
+ # +snapshot_ids:+ and the other filters all left at their defaults,
8
8
  # rewrites every snapshot in the repository.
9
9
  #
10
- # @param snapshot_ids [Array<String>, String] snapshots to rewrite; all of them if empty
11
- # @param hosts [Array<String>, String] filter by hostname(s)
12
- # @param tags [Array<String>, String] filter by tag(s)
13
- # @param paths [Array<String>, String] filter by originally backed-up path(s)
14
- # @param excludes [Array<String>, String] exclude files matching these glob patterns
15
- # @param exclude_files [Array<String>, String] path(s) to file(s) listing exclude patterns
16
- # @param iexcludes [Array<String>, String] like `excludes`, case-insensitive
17
- # @param iexclude_files [Array<String>, String] like `exclude_files`, case-insensitive
18
- # @param includes [Array<String>, String] only include files matching these glob patterns
19
- # @param include_files [Array<String>, String] path(s) to file(s) listing include patterns
20
- # @param iincludes [Array<String>, String] like `includes`, case-insensitive
21
- # @param iinclude_files [Array<String>, String] like `include_files`, case-insensitive
22
- # @param dry_run [Boolean] report what would happen without doing it
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
- # `restic snapshots`: lists snapshots, optionally filtered.
7
+ # +restic snapshots+: lists snapshots, optionally filtered.
8
8
  #
9
- # @param tags [Array<String>, String] filter by tag(s)
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 compact [Boolean] compact the printed listing
13
- # @param group_by [String, nil] group results, e.g. `"host"`
14
- # @param latest [Integer, nil] limit to the N most recent per group
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
- JSON.parse(execution[:output].to_s).map { |raw| Snapshot.new(raw) }
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
- # @private
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
- # `restic stats`: accumulates statistics about the repository's data.
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
- # @param snapshot_ids [Array<String>, String] restrict to these snapshot ID(s); the whole repository if empty
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
- RepositoryStats.new(JSON.parse(execution[:output].to_s))
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 {Yobi::Repository#stats} call.
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
- # @return [Integer] repository size in bytes
41
+ # Repository size in bytes.
40
42
  def total_size
41
43
  self["total_size"] || 0
42
44
  end
43
45
 
44
- # @return [Integer] number of files backed up in the repository
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
- # @return [Integer] number of blobs in the repository
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
- # @return [Integer] number of processed snapshots
56
+ # Number of processed snapshots.
55
57
  def snapshots_count
56
58
  self["snapshots_count"] || 0
57
59
  end
58
60
 
59
- # @return [Integer] repository size in bytes if blobs were uncompressed
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
- # @return [Float] factor by which the already compressed data has shrunk due to compression
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
- # @return [Float] percentage of already compressed data
72
+ # Percentage of already compressed data.
70
73
  def compression_progress
71
74
  self["compression_progress"] || 0.0
72
75
  end
73
76
 
74
- # @return [Float] overall space saving due to compression
77
+ # Overall space saving due to compression.
75
78
  def compression_space_saving
76
79
  self["compression_space_saving"] || 0.0
77
80
  end
@@ -2,18 +2,22 @@
2
2
 
3
3
  module Yobi
4
4
  class Repository
5
- # `restic tag`: modifies tags on existing snapshots. Tags are part of
6
- # a snapshot's content-addressed identity, so changing them produces a
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
- # @param snapshot_ids [Array<String>, String] snapshots to modify; all matching `hosts:`/`paths:` if empty
10
- # @param add [Array<String>, String] tags to add, keeping existing ones
11
- # @param remove [Array<String>, String] tags to remove
12
- # @param set [Array<String>, String] replace all tags with exactly this set (exclusive with `add:`/`remove:`)
13
- # @param tags [Array<String>, String] filter which snapshots to modify, by their current tags
14
- # @param hosts [Array<String>, String] filter which snapshots to modify, by hostname
15
- # @param paths [Array<String>, String] filter which snapshots to modify, by originally backed-up path
16
- # @return [Yobi::TagOutcome]
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
- # 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
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 {Yobi::Repository#tag} call.
46
+ # The outcome of one Yobi::Repository#tag call.
52
47
  class TagOutcome
53
- # @return [Yobi::ResticOutput]
48
+ # The Yobi::ResticOutput backing this outcome.
54
49
  attr_reader :output
55
50
 
56
- # @private
57
- def initialize(execution)
51
+ def initialize(execution) # :nodoc:
58
52
  @output = execution[:output]
59
53
  end
60
54
 
61
- # @return [Yobi::TagSummary] Restic's own `"summary"` fields (`"changed_snapshots"`)
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
- # @return [Enumerable<Yobi::TagChange>] one per snapshot actually modified
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 `"changed"` message from a tag run. `old_snapshot_id` is now stale:
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 `"summary"` message from a tag run, the final result once the
97
- # command finishes. Dispatched to {Yobi::TagOutcome#summary}.
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
- # `restic unlock`: removes stale locks left by other Restic processes.
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
@@ -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
- # @return [String] the repository location
10
+ # The repository location.
10
11
  attr_reader :url
11
- # @return [String, Array, Symbol, #call] the repository's encryption password, as given to {#initialize}
12
+ # The repository's encryption password, as given to #initialize.
12
13
  attr_reader :password
13
- # @return [Hash, #call] the storage backend's own credentials, as given to {#initialize}
14
+ # The storage backend's own credentials, as given to #initialize.
14
15
  attr_reader :backend_credentials
15
16
 
16
- # @param url [String] the repository location, e.g. `"s3:s3.amazonaws.com/bucket"`
17
- # @param password [String, Array, Symbol, #call] a literal password; a
18
- # `[:command, "..."]`/`[:file, "..."]` tuple, resolved natively by Restic
19
- # itself; `:insecure_no_password`; or anything responding to `#call`
20
- # (invoked fresh immediately before every Restic invocation)
21
- # @param backend_credentials [Hash{String => String}, #call] the storage
22
- # backend's own env vars (`AWS_*`/`AZURE_*`/etc.), or anything
23
- # responding to `#call` returning such a Hash
24
- # @param restic [Yobi::Restic, String, nil] a `Restic` instance to share,
25
- # a bare Restic binary path, or `nil` to create a default one
26
- # @raise [ArgumentError] if `password:` is `nil`, or if `password:`/`backend_credentials:`/`restic:` is an invalid shape
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
- # @return [Hash{String => String}]
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
- # @return [String]
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