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.
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "delegate"
6
+
7
+ module Yobi
8
+ class Repository
9
+ # `restic key add`: creates a new key (password) for this repository.
10
+ #
11
+ # @param host [String, nil] hostname to record on the new key
12
+ # @param user [String, nil] username to record on the new key
13
+ # @param new_insecure_no_password [Boolean] create the new key with an empty password
14
+ # @param new_password_file [String, nil] path to a file containing the new key's password
15
+ # @return [true]
16
+ def key_add(host: nil, user: nil, new_insecure_no_password: false, new_password_file: nil)
17
+ argv = build_argv("key", "add") do |a|
18
+ a.flag(:host, host) unless host.nil?
19
+ a.flag(:user, user) unless user.nil?
20
+ a.flag(:new_insecure_no_password) if new_insecure_no_password
21
+ a.flag(:new_password_file, new_password_file) unless new_password_file.nil?
22
+ end
23
+ run_restic(argv)
24
+ true
25
+ end
26
+ alias_method :add_key, :key_add
27
+
28
+ # `restic key list`: lists every key (password) associated with this repository.
29
+ #
30
+ # @return [Enumerable<Yobi::Key>]
31
+ def key_list
32
+ execution = run_restic(build_argv("key", "list"))
33
+ Keys.new(execution[:output])
34
+ end
35
+ alias_method :keys, :key_list
36
+
37
+ # `restic key passwd`: creates a new key for this repository and
38
+ # removes the key currently in use. Doesn't mutate this `Repository`
39
+ # instance's own `password:`. Build a new `Repository` with the
40
+ # rotated password to keep talking to this repository afterward.
41
+ #
42
+ # @param host [String, nil] hostname to record on the new key
43
+ # @param user [String, nil] username to record on the new key
44
+ # @param new_insecure_no_password [Boolean] set the new key's password to empty
45
+ # @param new_password_file [String, nil] path to a file containing the new key's password
46
+ # @return [true]
47
+ def key_passwd(host: nil, user: nil, new_insecure_no_password: false, new_password_file: nil)
48
+ argv = build_argv("key", "passwd") do |a|
49
+ a.flag(:host, host) unless host.nil?
50
+ a.flag(:user, user) unless user.nil?
51
+ a.flag(:new_insecure_no_password) if new_insecure_no_password
52
+ a.flag(:new_password_file, new_password_file) unless new_password_file.nil?
53
+ end
54
+ run_restic(argv)
55
+ true
56
+ end
57
+ alias_method :change_password, :key_passwd
58
+
59
+ # `restic key remove`: removes the given key ID from this repository.
60
+ # Restic refuses to remove the key currently being used to access the
61
+ # repository.
62
+ #
63
+ # @param id [String]
64
+ # @return [true]
65
+ def key_remove(id:)
66
+ run_restic(build_argv("key", "remove", id))
67
+ true
68
+ end
69
+ alias_method :remove_key, :key_remove
70
+ end
71
+
72
+ # One key (password) associated with the repository, from {Yobi::Repository#key_list}.
73
+ class Key < SimpleDelegator
74
+ # @return [String]
75
+ def id
76
+ self["id"]
77
+ end
78
+
79
+ # @return [String]
80
+ def user_name
81
+ self["userName"]
82
+ end
83
+
84
+ # @return [String]
85
+ def host_name
86
+ self["hostName"]
87
+ end
88
+
89
+ # @return [Boolean] whether this is the key currently in use
90
+ def current?
91
+ self["current"]
92
+ end
93
+
94
+ # @return [Time]
95
+ def created
96
+ @created ||= Time.parse(self["created"])
97
+ end
98
+ end
99
+
100
+ # Enumerable over every {Yobi::Key} in one {Yobi::Repository#key_list} call.
101
+ class Keys
102
+ include Enumerable
103
+
104
+ # @return [Yobi::ResticOutput]
105
+ attr_reader :output
106
+
107
+ # @param output [Yobi::ResticOutput]
108
+ def initialize(output)
109
+ @output = output
110
+ end
111
+
112
+ # @yieldparam key [Yobi::Key]
113
+ # @return [Enumerator] if no block is given
114
+ def each(&block)
115
+ return enum_for(:each) unless block_given?
116
+
117
+ keys.each(&block)
118
+ end
119
+
120
+ private
121
+
122
+ def keys
123
+ @keys ||= JSON.parse(output.to_s).map { |raw| Yobi::Key.new(raw) }
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ class Repository
5
+ # `restic list`: lists every object ID of the given type in this
6
+ # repository. Restic ignores `--json` for this command; each line is a
7
+ # bare ID, except for `:blobs`, where each line is `"data <id>"` or `"tree <id>"`.
8
+ #
9
+ # @param type [Symbol, String] `:blobs`, `:packs`, `:index`, `:snapshots`, `:keys`, or `:locks`
10
+ # @return [Array<String>]
11
+ def list(type)
12
+ argv = build_argv("list", type)
13
+ execution = run_restic(argv)
14
+ execution[:output].to_s.each_line.map(&:strip).reject(&:empty?)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require "delegate"
6
+
7
+ module Yobi
8
+ class Repository
9
+ # `restic ls`: lists a snapshot's files/directories.
10
+ #
11
+ # @param snapshot_id [String] also accepts `"latest"`
12
+ # @param dirs [Array<String>, String] restrict to these directories within the snapshot
13
+ # @param hosts [Array<String>, String] only relevant when `snapshot_id` is `"latest"`
14
+ # @param human_readable [Boolean] format sizes for humans
15
+ # @param long [Boolean] include long listing format
16
+ # @param paths [Array<String>, String] only relevant when `snapshot_id` is `"latest"`
17
+ # @param recursive [Boolean] recurse into subdirectories
18
+ # @param reverse [Boolean] reverse sort order
19
+ # @param sort [String, nil] sort key, e.g. `"size"`
20
+ # @param tags [Array<String>, String] only relevant when `snapshot_id` is `"latest"`
21
+ # @return [Yobi::LsOutcome]
22
+ def ls(snapshot_id:, dirs: [], hosts: [], human_readable: false, long: false, paths: [],
23
+ recursive: false, reverse: false, sort: nil, tags: [])
24
+ argv = build_argv("ls", snapshot_id) do |a|
25
+ a.repeat_flag(:host, hosts)
26
+ a.flag(:human_readable) if human_readable
27
+ a.flag(:long) if long
28
+ a.repeat_flag(:path, paths)
29
+ a.flag(:recursive) if recursive
30
+ a.flag(:reverse) if reverse
31
+ a.flag(:sort, sort) unless sort.nil?
32
+ a.repeat_flag(:tag, tags)
33
+ a.end_of_options.append(dirs)
34
+ end
35
+ execution = run_restic(argv)
36
+ LsOutcome.new(execution[:exit_code], execution[:output])
37
+ end
38
+ end
39
+
40
+ # The outcome of one {Yobi::Repository#ls} call.
41
+ class LsOutcome < Struct.new(:exit_code, :output)
42
+ # @return [Yobi::Snapshot] the resolved snapshot, useful when `snapshot_id:` was `"latest"`
43
+ def snapshot
44
+ @snapshot ||= Yobi::Snapshot.new(JSON.parse(snapshot_line))
45
+ end
46
+
47
+ # @return [Enumerable<Yobi::LsEntry>]
48
+ def entries
49
+ @entries ||= LsEntries.new(output)
50
+ end
51
+
52
+ private
53
+
54
+ def snapshot_line
55
+ offset = output.index["snapshot"].first
56
+ output.read_line_at(offset)
57
+ end
58
+ end
59
+
60
+ # One file/directory entry from a {Yobi::Repository#ls} call.
61
+ class LsEntry < SimpleDelegator
62
+ # @return [String]
63
+ def name
64
+ self["name"]
65
+ end
66
+
67
+ # @return [String]
68
+ def type
69
+ self["type"]
70
+ end
71
+
72
+ # @return [String]
73
+ def path
74
+ self["path"]
75
+ end
76
+
77
+ # @return [Integer]
78
+ def size
79
+ self["size"]
80
+ end
81
+
82
+ # @return [String]
83
+ def permissions
84
+ self["permissions"]
85
+ end
86
+
87
+ # @return [Integer]
88
+ def uid
89
+ self["uid"]
90
+ end
91
+
92
+ # @return [Integer]
93
+ def gid
94
+ self["gid"]
95
+ end
96
+
97
+ # @return [Integer]
98
+ def inode
99
+ self["inode"]
100
+ end
101
+
102
+ # @return [Time]
103
+ def mtime
104
+ @mtime ||= Time.parse(self["mtime"])
105
+ end
106
+
107
+ # @return [Time]
108
+ def atime
109
+ @atime ||= Time.parse(self["atime"])
110
+ end
111
+
112
+ # @return [Time]
113
+ def ctime
114
+ @ctime ||= Time.parse(self["ctime"])
115
+ end
116
+ 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
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ class Repository
5
+ # `restic migrate`: checks which migrations can be applied to this
6
+ # repository, or applies the given ones. Restic ignores `--json` for
7
+ # this command, so there's no programmatic way to discover which
8
+ # migrations are available. Run `restic migrate` directly for that.
9
+ #
10
+ # @param names [Array<String>, String] migration names to apply; only lists available ones if empty
11
+ # @param force [Boolean] reapply a migration already marked as applied
12
+ # @return [true]
13
+ def migrate(names: [], force: false)
14
+ argv = build_argv("migrate", names) do |a|
15
+ a.flag(:force) if force
16
+ end
17
+ run_restic(argv)
18
+ true
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "tempfile"
5
+
6
+ module Yobi
7
+ class Repository
8
+ # `restic mount`: serves this repository as a read-only FUSE
9
+ # filesystem at `mountpoint`, which must already exist.
10
+ #
11
+ # Without a block, returns a {Yobi::Mount} once Restic reports itself
12
+ # ready; call `#stop` yourself once done. With one, yields the `Mount`
13
+ # and stops it automatically once the block returns or raises.
14
+ #
15
+ # @param mountpoint [String] must already exist
16
+ # @param hosts [Array<String>, String] restrict which snapshots appear under `snapshots/`
17
+ # @param paths [Array<String>, String] restrict which snapshots appear under `snapshots/`
18
+ # @param tags [Array<String>, String] restrict which snapshots appear under `snapshots/`
19
+ # @param allow_other [Boolean] allow other users to access the mount
20
+ # @param no_default_permissions [Boolean] don't check file permissions
21
+ # @param owner_root [Boolean] mount files as owned by root
22
+ # @param path_templates [Array<String>, String] directory naming scheme(s) under `snapshots/`
23
+ # @param time_template [String, nil] directory naming scheme for time-based paths
24
+ # @param ready_timeout [Numeric] seconds to wait for Restic's readiness message before raising
25
+ # @yieldparam mount [Yobi::Mount]
26
+ # @return [Yobi::Mount] if no block is given
27
+ # @return [Object] the block's own return value, otherwise
28
+ # @raise [Yobi::MountTimeout]
29
+ def mount(mountpoint:, hosts: [], paths: [], tags: [], allow_other: false,
30
+ no_default_permissions: false, owner_root: false, path_templates: [], time_template: nil,
31
+ ready_timeout: 10)
32
+ argv = build_argv("mount") do |a|
33
+ a.flag(:allow_other) if allow_other
34
+ a.repeat_flag(:host, hosts)
35
+ a.flag(:no_default_permissions) if no_default_permissions
36
+ a.flag(:owner_root) if owner_root
37
+ a.repeat_flag(:path, paths)
38
+ a.repeat_flag(:path_template, path_templates)
39
+ a.repeat_flag(:tag, tags)
40
+ a.flag(:time_template, time_template) unless time_template.nil?
41
+ a.end_of_options.append(mountpoint)
42
+ end
43
+
44
+ mount = run_restic_mount(argv, mountpoint: mountpoint.to_s, ready_timeout: ready_timeout)
45
+ return mount unless block_given?
46
+
47
+ begin
48
+ yield mount
49
+ ensure
50
+ mount.stop
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def run_restic_mount(argv, mountpoint:, ready_timeout:)
57
+ @restic.run_mount(argv, mountpoint: mountpoint, extra_env: env, ready_timeout: ready_timeout)
58
+ end
59
+ end
60
+
61
+ class Restic
62
+ # Restic's own stdout line signaling a mount is ready to serve.
63
+ READY_LINE = "Now serving the repository at"
64
+
65
+ # For {Yobi::Repository#mount}. Spawns Restic and waits for its
66
+ # readiness line (or `ready_timeout:` seconds, or Restic exiting on
67
+ # its own) before returning.
68
+ #
69
+ # @param argv [Array<String>]
70
+ # @param mountpoint [String]
71
+ # @param extra_env [Hash{String => String}]
72
+ # @param ready_timeout [Numeric]
73
+ # @return [Yobi::Mount]
74
+ # @raise [Yobi::MountTimeout]
75
+ def run_mount(argv, mountpoint:, extra_env: {}, ready_timeout: 10)
76
+ ensure_minimum_version!
77
+ file = Tempfile.new("yobi-restic-output")
78
+ file.unlink
79
+
80
+ stdin, output, wait_thr = Open3.popen2e(env.merge(extra_env), restic_path, *argv)
81
+ stdin.close
82
+
83
+ case wait_for_ready(output, file, mountpoint, ready_timeout)
84
+ when :ready
85
+ Yobi::Mount.new(wait_thr: wait_thr, mountpoint: mountpoint, output: output, output_file: file, argv: argv)
86
+ when :timeout
87
+ Process.kill("INT", wait_thr.pid)
88
+ wait_thr.value
89
+ raise Yobi::MountTimeout.new(argv: argv, timeout: ready_timeout)
90
+ when :exited
91
+ status = wait_thr.value
92
+ self.class.dispatch(exit_code: status.exitstatus, output: Yobi::ResticOutput.new(file), argv: argv)
93
+ end
94
+ rescue Errno::ENOENT
95
+ file&.close
96
+ raise Yobi::ResticNotFound.new(restic_path: restic_path, argv: argv)
97
+ end
98
+
99
+ private
100
+
101
+ def wait_for_ready(output, file, mountpoint, timeout)
102
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
103
+
104
+ loop do
105
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
106
+ return :timeout if remaining <= 0
107
+ return :timeout if IO.select([output], nil, nil, remaining).nil?
108
+
109
+ line = output.gets
110
+ return :exited if line.nil?
111
+
112
+ file.write(line)
113
+ return (wait_for_mountpoint(mountpoint, deadline) ? :ready : :timeout) if line.include?(READY_LINE)
114
+ end
115
+ end
116
+
117
+ def wait_for_mountpoint(mountpoint, deadline)
118
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) > deadline
119
+ begin
120
+ return true unless Dir.empty?(mountpoint)
121
+ rescue Errno::ENOENT, Errno::ENOTCONN, Errno::ENXIO, Errno::EIO
122
+ nil
123
+ end
124
+ sleep 0.005
125
+ end
126
+ false
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ class Repository
5
+ # `restic prune`: removes data no longer referenced by any snapshot.
6
+ #
7
+ # @param dry_run [Boolean] report what would happen without doing it
8
+ # @param max_repack_size [String, nil] limit the amount of data repacked in this run
9
+ # @param max_unused [String, nil] target maximum unused space after pruning, e.g. `"10%"`
10
+ # @param repack_cacheable_only [Boolean] only repack packs that are cached locally
11
+ # @param repack_smaller_than [String, nil] also repack packs smaller than this size
12
+ # @param repack_uncompressed [Boolean] repack packs not yet using compression
13
+ # @param unsafe_recover_no_free_space [String, nil] proceed even without enough free space, at the given risk acknowledgement
14
+ # @return [true]
15
+ def prune(dry_run: false, max_repack_size: nil, max_unused: nil, repack_cacheable_only: false,
16
+ repack_smaller_than: nil, repack_uncompressed: false, unsafe_recover_no_free_space: nil)
17
+ argv = build_argv("prune") do |a|
18
+ a.flag(:dry_run) if dry_run
19
+ a.flag(:max_repack_size, max_repack_size) unless max_repack_size.nil?
20
+ a.flag(:max_unused, max_unused) unless max_unused.nil?
21
+ a.flag(:repack_cacheable_only) if repack_cacheable_only
22
+ a.flag(:repack_smaller_than, repack_smaller_than) unless repack_smaller_than.nil?
23
+ a.flag(:repack_uncompressed) if repack_uncompressed
24
+ a.flag(:unsafe_recover_no_free_space, unsafe_recover_no_free_space) unless unsafe_recover_no_free_space.nil?
25
+ end
26
+ run_restic(argv)
27
+ true
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ class Repository
5
+ # `restic recover`: builds a new snapshot from any data found in this
6
+ # repository that isn't referenced by an existing snapshot (e.g. after
7
+ # an accidental {#forget}). Call {#snapshots} afterward to find the
8
+ # recovered snapshot, if any was created.
9
+ #
10
+ # @return [true]
11
+ def recover
12
+ run_restic(build_argv("recover"))
13
+ true
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Yobi
4
+ class Repository
5
+ # `restic repair index`: creates a new index based on the pack files
6
+ # present in this repository. Successor to the deprecated
7
+ # `rebuild-index` command.
8
+ #
9
+ # @param read_all_packs [Boolean] read every pack file fully instead of just headers
10
+ # @return [true]
11
+ def repair_index(read_all_packs: false)
12
+ argv = build_argv("repair", "index") do |a|
13
+ a.flag(:read_all_packs) if read_all_packs
14
+ end
15
+ run_restic(argv)
16
+ true
17
+ end
18
+
19
+ # `restic repair packs`: extracts intact blobs from the given pack
20
+ # files, rebuilds the index to drop the damaged packs, and removes
21
+ # them from the repository. Restic also writes a backup copy of each
22
+ # given pack file (named `pack-<id>`) into the calling process's
23
+ # current working directory before removing it. There's no flag to
24
+ # disable this.
25
+ #
26
+ # @param ids [Array<String>, String] pack IDs
27
+ # @return [true]
28
+ def repair_packs(ids:)
29
+ argv = build_argv("repair", "packs", ids)
30
+ run_restic(argv)
31
+ true
32
+ end
33
+
34
+ # `restic repair snapshots`: scans the given snapshots and generates
35
+ # new ones with damaged directories/file contents removed. This
36
+ # causes data loss for the content actually removed; prefer a fresh
37
+ # {Yobi::Repository#backup} where the source data is still available.
38
+ # Depends on a correct index. Call {#repair_index} first.
39
+ #
40
+ # @param snapshot_ids [Array<String>, String] snapshots to repair; all of them if empty
41
+ # @param dry_run [Boolean] report what would happen without doing it
42
+ # @param forget [Boolean] remove the original damaged snapshots after repairing
43
+ # @param hosts [Array<String>, String] filter by hostname(s)
44
+ # @param paths [Array<String>, String] filter by originally backed-up path(s)
45
+ # @param tags [Array<String>, String] filter by tag(s)
46
+ # @return [true]
47
+ def repair_snapshots(snapshot_ids: [], dry_run: false, forget: false, hosts: [], paths: [], tags: [])
48
+ argv = build_argv("repair", "snapshots", snapshot_ids) do |a|
49
+ a.flag(:dry_run) if dry_run
50
+ a.flag(:forget) if forget
51
+ a.repeat_flag(:host, hosts)
52
+ a.repeat_flag(:path, paths)
53
+ a.repeat_flag(:tag, tags)
54
+ end
55
+ run_restic(argv)
56
+ true
57
+ end
58
+ end
59
+ end