easy_sync 0.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +8 -0
  3. data/.rspec +2 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +85 -0
  6. data/CLAUDE.md +133 -0
  7. data/Gemfile +2 -0
  8. data/README.md +607 -21
  9. data/Rakefile +6 -8
  10. data/bin/easy_sync +8 -1
  11. data/docs/fine-placement.md +174 -0
  12. data/docs/hardware-notes.md +71 -0
  13. data/docs/integrity-scan.md +435 -0
  14. data/docs/parallel-scrub.md +280 -0
  15. data/docs/performance.md +146 -0
  16. data/docs/tripwire.md +232 -0
  17. data/easy_sync.gemspec +25 -16
  18. data/lib/easy_sync/cli.rb +1246 -0
  19. data/lib/easy_sync/config.rb +202 -0
  20. data/lib/easy_sync/error.rb +5 -0
  21. data/lib/easy_sync/jbod/backblaze.rb +113 -0
  22. data/lib/easy_sync/jbod/benchmarker.rb +149 -0
  23. data/lib/easy_sync/jbod/cleaner.rb +93 -0
  24. data/lib/easy_sync/jbod/dashboard.rb +611 -0
  25. data/lib/easy_sync/jbod/keep_awake.rb +33 -0
  26. data/lib/easy_sync/jbod/manifest.rb +1068 -0
  27. data/lib/easy_sync/jbod/mirror.rb +204 -0
  28. data/lib/easy_sync/jbod/models.rb +165 -0
  29. data/lib/easy_sync/jbod/page_cache.rb +56 -0
  30. data/lib/easy_sync/jbod/placement.rb +71 -0
  31. data/lib/easy_sync/jbod/planner.rb +76 -0
  32. data/lib/easy_sync/jbod/purger.rb +161 -0
  33. data/lib/easy_sync/jbod/restorer.rb +142 -0
  34. data/lib/easy_sync/jbod/run_lock.rb +98 -0
  35. data/lib/easy_sync/jbod/run_log.rb +57 -0
  36. data/lib/easy_sync/jbod/runner.rb +615 -0
  37. data/lib/easy_sync/jbod/scrub_pool.rb +136 -0
  38. data/lib/easy_sync/jbod/scrubber.rb +321 -0
  39. data/lib/easy_sync/jbod/share_scan.rb +26 -0
  40. data/lib/easy_sync/jbod/sync_eta.rb +68 -0
  41. data/lib/easy_sync/jbod/tripwire.rb +80 -0
  42. data/lib/easy_sync/jbod/volume_info.rb +283 -0
  43. data/lib/easy_sync/jbod.rb +35 -0
  44. data/lib/easy_sync/shell.rb +62 -0
  45. data/lib/easy_sync/templates/dashboard.html.erb +419 -0
  46. data/lib/easy_sync/version.rb +3 -1
  47. data/lib/easy_sync.rb +7 -3
  48. metadata +84 -45
  49. data/Gemfile.lock +0 -55
  50. data/Guardfile +0 -28
  51. data/lib/easy_sync/rsync.rb +0 -60
  52. data/lib/easy_sync/sync_logger.rb +0 -20
  53. data/lib/easy_sync/sync_runner.rb +0 -44
  54. data/spec/easy_sync/rsync_spec.rb +0 -25
  55. data/spec/easy_sync/sync_runner_spec.rb +0 -54
  56. data/spec/spec_helper.rb +0 -33
  57. data/spec/support.rb +0 -89
data/README.md CHANGED
@@ -1,35 +1,621 @@
1
1
  easy_sync
2
2
  =========
3
3
 
4
- ### Why?
5
- * Did you just get a shiny new 1TB 2.5 inch external hard drive and suddenly have the urge to back :poop: up?
6
- * Did you just hear about [CryptoLocker](http://en.wikipedia.org/wiki/CryptoLocker) and thought crap how can i protect my friends and family?
4
+ Backs up a NAS onto a set of independent drives, one folder at a time.
7
5
 
8
- **Note:** Once you get [CryptoLocker](http://en.wikipedia.org/wiki/CryptoLocker) it will encrypt the crap out
9
- of any drive letter it can find including mapped network shares :cry:
6
+ The drives are plain APFS volumes of different sizes, each used to the full, no
7
+ RAID. `easy_sync` decides which drive each folder lives on, mirrors it there with
8
+ `rsync`, remembers the placement in a SQLite manifest, waits out a grace period
9
+ before deleting anything, and writes an HTML dashboard with each drive's SMART
10
+ health. One folder always lives whole on one drive; `easy_sync restore` copies
11
+ folders back the other way, onto the NAS, when you need to repopulate it (see
12
+ "Restoring the NAS" below).
10
13
 
11
- Now if you use **Ruby** plus **Rsync** you can easily have many cold snapshots to restore from :thumbsup:
14
+ **macOS only.** It leans on `diskutil` for APFS volume identity and lock state
15
+ and on `caffeinate` to keep the Mac awake, so it needs macOS 10.13 High Sierra
16
+ or later (the first release with APFS on external drives). It is developed and
17
+ tested on macOS 26. Also needed: Ruby 3.3 or newer and rsync 3.0 or newer
18
+ (`brew install rsync`; the copy macOS ships is too old). `smartctl`
19
+ (`brew install smartmontools`) is optional and adds drive health.
20
+ Backblaze Personal is
21
+ optional too: if it backs up the drives, easy_sync reads its local state
22
+ (read-only) to show whether each drive has finished uploading, and `eject`
23
+ asks before powering off one that hasn't (see "Dashboard" below).
12
24
 
25
+ Quick start
26
+ -----------
13
27
 
14
- ### Installation
15
28
  gem install easy_sync
16
29
 
30
+ easy_sync add-source /Volumes/tv # once per NAS share, mounted on the Mac
31
+ easy_sync add-source /Volumes/movies
32
+ easy_sync register-drive /Volumes/backup-01-3tb # once per drive, while mounted
33
+ easy_sync plan # measures each share, checks every folder fits a drive
34
+ easy_sync sync --dry-run # what would be placed and copied, nothing written
35
+ easy_sync sync # the real thing
36
+ open ~/.easy_sync/dashboard.html
17
37
 
18
- ### Requirements
19
- * rsync 2.5.6 and up
38
+ Mount and unlock the drives yourself first; the tool never unlocks anything.
20
39
 
40
+ Configuration
41
+ -------------
21
42
 
22
- ### Usage
23
- Just run **easy_sync** to generate a template mapping file, configure
24
- your paths and next time you run **easy_sync** it will create the first
25
- backup. After first backup it will create incremental backups.
43
+ The first command you run, even a bare `easy_sync`, creates
44
+ `~/.easy_sync/config.yml`. You don't need to edit it: `add-source` and
45
+ `remove-source` maintain it for you, and `sources` lists it.
46
+ The file stays readable and commented if you want to change the other settings
47
+ by hand:
26
48
 
49
+ ```yaml
50
+ :sources: # each NAS share, as mounted on the Mac
51
+ - :path: "/Volumes/photos"
52
+ - :path: "/Volumes/tv"
53
+ - :path: "/Volumes/movies"
54
+ :mount_root: "/Volumes" # where the backup drives appear
55
+ :manifest_path: "~/.easy_sync/manifest.sqlite3"
56
+ :dashboard_path: "~/.easy_sync/dashboard.html"
57
+ :lock_path: "~/.easy_sync/jbod.lock" # refuses a second concurrent sync
58
+ :log_dir: "~/.easy_sync/logs" # one log per sync run
59
+ :keep_logs: 20
60
+ :reserve: "2gb" # headroom placement always leaves on every drive
61
+ :keep_awake: true # caffeinate for the length of a sync
62
+ :purge: true # delete from the drives only after...
63
+ :grace_days: 7 # ...this many days missing on the NAS
64
+ :grace_runs: 2 # ...confirmed on this many separate runs
65
+ :scrub_stale_days: 30 # a drive is overdue for `scrub` after this many days unchecked
66
+ :scrub_jobs: 4 # `scrub --all`/named targets scrub this many drives at once by default
67
+ :tripwire_enforce: false # false: the tripwire only reports what would have stopped a sync
68
+ :tripwire_run_files: 500 # stop a sync that would change this many existing files (0: off)
69
+ :tripwire_folder_files: 50 # hold back a folder with at least this many changed files...
70
+ :tripwire_folder_ratio: 0.25 # ...that are at least this share of its files
71
+ :exclude_folders: ["#recycle", "@eaDir", ".DS_Store", ".sync", ".TemporaryItems", ".Trashes",
72
+ ".smbdelete*", ".com.apple.timemachine.supported*", ".Spotlight-V100", ".fseventsd"]
73
+ # never placed, and excluded from every rsync at any depth
74
+ :rsync_args: [] # extra arguments appended to every rsync
75
+ ```
27
76
 
28
- ### Todo
29
- * ~~Given a source and destination a snapshot should be created.~~
30
- * ~~It should use the latest backup for Rsync's --link-dest option and create a new snapshot with the files that changed.~~
31
- * ~~Add logging~~
32
- * If source or destination doesn't exists don't run rsync
33
- * ~~excluded list~~
34
- * ~~Support multiple source and destination configurations by using a yaml config file.~~
35
- * ~~Convert to a gem and create a easy_sync bin file~~
77
+ To keep a test setup apart from the real one, pass `--config PATH` before the
78
+ command or set `EASY_SYNC_CONFIG`:
79
+
80
+ easy_sync --config ~/jbod-test.yml sync
81
+
82
+
83
+ Drives
84
+ ------
85
+
86
+ Any enclosure works as long as each drive mounts as its own independent APFS
87
+ volume rather than a RAID array — a multi-bay Thunderbolt dock like the
88
+ [OWC ThunderBay 8](https://www.owc.com/solutions/thunderbay-8) run in JBOD
89
+ ("just a bunch of disks") mode, one volume per bay with no SoftRAID array
90
+ across them, is a natural fit for a fleet that grows one drive at a time.
91
+
92
+ A drive's friendly name defaults to its volume name, so naming the volume
93
+ itself when you erase it (Disk Utility, or `diskutil apfs addVolume`) is
94
+ usually enough — no need for `--name`. `backup-0N-<capacity>` (sequence
95
+ number, then a size hint) is the convention used throughout this README:
96
+ `backup-01-3tb`, `backup-02-6tb`, `backup-03-8tb`, and so on. The name is
97
+ just a label for `status`, the dashboard and `replace-drive`; drives are
98
+ matched by the serial in `.easy_sync/drive.json` (see below), never by name
99
+ or mount path, so renaming a volume later is safe. To change the label
100
+ easy_sync itself uses, without touching the volume: `easy_sync rename-drive
101
+ OLD NEW`; naming NEW after another registered drive swaps the two instead of
102
+ erroring, done as one transaction so you never have to pick a temporary name
103
+ yourself. It never renames the actual macOS volume (the tool never mutates
104
+ disk state, the same way it never unlocks one) — it prints the `diskutil
105
+ rename` command to run yourself if you want that to match too.
106
+
107
+ easy_sync register-drive /Volumes/backup-04-8tb
108
+ easy_sync register-drive /Volumes/backup-01-3tb --name drive-one --serial WD-WX12345678
109
+
110
+ Registering records the drive's serial, name and capacity in the manifest and
111
+ creates `.easy_sync/` at the root of the volume. The serial comes from `smartctl`
112
+ when the enclosure passes SMART through, otherwise from the APFS Volume UUID;
113
+ `--serial` overrides both.
114
+
115
+ **Drives are recognised by that folder, never by mount path.** Every run scans
116
+ the mount root and matches each drive by the serial in its `.easy_sync/drive.json`.
117
+ If macOS mounts a drive as `/Volumes/backup-02-6tb 1`, or the drives come up in
118
+ a different order, the data still goes to the right one. A volume with no marker,
119
+ or a marker the manifest doesn't know, is never written to.
120
+
121
+ After every sync each mounted drive's `.easy_sync/` also receives a fresh copy
122
+ of the manifest and the config, so any single surviving drive can rebuild the
123
+ map of where everything lives even without the Mac.
124
+
125
+ Sources and placement
126
+ ---------------------
127
+
128
+ Each top-level folder of a share is placed on its own and lands at
129
+ `/Volumes/<drive>/tv/<Show Name>`. Loose files at the top of a share (not in
130
+ any folder) are backed up too, together, as one more unit that lands in
131
+ `/Volumes/<drive>/tv/` beside the folders; the dashboard lists it as
132
+ `tv (loose files)`. There is no setting to choose: this is what mergerfs and
133
+ unRAID do, and it means a share can be any size while every drive stays
134
+ readable on its own in Finder.
135
+
136
+ A new folder goes to a drive that already holds part of the same share, if it
137
+ fits there, so a share stays together on one drive for as long as that drive
138
+ has room and only spills onto another when it must. Otherwise, or for a
139
+ share's first folder, it goes to the mounted drive with the most free space.
140
+ Either way it has to fit while leaving `reserve` (2 GB by default) untouched
141
+ for APFS metadata, the drive's `.easy_sync/` copies and rsync's temporary
142
+ files. A folder with no real files on the NAS (a show folder left holding only
143
+ a `.DS_Store`) is not placed; the run says so.
144
+
145
+ Once placed, a folder never moves on its own: there is no automatic
146
+ rebalancing. Moving data between drives costs time, needs both drives
147
+ connected, and makes an offsite backup taken from the drives (such as
148
+ Backblaze Personal) upload it all again, so it only happens when you ask. To move a folder, or
149
+ every folder of a share, run `easy_sync reassign FOLDER|SHARE DRIVE --copy`:
150
+ it copies drive-to-drive (much faster than the NAS), and the next sync only
151
+ confirms the copy. The old copy is removed later, after the grace period (see
152
+ "Deletions have a grace period" below).
153
+
154
+ `easy_sync plan` measures every share (seconds for thousands of single-file
155
+ movie folders, minutes for a share with hundreds of thousands of files) and
156
+ warns about any single folder that is bigger than the largest drive, which
157
+ could not be placed anywhere. Pass `--largest-drive 8tb` before any drive is
158
+ registered, and name one or more shares (by folder name or full path, e.g.
159
+ `easy_sync plan pro`) to measure just those. It only reads.
160
+
161
+ A sync run
162
+ ----------
163
+
164
+ easy_sync sync # --dry-run previews; --no-purge skips deletions this time
165
+ easy_sync sync --accept-changes [FOLDER ...] # let through a bulk change the tripwire stopped
166
+
167
+ 1. A share whose mount point is missing or empty is skipped with a warning (a
168
+ stale mount point left by macOS looks exactly like that). If none is
169
+ available the run stops.
170
+ 2. Each folder already in the manifest is mirrored back to its drive. If the
171
+ drive isn't mounted the folder is skipped, and the warning says whether the
172
+ drive is merely locked.
173
+ 3. Each new folder is measured with `du`, placed, recorded, then mirrored. The
174
+ run announces how many it has to measure and names each one as it goes.
175
+ 4. Before anything is copied, every folder that was copied before is checked
176
+ (read-only) for files the copy would overwrite and files gone from the NAS.
177
+ That feeds the tripwire (below) and the deletion grace period.
178
+ 5. Files gone from the NAS are recorded once their folder copies cleanly (see
179
+ below), and any that have been gone long enough are removed from the drives.
180
+ 6. Drive usage and SMART health are recorded, the manifest and config are copied
181
+ to every mounted drive, and the dashboard is regenerated.
182
+
183
+ A folder that has outgrown its drive gets a distinct "drive full" status rather
184
+ than a bare rsync error; reassign it to a roomier drive with `easy_sync
185
+ reassign FOLDER DRIVE_NAME --copy`, which checks the target actually has room
186
+ first (`--force` skips that check) and copies it there from the full drive.
187
+ Without `--copy` only the manifest changes and the next sync copies the
188
+ folder from the NAS instead, from scratch. Either way the old, now-stale copy
189
+ left on the full drive is scheduled for cleanup the same way a file gone from
190
+ the NAS is: removed once the folder is verified synced to its new drive and
191
+ it's been that way for `grace_days` (see "Deletions have a grace period"
192
+ below) - it is not deleted immediately, so a bad reassign can still be undone
193
+ before the old copy disappears.
194
+
195
+ ### Powering off between syncs
196
+
197
+ easy_sync eject # --dry-run lists the drives; name drives to eject just those
198
+
199
+ Ejects every connected backup drive the way Finder's Eject does, so the
200
+ enclosure can be switched off until the next sync. It refuses while a sync or
201
+ scrub is running, and never forces a drive that something (Spotlight,
202
+ Backblaze) still has open: it names the process, says not to power off yet,
203
+ and exits 1. Each drive is marked as last seen at that moment, and the
204
+ dashboard is regenerated to show them disconnected. With Backblaze installed,
205
+ it ends with the date to connect them again by: Backblaze drops a drive from
206
+ its current backup after 30 days disconnected.
207
+
208
+ If Backblaze hasn't finished uploading a drive (see Dashboard below), it lists
209
+ those drives and asks `Eject anyway? [y/N]`; anything but `y` ejects nothing.
210
+ `--yes` skips the question, and with no terminal to ask on (a script) it
211
+ refuses unless `--yes` is given.
212
+
213
+ Dashboard
214
+ ---------
215
+
216
+ <picture>
217
+ <source media="(prefers-color-scheme: dark)" srcset="docs/images/dashboard-dark.png">
218
+ <img alt="The dashboard: a red summary box saying 2 of 112 folders are not backed up, and eight drive tiles showing how full each drive is, its SMART health and when it was last scrubbed" src="docs/images/dashboard-light.png">
219
+ </picture>
220
+
221
+ *Made-up example data: one drive with stable old wear (blue), one overdue for
222
+ a scrub, one still uploading to Backblaze, one not connected, and two folders
223
+ too big for any drive's free space.*
224
+
225
+ Drive tiles are coloured by **SMART health, never by fullness**: a drive at 97%
226
+ is doing its job. Green: self-test passed, no bad-sector counters. Amber: passed,
227
+ but reallocated, pending or uncorrectable sectors (or an NVMe critical flag) are
228
+ non-zero *and growing*, so the drive is starting to fail. Blue: reallocated
229
+ sectors are non-zero but haven't grown since they were first seen (or since the
230
+ last `verify-drive` checkpoint) - old, stable wear rather than an active
231
+ failure in progress; pending/uncorrectable sectors, media errors, or a critical
232
+ flag always stay amber regardless of trend. Red: the self-test failed. Grey: the
233
+ enclosure doesn't expose SMART. Amber and red also raise an alert at the top of
234
+ the page and a warning on the terminal; blue does not. Health is read on every
235
+ sync and at registration, via `smartctl` on the physical disk, falling back to
236
+ `diskutil`; every read's reallocated-sector count is kept in `smart_checks` so
237
+ growth can be told apart from a number that just sits there.
238
+
239
+ If an independent full-surface scan (SpinRite, `badblocks`, etc.) confirms a
240
+ flagged drive has zero new defects, `easy_sync verify-drive NAME [--note TEXT]`
241
+ records that as a checkpoint: future checks compare against today's count, not
242
+ whatever it was before, and an active `warning` on reallocated sectors alone
243
+ drops to the stable blue state immediately.
244
+
245
+ Each tile's "Drive details" opens its serial, model and, when `smartctl`
246
+ reports it, how long the drive has actually been powered on (SMART's
247
+ Power_On_Hours), not calendar age — a 5-year-old drive that sat on a shelf
248
+ can show far fewer hours than one bought last year and run around the clock
249
+ — plus every folder on it. A drive that isn't connected shows when it was
250
+ last seen. With Backblaze Personal installed, that turns amber from 21 days
251
+ and red from 30, with a warning at the top: Backblaze drops a drive from its
252
+ current backup after 30 days disconnected.
253
+
254
+ Each tile also says "scrubbed N days ago" or "never scrubbed", with an
255
+ overdue badge once it passes `scrub_stale_days` - this never changes the
256
+ tile's colour, which stays SMART-only. A "Scrub findings" section, next to
257
+ Pending deletions, lists every file `scrub` has flagged: which drive, its
258
+ path, and whether it's awaiting refetch, refetched and awaiting re-check, or
259
+ unresolved. With nothing flagged it is a single line.
260
+
261
+ When Backblaze Personal is installed on this Mac, each tile (and a
262
+ BACKBLAZE column in `status`) says whether that drive's data has reached
263
+ Backblaze: "up to date", "uploading, N files (size) left", or "not scanned
264
+ since the last sync" when Backblaze's zero was counted before the last
265
+ copy onto the drive. The header and `status` sum it up in one line. This
266
+ is the thing to check before powering the drives off, since Backblaze
267
+ uploads from the drives. It reads Backblaze's own state files under
268
+ `/Library/Backblaze.bzpkg/bzdata` (read-only), the same numbers its
269
+ menu-bar icon shows; without Backblaze nothing about it appears.
270
+
271
+ Running `easy_sync dashboard` (or `status`) while a `sync` is in progress
272
+ shows a rough estimate of time remaining, from what that run has actually
273
+ copied so far — the same estimate either command shows, worded the same way.
274
+
275
+ The page opens with one answer: a green, amber or red box headed "All 2,692
276
+ folders backed up" (or how many are **not**, because no mounted drive has
277
+ room: that is the number that matters), the last sync (when, how long, what
278
+ it copied), and a line for everything that needs you: folders not backed up,
279
+ a failing drive, folders in a bad state, a sync with failures or none for a
280
+ week, scrub findings, overdue scrubs, and a drive about to drop out of (or
281
+ already out of) Backblaze's current backup. "Nothing needs your attention"
282
+ otherwise. Every run decides all placements before it copies anything, so
283
+ the not-backed-up count is complete even if the copy phase is interrupted.
284
+
285
+ Then: the drives; "Latest sync", only the folders it actually copied or
286
+ failed on (the rest just confirmed nothing had changed); the folders,
287
+ grouped by share so thousands stay readable (each drive tile shows one line
288
+ per share, and the folders list has a collapsible section per share with
289
+ "Needs attention" and "Not backed up" on top; only those start open); scrub
290
+ findings and pending deletions, one line each when there are none; and
291
+ "Activity", one feed grouped by day: each sync run, folders placed or moved,
292
+ and what was deleted from which drive, with a batch (a first placement, a
293
+ 60-folder move, a clean of `.DS_Store` files) shown as one expandable line.
294
+
295
+ Replacing or upgrading a drive
296
+ ------------------------------
297
+
298
+ Three situations, one command each. In every case the new drive is registered
299
+ first, and the sync afterwards does the copying.
300
+
301
+ **1. Upgrading a drive, or replacing one that is failing but still reads**
302
+ (the dashboard shows it amber). Both drives mounted:
303
+
304
+ easy_sync register-drive /Volumes/backup-08-12tb
305
+ easy_sync replace-drive backup-04-8tb --to backup-08-12tb --copy
306
+ easy_sync sync
307
+
308
+ `--copy` copies the old drive's contents straight onto the new one over the
309
+ local bus, far faster than pulling them from the NAS again. Every folder is then
310
+ recorded as living on the new drive and the old drive is retired. The sync only
311
+ verifies each folder against the NAS. If the copy fails, nothing is changed.
312
+
313
+ **2. Replacing a drive that is dead** (nothing to copy from):
314
+
315
+ easy_sync register-drive /Volumes/backup-08-12tb
316
+ easy_sync replace-drive backup-04-8tb --to backup-08-12tb
317
+ easy_sync sync
318
+
319
+ The folders are recorded on the new drive and the sync copies every one of them
320
+ from the NAS. That takes as long as the first sync did for those folders.
321
+
322
+ **3. Retiring a drive with no replacement, or a smaller one:**
323
+
324
+ easy_sync replace-drive backup-04-8tb
325
+ easy_sync sync
326
+
327
+ The old drive's folders are forgotten. The sync places each one afresh across
328
+ whatever drives are mounted, by the usual most-free-space rule, and copies it
329
+ from the NAS.
330
+
331
+ Afterwards, in all three cases:
332
+
333
+ easy_sync status # the retired drive is listed at the end
334
+ easy_sync history "tv/Show Name" # still shows the drive it used to live on
335
+
336
+ A retired drive is never placed on or written to again, even if it turns up
337
+ mounted.
338
+
339
+ Restoring the NAS
340
+ ------------------
341
+
342
+ If a share gets wiped, reformatted, or you're rebuilding the NAS from
343
+ scratch, `restore` copies folders back the other way: from wherever each one
344
+ currently lives on a drive, onto its NAS share. Unlike `sync`, it **never
345
+ deletes anything** — it only adds and updates files on the NAS, so restoring
346
+ onto a share that already has some files on it (a partial wipe, a share you
347
+ rebuilt by hand) is safe.
348
+
349
+ easy_sync restore "tv/Breaking Bad" # one folder
350
+ easy_sync restore tv # every folder placed under the tv share
351
+ easy_sync restore --all # everything in the manifest
352
+ easy_sync restore tv --dry-run # show what rsync would do first
353
+
354
+ Because folders for one share can be spread across several drives (unlike
355
+ the single Drobo volume this replaced), a restore plugs in and pulls from
356
+ whichever drives are mounted; a folder whose drive isn't mounted yet is
357
+ skipped with a warning, and running `restore` again once that drive is
358
+ plugged in picks it up. It needs the share's `:sources:` entry to still
359
+ exist (`add-source` it again first if you'd removed it) so it knows where on
360
+ the NAS each folder belongs. A real restore takes the same lock a `sync`
361
+ does, so the two never run at the same time; `--dry-run` doesn't need it.
362
+
363
+ Restoring a single file or folder you know the location of is still just
364
+ browsing `/Volumes/<drive>/<folder>` in the Finder — `restore` is for when
365
+ you want the tool to find and reassemble more than that.
366
+
367
+ The tripwire
368
+ ------------
369
+
370
+ A mirror copies damage too: if ransomware rewrites or renames files on the NAS,
371
+ the next sync would overwrite the good copies on the drives, and an offsite
372
+ backup of the drives would then upload the damage from them. The tripwire stops a sync before
373
+ it copies anything when far more existing files would change than a media
374
+ library changes normally:
375
+
376
+ - **One folder** is held back (not copied) when at least
377
+ `tripwire_folder_files` of its files would be replaced or are gone from the
378
+ NAS, **and** those are at least `tripwire_folder_ratio` of its files.
379
+ - **The whole run** stops (nothing copied, nothing purged) when the changes
380
+ add up to `tripwire_run_files` across all folders, which catches damage
381
+ spread across thousands of single-file movie folders.
382
+
383
+ New files never count. A rename (`photo.jpg` -> `photo.jpg.locked`) counts
384
+ once, as the file gone from the NAS. A file an earlier run already found
385
+ missing doesn't count again while it waits out the grace period. The log and
386
+ the dashboard list each held-back folder with its counts and up to ten sample
387
+ paths. `sync` exits non-zero. Nothing is copied until you accept the change, so
388
+ the next run trips again:
389
+
390
+ easy_sync sync --dry-run # look first
391
+ easy_sync sync --accept-changes # it was you: let every trip through, this run only
392
+ easy_sync sync --accept-changes music/Jazz # ...or only these folders
393
+
394
+ Acceptance is never saved. The dashboard keeps a list of accepted bulk
395
+ changes. The tripwire is **report-only** until `tripwire_enforce: true`: it
396
+ logs what it would have stopped and copies anyway. That lets the thresholds
397
+ be tuned from real runs before it enforces anything. Design:
398
+ [docs/tripwire.md](docs/tripwire.md).
399
+
400
+ Deletions have a grace period
401
+ -----------------------------
402
+
403
+ rsync never deletes anything. Each folder gets a read-only probe before its
404
+ copy pass that only *reports* files on the drive that no longer exist on the
405
+ NAS; the copy pass itself has no deletion flags. Each reported path becomes a candidate with the time it
406
+ was first seen missing and a count of the runs that confirmed it. A candidate is
407
+ removed only once it has been missing for `grace_days` **and** confirmed on
408
+ `grace_runs` separate runs, so one bad run (a half-mounted share, a
409
+ reorganisation in progress) never deletes anything. A file that reappears is
410
+ forgotten and its clock restarts. A whole folder that vanishes from a mounted
411
+ share follows the same rule; on expiry its manifest row is removed and a
412
+ `removed` entry goes into the placement history. Every removal is written to an
413
+ audit table and shown on the dashboard.
414
+
415
+ `easy_sync reassign` schedules the same kind of candidate for the copy it
416
+ leaves behind on the old drive, except its clock only starts once the folder
417
+ has been verified synced to its new drive (grace_days alone can't tell you
418
+ that) - so a folder that hasn't actually landed anywhere yet never gets its
419
+ only copy deleted. `replace-drive` is the exception: it retires the old drive
420
+ immediately, so whatever was on it is left as-is rather than tracked here.
421
+
422
+ easy_sync pending # every candidate and when it expires
423
+ easy_sync clean # remove excluded junk from the drives now, no waiting
424
+
425
+ Entries whose name matches `exclude_folders` (a `#recycle` copied before the
426
+ exclusion existed, stray `.DS_Store` files) are never legitimately part of a
427
+ backup, so `clean` removes them from every placed folder on every mounted drive
428
+ immediately. `--dry-run` lists them first.
429
+
430
+ Bit rot: `scrub`
431
+ -----------------
432
+
433
+ rsync only checks data while it copies it; nothing checks it again afterwards.
434
+ If a bit flips on a drive a year later, the file keeps the same size and
435
+ mtime, so rsync's quick check skips it on every future sync - the bad copy
436
+ sits there unnoticed. That matters when **an offsite backup (such as
437
+ Backblaze Personal) is taken from the drives, not from the NAS**: a rotted file gets
438
+ uploaded as a "change", and the only good copy is the one on the NAS you
439
+ don't know you need to go get.
440
+
441
+ easy_sync scrub # the drive that's gone longest without a full check
442
+ easy_sync scrub backup-04-8tb # a specific drive
443
+ easy_sync scrub --all # every mounted, non-retired drive, stalest first
444
+ easy_sync scrub --all --jobs 8 # scrub this many drives at once (default: config scrub_jobs, 4)
445
+ easy_sync scrub --for 8h # stop after this long; the next scrub picks up where it left off
446
+ easy_sync scrub --dry-run # what would be hashed, without reading or writing anything
447
+
448
+ `scrub` is read-only: it walks a drive's assigned folders to track
449
+ new/removed/legitimately-changed files, then reads each tracked file back off
450
+ the platter (bypassing the page cache) and hashes it with SHA-256, comparing
451
+ against the hash from the first time it was ever checked. Naming several
452
+ drives, or `--all`, scrubs up to `--jobs` of them at once (one thread per
453
+ drive, never two on the same one) - each drive has its own read path, so
454
+ several at a time add real throughput instead of contending with each other;
455
+ `--jobs 1` scrubs them one at a time instead. A mismatch is reported as
456
+ **corrupt**; a read error as
457
+ **unreadable**. Nothing on the drive is ever touched - repair happens the
458
+ other way around, in `sync`: once a folder's normal copy pass succeeds, any
459
+ files `scrub` flagged for it are re-copied from the NAS with `rsync -I`
460
+ (ignoring the quick check that let rot go unnoticed the first time),
461
+ overwriting the bad copy. `scrub`'s next pass re-hashes a refetched file; a
462
+ match goes back to `ok` ("repaired"), a mismatch becomes **unresolved** and is
463
+ never refetched again automatically - it needs a look by hand, comparing
464
+ against the NAS copy. `restore` warns and lists any flagged files in a folder
465
+ before copying it back to the NAS, so a rotted file never quietly overwrites
466
+ a good one there.
467
+
468
+ A drive is overdue once it's gone `scrub_stale_days` (30 by default, matching
469
+ how long Backblaze keeps a disconnected drive in the *current* backup) since
470
+ its last full check; `status` and the dashboard say so, and the dashboard
471
+ lists every outstanding finding. `scrub` takes the same lock a `sync` does, so
472
+ the two never run at the same time - connect the drives, run `scrub --all`,
473
+ and leave it; a full pass over the whole fleet takes a while (SHA-256 itself
474
+ runs at gigabytes/second, so a spinning drive's read speed is the limit, not
475
+ the hashing).
476
+
477
+ Drive speed: `benchmark`
478
+ ------------------------
479
+
480
+ A drive whose write or read speed is falling can be on its way out before
481
+ SMART says anything. `benchmark` measures it so it can be tracked:
482
+
483
+ easy_sync benchmark # the drive benchmarked longest ago (never, first)
484
+ easy_sync benchmark backup-04-8tb # a specific drive
485
+ easy_sync benchmark --all # every mounted, non-retired drive, one at a time
486
+ easy_sync benchmark --size 2gb # a smaller test file (default 8gb)
487
+ easy_sync benchmark --history # the kept runs for every drive, newest first
488
+
489
+ It writes a test file of random data into the drive's `.easy_sync/` folder,
490
+ bypassing the page cache and timing the closing `fsync`, reads it back
491
+ straight off the platter, and deletes it (also on Ctrl-C or an error).
492
+ Nothing else on the drive is touched. The last 25 runs per drive are kept and
493
+ each new one is compared with the median of the drive's earlier runs. Once
494
+ there are 3 earlier runs, a result more than 15% below that median is
495
+ flagged **SLOWER** and the command exits 1. Runs normally vary by about 7%, so
496
+ re-run before worrying. A drive that has filled up since writes to slower
497
+ inner tracks, so each run also records how full the drive was.
498
+
499
+ Drives are measured one at a time: several at once would share the
500
+ enclosure and skew each other. A drive without room for the test file plus
501
+ `reserve` is skipped. `benchmark` takes the same lock as `sync` and `scrub`,
502
+ so it never measures a drive that something else is using. How to read the
503
+ numbers, and the baselines they're compared with: docs/performance.md.
504
+
505
+ Long runs
506
+ ---------
507
+
508
+ A 30 TB library over gigabit Ethernet takes three to four days the first time.
509
+
510
+ - Runs are resumable per folder: a folder interrupted mid-copy is synced again
511
+ next time, and the copy pass never deletes, so Ctrl-C is safe.
512
+ - The Mac stays awake by itself: `sync` starts `caffeinate -i -w <its own pid>`,
513
+ which holds off idle sleep exactly as long as the run lasts. `--no-keep-awake`
514
+ or `:keep_awake: false` turns that off. Keep a laptop's lid open.
515
+ - Every run is logged to `~/.easy_sync/logs/sync-<timestamp>.log` (`scrub` gets
516
+ its own `scrub-<timestamp>.log`, pruned separately): the same lines as the
517
+ terminal, without rsync's in-place progress updates. The newest `keep_logs`
518
+ of each are kept.
519
+ - Only one sync or scrub runs at a time - they share a lock. A second one is
520
+ refused with the running PID; a lock left by a dead process is reclaimed
521
+ automatically.
522
+
523
+ Commands
524
+ --------
525
+
526
+ ```
527
+ Set up, once:
528
+ add-source PATH add a NAS share; each of its folders is placed on its own
529
+ register-drive MOUNT [--name N] [--serial S]
530
+ add a mounted drive
531
+ plan [SHARE ...] [--largest-drive 8tb]
532
+ measure each share (or just those named) and check every folder fits a
533
+ drive
534
+
535
+ Back up:
536
+ sync [--dry-run] [--no-purge] [--no-keep-awake] [--accept-changes [FOLDER ...]]
537
+ mirror the shares onto the drives; --accept-changes lets through a
538
+ bulk change the tripwire stopped (this run only)
539
+ eject [NAME ...] [--dry-run] [--yes]
540
+ eject every connected backup drive (or just those named) so the
541
+ enclosure can be powered off; refuses while a run is in progress,
542
+ asks first if Backblaze hasn't finished one (--yes: don't ask)
543
+ status [--all] [--smart] whether a sync is running (and for how long), drives, health and
544
+ folders, in the terminal. Health is the last sync's SMART reading (an
545
+ n/a says when it was taken); --smart reads the mounted drives now,
546
+ without saving it
547
+ dashboard regenerate the HTML report only
548
+
549
+ Maintain:
550
+ pending deletion candidates and their expiry dates
551
+ clean [--dry-run] remove excluded junk from the drives now, without waiting
552
+ scrub [NAME ...] | --all [--jobs N] [--for DURATION] [--dry-run]
553
+ read tracked files back off a drive and check them against their
554
+ baseline; catches bit rot rsync can't see
555
+ benchmark [NAME ...] | --all [--size SIZE] [--history]
556
+ time a drive's sequential write and read, compared with its own last
557
+ 25 runs; flags one that has slowed down
558
+ history [FOLDER] where a folder has lived
559
+ reassign FOLDER|SHARE DRIVE [--copy] [--note TEXT] [--force]
560
+ move a folder, or every folder of a share, to another drive; --copy
561
+ copies it drive-to-drive now instead of the next sync pulling it from
562
+ the NAS; refuses a drive without room unless --force
563
+ rename-drive OLD NEW relabel a drive, or swap two drives' names; the manifest only, never
564
+ the volume
565
+ replace-drive OLD [--to NEW] [--copy]
566
+ retire a drive, handing its folders to NEW (or to the next sync)
567
+ forget-drive NAME [...] [--dry-run]
568
+ delete a retired drive and all its history (placements, sync runs,
569
+ checksums, benchmarks, SMART checks) from the manifest; for test
570
+ drives. Touches no drive
571
+ verify-drive NAME [--note TEXT]
572
+ record that a full-surface scan (SpinRite etc.) found no new defects;
573
+ resets the reallocated-sector baseline
574
+ restore FOLDER|SHARE [...] | --all [--dry-run]
575
+ copy folders back onto the NAS from wherever they live (reverse of
576
+ sync; never deletes)
577
+ remove-source PATH stop backing up a share (drives untouched)
578
+ sources list the configured shares and whether each is mounted
579
+ ```
580
+
581
+ `--config PATH` goes before the command.
582
+
583
+ Where things live
584
+ -----------------
585
+
586
+ | on the Mac, `~/.easy_sync/` | on each drive, `<drive>/.easy_sync/` |
587
+ |---|---|
588
+ | `config.yml` | `drive.json`, the drive's identity |
589
+ | `manifest.sqlite3` | `manifest.sqlite3`, a copy as of the last sync |
590
+ | `dashboard.html` | `config.yml`, a copy as of the last sync |
591
+ | `logs/sync-*.log`, `logs/scrub-*.log` | `README.txt` |
592
+ | `jbod.lock` while a sync, scrub or benchmark runs | `benchmark.tmp`, only while `benchmark` runs |
593
+
594
+ Manifest schema
595
+ ---------------
596
+
597
+ SQLite. Timestamps are ISO 8601 UTC, sizes are bytes.
598
+
599
+ | table | holds |
600
+ |---|---|
601
+ | `drives` | serial (PK), name, capacity, added date, volume UUID, model, last seen usage, SMART status/detail/power-on hours, retired date |
602
+ | `smart_checks` | one row per SMART read: drive serial, timestamp, reallocated-sector count, whether it's a manually verified checkpoint |
603
+ | `folders` | folder path (PK), drive serial, size, assigned and last-synced times, last status, scope (`tree`, or `root` for a share's loose top-level files) |
604
+ | `placement_history` | every `assigned`, `reassigned` and `removed` event (older rows can say `split`, from a since-removed command) |
605
+ | `sync_runs` | one row per folder synced: exit status, `--stats` byte counts, and the start time of the `sync` run it belonged to |
606
+ | `pending_deletions` | paths gone from the NAS (cause `missing_on_nas`, first seen and runs confirmed) or a folder's old drive after a reassign (cause `reassigned`) |
607
+ | `source_inventory` | every folder seen on the NAS last run: placed, not backed up, or empty |
608
+ | `deletions` | audit log of everything actually removed from a drive |
609
+ | `drive_benchmarks` | the last 25 `benchmark` runs per drive: when, test size, write and read MB/s, how full the drive was |
610
+ | `file_checksums` | one row per tracked file per drive: size, mtime, SHA-256 baseline, status (`ok`/`corrupt`/`unreadable`/`unresolved`), when it last failed or was refetched |
611
+
612
+ Development
613
+ -----------
614
+
615
+ bundle install
616
+ bundle exec rake # RSpec; every external call is faked, no drives or rsync needed
617
+ bundle exec rake install # build the gem from this checkout and install it locally
618
+ bundle exec rake release # tag the version and push it to rubygems.org
619
+
620
+ Version 2 removed the original snapshot mode (dated hard-linked snapshots of one
621
+ directory). It lives on in the 1.x tags.
data/Rakefile CHANGED
@@ -1,10 +1,8 @@
1
- require "rake/testtask"
2
- require "bundler/gem_tasks"
1
+ # frozen_string_literal: true
3
2
 
4
- Rake::TestTask.new do |t|
5
- t.libs << "lib"
6
- t.libs << "spec"
7
- t.pattern = "spec/**/*_spec.rb"
8
- end
3
+ require 'bundler/gem_tasks'
4
+ require 'rspec/core/rake_task'
9
5
 
10
- task :default => :test
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ task default: :spec