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.
- checksums.yaml +5 -5
- data/.gitignore +8 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +85 -0
- data/CLAUDE.md +133 -0
- data/Gemfile +2 -0
- data/README.md +607 -21
- data/Rakefile +6 -8
- data/bin/easy_sync +8 -1
- data/docs/fine-placement.md +174 -0
- data/docs/hardware-notes.md +71 -0
- data/docs/integrity-scan.md +435 -0
- data/docs/parallel-scrub.md +280 -0
- data/docs/performance.md +146 -0
- data/docs/tripwire.md +232 -0
- data/easy_sync.gemspec +25 -16
- data/lib/easy_sync/cli.rb +1246 -0
- data/lib/easy_sync/config.rb +202 -0
- data/lib/easy_sync/error.rb +5 -0
- data/lib/easy_sync/jbod/backblaze.rb +113 -0
- data/lib/easy_sync/jbod/benchmarker.rb +149 -0
- data/lib/easy_sync/jbod/cleaner.rb +93 -0
- data/lib/easy_sync/jbod/dashboard.rb +611 -0
- data/lib/easy_sync/jbod/keep_awake.rb +33 -0
- data/lib/easy_sync/jbod/manifest.rb +1068 -0
- data/lib/easy_sync/jbod/mirror.rb +204 -0
- data/lib/easy_sync/jbod/models.rb +165 -0
- data/lib/easy_sync/jbod/page_cache.rb +56 -0
- data/lib/easy_sync/jbod/placement.rb +71 -0
- data/lib/easy_sync/jbod/planner.rb +76 -0
- data/lib/easy_sync/jbod/purger.rb +161 -0
- data/lib/easy_sync/jbod/restorer.rb +142 -0
- data/lib/easy_sync/jbod/run_lock.rb +98 -0
- data/lib/easy_sync/jbod/run_log.rb +57 -0
- data/lib/easy_sync/jbod/runner.rb +615 -0
- data/lib/easy_sync/jbod/scrub_pool.rb +136 -0
- data/lib/easy_sync/jbod/scrubber.rb +321 -0
- data/lib/easy_sync/jbod/share_scan.rb +26 -0
- data/lib/easy_sync/jbod/sync_eta.rb +68 -0
- data/lib/easy_sync/jbod/tripwire.rb +80 -0
- data/lib/easy_sync/jbod/volume_info.rb +283 -0
- data/lib/easy_sync/jbod.rb +35 -0
- data/lib/easy_sync/shell.rb +62 -0
- data/lib/easy_sync/templates/dashboard.html.erb +419 -0
- data/lib/easy_sync/version.rb +3 -1
- data/lib/easy_sync.rb +7 -3
- metadata +84 -45
- data/Gemfile.lock +0 -55
- data/Guardfile +0 -28
- data/lib/easy_sync/rsync.rb +0 -60
- data/lib/easy_sync/sync_logger.rb +0 -20
- data/lib/easy_sync/sync_runner.rb +0 -44
- data/spec/easy_sync/rsync_spec.rb +0 -25
- data/spec/easy_sync/sync_runner_spec.rb +0 -54
- data/spec/spec_helper.rb +0 -33
- data/spec/support.rb +0 -89
data/README.md
CHANGED
|
@@ -1,35 +1,621 @@
|
|
|
1
1
|
easy_sync
|
|
2
2
|
=========
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
2
|
-
require "bundler/gem_tasks"
|
|
1
|
+
# frozen_string_literal: true
|
|
3
2
|
|
|
4
|
-
|
|
5
|
-
|
|
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
|
-
|
|
6
|
+
RSpec::Core::RakeTask.new(:spec)
|
|
7
|
+
|
|
8
|
+
task default: :spec
|