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/README.md
ADDED
|
@@ -0,0 +1,897 @@
|
|
|
1
|
+
# Yobi
|
|
2
|
+
|
|
3
|
+
Yobi is a Ruby interface for the [Restic](https://restic.net/) backup program.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Install the gem:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
bundle add yobi
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
or, without Bundler:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
gem install yobi
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Installing Restic
|
|
20
|
+
|
|
21
|
+
Yobi shells out to a real `restic` binary; it doesn't install or bundle one. You need `restic` on `PATH` (or point Yobi at it explicitly, see below) before any of this works.
|
|
22
|
+
|
|
23
|
+
Install it however you'd install any other system tool:
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
# macOS
|
|
27
|
+
brew install restic
|
|
28
|
+
|
|
29
|
+
# Debian/Ubuntu
|
|
30
|
+
apt install restic
|
|
31
|
+
|
|
32
|
+
# Arch
|
|
33
|
+
pacman -S restic
|
|
34
|
+
|
|
35
|
+
# or download a binary directly
|
|
36
|
+
# https://github.com/restic/restic/releases
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Confirm it's reachable:
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
restic version
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
### Minimum supported version
|
|
46
|
+
|
|
47
|
+
Yobi requires Restic **0.17.1** or newer. This isn't arbitrary: `Yobi::Restic`'s exit-code-based error dispatch (mapping Restic's own exit codes to `Yobi::RepositoryNotFound`/`RepositoryLocked`/`AuthenticationFailed`) depends on a convention that's only guaranteed since that version. An older Restic could reuse those same exit codes for different failures, so rather than risk silently misclassifying an error, Yobi checks the installed version before running any real command and raises `Yobi::UnsupportedResticVersion` if it's too old:
|
|
48
|
+
|
|
49
|
+
```ruby
|
|
50
|
+
begin
|
|
51
|
+
repo.snapshots
|
|
52
|
+
rescue Yobi::UnsupportedResticVersion => e
|
|
53
|
+
puts "Restic #{e.installed_version} is too old, need >= #{e.minimum_version}"
|
|
54
|
+
end
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
See ["Minimum-version enforcement, in detail"](#minimum-version-enforcement-in-detail) below for the mechanics. If you'd rather fail fast at application startup instead of on the first backup attempt, call it explicitly:
|
|
58
|
+
|
|
59
|
+
```ruby
|
|
60
|
+
RESTIC = Yobi::Restic.new
|
|
61
|
+
RESTIC.ensure_minimum_version! # raises immediately if too old, otherwise a no-op
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### A non-default Restic binary
|
|
65
|
+
|
|
66
|
+
If `restic` isn't on `PATH`, or you want to pin a specific binary:
|
|
67
|
+
|
|
68
|
+
```ruby
|
|
69
|
+
# via $RESTIC_PATH
|
|
70
|
+
ENV["RESTIC_PATH"] = "/opt/restic/bin/restic"
|
|
71
|
+
Yobi::Restic.new
|
|
72
|
+
|
|
73
|
+
# or explicitly
|
|
74
|
+
Yobi::Restic.new("/opt/restic/bin/restic")
|
|
75
|
+
Yobi::Repository.new(url: "...", password: "...", restic: "/opt/restic/bin/restic")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Core concepts
|
|
79
|
+
|
|
80
|
+
Two classes make up the whole API:
|
|
81
|
+
|
|
82
|
+
- **`Yobi::Restic`**: a specific `restic` binary plus its process-level settings that apply no matter which repository you're talking to (cache directory, bandwidth limits, compression, and so on).
|
|
83
|
+
- **`Yobi::Repository`**: one repository, covering its URL, password, and backend credentials, plus every Restic subcommand as a method. Holds a `Restic` internally, either one you hand it explicitly via `restic:` (useful when several repositories should share the same operational settings) or a default one it creates for you.
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
require "yobi"
|
|
87
|
+
|
|
88
|
+
repo = Yobi::Repository.new(
|
|
89
|
+
url: "sftp:backup@nas.local:/repos/home",
|
|
90
|
+
password: [:command, "security find-generic-password -a restic -s home -w"]
|
|
91
|
+
)
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Quick start
|
|
95
|
+
|
|
96
|
+
A repository has to exist before anything else works: `#init` creates one at `url`. From there, `#backup` creates a snapshot, `#snapshots` lists what exists, and `#restore` pulls one back out:
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
repo.init
|
|
100
|
+
|
|
101
|
+
outcome = repo.backup(
|
|
102
|
+
source: "/Users/zia/Documents",
|
|
103
|
+
excludes: ["*.tmp", "node_modules"],
|
|
104
|
+
tags: ["documents", "daily"]
|
|
105
|
+
)
|
|
106
|
+
outcome.success? # => true
|
|
107
|
+
outcome.report["snapshot_id"]
|
|
108
|
+
outcome.report["data_added"]
|
|
109
|
+
|
|
110
|
+
repo.snapshots.each do |snapshot|
|
|
111
|
+
puts "#{snapshot.short_id} #{snapshot.time} #{snapshot.paths.join(", ")}"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
repo.restore(snapshot_id: "latest", target: "/tmp/restore")
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Every verb method returns something typed rather than a raw Hash. `#backup` returns a `BackupOutcome` (`#success?`, `#report`, `#errors`), `#snapshots` returns an Enumerable of `Snapshot` objects, and so on throughout the API.
|
|
118
|
+
|
|
119
|
+
## Constructing a `Repository`
|
|
120
|
+
|
|
121
|
+
```ruby
|
|
122
|
+
repo = Yobi::Repository.new(
|
|
123
|
+
url: "sftp:backup@nas.local:/repos/home",
|
|
124
|
+
password: [:command, "security find-generic-password -a restic -s home -w"]
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `url:`
|
|
129
|
+
|
|
130
|
+
The repository location, in whatever form Restic itself accepts:
|
|
131
|
+
|
|
132
|
+
- a plain path for a local repository
|
|
133
|
+
- or a URL prefixed with backend type such as `sftp:`, `s3:`, `azure:`, `b2:`, `gs:`, `rest:`, `rclone:`, etc.
|
|
134
|
+
|
|
135
|
+
See [Restic's documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html) for the full list of supported backends and their URL-ish schemes.
|
|
136
|
+
|
|
137
|
+
### `password:`
|
|
138
|
+
|
|
139
|
+
Identifies the repository's encryption password. Five shapes:
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
# A literal password
|
|
143
|
+
password = "correct horse battery staple"
|
|
144
|
+
|
|
145
|
+
# Restic resolves this itself, Yobi never sees the value
|
|
146
|
+
password = [:command, "security find-generic-password -a restic -w"]
|
|
147
|
+
|
|
148
|
+
# Same idea, from a file
|
|
149
|
+
password = [:file, "/etc/restic/password"]
|
|
150
|
+
|
|
151
|
+
# Resolved on the Ruby side, called fresh immediately before every Restic
|
|
152
|
+
# invocation, use this for a secret store (see Recipes below)
|
|
153
|
+
password = -> { Vault.logical.read("secret/restic").data[:password] }
|
|
154
|
+
|
|
155
|
+
# An explicit "there is no password" declaration (Restic's own
|
|
156
|
+
# --insecure-no-password flag)
|
|
157
|
+
password = :insecure_no_password
|
|
158
|
+
|
|
159
|
+
Yobi::Repository.new(url: "...", password: password)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
If `password:` is left unspecified, Yobi adds nothing extra to the environment: Restic falls back to `RESTIC_PASSWORD`/`RESTIC_PASSWORD_COMMAND` if either happens to already be set in the calling process's own environment (a container, systemd unit, or parent shell outside Yobi's control). If nothing is set anywhere, Restic refuses a truly empty password (unless `--insecure-no-password` is given) and fails loudly and safely on its own.
|
|
163
|
+
|
|
164
|
+
### `backend_credentials:`
|
|
165
|
+
|
|
166
|
+
Separate from `password:`: this is whatever the storage *backend* needs to authenticate, unrelated to whether the repository's contents can be decrypted. A Hash of env vars, or a callable returning one, resolved fresh on every call the same way a callable `password:` is:
|
|
167
|
+
|
|
168
|
+
```ruby
|
|
169
|
+
# S3
|
|
170
|
+
Yobi::Repository.new(
|
|
171
|
+
url: "s3:s3.amazonaws.com/my-bucket",
|
|
172
|
+
password: "...",
|
|
173
|
+
backend_credentials: {"AWS_ACCESS_KEY_ID" => "...", "AWS_SECRET_ACCESS_KEY" => "..."}
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
# Azure
|
|
177
|
+
Yobi::Repository.new(
|
|
178
|
+
url: "azure:my-container:/",
|
|
179
|
+
password: "...",
|
|
180
|
+
backend_credentials: {"AZURE_ACCOUNT_NAME" => "...", "AZURE_ACCOUNT_KEY" => "..."}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
# Google Cloud Storage
|
|
184
|
+
Yobi::Repository.new(
|
|
185
|
+
url: "gs:my-bucket:/",
|
|
186
|
+
password: "...",
|
|
187
|
+
backend_credentials: {"GOOGLE_PROJECT_ID" => "...", "GOOGLE_APPLICATION_CREDENTIALS" => "/path/to/service-account.json"}
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
# REST server, credentials embedded directly in the URL instead of backend_credentials:
|
|
191
|
+
Yobi::Repository.new(url: "rest:https://user:pass@host/", password: "...")
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
For that last case, Yobi extracts the embedded username/password into `RESTIC_REST_USERNAME`/`RESTIC_REST_PASSWORD` and strips them from the stored `url` so it won't leak via `repo#inspect`, logs, or a stray `puts`. Explicit `backend_credentials:` values win over extracted ones if both are given.
|
|
195
|
+
|
|
196
|
+
See [Restic's documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html) for the full list of supported backends and their env vars.
|
|
197
|
+
|
|
198
|
+
### `restic:`
|
|
199
|
+
|
|
200
|
+
A `Yobi::Restic` instance to share, a bare Restic binary path, or just leave it out to get a default one:
|
|
201
|
+
|
|
202
|
+
```ruby
|
|
203
|
+
Yobi::Repository.new(url: "...", password: "...") # default Restic
|
|
204
|
+
|
|
205
|
+
Yobi::Repository.new(url: "...", password: "...", restic: "/opt/restic/bin/restic") # bare path
|
|
206
|
+
|
|
207
|
+
Yobi::Repository.new(url: "...", password: "...", restic: restic) # shared Yobi::Restic instance
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
See ["Sharing a `Restic` across repositories"](#sharing-a-restic-across-repositories) below for why you'd want to share one explicitly (global settings like bandwidth limits or cache directory).
|
|
211
|
+
|
|
212
|
+
## Error handling
|
|
213
|
+
|
|
214
|
+
Every failure raises a specific, typed error rather than a generic one, so you can handle the cases that matter to you and let the rest propagate:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
begin
|
|
218
|
+
repo.snapshots
|
|
219
|
+
rescue Yobi::RepositoryLocked
|
|
220
|
+
# another Restic process holds the lock, see Recipes below for a retry pattern
|
|
221
|
+
rescue Yobi::AuthenticationFailed
|
|
222
|
+
# wrong password
|
|
223
|
+
rescue Yobi::RepositoryNotFound
|
|
224
|
+
# url doesn't point to an initialized repository yet, maybe call #init first
|
|
225
|
+
end
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
The full hierarchy, all under `Yobi::Error < StandardError`:
|
|
229
|
+
|
|
230
|
+
- `Yobi::ResticNotFound`: the `restic` binary itself couldn't be found or executed.
|
|
231
|
+
- `Yobi::UnsupportedResticVersion`: the installed Restic is older than the minimum supported version.
|
|
232
|
+
- `Yobi::RepositoryNotFound`, `Yobi::RepositoryLocked`, `Yobi::AuthenticationFailed`: Restic's own typed exit codes (10/11/12).
|
|
233
|
+
- `Yobi::ResticCommandFailed`: any other non-zero exit, for a failure that doesn't fit one of the above.
|
|
234
|
+
- `Yobi::MountTimeout`: `#mount` didn't report itself ready within its timeout.
|
|
235
|
+
|
|
236
|
+
Every method below lives on `Yobi::Repository` unless noted otherwise.
|
|
237
|
+
|
|
238
|
+
## Repository lifecycle
|
|
239
|
+
|
|
240
|
+
### `#init`
|
|
241
|
+
|
|
242
|
+
Creates the repository at `url`.
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
repo.init
|
|
246
|
+
# => {"message_type"=>"initialized", "id"=>"...", "repository"=>"..."}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`copy_chunker_params:`/`from_repo:`/`from_repository_file:`/`from_key_hint:`/`from_password_command:`/`from_password_file:`/`from_insecure_no_password:` copy chunker parameters from a secondary repository. Useful when you plan to `#copy` between the two later, since chunker params have to match for deduplication to work across repositories. `repository_version:` picks a specific repository format version instead of Restic's current default.
|
|
250
|
+
|
|
251
|
+
### `#config`
|
|
252
|
+
|
|
253
|
+
The repository's own config document (`cat config`), as a Hash. A cheap way to confirm a repository exists and the password is correct without doing anything else.
|
|
254
|
+
|
|
255
|
+
```ruby
|
|
256
|
+
repo.config
|
|
257
|
+
# => {"version"=>2, "id"=>"...", "chunker_polynomial"=>"..."}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
Raises `Yobi::RepositoryNotFound` if `url` doesn't point to an initialized repository, `Yobi::AuthenticationFailed` if the password is wrong.
|
|
261
|
+
|
|
262
|
+
## Backup and restore
|
|
263
|
+
|
|
264
|
+
### `#backup`
|
|
265
|
+
|
|
266
|
+
```ruby
|
|
267
|
+
outcome = repo.backup(
|
|
268
|
+
source: "/Users/zia/Documents",
|
|
269
|
+
excludes: ["*.tmp", "node_modules"],
|
|
270
|
+
tags: ["documents", "daily"]
|
|
271
|
+
)
|
|
272
|
+
outcome.success? # => true
|
|
273
|
+
outcome.report["snapshot_id"]
|
|
274
|
+
outcome.report["files_new"]
|
|
275
|
+
outcome.report["data_added"]
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
`source:` is either a path String, or a `[:stdin_from_command, command]`/`[:stdin_from_command, command, filename]` tuple: Restic spawns and executes `command` itself, capturing its stdout as the backup content (see ["Database dumps via `stdin_from_command`"](#database-dumps-via-stdin_from_command) below for a `pg_dump` example). `command` can be a String (tokenized with `Shellwords.split`, so quoted arguments survive) or an Array of already-discrete arguments (used as-is; needed when an argument itself contains a literal space, which `Shellwords` would otherwise split incorrectly).
|
|
279
|
+
|
|
280
|
+
**Filtering:**
|
|
281
|
+
|
|
282
|
+
- `excludes:`/`exclude_files:`/`iexcludes:`/`iexclude_files:` (case-insensitive): exclude by glob pattern or pattern file
|
|
283
|
+
- `exclude_if_present:`: skip a directory containing a named marker file
|
|
284
|
+
- `exclude_larger_than:`: skip by size
|
|
285
|
+
- `exclude_caches:`/`exclude_cloud_files:`: skip CACHEDIR.TAG-marked dirs and cloud-placeholder files respectively
|
|
286
|
+
- `files_from:`/`files_from_raw:`/`files_from_verbatim:`: read the backup list from a file instead of (or alongside) `source:`
|
|
287
|
+
|
|
288
|
+
**Behavior:**
|
|
289
|
+
|
|
290
|
+
- `dry_run:`: report what would happen without doing it
|
|
291
|
+
- `force:`: back up unchanged files anyway
|
|
292
|
+
- `one_file_system:`: don't cross filesystem boundaries
|
|
293
|
+
- `ignore_ctime:`/`ignore_inode:`: relax change detection
|
|
294
|
+
- `no_scan:`: skip the pre-backup scan, disabling percentage progress
|
|
295
|
+
- `skip_if_unchanged:`: don't create a snapshot if nothing changed
|
|
296
|
+
- `parent:`: pick a specific parent snapshot instead of the latest one
|
|
297
|
+
- `group_by:`: grouping used to find the parent snapshot instead, e.g. `"host,paths"`, when `parent:` isn't given
|
|
298
|
+
- `read_concurrency:`: number of concurrent file reads
|
|
299
|
+
- `time:`: record a specific timestamp instead of now
|
|
300
|
+
- `with_atime:`: also store files' access times
|
|
301
|
+
- `host:`: hostname recorded on the *new* snapshot (a single value: this is metadata being written, not a filter, unlike every `hosts:` elsewhere in this API)
|
|
302
|
+
|
|
303
|
+
Returns a `BackupOutcome`: `#success?`/`#partial?` (exit code 3, some files were skipped, e.g. permission errors), `#report` (the summary Hash, with `"backup_start"`/`"backup_end"` parsed into `Time`), `#errors` (lazy Enumerable of `BackupError`), `#command_output` (the `source: [:stdin_from_command, ...]` subprocess's own stderr, de-prefixed, if any).
|
|
304
|
+
|
|
305
|
+
Also accepts a block, to receive typed progress objects as the backup runs instead of only getting the final `BackupOutcome`:
|
|
306
|
+
|
|
307
|
+
```ruby
|
|
308
|
+
repo.backup(source: "/Users/zia/Documents") do |message|
|
|
309
|
+
case message
|
|
310
|
+
when Yobi::BackupStatus
|
|
311
|
+
puts "#{((message.percent_done || 0) * 100).round}% (#{message.files_done}/#{message.total_files})"
|
|
312
|
+
when Yobi::BackupError
|
|
313
|
+
warn "#{message.item}: #{message.message}"
|
|
314
|
+
when Yobi::BackupVerboseStatus
|
|
315
|
+
puts "#{message.action} #{message.item}"
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`Yobi::BackupVerboseStatus` (one message per file) only streams when `verbose: true` is passed; it's not emitted by default, since a large backup can otherwise produce hundreds of MB of these. Behavior without a block is identical, just without the live callbacks; the returned `BackupOutcome` is the same either way.
|
|
321
|
+
|
|
322
|
+
### `#restore`
|
|
323
|
+
|
|
324
|
+
```ruby
|
|
325
|
+
repo.restore(snapshot_id: "latest", target: "/tmp/restore")
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
`snapshot_id:` accepts `"latest"` or a real ID; `target:` is the destination directory.
|
|
329
|
+
|
|
330
|
+
- `excludes:`/`includes:` (and their `i`-prefixed case-insensitive/`_file` file-based variants): filter what gets restored. `includes:` has no backup-time equivalent, since restoring is the one direction where "only these" makes sense as well as "all but these."
|
|
331
|
+
- `exclude_xattrs:`/`include_xattrs:`: filter extended attributes specifically
|
|
332
|
+
- `dry_run:`: report what would happen without doing it
|
|
333
|
+
- `delete:`: removes files in `target:` not present in the snapshot
|
|
334
|
+
- `overwrite:` (`"always"`/`"if-changed"`/`"if-newer"`): controls what happens to files that already exist there
|
|
335
|
+
- `sparse:`: writes sparse files
|
|
336
|
+
- `ownership_by_name:`: maps ownership by user/group name instead of numeric ID
|
|
337
|
+
- `verify:`: re-reads restored content against the repository to confirm it matches
|
|
338
|
+
- `hosts:`/`paths:`/`tags:`: only matter when `snapshot_id:` is `"latest"`, same filters as everywhere else, used to resolve which snapshot "latest" means
|
|
339
|
+
|
|
340
|
+
Returns a `RestoreOutcome` (`#report`, no `#success?`/`#partial?`: restore has no exit code 3, any item-level failure is a hard error that raises before an outcome exists).
|
|
341
|
+
|
|
342
|
+
Accepts a block the same way `#backup` does, yielding `Yobi::RestoreStatus`/`Yobi::RestoreVerboseStatus` (the latter only when `verbose: true`) instead of `Yobi::BackupStatus`/`Yobi::BackupVerboseStatus`.
|
|
343
|
+
|
|
344
|
+
### `#dump`
|
|
345
|
+
|
|
346
|
+
Extracts a single file (raw bytes) or a whole folder (as a tar/zip archive) from a snapshot, without a full restore:
|
|
347
|
+
|
|
348
|
+
```ruby
|
|
349
|
+
# Straight to a file
|
|
350
|
+
repo.dump(snapshot_id: "latest", file: "/etc/hosts", target: "/tmp/hosts")
|
|
351
|
+
|
|
352
|
+
# Or stream it yourself
|
|
353
|
+
repo.dump(snapshot_id: "latest", file: "/etc/hosts") do |io|
|
|
354
|
+
IO.copy_stream(io, "/tmp/hosts")
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# A whole folder as an archive (default to tar):
|
|
358
|
+
repo.dump(snapshot_id: "latest", file: "/var/www", target: "/tmp/www.tar")
|
|
359
|
+
repo.dump(snapshot_id: "latest", file: "/var/www", target: "/tmp/www.zip", archive: "zip")
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
Give at most one of `target:` or a block. Without either, returns a `Yobi::IOHandle` instead:
|
|
363
|
+
|
|
364
|
+
```ruby
|
|
365
|
+
handle = repo.dump(snapshot_id: "latest", file: "/var/www")
|
|
366
|
+
begin
|
|
367
|
+
# ... hand handle.io to something else that reads from it later ...
|
|
368
|
+
ensure
|
|
369
|
+
# put this in ensure block so the restic subprocess doesn't become a zombie:
|
|
370
|
+
handle.close # may raise error based on restic's exit code.
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
### `#diff`
|
|
375
|
+
|
|
376
|
+
```ruby
|
|
377
|
+
outcome = repo.diff(from: older_snapshot.id, to: newer_snapshot.id)
|
|
378
|
+
outcome.report["changed_files"]
|
|
379
|
+
outcome.changes.each { |change| puts "#{change.modifier} #{change.path}" }
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
`metadata: true` also reports metadata-only changes (permissions, timestamps) alongside content changes. `change.modifier` is Restic's own single-character code: `+` added, `-` removed, `U` metadata updated, `M` content modified, `T` type changed, `?` bitrot detected.
|
|
383
|
+
|
|
384
|
+
## Snapshot management
|
|
385
|
+
|
|
386
|
+
### `#snapshots`
|
|
387
|
+
|
|
388
|
+
```ruby
|
|
389
|
+
repo.snapshots(tags: ["daily"], hosts: "web-1").each do |snapshot|
|
|
390
|
+
puts "#{snapshot.short_id} #{snapshot.time} #{snapshot.paths.join(", ")}"
|
|
391
|
+
end
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
Returns an Enumerable of `Snapshot` (`#id`, `#short_id`, `#time`, `#host`, `#tags`, `#paths`, `#parent_id`). `compact:` compacts the underlying Restic output; `group_by:`/`latest:` group and limit results, e.g. `latest: 1` per group for "the most recent backup of each host."
|
|
395
|
+
|
|
396
|
+
### `#tag`
|
|
397
|
+
|
|
398
|
+
```ruby
|
|
399
|
+
repo.tag(snapshot_ids: snapshot.id, add: "verified")
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`add:`/`remove:`/`set:` (mutually exclusive with `add:`/`remove:` in Restic itself) modify tags on snapshots matched by `snapshot_ids:` (or by `hosts:`/`paths:`/`tags:` filters when no explicit IDs are given). Since tags are part of a snapshot's content-addressed identity, tagging produces a *new* snapshot ID for every snapshot touched. `TagOutcome#changes` gives you the old-ID/new-ID pairs.
|
|
403
|
+
|
|
404
|
+
### `#forget`
|
|
405
|
+
|
|
406
|
+
```ruby
|
|
407
|
+
repo.forget(keep_daily: 7, keep_weekly: 4, keep_monthly: 12, prune: true)
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
Applies a retention policy, removing snapshots that don't match any `keep_*` rule: `keep_last:`/`keep_hourly:`/`keep_daily:`/`keep_weekly:`/`keep_monthly:`/`keep_yearly:` (counts) and `keep_within:`/`keep_within_hourly:`/etc. (durations, e.g. `"30d"`). `keep_tags:` always keeps snapshots carrying any of the given tags regardless of the numeric rules. `prune: true` also reclaims the disk space the forgotten snapshots held (equivalent to a separate `#prune` call afterward); the `max_unused:`/`max_repack_size:`/`repack_*:` kwargs tune that implied prune step exactly like `#prune`'s own. `dry_run:` reports what would be removed without doing it. `unsafe_allow_remove_all:` is required if a policy would remove every snapshot.
|
|
411
|
+
|
|
412
|
+
Returns an Enumerable of `ForgetGroup` (Restic evaluates the policy per group, by default grouped by host+paths). Each exposes `#keep`/`#remove` (Arrays of `Snapshot`) and `#reasons` (an Array of `ForgetReason` explaining which rule kept each surviving snapshot, e.g. `"daily snapshot"`).
|
|
413
|
+
|
|
414
|
+
### `#find`
|
|
415
|
+
|
|
416
|
+
```ruby
|
|
417
|
+
repo.find(patterns: "*.pem").each do |matches|
|
|
418
|
+
puts "in #{matches.snapshot["short_id"]}: #{matches.hits} hits"
|
|
419
|
+
matches.matches.each { |m| puts " #{m.path}" }
|
|
420
|
+
end
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Searches for files/directories by name pattern across snapshots. `blob:`/`pack:`/`tree:` switch to matching object IDs instead, for low-level troubleshooting. Returns an Enumerable of `MatchesPerSnapshot` (Restic's own docs describe find's output as "organized by snapshot," not a flat list). Each has `#hits` and `#matches` (an Array of `FindMatch`, with the usual file metadata: `#path`, `#size`, `#mtime`, etc.).
|
|
424
|
+
|
|
425
|
+
### `#ls`
|
|
426
|
+
|
|
427
|
+
```ruby
|
|
428
|
+
outcome = repo.ls(snapshot_id: "latest", dirs: "/var/www")
|
|
429
|
+
outcome.snapshot.short_id
|
|
430
|
+
outcome.entries.each { |entry| puts entry.path }
|
|
431
|
+
```
|
|
432
|
+
|
|
433
|
+
Lists a snapshot's files/directories. `recursive:` descends into subdirectories; `dirs:` restricts to specific paths within the snapshot. Returns an `LsOutcome`: `#snapshot` (the resolved `Snapshot`, useful to see what `"latest"` actually resolved to) and lazy `#entries` (Enumerable of `LsEntry`).
|
|
434
|
+
|
|
435
|
+
## Maintenance and health
|
|
436
|
+
|
|
437
|
+
### `#check`
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
outcome = repo.check(read_data_subset: "5%")
|
|
441
|
+
outcome.report["num_errors"]
|
|
442
|
+
outcome.errors.each { |error| puts error.message }
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
Verifies repository integrity. `read_data:` also reads and verifies every pack file's actual contents, not just structure (slow, thorough); `read_data_subset:` does a partial version of the same (e.g. `"5%"`, or `"1/20"` for a fifth each day in rotation); `with_cache:` uses the local cache instead of bypassing it. Returns a `CheckOutcome` (no `#success?`/`#partial?`: check has no exit code 3; check `report["num_errors"]` instead).
|
|
446
|
+
|
|
447
|
+
### `#prune`
|
|
448
|
+
|
|
449
|
+
```ruby
|
|
450
|
+
repo.prune(max_unused: "5%")
|
|
451
|
+
```
|
|
452
|
+
|
|
453
|
+
Removes data no longer referenced by any snapshot. `dry_run:` reports what would happen without doing it. `max_unused:` targets a maximum acceptable unused-space ratio after pruning; `max_repack_size:` bounds how much gets repacked in one run (for spreading a large prune across multiple scheduled invocations); `repack_cacheable_only:`/`repack_uncompressed:`/`repack_smaller_than:` tune which packs get rewritten. `unsafe_recover_no_free_space:` proceeds even without enough free space, at the given risk acknowledgement string. Returns `true`.
|
|
454
|
+
|
|
455
|
+
### `#repair_index`, `#repair_packs`, `#repair_snapshots`
|
|
456
|
+
|
|
457
|
+
```ruby
|
|
458
|
+
repo.repair_index
|
|
459
|
+
repo.repair_packs(ids: damaged_pack_ids)
|
|
460
|
+
repo.repair_snapshots(snapshot_ids: affected_ids, forget: true)
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
`#repair_index` rebuilds the index from the pack files present (the modern successor to Restic's now-deprecated `rebuild-index`). `#repair_packs` extracts intact blobs from damaged pack files and drops the rest. Restic also writes a backup copy of each given pack (named `pack-<id>`) into the *calling process's own current working directory* first, with no flag to disable this; be aware of where your process runs from before calling it. `#repair_snapshots` regenerates snapshots with damaged content removed. This is real data loss for whatever gets removed, so prefer a fresh `#backup` where the source data is still available, and run `#repair_index` first since this depends on a correct index.
|
|
464
|
+
|
|
465
|
+
### `#recover`
|
|
466
|
+
|
|
467
|
+
```ruby
|
|
468
|
+
repo.recover
|
|
469
|
+
```
|
|
470
|
+
|
|
471
|
+
Builds a new snapshot from any data present in the repository but not referenced by an existing snapshot (e.g. after an accidental `#forget`). Call `#snapshots` afterward to see whether anything was actually recovered. Restic reports "nothing to do" as plain text with no structured way to tell the two cases apart.
|
|
472
|
+
|
|
473
|
+
### `#rewrite`
|
|
474
|
+
|
|
475
|
+
```ruby
|
|
476
|
+
repo.rewrite(snapshot_ids: old.id, excludes: "*.log", new_time: Time.now.iso8601)
|
|
477
|
+
```
|
|
478
|
+
|
|
479
|
+
Creates new snapshots from existing ones with filters applied or metadata changed. With no `snapshot_ids:`/`hosts:`/`tags:`/`paths:` given, rewrites every snapshot in the repository. Restic's own default, not something Yobi adds. `excludes:`/`exclude_files:`/`includes:`/`include_files:` (and their `i`-prefixed case-insensitive variants) filter file content the same way as `#backup`/`#restore`. `dry_run:` reports what would happen without doing it. `new_host:`/`new_time:` change the recorded hostname/timestamp; `snapshot_summary:` regenerates the snapshot summary. `forget: false` (the default) tags the new snapshots `"rewrite"` and keeps the originals; `forget: true` removes the originals instead (their data isn't reclaimed until a later `#prune`).
|
|
480
|
+
|
|
481
|
+
### `#migrate`
|
|
482
|
+
|
|
483
|
+
```ruby
|
|
484
|
+
repo.migrate(names: "upgrade_repo_v2")
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
Applies the given named migrations; with `names:` left empty, only lists what's available (as Restic's own plain-text output; there's no programmatic way to enumerate migrations, since `--json` is ignored for this command).
|
|
488
|
+
|
|
489
|
+
### `#unlock`
|
|
490
|
+
|
|
491
|
+
```ruby
|
|
492
|
+
repo.unlock
|
|
493
|
+
```
|
|
494
|
+
|
|
495
|
+
Removes stale locks left by other Restic processes. `remove_all: true` removes every lock, not just stale ones.
|
|
496
|
+
|
|
497
|
+
### `#stats`
|
|
498
|
+
|
|
499
|
+
```ruby
|
|
500
|
+
repo.stats(mode: "raw-data")
|
|
501
|
+
# => {"total_size"=>..., "total_file_count"=>..., "snapshots_count"=>...}
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
Repository size/file-count statistics. `mode:` picks the counting strategy, as a String or Symbol: `"restore-size"`/`:restore_size` (default), `"files-by-contents"`/`:files_by_contents`, `"blobs-per-file"`/`:blobs_per_file`, `"raw-data"`/`:raw_data` (actual on-disk size, accounting for deduplication). Anything else raises `ArgumentError`.
|
|
505
|
+
|
|
506
|
+
## Key management
|
|
507
|
+
|
|
508
|
+
```ruby
|
|
509
|
+
repo.key_add(user: "ci-runner")
|
|
510
|
+
repo.key_list.each { |key| puts "#{key.id} #{key.user_name}@#{key.host_name} current=#{key.current?}" }
|
|
511
|
+
repo.key_passwd(new_password_file: "/etc/restic/new-password")
|
|
512
|
+
repo.key_remove(id: old_key.id)
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
`#key_add`/`#key_list`/`#key_passwd`/`#key_remove` (aliased `#add_key`/`#keys`/`#change_password`/`#remove_key`) manage passwords. Every key here is a different way to unlock the same underlying master key, not a separate encryption key of its own (see ["Low-level (`cat`) and object listing"](#low-level-cat-and-object-listing)'s note on `#cat_masterkey_and_game_over_if_this_leaks` below). `#key_passwd` doesn't mutate the `Repository` you called it on; build a new one with the rotated password to keep talking to the repository afterward.
|
|
516
|
+
|
|
517
|
+
`#key_add`/`#key_passwd` share the same kwargs: `host:`/`user:` record a hostname/username on the new key (defaults to the OS's own, same as Restic itself); `new_insecure_no_password:` sets the new key's password to empty; `new_password_file:` reads the new password from a file instead of prompting interactively.
|
|
518
|
+
|
|
519
|
+
Restic refuses to `#key_remove` the key currently in use.
|
|
520
|
+
|
|
521
|
+
## Low-level (`cat`) and object listing
|
|
522
|
+
|
|
523
|
+
```ruby
|
|
524
|
+
repo.cat_snapshot(snapshot) # => raw stored record, as a Hash
|
|
525
|
+
repo.cat_tree(snapshot, subfolder: "var/log")
|
|
526
|
+
repo.cat_pack(pack_id) { |io| ... } # raw, still-encrypted bytes
|
|
527
|
+
repo.cat_blob(blob_id) { |io| ... } # raw, decrypted bytes
|
|
528
|
+
repo.list(:packs) # => Array of pack IDs
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
`#cat_snapshot`/`#cat_index`/`#cat_key`/`#cat_tree` return a repository object's raw stored record as a Hash, lower-level than `#snapshots`/`#key_list`, which wrap entries in `Snapshot`/`Key` instead. `#cat_snapshot`/`#cat_key`/`#cat_tree` each accept either a bare ID String or the corresponding wrapper object (`Snapshot`/`Key`/`Snapshot`) directly; `#cat_index` only takes a bare ID String, since index entries have no wrapper class of their own. `#cat_pack`/`#cat_blob` are raw-bytes commands with the same block/`IOHandle` shape as `#dump`. `#list(type)` enumerates every object ID of a given type (`:blobs`/`:packs`/`:index`/`:snapshots`/`:keys`/`:locks`) as plain strings. Restic ignores `--json` for this command, so these come back exactly as Restic prints them.
|
|
532
|
+
|
|
533
|
+
`#cat_masterkey_and_game_over_if_this_leaks` returns the repository's actual encryption/MAC key material: not a redacted reference, the real thing. There is no operation that rotates this key; every password change made afterward protects nothing already backed up if this value ever leaks. Treat the return value with more care than you'd give `password:` itself: unlike a password, this can't be rotated away after a leak.
|
|
534
|
+
|
|
535
|
+
## Across repositories
|
|
536
|
+
|
|
537
|
+
### `#copy`
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
repo.copy(from_repo: source_repo, hosts: "web-1")
|
|
541
|
+
```
|
|
542
|
+
|
|
543
|
+
Replicates snapshots from another repository into this one. `from_repo:` accepts a plain URL String or a `Repository` instance. Given the latter, its own `#url`/`#password` are used automatically unless `from_password:` is given explicitly. `from_key_hint:`/`from_repository_file:` are passed straight through to Restic (a key ID hint for the source repository, and reading the source URL from a file, respectively). Already-copied snapshots are skipped automatically on a repeat run. Returns `true`.
|
|
544
|
+
|
|
545
|
+
## Mounting
|
|
546
|
+
|
|
547
|
+
### `#mount`
|
|
548
|
+
|
|
549
|
+
```ruby
|
|
550
|
+
repo.mount(mountpoint: "/mnt/restic") do |mount|
|
|
551
|
+
Dir.children("#{mount.mountpoint}/snapshots/latest")
|
|
552
|
+
end
|
|
553
|
+
```
|
|
554
|
+
|
|
555
|
+
Serves the repository as a read-only FUSE filesystem at `mountpoint` (which must already exist). With a block, yields a `Yobi::Mount` and stops it automatically afterward. Without one, returns the `Mount` for you to `#stop` yourself:
|
|
556
|
+
|
|
557
|
+
```ruby
|
|
558
|
+
mount = repo.mount(mountpoint: "/mnt/restic")
|
|
559
|
+
begin
|
|
560
|
+
# ... the mount stays live until something calls #stop ...
|
|
561
|
+
ensure
|
|
562
|
+
mount.stop
|
|
563
|
+
end
|
|
564
|
+
```
|
|
565
|
+
|
|
566
|
+
`#stop` is safe to call more than once, and `Mount#pid`/`Mount#stop` are safe even if something *else* already triggered the unmount externally (`kill -INT <pid>`, or the OS's own `umount`/`fusermount` directly). `hosts:`/`paths:`/`tags:` restrict which snapshots appear under the mount's own `snapshots/` directory; `path_templates:`/`time_template:` control the directory naming scheme Restic generates there. `allow_other:`/`no_default_permissions:`/`owner_root:` are FUSE-level access options (allow other users to access the mount, skip permission checks, mount files as owned by root, respectively). `ready_timeout:` (default 10 seconds) bounds how long `#mount` waits for Restic's own readiness message before raising `Yobi::MountTimeout`.
|
|
567
|
+
|
|
568
|
+
## Restic-level (not scoped to a repository)
|
|
569
|
+
|
|
570
|
+
```ruby
|
|
571
|
+
restic = Yobi::Restic.new
|
|
572
|
+
restic.version # => {"version"=>"0.19.1", "go_version"=>"...", ...}
|
|
573
|
+
restic.cache(cleanup: true)
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
`#version` and `#cache` are the two Restic subcommands that don't touch a repository at all, so they live on `Yobi::Restic` rather than `Repository`. `#cache` lists (or, with `cleanup: true`, cleans) local cache directories, returning `true`. `max_age:` limits cleanup to caches older than the given duration; `no_size:` skips computing directory sizes, for a faster listing.
|
|
577
|
+
|
|
578
|
+
## Global `Restic` settings
|
|
579
|
+
|
|
580
|
+
Settings that apply regardless of which repository is being operated on live on `Yobi::Restic`, not `Repository`:
|
|
581
|
+
|
|
582
|
+
```ruby
|
|
583
|
+
restic = Yobi::Restic.new(
|
|
584
|
+
cache_dir: "/var/cache/restic",
|
|
585
|
+
limit_upload: 5000,
|
|
586
|
+
no_lock: true
|
|
587
|
+
)
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
Two genuinely different kinds, both exposed as plain `attr_accessor`s:
|
|
591
|
+
|
|
592
|
+
- **Env-var-backed**: `cache_dir:`, `compression:`, `pack_size:`, `read_concurrency:`, `host:`, `progress_fps:`, `cacert:`, `tls_client_cert:`, `key_hint:`. `Restic#env` computes the corresponding `RESTIC_*` vars fresh from the current accessor values on every call. Mutating one after construction is picked up by the very next command.
|
|
593
|
+
- **CLI-flag-only, no env var equivalent**: `limit_download:`, `limit_upload:`, `retry_lock:`, `no_lock:`, `no_cache:`, `cleanup_cache:`, `no_extra_verify:`, `stuck_request_timeout:`, `options:` (Restic's own `--option`/`-o`, an escape hatch for backend-specific tuning like `s3.connections=10`), `http_user_agent:`, `quiet:`.
|
|
594
|
+
|
|
595
|
+
"Picked up by the very next command" assumes calls happen one at a time. If you mutate one of these accessors on a `Restic` shared across threads while another thread has a command already in flight on it, which value that in-flight command actually uses is a race with no ordering guarantee, though not a crash: each accessor read/write is still atomic, so you won't get a corrupted argv, just an unpredictable choice between the old and new value.
|
|
596
|
+
|
|
597
|
+
### Sharing a `Restic` across repositories
|
|
598
|
+
|
|
599
|
+
```ruby
|
|
600
|
+
restic = Yobi::Restic.new(limit_upload: 5000)
|
|
601
|
+
|
|
602
|
+
customer_a = Yobi::Repository.new(url: "s3:.../customer-a", password: "...", restic: restic)
|
|
603
|
+
customer_b = Yobi::Repository.new(url: "s3:.../customer-b", password: "...", restic: restic)
|
|
604
|
+
```
|
|
605
|
+
|
|
606
|
+
Both repositories now use the same `limit_upload:` value, and changing `restic.limit_upload` later affects every repository built from it. This isn't a shared, pooled cap: Restic applies `--limit-upload` per invocation, so two backups running concurrently through this same `restic` are each independently capped at 5000, not splitting one combined 5000 between them. Useful when one process backs up many repositories under one operational policy, without repeating the same settings on every `Repository.new` call.
|
|
607
|
+
|
|
608
|
+
### Minimum-version enforcement, in detail
|
|
609
|
+
|
|
610
|
+
`Yobi::Restic#run`/`#run_dump`/`#run_mount` all call `#ensure_minimum_version!` before executing a real command. It's memoized per `Restic` instance. The check runs once, not on every call, since the binary on disk isn't expected to change mid-process. See ["Minimum supported version"](#minimum-supported-version) above for what triggers it and why 0.17.1 specifically.
|
|
611
|
+
|
|
612
|
+
## Redaction
|
|
613
|
+
|
|
614
|
+
Both `Repository#inspect` and `Restic#inspect` redact sensitive values, so a stray `pp`/`puts`/log call never prints a credential in plaintext:
|
|
615
|
+
|
|
616
|
+
```ruby
|
|
617
|
+
repo.inspect
|
|
618
|
+
# => #<Yobi::Repository url="s3:..." password="[FILTERED]" backend_credentials={"AWS_ACCESS_KEY_ID"=>"[FILTERED]", "AWS_SECRET_ACCESS_KEY"=>"[FILTERED]"}>
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
A literal or command/file-sourced `password:` shows as `"[FILTERED]"`; anything callable shows as `"[RESOLVER]"` **without ever being called**. Printing a `Repository` never triggers a real secret-store lookup as a side effect. `:insecure_no_password` shows plainly, since it's an explicit "there is no secret" declaration, not a value that could leak anything. `backend_credentials:` keys not in Restic's own documented list of non-identity operational env vars (`Yobi::Restic::ALLOWED_ENV_VARS`) are filtered the same way; the allowed ones (`RESTIC_CACHE_DIR`, `AWS_DEFAULT_REGION`, and similar non-secret operational vars) stay visible.
|
|
622
|
+
|
|
623
|
+
`#env` itself is never redacted. It's the method that actually builds what gets passed to `Process.spawn`, so it has to return the real values. Only `#inspect` (the thing a stray debug print might accidentally call) filters.
|
|
624
|
+
|
|
625
|
+
## Recipes
|
|
626
|
+
|
|
627
|
+
Examples for common day-to-day scenarios.
|
|
628
|
+
|
|
629
|
+
### Scheduled backup with retention
|
|
630
|
+
|
|
631
|
+
A nightly job (cron, `whenever`, a Sidekiq-cron/`sidekiq-scheduler` job, whatever runs on your schedule):
|
|
632
|
+
|
|
633
|
+
```ruby
|
|
634
|
+
repo = Yobi::Repository.new(
|
|
635
|
+
url: ENV.fetch("RESTIC_REPOSITORY"),
|
|
636
|
+
password: [:file, "/etc/restic/password"]
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
outcome = repo.backup(
|
|
640
|
+
source: "/var/myapp/uploads",
|
|
641
|
+
tags: ["myapp", "nightly"],
|
|
642
|
+
exclude_caches: true
|
|
643
|
+
)
|
|
644
|
+
|
|
645
|
+
unless outcome.success?
|
|
646
|
+
outcome.errors.each { |e| log_error("backup: #{e.item}: #{e.message}") }
|
|
647
|
+
end
|
|
648
|
+
|
|
649
|
+
repo.forget(
|
|
650
|
+
keep_daily: 7,
|
|
651
|
+
keep_weekly: 4,
|
|
652
|
+
keep_monthly: 12,
|
|
653
|
+
tags: ["myapp"],
|
|
654
|
+
prune: true
|
|
655
|
+
)
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
`outcome.partial?` (exit code 3) means some files were skipped but the snapshot was still created (often fine to continue past, e.g. permission-denied on a handful of files); a full failure raises before `outcome` even exists via the usual `Yobi::ResticCommandFailed`/`Yobi::RepositoryLocked`/etc. hierarchy, so wrap the whole thing in the usual `rescue` if you want to alert on that separately (see ["Graceful lock-contention handling"](#graceful-lock-contention-handling) below).
|
|
659
|
+
|
|
660
|
+
### Restore workflows
|
|
661
|
+
|
|
662
|
+
**Restoring the latest snapshot**, the common case:
|
|
663
|
+
|
|
664
|
+
```ruby
|
|
665
|
+
repo.restore(snapshot_id: "latest", target: "/var/myapp/uploads")
|
|
666
|
+
```
|
|
667
|
+
|
|
668
|
+
**Restoring a specific snapshot**, found by filtering:
|
|
669
|
+
|
|
670
|
+
```ruby
|
|
671
|
+
snapshot = repo.snapshots(tags: "nightly").max_by(&:time)
|
|
672
|
+
repo.restore(snapshot_id: snapshot.id, target: "/tmp/restore-#{snapshot.short_id}")
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
**Browsing before committing to a restore**: mount the repository and look around, rather than restoring blind:
|
|
676
|
+
|
|
677
|
+
```ruby
|
|
678
|
+
repo.mount(mountpoint: "/mnt/restic") do |mount|
|
|
679
|
+
Dir.children("#{mount.mountpoint}/snapshots/latest/var/myapp/uploads")
|
|
680
|
+
end
|
|
681
|
+
```
|
|
682
|
+
|
|
683
|
+
or list without mounting anything:
|
|
684
|
+
|
|
685
|
+
```ruby
|
|
686
|
+
repo.ls(snapshot_id: "latest", dirs: "/var/myapp/uploads").entries.each do |entry|
|
|
687
|
+
puts "#{entry.type} #{entry.size} #{entry.path}"
|
|
688
|
+
end
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
**Partial restore**: only what you actually need back:
|
|
692
|
+
|
|
693
|
+
```ruby
|
|
694
|
+
repo.restore(
|
|
695
|
+
snapshot_id: "latest",
|
|
696
|
+
target: "/tmp/restore",
|
|
697
|
+
includes: "/var/myapp/uploads/2026-*"
|
|
698
|
+
)
|
|
699
|
+
```
|
|
700
|
+
|
|
701
|
+
### Credentials from a secret store
|
|
702
|
+
|
|
703
|
+
Any callable works for `password:`/`backend_credentials:`. It's invoked fresh immediately before every Restic command, never cached by Yobi itself:
|
|
704
|
+
|
|
705
|
+
```ruby
|
|
706
|
+
repo = Yobi::Repository.new(
|
|
707
|
+
url: "s3:s3.amazonaws.com/my-bucket",
|
|
708
|
+
password: -> { Rails.application.credentials.dig(:restic, :password) },
|
|
709
|
+
backend_credentials: -> {
|
|
710
|
+
{
|
|
711
|
+
"AWS_ACCESS_KEY_ID" => Rails.application.credentials.dig(:aws, :access_key_id),
|
|
712
|
+
"AWS_SECRET_ACCESS_KEY" => Rails.application.credentials.dig(:aws, :secret_access_key)
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
)
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
Or from Vault:
|
|
719
|
+
|
|
720
|
+
```ruby
|
|
721
|
+
password: -> {
|
|
722
|
+
Vault.logical.read("secret/data/restic")&.data&.dig(:data, :password)
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
If the resolver itself is slow or rate-limited (a network round-trip per Restic invocation adds up over many commands), add your own caching inside the lambda; how long the result stays valid is entirely your call.
|
|
727
|
+
|
|
728
|
+
### Database dumps via `stdin_from_command`
|
|
729
|
+
|
|
730
|
+
Backing up a live database consistently means dumping it first, not copying its on-disk files while they might be mid-write. `source: [:stdin_from_command, ...]` has Restic spawn the dump command itself and back up its stdout directly, with no intermediate file ever touching disk:
|
|
731
|
+
|
|
732
|
+
```ruby
|
|
733
|
+
repo.backup(
|
|
734
|
+
source: [:stdin_from_command, "pg_dump --no-owner mydb", "mydb.sql"],
|
|
735
|
+
tags: ["mydb", "nightly"]
|
|
736
|
+
)
|
|
737
|
+
```
|
|
738
|
+
|
|
739
|
+
The third element (`"mydb.sql"`) names the resulting virtual file inside the snapshot. Omit it to fall back to Restic's own `"stdin"` default. If the command needs an argument containing a literal space (a password with a space in it, an unusual path), pass it as an Array of already-discrete arguments instead of a single String, since `Shellwords`-splitting a string would otherwise break it apart incorrectly. There's no shell involved either way (Restic execs the tokenized argv directly), but if any part of the String form is ever built from untrusted input rather than a hardcoded command like above, `Shellwords.split` will still tokenize whatever that input contains, letting it inject extra arguments into what gets executed. Use the Array form with untrusted values kept to their own discrete elements instead.
|
|
740
|
+
|
|
741
|
+
```ruby
|
|
742
|
+
repo.backup(source: [:stdin_from_command, ["mysqldump", "-u", "backup", "my app db"]])
|
|
743
|
+
```
|
|
744
|
+
|
|
745
|
+
If the dump command itself fails partway (wrong credentials, database down), Restic detects the non-zero exit and does **not** persist a snapshot. It raises `Yobi::ResticCommandFailed` the same as any other command failure, so a broken dump never silently becomes a "successful" backup of garbage data.
|
|
746
|
+
|
|
747
|
+
### Live progress reporting
|
|
748
|
+
|
|
749
|
+
Streaming `#backup`'s block into something other than stdout: a logger, a progress bar, a broadcast to a browser:
|
|
750
|
+
|
|
751
|
+
```ruby
|
|
752
|
+
# A logger
|
|
753
|
+
repo.backup(source: source_path) do |message|
|
|
754
|
+
case message
|
|
755
|
+
when Yobi::BackupStatus
|
|
756
|
+
logger.info("backup: #{((message.percent_done || 0) * 100).round}%")
|
|
757
|
+
when Yobi::BackupError
|
|
758
|
+
logger.error("backup: #{message.item}: #{message.message}")
|
|
759
|
+
end
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
# ActionCable, for a live-updating admin dashboard
|
|
763
|
+
repo.backup(source: source_path) do |message|
|
|
764
|
+
next unless message.is_a?(Yobi::BackupStatus)
|
|
765
|
+
|
|
766
|
+
BackupChannel.broadcast_to(job, {
|
|
767
|
+
percent: message.percent_done,
|
|
768
|
+
files_done: message.files_done,
|
|
769
|
+
total_files: message.total_files
|
|
770
|
+
})
|
|
771
|
+
end
|
|
772
|
+
```
|
|
773
|
+
|
|
774
|
+
### Multi-repository operations
|
|
775
|
+
|
|
776
|
+
One `Restic` (holding shared operational settings) backing many `Repository` instances, e.g. one repository per customer or per managed server:
|
|
777
|
+
|
|
778
|
+
```ruby
|
|
779
|
+
restic = Yobi::Restic.new(limit_upload: 10_000, cache_dir: "/var/cache/restic")
|
|
780
|
+
|
|
781
|
+
def repository_for(customer)
|
|
782
|
+
Yobi::Repository.new(
|
|
783
|
+
url: "s3:s3.amazonaws.com/backups/#{customer.id}",
|
|
784
|
+
password: -> { customer.restic_password },
|
|
785
|
+
restic: restic
|
|
786
|
+
)
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
Customer.find_each do |customer|
|
|
790
|
+
repository_for(customer).backup(source: customer.data_path, tags: [customer.id.to_s])
|
|
791
|
+
end
|
|
792
|
+
```
|
|
793
|
+
|
|
794
|
+
Running commands concurrently through a shared `Repository`/`Restic` is safe (see ["Global `Restic` settings"](#global-restic-settings) above for the one caveat, mutating `restic`'s tuning accessors from another thread while backups are in flight), so backing up many repositories doesn't have to run strictly one at a time. A `Thread.new` per customer would spawn an unbounded number of concurrent `restic` subprocesses, and Ruby silently drops an unhandled exception from a thread that's never joined, so a single customer's failure could vanish instead of getting logged. A small fixed-size worker pool avoids both:
|
|
795
|
+
|
|
796
|
+
```ruby
|
|
797
|
+
queue = Queue.new
|
|
798
|
+
Customer.find_each { |customer| queue << customer }
|
|
799
|
+
queue.close
|
|
800
|
+
|
|
801
|
+
workers = 4.times.map do
|
|
802
|
+
Thread.new do
|
|
803
|
+
while (customer = queue.pop)
|
|
804
|
+
begin
|
|
805
|
+
repository_for(customer).backup(source: customer.data_path, tags: [customer.id.to_s])
|
|
806
|
+
rescue Yobi::Error => e
|
|
807
|
+
log_error("backup failed for customer #{customer.id}: #{e.message}")
|
|
808
|
+
end
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
end
|
|
812
|
+
workers.each(&:join)
|
|
813
|
+
```
|
|
814
|
+
|
|
815
|
+
**Copying between repositories** (e.g. replicating to a second, offsite backend for a 3-2-1 strategy):
|
|
816
|
+
|
|
817
|
+
```ruby
|
|
818
|
+
offsite = Yobi::Repository.new(url: "b2:my-bucket:/", password: "...")
|
|
819
|
+
offsite.copy(from_repo: primary_repo, tags: "nightly")
|
|
820
|
+
```
|
|
821
|
+
|
|
822
|
+
### Repository maintenance and health checks
|
|
823
|
+
|
|
824
|
+
A separate, less-frequent job (weekly, say) than the nightly backup itself:
|
|
825
|
+
|
|
826
|
+
```ruby
|
|
827
|
+
check = repo.check(read_data_subset: "10%")
|
|
828
|
+
if check.report["num_errors"].to_i.positive?
|
|
829
|
+
check.errors.each { |e| AlertService.notify("Restic check: #{e.message}") }
|
|
830
|
+
end
|
|
831
|
+
|
|
832
|
+
repo.prune(max_unused: "5%")
|
|
833
|
+
```
|
|
834
|
+
|
|
835
|
+
`read_data_subset:` rotates through the repository over time (e.g. `"1/20"` today, `"2/20"` tomorrow) rather than reading everything in one run, so a full verification eventually happens without one job spending hours reading the entire dataset.
|
|
836
|
+
|
|
837
|
+
### Key rotation
|
|
838
|
+
|
|
839
|
+
```ruby
|
|
840
|
+
repo.key_passwd(new_password_file: "/etc/restic/new-password")
|
|
841
|
+
|
|
842
|
+
# repo itself still holds the OLD password - rebuild before continuing
|
|
843
|
+
rotated_repo = Yobi::Repository.new(
|
|
844
|
+
url: repo.url,
|
|
845
|
+
password: [:file, "/etc/restic/new-password"]
|
|
846
|
+
)
|
|
847
|
+
```
|
|
848
|
+
|
|
849
|
+
This rotates the *password*, not the underlying encryption key itself (see ["Key management"](#key-management) above for why `repo` still needs rebuilding afterward). See ["Low-level (`cat`) and object listing"](#low-level-cat-and-object-listing)'s note on `#cat_masterkey_and_game_over_if_this_leaks` if that distinction matters for your threat model.
|
|
850
|
+
|
|
851
|
+
### Graceful lock-contention handling
|
|
852
|
+
|
|
853
|
+
Two processes touching the same repository at once (a backup job and a prune job overlapping, say): retry instead of failing the whole run:
|
|
854
|
+
|
|
855
|
+
```ruby
|
|
856
|
+
def with_retry(max_attempts: 3, delay: 30)
|
|
857
|
+
attempts = 0
|
|
858
|
+
begin
|
|
859
|
+
yield
|
|
860
|
+
rescue Yobi::RepositoryLocked
|
|
861
|
+
attempts += 1
|
|
862
|
+
raise if attempts >= max_attempts
|
|
863
|
+
|
|
864
|
+
sleep(delay + rand(delay)) # jittered, so lockstep retries against the same lock spread out
|
|
865
|
+
retry
|
|
866
|
+
end
|
|
867
|
+
end
|
|
868
|
+
|
|
869
|
+
with_retry { repo.backup(source: source_path) }
|
|
870
|
+
```
|
|
871
|
+
|
|
872
|
+
For a one-off manual fix instead of retrying, `repo.unlock` removes stale locks left by a process that crashed without cleaning up after itself, but don't call it reflexively inside a retry loop that might be racing a *legitimately still-running* second process; that would double-run the backup.
|
|
873
|
+
|
|
874
|
+
## Non-goals
|
|
875
|
+
|
|
876
|
+
- **Windows.** Relies on `Process.spawn`/POSIX `unlink` semantics; only verified on Linux and macOS so far.
|
|
877
|
+
- **Bundled secret-vault integration.** Credentials are your own responsibility. Pass a callable for `password:`/`backend_credentials:` if you need one resolved from Vault, 1Password, or similar (see ["Credentials from a secret store"](#credentials-from-a-secret-store) above).
|
|
878
|
+
- **A built-in scheduler/orchestrator.** Yobi runs a command when you call it; deciding when that happens (cron, `whenever`, a Sidekiq-cron job) is your own responsibility, see ["Scheduled backup with retention"](#scheduled-backup-with-retention) above.
|
|
879
|
+
|
|
880
|
+
## Contributing
|
|
881
|
+
|
|
882
|
+
Bug reports and pull requests are welcome at https://codeberg.org/ukazap/yobi.
|
|
883
|
+
|
|
884
|
+
After checking out the repo, run `bin/setup` to install dependencies.
|
|
885
|
+
|
|
886
|
+
- `bin/test-unit`: the unit suite, no I/O, no Restic/minio needed.
|
|
887
|
+
- `bin/test-integration`: the integration suite, shelling out to a real Restic binary; skipped automatically if Restic isn't installed.
|
|
888
|
+
- `bin/test`: both, in parallel.
|
|
889
|
+
- `bin/standardrb`: lint.
|
|
890
|
+
- `bin/console`: an interactive prompt with the gem loaded.
|
|
891
|
+
- `bin/docs`: serves YARD docs at http://localhost:8808, reparsing on every request.
|
|
892
|
+
|
|
893
|
+
See `MAINTAINERS.md` for what to keep in sync as the API changes, and the release checklist.
|
|
894
|
+
|
|
895
|
+
## License
|
|
896
|
+
|
|
897
|
+
The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
|