yobi 0.3.1 → 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)
@@ -2,21 +2,23 @@
2
2
 
3
3
  module Yobi
4
4
  class Repository
5
- # @private
6
- 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:
7
6
  hash[value] = value
8
7
  hash[value.tr("-", "_").to_sym] = value
9
8
  end.freeze
10
9
 
11
- # `restic stats`: accumulates statistics about the repository's data.
10
+ # +restic stats+: accumulates statistics about the repository's data.
12
11
  #
13
- # @param snapshot_ids [Array<String>, String] restrict to these snapshot ID(s); the whole repository if empty
14
- # @param hosts [Array<String>, String] filter by hostname(s)
15
- # @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`
16
- # @param paths [Array<String>, String] filter by originally backed-up path(s)
17
- # @param tags [Array<String>, String] filter by tag(s)
18
- # @return [Yobi::RepositoryStats]
19
- # @raise [ArgumentError] if `mode:` isn't one of the values listed above
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.
20
+ #
21
+ # Returns a Yobi::RepositoryStats.
20
22
  def stats(snapshot_ids: [], hosts: [], mode: nil, paths: [], tags: [])
21
23
  argv = build_argv("stats", snapshot_ids) do |a|
22
24
  a.repeat_flag(:host, hosts)
@@ -27,49 +29,52 @@ module Yobi
27
29
  a.repeat_flag(:tag, tags)
28
30
  end
29
31
  execution = run_restic(argv)
30
- RepositoryStats.new(parse_json_output(execution))
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))
31
35
  end
32
36
  end
33
37
 
34
- # The result of one {Yobi::Repository#stats} call.
35
- # 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.
36
40
  class RepositoryStats < Yobi::FancyHash
37
- # @return [Integer] repository size in bytes
41
+ # Repository size in bytes.
38
42
  def total_size
39
43
  self["total_size"] || 0
40
44
  end
41
45
 
42
- # @return [Integer] number of files backed up in the repository
46
+ # Number of files backed up in the repository.
43
47
  def total_file_count
44
48
  self["total_file_count"] || 0
45
49
  end
46
50
 
47
- # @return [Integer] number of blobs in the repository
51
+ # Number of blobs in the repository.
48
52
  def total_blob_count
49
53
  self["total_blob_count"] || 0
50
54
  end
51
55
 
52
- # @return [Integer] number of processed snapshots
56
+ # Number of processed snapshots.
53
57
  def snapshots_count
54
58
  self["snapshots_count"] || 0
55
59
  end
56
60
 
57
- # @return [Integer] repository size in bytes if blobs were uncompressed
61
+ # Repository size in bytes if blobs were uncompressed.
58
62
  def total_uncompressed_size
59
63
  self["total_uncompressed_size"] || 0
60
64
  end
61
65
 
62
- # @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.
63
68
  def compression_ratio
64
69
  self["compression_ratio"] || 0.0
65
70
  end
66
71
 
67
- # @return [Float] percentage of already compressed data
72
+ # Percentage of already compressed data.
68
73
  def compression_progress
69
74
  self["compression_progress"] || 0.0
70
75
  end
71
76
 
72
- # @return [Float] overall space saving due to compression
77
+ # Overall space saving due to compression.
73
78
  def compression_space_saving
74
79
  self["compression_space_saving"] || 0.0
75
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
@@ -7,24 +7,48 @@ module Yobi
7
7
  # One Restic repository. Every Restic subcommand that operates on a
8
8
  # repository is a method here.
9
9
  class Repository
10
- # @return [String] the repository location
10
+ # The repository location.
11
11
  attr_reader :url
12
- # @return [String, Array, Symbol, #call] the repository's encryption password, as given to {#initialize}
12
+ # The repository's encryption password, as given to #initialize.
13
13
  attr_reader :password
14
- # @return [Hash, #call] the storage backend's own credentials, as given to {#initialize}
14
+ # The storage backend's own credentials, as given to #initialize.
15
15
  attr_reader :backend_credentials
16
16
 
17
- # @param url [String] the repository location, e.g. `"s3:s3.amazonaws.com/bucket"`
18
- # @param password [String, Array, Symbol, #call] a literal password; a
19
- # `[:command, "..."]`/`[:file, "..."]` tuple, resolved natively by Restic
20
- # itself; `:insecure_no_password`; or anything responding to `#call`
21
- # (invoked fresh immediately before every Restic invocation)
22
- # @param backend_credentials [Hash{String => String}, #call] the storage
23
- # backend's own env vars (`AWS_*`/`AZURE_*`/etc.), or anything
24
- # responding to `#call` returning such a Hash
25
- # @param restic [Yobi::Restic, String, nil] a `Restic` instance to share,
26
- # a bare Restic binary path, or `nil` to create a default one
27
- # @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.
28
52
  def initialize(url:, password:, backend_credentials: {}, restic: nil)
29
53
  validate_password_shape!(password)
30
54
  validate_backend_credentials_shape!(backend_credentials)
@@ -35,14 +59,16 @@ module Yobi
35
59
  @restic = initialize_restic(restic)
36
60
  end
37
61
 
38
- # @return [Hash{String => String}]
62
+ # The full env Hash Restic will see for this repository: +RESTIC_REPOSITORY+
63
+ # plus resolved password and backend credentials.
39
64
  def env
40
65
  {"RESTIC_REPOSITORY" => url}
41
66
  .merge(resolved_password)
42
67
  .merge(resolved_backend_credentials)
43
68
  end
44
69
 
45
- # @return [String]
70
+ # Redacts sensitive values so a stray +pp+/+puts+/log call never prints a
71
+ # credential in plaintext.
46
72
  def inspect
47
73
  "#<#{self.class} url=#{url.inspect} password=#{redacted_password.inspect} backend_credentials=#{redacted_backend_credentials.inspect}>"
48
74
  end
@@ -63,14 +89,10 @@ module Yobi
63
89
  builder.to_a
64
90
  end
65
91
 
66
- # Parses a command's captured output as JSON. If parsing fails, raises
67
- # {Yobi::ResticCommandFailed} when `execution[:exit_code]` is `3`
68
- # (Restic reported a problem alongside otherwise-JSON output), or
69
- # re-raises the original +JSON::ParserError+ when it's `0`.
70
- #
71
- # @param execution [Hash] `{exit_code:, output:, argv:}`
72
- # @return [Object] the parsed JSON
73
- # @raise [Yobi::ResticCommandFailed] if parsing fails and `exit_code` is `3`
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.
74
96
  def parse_json_output(execution)
75
97
  JSON.parse(execution[:output].to_s)
76
98
  rescue JSON::ParserError
@@ -78,6 +100,37 @@ module Yobi
78
100
  raise Yobi::ResticCommandFailed, execution
79
101
  end
80
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
+
81
134
  def resolved_password
82
135
  password_env(password)
83
136
  end