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.
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).