Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9f1e5e78d |
+5
-19
@@ -279,8 +279,8 @@ Key methods:
|
||||
- `Scan(ctx, path, snapshotID)` → Main scan operation
|
||||
|
||||
Scan phases:
|
||||
1. **Phase 1**: Walk directory, identify files needing processing
|
||||
2. **Phase 1b**: Count as deleted the files that the previous snapshot of the same name lists, the walk did not find, and are gone from disk; a file that is excluded, or inside an excluded directory, is not counted
|
||||
1. **Phase 0**: Detect deleted files from previous snapshots
|
||||
2. **Phase 1**: Walk directory, identify files needing processing
|
||||
3. **Phase 2**: Process files (chunk → pack → upload)
|
||||
|
||||
#### SnapshotManager
|
||||
@@ -311,8 +311,6 @@ Repository interfaces:
|
||||
|
||||
```
|
||||
CreateSnapshot(opts)
|
||||
│
|
||||
├─► markDestination() // Refuse a destination without the marker, then write it
|
||||
│
|
||||
├─► PruneDatabase() // Critical: avoid dedup errors
|
||||
│
|
||||
@@ -322,13 +320,13 @@ CreateSnapshot(opts)
|
||||
│ │
|
||||
│ ├─► scanner.Scan(ctx, path, snapshotID)
|
||||
│ │ │
|
||||
│ │ ├─► Phase 0: detectDeletedFiles()
|
||||
│ │ │
|
||||
│ │ ├─► Phase 1: scanPhase()
|
||||
│ │ │ Walk directory
|
||||
│ │ │ Check file metadata changes
|
||||
│ │ │ Build list of files to process
|
||||
│ │ │
|
||||
│ │ ├─► Phase 1b: detectDeletedFiles()
|
||||
│ │ │
|
||||
│ │ └─► Phase 2: processPhase()
|
||||
│ │ For each file:
|
||||
│ │ chunker.ChunkReaderStreaming()
|
||||
@@ -359,7 +357,7 @@ CreateSnapshot(opts)
|
||||
|
||||
## Deduplication Strategy
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
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.
|
||||
|
||||
@@ -369,8 +367,6 @@ CreateSnapshot(opts)
|
||||
|
||||
```
|
||||
bucket/
|
||||
├── vaultik-destination.txt # Marker written by every backup
|
||||
│
|
||||
├── blobs/
|
||||
│ └── {hash[0:2]}/
|
||||
│ └── {hash[2:4]}/
|
||||
@@ -382,16 +378,6 @@ bucket/
|
||||
└── manifest.json.zst # Blob list (for pruning/verification)
|
||||
```
|
||||
|
||||
A destination without `vaultik-destination.txt` 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. 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
|
||||
|
||||
@@ -24,10 +24,6 @@ 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
|
||||
@@ -96,14 +92,6 @@ 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, 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
|
||||
@@ -190,7 +178,7 @@ vaultik version
|
||||
* `--verbose`, `-v`: Enable verbose output (on stderr — see below)
|
||||
* `--debug`: Enable debug output (on stderr — see below)
|
||||
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
|
||||
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors while creating a snapshot (which would leave a chunk recorded but not stored) still abort the run. A run that skipped anything still finishes but exits non-zero: `snapshot create` keeps the snapshot and counts each file or directory it skipped as failed. A file removed during the backup is also counted as failed but does not change the exit status.
|
||||
* `--skip-errors`: Skip files that cannot be read when creating a snapshot, or that cannot be restored when restoring, instead of aborting. Packing and storage errors while creating a snapshot (which would leave a chunk recorded but not stored) still abort the run.
|
||||
|
||||
### locking
|
||||
|
||||
@@ -200,22 +188,7 @@ 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.
|
||||
|
||||
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 before the manifest that lists them, so a
|
||||
prune from elsewhere at that moment finds blobs no snapshot lists. It
|
||||
deletes such a blob only once it is older than `prune_grace_period`
|
||||
(default `24h`), which therefore 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.
|
||||
corrupt the local index or the destination store.
|
||||
|
||||
Read-only commands — `info`, `snapshot list`, `snapshot verify`, and
|
||||
`remote info` — do not take the lock and are never blocked, so they run
|
||||
@@ -319,21 +292,12 @@ value where a list belongs or a word where a number belongs), is refused.
|
||||
* `--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 older than `prune_grace_period` from remote storage,
|
||||
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. A file removed during the backup does not stop the prune.
|
||||
remove orphaned blobs from remote storage. By default keeps only the latest
|
||||
snapshot per name; use `--keep-newer-than` for a rolling window.
|
||||
* `--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 has no `vaultik-destination.txt` (see
|
||||
[remote storage layout](#remote-storage-layout))
|
||||
|
||||
**`snapshot list`**: Show every snapshot known to this host — the union
|
||||
of the local index and the backup destination store — with timestamps
|
||||
@@ -359,27 +323,18 @@ read from the manifest.
|
||||
|
||||
Snapshots in the local index with no counterpart on the destination
|
||||
store are reported below the table as drift, with the `vaultik prune`
|
||||
invocation that reconciles them. If the local index is bound to a
|
||||
different destination than the configured one (after a change to
|
||||
`storage_url`, or `s3.endpoint`, `s3.bucket` or `s3.prefix`), the
|
||||
command says so instead and gives the same two ways out that `prune`
|
||||
gives: revert the configuration, or run `vaultik database delete`.
|
||||
invocation that reconciles them.
|
||||
|
||||
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. It does the same when the
|
||||
destination 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.
|
||||
local index alone, and still exits zero.
|
||||
* `--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
|
||||
on the destination store, or `null` if the destination could not be
|
||||
listed). Warnings about an unlistable destination, a local index
|
||||
bound to a different destination (with both destinations and the
|
||||
same two ways out), unreadable manifests, and a truncated listing all
|
||||
go to stderr through the logger, so stdout stays a single parseable
|
||||
document.
|
||||
listed). Warnings about an unlistable destination, unreadable
|
||||
manifests, and a truncated listing all go to stderr through the
|
||||
logger, so stdout stays a single parseable document.
|
||||
|
||||
**`snapshot verify`**: Verify snapshot integrity.
|
||||
* Default (shallow): checks that every blob the manifest lists is present in
|
||||
@@ -412,14 +367,13 @@ 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,
|
||||
or the local index holds snapshots and the destination 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`.
|
||||
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`.
|
||||
* `--local-only`: Skip remote cleanup; only touch the local index
|
||||
* `--dry-run`: Show what would be deleted without deleting
|
||||
* `--force`: Skip confirmation prompt
|
||||
@@ -447,28 +401,15 @@ 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 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) 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 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 cannot be deleted, the command (and `snapshot create --prune`)
|
||||
exits non-zero.
|
||||
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.
|
||||
* `--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`.
|
||||
|
||||
**`info`**: Display system configuration, storage settings, encryption
|
||||
recipients, and local database statistics.
|
||||
@@ -488,17 +429,11 @@ 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, 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 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.
|
||||
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.
|
||||
* `--force`: Required to confirm destruction.
|
||||
|
||||
**`database delete`**: Delete the local SQLite state database file
|
||||
@@ -523,12 +458,7 @@ 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 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.
|
||||
the same directory.
|
||||
|
||||
**Rclone** (`rclone://remote/path`): Uses rclone's 70+ supported cloud
|
||||
providers. Requires rclone to be configured separately (`rclone config`).
|
||||
@@ -543,14 +473,6 @@ 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, 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.
|
||||
@@ -567,7 +489,6 @@ 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/
|
||||
@@ -576,16 +497,6 @@ 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. A destination without it is treated as not
|
||||
present while the local index holds snapshots stored there:
|
||||
`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.
|
||||
* 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
|
||||
@@ -637,8 +548,7 @@ 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 that is older than
|
||||
`prune_grace_period`
|
||||
4. Delete any blob not in the referenced set
|
||||
|
||||
### chunking and deduplication
|
||||
|
||||
@@ -683,7 +593,6 @@ 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. A backup uploads its blobs before the manifest that lists them, 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 at once |
|
||||
| `hostname` | system hostname | Hostname used in snapshot IDs |
|
||||
| `index_path` | platform data dir | Local SQLite index path |
|
||||
|
||||
@@ -807,10 +716,8 @@ the `NO_COLOR` environment variable is unset (https://no-color.org/).
|
||||
reports and tables (`version`, `info`, `remote info`, `snapshot
|
||||
verify`, `snapshot list`) but never the machine-consumed `config get`
|
||||
value or the `--json` documents, which a script depends on. The
|
||||
confirmation prompts of `database delete`, `prune`, `snapshot purge`
|
||||
and `snapshot remove`, each with what it asks to delete, are also
|
||||
written this way and always shown: they are an interactive exchange
|
||||
the operator must see.
|
||||
`database delete` confirmation prompt is also written this way and
|
||||
always shown: it is an interactive exchange the operator must see.
|
||||
|
||||
`internal/ui` writes to stdout; it is the output the user asked for. The
|
||||
exceptions are the startup banner and the error a failed command ends
|
||||
|
||||
@@ -15,229 +15,13 @@ 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) 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`.
|
||||
[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.
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 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
|
||||
|
||||
@@ -328,18 +328,6 @@ 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
|
||||
# A backup uploads its blobs before the manifest that lists them, 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 them at once
|
||||
# Default: 24h
|
||||
#prune_grace_period: 24h
|
||||
|
||||
# Hostname to use in backup metadata
|
||||
# Default: system hostname
|
||||
#hostname: myserver
|
||||
|
||||
+1
-2
@@ -228,8 +228,7 @@ 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, once older than `prune_grace_period`, and from the local
|
||||
database
|
||||
- Delete from S3 and local database
|
||||
|
||||
### 6. Incomplete Snapshot Cleanup
|
||||
|
||||
|
||||
@@ -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, plus a marker file:
|
||||
Vaultik stores all backup data in an S3-compatible object store. The repository consists of two main components:
|
||||
1. **Blobs** - The actual backup data (content-addressed, encrypted)
|
||||
2. **Metadata** - Snapshot information and manifests (partially encrypted)
|
||||
|
||||
@@ -12,7 +12,6 @@ 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]>/
|
||||
@@ -25,10 +24,6 @@ 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. A destination without it is treated as not present while the local index holds snapshots stored there: `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.
|
||||
|
||||
## Blobs Directory (`blobs/`)
|
||||
|
||||
### Structure
|
||||
@@ -166,7 +161,7 @@ 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
|
||||
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. It deletes an unreferenced blob only once the blob is older than `prune_grace_period` (default `24h`). A backup uploads its blobs before the manifest that lists them, 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
|
||||
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
|
||||
|
||||
## Restoration Requirements
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ require (
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/spf13/pflag v1.0.10
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.uber.org/dig v1.19.0
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/sync v0.18.0
|
||||
golang.org/x/sys v0.38.0
|
||||
@@ -257,6 +256,7 @@ require (
|
||||
go.opentelemetry.io/otel/metric v1.38.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.38.0 // indirect
|
||||
go.shabbyrobe.org/gocovmerge v0.0.0-20230507111327-fa4f82cfbf4d // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.uber.org/zap v1.27.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
|
||||
+10
-6
@@ -8,12 +8,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/spf13/cobra"
|
||||
"go.uber.org/dig"
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
@@ -146,12 +146,16 @@ func (e *startupError) Error() string {
|
||||
// could not build arguments for function "Y" (file:line): failed to build U:
|
||||
// received non-nil error from function "Z" (file:line): <real error>
|
||||
//
|
||||
// Users care about the real error, not the DI plumbing. dig.RootCause,
|
||||
// from the library fx is built on, unwraps those layers and returns the
|
||||
// real error whole. Cutting the text at "): " instead would also cut a
|
||||
// real error that contains it, such as the blob_size_limit one.
|
||||
// Users care about the real error, not the DI plumbing. We strip everything
|
||||
// up through the last "): " (which is always the close-paren of an fx
|
||||
// function-location annotation followed by the wrapped error).
|
||||
func cleanStartupError(err error) error {
|
||||
return &startupError{msg: dig.RootCause(err).Error()}
|
||||
msg := err.Error()
|
||||
if idx := strings.LastIndex(msg, "): "); idx >= 0 {
|
||||
msg = msg[idx+3:]
|
||||
}
|
||||
|
||||
return &startupError{msg: msg}
|
||||
}
|
||||
|
||||
// RunApp starts the fx application, blocks until it is asked to stop, and
|
||||
|
||||
+39
-46
@@ -2,65 +2,58 @@ package cli //nolint:testpackage // needs access to unexported cleanStartupError
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/fx"
|
||||
"sneak.berlin/go/vaultik/internal/config"
|
||||
"sneak.berlin/go/vaultik/internal/pidlock"
|
||||
)
|
||||
|
||||
// TestCleanStartupError starts a real fx app whose config has a
|
||||
// blob_size_limit below the minimum. fx puts annotations naming the
|
||||
// functions it was building in front of the config error; only those
|
||||
// may be removed, although the config error itself contains "): ".
|
||||
func TestCleanStartupError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "config.yml")
|
||||
|
||||
err := os.WriteFile(path, []byte("snapshots:\n"+
|
||||
" test:\n"+
|
||||
" paths: [/tmp/vaultik-test-source]\n"+
|
||||
"storage_url: file:///tmp/vaultik-test-storage\n"+
|
||||
"chunk_size: 10MB\n"+
|
||||
"blob_size_limit: 30MB\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
tests := []struct {
|
||||
name string
|
||||
in string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "real fx error chain",
|
||||
in: `could not build arguments for function ` +
|
||||
`"sneak.berlin/go/vaultik/internal/cli".newSnapshotCreateCommand.func1.1 ` +
|
||||
`(/Users/user/dev/vaultik/internal/cli/snapshot.go:71): ` +
|
||||
`failed to build *vaultik.Vaultik: ` +
|
||||
`could not build arguments for function ` +
|
||||
`"sneak.berlin/go/vaultik/internal/vaultik".New ` +
|
||||
`(/Users/user/dev/vaultik/internal/vaultik/vaultik.go:59): ` +
|
||||
`failed to build storage.Storer: ` +
|
||||
`received non-nil error from function ` +
|
||||
`"sneak.berlin/go/vaultik/internal/storage".NewStorer ` +
|
||||
`(/Users/user/dev/vaultik/internal/storage/module.go:23): ` +
|
||||
`creating base path: mkdir /Volumes/BACKUPS: permission denied`,
|
||||
want: `creating base path: mkdir /Volumes/BACKUPS: permission denied`,
|
||||
},
|
||||
{
|
||||
name: "no fx wrapping",
|
||||
in: "plain error",
|
||||
want: "plain error",
|
||||
},
|
||||
{
|
||||
name: "single fx wrapping",
|
||||
in: `received non-nil error from function "foo" (file.go:1): underlying problem`,
|
||||
want: "underlying problem",
|
||||
},
|
||||
}
|
||||
|
||||
app := fx.New(
|
||||
fx.NopLogger,
|
||||
fx.Supply(config.Path(path)),
|
||||
config.Module,
|
||||
fx.Invoke(func(*config.Config) {}),
|
||||
)
|
||||
|
||||
err = app.Start(t.Context())
|
||||
if err == nil {
|
||||
t.Fatal("app started; want a blob_size_limit error")
|
||||
}
|
||||
|
||||
want := "failed to load config: invalid config: blob_size_limit must " +
|
||||
"be at least the largest chunk the chunker can emit (chunk_size " +
|
||||
"times the FastCDC size spread): need at least 40000000 bytes"
|
||||
|
||||
got := cleanStartupError(err).Error()
|
||||
if got != want {
|
||||
t.Errorf("got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleanStartupErrorNotFromFx checks that an error fx did not wrap is
|
||||
// shown unchanged.
|
||||
func TestCleanStartupErrorNotFromFx(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
//nolint:err113 // a one-off error standing in for a startup failure
|
||||
got := cleanStartupError(errors.New("plain error")).Error()
|
||||
if got != "plain error" {
|
||||
t.Errorf("got %q, want %q", got, "plain error")
|
||||
//nolint:err113 // test constructs errors from table input
|
||||
got := cleanStartupError(errors.New(tt.in)).Error()
|
||||
if got != tt.want {
|
||||
t.Errorf("got %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-18
@@ -239,16 +239,6 @@ storage_url: ""
|
||||
# Default: 3
|
||||
# compression_level: 3
|
||||
|
||||
# How old a blob that no snapshot lists must be before prune deletes it.
|
||||
# A backup uploads its blobs before the manifest that lists them, 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 them at once.
|
||||
# Default: 24h
|
||||
# prune_grace_period: 24h
|
||||
|
||||
# Hostname used in snapshot IDs. Default: system hostname.
|
||||
# hostname: myserver
|
||||
|
||||
@@ -343,15 +333,8 @@ func newConfigEditCommand() *cobra.Command {
|
||||
editor = "vi"
|
||||
}
|
||||
|
||||
// Run $EDITOR through the shell, as git does, so a value
|
||||
// with arguments such as "code --wait" works. The path
|
||||
// goes in as an argument ("$@"), not into the command
|
||||
// text, so a path with spaces or quotes arrives whole.
|
||||
// sh -c takes the word after the command as $0, which
|
||||
// is why editor appears twice.
|
||||
//nolint:gosec // G204: launching the operator's own $EDITOR is the point
|
||||
ed := exec.CommandContext(cmd.Context(),
|
||||
"sh", "-c", editor+` "$@"`, editor, path)
|
||||
ed := exec.CommandContext(cmd.Context(), editor, path)
|
||||
ed.Stdin = os.Stdin
|
||||
ed.Stdout = os.Stdout
|
||||
ed.Stderr = os.Stderr
|
||||
|
||||
@@ -647,30 +647,6 @@ func TestWriteConfigSetTightensMode(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigEditEditorWithArgument checks that an $EDITOR carrying an
|
||||
// argument works and that a config path with a space reaches it whole.
|
||||
// The "editor" is cp with the replacement file as its argument, so the
|
||||
// config ends up with the replacement's content only if both arrived.
|
||||
func TestConfigEditEditorWithArgument(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := seedFile(t, dir, "my config.yml", "compression_level: 3\n")
|
||||
edited := seedFile(t, dir, "edited.yml", "compression_level: 9\n")
|
||||
|
||||
setRootFlags(t, RootFlags{ConfigPath: path})
|
||||
t.Setenv("EDITOR", "cp '"+edited+"'")
|
||||
|
||||
mustExecute(t, newConfigEditCommand())
|
||||
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: test-controlled path
|
||||
if err != nil {
|
||||
t.Fatalf("reading config back: %v", err)
|
||||
}
|
||||
|
||||
if string(data) != "compression_level: 9\n" {
|
||||
t.Errorf("config after edit = %q, want the edited content", data)
|
||||
}
|
||||
}
|
||||
|
||||
func splitPath(s string) []string {
|
||||
return strings.Split(s, ".")
|
||||
}
|
||||
|
||||
@@ -101,8 +101,7 @@ 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, and gives the destination the
|
||||
// marker a backup writes. Returns the config path.
|
||||
// counterpart on the destination store. Returns the config path.
|
||||
func writeHermeticPruneConfig(t *testing.T, seedStale bool) string {
|
||||
t.Helper()
|
||||
|
||||
@@ -132,11 +131,6 @@ 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 without the marker a backup writes.
|
||||
require.NoError(t, os.WriteFile(
|
||||
filepath.Join(storeDir, "vaultik-destination.txt"), nil, 0o600))
|
||||
}
|
||||
|
||||
return configPath
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// flagCron is used to build the argument vectors below.
|
||||
const flagCron = "--cron"
|
||||
|
||||
// plantUnreferencedBlob writes a blob that no snapshot references to the
|
||||
// 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()
|
||||
|
||||
hash := strings.Repeat("b", 64)
|
||||
path := filepath.Join(storeDir, "blobs", hash[:2], hash[2:4], hash)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
// TestEntrySnapshotCreatePruneIsSilent checks that a successful
|
||||
// `snapshot create --prune` under --cron or --quiet prints nothing on
|
||||
// stdout or stderr, whether or not the prune finds a blob to delete.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotCreatePruneIsSilent(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
args []string
|
||||
plantBlob bool
|
||||
}{
|
||||
{
|
||||
name: "cron, no blob to delete",
|
||||
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||
},
|
||||
{
|
||||
name: "cron, blob to delete",
|
||||
args: []string{cmdSnapshot, cmdCreate, flagCron, flagPrune},
|
||||
plantBlob: true,
|
||||
},
|
||||
{
|
||||
name: "cron with keep-newer-than",
|
||||
args: []string{
|
||||
cmdSnapshot, cmdCreate, flagCron, flagPrune,
|
||||
flagKeepNewerThan, "4w",
|
||||
},
|
||||
plantBlob: true,
|
||||
},
|
||||
{
|
||||
name: "quiet",
|
||||
args: []string{flagQuiet, cmdSnapshot, cmdCreate, flagPrune},
|
||||
plantBlob: true,
|
||||
},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
configPath, storeDir := writeBackupConfig(t)
|
||||
|
||||
var blob string
|
||||
if testCase.plantBlob {
|
||||
blob = plantUnreferencedBlob(t, storeDir)
|
||||
}
|
||||
|
||||
code, stdout, stderr := runEntry(t,
|
||||
append([]string{flagConfig, configPath}, testCase.args...)...)
|
||||
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Empty(t, stdout)
|
||||
assert.Empty(t, stderr)
|
||||
|
||||
if testCase.plantBlob {
|
||||
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntryPruneQuietIsSilent checks that a successful `prune --force`
|
||||
// under --quiet prints nothing on stdout or stderr, both when there is
|
||||
// nothing to remove and when it removes a stale local snapshot record
|
||||
// and an unreferenced blob.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntryPruneQuietIsSilent(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
name string
|
||||
staleRecordAndBlob bool
|
||||
}{
|
||||
{name: "nothing to remove"},
|
||||
{name: "stale record and blob to remove", staleRecordAndBlob: true},
|
||||
} {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
configPath := writeHermeticPruneConfig(t, testCase.staleRecordAndBlob)
|
||||
|
||||
var blob string
|
||||
if testCase.staleRecordAndBlob {
|
||||
// writeHermeticPruneConfig puts the store beside the config.
|
||||
blob = plantUnreferencedBlob(t,
|
||||
filepath.Join(filepath.Dir(configPath), "store"))
|
||||
}
|
||||
|
||||
code, stdout, stderr := runEntry(t,
|
||||
flagConfig, configPath, flagQuiet, cmdPrune, flagForce)
|
||||
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Empty(t, stdout)
|
||||
assert.Empty(t, stderr)
|
||||
|
||||
if testCase.staleRecordAndBlob {
|
||||
assert.NoFileExists(t, blob, "the prune must delete the blob")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
package cli //nolint:testpackage // shares runEntry and the hermetic config helpers
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips backs up,
|
||||
// under --skip-errors, a source directory that does not exist, which the
|
||||
// walk skips. 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/310.
|
||||
//
|
||||
//nolint:paralleltest // replaces os.Args, os.Stdout, os.Stderr and the xdg globals
|
||||
func TestEntrySnapshotCreateSkipErrorsExitsNonZeroWhenItSkips(t *testing.T) {
|
||||
// writeBackupConfig would create the source directory.
|
||||
configPath := writeHermeticPruneConfig(t, false)
|
||||
|
||||
code, stdout, stderr := runEntry(t,
|
||||
flagConfig, configPath, "--skip-errors", cmdSnapshot, cmdCreate)
|
||||
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stdout,
|
||||
"Files: 1 examined, 0 backed up, 0 unchanged, 1 failed.")
|
||||
assert.NotContains(t, stdout, "Finished successfully.")
|
||||
assert.Contains(t, stderr,
|
||||
"Backup completed, but 1 file(s) could not be read")
|
||||
assert.NotContains(t, stderr, "Snapshot creation failed")
|
||||
}
|
||||
@@ -41,11 +41,6 @@ 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 without the marker a backup writes.
|
||||
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))
|
||||
@@ -64,12 +59,10 @@ func TestEntrySnapshotPurgeKeepsMetadataWhenLocalIndexDeleteFails(t *testing.T)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.Close())
|
||||
|
||||
code, _, stderr := runEntry(t, flagConfig, configPath,
|
||||
code, _, _ := 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")
|
||||
}
|
||||
|
||||
@@ -20,12 +20,7 @@ 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 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.
|
||||
set.
|
||||
|
||||
Snapshot create --prune runs the same cleanup automatically; this
|
||||
command is the manual entry point for the same work (e.g. after a
|
||||
|
||||
@@ -115,13 +115,6 @@ on Linux), then /etc/vaultik/config.yml.`,
|
||||
return
|
||||
}
|
||||
|
||||
if errors.Is(err, vaultik.ErrFilesUnreadable) {
|
||||
log.Error("Backup completed with unreadable files", "error", err)
|
||||
ReportErrorf("Backup completed, but %v", err)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
log.Error("Snapshot creation failed", "error", err)
|
||||
ReportErrorf("Snapshot creation failed: %v", err)
|
||||
})
|
||||
@@ -132,8 +125,7 @@ on Linux), then /etc/vaultik/config.yml.`,
|
||||
"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 older than prune_grace_period (not after a "+
|
||||
"backup that skipped unreadable files)")
|
||||
"orphaned blobs")
|
||||
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) "+
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"git.eeqj.de/sneak/smartconfig"
|
||||
@@ -39,7 +38,6 @@ 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
|
||||
@@ -69,8 +67,6 @@ 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")
|
||||
@@ -191,13 +187,6 @@ 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. A backup lists its blobs in a manifest only
|
||||
// once it has uploaded them all, so a younger one 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:
|
||||
@@ -299,7 +288,6 @@ func Parse(data []byte) (*Config, error) {
|
||||
ChunkSize: defaultChunkSize,
|
||||
IndexPath: filepath.Join(xdg.DataHome, appName, "index.sqlite"),
|
||||
CompressionLevel: defaultCompressionLevel,
|
||||
PruneGracePeriod: defaultPruneGracePeriod,
|
||||
S3: S3Config{PartSize: defaultS3PartSize},
|
||||
}
|
||||
|
||||
@@ -368,7 +356,6 @@ 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 {
|
||||
@@ -418,10 +405,6 @@ func (c *Config) Validate() error {
|
||||
return errBadS3PartSize
|
||||
}
|
||||
|
||||
if c.PruneGracePeriod < 0 {
|
||||
return errNegativePruneGracePeriod
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/chunker"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
@@ -413,73 +412,6 @@ func TestDocumentedSizeExamples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidatePruneGracePeriod checks that a negative prune_grace_period
|
||||
// is refused rather than acting like 0s, which deletes unreferenced blobs
|
||||
// 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
|
||||
|
||||
@@ -267,45 +267,6 @@ func (r *FileRepository) ListUnderPath(
|
||||
return files, rows.Err()
|
||||
}
|
||||
|
||||
// ListUnderPathInSnapshot returns the files that ListUnderPath returns
|
||||
// for path and that the snapshot lists, in no particular order.
|
||||
func (r *FileRepository) ListUnderPathInSnapshot(
|
||||
ctx context.Context, path, snapshotID string,
|
||||
) ([]*File, error) {
|
||||
path = strings.TrimRight(path, "/")
|
||||
dirPrefix := path + "/"
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.path, f.source_path, f.mtime, f.mtime_nsec, f.size,
|
||||
f.mode, f.uid, f.gid, f.link_target
|
||||
FROM files f
|
||||
JOIN snapshot_files sf ON sf.file_id = f.id
|
||||
WHERE sf.snapshot_id = ?
|
||||
AND (f.path = ? OR substr(f.path, 1, length(?)) = ?)
|
||||
`
|
||||
|
||||
rows, err := r.db.conn.QueryContext(ctx, query,
|
||||
snapshotID, path, dirPrefix, dirPrefix)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying files: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
var files []*File
|
||||
|
||||
for rows.Next() {
|
||||
file, err := r.scanFileRows(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning file: %w", err)
|
||||
}
|
||||
|
||||
files = append(files, file)
|
||||
}
|
||||
|
||||
return files, rows.Err()
|
||||
}
|
||||
|
||||
// ListIDsWithChunksNotInUploadedBlobs returns the IDs of the files whose
|
||||
// path starts with prefix and that list at least one chunk held by no
|
||||
// blob whose upload has completed (uploaded_ts set). A new snapshot
|
||||
|
||||
@@ -212,65 +212,6 @@ func TestFileRepositoryListUnderPath(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ListUnderPathInSnapshot leaves out files outside path and files the
|
||||
// snapshot does not list, though another snapshot lists them.
|
||||
func TestFileRepositoryListUnderPathInSnapshot(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
db, cleanup := setupTestDB(t)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
files := database.NewFileRepository(db)
|
||||
snapshots := database.NewSnapshotRepository(db)
|
||||
|
||||
listed := map[string][]string{
|
||||
"one": {"/src/a.txt", "/src2/c.txt"},
|
||||
"two": {"/src/b.txt"},
|
||||
}
|
||||
|
||||
for snapshotID, paths := range listed {
|
||||
err := snapshots.Create(ctx, nil, &database.Snapshot{
|
||||
ID: types.SnapshotID(snapshotID),
|
||||
Hostname: "host",
|
||||
StartedAt: time.Now(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create snapshot %s: %v", snapshotID, err)
|
||||
}
|
||||
|
||||
for _, path := range paths {
|
||||
err := files.Create(ctx, nil, &database.File{
|
||||
Path: types.FilePath(path),
|
||||
MTime: time.Now().Truncate(time.Second),
|
||||
Mode: 0644,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create %s: %v", path, err)
|
||||
}
|
||||
|
||||
err = snapshots.AddFile(ctx, nil, snapshotID, path)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add %s to %s: %v", path, snapshotID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
found, err := files.ListUnderPathInSnapshot(ctx, "/src", "one")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to list files: %v", err)
|
||||
}
|
||||
|
||||
got := make([]string, 0, len(found))
|
||||
for _, f := range found {
|
||||
got = append(got, f.Path.String())
|
||||
}
|
||||
|
||||
if !slices.Equal(got, []string{"/src/a.txt"}) {
|
||||
t.Errorf("got %q, want only /src/a.txt", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFileRepositorySymlink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"io"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/config"
|
||||
@@ -303,9 +302,6 @@ func IsNotFound(err error) bool {
|
||||
type ObjectInfo struct {
|
||||
Key string
|
||||
Size int64
|
||||
// ModTime is the object's LastModified time. Only ListObjectsStream
|
||||
// sets it.
|
||||
ModTime time.Time
|
||||
Err error
|
||||
}
|
||||
|
||||
@@ -349,7 +345,6 @@ func (c *Client) ListObjectsStream(
|
||||
ch <- ObjectInfo{
|
||||
Key: key,
|
||||
Size: *obj.Size,
|
||||
ModTime: aws.ToTime(obj.LastModified),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ type ScannerParams struct {
|
||||
Fs afero.Fs
|
||||
Exclude []string // Exclude patterns (combined global + snapshot-specific)
|
||||
SkipErrors bool // Skip file read errors (log loudly but continue)
|
||||
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||
// same name, "" for none.
|
||||
PreviousSnapshotID string
|
||||
}
|
||||
|
||||
// Module exports backup functionality as an fx module.
|
||||
@@ -58,7 +55,6 @@ func provideScannerFactory(
|
||||
UI: params.UI,
|
||||
Exclude: excludes,
|
||||
SkipErrors: params.SkipErrors,
|
||||
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,8 @@ func TestProgressPercentWithSecondPathSmaller(t *testing.T) {
|
||||
|
||||
stats := progress.GetStats()
|
||||
|
||||
// With both paths counted, the percentage must not exceed 100%.
|
||||
// Directories count toward the total size but produce no chunks, so
|
||||
// the percentage ends just under 100%.
|
||||
percent := float64(stats.BytesProcessed.Load()) /
|
||||
float64(stats.TotalSize.Load()) * 100
|
||||
if percent > 100 {
|
||||
|
||||
+105
-239
@@ -58,10 +58,6 @@ type Scanner struct {
|
||||
compressionLevel int
|
||||
ageRecipient string
|
||||
snapshotID string // Current snapshot being processed
|
||||
// previousSnapshotID is the most recent completed snapshot of the
|
||||
// same name, "" for none. Only a file it lists can be counted as
|
||||
// deleted.
|
||||
previousSnapshotID string
|
||||
// currentSourcePath is the source directory being scanned, stored with
|
||||
// each file record.
|
||||
currentSourcePath string
|
||||
@@ -133,20 +129,15 @@ type ScannerConfig struct {
|
||||
// SkipErrors skips files that cannot be opened or read (log loudly but
|
||||
// continue); packer, database, encryption, and upload errors still abort.
|
||||
SkipErrors bool
|
||||
// PreviousSnapshotID is the most recent completed snapshot of the
|
||||
// same name, "" for none.
|
||||
PreviousSnapshotID string
|
||||
}
|
||||
|
||||
// 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, 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.
|
||||
// new and changed files that phase 2 could not store; 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
|
||||
@@ -163,11 +154,6 @@ type ScanResult struct {
|
||||
UploadDuration time.Duration
|
||||
StartTime time.Time
|
||||
EndTime time.Time
|
||||
|
||||
// FilesUnreadable is the part of FilesFailed that --skip-errors
|
||||
// skipped because it could not be read or listed; the rest are files
|
||||
// removed after the walk listed them.
|
||||
FilesUnreadable int
|
||||
}
|
||||
|
||||
// NewScanner creates a new scanner instance
|
||||
@@ -221,7 +207,6 @@ func NewScanner(cfg ScannerConfig) *Scanner {
|
||||
compiledExclude: compiledExclude,
|
||||
progress: progress,
|
||||
skipErrors: cfg.SkipErrors,
|
||||
previousSnapshotID: cfg.PreviousSnapshotID,
|
||||
ui: uiw,
|
||||
pendingChunkHashes: make(map[string]struct{}),
|
||||
}
|
||||
@@ -273,9 +258,8 @@ func (s *Scanner) Scan(
|
||||
|
||||
filesToProcess := scanResult.FilesToProcess
|
||||
|
||||
// Phase 1b: Detect deleted files by comparing the previous snapshot
|
||||
// of this name against scanned files
|
||||
err = s.detectDeletedFiles(ctx, path, existingFiles, result)
|
||||
// Phase 1b: Detect deleted files by comparing DB against scanned files
|
||||
err = s.detectDeletedFilesFromMap(ctx, knownFiles, existingFiles, result)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("detecting deleted files: %w", err)
|
||||
}
|
||||
@@ -291,11 +275,6 @@ func (s *Scanner) Scan(
|
||||
}
|
||||
}
|
||||
|
||||
err = s.recordDirectories(ctx, scanResult.Directories)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Summarize scan phase results and update progress
|
||||
s.summarizeScanPhase(result, filesToProcess)
|
||||
|
||||
@@ -460,8 +439,9 @@ func (s *Scanner) summarizeScanPhase(
|
||||
}
|
||||
|
||||
// loadKnownFiles loads the known files at and beneath path from the
|
||||
// database into a map for fast lookup. This avoids per-file database
|
||||
// queries during the scan phase.
|
||||
// database into a map for fast lookup. Every loaded file the scan does
|
||||
// not find is counted as deleted. This avoids per-file database queries
|
||||
// during the scan phase.
|
||||
func (s *Scanner) loadKnownFiles(
|
||||
ctx context.Context, path string,
|
||||
) (map[string]*database.File, error) {
|
||||
@@ -868,14 +848,13 @@ func (s *Scanner) batchInsertFileData(
|
||||
type ScanPhaseResult struct {
|
||||
FilesToProcess []*FileToProcess
|
||||
UnchangedFileIDs []types.FileID // IDs of unchanged files to associate with snapshot
|
||||
Directories []*FileToProcess // Every directory found, to record
|
||||
}
|
||||
|
||||
// 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, IDs of unchanged files for snapshot
|
||||
// association, and the directories found.
|
||||
// Returns files needing processing and IDs of unchanged files for snapshot
|
||||
// association.
|
||||
func (s *Scanner) scanPhase(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
@@ -883,10 +862,8 @@ 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). The walk does not count directories, so the
|
||||
// estimate leaves them out.
|
||||
estimatedTotal := countNonDirectories(knownFiles)
|
||||
// Use known file count as estimate for progress (accurate for subsequent backups)
|
||||
estimatedTotal := int64(len(knownFiles))
|
||||
|
||||
collector := &scanCollector{}
|
||||
|
||||
@@ -897,28 +874,8 @@ 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)
|
||||
return s.handleWalkError(filePath, err)
|
||||
}
|
||||
|
||||
// Check context cancellation
|
||||
@@ -928,9 +885,18 @@ 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
|
||||
}
|
||||
|
||||
// Handle symlinks and directories
|
||||
handled, err := s.recordSpecialEntry(
|
||||
filePath, info, existingFiles, knownFiles, collector, result)
|
||||
filePath, info, existingFiles, collector, result)
|
||||
if handled {
|
||||
return err
|
||||
}
|
||||
@@ -944,10 +910,9 @@ func (s *Scanner) scanPhase(
|
||||
existingFiles[filePath] = struct{}{}
|
||||
|
||||
// Check file against in-memory map (no DB query!)
|
||||
file := s.buildRegularFileEntry(filePath, info)
|
||||
needsProcessing := s.checkFileInMemory(file, knownFiles)
|
||||
file, needsProcessing := s.checkFileInMemory(filePath, info, knownFiles)
|
||||
|
||||
filesScanned, changedCount := collector.recordFile(
|
||||
filesScanned, changedCount := collector.recordRegularFile(
|
||||
filePath, info, file, needsProcessing)
|
||||
|
||||
// Update result stats
|
||||
@@ -971,45 +936,27 @@ func (s *Scanner) scanPhase(
|
||||
return &ScanPhaseResult{
|
||||
FilesToProcess: collector.filesToProcess,
|
||||
UnchangedFileIDs: collector.unchangedFileIDs,
|
||||
Directories: collector.directories,
|
||||
}, 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. 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.
|
||||
// data to chunk) and reports whether it handled the entry. 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
|
||||
if info.Mode()&os.ModeSymlink != 0 {
|
||||
file, err := s.buildSymlinkEntry(filePath, info)
|
||||
if err != nil {
|
||||
return true, s.handleWalkError(filePath, err, result)
|
||||
return true, s.handleWalkError(filePath, err)
|
||||
}
|
||||
|
||||
existingFiles[filePath] = struct{}{}
|
||||
needsProcessing := s.checkFileInMemory(file, knownFiles)
|
||||
collector.recordFile(filePath, info, file, needsProcessing)
|
||||
s.updateScanEntryStats(result, needsProcessing, info)
|
||||
collector.addToProcess(filePath, info, file)
|
||||
s.updateScanEntryStats(result, true, info)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -1019,7 +966,7 @@ func (s *Scanner) recordSpecialEntry(
|
||||
if info.IsDir() {
|
||||
file := s.buildDirectoryEntry(filePath, info)
|
||||
existingFiles[filePath] = struct{}{}
|
||||
collector.addDirectory(filePath, info, file)
|
||||
collector.addToProcess(filePath, info, file)
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -1027,38 +974,15 @@ func (s *Scanner) recordSpecialEntry(
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (s *Scanner) handleWalkError(
|
||||
filePath string, err error, result *ScanResult,
|
||||
) error {
|
||||
// skipped (with a UI notice) under --skip-errors, fatal otherwise.
|
||||
func (s *Scanner) handleWalkError(filePath string, err error) error {
|
||||
if s.skipErrors {
|
||||
log.Error("Failed to access file (skipping due to --skip-errors)",
|
||||
"path", filePath, "error", err)
|
||||
s.ui.Errorf("Failed to access %s: %v. Skipping (--skip-errors).",
|
||||
s.ui.Path(filePath), err)
|
||||
|
||||
result.FilesScanned++
|
||||
result.FilesFailed++
|
||||
result.FilesUnreadable++
|
||||
|
||||
return nil // Continue scanning
|
||||
}
|
||||
|
||||
@@ -1072,29 +996,28 @@ type scanCollector struct {
|
||||
mu sync.Mutex
|
||||
filesToProcess []*FileToProcess
|
||||
unchangedFileIDs []types.FileID // Just IDs - no new records needed
|
||||
directories []*FileToProcess
|
||||
filesScanned int64
|
||||
}
|
||||
|
||||
// addDirectory sets a directory aside to be recorded. It is not counted
|
||||
// as a file.
|
||||
func (c *scanCollector) addDirectory(
|
||||
// addToProcess queues a file entry for processing.
|
||||
func (c *scanCollector) addToProcess(
|
||||
path string, info os.FileInfo, file *database.File,
|
||||
) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
c.directories = append(c.directories, &FileToProcess{
|
||||
c.filesToProcess = append(c.filesToProcess, &FileToProcess{
|
||||
Path: path,
|
||||
FileInfo: info,
|
||||
File: file,
|
||||
})
|
||||
c.filesScanned++
|
||||
}
|
||||
|
||||
// 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(
|
||||
// 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(
|
||||
path string, info os.FileInfo, file *database.File, needsProcessing bool,
|
||||
) (int64, int) {
|
||||
c.mu.Lock()
|
||||
@@ -1197,28 +1120,7 @@ func (s *Scanner) printScanProgressLine(
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// buildSymlinkEntry creates a File record for a symlink.
|
||||
func (s *Scanner) buildSymlinkEntry(
|
||||
path string, info os.FileInfo,
|
||||
) (*database.File, error) {
|
||||
@@ -1234,6 +1136,7 @@ func (s *Scanner) buildSymlinkEntry(
|
||||
}
|
||||
|
||||
return &database.File{
|
||||
ID: types.NewFileID(),
|
||||
Path: types.FilePath(path),
|
||||
SourcePath: types.SourcePath(s.currentSourcePath),
|
||||
MTime: info.ModTime(),
|
||||
@@ -1265,22 +1168,6 @@ func (s *Scanner) buildDirectoryEntry(path string, info os.FileInfo) *database.F
|
||||
}
|
||||
}
|
||||
|
||||
// recordDirectories records every directory the walk found. 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, directories []*FileToProcess,
|
||||
) error {
|
||||
for _, dir := range directories {
|
||||
err := s.recordNonRegularFile(ctx, dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("recording directory %s: %w", dir.Path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -1294,36 +1181,62 @@ func (s *Scanner) recordNonRegularFile(ctx context.Context, ftp *FileToProcess)
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// checkFileInMemory checks if a file needs processing using the in-memory map
|
||||
// No database access is performed - this is purely CPU/memory work
|
||||
func (s *Scanner) checkFileInMemory(
|
||||
file *database.File, knownFiles map[string]*database.File,
|
||||
) bool {
|
||||
existingFile, exists := knownFiles[file.Path.String()]
|
||||
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,
|
||||
}
|
||||
|
||||
// New file - needs processing
|
||||
if !exists {
|
||||
file.ID = types.NewFileID()
|
||||
|
||||
return true
|
||||
return file, true
|
||||
}
|
||||
|
||||
file.ID = existingFile.ID
|
||||
|
||||
// No uploaded blob holds one of its chunks (see loadFilesToRechunk)
|
||||
if _, rechunk := s.filesToRechunk[file.ID]; rechunk {
|
||||
return true
|
||||
if _, rechunk := s.filesToRechunk[fileID]; rechunk {
|
||||
return file, true
|
||||
}
|
||||
|
||||
// Check if file has changed
|
||||
return existingFile.Size != file.Size ||
|
||||
if existingFile.Size != file.Size ||
|
||||
!existingFile.MTime.Equal(file.MTime) ||
|
||||
existingFile.Mode != file.Mode ||
|
||||
existingFile.UID != file.UID ||
|
||||
existingFile.GID != file.GID ||
|
||||
existingFile.LinkTarget != file.LinkTarget
|
||||
existingFile.GID != file.GID {
|
||||
return file, true
|
||||
}
|
||||
|
||||
// File unchanged
|
||||
return file, false
|
||||
}
|
||||
|
||||
// batchAddFilesToSnapshot adds existing file IDs to the snapshot association table
|
||||
@@ -1450,16 +1363,17 @@ func (s *Scanner) processPhase(
|
||||
return s.finalizeProcessPhase(ctx, result)
|
||||
}
|
||||
|
||||
// processFileWithErrorHandling records a symlink, or wraps
|
||||
// processFileWithErrorHandling records a directory or 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 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 {
|
||||
// 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() {
|
||||
err := s.recordNonRegularFile(ctx, fileToProcess)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("processing file %s: %w", fileToProcess.Path, err)
|
||||
@@ -1500,7 +1414,6 @@ func (s *Scanner) processFileWithErrorHandling(
|
||||
s.ui.Path(fileToProcess.Path), err)
|
||||
|
||||
countFailedFile(fileToProcess, result)
|
||||
result.FilesUnreadable++
|
||||
|
||||
return true, nil
|
||||
}
|
||||
@@ -2041,29 +1954,21 @@ func (s *Scanner) queueFileForBatchInsert(
|
||||
})
|
||||
}
|
||||
|
||||
// detectDeletedFiles counts as deleted each file at or beneath path that
|
||||
// the previous snapshot of this name lists, the walk did not find, and
|
||||
// is gone from disk. A file that is excluded, or inside an excluded
|
||||
// directory, is not counted: the walk skips it whether or not it is
|
||||
// still on disk.
|
||||
func (s *Scanner) detectDeletedFiles(
|
||||
// detectDeletedFilesFromMap finds files that existed in previous snapshots
|
||||
// but no longer exist. Uses pre-loaded maps to avoid any filesystem or
|
||||
// database access.
|
||||
func (s *Scanner) detectDeletedFilesFromMap(
|
||||
ctx context.Context,
|
||||
path string,
|
||||
knownFiles map[string]*database.File,
|
||||
existingFiles map[string]struct{},
|
||||
result *ScanResult,
|
||||
) error {
|
||||
if s.previousSnapshotID == "" {
|
||||
if len(knownFiles) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
previousFiles, err := s.repos.Files.ListUnderPathInSnapshot(
|
||||
ctx, path, s.previousSnapshotID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listing files of snapshot %s: %w",
|
||||
s.previousSnapshotID, err)
|
||||
}
|
||||
|
||||
for _, file := range previousFiles {
|
||||
// Check each known file against the enumerated set (no filesystem access needed)
|
||||
for path, file := range knownFiles {
|
||||
// Check context cancellation periodically
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -2071,22 +1976,13 @@ func (s *Scanner) detectDeletedFiles(
|
||||
default:
|
||||
}
|
||||
|
||||
filePath := file.Path.String()
|
||||
|
||||
_, exists := existingFiles[filePath]
|
||||
if exists || s.excludedOrInExcludedDir(filePath, path) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Under --skip-errors the walk also misses what it cannot reach,
|
||||
// such as the files in a directory it cannot open.
|
||||
if !s.goneFromDisk(filePath) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the file exists in our enumerated set
|
||||
if _, exists := existingFiles[path]; !exists {
|
||||
// File has been deleted
|
||||
result.FilesDeleted++
|
||||
result.BytesDeleted += file.Size
|
||||
log.Debug("Detected deleted file", "path", filePath, "size", file.Size)
|
||||
log.Debug("Detected deleted file", "path", path, "size", file.Size)
|
||||
}
|
||||
}
|
||||
|
||||
if result.FilesDeleted > 0 {
|
||||
@@ -2097,36 +1993,6 @@ func (s *Scanner) detectDeletedFiles(
|
||||
return nil
|
||||
}
|
||||
|
||||
// excludedOrInExcludedDir reports whether filePath, which is at or
|
||||
// beneath rootPath, or any directory between rootPath and it, matches an
|
||||
// exclude pattern. A pattern such as */.local/share/Trash matches the
|
||||
// directory but not the files inside it, which the walk skips too.
|
||||
func (s *Scanner) excludedOrInExcludedDir(filePath, rootPath string) bool {
|
||||
for p := filePath; len(p) > len(rootPath); p = filepath.Dir(p) {
|
||||
if s.shouldExclude(p, rootPath) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// goneFromDisk reports whether looking filePath up on disk finds that
|
||||
// it does not exist. Like the walk, it does not follow a symlink, so a
|
||||
// symlink whose target is gone is still on disk. A file whose directory
|
||||
// was replaced by a regular file is gone too, though the lookup reports
|
||||
// "not a directory".
|
||||
func (s *Scanner) goneFromDisk(filePath string) bool {
|
||||
var err error
|
||||
if lstater, ok := s.fs.(afero.Lstater); ok {
|
||||
_, _, err = lstater.LstatIfPossible(filePath)
|
||||
} else {
|
||||
_, err = s.fs.Stat(filePath)
|
||||
}
|
||||
|
||||
return errors.Is(err, os.ErrNotExist) || errors.Is(err, syscall.ENOTDIR)
|
||||
}
|
||||
|
||||
// wrapPermissionError augments permission errors with platform-specific
|
||||
// remediation instructions. On macOS, TCC-protected directories (Calendars,
|
||||
// Reminders, Photos, etc.) return EPERM unless the running application has
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
@@ -20,17 +19,15 @@ import (
|
||||
// constructor, so the contract is defined once.
|
||||
//
|
||||
// It exercises the public Storer interface: round-trip, stat, list with
|
||||
// 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.
|
||||
// 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.
|
||||
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))
|
||||
}
|
||||
@@ -121,35 +118,6 @@ 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) {
|
||||
|
||||
@@ -21,13 +21,9 @@ type FileStorer struct {
|
||||
// NewFileStorer creates a new filesystem storage backend.
|
||||
//
|
||||
// Construction is intentionally cheap and does not touch the filesystem.
|
||||
// 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.
|
||||
// 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.
|
||||
// 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.
|
||||
@@ -237,7 +233,6 @@ func (f *FileStorer) ListStream(ctx context.Context, prefix string) <-chan Objec
|
||||
ch <- ObjectInfo{
|
||||
Key: relPath,
|
||||
Size: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,19 +299,7 @@ func (f *FileStorer) writeAtomic(
|
||||
path := f.fullPath(key)
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
// 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)
|
||||
err := f.fs.MkdirAll(dir, storageDirPerm)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating directories: %w", err)
|
||||
}
|
||||
|
||||
@@ -292,43 +292,3 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,12 +7,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rclone/rclone/fs"
|
||||
"github.com/rclone/rclone/fs/config"
|
||||
"github.com/rclone/rclone/fs/config/configfile"
|
||||
"github.com/rclone/rclone/fs/operations"
|
||||
|
||||
@@ -35,15 +33,6 @@ type RcloneStorer struct {
|
||||
// The remote parameter is the rclone remote name (as configured via `rclone config`).
|
||||
// The path parameter is the path within the remote.
|
||||
func NewRcloneStorer(ctx context.Context, remote, path string) (*RcloneStorer, error) {
|
||||
// rclone reads RCLONE_CONFIG only as the value of its --config flag,
|
||||
// which vaultik does not have, so it is applied here as that flag would.
|
||||
if configPath, ok := os.LookupEnv("RCLONE_CONFIG"); ok {
|
||||
err := config.SetConfigPath(configPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("using RCLONE_CONFIG %q: %w", configPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Install the default config file handler
|
||||
configfile.Install()
|
||||
|
||||
@@ -53,14 +42,6 @@ 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 {
|
||||
@@ -221,7 +202,6 @@ func (r *RcloneStorer) ListStream(
|
||||
ch <- ObjectInfo{
|
||||
Key: key,
|
||||
Size: obj.Size(),
|
||||
ModTime: obj.ModTime(ctx),
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -5,19 +5,12 @@ 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"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
@@ -397,110 +390,3 @@ func TestNewRcloneStorerUnknownRemote(t *testing.T) {
|
||||
t.Errorf("NewRcloneStorer error = %v, want ErrRemoteNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNewRcloneStorerReadsRcloneConfigEnv checks that a remote defined only
|
||||
// in the file RCLONE_CONFIG names is found and written to, as rclone itself
|
||||
// would.
|
||||
func TestNewRcloneStorerReadsRcloneConfigEnv(t *testing.T) {
|
||||
// rclone keeps the config file path in a process-global variable, which
|
||||
// NewRcloneStorer leaves pointing at this test's file.
|
||||
previous := config.GetConfigPath()
|
||||
|
||||
t.Cleanup(func() { _ = config.SetConfigPath(previous) })
|
||||
|
||||
confFile := filepath.Join(t.TempDir(), "rclone.conf")
|
||||
|
||||
err := os.WriteFile(confFile, []byte("[loc]\ntype = local\n"), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("writing rclone config: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("RCLONE_CONFIG", confFile)
|
||||
|
||||
dir := t.TempDir()
|
||||
ctx := context.Background()
|
||||
|
||||
s, err := storage.NewRcloneStorer(ctx, "loc", dir)
|
||||
if err != nil {
|
||||
t.Fatalf("NewRcloneStorer: %v", err)
|
||||
}
|
||||
|
||||
err = s.Put(ctx, testBlobKey, strings.NewReader("blob-bytes"))
|
||||
if err != nil {
|
||||
t.Fatalf("Put: %v", err)
|
||||
}
|
||||
|
||||
_, err = os.Stat(filepath.Join(dir, testBlobKey))
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,6 @@ func (s *S3Storer) ListStream(ctx context.Context, prefix string) <-chan ObjectI
|
||||
ch <- ObjectInfo{
|
||||
Key: info.Key,
|
||||
Size: info.Size,
|
||||
ModTime: info.ModTime,
|
||||
Err: info.Err,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when an object does not exist.
|
||||
@@ -28,9 +27,6 @@ type ProgressCallback func(bytesTransferred int64) error
|
||||
type ObjectInfo struct {
|
||||
Key string // Object key/path
|
||||
Size int64 // Size in bytes
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -45,8 +41,7 @@ type Info struct {
|
||||
// directory for filesystem).
|
||||
type Storer interface {
|
||||
// Put stores data at the specified key.
|
||||
// The filesystem backend fails when its destination directory does
|
||||
// not exist, and creates the directories below it as needed.
|
||||
// Parent directories are created automatically for filesystem backends.
|
||||
Put(ctx context.Context, key string, data io.Reader) error
|
||||
|
||||
// PutWithProgress stores data with progress reporting.
|
||||
@@ -71,8 +66,7 @@ type Storer interface {
|
||||
// For large result sets, prefer ListStream.
|
||||
List(ctx context.Context, prefix string) ([]string, error)
|
||||
|
||||
// ListStream returns a channel of ObjectInfo, with each object's key,
|
||||
// size and modification time, for large result sets.
|
||||
// ListStream returns a channel of ObjectInfo 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
|
||||
|
||||
+6
-47
@@ -30,12 +30,6 @@ var (
|
||||
"storage URL must not carry credentials; " +
|
||||
"set s3.access_key_id and s3.secret_access_key in the config instead")
|
||||
ErrURLUnknownParam = errors.New("unknown query parameter in storage URL")
|
||||
ErrURLBadQuery = errors.New(
|
||||
"storage URL query cannot be decoded; " +
|
||||
"check it for a ; or a % not followed by two hex digits")
|
||||
ErrRcloneRemoteParams = errors.New(
|
||||
"rclone URL must not carry parameters in the remote name; " +
|
||||
"set them on the remote with rclone config instead")
|
||||
)
|
||||
|
||||
// URL represents a parsed storage URL.
|
||||
@@ -96,9 +90,8 @@ func parseFileURL(rawPath string) (*URL, error) {
|
||||
}
|
||||
|
||||
// parseS3URL parses an s3://bucket/prefix URL. It rejects credentials in
|
||||
// the userinfo or in the endpoint parameter's userinfo, a query that
|
||||
// cannot be decoded, and any query parameter other than endpoint, region
|
||||
// and ssl, so a credential-bearing URL is never stored or echoed.
|
||||
// the userinfo and any query parameter other than endpoint, region and
|
||||
// ssl, so a credential-bearing URL is never stored or echoed.
|
||||
func parseS3URL(rawURL string) (*URL, error) {
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
@@ -114,26 +107,13 @@ func parseS3URL(rawURL string) (*URL, error) {
|
||||
return nil, ErrMissingBucket
|
||||
}
|
||||
|
||||
// u.Query() would silently drop a part it cannot decode, endpoint
|
||||
// included. The decode error is not wrapped: it can quote the value.
|
||||
query, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
return nil, ErrURLBadQuery
|
||||
}
|
||||
query := u.Query()
|
||||
|
||||
err = rejectUnknownParams(query, "endpoint", "region", "ssl")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// A host name never contains "@", so one in an endpoint, with or
|
||||
// without a scheme, can only introduce userinfo.
|
||||
for _, endpoint := range query["endpoint"] {
|
||||
if strings.Contains(endpoint, "@") {
|
||||
return nil, ErrURLCredentials
|
||||
}
|
||||
}
|
||||
|
||||
return &URL{
|
||||
Scheme: schemeS3,
|
||||
Bucket: bucket,
|
||||
@@ -145,23 +125,9 @@ func parseS3URL(rawURL string) (*URL, error) {
|
||||
}
|
||||
|
||||
// parseRcloneURL parses an rclone://remote/path URL. rclone:// takes no
|
||||
// query parameters, so credentials in the userinfo, parameters in the
|
||||
// remote name, a query that cannot be decoded and any query parameter at
|
||||
// query parameters, so credentials in the userinfo and any parameter at
|
||||
// all are rejected rather than silently ignored.
|
||||
func parseRcloneURL(rawURL string) (*URL, error) {
|
||||
// The remote name runs up to the first "/", "?" or "#". It never
|
||||
// contains a comma; one starts rclone's remote,param=value form,
|
||||
// whose values can be credentials. This is checked before url.Parse,
|
||||
// whose error for such a name can print part of it.
|
||||
remote := strings.TrimPrefix(rawURL, "rclone://")
|
||||
if end := strings.IndexAny(remote, "/?#"); end >= 0 {
|
||||
remote = remote[:end]
|
||||
}
|
||||
|
||||
if strings.Contains(remote, ",") {
|
||||
return nil, ErrRcloneRemoteParams
|
||||
}
|
||||
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return nil, wrapParseError(err)
|
||||
@@ -171,19 +137,12 @@ func parseRcloneURL(rawURL string) (*URL, error) {
|
||||
return nil, ErrURLCredentials
|
||||
}
|
||||
|
||||
remote = u.Host
|
||||
remote := u.Host
|
||||
if remote == "" {
|
||||
return nil, ErrMissingRemote
|
||||
}
|
||||
|
||||
// As in parseS3URL: u.Query() would drop a part it cannot decode, and
|
||||
// the decode error can quote the value.
|
||||
query, err := url.ParseQuery(u.RawQuery)
|
||||
if err != nil {
|
||||
return nil, ErrURLBadQuery
|
||||
}
|
||||
|
||||
err = rejectUnknownParams(query)
|
||||
err = rejectUnknownParams(u.Query())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -150,19 +150,6 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{key, secret, "mybucket"},
|
||||
},
|
||||
{
|
||||
name: "s3 userinfo in endpoint",
|
||||
raw: "s3://mybucket?endpoint=https://" +
|
||||
key + ":" + secret + "@minio.example.com",
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{key, secret},
|
||||
},
|
||||
{
|
||||
name: "s3 userinfo in endpoint without scheme",
|
||||
raw: "s3://mybucket?endpoint=" + key + ":" + secret + "@minio.example.com",
|
||||
wantErr: storage.ErrURLCredentials,
|
||||
secrets: []string{key, secret},
|
||||
},
|
||||
{
|
||||
name: "s3 unknown query param",
|
||||
raw: "s3://mybucket?access_key=" + key + "&secret=" + secret,
|
||||
@@ -216,112 +203,6 @@ func TestParseStorageURLRejectsCredentials(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLRejectsRcloneRemoteParams checks that an rclone
|
||||
// remote name carrying parameters (rclone's "remote,param=value" form,
|
||||
// which can hold a credential) is rejected without echoing the value,
|
||||
// including the URLs that url.Parse itself refuses.
|
||||
func TestParseStorageURLRejectsRcloneRemoteParams(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
secrets []string // must not appear in the error message
|
||||
}{
|
||||
{
|
||||
name: "configured remote",
|
||||
raw: "rclone://gdrive,client_secret=topsecret/backups",
|
||||
secrets: []string{"topsecret"},
|
||||
},
|
||||
{
|
||||
name: "remote defined inline",
|
||||
raw: "rclone://:s3,access_key_id=KEY,secret_access_key=SECRET/bucket",
|
||||
secrets: []string{"KEY", "SECRET"},
|
||||
},
|
||||
{
|
||||
name: "colon in a parameter value",
|
||||
raw: "rclone://gdrive,client_secret=ab:cd/backups",
|
||||
secrets: []string{"cd"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := storage.ParseStorageURL(tc.raw)
|
||||
if !errors.Is(err, storage.ErrRcloneRemoteParams) {
|
||||
t.Fatalf("ParseStorageURL(%q) error = %v, want %v",
|
||||
tc.raw, err, storage.ErrRcloneRemoteParams)
|
||||
}
|
||||
|
||||
for _, secret := range tc.secrets {
|
||||
if strings.Contains(err.Error(), secret) {
|
||||
t.Errorf("error message leaked %q: %v", secret, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLRejectsUndecodableQuery checks that an s3 or rclone
|
||||
// URL whose query cannot be decoded is rejected, not read with that part
|
||||
// of the query left out, and that the error does not echo it.
|
||||
func TestParseStorageURLRejectsUndecodableQuery(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
key = "AKIAKEY"
|
||||
secret = "topsecret"
|
||||
)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{
|
||||
name: "semicolon",
|
||||
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||
"@minio.example.com:9000;region=us-east-1",
|
||||
},
|
||||
{
|
||||
name: "bad percent escape",
|
||||
raw: "s3://mybucket/prefix?endpoint=https://" + key + ":" + secret +
|
||||
"%zz@minio.example.com:9000",
|
||||
},
|
||||
{
|
||||
name: "rclone semicolon",
|
||||
raw: "rclone://gdrive/backups?token=" + secret + ";x=1",
|
||||
},
|
||||
{
|
||||
name: "rclone bad percent escape",
|
||||
raw: "rclone://gdrive/backups?token=" + secret + "%zz",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := storage.ParseStorageURL(tc.raw)
|
||||
if err == nil {
|
||||
t.Fatalf("ParseStorageURL(%q) returned no error", tc.raw)
|
||||
}
|
||||
|
||||
if !errors.Is(err, storage.ErrURLBadQuery) {
|
||||
t.Errorf("ParseStorageURL(%q) error = %v, want %v",
|
||||
tc.raw, err, storage.ErrURLBadQuery)
|
||||
}
|
||||
|
||||
for _, s := range []string{key, secret, "minio.example.com"} {
|
||||
if strings.Contains(err.Error(), s) {
|
||||
t.Errorf("error message leaked %q: %v", s, err.Error())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseStorageURLParseFailureHidesURL checks that when url.Parse
|
||||
// itself fails, the wrapped error carries only the inner cause, not the
|
||||
// *url.Error whose text embeds the raw (possibly credential-bearing) URL.
|
||||
|
||||
@@ -104,8 +104,7 @@ func (v *Vaultik) FetchAndDecryptBlob(
|
||||
|
||||
// FetchBlob downloads a blob and returns a reader for the encrypted data.
|
||||
// The reader is closed as soon as ctx is cancelled, which ends a read
|
||||
// blocked inside it. A storage error is returned as is: restore and deep
|
||||
// verify name the blob in their own errors.
|
||||
// blocked inside it.
|
||||
func (v *Vaultik) FetchBlob(
|
||||
ctx context.Context, blobHash string,
|
||||
) (io.ReadCloser, error) {
|
||||
@@ -120,7 +119,7 @@ func (v *Vaultik) FetchBlob(
|
||||
|
||||
rc, err := v.Storage.Get(ctx, blobPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("downloading blob %s: %w", shortHash(blobHash), err)
|
||||
}
|
||||
|
||||
return newCloseOnCancelReader(ctx, rc), nil
|
||||
|
||||
@@ -39,9 +39,9 @@ type blobDiskCacheEntry struct {
|
||||
// internally and will fall back to LRU eviction if curBytes exceeds
|
||||
// maxBytes. Restore passes math.MaxInt64 as maxBytes and drives
|
||||
// eviction itself via Delete() through restoreSweeper, which deletes
|
||||
// each blob once no file still to be restored in this run needs it.
|
||||
// LRU never fires under that configuration; it is kept as a safety net
|
||||
// for callers that don't manage eviction themselves.
|
||||
// each blob the moment every file that references its chunks has been
|
||||
// written. LRU never fires under that configuration; it is kept as a
|
||||
// safety net for callers that don't manage eviction themselves.
|
||||
//
|
||||
// Get/ReadAt/peak-Len counters are debugging instrumentation used by
|
||||
// tests to assert that the restore code path uses ReadAt rather than
|
||||
|
||||
@@ -137,7 +137,7 @@ func TestBackupAfterRemovingNewestSnapshotRestoresChangedFile(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
changedPath := filepath.Join(dataDir, "appended.bin")
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"crypto/rand"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -35,7 +34,7 @@ func TestDeepVerifyAcceptsHealthyAndRejectsCorruptBlob(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
@@ -94,7 +93,7 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||
|
||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||
|
||||
@@ -143,15 +142,14 @@ func TestDeepVerifyJSONCountsBlobs(t *testing.T) {
|
||||
|
||||
// TestDeepVerifyJSONCountsWrongSizeBlob checks that under --deep --json a
|
||||
// blob of the wrong size is counted in mismatched and fails the run, and
|
||||
// that the check goes on past it to count a missing blob after it. The
|
||||
// store is on disk, so the message must not call it S3.
|
||||
// that the check goes on past it to count a missing blob after it.
|
||||
func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||
|
||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||
|
||||
@@ -170,20 +168,17 @@ func TestDeepVerifyJSONCountsWrongSizeBlob(t *testing.T) {
|
||||
require.Equal(t, 1, got.Mismatched)
|
||||
require.Equal(t, 1, got.Missing)
|
||||
require.Zero(t, got.Verified)
|
||||
require.Contains(t, got.ErrorMessage, "with the wrong size")
|
||||
require.NotContains(t, got.ErrorMessage, "S3")
|
||||
}
|
||||
|
||||
// TestDeepVerifyStopsOnBlobThatCannotBeChecked checks that a blob that is
|
||||
// present in storage but cannot be checked is not counted as missing:
|
||||
// deep verify stops and fails, saying it could not check the blob, with
|
||||
// the storage error.
|
||||
// deep verify stops and fails with the storage error.
|
||||
func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(t.TempDir(), "remote")
|
||||
|
||||
cfg, storer, snapshotID, keys := backUpSeveralBlobs(ctx, t, storeDir)
|
||||
failing := statFailingStorer{Storer: storer, failKeys: []string{keys[0]}}
|
||||
@@ -192,8 +187,6 @@ func TestDeepVerifyStopsOnBlobThatCannotBeChecked(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Equal(t, "failed", got.Status)
|
||||
require.Zero(t, got.Missing)
|
||||
require.Contains(t, got.ErrorMessage,
|
||||
"could not check blob "+path.Base(keys[0]))
|
||||
require.Contains(t, got.ErrorMessage, errStatDenied.Error())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
package vaultik
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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 without it 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 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 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.
|
||||
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)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking for %s: %w", destinationMarkerKey, 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
|
||||
}
|
||||
@@ -1,297 +0,0 @@
|
||||
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/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 without 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)
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
@@ -192,7 +192,7 @@ func assertRestoreRejectsDamagedBlob(
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -298,6 +298,10 @@ 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)
|
||||
|
||||
@@ -361,7 +365,7 @@ func TestBackupRetryAfterInterruptedUploadIsRestorable(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -425,7 +429,7 @@ func TestBackupSurvivesMetadataExportInterruption(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -441,10 +445,6 @@ 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,14 +493,8 @@ 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
|
||||
@@ -517,7 +511,7 @@ func TestBackupCompletesOnlyAfterMetadataExport(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -678,7 +672,6 @@ func faultScannerFactory(
|
||||
UI: params.UI,
|
||||
Exclude: params.Exclude,
|
||||
SkipErrors: params.SkipErrors,
|
||||
PreviousSnapshotID: params.PreviousSnapshotID,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -693,7 +686,7 @@ func TestRestoreReportsDiskFull(t *testing.T) {
|
||||
osFS := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -457,9 +457,9 @@ func (v *Vaultik) scanRemoteBlobStorage(
|
||||
return err
|
||||
}
|
||||
|
||||
for _, blob := range allBlobs {
|
||||
for _, size := range allBlobs {
|
||||
result.TotalBlobCount++
|
||||
result.TotalBlobSize += blob.Size
|
||||
result.TotalBlobSize += 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, blob := range allBlobs {
|
||||
for hash, size := range allBlobs {
|
||||
if _, referenced := referencedBlobs[hash]; !referenced {
|
||||
orphanedCount++
|
||||
orphanedSize += blob.Size
|
||||
orphanedSize += size
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -771,7 +771,7 @@ func TestEndToEndFileStorage(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -24,11 +24,13 @@ 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.
|
||||
|
||||
// newFileDestinationVaultik returns a Vaultik that backs up the
|
||||
// snapshot named "first" to a file:// destination at storeDir.
|
||||
func newFileDestinationVaultik(
|
||||
// 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(
|
||||
ctx context.Context, t *testing.T, storeDir string,
|
||||
) (*vaultik.Vaultik, *database.Repositories) {
|
||||
) (*vaultik.Vaultik, *database.Repositories, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
osFs := afero.NewOsFs()
|
||||
@@ -47,22 +49,8 @@ func newFileDestinationVaultik(
|
||||
|
||||
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{}
|
||||
@@ -87,28 +75,21 @@ func backUpThenUnplug(
|
||||
return v, repos, out
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
volume := filepath.Join(t.TempDir(), "volume")
|
||||
v, repos := newFileDestinationVaultik(ctx, t, filepath.Join(volume, "backup"))
|
||||
storeDir := filepath.Join(t.TempDir(), "volume", "backup")
|
||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||
|
||||
err := backUp(v, "first")
|
||||
require.ErrorIs(t, err, fs.ErrNotExist)
|
||||
require.ErrorContains(t, err, "create it, or mount the volume it is on")
|
||||
require.NoError(t, v.ListSnapshots(false))
|
||||
|
||||
_, 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)
|
||||
assert.NotContains(t, out.String(), "Could not list backup destination store")
|
||||
assert.NotContains(t, out.String(), "not found in backup destination store")
|
||||
}
|
||||
|
||||
// TestListSnapshotsWarnsWhenDestinationMissing checks that snapshot list
|
||||
@@ -188,41 +169,3 @@ 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")
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestNewChunkSizeOfChangedFileStaysFixed(t *testing.T) {
|
||||
|
||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||
|
||||
store, err := storage.NewFileStorer(t.TempDir())
|
||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
|
||||
@@ -104,32 +104,6 @@ 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.
|
||||
@@ -180,7 +154,8 @@ func TestNukeRemoteRefusesAnotherDestinationWithoutRetryAdvice(t *testing.T) {
|
||||
|
||||
// TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved checks that a
|
||||
// snapshot remote nuke cannot remove from the local index fails the
|
||||
// run, and that nuke then does not report the destination store empty.
|
||||
// run: nuke then leaves that snapshot's metadata on the destination
|
||||
// store.
|
||||
func TestNukeRemoteFailsWhenALocalSnapshotCannotBeRemoved(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
+25
-87
@@ -5,10 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/storage"
|
||||
)
|
||||
|
||||
// PruneOptions contains options for the prune command
|
||||
@@ -44,10 +42,9 @@ 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, 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.
|
||||
// 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.
|
||||
//
|
||||
// Refuses to run unless force is true. The caller is responsible for
|
||||
// confirming with the user.
|
||||
@@ -57,19 +54,12 @@ 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 these errors.
|
||||
// The listing also fails on a destination without the marker while
|
||||
// the local index holds snapshots.
|
||||
// the same way, so the advice below must not follow this error.
|
||||
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 "+
|
||||
@@ -115,13 +105,6 @@ 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
|
||||
}
|
||||
|
||||
@@ -165,10 +148,6 @@ 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"`
|
||||
}
|
||||
|
||||
// Prune removes orphaned data from the local index database AND
|
||||
@@ -214,15 +193,9 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
unreferencedBlobs, totalSize, tooNew := v.findUnreferencedBlobs(
|
||||
allBlobs, allBlobsReferenced)
|
||||
unreferencedBlobs, totalSize := v.findUnreferencedBlobs(allBlobs, allBlobsReferenced)
|
||||
|
||||
result := &PruneBlobsResult{
|
||||
BlobsFound: len(unreferencedBlobs),
|
||||
BlobsTooNew: tooNew,
|
||||
}
|
||||
|
||||
v.reportBlobsTooNew(tooNew, opts)
|
||||
result := &PruneBlobsResult{BlobsFound: len(unreferencedBlobs)}
|
||||
|
||||
if len(unreferencedBlobs) == 0 {
|
||||
log.Info("No unreferenced blobs found")
|
||||
@@ -231,7 +204,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
return v.outputPruneBlobsJSON(result)
|
||||
}
|
||||
|
||||
v.UI.Infof("No unreferenced blobs to remove.")
|
||||
v.printlnStdout("No unreferenced blobs to remove.")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -239,17 +212,12 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
log.Info("Found unreferenced blobs",
|
||||
"count", len(unreferencedBlobs), "total_size", ubytes(totalSize))
|
||||
|
||||
if opts.Force && !opts.JSON {
|
||||
v.UI.Infof("Found %d unreferenced blob(s) totaling %s.",
|
||||
if !opts.JSON {
|
||||
v.stdoutf("Found %d unreferenced blob(s) totaling %s\n",
|
||||
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
|
||||
@@ -276,7 +244,7 @@ func (v *Vaultik) PruneBlobs(opts *PruneOptions) error {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
v.UI.Infof("Deleted %d blob(s) totaling %s.",
|
||||
v.stdoutf("\nDeleted %d blob(s) totaling %s\n",
|
||||
result.BlobsDeleted, ubytes(result.BytesFreed))
|
||||
}
|
||||
|
||||
@@ -356,12 +324,11 @@ func (v *Vaultik) listUniqueSnapshotIDs() ([]string, error) {
|
||||
return snapshotIDs, nil
|
||||
}
|
||||
|
||||
// listAllRemoteBlobs returns a map of all blob hashes in remote storage
|
||||
// to their listing entries.
|
||||
func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) {
|
||||
// listAllRemoteBlobs returns a map of all blob hashes to their sizes in remote storage
|
||||
func (v *Vaultik) listAllRemoteBlobs() (map[string]int64, error) {
|
||||
log.Info("Listing all blobs in storage")
|
||||
|
||||
allBlobs := make(map[string]storage.ObjectInfo)
|
||||
allBlobs := make(map[string]int64)
|
||||
blobObjectCh := v.Storage.ListStream(v.ctx, "blobs/")
|
||||
|
||||
for object := range blobObjectCh {
|
||||
@@ -385,7 +352,7 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
allBlobs[parts[3]] = object
|
||||
allBlobs[parts[3]] = object.Size
|
||||
}
|
||||
|
||||
log.Info("Found blobs in storage", "count", len(allBlobs))
|
||||
@@ -393,59 +360,30 @@ func (v *Vaultik) listAllRemoteBlobs() (map[string]storage.ObjectInfo, error) {
|
||||
return allBlobs, nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// findUnreferencedBlobs returns blob hashes not referenced by any
|
||||
// manifest and their total size.
|
||||
func (v *Vaultik) findUnreferencedBlobs(
|
||||
allBlobs map[string]storage.ObjectInfo, referenced map[string]bool,
|
||||
) ([]string, int64, int) {
|
||||
allBlobs map[string]int64, referenced map[string]bool,
|
||||
) ([]string, int64) {
|
||||
var (
|
||||
unreferenced []string
|
||||
totalSize int64
|
||||
tooNew int
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
for hash, size := range allBlobs {
|
||||
if !referenced[hash] {
|
||||
unreferenced = append(unreferenced, hash)
|
||||
totalSize += blob.Size
|
||||
totalSize += size
|
||||
}
|
||||
}
|
||||
|
||||
return unreferenced, totalSize, tooNew
|
||||
}
|
||||
|
||||
// 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)
|
||||
return unreferenced, totalSize
|
||||
}
|
||||
|
||||
// deleteUnreferencedBlobs deletes the given blobs from storage and
|
||||
// populates the result.
|
||||
func (v *Vaultik) deleteUnreferencedBlobs(
|
||||
unreferencedBlobs []string, allBlobs map[string]storage.ObjectInfo,
|
||||
result *PruneBlobsResult,
|
||||
unreferencedBlobs []string, allBlobs map[string]int64, result *PruneBlobsResult,
|
||||
) {
|
||||
log.Info("Deleting unreferenced blobs")
|
||||
|
||||
@@ -460,7 +398,7 @@ func (v *Vaultik) deleteUnreferencedBlobs(
|
||||
}
|
||||
|
||||
result.BlobsDeleted++
|
||||
result.BytesFreed += allBlobs[hash].Size
|
||||
result.BytesFreed += allBlobs[hash]
|
||||
|
||||
if (i+1)%progressLogEvery == 0 || i == len(unreferencedBlobs)-1 {
|
||||
log.Info("Deletion progress",
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
package vaultik //nolint:testpackage // exercises unexported count helpers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"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/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// TestTableCountForReportSurfacesReadFailure is the regression guard for
|
||||
@@ -80,59 +76,3 @@ func TestCountDiffUnknownWhenEitherSideUnknown(t *testing.T) {
|
||||
assert.Nil(t, countDiff(&before, nil), "unknown after yields unknown delta")
|
||||
assert.Nil(t, countDiff(nil, nil))
|
||||
}
|
||||
|
||||
// TestPruneDatabaseSummaryLabelsTotals removes the only incomplete
|
||||
// snapshot from an index that also holds a complete one. The figure in
|
||||
// brackets is every snapshot left in the local index, so the line must
|
||||
// say so rather than read as incomplete snapshots that remain.
|
||||
func TestPruneDatabaseSummaryLabelsTotals(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
db, err := database.New(ctx, ":memory:")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
|
||||
startedAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
completedAt := startedAt.Add(time.Minute)
|
||||
|
||||
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
||||
ID: "host_complete_2026-01-02T03:04:05Z",
|
||||
Hostname: "host",
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: &completedAt,
|
||||
}))
|
||||
require.NoError(t, repos.Snapshots.Create(ctx, nil, &database.Snapshot{
|
||||
ID: "host_incomplete_2026-01-02T03:04:05Z",
|
||||
Hostname: "host",
|
||||
StartedAt: startedAt,
|
||||
}))
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v := &Vaultik{
|
||||
DB: db,
|
||||
Repositories: repos,
|
||||
SnapshotManager: snapshot.NewSnapshotManager(
|
||||
snapshot.SnapshotManagerParams{Repos: repos}),
|
||||
UI: ui.NewWithColor(&out, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
result, err := v.PruneDatabase()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), result.SnapshotsDeleted)
|
||||
|
||||
assert.Contains(t, out.String(), "Incomplete snapshots: 1 removed "+
|
||||
"(total snapshots left in the local index: 1).")
|
||||
assert.Contains(t, out.String(), "Orphaned files: 0 removed "+
|
||||
"(total files left in the local index: 0).")
|
||||
assert.Contains(t, out.String(), "Orphaned chunks: 0 removed "+
|
||||
"(total chunks left in the local index: 0).")
|
||||
assert.Contains(t, out.String(), "Orphaned blobs: 0 removed "+
|
||||
"(total blobs left in the local index: 0).")
|
||||
}
|
||||
|
||||
@@ -1,166 +0,0 @@
|
||||
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 but not yet its manifest, so no manifest lists those blobs. The
|
||||
// prune must leave them, 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")
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
@@ -35,9 +34,6 @@ 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])
|
||||
@@ -62,16 +58,13 @@ func setupConsistencyTest(
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
stdout := &bytes.Buffer{}
|
||||
|
||||
v := &vaultik.Vaultik{
|
||||
Storage: mockStorage,
|
||||
Repositories: repos,
|
||||
DB: db,
|
||||
Stdout: stdout,
|
||||
Stdout: &bytes.Buffer{},
|
||||
Stderr: &bytes.Buffer{},
|
||||
Stdin: &bytes.Buffer{},
|
||||
UI: ui.NewWithColor(stdout, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/snapshot"
|
||||
"sneak.berlin/go/vaultik/internal/types"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
@@ -45,9 +44,6 @@ 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 {
|
||||
@@ -90,7 +86,6 @@ func setupPurgeTest(
|
||||
Stdout: stdout,
|
||||
Stderr: stderr,
|
||||
Stdin: stdin,
|
||||
UI: ui.NewWithColor(stdout, false),
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
@@ -284,8 +279,7 @@ func TestPurgeOlderThan_TooLargeDeletesNothing(t *testing.T) {
|
||||
OlderThan: "1000y",
|
||||
Force: true,
|
||||
})
|
||||
require.EqualError(t, err,
|
||||
`invalid duration: "1000y" is longer than about 292 years`)
|
||||
require.Error(t, err)
|
||||
|
||||
err = v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
OlderThan: "292y",
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// TestPurgeSnapshots_QuietSuppressesReport checks that `snapshot purge
|
||||
// --keep-latest --force` under --quiet deletes the older snapshot and
|
||||
// prints nothing.
|
||||
func TestPurgeSnapshots_QuietSuppressesReport(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v.Stdout = &out
|
||||
v.UI = ui.NewWithColor(&out, false)
|
||||
v.UI.SetQuiet(true)
|
||||
|
||||
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
Force: true,
|
||||
}))
|
||||
|
||||
assert.Equal(t, []string{snapHomeT1}, listRemainingSnapshots(t, v))
|
||||
assert.Empty(t, out.String())
|
||||
}
|
||||
|
||||
// TestPurgeSnapshots_QuietPromptListsSnapshots checks that `snapshot
|
||||
// purge --keep-latest` without --force still lists the snapshots its
|
||||
// prompt asks to delete under --quiet.
|
||||
func TestPurgeSnapshots_QuietPromptListsSnapshots(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
v := setupPurgeTest(t, snapHostname, []string{snapHomeT0, snapHomeT1})
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
v.Stdout = &out
|
||||
v.UI = ui.NewWithColor(&out, false)
|
||||
v.UI.SetQuiet(true)
|
||||
|
||||
// Stdin is empty, so the prompt reads no answer and cancels.
|
||||
require.NoError(t, v.PurgeSnapshotsWithOptions(&vaultik.SnapshotPurgeOptions{
|
||||
KeepLatest: true,
|
||||
}))
|
||||
|
||||
assert.Contains(t, out.String(), snapHomeT0)
|
||||
assert.Contains(t, out.String(), "Delete 1 snapshot(s)? [y/N]")
|
||||
assert.Len(t, listRemainingSnapshots(t, v), 2)
|
||||
}
|
||||
|
||||
// TestPruneBlobs_QuietPromptShowsBlobTotal checks that `prune` without
|
||||
// --force still states the count and total size of the blobs its prompt
|
||||
// asks to delete under --quiet.
|
||||
func TestPruneBlobs_QuietPromptShowsBlobTotal(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newListEnv(t)
|
||||
env.v.UI.SetQuiet(true)
|
||||
|
||||
// The store holds no manifest, so nothing references this blob.
|
||||
addBlob(t, env.store.testStorer, testBlobHashA)
|
||||
|
||||
// Stdin is empty, so the prompt reads no answer and cancels.
|
||||
require.NoError(t, env.v.PruneBlobs(&vaultik.PruneOptions{}))
|
||||
|
||||
assert.Contains(t, env.stdout.String(),
|
||||
"Found 1 unreferenced blob(s) totaling")
|
||||
assert.Contains(t, env.stdout.String(),
|
||||
"Delete 1 unreferenced blob(s)? [y/N]")
|
||||
}
|
||||
|
||||
// TestRemoveSnapshot_QuietSuppressesReport checks that `snapshot remove`
|
||||
// under --quiet prints nothing with --dry-run or with --force, and that
|
||||
// the --json document still prints.
|
||||
func TestRemoveSnapshot_QuietSuppressesReport(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
newQuietRemover := func() (*vaultik.TestVaultik, *testStorer) {
|
||||
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)
|
||||
|
||||
return tv, store
|
||||
}
|
||||
|
||||
tv, store := newQuietRemover()
|
||||
|
||||
_, err := tv.RemoveSnapshot("snapshot-001",
|
||||
&vaultik.RemoveOptions{DryRun: true})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, tv.Stdout.String(),
|
||||
"--dry-run must print nothing under --quiet")
|
||||
|
||||
_, err = tv.RemoveSnapshot("snapshot-001",
|
||||
&vaultik.RemoveOptions{Force: true})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||
assert.Empty(t, tv.Stdout.String(),
|
||||
"--force must print nothing under --quiet")
|
||||
|
||||
tv, _ = newQuietRemover()
|
||||
|
||||
_, err = tv.RemoveSnapshot("snapshot-001",
|
||||
&vaultik.RemoveOptions{JSON: true})
|
||||
require.NoError(t, err)
|
||||
|
||||
var result vaultik.RemoveResult
|
||||
|
||||
require.NoError(t, json.Unmarshal(tv.Stdout.Bytes(), &result),
|
||||
"the --json document must still print under --quiet")
|
||||
assert.True(t, result.RemoteRemoved)
|
||||
}
|
||||
|
||||
// TestNukeRemote_QuietSuppressesReport checks that `remote nuke --force`
|
||||
// under --quiet prints nothing while it removes a snapshot's metadata
|
||||
// and its blobs.
|
||||
func TestNukeRemote_QuietSuppressesReport(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
ctx := context.Background()
|
||||
storeDir := filepath.Join(t.TempDir(), "store")
|
||||
v, _, out := backUpToFileDestination(ctx, t, storeDir)
|
||||
v.UI.SetQuiet(true)
|
||||
|
||||
require.NoError(t, v.NukeRemote(true))
|
||||
assert.Empty(t, out.String())
|
||||
}
|
||||
@@ -26,14 +26,11 @@ 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),
|
||||
putTimes: make(map[string]time.Time),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +44,6 @@ func (s *testStorer) Put(_ context.Context, key string, reader io.Reader) error
|
||||
}
|
||||
|
||||
s.data[key] = data
|
||||
s.putTimes[key] = time.Now().UTC()
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -91,7 +87,6 @@ func (s *testStorer) Delete(_ context.Context, key string) error {
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.data, key)
|
||||
delete(s.putTimes, key)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -127,7 +122,6 @@ func (s *testStorer) ListStream(
|
||||
ch <- storage.ObjectInfo{
|
||||
Key: key,
|
||||
Size: int64(len(data)),
|
||||
ModTime: s.putTimes[key],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -310,10 +304,6 @@ 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")
|
||||
|
||||
@@ -361,7 +351,7 @@ func TestRemoveSnapshot_DryRun(t *testing.T) {
|
||||
assert.True(t, store.hasKey("blobs/aa/aa/"+blobA))
|
||||
assert.True(t, store.hasKey(remoteKeyPath("snapshot-001")))
|
||||
|
||||
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
|
||||
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
||||
}
|
||||
|
||||
// TestRemoveSnapshot_UnknownIDIsError checks that an ID naming no
|
||||
@@ -383,10 +373,6 @@ 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")
|
||||
|
||||
@@ -407,12 +393,7 @@ func TestRemoveSnapshot_NoMetadataOnDestination(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
// 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 := vaultik.NewForTesting(newTestStorer())
|
||||
tv.Repositories = newLocalIndex(t, "snapshot-001")
|
||||
|
||||
result, err := tv.RemoveSnapshot("snapshot-001",
|
||||
@@ -558,7 +539,7 @@ func TestRemoveAllSnapshots_DryRun(t *testing.T) {
|
||||
|
||||
assert.Equal(t, initialCount, store.keyCount())
|
||||
|
||||
assert.Contains(t, tv.Stdout.String(), "Dry run: no changes made.")
|
||||
assert.Contains(t, tv.Stdout.String(), "[Dry run - no changes made]")
|
||||
}
|
||||
|
||||
func TestRemoveAllSnapshots_NoSnapshots(t *testing.T) {
|
||||
|
||||
+15
-50
@@ -12,7 +12,6 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
@@ -220,7 +219,9 @@ func (v *Vaultik) finishRestore(
|
||||
)
|
||||
|
||||
if os.Geteuid() != 0 {
|
||||
v.warnIfOwnershipNotPreserved(files, opts.TargetDir)
|
||||
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.")
|
||||
}
|
||||
|
||||
if result.FilesFailed > 0 {
|
||||
@@ -246,43 +247,6 @@ 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.
|
||||
@@ -359,6 +323,12 @@ func (v *Vaultik) restoreAllFiles(
|
||||
_ = blobCache.Close()
|
||||
}()
|
||||
|
||||
// Per-restore sweep state: every blob_size_limit/100 bytes written,
|
||||
// scan the cache and delete any blob whose remaining file references
|
||||
// are all already restored.
|
||||
sweeper := newRestoreSweeper(v.ctx, repos, blobCache,
|
||||
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
|
||||
|
||||
blobByHash, blobIDToHash, err := v.buildBlobIndexes(repos)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -370,12 +340,6 @@ func (v *Vaultik) restoreAllFiles(
|
||||
return nil, fmt.Errorf("building restore plan: %w", err)
|
||||
}
|
||||
|
||||
// Per-restore sweep state: every blob_size_limit/100 bytes written,
|
||||
// scan the cache and delete any blob that no pending file in the
|
||||
// plan references.
|
||||
sweeper := newRestoreSweeper(plan, blobCache,
|
||||
v.Config.BlobSizeLimit.Int64()/sweepIntervalDivisor)
|
||||
|
||||
// Ranging over files rather than the map reports them in a stable order.
|
||||
for _, file := range files {
|
||||
if planErr, ok := unplanned[file.ID]; ok {
|
||||
@@ -463,8 +427,10 @@ func (v *Vaultik) runRestoreLoop(
|
||||
continue
|
||||
}
|
||||
|
||||
// Drop the file from the plan's indexes so future picks ignore
|
||||
// it and the sweeper can free blobs no pending file needs.
|
||||
// Record the file as restored so the sweeper can free blobs
|
||||
// once all referencing files are done, and drop it from the
|
||||
// plan's indexes so future picks ignore it.
|
||||
session.sweeper.fileRestored(fileID.String())
|
||||
plan.finishFile(fileID)
|
||||
|
||||
processed++
|
||||
@@ -1001,9 +967,8 @@ 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; warnIfOwnershipNotPreserved warns
|
||||
// once at the end of the restore if that left any owner or group
|
||||
// other than recorded.
|
||||
// the call entirely as non-root and emit one warning at the end
|
||||
// of the restore explaining that ownership was not preserved.
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(root, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(root, "remote")
|
||||
restoreDir := filepath.Join(root, "restored")
|
||||
dbPath := filepath.Join(root, "index.sqlite")
|
||||
|
||||
@@ -74,8 +74,7 @@ func TestRestoreCleansTempDirOnInterrupt(t *testing.T) {
|
||||
buildLocalityFixture(t, fs, dataDir)
|
||||
|
||||
cfg, storer, snapshotID := setupLocalityBackup(
|
||||
context.Background(), t, fs, dataDir, storeDir, dbPath,
|
||||
localitySourceBytes)
|
||||
context.Background(), t, fs, dataDir, storeDir, dbPath)
|
||||
|
||||
// Point the "" temp paths (the blob cache directory and the
|
||||
// snapshot-database directory) at a private directory so the test can
|
||||
|
||||
@@ -63,10 +63,6 @@ type localityCopy struct {
|
||||
data []byte
|
||||
}
|
||||
|
||||
// localitySourceBytes is the size of each source file of the locality
|
||||
// fixture.
|
||||
const localitySourceBytes = 1024 * 1024
|
||||
|
||||
// buildLocalityFixture writes the adversarial source layout described
|
||||
// in TestRestoreLocalityAndReadAt: 15 sources of 1 MiB each (which the
|
||||
// backup packs into 3 blobs of 5 chunks) plus 9 interleaved-name copies
|
||||
@@ -77,6 +73,7 @@ func buildLocalityFixture(
|
||||
t.Helper()
|
||||
|
||||
const (
|
||||
srcBytes = 1024 * 1024
|
||||
srcCount = 15
|
||||
blobsCount = 3
|
||||
perBlob = srcCount / blobsCount
|
||||
@@ -86,7 +83,7 @@ func buildLocalityFixture(
|
||||
for i := range srcCount {
|
||||
s := &localitySource{
|
||||
path: fmt.Sprintf("src-%03d.bin", i+1),
|
||||
data: randomBytes(t, localitySourceBytes),
|
||||
data: randomBytes(t, srcBytes),
|
||||
}
|
||||
sources[i] = s
|
||||
require.NoError(t, afero.WriteFile(
|
||||
@@ -118,20 +115,19 @@ func buildLocalityFixture(
|
||||
|
||||
// setupLocalityBackup runs the backup half of the locality test: a
|
||||
// snapshot of dataDir into a file storer with sizes tuned so 15 one-chunk
|
||||
// files of srcBytes pack into 3 blobs. The index database is closed
|
||||
// before returning so the restore half runs from remote bytes only.
|
||||
// files pack into 3 blobs. The index database is closed before returning
|
||||
// so the restore half runs from remote bytes only.
|
||||
func setupLocalityBackup(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||
dataDir, storeDir, dbPath string, srcBytes int64,
|
||||
dataDir, storeDir, dbPath string,
|
||||
) (*config.Config, *storage.FileStorer, string) {
|
||||
t.Helper()
|
||||
|
||||
// An average chunk size of four times srcBytes makes the minimum
|
||||
// chunk srcBytes, so each source file becomes one chunk. A blob limit
|
||||
// of five times srcBytes packs exactly 5 chunks per blob, yielding 3
|
||||
// blobs from 15 source files.
|
||||
chunkSize := 4 * srcBytes
|
||||
maxBlobSize := 5 * srcBytes
|
||||
// chunkSize avg = 4 MiB makes minSize = 1 MiB, so a 1 MiB file
|
||||
// becomes one chunk. maxBlobSize = 5 MiB packs exactly 5 chunks
|
||||
// per blob, yielding 3 blobs from 15 source files.
|
||||
chunkSize := int64(4 * 1024 * 1024)
|
||||
maxBlobSize := int64(5 * 1024 * 1024)
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
require.NoError(t, err)
|
||||
@@ -201,7 +197,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
@@ -212,7 +208,7 @@ func TestRestoreLocalityAndReadAt(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := setupLocalityBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, localitySourceBytes)
|
||||
ctx, t, fs, dataDir, storeDir, dbPath)
|
||||
|
||||
// Wrap the storer so we can count downloads per blob key.
|
||||
counter := newCountingStorer(storer)
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func backupOneFile(
|
||||
require.NoError(t, afero.WriteFile(fs, srcPath, content, mode))
|
||||
require.NoError(t, fs.Chmod(srcPath, mode))
|
||||
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
storer, err := storage.NewFileStorer(storeDir)
|
||||
|
||||
@@ -31,8 +31,7 @@ var (
|
||||
// with no further downloads.
|
||||
// - blobFiles: for each blob, the set of pending files referencing
|
||||
// it. Used to short-circuit "when this blob lands, which files
|
||||
// become ready" without a global scan, and by the sweeper to tell
|
||||
// whether a cached blob is still needed.
|
||||
// become ready" without a global scan.
|
||||
type restorePlan struct {
|
||||
fileBlobs map[types.FileID]map[string]struct{}
|
||||
blobFiles map[string]map[types.FileID]struct{}
|
||||
@@ -238,12 +237,6 @@ func (p *restorePlan) filesReferencingBlob(blobHash string) []types.FileID {
|
||||
return out
|
||||
}
|
||||
|
||||
// pendingFileUsesBlob reports whether any pending file references the
|
||||
// named blob, whether or not the blob is cached.
|
||||
func (p *restorePlan) pendingFileUsesBlob(blobHash string) bool {
|
||||
return len(p.blobFiles[blobHash]) > 0
|
||||
}
|
||||
|
||||
// hasPending reports whether any unfinished files remain.
|
||||
func (p *restorePlan) hasPending() bool {
|
||||
return len(p.fileBlobs) > 0
|
||||
|
||||
@@ -63,6 +63,15 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
|
||||
|
||||
t.Cleanup(func() { _ = cache.Close() })
|
||||
|
||||
v := &Vaultik{ctx: ctx}
|
||||
session := &restoreSession{
|
||||
v: v,
|
||||
ctx: ctx,
|
||||
repos: repos,
|
||||
sweeper: newRestoreSweeper(ctx, repos, cache, 1),
|
||||
result: &RestoreResult{},
|
||||
}
|
||||
|
||||
// A file that is still pending but whose uncached-blob set is empty
|
||||
// and which was never queued as ready: the loop can neither restore
|
||||
// nor download it. This is the abandonment the guard must catch.
|
||||
@@ -74,15 +83,6 @@ func TestRunRestoreLoopFailsOnAbandonedFiles(t *testing.T) {
|
||||
cached: map[string]struct{}{},
|
||||
}
|
||||
|
||||
v := &Vaultik{ctx: ctx}
|
||||
session := &restoreSession{
|
||||
v: v,
|
||||
ctx: ctx,
|
||||
repos: repos,
|
||||
sweeper: newRestoreSweeper(plan, cache, 1),
|
||||
result: &RestoreResult{},
|
||||
}
|
||||
|
||||
err = v.runRestoreLoop(session, plan, map[types.FileID]*database.File{}, 0)
|
||||
require.ErrorIs(t, err, errRestoreIncomplete)
|
||||
}
|
||||
|
||||
@@ -231,8 +231,7 @@ func (f trailingByteFile) Close() error {
|
||||
}
|
||||
|
||||
// TestRestoreMissingBlobAbortsWithoutSkipErrors checks that a deleted blob
|
||||
// still ends the restore with an error when SkipErrors is not set, and
|
||||
// that the error names the blob once.
|
||||
// still ends the restore with an error when SkipErrors is not set.
|
||||
func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
@@ -248,8 +247,6 @@ func TestRestoreMissingBlobAbortsWithoutSkipErrors(t *testing.T) {
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, storage.ErrNotFound)
|
||||
assert.Equal(t, 1, strings.Count(err.Error(), "downloading blob"),
|
||||
err.Error())
|
||||
}
|
||||
|
||||
// TestRestoreChunkInNoBlobAbortsWithoutSkipErrors checks that a chunk held
|
||||
@@ -348,7 +345,7 @@ func backupSingleChunkFiles(
|
||||
require.NoError(t, afero.WriteFile(fs, path, files[path], 0o644))
|
||||
}
|
||||
|
||||
storer, err := storage.NewFileStorer(t.TempDir())
|
||||
storer, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
@@ -100,7 +100,7 @@ func TestDeepVerifyRejectsSwappedDatabaseWithEmptyManifest(t *testing.T) {
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
|
||||
@@ -1,30 +1,46 @@
|
||||
package vaultik
|
||||
|
||||
// restoreSweeper frees cached blobs once no file still to be restored in
|
||||
// this run needs them. It works as follows:
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"sneak.berlin/go/vaultik/internal/database"
|
||||
"sneak.berlin/go/vaultik/internal/log"
|
||||
)
|
||||
|
||||
// restoreSweeper frees cached blobs once all files that reference any of
|
||||
// their chunks have been restored. It works as follows:
|
||||
//
|
||||
// 1. After each chunk is restored, chunkRestored accumulates a running
|
||||
// 1. Callers add a file's ID to an in-memory restored set via
|
||||
// fileRestored once the file is fully written to disk.
|
||||
// 2. After each chunk is restored, chunkRestored accumulates a running
|
||||
// byte count.
|
||||
// 2. When the accumulator crosses a threshold (one hundredth of the
|
||||
// 3. When the accumulator crosses a threshold (one hundredth of the
|
||||
// configured blob size — so a sweep runs about a hundred times per
|
||||
// blob's worth of restored bytes), the sweeper iterates every key in
|
||||
// the cache and deletes each blob that the restore plan no longer
|
||||
// needs.
|
||||
// the cache. For each cached blob it asks the DB which files
|
||||
// reference any chunk in that blob, then compares that list against
|
||||
// the in-memory restored set. If any referencing file is missing
|
||||
// from the set the blob is kept; otherwise the cache entry is
|
||||
// deleted.
|
||||
//
|
||||
// The plan drops a file once it is restored or has failed, and holds
|
||||
// only the files this run restores, so files outside the requested paths
|
||||
// never keep a blob.
|
||||
// All DB reads happen against the snapshot's temporary metadata DB,
|
||||
// which is local, indexed, and not under contention — the queries are
|
||||
// cheap and run at most once per blob per sweep interval.
|
||||
type restoreSweeper struct {
|
||||
plan *restorePlan
|
||||
ctx context.Context //nolint:containedctx // ctx bound at construction by design
|
||||
repos *database.Repositories
|
||||
cache *blobDiskCache
|
||||
threshold int64
|
||||
bytesAccum int64
|
||||
restored map[string]struct{}
|
||||
}
|
||||
|
||||
// newRestoreSweeper returns a sweeper that triggers eviction every
|
||||
// `threshold` bytes restored. Callers should pass blob_size_limit/100.
|
||||
func newRestoreSweeper(
|
||||
plan *restorePlan,
|
||||
ctx context.Context,
|
||||
repos *database.Repositories,
|
||||
cache *blobDiskCache,
|
||||
threshold int64,
|
||||
) *restoreSweeper {
|
||||
@@ -33,12 +49,21 @@ func newRestoreSweeper(
|
||||
}
|
||||
|
||||
return &restoreSweeper{
|
||||
plan: plan,
|
||||
ctx: ctx,
|
||||
repos: repos,
|
||||
cache: cache,
|
||||
threshold: threshold,
|
||||
restored: make(map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// fileRestored records a file as fully restored. After this call, any
|
||||
// blob whose only remaining references come from files in the restored
|
||||
// set may be evicted on the next sweep.
|
||||
func (s *restoreSweeper) fileRestored(fileID string) {
|
||||
s.restored[fileID] = struct{}{}
|
||||
}
|
||||
|
||||
// chunkRestored accounts n bytes against the sweep threshold and runs a
|
||||
// sweep if the threshold has been crossed since the last sweep.
|
||||
func (s *restoreSweeper) chunkRestored(n int64) {
|
||||
@@ -51,11 +76,60 @@ func (s *restoreSweeper) chunkRestored(n int64) {
|
||||
s.sweep()
|
||||
}
|
||||
|
||||
// sweep deletes every cached blob that no pending file references.
|
||||
// sweep deletes any cached blob whose chunks are no longer referenced
|
||||
// by an unrestored file. Per-blob DB failures are logged and the blob
|
||||
// is kept — we'd rather hold a blob longer than risk a re-download.
|
||||
func (s *restoreSweeper) sweep() {
|
||||
for _, blobHash := range s.cache.Keys() {
|
||||
if !s.plan.pendingFileUsesBlob(blobHash) {
|
||||
needed, err := s.blobStillNeeded(blobHash)
|
||||
if err != nil {
|
||||
log.Debug("sweeper referencing-files query failed",
|
||||
"blob_hash", blobHash[:16], "error", err)
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if !needed {
|
||||
s.cache.Delete(blobHash)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// blobStillNeeded returns true if any file that references a chunk in
|
||||
// this blob has not yet been restored. On any error the function
|
||||
// returns true — keeping the blob is always the safe answer because we
|
||||
// can't prove we're done with it.
|
||||
func (s *restoreSweeper) blobStillNeeded(blobHash string) (bool, error) {
|
||||
rows, err := s.repos.DB().Conn().QueryContext(s.ctx, `
|
||||
SELECT DISTINCT fc.file_id
|
||||
FROM file_chunks fc
|
||||
JOIN blob_chunks bc ON bc.chunk_hash = fc.chunk_hash
|
||||
JOIN blobs b ON b.id = bc.blob_id
|
||||
WHERE b.blob_hash = ?
|
||||
`, blobHash)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("querying referencing files: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = rows.Close() }()
|
||||
|
||||
for rows.Next() {
|
||||
var fileID string
|
||||
|
||||
err := rows.Scan(&fileID)
|
||||
if err != nil {
|
||||
return true, fmt.Errorf("scanning file_id: %w", err)
|
||||
}
|
||||
|
||||
if _, ok := s.restored[fileID]; !ok {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
err = rows.Err()
|
||||
if err != nil {
|
||||
return true, err
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func TestRestoreSweeperEvictsBlobs(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
package vaultik //nolint:testpackage // reads the restore blob cache's peak size
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"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/storage"
|
||||
"sneak.berlin/go/vaultik/internal/ui"
|
||||
)
|
||||
|
||||
// TestRestoreFreesBlobsNoPendingFileNeeds asserts that the restore cache
|
||||
// frees a blob once no file still to be restored in this run needs it.
|
||||
// Every original file has a copy with the same content, so every blob
|
||||
// holds chunks of copies: a restore that leaves the copies out of its
|
||||
// paths, or fails on them, must not keep the blobs for them.
|
||||
func TestRestoreFreesBlobsNoPendingFileNeeds(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
// Small files keep the backup and both restores quick.
|
||||
const fileBytes = 16 * 1024
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
originalsDir := filepath.Join(dataDir, "originals")
|
||||
copiesDir := filepath.Join(dataDir, "copies")
|
||||
storeDir := t.TempDir()
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
originals := writeFilesWithCopies(t, fs, originalsDir, copiesDir, fileBytes)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cfg, storer, snapshotID := setupLocalityBackup(
|
||||
ctx, t, fs, dataDir, storeDir, dbPath, fileBytes)
|
||||
|
||||
t.Run("restore by path", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
restoreDir := filepath.Join(tempDir, "by-path")
|
||||
|
||||
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
|
||||
&RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: restoreDir,
|
||||
Paths: []string{originalsDir},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
requireRestoredFiles(t, fs, restoreDir, originals)
|
||||
assert.LessOrEqualf(t, peak, 1,
|
||||
"peak cached blobs was %d; blobs were kept for unselected files",
|
||||
peak)
|
||||
})
|
||||
|
||||
t.Run("restore with failed files", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
restoreDir := filepath.Join(tempDir, "with-failures")
|
||||
|
||||
// A regular file where the copies directory belongs makes every
|
||||
// copy fail to restore.
|
||||
blocker := filepath.Join(restoreDir, copiesDir)
|
||||
require.NoError(t, fs.MkdirAll(filepath.Dir(blocker), 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, blocker, nil, 0o644))
|
||||
|
||||
peak, err := restorePeakCachedBlobs(ctx, t, fs, cfg, storer,
|
||||
&RestoreOptions{
|
||||
SnapshotID: snapshotID,
|
||||
TargetDir: restoreDir,
|
||||
SkipErrors: true,
|
||||
})
|
||||
require.ErrorIs(t, err, errFilesFailedRestore)
|
||||
|
||||
requireRestoredFiles(t, fs, restoreDir, originals)
|
||||
assert.LessOrEqualf(t, peak, 1,
|
||||
"peak cached blobs was %d; blobs were kept for failed files", peak)
|
||||
})
|
||||
}
|
||||
|
||||
// writeFilesWithCopies writes 15 random files of fileBytes into
|
||||
// originalsDir and a copy of each into copiesDir. With
|
||||
// setupLocalityBackup given the same size, each file is one chunk and the
|
||||
// 15 chunks fill 3 blobs. Returns the originals' content by path.
|
||||
func writeFilesWithCopies(
|
||||
t *testing.T, fs afero.Fs, originalsDir, copiesDir string, fileBytes int,
|
||||
) map[string][]byte {
|
||||
t.Helper()
|
||||
|
||||
const fileCount = 15
|
||||
|
||||
require.NoError(t, fs.MkdirAll(originalsDir, 0o755))
|
||||
require.NoError(t, fs.MkdirAll(copiesDir, 0o755))
|
||||
|
||||
originals := make(map[string][]byte, fileCount)
|
||||
|
||||
for i := range fileCount {
|
||||
name := fmt.Sprintf("file-%02d.bin", i)
|
||||
data := randomBytes(t, fileBytes)
|
||||
original := filepath.Join(originalsDir, name)
|
||||
originals[original] = data
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, original, data, 0o644))
|
||||
require.NoError(t, afero.WriteFile(
|
||||
fs, filepath.Join(copiesDir, name), data, 0o644))
|
||||
}
|
||||
|
||||
return originals
|
||||
}
|
||||
|
||||
// requireRestoredFiles requires every file in want to have been restored
|
||||
// under restoreDir with its content intact.
|
||||
func requireRestoredFiles(
|
||||
t *testing.T, fs afero.Fs, restoreDir string, want map[string][]byte,
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
for path, data := range want {
|
||||
got, err := afero.ReadFile(fs, filepath.Join(restoreDir, path))
|
||||
require.NoErrorf(t, err, "file missing after restore: %s", path)
|
||||
require.Equalf(t, data, got, "byte mismatch for %s", path)
|
||||
}
|
||||
}
|
||||
|
||||
// restorePeakCachedBlobs runs a restore and returns the most blobs its
|
||||
// cache held at once, together with the restore's error.
|
||||
func restorePeakCachedBlobs(
|
||||
ctx context.Context, t *testing.T, fs afero.Fs,
|
||||
cfg *config.Config, storer storage.Storer, opts *RestoreOptions,
|
||||
) (int, error) {
|
||||
t.Helper()
|
||||
|
||||
var cache *blobDiskCache
|
||||
|
||||
v := &Vaultik{
|
||||
Config: cfg,
|
||||
Storage: storer,
|
||||
Fs: fs,
|
||||
Stdout: io.Discard,
|
||||
Stderr: io.Discard,
|
||||
UI: ui.NewWithColor(io.Discard, false),
|
||||
restoreCacheObserver: func(c *blobDiskCache) {
|
||||
cache = c
|
||||
},
|
||||
}
|
||||
v.SetContext(ctx)
|
||||
|
||||
err := v.Restore(opts)
|
||||
|
||||
require.NotNil(t, cache, "restoreCacheObserver must fire during restore")
|
||||
|
||||
return cache.PeakLen(), err
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func TestRestoreRejectsTruncatedMetadataDB(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
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 := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestVerifyPassesWhenBackupReusesStoredBlob(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
secondDBPath := filepath.Join(tempDir, "second.sqlite")
|
||||
|
||||
chunkSize := int64(64 * 1024)
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestBackupOfSameSecondRewriteRestoresNewContent(t *testing.T) {
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
dataDir := filepath.Join(tempDir, "src")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
rewrittenPath := filepath.Join(dataDir, "small.txt")
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
// then grows one stored blob so its size no longer matches the manifest.
|
||||
// Shallow verify must then fail, count the grown blob as a size mismatch,
|
||||
// and drop it from the verified count rather than continuing to report it
|
||||
// as checked, and its error must not repeat the cli layer's prefix.
|
||||
// as checked.
|
||||
func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
@@ -36,7 +36,7 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
@@ -96,12 +96,6 @@ func TestShallowVerifyDetectsWrongBlobSize(t *testing.T) {
|
||||
require.Equal(t, 1, bad.Mismatched)
|
||||
require.Equal(t, healthy.BlobCount-1, bad.Verified,
|
||||
"the wrong-sized blob must not be counted as verified")
|
||||
|
||||
// The cli layer prints this error after its own "Verification failed:".
|
||||
err = newVerifier(io.Discard).VerifySnapshotWithOptions(
|
||||
snapshotID, &vaultik.VerifyOptions{})
|
||||
require.EqualError(t, err,
|
||||
"problems found in storage: 1 blobs have the wrong size")
|
||||
}
|
||||
|
||||
// TestShallowVerifyDetectsMissingDatabase backs up a real snapshot,
|
||||
@@ -116,7 +110,7 @@ func TestShallowVerifyDetectsMissingDatabase(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
@@ -199,7 +193,7 @@ func TestShallowVerifyReportsStatErrorsApartFromMissing(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
@@ -288,7 +282,7 @@ func TestShallowVerifyQuietFailsOnStatErrorAlone(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
|
||||
+97
-234
@@ -23,7 +23,6 @@ var (
|
||||
errSnapshotNotInConfig = errors.New("snapshot not found in config")
|
||||
errNoSnapshotsInConfig = errors.New("no snapshots configured")
|
||||
errSnapshotVerifyFailed = errors.New("verification failed")
|
||||
errProblemsInStorage = errors.New("problems found in storage")
|
||||
errRemoveAllNeedsForce = errors.New("--all requires --force")
|
||||
errSnapshotNotFound = errors.New("snapshot not found")
|
||||
errSnapshotRemoveFailed = errors.New("snapshot(s) could not be removed")
|
||||
@@ -39,11 +38,6 @@ var (
|
||||
// caller can report it apart from a failed backup.
|
||||
var ErrPostBackupPruneFailed = errors.New("post-backup prune failed")
|
||||
|
||||
// ErrFilesUnreadable is returned when --skip-errors skipped files or
|
||||
// directories it could not read or list. The snapshot is complete and
|
||||
// kept, so the caller can report it apart from a failed backup.
|
||||
var ErrFilesUnreadable = errors.New("file(s) could not be read")
|
||||
|
||||
// listRecentLimit caps how many snapshot rows are fetched from the
|
||||
// local index for listing and purge operations.
|
||||
const listRecentLimit = 10000
|
||||
@@ -100,13 +94,6 @@ 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
|
||||
@@ -129,22 +116,30 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
return fmt.Errorf("prune database: %w", err)
|
||||
}
|
||||
|
||||
snapshotNames, err := v.snapshotNamesToCreate(opts.Snapshots)
|
||||
if err != nil {
|
||||
return 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(snapshotNames) == 0 {
|
||||
return errNoSnapshotsInConfig
|
||||
}
|
||||
|
||||
// Process each named snapshot
|
||||
filesUnreadable := 0
|
||||
|
||||
for snapIdx, snapName := range snapshotNames {
|
||||
unreadable, err := v.createNamedSnapshot(
|
||||
err = v.createNamedSnapshot(
|
||||
opts, hostname, snapName, snapIdx+1, len(snapshotNames))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filesUnreadable += unreadable
|
||||
}
|
||||
|
||||
// Print overall summary if multiple snapshots
|
||||
@@ -153,34 +148,13 @@ func (v *Vaultik) CreateSnapshot(opts *SnapshotCreateOptions) error {
|
||||
len(snapshotNames), v.UI.Duration(time.Since(overallStartTime)))
|
||||
}
|
||||
|
||||
switch {
|
||||
case opts.Prune && filesUnreadable > 0:
|
||||
// An older snapshot may be the only one that still holds a file
|
||||
// this run could not read.
|
||||
log.Warn("Skipping post-backup prune",
|
||||
"files_unreadable", filesUnreadable)
|
||||
v.UI.Warningf("Skipped the post-backup prune because %d file(s) "+
|
||||
"could not be read; older snapshots are kept.", filesUnreadable)
|
||||
case opts.Prune:
|
||||
if opts.Prune {
|
||||
err = v.runPostBackupPrune(snapshotNames, opts.KeepNewerThan)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", ErrPostBackupPruneFailed, err)
|
||||
}
|
||||
}
|
||||
|
||||
return v.finishCreateSnapshot(filesUnreadable)
|
||||
}
|
||||
|
||||
// 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
|
||||
// closing line. A file removed during the run is counted as failed in
|
||||
// the summary but is not an error, with or without --skip-errors.
|
||||
func (v *Vaultik) finishCreateSnapshot(filesUnreadable int) error {
|
||||
if filesUnreadable > 0 {
|
||||
return fmt.Errorf("%d %w", filesUnreadable, ErrFilesUnreadable)
|
||||
}
|
||||
|
||||
// Terminus must obey the --cron invariant: silent on total
|
||||
// success only. UI.Complete is dropped in cron/quiet mode (that's
|
||||
// the success path), but if any warnings fired during the run we
|
||||
@@ -196,28 +170,6 @@ 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
|
||||
@@ -270,22 +222,16 @@ type snapshotStats struct {
|
||||
totalBlobsUploaded int
|
||||
uploadDuration time.Duration
|
||||
|
||||
// The part of totalFilesFailed that --skip-errors skipped because it
|
||||
// could not be read or listed.
|
||||
totalFilesUnreadable int
|
||||
|
||||
// The sizes of all blobs the snapshot references, set by
|
||||
// finalizeSnapshotMetadata once snapshot_blobs is populated.
|
||||
blobSize int64
|
||||
blobUncompressedSize int64
|
||||
}
|
||||
|
||||
// createNamedSnapshot creates a single named snapshot and returns the
|
||||
// number of entries --skip-errors skipped because it could not read or
|
||||
// list them.
|
||||
// createNamedSnapshot creates a single named snapshot
|
||||
func (v *Vaultik) createNamedSnapshot(
|
||||
opts *SnapshotCreateOptions, hostname, snapName string, idx, total int,
|
||||
) (int, error) {
|
||||
) error {
|
||||
snapshotStartTime := time.Now()
|
||||
|
||||
if total > 1 {
|
||||
@@ -294,12 +240,7 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
|
||||
resolvedDirs, err := v.resolveSnapshotPaths(snapName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
previousSnapshotID, err := v.previousSnapshotID(snapName)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return err
|
||||
}
|
||||
|
||||
scanner := v.ScannerFactory(snapshot.ScannerParams{
|
||||
@@ -308,13 +249,12 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
Fs: v.Fs,
|
||||
Exclude: v.Config.GetExcludes(snapName),
|
||||
SkipErrors: opts.SkipErrors,
|
||||
PreviousSnapshotID: previousSnapshotID,
|
||||
})
|
||||
|
||||
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 fmt.Errorf("creating snapshot: %w", err)
|
||||
}
|
||||
|
||||
log.Info("Beginning snapshot", "snapshot_id", snapshotID, "name", snapName)
|
||||
@@ -322,12 +262,12 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
|
||||
stats, err := v.scanAllDirectories(scanner, resolvedDirs, snapshotID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return err
|
||||
}
|
||||
|
||||
err = v.finalizeSnapshotMetadata(snapshotID, stats)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
return err
|
||||
}
|
||||
|
||||
log.Info("Snapshot complete",
|
||||
@@ -340,25 +280,7 @@ func (v *Vaultik) createNamedSnapshot(
|
||||
|
||||
v.printSnapshotSummary(snapshotID, snapshotStartTime, stats)
|
||||
|
||||
return stats.totalFilesUnreadable, nil
|
||||
}
|
||||
|
||||
// previousSnapshotID returns the ID of the most recent completed snapshot
|
||||
// named snapName in the local index, or "" when there is none.
|
||||
func (v *Vaultik) previousSnapshotID(snapName string) (string, error) {
|
||||
snapshots, err := v.Repositories.Snapshots.ListRecent(v.ctx, listRecentLimit)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("listing snapshots: %w", err)
|
||||
}
|
||||
|
||||
for _, s := range snapshots {
|
||||
if s.CompletedAt != nil &&
|
||||
parseSnapshotName(s.ID.String(), s.Hostname.String()) == snapName {
|
||||
return s.ID.String(), nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveSnapshotPaths resolves source directories to absolute paths
|
||||
@@ -426,7 +348,6 @@ func (v *Vaultik) scanAllDirectories(
|
||||
stats.totalBlobs += result.BlobsCreated
|
||||
stats.totalFilesSkipped += result.FilesSkipped
|
||||
stats.totalFilesFailed += result.FilesFailed
|
||||
stats.totalFilesUnreadable += result.FilesUnreadable
|
||||
stats.totalBytesSkipped += result.BytesSkipped
|
||||
stats.totalFilesDeleted += result.FilesDeleted
|
||||
stats.totalBytesDeleted += result.BytesDeleted
|
||||
@@ -653,7 +574,7 @@ func (v *Vaultik) PurgeSnapshotsWithOptions(opts *SnapshotPurgeOptions) error {
|
||||
|
||||
if len(toDelete) == 0 {
|
||||
if !opts.Quiet {
|
||||
v.UI.Infof("No snapshots to delete.")
|
||||
v.printlnStdout("No snapshots to delete")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -691,7 +612,7 @@ func selectSnapshotsToPurge(
|
||||
case opts.OlderThan != "":
|
||||
duration, err := parseDuration(opts.OlderThan)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("invalid duration: %w", err)
|
||||
}
|
||||
|
||||
cutoff := time.Now().UTC().Add(-duration)
|
||||
@@ -710,19 +631,19 @@ func selectSnapshotsToPurge(
|
||||
func (v *Vaultik) confirmAndExecutePurge(
|
||||
toDelete []SnapshotInfo, force, quiet bool,
|
||||
) error {
|
||||
// Confirm unless --force is used. The list, the prompt and its answer
|
||||
// bypass the UI: they are an interactive exchange the operator must
|
||||
// see, so --quiet does not silence them.
|
||||
if !force {
|
||||
if !quiet {
|
||||
v.stdoutf("The following snapshots will be deleted:\n\n")
|
||||
|
||||
for _, snap := range toDelete {
|
||||
v.stdoutf(" %s (%s, %s)\n",
|
||||
snap.ID,
|
||||
snap.Timestamp.Format(time.DateTime),
|
||||
snap.Timestamp.Format("2006-01-02 15:04:05"),
|
||||
ubytes(snap.CompressedSize))
|
||||
}
|
||||
}
|
||||
|
||||
// Confirm unless --force is used
|
||||
if !force {
|
||||
v.stdoutf("\nDelete %d snapshot(s)? [y/N] ", len(toDelete))
|
||||
|
||||
var confirm string
|
||||
@@ -740,16 +661,7 @@ func (v *Vaultik) confirmAndExecutePurge(
|
||||
return nil
|
||||
}
|
||||
} else if !quiet {
|
||||
v.UI.Infof("The following snapshots will be deleted:")
|
||||
|
||||
for _, snap := range toDelete {
|
||||
v.UI.Detailf("%s (%s, %s)",
|
||||
v.UI.Snapshot(snap.ID.String()),
|
||||
snap.Timestamp.Format(time.DateTime),
|
||||
ubytes(snap.CompressedSize))
|
||||
}
|
||||
|
||||
v.UI.Infof("Deleting %d snapshot(s) (--force specified).", len(toDelete))
|
||||
v.stdoutf("\nDeleting %d snapshot(s) (--force specified)\n", len(toDelete))
|
||||
}
|
||||
|
||||
// Delete snapshots (both local and remote)
|
||||
@@ -782,9 +694,9 @@ func (v *Vaultik) confirmAndExecutePurge(
|
||||
}
|
||||
|
||||
if !quiet {
|
||||
v.UI.Infof("Deleted %d snapshot(s).", len(toDelete))
|
||||
v.UI.Noticef("Run '%s' to clean up unreferenced remote blobs.",
|
||||
pruneCommandHint)
|
||||
v.stdoutf("Deleted %d snapshot(s)\n", len(toDelete))
|
||||
v.printlnStdout(
|
||||
"\nNote: Run 'vaultik prune' to clean up unreferenced remote blobs.")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1020,10 +932,8 @@ func (v *Vaultik) formatVerifyResult(
|
||||
v.printVerifySummary(result, failure)
|
||||
}
|
||||
|
||||
// Not errSnapshotVerifyFailed: the cli layer prints this after its own
|
||||
// "Verification failed:".
|
||||
if failure != "" {
|
||||
return fmt.Errorf("%w: %s", errProblemsInStorage, failure)
|
||||
return fmt.Errorf("%w: %s", errSnapshotVerifyFailed, failure)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1159,7 +1069,7 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
|
||||
log.Info("Removing stale local snapshot record", "snapshot_id", id)
|
||||
|
||||
if !opts.JSON {
|
||||
v.UI.Infof("Removing stale local record: %s.", v.UI.Snapshot(id))
|
||||
v.stdoutf("Removing stale local record: %s\n", id)
|
||||
}
|
||||
|
||||
err = v.deleteSnapshotFromLocalDB(id)
|
||||
@@ -1182,9 +1092,9 @@ func (v *Vaultik) CleanupLocalSnapshots(opts *PruneOptions) error {
|
||||
}
|
||||
|
||||
if removed == 0 {
|
||||
v.UI.Infof("No stale local snapshots found.")
|
||||
v.printlnStdout("No stale local snapshots found.")
|
||||
} else {
|
||||
v.UI.Infof("Removed %d stale local snapshot record(s).", removed)
|
||||
v.stdoutf("Removed %d stale local snapshot record(s).\n", removed)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -1335,18 +1245,10 @@ func (v *Vaultik) RemoveSnapshot(
|
||||
return v.removeSnapshotDryRun(result, snapshotID, opts)
|
||||
}
|
||||
|
||||
if !v.confirmRemoveSnapshot(snapshotID, opts) {
|
||||
if !opts.Force && !opts.JSON && !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)
|
||||
|
||||
@@ -1357,7 +1259,7 @@ func (v *Vaultik) RemoveSnapshot(
|
||||
}
|
||||
|
||||
result.RemoteRemoved, err = v.removeSnapshotRemote(
|
||||
snapshotID, inLocalIndex, markerErr, opts)
|
||||
snapshotID, inLocalIndex, opts)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
@@ -1375,15 +1277,14 @@ func (v *Vaultik) RemoveSnapshot(
|
||||
}
|
||||
|
||||
if inLocalIndex {
|
||||
v.UI.Infof("Removed snapshot '%s' from local database.",
|
||||
v.UI.Snapshot(snapshotID))
|
||||
v.stdoutf("Removed snapshot '%s' from local database\n", snapshotID)
|
||||
}
|
||||
|
||||
if result.RemoteRemoved {
|
||||
v.UI.Infof("Removed snapshot metadata from remote storage.")
|
||||
v.UI.Noticef("The removed snapshot's blobs remain on the remote. "+
|
||||
v.printlnStdout("Removed snapshot metadata from remote storage")
|
||||
v.stdoutf("\nNote: The removed snapshot's blobs remain on the remote. "+
|
||||
"Run '%s' to delete any blobs no longer referenced by any "+
|
||||
"remaining remote snapshot.", pruneCommandHint)
|
||||
"remaining remote snapshot.\n", pruneCommandHint)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -1394,9 +1295,7 @@ 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,
|
||||
// and only after checkDestinationMarker passes, so an unplugged volume's
|
||||
// empty mount point is not taken for a store without the snapshot.
|
||||
// still be removed from it while the destination store is unreachable.
|
||||
func (v *Vaultik) findSnapshotToRemove(
|
||||
snapshotID string, opts *RemoveOptions,
|
||||
) (bool, error) {
|
||||
@@ -1416,11 +1315,6 @@ 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)
|
||||
@@ -1446,28 +1340,22 @@ func (v *Vaultik) removeSnapshotDryRun(
|
||||
return result, v.outputRemoveJSON(result)
|
||||
}
|
||||
|
||||
v.UI.Infof("Would remove snapshot: %s.", v.UI.Snapshot(snapshotID))
|
||||
v.stdoutf("Would remove snapshot: %s\n", snapshotID)
|
||||
|
||||
if !opts.LocalOnly {
|
||||
v.UI.Infof("Would also remove snapshot metadata from " +
|
||||
"remote storage (blobs untouched).")
|
||||
v.printlnStdout("Would also remove snapshot metadata from " +
|
||||
"remote storage (blobs untouched)")
|
||||
}
|
||||
|
||||
v.UI.Noticef("Dry run: no changes made.")
|
||||
v.printlnStdout("[Dry run - no changes made]")
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// confirmRemoveSnapshot prompts for confirmation before removing a
|
||||
// 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.
|
||||
// snapshot. Returns false (treating EOF/read errors as "no") unless the
|
||||
// user answers "y".
|
||||
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)
|
||||
@@ -1497,17 +1385,15 @@ 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. 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).
|
||||
// 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).
|
||||
func (v *Vaultik) removeSnapshotRemote(
|
||||
snapshotID string, inLocalIndex bool, markerErr error,
|
||||
opts *RemoveOptions,
|
||||
snapshotID string, inLocalIndex bool, opts *RemoveOptions,
|
||||
) (bool, error) {
|
||||
if opts.LocalOnly {
|
||||
return false, nil
|
||||
@@ -1516,14 +1402,9 @@ func (v *Vaultik) removeSnapshotRemote(
|
||||
log.Info("Removing snapshot metadata from remote storage",
|
||||
"snapshot_id", snapshotID)
|
||||
|
||||
var deleted int
|
||||
|
||||
err := markerErr
|
||||
if err == nil {
|
||||
deleted, err = v.deleteRemoteSnapshotByKey(
|
||||
snapshot.RemoteSnapshotKey(snapshotID))
|
||||
}
|
||||
remoteKey := snapshot.RemoteSnapshotKey(snapshotID)
|
||||
|
||||
deleted, err := v.deleteRemoteSnapshotByKey(remoteKey)
|
||||
if err != nil && !inLocalIndex {
|
||||
return false, fmt.Errorf(
|
||||
"removing snapshot metadata from destination store: %w", err)
|
||||
@@ -1553,9 +1434,7 @@ 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. Unless LocalOnly is set, it fails
|
||||
// before it removes anything when the destination store cannot be
|
||||
// listed or is not present by checkDestinationMarker.
|
||||
// remote storage have diverged.
|
||||
func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error) {
|
||||
err := v.EnsureStorageBinding()
|
||||
if err != nil {
|
||||
@@ -1568,15 +1447,11 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
|
||||
}
|
||||
|
||||
// remoteKeys is the set of metadata/<key>/ subdirectories on the
|
||||
// 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.
|
||||
// destination store; failures are downgraded to a warning so a
|
||||
// permission-denied or unreachable remote can't block a local-only
|
||||
// remove.
|
||||
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)
|
||||
}
|
||||
@@ -1599,7 +1474,7 @@ func (v *Vaultik) RemoveAllSnapshots(opts *RemoveOptions) (*RemoveResult, error)
|
||||
|
||||
if len(localSnaps) == 0 && len(orphanRemoteKeys) == 0 {
|
||||
if !opts.JSON {
|
||||
v.UI.Infof("No snapshots found.")
|
||||
v.printlnStdout("No snapshots found")
|
||||
}
|
||||
|
||||
return &RemoveResult{}, nil
|
||||
@@ -1638,7 +1513,8 @@ 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, or is not present by checkDestinationMarker.
|
||||
// listed; callers must treat that as "no remote info available," not
|
||||
// fatal.
|
||||
func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||
log.Info("Listing all remote snapshots")
|
||||
|
||||
@@ -1687,11 +1563,6 @@ func (v *Vaultik) listAllRemoteSnapshotKeys() ([]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
err := v.checkDestinationMarker()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
@@ -1708,27 +1579,27 @@ func (v *Vaultik) handleRemoveAllDryRun(
|
||||
}
|
||||
|
||||
if !opts.JSON {
|
||||
v.UI.Infof("Would remove %d local snapshot(s):", len(localSnaps))
|
||||
v.stdoutf("Would remove %d local snapshot(s):\n", len(localSnaps))
|
||||
|
||||
for _, id := range localSnaps {
|
||||
v.UI.Detailf("%s", v.UI.Snapshot(id))
|
||||
v.stdoutf(" %s\n", id)
|
||||
}
|
||||
|
||||
if !opts.LocalOnly {
|
||||
if len(orphanRemoteKeys) > 0 {
|
||||
v.UI.Infof("Would also remove %d orphan remote snapshot key(s):",
|
||||
v.stdoutf("Would also remove %d orphan remote snapshot key(s):\n",
|
||||
len(orphanRemoteKeys))
|
||||
|
||||
for _, key := range orphanRemoteKeys {
|
||||
v.UI.Detailf("%s", key)
|
||||
v.stdoutf(" %s\n", key)
|
||||
}
|
||||
} else {
|
||||
v.UI.Infof("Would also remove snapshot metadata from " +
|
||||
"remote storage (blobs untouched).")
|
||||
v.printlnStdout("Would also remove snapshot metadata from " +
|
||||
"remote storage (blobs untouched)")
|
||||
}
|
||||
}
|
||||
|
||||
v.UI.Noticef("Dry run: no changes made.")
|
||||
v.printlnStdout("[Dry run - no changes made]")
|
||||
}
|
||||
|
||||
if opts.JSON {
|
||||
@@ -1777,10 +1648,10 @@ func (v *Vaultik) executeRemoveAll(
|
||||
return result, v.outputRemoveJSON(result)
|
||||
}
|
||||
|
||||
v.UI.Infof("Removed %d snapshot(s).", len(result.SnapshotsRemoved))
|
||||
v.stdoutf("Removed %d snapshot(s)\n", len(result.SnapshotsRemoved))
|
||||
|
||||
if !opts.LocalOnly && result.RemoteRemoved {
|
||||
v.UI.Infof("Removed snapshot metadata from remote storage.")
|
||||
v.printlnStdout("Removed snapshot metadata from remote storage")
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -1822,12 +1693,10 @@ func (v *Vaultik) deleteIncompleteSnapshot(snapshotIDStr string, id any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
func (v *Vaultik) removeAllLocalSnapshots(
|
||||
localSnaps []string, opts *RemoveOptions, result *RemoveResult,
|
||||
) int {
|
||||
@@ -1836,19 +1705,6 @@ 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",
|
||||
@@ -1859,6 +1715,17 @@ 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)
|
||||
}
|
||||
|
||||
@@ -2041,17 +1908,13 @@ func (v *Vaultik) PruneDatabase() (*PruneResult, error) {
|
||||
snapshotsRemain := countDiff(snapshotCountBefore, &result.SnapshotsDeleted)
|
||||
|
||||
v.UI.Completef("Pruned local index database.")
|
||||
v.UI.Detailf("Incomplete snapshots: %s removed "+
|
||||
"(total snapshots left in the local index: %s).",
|
||||
v.UI.Detailf("Incomplete snapshots: %s removed (%s remain).",
|
||||
countText(&result.SnapshotsDeleted), countText(snapshotsRemain))
|
||||
v.UI.Detailf("Orphaned files: %s removed "+
|
||||
"(total files left in the local index: %s).",
|
||||
v.UI.Detailf("Orphaned files: %s removed (%s remain).",
|
||||
countText(result.FilesDeleted), countText(fileCountAfter))
|
||||
v.UI.Detailf("Orphaned chunks: %s removed "+
|
||||
"(total chunks left in the local index: %s).",
|
||||
v.UI.Detailf("Orphaned chunks: %s removed (%s remain).",
|
||||
countText(result.ChunksDeleted), countText(chunkCountAfter))
|
||||
v.UI.Detailf("Orphaned blobs: %s removed "+
|
||||
"(total blobs left in the local index: %s).",
|
||||
v.UI.Detailf("Orphaned blobs: %s removed (%s remain).",
|
||||
countText(result.BlobsDeleted), countText(blobCountAfter))
|
||||
|
||||
return result, nil
|
||||
|
||||
@@ -1,251 +0,0 @@
|
||||
package vaultik_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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/ui"
|
||||
"sneak.berlin/go/vaultik/internal/vaultik"
|
||||
)
|
||||
|
||||
// These tests cover https://git.eeqj.de/sneak/vaultik/issues/304: a
|
||||
// backup counts a file as deleted only when the previous snapshot of
|
||||
// the same name had it and it is gone, and never counts an excluded
|
||||
// file.
|
||||
|
||||
// deletedCountEnv is a backup setup over the source directory src whose
|
||||
// user-facing output is kept in out. Each test configures its snapshots
|
||||
// with setSnapshot.
|
||||
type deletedCountEnv struct {
|
||||
v *vaultik.Vaultik
|
||||
out *bytes.Buffer
|
||||
src string
|
||||
}
|
||||
|
||||
// newDeletedCountEnv writes files, keyed by path relative to src.
|
||||
func newDeletedCountEnv(t *testing.T, files map[string][]byte) *deletedCountEnv {
|
||||
t.Helper()
|
||||
|
||||
ctx := context.Background()
|
||||
fs := afero.NewOsFs()
|
||||
tempDir := t.TempDir()
|
||||
srcDir := filepath.Join(tempDir, "src")
|
||||
|
||||
for rel, content := range files {
|
||||
path := filepath.Join(srcDir, rel)
|
||||
require.NoError(t, fs.MkdirAll(filepath.Dir(path), 0o755))
|
||||
require.NoError(t, afero.WriteFile(fs, path, content, 0o644))
|
||||
}
|
||||
|
||||
cfg := faultTestConfig()
|
||||
cfg.IndexPath = filepath.Join(tempDir, "index.sqlite")
|
||||
cfg.Snapshots = map[string]config.SnapshotConfig{}
|
||||
|
||||
store, err := storage.NewFileStorer(t.TempDir())
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, cfg.IndexPath)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
repos := database.NewRepositories(db)
|
||||
out := &bytes.Buffer{}
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
v.UI = ui.NewWithColor(out, false)
|
||||
|
||||
return &deletedCountEnv{v: v, out: out, src: srcDir}
|
||||
}
|
||||
|
||||
// setSnapshot configures the snapshot name to back up the path rel
|
||||
// beneath src, with the given excludes.
|
||||
func (e *deletedCountEnv) setSnapshot(name, rel string, exclude ...string) {
|
||||
e.v.Config.Snapshots[name] = config.SnapshotConfig{
|
||||
Paths: []string{filepath.Join(e.src, rel)},
|
||||
Exclude: exclude,
|
||||
}
|
||||
}
|
||||
|
||||
// backUp runs a backup of the named snapshot and returns its output.
|
||||
func (e *deletedCountEnv) backUp(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
|
||||
e.out.Reset()
|
||||
require.NoError(t, e.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
Snapshots: []string{name},
|
||||
}))
|
||||
|
||||
return e.out.String()
|
||||
}
|
||||
|
||||
// A file this snapshot excludes is not counted as deleted: neither one
|
||||
// that another snapshot backs up, nor one that the previous snapshot of
|
||||
// this name had before the exclude was added.
|
||||
func TestBackupDoesNotCountExcludedFileAsDeleted(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"top.txt": []byte("top"),
|
||||
"cache/tmp.bin": []byte("cache"),
|
||||
"photos/a.jpg": []byte("photo a"),
|
||||
"photos/b.jpg": []byte("photo b"),
|
||||
"photos/c.jpg": []byte("photo c"),
|
||||
})
|
||||
env.setSnapshot("photos", "photos")
|
||||
env.setSnapshot("home", "", "/photos")
|
||||
|
||||
env.backUp(t, "photos")
|
||||
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
|
||||
|
||||
env.setSnapshot("home", "", "/photos", "/cache")
|
||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||
}
|
||||
|
||||
// A file inside a directory that an exclude added after a backup
|
||||
// matches is not counted as deleted, though the pattern matches only the
|
||||
// directory and the directory is gone from disk.
|
||||
func TestBackupDoesNotCountFileInExcludedDirectoryAsDeleted(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"top.txt": []byte("top"),
|
||||
"user/.local/share/Trash/old.txt": []byte("old"),
|
||||
})
|
||||
env.setSnapshot("home", "")
|
||||
|
||||
assert.Contains(t, env.backUp(t, "home"), filesLine(2, 2, 0))
|
||||
|
||||
require.NoError(t, os.RemoveAll(
|
||||
filepath.Join(env.src, "user/.local/share/Trash")))
|
||||
|
||||
env.setSnapshot("home", "", "*/.local/share/Trash")
|
||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||
}
|
||||
|
||||
// A file whose directory was replaced by a regular file of the same name
|
||||
// is counted as deleted.
|
||||
func TestBackupCountsFileInDirectoryReplacedByFileAsDeleted(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"dir/inside.txt": []byte("inside"),
|
||||
})
|
||||
env.setSnapshot("home", "")
|
||||
|
||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 1, 0))
|
||||
|
||||
dir := filepath.Join(env.src, "dir")
|
||||
require.NoError(t, os.RemoveAll(dir))
|
||||
require.NoError(t, os.WriteFile(dir, []byte("now a file"), 0o600))
|
||||
|
||||
assert.Contains(t, env.backUp(t, "home"),
|
||||
"Files: 1 examined, 1 backed up, 0 unchanged, 1 deleted.")
|
||||
}
|
||||
|
||||
// Under --skip-errors a file inside a directory that the walk cannot
|
||||
// open is not counted as deleted: it is still on disk.
|
||||
func TestBackupDoesNotCountFileInUnopenableDirectoryAsDeleted(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"open.txt": []byte("open"),
|
||||
"locked/inside.txt": []byte("inside"),
|
||||
})
|
||||
env.setSnapshot("work", "")
|
||||
|
||||
assert.Contains(t, env.backUp(t, "work"), filesLine(2, 2, 0))
|
||||
|
||||
// A directory's mode does not lock out root, who runs the tests, so
|
||||
// opening the directory is made to fail instead.
|
||||
env.v.Fs = &openFailFs{
|
||||
path: filepath.Join(env.src, "locked"),
|
||||
err: os.ErrPermission,
|
||||
}
|
||||
|
||||
env.out.Reset()
|
||||
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
SkipErrors: true,
|
||||
Snapshots: []string{"work"},
|
||||
})
|
||||
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||
assert.Contains(t, env.out.String(),
|
||||
"Files: 2 examined, 0 backed up, 1 unchanged, 1 failed.")
|
||||
}
|
||||
|
||||
// A backup counts only files that the previous snapshot of its own name
|
||||
// had, not one that a later snapshot of another name over the same path
|
||||
// had and that is since removed.
|
||||
func TestBackupIgnoresSnapshotOfAnotherName(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"kept.txt": []byte("kept"),
|
||||
})
|
||||
env.setSnapshot("home", "")
|
||||
env.setSnapshot("other", "")
|
||||
|
||||
env.backUp(t, "home")
|
||||
|
||||
// Snapshot start times are kept in whole seconds: wait so that other's
|
||||
// snapshot is strictly the most recent.
|
||||
time.Sleep(time.Second)
|
||||
|
||||
extra := filepath.Join(env.src, "extra.txt")
|
||||
require.NoError(t, os.WriteFile(extra, []byte("extra"), 0o600))
|
||||
env.backUp(t, "other")
|
||||
require.NoError(t, os.Remove(extra))
|
||||
|
||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||
}
|
||||
|
||||
// A file removed from disk is counted as deleted by the next backup of
|
||||
// the snapshot that had it, and not by any later backup of that
|
||||
// snapshot, nor by the first backup of another name over the same path.
|
||||
func TestBackupCountsDeletedFileOnce(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
aContent := bytes.Repeat([]byte("a"), 1000)
|
||||
bContent := bytes.Repeat([]byte("b"), 2000)
|
||||
keptContent := bytes.Repeat([]byte("k"), 500)
|
||||
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"a.txt": aContent,
|
||||
"b.txt": bContent,
|
||||
"kept.txt": keptContent,
|
||||
})
|
||||
env.setSnapshot("home", "")
|
||||
env.setSnapshot("other", "")
|
||||
|
||||
assert.Contains(t, env.backUp(t, "home"), filesLine(3, 3, 0))
|
||||
|
||||
require.NoError(t, os.Remove(filepath.Join(env.src, "a.txt")))
|
||||
require.NoError(t, os.Remove(filepath.Join(env.src, "b.txt")))
|
||||
|
||||
summary := env.backUp(t, "home")
|
||||
assert.Contains(t, summary,
|
||||
"Files: 1 examined, 0 backed up, 1 unchanged, 2 deleted.")
|
||||
assert.Contains(t, summary,
|
||||
fmt.Sprintf("Data: %s total (%s backed up), %s deleted.",
|
||||
env.v.UI.Size(int64(len(keptContent))), env.v.UI.Size(0),
|
||||
env.v.UI.Size(int64(len(aContent)+len(bContent)))))
|
||||
|
||||
assert.Contains(t, env.backUp(t, "home"), filesLine(1, 0, 1))
|
||||
assert.Contains(t, env.backUp(t, "other"), filesLine(1, 0, 1))
|
||||
}
|
||||
@@ -49,19 +49,13 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
||||
name string
|
||||
openErr error
|
||||
skipErrors bool
|
||||
wantErr error // nil for none
|
||||
}{
|
||||
// What a normal user gets opening a file with mode 000.
|
||||
{name: "unopenable under skip-errors",
|
||||
openErr: os.ErrPermission, skipErrors: true,
|
||||
wantErr: vaultik.ErrFilesUnreadable},
|
||||
openErr: os.ErrPermission, skipErrors: true},
|
||||
// What opening a file removed after phase 1 gives.
|
||||
{name: "removed between the phases",
|
||||
openErr: os.ErrNotExist, skipErrors: false},
|
||||
// A removal is not an error, so --skip-errors does not make the
|
||||
// run fail. See https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||
{name: "removed between the phases under skip-errors",
|
||||
openErr: os.ErrNotExist, skipErrors: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -93,7 +87,7 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
||||
"src": {Paths: []string{srcDir}},
|
||||
}
|
||||
|
||||
store, err := storage.NewFileStorer(t.TempDir())
|
||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, cfg.IndexPath)
|
||||
@@ -105,11 +99,10 @@ func TestSnapshotSummaryCountsFileNotStoredAsFailed(t *testing.T) {
|
||||
v := newBackupVaultik(ctx, cfg, store, repos, db, fs)
|
||||
v.UI = ui.NewWithColor(out, false)
|
||||
|
||||
err = v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
require.NoError(t, v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
SkipErrors: tt.skipErrors,
|
||||
Snapshots: []string{"src"},
|
||||
})
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
}))
|
||||
|
||||
summary := out.String()
|
||||
assert.Contains(t, summary,
|
||||
|
||||
@@ -61,9 +61,7 @@ const tabPadding = 3
|
||||
// attempt is made to recover or fabricate the human ID.
|
||||
//
|
||||
// Snapshots in the local index with no counterpart on the destination
|
||||
// store are reported as drift below the table, unless the local index
|
||||
// is bound to a different destination than the configured one: that is
|
||||
// reported instead.
|
||||
// store are reported as drift below the table.
|
||||
//
|
||||
// If remote listing fails (unmounted volume, permission denied,
|
||||
// network), we degrade to local-only with a warning. List never fails
|
||||
@@ -105,26 +103,7 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
||||
return snapshots[i].Timestamp.After(snapshots[j].Timestamp)
|
||||
})
|
||||
|
||||
stored, configured, err := v.storageBinding()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A local index bound to another destination describes that one, so
|
||||
// its records are expected to be missing here, and prune refuses to
|
||||
// run. The binding is reported instead of advising prune.
|
||||
boundElsewhere := stored != "" && stored != configured
|
||||
|
||||
if jsonOutput {
|
||||
// stdout holds only the document, so this goes to the logger.
|
||||
if boundElsewhere {
|
||||
log.Warn("Local index is bound to a different backup "+
|
||||
"destination; to proceed, revert storage_url (or the s3.* "+
|
||||
"endpoint, bucket and prefix) to the bound destination, "+
|
||||
"or run 'vaultik database delete'",
|
||||
"bound_to", stored, "configured", configured)
|
||||
}
|
||||
|
||||
if remoteErr == nil {
|
||||
v.reportJSONListingLimits(listing)
|
||||
}
|
||||
@@ -146,13 +125,8 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
|
||||
}
|
||||
}
|
||||
|
||||
if boundElsewhere {
|
||||
v.UI.Warningf("%v\n%s", errStorageBindingMismatch,
|
||||
buildBindingMismatchMessage(stored, configured))
|
||||
}
|
||||
|
||||
if remoteErr == nil {
|
||||
v.reportListDrift(snapshots, listing, boundElsewhere)
|
||||
v.reportListDrift(snapshots, listing)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -458,9 +432,7 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
||||
// reportListDrift prints the reconciliation notes the merged table
|
||||
// cannot express on its own: local records with no counterpart on the
|
||||
// destination store, plus counts of remote snapshots that were
|
||||
// unreadable or omitted. Local records are not reported when
|
||||
// boundElsewhere is set: ListSnapshots has already said why they are
|
||||
// missing.
|
||||
// unreadable or omitted.
|
||||
//
|
||||
// This is what remains of the old reportRemoteDrift, and it no longer
|
||||
// touches the destination store. Its remote-only half collapsed into
|
||||
@@ -469,7 +441,6 @@ func (v *Vaultik) snapshotInfoFromLocal(ls *database.Snapshot) SnapshotInfo {
|
||||
// so `snapshot list` lists the destination exactly once per invocation.
|
||||
func (v *Vaultik) reportListDrift(
|
||||
snapshots []SnapshotInfo, listing *remoteSnapshotListing,
|
||||
boundElsewhere bool,
|
||||
) {
|
||||
var localOnly []string
|
||||
|
||||
@@ -479,7 +450,7 @@ func (v *Vaultik) reportListDrift(
|
||||
}
|
||||
}
|
||||
|
||||
if len(localOnly) > 0 && !boundElsewhere {
|
||||
if len(localOnly) > 0 {
|
||||
v.UI.Warningf("%d local snapshot record(s) not found in backup "+
|
||||
"destination store:", len(localOnly))
|
||||
|
||||
|
||||
@@ -122,9 +122,6 @@ 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: "",
|
||||
@@ -424,74 +421,6 @@ func TestListSnapshots_LocalOnlyReportedAsDrift(t *testing.T) {
|
||||
assert.NotContains(t, out, "snapshot cleanup")
|
||||
}
|
||||
|
||||
// TestListSnapshots_BoundElsewhereDoesNotAdvisePrune covers a changed
|
||||
// storage_url: the local index is still bound to the old destination,
|
||||
// so its records are missing from the new one. prune refuses to run in
|
||||
// that state, so the listing must give the choices prune gives instead
|
||||
// of advising it.
|
||||
func TestListSnapshots_BoundElsewhereDoesNotAdvisePrune(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
const (
|
||||
oldURL = "file:///mnt/backups/old"
|
||||
newURL = "file:///mnt/backups/new"
|
||||
)
|
||||
|
||||
env := newListEnv(t)
|
||||
env.v.Config.StorageURL = newURL
|
||||
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
|
||||
database.LocalMetaKeyStorageURL, oldURL))
|
||||
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
err := env.v.ListSnapshots(false)
|
||||
require.NoError(t, err)
|
||||
|
||||
out := env.stdout.String()
|
||||
|
||||
assert.Contains(t, out, listLocalID)
|
||||
assert.Contains(t, out,
|
||||
"local index is bound to a different backup destination")
|
||||
assert.Contains(t, out, oldURL)
|
||||
assert.Contains(t, out, newURL)
|
||||
assert.Contains(t, out, "revert storage_url")
|
||||
assert.Contains(t, out, "vaultik database delete")
|
||||
assert.NotContains(t, out, "vaultik prune")
|
||||
}
|
||||
|
||||
// TestListSnapshots_JSONReportsBoundElsewhere is the --json side of the
|
||||
// test above. Every local row comes out with remote_present false, which
|
||||
// a script reads as stale local records unless it is told, on stderr,
|
||||
// that the local index is bound to a different destination.
|
||||
//
|
||||
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
|
||||
func TestListSnapshots_JSONReportsBoundElsewhere(t *testing.T) {
|
||||
const (
|
||||
oldURL = "file:///mnt/backups/old"
|
||||
newURL = "file:///mnt/backups/new"
|
||||
)
|
||||
|
||||
env := newListEnv(t)
|
||||
env.v.Config.StorageURL = newURL
|
||||
require.NoError(t, env.v.Repositories.LocalMeta.Set(context.Background(),
|
||||
database.LocalMetaKeyStorageURL, oldURL))
|
||||
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
|
||||
|
||||
stderr := captureProcessStderr(t, func() {
|
||||
require.NoError(t, env.v.ListSnapshots(true))
|
||||
})
|
||||
|
||||
rows := decodeListJSON(t, env.stdout.String())
|
||||
require.Len(t, rows, 1)
|
||||
assert.Equal(t, listLocalID, rows[0].ID)
|
||||
|
||||
assert.Contains(t, stderr, "bound to a different backup destination")
|
||||
assert.Contains(t, stderr, oldURL)
|
||||
assert.Contains(t, stderr, newURL)
|
||||
assert.Contains(t, stderr, "revert storage_url")
|
||||
assert.Contains(t, stderr, "vaultik database delete")
|
||||
}
|
||||
|
||||
// TestListSnapshots_UnreachableRemoteDegrades covers the promise in the
|
||||
// doc comment: an unreachable destination is a warning plus local-only
|
||||
// output, never a failure.
|
||||
|
||||
@@ -3,7 +3,6 @@ package vaultik_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
@@ -46,7 +45,7 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
||||
"tree": {Paths: []string{srcDir}},
|
||||
}
|
||||
|
||||
store, err := storage.NewFileStorer(t.TempDir())
|
||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, cfg.IndexPath)
|
||||
@@ -74,210 +73,3 @@ func TestSkipErrorsBackupStopsOnIndexErrorRecordingDirectory(t *testing.T) {
|
||||
require.Len(t, snapshots, 1)
|
||||
assert.Nil(t, snapshots[0].CompletedAt)
|
||||
}
|
||||
|
||||
// A backup under --skip-errors that skips a file it cannot open, or a
|
||||
// directory it cannot list, keeps the snapshot, counts what it skipped
|
||||
// as failed and returns an error instead of finishing successfully. See
|
||||
// https://git.eeqj.de/sneak/vaultik/issues/310.
|
||||
func TestSkipErrorsBackupKeepsSnapshotAndFailsWhenItSkips(t *testing.T) {
|
||||
log.Initialize(log.Config{})
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
skipped string // relative to the source directory
|
||||
}{
|
||||
{name: "file it cannot open", skipped: "skipdir/file.txt"},
|
||||
{name: "directory it cannot list", skipped: "skipdir"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
env := newDeletedCountEnv(t, map[string][]byte{
|
||||
"stored.txt": []byte("stored"),
|
||||
"skipdir/file.txt": []byte("in skipdir"),
|
||||
})
|
||||
env.setSnapshot("work", "")
|
||||
|
||||
// A mode does not lock out root, who runs the tests, so
|
||||
// opening the skipped path is made to fail instead.
|
||||
env.v.Fs = &openFailFs{
|
||||
path: filepath.Join(env.src, tt.skipped),
|
||||
err: os.ErrPermission,
|
||||
}
|
||||
|
||||
err := env.v.CreateSnapshot(&vaultik.SnapshotCreateOptions{
|
||||
SkipErrors: true,
|
||||
Snapshots: []string{"work"},
|
||||
})
|
||||
require.ErrorIs(t, err, vaultik.ErrFilesUnreadable)
|
||||
|
||||
assert.Contains(t, env.out.String(),
|
||||
"Files: 2 examined, 1 backed up, 0 unchanged, 1 failed.")
|
||||
assert.NotContains(t, env.out.String(), "Finished successfully.")
|
||||
|
||||
snapshots, err := env.v.Repositories.Snapshots.ListRecent(
|
||||
context.Background(), listRecentTestLimit)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, snapshots, 1)
|
||||
assert.NotNil(t, snapshots[0].CompletedAt, "the snapshot is kept")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ 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"
|
||||
@@ -85,7 +84,7 @@ func newSummaryEnv(t *testing.T) *summaryEnv {
|
||||
"split": {Paths: []string{dirOne, dirTwo}},
|
||||
}
|
||||
|
||||
inner, err := storage.NewFileStorer(t.TempDir())
|
||||
inner, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||
require.NoError(t, err)
|
||||
|
||||
store := faultstore.New(inner)
|
||||
@@ -189,42 +188,6 @@ 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.
|
||||
@@ -285,85 +248,6 @@ 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.
|
||||
|
||||
@@ -45,9 +45,17 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
stored, configured, err := v.storageBinding()
|
||||
configured := v.Config.StorageURL
|
||||
if configured == "" {
|
||||
// The s3.* form: bind on its endpoint, bucket and prefix,
|
||||
// written the way storage_url would name the same destination.
|
||||
s3 := v.Config.S3
|
||||
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
|
||||
}
|
||||
|
||||
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("reading local storage binding: %w", err)
|
||||
}
|
||||
|
||||
if stored == "" {
|
||||
@@ -70,27 +78,6 @@ func (v *Vaultik) EnsureStorageBinding() error {
|
||||
errStorageBindingMismatch, buildBindingMismatchMessage(stored, configured))
|
||||
}
|
||||
|
||||
// storageBinding returns the destination the local index is bound to
|
||||
// ("" when no binding is recorded yet), then the configured destination
|
||||
// written the same way. It records nothing, so a read-only command can
|
||||
// call it.
|
||||
func (v *Vaultik) storageBinding() (string, string, error) {
|
||||
configured := v.Config.StorageURL
|
||||
if configured == "" {
|
||||
// The s3.* form: bind on its endpoint, bucket and prefix,
|
||||
// written the way storage_url would name the same destination.
|
||||
s3 := v.Config.S3
|
||||
configured = "s3://" + s3.Bucket + "/" + s3.Prefix + "?endpoint=" + s3.Endpoint
|
||||
}
|
||||
|
||||
stored, err := v.Repositories.LocalMeta.Get(v.ctx, database.LocalMetaKeyStorageURL)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("reading local storage binding: %w", err)
|
||||
}
|
||||
|
||||
return stored, configured, nil
|
||||
}
|
||||
|
||||
// buildBindingMismatchMessage assembles the multi-line explanation
|
||||
// shown when the local index is bound to a different destination than
|
||||
// the currently-configured one (the first line lives in the
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestBackupWithoutCronOfTwoPathSnapshotRestoresBothPaths(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
firstDir := filepath.Join(tempDir, "first")
|
||||
secondDir := filepath.Join(tempDir, "second")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
restoreDir := filepath.Join(tempDir, "restored")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@ func TestUncompressedSizeOfChangedFileStaysFixed(t *testing.T) {
|
||||
|
||||
appendRandomBytes(t, fs, files, changedPath, appendedFileSize)
|
||||
|
||||
store, err := storage.NewFileStorer(t.TempDir())
|
||||
store, err := storage.NewFileStorer(filepath.Join(tempDir, "remote"))
|
||||
require.NoError(t, err)
|
||||
|
||||
db, err := database.New(ctx, dbPath)
|
||||
|
||||
@@ -680,7 +680,7 @@ func (v *Vaultik) verifyManifestAgainstDatabase(
|
||||
func (v *Vaultik) verifyBlobExistenceFromDB(
|
||||
blobs []snapshot.BlobInfo, result *VerifyResult,
|
||||
) error {
|
||||
log.Info("Verifying blob existence in storage", "blob_count", len(blobs))
|
||||
log.Info("Verifying blob existence in S3", "blob_count", len(blobs))
|
||||
|
||||
for i, blob := range blobs {
|
||||
// The hash is read from the snapshot database, which is not
|
||||
@@ -700,7 +700,7 @@ func (v *Vaultik) verifyBlobExistenceFromDB(
|
||||
result.Missing++
|
||||
result.MissingSize += blob.CompressedSize
|
||||
case err != nil:
|
||||
return fmt.Errorf("could not check blob %s in storage: %w",
|
||||
return fmt.Errorf("checking blob %s in storage: %w",
|
||||
blob.Hash, err)
|
||||
case stat.Size != blob.CompressedSize:
|
||||
log.Warn("Blob has the wrong size in storage",
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestVerify_QuietSuppressesReport(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
dataDir := filepath.Join(tempDir, "source")
|
||||
storeDir := t.TempDir()
|
||||
storeDir := filepath.Join(tempDir, "remote")
|
||||
dbPath := filepath.Join(tempDir, "index.sqlite")
|
||||
|
||||
chunkSize := int64(32 * 1024)
|
||||
|
||||
Reference in New Issue
Block a user