12 Commits
Author SHA1 Message Date
clawbot 5abacbc5c1 Do not prune when a backup misses an earlier mount point (closes #395)
check / check (push) Waiting to run
release / release (push) Waiting to run
snapshot create --prune purged the older snapshots holding a volume's
files when the volume, mounted below a configured path, was not
mounted: its empty mount point read as deleted files.

The scan now records in the local index each directory at or below a
configured path that the walk found an entry in and that, once listed,
is on a different device from its parent; listing mounts an
automounted volume. Under --prune, when an earlier snapshot of that
name recorded such a directory and this run does not, the run skips
the prune, keeps every older snapshot and exits non-zero, naming the
directory. For a volume retired on purpose, snapshot purge removes the
older snapshots.

Model: opus-5-5
2026-10-15 08:32:28 +02:00
clawbot ff50a137dd Delete metadata directories a backup left without a manifest (closes #390)
check / check (push) Waiting to run
A backup that stopped between uploading db.zst.age and its manifest
left that directory under metadata/ on the destination for good:
prune deleted nothing there.

The blob step of prune, which snapshot create --prune also runs, now
deletes each directory under metadata/ that has no manifest.json.zst,
that no local snapshot record claims, and all of whose objects are
older than prune_grace_period; an object listed with no time keeps its
directory, and only names that are remote keys are deleted. The prompt
and the --force output name each such directory, the --json result
counts them (metadata_dirs_deleted, metadata_dirs_failed), and a failed
delete fails the run. Every description of the grace period now
covers these directories.

Model: opus-5-5
2026-10-15 03:49:19 +02:00
clawbot e9b17cc5fd Do not prune after a backup that found a configured path empty (closes #388)
check / check (push) Waiting to run
snapshot create --prune of a snapshot whose path was an empty
directory, as the mount point of a volume that is not mounted is,
recorded an empty snapshot and then purged the older snapshots that
held the data, with their blobs.

The scan now counts the entries it finds in each configured path.
Under --prune, a run in which a path of a snapshot it made held
nothing skips the prune, keeps every older snapshot, names each such
path with its snapshot, and exits non-zero, as
#382 does for unreadable
files. Only the current run is looked at. Without --prune this check
does nothing. A configured path that is a symlink whose target is
gone now fails every run, with or without --prune.

Model: opus-5-5
2026-10-15 02:49:21 +02:00
clawbot 850e2de25a Go on with a backup when the destination marker cannot be checked (closes #389)
check / check (push) Waiting to run
A source host whose credentials allow only PUT, which the documents
say is enough to back up, failed every backup after its first: the
marker check looks the marker up with an S3 HEAD, which those
credentials are refused, and any error other than not found was
fatal.

Such an error now means the check could not be made: it is logged at
info level, so --cron stays silent, and the command goes on. Not found
still stops it. The documents now say so, and that such a host does
not notice an emptied destination, so vaultik database delete must be
run on it after the destination is emptied. The fault-injecting test
store gains a Stat hook for the test.

Model: opus-5-5
2026-10-15 02:02:35 +02:00
clawbot 6a57750a7b Keep unreferenced blobs newer than prune_grace_period (closes #387)
check / check (push) Waiting to run
release / release (push) Waiting to run
A backup uploads its blobs before the manifest that lists them, and the
process lock covers one user on one machine, so a prune from another host
or user sharing the destination deleted a running backup's blobs.

Prune now deletes an unreferenced blob only once its listed time is older
than the new config key prune_grace_period (default 24h; a negative value
is refused). A newer blob, or one listed with no time, is kept and counted
(blobs_too_new). The rclone backend takes each object's time from the
listing (use_server_modtime).

Decision: a blob a backup reuses from an earlier run that stopped before
its manifest keeps its old time and is not protected; the docs and config
comments say so.

Model: opus-5-5
2026-10-14 13:06:35 +02:00
clawbot 2590f49583 Record the defect-hunt fixes in TODO.md Completed Steps (closes #334)
release / release (push) Failing after 1m25s
check / check (push) Failing after 21m10s
Adds a Completed Steps entry, newest first, for every issue from the
defect hunt (#288) that a merged
fix closed and that had none: the hunt's own issues, those filed while
fixing them, and the owner's ruling on
#239. Each entry says in one or
two sentences what was wrong and what changed; the detail is in each
fix's commit. Next Step now says the hunt's fixes have landed.

Judgement call: #332, closed as
covered by the fix for #296, is
named in that entry rather than given its own.
Judgement call: #351, a Docker
build fix filed while gating these fixes, gets an entry.

Model: opus-5-5
2026-10-14 09:18:09 +02:00
clawbot 5b8d84e38a Do not prune after a backup that skipped unreadable files (closes #382)
check / check (push) Canceled after 0s
snapshot create --skip-errors --prune pruned even when files were
skipped, so by default it deleted the previous snapshot of that name,
the only one still holding a file that had become unreadable, along
with blobs only that snapshot referenced.

The prune is now skipped exactly when the run exits non-zero for
entries --skip-errors could not read or list: older snapshots are
kept, a warning says why, and the run still exits non-zero. A file
removed during the run does not count, so such a run still prunes.
The README's --prune text and the flag's help say so.

Model: opus-5-5
2026-10-14 07:44:50 +02:00
clawbot 7a950ea2aa Exit non-zero when snapshot create --skip-errors skips anything it cannot read (closes #310)
check / check (push) Canceled after 0s
A backup under --skip-errors printed its skip errors, then "Finished
successfully.", and exited 0, so a cron wrapper could not tell it from
a complete backup.

Each skipped entry, including a directory the walk could not list, is
now counted as examined and failed. If --skip-errors skipped anything
it could not read or list, the run still keeps its snapshots and
prunes as before, then fails with "Backup completed, but N file(s)
could not be read". A file removed during the run, even before the
walk reaches it, counts once as failed and leaves the exit status
alone; without the flag it no longer stops the backup. Excluded
entries are never counted.

Model: opus-5-5
2026-10-14 07:13:48 +02:00
clawbot 16b03dfda5 Keep the local index when remote nuke cannot list or find the destination (closes #292)
check / check (push) Canceled after 0s
`remote nuke` took a destination store it could not list, or one
without `vaultik-destination.txt` while the local index held
snapshots, as a warning: it then cleared the local snapshot records,
and for an unplugged volume's empty mount point reported the store
empty and exited zero. Nuke now fails on either before it removes
anything, without its advice to run it again, which would fail the
same way. The bulk remove it runs fails on either too; only a
local-only remove still goes on with a warning. Nuke also deletes each
snapshot's remote metadata before its local record, so a failed delete
keeps the snapshot in the local index rather than leaving it on the
store as a remote-only snapshot.

Model: opus-5-5
2026-10-14 06:47:38 +02:00
clawbot 2125a9d451 Stop counting directories and unchanged symlinks as files to back up (closes #305)
check / check (push) Canceled after 0s
Every backup queued every directory and symlink for the second phase.
The line ending the walk counted directories, and their stat sizes, as
files to back up while its enumerated count left them out, and a
symlink was counted as backed up on every run, its target length as
data.

The walk now sets directories aside: they are recorded before the files
are backed up and never counted as files or data, and the walk's
progress estimate leaves out the directories the local index knows. A
symlink is checked against its record like a regular file, its target
included, so an unchanged one counts as unchanged.

Judgement call: every directory is still written to the local index on
every backup, as before; no report shows it.

Model: opus-5-5
2026-10-14 06:46:45 +02:00
clawbot a0de8bfdb7 Refuse a backup destination an unplugged volume left empty (closes #239)
check / check (push) Canceled after 0s
Every backup writes vaultik-destination.txt at the destination root.
While the local index holds snapshots stored there, a destination
without it is not present: snapshot create refuses before writing,
prune and snapshot purge fail, and snapshot list and snapshot remove
act as for a destination that cannot be listed. snapshot create and
snapshot remove check it first. remote nuke deletes the marker last.
The file:// backend never creates its destination directory, which
protects a directory on a removable volume, not a mount point.

Tests back up into directories that exist; fixtures for a written
destination carry the marker.

Judgement call: the marker is rewritten on every backup.
Deviation: for the complexity limit, two blocks moved, unchanged, into
snapshotNamesToCreate and confirmRemoveSnapshot.

Model: opus-5-5
2026-10-14 05:46:42 +02:00
clawbot 291c39174f Warn about restored ownership only when an entry's owner or group differs (closes #316)
check / check (push) Canceled after 0s
A restore by a normal user always warned that file ownership was not
preserved, even when every entry came out with its recorded owner and
group. The owner and group each restored entry has on disk are now
compared with the recorded ones, and the warning is printed once if any
differ. The comparison is made on disk because on macOS, and in a setgid
directory on Linux, a new entry takes the group of its directory, not
the user's.

Judgement call: an entry that is not on disk after the restore is not compared.
Unverified: not run on a Mac; the directory-group test runs only as root.

Model: opus-5-5
2026-10-14 05:39:27 +02:00
78 changed files with 4232 additions and 431 deletions
+20 -1
View File
@@ -311,6 +311,8 @@ Repository interfaces:
```
CreateSnapshot(opts)
│
├─► markDestination() // Check the marker (see Storage Layout in S3), then write it
│
├─► PruneDatabase() // Critical: avoid dedup errors
│
@@ -357,7 +359,7 @@ CreateSnapshot(opts)
## Deduplication Strategy
1. **File-level**: Files unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid), unless the file lists a chunk that no uploaded blob holds; such a file is re-chunked
1. **File-level**: Files and symlinks unchanged since last backup are skipped (metadata comparison: size, mtime, mode, uid, gid, and a symlink's target), unless the file lists a chunk that no uploaded blob holds; such a file is re-chunked
2. **Chunk-level**: Chunks are content-addressed by SHA256 hash. If a chunk hash already exists in the database, the chunk data is not re-uploaded.
@@ -367,6 +369,8 @@ CreateSnapshot(opts)
```
bucket/
├── vaultik-destination.txt # Marker written by every backup
│
├── blobs/
│ └── {hash[0:2]}/
│ └── {hash[2:4]}/
@@ -378,6 +382,21 @@ bucket/
└── manifest.json.zst # Blob list (for pruning/verification)
```
A destination that answers a look-up of `vaultik-destination.txt` with not
found is treated as not present while the local index holds snapshots stored
there (`checkDestinationMarker` in `internal/vaultik/destination_marker.go`):
`snapshot create` refuses to write to it, `prune`, `snapshot purge` and
`remote nuke` fail, and `snapshot list` and `snapshot remove` behave as for a
destination that cannot be listed. This keeps those commands from taking an
unplugged volume's empty mount point for a destination whose snapshots are
all gone. When the look-up fails for any reason other than not found, for
example when S3 refuses it to credentials that allow only PUT, the check is
skipped, so a host with such credentials does not notice an emptied
destination; see
[docs/REPOSTRUCTURE.md](docs/REPOSTRUCTURE.md#marker-file-vaultik-destinationtxt).
The `file://` backend never creates its destination directory, so a write to a
missing one fails.
The `{remote-key}` directory name is a one-way double SHA-256 hash of the human
snapshot ID, so the human ID (hostname, snapshot name, timestamp) is never
written to the store as a directory name. See
+153 -24
View File
@@ -24,6 +24,10 @@ grep 'public key' vaultik_backup_private_key.txt
vaultik config set age_recipients.0 age1YOUR_PUBLIC_KEY_HERE
vaultik config set storage_url "file:///Volumes/usbstick/mybackup"
# create the destination directory; vaultik never creates it, so a backup
# to a volume that is not plugged in fails instead of filling the disk
mkdir /Volumes/usbstick/mybackup
# macOS only: grant your terminal app Full Disk Access first
# (System Settings → Privacy & Security → Full Disk Access), otherwise
# the backup will abort with a permission error on protected directories
@@ -92,6 +96,15 @@ vaultik snapshot restore <snapshot-id> ~/vaultik-restore
# 0 3 * * * vaultik snapshot create --cron --prune --keep-newer-than 4w
```
On a destination shared between hosts or users, the prune in that cron line
relies on `prune_grace_period` (default `24h`) to leave the blobs of a backup
another one is still running, and its directory under `metadata/` that has no
manifest yet, so it must be longer than the longest backup run. It does not
protect a blob that backup reuses from an earlier run that stopped before its
manifest: the prune can delete such a blob, and the new snapshot then cannot
be restored. To not depend on the grace period, give each host its own prefix
or directory. See [locking](#locking).
## restoring on another machine
Restoring on a host that never ran the backup — a replacement machine
@@ -188,7 +201,23 @@ process-wide lock at `$XDG_DATA_HOME/vaultik/vaultik.pid`
(`~/.local/share/vaultik/vaultik.pid` on Linux) for the whole run. Only
one of them runs at a time: a second one exits immediately with an
"already running" error rather than waiting, so two writers can never
corrupt the local index or the destination store.
corrupt the local index.
The lock covers one user on one machine. It does not stop another user,
or another host, from writing to the same destination store at the same
time. A backup uploads its blobs, and then its `db.zst.age`, before its
manifest, so a prune from elsewhere at that moment finds blobs no snapshot
lists and a directory under `metadata/` with no manifest. It
deletes such a blob only once it is older than `prune_grace_period` (default
`24h`), and such a directory only once every object in it is, so the grace
period must be longer than the longest backup run of any host or user sharing
the destination. The grace period does not protect a blob a backup reuses
from an earlier run that stopped before its manifest: a backup skips the
upload of a blob already on the destination, so that blob keeps the time of
its first upload, and a prune from elsewhere during the backup can delete it;
the new snapshot then cannot be restored. `remote nuke` deletes everything
regardless. To not depend on the grace period, give each host or user its own
prefix (or directory) on the destination.
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
`remote info` — do not take the lock and are never blocked, so they run
@@ -291,13 +320,46 @@ value where a list belongs or a word where a number belongs), is refused.
error that explains how to fix it
* `--cron`: Silent on total success; warnings and errors are still printed
(for crontab)
* `--prune`: After backup, drop older snapshots of each backed-up name and
remove orphaned blobs from remote storage. By default keeps only the latest
snapshot per name; use `--keep-newer-than` for a rolling window.
* `--prune`: After backup, drop older snapshots of each backed-up name,
remove orphaned blobs older than `prune_grace_period` from remote storage,
and delete each directory under `metadata/` that has no
`manifest.json.zst` once every object in it is older than
`prune_grace_period` and its snapshot is not in the local index, as `prune`
does. By default keeps only the latest
snapshot per name; use `--keep-newer-than` for a rolling window. When
`--skip-errors` skipped a file or directory it could not read or list, the
prune does not run and every older snapshot is kept, since one of them may
be the only one that holds what was skipped; the command says so and exits
non-zero. When the backup found nothing in a configured path, as in the
mount point of a volume that is not mounted, the prune does not run
either: every older snapshot is kept, and the command names the path and
exits non-zero, on every run until the path holds something again. Take a
path that is empty on purpose out of the config, or remove the older
snapshots by hand with `snapshot purge`. When an earlier snapshot of the
same name in the local index found a volume mounted at or below a
configured path (a directory on a different filesystem from its parent,
with an entry in it that the backup does not exclude) and this backup does
not, the prune does not run either: every older snapshot is kept, and the
command names the directory and exits non-zero, on every run until the
volume is mounted again. A backup also does not find the volume when it
finds no such entry in its directory, as when an automounted volume cannot
be mounted, or when that directory is no longer backed up, because it is
excluded or its configured path was removed. If any of this is on purpose,
as for a retired volume, remove the older snapshots by hand with
`snapshot purge`. A file removed during the backup does not stop the
prune.
* `--keep-newer-than <duration>`: With `--prune`, keep snapshots newer than
this duration instead of only the latest (e.g. `4w`, `30d`, `6mo`, `1y`).
Without `--prune`, or with a value that is not a valid duration, the
command fails before the backup starts.
* Fails before it writes anything when a `file://` destination directory
does not exist, or when the local index holds snapshots and the
destination answers that it has no `vaultik-destination.txt`. When that
look-up fails for any reason other than not found, for example when S3
refuses it to credentials that allow only PUT, the check is skipped (see
[remote storage layout](#remote-storage-layout))
* Fails, before anything is pruned, when a configured path is a symlink
whose target does not exist, as one into a volume that is not mounted
**`snapshot list`**: Show every snapshot known to this host — the union
of the local index and the backup destination store — with timestamps
@@ -331,7 +393,10 @@ gives: revert the configuration, or run `vaultik database delete`.
If the destination store cannot be listed (unmounted volume,
permission denied, network down), the command warns, falls back to the
local index alone, and still exits zero.
local index alone, and still exits zero. It does the same when the
destination answers that it has no `vaultik-destination.txt` while the
local index holds snapshots stored there, as when a volume mounted at
the destination is unplugged and leaves its empty mount point behind.
* `--json`: Output in JSON format. Each entry carries `locally_tracked`
(whether the snapshot is in the local index), `remote_key` (the full
64-character storage key), and `remote_present` (whether it was seen
@@ -373,13 +438,14 @@ snapshot in the local index or on the destination store is an error;
with `--local-only`, the snapshot must be in the local index. Local row
cleanup (files, chunks, blobs the snapshot was the last referrer for)
runs automatically. If removing the snapshot's metadata from the
destination store fails (for example because the store is unreachable),
the local-DB removal still completes and a warning is emitted; run
`vaultik snapshot remove <snapshot-id>` again once the store is reachable
to remove the snapshot's metadata from it (`vaultik prune` does not). For
a snapshot that is only on the destination store, that failure is an
error. To wipe everything on the destination in one go, use `vaultik
remote nuke --force`.
destination store fails (for example because the store is unreachable,
or the local index holds snapshots and the destination answers that it
has no `vaultik-destination.txt`), the local-DB removal still completes and a
warning is emitted; run `vaultik snapshot remove <snapshot-id>` again
once the store is reachable to remove the snapshot's metadata from it
(`vaultik prune` does not). For a snapshot that is only on the
destination store, that failure is an error. To wipe everything on the
destination in one go, use `vaultik remote nuke --force`.
* `--local-only`: Skip remote cleanup; only touch the local index
* `--dry-run`: Show what would be deleted without deleting
* `--force`: Skip confirmation prompt
@@ -407,15 +473,35 @@ local snapshot whose remote metadata is missing is dropped from the
local index; (2) delete orphaned local rows (files, chunks, blobs no
longer referenced by any completed snapshot); (3) list every remote
manifest on the destination store to compute the still-referenced blob
set and delete any blob not in that set. Step (3) reads all remote
manifests — network cost scales with the number of snapshots. `snapshot
create --prune` runs the same cleanup automatically; this is the
manual entry point for the same work. If a blob cannot be deleted, the
command (and `snapshot create --prune`) exits non-zero.
set and delete any blob not in that set that is older than
`prune_grace_period` (default `24h`). A newer one is kept and counted: it
may belong to a backup still running, on this host or another sharing the
destination store, that has not yet uploaded its manifest. The grace period
does not protect a blob such a backup reuses from an earlier run that stopped
before its manifest: that blob keeps the time of its first upload, so it can
be deleted. Step (3) also deletes each directory under `metadata/` that has
no `manifest.json.zst`, as a backup leaves when it stops after uploading
`db.zst.age` but before its manifest, once every object in it is older than
`prune_grace_period` and its snapshot is not in the local index. An object the
listing gives no time for keeps its directory, and a directory whose name is
not a remote key is left alone. Step (3) reads all remote manifests — network
cost scales with the number of snapshots.
`snapshot create --prune` runs the same cleanup automatically; this is the
manual entry point for the same work. If the destination store cannot
be listed, or the local index holds snapshots and the destination answers
that it has no `vaultik-destination.txt` (an unplugged volume's empty mount
point), prune fails and deletes nothing. `snapshot purge` does the same. If a
blob or such a directory cannot be deleted, the command (and
`snapshot create --prune`) exits non-zero.
* `--force`: Skip confirmation prompt
* `--json`: Output stats as JSON. Also skips the confirmation prompt, as
`--force` does. A blob that cannot be deleted is counted in
`blobs_failed`, and the document is printed before the non-zero exit.
A blob kept for being newer than `prune_grace_period`, or because the
listing of the destination gives no time for it, is counted in
`blobs_too_new` and not in `blobs_found`. A directory under `metadata/`
with no manifest is counted in `metadata_dirs_deleted` once deleted, or
in `metadata_dirs_failed` if it cannot be.
**`info`**: Display system configuration, storage settings, encryption
recipients, and local database statistics.
@@ -435,11 +521,17 @@ since `prune` never deletes it.
* `--json`: Output as JSON
**`remote nuke`**: Delete every object under `metadata/` and `blobs/` on
the backup destination store, whatever its name, leaving the bucket prefix
empty. Destructive and irreversible. This is the single supported way to
wipe the entire destination store. If a listing afterwards still finds
anything there, the command names each object left and exits non-zero;
running it again deletes what is left.
the backup destination store, whatever its name, and then
`vaultik-destination.txt`, leaving the bucket prefix empty. Destructive
and irreversible. This is the single supported way to wipe the entire
destination store. A snapshot is removed from the local index only once
its metadata is deleted from the destination store. If the destination
store cannot be listed, or the local index holds snapshots and the
destination answers that it has no `vaultik-destination.txt` (an unplugged
volume's empty mount point), the command fails before it removes anything. If a
listing afterwards still finds anything under `metadata/` or `blobs/`,
the command names each object left and exits non-zero; running it again
deletes what is left.
* `--force`: Required to confirm destruction.
**`database delete`**: Delete the local SQLite state database file
@@ -464,7 +556,12 @@ object store. Credentials are read from `s3.access_key_id` and
a local or mounted filesystem. Useful for testing or backing up to a NAS. The
path must be absolute; a relative one is refused when the config loads.
Percent-escapes in the path are decoded, so `my%20stick` and `my stick` name
the same directory.
the same directory. The directory must exist before the first backup: vaultik
never creates it. A directory on a removable volume is gone while the volume is
not mounted, so a backup then fails instead of writing to the disk under the
mount point. The mount point itself stays behind when the volume is unplugged:
there only `vaultik-destination.txt` stops such a backup, and only while the
local index holds snapshots.
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
providers. Requires rclone to be configured separately (`rclone config`).
@@ -479,6 +576,15 @@ being written, a killed upload can leave a truncated object under its name,
which a later backup takes for complete. A leftover `.partial` file is ignored
and can be deleted.
A destination shared between hosts or users, each with its own local index,
relies on `prune_grace_period` to keep one's prune from deleting the blobs of
another's backup still running, or its directory under `metadata/` that has
no manifest yet, so it must be longer than the longest backup run. It does not
protect a blob that backup reuses from an earlier run that
stopped before its manifest, which the prune can delete. To not depend on the
grace period, give each host its own prefix (or directory). See
[locking](#locking).
Legacy S3 configuration via `s3.*` fields (endpoint, bucket, prefix, etc.) is
still supported for backward compatibility. `storage_url` takes precedence if
both are set.
@@ -495,6 +601,7 @@ For an implementation-level view of the internals — the data model, the
```
<bucket>/<prefix>/
├── vaultik-destination.txt # Marker written by every backup
├── blobs/
│ └── <aa>/<bb>/<full_blob_hash>
└── metadata/
@@ -503,6 +610,23 @@ For an implementation-level view of the internals — the data model, the
└── manifest.json.zst # Unencrypted blob list (for pruning)
```
* `vaultik-destination.txt` holds one fixed line of text, with no host
name, path or time. While the local index holds snapshots stored
there, a destination that answers a look-up of it with not found is
treated as not present: `snapshot create` refuses to write to it,
`prune`, `snapshot purge` and `remote nuke` fail, and `snapshot list`
and `snapshot remove` behave as for a destination that cannot be
listed. This keeps an unplugged volume's empty mount point from being
taken for a destination whose snapshots are all gone. Mount
the volume if it is removable; if the destination was emptied on
purpose, run `vaultik database delete` and back up again.
When the look-up fails for any reason other than not found, the check
is skipped and the command goes on. S3 refuses it on every run to
credentials that allow only PUT, so a host with such credentials does
not notice a destination emptied since its last backup, and its next
backup would leave out the data its local index records as already
stored. After the destination is emptied, run
`vaultik database delete` on such a host before it backs up again.
* Blobs are two-level directory sharded using the first 4 hex chars of the blob hash
* `db.zst.age` is a binary SQLite database (zstd compressed, age encrypted)
containing all file metadata, chunk mappings, and relationships for the snapshot
@@ -554,7 +678,11 @@ format does and does not protect.
1. List all snapshot manifests
2. Build set of all referenced blob hashes
3. List all blobs in storage
4. Delete any blob not in the referenced set
4. Delete any blob not in the referenced set that is older than
`prune_grace_period`
5. Delete each directory under `metadata/` with no manifest whose objects
are all older than `prune_grace_period`, unless its snapshot is in the
local index
### chunking and deduplication
@@ -599,6 +727,7 @@ complete annotated example also lives in
| `chunk_size` | `10MB` | Average chunk size for content-defined chunking |
| `blob_size_limit` | `10GB` | Maximum blob size before splitting. Must be at least four times `chunk_size` (the largest chunk the chunker can emit), otherwise a single-chunk blob could exceed the limit. A backup needs free temporary space, because each blob is written whole to a temporary file before it is uploaded (up to about `blob_size_limit`; an rclone destination that cannot stream uploads needs about twice that) and the metadata export writes copies of the local index. Temporary files go to `$TMPDIR` (default `/tmp`); with `TMPDIR` unset, SQLite writes one of those copies to `/var/tmp` |
| `compression_level` | `3` | zstd compression level (1-19) |
| `prune_grace_period` | `24h` | How old a blob that no snapshot lists must be before `prune` deletes it, and how old every object in a directory under `metadata/` with no manifest must be before `prune` deletes that directory. A backup uploads its blobs and its `db.zst.age` before its manifest, so on a destination shared between hosts or users this must be longer than the longest backup run (see [locking](#locking)). It does not protect a blob a backup reuses from an earlier run that stopped before its manifest: that blob keeps the time of its first upload, so a prune can delete it. A duration such as `36h` or `90m`; `0s` deletes such blobs and directories at once |
| `hostname` | system hostname | Hostname used in snapshot IDs |
| `index_path` | platform data dir | Local SQLite index path |
+237 -4
View File
@@ -15,13 +15,246 @@ pre-1.0
# Next Step
The 1.0 work is complete on `next`: the scope settled on
[issue #125](https://git.eeqj.de/sneak/vaultik/issues/125) was the work
already planned for 1.0, and all of it has landed. The mechanism to cut
the tag exists and is exercised; what is left is merging `next` to
`main` and tagging, both the owner's.
[issue #125](https://git.eeqj.de/sneak/vaultik/issues/125) has landed,
and so has every fix from the defect hunt before the release candidate
([issue #288](https://git.eeqj.de/sneak/vaultik/issues/288)). What is
left is merging `next` to `main` and tagging the release, both the
owner's; a `v*` tag runs the release build in
`.gitea/workflows/release.yml`.
# Completed Steps
- 2026-10-15: Stopped `snapshot create --prune` pruning after a backup
in which a volume mounted below a configured path in an earlier
snapshot of the same name was not mounted
([issue #395](https://git.eeqj.de/sneak/vaultik/issues/395)). Such a
prune could delete every snapshot still holding the volume's files.
Each snapshot now records in the local index the directories it found
on a different filesystem from their parent and found an entry in that
the backup does not exclude.
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
that found nothing in a configured path
([issue #388](https://git.eeqj.de/sneak/vaultik/issues/388)), as in
the mount point of a volume that is not mounted. Such a prune could
delete every snapshot still holding that path's files. A configured
path that is a symlink whose target is gone, as one into such a
volume, now fails the backup, with or without `--prune`.
- 2026-10-14: Stopped `snapshot create --prune` pruning after a backup
that skipped files it could not read
([issue #382](https://git.eeqj.de/sneak/vaultik/issues/382)). Such a
prune could delete the only snapshot still holding those files.
- 2026-10-14: Made `snapshot create --skip-errors` exit non-zero,
instead of 0, when it skips anything it cannot read
([issue #310](https://git.eeqj.de/sneak/vaultik/issues/310)).
- 2026-10-14: Kept the local index when `remote nuke` cannot list or
find the destination store
([issue #292](https://git.eeqj.de/sneak/vaultik/issues/292)). Nuke
had cleared the local snapshot records anyway; it now fails before
removing anything.
- 2026-10-14: Stopped counting directories and unchanged symlinks as
files to back up
([issue #305](https://git.eeqj.de/sneak/vaultik/issues/305)).
- 2026-10-14: Refused a backup destination that an unplugged volume
left empty, as the owner ruled
([issue #239](https://git.eeqj.de/sneak/vaultik/issues/239)). Every
backup now writes `vaultik-destination.txt` at the destination root,
and while the local index holds snapshots stored there, a destination
without it is treated as not present.
- 2026-10-14: Warned about restored ownership only when an entry's
owner or group differs from the recorded one
([issue #316](https://git.eeqj.de/sneak/vaultik/issues/316)); a
restore by a non-root user had warned even when none did.
- 2026-10-14: Made `snapshot list` report a changed destination instead
of advising `prune`, which refuses in that case
([issue #317](https://git.eeqj.de/sneak/vaultik/issues/317)).
- 2026-10-14: Counted a file as deleted by a backup only if the
previous snapshot of the same name listed it
([issue #304](https://git.eeqj.de/sneak/vaultik/issues/304)); files
that snapshot did not list had been counted too.
- 2026-10-14: Named each prefix once in restore, verify and purge
errors, some of which repeated it
([issue #327](https://git.eeqj.de/sneak/vaultik/issues/327)). A deep
verify log line that named S3 whatever the destination now names
storage.
- 2026-10-14: Refused credentials in a storage URL's endpoint or rclone
remote name, which were accepted and could then be printed
([issue #322](https://git.eeqj.de/sneak/vaultik/issues/322)).
- 2026-10-14: Ran `$EDITOR` through the shell in `config edit`, so a
value with arguments, such as `code --wait`, no longer fails
([issue #315](https://git.eeqj.de/sneak/vaultik/issues/315)).
- 2026-10-14: Made `snapshot remove` fail, instead of exiting 0, when
it cannot delete the metadata of a snapshot that is only on the
destination store
([issue #340](https://git.eeqj.de/sneak/vaultik/issues/340)). The
README and the help now say when it prints the `vaultik prune`
follow-up.
- 2026-10-14: Labelled the totals in the local index cleanup before
each backup as totals
([issue #306](https://git.eeqj.de/sneak/vaultik/issues/306)). Each
was shown as `(N remain)`, which read as rows left to clean up.
- 2026-10-13: Freed a cached blob once no file still to restore needs
it ([issue #324](https://git.eeqj.de/sneak/vaultik/issues/324)); one
also used by a file outside the selected paths had stayed in the
temporary cache until the restore ended.
- 2026-10-13: Kept the whole startup error when it contains `): `
([issue #320](https://git.eeqj.de/sneak/vaultik/issues/320)). Such
an error was cut at its last `): `, so the `blob_size_limit` one lost
the setting's name.
- 2026-10-13: Silenced the status lines of `prune` and snapshot removal
under `--cron` and `--quiet`
([issue #294](https://git.eeqj.de/sneak/vaultik/issues/294)).
- 2026-10-13: Read the rclone config file that `RCLONE_CONFIG` names
([issue #321](https://git.eeqj.de/sneak/vaultik/issues/321)); a
remote defined only there had been reported as not found.
- 2026-10-13: Made `/**/` in an exclude pattern match zero directories,
as in gitignore
([issue #338](https://git.eeqj.de/sneak/vaultik/issues/338));
`a/**/*.log` had missed `a/x.log`.
- 2026-10-13: Made the `chunk_size` and `blob_size_limit` defaults and
the `chunk_size` minimum 1000-based, like the size parser and the
docs ([issue #313](https://git.eeqj.de/sneak/vaultik/issues/313)).
- 2026-10-13: Kept a snapshot's remote metadata when `snapshot purge`
cannot remove it from the local index
([issue #337](https://git.eeqj.de/sneak/vaultik/issues/337)); the
purge had deleted the metadata anyway and exited 0.
- 2026-10-13: Made `snapshot restore` fail for a requested path that
matches nothing, which it had ignored
([issue #309](https://git.eeqj.de/sneak/vaultik/issues/309)).
- 2026-10-13: Made `remote nuke` delete every object under `metadata/`
and `blobs/`
([issue #291](https://git.eeqj.de/sneak/vaultik/issues/291)); it had
left some, such as a stray `.DS_Store`, while reporting the store
empty.
- 2026-10-13: Made `config set` create a list for an index under a
missing key
([issue #308](https://git.eeqj.de/sneak/vaultik/issues/308)); it had
written a map, after which no command could load the config.
- 2026-10-13: Listed a `file://` destination through a symlinked
`metadata/` or `blobs/`, which had listed as empty
([issue #296](https://git.eeqj.de/sneak/vaultik/issues/296)). A link
to a missing target now fails the listing
([issue #332](https://git.eeqj.de/sneak/vaultik/issues/332)).
- 2026-10-13: Made `snapshot verify --deep --json` report its blob
counts ([issue #314](https://git.eeqj.de/sneak/vaultik/issues/314));
a failed run had reported 0 missing and 0 verified.
- 2026-10-13: Limited and retried the module download in the Docker
build ([issue #351](https://git.eeqj.de/sneak/vaultik/issues/351)),
which had hung for as long as a stalled connection to the module
proxy lasted.
- 2026-10-13: Recorded each snapshot's uncompressed size when its
backup runs
([issue #360](https://git.eeqj.de/sneak/vaultik/issues/360)). A later
backup could change an older snapshot's UNCOMPRESSED SIZE in
`snapshot list`.
- 2026-10-13: Stopped `snapshot restore --skip-errors --verify`
reporting a file that failed to restore again as a checksum failure
([issue #323](https://git.eeqj.de/sneak/vaultik/issues/323)).
- 2026-10-13: Recorded the stored size of a blob a backup finds already
stored, not the size of its local copy, which can differ, so
`snapshot verify` could fail on a good snapshot
([issue #300](https://git.eeqj.de/sneak/vaultik/issues/300)).
- 2026-10-13: Stopped the local index and the blob upload step ending
the process on a failed close of query results or a failed temp file
cleanup ([issue #345](https://git.eeqj.de/sneak/vaultik/issues/345));
both are now returned as errors.
- 2026-10-13: Closed the snapshot database's storage reader too when
restore or deep verify is interrupted
([issue #356](https://git.eeqj.de/sneak/vaultik/issues/356)).
- 2026-10-13: Made `prune` and `remote nuke` fail when a delete on the
backup destination store fails
([issue #290](https://git.eeqj.de/sneak/vaultik/issues/290)).
- 2026-10-13: Made an S3 listing that the server cuts short an error
([issue #297](https://git.eeqj.de/sneak/vaultik/issues/297)); `prune`
had taken it as complete and could delete blobs still in use.
- 2026-10-13: Made shallow `snapshot verify` report a blob it could not
check apart from a missing one
([issue #318](https://git.eeqj.de/sneak/vaultik/issues/318)); any
error checking a blob had counted as missing, its cause dropped.
- 2026-10-13: Restored the other files when a file's chunk is in no
blob under `--skip-errors`
([issue #326](https://git.eeqj.de/sneak/vaultik/issues/326)); that
file had stopped the restore before anything was restored.
- 2026-10-13: Recorded each snapshot's new chunk size when its backup
runs ([issue #311](https://git.eeqj.de/sneak/vaultik/issues/311)).
`snapshot list` could show a NEW CHUNK SIZE of 0 B for a snapshot
whose change was a modified file.
- 2026-10-13: Made a backup interrupted during a local index write exit
as interrupted
([issue #301](https://git.eeqj.de/sneak/vaultik/issues/301)); it could
end with `FATAL: failed to rollback transaction` and exit 1 instead of
130.
- 2026-10-13: Printed `snapshot list` and `snapshot purge` sizes like
every other command
([issue #312](https://git.eeqj.de/sneak/vaultik/issues/312)). They,
and the `bytes` value in terminal log lines, were 1024-based but
labelled KB or MB, and now use 1000-based units.
- 2026-10-13: Closed each blob's storage reader when restore or deep
verify is interrupted, which ends a stalled read unless it is stuck in
the kernel ([issue #325](https://git.eeqj.de/sneak/vaultik/issues/325)).
- 2026-10-13: Made a mistyped subcommand under `snapshot`, `config`,
`remote`, `database` or `completion` an error
([issue #319](https://git.eeqj.de/sneak/vaultik/issues/319)); each had
printed its help and exited 0.
- 2026-10-13: Made an exclude pattern starting with `**/` match
directly under the snapshot path too, as in gitignore
([issue #303](https://git.eeqj.de/sneak/vaultik/issues/303)).
- 2026-10-13: Refused a relative `file://` storage URL and decoded its
percent-escapes
([issue #299](https://git.eeqj.de/sneak/vaultik/issues/299)); a
relative path had moved with the working directory.
- 2026-10-13: Removed the blob temp file when a backup stops part-way
([issue #302](https://git.eeqj.de/sneak/vaultik/issues/302)); the
half-written blob had stayed in `$TMPDIR`.
- 2026-10-13: Checked `--keep-newer-than` before a backup starts
([issue #307](https://git.eeqj.de/sneak/vaultik/issues/307)); an
invalid value had been read only by the prune after the backup, so
retention never ran.
- 2026-10-13: Stopped `remote info` counting a file under `blobs/` that
is not a blob as an orphaned blob
([issue #295](https://git.eeqj.de/sneak/vaultik/issues/295)). A stray
+14
View File
@@ -328,6 +328,20 @@ storage_url: "rclone://myremote/path/to/backups"
# Higher = better compression but slower
# Default: 3
compression_level: 5
# How old a blob that no snapshot lists must be before prune deletes it, and
# how old every object in a directory under metadata/ with no manifest must
# be before prune deletes that directory
# A backup uploads its blobs and its db.zst.age before its manifest, so on a
# destination shared between hosts or users this must be longer than the
# longest backup run
# It does not protect a blob a backup reuses from an earlier run that stopped
# before its manifest: that blob keeps the time of its first upload, so a
# prune can delete it
# Supports: 36h, 90m, etc.; 0s deletes such blobs and directories at once
# Default: 24h
#prune_grace_period: 24h
# Hostname to use in backup metadata
# Default: system hostname
#hostname: myserver
+18 -2
View File
@@ -153,7 +153,17 @@ Maps snapshots to the blobs they reference.
**Purpose:** Tracks blob dependencies for snapshots and enables manifest generation.
### 10. `uploads`
### 10. `snapshot_mount_points`
The directories at or below a configured path, the configured path itself included, that a snapshot's backup found an entry in and, once it had listed them, found on a different filesystem from their parent directory (a different device number), as a mount point is. An automount point whose volume cannot be mounted is not recorded, because the backup finds no entry in it.
**Columns:**
- `snapshot_id` (TEXT) - Snapshot ID (FK to snapshots.id)
- `path` (TEXT) - Absolute path of the directory
- PRIMARY KEY (`snapshot_id`, `path`)
**Purpose:** Lets `snapshot create --prune` skip the prune when a backup does not find a mount point that an earlier snapshot of the same name found, as when the volume is not mounted.
### 11. `uploads`
Tracks blob upload metrics.
**Columns:**
@@ -173,6 +183,7 @@ Tracks blob upload metrics.
- `INSERT OR REPLACE INTO files` - Update file metadata
- `SELECT * FROM files WHERE path = ?` - Check if file has changed
- `INSERT INTO snapshot_files` - Add file to current snapshot
- `INSERT OR IGNORE INTO snapshot_mount_points` - Record each directory on a different filesystem from its parent that the walk found an entry in
2. **Chunking** (for changed files)
- `INSERT OR IGNORE INTO chunks` - Store new chunks
@@ -228,7 +239,12 @@ The restore process doesn't use the local database. Instead:
1. **Identify Unreferenced Blobs**
- Query blobs not referenced by any remaining snapshot
- Delete from S3 and local database
- Delete from S3, once older than `prune_grace_period`, and from the local
database
2. **Delete Metadata Directories With No Manifest**
- Delete each directory under `metadata/` that has no `manifest.json.zst`
and no local snapshot record, once every object in it is older than
`prune_grace_period`
### 6. Incomplete Snapshot Cleanup
+10 -3
View File
@@ -4,7 +4,7 @@ This document describes the structure and organization of data stored in the S3
## Overview
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components, plus a marker file:
1. **Blobs** - The actual backup data (content-addressed, encrypted)
2. **Metadata** - Snapshot information and manifests (partially encrypted)
@@ -12,6 +12,7 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
```
<bucket>/<prefix>/
├── vaultik-destination.txt
├── blobs/
│ └── <hash[0:2]>/
│ └── <hash[2:4]>/
@@ -24,6 +25,12 @@ Vaultik stores all backup data in an S3-compatible object store. The repository
The metadata subdirectory is named with the **remote key**, a one-way hash of the snapshot ID, not with the human-readable snapshot ID itself. See [Remote Key Derivation](#remote-key-derivation).
## Marker File (`vaultik-destination.txt`)
Every backup writes this file at the root before it uploads anything. It holds one fixed line of plain text and names no host, path or time. While the local index holds snapshots stored there, a destination that answers a look-up of the marker with not found is treated as not present: `snapshot create` refuses to write to it, and `prune`, `snapshot purge`, `snapshot remove`, `snapshot list` and `remote nuke` behave as for a destination that cannot be listed. An unplugged volume leaves an empty directory at its mount point, and without the marker that directory would read as a destination whose snapshots are all gone. `remote nuke` deletes the marker last.
When the look-up of the marker fails for any reason other than not found, the check is skipped and the command goes on. S3 refuses it on every run to credentials that allow only PUT, so a host with such credentials does not notice a destination emptied since its last backup, and its next backup would leave out the data its local index records as already stored. After the destination is emptied, run `vaultik database delete` on such a host before it backs up again.
## Blobs Directory (`blobs/`)
### Structure
@@ -158,10 +165,10 @@ These are known, deliberate properties of the format and the tooling, recorded s
## Pruning Safety
The prune operation is safe because:
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references
1. It keeps every blob listed in any snapshot's manifest and deletes only blobs that no manifest references. Under `metadata/` it deletes only a directory that has no `manifest.json.zst`, whose name is a remote key, and that no local snapshot record claims
2. Manifests are unencrypted and can be read without keys
3. If any manifest cannot be downloaded or decoded, prune deletes nothing and exits with an error, rather than treating that snapshot's blobs as unreferenced
4. Prune requires exclusive access to the destination: running it during a concurrent backup can race a snapshot whose manifest is not yet written, so do not prune while a backup is in progress
4. It deletes an unreferenced blob only once the blob is older than `prune_grace_period` (default `24h`), and a directory under `metadata/` with no manifest only once every object in it is. A backup uploads its blobs and its `db.zst.age` before its manifest, so a prune running meanwhile, on this host or another sharing the destination, leaves them as long as no backup run takes longer than the grace period. The grace period does not protect a blob a backup reuses from an earlier run that stopped before its manifest: the backup skips the upload of a blob already on the destination, so that blob keeps the time of its first upload, and a prune before the backup's manifest is written can delete it; the new snapshot then cannot be restored. `remote nuke` has no such check
## Restoration Requirements
+12
View File
@@ -239,6 +239,18 @@ storage_url: ""
# Default: 3
# compression_level: 3
# How old a blob that no snapshot lists must be before prune deletes it,
# and how old every object in a directory under metadata/ with no manifest
# must be before prune deletes that directory. A backup uploads its blobs
# and its db.zst.age before its manifest, so on a destination shared
# between hosts or users this must be longer than the longest backup run.
# It does not protect a blob a backup reuses from an earlier run that
# stopped before its manifest: that blob keeps the time of its first
# upload, so a prune can delete it.
# Accepts: 36h, 90m, etc. 0s deletes such blobs and directories at once.
# Default: 24h
# prune_grace_period: 24h
# Hostname used in snapshot IDs. Default: system hostname.
# hostname: myserver
+7 -1
View File
@@ -101,7 +101,8 @@ func TestEntryPruneJSONStdoutIsExactlyOneDocument(t *testing.T) {
// writeHermeticPruneConfig builds a config over a temp directory with an
// empty destination directory and, if seedStale is set, creates the
// index database up front with one snapshot record that has no
// counterpart on the destination store. Returns the config path.
// counterpart on the destination store, and gives the destination the
// marker a backup writes. Returns the config path.
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
t.Helper()
@@ -131,6 +132,11 @@ func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
if seedStale {
seedStaleSnapshotRecord(t, indexPath)
// While the local index holds a snapshot, prune fails on a
// destination that answers that it has no marker.
require.NoError(t, os.WriteFile(
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
}
return configPath
+6 -2
View File
@@ -5,6 +5,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -14,8 +15,8 @@ import (
const flagCron = "--cron"
// plantUnreferencedBlob writes a blob that no snapshot references to the
// destination store at storeDir, so that a prune deletes it. Returns its
// path.
// destination store at storeDir, dated long before any prune grace period,
// so that a prune deletes it. Returns its path.
func plantUnreferencedBlob(t *testing.T, storeDir string) string {
t.Helper()
@@ -25,6 +26,9 @@ func plantUnreferencedBlob(t *testing.T, storeDir string) string {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o750))
require.NoError(t, os.WriteFile(path, []byte("unreferenced"), 0o600))
longAgo := time.Unix(0, 0)
require.NoError(t, os.Chtimes(path, longAgo, longAgo))
return path
}
@@ -1,6 +1,7 @@
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
import (
"context"
"os"
"path/filepath"
"strings"
@@ -8,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
)
// Flags of `snapshot create` used to build argument vectors below.
@@ -87,6 +89,81 @@ func TestEntrySnapshotCreateReportsPostBackupPruneFailure(t *testing.T) {
"the new snapshot is on the store beside the planted one")
}
// TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty backs up,
// empties the source directory, as a volume that is not mounted leaves
// its mount point, and runs `snapshot create --prune`. The command exits
// non-zero and reports the backup as completed, not as a failed snapshot
// creation. See https://git.eeqj.de/sneak/vaultik/issues/388.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneExitsNonZeroWhenSourceIsEmpty(t *testing.T) {
configPath, storeDir := writeBackupConfig(t)
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code)
// writeBackupConfig puts the source directory beside the config file.
require.NoError(t, os.Remove(
filepath.Join(filepath.Dir(configPath), "source", "file.txt")))
code, _, stderr := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
assert.Equal(t, 1, code)
assert.Contains(t, stderr,
"Backup completed, but 1 source path(s) were empty")
assert.NotContains(t, stderr, "Snapshot creation failed")
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
require.NoError(t, err)
assert.Len(t, snapshots, 2, "the older snapshot is kept")
}
// TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing backs
// up, records in the local index that this backup found the source
// directory as a mount point, and runs `snapshot create --prune`, which
// does not find it as one. The command exits non-zero, reports the
// backup as completed, not as a failed snapshot creation, and keeps the
// older snapshot. See https://git.eeqj.de/sneak/vaultik/issues/395.
//
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
func TestEntrySnapshotCreatePruneExitsNonZeroWhenMountPointIsMissing(
t *testing.T,
) {
configPath, storeDir := writeBackupConfig(t)
code, _, _ := runEntry(t, flagConfig, configPath, cmdSnapshot, cmdCreate)
require.Equal(t, 0, code)
// writeBackupConfig puts the source directory and the local index
// beside the config file.
dir := filepath.Dir(configPath)
ctx := context.Background()
db, err := database.New(ctx, filepath.Join(dir, "index.sqlite"))
require.NoError(t, err)
repos := database.NewRepositories(db)
backedUp, err := repos.Snapshots.GetOnlySnapshot(ctx)
require.NoError(t, err)
require.NoError(t, repos.Snapshots.AddMountPoint(ctx, nil,
backedUp.ID.String(), filepath.Join(dir, "source")))
require.NoError(t, db.Close())
code, _, stderr := runEntry(t,
flagConfig, configPath, cmdSnapshot, cmdCreate, flagPrune)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "Backup completed, but 1 mount point(s) "+
"an earlier snapshot recorded were not found")
assert.NotContains(t, stderr, "Snapshot creation failed")
snapshots, err := os.ReadDir(filepath.Join(storeDir, "metadata"))
require.NoError(t, err)
assert.Len(t, snapshots, 2, "the older snapshot is kept")
}
// writeBackupConfig builds a hermetic config with one small file in its
// source directory and an empty destination store directory. Returns the
// config path and the store directory.
+8 -1
View File
@@ -41,6 +41,11 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
// too, so the purge selects it rather than dropping it as stale.
seedStaleSnapshotRecord(t, indexPath)
// While the local index holds a snapshot, purge fails on a
// destination that answers that it has no marker.
require.NoError(t, os.WriteFile(
filepath.Join(dir, "store", "vaultik-destination.txt"), nil, 0o600))
manifestDir := filepath.Join(dir, "store", "metadata",
snapshot.RemoteSnapshotKey(stalePruneSnapshotID))
require.NoError(t, os.MkdirAll(manifestDir, 0o750))
@@ -59,10 +64,12 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
require.NoError(t, err)
require.NoError(t, db.Close())
code, _, _ := runEntry(t, flagConfig, configPath,
code, _, stderr := runEntry(t, flagConfig, configPath,
cmdSnapshot, cmdPurge, flagOlderThan, "30d", flagForce)
assert.Equal(t, 1, code)
assert.Contains(t, stderr, "simulated index error",
"the purge must fail at the local-index delete")
assert.FileExists(t, manifestPath,
"the snapshot is still in the local index, so its metadata must stay")
}
+13 -3
View File
@@ -13,14 +13,24 @@ func NewPruneCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "prune",
Short: "Tidy local database and remote storage",
Long: `Removes orphaned data from both the local index database and
unreferenced blobs from the backup destination store.
Long: `Removes orphaned data from the local index database, and
unreferenced blobs and directories under metadata/ with no manifest
from the backup destination store.
Local cleanup drops incomplete snapshots and any files, chunks, or
blobs no longer referenced by a completed snapshot. Remote cleanup
scans every snapshot manifest in the destination store, builds the
set of still-referenced blob hashes, and deletes any blob not in that
set.
set that is older than prune_grace_period (default 24h). A newer one
is kept: it may belong to a backup still running, on this host or
another sharing the destination store, that has not yet uploaded its
manifest. The grace period does not protect a blob such a backup
reuses from an earlier run that stopped before its manifest: that
blob keeps the time of its first upload, so it can be deleted.
Remote cleanup also deletes each directory under metadata/ with no
manifest, as a backup that stopped before its manifest leaves, once
all its objects are older than prune_grace_period and its snapshot is
not in the local index.
Snapshot create --prune runs the same cleanup automatically; this
command is the manual entry point for the same work (e.g. after a
+11 -4
View File
@@ -115,8 +115,10 @@ on Linux), then /etc/vaultik/config.yml.`,
return
}
if errors.Is(err, vaultik.ErrFilesUnreadable) {
log.Error("Backup completed with unreadable files", "error", err)
if errors.Is(err, vaultik.ErrFilesUnreadable) ||
errors.Is(err, vaultik.ErrSourcePathEmpty) ||
errors.Is(err, vaultik.ErrVolumeNotMounted) {
log.Error("Backup completed with an error", "error", err)
ReportErrorf("Backup completed, but %v", err)
return
@@ -131,8 +133,13 @@ on Linux), then /etc/vaultik/config.yml.`,
cmd.Flags().BoolVar(&opts.Cron, "cron", false,
"Run in cron mode (silent unless warning or error)")
cmd.Flags().BoolVar(&opts.Prune, "prune", false,
"After backup, drop older snapshots of the same name and remove "+
"orphaned blobs")
"After backup, drop older snapshots of the same name, remove "+
"orphaned blobs older than prune_grace_period, and delete each "+
"directory under metadata/ with no manifest once all its "+
"objects are older than prune_grace_period and its snapshot is "+
"not in the local index (not after a backup that skipped "+
"unreadable files, found a configured path empty, or did not "+
"find a mount point an earlier snapshot found)")
cmd.Flags().StringVar(&opts.KeepNewerThan, "keep-newer-than", "",
"With --prune: keep snapshots newer than this duration "+
"(e.g. 30d, 4w, 6mo, 1y; m is minutes, mo is months) "+
+19
View File
@@ -11,6 +11,7 @@ import (
"path/filepath"
"sort"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/smartconfig"
@@ -38,6 +39,7 @@ const (
defaultChunkSize = Size(10 * 1000 * 1000) // 10MB
defaultS3PartSize = Size(5 * 1024 * 1024) // 5MiB
defaultCompressionLevel = 3
defaultPruneGracePeriod = 24 * time.Hour
minChunkSize = 1000 * 1000 // 1MB
minCompressionLevel = 1
maxCompressionLevel = 19
@@ -67,6 +69,8 @@ var (
errFileURLNotAbsolute = errors.New(
"a file:// storage_url needs an absolute path, " +
"as in file:///path/to/backup")
errNegativePruneGracePeriod = errors.New(
"prune_grace_period must not be negative")
errStorageNotConfigured = errors.New(
"storage not configured; set storage_url or provide s3.endpoint + " +
"s3.bucket + credentials")
@@ -187,6 +191,15 @@ type Config struct {
Snapshots map[string]SnapshotConfig `yaml:"snapshots"`
CompressionLevel int `yaml:"compression_level"`
// PruneGracePeriod is how old a blob no manifest lists must be before
// a prune deletes it, and how old every object in a directory under
// metadata/ with no manifest must be before a prune deletes that
// directory. A backup writes its manifest only once it has uploaded
// all its blobs and its db.zst.age, so a younger blob or directory may
// belong to a backup still running, on this host or another sharing
// the destination store.
PruneGracePeriod time.Duration `yaml:"prune_grace_period"`
// StorageURL specifies the storage backend using a URL format.
// Takes precedence over S3Config if set.
// Supported formats:
@@ -288,6 +301,7 @@ func Parse(data []byte) (*Config, error) {
ChunkSize: defaultChunkSize,
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
CompressionLevel: defaultCompressionLevel,
PruneGracePeriod: defaultPruneGracePeriod,
S3: S3Config{PartSize: defaultS3PartSize},
}
@@ -356,6 +370,7 @@ func Parse(data []byte) (*Config, error) {
// exceeds the configured limit
// - Compression level must be between 1 and 19
// - S3 part size must be between 5MiB and 5GiB, the part sizes S3 accepts
// - Prune grace period must not be negative
//
// Returns an error describing the first validation failure encountered.
func (c *Config) Validate() error {
@@ -405,6 +420,10 @@ func (c *Config) Validate() error {
return errBadS3PartSize
}
if c.PruneGracePeriod < 0 {
return errNegativePruneGracePeriod
}
return nil
}
+69
View File
@@ -6,6 +6,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"sneak.berlin/go/vaultik/internal/chunker"
"sneak.berlin/go/vaultik/internal/log"
@@ -412,6 +413,74 @@ func TestDocumentedSizeExamples(t *testing.T) {
}
}
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
// is refused rather than acting like 0s, which deletes unreferenced blobs
// and directories under metadata/ with no manifest at once, and that 0s
// and the documented examples are accepted.
func TestValidatePruneGracePeriod(t *testing.T) {
t.Parallel()
const withoutGracePeriod = "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n"
tests := []struct {
setting string
wantErr error
}{
{"prune_grace_period: 0s", nil},
{"prune_grace_period: 90m", nil},
{"prune_grace_period: 36h", nil},
{"prune_grace_period: -1h", errNegativePruneGracePeriod},
}
for _, tt := range tests {
t.Run(tt.setting, func(t *testing.T) {
t.Parallel()
cfg, err := Parse([]byte(withoutGracePeriod + tt.setting + "\n"))
if err != nil {
t.Fatalf("Parse() unexpected error: %v", err)
}
err = cfg.Validate()
if !errors.Is(err, tt.wantErr) {
t.Fatalf("Validate() error = %v, want %v", err, tt.wantErr)
}
})
}
}
// TestLoadPruneGracePeriod checks that a config file without
// prune_grace_period loads with the documented 24h. The value is written out
// here rather than taken from defaultPruneGracePeriod, so that a change of the
// default fails this test.
func TestLoadPruneGracePeriod(t *testing.T) {
t.Parallel()
const withoutGracePeriod = "snapshots:\n" +
" test:\n" +
" paths: [/tmp/vaultik-test-source]\n" +
"storage_url: file:///tmp/vaultik-test-storage\n"
path := filepath.Join(t.TempDir(), "config.yml")
err := os.WriteFile(path, []byte(withoutGracePeriod), 0o600)
if err != nil {
t.Fatalf("write config: %v", err)
}
cfg, err := Load(path)
if err != nil {
t.Fatalf("Load() unexpected error: %v", err)
}
if cfg.PruneGracePeriod != 24*time.Hour {
t.Errorf("prune_grace_period = %s, want 24h", cfg.PruneGracePeriod)
}
}
// TestLoadRejectsRelativeFileURL checks that a file:// storage_url with a
// relative path fails at load. Accepted, it would resolve against the
// working directory, so a backup run from another directory would write
+10
View File
@@ -123,6 +123,16 @@ CREATE TABLE IF NOT EXISTS snapshot_blobs (
-- Index for efficient blob lookups (used in orphan detection)
CREATE INDEX IF NOT EXISTS idx_snapshot_blobs_blob_id ON snapshot_blobs(blob_id);
-- Snapshot mount points table: the directories at or below a configured
-- path that the snapshot's backup found on a different filesystem from
-- their parent directory, as a mount point is, and found an entry in
CREATE TABLE IF NOT EXISTS snapshot_mount_points (
snapshot_id TEXT NOT NULL,
path TEXT NOT NULL,
PRIMARY KEY (snapshot_id, path),
FOREIGN KEY (snapshot_id) REFERENCES snapshots(id) ON DELETE CASCADE
);
-- Uploads table: tracks blob upload metrics
CREATE TABLE IF NOT EXISTS uploads (
blob_hash TEXT PRIMARY KEY,
+74 -15
View File
@@ -24,7 +24,8 @@ var (
)
// SnapshotRepository provides access to the snapshots table and its
// snapshot_files / snapshot_blobs association tables.
// snapshot_files / snapshot_blobs association tables, and to the mount
// points each snapshot's backup found (snapshot_mount_points).
type SnapshotRepository struct {
db *DB
}
@@ -494,33 +495,63 @@ func (r *SnapshotRepository) GetBlobHashes(
ctx context.Context, snapshotID string,
) ([]string, error) {
query := `
SELECT sb.blob_hash
SELECT sb.blob_hash
FROM snapshot_blobs sb
WHERE sb.snapshot_id = ?
ORDER BY sb.blob_hash
`
rows, err := r.db.conn.QueryContext(ctx, query, snapshotID)
blobs, err := r.queryStrings(ctx, query, snapshotID)
if err != nil {
return nil, fmt.Errorf("querying blob hashes: %w", err)
}
defer func() { _ = rows.Close() }()
return blobs, nil
}
var blobs []string
// AddMountPoint records dir as a directory that the backup of a snapshot
// found on a different filesystem from its parent directory, as a mount
// point is, using tx when non-nil.
func (r *SnapshotRepository) AddMountPoint(
ctx context.Context, tx *sql.Tx, snapshotID string, dir string,
) error {
query := `
INSERT OR IGNORE INTO snapshot_mount_points (snapshot_id, path)
VALUES (?, ?)
`
for rows.Next() {
var blobHash string
err := rows.Scan(&blobHash)
if err != nil {
return nil, fmt.Errorf("scanning blob hash: %w", err)
}
blobs = append(blobs, blobHash)
var err error
if tx != nil {
_, err = tx.ExecContext(ctx, query, snapshotID, dir)
} else {
_, err = r.db.ExecWithLog(ctx, query, snapshotID, dir)
}
return blobs, rows.Err()
if err != nil {
return fmt.Errorf("adding mount point to snapshot: %w", err)
}
return nil
}
// GetMountPoints returns the directories AddMountPoint recorded for a
// snapshot, sorted.
func (r *SnapshotRepository) GetMountPoints(
ctx context.Context, snapshotID string,
) ([]string, error) {
query := `
SELECT path
FROM snapshot_mount_points
WHERE snapshot_id = ?
ORDER BY path
`
dirs, err := r.queryStrings(ctx, query, snapshotID)
if err != nil {
return nil, fmt.Errorf("querying mount points: %w", err)
}
return dirs, nil
}
// GetSnapshotTotalCompressedSize returns the total compressed size of all
@@ -709,6 +740,34 @@ func (r *SnapshotRepository) DeleteSnapshotUploads(
return nil
}
// queryStrings runs query, which selects one text column, and returns
// its values in the order of the rows.
func (r *SnapshotRepository) queryStrings(
ctx context.Context, query string, args ...any,
) ([]string, error) {
rows, err := r.db.conn.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var values []string
for rows.Next() {
var value string
err := rows.Scan(&value)
if err != nil {
return nil, err
}
values = append(values, value)
}
return values, rows.Err()
}
// extendedCompressionRatio computes the compression ratio for a snapshot
// from its stored blob_size and the given uncompressed size. Returns 1.0
// when the uncompressed size is zero.
+50
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"math"
"slices"
"testing"
"time"
@@ -422,3 +423,52 @@ func TestSnapshotRepositoryDuplicate(t *testing.T) {
t.Error("expected error for duplicate snapshot")
}
}
// Deleting a snapshot from the local index deletes the mount points its
// backup recorded, and no other snapshot's. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestSnapshotRepositoryDeleteRemovesMountPoints(t *testing.T) {
t.Parallel()
db, cleanup := setupTestDB(t)
defer cleanup()
ctx := context.Background()
repo := database.NewSnapshotRepository(db)
for _, id := range []types.SnapshotID{"kept", "deleted"} {
err := repo.Create(ctx, nil, &database.Snapshot{
ID: id,
Hostname: testHostname,
VaultikVersion: testVersion,
StartedAt: time.Now().Truncate(time.Second),
})
if err != nil {
t.Fatalf("failed to create snapshot: %v", err)
}
err = repo.AddMountPoint(ctx, nil, id.String(), "/data/nas")
if err != nil {
t.Fatalf("failed to add mount point: %v", err)
}
}
err := repo.Delete(ctx, "deleted")
if err != nil {
t.Fatalf("failed to delete snapshot: %v", err)
}
for id, want := range map[string][]string{
"kept": {"/data/nas"},
"deleted": nil,
} {
got, err := repo.GetMountPoints(ctx, id)
if err != nil {
t.Fatalf("failed to read mount points: %v", err)
}
if !slices.Equal(got, want) {
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
}
}
}
+8 -3
View File
@@ -8,6 +8,7 @@ import (
"io"
"strings"
"sync/atomic"
"time"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/config"
@@ -302,7 +303,10 @@ func IsNotFound(err error) bool {
type ObjectInfo struct {
Key string
Size int64
Err error
// ModTime is the object's LastModified time. Only ListObjectsStream
// sets it.
ModTime time.Time
Err error
}
// ListObjectsStream lists objects with the given prefix and returns a channel.
@@ -343,8 +347,9 @@ func (c *Client) ListObjectsStream(
}
ch <- ObjectInfo{
Key: key,
Size: *obj.Size,
Key: key,
Size: *obj.Size,
ModTime: aws.ToTime(obj.LastModified),
}
}
}
+40
View File
@@ -0,0 +1,40 @@
//nolint:testpackage // exercises the unexported deviceNumberFromStat
package snapshot
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The device number a real backup reads is the same for a directory and
// a file in it, and differs between the temp directory and /dev.
// Without it no mount point is ever found. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestDeviceNumberFromStat(t *testing.T) {
t.Parallel()
dir := t.TempDir()
file := filepath.Join(dir, "file.txt")
require.NoError(t, os.WriteFile(file, nil, 0o600))
deviceOf := func(path string) uint64 {
t.Helper()
info, err := os.Stat(path)
require.NoError(t, err)
device, ok := deviceNumberFromStat(info)
require.True(t, ok, "no device number read for %s", path)
return device
}
assert.Equal(t, deviceOf(dir), deviceOf(file))
// /dev is a different filesystem from the temp dir on Linux, macOS and in Docker.
assert.NotEqual(t, deviceOf(dir), deviceOf("/dev"))
}
+6
View File
@@ -1,6 +1,8 @@
package snapshot
import (
"os"
"github.com/spf13/afero"
"go.uber.org/fx"
"sneak.berlin/go/vaultik/internal/config"
@@ -19,6 +21,9 @@ type ScannerParams struct {
// PreviousSnapshotID is the most recent completed snapshot of the
// same name, "" for none.
PreviousSnapshotID string
// DeviceNumber is ScannerConfig.DeviceNumber; nil reads it from the
// file's stat.
DeviceNumber func(info os.FileInfo) (uint64, bool)
}
// Module exports backup functionality as an fx module.
@@ -59,6 +64,7 @@ func provideScannerFactory(
Exclude: excludes,
SkipErrors: params.SkipErrors,
PreviousSnapshotID: params.PreviousSnapshotID,
DeviceNumber: params.DeviceNumber,
})
}
}
+245
View File
@@ -0,0 +1,245 @@
package snapshot_test
import (
"context"
"os"
"path/filepath"
"slices"
"strings"
"syscall"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/snapshot"
)
// A scan records with the snapshot each directory on a different device
// from its parent directory that the walk found an entry in, as a mount
// point is, the scanned path included. A filesystem that gives no device
// numbers, as the in-memory one, records none. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestScanRecordsMountPoints(t *testing.T) {
t.Parallel()
const (
scannedPath = "/data/source"
snapshotID = "mount-points"
)
tests := []struct {
name string
// onVolume names the directories on the volume's device; every
// other directory is on another one. nil leaves the device
// numbers to the in-memory filesystem, which has none.
onVolume []string
want []string
}{
{
name: "volume below the scanned path",
onVolume: []string{"nas"},
want: []string{scannedPath + "/nas"},
},
{
name: "volume at the scanned path",
onVolume: []string{"source", "nas"},
want: []string{scannedPath},
},
{name: "no device numbers", want: nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
fs := afero.NewMemMapFs()
for _, path := range []string{
scannedPath + "/notes.txt", scannedPath + "/nas/a.txt",
} {
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
}
db, err := database.NewTestDB()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
createTestSnapshotRecord(ctx, t, repos, snapshotID)
cfg := snapshot.ScannerConfig{
FS: fs,
ChunkSize: int64(1024 * 16),
Repositories: repos,
MaxBlobSize: int64(1024 * 1024),
CompressionLevel: 3,
AgeRecipients: []string{testAgePublicKey},
}
if tt.onVolume != nil {
cfg.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
if slices.Contains(tt.onVolume, info.Name()) {
return 2, true
}
return 1, true
}
}
_, err = snapshot.NewScanner(cfg).Scan(ctx, scannedPath, snapshotID)
require.NoError(t, err)
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
// automountFs is an in-memory filesystem with a volume at mountPoint
// that is mounted on first access: opening mountPoint to list it mounts
// the volume or, if it cannot be mounted, fails with "no such file or
// directory", as with the autofs daemon. Stat records the device an
// entry is on at that moment, as a real stat does: unmountedDevice for
// mountPoint until the volume is mounted; the volume's, 2, for
// mountPoint once it is and for everything in it; 1 for everything else.
type automountFs struct {
afero.Fs
mountPoint string
unmountedDevice uint64
canMount bool
mounted bool
}
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
func (fs *automountFs) Open(name string) (afero.File, error) {
if name == fs.mountPoint && !fs.canMount {
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
}
if name == fs.mountPoint {
fs.mounted = true
}
return fs.Fs.Open(name)
}
func (fs *automountFs) Stat(name string) (os.FileInfo, error) {
info, err := fs.Fs.Stat(name)
if err != nil {
return nil, err
}
device := uint64(1)
switch {
case name == fs.mountPoint && !fs.mounted:
device = fs.unmountedDevice
case name == fs.mountPoint || strings.HasPrefix(name, fs.mountPoint+"/"):
device = 2
}
return statWithDevice{FileInfo: info, device: device}, nil
}
// statWithDevice is an entry automountFs.Stat looked up, with the device
// it was on then.
type statWithDevice struct {
os.FileInfo
device uint64
}
// deviceAtLookup returns the device automountFs.Stat recorded for info.
func deviceAtLookup(info os.FileInfo) (uint64, bool) {
stat, ok := info.(statWithDevice)
return stat.device, ok
}
// A volume mounted on first access is mounted when the walk lists its
// mount point. Until then the mount point is on the automounter's own
// device (a systemd automount) or on its parent's (an autofs map that
// shows its mount points before mounting them). Either way only the
// mount point is recorded, by the backup that mounts the volume and by a
// later one that finds it mounted. Listing an automount point whose
// volume cannot be mounted fails; it is not recorded, because the walk
// finds no entry in it. See https://git.eeqj.de/sneak/vaultik/issues/395.
func TestScanRecordsOnlyTheMountPointOfAnAutomountedVolume(t *testing.T) {
t.Parallel()
const scannedPath = "/data/source"
mountPoint := scannedPath + "/nas"
tests := []struct {
name string
unmountedDevice uint64
canMount bool
want []string
}{
{
name: "on the automounter's device until listed",
unmountedDevice: 3, canMount: true, want: []string{mountPoint},
},
{
name: "on its parent's device until listed",
unmountedDevice: 1, canMount: true, want: []string{mountPoint},
},
{
name: "volume that cannot be mounted",
unmountedDevice: 1, canMount: false, want: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
fs := &automountFs{
Fs: afero.NewMemMapFs(), mountPoint: mountPoint,
unmountedDevice: tt.unmountedDevice, canMount: tt.canMount,
}
require.NoError(t, fs.MkdirAll(mountPoint+"/photos", 0o755))
for _, path := range []string{
scannedPath + "/notes.txt", mountPoint + "/photos/a.jpg",
} {
require.NoError(t, afero.WriteFile(fs, path, []byte(path), 0o644))
}
db, err := database.NewTestDB()
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
repos := database.NewRepositories(db)
for _, snapshotID := range []string{"first", "later"} {
createTestSnapshotRecord(ctx, t, repos, snapshotID)
scanner := snapshot.NewScanner(snapshot.ScannerConfig{
FS: fs,
ChunkSize: int64(1024 * 16),
Repositories: repos,
MaxBlobSize: int64(1024 * 1024),
CompressionLevel: 3,
AgeRecipients: []string{testAgePublicKey},
DeviceNumber: deviceAtLookup,
})
_, err = scanner.Scan(ctx, scannedPath, snapshotID)
require.NoError(t, err)
got, err := repos.Snapshots.GetMountPoints(ctx, snapshotID)
require.NoError(t, err)
assert.Equal(t, tt.want, got, "mount points of the %s backup", snapshotID)
}
})
}
}
+1 -2
View File
@@ -78,8 +78,7 @@ func TestProgressPercentWithSecondPathSmaller(t *testing.T) {
stats := progress.GetStats()
// Directories count toward the total size but produce no chunks, so
// the percentage ends just under 100%.
// With both paths counted, the percentage must not exceed 100%.
percent := float64(stats.BytesProcessed.Load()) /
float64(stats.TotalSize.Load()) * 100
if percent > 100 {
+307 -93
View File
@@ -73,6 +73,9 @@ type Scanner struct {
skipErrors bool
// ui is the user-facing output; never nil (defaults to a discarding writer).
ui *ui.Writer
// deviceNumber returns the number of the device that holds a file;
// never nil (defaults to deviceNumberFromStat).
deviceNumber func(info os.FileInfo) (uint64, bool)
// In-memory cache of known chunk hashes for fast existence checks
knownChunks map[string]struct{}
@@ -136,16 +139,21 @@ type ScannerConfig struct {
// PreviousSnapshotID is the most recent completed snapshot of the
// same name, "" for none.
PreviousSnapshotID string
// DeviceNumber returns the number of the device that holds a file,
// and false when there is none to read. nil reads it from the file's
// stat; tests replace it, since the in-memory test filesystem has none.
DeviceNumber func(info os.FileInfo) (uint64, bool)
}
// ScanResult contains the results of a scan operation. Files and bytes
// are counted per file: BytesScanned is the size of the new and changed
// files, BytesSkipped that of the unchanged ones. FilesFailed counts the
// new and changed files that phase 2 could not store and, under
// --skip-errors, each entry the walk could not read, such as a directory
// it could not list; FilesScanned includes them and BytesScanned does
// not. ChunksCreated counts the chunks that were not already stored, and
// NewChunkSize is their total size.
// new and changed files that phase 2 could not store, each entry removed
// after the walk listed its directory and, under --skip-errors, each
// entry the walk could not read, such as a directory it could not list;
// FilesScanned includes them and BytesScanned does not. ChunksCreated
// counts the chunks that were not already stored, and NewChunkSize is
// their total size.
type ScanResult struct {
FilesScanned int
FilesSkipped int
@@ -167,6 +175,12 @@ type ScanResult struct {
// skipped because it could not be read or listed; the rest are files
// removed after the walk listed them.
FilesUnreadable int
// EntriesFound counts the entries the walk found and could look up
// that no exclude pattern matches, leaving out the scanned path
// itself when it is a directory. It is zero for an empty directory,
// such as the mount point of a volume that is not mounted.
EntriesFound int
}
// NewScanner creates a new scanner instance
@@ -207,6 +221,11 @@ func NewScanner(cfg ScannerConfig) *Scanner {
uiw = ui.NewWithColor(io.Discard, false)
}
deviceNumber := cfg.DeviceNumber
if deviceNumber == nil {
deviceNumber = deviceNumberFromStat
}
return &Scanner{
fs: cfg.FS,
chunker: chunker.NewChunker(cfg.ChunkSize),
@@ -222,6 +241,7 @@ func NewScanner(cfg ScannerConfig) *Scanner {
skipErrors: cfg.SkipErrors,
previousSnapshotID: cfg.PreviousSnapshotID,
ui: uiw,
deviceNumber: deviceNumber,
pendingChunkHashes: make(map[string]struct{}),
}
}
@@ -290,6 +310,11 @@ func (s *Scanner) Scan(
}
}
err = s.recordDirectories(ctx, scanResult)
if err != nil {
return nil, err
}
// Summarize scan phase results and update progress
s.summarizeScanPhase(result, filesToProcess)
@@ -861,14 +886,20 @@ func (s *Scanner) batchInsertFileData(
// ScanPhaseResult contains the results of the scan phase
type ScanPhaseResult struct {
FilesToProcess []*FileToProcess
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
Directories []*FileToProcess // Every directory found, to record
// MountPoints are the directories, the scanned path included, that
// the walk found an entry in and, once it had listed them, on a
// different device from their parent directory (see
// recordIfMountPoint).
MountPoints []string
}
// scanPhase performs the initial directory scan to identify files to process
// It uses the pre-loaded knownFiles map for fast change detection without DB
// queries. It also populates existingFiles map for deletion detection.
// Returns files needing processing and IDs of unchanged files for snapshot
// association.
// Returns files needing processing, IDs of unchanged files for snapshot
// association, and the directories found.
func (s *Scanner) scanPhase(
ctx context.Context,
path string,
@@ -876,10 +907,12 @@ func (s *Scanner) scanPhase(
existingFiles map[string]struct{},
knownFiles map[string]*database.File,
) (*ScanPhaseResult, error) {
// Use known file count as estimate for progress (accurate for subsequent backups)
estimatedTotal := int64(len(knownFiles))
// Use known file count as estimate for progress (accurate for
// subsequent backups). The walk does not count directories, so the
// estimate leaves them out.
estimatedTotal := countNonDirectories(knownFiles)
collector := &scanCollector{}
collector := &scanCollector{dirsWithEntries: make(map[string]struct{})}
// Set up periodic status output
startTime := time.Now()
@@ -888,6 +921,26 @@ func (s *Scanner) scanPhase(
log.Debug("Starting directory walk", "path", path)
walkFn := func(filePath string, info os.FileInfo, err error) error {
// Check exclude patterns first, so an excluded entry that is gone
// or cannot be looked up is neither counted nor reported. For
// directories, skip the entire subtree. info is nil when the
// walk could not look the entry up.
if s.shouldExclude(filePath, path) {
if info != nil && info.IsDir() {
return filepath.SkipDir
}
return nil
}
// The source path is in no directory listing, so its absence is
// an error rather than a removal.
if errors.Is(err, os.ErrNotExist) && filePath != path {
countRemovedDuringWalk(filePath, existingFiles, result)
return nil
}
if err != nil {
return s.handleWalkError(filePath, err, result)
}
@@ -899,18 +952,16 @@ func (s *Scanner) scanPhase(
default:
}
// Check exclude patterns - for directories, skip the entire subtree
if s.shouldExclude(filePath, path) {
if info.IsDir() {
return filepath.SkipDir
}
return nil
// The scanned directory itself is not an entry found in it.
if filePath != path || !info.IsDir() {
result.EntriesFound++
}
s.recordIfMountPoint(filePath, path, collector)
// Handle symlinks and directories
handled, err := s.recordSpecialEntry(
filePath, info, existingFiles, collector, result)
filePath, info, existingFiles, knownFiles, collector, result)
if handled {
return err
}
@@ -920,17 +971,8 @@ func (s *Scanner) scanPhase(
return nil
}
// Track this file as existing (for deletion detection)
existingFiles[filePath] = struct{}{}
// Check file against in-memory map (no DB query!)
file, needsProcessing := s.checkFileInMemory(filePath, info, knownFiles)
filesScanned, changedCount := collector.recordRegularFile(
filePath, info, file, needsProcessing)
// Update result stats
s.updateScanEntryStats(result, needsProcessing, info)
filesScanned, changedCount := s.recordRegularFile(
filePath, info, existingFiles, knownFiles, collector, result)
// Output periodic status
if time.Since(lastStatusTime) >= scanStatusInterval {
@@ -950,15 +992,33 @@ func (s *Scanner) scanPhase(
return &ScanPhaseResult{
FilesToProcess: collector.filesToProcess,
UnchangedFileIDs: collector.unchangedFileIDs,
Directories: collector.directories,
MountPoints: collector.mountPoints,
}, nil
}
// countNonDirectories returns how many of files are not directories.
func countNonDirectories(files map[string]*database.File) int64 {
var count int64
for _, f := range files {
if f.Mode&uint32(os.ModeDir) == 0 {
count++
}
}
return count
}
// recordSpecialEntry records symlinks and directories (which have no
// data to chunk) and reports whether it handled the entry. For a symlink
// whose target cannot be read it returns handleWalkError's result.
// data to chunk) and reports whether it handled the entry. A symlink is
// counted and checked for changes as a regular file is; a directory is
// set aside to be recorded and is not counted. For a symlink whose
// target cannot be read it returns handleWalkError's result.
func (s *Scanner) recordSpecialEntry(
filePath string, info os.FileInfo,
existingFiles map[string]struct{},
knownFiles map[string]*database.File,
collector *scanCollector, result *ScanResult,
) (bool, error) {
// Handle symlinks
@@ -969,8 +1029,9 @@ func (s *Scanner) recordSpecialEntry(
}
existingFiles[filePath] = struct{}{}
collector.addToProcess(filePath, info, file)
s.updateScanEntryStats(result, true, info)
needsProcessing := s.checkFileInMemory(file, knownFiles)
collector.recordFile(filePath, info, file, needsProcessing)
s.updateScanEntryStats(result, needsProcessing, info)
return true, nil
}
@@ -980,7 +1041,7 @@ func (s *Scanner) recordSpecialEntry(
if info.IsDir() {
file := s.buildDirectoryEntry(filePath, info)
existingFiles[filePath] = struct{}{}
collector.addToProcess(filePath, info, file)
collector.addDirectory(filePath, info, file)
return true, nil
}
@@ -988,6 +1049,46 @@ func (s *Scanner) recordSpecialEntry(
return false, nil
}
// recordRegularFile records a regular file as recordSpecialEntry does a
// symlink and returns the running scanned and changed counts.
func (s *Scanner) recordRegularFile(
filePath string, info os.FileInfo,
existingFiles map[string]struct{},
knownFiles map[string]*database.File,
collector *scanCollector, result *ScanResult,
) (int64, int) {
// Track this file as existing (for deletion detection)
existingFiles[filePath] = struct{}{}
// Check file against in-memory map (no DB query!)
file := s.buildRegularFileEntry(filePath, info)
needsProcessing := s.checkFileInMemory(file, knownFiles)
filesScanned, changedCount := collector.recordFile(
filePath, info, file, needsProcessing)
// Update result stats
s.updateScanEntryStats(result, needsProcessing, info)
return filesScanned, changedCount
}
// countRemovedDuringWalk counts an entry that was in its directory's
// listing but was gone when the walk looked it up, like a file removed
// before phase 2 reads it: as examined and failed, and not as an error,
// with or without --skip-errors.
func countRemovedDuringWalk(
filePath string, existingFiles map[string]struct{}, result *ScanResult,
) {
log.Warn("File was deleted during the directory walk, skipping",
"path", filePath)
// The walk found it, so it is not also counted as deleted.
existingFiles[filePath] = struct{}{}
result.FilesScanned++
result.FilesFailed++
}
// handleWalkError deals with a filesystem error surfaced by the walk:
// under --skip-errors the entry is skipped with a UI notice and counted
// as examined, failed and unreadable, otherwise the error is fatal.
@@ -1017,28 +1118,52 @@ type scanCollector struct {
mu sync.Mutex
filesToProcess []*FileToProcess
unchangedFileIDs []types.FileID // Just IDs - no new records needed
directories []*FileToProcess
filesScanned int64
// dirsWithEntries holds each directory the walk has found an entry in.
dirsWithEntries map[string]struct{}
mountPoints []string
}
// addToProcess queues a file entry for processing.
func (c *scanCollector) addToProcess(
// firstEntryIn notes that the walk found an entry in directory dir and
// reports whether it is the first one.
func (c *scanCollector) firstEntryIn(dir string) bool {
c.mu.Lock()
defer c.mu.Unlock()
_, seen := c.dirsWithEntries[dir]
c.dirsWithEntries[dir] = struct{}{}
return !seen
}
// addMountPoint sets a mount point aside to be recorded.
func (c *scanCollector) addMountPoint(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
c.mountPoints = append(c.mountPoints, dir)
}
// addDirectory sets a directory aside to be recorded. It is not counted
// as a file.
func (c *scanCollector) addDirectory(
path string, info os.FileInfo, file *database.File,
) {
c.mu.Lock()
defer c.mu.Unlock()
c.filesToProcess = append(c.filesToProcess, &FileToProcess{
c.directories = append(c.directories, &FileToProcess{
Path: path,
FileInfo: info,
File: file,
})
c.filesScanned++
}
// recordRegularFile records a regular file as changed (to process) or
// unchanged (snapshot association only) and returns the running scanned
// and changed counts.
func (c *scanCollector) recordRegularFile(
// recordFile records a regular file or symlink as changed (to process)
// or unchanged (snapshot association only) and returns the running
// scanned and changed counts.
func (c *scanCollector) recordFile(
path string, info os.FileInfo, file *database.File, needsProcessing bool,
) (int64, int) {
c.mu.Lock()
@@ -1141,7 +1266,28 @@ func (s *Scanner) printScanProgressLine(
}
}
// buildSymlinkEntry creates a File record for a symlink.
// buildRegularFileEntry creates a File record for a regular file, with
// no ID: checkFileInMemory sets it.
func (s *Scanner) buildRegularFileEntry(path string, info os.FileInfo) *database.File {
var uid, gid uint32
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
uid = stat.Uid
gid = stat.Gid
}
return &database.File{
Path: types.FilePath(path),
SourcePath: types.SourcePath(s.currentSourcePath),
MTime: info.ModTime(),
Size: info.Size(),
Mode: uint32(info.Mode()),
UID: uid,
GID: gid,
}
}
// buildSymlinkEntry creates a File record for a symlink, with no ID:
// checkFileInMemory sets it.
func (s *Scanner) buildSymlinkEntry(
path string, info os.FileInfo,
) (*database.File, error) {
@@ -1157,7 +1303,6 @@ func (s *Scanner) buildSymlinkEntry(
}
return &database.File{
ID: types.NewFileID(),
Path: types.FilePath(path),
SourcePath: types.SourcePath(s.currentSourcePath),
MTime: info.ModTime(),
@@ -1189,6 +1334,102 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
}
}
// recordDirectories records every directory the walk found, and its
// mount points (see recordMountPoints). A directory has no data to back
// up, so it is not among the files counted and backed up.
func (s *Scanner) recordDirectories(
ctx context.Context, scanned *ScanPhaseResult,
) error {
for _, dir := range scanned.Directories {
err := s.recordNonRegularFile(ctx, dir)
if err != nil {
return fmt.Errorf("recording directory %s: %w", dir.Path, err)
}
}
return s.recordMountPoints(ctx, scanned.MountPoints)
}
// recordMountPoints records with the snapshot the mount points the walk
// found (see ScanPhaseResult.MountPoints).
func (s *Scanner) recordMountPoints(
ctx context.Context, mountPoints []string,
) error {
return s.repos.WithTx(ctx, func(txCtx context.Context, tx *sql.Tx) error {
for _, dir := range mountPoints {
log.Info("Found a mount point", "path", dir)
err := s.repos.Snapshots.AddMountPoint(txCtx, tx, s.snapshotID, dir)
if err != nil {
return fmt.Errorf("recording mount point %s: %w", dir, err)
}
}
return nil
})
}
// recordIfMountPoint sets the directory that holds entry, a path the
// walk found, aside to be recorded as a mount point if it is on a
// different device from its parent directory. The walk calls it for each
// entry it finds, and it decides on the first entry in a directory, so a
// directory the walk found no entry in is never recorded. By then the
// walk has listed the directory, which mounts a volume mounted there on
// first access, so the directory and its parent are looked up again:
// before the listing, the directory can be on the automounter's own
// device (a systemd automount) or on its parent's (an autofs map that
// shows its mount points before mounting them). The scanned path is in
// no listing the walk made, so it is skipped as an entry. A directory
// that cannot be looked up then, or whose parent cannot, is not
// recorded, so this snapshot does not stop the prune of a later backup
// that misses its volume.
func (s *Scanner) recordIfMountPoint(
entry, scannedPath string, collector *scanCollector,
) {
dir := filepath.Dir(entry)
if entry == scannedPath || !collector.firstEntryIn(dir) {
return
}
dirDevice, ok := s.deviceNumberAt(dir)
if !ok {
return
}
parentDevice, ok := s.deviceNumberAt(filepath.Dir(dir))
if ok && dirDevice != parentDevice {
collector.addMountPoint(dir)
}
}
// deviceNumberAt looks path up now and returns the number of the device
// that holds it, and false when the lookup fails (logged) or gives no
// device number.
func (s *Scanner) deviceNumberAt(path string) (uint64, bool) {
info, err := s.fs.Stat(path)
if err != nil {
log.Warn("Could not look up a directory to check for a mount point",
"path", path, "error", err)
return 0, false
}
return s.deviceNumber(info)
}
// deviceNumberFromStat returns the number of the device that holds the
// file info describes, and false when info carries no stat, as from the
// in-memory test filesystem.
func deviceNumberFromStat(info os.FileInfo) (uint64, bool) {
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return 0, false
}
return uint64(stat.Dev), true //nolint:unconvert // Dev is an int32 on darwin
}
// recordNonRegularFile writes a symlink or directory entry to the database
// and associates it with the current snapshot. No chunking is performed.
func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess) error {
@@ -1202,62 +1443,36 @@ func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess)
})
}
// checkFileInMemory checks if a file needs processing using the in-memory map
// checkFileInMemory reports whether a regular file or symlink needs
// processing, and gives file the ID of its known record, or a new ID for
// a new path.
// No database access is performed - this is purely CPU/memory work
func (s *Scanner) checkFileInMemory(
path string, info os.FileInfo, knownFiles map[string]*database.File,
) (*database.File, bool) {
var uid, gid uint32
if stat, ok := info.Sys().(*syscall.Stat_t); ok {
uid = stat.Uid
gid = stat.Gid
}
// Check against in-memory map first to get existing ID if available
existingFile, exists := knownFiles[path]
// Create file record with ID set upfront
// For new files, generate UUID immediately so it's available for chunk associations
// For existing files, reuse the existing ID
var fileID types.FileID
if exists {
fileID = existingFile.ID
} else {
fileID = types.NewFileID()
}
file := &database.File{
ID: fileID,
Path: types.FilePath(path),
SourcePath: types.SourcePath(s.currentSourcePath),
MTime: info.ModTime(),
Size: info.Size(),
Mode: uint32(info.Mode()),
UID: uid,
GID: gid,
}
file *database.File, knownFiles map[string]*database.File,
) bool {
existingFile, exists := knownFiles[file.Path.String()]
// New file - needs processing
if !exists {
return file, true
file.ID = types.NewFileID()
return true
}
file.ID = existingFile.ID
// No uploaded blob holds one of its chunks (see loadFilesToRechunk)
if _, rechunk := s.filesToRechunk[fileID]; rechunk {
return file, true
if _, rechunk := s.filesToRechunk[file.ID]; rechunk {
return true
}
// Check if file has changed
if existingFile.Size != file.Size ||
return existingFile.Size != file.Size ||
!existingFile.MTime.Equal(file.MTime) ||
existingFile.Mode != file.Mode ||
existingFile.UID != file.UID ||
existingFile.GID != file.GID {
return file, true
}
// File unchanged
return file, false
existingFile.GID != file.GID ||
existingFile.LinkTarget != file.LinkTarget
}
// batchAddFilesToSnapshot adds existing file IDs to the snapshot association table
@@ -1384,17 +1599,16 @@ func (s *Scanner) processPhase(
return s.finalizeProcessPhase(ctx, result)
}
// processFileWithErrorHandling records a directory or symlink, or wraps
// processFileWithErrorHandling records a symlink, or wraps
// processFileStreaming for a regular file with error recovery for deleted
// files and skip-errors mode. Returns (skipped, error).
func (s *Scanner) processFileWithErrorHandling(
ctx context.Context, fileToProcess *FileToProcess, result *ScanResult,
) (bool, error) {
// A directory or symlink has no data to open or read; it is only
// recorded in the local index. An error recording it stops the run
// even under --skip-errors, or the snapshot would complete without it.
mode := os.FileMode(fileToProcess.File.Mode)
if mode&os.ModeSymlink != 0 || mode.IsDir() {
// A symlink has no data to open or read; it is only recorded in the
// local index. An error recording it stops the run even under
// --skip-errors, or the snapshot would complete without it.
if os.FileMode(fileToProcess.File.Mode)&os.ModeSymlink != 0 {
err := s.recordNonRegularFile(ctx, fileToProcess)
if err != nil {
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
@@ -0,0 +1,28 @@
//nolint:testpackage // exercises the unexported countNonDirectories helper
package snapshot
import (
"os"
"testing"
"sneak.berlin/go/vaultik/internal/database"
)
// The walk counts regular files and symlinks, not directories, so the
// estimate of its count made from the local index's records leaves the
// directories out. See https://git.eeqj.de/sneak/vaultik/issues/305.
func TestCountNonDirectories(t *testing.T) {
t.Parallel()
known := map[string]*database.File{
"/src": {Mode: uint32(os.ModeDir | 0o755)},
"/src/sub": {Mode: uint32(os.ModeDir | 0o755)},
"/src/a.txt": {Mode: 0o644},
"/src/link": {Mode: uint32(os.ModeSymlink | 0o777), LinkTarget: "a.txt"},
}
got := countNonDirectories(known)
if got != 2 {
t.Errorf("countNonDirectories = %d, want 2", got)
}
}
+17 -4
View File
@@ -518,8 +518,8 @@ type CleanupStats struct {
//
// The cleanup is performed in a specific order to maintain referential integrity:
// 1. Delete other snapshots
// 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs)
// for deleted snapshots
// 2. Delete orphaned snapshot associations (snapshot_files, snapshot_blobs,
// snapshot_mount_points) for deleted snapshots
// 3. Delete orphaned files (not in the current snapshot)
// 4. Delete orphaned chunk-to-file mappings (references to deleted files)
// 5. Delete orphaned blobs (not in the current snapshot)
@@ -948,8 +948,8 @@ func (sm *SnapshotManager) deleteOtherSnapshots(
return nil
}
// deleteOrphanedSnapshotAssociations deletes snapshot_files and
// snapshot_blobs for deleted snapshots
// deleteOrphanedSnapshotAssociations deletes snapshot_files,
// snapshot_blobs and snapshot_mount_points for deleted snapshots
func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
ctx context.Context, tx *sql.Tx, currentSnapshotID string,
) error {
@@ -983,6 +983,19 @@ func (sm *SnapshotManager) deleteOrphanedSnapshotAssociations(
log.Debug("[Temp DB Cleanup] Deleted snapshot_blobs associations",
"count", rowsAffected)
// Delete the mount points other snapshots recorded
query := "DELETE FROM snapshot_mount_points WHERE snapshot_id != ?"
database.LogSQL("Execute", query, currentSnapshotID)
result, err = tx.ExecContext(ctx, query, currentSnapshotID)
if err != nil {
return fmt.Errorf("deleting orphaned snapshot_mount_points: %w", err)
}
rowsAffected, _ = result.RowsAffected()
log.Debug("[Temp DB Cleanup] Deleted snapshot_mount_points rows",
"count", rowsAffected)
return nil
}
+73
View File
@@ -7,12 +7,14 @@ import (
"database/sql"
"io"
"path/filepath"
"slices"
"testing"
"github.com/spf13/afero"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/types"
)
const (
@@ -387,6 +389,77 @@ func TestCleanSnapshotDBNonExistentSnapshot(t *testing.T) {
}
}
// The exported metadata database keeps only the mount points that the
// exported snapshot recorded. See
// https://git.eeqj.de/sneak/vaultik/issues/395.
func TestCleanSnapshotDBKeepsOnlyTheSnapshotsMountPoints(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
dbPath := filepath.Join(t.TempDir(), "export.db")
db, err := database.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to create database: %v", err)
}
repos := database.NewRepositories(db)
for _, id := range []types.SnapshotID{"exported", "other"} {
err = repos.Snapshots.Create(ctx, nil, &database.Snapshot{ID: id})
if err != nil {
t.Fatalf("failed to create snapshot: %v", err)
}
err = repos.Snapshots.AddMountPoint(ctx, nil, id.String(), "/data/"+id.String())
if err != nil {
t.Fatalf("failed to add mount point: %v", err)
}
}
err = db.Close()
if err != nil {
t.Fatalf("failed to close database: %v", err)
}
sm := &SnapshotManager{
config: &config.Config{
CompressionLevel: 3,
AgeRecipients: []string{testAgeRecipient},
},
fs: afero.NewOsFs(),
}
_, err = sm.cleanSnapshotDB(ctx, dbPath, "exported")
if err != nil {
t.Fatalf("failed to clean snapshot database: %v", err)
}
cleaned, err := database.New(ctx, dbPath)
if err != nil {
t.Fatalf("failed to open cleaned database: %v", err)
}
defer func() { _ = cleaned.Close() }()
cleanedRepos := database.NewRepositories(cleaned)
for id, want := range map[string][]string{
"exported": {"/data/exported"},
"other": nil,
} {
got, err := cleanedRepos.Snapshots.GetMountPoints(ctx, id)
if err != nil {
t.Fatalf("failed to read mount points: %v", err)
}
if !slices.Equal(got, want) {
t.Errorf("mount points of %s: got %v, want %v", id, got, want)
}
}
}
// Two creates of one snapshot name back to back start within one second,
// the resolution of the timestamp in a snapshot ID. See
// https://git.eeqj.de/sneak/vaultik/issues/270.
+35 -3
View File
@@ -8,6 +8,7 @@ import (
"reflect"
"sort"
"testing"
"time"
"sneak.berlin/go/vaultik/internal/storage"
)
@@ -19,15 +20,17 @@ import (
// constructor, so the contract is defined once.
//
// It exercises the public Storer interface: round-trip, stat, list with
// prefix filtering, overwrite, delete, delete-of-missing, and not-found on
// Get and Stat. Each section takes its own fresh backend instance, so the
// order of sections never matters and no section sees another's objects.
// prefix filtering, the modification time ListStream reports, overwrite,
// delete, delete-of-missing, and not-found on Get and Stat. Each section
// takes its own fresh backend instance, so the order of sections never
// matters and no section sees another's objects.
func runStorerConformance(t *testing.T, newStorer func(*testing.T) storage.Storer) {
t.Helper()
conformanceRoundTrip(t, newStorer(t))
conformanceOverwrite(t, newStorer(t))
conformanceList(t, newStorer(t))
conformanceListStreamModTime(t, newStorer(t))
conformanceDelete(t, newStorer(t))
conformanceNotFound(t, newStorer(t))
}
@@ -118,6 +121,35 @@ func conformanceList(t *testing.T, s storage.Storer) {
}
}
// conformanceListStreamModTime checks that ListStream gives an object
// just written a modification time of about now, which prune compares
// with its grace period.
func conformanceListStreamModTime(t *testing.T, s storage.Storer) {
t.Helper()
ctx := context.Background()
err := s.Put(ctx, "blobs/dd/written-now", bytes.NewReader([]byte("data")))
if err != nil {
t.Fatalf("Put: %v", err)
}
var listed []storage.ObjectInfo
for info := range s.ListStream(ctx, "blobs/") {
listed = append(listed, info)
}
if len(listed) != 1 || listed[0].Err != nil {
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
}
if time.Since(listed[0].ModTime).Abs() > time.Minute {
t.Errorf("ListStream ModTime = %v, want within a minute of now",
listed[0].ModTime)
}
}
// conformanceDelete checks that Delete removes an object and that deleting
// a missing key is not an error.
func conformanceDelete(t *testing.T, s storage.Storer) {
+23 -8
View File
@@ -2,15 +2,17 @@
// faults on demand, so tests can reproduce the failure modes a real
// backend exhibits: an upload that fails partway, a backend that reports
// success while storing nothing, reads that return corrupt or truncated
// bytes, and a delete the backend refuses. It is the seam called for by
// the fault-injection tests (sneak/vaultik issue 72) and is meant to be
// reused by future tests rather than re-implemented per case.
// bytes, and a delete or a stat the backend refuses. It is the seam
// called for by the fault-injection tests (sneak/vaultik issue 72) and
// is meant to be reused by future tests rather than re-implemented per
// case.
//
// The wrapper delegates every method to the inner Storer. Three hooks
// The wrapper delegates every method to the inner Storer. Four hooks
// change that: OnPut decides the fate of each write, OnGet decides how
// each read's bytes are returned, and OnDelete decides whether each
// delete fails. All are keyed by the object key, so a test can fault
// only blobs, only metadata, or a single object.
// each read's bytes are returned, OnDelete decides whether each delete
// fails, and OnStat whether each stat fails. All are keyed by the object
// key, so a test can fault only blobs, only metadata, or a single
// object.
package faultstore
import (
@@ -30,6 +32,11 @@ var ErrInjectedUpload = errors.New("faultstore: injected upload failure")
// fail.
var ErrInjectedDelete = errors.New("faultstore: injected delete failure")
// ErrInjectedStat is returned by a Stat the OnStat hook chose to fail.
// It is not storage.ErrNotFound: the backend refused the request, as S3
// refuses a HEAD to credentials that allow only PUT.
var ErrInjectedStat = errors.New("faultstore: injected stat failure")
// PutAction is the disposition OnPut assigns to a write.
type PutAction int
@@ -76,6 +83,10 @@ type Storer struct {
// object key. Returning true fails the delete and leaves the object
// in place.
OnDelete func(key string) bool
// OnStat, when set, is consulted before every Stat with the object
// key. Returning true fails the stat with ErrInjectedStat.
OnStat func(key string) bool
}
// New wraps inner. inner must be non-nil.
@@ -159,8 +170,12 @@ func damage(fault GetFault, data []byte) []byte {
return out
}
// Stat delegates unchanged.
// Stat returns the object's info unless OnStat fails the stat.
func (f *Storer) Stat(ctx context.Context, key string) (*storage.ObjectInfo, error) {
if f.OnStat != nil && f.OnStat(key) {
return nil, fmt.Errorf("%w for %q", ErrInjectedStat, key)
}
return f.inner.Stat(ctx, key)
}
+23 -6
View File
@@ -21,9 +21,13 @@ type FileStorer struct {
// NewFileStorer creates a new filesystem storage backend.
//
// Construction is intentionally cheap and does not touch the filesystem.
// The basePath is recorded; the directory is created lazily on first
// write. Write operations (Put/PutWithProgress) call MkdirAll for the
// per-blob parent directory, which also covers basePath on first use.
// The backend never creates basePath: the user does, so that when
// basePath is a directory on an unplugged volume, a write fails instead
// of landing on the disk under the mount point. This does not hold when
// basePath is the mount point itself, which stays behind when its volume
// is unplugged.
// Put and PutWithProgress fail on a missing basePath and create the
// directories below it as needed.
// Get and Stat report a key under a missing basePath as ErrNotFound.
// List and ListStream fail on a missing basePath, because listing it as
// an empty store would make `prune` drop every local snapshot record.
@@ -231,8 +235,9 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
// Normalize path separators
relPath = strings.ReplaceAll(relPath, string(filepath.Separator), "/")
ch <- ObjectInfo{
Key: relPath,
Size: info.Size(),
Key: relPath,
Size: info.Size(),
ModTime: info.ModTime(),
}
}
@@ -299,7 +304,19 @@ func (f *FileStorer) writeAtomic(
path := f.fullPath(key)
dir := filepath.Dir(path)
err := f.fs.MkdirAll(dir, storageDirPerm)
// Checked on every write, so a destination that disappears part-way
// through a backup fails it rather than being recreated by MkdirAll.
_, err := f.fs.Stat(f.basePath)
if os.IsNotExist(err) {
return fmt.Errorf("destination directory does not exist; "+
"create it, or mount the volume it is on: %w", err)
}
if err != nil {
return fmt.Errorf("checking destination directory: %w", err)
}
err = f.fs.MkdirAll(dir, storageDirPerm)
if err != nil {
return fmt.Errorf("creating directories: %w", err)
}
+40
View File
@@ -292,3 +292,43 @@ func TestFileStorerDeletePartialUploadsThroughSymlinkedDirectory(t *testing.T) {
})
}
}
// TestFileStorerPutMissingDestination checks that a write fails when the
// destination directory has gone, even after earlier writes to it
// succeeded, and that it does not create the directory again.
func TestFileStorerPutMissingDestination(t *testing.T) {
t.Parallel()
ctx := context.Background()
base := filepath.Join(t.TempDir(), "usbstick")
err := os.Mkdir(base, 0o750)
if err != nil {
t.Fatalf("Mkdir: %v", err)
}
s, err := storage.NewFileStorer(base)
if err != nil {
t.Fatalf("NewFileStorer: %v", err)
}
err = s.Put(ctx, "blobs/aa/bb/first", strings.NewReader("first"))
if err != nil {
t.Fatalf("first Put: %v", err)
}
err = os.Rename(base, base+"-unplugged")
if err != nil {
t.Fatalf("Rename: %v", err)
}
err = s.Put(ctx, "blobs/aa/bb/second", strings.NewReader("second"))
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Put = %v, want a not-exist error", err)
}
_, err = os.Stat(base)
if !errors.Is(err, fs.ErrNotExist) {
t.Errorf("Put created the destination directory again (Stat = %v)", err)
}
}
+11 -2
View File
@@ -53,6 +53,14 @@ func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, e
rclonePath += path
}
// On S3, Swift and some other remotes rclone keeps a time of its own in
// each object's metadata, which costs one request per object to read.
// rclone's use_server_modtime setting makes ModTime the time the
// listing gives instead: when the object was stored, which is what
// prune compares with prune_grace_period.
ctx, ci := fs.AddConfig(ctx)
ci.UseServerModTime = true
// Create the rclone filesystem
fsys, err := fs.NewFs(ctx, rclonePath)
if err != nil {
@@ -211,8 +219,9 @@ func (r *RcloneStorer) ListStream(
if prefix == "" || strings.HasPrefix(key, prefix) {
ch <- ObjectInfo{
Key: key,
Size: obj.Size(),
Key: key,
Size: obj.Size(),
ModTime: obj.ModTime(ctx),
}
}
})
+75
View File
@@ -5,11 +5,17 @@ import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/johannesboyne/gofakes3"
"github.com/johannesboyne/gofakes3/backend/s3mem"
"github.com/rclone/rclone/fs"
"github.com/rclone/rclone/fs/config"
"github.com/rclone/rclone/fs/config/configmap"
@@ -429,3 +435,72 @@ func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
t.Errorf("object is not in the remote's directory: %v", err)
}
}
// TestRcloneListStreamOnS3MakesNoRequestPerObject checks that listing an
// rclone S3 remote takes each object's time from the listing itself. By
// default rclone reads the time it keeps in each object's metadata, one
// request per object, and every command that lists the destination store
// would slow down as it grows.
//
//nolint:paralleltest // NewRcloneStorer installs the process-global rclone config
func TestRcloneListStreamOnS3MakesNoRequestPerObject(t *testing.T) {
backend := s3mem.New()
err := backend.CreateBucket(s3TestBucket)
if err != nil {
t.Fatalf("create bucket: %v", err)
}
const body = "blob-bytes"
_, err = backend.PutObject(s3TestBucket, testBlobKey, nil,
strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("seed object: %v", err)
}
var headRequests atomic.Int64
fakeS3 := gofakes3.New(backend).Server()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodHead {
headRequests.Add(1)
}
fakeS3.ServeHTTP(w, r)
}))
t.Cleanup(srv.Close)
remote := ":s3,provider=Other" +
",access_key_id=" + s3TestAccessKeyID +
",secret_access_key=" + s3TestSecretAccessKey +
",endpoint='" + srv.URL + "'"
ctx := context.Background()
s, err := storage.NewRcloneStorer(ctx, remote, s3TestBucket)
if err != nil {
t.Fatalf("NewRcloneStorer: %v", err)
}
headRequests.Store(0)
var listed []storage.ObjectInfo
for info := range s.ListStream(ctx, "blobs/") {
listed = append(listed, info)
}
if len(listed) != 1 || listed[0].Err != nil {
t.Fatalf("ListStream(\"blobs/\") = %v, want the one object", listed)
}
if time.Since(listed[0].ModTime).Abs() > time.Minute {
t.Errorf("ListStream ModTime = %v, want within a minute of now",
listed[0].ModTime)
}
if n := headRequests.Load(); n != 0 {
t.Errorf("ListStream made %d HEAD request(s), want none", n)
}
}
+4 -3
View File
@@ -89,9 +89,10 @@ func (s *S3Storer) ListStream(ctx context.Context, prefix string) <-chan ObjectI
for info := range s.client.ListObjectsStream(ctx, prefix, false) {
ch <- ObjectInfo{
Key: info.Key,
Size: info.Size,
Err: info.Err,
Key: info.Key,
Size: info.Size,
ModTime: info.ModTime,
Err: info.Err,
}
}
}()
+3 -2
View File
@@ -100,8 +100,9 @@ func TestS3StorerMissingKeyMapsToErrNotFound(t *testing.T) {
// TestS3ListingCutShortIsAnError checks that List and ListStream fail on a
// listing page marked truncated that carries no continuation token, as a
// server answering V2 listings V1-style sends it. Taken as the last page,
// it would hide every later key, and prune would treat the blobs of the
// snapshots listed there as unreferenced.
// it would hide every later key: prune would treat the blobs of the
// snapshots listed there as unreferenced, and a directory under metadata/
// whose manifest is listed there as one with no manifest.
func TestS3ListingCutShortIsAnError(t *testing.T) {
t.Parallel()
+9 -3
View File
@@ -14,6 +14,7 @@ import (
"context"
"errors"
"io"
"time"
)
// ErrNotFound is returned when an object does not exist.
@@ -27,7 +28,10 @@ type ProgressCallback func(bytesTransferred int64) error
type ObjectInfo struct {
Key string // Object key/path
Size int64 // Size in bytes
Err error // Error for streaming results (nil on success)
// ModTime is when the object was written, as the store reports it.
// Only ListStream sets it; Stat leaves it zero.
ModTime time.Time
Err error // Error for streaming results (nil on success)
}
// Info provides human-readable storage configuration.
@@ -41,7 +45,8 @@ type Info struct {
// directory for filesystem).
type Storer interface {
// Put stores data at the specified key.
// Parent directories are created automatically for filesystem backends.
// The filesystem backend fails when its destination directory does
// not exist, and creates the directories below it as needed.
Put(ctx context.Context, key string, data io.Reader) error
// PutWithProgress stores data with progress reporting.
@@ -66,7 +71,8 @@ type Storer interface {
// For large result sets, prefer ListStream.
List(ctx context.Context, prefix string) ([]string, error)
// ListStream returns a channel of ObjectInfo for large result sets.
// ListStream returns a channel of ObjectInfo, with each object's key,
// size and modification time, for large result sets.
// The channel is closed when listing completes.
// If an error occurs during listing, the final item will have Err set.
ListStream(ctx context.Context, prefix string) <-chan ObjectInfo
@@ -137,7 +137,7 @@ func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
changedPath := filepath.Join(dataDir, "appended.bin")
@@ -184,7 +184,7 @@ func TestBackupAfterInterruptedRunRestoresChangedFile(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
changedPath := filepath.Join(dataDir, "appended.bin")
+4 -4
View File
@@ -35,7 +35,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(64 * 1024)
@@ -94,7 +94,7 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
fs := afero.NewOsFs()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "remote")
storeDir := t.TempDir()
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
@@ -151,7 +151,7 @@ func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
fs := afero.NewOsFs()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "remote")
storeDir := t.TempDir()
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
@@ -183,7 +183,7 @@ func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "remote")
storeDir := t.TempDir()
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
+103
View File
@@ -0,0 +1,103 @@
package vaultik
import (
"errors"
"fmt"
"strings"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
)
// destinationMarkerKey is the object every backup writes at the root of
// the backup destination store. While the local index holds a snapshot
// stored there, a destination that answers a look-up of it with not
// found is treated as not present: an unplugged volume leaves an empty
// directory at its mount point, which would otherwise read as a store
// whose snapshots are all gone.
const destinationMarkerKey = "vaultik-destination.txt"
// destinationMarkerText is the whole content of the marker. It names no
// host, path or time.
const destinationMarkerText = "This is a vaultik backup destination store.\n"
// errDestinationNotPresent is returned when the local index holds a
// snapshot stored on the backup destination store and the store answers
// that it has no marker.
var errDestinationNotPresent = errors.New("backup destination store is not present")
// checkDestinationMarker returns errDestinationNotPresent when the local
// index holds a snapshot stored on the backup destination store and the
// store answers that it has no marker. With no snapshot in the local
// index, as on a first backup or on another machine, there is nothing to
// check; nor when the local index is bound to a different destination
// than the configured one, since its snapshots are stored there.
//
// A look-up of the marker that fails for any other reason than not
// found is logged and otherwise ignored, because the check could not be
// made: S3 refuses it to credentials that allow only PUT, which is all
// a backup needs. The check exists for a file:// volume's empty mount
// point, which always answers not found, and a destination that cannot
// be reached fails on the next request made to it, such as the backup's
// PUT of the marker.
func (v *Vaultik) checkDestinationMarker() error {
ids, err := v.localSnapshotIDs()
if err != nil {
return fmt.Errorf("listing local snapshots: %w", err)
}
if len(ids) == 0 {
return nil
}
// Config is nil only in tests, which then record no binding (see
// EnsureStorageBinding).
if v.Config != nil {
stored, configured, err := v.storageBinding()
if err != nil {
return err
}
if stored != "" && stored != configured {
return nil
}
}
_, err = v.Storage.Stat(v.ctx, destinationMarkerKey)
if errors.Is(err, storage.ErrNotFound) {
return fmt.Errorf("%w: %s has no %s, but the local index holds "+
"snapshots stored there; mount the volume if it is removable, "+
"or if the destination was emptied on purpose, run "+
"'vaultik database delete' and back up again",
errDestinationNotPresent, v.Storage.Info().Location,
destinationMarkerKey)
}
// Info, not a warning: --cron must stay silent on a successful
// backup from a host whose credentials allow only PUT.
if err != nil {
log.Info("Could not check the backup destination store for its marker",
"key", destinationMarkerKey, "error", err)
}
return nil
}
// markDestination checks the backup destination store with
// checkDestinationMarker and then writes the marker, so a first backup
// marks its destination and a later one writes the same text again.
func (v *Vaultik) markDestination() error {
err := v.checkDestinationMarker()
if err != nil {
return err
}
err = v.Storage.Put(v.ctx, destinationMarkerKey,
strings.NewReader(destinationMarkerText))
if err != nil {
return fmt.Errorf("writing %s to the backup destination store: %w",
destinationMarkerKey, err)
}
return nil
}
+331
View File
@@ -0,0 +1,331 @@
package vaultik_test
import (
"bytes"
"context"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/239: a
// file:// destination at a volume's mount point leaves an empty directory
// behind when the volume is unplugged. While the local index holds
// snapshots, a destination that answers that it has no
// vaultik-destination.txt is not present, so no command may write to it
// or conclude from it that the local snapshots are gone.
// markerFile is the name of the file a backup writes at the root of the
// destination.
const markerFile = "vaultik-destination.txt"
// backUpThenUnplugMountPoint backs up to a file:// destination, then
// moves the destination directory away and leaves an empty directory in
// its place, as unplugging a volume mounted there would. It returns the
// path of that empty directory.
func backUpThenUnplugMountPoint(
ctx context.Context, t *testing.T,
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer, string) {
t.Helper()
storeDir := filepath.Join(t.TempDir(), "mnt")
v, repos, out := backUpToFileDestination(ctx, t, storeDir)
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
require.NoError(t, os.Mkdir(storeDir, 0o750))
return v, repos, out, storeDir
}
// assertUntouched checks that the local index still holds its one
// snapshot and that nothing was written to the empty mount point.
func assertUntouched(
ctx context.Context, t *testing.T,
repos *database.Repositories, storeDir string,
) {
t.Helper()
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1, "no local snapshot record may be deleted")
entries, err := os.ReadDir(storeDir)
require.NoError(t, err)
assert.Empty(t, entries, "nothing may be written to the empty mount point")
}
// TestFirstBackupWritesMarker checks that a backup writes the marker
// file at the root of the destination.
func TestFirstBackupWritesMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
storeDir := t.TempDir()
backUpToFileDestination(context.Background(), t, storeDir)
assert.FileExists(t, filepath.Join(storeDir, markerFile))
}
// TestBackupRefusesDestinationWithoutMarker checks that snapshot create
// --prune writes nothing and drops no local snapshot record when the
// destination has no marker.
func TestBackupRefusesDestinationWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
err := v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Cron: true,
Prune: true,
})
require.ErrorContains(t, err, markerFile)
assertUntouched(ctx, t, repos, storeDir)
}
// TestBackupGoesOnWhenMarkerCannotBeChecked checks that a backup goes on
// and writes the marker when looking it up fails with an error other
// than not found, as S3 answers credentials that allow only PUT
// (https://git.eeqj.de/sneak/vaultik/issues/389), and that a marker
// that is not found still stops it.
func TestBackupGoesOnWhenMarkerCannotBeChecked(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := t.TempDir()
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
// Removed so the test sees the backup write it again: with the
// look-up failing, the backup cannot tell that it is gone.
markerPath := filepath.Join(storeDir, markerFile)
require.NoError(t, os.Remove(markerPath))
store := faultstore.New(v.Storage)
store.OnStat = func(key string) bool { return key == markerFile }
v.Storage = store
require.NoError(t, backUp(v, "second"))
localSnapshotID(ctx, t, repos, "second")
assert.FileExists(t, markerPath)
require.NoError(t, os.Remove(markerPath))
store.OnStat = nil
require.ErrorContains(t, backUp(v, "third"), markerFile)
}
// TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots checks that
// snapshot create writes nothing when the local index holds only an
// incomplete snapshot and the destination has no marker. The backup drops
// incomplete snapshots before it scans; checked after that, the local
// index would hold none and nothing would be checked.
func TestBackupChecksMarkerBeforeDroppingIncompleteSnapshots(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := t.TempDir()
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
ID: "host_first_2026-01-02T03:04:05Z",
Hostname: "host",
StartedAt: time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC),
}))
err := backUp(v, "first")
require.ErrorContains(t, err, markerFile)
assertUntouched(ctx, t, repos, storeDir)
}
// TestPruneRefusesDestinationWithoutMarker checks that prune fails and
// deletes no local snapshot record when the destination has no marker.
func TestPruneRefusesDestinationWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
err := v.Prune(&vaultik.PruneOptions{Force: true})
require.ErrorContains(t, err, markerFile)
assertUntouched(ctx, t, repos, storeDir)
}
// TestPurgeRefusesDestinationWithoutMarker checks that snapshot purge
// fails and deletes no local snapshot record when the destination has no
// marker.
func TestPurgeRefusesDestinationWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, _, storeDir := backUpThenUnplugMountPoint(ctx, t)
err := v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
KeepLatest: true,
Force: true,
})
require.ErrorContains(t, err, markerFile)
assertUntouched(ctx, t, repos, storeDir)
}
// TestNukeRemoteRefusesDestinationWithoutMarker checks that remote nuke
// fails, deletes no local snapshot record, and neither reports the
// destination empty nor advises running nuke again when the destination
// has no marker.
func TestNukeRemoteRefusesDestinationWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out, storeDir := backUpThenUnplugMountPoint(ctx, t)
err := v.NukeRemote(true)
require.ErrorContains(t, err, markerFile)
assert.NotContains(t, out.String(), "now empty")
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
assertUntouched(ctx, t, repos, storeDir)
}
// TestRemoveSnapshotWarnsWhenDestinationHasNoMarker checks that snapshot
// remove warns that the metadata could not be removed from a destination
// without a marker, instead of reporting that it was.
func TestRemoveSnapshotWarnsWhenDestinationHasNoMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
result, err := v.RemoveSnapshot(localSnapshotID(ctx, t, repos, "first"),
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.False(t, result.RemoteRemoved)
assert.Contains(t, out.String(),
"Could not remove snapshot metadata from remote")
assert.Contains(t, out.String(), markerFile)
assert.NotContains(t, out.String(),
"Removed snapshot metadata from remote storage")
}
// TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker checks that
// snapshot remove, for a snapshot that is only on the destination, fails
// saying the destination is not present when it has no marker, instead
// of saying the snapshot does not exist.
func TestRemoveSnapshotOnlyOnDestinationFailsWithoutMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "mnt")
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
require.NoError(t, backUp(v, "second"))
id := localSnapshotID(ctx, t, repos, "first")
_, err := v.RemoveSnapshot(id,
&vaultik.RemoveOptions{Force: true, LocalOnly: true})
require.NoError(t, err)
// Unplug the volume: an empty directory stays at its mount point.
require.NoError(t, os.Rename(storeDir, storeDir+"-unplugged"))
require.NoError(t, os.Mkdir(storeDir, 0o750))
_, err = v.RemoveSnapshot(id, &vaultik.RemoveOptions{Force: true})
require.ErrorContains(t, err, markerFile)
require.ErrorContains(t, err, "mount the volume")
assertUntouched(ctx, t, repos, storeDir)
}
// TestListSnapshotsWarnsWhenDestinationHasNoMarker checks that snapshot
// list warns and shows the local index alone when the destination has no
// marker, without reporting the local snapshot as missing from it.
func TestListSnapshotsWarnsWhenDestinationHasNoMarker(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out, _ := backUpThenUnplugMountPoint(ctx, t)
id := localSnapshotID(ctx, t, repos, "first")
require.NoError(t, v.ListSnapshots(false))
assert.Contains(t, out.String(), "Could not list backup destination store")
assert.Contains(t, out.String(), markerFile)
assert.Contains(t, out.String(), id)
assert.NotContains(t, out.String(), "not found in backup destination store")
}
// TestListSnapshotsAfterStorageURLChangeToEmptyDirectory checks that
// snapshot list, after storage_url is changed to an empty directory,
// warns only that the local index is bound to a different destination:
// the local snapshots are stored at the old one, so the new one's lack
// of a marker says nothing. With --json the local row is not on the new
// destination, so remote_present is false, not unknown.
func TestListSnapshotsAfterStorageURLChangeToEmptyDirectory(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out := backUpToFileDestination(ctx, t, t.TempDir())
id := localSnapshotID(ctx, t, repos, "first")
newDir := t.TempDir()
store, err := storage.NewFileStorer(newDir)
require.NoError(t, err)
v.Storage = store
v.Config.StorageURL = "file://" + newDir
require.NoError(t, v.ListSnapshots(false))
assert.Contains(t, out.String(), id)
assert.Contains(t, out.String(),
"local index is bound to a different backup destination")
assert.NotContains(t, out.String(), "Could not list backup destination store")
assert.NotContains(t, out.String(), markerFile)
out.Reset()
require.NoError(t, v.ListSnapshots(true))
rows := decodeListJSON(t, out.String())
require.Len(t, rows, 1)
require.NotNil(t, rows[0].RemotePresent)
assert.False(t, *rows[0].RemotePresent)
}
// TestBackupAfterNukeRemoteSucceeds checks that remote nuke leaves the
// destination ready for a new first backup, which writes the marker
// again.
func TestBackupAfterNukeRemoteSucceeds(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
storeDir := t.TempDir()
v, _, _ := backUpToFileDestination(context.Background(), t, storeDir)
require.NoError(t, v.NukeRemote(true))
assert.NoFileExists(t, filepath.Join(storeDir, markerFile))
require.NoError(t, backUp(v, "second"))
assert.FileExists(t, filepath.Join(storeDir, markerFile))
}
+18 -11
View File
@@ -192,7 +192,7 @@ func assertRestoreRejectsDamagedBlob(
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -238,7 +238,7 @@ func TestDeepVerifyCatchesLyingBackend(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background()
@@ -290,7 +290,7 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background()
@@ -298,10 +298,6 @@ func TestInterruptedBlobUploadRecordsNoUploadedBlob(t *testing.T) {
writeFaultSourceTree(t, fs, dataDir)
// No upload succeeds, so nothing creates the destination directory.
// It must exist for the listing below to show that no blob survived.
require.NoError(t, os.Mkdir(storeDir, 0o750))
inner, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
@@ -365,7 +361,7 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -429,7 +425,7 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background()
@@ -445,6 +441,10 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
repos := database.NewRepositories(db)
// snapshot create writes the marker; this backup runs its steps one
// by one, so the marker is written here.
require.NoError(t, inner.Put(ctx, markerFile, strings.NewReader("")))
// Back up and complete with a working backend.
sm := newFaultSnapshotManager(fs, inner, cfg, repos)
scanner := newFaultScanner(fs, inner, cfg, repos)
@@ -493,8 +493,14 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
"shallow verify must report the missing manifest")
// snapshot list must not crash on the partial snapshot.
var listOut strings.Builder
reader.UI = ui.NewWithColor(&listOut, false)
require.NoError(t, reader.ListSnapshots(false),
"snapshot list must tolerate a partially-exported snapshot")
assert.NotContains(t, listOut.String(),
"Could not list backup destination store",
"snapshot list must read the partial snapshot from the destination")
}
// Scenario 2, repair: the process dies during the metadata export of a
@@ -511,7 +517,7 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -673,6 +679,7 @@ func faultScannerFactory(
Exclude: params.Exclude,
SkipErrors: params.SkipErrors,
PreviousSnapshotID: params.PreviousSnapshotID,
DeviceNumber: params.DeviceNumber,
})
}
}
@@ -687,7 +694,7 @@ func TestRestoreReportsDiskFull(t *testing.T) {
osFS := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
+4 -4
View File
@@ -457,9 +457,9 @@ func (v *Vaultik) scanRemoteBlobStorage(
return err
}
for _, size := range allBlobs {
for _, blob := range allBlobs {
result.TotalBlobCount++
result.TotalBlobSize += size
result.TotalBlobSize += blob.Size
}
// A blob named only by a manifest that could not be read, or by one
@@ -474,10 +474,10 @@ func (v *Vaultik) scanRemoteBlobStorage(
orphanedSize int64
)
for hash, size := range allBlobs {
for hash, blob := range allBlobs {
if _, referenced := referencedBlobs[hash]; !referenced {
orphanedCount++
orphanedSize += size
orphanedSize += blob.Size
}
}
+2 -2
View File
@@ -771,7 +771,7 @@ func TestEndToEndFileStorage(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -964,7 +964,7 @@ func TestDedupOnlySnapshotRestores(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
+73 -16
View File
@@ -24,13 +24,11 @@ import (
// that is not plugged in, cannot be listed. It is not an empty store, so
// no command may conclude from it that the local snapshots are gone.
// backUpToFileDestination backs up the snapshot named "first" to a
// file:// destination at storeDir, which need not exist yet. Everything
// the returned Vaultik prints after the backup goes to the returned
// buffer.
func backUpToFileDestination(
// newFileDestinationVaultik returns a Vaultik that backs up the
// snapshot named "first" to a file:// destination at storeDir.
func newFileDestinationVaultik(
ctx context.Context, t *testing.T, storeDir string,
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
) (*vaultik.Vaultik, *database.Repositories) {
t.Helper()
osFs := afero.NewOsFs()
@@ -49,8 +47,22 @@ func backUpToFileDestination(
repos := database.NewRepositories(db)
cfg := changedFileConfig(dataDir, dbPath)
v := newBackupVaultik(ctx, cfg, store, repos, db, osFs)
return newBackupVaultik(ctx, cfg, store, repos, db, osFs), repos
}
// backUpToFileDestination creates the directory storeDir, as a user must
// before the first backup, and backs up the snapshot named "first" to a
// file:// destination there. Everything the returned Vaultik prints
// after the backup goes to the returned buffer.
func backUpToFileDestination(
ctx context.Context, t *testing.T, storeDir string,
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
t.Helper()
require.NoError(t, os.MkdirAll(storeDir, 0o750))
v, repos := newFileDestinationVaultik(ctx, t, storeDir)
require.NoError(t, backUp(v, "first"))
out := &bytes.Buffer{}
@@ -75,21 +87,28 @@ func backUpThenUnplug(
return v, repos, out
}
// TestFirstBackupCreatesDestinationDirectory checks that a first backup
// to a destination directory that does not exist yet creates it, and
// that the destination can be listed afterwards.
func TestFirstBackupCreatesDestinationDirectory(t *testing.T) {
// TestBackupFailsWhenDestinationDirectoryMissing checks that a backup to
// a file:// destination whose directory does not exist fails before it
// records a snapshot, and does not create the directory.
func TestBackupFailsWhenDestinationDirectoryMissing(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
v, _, out := backUpToFileDestination(ctx, t, storeDir)
volume := filepath.Join(t.TempDir(), "volume")
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
require.NoError(t, v.ListSnapshots(false))
err := backUp(v, "first")
require.ErrorIs(t, err, fs.ErrNotExist)
require.ErrorContains(t, err, "create it, or mount the volume it is on")
assert.NotContains(t, out.String(), "Could not list backup destination store")
assert.NotContains(t, out.String(), "not found in backup destination store")
_, err = os.Stat(volume)
require.ErrorIs(t, err, fs.ErrNotExist,
"the backup must not create the destination directory")
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Empty(t, snapshots)
}
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
@@ -169,3 +188,41 @@ func TestPurgeSaysListingFailedOnceWhenDestinationMissing(t *testing.T) {
assert.Equal(t, 1, strings.Count(err.Error(), "listing remote snapshots"),
err.Error())
}
// TestNukeRemoteKeepsLocalIndexWhenDestinationMissing checks that remote
// nuke fails on a destination it cannot list, leaves the local index
// unchanged, and neither reports the destination empty nor advises
// running nuke again.
func TestNukeRemoteKeepsLocalIndexWhenDestinationMissing(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
v, repos, out := backUpThenUnplug(ctx, t)
filesBefore, err := repos.Files.ListAll(ctx)
require.NoError(t, err)
require.NotEmpty(t, filesBefore)
blobsBefore, err := repos.Blobs.GetAll(ctx)
require.NoError(t, err)
require.NotEmpty(t, blobsBefore)
err = v.NukeRemote(true)
require.ErrorIs(t, err, fs.ErrNotExist)
require.ErrorContains(t, err, "listing remote snapshots")
assert.NotContains(t, out.String(), "now empty")
assert.NotContains(t, out.String(), "vaultik remote nuke --force")
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1, "nuke must delete no local snapshot record")
files, err := repos.Files.ListAll(ctx)
require.NoError(t, err)
assert.Len(t, files, len(filesBefore), "nuke must delete no local file record")
blobs, err := repos.Blobs.GetAll(ctx)
require.NoError(t, err)
assert.Len(t, blobs, len(blobsBefore), "nuke must delete no local blob record")
}
+1 -1
View File
@@ -39,7 +39,7 @@ func TestNewChunkSizeOfChangedFileStaysFixed(t *testing.T) {
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
store, err := storage.NewFileStorer(t.TempDir())
require.NoError(t, err)
db, err := database.New(ctx, dbPath)
+28 -3
View File
@@ -76,7 +76,7 @@ func TestNukeRemoteLeavesNoFiles(t *testing.T) {
// TestNukeRemoteFailsWhenADeleteFails checks that remote nuke returns an
// error when a snapshot's metadata or a blob cannot be deleted, does not
// report the store empty, and advises running nuke again: prune deletes
// no metadata.
// no snapshot metadata that has its manifest.
func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
@@ -104,6 +104,32 @@ func TestNukeRemoteFailsWhenADeleteFails(t *testing.T) {
}
}
// TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted checks that
// remote nuke keeps a snapshot in the local index while its metadata
// cannot be deleted from the destination store: dropped from the local
// index, it would be left there as a remote-only snapshot without its
// name.
func TestNukeRemoteKeepsLocalSnapshotWhoseMetadataIsNotDeleted(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := filepath.Join(t.TempDir(), "store")
v, repos, _ := backUpToFileDestination(ctx, t, storeDir)
store := faultstore.New(v.Storage)
store.OnDelete = func(key string) bool {
return strings.HasPrefix(key, "metadata/")
}
v.Storage = store
require.Error(t, v.NukeRemote(true))
snapshots, err := repos.Snapshots.ListRecent(ctx, listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1)
}
// TestNukeRemoteNamesWhatIsLeft checks that remote nuke, when an object
// it does not recognise cannot be deleted, does not report the store
// empty but names that object and fails.
@@ -154,8 +180,7 @@ func TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice(t *testing.T) {
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
// snapshot remote nuke cannot remove from the local index fails the
// run: nuke then leaves that snapshot's metadata on the destination
// store.
// run, and that nuke then does not report the destination store empty.
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
+322 -81
View File
@@ -4,9 +4,13 @@ import (
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/storage"
)
// PruneOptions contains options for the prune command
@@ -22,6 +26,11 @@ var errNukeRequiresForce = errors.New(
// errBlobDeleteFailed reports unreferenced blobs prune could not delete.
var errBlobDeleteFailed = errors.New("blob(s) could not be deleted")
// errMetadataDirDeleteFailed reports directories under metadata/ with no
// manifest that prune could not delete.
var errMetadataDirDeleteFailed = errors.New(
"metadata director(ies) with no manifest could not be deleted")
// errObjectsLeft reports objects remote nuke still finds on the backup
// destination store after deleting.
var errObjectsLeft = errors.New(
@@ -29,7 +38,8 @@ var errObjectsLeft = errors.New(
// nukeCommandHint is the command suggested when a remote nuke could not
// delete everything: running it again deletes what is left, which
// `vaultik prune` does not do for snapshot metadata.
// `vaultik prune` does not do for snapshot metadata that has its
// manifest.
const nukeCommandHint = "vaultik remote nuke --force"
// metadataDirName is the top-level remote directory holding snapshot
@@ -42,9 +52,10 @@ const blobKeyParts = 4
// NukeRemote deletes every object under metadata/ and blobs/ in remote
// storage, whatever its name, along with any object an upload cut off
// part-way left under a temporary `.partial` name. It returns
// successfully only when a listing afterwards finds nothing left there,
// and the next backup then starts from scratch.
// part-way left under a temporary `.partial` name, and last the
// destination marker. It returns successfully only when a listing
// afterwards finds nothing left under metadata/ and blobs/, and the next
// backup then starts from scratch.
//
// Refuses to run unless force is true. The caller is responsible for
// confirming with the user.
@@ -54,12 +65,19 @@ func (v *Vaultik) NukeRemote(force bool) error {
}
// Checked before anything is deleted: running nuke again would fail
// the same way, so the advice below must not follow this error.
// the same way, so the advice below must not follow these errors.
// The listing also fails on a destination that answers that it has
// no marker while the local index holds snapshots.
err := v.EnsureStorageBinding()
if err != nil {
return err
}
_, err = v.listAllRemoteSnapshotKeys()
if err != nil {
return err
}
err = v.emptyDestinationStore()
if err != nil {
v.UI.Warningf("Run '%s' again to delete what is left on the "+
@@ -105,6 +123,13 @@ func (v *Vaultik) emptyDestinationStore() error {
return fmt.Errorf("%d %w", len(left), errObjectsLeft)
}
// Last, once the local snapshot records are gone: the next backup
// then has none and writes the marker again.
err = v.Storage.Delete(v.ctx, destinationMarkerKey)
if err != nil {
return fmt.Errorf("deleting %s: %w", destinationMarkerKey, err)
}
return nil
}
@@ -148,14 +173,24 @@ type PruneBlobsResult struct {
BlobsDeleted int `json:"blobs_deleted"`
BlobsFailed int `json:"blobs_failed,omitempty"`
BytesFreed int64 `json:"bytes_freed"`
// BlobsTooNew counts the unreferenced blobs kept because they are
// newer than the prune grace period, or of unknown age. BlobsFound
// does not include them.
BlobsTooNew int `json:"blobs_too_new"`
// MetadataDirsDeleted counts the directories under metadata/ with no
// manifest that prune deleted, and MetadataDirsFailed those it could
// not delete.
MetadataDirsDeleted int `json:"metadata_dirs_deleted"`
MetadataDirsFailed int `json:"metadata_dirs_failed,omitempty"`
}
// Prune removes orphaned data from the local index database AND
// unreferenced blobs from the backup destination store. This is the
// single user-facing prune entry point — the split between local and
// remote cleanup is an implementation detail. Calling code should
// prefer this method over PruneDatabase or PruneBlobs individually
// unless it specifically wants one half.
// Prune removes orphaned data from the local index database AND, from the
// backup destination store, unreferenced blobs and the directories under
// metadata/ with no manifest. This is the single user-facing prune entry
// point — the split between local and remote cleanup is an
// implementation detail. Calling code should prefer this method over
// PruneDatabase or PruneBlobs individually unless it specifically wants
// one half.
func (v *Vaultik) Prune(opts *PruneOptions) error {
err := v.EnsureStorageBinding()
if err != nil {
@@ -179,11 +214,17 @@ func (v *Vaultik) Prune(opts *PruneOptions) error {
return v.PruneBlobs(opts)
}
// PruneBlobs removes unreferenced blobs from storage
// PruneBlobs removes unreferenced blobs from storage, and each directory
// under metadata/ that a backup left with no manifest.
func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
log.Info("Starting prune operation")
allBlobsReferenced, err := v.collectReferencedBlobs()
metadataObjects, err := v.listMetadataObjects()
if err != nil {
return err
}
allBlobsReferenced, err := v.collectReferencedBlobs(metadataObjects)
if err != nil {
return err
}
@@ -193,11 +234,22 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return err
}
unreferencedBlobs, totalSize := v.findUnreferencedBlobs(allBlobs, allBlobsReferenced)
unreferencedBlobs, totalSize, tooNew := v.findUnreferencedBlobs(
allBlobs, allBlobsReferenced)
result := &PruneBlobsResult{BlobsFound: len(unreferencedBlobs)}
dirsWithoutManifest, err := v.findMetadataDirsWithoutManifest(metadataObjects)
if err != nil {
return err
}
if len(unreferencedBlobs) == 0 {
result := &PruneBlobsResult{
BlobsFound: len(unreferencedBlobs),
BlobsTooNew: tooNew,
}
v.reportBlobsTooNew(tooNew, opts)
if len(unreferencedBlobs) == 0 && len(dirsWithoutManifest) == 0 {
log.Info("No unreferenced blobs found")
if opts.JSON {
@@ -210,38 +262,16 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
}
log.Info("Found unreferenced blobs",
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize),
"metadata_dirs_without_manifest", len(dirsWithoutManifest))
if opts.Force && !opts.JSON {
v.UI.Infof("Found %d unreferenced blob(s) totaling %s.",
len(unreferencedBlobs), ubytes(totalSize))
}
if !opts.Force && !opts.JSON {
// The count, the prompt and its answer bypass the UI: they are an
// interactive exchange the operator must see, so --quiet does not
// silence them.
v.stdoutf("Found %d unreferenced blob(s) totaling %s.\n",
len(unreferencedBlobs), ubytes(totalSize))
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", len(unreferencedBlobs))
var confirm string
_, err = v.scanStdin(&confirm)
if err != nil {
v.printlnStdout("Cancelled")
return nil //nolint:nilerr // read failure means no confirmation
}
if strings.ToLower(confirm) != "y" {
v.printlnStdout("Cancelled")
return nil
}
if !v.confirmPrune(opts, len(unreferencedBlobs), totalSize,
dirsWithoutManifest) {
return nil
}
v.deleteUnreferencedBlobs(unreferencedBlobs, allBlobs, result)
v.deleteMetadataDirs(dirsWithoutManifest, result)
if opts.JSON {
err = v.outputPruneBlobsJSON(result)
@@ -249,33 +279,142 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
return err
}
} else {
v.reportPruneDeletions(result, len(dirsWithoutManifest))
}
var failures []error
if result.BlobsFailed > 0 {
failures = append(failures,
fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed))
}
if result.MetadataDirsFailed > 0 {
failures = append(failures, fmt.Errorf("%d %w",
result.MetadataDirsFailed, errMetadataDirDeleteFailed))
}
return errors.Join(failures...)
}
// confirmPrune states what prune found to delete, unreferenced blobs and
// directories under metadata/ with no manifest, naming each directory by
// its remote key, and, unless --force is given, asks the operator to
// confirm. Under --json it prints nothing and does not ask. It reports
// whether to go ahead.
func (v *Vaultik) confirmPrune(
opts *PruneOptions, blobCount int, blobsSize int64, dirs []string,
) bool {
if opts.JSON {
return true
}
dirCount := len(dirs)
blobsFound := fmt.Sprintf("Found %d unreferenced blob(s) totaling %s.",
blobCount, ubytes(blobsSize))
dirsFound := fmt.Sprintf("Found %d metadata director(ies) left by "+
"backups that stopped before their manifest:", dirCount)
if opts.Force {
if blobCount > 0 {
v.UI.Infof("%s", blobsFound)
}
if dirCount > 0 {
v.UI.Infof("%s", dirsFound)
for _, remoteKey := range dirs {
v.UI.Detailf("metadata/%s/", remoteKey)
}
}
return true
}
// The counts, the names, the prompt and its answer bypass the UI:
// they are an interactive exchange the operator must see, so --quiet
// does not silence them.
if blobCount > 0 {
v.stdoutf("%s\n", blobsFound)
}
if dirCount > 0 {
v.stdoutf("%s\n", dirsFound)
for _, remoteKey := range dirs {
v.stdoutf(" metadata/%s/\n", remoteKey)
}
}
switch {
case dirCount == 0:
v.stdoutf("\nDelete %d unreferenced blob(s)? [y/N] ", blobCount)
case blobCount == 0:
v.stdoutf("\nDelete %d metadata director(ies)? [y/N] ", dirCount)
default:
v.stdoutf("\nDelete %d unreferenced blob(s) and %d metadata "+
"director(ies)? [y/N] ", blobCount, dirCount)
}
var confirm string
// A failure to read the answer means no confirmation.
_, err := v.scanStdin(&confirm)
if err != nil || strings.ToLower(confirm) != "y" {
v.printlnStdout("Cancelled")
return false
}
return true
}
// reportPruneDeletions says how many blobs and metadata directories
// prune deleted, naming each kind only when it found some to delete.
func (v *Vaultik) reportPruneDeletions(result *PruneBlobsResult, dirCount int) {
if result.BlobsFound > 0 {
v.UI.Infof("Deleted %d blob(s) totaling %s.",
result.BlobsDeleted, ubytes(result.BytesFreed))
}
if result.BlobsFailed > 0 {
return fmt.Errorf("%d %w", result.BlobsFailed, errBlobDeleteFailed)
if dirCount > 0 {
v.UI.Infof("Deleted %d metadata director(ies) with no manifest.",
result.MetadataDirsDeleted)
}
return nil
}
// collectReferencedBlobs downloads all manifests and returns the set of
// referenced blob hashes.
// listMetadataObjects lists every object under metadata/ on the
// destination store.
func (v *Vaultik) listMetadataObjects() ([]storage.ObjectInfo, error) {
log.Info("Listing remote snapshots")
var objects []storage.ObjectInfo
for object := range v.Storage.ListStream(v.ctx, "metadata/") {
if object.Err != nil {
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
}
objects = append(objects, object)
}
return objects, nil
}
// collectReferencedBlobs downloads every manifest among the listed
// metadata objects and returns the set of referenced blob hashes.
//
// Every manifest must be read successfully. A manifest that cannot be
// downloaded or decoded means its snapshot's blobs are unknown, so
// treating them as unreferenced would let prune delete data a snapshot
// still needs. Rather than risk that silent loss, any failure returns an
// error naming the remote key and prune deletes nothing.
func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
log.Info("Listing remote snapshots")
// IDs returned by listUniqueSnapshotIDs are remote keys (hashed
func (v *Vaultik) collectReferencedBlobs(
metadataObjects []storage.ObjectInfo,
) (map[string]bool, error) {
// IDs returned by uniqueSnapshotIDs are remote keys (hashed
// subdirectories under metadata/), not human snapshot IDs.
remoteKeys, err := v.listUniqueSnapshotIDs()
if err != nil {
return nil, fmt.Errorf("listing snapshot keys: %w", err)
}
remoteKeys := uniqueSnapshotIDs(metadataObjects)
log.Info("Found manifests in remote storage", "count", len(remoteKeys))
@@ -300,18 +439,14 @@ func (v *Vaultik) collectReferencedBlobs() (map[string]bool, error) {
return allBlobsReferenced, nil
}
// listUniqueSnapshotIDs returns deduplicated snapshot IDs from remote metadata
func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
objectCh := v.Storage.ListStream(v.ctx, "metadata/")
// uniqueSnapshotIDs returns deduplicated snapshot IDs from the listed
// metadata objects.
func uniqueSnapshotIDs(metadataObjects []storage.ObjectInfo) []string {
seen := make(map[string]bool)
var snapshotIDs []string
for object := range objectCh {
if object.Err != nil {
return nil, fmt.Errorf("listing metadata objects: %w", object.Err)
}
for _, object := range metadataObjects {
parts := strings.Split(object.Key, "/")
if len(parts) >= minSnapshotIDParts &&
parts[0] == metadataDirName && parts[1] != "" {
@@ -326,14 +461,15 @@ func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
}
}
return snapshotIDs, nil
return snapshotIDs
}
// listAllRemoteBlobs returns a map of all blob hashes to their sizes in remote storage
func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
// listAllRemoteBlobs returns a map of all blob hashes in remote storage
// to their listing entries.
func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) {
log.Info("Listing all blobs in storage")
allBlobs := make(map[string]int64)
allBlobs := make(map[string]storage.ObjectInfo)
blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
for object := range blobObjectCh {
@@ -357,7 +493,7 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
continue
}
allBlobs[parts[3]] = object.Size
allBlobs[parts[3]] = object
}
log.Info("Found blobs in storage", "count", len(allBlobs))
@@ -365,30 +501,135 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
return allBlobs, nil
}
// findUnreferencedBlobs returns blob hashes not referenced by any
// manifest and their total size.
// findUnreferencedBlobs returns the hashes of the blobs no manifest
// references that are older than the prune grace period, and their total
// size. It also returns how many unreferenced blobs it left out for
// being newer than that, or of unknown age because the listing gave no
// time for them.
func (v *Vaultik) findUnreferencedBlobs(
allBlobs map[string]int64, referenced map[string]bool,
) ([]string, int64) {
allBlobs map[string]storage.ObjectInfo, referenced map[string]bool,
) ([]string, int64, int) {
var (
unreferenced []string
totalSize int64
tooNew int
)
for hash, size := range allBlobs {
if !referenced[hash] {
unreferenced = append(unreferenced, hash)
totalSize += size
cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
for hash, blob := range allBlobs {
if referenced[hash] {
continue
}
if blob.ModTime.IsZero() || blob.ModTime.After(cutoff) {
tooNew++
continue
}
unreferenced = append(unreferenced, hash)
totalSize += blob.Size
}
return unreferenced, totalSize, tooNew
}
// findMetadataDirsWithoutManifest returns the remote key of each
// directory under metadata/ that has no manifest.json.zst, such as one a
// backup leaves when its manifest upload fails after db.zst.age was
// uploaded. It leaves out a directory a local snapshot record claims,
// and one holding an object newer than the prune grace period, or of
// unknown age because the listing gave no time for it: a backup still
// running may be writing there. A name that is not a remote key is
// never returned.
func (v *Vaultik) findMetadataDirsWithoutManifest(
metadataObjects []storage.ObjectInfo,
) ([]string, error) {
localIDs, err := v.localSnapshotIDs()
if err != nil {
return nil, fmt.Errorf("listing local snapshots: %w", err)
}
keep := make(map[string]bool, len(localIDs))
for _, id := range localIDs {
keep[snapshot.RemoteSnapshotKey(id)] = true
}
cutoff := time.Now().UTC().Add(-v.Config.PruneGracePeriod)
listed := make(map[string]bool)
for _, object := range metadataObjects {
parts := strings.Split(object.Key, "/")
if len(parts) < metadataKeyParts || !isBlobHash(parts[1]) {
continue
}
remoteKey := parts[1]
listed[remoteKey] = true
if parts[2] == "manifest.json.zst" ||
object.ModTime.IsZero() || object.ModTime.After(cutoff) {
keep[remoteKey] = true
}
}
return unreferenced, totalSize
var remoteKeys []string
for remoteKey := range listed {
if !keep[remoteKey] {
remoteKeys = append(remoteKeys, remoteKey)
}
}
sort.Strings(remoteKeys)
return remoteKeys, nil
}
// deleteMetadataDirs deletes everything in the given directories under
// metadata/ and counts in the result the directories deleted and those
// it could not delete.
func (v *Vaultik) deleteMetadataDirs(
remoteKeys []string, result *PruneBlobsResult,
) {
for _, remoteKey := range remoteKeys {
_, err := v.deleteRemoteSnapshotByKey(remoteKey)
if err != nil {
log.Error("Failed to delete metadata directory with no manifest",
"remote_key", remoteKey, "error", err)
result.MetadataDirsFailed++
continue
}
log.Info("Deleted metadata directory with no manifest",
"remote_key", remoteKey)
result.MetadataDirsDeleted++
}
}
// reportBlobsTooNew says how many unreferenced blobs prune kept for being
// newer than the grace period or of unknown age. Under --json the
// document carries the count instead.
func (v *Vaultik) reportBlobsTooNew(tooNew int, opts *PruneOptions) {
if tooNew == 0 || opts.JSON {
return
}
v.UI.Infof("Kept %d unreferenced blob(s) newer than "+
"prune_grace_period (%s), or of unknown age: a backup still "+
"running may need them.",
tooNew, v.Config.PruneGracePeriod)
}
// deleteUnreferencedBlobs deletes the given blobs from storage and
// populates the result.
func (v *Vaultik) deleteUnreferencedBlobs(
unreferencedBlobs []string, allBlobs map[string]int64, result *PruneBlobsResult,
unreferencedBlobs []string, allBlobs map[string]storage.ObjectInfo,
result *PruneBlobsResult,
) {
log.Info("Deleting unreferenced blobs")
@@ -403,7 +644,7 @@ func (v *Vaultik) deleteUnreferencedBlobs(
}
result.BlobsDeleted++
result.BytesFreed += allBlobs[hash]
result.BytesFreed += allBlobs[hash].Size
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
log.Info("Deletion progress",
+168
View File
@@ -0,0 +1,168 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// TestPruneDuringBackupKeepsItsBlobs runs a prune from another host
// sharing the destination store at the moment a backup has uploaded its
// blobs and its db.zst.age but not yet its manifest, so no manifest lists
// those blobs. The prune must leave the blobs and the db.zst.age, and the
// backup must then restore.
func TestPruneDuringBackupKeepsItsBlobs(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
ctx := context.Background()
cfg := faultTestConfig()
testFiles := writeFaultSourceTree(t, fs, dataDir)
inner, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
otherHostCfg := faultTestConfig()
otherHostCfg.PruneGracePeriod = time.Hour
otherHost := newReaderVaultik(ctx, otherHostCfg, inner, nil, fs)
var (
pruned bool
pruneErr error
)
store := faultstore.New(inner)
store.OnPut = func(key string) faultstore.PutAction {
if strings.HasSuffix(key, "/manifest.json.zst") {
pruned = true
pruneErr = otherHost.PruneBlobs(&vaultik.PruneOptions{Force: true})
}
return faultstore.PutNormal
}
db, err := database.New(ctx, dbPath)
require.NoError(t, err)
repos := database.NewRepositories(db)
id := fullFaultBackup(ctx, t, fs, store, cfg, repos, dataDir, dbPath, "shared")
require.NoError(t, db.Close())
require.True(t, pruned, "the prune must run before the manifest upload")
require.NoError(t, pruneErr)
v := newReaderVaultik(ctx, cfg, inner, nil, fs)
require.NoError(t, v.Restore(&vaultik.RestoreOptions{
SnapshotID: id,
TargetDir: restoreDir,
Verify: true,
}), "a prune during the backup must not delete the backup's blobs "+
"or its db.zst.age")
assertRestoredTree(t, fs, restoreDir, testFiles)
}
// TestPruneKeepsUnreferencedBlobWithNoTime checks that a blob no
// manifest lists, whose listing gives no modification time (as an S3
// listing entry without LastModified does), is kept and counted with
// the blobs too new to delete: its age is unknown.
func TestPruneKeepsUnreferencedBlobWithNoTime(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
// Written into the mock store's map directly rather than through
// Put, so its listing gives no time.
blobKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
"/" + testBlobHashA
env.store.data[blobKey] = []byte("blob")
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
assert.True(t, env.store.hasKey(blobKey),
"a blob of unknown age must be kept")
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result))
assert.Equal(t, 0, result.BlobsDeleted)
assert.Equal(t, 1, result.BlobsTooNew)
}
// TestPruneDeletesUnreferencedBlobOlderThanGracePeriod checks that the
// grace period only delays deletion: a blob no manifest lists is deleted
// once it is older than the grace period, and kept and counted until
// then.
func TestPruneDeletesUnreferencedBlobOlderThanGracePeriod(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := t.TempDir()
store, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
oldKey := "blobs/" + testBlobHashA[:2] + "/" + testBlobHashA[2:4] +
"/" + testBlobHashA
newKey := "blobs/" + testBlobHashB[:2] + "/" + testBlobHashB[2:4] +
"/" + testBlobHashB
for _, key := range []string{oldKey, newKey} {
require.NoError(t, store.Put(ctx, key, strings.NewReader("blob")))
}
gracePeriod := time.Hour
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
require.NoError(t, os.Chtimes(filepath.Join(storeDir, oldKey),
beforeGracePeriod, beforeGracePeriod))
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{
Config: &config.Config{PruneGracePeriod: gracePeriod},
Storage: store,
Stdout: stdout,
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
_, err = store.Stat(ctx, oldKey)
require.ErrorIs(t, err, storage.ErrNotFound,
"a blob older than the grace period must be deleted")
_, err = store.Stat(ctx, newKey)
require.NoError(t, err, "a blob newer than the grace period must be kept")
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
assert.Equal(t, 1, result.BlobsDeleted)
assert.Equal(t, 1, result.BlobsTooNew)
}
+225
View File
@@ -0,0 +1,225 @@
package vaultik_test
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/storage"
"sneak.berlin/go/vaultik/internal/storage/faultstore"
"sneak.berlin/go/vaultik/internal/ui"
"sneak.berlin/go/vaultik/internal/vaultik"
)
// metadataObjectKey returns the key of the named file in the metadata
// directory of the snapshot with the given human ID.
func metadataObjectKey(snapshotID, name string) string {
return "metadata/" + snapshot.RemoteSnapshotKey(snapshotID) + "/" + name
}
// TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod checks
// that prune deletes a directory under metadata/ holding only
// db.zst.age, as a backup leaves when its manifest upload fails, once it
// is older than the grace period, and keeps one newer than that. A
// directory with a manifest is never deleted, however old.
func TestPruneDeletesMetadataDirWithoutManifestOlderThanGracePeriod(
t *testing.T,
) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
storeDir := t.TempDir()
store, err := storage.NewFileStorer(storeDir)
require.NoError(t, err)
const (
oldID = "testhost_home_2026-01-01T00:00:00Z"
newID = "testhost_home_2026-01-02T00:00:00Z"
completeID = "testhost_home_2026-01-03T00:00:00Z"
)
oldDB := metadataObjectKey(oldID, "db.zst.age")
newDB := metadataObjectKey(newID, "db.zst.age")
completeDB := metadataObjectKey(completeID, "db.zst.age")
completeManifest := metadataObjectKey(completeID, "manifest.json.zst")
manifest, err := snapshot.EncodeManifest(&snapshot.Manifest{
SnapshotID: snapshot.RemoteSnapshotKey(completeID),
}, 3)
require.NoError(t, err)
for _, key := range []string{oldDB, newDB, completeDB} {
require.NoError(t, store.Put(ctx, key, strings.NewReader("db")))
}
require.NoError(t, store.Put(ctx, completeManifest,
bytes.NewReader(manifest)))
gracePeriod := time.Hour
beforeGracePeriod := time.Now().Add(-gracePeriod - time.Minute)
for _, key := range []string{oldDB, completeDB, completeManifest} {
require.NoError(t, os.Chtimes(filepath.Join(storeDir, key),
beforeGracePeriod, beforeGracePeriod))
}
stdout := &bytes.Buffer{}
v := &vaultik.Vaultik{
Config: &config.Config{PruneGracePeriod: gracePeriod},
Storage: store,
Stdout: stdout,
UI: ui.NewWithColor(stdout, false),
}
v.SetContext(ctx)
require.NoError(t, v.PruneBlobs(&vaultik.PruneOptions{JSON: true}))
_, err = store.Stat(ctx, oldDB)
require.ErrorIs(t, err, storage.ErrNotFound,
"a directory with no manifest older than the grace period "+
"must be deleted")
for _, key := range []string{newDB, completeDB, completeManifest} {
_, err = store.Stat(ctx, key)
require.NoError(t, err, "%s must be kept", key)
}
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(stdout.Bytes(), &result))
assert.Equal(t, 1, result.MetadataDirsDeleted)
}
// TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey checks that
// prune --force keeps a directory under metadata/ with no manifest when a
// local snapshot record claims it, when the listing gives no time for an
// object in it, or when its name is not a remote key. A fourth such
// directory is named, deleted and reported, although there are no
// unreferenced blobs to delete.
func TestPruneKeepsMetadataDirClaimedUnknownAgeOrNotRemoteKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
ctx := context.Background()
env := newListEnv(t)
const (
claimedID = "testhost_home_2026-01-01T00:00:00Z"
leftID = "testhost_home_2026-01-03T00:00:00Z"
)
env.addLocal(t, claimedID, time.Now().Add(-time.Hour))
claimedDB := metadataObjectKey(claimedID, "db.zst.age")
unknownAgeDB := metadataObjectKey("testhost_home_2026-01-02T00:00:00Z",
"db.zst.age")
leftDB := metadataObjectKey(leftID, "db.zst.age")
notRemoteKeyDB := "metadata/not-a-remote-key/db.zst.age"
for _, key := range []string{claimedDB, leftDB, notRemoteKeyDB} {
require.NoError(t, env.store.Put(ctx, key, strings.NewReader("db")))
}
// Written into the mock store's map directly rather than through
// Put, so its listing gives no time.
env.store.data[unknownAgeDB] = []byte("db")
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{Force: true}))
assert.True(t, env.store.hasKey(claimedDB),
"a directory a local snapshot record claims must be kept")
assert.True(t, env.store.hasKey(unknownAgeDB),
"a directory holding an object of unknown age must be kept")
assert.True(t, env.store.hasKey(notRemoteKeyDB),
"a directory whose name is not a remote key must be kept")
assert.False(t, env.store.hasKey(leftDB),
"a directory with no manifest, unclaimed and old, must be deleted")
output := env.stdout.String()
assert.Contains(t, output, "Found 1 metadata director(ies) left by "+
"backups that stopped before their manifest:")
assert.Contains(t, output,
"metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/")
assert.NotContains(t, output, "not-a-remote-key")
assert.Contains(t, output,
"Deleted 1 metadata director(ies) with no manifest.")
assert.NotContains(t, output, "No unreferenced blobs to remove.")
}
// TestPruneDeclinedKeepsMetadataDirWithoutManifest checks that the prompt
// names a directory under metadata/ with no manifest that prune is about
// to delete, and that answering no keeps it.
func TestPruneDeclinedKeepsMetadataDirWithoutManifest(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
const leftID = "testhost_home_2026-01-01T00:00:00Z"
leftDB := metadataObjectKey(leftID, "db.zst.age")
require.NoError(t, env.store.Put(context.Background(), leftDB,
strings.NewReader("db")))
env.v.Stdin = strings.NewReader("n\n")
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
assert.True(t, env.store.hasKey(leftDB),
"a declined prune must keep the directory")
output := env.stdout.String()
assert.Contains(t, output,
" metadata/"+snapshot.RemoteSnapshotKey(leftID)+"/\n")
assert.Contains(t, output, "Delete 1 metadata director(ies)? [y/N] ")
assert.Contains(t, output, "Cancelled")
assert.NotContains(t, output, "Deleted")
}
// TestPruneFailedMetadataDirDeleteReturnsError checks that prune --json
// counts a directory under metadata/ with no manifest that it cannot
// delete, and then returns an error, so the command exits non-zero.
func TestPruneFailedMetadataDirDeleteReturnsError(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
leftDB := metadataObjectKey("testhost_home_2026-01-01T00:00:00Z",
"db.zst.age")
require.NoError(t, env.store.Put(context.Background(), leftDB,
strings.NewReader("db")))
store := faultstore.New(env.store)
store.OnDelete = func(key string) bool {
return strings.HasPrefix(key, "metadata/")
}
env.v.Storage = store
err := env.v.PruneBlobs(&vaultik.PruneOptions{JSON: true})
require.ErrorContains(t, err,
"1 metadata director(ies) with no manifest could not be deleted")
assert.True(t, env.store.hasKey(leftDB))
var result vaultik.PruneBlobsResult
require.NoError(t, json.Unmarshal(env.stdout.Bytes(), &result),
"stdout must hold the JSON document, got:\n%s", env.stdout.String())
assert.Equal(t, 1, result.MetadataDirsFailed)
assert.Equal(t, 0, result.MetadataDirsDeleted)
}
@@ -35,6 +35,9 @@ func setupConsistencyTest(
repos := database.NewRepositories(db)
mockStorage := NewMockStorer()
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
for _, id := range snapshotIDs {
parts := strings.Split(id, "_")
startedAt, err := time.Parse(time.RFC3339, parts[len(parts)-1])
+3
View File
@@ -45,6 +45,9 @@ func setupPurgeTest(
repos := database.NewRepositories(db)
mockStorage := NewMockStorer()
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, mockStorage.Put(ctx, markerFile, strings.NewReader("")))
// Insert each snapshot into the DB and create remote metadata stubs.
// Use timestamps parsed from snapshot IDs for realistic ordering.
for _, id := range snapshotIDs {
+4
View File
@@ -96,6 +96,10 @@ func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
tv.UI.SetQuiet(true)
+23 -4
View File
@@ -26,11 +26,14 @@ import (
type testStorer struct {
mu sync.Mutex
data map[string][]byte
// putTimes holds when Put stored each object; ListStream reports it.
putTimes map[string]time.Time
}
func newTestStorer() *testStorer {
return &testStorer{
data: make(map[string][]byte),
data: make(map[string][]byte),
putTimes: make(map[string]time.Time),
}
}
@@ -44,6 +47,7 @@ func (s *testStorer) Put(_ context.Context, key string, reader io.Reader) error
}
s.data[key] = data
s.putTimes[key] = time.Now().UTC()
return nil
}
@@ -87,6 +91,7 @@ func (s *testStorer) Delete(_ context.Context, key string) error {
defer s.mu.Unlock()
delete(s.data, key)
delete(s.putTimes, key)
return nil
}
@@ -120,8 +125,9 @@ func (s *testStorer) ListStream(
for key, data := range s.data {
if prefix == "" || strings.HasPrefix(key, prefix) {
ch <- storage.ObjectInfo{
Key: key,
Size: int64(len(data)),
Key: key,
Size: int64(len(data)),
ModTime: s.putTimes[key],
}
}
}
@@ -304,6 +310,10 @@ func TestRemoveSnapshot_DefaultRemovesMetadataNotBlobs(t *testing.T) {
addBlob(t, store, blobUnique)
addBlob(t, store, blobShared)
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001", "snapshot-002")
@@ -373,6 +383,10 @@ func TestRemoveSnapshot_UnknownIDIsError(t *testing.T) {
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
@@ -393,7 +407,12 @@ func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
tv := vaultik.NewForTesting(newTestStorer())
// A backup wrote to this destination, so it carries the marker.
store := newTestStorer()
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t, "snapshot-001")
result, err := tv.RemoveSnapshot("snapshot-001",
+42 -5
View File
@@ -12,6 +12,7 @@ import (
"path/filepath"
"slices"
"strings"
"syscall"
"time"
"filippo.io/age"
@@ -219,9 +220,7 @@ func (v *Vaultik) finishRestore(
)
if os.Geteuid() != 0 {
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
"requires root. Re-run as root (e.g. with sudo) if you need " +
"original UID/GID preserved.")
v.warnIfOwnershipNotPreserved(files, opts.TargetDir)
}
if result.FilesFailed > 0 {
@@ -247,6 +246,43 @@ func (v *Vaultik) finishRestore(
return unmatchedErr
}
// warnIfOwnershipNotPreserved warns once if some entry restored under
// targetDir has another owner or group on disk than the snapshot
// records. It is meant for a restore by a normal user, which changes no
// owner or group: a new entry is owned by that user, and its group is
// that user's, or the group of the directory it is created in on macOS
// and in a setgid directory on Linux. An entry that is not on disk, or
// whose filesystem reports no owner, is skipped.
func (v *Vaultik) warnIfOwnershipNotPreserved(
files []*database.File, targetDir string,
) {
for _, file := range files {
targetPath, err := containedRestorePath(
v.Fs, targetDir, file.Path.String())
if err != nil {
continue
}
info, err := lstatIfPossible(v.Fs, targetPath)
if err != nil {
continue
}
owner, ok := info.Sys().(*syscall.Stat_t)
if !ok {
continue
}
if owner.Uid != file.UID || owner.Gid != file.GID {
v.UI.Warningf("Restore did not preserve file ownership: chown(2) " +
"requires root. Re-run as root (e.g. with sudo) if you need " +
"original UID/GID preserved.")
return
}
}
}
// reportUnmatchedPaths warns, by name, about each requested path that
// matched nothing in the snapshot, and returns an error counting them,
// or nil when there are none.
@@ -965,8 +1001,9 @@ type restoreSession struct {
// root can chown a file to an arbitrary UID/GID — non-root chown
// always fails with EPERM. Attempting it anyway produces N
// guaranteed-failed syscalls + N noisy debug lines, so we skip
// the call entirely as non-root and emit one warning at the end
// of the restore explaining that ownership was not preserved.
// the call entirely as non-root; warnIfOwnershipNotPreserved warns
// once at the end of the restore if that left any owner or group
// other than recorded.
runningAsRoot bool
// directories holds every restored directory, for
// applyDirectoryMetadata to finish after the restore loop.
@@ -42,7 +42,7 @@ func TestRestoreOnAnotherMachine(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
+1 -1
View File
@@ -65,7 +65,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
root := t.TempDir()
dataDir := filepath.Join(root, "source")
storeDir := filepath.Join(root, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(root, "restored")
dbPath := filepath.Join(root, "index.sqlite")
+1 -1
View File
@@ -201,7 +201,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -0,0 +1,131 @@
package vaultik //nolint:testpackage // calls the unexported ownership check
import (
"bytes"
"context"
"os"
"strings"
"syscall"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/ui"
)
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/316: a
// restore by a normal user warns that ownership was not preserved only
// when some restored entry has another owner or group on disk than the
// snapshot records.
const ownershipWarning = "did not preserve file ownership"
// TestOwnershipWarningSilentWhenEntriesMatch checks that a restore whose
// entries all end up with their recorded owner and group prints no
// warning.
func TestOwnershipWarningSilentWhenEntriesMatch(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
targetDir := t.TempDir()
owner := ownerOf(t, targetDir)
out := restoreAsNormalUser(t, targetDir, []*database.File{
{Path: "/a", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
{Path: "/b", Mode: plainFileMode, UID: owner.Uid, GID: owner.Gid},
})
assert.Empty(t, out)
}
// TestOwnershipWarningWhenOwnerDiffers checks that the warning is printed
// when an entry recorded with another owner is restored owned by the
// restoring user, even though its group matches the recorded one.
func TestOwnershipWarningWhenOwnerDiffers(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
targetDir := t.TempDir()
owner := ownerOf(t, targetDir)
out := restoreAsNormalUser(t, targetDir, []*database.File{
{Path: "/a", Mode: plainFileMode, UID: otherOwnerID, GID: owner.Gid},
})
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
}
// TestOwnershipWarningWhenDirectoryGivesItsGroup checks that the warning
// is printed, once, when entries recorded with the restoring user's
// owner and group take another group from the setgid directory they are
// restored into, as every new entry takes its directory's group on
// macOS.
func TestOwnershipWarningWhenDirectoryGivesItsGroup(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
if os.Geteuid() != 0 {
t.Skip("giving a directory to another group needs root")
}
targetDir := t.TempDir()
user := ownerOf(t, targetDir)
require.NoError(t, os.Chown(targetDir, -1, int(otherOwnerID)))
require.NoError(t, os.Chmod(targetDir, os.ModeSetgid|writableTestMode))
out := restoreAsNormalUser(t, targetDir, []*database.File{
{Path: "/a", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
{Path: "/b", Mode: plainFileMode, UID: user.Uid, GID: user.Gid},
})
assert.Equal(t, 1, strings.Count(out, ownershipWarning), out)
}
// ownerOf returns the owner and group path has on disk.
func ownerOf(t *testing.T, path string) *syscall.Stat_t {
t.Helper()
info, err := os.Lstat(path)
require.NoError(t, err)
owner, ok := info.Sys().(*syscall.Stat_t)
require.True(t, ok)
return owner
}
// restoreAsNormalUser restores rows, all empty regular files, into
// targetDir without changing any owner or group, as restore does for a
// normal user, and returns what warnIfOwnershipNotPreserved prints.
func restoreAsNormalUser(
t *testing.T, targetDir string, rows []*database.File,
) string {
t.Helper()
ctx := context.Background()
rows, repos := makeFiles(ctx, t, rows)
var out bytes.Buffer
v := newContainmentVaultik(ctx, afero.NewOsFs())
v.UI = ui.NewWithColor(&out, false)
session := &restoreSession{
v: v,
ctx: ctx,
repos: repos,
opts: &RestoreOptions{TargetDir: targetDir},
result: &RestoreResult{},
}
for _, row := range rows {
require.NoError(t, session.restoreFile(row))
}
v.warnIfOwnershipNotPreserved(rows, targetDir)
return out.String()
}
+1 -1
View File
@@ -107,7 +107,7 @@ func backupOneFile(
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
require.NoError(t, fs.Chmod(srcPath, mode))
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
storer, err := storage.NewFileStorer(storeDir)
+1 -1
View File
@@ -348,7 +348,7 @@ func backupSingleChunkFiles(
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
}
storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
storer, err := storage.NewFileStorer(t.TempDir())
require.NoError(t, err)
db, err := database.New(ctx, dbPath)
@@ -29,7 +29,7 @@ func TestRestoreAndDeepVerifyRejectSwappedDatabase(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
chunkSize := int64(64 * 1024)
@@ -100,7 +100,7 @@ func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
chunkSize := int64(64 * 1024)
@@ -186,7 +186,7 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
+1 -1
View File
@@ -34,7 +34,7 @@ func TestRestoreFreesBlobsNoPendingFileNeeds(t *testing.T) {
dataDir := filepath.Join(tempDir, "source")
originalsDir := filepath.Join(dataDir, "originals")
copiesDir := filepath.Join(dataDir, "copies")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
@@ -33,7 +33,7 @@ func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -32,7 +32,7 @@ func TestRestoreNamesUnmatchedPathAndRestoresTheMatchingOne(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -89,7 +89,7 @@ func TestRestoreNamesEveryPathWhenNothingMatches(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
linkDir := filepath.Join(tempDir, "srclink")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
@@ -152,7 +152,7 @@ func TestRestoreNamesUnmatchedPathWhenADamagedBlobStopsIt(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
+1 -1
View File
@@ -28,7 +28,7 @@ func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
secondDBPath := filepath.Join(tempDir, "second.sqlite")
chunkSize := int64(64 * 1024)
+1 -1
View File
@@ -25,7 +25,7 @@ func TestBackupOfSameSecondRewriteRestoresNewContent(t *testing.T) {
fs := afero.NewOsFs()
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "src")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
rewrittenPath := filepath.Join(dataDir, "small.txt")
+4 -4
View File
@@ -36,7 +36,7 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(32 * 1024)
@@ -116,7 +116,7 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(32 * 1024)
@@ -199,7 +199,7 @@ func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(32 * 1024)
@@ -288,7 +288,7 @@ func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(32 * 1024)
+303 -83
View File
@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
@@ -44,6 +45,25 @@ var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
// kept, so the caller can report it apart from a failed backup.
var ErrFilesUnreadable = errors.New("file(s) could not be read")
// ErrSourcePathEmpty is returned when --prune skipped the post-backup
// prune because the scan found nothing in a configured path of a
// snapshot, as in the mount point of a volume that is not mounted. The
// snapshots are complete and kept, so the caller can report it apart
// from a failed backup. A run that also skipped unreadable files
// returns ErrFilesUnreadable instead.
var ErrSourcePathEmpty = errors.New("source path(s) were empty")
// ErrVolumeNotMounted is returned when --prune skipped the post-backup
// prune because a directory that an earlier snapshot of the same name
// found as a mount point was not found as one in this run: the volume
// mounted there is not mounted, the run found no entry in it, or the
// directory is no longer backed up. The snapshots are complete and
// kept, so the caller can report it apart from a failed backup. A run
// that also skipped unreadable files or found a configured path empty
// returns ErrFilesUnreadable or ErrSourcePathEmpty instead.
var ErrVolumeNotMounted = errors.New(
"mount point(s) an earlier snapshot recorded were not found")
// listRecentLimit caps how many snapshot rows are fetched from the
// local index for listing and purge operations.
const listRecentLimit = 10000
@@ -100,6 +120,13 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
return err
}
// Before PruneDatabase: it drops incomplete snapshots, which could
// leave the local index without one and so skip the marker check.
err = v.markDestination()
if err != nil {
return err
}
// Clean up incomplete snapshots FIRST, before any scanning.
// This is critical for data safety; PruneDatabase below does it.
hostname := v.Config.Hostname
@@ -122,34 +149,15 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
return fmt.Errorf("prune database: %w", err)
}
// Determine which snapshots to process
snapshotNames := opts.Snapshots
if len(snapshotNames) == 0 {
snapshotNames = v.Config.SnapshotNames()
} else {
// Validate requested snapshot names exist
for _, name := range snapshotNames {
if _, ok := v.Config.Snapshots[name]; !ok {
return fmt.Errorf("%w: %q", errSnapshotNotInConfig, name)
}
}
snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots)
if err != nil {
return err
}
if len(snapshotNames) == 0 {
return errNoSnapshotsInConfig
}
// Process each named snapshot
filesUnreadable := 0
for snapIdx, snapName := range snapshotNames {
unreadable, err := v.createNamedSnapshot(
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
if err != nil {
return err
}
filesUnreadable += unreadable
filesUnreadable, emptyPaths, unmountedPaths, err := v.createNamedSnapshots(
opts, hostname, snapshotNames)
if err != nil {
return err
}
// Print overall summary if multiple snapshots
@@ -158,7 +166,12 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
len(snapshotNames), v.UI.Duration(time.Since(overallStartTime)))
}
if opts.Prune {
switch {
case opts.Prune && (filesUnreadable > 0 || len(emptyPaths) > 0 ||
len(unmountedPaths) > 0):
return v.skipPostBackupPrune(
filesUnreadable, emptyPaths, unmountedPaths)
case opts.Prune:
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
if err != nil {
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
@@ -168,6 +181,84 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
return v.finishCreateSnapshot(filesUnreadable)
}
// createNamedSnapshots creates each named snapshot in turn. It returns
// the number of entries --skip-errors skipped because it could not read
// or list them, each configured path in which the scan found nothing,
// and, under --prune, each directory where an earlier snapshot found a
// volume mounted and this run did not, the paths named with their
// snapshot.
func (v *Vaultik) createNamedSnapshots(
opts *SnapshotCreateOptions, hostname string, snapshotNames []string,
) (int, []string, []string, error) {
filesUnreadable := 0
var emptyPaths, unmountedPaths []string
for snapIdx, snapName := range snapshotNames {
stats, err := v.createNamedSnapshot(
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
if err != nil {
return 0, nil, nil, err
}
filesUnreadable += stats.totalFilesUnreadable
for _, dir := range stats.emptyPaths {
emptyPaths = append(emptyPaths, dir+" of snapshot "+snapName)
}
for _, dir := range stats.unmountedPaths {
unmountedPaths = append(unmountedPaths,
dir+" of snapshot "+snapName)
}
}
return filesUnreadable, emptyPaths, unmountedPaths, nil
}
// skipPostBackupPrune says why the post-backup prune does not run, naming
// every cause, and returns the error the run ends with. An older snapshot
// may be the only one that still holds a file this run could not read,
// or what an empty path or a volume that is not mounted held. The
// previous snapshot is no guide: after one such run it lacks them too.
func (v *Vaultik) skipPostBackupPrune(
filesUnreadable int, emptyPaths, unmountedPaths []string,
) error {
log.Warn("Skipping post-backup prune",
"files_unreadable", filesUnreadable, "empty_paths", emptyPaths,
"unmounted_paths", unmountedPaths)
if len(emptyPaths) > 0 {
v.UI.Warningf("Skipped the post-backup prune because nothing was "+
"found in %s; older snapshots are kept.",
strings.Join(emptyPaths, ", "))
}
if len(unmountedPaths) > 0 {
v.UI.Warningf("Skipped the post-backup prune because this backup "+
"did not find a mount point at %s, where an earlier snapshot "+
"found one: the volume is not mounted, this backup found no "+
"entry in it, or the directory is no longer backed up. Older "+
"snapshots are kept. Mount the volume again, or, if that is on "+
"purpose, remove the older snapshots by hand with "+
"'vaultik snapshot purge'.",
strings.Join(unmountedPaths, ", "))
}
if filesUnreadable > 0 {
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
"could not be read; older snapshots are kept.", filesUnreadable)
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
}
if len(emptyPaths) > 0 {
return fmt.Errorf("%d %w", len(emptyPaths), ErrSourcePathEmpty)
}
return fmt.Errorf("%d %w", len(unmountedPaths), ErrVolumeNotMounted)
}
// finishCreateSnapshot ends a run whose snapshots are all complete and
// kept. Like finishRestore, it returns an error when --skip-errors
// skipped anything it could not read or list; otherwise it prints the
@@ -193,10 +284,33 @@ func (v *Vaultik) finishCreateSnapshot(filesUnreadable int) error {
return nil
}
// snapshotNamesToCreate returns the requested snapshot names, each checked
// against the config, or every configured name when none was requested.
func (v *Vaultik) snapshotNamesToCreate(requested []string) ([]string, error) {
snapshotNames := requested
if len(snapshotNames) == 0 {
snapshotNames = v.Config.SnapshotNames()
} else {
// Validate requested snapshot names exist
for _, name := range snapshotNames {
if _, ok := v.Config.Snapshots[name]; !ok {
return nil, fmt.Errorf("%w: %q", errSnapshotNotInConfig, name)
}
}
}
if len(snapshotNames) == 0 {
return nil, errNoSnapshotsInConfig
}
return snapshotNames, nil
}
// runPostBackupPrune drops older snapshots of the given names and removes
// orphan blobs from remote storage. If keepNewerThan is set (e.g. "4w"),
// snapshots newer than that duration are kept. Otherwise only the latest
// snapshot of each name is kept.
// from remote storage the orphan blobs and the directories under
// metadata/ with no manifest, as PruneBlobs does. If keepNewerThan is set
// (e.g. "4w"), snapshots newer than that duration are kept. Otherwise only
// the latest snapshot of each name is kept.
func (v *Vaultik) runPostBackupPrune(
snapshotNames []string, keepNewerThan string,
) error {
@@ -223,7 +337,7 @@ func (v *Vaultik) runPostBackupPrune(
err = v.PruneBlobs(&PruneOptions{Force: true})
if err != nil {
return fmt.Errorf("pruning orphaned blobs: %w", err)
return fmt.Errorf("pruning the destination store: %w", err)
}
return nil
@@ -249,6 +363,13 @@ type snapshotStats struct {
// could not be read or listed.
totalFilesUnreadable int
// The configured paths in which the scan found nothing.
emptyPaths []string
// Under --prune, the directories where an earlier snapshot of the
// same name found a volume mounted and this one did not.
unmountedPaths []string
// The sizes of all blobs the snapshot references, set by
// finalizeSnapshotMetadata once snapshot_blobs is populated.
blobSize int64
@@ -256,11 +377,10 @@ type snapshotStats struct {
}
// createNamedSnapshot creates a single named snapshot and returns the
// number of entries --skip-errors skipped because it could not read or
// list them.
// statistics of its scan.
func (v *Vaultik) createNamedSnapshot(
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
) (int, error) {
) (*snapshotStats, error) {
snapshotStartTime := time.Now()
if total > 1 {
@@ -269,12 +389,12 @@ func (v *Vaultik) createNamedSnapshot(
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
if err != nil {
return 0, err
return nil, err
}
previousSnapshotID, err := v.previousSnapshotID(snapName)
if err != nil {
return 0, err
return nil, err
}
scanner := v.ScannerFactory(snapshot.ScannerParams{
@@ -289,7 +409,7 @@ func (v *Vaultik) createNamedSnapshot(
snapshotID, err := v.SnapshotManager.CreateSnapshotWithName(
v.ctx, hostname, snapName, v.Globals.Version, v.Globals.Commit)
if err != nil {
return 0, fmt.Errorf("creating snapshot: %w", err)
return nil, fmt.Errorf("creating snapshot: %w", err)
}
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
@@ -297,12 +417,12 @@ func (v *Vaultik) createNamedSnapshot(
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
if err != nil {
return 0, err
return nil, err
}
err = v.finalizeSnapshotMetadata(snapshotID, stats)
if err != nil {
return 0, err
return nil, err
}
log.Info("Snapshot complete",
@@ -315,7 +435,55 @@ func (v *Vaultik) createNamedSnapshot(
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
return stats.totalFilesUnreadable, nil
if opts.Prune {
stats.unmountedPaths, err = v.findUnmountedPaths(snapName, snapshotID)
if err != nil {
return nil, err
}
}
return stats, nil
}
// findUnmountedPaths returns each directory that an earlier snapshot
// named snapName in the local index recorded as a mount point and
// snapshot snapshotID did not, as when the volume mounted there is not
// mounted.
func (v *Vaultik) findUnmountedPaths(
snapName, snapshotID string,
) ([]string, error) {
found, err := v.Repositories.Snapshots.GetMountPoints(v.ctx, snapshotID)
if err != nil {
return nil, err
}
snapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
if err != nil {
return nil, fmt.Errorf("listing snapshots: %w", err)
}
var unmounted []string
for _, s := range snapshots {
if s.ID.String() == snapshotID ||
parseSnapshotName(s.ID.String(), s.Hostname.String()) != snapName {
continue
}
recorded, err := v.Repositories.Snapshots.GetMountPoints(
v.ctx, s.ID.String())
if err != nil {
return nil, err
}
for _, dir := range recorded {
if !slices.Contains(found, dir) && !slices.Contains(unmounted, dir) {
unmounted = append(unmounted, dir)
}
}
}
return unmounted, nil
}
// previousSnapshotID returns the ID of the most recent completed snapshot
@@ -337,7 +505,8 @@ func (v *Vaultik) previousSnapshotID(snapName string) (string, error) {
}
// resolveSnapshotPaths resolves source directories to absolute paths
// with symlink resolution.
// with symlink resolution. A path that is a symlink whose target does
// not exist is an error.
func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
snapConfig := v.Config.Snapshots[snapName]
resolvedDirs := make([]string, 0, len(snapConfig.Paths))
@@ -350,12 +519,18 @@ func (v *Vaultik) resolveSnapshotPaths(snapName string) ([]string, error) {
resolvedPath, err := filepath.EvalSymlinks(absPath)
if err != nil {
if os.IsNotExist(err) {
resolvedPath = absPath
} else {
// A path that does not exist is left for the scan to report.
// One that exists is a symlink whose target is gone, as one
// into a volume that is not mounted: the scan would record
// only the link, and --prune would then drop the older
// snapshots that hold the files.
_, lstatErr := os.Lstat(absPath)
if !os.IsNotExist(err) || lstatErr == nil {
return nil, fmt.Errorf(
"failed to resolve symlinks for %s: %w", absPath, err)
}
resolvedPath = absPath
}
resolvedDirs = append(resolvedDirs, resolvedPath)
@@ -409,6 +584,10 @@ func (v *Vaultik) scanAllDirectories(
stats.totalBytesUploaded += result.BytesUploaded
stats.uploadDuration += result.UploadDuration
if result.EntriesFound == 0 {
stats.emptyPaths = append(stats.emptyPaths, dir)
}
log.Info("Directory scan complete",
"path", dir,
"files", result.FilesScanned,
@@ -1276,7 +1455,7 @@ const pruneCommandHint = "vaultik prune"
// snapshotRemoveCommandHint is the command suggested, with the
// snapshot's ID, when a remove could not reach the destination store:
// running it again removes the snapshot's metadata there, which
// `vaultik prune` never does.
// `vaultik prune` never does while the manifest is there.
const snapshotRemoveCommandHint = "vaultik snapshot remove"
// RemoveSnapshot removes a snapshot from the local index database and,
@@ -1310,10 +1489,18 @@ func (v *Vaultik) RemoveSnapshot(
return v.removeSnapshotDryRun(result, snapshotID, opts)
}
if !opts.Force && !opts.JSON && !v.confirmRemoveSnapshot(snapshotID, opts) {
if !v.confirmRemoveSnapshot(snapshotID, opts) {
return result, nil
}
// Checked before the local record is deleted: it may be the last
// snapshot in the local index, and the check applies only while the
// local index holds one.
var markerErr error
if !opts.LocalOnly {
markerErr = v.checkDestinationMarker()
}
if inLocalIndex {
log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
@@ -1324,7 +1511,7 @@ func (v *Vaultik) RemoveSnapshot(
}
result.RemoteRemoved, err = v.removeSnapshotRemote(
snapshotID, inLocalIndex, opts)
snapshotID, inLocalIndex, markerErr, opts)
if err != nil {
return result, err
}
@@ -1361,7 +1548,9 @@ func (v *Vaultik) RemoveSnapshot(
// remove: no row and, unless LocalOnly is set, no metadata on the
// destination store either. The destination store is consulted only
// when the local index has no row, so a snapshot in the local index can
// still be removed from it while the destination store is unreachable.
// still be removed from it while the destination store is unreachable,
// and only after checkDestinationMarker passes, so an unplugged volume's
// empty mount point is not taken for a store without the snapshot.
func (v *Vaultik) findSnapshotToRemove(
snapshotID string, opts *RemoveOptions,
) (bool, error) {
@@ -1381,6 +1570,11 @@ func (v *Vaultik) findSnapshotToRemove(
errSnapshotNotFound, snapshotID)
}
err := v.checkDestinationMarker()
if err != nil {
return false, err
}
objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
if err != nil {
return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
@@ -1419,11 +1613,15 @@ func (v *Vaultik) removeSnapshotDryRun(
}
// confirmRemoveSnapshot prompts for confirmation before removing a
// snapshot. Returns false (treating EOF/read errors as "no") unless the
// user answers "y". The prompt and its answer bypass the UI: they are an
// interactive exchange the operator must see, so --quiet does not
// silence them.
// snapshot, unless --force or --json is set. Returns false (treating
// EOF/read errors as "no") unless the user answers "y". The prompt and
// its answer bypass the UI: they are an interactive exchange the
// operator must see, so --quiet does not silence them.
func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions) bool {
if opts.Force || opts.JSON {
return true
}
if opts.LocalOnly {
v.stdoutf("Remove snapshot '%s' from local database "+
"(remote untouched)? [y/N] ", snapshotID)
@@ -1453,15 +1651,18 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
// removeSnapshotRemote strips the snapshot's metadata from the
// destination store. It returns true only when the delete finished
// without an error and removed at least one object; under LocalOnly it
// does nothing. When the snapshot is only on the destination store, a
// failure is returned as an error, because that removal is all the
// command does. When the snapshot was in the local index, its removal
// there has already happened, so a failure is only a warning telling the
// user to run `vaultik snapshot remove` for the snapshot again once the
// destination store is reachable (`vaultik prune` never removes snapshot
// metadata).
// does nothing. markerErr is the result of checkDestinationMarker; when
// it is set, nothing is deleted and it is the failure. When the snapshot
// is only on the destination store, a failure is returned as an error,
// because that removal is all the command does. When the snapshot was in
// the local index, its removal there has already happened, so a failure
// is only a warning telling the user to run `vaultik snapshot remove`
// for the snapshot again once the destination store is reachable
// (`vaultik prune` never removes snapshot metadata that has its
// manifest).
func (v *Vaultik) removeSnapshotRemote(
snapshotID string, inLocalIndex bool, opts *RemoveOptions,
snapshotID string, inLocalIndex bool, markerErr error,
opts *RemoveOptions,
) (bool, error) {
if opts.LocalOnly {
return false, nil
@@ -1470,9 +1671,14 @@ func (v *Vaultik) removeSnapshotRemote(
log.Info("Removing snapshot metadata from remote storage",
"snapshot_id", snapshotID)
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
var deleted int
err := markerErr
if err == nil {
deleted, err = v.deleteRemoteSnapshotByKey(
snapshot.RemoteSnapshotKey(snapshotID))
}
deleted, err := v.deleteRemoteSnapshotByKey(remoteKey)
if err != nil && !inLocalIndex {
return false, fmt.Errorf(
"removing snapshot metadata from destination store: %w", err)
@@ -1502,7 +1708,9 @@ func (v *Vaultik) removeSnapshotRemote(
// database from the local index, and (with --remote) every snapshot
// metadata directory in remote storage. Both sides are processed so a
// "remove --all" leaves nothing behind, even when the local DB and
// remote storage have diverged.
// remote storage have diverged. Unless LocalOnly is set, it fails
// before it removes anything when the destination store cannot be
// listed or is not present by checkDestinationMarker.
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
err := v.EnsureStorageBinding()
if err != nil {
@@ -1515,11 +1723,15 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
}
// remoteKeys is the set of metadata/<key>/ subdirectories on the
// destination store; failures are downgraded to a warning so a
// permission-denied or unreachable remote can't block a local-only
// remove.
// destination store. Only for a local-only remove is a failure
// downgraded to a warning, so a permission-denied or unreachable
// remote can't block it.
remoteKeys, remoteErr := v.listAllRemoteSnapshotKeys()
if remoteErr != nil {
if !opts.LocalOnly {
return nil, remoteErr
}
log.Warn("Could not list remote snapshots", "error", remoteErr)
v.UI.Warningf("Could not list remote snapshots: %v.", remoteErr)
}
@@ -1581,8 +1793,7 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
// listAllRemoteSnapshotKeys collects the hashed remote keys
// (subdirectories under metadata/) currently present in the
// destination store. Returns (nil, err) when the store cannot be
// listed; callers must treat that as "no remote info available," not
// fatal.
// listed, or is not present by checkDestinationMarker.
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
log.Info("Listing all remote snapshots")
@@ -1631,6 +1842,11 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
}
}
err := v.checkDestinationMarker()
if err != nil {
return nil, err
}
return keys, nil
}
@@ -1761,10 +1977,12 @@ func (v *Vaultik) deleteIncompleteSnapshot(snapshotIDStr string, id any) bool {
return true
}
// removeAllLocalSnapshots removes each known local snapshot (and, unless
// --local-only, its remote metadata), returning the number of snapshots
// it could not remove. A snapshot left in the local index keeps its
// remote metadata too, since it is not an orphan remote key.
// removeAllLocalSnapshots removes each known local snapshot's remote
// metadata, unless --local-only, and then its local record, returning
// the number of snapshots it could not remove. The local record goes
// only once the metadata is deleted: dropped first, it would leave that
// metadata on the destination store as a remote-only snapshot whose name
// is lost.
func (v *Vaultik) removeAllLocalSnapshots(
localSnaps []string, opts *RemoveOptions, result *RemoveResult,
) int {
@@ -1773,6 +1991,19 @@ func (v *Vaultik) removeAllLocalSnapshots(
for _, snapshotID := range localSnaps {
log.Info("Removing snapshot", "snapshot_id", snapshotID)
if !opts.LocalOnly {
_, err := v.deleteRemoteSnapshotByKey(
snapshot.RemoteSnapshotKey(snapshotID))
if err != nil {
log.Warn("Failed to remove snapshot metadata from remote",
"snapshot_id", snapshotID, "error", err)
failures++
continue
}
}
err := v.deleteSnapshotFromLocalDB(snapshotID)
if err != nil {
log.Error("Failed to remove from local database",
@@ -1783,17 +2014,6 @@ func (v *Vaultik) removeAllLocalSnapshots(
continue
}
if !opts.LocalOnly {
_, err = v.deleteRemoteSnapshotByKey(
snapshot.RemoteSnapshotKey(snapshotID))
if err != nil {
log.Warn("Failed to remove snapshot metadata from remote",
"snapshot_id", snapshotID, "error", err)
failures++
}
}
result.SnapshotsRemoved = append(result.SnapshotsRemoved, snapshotID)
}
@@ -53,7 +53,7 @@ func newDeletedCountEnv(t *testing.T, files map[string][]byte) *deletedCountEnv
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
cfg.Snapshots = map[string]config.SnapshotConfig{}
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
store, err := storage.NewFileStorer(t.TempDir())
require.NoError(t, err)
db, err := database.New(ctx, cfg.IndexPath)
@@ -93,7 +93,7 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
"src": {Paths: []string{srcDir}},
}
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
store, err := storage.NewFileStorer(t.TempDir())
require.NoError(t, err)
db, err := database.New(ctx, cfg.IndexPath)
+3
View File
@@ -122,6 +122,9 @@ func newListEnv(t *testing.T) *listEnv {
store := newObservingStorer()
stdout := &bytes.Buffer{}
// A backup wrote to this destination, so it carries the marker.
require.NoError(t, store.Put(ctx, markerFile, strings.NewReader("")))
v := &vaultik.Vaultik{
Config: &config.Config{
AgeSecretKey: "",
+386
View File
@@ -1,8 +1,18 @@
package vaultik_test
import (
"context"
"os"
"path/filepath"
"syscall"
"testing"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/log"
"sneak.berlin/go/vaultik/internal/snapshot"
"sneak.berlin/go/vaultik/internal/vaultik"
)
@@ -25,3 +35,379 @@ func TestSnapshotCreateOptions_PruneFlag(t *testing.T) {
t.Error("Expected Prune to be false")
}
}
// A backup under --prune that finds nothing in a configured path, as in
// the mount point of a volume that is not mounted, does not prune: run
// after run, the snapshot that holds the files is kept with everything
// it stored, and the run fails. That holds whether the empty path is the
// snapshot's only path or one of two. See
// https://git.eeqj.de/sneak/vaultik/issues/388.
func TestBackupWithEmptySourcePathDoesNotPrune(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
// emptied is the path, relative to the source directory, that is
// emptied after the first backup.
const (
snapshotName = "media"
emptied = "music"
)
tests := []struct {
name string
paths []string // relative to the source directory
}{
{name: "only path", paths: []string{emptied}},
{name: "one of two paths", paths: []string{emptied, "docs"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
env := newDeletedCountEnv(t, map[string][]byte{
emptied + "/a.mp3": []byte("song a"),
emptied + "/b.mp3": []byte("song b"),
"docs/notes.txt": []byte("notes"),
})
paths := make([]string, 0, len(tt.paths))
for _, rel := range tt.paths {
paths = append(paths, filepath.Join(env.src, rel))
}
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
Paths: paths,
}
env.backUp(t, snapshotName)
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
stored, err := env.v.Storage.List(ctx, "")
require.NoError(t, err)
// A volume that is not mounted leaves its mount point behind
// as an empty directory.
emptiedDir := filepath.Join(env.src, emptied)
require.NoError(t, os.RemoveAll(emptiedDir))
require.NoError(t, os.Mkdir(emptiedDir, 0o750))
for range 2 {
env.out.Reset()
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
Snapshots: []string{snapshotName},
})
require.ErrorIs(t, err, vaultik.ErrSourcePathEmpty)
assert.Contains(t, env.out.String(),
"Skipped the post-backup prune because nothing was found in ")
assert.Contains(t, env.out.String(),
emptied+" of snapshot "+snapshotName+";")
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
}
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, snapshots, 3)
assert.Equal(t, firstID, snapshots[2].ID.String(),
"the snapshot that holds the files is kept")
for _, key := range stored {
_, err := env.v.Storage.Stat(ctx, key)
require.NoError(t, err, "%s is kept", key)
}
})
}
}
// A backup under --prune of a configured path that is a symlink whose
// target is gone, as one into a volume that is not mounted, fails before
// anything is pruned: the snapshot that holds the files is kept with
// everything it stored. See https://git.eeqj.de/sneak/vaultik/issues/388.
func TestBackupThroughSymlinkWhoseTargetIsGoneDoesNotPrune(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
const snapshotName = "photos"
ctx := context.Background()
env := newDeletedCountEnv(t, map[string][]byte{
"volume/a.jpg": []byte("photo a"),
"volume/b.jpg": []byte("photo b"),
})
target := filepath.Join(env.src, "volume")
link := filepath.Join(env.src, "photos")
require.NoError(t, os.Symlink(target, link))
env.setSnapshot(snapshotName, "photos")
env.backUp(t, snapshotName)
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
stored, err := env.v.Storage.List(ctx, "")
require.NoError(t, err)
// The volume is not mounted, so the symlink points at nothing.
require.NoError(t, os.RemoveAll(target))
env.out.Reset()
err = env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
Snapshots: []string{snapshotName},
})
require.ErrorIs(t, err, os.ErrNotExist)
assert.Contains(t, err.Error(), link)
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, snapshots, 1)
assert.Equal(t, firstID, snapshots[0].ID.String(),
"the snapshot that holds the files is kept")
for _, key := range stored {
_, err := env.v.Storage.Stat(ctx, key)
require.NoError(t, err, "%s is kept", key)
}
}
// A backup under --skip-errors --prune that both skipped a file it could
// not read and found nothing in a configured path names the empty path
// too when it skips the prune. See
// https://git.eeqj.de/sneak/vaultik/issues/388.
func TestBackupWithEmptySourcePathAndUnreadableFileNamesBoth(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
const snapshotName = "media"
env := newDeletedCountEnv(t, map[string][]byte{
"docs/notes.txt": []byte("notes"),
})
require.NoError(t, os.Mkdir(filepath.Join(env.src, "music"), 0o750))
env.v.Config.Snapshots[snapshotName] = config.SnapshotConfig{
Paths: []string{
filepath.Join(env.src, "docs"),
filepath.Join(env.src, "music"),
},
}
// A mode does not lock out root, who runs the tests, so opening the
// file is made to fail instead.
env.v.Fs = &openFailFs{
path: filepath.Join(env.src, "docs", "notes.txt"),
err: os.ErrPermission,
}
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
SkipErrors: true,
Snapshots: []string{snapshotName},
})
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
assert.Contains(t, env.out.String(),
"Skipped the post-backup prune because 1 file(s) could not be read")
assert.Contains(t, env.out.String(),
"Skipped the post-backup prune because nothing was found in ")
assert.Contains(t, env.out.String(), "music of snapshot "+snapshotName+";")
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
}
// A backup under --prune that finds something in each configured path
// prunes the previous snapshot, also when a path is a single file. See
// https://git.eeqj.de/sneak/vaultik/issues/388.
func TestBackupWithNoEmptySourcePathPrunes(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
const (
snapshotName = "album"
file = "pictures/one.jpg" // relative to the source directory
)
tests := []struct {
name string
path string // relative to the source directory
}{
{name: "directory", path: filepath.Dir(file)},
{name: "single file", path: file},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := context.Background()
env := newDeletedCountEnv(t, map[string][]byte{
file: []byte("picture"),
})
env.setSnapshot(snapshotName, tt.path)
env.backUp(t, snapshotName)
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
Snapshots: []string{snapshotName},
}))
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, snapshots, 1, "the previous snapshot is pruned")
assert.NotEqual(t, firstID, snapshots[0].ID.String())
})
}
}
// automountFs is the real filesystem with a volume at volumeDir that is
// mounted on first access, as by an autofs map that shows its mount
// points before mounting them: listing volumeDir mounts the volume or,
// while cannotMount, fails with "no such file or directory".
type automountFs struct {
afero.OsFs
volumeDir string
cannotMount bool
mounted bool
}
//nolint:ireturn // afero.Fs.Open is defined to return the interface.
func (f *automountFs) Open(name string) (afero.File, error) {
if name == f.volumeDir && f.cannotMount {
return nil, &os.PathError{Op: "open", Path: name, Err: syscall.ENOENT}
}
if name == f.volumeDir {
f.mounted = true
}
return f.OsFs.Open(name)
}
// A backup under --prune in which a volume that an earlier snapshot found
// mounted below the configured path is missing does not prune: run after
// run, the snapshot that holds the volume's files is kept with everything
// it stored, and the run fails. Once the volume is back, the prune runs.
// The volume is mounted on first access and every backup starts with it
// not mounted, so its directory is on its parent's device until the walk
// lists it. While the volume is missing, its directory is left empty, or
// listing it fails with "no such file or directory", as when an autofs
// volume cannot be mounted, which the walk counts as a directory removed
// during the backup. See https://git.eeqj.de/sneak/vaultik/issues/395.
func TestBackupWithVolumeNotMountedDoesNotPrune(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
tests := []struct {
name string
listingFails bool
}{
{name: "directory left empty", listingFails: false},
{name: "listing fails", listingFails: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
backUpWithVolumeNotMounted(t, tt.listingFails)
})
}
}
// backUpWithVolumeNotMounted runs the backups of
// TestBackupWithVolumeNotMountedDoesNotPrune. While the volume is
// missing, listing its directory fails if listingFails; otherwise the
// directory is empty.
func backUpWithVolumeNotMounted(t *testing.T, listingFails bool) {
t.Helper()
// volume is the directory, below the configured path, where the
// volume is mounted.
const (
snapshotName = "data"
volume = "nas"
)
ctx := context.Background()
env := newDeletedCountEnv(t, map[string][]byte{
volume + "/a.txt": []byte("file a"),
"notes.txt": []byte("notes"),
})
env.setSnapshot(snapshotName, "")
prune := &vaultik.SnapshotCreateOptions{
Prune: true, Snapshots: []string{snapshotName},
}
// The volume's directory is on the volume's device, 2, while the
// volume is mounted. Everything else is on device 1.
volumeDir := filepath.Join(env.src, volume)
fs := &automountFs{volumeDir: volumeDir}
newScanner := env.v.ScannerFactory
env.v.ScannerFactory = func(params snapshot.ScannerParams) *snapshot.Scanner {
fs.mounted = false
params.Fs = fs
params.DeviceNumber = func(info os.FileInfo) (uint64, bool) {
if info.Name() == volume && fs.mounted {
return 2, true
}
return 1, true
}
return newScanner(params)
}
env.backUp(t, snapshotName)
firstID := localSnapshotID(ctx, t, env.v.Repositories, snapshotName)
stored, err := env.v.Storage.List(ctx, "")
require.NoError(t, err)
// The volume goes missing.
fs.cannotMount = listingFails
if !listingFails {
require.NoError(t, os.RemoveAll(volumeDir))
require.NoError(t, os.Mkdir(volumeDir, 0o750))
}
for range 2 {
env.out.Reset()
err := env.v.CreateSnapshot(prune)
require.ErrorIs(t, err, vaultik.ErrVolumeNotMounted)
assert.Contains(t, env.out.String(),
"did not find a mount point at "+volumeDir+" of snapshot "+snapshotName)
assert.Contains(t, env.out.String(), "'vaultik snapshot purge'")
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
}
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, snapshots, 3)
assert.Equal(t, firstID, snapshots[2].ID.String(),
"the snapshot that holds the volume's files is kept")
for _, key := range stored {
_, err := env.v.Storage.Stat(ctx, key)
require.NoError(t, err, "%s is kept", key)
}
// The volume is back.
fs.cannotMount = false
require.NoError(t, os.WriteFile(
filepath.Join(volumeDir, "a.txt"), []byte("file a"), 0o600))
require.NoError(t, env.v.CreateSnapshot(prune))
snapshots, err = env.v.Repositories.Snapshots.ListRecent(
ctx, listRecentTestLimit)
require.NoError(t, err)
require.Len(t, snapshots, 1, "the older snapshots are pruned")
}
+156 -1
View File
@@ -46,7 +46,7 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
"tree": {Paths: []string{srcDir}},
}
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
store, err := storage.NewFileStorer(t.TempDir())
require.NoError(t, err)
db, err := database.New(ctx, cfg.IndexPath)
@@ -126,3 +126,158 @@ func TestSkipErrorsBackupKeepsSnapshotAndFailsWhenItSkips(t *testing.T) {
})
}
}
// removeOnLookupFs is the real filesystem, except that path is removed
// just before it is looked up. The walk looks an entry up after listing
// its directory, so path is listed but gone when the walk reaches it.
type removeOnLookupFs struct {
afero.OsFs
path string
}
func (f *removeOnLookupFs) LstatIfPossible(
name string,
) (os.FileInfo, bool, error) {
if name == f.path {
_ = os.Remove(name)
}
return f.OsFs.LstatIfPossible(name)
}
// A file removed before the walk reaches it is counted once, as failed,
// like a file removed before its data is read, and the backup finishes
// successfully, with or without --skip-errors. See
// https://git.eeqj.de/sneak/vaultik/issues/310.
func TestBackupCountsFileRemovedBeforeWalkReachesItAsFailed(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
tests := []struct {
name string
skipErrors bool
}{
{name: "under skip-errors", skipErrors: true},
{name: "without skip-errors", skipErrors: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"stays.txt": []byte("stays"),
"removed.txt": []byte("removed"),
})
env.setSnapshot("walk", "")
// The previous snapshot has the file, so a removal the walk
// also counted as deleted would show.
env.backUp(t, "walk")
env.v.Fs = &removeOnLookupFs{
path: filepath.Join(env.src, "removed.txt"),
}
env.out.Reset()
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
SkipErrors: tt.skipErrors,
Snapshots: []string{"walk"},
}))
assert.Contains(t, env.out.String(),
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
assert.Contains(t, env.out.String(), "Finished successfully.")
})
}
}
// A file an exclude pattern matches is not counted when it is removed
// before the walk reaches it. See
// https://git.eeqj.de/sneak/vaultik/issues/310.
func TestBackupIgnoresExcludedFileRemovedBeforeWalkReachesIt(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"stays.txt": []byte("stays"),
"removed.tmp": []byte("removed"),
})
env.setSnapshot("walk", "", "*.tmp")
env.v.Fs = &removeOnLookupFs{
path: filepath.Join(env.src, "removed.tmp"),
}
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
SkipErrors: true,
Snapshots: []string{"walk"},
}))
assert.Contains(t, env.out.String(), filesLine(1, 1, 0))
assert.Contains(t, env.out.String(), "Finished successfully.")
}
// A backup under --skip-errors --prune that skipped a file it could not
// read does not prune: the previous snapshot, the only one that holds
// the file, is kept, and the output says the prune was skipped. See
// https://git.eeqj.de/sneak/vaultik/issues/382.
func TestSkipErrorsBackupDoesNotPruneWhenItSkips(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"other.txt": []byte("other"),
"skipped.txt": []byte("skipped"),
})
env.setSnapshot("photos", "")
env.backUp(t, "photos")
// An unchanged file is not read again, so the file is changed before
// it becomes unreadable.
skipped := filepath.Join(env.src, "skipped.txt")
require.NoError(t, os.WriteFile(skipped, []byte("changed"), 0o600))
env.v.Fs = &openFailFs{path: skipped, err: os.ErrPermission}
env.out.Reset()
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
SkipErrors: true,
Snapshots: []string{"photos"},
})
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
assert.Contains(t, env.out.String(),
"Skipped the post-backup prune because 1 file(s) could not be read")
assert.NotContains(t, env.out.String(), "Running post-backup prune.")
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
context.Background(), listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 2, "the previous snapshot is kept")
}
// A file removed during a backup under --skip-errors --prune is not a
// skipped file, so the backup prunes as usual. See
// https://git.eeqj.de/sneak/vaultik/issues/382.
func TestSkipErrorsBackupPrunesWhenAFileIsRemovedDuringIt(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newDeletedCountEnv(t, map[string][]byte{
"other.txt": []byte("other"),
"removed.txt": []byte("removed"),
})
env.setSnapshot("photos", "")
env.backUp(t, "photos")
env.v.Fs = &removeOnLookupFs{path: filepath.Join(env.src, "removed.txt")}
require.NoError(t, env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
Prune: true,
SkipErrors: true,
Snapshots: []string{"photos"},
}))
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
context.Background(), listRecentTestLimit)
require.NoError(t, err)
assert.Len(t, snapshots, 1, "the previous snapshot is pruned")
}
+117 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/unix"
"sneak.berlin/go/vaultik/internal/config"
"sneak.berlin/go/vaultik/internal/database"
"sneak.berlin/go/vaultik/internal/log"
@@ -84,7 +85,7 @@ func newSummaryEnv(t *testing.T) *summaryEnv {
"split": {Paths: []string{dirOne, dirTwo}},
}
inner, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
inner, err := storage.NewFileStorer(t.TempDir())
require.NoError(t, err)
store := faultstore.New(inner)
@@ -188,6 +189,42 @@ func (e *summaryEnv) dataLine(total, backedUp int64) string {
e.v.UI.Size(total), e.v.UI.Size(backedUp))
}
// enumeratedLine returns the line printed when the walk of a source path
// ends.
func (e *summaryEnv) enumeratedLine(
files int, total int64, toBackUp int, toBackUpSize int64,
) string {
return fmt.Sprintf(
"Enumerated %d snapshot source files (%s total), %d to back up (%s).",
files, e.v.UI.Size(total), toBackUp, e.v.UI.Size(toBackUpSize))
}
// snapshotPaths returns the paths the snapshot lists, sorted.
func (e *summaryEnv) snapshotPaths(t *testing.T, snapshotID string) []string {
t.Helper()
rows, err := e.db.Conn().QueryContext(context.Background(), `
SELECT f.path FROM snapshot_files sf JOIN files f ON f.id = sf.file_id
WHERE sf.snapshot_id = ? ORDER BY f.path`, snapshotID)
require.NoError(t, err)
defer func() { _ = rows.Close() }()
var paths []string
for rows.Next() {
var path string
require.NoError(t, rows.Scan(&path))
paths = append(paths, path)
}
require.NoError(t, rows.Err())
return paths
}
// A first backup stores copy.bin's chunks while backing up a.bin, so
// copy.bin's chunks are deduplicated within the run. Each file and byte
// is still counted once.
@@ -248,6 +285,85 @@ func TestSnapshotSummaryIncrementalRunWithDeduplicatedChunks(t *testing.T) {
assert.Equal(t, uncompressed, snap.BlobUncompressedSize)
}
// A backup of a tree with directories and a symlink, none of which
// changed since the last backup, backs up nothing, and the snapshot
// still lists every one of them. Both sides of the line that ends the
// walk count the same entries, the files and the symlink, and no
// directory's size. See https://git.eeqj.de/sneak/vaultik/issues/305.
func TestSnapshotSummaryUnchangedRunWithDirectoriesAndSymlink(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newSummaryEnv(t)
const linkTarget = "a.bin"
require.NoError(t, os.Symlink(linkTarget,
filepath.Join(filepath.Dir(env.aPath), "link")))
// A symlink's size is the length of its target.
total := env.totalSize + int64(len(linkTarget))
first := env.backUp(t, "first", false)
assert.Contains(t, first, env.enumeratedLine(4, total, 4, total))
assert.Contains(t, first, filesLine(4, 4, 0))
second := env.backUp(t, "second", false)
assert.Contains(t, second, env.enumeratedLine(4, total, 0, 0))
assert.Contains(t, second, filesLine(4, 0, 4))
assert.Contains(t, second, env.dataLine(total, 0))
assert.Equal(t,
env.snapshotPaths(t, env.snapshot(t, "first").ID.String()),
env.snapshotPaths(t, env.snapshot(t, "second").ID.String()))
}
// A directory whose mtime changed and a symlink whose target changed are
// recorded again by the next backup, which counts only the symlink as
// backed up.
func TestSnapshotSummaryChangedDirectoryAndSymlink(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newSummaryEnv(t)
// The local index keeps paths with symlinks resolved.
dir, err := filepath.EvalSymlinks(filepath.Dir(env.aPath))
require.NoError(t, err)
// The new link gets the old one's mtime, and a target, which need not
// exist, of the old target's length, a symlink's size: only the
// target differs.
linkTime := unix.NsecToTimeval(
time.Date(2029, time.March, 4, 5, 6, 7, 0, time.UTC).UnixNano())
link := filepath.Join(dir, "link")
require.NoError(t, os.Symlink("a.bin", link))
require.NoError(t, unix.Lutimes(link, []unix.Timeval{linkTime, linkTime}))
env.backUp(t, "first", false)
require.NoError(t, os.Remove(link))
require.NoError(t, os.Symlink("b.bin", link))
require.NoError(t, unix.Lutimes(link, []unix.Timeval{linkTime, linkTime}))
mtime := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC)
require.NoError(t, os.Chtimes(dir, mtime, mtime))
assert.Contains(t, env.backUp(t, "second", false), filesLine(4, 1, 3))
ctx := context.Background()
dirRow, err := env.repos.Files.GetByPath(ctx, dir)
require.NoError(t, err)
require.NotNil(t, dirRow)
assert.Equal(t, mtime.Unix(), dirRow.MTime.Unix())
linkRow, err := env.repos.Files.GetByPath(ctx, link)
require.NoError(t, err)
require.NotNil(t, linkRow)
assert.Equal(t, "b.bin", linkRow.LinkTarget.String())
}
// Under --cron the progress reporter is off; the upload figures must
// still reach the summary and the snapshots row. The snapshot has two
// paths, each backed up by its own scan.
+1 -1
View File
@@ -28,7 +28,7 @@ func TestBackupWithoutCronOfTwoPathSnapshotRestoresBothPaths(t *testing.T) {
tempDir := t.TempDir()
firstDir := filepath.Join(tempDir, "first")
secondDir := filepath.Join(tempDir, "second")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
restoreDir := filepath.Join(tempDir, "restored")
dbPath := filepath.Join(tempDir, "index.sqlite")
+1 -1
View File
@@ -36,7 +36,7 @@ func TestUncompressedSizeOfChangedFileStaysFixed(t *testing.T) {
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
store, err := storage.NewFileStorer(t.TempDir())
require.NoError(t, err)
db, err := database.New(ctx, dbPath)
+1 -1
View File
@@ -28,7 +28,7 @@ func TestVerify_QuietSuppressesReport(t *testing.T) {
tempDir := t.TempDir()
dataDir := filepath.Join(tempDir, "source")
storeDir := filepath.Join(tempDir, "remote")
storeDir := t.TempDir()
dbPath := filepath.Join(tempDir, "index.sqlite")
chunkSize := int64(32 * 1024)